From 2380a3ae616769d355e1fc731b4febeac15a97f2 Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Sun, 2 Jun 2024 16:45:21 +1200 Subject: [PATCH] soft deletes and deleted content admin area #193 --- app/activitypub/routes.py | 7 +- app/activitypub/util.py | 41 +++++---- app/admin/routes.py | 38 +++++++- app/cli.py | 9 ++ app/community/routes.py | 5 +- app/community/util.py | 4 +- app/domain/routes.py | 4 +- app/main/routes.py | 10 ++- app/models.py | 16 +++- app/post/routes.py | 89 ++++++++++++++++--- app/post/util.py | 6 +- app/tag/routes.py | 2 +- app/templates/admin/deleted_posts.html | 41 +++++++++ app/templates/base.html | 1 + app/templates/post/post_options.html | 61 +++++++------ app/templates/post/post_reply_options.html | 45 +++++----- app/topic/routes.py | 5 +- app/user/routes.py | 4 +- .../versions/5e84668d279e_soft_deletes.py | 45 ++++++++++ 19 files changed, 326 insertions(+), 107 deletions(-) create mode 100644 app/templates/admin/deleted_posts.html create mode 100644 migrations/versions/5e84668d279e_soft_deletes.py diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 498fdb92..52918213 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -1010,7 +1010,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): post.delete_dependencies() post.community.post_count -= 1 announce_activity_to_followers(post.community, post.author, request_json) - db.session.delete(post) + post.deleted = True db.session.commit() activity_log.result = 'success' else: @@ -1023,6 +1023,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): reply.body_html = '
deleted
' reply.body = 'deleted' reply.post.reply_count -= 1 + reply.deleted = True announce_activity_to_followers(reply.community, reply.author, request_json) db.session.commit() activity_log.result = 'success' @@ -1213,7 +1214,7 @@ def community_outbox(actor): actor = actor.strip() community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() if community is not None: - posts = community.posts.limit(50).all() + posts = community.posts.filter(Post.deleted == False).limit(50).all() community_data = { "@context": default_context(), @@ -1234,7 +1235,7 @@ def community_featured(actor): actor = actor.strip() community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() if community is not None: - posts = Post.query.filter_by(community_id=community.id, sticky=True).all() + posts = Post.query.filter_by(community_id=community.id, sticky=True, deleted=False).all() community_data = { "@context": default_context(), diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 4a23dd92..51cfc839 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -78,11 +78,11 @@ def active_day(): def local_posts(): - return db.session.execute(text('SELECT COUNT(id) as c FROM "post" WHERE instance_id = 1')).scalar() + return db.session.execute(text('SELECT COUNT(id) as c FROM "post" WHERE instance_id = 1 AND deleted is false')).scalar() def local_comments(): - return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE instance_id = 1')).scalar() + return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE instance_id = 1 and deleted is false')).scalar() def local_communities(): @@ -1409,7 +1409,7 @@ def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id if deletor.is_admin() or community.is_moderator(deletor) or community.is_instance_admin(deletor) or to_delete.author.id == deletor.id: if isinstance(to_delete, Post): to_delete.delete_dependencies() - db.session.delete(to_delete) + to_delete.deleted = True community.post_count -= 1 db.session.commit() elif isinstance(to_delete, PostReply): @@ -1419,7 +1419,7 @@ def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id to_delete.body_html = lemmy_markdown_to_html(to_delete.body) else: to_delete.delete_dependencies() - db.session.delete(to_delete) + to_delete.deleted = True db.session.commit() @@ -1447,31 +1447,30 @@ def remove_data_from_banned_user_task(deletor_ap_id, user_ap_id, target): # community bans by mods elif community and community.is_moderator(deletor): - post_replies = PostReply.query.filter_by(user_id=user.id, community_id=community.id) - posts = Post.query.filter_by(user_id=user.id, community_id=community.id) - + post_replies = PostReply.query.filter_by(user_id=user.id, community_id=community.id, deleted=False) + posts = Post.query.filter_by(user_id=user.id, community_id=community.id, deleted=False) else: return - for pr in post_replies: - pr.post.reply_count -= 1 - if pr.has_replies(): - pr.body = 'Banned' - pr.body_html = lemmy_markdown_to_html(pr.body) + for post_reply in post_replies: + post_reply.post.reply_count -= 1 + if post_reply.has_replies(): + post_reply.body = 'Banned' + post_reply.body_html = lemmy_markdown_to_html(post_reply.body) else: - pr.delete_dependencies() - db.session.delete(pr) + post_reply.delete_dependencies() + post_reply.deleted = True db.session.commit() - for p in posts: - if p.cross_posts: - old_cross_posts = Post.query.filter(Post.id.in_(p.cross_posts)).all() + for post in posts: + if post.cross_posts: + old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all() for ocp in old_cross_posts: if ocp.cross_posts is not None: - ocp.cross_posts.remove(p.id) - p.delete_dependencies() - db.session.delete(p) - p.community.post_count -= 1 + ocp.cross_posts.remove(post.id) + post.delete_dependencies() + post.deleted = True + post.community.post_count -= 1 db.session.commit() diff --git a/app/admin/routes.py b/app/admin/routes.py index e1c477e0..6faa4f5d 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -552,7 +552,7 @@ def admin_content_trash(): page = request.args.get('page', 1, type=int) - posts = Post.query.filter(Post.posted_at > utcnow() - timedelta(days=3)).order_by(Post.score) + posts = Post.query.filter(Post.posted_at > utcnow() - timedelta(days=3), Post.deleted == False).order_by(Post.score) posts = posts.paginate(page=page, per_page=100, error_out=False) next_url = url_for('admin.admin_content_trash', page=posts.next_num) if posts.has_next else None @@ -577,12 +577,14 @@ def admin_content_spam(): posts = Post.query.join(User, User.id == Post.user_id).\ filter(User.created > utcnow() - timedelta(days=3)).\ filter(Post.posted_at > utcnow() - timedelta(days=3)).\ + filter(Post.deleted == False).\ filter(Post.score <= 0).order_by(Post.score) posts = posts.paginate(page=page, per_page=100, error_out=False) post_replies = PostReply.query.join(User, User.id == PostReply.user_id). \ filter(User.created > utcnow() - timedelta(days=3)). \ filter(PostReply.posted_at > utcnow() - timedelta(days=3)). \ + filter(PostReply.deleted == False). \ filter(PostReply.score <= 0).order_by(PostReply.score) post_replies = post_replies.paginate(page=replies_page, per_page=100, error_out=False) @@ -602,6 +604,40 @@ def admin_content_spam(): ) +@bp.route('/content/deleted', methods=['GET']) +@login_required +@permission_required('administer all users') +def admin_content_deleted(): + # Shows all soft deleted posts + page = request.args.get('page', 1, type=int) + replies_page = request.args.get('replies_page', 1, type=int) + + posts = Post.query.\ + filter(Post.deleted == True).\ + order_by(Post.posted_at) + posts = posts.paginate(page=page, per_page=100, error_out=False) + + post_replies = PostReply.query. \ + filter(PostReply.deleted == True). \ + order_by(PostReply.posted_at) + post_replies = post_replies.paginate(page=replies_page, per_page=100, error_out=False) + + next_url = url_for('admin.admin_content_deleted', page=posts.next_num) if posts.has_next else None + prev_url = url_for('admin.admin_content_deleted', page=posts.prev_num) if posts.has_prev and page != 1 else None + next_url_replies = url_for('admin.admin_content_deleted', replies_page=post_replies.next_num) if post_replies.has_next else None + prev_url_replies = url_for('admin.admin_content_deleted', replies_page=post_replies.prev_num) if post_replies.has_prev and replies_page != 1 else None + + return render_template('admin/deleted_posts.html', title=_('Deleted content'), + next_url=next_url, prev_url=prev_url, + next_url_replies=next_url_replies, prev_url_replies=prev_url_replies, + posts=posts, post_replies=post_replies, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.get_id()), + menu_topics=menu_topics(), + site=g.site + ) + + @bp.route('/approve_registrations', methods=['GET']) @login_required @permission_required('approve registrations') diff --git a/app/cli.py b/app/cli.py index a424e826..7680ee29 100644 --- a/app/cli.py +++ b/app/cli.py @@ -266,6 +266,15 @@ def register(app): InstanceRole.role == 'admin').delete() db.session.commit() + # Delete soft-deleted content after 7 days + for post_reply in PostReply.query.filter(PostReply.deleted == True, PostReply.posted_at < utcnow() - timedelta(days=7)).all(): + db.session.delete(post_reply) + + for post in Post.query.filter(Post.deleted == True, Post.posted_at < utcnow() - timedelta(days=7)).all(): + db.session.delete(post) + + db.session.commit() + @app.cli.command("spaceusage") def spaceusage(): with app.app_context(): diff --git a/app/community/routes.py b/app/community/routes.py index 669b1202..8ce767a9 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -197,7 +197,7 @@ def show_community(community: Community): # filter out nsfw and nsfl if desired if current_user.is_anonymous: - posts = posts.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False) + posts = posts.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False, Post.deleted == False) content_filters = {} else: if current_user.ignore_bots: @@ -207,6 +207,7 @@ def show_community(community: Community): if current_user.show_nsfw is False: posts = posts.filter(Post.nsfw == False) content_filters = user_filters_posts(current_user.id) + posts = posts.filter(Post.deleted == False) # filter domains and instances domains_ids = blocked_domains(current_user.id) @@ -319,7 +320,7 @@ def show_community_rss(actor): if request_etag_matches(current_etag): return return_304(current_etag, 'application/rss+xml') - posts = community.posts.filter(Post.from_bot == False).order_by(desc(Post.created_at)).limit(100).all() + posts = community.posts.filter(Post.from_bot == False, Post.deleted == False).order_by(desc(Post.created_at)).limit(100).all() description = shorten_string(community.description, 150) if community.description else None og_image = community.image.source_url if community.image_id else None fg = FeedGenerator() diff --git a/app/community/util.py b/app/community/util.py index a2d85345..07e20656 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -541,7 +541,7 @@ def delete_post_from_community_task(post_id): post = Post.query.get(post_id) community = post.community post.delete_dependencies() - db.session.delete(post) + post.deleted = True db.session.commit() if not community.local_only: @@ -600,7 +600,7 @@ def delete_post_reply_from_community_task(post_reply_id): post_reply.body_html = markdown_to_html(post_reply.body) else: post_reply.delete_dependencies() - db.session.delete(post_reply) + post_reply.deleted = True db.session.commit() # federate delete diff --git a/app/domain/routes.py b/app/domain/routes.py index ddc1011d..cd96ca17 100644 --- a/app/domain/routes.py +++ b/app/domain/routes.py @@ -26,10 +26,10 @@ def show_domain(domain_id): if domain: if current_user.is_anonymous or current_user.ignore_bots: posts = Post.query.join(Community, Community.id == Post.community_id).\ - filter(Post.from_bot == False, Post.domain_id == domain.id, Community.banned == False).\ + filter(Post.from_bot == False, Post.domain_id == domain.id, Community.banned == False, Post.deleted == False).\ order_by(desc(Post.posted_at)) else: - posts = Post.query.join(Community).filter(Post.domain_id == domain.id, Community.banned == False).order_by(desc(Post.posted_at)) + posts = Post.query.join(Community).filter(Post.domain_id == domain.id, Community.banned == False, Post.deleted == False).order_by(desc(Post.posted_at)) if current_user.is_authenticated: instance_ids = blocked_instances(current_user.id) diff --git a/app/main/routes.py b/app/main/routes.py index 00b76fc8..a3251cda 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -30,7 +30,7 @@ from app.utils import render_template, get_setting, gibberish, request_etag_matc blocked_instances, communities_banned_from, topic_tree, recently_upvoted_posts, recently_downvoted_posts, \ generate_image_from_video_url, blocked_users, microblog_content_to_title, menu_topics from app.models import Community, CommunityMember, Post, Site, User, utcnow, Domain, Topic, File, Instance, \ - InstanceRole, Notification, Language, community_language + InstanceRole, Notification, Language, community_language, PostReply from PIL import Image import pytesseract @@ -73,7 +73,7 @@ def home_page(type, sort): if current_user.is_anonymous: flash(_('Create an account to tailor this feed to your interests.')) - posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False) + posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False, Post.deleted == False) posts = posts.join(Community, Community.id == Post.community_id) if type == 'home': posts = posts.filter(Community.show_home == True) @@ -368,7 +368,7 @@ def robots(): @bp.route('/sitemap.xml') @cache.cached(timeout=6000) def sitemap(): - posts = Post.query.filter(Post.from_bot == False) + posts = Post.query.filter(Post.from_bot == False, Post.deleted == False) posts = posts.join(Community, Community.id == Post.community_id) posts = posts.filter(Community.show_all == True, Community.ap_id == None) # sitemap.xml only includes local posts if not g.site.enable_nsfw: @@ -396,6 +396,10 @@ def list_files(directory): @bp.route('/test') def test(): + #for community in Community.query.filter(Community.content_retention != -1): + # for post in community.posts.filter(Post.posted_at < utcnow() - timedelta(days=Community.content_retention)): + # post.delete_dependencies() + return 'done' diff --git a/app/models.py b/app/models.py index 7e2d6478..e2075c94 100644 --- a/app/models.py +++ b/app/models.py @@ -880,7 +880,7 @@ class User(UserMixin, db.Model): db.session.query(Notification).filter(Notification.user_id == self.id).delete() db.session.query(PollChoiceVote).filter(PollChoiceVote.user_id == self.id).delete() - def purge_content(self): + def purge_content(self, soft=True): files = File.query.join(Post).filter(Post.user_id == self.id).all() for file in files: file.delete_from_disk() @@ -888,12 +888,18 @@ class User(UserMixin, db.Model): posts = Post.query.filter_by(user_id=self.id).all() for post in posts: post.delete_dependencies() - db.session.delete(post) + if soft: + post.deleted = True + else: + db.session.delete(post) db.session.commit() post_replies = PostReply.query.filter_by(user_id=self.id).all() for reply in post_replies: reply.delete_dependencies() - db.session.delete(reply) + if soft: + reply.deleted = True + else: + db.session.delete(reply) db.session.commit() def mention_tag(self): @@ -938,6 +944,7 @@ class Post(db.Model): body_html = db.Column(db.Text) type = db.Column(db.Integer) comments_enabled = db.Column(db.Boolean, default=True) + deleted = db.Column(db.Boolean, default=False, index=True) mea_culpa = db.Column(db.Boolean, default=False) has_embed = db.Column(db.Boolean, default=False) reply_count = db.Column(db.Integer, default=0) @@ -1066,6 +1073,7 @@ class PostReply(db.Model): notify_author = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, index=True, default=utcnow) posted_at = db.Column(db.DateTime, index=True, default=utcnow) + deleted = db.Column(db.Boolean, default=False, index=True) ip = db.Column(db.String(50)) from_bot = db.Column(db.Boolean, default=False) up_votes = db.Column(db.Integer, default=0) @@ -1142,7 +1150,7 @@ class PostReply(db.Model): return PostReply.query.filter_by(parent_id=self.id).all() def has_replies(self): - reply = PostReply.query.filter_by(parent_id=self.id).first() + reply = PostReply.query.filter_by(parent_id=self.id).filter(PostReply.deleted == False).first() return reply is not None def blocked_by_content_filter(self, content_filters): diff --git a/app/post/routes.py b/app/post/routes.py index b9041662..9ac2497a 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -36,7 +36,7 @@ def show_post(post_id: int): post = Post.query.get_or_404(post_id) community: Community = post.community - if community.banned: + if community.banned or post.deleted: abort(404) sort = request.args.get('sort', 'hot') @@ -231,6 +231,7 @@ def show_post(post_id: int): og_image = post.image.source_url if post.image_id else None description = shorten_string(markdown_to_text(post.body), 150) if post.body else None + # Breadcrumbs breadcrumbs = [] breadcrumb = namedtuple("Breadcrumb", ['text', 'url']) breadcrumb.text = _('Home') @@ -288,15 +289,15 @@ def show_post(post_id: int): poll_total_votes = 0 if post.type == POST_TYPE_POLL: poll_data = Poll.query.get(post.id) - poll_choices = PollChoice.query.filter_by(post_id=post.id).order_by(PollChoice.sort_order).all() - poll_total_votes = poll_data.total_votes() - # Show poll results to everyone after the poll finishes, to the poll creator and to those who have voted - if (current_user.is_authenticated and (poll_data.has_voted(current_user.id))) \ - or poll_data.end_poll < datetime.utcnow(): - - poll_results = True - else: - poll_form = True + if poll_data: + poll_choices = PollChoice.query.filter_by(post_id=post.id).order_by(PollChoice.sort_order).all() + poll_total_votes = poll_data.total_votes() + # Show poll results to everyone after the poll finishes, to the poll creator and to those who have voted + if (current_user.is_authenticated and (poll_data.has_voted(current_user.id))) \ + or poll_data.end_poll < datetime.utcnow(): + poll_results = True + else: + poll_form = True response = render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, community=post.community, breadcrumbs=breadcrumbs, related_communities=related_communities, mods=mod_list, @@ -612,7 +613,7 @@ def poll_vote(post_id): def continue_discussion(post_id, comment_id): post = Post.query.get_or_404(post_id) comment = PostReply.query.get_or_404(comment_id) - if post.community.banned: + if post.community.banned or post.deleted or comment.deleted: abort(404) mods = post.community.moderators() is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods) @@ -844,6 +845,10 @@ def add_reply(post_id: int, comment_id: int): @bp.route('/post/{{ _('No deleted posts.') }}
+ {% endfor %} +{{ _('No deleted comments.') }}
+ {% endfor %} +{{ _('No comments yet.') }}
+ {% endif %} +