Compare commits

..

1 Commits

Author SHA1 Message Date
Jonas Schäfer
788ca73d86 WIP: implement setting circle avatar
missing: actually doing the thing, we get forbidden back because the
role doesn't propagate to groups.$SNIKKET_DOMAIN

Fixes #49.
2023-03-28 21:47:24 +02:00
7 changed files with 144 additions and 138 deletions

View File

@@ -28,6 +28,7 @@ from flask_babel import lazy_gettext as _l, _
from . import prosodyclient, _version
from .infra import client, circle_name, BaseForm
from .user import EAVATARTOOBIG
bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -77,7 +78,7 @@ class EditUserForm(BaseForm):
_l("Access Level"),
choices=[
("prosody:restricted", _("Limited")),
("prosody:user", _l("Normal user")),
("prosody:normal", _l("Normal user")),
("prosody:admin", _l("Administrator")),
],
)
@@ -116,7 +117,7 @@ async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]:
await client.update_user(
localpart,
display_name=form.display_name.data,
role=form.role.data,
roles=[form.role.data],
)
await flash(
@@ -131,7 +132,7 @@ async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]:
if target_user_info.roles:
form.role.data = target_user_info.roles[0]
else:
form.role.data = "prosody:user"
form.role.data = "prosody:normal"
return await render_template(
"admin_edit_user.html",
@@ -443,6 +444,10 @@ class EditCircleForm(BaseForm):
validators=[wtforms.validators.InputRequired()],
)
avatar = wtforms.FileField(
_l("Avatar")
)
user_to_add = wtforms.SelectField(
_l("Select user"),
validate_choice=False,
@@ -452,6 +457,10 @@ class EditCircleForm(BaseForm):
_l("Update circle")
)
action_delete = wtforms.SubmitField(
_l("Delete circle permanently")
)
action_remove_user = wtforms.StringField()
action_add_user = wtforms.SubmitField(
@@ -462,6 +471,8 @@ class EditCircleForm(BaseForm):
@bp.route("/circle/<id_>", methods=["GET", "POST"])
@client.require_admin_session()
async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
max_avatar_size = current_app.config["MAX_AVATAR_SIZE"]
async with client.authenticated_session() as session:
try:
circle = await client.get_group_by_id(
@@ -507,10 +518,31 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
id_,
new_name=form.name.data,
)
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) > max_avatar_size:
form.avatar.errors.append(EAVATARTOOBIG)
ok = False
elif len(data) > 0:
print("setting muc avatar")
await client.set_muc_avatar(
circle.muc_jid,
data,
mimetype,
)
await flash(
_("Circle data updated"),
"success",
)
elif form.action_delete.data:
await client.delete_group(id_)
await flash(
_("Circle deleted"),
"success",
)
return redirect(url_for(".circles"))
elif form.action_add_user.data:
if form.user_to_add.data in valid_users:
await client.add_group_member(
@@ -539,47 +571,9 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
form=form,
circle_members=circle_members,
invite_form=invite_form,
)
class DeleteCircleForm(BaseForm):
action_delete = wtforms.SubmitField(
_l("Delete circle permanently")
)
@bp.route("/circle/<id_>/delete", methods=["GET", "POST"])
@client.require_admin_session()
async def delete_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
async with client.authenticated_session() as session:
try:
circle = await client.get_group_by_id(
id_,
session=session,
)
except aiohttp.ClientResponseError as exc:
if exc.status == 404:
await flash(
_("No such circle exists"),
"alert",
)
return redirect(url_for(".circles"))
raise
form = DeleteCircleForm()
if form.validate_on_submit():
if form.action_delete.data:
await client.delete_group(id_)
await flash(
_("Circle deleted"),
"success",
)
return redirect(url_for(".circles"))
return await render_template(
"admin_delete_circle.html",
target_circle=circle,
form=form,
max_avatar_size=max_avatar_size,
avatar_too_big_warning_header=_l("Error"),
avatar_too_big_warning=EAVATARTOOBIG,
)

View File

@@ -61,18 +61,12 @@ class AdminUserInfo:
cls,
data: typing.Mapping[str, typing.Any],
) -> "AdminUserInfo":
try:
roles: typing.Optional[typing.List[str]] = [data["role"]]
assert roles is not None # make mypy happy
roles.extend(data.get("secondary_roles", []))
except KeyError:
roles = data.get("roles")
return cls(
localpart=data["username"],
display_name=data.get("display_name") or None,
email=data.get("email") or None,
phone=data.get("phone") or None,
roles=roles,
roles=data.get("roles"),
)
@@ -885,7 +879,7 @@ class ProsodyClient:
localpart: str,
*,
display_name: typing.Optional[str],
role: typing.Optional[str],
roles: typing.Optional[typing.Collection[str]],
session: aiohttp.ClientSession,
) -> None:
payload: typing.Dict[str, typing.Any] = {
@@ -893,8 +887,8 @@ class ProsodyClient:
}
if display_name is not None:
payload["display_name"] = display_name
if role is not None:
payload["role"] = role
if roles is not None:
payload["roles"] = list(roles)
async with session.put(
self._admin_v1_endpoint("/users/{}".format(localpart)),
@@ -1252,3 +1246,23 @@ class ProsodyClient:
json=payload) as resp:
self._raise_error_from_response(resp)
resp.raise_for_status()
@autosession
async def set_muc_avatar(
self,
muc_jid: str,
data: bytes,
mimetype: str,
*,
session: aiohttp.ClientSession,
):
xmpputil.extract_iq_reply(
await self._xml_iq_call(
session,
xmpputil.make_muc_avatar_set_request(
muc_jid,
data,
mimetype,
),
)
)

