Implement support for avatars

This commit is contained in:
Jonas Schäfer
2020-03-07 12:38:17 +01:00
parent 8785a99621
commit c902c59f8b
9 changed files with 304 additions and 27 deletions

View File

@@ -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/<from_>/<code>")
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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "library.j2" import avatar with context %}
{% block head_lead %}
<title>Snikket Web Portal</title>
{% endblock %}
@@ -10,7 +11,7 @@
<div id="topbar">
<header><a href="{{ url_for('user.index') }}"><span>{{ config["SNIKKET_DOMAIN"] }}</span></a></header>
<div class="filler"></div>
<nav class="usermenu">{{ user_info.display_name }}</nav>
<nav class="usermenu">{% call avatar(user_info.address, user_info.avatar_hash ) %}{% endcall %}{{ user_info.display_name }}</nav>
</div>
<main>{% block content %}{% endblock %}</main>
<footer>

View File

@@ -1,3 +1,10 @@
{% macro box(type, title, slim=False, caller=None) %}
<aside class="box{% if slim %} slim{% endif %} {{ type }}"><header>{{ title }}</header> {{ caller() }}</aside>
{% endmacro %}
{% macro avatar(from_, hash, caller=None) -%}
{%- if hash -%}
<div class="avatar" style="background-image: url('{{ url_for_avatar(from_, hash) }}');"></div>
{%- else -%}
{%- endif -%}
{%- endmacro %}

View File

@@ -16,9 +16,4 @@
<p>Exit the Snikket Web Portal, without logging out your other devices.</p>
</a>
</div>
<!-- <ul>
<li><a href="{{ url_for('user.change_pw') }}">Change password</a></li>
<li><a href="{{ url_for('user.change_pw') }}">Change password</a></li>
<li><a href="{{ url_for('user.logout') }}">Log out</a></li>
</ul> -->
{% endblock %}

View File

@@ -3,13 +3,17 @@
<title>Snikket Web Portal</title>
{% endblock %}
{% block content %}
<div class="form layout-expanded"><form method="POST">
<div class="form layout-expanded"><form method="POST" enctype="multipart/form-data">
<h2 class="form-title">Profile</h2>
{{ form.csrf_token }}
<div class="f-ebox">
{{ form.nickname.label }}
{{ form.nickname(placeholder=user_info.username) }}
</div>
<div class="f-ebox">
{{ form.avatar.label }}
{{ form.avatar }}
</div>
<div class="f-bbox">
<a href="{{ url_for('user.index') }}" class="button secondary">Back</a><button type="submit" class="primary">Update</button>
</div>

View File

@@ -49,6 +49,10 @@ class ProfileForm(FlaskForm):
"Display name",
)
avatar = wtforms.FileField(
"Avatar"
)
@user_bp.route("/")
async def index():
@@ -86,6 +90,14 @@ async def profile():
if form.validate_on_submit():
user_info = await client.get_user_info()
file_info = (await request.files).get(form.avatar.name)
if file_info is not None:
mimetype = file_info.mimetype
data = file_info.stream.read()
if len(data) > 0:
await client.set_user_avatar(data, mimetype)
if user_info.get("nickname") != form.nickname.data:
await client.set_user_nickname(form.nickname.data)
return redirect(url_for(".profile"))

View File

