From f7429413cd043947fceb9da05e07147706507103 Mon Sep 17 00:00:00 2001 From: Matthew Wild Date: Mon, 10 Jan 2022 13:47:26 +0000 Subject: [PATCH 01/10] Add more icons to the repertoire --- snikket_web/static/img/icons.svg | 15 +++++++++++++++ tools/icons.list | 3 +++ 2 files changed, 18 insertions(+) diff --git a/snikket_web/static/img/icons.svg b/snikket_web/static/img/icons.svg index dd6a38d..4b041a6 100644 --- a/snikket_web/static/img/icons.svg +++ b/snikket_web/static/img/icons.svg @@ -42,6 +42,11 @@ licensed under the terms of the Apache 2.0 License --> + + + + + @@ -88,6 +93,16 @@ licensed under the terms of the Apache 2.0 License --> + + + + + + + + + + diff --git a/tools/icons.list b/tools/icons.list index da134b8..b68f97d 100644 --- a/tools/icons.list +++ b/tools/icons.list @@ -6,6 +6,7 @@ action/logout:logout action/login:login action/exit_to_app:exit_to_app action/lock:lock +communication/import_export:import_export communication/qr_code:qrcode communication/vpn_key:passwd communication/rss_feed:broadcast @@ -15,6 +16,8 @@ content/remove_circle_outline:remove content/content_copy:copy content/link_off:remove_link content/send:send +file/file_download:download +file/folder:folder navigation/arrow_back:back navigation/arrow_forward:forward navigation/cancel:cancel From e18f727db0374c5a9a9b0dde30fe7c33f02cfc33 Mon Sep 17 00:00:00 2001 From: Matthew Wild Date: Mon, 10 Jan 2022 14:20:06 +0000 Subject: [PATCH 02/10] prosodyclient: Add support for exporting a user's account data --- snikket_web/prosodyclient.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py index ab6346f..c9a98ec 100644 --- a/snikket_web/prosodyclient.py +++ b/snikket_web/prosodyclient.py @@ -296,6 +296,9 @@ class ProsodyClient: def _public_v1_endpoint(self, subpath: str) -> str: return "{}/register_api{}".format(self._endpoint_base, subpath) + def _xep227_endpoint(self, subpath: str) -> str: + return "{}/xep227{}".format(self._endpoint_base, subpath) + async def _oauth2_bearer_token(self, session: aiohttp.ClientSession, jid: str, @@ -1121,6 +1124,18 @@ class ProsodyClient: ) as resp: self._raise_error_from_response(resp) + @autosession + async def export_account_data( + self, + *, + session: aiohttp.ClientSession, + ) -> str: + async with session.get( + self._xep227_endpoint("/export?stores=roster,vcard,pep"), + ) as resp: + self._raise_error_from_response(resp) + return await resp.text() + @autosession async def revoke_token( self, From 275b302531161c064f0144bc76065003223fbdb9 Mon Sep 17 00:00:00 2001 From: Matthew Wild Date: Mon, 10 Jan 2022 14:20:33 +0000 Subject: [PATCH 03/10] Add UI for exporting user account data --- snikket_web/templates/user_home.html | 1 + snikket_web/templates/user_manage_data.html | 23 ++++++++++ snikket_web/user.py | 47 +++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 snikket_web/templates/user_manage_data.html diff --git a/snikket_web/templates/user_home.html b/snikket_web/templates/user_home.html index 15b7a79..faf2b68 100644 --- a/snikket_web/templates/user_home.html +++ b/snikket_web/templates/user_home.html @@ -30,6 +30,7 @@
{% call standard_button("edit", url_for(".profile"), class="primary") %}{% trans %}Edit profile{% endtrans %}{% endcall %}
{% call standard_button("passwd", url_for(".change_pw"), class="secondary") %}{% trans %}Change password{% endtrans %}{% endcall %}
+
{% call standard_button("folder", url_for(".manage_data"), class="secondary") %}{% trans %}Manage your data{% endtrans %}{% endcall %}
{#- -#} diff --git a/snikket_web/templates/user_manage_data.html b/snikket_web/templates/user_manage_data.html new file mode 100644 index 0000000..4c9dfae --- /dev/null +++ b/snikket_web/templates/user_manage_data.html @@ -0,0 +1,23 @@ +{% extends "app.html" %} +{% from "library.j2" import standard_button, form_button, render_errors, avatar with context %} +{% block content %} +

{% trans %}Manage your data{% endtrans %}

+

{% trans user_name=user_info.display_name %}Welcome home, {{ user_name }}.{% endtrans %}

+ +{% endblock %} diff --git a/snikket_web/user.py b/snikket_web/user.py index a51ec37..978ae4b 100644 --- a/snikket_web/user.py +++ b/snikket_web/user.py @@ -1,9 +1,12 @@ +import aiohttp import asyncio import typing +import urllib import quart.flask_patch from quart import ( Blueprint, + Response, render_template, request, redirect, @@ -168,6 +171,50 @@ async def profile() -> typing.Union[str, quart.Response]: avatar_too_big_warning=EAVATARTOOBIG) +class DataExportForm(BaseForm): + action_export = wtforms.SubmitField( + _l("Export") + ) + + +@bp.route("/manage_data", methods=["GET", "POST"]) +@client.require_session() +async def manage_data() -> typing.Union[str, quart.Response]: + form = DataExportForm() + + if form.validate_on_submit(): + user_info = await client.get_user_info() + # The UTF-8 version of the filename needs to be percent-encoded + encoded_address = urllib.parse.quote( + user_info["address"].encode(encoding='utf-8', errors='strict') + ) + try: + account_data = await client.export_account_data() + except aiohttp.ClientResponseError as e: + if e.status == 404: + await flash( + _("You currently have no account data to export."), + "alert" + ) + else: + raise e + else: + return Response(account_data, + mimetype="application/xml", + headers={ + # We provide the UTF-8 filename, but the ASCII + # one will be used as a fallback for legacy + # browsers (RFC 5987) + "Content-Disposition": ( + 'attachment; filename="account-data.xml"; ' + 'filename*="UTF-8\'\'account-data-{}.xml"' + ).format(encoded_address) + }) + return await render_template("user_manage_data.html", + form=form, + ) + + @bp.route("/logout", methods=["GET", "POST"]) @client.require_session() async def logout() -> typing.Union[quart.Response, str]: From 481379d03fccb2ddafbd576be8541e2adf0a0d32 Mon Sep 17 00:00:00 2001 From: Matthew Wild Date: Tue, 11 Jan 2022 11:20:57 +0000 Subject: [PATCH 04/10] Switch to HTTP 204 to indicate no data to export This is more robust, as it indicates the request was successfully authenticated and processed, but that there is no data to export. This is different from the URL not existing (which would also happen if the module was unavailable, which should be a notable error instead). --- snikket_web/prosodyclient.py | 4 +++- snikket_web/user.py | 17 ++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py index c9a98ec..6b530f1 100644 --- a/snikket_web/prosodyclient.py +++ b/snikket_web/prosodyclient.py @@ -1129,11 +1129,13 @@ class ProsodyClient: self, *, session: aiohttp.ClientSession, - ) -> str: + ) -> typing.Optional[str]: async with session.get( self._xep227_endpoint("/export?stores=roster,vcard,pep"), ) as resp: self._raise_error_from_response(resp) + if resp.status == 204: + return None return await resp.text() @autosession diff --git a/snikket_web/user.py b/snikket_web/user.py index 978ae4b..eb9e278 100644 --- a/snikket_web/user.py +++ b/snikket_web/user.py @@ -1,4 +1,3 @@ -import aiohttp import asyncio import typing import urllib @@ -188,16 +187,12 @@ async def manage_data() -> typing.Union[str, quart.Response]: encoded_address = urllib.parse.quote( user_info["address"].encode(encoding='utf-8', errors='strict') ) - try: - account_data = await client.export_account_data() - except aiohttp.ClientResponseError as e: - if e.status == 404: - await flash( - _("You currently have no account data to export."), - "alert" - ) - else: - raise e + account_data = await client.export_account_data() + if account_data is None: + await flash( + _("You currently have no account data to export."), + "alert" + ) else: return Response(account_data, mimetype="application/xml", From 3cb8185b1a93160b448ac36d92f85b93ad03ec4c Mon Sep 17 00:00:00 2001 From: Matthew Wild Date: Wed, 12 Jan 2022 16:19:10 +0000 Subject: [PATCH 05/10] prosodyclient: Add API to import XEP-0227 account data --- snikket_web/prosodyclient.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py index 6b530f1..f743f16 100644 --- a/snikket_web/prosodyclient.py +++ b/snikket_web/prosodyclient.py @@ -1138,6 +1138,20 @@ class ProsodyClient: return None return await resp.text() + @autosession + async def import_account_data( + self, + user_xml: str, + *, + session: aiohttp.ClientSession, + ) -> bool: + async with session.post( + self._xep227_endpoint("/import?stores=roster,vcard,pep"), + data=user_xml, + ) as resp: + self._raise_error_from_response(resp) + return True + @autosession async def revoke_token( self, From 32179c72cda4f318400cbc594e5862cb257e9052 Mon Sep 17 00:00:00 2001 From: Matthew Wild Date: Wed, 12 Jan 2022 16:20:44 +0000 Subject: [PATCH 06/10] Add account data import UI on registration success page --- snikket_web/invite.py | 28 +++++++++++++++ snikket_web/static/img/icons.svg | 5 +++ snikket_web/templates/invite_success.html | 43 ++++++++++++++++++++++- snikket_web/user.py | 10 ++++++ tools/icons.list | 1 + 5 files changed, 86 insertions(+), 1 deletion(-) diff --git a/snikket_web/invite.py b/snikket_web/invite.py index 742509e..226b4b7 100644 --- a/snikket_web/invite.py +++ b/snikket_web/invite.py @@ -26,6 +26,8 @@ bp = Blueprint("invite", __name__) INVITE_SESSION_JID = "invite-session-jid" +EIMPORTTOOBIG = _l("The account data you tried to import is too large to" + "upload. Please contact your Snikket operator.") # https://play.google.com/store/apps/details?id=org.snikket.android&referrer={uri|urlescape}&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1 @@ -163,6 +165,7 @@ async def register(id_: str) -> typing.Union[str, quart.Response]: raise else: http_session[INVITE_SESSION_JID] = jid + await client.login(jid, form.password.data) return redirect(url_for(".success")) return await render_template( @@ -232,11 +235,36 @@ async def reset(id_: str) -> typing.Union[str, quart.Response]: ) +class DataImportForm(BaseForm): + account_data_file = wtforms.FileField( + _l("Account data file") + ) + + action_import = wtforms.SubmitField( + _l("Import data") + ) + + @bp.route("/success", methods=["GET", "POST"]) +@client.require_session() async def success() -> str: + form = DataImportForm() + if form.validate_on_submit(): + client.import_account_data() + # Re-render success page, this time with no import option + return await render_template( + "invite_success.html", + jid=http_session.get(INVITE_SESSION_JID, ""), + migration_success=True, + ) return await render_template( "invite_success.html", jid=http_session.get(INVITE_SESSION_JID, ""), + migration_success=False, + form=form, + max_import_size=5*1024*1024, # 5MB + import_too_big_warning_header=_l("Error"), + import_too_big_warning=EIMPORTTOOBIG, ) diff --git a/snikket_web/static/img/icons.svg b/snikket_web/static/img/icons.svg index 4b041a6..17ecc6d 100644 --- a/snikket_web/static/img/icons.svg +++ b/snikket_web/static/img/icons.svg @@ -98,6 +98,11 @@ licensed under the terms of the Apache 2.0 License -->
+ + + + + diff --git a/snikket_web/templates/invite_success.html b/snikket_web/templates/invite_success.html index 00cd482..e169633 100644 --- a/snikket_web/templates/invite_success.html +++ b/snikket_web/templates/invite_success.html @@ -1,6 +1,6 @@ {% extends "invite.html" %} {% set body_id = "invite" %} -{% from "library.j2" import form_button, clipboard_button %} +{% from "library.j2" import form_button, clipboard_button, render_errors %} {% block head_lead %} {% trans site_name=config["SITE_NAME"] %}Successfully registered on {{ site_name }} | Snikket{% endtrans %} {%- include "copy-snippet.html" -%} @@ -16,5 +16,46 @@ {%- endcall -%}

{% trans %}You can now set up your legacy XMPP client with the above address and the password you chose during registration.{% endtrans %}

{% trans login_url=url_for('main.login') %}You can now safely close this page, or log in to the web portal to manage your account.{% endtrans %}

+ + {% if migration_success %} +

{% trans %}Import successful{% endtrans %}

+

{% trans %}Congratulations! Your account data has been successfully imported.{% endtrans %}

+ {% endif %} + + {% if form %} +

{% trans %}Moving to Snikket?{% endtrans %}

+

{% trans %}If you are moving from a different Snikket instance or another XMPP-compatible service, you may optionally import the data (contacts, profile information, etc.) from your previous account. When you have exported the data from your previous account, upload it using the form below.{% endtrans %}

+ +
+

{% trans %}Upload account data{% endtrans %}

+ {{ form.csrf_token }} + {% call render_errors(form) %}{% endcall %} +
+ {{ form.account_data_file.label }} + {{ form.account_data_file(accept="application/xml", + data_maxsize=max_import_size, + data_warning_header=import_too_big_warning_header, + data_maxsize_warning=import_too_big_warning) }} +
+
+ {%- call form_button("upload", form.action_import, class="secondary") %}{% endcall -%} +
+ +
+ {% endif %} {% endblock %} diff --git a/snikket_web/user.py b/snikket_web/user.py index eb9e278..c8c1ed7 100644 --- a/snikket_web/user.py +++ b/snikket_web/user.py @@ -77,6 +77,16 @@ class ProfileForm(BaseForm): ) +class ImportAccountDataForm(BaseForm): + account_data_file = wtforms.FileField( + _l("Account data") + ) + + action_upload = wtforms.SubmitField( + _l("Upload"), + ) + + @bp.route("/") @client.require_session() async def index() -> str: diff --git a/tools/icons.list b/tools/icons.list index b68f97d..050a9e5 100644 --- a/tools/icons.list +++ b/tools/icons.list @@ -17,6 +17,7 @@ content/content_copy:copy content/link_off:remove_link content/send:send file/file_download:download +file/file_upload:upload file/folder:folder navigation/arrow_back:back navigation/arrow_forward:forward From f6395d4d9c37d86e4728fbbd1bb2a6a4af7a821a Mon Sep 17 00:00:00 2001 From: Matthew Wild Date: Sun, 16 Jan 2022 15:21:34 +0000 Subject: [PATCH 07/10] Complete the implementation of data import --- snikket_web/invite.py | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/snikket_web/invite.py b/snikket_web/invite.py index 226b4b7..1ccda08 100644 --- a/snikket_web/invite.py +++ b/snikket_web/invite.py @@ -10,13 +10,14 @@ from quart import ( current_app, render_template, redirect, + request, url_for, session as http_session, ) import wtforms -from flask_babel import lazy_gettext as _l +from flask_babel import lazy_gettext as _l, gettext from .infra import client, selected_locale, BaseForm @@ -26,6 +27,9 @@ bp = Blueprint("invite", __name__) INVITE_SESSION_JID = "invite-session-jid" +MAX_IMPORT_DATA_SIZE = 5*1024*1024 # 5MB +SUPPORTED_IMPORT_TYPES = ["application/xml", "text/xml"] + EIMPORTTOOBIG = _l("The account data you tried to import is too large to" "upload. Please contact your Snikket operator.") @@ -250,19 +254,38 @@ class DataImportForm(BaseForm): async def success() -> str: form = DataImportForm() if form.validate_on_submit(): - client.import_account_data() - # Re-render success page, this time with no import option - return await render_template( - "invite_success.html", - jid=http_session.get(INVITE_SESSION_JID, ""), - migration_success=True, + ok = True + file_info = (await request.files).get(form.account_data_file.name) + if file_info is not None: + mimetype = file_info.mimetype + data = file_info.stream.read() + if len(data) > MAX_IMPORT_DATA_SIZE: + form.account_data_file.errors.append(EIMPORTTOOBIG) + ok = False + elif mimetype not in SUPPORTED_IMPORT_TYPES: + form.account_data_file.errors.append( + # not breaking the line here to avoid extract + # translations failing (defensive) + gettext("The account data you tried to import is in an unknown format. Please upload an XML file in XEP-0227 format (provided format: %(mimetype)s).", mimetype=mimetype), # noqa:E501 ) + ok = False + elif len(data) > 0: + await client.import_account_data(data) + + if ok: + # Re-render success page, this time with no import option + return await render_template( + "invite_success.html", + jid=http_session.get(INVITE_SESSION_JID, ""), + migration_success=True, + ) + return await render_template( "invite_success.html", jid=http_session.get(INVITE_SESSION_JID, ""), migration_success=False, form=form, - max_import_size=5*1024*1024, # 5MB + max_import_size=MAX_IMPORT_DATA_SIZE, import_too_big_warning_header=_l("Error"), import_too_big_warning=EIMPORTTOOBIG, ) From 390ecded42ab202137eb9e213bdcd51c0208e8e1 Mon Sep 17 00:00:00 2001 From: Matthew Wild Date: Sun, 16 Jan 2022 15:22:12 +0000 Subject: [PATCH 08/10] Include PEP data in export/import --- snikket_web/prosodyclient.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py index f743f16..23bd841 100644 --- a/snikket_web/prosodyclient.py +++ b/snikket_web/prosodyclient.py @@ -1131,7 +1131,7 @@ class ProsodyClient: session: aiohttp.ClientSession, ) -> typing.Optional[str]: async with session.get( - self._xep227_endpoint("/export?stores=roster,vcard,pep"), + self._xep227_endpoint("/export?stores=roster,vcard,pep,pep_data"), # noqa:E501 ) as resp: self._raise_error_from_response(resp) if resp.status == 204: @@ -1146,7 +1146,7 @@ class ProsodyClient: session: aiohttp.ClientSession, ) -> bool: async with session.post( - self._xep227_endpoint("/import?stores=roster,vcard,pep"), + self._xep227_endpoint("/import?stores=roster,vcard,pep,pep_data"), # noqa:E501 data=user_xml, ) as resp: self._raise_error_from_response(resp) From 05caf38d37e15e5de5290aa010a7114eb7191692 Mon Sep 17 00:00:00 2001 From: Matthew Wild Date: Sun, 16 Jan 2022 15:22:37 +0000 Subject: [PATCH 09/10] Use PUT method instead of POST, as expected by API --- snikket_web/prosodyclient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py index 23bd841..f065c25 100644 --- a/snikket_web/prosodyclient.py +++ b/snikket_web/prosodyclient.py @@ -1145,7 +1145,7 @@ class ProsodyClient: *, session: aiohttp.ClientSession, ) -> bool: - async with session.post( + async with session.put( self._xep227_endpoint("/import?stores=roster,vcard,pep,pep_data"), # noqa:E501 data=user_xml, ) as resp: From 958b3365f759dfe8d6238d3ac6aee3a1c32cd739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Sch=C3=A4fer?= Date: Mon, 17 Jan 2022 16:34:30 +0100 Subject: [PATCH 10/10] Remove strange greeting copied over from user_home --- snikket_web/templates/user_manage_data.html | 1 - 1 file changed, 1 deletion(-) diff --git a/snikket_web/templates/user_manage_data.html b/snikket_web/templates/user_manage_data.html index 4c9dfae..0f958e8 100644 --- a/snikket_web/templates/user_manage_data.html +++ b/snikket_web/templates/user_manage_data.html @@ -2,7 +2,6 @@ {% from "library.j2" import standard_button, form_button, render_errors, avatar with context %} {% block content %}

{% trans %}Manage your data{% endtrans %}

-

{% trans user_name=user_info.display_name %}Welcome home, {{ user_name }}.{% endtrans %}