View File

@@ -1,21 +0,0 @@
{% extends "admin_app.html" %}
{% from "library.j2" import box, form_button, standard_button %}
{% block content %}
<h1>{% trans circle_name=target_circle.name %}Delete circle {{ circle_name }}{% endtrans %}</h1>
<div class="form layout-expanded"><form method="POST">
<h2 class="form-title">{% trans %}Delete circle{% endtrans %}</h2>
{{ form.csrf_token }}
<p class="form-descr">{% trans %}Are you sure you want to delete the following circle?{% endtrans %}</p>
<dl>
<dt>{% trans %}Name{% endtrans %}</dt>
<dd>{{ target_circle.name }}</dd>
</dl>
{% call box("alert", _("Danger")) %}
<p>{% trans %}The circle and the corresponding chat will be deleted, permanently and immediately upon pushing the below button. <strong>There is no way back!</strong>{% endtrans %}</p>
{% endcall %}
<div class="f-bbox">
{%- call standard_button("back", url_for(".edit_circle", id_=target_circle.id_), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
{%- call form_button("delete", form.action_delete, class="primary danger") %}{% endcall -%}
</div>
</form></div>
{% endblock %}

View File

@@ -1,12 +1,12 @@
{% extends "admin_app.html" %}
{% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button, icon %}
{% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button, icon, avatar with context %}
{% block head_lead %}
{{ super() }}
{% include "copy-snippet.html" %}
{% endblock %}
{% block content %}
<h1>{% trans circle_name=(target_circle | circle_name) %}Edit circle {{ circle_name }}{% endtrans %}</h1>
<form method="POST">
<form method="POST" enctype="multipart/form-data">
{{- form.csrf_token -}}
{%- if target_circle.id_ == "default" -%}
<input type="hidden" name="{{ form.name.name }}" value="{{ form.name.data }}">{#- -#}
@@ -39,6 +39,15 @@
<p>{% trans %}This circle has no group chat associated.{% endtrans %}<p>
{%- endif -%}
</div>
<div class="f-ebox">
{{ form.avatar.label }}
<div class="avatar-wrap">
{{ form.avatar(accept="image/png",
data_maxsize=max_avatar_size,
data_warning_header=avatar_too_big_warning_header,
data_maxsize_warning=avatar_too_big_warning) }}
</div>
</div>
<div class="f-bbox">
{%- call standard_button("back", url_for(".circles"), class="tertiary") -%}
{% trans %}Return to circle list{% endtrans %}
@@ -48,7 +57,7 @@
<h3 class="form-title">{% trans %}Delete circle{% endtrans %}</h3>
<p class="form-desc">{% trans %}Deleting a circle does not delete any users in the circle.{% endtrans %}</p>
<div class="f-bbox">
{%- call standard_button("delete", url_for(".delete_circle", id_=target_circle.id_), class="secondary danger") %}{% trans %}Delete circle{% endtrans %}{% endcall -%}
{%- call form_button("delete", form.action_delete, class="secondary danger") %}{% endcall -%}
</div>
</div>
{%- endif -%}

View File

@@ -3,7 +3,7 @@
{% macro access_level_description(role, caller=None) %}
{%- if role == "prosody:restricted" -%}
{% trans %}Limited users can interact with users on the same Snikket service and be members of circles.{% endtrans %}
{%- elif role == "prosody:user" -%}
{%- elif role == "prosody:normal" -%}
{% trans %}Like limited users and can also interact with users on other Snikket services.{% endtrans %}
{%- elif role == "prosody:admin" -%}
{% trans %}Like normal users and can access the admin panel in the web portal.{% endtrans %}

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2023-04-01 10:07+0200\n"
"POT-Creation-Date: 2023-03-28 19:16+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -142,7 +142,6 @@ msgid "Invitation revoked"
msgstr ""
#: snikket_web/admin.py:394 snikket_web/admin.py:442
#: snikket_web/templates/admin_delete_circle.html:10
msgid "Name"
msgstr ""
@@ -162,51 +161,51 @@ msgstr ""
msgid "Update circle"
msgstr ""
#: snikket_web/admin.py:458
#: snikket_web/admin.py:456
msgid "Delete circle permanently"
msgstr ""
#: snikket_web/admin.py:462
msgid "Add user"
msgstr ""
#: snikket_web/admin.py:474 snikket_web/admin.py:563
#: snikket_web/admin.py:478
msgid "No such circle exists"
msgstr ""
#: snikket_web/admin.py:511
#: snikket_web/admin.py:515
msgid "Circle data updated"
msgstr ""
#: snikket_web/admin.py:521
msgid "User added to circle"
msgstr ""
#: snikket_web/admin.py:530
msgid "User removed from circle"
msgstr ""
#: snikket_web/admin.py:547
msgid "Delete circle permanently"
msgstr ""
#: snikket_web/admin.py:574
msgid "Circle deleted"
msgstr ""
#: snikket_web/admin.py:640
#: snikket_web/admin.py:532
msgid "User added to circle"
msgstr ""
#: snikket_web/admin.py:541
msgid "User removed from circle"
msgstr ""
#: snikket_web/admin.py:610
msgid "Message contents"
msgstr ""
#: snikket_web/admin.py:646
#: snikket_web/admin.py:616
msgid "Only send to online users"
msgstr ""
#: snikket_web/admin.py:650
#: snikket_web/admin.py:620
msgid "Post to all users"
msgstr ""
#: snikket_web/admin.py:654
#: snikket_web/admin.py:624
msgid "Send preview to yourself"
msgstr ""
#: snikket_web/admin.py:676
#: snikket_web/admin.py:646
msgid "Announcement sent!"
msgstr ""
@@ -553,43 +552,6 @@ msgstr ""
msgid "Copy complete output"
msgstr ""
#: snikket_web/templates/admin_delete_circle.html:4
#, python-format
msgid "Delete circle %(circle_name)s"
msgstr ""
#: snikket_web/templates/admin_delete_circle.html:6
#: snikket_web/templates/admin_edit_circle.html:48
#: snikket_web/templates/admin_edit_circle.html:51
msgid "Delete circle"
msgstr ""
#: snikket_web/templates/admin_delete_circle.html:8
msgid "Are you sure you want to delete the following circle?"
msgstr ""
#: snikket_web/templates/admin_delete_circle.html:13
#: snikket_web/templates/admin_delete_user.html:15
msgid "Danger"
msgstr ""
#: snikket_web/templates/admin_delete_circle.html:14
msgid ""
"The circle and the corresponding chat will be deleted, permanently and "
"immediately upon pushing the below button. <strong>There is no way "
"back!</strong>"
msgstr ""
#: snikket_web/templates/admin_delete_circle.html:17
#: snikket_web/templates/admin_delete_user.html:19
#: snikket_web/templates/admin_reset_user_password.html:25
#: snikket_web/templates/user_logout.html:10
#: snikket_web/templates/user_manage_data.html:14
#: snikket_web/templates/user_passwd.html:27
#: snikket_web/templates/user_profile.html:32
msgid "Back"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:4
#, python-format
msgid "Delete user %(user_name)s"
@@ -604,6 +566,10 @@ msgstr ""
msgid "Are you sure you want to delete the following user?"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:15
msgid "Danger"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:16
msgid ""
"The user and their data will be deleted irrevocably, permanently and "
@@ -611,6 +577,15 @@ msgid ""
"back!</strong>"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:19
#: snikket_web/templates/admin_reset_user_password.html:25
#: snikket_web/templates/user_logout.html:10
#: snikket_web/templates/user_manage_data.html:14
#: snikket_web/templates/user_passwd.html:27
#: snikket_web/templates/user_profile.html:32
msgid "Back"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:14
msgid "This is your main circle"
msgstr ""
@@ -643,6 +618,10 @@ msgstr ""
msgid "Return to circle list"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:48
msgid "Delete circle"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:49
msgid "Deleting a circle does not delete any users in the circle."
msgstr ""

View File

@@ -49,6 +49,8 @@ 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"
NS_VCARD_TEMP = "vcard-temp"
SimpleJID = typing.Tuple[typing.Optional[str], str, typing.Optional[str]]
T = typing.TypeVar("T")
@@ -226,6 +228,35 @@ def make_avatar_metadata_set_request(
return req
def make_muc_avatar_set_request(
to: str,
data: bytes,
mimetype: str,
) -> ET.Element:
req = ET.Element("iq", type="set", to=to)
vcard = ET.SubElement(
req,
"vCard",
xmlns=NS_VCARD_TEMP,
)
photo_el = ET.SubElement(
vcard,
"PHOTO",
xmlns=NS_VCARD_TEMP,
)
ET.SubElement(
photo_el,
"BINVAL",
xmlns=NS_VCARD_TEMP,
).text = base64.b64encode(data).decode("ascii")
ET.SubElement(
photo_el,
"TYPE",
xmlns=NS_VCARD_TEMP,
).text = mimetype
return req
def _require_child(t: ET.Element, tag: str) -> ET.Element:
el = t.find(tag)
if el is None: