From 427f73811ca5786f2fdee10f8801dc8d11c976d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Sch=C3=A4fer?= Date: Sun, 17 Jan 2021 14:26:32 +0100 Subject: [PATCH] Add support for modifying profile access model Fixes #17. --- snikket_web/prosodyclient.py | 133 ++++++++++++++++++ snikket_web/scss/app.scss | 12 ++ snikket_web/templates/user_profile.html | 6 + .../translations/de/LC_MESSAGES/messages.po | 44 ++++-- .../translations/en/LC_MESSAGES/messages.po | 45 ++++-- snikket_web/user.py | 29 ++++ snikket_web/xmpputil.py | 85 ++++++++++- 7 files changed, 337 insertions(+), 17 deletions(-) diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py index a54a047..c110a3b 100644 --- a/snikket_web/prosodyclient.py +++ b/snikket_web/prosodyclient.py @@ -1,3 +1,4 @@ +import asyncio import dataclasses import enum import functools @@ -494,6 +495,138 @@ class ProsodyClient: ) return xmpputil.extract_avatar_data_get_reply(data_resp) + @autosession + async def get_pubsub_node_access_model( + self, + to: str, + node: str, + default: str, + *, + session: aiohttp.ClientSession) -> str: + config = xmpputil.extract_pubsub_node_config_get_reply( + await self._xml_iq_call( + session, + xmpputil.make_pubsub_node_config_get_request( + to, + node, + ), + ) + ) + try: + return config[xmpputil.FORM_FIELD_PUBSUB_ACCESS_MODEL][0] + except (ValueError, KeyError): + return default + + @autosession + async def set_pubsub_node_access_model( + self, + to: str, + node: str, + new_access_model: str, + *, + ignore_not_found: bool = False, + session: aiohttp.ClientSession) -> None: + try: + xmpputil.extract_iq_reply(await self._xml_iq_call( + session, + xmpputil.make_pubsub_access_model_put_request( + to, + node, + new_access_model, + ) + )) + except quart.exceptions.NotFound: + if ignore_not_found: + return + raise + + @autosession + async def get_nickname_access_model( + self, + *, + session: aiohttp.ClientSession) -> str: + return await self.get_pubsub_node_access_model( + self.session_address, + xmpputil.NODE_USER_NICKNAME, + "open", + session=session, + ) + + @autosession + async def set_nickname_access_model( + self, + new_access_model: str, + *, + session: aiohttp.ClientSession) -> None: + await self.set_pubsub_node_access_model( + self.session_address, + xmpputil.NODE_USER_NICKNAME, + new_access_model, + session=session, + ignore_not_found=True, + ) + + @autosession + async def get_avatar_access_model( + self, + *, + session: aiohttp.ClientSession) -> str: + return await self.get_pubsub_node_access_model( + self.session_address, + xmpputil.NODE_USER_AVATAR_METADATA, + "open", + session=session, + ) + + @autosession + async def set_avatar_access_model( + self, + new_access_model: str, + *, + session: aiohttp.ClientSession) -> None: + await asyncio.gather( + self.set_pubsub_node_access_model( + self.session_address, + xmpputil.NODE_USER_AVATAR_DATA, + new_access_model, + ignore_not_found=True, + session=session, + ), + self.set_pubsub_node_access_model( + self.session_address, + xmpputil.NODE_USER_AVATAR_METADATA, + new_access_model, + ignore_not_found=True, + session=session, + ) + ) + + @autosession + async def get_vcard_access_model( + self, + *, + session: aiohttp.ClientSession) -> str: + return await self.get_pubsub_node_access_model( + self.session_address, + xmpputil.NODE_VCARD, + "open", + session=session, + ) + + @autosession + async def set_vcard_access_model( + self, + new_access_model: str, + *, + session: aiohttp.ClientSession) -> None: + await self.set_pubsub_node_access_model( + self.session_address, + xmpputil.NODE_VCARD, + new_access_model, + session=session, + ignore_not_found=True, + ) + @autosession async def set_user_avatar( self, diff --git a/snikket_web/scss/app.scss b/snikket_web/scss/app.scss index fd1795c..7dc75da 100644 --- a/snikket_web/scss/app.scss +++ b/snikket_web/scss/app.scss @@ -389,6 +389,18 @@ div.form.layout-expanded { } } +.f-ebox > ul { + /* radio group */ + list-style-type: none; + margin: 0; + padding: 0; + + > li { + margin: 0; + padding: 0; + } +} + /* form buttons */ diff --git a/snikket_web/templates/user_profile.html b/snikket_web/templates/user_profile.html index 3c079cf..e3467f2 100644 --- a/snikket_web/templates/user_profile.html +++ b/snikket_web/templates/user_profile.html @@ -14,6 +14,12 @@ {{ form.avatar.label }} {{ form.avatar }} +

