diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index bbed2c98..e31a6ce6 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -835,8 +835,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): for ocp in old_cross_posts: if ocp.cross_posts is not None and post.id in ocp.cross_posts: ocp.cross_posts.remove(post.id) - delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id) - activity_log.result = 'success' + delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id, activity_log.id) elif request_json['object']['type'] == 'Page': # Sent for Mastodon's benefit activity_log.result = 'ignored' activity_log.exception_message = 'Intended for Mastodon' diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 4def65ef..0f41bfda 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -220,6 +220,13 @@ def comment_model_to_json(reply: PostReply) -> dict: } if reply.edited_at: reply_data['updated'] = ap_datetime(reply.edited_at) + if reply.deleted: + if reply.deleted_by == reply.user_id: + reply_data['content'] = '

Deleted by author

' + reply_data['source']['content'] = 'Deleted by author' + else: + reply_data['content'] = '

Deleted by moderator

' + reply_data['source']['content'] = 'Deleted by moderator' return reply_data @@ -1295,18 +1302,24 @@ def is_activitypub_request(): return 'application/ld+json' in request.headers.get('Accept', '') or 'application/activity+json' in request.headers.get('Accept', '') -def delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id): +def delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id, aplog_id): if current_app.debug: - delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id) + delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id, aplog_id) else: - delete_post_or_comment_task.delay(user_ap_id, community_ap_id, to_be_deleted_ap_id) + delete_post_or_comment_task.delay(user_ap_id, community_ap_id, to_be_deleted_ap_id, aplog_id) @celery.task -def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id): +def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id, aplog_id): deletor = find_actor_or_create(user_ap_id) community = find_actor_or_create(community_ap_id, community_only=True) to_delete = find_liked_object(to_be_deleted_ap_id) + if to_delete.deleted: + aplog = ActivityPubLog.query.get(aplog_id) + if aplog: + aplog.result = 'ignored' + aplog.exception_message = 'Activity about local content which is already present' + return if deletor and community and to_delete: if deletor.is_admin() or community.is_moderator(deletor) or community.is_instance_admin(deletor) or to_delete.author.id == deletor.id: @@ -1323,12 +1336,8 @@ def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id elif isinstance(to_delete, PostReply): if not to_delete.author.bot: to_delete.post.reply_count -= 1 - if to_delete.has_replies(): - to_delete.body = 'Deleted by author' if to_delete.author.id == deletor.id else 'Deleted by moderator' - to_delete.body_html = markdown_to_html(to_delete.body) - else: - to_delete.delete_dependencies() - to_delete.deleted = True + to_delete.deleted = True + to_delete.deleted_by = deletor.id to_delete.author.post_reply_count -= 1 to_delete.deleted_by = deletor.id db.session.commit() @@ -1398,12 +1407,8 @@ def remove_data_from_banned_user_task(deletor_ap_id, user_ap_id, target): for post_reply in post_replies: if not user.bot: post_reply.post.reply_count -= 1 - if post_reply.has_replies(): - post_reply.body = 'Banned' - post_reply.body_html = markdown_to_html(post_reply.body) - else: - post_reply.delete_dependencies() - post_reply.deleted = True + post_reply.deleted = True + post_reply.deleted_by = deletor.id db.session.commit() for post in posts: diff --git a/app/cli.py b/app/cli.py index a42783b9..4b22c811 100644 --- a/app/cli.py +++ b/app/cli.py @@ -195,7 +195,8 @@ def register(app): for post_reply in PostReply.query.filter(PostReply.deleted == True, PostReply.posted_at < utcnow() - timedelta(days=7)).all(): post_reply.delete_dependencies() - db.session.delete(post_reply) + if not post_reply.has_replies(): + db.session.delete(post_reply) db.session.commit() for post in Post.query.filter(Post.deleted == True, diff --git a/app/community/util.py b/app/community/util.py index b865d48c..7d5eba0f 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -566,12 +566,8 @@ def delete_post_reply_from_community_task(post_reply_id): post = post_reply.post community = post.community if post_reply.user_id == current_user.id or community.is_moderator(): - if post_reply.has_replies(): - post_reply.body = 'Deleted by author' if post_reply.author.id == current_user.id else 'Deleted by moderator' - post_reply.body_html = markdown_to_html(post_reply.body) - else: - post_reply.delete_dependencies() - post_reply.deleted = True + post_reply.deleted = True + post_reply.deleted_by = current_user.id db.session.commit() # federate delete diff --git a/app/models.py b/app/models.py index 38f67f19..2779d2f0 100644 --- a/app/models.py +++ b/app/models.py @@ -1513,9 +1513,18 @@ class PostReply(db.Model): return parent.author.public_url() def delete_dependencies(self): + """ + The first loop doesn't seem to ever be invoked with the current behaviour. + For replies with their own replies: functions which deal with removal don't set reply.deleted and don't call this, and + because reply.deleted isn't set, the cli task 7 days later doesn't call this either. + + The plan is to set reply.deleted whether there's child replies or not (as happens with the API call), so I've commented + it out so the current behaviour isn't changed. + for child_reply in self.child_replies(): child_reply.delete_dependencies() db.session.delete(child_reply) + """ db.session.query(PostReplyBookmark).filter(PostReplyBookmark.post_reply_id == self.id).delete() db.session.query(Report).filter(Report.suspect_post_reply_id == self.id).delete() diff --git a/app/post/routes.py b/app/post/routes.py index 47f5fc77..6770d1fc 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -681,9 +681,13 @@ def post_options(post_id: int): def post_reply_options(post_id: int, comment_id: int): post = Post.query.get_or_404(post_id) post_reply = PostReply.query.get_or_404(comment_id) - if current_user.is_anonymous or not current_user.is_admin(): - if post.deleted or post_reply.deleted: + if post.deleted or post_reply.deleted: + if current_user.is_anonymous: abort(404) + if (not post.community.is_moderator() and + not current_user.is_admin() and + (post_reply.deleted_by is not None and post_reply.deleted_by != current_user.id)): + abort(401) existing_bookmark = [] if current_user.is_authenticated: @@ -1597,17 +1601,12 @@ def post_reply_delete(post_id: int, comment_id: int): post_reply = PostReply.query.get_or_404(comment_id) community = post.community if post_reply.user_id == current_user.id or community.is_moderator() or current_user.is_admin(): - if post_reply.has_replies(): - post_reply.body = 'Deleted by author' if post_reply.author.id == current_user.id else 'Deleted by moderator' - post_reply.body_html = markdown_to_html(post_reply.body) - else: - post_reply.delete_dependencies() - post_reply.deleted = True + post_reply.deleted = True + post_reply.deleted_by = current_user.id g.site.last_active = community.last_active = utcnow() if not post_reply.author.bot: post.reply_count -= 1 post_reply.author.post_reply_count -= 1 - post_reply.deleted_by = current_user.id db.session.commit() flash(_('Comment deleted.')) # federate delete @@ -1627,11 +1626,12 @@ def post_reply_delete(post_id: int, comment_id: int): if post_reply.user_id != current_user.id: delete_json['summary'] = 'Deleted by mod' - 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, current_user.private_key, - current_user.public_url() + '#main-key') - if success is False or isinstance(success, str): - flash('Failed to send delete to remote server', 'error') + if not post.community.is_local(): + if post_reply.user_id == current_user.id or post.community.is_moderator(current_user): + success = post_request(post.community.ap_inbox_url, delete_json, current_user.private_key, + current_user.public_url() + '#main-key') + 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 announce = { "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", @@ -1658,6 +1658,107 @@ def post_reply_delete(post_id: int, comment_id: int): return redirect(url_for('activitypub.post_ap', post_id=post.id)) +@bp.route('/post//comment//restore', methods=['GET', 'POST']) +@login_required +def post_reply_restore(post_id: int, comment_id: int): + post = Post.query.get_or_404(post_id) + post_reply = PostReply.query.get_or_404(comment_id) + community = post.community + if post_reply.user_id == current_user.id or post.community.is_moderator() or current_user.is_admin(): + if post_reply.deleted_by == post_reply.user_id: + was_mod_deletion = False + else: + was_mod_deletion = True + post_reply.deleted = False + post_reply.deleted_by = None + if not post_reply.author.bot: + post.reply_count += 1 + post_reply.author.post_reply_count += 1 + db.session.commit() + flash(_('Comment restored.')) + + # Federate un-delete + if not post.community.local_only: + delete_json = { + "actor": current_user.public_url(), + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "object": { + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/delete/{gibberish(15)}", + 'type': 'Delete', + 'actor': current_user.public_url(), + 'audience': post.community.public_url(), + 'to': [post.community.public_url(), 'https://www.w3.org/ns/activitystreams#Public'], + 'published': ap_datetime(utcnow()), + 'cc': [ + current_user.followers_url() + ], + 'object': post_reply.ap_id, + 'uri': post_reply.ap_id, + }, + "cc": [post.community.public_url()], + "audience": post.community.public_url(), + "type": "Undo", + "id": f"https://{current_app.config['SERVER_NAME']}/activities/undo/{gibberish(15)}" + } + if was_mod_deletion: + delete_json['object']['summary'] = "Deleted by mod" + + if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it + if not was_mod_deletion or (was_mod_deletion and post.community.is_moderator(current_user)): + success = post_request(post.community.ap_inbox_url, delete_json, current_user.private_key, + current_user.public_url() + '#main-key') + 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 + 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.ap_followers_url + ], + '@context': default_context(), + 'object': delete_json + } + + for instance in post.community.following_instances(): + if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): + send_to_remote_instance(instance.id, post.community.id, announce) + + if post_reply.user_id != current_user.id: + add_to_modlog('restore_post_reply', community_id=post.community.id, link_text=f'comment on {shorten_string(post.title)}', + link=f'post/{post.id}#comment_{post_reply.id}') + + return redirect(url_for('activitypub.post_ap', post_id=post.id)) + + +@bp.route('/post//comment//purge', methods=['GET', 'POST']) +@login_required +def post_reply_purge(post_id: int, comment_id: int): + post = Post.query.get_or_404(post_id) + post_reply = PostReply.query.get_or_404(comment_id) + if not post_reply.deleted: + abort(404) + if post_reply.user_id == current_user.id and (post_reply.deleted_by is None or post_reply.deleted_by != post_reply.user_id): + abort(401) + if post_reply.user_id == current_user.id or post.community.is_moderator() or current_user.is_admin(): + if not post_reply.has_replies(): + post_reply.delete_dependencies() + db.session.delete(post_reply) + db.session.commit() + flash(_('Comment purged.')) + else: + flash(_('Comments that have been replied to cannot be purged.')) + else: + abort(401) + + return redirect(url_for('activitypub.post_ap', post_id=post.id)) + + @bp.route('/post//notification', methods=['GET', 'POST']) @login_required def post_notification(post_id: int): diff --git a/app/post/util.py b/app/post/util.py index f904abb3..3eddaedf 100644 --- a/app/post/util.py +++ b/app/post/util.py @@ -11,7 +11,7 @@ from app.utils import blocked_instances, blocked_users # replies to a post, in a tree, sorted by a variety of methods def post_replies(post_id: int, sort_by: str, show_first: int = 0) -> List[PostReply]: - comments = PostReply.query.filter_by(post_id=post_id).filter(PostReply.deleted == False) + comments = PostReply.query.filter_by(post_id=post_id) if current_user.is_authenticated: instance_ids = blocked_instances(current_user.id) if instance_ids: @@ -52,7 +52,7 @@ def get_comment_branch(post_id: int, comment_id: int, sort_by: str) -> List[Post if parent_comment is None: return [] - comments = PostReply.query.filter(PostReply.post_id == post_id, PostReply.deleted == False) + comments = PostReply.query.filter(PostReply.post_id == post_id) if current_user.is_authenticated: instance_ids = blocked_instances(current_user.id) if instance_ids: diff --git a/app/templates/post/_post_reply_teaser.html b/app/templates/post/_post_reply_teaser.html index 6a69e246..c82612b3 100644 --- a/app/templates/post/_post_reply_teaser.html +++ b/app/templates/post/_post_reply_teaser.html @@ -4,7 +4,7 @@ no_collapse: Don't collapse for admin and moderator views #} {% if current_user.is_authenticated -%} - {% set collapsed = (post_reply.score <= current_user.reply_collapse_threshold) + {% set collapsed = ((post_reply.score <= current_user.reply_collapse_threshold) or post_reply.deleted) and not no_collapse -%} {% else -%} {% set collapsed = (post_reply.score <= -10) and not no_collapse -%} @@ -36,6 +36,11 @@ {% endif -%} +
+ {% if post_reply.deleted -%} + + {% endif -%} +
diff --git a/app/templates/post/continue_discussion.html b/app/templates/post/continue_discussion.html index a30f11a6..bda80148 100644 --- a/app/templates/post/continue_discussion.html +++ b/app/templates/post/continue_discussion.html @@ -28,7 +28,15 @@ {% endif %}
- {{ comment['comment'].body_html | safe }} + {% if comment['comment'].deleted -%} + {% if comment['comment'].deleted_by is none or comment['comment'].deleted_by != comment['comment'].user_id -%} +

