From 6ee41578d6bd1a226921b48e9ac9e16d6d25717c Mon Sep 17 00:00:00 2001 From: freamon Date: Fri, 17 Jan 2025 17:24:26 +0000 Subject: [PATCH] mentions in post bodies - use shared code for outbound federation / notification --- app/activitypub/util.py | 14 -- app/community/routes.py | 300 +--------------------------------------- app/community/util.py | 227 ------------------------------ app/post/routes.py | 216 ++--------------------------- 4 files changed, 21 insertions(+), 736 deletions(-) diff --git a/app/activitypub/util.py b/app/activitypub/util.py index fd94c34b..898a509c 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -2569,21 +2569,7 @@ def verify_object_from_source(request_json): return request_json -# This is for followers on microblog apps -# Used to let them know a Poll has been updated with a new vote -# The plan is to also use it for activities on local user's posts that aren't understood by being Announced (anything beyond the initial Create) -# This would need for posts to have things like a 'Replies' collection and a 'Likes' collection, so these can be downloaded when the post updates -# Using collecions like this (as PeerTube does) circumvents the problem of not having a remote user's private key. -# The problem of what to do for remote user's activity on a remote user's post in a local community still exists (can't Announce it, can't inform of post update) def inform_followers_of_post_update(post_id: int, sending_instance_id: int): - if current_app.debug: - inform_followers_of_post_update_task(post_id, sending_instance_id) - else: - inform_followers_of_post_update_task.delay(post_id, sending_instance_id) - - -@celery.task -def inform_followers_of_post_update_task(post_id: int, sending_instance_id: int): post = Post.query.get(post_id) page_json = post_to_page(post) page_json['updated'] = ap_datetime(utcnow()) diff --git a/app/community/routes.py b/app/community/routes.py index d5e61031..319a469a 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -24,7 +24,7 @@ from app.community.forms import SearchRemoteCommunity, CreateDiscussionForm, Cre EscalateReportForm, ResolveReportForm, CreateVideoForm, CreatePollForm, RetrieveRemotePost, \ EditCommunityWikiPageForm from app.community.util import search_for_community, actor_to_community, \ - save_post, save_icon_file, save_banner_file, send_to_remote_instance, \ + save_icon_file, save_banner_file, send_to_remote_instance, \ delete_post_from_community, delete_post_reply_from_community, community_in_list, find_local_users, tags_from_string, \ allowed_extensions, end_poll_date from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \ @@ -43,6 +43,7 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_ community_moderators, communities_banned_from, show_ban_message, recently_upvoted_posts, recently_downvoted_posts, \ blocked_users, languages_for_form, menu_topics, add_to_modlog, \ blocked_communities, remove_tracking_from_link, piefed_markdown_to_lemmy_markdown, ensure_directory_exists +from app.shared.post import make_post from feedgen.feed import FeedGenerator from datetime import timezone, timedelta from copy import copy @@ -630,110 +631,15 @@ def add_post(actor, type): form.language_id.choices = languages_for_form() - if not can_create_post(current_user, community): - abort(401) - if form.validate_on_submit(): community = Community.query.get_or_404(form.communities.data) - if not can_create_post(current_user, community): + try: + uploaded_file = request.files['image_file'] if type == 'image' else None + post = make_post(form, community, post_type, 1, uploaded_file=uploaded_file) + except Exception as ex: + flash(_('Your post was not accepted because %(reason)s', reason=str(ex)), 'error') abort(401) - language = Language.query.get(form.language_id.data) - - request_json = { - 'id': None, - 'object': { - 'name': form.title.data, - 'type': 'Page', - 'stickied': form.sticky.data, - 'sensitive': form.nsfw.data, - 'nsfl': form.nsfl.data, - 'id': gibberish(), # this will be updated once we have the post.id - 'mediaType': 'text/markdown', - 'content': form.body.data, - 'tag': tags_from_string(form.tags.data), - 'language': {'identifier': language.code, 'name': language.name} - } - } - if type == 'link': - request_json['object']['attachment'] = [{'type': 'Link', 'href': form.link_url.data}] - elif type == 'image': - uploaded_file = request.files['image_file'] - if uploaded_file and uploaded_file.filename != '': - # check if this is an allowed type of file - file_ext = os.path.splitext(uploaded_file.filename)[1] - if file_ext.lower() not in allowed_extensions: - abort(400, description="Invalid image type.") - - new_filename = gibberish(15) - # set up the storage directory - directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4] - ensure_directory_exists(directory) - - final_place = os.path.join(directory, new_filename + file_ext) - uploaded_file.seek(0) - uploaded_file.save(final_place) - - if file_ext.lower() == '.heic': - register_heif_opener() - if file_ext.lower() == '.avif': - import pillow_avif - - Image.MAX_IMAGE_PIXELS = 89478485 - - # resize if necessary - if not final_place.endswith('.svg'): - img = Image.open(final_place) - if '.' + img.format.lower() in allowed_extensions: - img = ImageOps.exif_transpose(img) - - # limit full sized version to 2000px - img.thumbnail((2000, 2000)) - img.save(final_place) - - request_json['object']['attachment'] = [{ - 'type': 'Image', - 'url': f'https://{current_app.config["SERVER_NAME"]}/{final_place.replace("app/", "")}', - 'name': form.image_alt_text.data, - 'file_path': final_place - }] - - elif type == 'video': - request_json['object']['attachment'] = [{'type': 'Document', 'url': form.video_url.data}] - elif type == 'poll': - request_json['object']['type'] = 'Question' - choices = [form.choice_1, form.choice_2, form.choice_3, form.choice_4, form.choice_5, - form.choice_6, form.choice_7, form.choice_8, form.choice_9, form.choice_10] - key = 'oneOf' if form.mode.data == 'single' else 'anyOf' - request_json['object'][key] = [] - for choice in choices: - choice_data = choice.data.strip() - if choice_data: - request_json['object'][key].append({'name': choice_data}) - request_json['object']['endTime'] = end_poll_date(form.finish_in.data) - - # todo: add try..except - post = Post.new(current_user, community, request_json) - - if form.notify_author.data: - new_notification = NotificationSubscription(name=post.title, user_id=current_user.id, entity_id=post.id, type=NOTIF_POST) - db.session.add(new_notification) - current_user.language_id = form.language_id.data - g.site.last_active = utcnow() - post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}" - db.session.commit() - - if post.type == POST_TYPE_POLL: - poll = Poll.query.filter_by(post_id=post.id).first() - if not poll.local_only: - federate_post_to_user_followers(post) - if not community.local_only and not poll.local_only: - federate_post(community, post) - else: - federate_post_to_user_followers(post) - if not community.local_only: - federate_post(community, post) - resp = make_response(redirect(f"/post/{post.id}")) # remove cookies used to maintain state when switching post type resp.delete_cookie('post_title') @@ -760,7 +666,6 @@ def add_post(actor, type): form.language_id.data = source_post.language_id form.link_url.data = source_post.url - # empty post to pass since add_post.html extends edit_post.html # and that one checks for a post.image_id for editing image posts post = None @@ -775,197 +680,6 @@ def add_post(actor, type): ) -def federate_post(community, post): - page = { - 'type': 'Page', - 'id': post.ap_id, - 'attributedTo': current_user.public_url(), - 'to': [ - community.public_url(), - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'name': post.title, - 'cc': [], - 'content': post.body_html if post.body_html else '', - 'mediaType': 'text/html', - 'source': {'content': post.body if post.body else '', 'mediaType': 'text/markdown'}, - 'attachment': [], - 'commentsEnabled': post.comments_enabled, - 'sensitive': post.nsfw, - 'nsfl': post.nsfl, - 'stickied': post.sticky, - 'published': ap_datetime(utcnow()), - 'audience': community.public_url(), - 'language': { - 'identifier': post.language_code(), - 'name': post.language_name() - }, - 'tag': post.tags_for_activitypub() - } - create = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", - "actor": current_user.public_url(), - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "cc": [ - community.public_url() - ], - "type": "Create", - "audience": community.public_url(), - "object": page, - '@context': default_context() - } - if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO: - page['attachment'] = [{'href': post.url, 'type': 'Link'}] - elif post.image_id: - image_url = '' - if post.image.source_url: - image_url = post.image.source_url - elif post.image.file_path: - image_url = post.image.file_path.replace('app/static/', - f"https://{current_app.config['SERVER_NAME']}/static/") - elif post.image.thumbnail_path: - image_url = post.image.thumbnail_path.replace('app/static/', - f"https://{current_app.config['SERVER_NAME']}/static/") - # NB image is a dict while attachment is a list of dicts (usually just one dict in the list) - page['image'] = {'type': 'Image', 'url': image_url} - if post.type == POST_TYPE_IMAGE: - page['attachment'] = [{'type': 'Image', - 'url': post.image.source_url, # source_url is always a https link, no need for .replace() as done above - 'name': post.image.alt_text}] - - if post.type == POST_TYPE_POLL: - poll = Poll.query.filter_by(post_id=post.id).first() - page['type'] = 'Question' - page['endTime'] = ap_datetime(poll.end_poll) - page['votersCount'] = 0 - choices = [] - for choice in PollChoice.query.filter_by(post_id=post.id).all(): - choices.append({ - "type": "Note", - "name": choice.choice_text, - "replies": { - "type": "Collection", - "totalItems": 0 - } - }) - page['oneOf' if poll.mode == 'single' else 'anyOf'] = choices - if not community.is_local(): # this is a remote community - send the post to the instance that hosts it - post_request_in_background(community.ap_inbox_url, create, current_user.private_key, - current_user.public_url() + '#main-key', timeout=10) - flash(_('Your post to %(name)s has been made.', name=community.title)) - else: # local community - send (announce) post out to followers - announce = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", - "type": 'Announce', - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "actor": community.public_url(), - "cc": [ - community.ap_followers_url - ], - '@context': default_context(), - 'object': create - } - microblog_announce = copy(announce) - microblog_announce['object'] = post.ap_id - - sent_to = 0 - for instance in community.following_instances(): - if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned( - instance.domain): - if instance.software in MICROBLOG_APPS: - send_to_remote_instance(instance.id, community.id, microblog_announce) - else: - send_to_remote_instance(instance.id, community.id, announce) - sent_to += 1 - if sent_to: - flash(_('Your post to %(name)s has been made.', name=community.title)) - else: - flash(_('Your post to %(name)s has been made.', name=community.title)) - - -def federate_post_to_user_followers(post): - followers = UserFollower.query.filter_by(local_user_id=post.user_id) - if not followers: - return - - note = { - 'type': 'Note', - 'id': post.ap_id, - 'inReplyTo': None, - 'attributedTo': current_user.public_url(), - 'to': [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'cc': [ - current_user.followers_url() - ], - 'content': '', - 'mediaType': 'text/html', - 'attachment': [], - 'commentsEnabled': post.comments_enabled, - 'sensitive': post.nsfw, - 'nsfl': post.nsfl, - 'stickied': post.sticky, - 'published': ap_datetime(utcnow()), - 'language': { - 'identifier': post.language_code(), - 'name': post.language_name() - }, - 'tag': post.tags_for_activitypub() - } - create = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", - "actor": current_user.public_url(), - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "cc": [ - current_user.followers_url() - ], - "type": "Create", - "object": note, - '@context': default_context() - } - if post.type == POST_TYPE_ARTICLE: - note['content'] = '

