From afe6605ceb3f23feaf380fb629e75fc637022d14 Mon Sep 17 00:00:00 2001 From: freamon Date: Mon, 14 Oct 2024 04:28:36 +0000 Subject: [PATCH 1/2] API: support /comment/delete for user reply delete/restore --- app/api/alpha/routes.py | 15 ++- app/api/alpha/utils/__init__.py | 2 +- app/api/alpha/utils/reply.py | 26 ++++- app/api/alpha/views.py | 19 ++-- app/shared/reply.py | 166 +++++++++++++++++++++++++++++++- 5 files changed, 213 insertions(+), 15 deletions(-) diff --git a/app/api/alpha/routes.py b/app/api/alpha/routes.py index d552ffa9..dd4ee498 100644 --- a/app/api/alpha/routes.py +++ b/app/api/alpha/routes.py @@ -2,7 +2,7 @@ from app.api.alpha import bp from app.api.alpha.utils import get_site, post_site_block, \ get_search, \ get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe, \ - get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, \ + get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, post_reply_delete, \ get_community_list, get_community, post_community_follow, post_community_block, \ get_user, post_user_block from app.shared.auth import log_user_in @@ -230,6 +230,18 @@ def put_alpha_comment(): return jsonify({"error": str(ex)}), 400 +@bp.route('/api/alpha/comment/delete', methods=['POST']) +def post_alpha_comment_delete(): + if not current_app.debug: + return jsonify({'error': 'alpha api routes only available in debug mode'}) + try: + auth = request.headers.get('Authorization') + data = request.get_json(force=True) or {} + return jsonify(post_reply_delete(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + # User @bp.route('/api/alpha/user', methods=['GET']) def get_alpha_user(): @@ -310,7 +322,6 @@ def alpha_post(): # Reply - not yet implemented @bp.route('/api/alpha/comment', methods=['GET']) -@bp.route('/api/alpha/comment/delete', methods=['POST']) @bp.route('/api/alpha/comment/remove', methods=['POST']) @bp.route('/api/alpha/comment/mark_as_read', methods=['POST']) @bp.route('/api/alpha/comment/distinguish', methods=['POST']) diff --git a/app/api/alpha/utils/__init__.py b/app/api/alpha/utils/__init__.py index bae2f1db..d9688437 100644 --- a/app/api/alpha/utils/__init__.py +++ b/app/api/alpha/utils/__init__.py @@ -1,7 +1,7 @@ from app.api.alpha.utils.site import get_site, post_site_block from app.api.alpha.utils.misc import get_search from app.api.alpha.utils.post import get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe -from app.api.alpha.utils.reply import get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply +from app.api.alpha.utils.reply import get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, post_reply_delete from app.api.alpha.utils.community import get_community, get_community_list, post_community_follow, post_community_block from app.api.alpha.utils.user import get_user, post_user_block diff --git a/app/api/alpha/utils/reply.py b/app/api/alpha/utils/reply.py index 781058cf..aa457900 100644 --- a/app/api/alpha/utils/reply.py +++ b/app/api/alpha/utils/reply.py @@ -2,7 +2,8 @@ from app import cache from app.api.alpha.utils.validators import required, integer_expected, boolean_expected, string_expected from app.api.alpha.views import reply_view from app.models import PostReply, Post -from app.shared.reply import vote_for_reply, bookmark_the_post_reply, remove_the_bookmark_from_post_reply, toggle_post_reply_notification, make_reply, edit_reply +from app.shared.reply import vote_for_reply, bookmark_the_post_reply, remove_the_bookmark_from_post_reply, toggle_post_reply_notification, make_reply, edit_reply, \ + delete_reply, restore_reply from app.utils import authorise_api_user, blocked_users, blocked_instances from sqlalchemy import desc @@ -11,9 +12,9 @@ from sqlalchemy import desc @cache.memoize(timeout=3) def cached_reply_list(post_id, person_id, sort, max_depth, user_id): if post_id: - replies = PostReply.query.filter(PostReply.deleted == False, PostReply.post_id == post_id, PostReply.depth <= max_depth) + replies = PostReply.query.filter(PostReply.post_id == post_id, PostReply.depth <= max_depth) if person_id: - replies = PostReply.query.filter_by(deleted=False, user_id=person_id) + replies = PostReply.query.filter_by(user_id=person_id) if user_id is not None: blocked_person_ids = blocked_users(user_id) @@ -163,3 +164,22 @@ def put_reply(auth, data): reply_json = reply_view(reply=reply, variant=4, user_id=user_id) return reply_json + + +def post_reply_delete(auth, data): + required(['comment_id', 'deleted'], data) + integer_expected(['comment_id'], data) + boolean_expected(['deleted'], data) + + reply_id = data['comment_id'] + deleted = data['deleted'] + + if deleted == True: + user_id, reply = delete_reply(reply_id, SRC_API, auth) + else: + user_id, reply = restore_reply(reply_id, SRC_API, auth) + + reply_json = reply_view(reply=reply, variant=4, user_id=user_id) + return reply_json + + return {} diff --git a/app/api/alpha/views.py b/app/api/alpha/views.py index 45c69ecd..ee1cdd7a 100644 --- a/app/api/alpha/views.py +++ b/app/api/alpha/views.py @@ -270,25 +270,28 @@ def calculate_if_has_children(reply): # result used as True / False def reply_view(reply: PostReply | int, variant, user_id=None, my_vote=0): if isinstance(reply, int): reply = PostReply.query.get(reply) - if not reply or reply.deleted: + if not reply: raise Exception('reply_not_found') - # Variant 1 - models/comment/comment.dart if variant == 1: include = ['id', 'user_id', 'post_id', 'body', 'deleted'] v1 = {column.name: getattr(reply, column.name) for column in reply.__table__.columns if column.name in include} - v1['path'] = calculate_path(reply) - if reply.edited_at: - v1['edited_at'] = reply.edited_at.isoformat() + 'Z' - v1.update({'published': reply.posted_at.isoformat() + 'Z', 'ap_id': reply.profile_id(), 'local': reply.is_local(), 'language_id': reply.language_id if reply.language_id else 0, - 'removed': reply.deleted, - 'distinguished': False}) + 'distinguished': False, + 'removed': False}) + + v1['path'] = calculate_path(reply) + if reply.edited_at: + v1['edited_at'] = reply.edited_at.isoformat() + 'Z' + if reply.deleted == True: + v1['body'] = '' + if reply.deleted_by and reply.user_id != reply.deleted_by: + v1['removed'] = True return v1 diff --git a/app/shared/reply.py b/app/shared/reply.py index 53239754..417f07e3 100644 --- a/app/shared/reply.py +++ b/app/shared/reply.py @@ -2,7 +2,7 @@ from app import cache, db from app.activitypub.signature import default_context, post_request_in_background, post_request from app.community.util import send_to_remote_instance from app.constants import * -from app.models import NotificationSubscription, PostReply, PostReplyBookmark, User, utcnow +from app.models import NotificationSubscription, Post, PostReply, PostReplyBookmark, User, utcnow from app.utils import gibberish, instance_banned, render_template, authorise_api_user, recently_upvoted_post_replies, recently_downvoted_post_replies, shorten_string, \ piefed_markdown_to_lemmy_markdown, markdown_to_html, ap_datetime @@ -478,3 +478,167 @@ def edit_reply(input, reply, post, src, auth=None): return user.id, reply else: return + + +# just for deletes by owner (mod deletes are classed as 'remove') +# just for API for now, as WEB version needs attention to ensure that replies can be 'undeleted' +def delete_reply(reply_id, src, auth): + if src == SRC_API: + reply = PostReply.query.filter_by(id=reply_id, deleted=False).one() + post = Post.query.filter_by(id=reply.post_id).one() + user = authorise_api_user(auth, return_type='model', id_match=reply.user_id) + else: + reply = PostReply.query.get_or_404(reply_id) + post = Post.query.get_or_404(reply.post_id) + user = current_user + + reply.deleted = True + reply.deleted_by = user.id + # everything else (votes, body, reports, bookmarks, subscriptions, etc) only wants deleting when it's properly purged after 7 days + # reply_view will return '' in body if reply.deleted == True + + if not reply.author.bot: + post.reply_count -= 1 + reply.author.post_reply_count -= 1 + db.session.commit() + if src == SRC_WEB: + flash(_('Comment deleted.')) + + # federate delete + if not post.community.local_only: + delete_json = { + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/delete/{gibberish(15)}", + 'type': 'Delete', + 'actor': user.public_url(), + 'audience': post.community.public_url(), + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'published': ap_datetime(utcnow()), + 'cc': [ + post.community.public_url(), + user.followers_url() + ], + 'object': reply.ap_id, + '@context': default_context() + } + + 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.public_url() + '#main-key') + if src == SRC_WEB: + if success is False or isinstance(success, str): + flash('Failed to send delete to remote server', 'error') + + else: # local community - send it to followers on remote instances + del delete_json['@context'] + 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.public_url(), + 'cc': [ + post.community.public_url() + '/followers' + ], + '@context': default_context(), + 'object': delete_json + } + + for instance in post.community.following_instances(): + if instance.inbox: + send_to_remote_instance(instance.id, post.community.id, announce) + + if src == SRC_API: + return user.id, reply + else: + return + + +def restore_reply(reply_id, src, auth): + if src == SRC_API: + reply = PostReply.query.filter_by(id=reply_id, deleted=True).one() + post = Post.query.filter_by(id=reply.post_id).one() + user = authorise_api_user(auth, return_type='model', id_match=reply.user_id) + if reply.deleted_by and reply.user_id != reply.deleted_by: + raise Exception('incorrect_login') + else: + reply = PostReply.query.get_or_404(reply_id) + post = Post.query.get_or_404(reply.post_id) + user = current_user + + reply.deleted = False + reply.deleted_by = None + + if not reply.author.bot: + post.reply_count += 1 + reply.author.post_reply_count += 1 + db.session.commit() + if src == SRC_WEB: + flash(_('Comment restored.')) + + # federate undelete + if not post.community.local_only: + delete_json = { + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/delete/{gibberish(15)}", + 'type': 'Delete', + 'actor': user.public_url(), + 'audience': post.community.public_url(), + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'published': ap_datetime(utcnow()), + 'cc': [ + post.community.public_url(), + user.followers_url() + ], + 'object': reply.ap_id + } + undo_json = { + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/undo/{gibberish(15)}", + 'type': 'Undo', + 'actor': user.public_url(), + 'audience': post.community.public_url(), + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'cc': [ + post.community.public_url(), + user.followers_url() + ], + 'object': delete_json, + '@context': default_context() + } + + 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, undo_json, user.private_key, + user.public_url() + '#main-key') + if src == SRC_WEB: + if success is False or isinstance(success, str): + flash('Failed to send delete to remote server', 'error') + + else: # local community - send it to followers on remote instances + del undo_json['@context'] + 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.public_url(), + 'cc': [ + post.community.public_url() + '/followers' + ], + '@context': default_context(), + 'object': undo_json + } + + for instance in post.community.following_instances(): + if instance.inbox: + send_to_remote_instance(instance.id, post.community.id, announce) + + if src == SRC_API: + return user.id, reply + else: + return From 55d47168ce04bbad4be8fc4a879e1a6b491bc209 Mon Sep 17 00:00:00 2001 From: freamon Date: Tue, 15 Oct 2024 02:00:35 +0000 Subject: [PATCH 2/2] Use original follow id to unsubscribe from a.gup.pe groups (doesn't properly unsubscribe if gibberish is used) --- app/community/routes.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/community/routes.py b/app/community/routes.py index ba80efe8..01415089 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -499,13 +499,18 @@ def unsubscribe(actor): if '@' in actor: # this is a remote community, so activitypub is needed success = True if not community.instance.gone_forever: + follow_id = f"https://{current_app.config['SERVER_NAME']}/activities/follow/{gibberish(15)}" + if community.instance.domain == 'a.gup.pe': + join_request = CommunityJoinRequest.query.filter_by(user_id=current_user.id, community_id=community.id).first() + if join_request: + follow_id = f"https://{current_app.config['SERVER_NAME']}/activities/follow/{join_request.id}" undo_id = f"https://{current_app.config['SERVER_NAME']}/activities/undo/" + gibberish(15) follow = { "actor": current_user.public_url(), "to": [community.public_url()], "object": community.public_url(), "type": "Follow", - "id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{gibberish(15)}" + "id": follow_id } undo = { 'actor': current_user.public_url(),