import asyncio import typing import urllib import quart.flask_patch from quart import ( Blueprint, Response, render_template, request, redirect, url_for, flash, current_app, ) import werkzeug.exceptions import wtforms from flask_babel import lazy_gettext as _l, _ from .infra import client, BaseForm bp = Blueprint('user', __name__) class ChangePasswordForm(BaseForm): current_password = wtforms.PasswordField( _l("Current password"), validators=[wtforms.validators.InputRequired()] ) new_password = wtforms.PasswordField( _l("New password"), validators=[wtforms.validators.InputRequired()] ) new_password_confirm = wtforms.PasswordField( _l("Confirm new password"), validators=[wtforms.validators.InputRequired(), wtforms.validators.EqualTo( "new_password", _l("The new passwords must match.") )] ) class LogoutForm(BaseForm): action_signout = wtforms.SubmitField( _l("Sign out"), ) _ACCESS_MODEL_CHOICES = [ ("whitelist", _l("Nobody")), ("presence", _l("Friends only")), ("open", _l("Everyone")), ] class ProfileForm(BaseForm): nickname = wtforms.TextField( _l("Display name"), ) avatar = wtforms.FileField( _l("Avatar") ) profile_access_model = wtforms.RadioField( _l("Profile visibility"), choices=_ACCESS_MODEL_CHOICES, ) action_save = wtforms.SubmitField( _l("Update profile"), ) 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: user_info = await client.get_user_info() return await render_template("user_home.html", user_info=user_info) @bp.route('/passwd', methods=["GET", "POST"]) @client.require_session() async def change_pw() -> typing.Union[str, werkzeug.Response]: form = ChangePasswordForm() if form.validate_on_submit(): try: await client.change_password( form.current_password.data, form.new_password.data, ) except (werkzeug.exceptions.Unauthorized, werkzeug.exceptions.Forbidden): # server refused current password, set an appropriate error form.current_password.errors.append( _("Incorrect password."), ) else: await flash( _("Password changed"), "success", ) return redirect(url_for("user.change_pw")) return await render_template("user_passwd.html", form=form) EAVATARTOOBIG = _l( "The chosen avatar is too big. To be able to upload larger " "avatars, please use the app." ) @bp.route("/profile", methods=["GET", "POST"]) @client.require_session() async def profile() -> typing.Union[str, werkzeug.Response]: max_avatar_size = current_app.config["MAX_AVATAR_SIZE"] form = ProfileForm() if request.method != "POST": user_info = await client.get_user_info() # TODO: find a better way to determine the access model, e.g. by # taking the first access model which is defined in [nickname, avatar, # vcard] or by taking the most open one.- profile_access_model = await client.guess_profile_access_model() 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() ok = True 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: await client.set_user_avatar(data, mimetype) if ok: 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), ) await flash( _("Profile updated"), "success", ) return redirect(url_for(".profile")) return await render_template("user_profile.html", form=form, max_avatar_size=max_avatar_size, avatar_too_big_warning_header=_l("Error"), 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[werkzeug.Response, str]: form = LogoutForm() if form.validate_on_submit(): await client.logout() # No flashing here because we don’t collect flashes in the login page # and it’d be weird. # await flash( # _("Logged out"), # "success", # ) return redirect(url_for("main.index")) return await render_template("user_logout.html", form=form)