' + post.title + '

' - elif post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO: - note['content'] = '

' + post.title + '

' - elif post.type == POST_TYPE_IMAGE: - note['content'] = '

' + post.title + '

' - if post.image_id and post.image.source_url: - note['attachment'] = [{'type': 'Image', 'url': post.image.source_url, 'name': post.image.alt_text}] - - if post.body_html: - note['content'] = note['content'] + '

' + post.body_html + '

' - - if post.type == POST_TYPE_POLL: - poll = Poll.query.filter_by(post_id=post.id).first() - note['type'] = 'Question' - note['endTime'] = ap_datetime(poll.end_poll) - note['votersCount'] = 0 - choices = [] - for choice in PollChoice.query.filter_by(post_id=post.id).all(): - choices.append({ - "type": "Note", - "name": choice.choice_text, - "replies": { - "type": "Collection", - "totalItems": 0 - } - }) - note['oneOf' if poll.mode == 'single' else 'anyOf'] = choices - - instances = Instance.query.join(User, User.instance_id == Instance.id).join(UserFollower, UserFollower.remote_user_id == User.id) - instances = instances.filter(UserFollower.local_user_id == post.user_id).filter(Instance.gone_forever == False) - for instance in instances: - if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): - post_request_in_background(instance.inbox, create, current_user.private_key, current_user.public_url() + '#main-key') - - @bp.route('/community//report', methods=['GET', 'POST']) @login_required def community_report(community_id: int): diff --git a/app/community/util.py b/app/community/util.py index 186f59e9..11f54780 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -235,233 +235,6 @@ def actor_to_community(actor) -> Community: return community -def save_post(form, post: Post, type: int): - post.indexable = current_user.indexable - post.sticky = form.sticky.data - post.nsfw = form.nsfw.data - post.nsfl = form.nsfl.data - post.notify_author = form.notify_author.data - post.language_id = form.language_id.data - current_user.language_id = form.language_id.data - post.title = form.title.data.strip() - post.body = piefed_markdown_to_lemmy_markdown(form.body.data) - post.body_html = markdown_to_html(post.body) - if not type or type == POST_TYPE_ARTICLE: - post.type = POST_TYPE_ARTICLE - elif type == POST_TYPE_LINK: - url_changed = post.id is None or form.link_url.data != post.url - post.url = remove_tracking_from_link(form.link_url.data.strip()) - post.type = POST_TYPE_LINK - domain = domain_from_url(form.link_url.data) - domain.post_count += 1 - post.domain = domain - - if url_changed: - if post.image_id: - remove_old_file(post.image_id) - post.image_id = None - - if post.url.endswith('.mp4') or post.url.endswith('.webm'): - post.type = POST_TYPE_VIDEO - file = File(source_url=form.link_url.data) # make_image_sizes() will take care of turning this into a still image - post.image = file - db.session.add(file) - else: - unused, file_extension = os.path.splitext(form.link_url.data) - # this url is a link to an image - turn it into a image post - if file_extension.lower() in allowed_extensions: - file = File(source_url=form.link_url.data) - post.image = file - db.session.add(file) - post.type = POST_TYPE_IMAGE - else: - # check opengraph tags on the page and make a thumbnail if an image is available in the og:image meta tag - if not post.type == POST_TYPE_VIDEO: - tn_url = form.link_url.data - if tn_url[:32] == 'https://www.youtube.com/watch?v=': - tn_url = 'https://youtu.be/' + tn_url[32:43] # better chance of thumbnail from youtu.be than youtube.com - opengraph = opengraph_parse(tn_url) - if opengraph and (opengraph.get('og:image', '') != '' or opengraph.get('og:image:url', '') != ''): - filename = opengraph.get('og:image') or opengraph.get('og:image:url') - if not filename.startswith('/'): - file = url_to_thumbnail_file(filename) - if file: - file.alt_text = shorten_string(opengraph.get('og:title'), 295) - post.image = file - db.session.add(file) - - elif type == POST_TYPE_IMAGE: - post.type = POST_TYPE_IMAGE - alt_text = form.image_alt_text.data if form.image_alt_text.data else form.title.data - uploaded_file = request.files['image_file'] - # If we are uploading new file in the place of existing one just remove the old one - if post.image_id is not None and uploaded_file: - post.image.delete_from_disk() - image_id = post.image_id - post.image_id = None - db.session.add(post) - db.session.commit() - File.query.filter_by(id=image_id).delete() - - if uploaded_file and uploaded_file.filename != '': - if post.image_id: - remove_old_file(post.image_id) - post.image_id = None - - # check if this is an allowed type of file - file_ext = os.path.splitext(uploaded_file.filename)[1] - if file_ext.lower() not in allowed_extensions: - abort(400) - new_filename = gibberish(15) - - # set up the storage directory - directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4] - ensure_directory_exists(directory) - - # save the file - final_place = os.path.join(directory, new_filename + file_ext) - final_place_medium = os.path.join(directory, new_filename + '_medium.webp') - final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') - uploaded_file.seek(0) - uploaded_file.save(final_place) - - if file_ext.lower() == '.heic': - register_heif_opener() - - Image.MAX_IMAGE_PIXELS = 89478485 - - # resize if necessary - img = Image.open(final_place) - if '.' + img.format.lower() in allowed_extensions: - img = ImageOps.exif_transpose(img) - - # limit full sized version to 2000px - img_width = img.width - img_height = img.height - img.thumbnail((2000, 2000)) - img.save(final_place) - - # medium sized version - img.thumbnail((512, 512)) - img.save(final_place_medium, format="WebP", quality=93) - - # save a third, smaller, version as a thumbnail - img.thumbnail((170, 170)) - img.save(final_place_thumbnail, format="WebP", quality=93) - thumbnail_width = img.width - thumbnail_height = img.height - - file = File(file_path=final_place_medium, file_name=new_filename + file_ext, alt_text=alt_text, - width=img_width, height=img_height, thumbnail_width=thumbnail_width, - thumbnail_height=thumbnail_height, thumbnail_path=final_place_thumbnail, - source_url=final_place.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/")) - db.session.add(file) - db.session.commit() - post.image_id = file.id - - elif type == POST_TYPE_VIDEO: - form.video_url.data = form.video_url.data.strip() - url_changed = post.id is None or form.video_url.data != post.url - post.url = remove_tracking_from_link(form.video_url.data.strip()) - post.type = POST_TYPE_VIDEO - domain = domain_from_url(form.video_url.data) - domain.post_count += 1 - post.domain = domain - - if url_changed: - if post.image_id: - remove_old_file(post.image_id) - post.image_id = None - if form.video_url.data.endswith('.mp4') or form.video_url.data.endswith('.webm'): - file = File(source_url=form.video_url.data) # make_image_sizes() will take care of turning this into a still image - post.image = file - db.session.add(file) - else: - # check opengraph tags on the page and make a thumbnail if an image is available in the og:image meta tag - tn_url = form.video_url.data - if tn_url[:32] == 'https://www.youtube.com/watch?v=': - tn_url = 'https://youtu.be/' + tn_url[32:43] # better chance of thumbnail from youtu.be than youtube.com - opengraph = opengraph_parse(tn_url) - if opengraph and (opengraph.get('og:image', '') != '' or opengraph.get('og:image:url', '') != ''): - filename = opengraph.get('og:image') or opengraph.get('og:image:url') - if not filename.startswith('/'): - file = url_to_thumbnail_file(filename) - if file: - file.alt_text = shorten_string(opengraph.get('og:title'), 295) - post.image = file - db.session.add(file) - - elif type == POST_TYPE_POLL: - post.body = form.title.data + '\n' + form.body.data if post.title not in form.body.data else form.body.data - post.body_html = markdown_to_html(post.body) - post.type = POST_TYPE_POLL - else: - raise Exception('invalid post type') - - if post.id is None: - if current_user.reputation > 100: - post.up_votes = 1 - post.score = 1 - if current_user.reputation < -100: - post.score = -1 - post.ranking = post.post_ranking(post.score, utcnow()) - - # Filter by phrase - blocked_phrases_list = blocked_phrases() - for blocked_phrase in blocked_phrases_list: - if blocked_phrase in post.title: - abort(401) - return - if post.body: - for blocked_phrase in blocked_phrases_list: - if blocked_phrase in post.body: - abort(401) - return - - db.session.add(post) - else: - db.session.execute(text('DELETE FROM "post_tag" WHERE post_id = :post_id'), {'post_id': post.id}) - post.tags = tags_from_string_old(form.tags.data) - db.session.commit() - - # Save poll choices. NB this will delete all votes whenever a poll is edited. Partially because it's easier to code but also to stop malicious alterations to polls after people have already voted - if type == POST_TYPE_POLL: - db.session.execute(text('DELETE FROM "poll_choice_vote" WHERE post_id = :post_id'), {'post_id': post.id}) - db.session.execute(text('DELETE FROM "poll_choice" WHERE post_id = :post_id'), {'post_id': post.id}) - for i in range(1, 10): - choice_data = getattr(form, f"choice_{i}").data.strip() - if choice_data != '': - db.session.add(PollChoice(post_id=post.id, choice_text=choice_data, sort_order=i)) - - poll = Poll.query.filter_by(post_id=post.id).first() - if poll is None: - poll = Poll(post_id=post.id) - db.session.add(poll) - poll.mode = form.mode.data - if form.finish_in: - poll.end_poll = end_poll_date(form.finish_in.data) - poll.local_only = form.local_only.data - poll.latest_vote = None - db.session.commit() - - # Notify author about replies - # Remove any subscription that currently exists - existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == post.id, - NotificationSubscription.user_id == current_user.id, - NotificationSubscription.type == NOTIF_POST).first() - if existing_notification: - db.session.delete(existing_notification) - - # Add subscription if necessary - if form.notify_author.data: - new_notification = NotificationSubscription(name=post.title, user_id=current_user.id, entity_id=post.id, - type=NOTIF_POST) - db.session.add(new_notification) - - g.site.last_active = utcnow() - db.session.commit() - - def end_poll_date(end_choice): delta_mapping = { '30m': timedelta(minutes=30), diff --git a/app/post/routes.py b/app/post/routes.py index dee6a2b9..089b4a25 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -10,8 +10,8 @@ from wtforms import SelectField, RadioField from app import db, constants, cache, celery from app.activitypub.signature import HttpSignature, post_request, default_context, post_request_in_background -from app.activitypub.util import notify_about_post_reply, inform_followers_of_post_update, update_post_from_activity -from app.community.util import save_post, send_to_remote_instance +from app.activitypub.util import notify_about_post_reply, update_post_from_activity +from app.community.util import send_to_remote_instance from app.inoculation import inoculation from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm, CrossPostForm from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm, CreateVideoForm, CreatePollForm, EditImageForm @@ -35,6 +35,7 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_ languages_for_form, menu_topics, add_to_modlog, blocked_communities, piefed_markdown_to_lemmy_markdown, \ permission_required, blocked_users, get_request, is_local_image_url, is_video_url, can_upvote, can_downvote from app.shared.reply import make_reply, edit_reply +from app.shared.post import edit_post def show_post(post_id: int): @@ -359,7 +360,8 @@ def poll_vote(post_id): poll_votes = PollChoice.query.join(PollChoiceVote, PollChoiceVote.choice_id == PollChoice.id).filter(PollChoiceVote.post_id == post.id, PollChoiceVote.user_id == current_user.id).all() for pv in poll_votes: if post.author.is_local(): - inform_followers_of_post_update(post.id, 1) + from app.shared.tasks import task_selector + task_selector('edit_post', user_id=current_user.id, post_id=post.id) else: pollvote_json = { '@context': default_context(), @@ -587,7 +589,7 @@ def post_edit(post_id: int): del form.finish_in else: abort(404) - + del form.communities mods = post.community.moderators() @@ -607,26 +609,16 @@ def post_edit(post_id: int): form.nsfl.data = True form.nsfw.render_kw = {'disabled': True} - old_url = post.url - form.language_id.choices = languages_for_form() if form.validate_on_submit(): - save_post(form, post, post_type) - post.community.last_active = utcnow() - post.edited_at = utcnow() - - if post.url != old_url: - post.calculate_cross_posts(url_changed=True) - - db.session.commit() - - flash(_('Your changes have been saved.'), 'success') - - # federate edit - if not post.community.local_only: - federate_post_update(post) - federate_post_edit_to_user_followers(post) + try: + uploaded_file = request.files['image_file'] if post_type == POST_TYPE_IMAGE else None + edit_post(form, post, post_type, 1, uploaded_file=uploaded_file) + flash(_('Your changes have been saved.'), 'success') + except Exception as ex: + flash(_('Your edit was not accepted because %(reason)s', reason=str(ex)), 'error') + abort(401) return redirect(url_for('activitypub.post_ap', post_id=post.id)) else: @@ -651,7 +643,7 @@ def post_edit(post_id: int): ) with open(path, "rb")as file: form.image_file.data = file.read() - + elif post_type == POST_TYPE_VIDEO: form.video_url.data = post.url elif post_type == POST_TYPE_POLL: @@ -678,186 +670,6 @@ def post_edit(post_id: int): abort(401) -def federate_post_update(post): - page_json = { - 'type': 'Page', - 'id': post.ap_id, - 'attributedTo': current_user.public_url(), - 'to': [ - post.community.public_url(), - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'name': post.title, - 'cc': [], - 'content': post.body_html if post.body_html else '', - 'mediaType': 'text/html', - 'source': {'content': post.body if post.body else '', 'mediaType': 'text/markdown'}, - 'attachment': [], - 'commentsEnabled': post.comments_enabled, - 'sensitive': post.nsfw, - 'nsfl': post.nsfl, - 'stickied': post.sticky, - 'published': ap_datetime(post.posted_at), - 'updated': ap_datetime(post.edited_at), - 'audience': post.community.public_url(), - 'language': { - 'identifier': post.language_code(), - 'name': post.language_name() - }, - 'tag': post.tags_for_activitypub() - } - update_json = { - 'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}", - 'type': 'Update', - 'actor': current_user.public_url(), - 'audience': post.community.public_url(), - 'to': [post.community.public_url(), 'https://www.w3.org/ns/activitystreams#Public'], - 'published': ap_datetime(utcnow()), - 'cc': [ - current_user.followers_url() - ], - 'object': page_json, - } - if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO: - page_json['attachment'] = [{'href': post.url, 'type': 'Link'}] - elif post.image_id: - if post.image.file_path: - image_url = post.image.file_path.replace('app/static/', - f"https://{current_app.config['SERVER_NAME']}/static/") - elif post.image.thumbnail_path: - image_url = post.image.thumbnail_path.replace('app/static/', - f"https://{current_app.config['SERVER_NAME']}/static/") - else: - image_url = post.image.source_url - # NB image is a dict while attachment is a list of dicts (usually just one dict in the list) - page_json['image'] = {'type': 'Image', 'url': image_url} - if post.type == POST_TYPE_IMAGE: - page_json['attachment'] = [{'type': 'Image', - 'url': post.image.source_url, # source_url is always a https link, no need for .replace() as done above - 'name': post.image.alt_text}] - if post.type == POST_TYPE_POLL: - poll = Poll.query.filter_by(post_id=post.id).first() - page_json['type'] = 'Question' - page_json['endTime'] = ap_datetime(poll.end_poll) - page_json['votersCount'] = 0 - choices = [] - for choice in PollChoice.query.filter_by(post_id=post.id).all(): - choices.append({ - "type": "Note", - "name": choice.choice_text, - "replies": { - "type": "Collection", - "totalItems": 0 - } - }) - page_json['oneOf' if poll.mode == 'single' else 'anyOf'] = choices - - 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, update_json, current_user.private_key, - current_user.public_url() + '#main-key') - if success is False or isinstance(success, str): - flash('Failed to send edit to remote server', 'error') - else: # local community - send it to followers on remote instances - announce = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", - "type": 'Announce', - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "actor": post.community.ap_profile_id, - "cc": [ - post.community.ap_followers_url - ], - '@context': default_context(), - 'object': update_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) - - -def federate_post_edit_to_user_followers(post): - followers = UserFollower.query.filter_by(local_user_id=post.user_id) - if not followers: - return - - note = { - 'type': 'Note', - 'id': post.ap_id, - 'inReplyTo': None, - 'attributedTo': current_user.public_url(), - 'to': [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'cc': [ - current_user.followers_url() - ], - 'content': '', - 'mediaType': 'text/html', - 'source': {'content': post.body if post.body else '', 'mediaType': 'text/markdown'}, - 'attachment': [], - 'commentsEnabled': post.comments_enabled, - 'sensitive': post.nsfw, - 'nsfl': post.nsfl, - 'stickied': post.sticky, - 'published': ap_datetime(utcnow()), - 'updated': ap_datetime(post.edited_at), - 'language': { - 'identifier': post.language_code(), - 'name': post.language_name() - }, - 'tag': post.tags_for_activitypub() - } - update = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", - "actor": current_user.public_url(), - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "cc": [ - current_user.followers_url() - ], - "type": "Update", - "object": note, - '@context': default_context() - } - if post.type == POST_TYPE_ARTICLE: - note['content'] = '

