import json import resource import time import typing from datetime import datetime import aiohttp import werkzeug.exceptions import quart.flask_patch import wtforms from quart import ( Blueprint, render_template, redirect, url_for, request, abort, flash, current_app, ) from flask_babel import lazy_gettext as _l, _ from . import prosodyclient, _version from .infra import client, circle_name, BaseForm bp = Blueprint("admin", __name__, url_prefix="/admin") @bp.route("/") @client.require_admin_session() async def index() -> str: show_metrics = current_app.config["SHOW_METRICS"] return await render_template( "admin_home.html", show_metrics=show_metrics, ) class PasswordResetLinkPost(BaseForm): action_revoke = wtforms.StringField() @bp.route("/users") @client.require_admin_session() async def users() -> str: users = sorted( await client.list_users(), key=lambda x: x.localpart ) invite_form = InvitePost() await invite_form.init_choices() reset_form = PasswordResetLinkPost() return await render_template( "admin_users.html", users=users, reset_form=reset_form, invite_form=invite_form, ) class EditUserForm(BaseForm): localpart = wtforms.StringField( _l("Login name"), ) display_name = wtforms.StringField( _l("Display name"), ) role = wtforms.RadioField( _l("Access Level"), choices=[ ("prosody:restricted", _("Limited")), ("prosody:registered", _l("Normal user")), ("prosody:admin", _l("Administrator")), ], ) action_save = wtforms.SubmitField( _l("Update user"), ) action_create_reset = wtforms.SubmitField( _l("Create password reset link"), ) @bp.route("/user//", methods=["GET", "POST"]) @client.require_admin_session() async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]: target_user_info = await client.get_user_by_localpart(localpart) form = EditUserForm() if form.validate_on_submit(): if form.action_create_reset.data: target_user_info = await client.get_user_by_localpart(localpart) reset_link = await client.create_password_reset_invite( localpart=localpart, ttl=86400, ) await flash( _("Password reset link created"), "success", ) return redirect(url_for( ".user_password_reset_link", id_=reset_link.id_, )) await client.update_user( localpart, display_name=form.display_name.data, role=form.role.data, ) await flash( _("User information updated."), "success", ) return redirect(url_for(".edit_user", localpart=localpart)) elif request.method == "GET": form.localpart.data = target_user_info.localpart form.display_name.data = target_user_info.display_name if target_user_info.roles: form.role.data = target_user_info.roles[0] else: form.role.data = "prosody:registered" return await render_template( "admin_edit_user.html", target_user=target_user_info, form=form, ) class DeleteUserForm(BaseForm): action_delete = wtforms.SubmitField( _l("Delete user permanently") ) @bp.route("/user//delete", methods=["GET", "POST"]) @client.require_admin_session() async def delete_user(localpart: str) -> typing.Union[str, werkzeug.Response]: target_user_info = await client.get_user_by_localpart(localpart) form = DeleteUserForm() if form.validate_on_submit(): if form.action_delete.data: await client.delete_user_by_localpart(localpart) await flash( _("User deleted"), "success", ) return redirect(url_for(".users")) return await render_template( "admin_delete_user.html", target_user=target_user_info, form=form, ) @bp.route("/user//debug") @client.require_admin_session() async def debug_user(localpart: str) -> typing.Union[str, quart.Response]: target_user_info = await client.get_user_by_localpart(localpart) debug_info = json.dumps( await client.get_user_debug_info(localpart), indent=2, sort_keys=True, ) return await render_template( "admin_debug_user.html", target_user=target_user_info, debug_dump=debug_info, ) @bp.route("/users/password-reset/", methods=["GET", "POST"]) @client.require_admin_session() async def user_password_reset_link( id_: str, ) -> typing.Union[str, werkzeug.Response]: invite_info = await client.get_invite_by_id( id_, ) if invite_info.jid is None: await flash( _("Password reset link not found"), "alert", ) return redirect(url_for(".users")) localpart = prosodyclient.split_jid(invite_info.jid)[0] form = PasswordResetLinkPost() if form.validate_on_submit(): if form.action_revoke.data: await client.delete_invite(id_) await flash( _("Password reset link deleted"), "success", ) return redirect(url_for(".edit_user", localpart=localpart)) abort(400) return await render_template( "admin_reset_user_password.html", localpart=localpart, reset_link=invite_info, form=form, ) class InvitesListForm(BaseForm): action_revoke = wtforms.StringField() class InvitePost(BaseForm): circles = wtforms.SelectMultipleField( _l("Invite to circle"), # NOTE: This is for when/if we ever support multi-group invites. # also see the note in admin_create_invite_form.html # option_widget=wtforms.widgets.CheckboxInput(), widget=wtforms.widgets.Select(multiple=False), validators=[wtforms.validators.InputRequired( _l("At least one circle must be selected") )], ) lifetime = wtforms.SelectField( _l("Valid for"), choices=[ (3600, _l("One hour")), (12*3600, _l("Twelve hours")), (86400, _l("One day")), (7*86400, _l("One week")), (28*86400, _l("Four weeks")), ], default=7*86400, ) type_ = wtforms.RadioField( _l("Invitation type"), choices=[ ("account", _l("Individual")), ("group", _l("Group")), ], default="account", ) action_create_invite = wtforms.SubmitField( _l("New invitation link") ) async def init_choices( self, *, circles: typing.Optional[typing.Collection[ prosodyclient.AdminGroupInfo ]] = None) -> None: if circles is not None: self.circles.choices = [ (circle.id_, circle_name(circle)) for circle in sorted(circles, key=lambda x: x.name) ] return return await self.init_choices( circles=await client.list_groups() ) @bp.route("/invitations", methods=["GET", "POST"]) @client.require_admin_session() async def invitations() -> typing.Union[str, werkzeug.Response]: invites = sorted( ( invite for invite in await client.list_invites() if not invite.is_reset ), key=lambda x: x.created_at, reverse=True, ) circles = sorted( await client.list_groups(), key=lambda x: x.name ) circle_map = { circle.id_: circle for circle in circles } invite_form = InvitePost() await invite_form.init_choices(circles=circles) form = InvitesListForm() if form.validate_on_submit(): if form.action_revoke.data: await client.delete_invite(form.action_revoke.data) return redirect(url_for(".invitations")) return await render_template( "admin_invites.html", invites=invites, invite_form=invite_form, now=datetime.utcnow(), circle_map=circle_map, form=form, ) class InviteForm(BaseForm): action_revoke = wtforms.SubmitField( _l("Revoke") ) @bp.route("/invitation/-/new", methods=["POST"]) @client.require_admin_session() async def create_invite() -> typing.Union[str, werkzeug.Response]: form = InvitePost() circles = await client.list_groups() form.circles.choices = [ (c.id_, c.name) for c in circles ] if form.validate_on_submit(): if form.type_.data == "group": invite = await client.create_group_invite( group_ids=form.circles.data, ttl=form.lifetime.data, ) else: invite = await client.create_account_invite( group_ids=form.circles.data, ttl=form.lifetime.data, ) await flash( _("Invitation created"), "success", ) return redirect(url_for(".edit_invite", id_=invite.id_)) return await render_template("admin_create_invite.html", invite_form=form) @bp.route("/invitation/", methods=["GET", "POST"]) @client.require_admin_session() async def edit_invite(id_: str) -> typing.Union[str, werkzeug.Response]: try: invite_info = await client.get_invite_by_id(id_) except aiohttp.ClientResponseError as exc: if exc.status == 404: await flash( _("No such invitation exists"), "alert", ) return redirect(url_for(".invitations")) circles = await client.list_groups() circle_map = { circle.id_: circle for circle in circles } form = InviteForm() if form.validate_on_submit(): if form.action_revoke.data: await client.delete_invite(id_) await flash( _("Invitation revoked"), "success", ) return redirect(url_for(".invitations")) return redirect(url_for(".edit_invite", id_=id_)) return await render_template( "admin_edit_invite.html", invite=invite_info, now=datetime.utcnow(), form=form, circle_map=circle_map, ) class CirclePost(BaseForm): name = wtforms.StringField( _l("Name"), validators=[wtforms.validators.InputRequired()], ) action_create = wtforms.SubmitField( _l("Create circle") ) @bp.route("/circles") @client.require_admin_session() async def circles() -> str: circles = sorted( await client.list_groups(), key=lambda x: x.name ) invite_form = InvitePost() create_form = CirclePost() return await render_template( "admin_circles.html", circles=circles, invite_form=invite_form, create_form=create_form, ) @bp.route("/circle/-/new", methods=["POST"]) @client.require_admin_session() async def create_circle() -> typing.Union[str, werkzeug.Response]: create_form = CirclePost() if create_form.validate_on_submit(): circle = await client.create_group( name=create_form.name.data, ) await flash( _("Circle created"), "success", ) return redirect(url_for(".edit_circle", id_=circle.id_)) return await render_template( "admin_create_circle.html", create_form=create_form, ) class EditCircleForm(BaseForm): name = wtforms.StringField( _l("Name"), validators=[wtforms.validators.InputRequired()], ) user_to_add = wtforms.SelectField( _l("Select user"), validate_choice=False, ) action_save = wtforms.SubmitField( _l("Update circle") ) action_remove_user = wtforms.StringField() action_add_user = wtforms.SubmitField( _l("Add user") ) @bp.route("/circle/", methods=["GET", "POST"]) @client.require_admin_session() async def edit_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 users = { user.localpart: user for user in await client.list_users() } circle_members = [ (localpart, users.get(localpart)) for localpart in sorted(circle.members) ] form = EditCircleForm() form.user_to_add.choices = sorted( (localpart, localpart) for localpart in users.keys() if localpart not in circle.members ) valid_users = [x[0] for x in form.user_to_add.choices] invite_form = InvitePost() await invite_form.init_choices() invite_form.circles.data = [id_] if request.method != "POST": form.name.data = circle.name if form.validate_on_submit(): if form.action_save.data: await client.update_group( id_, new_name=form.name.data, ) await flash( _("Circle data updated"), "success", ) elif form.action_add_user.data: if form.user_to_add.data in valid_users: await client.add_group_member( id_, form.user_to_add.data, ) await flash( _("User added to circle"), "success", ) elif form.action_remove_user.data: await client.remove_group_member( id_, form.action_remove_user.data, ) await flash( _("User removed from circle"), "success", ) return redirect(url_for(".edit_circle", id_=id_)) return await render_template( "admin_edit_circle.html", target_circle=circle, form=form, circle_members=circle_members, invite_form=invite_form, ) class DeleteCircleForm(BaseForm): action_delete = wtforms.SubmitField( _l("Delete circle permanently") ) @bp.route("/circle//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, ) _CPU_EPOCH = time.process_time() _MONOTONIC_EPOCH = time.monotonic() def get_system_stats() -> typing.MutableMapping[ str, typing.Optional[typing.Union[int, float]]]: pagesize = resource.getpagesize() my_rss: typing.Optional[int] = None try: with open("/proc/self/statm") as f: stats = f.read().split() my_rss = int(stats[1]) * pagesize except (ValueError, IndexError, TypeError, OSError): pass my_cpu = ( (time.process_time() - _CPU_EPOCH) / (time.monotonic() - _MONOTONIC_EPOCH) ) mem_total, mem_available = None, None load5: typing.Optional[float] = None try: with open("/proc/loadavg") as f: stats = f.read().split() load5 = float(stats[1]) except (ValueError, IndexError, TypeError, OSError): pass try: with open("/proc/meminfo") as f: for line in f: if line.startswith("MemTotal"): mem_total = int(line.split()[1]) * 1024 elif line.startswith("MemAvailable"): mem_available = int(line.split()[1]) * 1024 if mem_total is not None and mem_available is not None: break except (ValueError, TypeError, IndexError, OSError): pass return { "portal_rss": my_rss, "portal_cpu": my_cpu, "load5": load5, "mem_total": mem_total, "mem_available": mem_available, } class AnnouncementForm(BaseForm): text = wtforms.StringField( _("Message contents"), widget=wtforms.widgets.TextArea(), validators=[wtforms.validators.DataRequired()], ) online_only = wtforms.BooleanField( _("Only send to online users"), ) action_post_all = wtforms.SubmitField( _("Post to all users"), ) action_send_preview = wtforms.SubmitField( _("Send preview to yourself"), ) @bp.route("/system/", methods=["GET", "POST"]) @client.require_admin_session() async def system() -> typing.Union[str, werkzeug.Response]: form = AnnouncementForm() if form.validate_on_submit(): recipients = "self" if form.action_post_all.data: if form.online_only.data: recipients = "online" else: recipients = "all" await client.post_announcement( form.text.data, recipients=recipients, ) await flash( _("Announcement sent!"), "success", ) if recipients != "self": # redirect only if not previewing return redirect(url_for(".system")) version = None now = None show_metrics = current_app.config["SHOW_METRICS"] if show_metrics: version = await client.get_server_version() now = time.time() try: prosody_metrics = await client.get_system_metrics() except werkzeug.exceptions.NotFound: # server does not offer the endpoint for whatever reason -- ignore prosody_metrics = {} metrics = get_system_stats() try: prosody_cpu_metrics = prosody_metrics["cpu"] except KeyError: pass else: metrics["prosody_cpu"] = (prosody_cpu_metrics["value"] / (now - prosody_cpu_metrics["since"])) try: metrics["prosody_rss"] = prosody_metrics["memory"] except KeyError: pass try: metrics["prosody_devices"] = prosody_metrics["c2s"] except KeyError: pass try: metrics["prosody_uploads"] = prosody_metrics["uploads"] except KeyError: pass for k in list(metrics.keys()): if metrics[k] is None: # so that defaulting in jinja works del metrics[k] else: metrics = {} return await render_template( "admin_system.html", metrics=metrics, version=_version.version, prosody_version=version, form=form, show_metrics=show_metrics, )