diff --git a/poetry.lock b/poetry.lock index e17a523..58d0601 100644 --- a/poetry.lock +++ b/poetry.lock @@ -187,6 +187,73 @@ files = [ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, ] +[[package]] +name = "pillow" +version = "10.0.1" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "Pillow-10.0.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:8f06be50669087250f319b706decf69ca71fdecd829091a37cc89398ca4dc17a"}, + {file = "Pillow-10.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:50bd5f1ebafe9362ad622072a1d2f5850ecfa44303531ff14353a4059113b12d"}, + {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6a90167bcca1216606223a05e2cf991bb25b14695c518bc65639463d7db722d"}, + {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f11c9102c56ffb9ca87134bd025a43d2aba3f1155f508eff88f694b33a9c6d19"}, + {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:186f7e04248103482ea6354af6d5bcedb62941ee08f7f788a1c7707bc720c66f"}, + {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0462b1496505a3462d0f35dc1c4d7b54069747d65d00ef48e736acda2c8cbdff"}, + {file = "Pillow-10.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d889b53ae2f030f756e61a7bff13684dcd77e9af8b10c6048fb2c559d6ed6eaf"}, + {file = "Pillow-10.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:552912dbca585b74d75279a7570dd29fa43b6d93594abb494ebb31ac19ace6bd"}, + {file = "Pillow-10.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:787bb0169d2385a798888e1122c980c6eff26bf941a8ea79747d35d8f9210ca0"}, + {file = "Pillow-10.0.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:fd2a5403a75b54661182b75ec6132437a181209b901446ee5724b589af8edef1"}, + {file = "Pillow-10.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2d7e91b4379f7a76b31c2dda84ab9e20c6220488e50f7822e59dac36b0cd92b1"}, + {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19e9adb3f22d4c416e7cd79b01375b17159d6990003633ff1d8377e21b7f1b21"}, + {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93139acd8109edcdeffd85e3af8ae7d88b258b3a1e13a038f542b79b6d255c54"}, + {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:92a23b0431941a33242b1f0ce6c88a952e09feeea9af4e8be48236a68ffe2205"}, + {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cbe68deb8580462ca0d9eb56a81912f59eb4542e1ef8f987405e35a0179f4ea2"}, + {file = "Pillow-10.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:522ff4ac3aaf839242c6f4e5b406634bfea002469656ae8358644fc6c4856a3b"}, + {file = "Pillow-10.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:84efb46e8d881bb06b35d1d541aa87f574b58e87f781cbba8d200daa835b42e1"}, + {file = "Pillow-10.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:898f1d306298ff40dc1b9ca24824f0488f6f039bc0e25cfb549d3195ffa17088"}, + {file = "Pillow-10.0.1-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:bcf1207e2f2385a576832af02702de104be71301c2696d0012b1b93fe34aaa5b"}, + {file = "Pillow-10.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d6c9049c6274c1bb565021367431ad04481ebb54872edecfcd6088d27edd6ed"}, + {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28444cb6ad49726127d6b340217f0627abc8732f1194fd5352dec5e6a0105635"}, + {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de596695a75496deb3b499c8c4f8e60376e0516e1a774e7bc046f0f48cd620ad"}, + {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2872f2d7846cf39b3dbff64bc1104cc48c76145854256451d33c5faa55c04d1a"}, + {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4ce90f8a24e1c15465048959f1e94309dfef93af272633e8f37361b824532e91"}, + {file = "Pillow-10.0.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ee7810cf7c83fa227ba9125de6084e5e8b08c59038a7b2c9045ef4dde61663b4"}, + {file = "Pillow-10.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b1be1c872b9b5fcc229adeadbeb51422a9633abd847c0ff87dc4ef9bb184ae08"}, + {file = "Pillow-10.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:98533fd7fa764e5f85eebe56c8e4094db912ccbe6fbf3a58778d543cadd0db08"}, + {file = "Pillow-10.0.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:764d2c0daf9c4d40ad12fbc0abd5da3af7f8aa11daf87e4fa1b834000f4b6b0a"}, + {file = "Pillow-10.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fcb59711009b0168d6ee0bd8fb5eb259c4ab1717b2f538bbf36bacf207ef7a68"}, + {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:697a06bdcedd473b35e50a7e7506b1d8ceb832dc238a336bd6f4f5aa91a4b500"}, + {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f665d1e6474af9f9da5e86c2a3a2d2d6204e04d5af9c06b9d42afa6ebde3f21"}, + {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:2fa6dd2661838c66f1a5473f3b49ab610c98a128fc08afbe81b91a1f0bf8c51d"}, + {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:3a04359f308ebee571a3127fdb1bd01f88ba6f6fb6d087f8dd2e0d9bff43f2a7"}, + {file = "Pillow-10.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:723bd25051454cea9990203405fa6b74e043ea76d4968166dfd2569b0210886a"}, + {file = "Pillow-10.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:71671503e3015da1b50bd18951e2f9daf5b6ffe36d16f1eb2c45711a301521a7"}, + {file = "Pillow-10.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:44e7e4587392953e5e251190a964675f61e4dae88d1e6edbe9f36d6243547ff3"}, + {file = "Pillow-10.0.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:3855447d98cced8670aaa63683808df905e956f00348732448b5a6df67ee5849"}, + {file = "Pillow-10.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ed2d9c0704f2dc4fa980b99d565c0c9a543fe5101c25b3d60488b8ba80f0cce1"}, + {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5bb289bb835f9fe1a1e9300d011eef4d69661bb9b34d5e196e5e82c4cb09b37"}, + {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0d3e54ab1df9df51b914b2233cf779a5a10dfd1ce339d0421748232cea9876"}, + {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:2cc6b86ece42a11f16f55fe8903595eff2b25e0358dec635d0a701ac9586588f"}, + {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:ca26ba5767888c84bf5a0c1a32f069e8204ce8c21d00a49c90dabeba00ce0145"}, + {file = "Pillow-10.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f0b4b06da13275bc02adfeb82643c4a6385bd08d26f03068c2796f60d125f6f2"}, + {file = "Pillow-10.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bc2e3069569ea9dbe88d6b8ea38f439a6aad8f6e7a6283a38edf61ddefb3a9bf"}, + {file = "Pillow-10.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:8b451d6ead6e3500b6ce5c7916a43d8d8d25ad74b9102a629baccc0808c54971"}, + {file = "Pillow-10.0.1-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:32bec7423cdf25c9038fef614a853c9d25c07590e1a870ed471f47fb80b244db"}, + {file = "Pillow-10.0.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7cf63d2c6928b51d35dfdbda6f2c1fddbe51a6bc4a9d4ee6ea0e11670dd981e"}, + {file = "Pillow-10.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f6d3d4c905e26354e8f9d82548475c46d8e0889538cb0657aa9c6f0872a37aa4"}, + {file = "Pillow-10.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:847e8d1017c741c735d3cd1883fa7b03ded4f825a6e5fcb9378fd813edee995f"}, + {file = "Pillow-10.0.1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:7f771e7219ff04b79e231d099c0a28ed83aa82af91fd5fa9fdb28f5b8d5addaf"}, + {file = "Pillow-10.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459307cacdd4138edee3875bbe22a2492519e060660eaf378ba3b405d1c66317"}, + {file = "Pillow-10.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b059ac2c4c7a97daafa7dc850b43b2d3667def858a4f112d1aa082e5c3d6cf7d"}, + {file = "Pillow-10.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6caf3cd38449ec3cd8a68b375e0c6fe4b6fd04edb6c9766b55ef84a6e8ddf2d"}, + {file = "Pillow-10.0.1.tar.gz", hash = "sha256:d72967b06be9300fed5cfbc8b5bafceec48bf7cdc7dab66b1d2549035287191d"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] + [[package]] name = "pyasn1" version = "0.5.0" @@ -273,4 +340,4 @@ email = ["email-validator"] [metadata] lock-version = "2.0" python-versions = "~3.11" -content-hash = "b026e5d2d31ebde4256e7793a62fbba895b049cfc270bf24ab1a0ed352f9d933" +content-hash = "5fe83018dde9d22d0820c713b674849afb60d077ad33429fcfa330cafcba4ef0" diff --git a/pyproject.toml b/pyproject.toml index d4f355e..d971f13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ itsdangerous = "^2.0" Jinja2 = "^3.0" kerberos = "^1.3.0" MarkupSafe = "^2.0" +pillow = "^10.0.1" python-ldap = "^3.2.0" uWSGI = "^2.0" Werkzeug = "^2.0" diff --git a/syrenka.png b/syrenka.png new file mode 100644 index 0000000..5001f35 Binary files /dev/null and b/syrenka.png differ diff --git a/webapp/__init__.py b/webapp/__init__.py index 3ddbe54..f3b304c 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -45,8 +45,8 @@ def start(): validation.sanitize_readable() from webapp import views - from webapp import auth, admin, vcard, passwd - for module in (auth, admin, vcard, passwd): + from webapp import auth, admin, avatar, vcard, passwd + for module in (auth, admin, avatar, vcard, passwd): app.register_blueprint(module.bp) app.connections = pools.LDAPConnectionPool(config.ldap_url, timeout=300.0) diff --git a/webapp/avatar.py b/webapp/avatar.py new file mode 100644 index 0000000..240ad78 --- /dev/null +++ b/webapp/avatar.py @@ -0,0 +1,215 @@ +""" +Module which serves users' avatars. + +Based on a simple cache which keeps all avatars in memory. +""" + +import base64 +import binascii +import colorsys +import time +import io +import logging +import random + +from PIL import Image, ImageDraw +import flask +import ldap + +from webapp import context, ldaputils + +bp = flask.Blueprint('avatar', __name__) +log = logging.getLogger('ldap-web.avatar') + + +# Stolen from https://stackoverflow.com/questions/43512615/reshaping-rectangular-image-to-square +def resize_image(image: Image, length: int) -> Image: + """ + Resize an image to a square. Can make an image bigger to make it fit or smaller if it doesn't fit. It also crops + part of the image. + + :param self: + :param image: Image to resize. + :param length: Width and height of the output image. + :return: Return the resized image. + """ + + """ + Resizing strategy : + 1) We resize the smallest side to the desired dimension (e.g. 1080) + 2) We crop the other side so as to make it fit with the same length as the smallest side (e.g. 1080) + """ + if image.size[0] < image.size[1]: + # The image is in portrait mode. Height is bigger than width. + + # This makes the width fit the LENGTH in pixels while conserving the ration. + resized_image = image.resize((length, int(image.size[1] * (length / image.size[0])))) + + # Amount of pixel to lose in total on the height of the image. + required_loss = (resized_image.size[1] - length) + + # Crop the height of the image so as to keep the center part. + resized_image = resized_image.crop( + box=(0, required_loss / 2, length, resized_image.size[1] - required_loss / 2)) + + # We now have a length*length pixels image. + return resized_image + else: + # This image is in landscape mode or already squared. The width is bigger than the heihgt. + + # This makes the height fit the LENGTH in pixels while conserving the ration. + resized_image = image.resize((int(image.size[0] * (length / image.size[1])), length)) + + # Amount of pixel to lose in total on the width of the image. + required_loss = resized_image.size[0] - length + + # Crop the width of the image so as to keep 1080 pixels of the center part. + resized_image = resized_image.crop( + box=(required_loss / 2, 0, resized_image.size[0] - required_loss / 2, length)) + + # We now have a length*length pixels image. + return resized_image + + +syrenka = Image.open("syrenka.png") + + +def default_avatar(uid: str) -> Image: + """ + Create little generative avatar for people who don't have a custom one + configured. + """ + img = Image.new('RGBA', (256, 256), (255, 255, 255, 0)) + draw = ImageDraw.Draw(img) + + # Deterministic rng for stable output. + rng = random.Random(uid) + + # Pick a nice random neon color. + n_h, n_s, n_l = rng.random(), 0.5 + rng.random()/2.0, 0.4 + rng.random()/5.0 + + # Use muted version for background. + r, g, b = [int(256*i) for i in colorsys.hls_to_rgb(n_h, n_l+0.3, n_s-0.1)] + draw.rectangle([(0, 0), (256, 256)], fill=(r,g,b,255)) + + # Scale logo by randomized factor. + factor = 0.7 + 0.1 * rng.random() + w, h = int(syrenka.size[0] * factor), int(syrenka.size[1] * factor) + overlay = syrenka.resize((w, h)) + + # Crop to headshot. + overlay = overlay.crop(box=(0, 0, w, w)) + + # Give it a little nudge. + overlay = overlay.rotate((rng.random() - 0.5) * 100) + + # Colorize with full neon color. + r, g, b = [int(256*i) for i in colorsys.hls_to_rgb(n_h,n_l,n_s)] + pixels = overlay.load() + for x in range(img.size[0]): + for y in range(img.size[1]): + alpha = pixels[x, y][3] + pixels[x, y] = (r, g, b, alpha) + + img.alpha_composite(overlay) + + res = io.BytesIO() + img.save(res, 'PNG') + return res.getvalue() + + +class AvatarCacheEntry: + # UID of the avatar's user. + uid: str + # Deadline when this entry expires. + deadline: float + # Cached source bytes + data: bytes + # Cached converted bytes + _converted: bytes + + def __init__(self, uid: str, data: bytes): + self.uid = uid + self.deadline = time.time() + 3600 + self.data = data + self._converted = b"" + + def serve(self): + """ + Serve sanitized image. Always re-encode to PNG 256x256. + """ + # Re-encode to PNG if we haven't yet. + if self._converted == b"": + try: + img = Image.open(io.BytesIO(self.data)) + except Exception as e: + log.warning("Could not parse avatar for {}: {}".format(self.uid, e)) + self.data = default_avatar(self.uid) + img = Image.open(io.BytesIO(self.data)) + + res = io.BytesIO() + img = resize_image(img, 256) + img.save(res, 'PNG') + self._converted = res.getvalue() + + return flask.Response(self._converted, mimetype='image/png') + + +class AvatarCache: + # keyed by uid + entries: dict[str, AvatarCacheEntry] + + def __init__(self): + self.entries = {} + + def get(self, uid: str, bust: bool = False) -> AvatarCacheEntry: + """ + Get avatar, either from cache or from LDAP on cache miss. If 'bust' is + set to True, the cache will not be consulted and the newest result from + LDAP will always be served. + """ + now = time.time() + # Try to get a cache entry to serve, if possible. + if not bust and uid in self.entries: + entry = self.entries[uid] + if entry.deadline > now: + return entry.serve() + + # Otherwise, retrieve from LDAP. + conn = context.get_admin_connection() + try: + dn = ldaputils.user_dn(uid) + res = conn.search_s(dn, ldap.SCOPE_SUBTREE) + except ldap.NO_SUCH_OBJECT: + res = [] + + avatar = None + if len(res) == 1: + for attr, vs in res[0][1].items(): + if attr == 'jpegPhoto': + for v in vs: + try: + avatar = base64.b64decode(v) + except binascii.Error as e: + log.warning("Could not b64decode avatar for {}".format(uid)) + avatar = None + else: + break + break + # If nothing was found in LDAP (either uid doesn't exist or uid doesn't + # have an avatar attached), serve default avatar. + if avatar is None: + avatar = default_avatar(uid) + + # Save avatar in cache. + entry = AvatarCacheEntry(uid, avatar) + self.entries[uid] = entry + + # And serve the entry. + return entry.serve() + +cache = AvatarCache() + +@bp.route('/avatar/', methods=['GET']) +def avatar_serve(uid): + return cache.get(uid)