diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 9127d140..6c36afde 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -206,17 +206,18 @@ def lemmy_federated_instances(): linked = [] allowed = [] blocked = [] + for instance in AllowedInstances.query.all(): + allowed.append({"id": instance.id, "domain": instance.domain, "published": utcnow(), "updated": utcnow()}) + for instance in BannedInstances.query.all(): + blocked.append({"id": instance.id, "domain": instance.domain, "published": utcnow(), "updated": utcnow()}) for instance in instances: instance_data = {"id": instance.id, "domain": instance.domain, "published": instance.created_at.isoformat(), "updated": instance.updated_at.isoformat()} if instance.software: instance_data['software'] = instance.software if instance.version: instance_data['version'] = instance.version - linked.append(instance_data) - for instance in AllowedInstances.query.all(): - allowed.append({"id": instance.id, "domain": instance.domain, "published": utcnow(), "updated": utcnow()}) - for instance in BannedInstances.query.all(): - blocked.append({"id": instance.id, "domain": instance.domain, "published": utcnow(), "updated": utcnow()}) + if not any(blocked_instance.get('domain') == instance.domain for blocked_instance in blocked): + linked.append(instance_data) return jsonify({ "federated_instances": { "linked": linked, @@ -619,6 +620,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): post = create_post(activity_log, community, request_json, user) if post: announce_activity_to_followers(community, user, request_json) + activity_log.result = 'success' except TypeError as e: activity_log.exception_message = 'TypeError. See log file.' current_app.logger.error('TypeError: ' + str(request_json)) @@ -1081,17 +1083,16 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): activity_log.result = 'success' elif request_json['object']['type'] == 'Delete': # undoing a delete activity_log.activity_type = 'Restore' - reply = PostReply.query.filter_by(ap_id=request_json['object']['object']).first() - if reply: + post = Post.query.filter_by(ap_id=request_json['object']['object']).first() + if post: deletor = find_actor_or_create(request_json['object']['actor'], create_if_not_found=False) if deletor: - if reply.author.id == deletor.id or reply.community.is_moderator(deletor) or reply.community.is_instance_admin(deletor): - reply.deleted = False - reply.deleted_by = None - if not reply.author.bot: - reply.post.reply_count += 1 - reply.author.post_reply_count += 1 - announce_activity_to_followers(reply.community, reply.author, request_json) + if post.author.id == deletor.id or post.community.is_moderator(deletor) or post.community.is_instance_admin(deletor): + post.deleted = False + post.deleted_by = None + post.author.post_count += 1 + post.community.post_count += 1 + announce_activity_to_followers(post.community, post.author, request_json) db.session.commit() activity_log.result = 'success' else: @@ -1099,7 +1100,25 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): else: activity_log.exception_message = 'Restorer did not already exist' else: - activity_log.exception_message = 'Reply not found, or object was not a reply' + reply = PostReply.query.filter_by(ap_id=request_json['object']['object']).first() + if reply: + deletor = find_actor_or_create(request_json['object']['actor'], create_if_not_found=False) + if deletor: + if reply.author.id == deletor.id or reply.community.is_moderator(deletor) or reply.community.is_instance_admin(deletor): + reply.deleted = False + reply.deleted_by = None + if not reply.author.bot: + reply.post.reply_count += 1 + reply.author.post_reply_count += 1 + announce_activity_to_followers(reply.community, reply.author, request_json) + db.session.commit() + activity_log.result = 'success' + else: + activity_log.exception_message = 'Restore attempt denied' + else: + activity_log.exception_message = 'Restorer did not already exist' + else: + activity_log.exception_message = 'Object not found, or object was not a post or a reply' elif request_json['type'] == 'Delete': if isinstance(request_json['object'], str): ap_id = request_json['object'] # lemmy @@ -1108,21 +1127,26 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): post = Post.query.filter_by(ap_id=ap_id).first() # Delete post if post: - if can_delete(request_json['actor'], post): - if post.url and post.cross_posts is not None: - old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all() - post.cross_posts.clear() - for ocp in old_cross_posts: - if ocp.cross_posts is not None: - ocp.cross_posts.remove(post.id) - post.delete_dependencies() - announce_activity_to_followers(post.community, post.author, request_json) - post.deleted = True - post.author.post_count -= 1 - db.session.commit() - activity_log.result = 'success' + deletor = find_actor_or_create(request_json['actor'], create_if_not_found=False) + if deletor: + if post.author.id == deletor.id or post.community.is_moderator(deletor) or post.community.is_instance_admin(deletor): + post.deleted = True + post.delted_by = deletor.id + post.author.post_count -= 1 + post.community.post_count -= 1 + if post.url and post.cross_posts is not None: + old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all() + post.cross_posts.clear() + for ocp in old_cross_posts: + if ocp.cross_posts is not None: + ocp.cross_posts.remove(post.id) + announce_activity_to_followers(post.community, post.author, request_json) + db.session.commit() + activity_log.result = 'success' + else: + activity_log.exception_message = 'Delete attempt denied' else: - activity_log.exception_message = 'Delete attempt denied' + activity_log.exception_message = 'Deletor did not already exist' else: # Delete PostReply reply = PostReply.query.filter_by(ap_id=ap_id).first() diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 2615cda7..3649ca7e 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -1323,24 +1323,22 @@ def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id 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: + if to_delete.author.id == deletor.id or deletor.is_admin() or community.is_moderator(deletor) or community.is_instance_admin(deletor): if isinstance(to_delete, Post): - to_delete.delete_dependencies() to_delete.deleted = True + to_delete.deleted_by = deletor.id community.post_count -= 1 to_delete.author.post_count -= 1 - to_delete.deleted_by = deletor.id db.session.commit() if to_delete.author.id != deletor.id: add_to_modlog_activitypub('delete_post', deletor, community_id=community.id, link_text=shorten_string(to_delete.title), link=f'post/{to_delete.id}') elif isinstance(to_delete, PostReply): - if not to_delete.author.bot: - to_delete.post.reply_count -= 1 to_delete.deleted = True to_delete.deleted_by = deletor.id to_delete.author.post_reply_count -= 1 - to_delete.deleted_by = deletor.id + if not to_delete.author.bot: + to_delete.post.reply_count -= 1 db.session.commit() if to_delete.author.id != deletor.id: add_to_modlog_activitypub('delete_post_reply', deletor, community_id=community.id, @@ -1383,7 +1381,6 @@ def restore_post_or_comment_task(object_json, aplog_id): if restorer and community and to_restore: if to_restore.author.id == restorer.id or restorer.is_admin() or community.is_moderator(restorer) or community.is_instance_admin(restorer): if isinstance(to_restore, Post): - # TODO: restore_dependencies() to_restore.deleted = False to_restore.deleted_by = None community.post_count += 1 diff --git a/app/api/alpha/routes.py b/app/api/alpha/routes.py index dd4ee498..f26fe531 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, post_reply_delete, \ + get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, post_reply_delete, post_reply_report, \ get_community_list, get_community, post_community_follow, post_community_block, \ get_user, post_user_block from app.shared.auth import log_user_in @@ -242,6 +242,18 @@ def post_alpha_comment_delete(): return jsonify({"error": str(ex)}), 400 +@bp.route('/api/alpha/comment/report', methods=['POST']) +def post_alpha_comment_report(): + 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_report(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + # User @bp.route('/api/alpha/user', methods=['GET']) def get_alpha_user(): @@ -325,7 +337,6 @@ def alpha_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']) -@bp.route('/api/alpha/comment/report', methods=['POST']) @bp.route('/api/alpha/comment/report/resolve', methods=['PUT']) @bp.route('/api/alpha/comment/report/list', methods=['GET']) def alpha_reply(): diff --git a/app/api/alpha/utils/__init__.py b/app/api/alpha/utils/__init__.py index d9688437..824050da 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, post_reply_delete +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, post_reply_report 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/community.py b/app/api/alpha/utils/community.py index 02c6dfaa..8222b377 100644 --- a/app/api/alpha/utils/community.py +++ b/app/api/alpha/utils/community.py @@ -17,15 +17,11 @@ def cached_community_list(type, user_id): else: communities = Community.query.filter_by(banned=False) - print(len(communities.all())) - if user_id is not None: blocked_instance_ids = blocked_instances(user_id) if blocked_instance_ids: communities = communities.filter(Community.instance_id.not_in(blocked_instance_ids)) - print(len(communities.all())) - return communities.all() diff --git a/app/api/alpha/utils/post.py b/app/api/alpha/utils/post.py index 05f241ec..0d9fcf32 100644 --- a/app/api/alpha/utils/post.py +++ b/app/api/alpha/utils/post.py @@ -98,10 +98,7 @@ def get_post(auth, data): user_id = authorise_api_user(auth) if auth else None post_json = post_view(post=id, variant=3, user_id=user_id) - if post_json: - return post_json - else: - raise Exception('post_not_found') + return post_json # would be in app/constants.py diff --git a/app/api/alpha/utils/reply.py b/app/api/alpha/utils/reply.py index aa457900..436b29fb 100644 --- a/app/api/alpha/utils/reply.py +++ b/app/api/alpha/utils/reply.py @@ -1,9 +1,9 @@ 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.api.alpha.views import reply_view, reply_report_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, \ - delete_reply, restore_reply + delete_reply, restore_reply, report_reply from app.utils import authorise_api_user, blocked_users, blocked_instances from sqlalchemy import desc @@ -131,9 +131,7 @@ def post_reply(auth, data): language_id = 2 # FIXME: use site language input = {'body': body, 'notify_author': True, 'language_id': language_id} - post = Post.query.get(post_id) - if not post: - raise Exception('parent_not_found') + post = Post.query.filter_by(id=post_id).one() user_id, reply = make_reply(input, post, parent_id, SRC_API, auth) @@ -153,12 +151,8 @@ def put_reply(auth, data): language_id = 2 # FIXME: use site language input = {'body': body, 'notify_author': True, 'language_id': language_id} - reply = PostReply.query.get(reply_id) - if not reply: - raise Exception('reply_not_found') - post = Post.query.get(reply.post_id) - if not post: - raise Exception('post_not_found') + reply = PostReply.query.filter_by(id=reply_id).one() + post = Post.query.filter_by(id=reply.post_id).one() user_id, reply = edit_reply(input, reply, post, SRC_API, auth) @@ -182,4 +176,18 @@ def post_reply_delete(auth, data): reply_json = reply_view(reply=reply, variant=4, user_id=user_id) return reply_json - return {} + +def post_reply_report(auth, data): + required(['comment_id', 'reason'], data) + integer_expected(['comment_id'], data) + string_expected(['reason'], data) + + reply_id = data['comment_id'] + reason = data['reason'] + input = {'reason': reason, 'description': '', 'report_remote': True} + + user_id, report = report_reply(reply_id, input, SRC_API, auth) + + reply_json = reply_report_view(report=report, reply_id=reply_id, user_id=user_id) + return reply_json + diff --git a/app/api/alpha/views.py b/app/api/alpha/views.py index ee1cdd7a..107b15d7 100644 --- a/app/api/alpha/views.py +++ b/app/api/alpha/views.py @@ -12,9 +12,7 @@ from sqlalchemy import text def post_view(post: Post | int, variant, stub=False, user_id=None, my_vote=0): if isinstance(post, int): - post = Post.query.get(post) - if not post or post.deleted: - raise Exception('post_not_found') + post = Post.query.filter_by(id=post, deleted=False).one() # Variant 1 - models/post/post.dart if variant == 1: @@ -133,9 +131,7 @@ def cached_user_view_variant_1(user: User, stub=False): # 'user' param can be anyone (including the logged in user), 'user_id' param belongs to the user making the request def user_view(user: User | int, variant, stub=False, user_id=None): if isinstance(user, int): - user = User.query.get(user) - if not user: - raise Exception('user_not_found') + user = User.query.filter_by(id=user).one() # Variant 1 - models/person/person.dart if variant == 1: @@ -190,12 +186,10 @@ def cached_community_view_variant_1(community: Community, stub=False): def community_view(community: Community | int | str, variant, stub=False, user_id=None): if isinstance(community, int): - community = Community.query.get(community) + community = Community.query.filter_by(id=community).one() elif isinstance(community, str): name, ap_domain = community.split('@') - community = Community.query.filter_by(name=name, ap_domain=ap_domain).first() - if not community: - raise Exception('community_not_found') + community = Community.query.filter_by(name=name, ap_domain=ap_domain).one() # Variant 1 - models/community/community.dart if variant == 1: @@ -269,9 +263,7 @@ 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: - raise Exception('reply_not_found') + reply = PostReply.query.filter_by(id=reply).one() # Variant 1 - models/comment/comment.dart if variant == 1: @@ -338,6 +330,48 @@ def reply_view(reply: PostReply | int, variant, user_id=None, my_vote=0): return v4 +def reply_report_view(report, reply_id, user_id): + # views/comment_report_view.dart - /comment/report api endpoint + reply_json = reply_view(reply=reply_id, variant=2, user_id=user_id) + post_json = post_view(post=reply_json['comment']['post_id'], variant=1, stub=True) + community_json = community_view(community=post_json['community_id'], variant=1, stub=True) + + banned = db.session.execute(text('SELECT user_id FROM "community_ban" WHERE user_id = :user_id and community_id = :community_id'), {'user_id': report.reporter_id, 'community_id': community_json['id']}).scalar() + moderator = db.session.execute(text('SELECT is_moderator FROM "community_member" WHERE user_id = :user_id and community_id = :community_id'), {'user_id': report.reporter_id, 'community_id': community_json['id']}).scalar() + admin = db.session.execute(text('SELECT user_id FROM "user_role" WHERE user_id = :user_id and role_id = 4'), {'user_id': report.reporter_id}).scalar() + + creator_banned_from_community = True if banned else False + creator_is_moderator = True if moderator else False + creator_is_admin = True if admin else False + + v1 = { + 'comment_report_view': { + 'comment_report': { + 'id': report.id, + 'creator_id': report.reporter_id, + 'comment_id': report.suspect_post_reply_id, + 'original_comment_text': reply_json['comment']['body'], + 'reason': report.reasons, + 'resolved': report.status == 3, + 'published': report.created_at.isoformat() + 'Z' + }, + 'comment': reply_json['comment'], + 'post': post_json, + 'community': community_json, + 'creator': user_view(user=user_id, variant=1, stub=True), + 'comment_creator': user_view(user=report.suspect_user_id, variant=1, stub=True), + 'counts': reply_json['counts'], + 'creator_banned_from_community': creator_banned_from_community, + 'creator_is_moderator': creator_is_moderator, + 'creator_is_admin': creator_is_admin, + 'creator_blocked': False, + 'subscribed': reply_json['subscribed'], + 'saved': reply_json['saved'] + } + } + return v1 + + def search_view(type): v1 = { 'type_': type, @@ -351,9 +385,7 @@ def search_view(type): def instance_view(instance: Instance | int, variant): if isinstance(instance, int): - instance = Instance.query.get(instance) - if not instance: - raise Exception('instance_not_found') + instance = Instance.query.filter_by(id=instance).one() if variant == 1: include = ['id', 'domain', 'software', 'version'] diff --git a/app/community/util.py b/app/community/util.py index c500d2d2..fd7eb1de 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -527,8 +527,8 @@ def delete_post_from_community(post_id): def delete_post_from_community_task(post_id): post = Post.query.get(post_id) community = post.community - post.delete_dependencies() post.deleted = True + post.deleted_by = current_user.id db.session.commit() if not community.local_only: diff --git a/app/models.py b/app/models.py index 1449e38f..3c0530ad 100644 --- a/app/models.py +++ b/app/models.py @@ -1041,11 +1041,8 @@ class User(UserMixin, db.Model): {'user_id': self.id, 'type': NOTIF_USER}).scalars()) def encode_jwt_token(self): - try: - payload = {'sub': str(self.id), 'iss': current_app.config['SERVER_NAME'], 'iat': int(time())} - return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') - except Exception as e: - return str(e) + payload = {'sub': str(self.id), 'iss': current_app.config['SERVER_NAME'], 'iat': int(time())} + return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') # mark a post as 'read' for this user def mark_post_as_read(self, post): @@ -1377,10 +1374,19 @@ class Post(db.Model): db.session.query(PollChoice).filter(PollChoice.post_id == self.id).delete() db.session.query(Poll).filter(Poll.post_id == self.id).delete() db.session.query(Report).filter(Report.suspect_post_id == self.id).delete() - db.session.execute(text('DELETE FROM "post_reply_vote" WHERE post_reply_id IN (SELECT id FROM post_reply WHERE post_id = :post_id)'), - {'post_id': self.id}) - db.session.execute(text('DELETE FROM "post_reply" WHERE post_id = :post_id'), {'post_id': self.id}) db.session.execute(text('DELETE FROM "post_vote" WHERE post_id = :post_id'), {'post_id': self.id}) + + reply_ids = db.session.execute(text('SELECT id FROM "post_reply" WHERE post_id = :post_id'), {'post_id': self.id}).scalars() + reply_ids = tuple(reply_ids) + if reply_ids: + db.session.execute(text('DELETE FROM "post_reply_vote" WHERE post_reply_id IN :reply_ids'), {'reply_ids': reply_ids}) + db.session.execute(text('DELETE FROM "post_reply_bookmark" WHERE post_reply_id IN :reply_ids'), {'reply_ids': reply_ids}) + db.session.execute(text('DELETE FROM "report" WHERE suspect_post_reply_id IN :reply_ids'), {'reply_ids': reply_ids}) + db.session.execute(text('DELETE FROM "post_reply" WHERE post_id = :post_id'), {'post_id': self.id}) + + self.community.post_reply_count = db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE community_id = :community_id AND deleted = false'), + {'community_id': self.community_id}).scalar() + if self.image_id: file = File.query.get(self.image_id) file.delete_from_disk() diff --git a/app/post/routes.py b/app/post/routes.py index b9f18edd..6f8bd647 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -670,14 +670,18 @@ def add_reply(post_id: int, comment_id: int): @bp.route('/post//options', methods=['GET']) def post_options(post_id: int): post = Post.query.get_or_404(post_id) - if current_user.is_anonymous or not current_user.is_admin(): - if post.deleted: + if post.deleted: + if current_user.is_anonymous: abort(404) + if (not post.community.is_moderator() and + not current_user.is_admin() and + (post.deleted_by is not None and post.deleted_by != current_user.id)): + abort(401) existing_bookmark = [] if current_user.is_authenticated: existing_bookmark = PostBookmark.query.filter(PostBookmark.post_id == post_id, PostBookmark.user_id == current_user.id).first() - + return render_template('post/post_options.html', post=post, existing_bookmark=existing_bookmark, moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()), @@ -1025,7 +1029,6 @@ def post_delete_post(community: Community, post: Post, user_id: int, federate_al 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) - post.delete_dependencies() post.deleted = True post.deleted_by = user_id post.author.post_count -= 1 @@ -1093,14 +1096,19 @@ def post_delete_post(community: Community, post: Post, user_id: int, federate_al @login_required def post_restore(post_id: int): post = Post.query.get_or_404(post_id) - if post.community.is_moderator() or post.community.is_owner() or current_user.is_admin(): + if post.user_id == current_user.id or post.community.is_moderator() or post.community.is_owner() or current_user.is_admin(): + if post.deleted_by == post.user_id: + was_mod_deletion = False + else: + was_mod_deletion = True post.deleted = False + post.deleted_by = None post.author.post_count += 1 post.community.post_count += 1 db.session.commit() # Federate un-delete - if post.is_local(): + if not post.community.local_only: delete_json = { "actor": current_user.public_url(), "to": ["https://www.w3.org/ns/activitystreams#Public"], @@ -1116,31 +1124,40 @@ def post_restore(post_id: int): ], 'object': post.ap_id, 'uri': post.ap_id, - "summary": "bad post", }, "cc": [post.community.public_url()], - "audience": post.author.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" - 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 - } + 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') - 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) + 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.user_id != current_user.id: add_to_modlog('restore_post', community_id=post.community.id, link_text=shorten_string(post.title), @@ -1150,6 +1167,23 @@ def post_restore(post_id: int): return redirect(url_for('activitypub.post_ap', post_id=post.id)) +@bp.route('/post//purge', methods=['GET', 'POST']) +@login_required +def post_purge(post_id: int): + post = Post.query.get_or_404(post_id) + if not post.deleted: + abort(404) + if post.deleted_by == current_user.id or post.community.is_moderator() or current_user.is_admin(): + post.delete_dependencies() + db.session.delete(post) + db.session.commit() + flash(_('Post purged.')) + else: + abort(401) + + return redirect(url_for('user.show_profile_by_id', user_id=post.user_id)) + + @bp.route('/post//bookmark', methods=['GET', 'POST']) @login_required def post_bookmark(post_id: int): @@ -1750,9 +1784,7 @@ def post_reply_purge(post_id: int, comment_id: int): 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 post_reply.deleted_by == 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) diff --git a/app/shared/auth.py b/app/shared/auth.py index b7bb4f90..669c7de3 100644 --- a/app/shared/auth.py +++ b/app/shared/auth.py @@ -21,23 +21,21 @@ def log_user_in(input, src): if src == SRC_WEB: username = input.user_name.data password = input.password.data + user = User.query.filter_by(user_name=username, ap_id=None).first() elif src == SRC_API: required(["username_or_email", "password"], input) string_expected(["username_or_email", "password"], input) username = input['username_or_email'] password = input['password'] + user = User.query.filter_by(user_name=username, ap_id=None, deleted=False).one() else: return None - user = User.query.filter_by(user_name=username, ap_id=None).first() - - if user is None or user.deleted: - if src == SRC_WEB: + if src == SRC_WEB: + if user is None or user.deleted: flash(_('No account exists with that user name.'), 'error') return redirect(url_for('auth.login')) - elif src == SRC_API: - raise Exception('incorrect_login') if not user.check_password(password): if src == SRC_WEB: @@ -96,13 +94,9 @@ def log_user_in(input, src): response.set_cookie('low_bandwidth', '0', expires=datetime(year=2099, month=12, day=30)) return response elif src == SRC_API: - token = user.encode_jwt_token() - if token: - login_json = { - "jwt": token, - "registration_created": user.verified, - "verify_email_sent": True - } - return login_json - else: - raise Exception('could_not_generate_token') + login_json = { + 'jwt': user.encode_jwt_token(), + 'registration_created': user.verified, + 'verify_email_sent': True + } + return login_json diff --git a/app/shared/community.py b/app/shared/community.py index a226ccb3..94aff35e 100644 --- a/app/shared/community.py +++ b/app/shared/community.py @@ -17,9 +17,7 @@ SRC_API = 3 # call from admin.federation not tested def join_community(community_id: int, src, auth=None, user_id=None, main_user_name=True): if src == SRC_API: - community = Community.query.get(community_id) - if not community: - raise Exception('community_not_found') + community = Community.query.filter_by(id=community_id).one() user = authorise_api_user(auth, return_type='model') else: community = Community.query.get_or_404(community_id) @@ -112,9 +110,7 @@ def join_community(community_id: int, src, auth=None, user_id=None, main_user_na # function can be shared between WEB and API (only API calls it for now) def leave_community(community_id: int, src, auth=None): if src == SRC_API: - community = Community.query.get(community_id) - if not community: - raise Exception('community_not_found') + community = Community.query.filter_by(id=community_id).one() user = authorise_api_user(auth, return_type='model') else: community = Community.query.get_or_404(community_id) diff --git a/app/shared/post.py b/app/shared/post.py index 91cd5d3d..c577f4a2 100644 --- a/app/shared/post.py +++ b/app/shared/post.py @@ -20,9 +20,7 @@ SRC_API = 3 def vote_for_post(post_id: int, vote_direction, src, auth=None): if src == SRC_API: - post = Post.query.get(post_id) - if not post: - raise Exception('post_not_found') + post = Post.query.filter_by(id=post_id).one() user = authorise_api_user(auth, return_type='model') else: post = Post.query.get_or_404(post_id) @@ -97,9 +95,7 @@ def vote_for_post(post_id: int, vote_direction, src, auth=None): # post_bookmark in app/post/routes would just need to do 'return bookmark_the_post(post_id, SRC_WEB)' def bookmark_the_post(post_id: int, src, auth=None): if src == SRC_API: - post = Post.query.get(post_id) - if not post or post.deleted: - raise Exception('post_not_found') + post = Post.query.filter_by(id=post_id, deleted=False).one() user_id = authorise_api_user(auth) else: post = Post.query.get_or_404(post_id) @@ -127,9 +123,7 @@ def bookmark_the_post(post_id: int, src, auth=None): # post_remove_bookmark in app/post/routes would just need to do 'return remove_the_bookmark_from_post(post_id, SRC_WEB)' def remove_the_bookmark_from_post(post_id: int, src, auth=None): if src == SRC_API: - post = Post.query.get(post_id) - if not post or post.deleted: - raise Exception('post_not_found') + post = Post.query.filter_by(id=post_id, deleted=False).one() user_id = authorise_api_user(auth) else: post = Post.query.get_or_404(post_id) @@ -156,9 +150,7 @@ def remove_the_bookmark_from_post(post_id: int, src, auth=None): def toggle_post_notification(post_id: int, src, auth=None): # Toggle whether the current user is subscribed to notifications about top-level replies to this post or not if src == SRC_API: - post = Post.query.get(post_id) - if not post or post.deleted: - raise Exception('post_not_found') + post = Post.query.filter_by(id=post_id, deleted=False).one() user_id = authorise_api_user(auth) else: post = Post.query.get_or_404(post_id) @@ -173,10 +165,8 @@ def toggle_post_notification(post_id: int, src, auth=None): db.session.delete(existing_notification) db.session.commit() else: # no subscription yet, so make one - new_notification = NotificationSubscription(name=shorten_string(_('Replies to my post %(post_title)s', - post_title=post.title)), - user_id=user_id, entity_id=post.id, - type=NOTIF_POST) + new_notification = NotificationSubscription(name=shorten_string(_('Replies to my post %(post_title)s', post_title=post.title)), + user_id=user_id, entity_id=post.id, type=NOTIF_POST) db.session.add(new_notification) db.session.commit() diff --git a/app/shared/reply.py b/app/shared/reply.py index 417f07e3..357a5875 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, Post, PostReply, PostReplyBookmark, User, utcnow +from app.models import Instance, Notification, NotificationSubscription, Post, PostReply, PostReplyBookmark, Report, Site, 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 @@ -21,9 +21,7 @@ SRC_API = 3 def vote_for_reply(reply_id: int, vote_direction, src, auth=None): if src == SRC_API: - reply = PostReply.query.get(reply_id) - if not reply: - raise Exception('reply_not_found') + reply = PostReply.query.filter_by(id=reply_id).one() user = authorise_api_user(auth, return_type='model') else: reply = PostReply.query.get_or_404(reply_id) @@ -98,9 +96,7 @@ def vote_for_reply(reply_id: int, vote_direction, src, auth=None): # post_reply_bookmark in app/post/routes would just need to do 'return bookmark_the_post_reply(comment_id, SRC_WEB)' def bookmark_the_post_reply(comment_id: int, src, auth=None): if src == SRC_API: - post_reply = PostReply.query.get(comment_id) - if not post_reply or post_reply.deleted: - raise Exception('comment_not_found') + post_reply = PostReply.query.filter_by(id=comment_id, deleted=False).one() user_id = authorise_api_user(auth) else: post_reply = PostReply.query.get_or_404(comment_id) @@ -129,9 +125,7 @@ def bookmark_the_post_reply(comment_id: int, src, auth=None): # post_reply_remove_bookmark in app/post/routes would just need to do 'return remove_the_bookmark_from_post_reply(comment_id, SRC_WEB)' def remove_the_bookmark_from_post_reply(comment_id: int, src, auth=None): if src == SRC_API: - post_reply = PostReply.query.get(comment_id) - if not post_reply or post_reply.deleted: - raise Exception('comment_not_found') + post_reply = PostReply.query.filter_by(id=comment_id, deleted=False).one() user_id = authorise_api_user(auth) else: post_reply = PostReply.query.get_or_404(comment_id) @@ -158,9 +152,7 @@ def remove_the_bookmark_from_post_reply(comment_id: int, src, auth=None): def toggle_post_reply_notification(post_reply_id: int, src, auth=None): # Toggle whether the current user is subscribed to notifications about replies to this reply or not if src == SRC_API: - post_reply = PostReply.query.get(post_reply_id) - if not post_reply or post_reply.deleted: - raise Exception('comment_not_found') + post_reply = PostReply.query.filter_by(id=post_reply_id, deleted=False).one() user_id = authorise_api_user(auth) else: post_reply = PostReply.query.get_or_404(post_reply_id) @@ -226,9 +218,7 @@ def make_reply(input, post, parent_id, src, auth=None): language_id = input.language_id.data if parent_id: - parent_reply = PostReply.query.get(parent_id) - if not parent_reply: - raise Exception('parent_reply_not_found') + parent_reply = PostReply.query.filter_by(id=parent_id).one() else: parent_reply = None @@ -375,7 +365,7 @@ def edit_reply(input, reply, post, src, auth=None): flash(_('Your changes have been saved.'), 'success') if reply.parent_id: - in_reply_to = PostReply.query.get(reply.parent_id) + in_reply_to = PostReply.query.filter_by(id=reply.parent_id).one() else: in_reply_to = post @@ -642,3 +632,77 @@ def restore_reply(reply_id, src, auth): return user.id, reply else: return + + +def report_reply(reply_id, input, src, auth=None): + if src == SRC_API: + reply = PostReply.query.filter_by(id=reply_id).one() + user = authorise_api_user(auth, return_type='model') + reason = input['reason'] + description = input['description'] + report_remote = input['report_remote'] + else: + reply = PostReply.query.get_or_404(reply_id) + user = current_user + reason = input.reasons_to_string(input.reasons.data) + description = input.description.data + report_remote = input.report_remote.data + + if reply.reports == -1: # When a mod decides to ignore future reports, reply.reports is set to -1 + if src == SRC_API: + raise Exception('already_reported') + else: + flash(_('Comment has already been reported, thank you!')) + return + + report = Report(reasons=reason, description=description, type=2, reporter_id=user.id, suspect_post_id=reply.post.id, suspect_community_id=reply.community.id, + suspect_user_id=reply.author.id, suspect_post_reply_id=reply.id, in_community_id=reply.community.id, source_instance_id=1) + db.session.add(report) + + # Notify moderators + already_notified = set() + for mod in reply.community.moderators(): + moderator = User.query.get(mod.user_id) + if moderator and moderator.is_local(): + notification = Notification(user_id=mod.user_id, title=_('A comment has been reported'), + url=f"https://{current_app.config['SERVER_NAME']}/comment/{reply.id}", + author_id=user.id) + db.session.add(notification) + already_notified.add(mod.user_id) + reply.reports += 1 + # todo: only notify admins for certain types of report + for admin in Site.admins(): + if admin.id not in already_notified: + notify = Notification(title='Suspicious content', url='/admin/reports', user_id=admin.id, author_id=user.id) + db.session.add(notify) + admin.unread_notifications += 1 + db.session.commit() + + # federate report to originating instance + if not reply.community.is_local() and report_remote: + summary = reason + if description: + summary += ' - ' + description + report_json = { + 'actor': user.public_url(), + 'audience': reply.community.public_url(), + 'content': None, + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/flag/{gibberish(15)}", + 'object': reply.ap_id, + 'summary': summary, + 'to': [ + reply.community.public_url() + ], + 'type': 'Flag' + } + instance = Instance.query.get(reply.community.instance_id) + if reply.community.ap_inbox_url and not user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): + success = post_request(reply.community.ap_inbox_url, report_json, user.private_key, user.public_url() + '#main-key') + if success is False or isinstance(success, str): + if src == SRC_WEB: + flash('Failed to send report to remote server', 'error') + + if src == SRC_API: + return user.id, report + else: + return diff --git a/app/templates/post/_post_reply_teaser.html b/app/templates/post/_post_reply_teaser.html index c82612b3..29ceb0f2 100644 --- a/app/templates/post/_post_reply_teaser.html +++ b/app/templates/post/_post_reply_teaser.html @@ -14,7 +14,11 @@ {% if teaser -%}
- reply to: {{ post_reply.post.title | truncate(80, True) }} + {% if post_reply.post.deleted: -%} + reply to: [deleted post] + {% else -%} + reply to: {{ post_reply.post.title | truncate(80, True) }} + {% endif -%} in {{ render_communityname(post_reply.post.community) }}
@@ -53,7 +57,11 @@
{% if post_reply.post.comments_enabled -%} - reply + {% if not post_reply.post.deleted and not post_reply.deleted -%} + reply + {% else -%} + reply + {% endif -%} {% endif -%}
{% with comment=post_reply, community=post_reply.post.community -%} @@ -74,7 +82,9 @@ {% endwith -%} {% endif -%}
- + {% if not post_reply.post.deleted -%} + + {% endif -%}
{% if not post_reply.author.indexable -%}{% endif -%} {% if collapsed -%} diff --git a/app/templates/post/post_options.html b/app/templates/post/post_options.html index 782e5f78..7c4a41eb 100644 --- a/app/templates/post/post_options.html +++ b/app/templates/post/post_options.html @@ -19,8 +19,10 @@ {% endif -%} {% if post.user_id == current_user.id or post.community.is_moderator() or post.community.is_owner() or current_user.is_admin() -%} {% if post.deleted -%} -
  • +
  • {{ _('Restore') }}
  • +
  • + {{ _('Purge') }}
  • {% else -%}
  • {{ _('Delete') }}
  • diff --git a/app/templates/post/post_teaser/_article.html b/app/templates/post/post_teaser/_article.html index 149dfa95..6377640e 100644 --- a/app/templates/post/post_teaser/_article.html +++ b/app/templates/post/post_teaser/_article.html @@ -6,6 +6,7 @@ {% endif -%} {% if post.sticky -%}{% endif -%} + {% if post.deleted -%}{% endif -%} {% if show_post_community -%} {% if post.community.icon_id and not low_bandwidth %}Community icon{% endif -%} diff --git a/app/templates/post/post_teaser/_image.html b/app/templates/post/post_teaser/_image.html index 728f58c3..15ee255b 100644 --- a/app/templates/post/post_teaser/_image.html +++ b/app/templates/post/post_teaser/_image.html @@ -9,6 +9,7 @@ {% endif -%} {% if post.sticky -%}{% endif -%} + {% if post.deleted -%}{% endif -%} {% if show_post_community -%} {% if post.community.icon_id and not low_bandwidth %}Community icon{% endif -%} diff --git a/app/templates/post/post_teaser/_link.html b/app/templates/post/post_teaser/_link.html index 45ef98c6..52c139d1 100644 --- a/app/templates/post/post_teaser/_link.html +++ b/app/templates/post/post_teaser/_link.html @@ -12,6 +12,7 @@ {% endif -%} {% if post.sticky -%}{% endif -%} + {% if post.deleted -%}{% endif -%} {% if show_post_community -%} {% if post.community.icon_id and not low_bandwidth %}Community icon{% endif -%} diff --git a/app/templates/post/post_teaser/_poll.html b/app/templates/post/post_teaser/_poll.html index bfb740ae..5d0c8542 100644 --- a/app/templates/post/post_teaser/_poll.html +++ b/app/templates/post/post_teaser/_poll.html @@ -7,6 +7,7 @@ {% endif -%} {% if post.sticky -%}{% endif -%} + {% if post.deleted -%}{% endif -%} {% if show_post_community -%} {% if post.community.icon_id and not low_bandwidth %}Community icon{% endif -%} diff --git a/app/templates/post/post_teaser/_video.html b/app/templates/post/post_teaser/_video.html index f93b3d84..821df3de 100644 --- a/app/templates/post/post_teaser/_video.html +++ b/app/templates/post/post_teaser/_video.html @@ -14,6 +14,7 @@ {% endif -%} {% if post.sticky -%}{% endif -%} + {% if post.deleted -%}{% endif -%} {% if show_post_community -%} {% if post.community.icon_id and not low_bandwidth %}Community icon{% endif -%} diff --git a/app/user/routes.py b/app/user/routes.py index 2be82cf8..5753f783 100644 --- a/app/user/routes.py +++ b/app/user/routes.py @@ -55,7 +55,6 @@ def show_profile(user): post_page = request.args.get('post_page', 1, type=int) replies_page = request.args.get('replies_page', 1, type=int) - posts = Post.query.filter_by(user_id=user.id).filter(Post.deleted == False).order_by(desc(Post.posted_at)).paginate(page=post_page, per_page=50, error_out=False) moderates = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == user.id)\ .filter(or_(CommunityMember.is_moderator, CommunityMember.is_owner)) if current_user.is_authenticated and (user.id == current_user.get_id() or current_user.is_admin()): @@ -65,10 +64,13 @@ def show_profile(user): 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 and not current_user.is_admin()): moderates = moderates.filter(Community.private_mods == False) + posts = Post.query.filter_by(user_id=user.id).filter(Post.deleted == False).order_by(desc(Post.posted_at)).paginate(page=post_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(): + posts = Post.query.filter_by(user_id=user.id).order_by(desc(Post.posted_at)).paginate(page=post_page, per_page=50, error_out=False) 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: + posts = Post.query.filter_by(user_id=user.id).filter(or_(Post.deleted == False, Post.deleted_by == user.id)).order_by(desc(Post.posted_at)).paginate(page=post_page, per_page=50, error_out=False) 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 diff --git a/app/utils.py b/app/utils.py index de9cbebf..23460d10 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1250,23 +1250,17 @@ def authorise_api_user(auth, return_type=None, id_match=None): raise Exception('incorrect_login') token = auth[7:] # remove 'Bearer ' - try: - decoded = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=["HS256"]) - if decoded: - user_id = decoded['sub'] - issued_at = decoded['iat'] # use to check against blacklisted JWTs - user = User.query.filter_by(id=user_id, ap_id=None, verified=True, banned=False, deleted=False).scalar() - if user: - if id_match and user.id != id_match: - raise Exception('incorrect_login') - if return_type and return_type == 'model': - return user - else: - return user.id - else: - raise Exception('incorrect_login') - except jwt.InvalidTokenError: - raise Exception('invalid_token') + decoded = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=["HS256"]) + if decoded: + user_id = decoded['sub'] + issued_at = decoded['iat'] # use to check against blacklisted JWTs + user = User.query.filter_by(id=user_id, ap_id=None, verified=True, banned=False, deleted=False).one() + if id_match and user.id != id_match: + raise Exception('incorrect_login') + if return_type and return_type == 'model': + return user + else: + return user.id @cache.memoize(timeout=86400)