Deleted by moderator

+ {% else -%} +

Deleted by author

+ {% endif -%} + {% else -%} + {{ comment['comment'].body_html | community_links | safe }} + {% endif -%}
diff --git a/app/templates/post/post.html b/app/templates/post/post.html index 1e9fa6c2..cc0094aa 100644 --- a/app/templates/post/post.html +++ b/app/templates/post/post.html @@ -80,7 +80,7 @@ aria-level="{{ comment['comment'].depth + 1 }}" role="treeitem" aria-expanded="true" tabindex="0">
{% if not comment['comment'].author.indexable -%}{% endif -%}
- {% with collapsed = comment['comment'].score < reply_collapse_threshold -%} + {% with collapsed = (comment['comment'].score < reply_collapse_threshold or comment['comment'].deleted) -%} {{ render_username(comment['comment'].author) }} {% endwith -%} {% if comment['comment'].author.id == post.author.id -%}[OP] {% endif -%} @@ -91,7 +91,15 @@ {% endif -%}
- {{ comment['comment'].body_html | community_links | safe }} + {% if comment['comment'].deleted -%} + {% if comment['comment'].deleted_by is none or comment['comment'].deleted_by != comment['comment'].user_id -%} +

Deleted by moderator

+ {% else -%} +

Deleted by author