@@ -1,3 +1,5 @@
import base64
import binascii
import typing
import xml.etree.ElementTree as ET
@@ -10,7 +12,7 @@ TAG_XMPP_ERROR = "error"
NS_XMPP_ERROR_CONDITION = "urn:ietf:params:xml:ns:xmpp-stanzas"
TAG_XMPP_ERROR_ITEM_NOT_FOUND = "{{{}}}item-not-found".format(NS_XMPP_ERROR_CONDITION)
TAG_XMPP_ERROR_TEXT = "{{{}}}text".format(TAG_XMPP_ERROR_ITEM_NOT_FOUND)
TAG_XMPP_ERROR_TEXT = "{{{}}}text".format(NS_XMPP_ERROR_CONDITION)
ERROR_CODE_MAP = {
TAG_XMPP_ERROR_ITEM_NOT_FOUND: 404,
@@ -22,8 +24,18 @@ TAG_PUBSUB_ITEM = "{{{}}}item".format(NS_PUBSUB)
TAG_PUBSUB_ITEMS = "{{{}}}items".format(NS_PUBSUB)
NS_USER_NICKNAME = "http://jabber.org/protocol/nick"
NODE_USER_NICKNAME = NS_USER_NICKNAME
TAG_USER_NICKNAME_NICK = "{{{}}}nick".format(NS_USER_NICKNAME)
NODE_USER_AVATAR_METADATA = "urn:xmpp:avatar:metadata"
NS_USER_AVATAR_METADATA = "urn:xmpp:avatar:metadata"
TAG_USER_AVATAR_METADATA = "{{{}}}metadata".format(NS_USER_AVATAR_METADATA)
TAG_USER_AVATAR_METADATA_INFO = "{{{}}}info".format(NS_USER_AVATAR_METADATA)
NODE_USER_AVATAR_DATA = "urn:xmpp:avatar:data"
NS_USER_AVATAR_DATA = "urn:xmpp:avatar:data"
TAG_USER_AVATAR_DATA = "{{{}}}data".format(NS_USER_AVATAR_DATA)
def split_jid(s):
bare, sep, resource = s.partition("/")
@@ -49,6 +61,8 @@ def raise_iq_error(err: ET.Element):
else:
err_app_def_condition_el = el
print(err_text_el, err_condition_el, err_app_def_condition_el)
abort(ERROR_CODE_MAP.get(err_condition_el.tag, 500),
err_condition_el.tag)
@@ -86,25 +100,83 @@ def make_password_change_request(jid, password):
return ET.tostring(req)
def make_nickname_set_request(to, nickname):
def make_pubsub_item_put_request(to, node, id_=None):
req = ET.Element("iq", type="set", to=to)
q = ET.SubElement(req, "pubsub", xmlns="http://jabber.org/protocol/pubsub")
publish = ET.SubElement(q, "publish", node="http://jabber.org/protocol/nick")
q = ET.SubElement(req, "pubsub", xmlns=NS_PUBSUB)
publish = ET.SubElement(q, "publish", node=node)
item = ET.SubElement(publish, "item")
ET.SubElement(
item,
"nick",
xmlns="http://jabber.org/protocol/nick"
).text = nickname
if id_ is not None:
item.set("id", id_)
return req, item
def make_nickname_set_request(to, nickname):
req, item = make_pubsub_item_put_request(
to,
NODE_USER_NICKNAME,
)
ET.SubElement(item, "nick", xmlns=NS_USER_NICKNAME).text = nickname
return ET.tostring(req)
def make_pubsub_item_request(to, node, id_=None):
req = ET.Element("iq", type="get", to=to)
q = ET.SubElement(req, "pubsub", xmlns=NS_PUBSUB)
items = ET.SubElement(q, "items", node=node)
if id_ is not None:
ET.SubElement(items, "item", id=id_)
else:
items.set("max_items", "1")
return ET.tostring(req)
def make_nickname_get_request(to):
req = ET.Element("iq", type="get", to=to)
q = ET.SubElement(req, "pubsub", xmlns="http://jabber.org/protocol/pubsub")
items = ET.SubElement(q, "items", node="http://jabber.org/protocol/nick", max_items="1")
return make_pubsub_item_request(to, NODE_USER_NICKNAME)
def make_avatar_metadata_request(to):
return make_pubsub_item_request(to, NODE_USER_AVATAR_METADATA)
def make_avatar_data_request(to, sha1):
return make_pubsub_item_request(to, NODE_USER_AVATAR_DATA, id_=sha1)
def make_avatar_data_set_request(to, data, id_):
req, item = make_pubsub_item_put_request(
to,
NODE_USER_AVATAR_DATA,
id_=id_,
)
ET.SubElement(item, "data", xmlns=NS_USER_AVATAR_DATA).text = \
base64.b64encode(data).decode("ascii")
return ET.tostring(req)
def make_avatar_metadata_set_request(to, mimetype: str, id_: str, size: int,
width: int = None,
height: int = None):
req, item = make_pubsub_item_put_request(
to,
NODE_USER_AVATAR_METADATA,
id_=id_,
)
metadata_wrap = ET.SubElement(
item,
"metadata", xmlns=NS_USER_AVATAR_METADATA)
attr = {
"id": id_,
"bytes": str(size),
"type": mimetype,
}
if width is not None:
attr["width"] = str(width)
if height is not None:
attr["height"] = str(height)
ET.SubElement(metadata_wrap, "info", xmlns=NS_USER_AVATAR_METADATA, **attr)
return ET.tostring(req)
@@ -115,7 +187,7 @@ def _require_child(t: ET.Element, tag: str) -> ET.Element:
return el
def extract_nickname_get_reply(iq_tree):
def extract_pubsub_item_get_reply(iq_tree, payload_tag):
try:
pubsub = extract_iq_reply(iq_tree, TAG_PUBSUB)
except quart.exceptions.NotFound:
@@ -125,6 +197,44 @@ def extract_nickname_get_reply(iq_tree):
if len(items) == 0:
return None
item = _require_child(items, TAG_PUBSUB_ITEM)
nick = _require_child(item, TAG_USER_NICKNAME_NICK)
return nick.text
return _require_child(_require_child(items, TAG_PUBSUB_ITEM), payload_tag)
def extract_nickname_get_reply(iq_tree):
return extract_pubsub_item_get_reply(iq_tree, TAG_USER_NICKNAME_NICK).text
def extract_avatar_metadata_get_reply(iq_tree):
metadata = extract_pubsub_item_get_reply(iq_tree, TAG_USER_AVATAR_METADATA)
if metadata is None:
return None
if len(metadata) != 1 or metadata[0].tag != TAG_USER_AVATAR_METADATA_INFO:
# raise an error instead?
return None
info = metadata[0]
attrs = info.attrib
result = {
"sha1": attrs["id"],
"type": attrs.get("type", "image/png"),
}
def extract_optional(key, type_=int):
try:
result[key] = type_(attrs[key])
except (KeyError, ValueError, TypeError):
pass
extract_optional("width")
extract_optional("height")
extract_optional("bytes")
return result
def extract_avatar_data_get_reply(iq_tree):
data = extract_pubsub_item_get_reply(iq_tree, TAG_USER_AVATAR_DATA)
if data.text is None:
return None
return base64.b64decode(data.text)