from datetime import datetime, timedelta from time import sleep from flask import request, flash, json, url_for, current_app, redirect from flask_login import login_required, current_user from flask_babel import _ from sqlalchemy import text, desc from app import db, celery from app.activitypub.routes import process_inbox_request, process_delete_request from app.activitypub.signature import post_request from app.activitypub.util import default_context from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm, EditCommunityForm, EditUserForm from app.community.util import save_icon_file, save_banner_file from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, \ User, Instance, File from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html from app.admin import bp @bp.route('/', methods=['GET', 'POST']) @login_required @permission_required('change instance settings') def admin_home(): return render_template('admin/home.html', title=_('Admin')) @bp.route('/site', methods=['GET', 'POST']) @login_required @permission_required('change instance settings') def admin_site(): form = SiteProfileForm() site = Site.query.get(1) if site is None: site = Site() if form.validate_on_submit(): site.name = form.name.data site.description = form.description.data site.sidebar = form.sidebar.data site.legal_information = form.legal_information.data site.updated = utcnow() if site.id is None: db.session.add(site) db.session.commit() flash('Settings saved.') elif request.method == 'GET': form.name.data = site.name form.description.data = site.description form.sidebar.data = site.sidebar form.legal_information.data = site.legal_information return render_template('admin/site.html', title=_('Site profile'), form=form) @bp.route('/misc', methods=['GET', 'POST']) @login_required @permission_required('change instance settings') def admin_misc(): form = SiteMiscForm() site = Site.query.get(1) if site is None: site = Site() if form.validate_on_submit(): site.enable_downvotes = form.enable_downvotes.data site.allow_local_image_posts = form.allow_local_image_posts.data site.remote_image_cache_days = form.remote_image_cache_days.data site.enable_nsfw = form.enable_nsfw.data site.enable_nsfl = form.enable_nsfl.data site.community_creation_admin_only = form.community_creation_admin_only.data site.reports_email_admins = form.reports_email_admins.data site.registration_mode = form.registration_mode.data site.application_question = form.application_question.data site.updated = utcnow() if site.id is None: db.session.add(site) db.session.commit() flash('Settings saved.') elif request.method == 'GET': form.enable_downvotes.data = site.enable_downvotes form.allow_local_image_posts.data = site.allow_local_image_posts form.remote_image_cache_days.data = site.remote_image_cache_days form.enable_nsfw.data = site.enable_nsfw form.enable_nsfl.data = site.enable_nsfl form.community_creation_admin_only.data = site.community_creation_admin_only form.reports_email_admins.data = site.reports_email_admins form.registration_mode.data = site.registration_mode form.application_question.data = site.application_question return render_template('admin/misc.html', title=_('Misc settings'), form=form) @bp.route('/federation', methods=['GET', 'POST']) @login_required @permission_required('change instance settings') def admin_federation(): form = FederationForm() site = Site.query.get(1) if site is None: site = Site() # todo: finish form site.updated = utcnow() if form.validate_on_submit(): if form.use_allowlist.data: set_setting('use_allowlist', True) db.session.execute(text('DELETE FROM allowed_instances')) for allow in form.allowlist.data.split('\n'): if allow.strip(): db.session.add(AllowedInstances(domain=allow.strip())) if form.use_blocklist.data: set_setting('use_allowlist', False) db.session.execute(text('DELETE FROM banned_instances')) for banned in form.blocklist.data.split('\n'): if banned.strip(): db.session.add(BannedInstances(domain=banned.strip())) db.session.commit() flash(_('Admin settings saved')) elif request.method == 'GET': form.use_allowlist.data = get_setting('use_allowlist', False) form.use_blocklist.data = not form.use_allowlist.data instances = BannedInstances.query.all() form.blocklist.data = '\n'.join([instance.domain for instance in instances]) instances = AllowedInstances.query.all() form.allowlist.data = '\n'.join([instance.domain for instance in instances]) return render_template('admin/federation.html', title=_('Federation settings'), form=form) @bp.route('/activities', methods=['GET']) @login_required @permission_required('change instance settings') def admin_activities(): db.session.query(ActivityPubLog).filter( ActivityPubLog.created_at < utcnow() - timedelta(days=3)).delete() db.session.commit() page = request.args.get('page', 1, type=int) activities = ActivityPubLog.query.order_by(desc(ActivityPubLog.created_at)).paginate(page=page, per_page=1000, error_out=False) next_url = url_for('admin.admin_activities', page=activities.next_num) if activities.has_next else None prev_url = url_for('admin.admin_activities', page=activities.prev_num) if activities.has_prev and page != 1 else None return render_template('admin/activities.html', title=_('ActivityPub Log'), next_url=next_url, prev_url=prev_url, activities=activities) @bp.route('/activity_json/') @login_required @permission_required('change instance settings') def activity_json(activity_id): activity = ActivityPubLog.query.get_or_404(activity_id) return render_template('admin/activity_json.html', title=_('Activity JSON'), activity_json_data=json.loads(activity.activity_json), activity=activity, current_app=current_app) @bp.route('/activity_json//replay') @login_required @permission_required('change instance settings') def activity_replay(activity_id): activity = ActivityPubLog.query.get_or_404(activity_id) request_json = json.loads(activity.activity_json) if 'type' in request_json and request_json['type'] == 'Delete' and request_json['id'].endswith('#delete'): process_delete_request(request_json, activity.id, None) else: process_inbox_request(request_json, activity.id, None) return 'Ok' @bp.route('/communities', methods=['GET']) @login_required @permission_required('administer all communities') def admin_communities(): page = request.args.get('page', 1, type=int) communities = Community.query.filter_by(banned=False).order_by(Community.title).paginate(page=page, per_page=1000, error_out=False) next_url = url_for('admin.admin_communities', page=communities.next_num) if communities.has_next else None prev_url = url_for('admin.admin_communities', page=communities.prev_num) if communities.has_prev and page != 1 else None return render_template('admin/communities.html', title=_('Communities'), next_url=next_url, prev_url=prev_url, communities=communities) @bp.route('/community//edit', methods=['GET', 'POST']) @login_required @permission_required('administer all communities') def admin_community_edit(community_id): form = EditCommunityForm() community = Community.query.get_or_404(community_id) if form.validate_on_submit(): community.name = form.url.data community.title = form.title.data community.description = form.description.data community.rules = form.rules.data community.nsfw = form.nsfw.data community.show_home = form.show_home.data community.show_popular = form.show_popular.data community.show_all = form.show_all.data community.low_quality = form.low_quality.data community.content_retention = form.content_retention.data icon_file = request.files['icon_file'] if icon_file and icon_file.filename != '': if community.icon_id: community.icon.delete_from_disk() file = save_icon_file(icon_file) if file: community.icon = file banner_file = request.files['banner_file'] if banner_file and banner_file.filename != '': if community.image_id: community.image.delete_from_disk() file = save_banner_file(banner_file) if file: community.image = file db.session.commit() flash(_('Saved')) return redirect(url_for('admin.admin_communities')) else: if not community.is_local(): flash(_('This is a remote community - most settings here will be regularly overwritten with data from the original server.'), 'warning') form.url.data = community.name form.title.data = community.title form.description.data = community.description form.rules.data = community.rules form.nsfw.data = community.nsfw form.show_home.data = community.show_home form.show_popular.data = community.show_popular form.show_all.data = community.show_all form.low_quality.data = community.low_quality form.content_retention.data = community.content_retention return render_template('admin/edit_community.html', title=_('Edit community'), form=form, community=community) @bp.route('/community//delete', methods=['GET']) @login_required @permission_required('administer all communities') def admin_community_delete(community_id): community = Community.query.get_or_404(community_id) community.banned = True # Unsubscribing everyone could take a long time so until that is completed hide this community from the UI by banning it. community.last_active = utcnow() db.session.commit() unsubscribe_everyone_then_delete(community.id) flash(_('Community deleted')) return redirect(url_for('admin.admin_communities')) def unsubscribe_everyone_then_delete(community_id): if current_app.debug: unsubscribe_everyone_then_delete_task(community_id) else: unsubscribe_everyone_then_delete_task.delay(community_id) @celery.task def unsubscribe_everyone_then_delete_task(community_id): community = Community.query.get_or_404(community_id) if not community.is_local(): members = CommunityMember.query.filter_by(community_id=community_id).all() for member in members: user = User.query.get(member.user_id) unsubscribe_from_community(community, user) else: # todo: federate delete of local community out to all following instances ... sleep(5) community.delete_dependencies() db.session.delete(community) # todo: when a remote community is deleted it will be able to be re-created by using the 'Add remote' function. Not ideal. Consider soft-delete. @bp.route('/users', methods=['GET']) @login_required @permission_required('administer all users') def admin_users(): page = request.args.get('page', 1, type=int) users = User.query.filter_by(deleted=False).order_by(User.user_name).paginate(page=page, per_page=1000, error_out=False) next_url = url_for('admin.admin_users', page=users.next_num) if users.has_next else None prev_url = url_for('admin.admin_users', page=users.prev_num) if users.has_prev and page != 1 else None return render_template('admin/users.html', title=_('Users'), next_url=next_url, prev_url=prev_url, users=users) @bp.route('/user//edit', methods=['GET', 'POST']) @login_required @permission_required('administer all users') def admin_user_edit(user_id): form = EditUserForm() user = User.query.get_or_404(user_id) if form.validate_on_submit(): user.about = form.about.data user.about_html = markdown_to_html(form.about.data) user.matrix_user_id = form.matrix_user_id.data user.bot = form.bot.data profile_file = request.files['profile_file'] if profile_file and profile_file.filename != '': # remove old avatar file = File.query.get(user.avatar_id) file.delete_from_disk() user.avatar_id = None db.session.delete(file) # add new avatar file = save_icon_file(profile_file, 'users') if file: user.avatar = file banner_file = request.files['banner_file'] if banner_file and banner_file.filename != '': # remove old cover file = File.query.get(user.cover_id) file.delete_from_disk() user.cover_id = None db.session.delete(file) # add new cover file = save_banner_file(banner_file, 'users') if file: user.cover = file user.newsletter = form.newsletter.data user.ignore_bots = form.ignore_bots.data user.show_nsfw = form.nsfw.data user.show_nsfl = form.nsfl.data user.searchable = form.searchable.data user.indexable = form.indexable.data user.ap_manually_approves_followers = form.manually_approves_followers.data db.session.commit() user.flush_cache() flash(_('Saved')) return redirect(url_for('admin.admin_users')) else: if not user.is_local(): flash(_('This is a remote user - most settings here will be regularly overwritten with data from the original server.'), 'warning') form.about.data = user.about form.matrix_user_id.data = user.matrix_user_id form.newsletter.data = user.newsletter form.bot.data = user.bot form.ignore_bots.data = user.ignore_bots form.nsfw.data = user.show_nsfw form.nsfl.data = user.show_nsfl form.searchable.data = user.searchable form.indexable.data = user.indexable form.manually_approves_followers.data = user.ap_manually_approves_followers return render_template('admin/edit_user.html', title=_('Edit user'), form=form, user=user) @bp.route('/user//delete', methods=['GET']) @login_required @permission_required('administer all users') def admin_user_delete(user_id): user = User.query.get_or_404(user_id) user.banned = True # Unsubscribing everyone could take a long time so until that is completed hide this user from the UI by banning it. user.last_active = utcnow() db.session.commit() if user.is_local(): unsubscribe_from_everything_then_delete(user.id) else: user.deleted = True user.delete_dependencies() db.session.commit() flash(_('User deleted')) return redirect(url_for('admin.admin_users')) def unsubscribe_from_everything_then_delete(user_id): if current_app.debug: unsubscribe_from_everything_then_delete_task(user_id) else: unsubscribe_from_everything_then_delete_task.delay(user_id) @celery.task def unsubscribe_from_everything_then_delete_task(user_id): user = User.query.get(user_id) if user: # unsubscribe communities = CommunityMember.query.filter_by(user_id=user_id).all() for membership in communities: community = Community.query.get(membership.community_id) unsubscribe_from_community(community, user) # federate deletion of account if user.is_local(): instances = Instance.query.all() site = Site.query.get(1) payload = { "@context": default_context(), "actor": user.ap_profile_id, "id": f"{user.ap_profile_id}#delete", "object": user.ap_profile_id, "to": [ "https://www.w3.org/ns/activitystreams#Public" ], "type": "Delete" } for instance in instances: if instance.inbox and instance.id != 1: post_request(instance.inbox, payload, site.private_key, f"https://{current_app.config['SERVER_NAME']}#main-key") user.deleted = True user.delete_dependencies() db.session.commit() def unsubscribe_from_community(community, user): undo_id = f"https://{current_app.config['SERVER_NAME']}/activities/undo/" + gibberish(15) follow = { "actor": f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}", "to": [community.ap_profile_id], "object": community.ap_profile_id, "type": "Follow", "id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{gibberish(15)}" } undo = { 'actor': user.profile_id(), 'to': [community.ap_profile_id], 'type': 'Undo', 'id': undo_id, 'object': follow } activity = ActivityPubLog(direction='out', activity_id=undo_id, activity_type='Undo', activity_json=json.dumps(undo), result='processing') db.session.add(activity) db.session.commit() post_request(community.ap_inbox_url, undo, user.private_key, user.profile_id() + '#main-key') activity.result = 'success' db.session.commit()