From c51c735038923ff6c0900e3fff67607c7da0708b Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Mon, 5 Feb 2024 19:39:08 +1300 Subject: [PATCH] federated purge of user content --- app/user/routes.py | 16 +++++-- app/user/utils.py | 117 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 app/user/utils.py diff --git a/app/user/routes.py b/app/user/routes.py index 45587948..a342d2df 100644 --- a/app/user/routes.py +++ b/app/user/routes.py @@ -14,6 +14,7 @@ from app.models import Post, Community, CommunityMember, User, PostReply, PostVo Instance, Report, UserBlock, CommunityBan, CommunityJoinRequest, CommunityBlock, Filter from app.user import bp from app.user.forms import ProfileForm, SettingsForm, DeleteAccountForm, ReportUserForm, FilterEditForm +from app.user.utils import purge_user_then_delete from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string, \ is_image_url, ensure_directory_exists, gibberish, file_get_contents, community_membership, user_filters_home, \ user_filters_posts, user_filters_replies, moderating_communities, joined_communities @@ -449,14 +450,19 @@ def ban_purge_profile(actor): flash(_('You cannot purge yourself.'), 'error') else: user.banned = True - user.deleted = True - db.session.commit() - - user.purge_content() + # user.deleted = True # DO NOT set user.deleted until the deletion of their posts has been federated db.session.commit() # todo: empty relevant caches - # todo: federate deletion + + # federate deletion + if user.is_local(): + purge_user_then_delete(user.id) + else: + user.deleted = True + user.delete_dependencies() + user.purge_content() + db.session.commit() flash(f'{actor} has been banned, deleted and all their content deleted.') else: diff --git a/app/user/utils.py b/app/user/utils.py new file mode 100644 index 00000000..a27597a4 --- /dev/null +++ b/app/user/utils.py @@ -0,0 +1,117 @@ +from time import sleep + +from flask import current_app, json + +from app import celery, db +from app.activitypub.signature import post_request +from app.activitypub.util import default_context +from app.community.util import send_to_remote_instance +from app.models import User, CommunityMember, Community, Instance, Site, utcnow, ActivityPubLog +from app.utils import gibberish, ap_datetime, instance_banned + + +def purge_user_then_delete(user_id): + if current_app.debug: + purge_user_then_delete_task(user_id) + else: + purge_user_then_delete_task.delay(user_id) + + +@celery.task +def purge_user_then_delete_task(user_id): + user = User.query.get(user_id) + if user: + # posts + for post in user.posts: + if not post.community.local_only: + delete_json = { + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/delete/{gibberish(15)}", + 'type': 'Delete', + 'actor': user.profile_id(), + 'audience': post.community.profile_id(), + 'to': [post.community.profile_id(), 'https://www.w3.org/ns/activitystreams#Public'], + 'published': ap_datetime(utcnow()), + 'cc': [ + user.followers_url() + ], + 'object': post.ap_id, + } + + if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it + success = post_request(post.community.ap_inbox_url, delete_json, user.private_key, + user.ap_profile_id + '#main-key') + + else: # local community - send it to followers on remote instances, using Announce + announce = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", + "type": 'Announce', + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "actor": post.community.ap_profile_id, + "cc": [ + post.community.ap_followers_url + ], + '@context': default_context(), + 'object': delete_json + } + + for instance in post.community.following_instances(): + if instance.inbox and not instance_banned(instance.domain): + send_to_remote_instance(instance.id, post.community.id, announce) + + # 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") + + sleep(100) # wait a while for any related activitypub traffic to die down. + user.deleted = True + user.delete_dependencies() + user.purge_content() + 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() \ No newline at end of file