+ {% endif -%} + {% else -%} + {{ comment['comment'].body_html | community_links | safe }} + {% endif -%}
{% if not comment['comment'].author.indexable -%}{% endif -%}
@@ -104,7 +112,7 @@ {% endwith -%}
- {% if comment['comment'].score <= reply_collapse_threshold -%} + {% if comment['comment'].score <= reply_collapse_threshold or comment['comment'].deleted -%} {% else -%} @@ -132,7 +140,7 @@ {% endif -%} {% endif -%}
- {% if comment['comment'].score <= reply_collapse_threshold -%} + {% if comment['comment'].score <= reply_collapse_threshold or comment['comment'].deleted -%} diff --git a/app/templates/post/post_reply_options.html b/app/templates/post/post_reply_options.html index 78bb7f36..339deb57 100644 --- a/app/templates/post/post_reply_options.html +++ b/app/templates/post/post_reply_options.html @@ -19,8 +19,14 @@ {% endif -%} {% if post_reply.user_id == current_user.id or post.community.is_moderator() or post.community.is_owner() or current_user.is_admin() -%} {% if post_reply.deleted -%} -
  • +
  • {{ _('Restore') }}
  • + {% if not post_reply.has_replies() -%} + {% if post.community.is_moderator() or current_user.is_admin() or (post_reply.user_id == current_user.id and post_reply.deleted_by == post_reply.user_id) -%} +
  • + {{ _('Purge') }}
  • + {% endif -%} + {% endif -%} {% else -%}
  • {{ _('Delete') }}
  • diff --git a/app/user/routes.py b/app/user/routes.py index 031fbbed..141be32e 100644 --- a/app/user/routes.py +++ b/app/user/routes.py @@ -63,9 +63,13 @@ def show_profile(user): else: upvoted = [] subscribed = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == user.id).all() - if current_user.is_anonymous or user.id != current_user.id: + if current_user.is_anonymous or (user.id != current_user.id and not current_user.is_admin()): moderates = moderates.filter(Community.private_mods == False) - post_replies = PostReply.query.filter_by(user_id=user.id).filter(PostReply.deleted == False).order_by(desc(PostReply.posted_at)).paginate(page=replies_page, per_page=50, error_out=False) + post_replies = PostReply.query.filter_by(user_id=user.id, deleted=False).order_by(desc(PostReply.posted_at)).paginate(page=replies_page, per_page=50, error_out=False) + elif current_user.is_admin(): + post_replies = PostReply.query.filter_by(user_id=user.id).order_by(desc(PostReply.posted_at)).paginate(page=replies_page, per_page=50, error_out=False) + elif current_user.id == user.id: + post_replies = PostReply.query.filter_by(user_id=user.id).filter(or_(PostReply.deleted == False, PostReply.deleted_by == user.id)).order_by(desc(PostReply.posted_at)).paginate(page=replies_page, per_page=50, error_out=False) # profile info canonical = user.ap_public_url if user.ap_public_url else None