' + post.title + '

' - elif post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO: - note['content'] = '

' + post.title + '

' - elif post.type == POST_TYPE_IMAGE: - note['content'] = '

' + post.title + '

' - if post.image_id and post.image.source_url: - note['attachment'] = [{'type': 'Image', 'url': post.image.source_url, 'name': post.image.alt_text}] - elif post.type == POST_TYPE_POLL: - poll = Poll.query.filter_by(post_id=post.id).first() - note['type'] = 'Question' - note['endTime'] = ap_datetime(poll.end_poll) - note['votersCount'] = 0 - choices = [] - for choice in PollChoice.query.filter_by(post_id=post.id).all(): - choices.append({ - "type": "Note", - "name": choice.choice_text, - "replies": { - "type": "Collection", - "totalItems": 0 - } - }) - note['oneOf' if poll.mode == 'single' else 'anyOf'] = choices - - if post.body_html: - note['content'] = note['content'] + '

' + post.body_html + '

' - - instances = Instance.query.join(User, User.instance_id == Instance.id).join(UserFollower, UserFollower.remote_user_id == User.id) - instances = instances.filter(UserFollower.local_user_id == post.user_id) - for instance in instances: - if instance.inbox and not instance_banned(instance.domain): - post_request_in_background(instance.inbox, update, current_user.private_key, current_user.public_url() + '#main-key') - - @bp.route('/post//delete', methods=['GET', 'POST']) @login_required def post_delete(post_id: int):