From 046a15e6175daa1b69d26e45d3a0b3dabdeca223 Mon Sep 17 00:00:00 2001 From: freamon Date: Mon, 18 Nov 2024 22:05:25 +0000 Subject: [PATCH] apf part 12: Create/Update requests --- app/activitypub/routes.py | 171 +++++++++++++++++++++++++++++++++++++- app/activitypub/util.py | 72 ++++++++++++---- 2 files changed, 225 insertions(+), 18 deletions(-) diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index d290b963..5dee4da9 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -25,11 +25,11 @@ from app.activitypub.util import public_key, users_total, active_half_year, acti update_post_from_activity, undo_vote, undo_downvote, post_to_page, get_redis_connection, find_reported_object, \ process_report, ensure_domains_match, can_edit, can_delete, remove_data_from_banned_user, resolve_remote_post, \ inform_followers_of_post_update, comment_model_to_json, restore_post_or_comment, ban_local_user, unban_local_user, \ - lock_post, log_incoming_ap + lock_post, log_incoming_ap, find_community_ap_id from app.utils import gibberish, get_setting, render_template, \ community_membership, ap_datetime, ip_address, can_downvote, \ can_upvote, can_create_post, awaken_dormant_instance, shorten_string, can_create_post_reply, sha256_digest, \ - community_moderators, markdown_to_html + community_moderators, markdown_to_html, html_to_text @bp.route('/testredis') @@ -630,6 +630,97 @@ def process_inbox_request(request_json, store_ap_json): log_incoming_ap(request_json['id'], APLOG_ACCEPT, APLOG_SUCCESS, request_json if store_ap_json else None) return + # Create is new content. Update is often an edit, but Updates from Lemmy can also be new content + if request_json['type'] == 'Create' or request_json['type'] == 'Update': + if request_json['object']['type'] == 'ChatMessage': + sender = user + recipient_ap_id = request_json['object']['to'][0] + recipient = find_actor_or_create(recipient_ap_id, create_if_not_found=False) + if recipient and recipient.is_local(): + if sender.created_recently() or sender.reputation <= -10: + log_incoming_ap(request_json['id'], APLOG_CHATMESSAGE, APLOG_FAILURE, request_json if store_ap_json else None, 'Sender not eligible to send') + return + elif recipient.has_blocked_user(sender.id) or recipient.has_blocked_instance(sender.instance_id): + log_incoming_ap(request_json['id'], APLOG_CHATMESSAGE, APLOG_FAILURE, request_json if store_ap_json else None, 'Sender blocked by recipient') + return + else: + # Find existing conversation to add to + existing_conversation = Conversation.find_existing_conversation(recipient=recipient, sender=sender) + if not existing_conversation: + existing_conversation = Conversation(user_id=sender.id) + existing_conversation.members.append(recipient) + existing_conversation.members.append(sender) + db.session.add(existing_conversation) + db.session.commit() + # Save ChatMessage to DB + encrypted = request_json['object']['encrypted'] if 'encrypted' in request_json['object'] else None + new_message = ChatMessage(sender_id=sender.id, recipient_id=recipient.id, conversation_id=existing_conversation.id, + body_html=request_json['object']['content'], + body=html_to_text(request_json['object']['content']), + encrypted=encrypted) + db.session.add(new_message) + existing_conversation.updated_at = utcnow() + db.session.commit() + + # Notify recipient + notify = Notification(title=shorten_string('New message from ' + sender.display_name()), + url=f'/chat/{existing_conversation.id}#message_{new_message}', user_id=recipient.id, + author_id=sender.id) + db.session.add(notify) + recipient.unread_notifications += 1 + existing_conversation.read = False + db.session.commit() + log_incoming_ap(request_json['id'], APLOG_CHATMESSAGE, APLOG_SUCCESS, request_json if store_ap_json else None) + return + # inner object of Create is not a ChatMessage + else: + if (request_json['object']['type'] == 'Note' and 'name' in request_json['object'] and # Poll Votes + 'inReplyTo' in request_json['object'] and 'attributedTo' in request_json['object']): + post_being_replied_to = Post.query.filter_by(ap_id=request_json['object']['inReplyTo']).first() + if post_being_replied_to: + poll_data = Poll.query.get(post_being_replied_to.id) + choice = PollChoice.query.filter_by(post_id=post_being_replied_to.id, choice_text=request_json['object']['name']).first() + if poll_data and choice: + poll_data.vote_for_choice(choice.id, user.id) + db.session.commit() + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_SUCCESS, request_json if store_ap_json else None) + if post_being_replied_to.author.is_local(): + inform_followers_of_post_update(post_being_replied_to.id, user.instance_id) + return + community_ap_id = find_community_ap_id(request_json) + if not ensure_domains_match(request_json['object']): + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Domains do not match') + return + community = find_actor_or_create(community_ap_id, community_only=True, create_if_not_found=False) if community_ap_id else None + if community and community.local_only: + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Remote Create in local_only community') + return + if not community: + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Blocked or unfound community') + return + + object_type = request_json['object']['type'] + new_content_types = ['Page', 'Article', 'Link', 'Note', 'Question'] + if object_type in new_content_types: # create or update a post + process_new_content(user, community, store_ap_json, request_json, announced=False) + return + elif object_type == 'Video': # PeerTube: editing a video (PT doesn't Announce these) + post = Post.query.filter_by(ap_id=request_json['object']['id']).first() + if post: + if user.id == post.user_id: + update_post_from_activity(post, request_json) + log_incoming_ap(request_json['id'], APLOG_UPDATE, APLOG_SUCCESS, request_json if store_ap_json else None) + return + else: + log_incoming_ap(request_json['id'], APLOG_UPDATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Edit attempt denied') + return + else: + log_incoming_ap(request_json['id'], APLOG_UPDATE, APLOG_FAILURE, request_json if store_ap_json else None, 'PeerTube post not found') + return + else: + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Unacceptable type (create): ' + object_type) + return + # -- below this point is code that will be incrementally replaced to use log_incoming_ap() instead -- @@ -1794,3 +1885,79 @@ def activity_result(id): return jsonify({'error': activity.result, 'message': activity.exception_message}) else: abort(404) + + +def process_new_content(user, community, store_ap_json, request_json, announced=True): + if not announced: + in_reply_to = request_json['object']['inReplyTo'] if 'inReplyTo' in request_json['object'] else None + ap_id = request_json['object']['id'] + announce_id = None + activity_json = request_json + else: + in_reply_to = request_json['object']['object']['inReplyTo'] if 'inReplyTo' in request_json['object']['object'] else None + ap_id = request_json['object']['object']['id'] + announce_id = request_json['id'] + activity_json = request_json['object'] + + if not in_reply_to: # Creating a new post + post = Post.query.filter_by(ap_id=ap_id).first() + if post: + if activity_json['type'] == 'Create': + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Create processed after Update') + return + if user.id == post.user_id: + update_post_from_activity(post, activity_json) + log_incoming_ap(request_json['id'], APLOG_UPDATE, APLOG_SUCCESS, request_json if store_ap_json else None) + if not announced: + announce_activity_to_followers(post.community, post.author, request_json) + return + else: + log_incoming_ap(request_json['id'], APLOG_UPDATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Edit attempt denied') + return + else: + if can_create_post(user, community): + try: + post = create_post(store_ap_json, community, activity_json, user, announce_id=announce_id) + if post: + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_SUCCESS, request_json if store_ap_json else None) + if not announced: + announce_activity_to_followers(community, user, request_json) + return + except TypeError as e: + current_app.logger.error('TypeError: ' + str(request_json)) + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'TypeError. See log file.') + return + else: + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'User cannot create post in Community') + return + else: # Creating a reply / comment + reply = PostReply.query.filter_by(ap_id=ap_id).first() + if reply: + if activity_json['type'] == 'Create': + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Create processed after Update') + return + if user.id == reply.user_id: + update_post_reply_from_activity(reply, activity_json) + log_incoming_ap(request_json['id'], APLOG_UPDATE, APLOG_SUCCESS, request_json if store_ap_json else None) + if not announced: + announce_activity_to_followers(reply.community, reply.author, request_json) + return + else: + log_incoming_ap(request_json['id'], APLOG_UPDATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Edit attempt denied') + return + else: + if can_create_post_reply(user, community): + try: + reply = create_post_reply(store_ap_json, community, in_reply_to, activity_json, user, announce_id=announce_id) + if reply: + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_SUCCESS, request_json if store_ap_json else None) + if not announced: + announce_activity_to_followers(community, user, request_json) + return + except TypeError as e: + current_app.logger.error('TypeError: ' + str(request_json)) + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'TypeError. See log file.') + return + else: + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'User cannot create reply in Community') + return diff --git a/app/activitypub/util.py b/app/activitypub/util.py index e4e92f1f..8f9bf4fd 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -1638,10 +1638,9 @@ def lock_post_task(mod_ap_id, post_id, comments_enabled): db.session.commit() -def create_post_reply(activity_log: ActivityPubLog, community: Community, in_reply_to, request_json: dict, user: User, announce_id=None) -> Union[PostReply, None]: +def create_post_reply(store_ap_json, community: Community, in_reply_to, request_json: dict, user: User, announce_id=None) -> Union[PostReply, None]: if community.local_only: - activity_log.exception_message = 'Community is local only, reply discarded' - activity_log.result = 'ignored' + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Community is local only, reply discarded') return None post_id, parent_comment_id, root_id = find_reply_parent(in_reply_to) @@ -1652,7 +1651,7 @@ def create_post_reply(activity_log: ActivityPubLog, community: Community, in_rep else: parent_comment = None if post_id is None: - activity_log.exception_message = 'Could not find parent post' + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Could not find parent post') return None post = Post.query.get(post_id) @@ -1678,31 +1677,29 @@ def create_post_reply(activity_log: ActivityPubLog, community: Community, in_rep language = find_language(next(iter(request_json['object']['contentMap']))) # Combination of next and iter gets the first key in a dict language_id = language.id if language else None - post_reply = None try: post_reply = PostReply.new(user, post, parent_comment, notify_author=True, body=body, body_html=body_html, language_id=language_id, request_json=request_json, announce_id=announce_id) - activity_log.result = 'success' + return post_reply except Exception as ex: - activity_log.exception_message = str(ex) - activity_log.result = 'ignored' - db.session.commit() - return post_reply + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, str(ex)) + return None + else: + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Unable to find parent post/comment') + return None -def create_post(activity_log: ActivityPubLog, community: Community, request_json: dict, user: User, announce_id=None) -> Union[Post, None]: +def create_post(store_ap_json, community: Community, request_json: dict, user: User, announce_id=None) -> Union[Post, None]: if community.local_only: - activity_log.exception_message = 'Community is local only, post discarded' - activity_log.result = 'ignored' + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, 'Community is local only, post discarded') return None try: post = Post.new(user, community, request_json, announce_id) + return post except Exception as ex: - activity_log.exception_message = str(ex) + log_incoming_ap(request_json['id'], APLOG_CREATE, APLOG_FAILURE, request_json if store_ap_json else None, str(ex)) return None - return post - def notify_about_post(post: Post): # todo: eventually this function could trigger a lot of DB activity. This function will need to be a celery task. @@ -2578,3 +2575,46 @@ def log_incoming_ap(id, aplog_type, aplog_result, request_json, message=None): activity_log.activity_json = json.dumps(request_json) db.session.add(activity_log) db.session.commit() + + +def find_community_ap_id(request_json): + locations = ['audience', 'cc', 'to'] + if 'object' in request_json and isinstance(request_json['object'], dict): + rjs = [request_json, request_json['object']] + else: + rjs = [request_json] + for rj in rjs: + for location in locations: + if location in rj: + potential_id = rj[location] + if isinstance(potential_id, str): + if not potential_id.startswith('https://www.w3.org') and not potential_id.endswith('/followers'): + potential_community = Community.query.filter_by(ap_profile_id=potential_id.lower()).first() + if potential_community: + return potential_id + if isinstance(potential_id, list): + for c in potential_id: + if not c.startswith('https://www.w3.org') and not c.endswith('/followers'): + potential_community = Community.query.filter_by(ap_profile_id=c.lower()).first() + if potential_community: + return c + + if not 'object' in request_json: + return None + + if 'inReplyTo' in request_json['object'] and request_json['object']['inReplyTo'] is not None: + post_being_replied_to = Post.query.filter_by(ap_id=request_json['object']['inReplyTo']).first() + if post_being_replied_to: + return post_being_replied_to.community.ap_profile_id + else: + comment_being_replied_to = PostReply.query.filter_by(ap_id=request_json['object']['inReplyTo']).first() + if comment_being_replied_to: + return comment_being_replied_to.community.ap_profile_id + + if request_json['object']['type'] == 'Video': # PeerTube + if 'attributedTo' in request_json['object'] and isinstance(request_json['object']['attributedTo'], list): + for a in request_json['object']['attributedTo']: + if a['type'] == 'Group': + return a['id'] + + return None