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 %}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 }}
+
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