diff --git a/snikket_web/__init__.py b/snikket_web/__init__.py index f30ee89..2aaccbe 100644 --- a/snikket_web/__init__.py +++ b/snikket_web/__init__.py @@ -1,6 +1,11 @@ +import base64 +import binascii + import quart.flask_patch -from quart import Quart, session, request, render_template, redirect, url_for +from quart import ( + Quart, session, request, render_template, redirect, url_for, Response +) from .prosodyclient import client @@ -43,5 +48,53 @@ async def about(): async def demo(): return await render_template("demo.html") -from .user import user_bp + +def repad(s): + return s + "=" * (4 - len(s) % 4) + + +@app.route("/avatar//") +async def avatar(from_, code): + try: + etag = request.headers["if-none-match"] + except KeyError: + etag = None + + address = base64.urlsafe_b64decode(repad(from_)).decode("utf-8") + info = await client.get_avatar(address, metadata_only=True) + bin_hash = binascii.a2b_hex(info["sha1"]) + new_etag = base64.urlsafe_b64encode(bin_hash).decode("ascii").rstrip("=") + + headers = { + "ETag": new_etag, + } + + if etag is not None: + if new_etag == etag: + return Response( + [], + 304, + content_type=info["type"], headers=headers + ) + + data = await client.get_avatar_data(address, info["sha1"]) + return Response(data, content_type=info["type"], headers=headers) + + +@app.context_processor +def proc(): + def url_for_avatar(entity, hash_, **kwargs): + return url_for( + "avatar", + from_=base64.urlsafe_b64encode(entity.encode("utf-8")).decode("ascii").rstrip("="), + code=base64.urlsafe_b64encode(binascii.a2b_hex(hash_)[:8]).decode("ascii").rstrip("="), + **kwargs + ) + + return { + "url_for_avatar": url_for_avatar + } + + +from .user import user_bp # NOQA app.register_blueprint(user_bp) diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py index 17f2726..3844a54 100644 --- a/snikket_web/prosodyclient.py +++ b/snikket_web/prosodyclient.py @@ -1,5 +1,7 @@ +import binascii import contextlib import functools +import hashlib import aiohttp @@ -9,6 +11,7 @@ from quart import ( current_app, _app_ctx_stack, session as http_session, abort, redirect, url_for, ) +import quart.exceptions from . import xmpputil from .xmpputil import split_jid @@ -214,10 +217,22 @@ class ProsodyClient: async with self._auth_session as session: nickname = await self.get_user_nickname(session=session) + try: + avatar_info = await self.get_avatar( + self.session_address, + metadata_only=True, + session=session, + ) + avatar_hash = avatar_info["sha1"] + except quart.exceptions.BaseException: + avatar_hash = None + return { + "address": self.session_address, "username": localpart, "nickname": nickname, "display_name": nickname or localpart, + "avatar_hash": avatar_hash, } @autosession @@ -238,6 +253,58 @@ class ProsodyClient: # just to throw errors xmpputil.extract_iq_reply(iq_resp) + @autosession + async def get_avatar(self, from_, session, + metadata_only=False): + metadata_resp = await self._xml_iq_call( + session, + xmpputil.make_avatar_metadata_request(from_) + ) + info = xmpputil.extract_avatar_metadata_get_reply(metadata_resp) + if info is None: + raise abort(404, "entity has no avatar") + + if not metadata_only: + info["data"] = await self.get_avatar_data( + from_, info["sha1"], + session=session, + ) + + return info + + @autosession + async def get_avatar_data(self, from_, id_, session): + data_resp = await self._xml_iq_call( + session, + xmpputil.make_avatar_data_request(from_, id_) + ) + return xmpputil.extract_avatar_data_get_reply(data_resp) + + @autosession + async def set_user_avatar(self, data, mimetype, session): + id_ = hashlib.sha1(data).hexdigest() + + data_resp = await self._xml_iq_call( + session, + xmpputil.make_avatar_data_set_request(self.session_address, + data, + id_) + ) + xmpputil.extract_iq_reply(data_resp) + + metadata_resp = await self._xml_iq_call( + session, + xmpputil.make_avatar_metadata_set_request( + self.session_address, + mimetype=mimetype, + id_=id_, + size=len(data), + width=None, + height=None, + ) + ) + xmpputil.extract_iq_reply(metadata_resp) + async def change_password(self, current_password, new_password): # we play it safe here and do not use the existing auth session; # instead, we do a login on the plain session and use the token we diff --git a/snikket_web/scss/app.scss b/snikket_web/scss/app.scss index 3e623b9..12fd8b4 100644 --- a/snikket_web/scss/app.scss +++ b/snikket_web/scss/app.scss @@ -1,6 +1,9 @@ @import "_theme.scss"; @import "_baseline.scss"; +$_top-h-size: nth($h-sizes, 1); +$_top-h-small-size: nth($h-small-sizes, 1); + /* coarse layout */ body { @@ -35,10 +38,11 @@ div#topbar { & > header { flex: 0 1 auto; color: black; - font-size: nth($h-sizes, 1); + font-size: $_top-h-size; + line-height: 1.5; @media screen and (max-width: $small-screen-threshold) { - font-size: nth($h-small-sizes, 1); + font-size: $_top-h-small-size; } & > a { @@ -627,6 +631,30 @@ button.lv-tertiary, .button.lv-tertiary { } } +/* avatar */ + +.avatar { + display: inline-block; + font-size: $_top-h-size; + width: 1.5em; + height: 1.5em; + vertical-align: middle; + background-size: cover; + box-shadow: inset 0px 0px 0px 2px rgba(0, 0, 0, 0.2); + border-radius: $w-s4; + + margin: 0 0.25em; + + @media screen and (max-width: $small-screen-threshold) { + font-size: $_top-h-small-size; + } +} + +nav.usermenu > .avatar { + /* we can increase the size to the size of the h1 here */ + +} + /* login page specials */ body#login { diff --git a/snikket_web/templates/app.html b/snikket_web/templates/app.html index 9aba0d4..9564d1c 100644 --- a/snikket_web/templates/app.html +++ b/snikket_web/templates/app.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "library.j2" import avatar with context %} {% block head_lead %} Snikket Web Portal {% endblock %} @@ -10,7 +11,7 @@
{{ config["SNIKKET_DOMAIN"] }}
- +
{% block content %}{% endblock %}