diff --git a/snikket_web/invite.py b/snikket_web/invite.py index 742509e..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,11 @@ 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.") # 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 +169,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 +239,55 @@ 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(): + 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=MAX_IMPORT_DATA_SIZE, + import_too_big_warning_header=_l("Error"), + import_too_big_warning=EIMPORTTOOBIG, ) diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py index ab6346f..f065c25 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,34 @@ class ProsodyClient: ) as resp: self._raise_error_from_response(resp) + @autosession + async def export_account_data( + self, + *, + session: aiohttp.ClientSession, + ) -> typing.Optional[str]: + async with session.get( + self._xep227_endpoint("/export?stores=roster,vcard,pep,pep_data"), # noqa:E501 + ) as resp: + self._raise_error_from_response(resp) + if resp.status == 204: + return None + return await resp.text() + + @autosession + async def import_account_data( + self, + user_xml: str, + *, + session: aiohttp.ClientSession, + ) -> bool: + async with session.put( + self._xep227_endpoint("/import?stores=roster,vcard,pep,pep_data"), # noqa:E501 + data=user_xml, + ) as resp: + self._raise_error_from_response(resp) + return True + @autosession async def revoke_token( self, diff --git a/snikket_web/static/img/icons.svg b/snikket_web/static/img/icons.svg index dd6a38d..17ecc6d 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,21 @@ 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/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..0f958e8 --- /dev/null +++ b/snikket_web/templates/user_manage_data.html @@ -0,0 +1,22 @@ +{% extends "app.html" %} +{% from "library.j2" import standard_button, form_button, render_errors, avatar with context %} +{% block content %} +

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

+ +{% endblock %} diff --git a/snikket_web/user.py b/snikket_web/user.py index a51ec37..c8c1ed7 100644 --- a/snikket_web/user.py +++ b/snikket_web/user.py @@ -1,9 +1,11 @@ import asyncio import typing +import urllib import quart.flask_patch from quart import ( Blueprint, + Response, render_template, request, redirect, @@ -75,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: @@ -168,6 +180,46 @@ 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') + ) + 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", + 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]: diff --git a/tools/icons.list b/tools/icons.list index da134b8..050a9e5 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,9 @@ content/remove_circle_outline:remove 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 navigation/cancel:cancel