You've already forked snikket-web-portal
Implement support for avatars
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user