{% trans %}Visibility{% endtrans %}

+

{% trans %}This section allows you to control who can see your profile information, like avatar and nickname.{% endtrans %}

+
+ {{ form.profile_access_model.label }} + {{ form.profile_access_model }} +
{% trans %}Back{% endtrans %}
diff --git a/snikket_web/translations/de/LC_MESSAGES/messages.po b/snikket_web/translations/de/LC_MESSAGES/messages.po index df87ff7..8933276 100644 --- a/snikket_web/translations/de/LC_MESSAGES/messages.po +++ b/snikket_web/translations/de/LC_MESSAGES/messages.po @@ -42,33 +42,49 @@ msgstr "Passwort" msgid "Invalid user name or password." msgstr "Benutzername oder Passwort falsch." -#: snikket_web/user.py:25 +#: snikket_web/user.py:26 msgid "Current password" msgstr "Aktuelles Passwort" -#: snikket_web/user.py:30 +#: snikket_web/user.py:31 msgid "New password" msgstr "Neues Passwort" -#: snikket_web/user.py:35 +#: snikket_web/user.py:36 msgid "Confirm new password" msgstr "Neues Passwort (Bestätigung)" -#: snikket_web/user.py:39 +#: snikket_web/user.py:40 msgid "The new passwords must match." msgstr "Die neuen Passwörter müssen übereinstimmen." +#: snikket_web/user.py:50 +msgid "Nobody" +msgstr "Niemand" + +#: snikket_web/user.py:51 +msgid "Friends only" +msgstr "Nur Freunde" + +#: snikket_web/user.py:52 +msgid "Everyone" +msgstr "Jeder" + #: snikket_web/templates/admin_delete_user.html:12 #: snikket_web/templates/admin_delete_user.html:16 -#: snikket_web/templates/admin_users.html:15 snikket_web/user.py:50 +#: snikket_web/templates/admin_users.html:15 snikket_web/user.py:58 msgid "Display name" msgstr "Anzeigename" -#: snikket_web/user.py:54 +#: snikket_web/user.py:62 msgid "Avatar" msgstr "Bild" -#: snikket_web/user.py:78 +#: snikket_web/user.py:66 +msgid "Profile visibility" +msgstr "Profilsichtbarkeit" + +#: snikket_web/user.py:91 msgid "Incorrect password" msgstr "Ungültiges Passwort" @@ -107,7 +123,7 @@ msgstr "Einladung anzeigen" #: snikket_web/templates/admin_edit_invite.html:39 #: snikket_web/templates/user_passwd.html:39 -#: snikket_web/templates/user_profile.html:18 +#: snikket_web/templates/user_profile.html:24 msgid "Back" msgstr "Zurück" @@ -307,7 +323,19 @@ msgstr "" msgid "Profile" msgstr "Profil" +#: snikket_web/templates/user_profile.html:17 +msgid "Visibility" +msgstr "Sichtbarkeit" + #: snikket_web/templates/user_profile.html:18 +msgid "" +"This section allows you to control who can see your profile information, " +"like avatar and nickname." +msgstr "" +"Hier kannst du einstellen, wer deine Profilinformationen, wie Bild oder " +"Anzeigename einsehen kann." + +#: snikket_web/templates/user_profile.html:24 msgid "Apply" msgstr "Übernehmen" diff --git a/snikket_web/translations/en/LC_MESSAGES/messages.po b/snikket_web/translations/en/LC_MESSAGES/messages.po index 9624f53..a6055f6 100644 --- a/snikket_web/translations/en/LC_MESSAGES/messages.po +++ b/snikket_web/translations/en/LC_MESSAGES/messages.po @@ -43,33 +43,49 @@ msgstr "Password" msgid "Invalid user name or password." msgstr "Confirm new password" -#: snikket_web/user.py:25 +#: snikket_web/user.py:26 msgid "Current password" msgstr "Current password" -#: snikket_web/user.py:30 +#: snikket_web/user.py:31 msgid "New password" msgstr "New password" -#: snikket_web/user.py:35 +#: snikket_web/user.py:36 msgid "Confirm new password" msgstr "Confirm new password" -#: snikket_web/user.py:39 +#: snikket_web/user.py:40 msgid "The new passwords must match." msgstr "The new passwords must match." +#: snikket_web/user.py:50 +msgid "Nobody" +msgstr "" + +#: snikket_web/user.py:51 +msgid "Friends only" +msgstr "" + +#: snikket_web/user.py:52 +msgid "Everyone" +msgstr "" + #: snikket_web/templates/admin_delete_user.html:12 #: snikket_web/templates/admin_delete_user.html:16 -#: snikket_web/templates/admin_users.html:15 snikket_web/user.py:50 +#: snikket_web/templates/admin_users.html:15 snikket_web/user.py:58 msgid "Display name" msgstr "Display name" -#: snikket_web/user.py:54 +#: snikket_web/user.py:62 msgid "Avatar" msgstr "Avatar" -#: snikket_web/user.py:78 +#: snikket_web/user.py:66 +msgid "Profile visibility" +msgstr "" + +#: snikket_web/user.py:91 msgid "Incorrect password" msgstr "Incorrect password" @@ -109,7 +125,7 @@ msgstr "" #: snikket_web/templates/admin_edit_invite.html:39 #: snikket_web/templates/user_passwd.html:39 -#: snikket_web/templates/user_profile.html:18 +#: snikket_web/templates/user_profile.html:24 msgid "Back" msgstr "Back" @@ -301,10 +317,23 @@ msgstr "" msgid "Profile" msgstr "Profile" +#: snikket_web/templates/user_profile.html:17 +msgid "Visibility" +msgstr "" + #: snikket_web/templates/user_profile.html:18 +msgid "" +"This section allows you to control who can see your profile information, " +"like avatar and nickname." +msgstr "" + +#: snikket_web/templates/user_profile.html:24 msgid "Apply" msgstr "Apply" #~ msgid "none" #~ msgstr "" +#~ msgid "Friends" +#~ msgstr "" + diff --git a/snikket_web/user.py b/snikket_web/user.py index 353f29d..0ea074a 100644 --- a/snikket_web/user.py +++ b/snikket_web/user.py @@ -1,3 +1,4 @@ +import asyncio import typing import quart.flask_patch @@ -45,6 +46,13 @@ class LogoutForm(flask_wtf.FlaskForm): # type:ignore pass +_ACCESS_MODEL_CHOICES = [ + ("whitelist", _l("Nobody")), + ("presence", _l("Friends only")), + ("open", _l("Everyone")), +] + + class ProfileForm(flask_wtf.FlaskForm): # type:ignore nickname = wtforms.TextField( _l("Display name"), @@ -54,6 +62,11 @@ class ProfileForm(flask_wtf.FlaskForm): # type:ignore _l("Avatar") ) + profile_access_model = wtforms.RadioField( + _l("Profile visibility"), + choices=_ACCESS_MODEL_CHOICES, + ) + @bp.route("/") @client.require_session() @@ -89,7 +102,15 @@ async def profile() -> typing.Union[str, quart.Response]: form = ProfileForm() if request.method != "POST": user_info = await client.get_user_info() + try: + profile_access_model = await client.get_nickname_access_model() + except quart.exceptions.NotFound: + # avatar node does not exist yet, default the access model to + # presence + # that is what will be set if the user now adds a new avatar. + profile_access_model = "presence" form.nickname.data = user_info.get("nickname", "") + form.profile_access_model.data = profile_access_model if form.validate_on_submit(): user_info = await client.get_user_info() @@ -103,6 +124,14 @@ async def profile() -> typing.Union[str, quart.Response]: if user_info.get("nickname") != form.nickname.data: await client.set_user_nickname(form.nickname.data) + + access_model = form.profile_access_model.data + await asyncio.gather( + client.set_avatar_access_model(access_model), + client.set_vcard_access_model(access_model), + client.set_nickname_access_model(access_model), + ) + return redirect(url_for(".profile")) return await render_template("user_profile.html", form=form) diff --git a/snikket_web/xmpputil.py b/snikket_web/xmpputil.py index 72c4972..52a78c7 100644 --- a/snikket_web/xmpputil.py +++ b/snikket_web/xmpputil.py @@ -19,9 +19,12 @@ ERROR_CODE_MAP = { } NS_PUBSUB = "http://jabber.org/protocol/pubsub" +NS_PUBSUB_OWNER = "http://jabber.org/protocol/pubsub#owner" TAG_PUBSUB = "{{{}}}pubsub".format(NS_PUBSUB) +TAG_PUBSUB_OWNER = "{{{}}}pubsub".format(NS_PUBSUB_OWNER) TAG_PUBSUB_ITEM = "{{{}}}item".format(NS_PUBSUB) TAG_PUBSUB_ITEMS = "{{{}}}items".format(NS_PUBSUB) +TAG_PUBSUB_CONFIGURE = "{{{}}}configure".format(NS_PUBSUB_OWNER) NS_USER_NICKNAME = "http://jabber.org/protocol/nick" NODE_USER_NICKNAME = NS_USER_NICKNAME @@ -36,6 +39,16 @@ 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) +NODE_VCARD = "urn:xmpp:vcard4" + +NS_DATA_FORM = "jabber:x:data" +TAG_DATA_FORM_X = "{{{}}}x".format(NS_DATA_FORM) +TAG_DATA_FORM_FIELD = "{{{}}}field".format(NS_DATA_FORM) +TAG_DATA_FORM_VALUE = "{{{}}}value".format(NS_DATA_FORM) + +FORM_NODE_CONFIG = "http://jabber.org/protocol/pubsub#node_config" +FORM_FIELD_PUBSUB_ACCESS_MODEL = "pubsub#access_model" + SimpleJID = typing.Tuple[typing.Optional[str], str, typing.Optional[str]] T = typing.TypeVar("T") @@ -211,7 +224,7 @@ def make_avatar_metadata_set_request( def _require_child(t: ET.Element, tag: str) -> ET.Element: el = t.find(tag) if el is None: - raise abort(500, "malformed reply") + raise abort(500, "malformed reply: missing {}".format(tag)) return el @@ -283,3 +296,73 @@ def extract_avatar_data_get_reply( if data is None or data.text is None: return None return base64.b64decode(data.text) + + +def make_pubsub_node_config_put_request( + to: str, node: str, + id_: typing.Optional[str] = None, + ) -> typing.Tuple[ET.Element, ET.Element]: + req = ET.Element("iq", type="set", to=to) + q = ET.SubElement(req, "pubsub", xmlns=NS_PUBSUB_OWNER) + configure = ET.SubElement(q, "configure", node=node) + form = ET.SubElement(configure, "x", + xmlns=NS_DATA_FORM, + type="submit") + form_type = ET.SubElement(form, "field", var="FORM_TYPE", type="hidden") + ET.SubElement(form_type, "value").text = FORM_NODE_CONFIG + return req, form + + +def make_pubsub_node_config_get_request( + to: str, node: str, + ) -> ET.Element: + req = ET.Element("iq", type="get", to=to) + q = ET.SubElement(req, "pubsub", xmlns=NS_PUBSUB_OWNER) + ET.SubElement(q, "configure", node=node) + return req + + +def add_form_field( + form: ET.Element, + var: str, + values: typing.Union[str, typing.Collection[str]], + type_: typing.Optional[str] = None, + ) -> ET.Element: + if isinstance(values, str): + values = [values] + field = ET.SubElement(form, "field", var=var) + if type_ is not None: + field.set("type", type_) + for v in values: + ET.SubElement(field, "value").text = v + return field + + +def make_pubsub_access_model_put_request( + to: str, + node: str, + new_access_model: str, + ) -> ET.Element: + req, form = make_pubsub_node_config_put_request(to, node) + add_form_field(form, FORM_FIELD_PUBSUB_ACCESS_MODEL, new_access_model) + return req + + +def extract_pubsub_node_config_get_reply( + iq_tree: ET.Element, + ) -> typing.Mapping[str, typing.Sequence[str]]: + payload = extract_iq_reply(iq_tree) + if payload is None: + raise ValueError("invalid reply") + form = _require_child(_require_child(payload, TAG_PUBSUB_CONFIGURE), + TAG_DATA_FORM_X) + result: typing.MutableMapping[str, typing.List[str]] = {} + for child in form.findall(TAG_DATA_FORM_FIELD): + var = child.get("var") + if var is None: + continue + values = [value_tag.text or "" + for value_tag in child.findall(TAG_DATA_FORM_VALUE)] + result[var] = values + + return result