From cb28b791087ba011061a3381005cc9fb45033ec8 Mon Sep 17 00:00:00 2001 From: freamon Date: Thu, 10 Oct 2024 15:39:36 +0100 Subject: [PATCH 1/2] For a.gup.pe groups, send votes to post author instead of to community --- app/models.py | 2 +- app/post/routes.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/models.py b/app/models.py index 032701e9..41f992ba 100644 --- a/app/models.py +++ b/app/models.py @@ -85,7 +85,7 @@ class Instance(db.Model): def votes_are_public(self): if self.trusted is True: # only vote privately with untrusted instances return False - return self.software.lower() == 'lemmy' or self.software.lower() == 'mbin' or self.software.lower() == 'kbin' + return self.software.lower() == 'lemmy' or self.software.lower() == 'mbin' or self.software.lower() == 'kbin' or self.software.lower() == 'guppe groups' def post_count(self): return db.session.execute(text('SELECT COUNT(id) as c FROM "post" WHERE instance_id = :instance_id'), diff --git a/app/post/routes.py b/app/post/routes.py index 120faf1e..eb09185f 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -324,7 +324,11 @@ def post_vote(post_id: int, vote_direction): 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: - post_request_in_background(post.community.ap_inbox_url, action_json, current_user.private_key, + inbox = post.community.ap_inbox_url + if (post.community.ap_domain and post.author.ap_inbox_url and # sanity check these fields aren't null + post.community.ap_domain == 'a.gup.pe' and vote_direction == 'upvote'): # send upvotes to post author's instance instead of a.gup.pe (who reject them) + inbox = post.author.ap_inbox_url + post_request_in_background(inbox, action_json, current_user.private_key, current_user.public_url(not(post.community.instance.votes_are_public() and current_user.vote_privately())) + '#main-key') recently_upvoted = [] From 699efbd2d96c246091e3551f4629827ab6a2dad7 Mon Sep 17 00:00:00 2001 From: freamon Date: Fri, 11 Oct 2024 17:09:32 +0000 Subject: [PATCH 2/2] API: support /comment endpoint for creating new post replies --- app/api/alpha/routes.py | 15 ++- app/api/alpha/utils/__init__.py | 2 +- app/api/alpha/utils/reply.py | 27 +++++- app/shared/reply.py | 165 +++++++++++++++++++++++++++++++- 4 files changed, 199 insertions(+), 10 deletions(-) diff --git a/app/api/alpha/routes.py b/app/api/alpha/routes.py index dec62f7b..d3da198e 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, \ + get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, \ get_community_list, get_community, post_community_follow, post_community_block, \ get_user, post_user_block from app.shared.auth import log_user_in @@ -206,6 +206,18 @@ def put_alpha_comment_subscribe(): return jsonify({"error": str(ex)}), 400 +@bp.route('/api/alpha/comment', methods=['POST']) +def post_alpha_comment(): + 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(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + # User @bp.route('/api/alpha/user', methods=['GET']) def get_alpha_user(): @@ -287,7 +299,6 @@ def alpha_post(): # Reply - not yet implemented @bp.route('/api/alpha/comment', methods=['GET']) @bp.route('/api/alpha/comment', methods=['PUT']) -@bp.route('/api/alpha/comment', methods=['POST']) @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']) diff --git a/app/api/alpha/utils/__init__.py b/app/api/alpha/utils/__init__.py index c00f038a..e47baeb3 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 +from app.api.alpha.utils.reply import get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply 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 06085e86..0678fc41 100644 --- a/app/api/alpha/utils/reply.py +++ b/app/api/alpha/utils/reply.py @@ -1,8 +1,8 @@ from app import cache -from app.api.alpha.utils.validators import required, integer_expected, boolean_expected +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 -from app.shared.reply import vote_for_reply, bookmark_the_post_reply, remove_the_bookmark_from_post_reply, toggle_post_reply_notification +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 from app.utils import authorise_api_user, blocked_users, blocked_instances from sqlalchemy import desc @@ -115,3 +115,24 @@ def put_reply_subscribe(auth, data): user_id = toggle_post_reply_notification(reply_id, SRC_API, auth) reply_json = reply_view(reply=reply_id, variant=4, user_id=user_id) return reply_json + + +def post_reply(auth,data): + required(['body', 'post_id'], data) + string_expected(['body',], data) + integer_expected(['post_id', 'parent_id', 'language_id'], data) + + body = data['body'] + post_id = data['post_id'] + parent_id = data['parent_id'] if 'parent_id' in data else None + language_id = data['language_id'] if 'language_id' in data else 2 + + input = {'body': body, 'notify_author': True, 'language_id': language_id} + post = Post.query.get(post_id) + if not post: + raise Exception('parent_not_found') + + user_id, reply = make_reply(input, post, parent_id, SRC_API, auth) + + reply_json = reply_view(reply=reply, variant=4, user_id=user_id) + return reply_json diff --git a/app/shared/reply.py b/app/shared/reply.py index 30dc92e0..ced6bb1e 100644 --- a/app/shared/reply.py +++ b/app/shared/reply.py @@ -1,9 +1,10 @@ from app import cache, db -from app.activitypub.signature import default_context, post_request_in_background +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 -from app.utils import gibberish, instance_banned, render_template, authorise_api_user, recently_upvoted_post_replies, recently_downvoted_post_replies, shorten_string +from app.models import NotificationSubscription, 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 from flask import abort, current_app, flash, redirect, request, url_for from flask_babel import _ @@ -175,7 +176,7 @@ def toggle_post_reply_notification(post_reply_id: int, src, auth=None): db.session.commit() else: # no subscription yet, so make one new_notification = NotificationSubscription(name=shorten_string(_('Replies to my comment on %(post_title)s', - post_title=post_reply.post.title)), user_id=user_id, entity_id=post_reply.id, + post_title=post_reply.post.title)), user_id=user_id, entity_id=post_reply.id, type=NOTIF_REPLY) db.session.add(new_notification) db.session.commit() @@ -184,3 +185,159 @@ def toggle_post_reply_notification(post_reply_id: int, src, auth=None): return user_id else: return render_template('post/_reply_notification_toggle.html', comment={'comment': post_reply}) + + +# there are undoubtedly better algos for this +def basic_rate_limit_check(user): + weeks_active = int((utcnow() - user.created).days / 7) + score = user.post_reply_count * weeks_active + + if score > 100: + score = 10 + else: + score = int(score/10) + + # a user with a 10-week old account, who has made 10 replies, will score 10, so their rate limit will be 0 + # a user with a new account, and/or has made zero replies, will score 0 (so will have to wait 10 minutes between each new comment) + # other users will score from 1-9, so their rate limits will be between 9 and 1 minutes. + + rate_limit = (10-score)*60 + + recent_reply = cache.get(f'{user.id} has recently replied') + if not recent_reply: + cache.set(f'{user.id} has recently replied', True, timeout=rate_limit) + return True + else: + return False + + +def make_reply(input, post, parent_id, src, auth=None): + if src == SRC_API: + user = authorise_api_user(auth, return_type='model') + if not basic_rate_limit_check(user): + raise Exception('rate_limited') + content = input['body'] + notify_author = input['notify_author'] + language_id = input['language_id'] + else: + user = current_user + content = input.body.data + notify_author = input.notify_author.data + 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') + else: + parent_reply = None + + + # WEBFORM would call 'make_reply' in a try block, so any exception from 'new' would bubble-up for it to handle + reply = PostReply.new(user, post, in_reply_to=parent_reply, body=piefed_markdown_to_lemmy_markdown(content), + body_html=markdown_to_html(content), notify_author=notify_author, + language_id=language_id) + + user.language_id = language_id + reply.ap_id = reply.profile_id() + db.session.commit() + + # federation + if parent_id: + in_reply_to = parent_reply + else: + in_reply_to = post + + if not post.community.local_only: + reply_json = { + 'type': 'Note', + 'id': reply.public_url(), + 'attributedTo': user.public_url(), + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'cc': [ + post.community.public_url(), + in_reply_to.author.public_url() + ], + 'content': reply.body_html, + 'inReplyTo': in_reply_to.profile_id(), + 'url': reply.profile_id(), + 'mediaType': 'text/html', + 'source': {'content': reply.body, 'mediaType': 'text/markdown'}, + 'published': ap_datetime(utcnow()), + 'distinguished': False, + 'audience': post.community.public_url(), + 'contentMap': { + 'en': reply.body_html + } + } + create_json = { + '@context': default_context(), + 'type': 'Create', + 'actor': user.public_url(), + 'audience': post.community.public_url(), + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'cc': [ + post.community.public_url(), + in_reply_to.author.public_url() + ], + 'object': reply_json, + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}" + } + if in_reply_to.notify_author and in_reply_to.author.ap_id is not None: + reply_json['tag'] = [ + { + 'href': in_reply_to.author.public_url(), + 'name': in_reply_to.author.mention_tag(), + 'type': 'Mention' + } + ] + create_json['tag'] = [ + { + 'href': in_reply_to.author.public_url(), + 'name': in_reply_to.author.mention_tag(), + 'type': 'Mention' + } + ] + 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, create_json, user.private_key, + user.public_url() + '#main-key') + if src != SRC_API: + if success is False or isinstance(success, str): + flash('Failed to send reply', 'error') + else: # local community - send it to followers on remote instances + del create_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.ap_followers_url + ], + '@context': default_context(), + 'object': create_json + } + for instance in post.community.following_instances(): + if instance.inbox and not user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): + send_to_remote_instance(instance.id, post.community.id, announce) + + # send copy of Note to comment author (who won't otherwise get it if no-one else on their instance is subscribed to the community) + if not in_reply_to.author.is_local() and in_reply_to.author.ap_domain != reply.community.ap_domain: + if not post.community.is_local() or (post.community.is_local and not post.community.has_followers_from_domain(in_reply_to.author.ap_domain)): + success = post_request(in_reply_to.author.ap_inbox_url, create_json, user.private_key, user.public_url() + '#main-key') + if success is False or isinstance(success, str): + # sending to shared inbox is good enough for Mastodon, but Lemmy will reject it the local community has no followers + personal_inbox = in_reply_to.author.public_url() + '/inbox' + post_request(personal_inbox, create_json, user.private_key, user.public_url() + '#main-key') + + + if src == SRC_API: + return user.id, reply + else: + return reply