diff --git a/app/community/forms.py b/app/community/forms.py index e843aa41..99bb0ffd 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -85,21 +85,23 @@ class BanUserCommunityForm(FlaskForm): submit = SubmitField(_l('Ban')) -class CreatePostForm(FlaskForm): +class CreateDiscussionForm(FlaskForm): communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) - post_type = HiddenField() # https://getbootstrap.com/docs/4.6/components/navs/#tabs - discussion_title = StringField(_l('Title'), validators=[Optional(), Length(min=3, max=255)]) - discussion_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'placeholder': 'Text (optional)'}) - link_title = StringField(_l('Title'), validators=[Optional(), Length(min=3, max=255)]) - link_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], - render_kw={'placeholder': 'Text (optional)'}) - link_url = StringField(_l('URL'), validators=[Optional(), Regexp(r'^https?://', message='Submitted links need to start with "http://"" or "https://"')], render_kw={'placeholder': 'https://...'}) - image_title = StringField(_l('Title'), validators=[Optional(), Length(min=3, max=255)]) - image_alt_text = StringField(_l('Alt text'), validators=[Optional(), Length(min=3, max=255)]) - image_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], - render_kw={'placeholder': 'Text (optional)'}) - image_file = FileField(_('Image')) - # flair = SelectField(_l('Flair'), coerce=int) + discussion_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) + discussion_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)]) + sticky = BooleanField(_l('Sticky')) + nsfw = BooleanField(_l('NSFW')) + nsfl = BooleanField(_l('Gore/gross')) + notify_author = BooleanField(_l('Notify about replies')) + submit = SubmitField(_l('Save')) + + +class CreateLinkForm(FlaskForm): + communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) + link_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) + link_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)]) + link_url = StringField(_l('URL'), validators=[DataRequired(), Regexp(r'^https?://', message='Submitted links need to start with "http://"" or "https://"')], + render_kw={'placeholder': 'https://...'}) sticky = BooleanField(_l('Sticky')) nsfw = BooleanField(_l('NSFW')) nsfl = BooleanField(_l('Gore/gross')) @@ -107,53 +109,45 @@ class CreatePostForm(FlaskForm): submit = SubmitField(_l('Save')) def validate(self, extra_validators=None) -> bool: - if not super().validate(): + domain = domain_from_url(self.link_url.data, create=False) + if domain and domain.banned: + self.link_url.errors.append(_("Links to %(domain)s are not allowed.", domain=domain.name)) return False - if self.post_type.data is None or self.post_type.data == '': - self.post_type.data = 'discussion' + return True - if self.post_type.data == 'discussion': - if self.discussion_title.data == '': - self.discussion_title.errors.append(_('Title is required.')) + +class CreateImageForm(FlaskForm): + communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) + image_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) + image_alt_text = StringField(_l('Alt text'), validators=[Optional(), Length(min=3, max=255)]) + image_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)]) + image_file = FileField(_('Image'), validators=[DataRequired()]) + sticky = BooleanField(_l('Sticky')) + nsfw = BooleanField(_l('NSFW')) + nsfl = BooleanField(_l('Gore/gross')) + notify_author = BooleanField(_l('Notify about replies')) + submit = SubmitField(_l('Save')) + + def validate(self, extra_validators=None) -> bool: + uploaded_file = request.files['image_file'] + if uploaded_file and uploaded_file.filename != '': + Image.MAX_IMAGE_PIXELS = 89478485 + # Do not allow fascist meme content + try: + image_text = pytesseract.image_to_string(Image.open(BytesIO(uploaded_file.read())).convert('L')) + except FileNotFoundError as e: + image_text = '' + if 'Anonymous' in image_text and ( + 'No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345' + self.image_file.errors.append( + f"This image is an invalid file type.") # deliberately misleading error message + current_user.reputation -= 1 + db.session.commit() return False - elif self.post_type.data == 'link': - if self.link_title.data == '': - self.link_title.errors.append(_('Title is required.')) - return False - if self.link_url.data == '': - self.link_url.errors.append(_('URL is required.')) - return False - domain = domain_from_url(self.link_url.data, create=False) - if domain and domain.banned: - self.link_url.errors.append(_("Links to %(domain)s are not allowed.", domain=domain.name)) - return False - elif self.post_type.data == 'image': - if self.image_title.data == '': - self.image_title.errors.append(_('Title is required.')) - return False - if self.image_file.data == '': - self.image_file.errors.append(_('File is required.')) - return False - uploaded_file = request.files['image_file'] - if uploaded_file and uploaded_file.filename != '': - Image.MAX_IMAGE_PIXELS = 89478485 - # Do not allow fascist meme content - try: - image_text = pytesseract.image_to_string(Image.open(BytesIO(uploaded_file.read())).convert('L')) - except FileNotFoundError as e: - image_text = '' - if 'Anonymous' in image_text and ('No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345' - self.image_file.errors.append(f"This image is an invalid file type.") # deliberately misleading error message - current_user.reputation -= 1 - db.session.commit() - return False - if self.communities: - community = Community.query.get(self.communities.data) - if community.is_local() and g.site.allow_local_image_posts is False: - self.communities.errors.append(_('Images cannot be posted to local communities.')) - elif self.post_type.data == 'poll': - self.discussion_title.errors.append(_('Poll not implemented yet.')) - return False + if self.communities: + community = Community.query.get(self.communities.data) + if community.is_local() and g.site.allow_local_image_posts is False: + self.communities.errors.append(_('Images cannot be posted to local communities.')) return True diff --git a/app/community/routes.py b/app/community/routes.py index de5e842b..cb96ebea 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -12,7 +12,7 @@ from app import db, constants, cache from app.activitypub.signature import RsaKeys, post_request from app.activitypub.util import default_context, notify_about_post, find_actor_or_create, make_image_sizes from app.chat.util import send_message -from app.community.forms import SearchRemoteCommunity, CreatePostForm, ReportCommunityForm, \ +from app.community.forms import SearchRemoteCommunity, CreateDiscussionForm, CreateImageForm, CreateLinkForm, ReportCommunityForm, \ DeleteCommunityForm, AddCommunityForm, EditCommunityForm, AddModeratorForm, BanUserCommunityForm, \ EscalateReportForm, ResolveReportForm from app.community.util import search_for_community, community_url_exists, actor_to_community, \ @@ -444,11 +444,13 @@ def join_then_add(actor): @bp.route('//submit', methods=['GET', 'POST']) @login_required @validation_required -def add_post(actor): +def add_discussion_post(actor): if current_user.banned: return show_ban_message() community = actor_to_community(actor) - form = CreatePostForm() + + form = CreateDiscussionForm() + if g.site.enable_nsfl is False: form.nsfl.render_kw = {'disabled': True} if community.nsfw: @@ -470,7 +472,63 @@ def add_post(actor): if not can_create_post(current_user, community): abort(401) post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) - save_post(form, post) + save_post(form, post, 'discussion') + community.post_count += 1 + community.last_active = g.site.last_active = utcnow() + db.session.commit() + post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}" + db.session.commit() + + notify_about_post(post) + + if not community.local_only: + federate_post(community, post) + + return redirect(f"/c/{community.link()}") + else: + form.communities.data = community.id + form.notify_author.data = True + + return render_template('community/add_discussion_post.html', title=_('Add post to community'), form=form, community=community, + markdown_editor=current_user.markdown_editor, low_bandwidth=False, actor=actor, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.id), + inoculation=inoculation[randint(0, len(inoculation) - 1)] + ) + + +@bp.route('//submit_image', methods=['GET', 'POST']) +@login_required +@validation_required +def add_image_post(actor): + if current_user.banned: + return show_ban_message() + community = actor_to_community(actor) + + form = CreateImageForm() + + if g.site.enable_nsfl is False: + form.nsfl.render_kw = {'disabled': True} + if community.nsfw: + form.nsfw.data = True + form.nsfw.render_kw = {'disabled': True} + if community.nsfl: + form.nsfl.data = True + form.nsfw.render_kw = {'disabled': True} + if not(community.is_moderator() or community.is_owner() or current_user.is_admin()): + form.sticky.render_kw = {'disabled': True} + + form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] + + 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): + abort(401) + post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) + save_post(form, post, 'image') community.post_count += 1 community.last_active = g.site.last_active = utcnow() db.session.commit() @@ -497,105 +555,183 @@ def add_post(actor): notify_about_post(post) if not community.local_only: - page = { - 'type': 'Page', - 'id': post.ap_id, - 'attributedTo': current_user.ap_profile_id, - 'to': [ - community.ap_profile_id, - '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.ap_profile_id - } - create = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", - "actor": current_user.ap_profile_id, - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "cc": [ - community.ap_profile_id - ], - "type": "Create", - "audience": community.ap_profile_id, - "object": page, - '@context': default_context() - } - if post.type == POST_TYPE_LINK: - page['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['image'] = {'type': 'Image', 'url': image_url} - if post.type == POST_TYPE_IMAGE: - page['attachment'] = [{'type': 'Link', 'href': post.image.source_url}] # source_url is always a https link, no need for .replace() as done above - if not community.is_local(): # this is a remote community - send the post to the instance that hosts it - success = post_request(community.ap_inbox_url, create, current_user.private_key, - current_user.ap_profile_id + '#main-key') - if success: - flash(_('Your post to %(name)s has been made.', name=community.title)) - else: - flash('There was a problem making your post to ' + 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.ap_profile_id, - "cc": [ - community.ap_followers_url - ], - '@context': default_context(), - 'object': create - } - - 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): - 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)) + federate_post(community, post) return redirect(f"/c/{community.link()}") else: - # when request.form has some data in it, it means form validation failed. Set the post_type so the correct tab is shown. See setupPostTypeTabs() in scripts.js - if request.form.get('post_type', None): - form.post_type.data = request.form.get('post_type') form.communities.data = community.id form.notify_author.data = True - return render_template('community/add_post.html', title=_('Add post to community'), form=form, community=community, - markdown_editor=current_user.markdown_editor, low_bandwidth=False, + return render_template('community/add_image_post.html', title=_('Add post to community'), form=form, community=community, + markdown_editor=current_user.markdown_editor, low_bandwidth=False, actor=actor, moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.id), inoculation=inoculation[randint(0, len(inoculation) - 1)] ) +@bp.route('//submit_link', methods=['GET', 'POST']) +@login_required +@validation_required +def add_link_post(actor): + if current_user.banned: + return show_ban_message() + community = actor_to_community(actor) + + form = CreateLinkForm() + + if g.site.enable_nsfl is False: + form.nsfl.render_kw = {'disabled': True} + if community.nsfw: + form.nsfw.data = True + form.nsfw.render_kw = {'disabled': True} + if community.nsfl: + form.nsfl.data = True + form.nsfw.render_kw = {'disabled': True} + if not(community.is_moderator() or community.is_owner() or current_user.is_admin()): + form.sticky.render_kw = {'disabled': True} + + form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] + + 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): + abort(401) + post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) + save_post(form, post, 'link') + community.post_count += 1 + community.last_active = g.site.last_active = utcnow() + db.session.commit() + post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}" + db.session.commit() + if post.image_id and post.image.file_path is None: + make_image_sizes(post.image_id, 150, 512, 'posts') # the 512 sized image is for masonry view + + # Update list of cross posts + if post.url: + other_posts = Post.query.filter(Post.id != post.id, Post.url == post.url, + Post.posted_at > post.posted_at - timedelta(days=6)).all() + for op in other_posts: + if op.cross_posts is None: + op.cross_posts = [post.id] + else: + op.cross_posts.append(post.id) + if post.cross_posts is None: + post.cross_posts = [op.id] + else: + post.cross_posts.append(op.id) + db.session.commit() + + notify_about_post(post) + + if not community.local_only: + federate_post(community, post) + + return redirect(f"/c/{community.link()}") + else: + form.communities.data = community.id + form.notify_author.data = True + + return render_template('community/add_link_post.html', title=_('Add post to community'), form=form, community=community, + markdown_editor=current_user.markdown_editor, low_bandwidth=False, actor=actor, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.id), + inoculation=inoculation[randint(0, len(inoculation) - 1)] + ) + + +def federate_post(community, post): + page = { + 'type': 'Page', + 'id': post.ap_id, + 'attributedTo': current_user.ap_profile_id, + 'to': [ + community.ap_profile_id, + '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.ap_profile_id + } + create = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", + "actor": current_user.ap_profile_id, + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + community.ap_profile_id + ], + "type": "Create", + "audience": community.ap_profile_id, + "object": page, + '@context': default_context() + } + if post.type == POST_TYPE_LINK: + page['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['image'] = {'type': 'Image', 'url': image_url} + if post.type == POST_TYPE_IMAGE: + page['attachment'] = [{'type': 'Link', + 'href': post.image.source_url}] # source_url is always a https link, no need for .replace() as done above + if not community.is_local(): # this is a remote community - send the post to the instance that hosts it + success = post_request(community.ap_inbox_url, create, current_user.private_key, + current_user.ap_profile_id + '#main-key') + if success: + flash(_('Your post to %(name)s has been made.', name=community.title)) + else: + flash('There was a problem making your post to ' + 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.ap_profile_id, + "cc": [ + community.ap_followers_url + ], + '@context': default_context(), + 'object': create + } + + 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): + 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)) + + @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 37875a42..c58dcad9 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -196,18 +196,18 @@ def url_to_thumbnail_file(filename) -> File: source_url=filename) -def save_post(form, post: Post): +def save_post(form, post: Post, type: str): 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 - if form.post_type.data == '' or form.post_type.data == 'discussion': + if type == '' or type == 'discussion': post.title = form.discussion_title.data post.body = form.discussion_body.data post.body_html = markdown_to_html(post.body) post.type = POST_TYPE_ARTICLE - elif form.post_type.data == 'link': + elif type == 'link': post.title = form.link_title.data post.body = form.link_body.data post.body_html = markdown_to_html(post.body) @@ -244,7 +244,7 @@ def save_post(form, post: Post): post.image = file db.session.add(file) - elif form.post_type.data == 'image': + elif type == 'image': post.title = form.image_title.data post.body = form.image_body.data post.body_html = markdown_to_html(post.body) @@ -304,7 +304,7 @@ def save_post(form, post: Post): post.image = file db.session.add(file) - elif form.post_type.data == 'poll': + elif type == 'poll': ... else: raise Exception('invalid post type') diff --git a/app/post/routes.py b/app/post/routes.py index 9aae27b9..9e1dd406 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -13,9 +13,9 @@ from app.activitypub.util import default_context from app.community.util import save_post, send_to_remote_instance from app.inoculation import inoculation from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm -from app.community.forms import CreatePostForm +from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm from app.post.util import post_replies, get_comment_branch, post_reply_count -from app.constants import SUBSCRIPTION_MEMBER, POST_TYPE_LINK, POST_TYPE_IMAGE +from app.constants import SUBSCRIPTION_MEMBER, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE from app.models import Post, PostReply, \ PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, \ Topic, User, Instance @@ -654,11 +654,82 @@ def post_reply_options(post_id: int, comment_id: int): ) -@bp.route('/post//edit', methods=['GET', 'POST']) +@bp.route('/post//edit', methods=['GET']) @login_required def post_edit(post_id: int): post = Post.query.get_or_404(post_id) - form = CreatePostForm() + if post.type == POST_TYPE_ARTICLE: + return redirect(url_for('post.post_edit_discussion_post', post_id=post_id)) + elif post.type == POST_TYPE_LINK: + return redirect(url_for('post.post_edit_link_post', post_id=post_id)) + elif post.type == POST_TYPE_IMAGE: + return redirect(url_for('post.post_edit_image_post', post_id=post_id)) + else: + abort(404) + + +@bp.route('/post//edit_discussion', methods=['GET', 'POST']) +@login_required +def post_edit_discussion_post(post_id: int): + post = Post.query.get_or_404(post_id) + form = CreateDiscussionForm() + del form.communities + + mods = post.community.moderators() + if post.community.private_mods: + mod_list = [] + else: + mod_user_ids = [mod.user_id for mod in mods] + mod_list = User.query.filter(User.id.in_(mod_user_ids)).all() + + if post.user_id == current_user.id or post.community.is_moderator() or current_user.is_admin(): + if g.site.enable_nsfl is False: + form.nsfl.render_kw = {'disabled': True} + if post.community.nsfw: + form.nsfw.data = True + form.nsfw.render_kw = {'disabled': True} + if post.community.nsfl: + form.nsfl.data = True + form.nsfw.render_kw = {'disabled': True} + + if form.validate_on_submit(): + save_post(form, post, 'discussion') + post.community.last_active = utcnow() + post.edited_at = utcnow() + db.session.commit() + + post.flush_cache() + flash(_('Your changes have been saved.'), 'success') + + # federate edit + if not post.community.local_only: + federate_post_update(post) + + return redirect(url_for('activitypub.post_ap', post_id=post.id)) + else: + form.discussion_title.data = post.title + form.discussion_body.data = post.body + form.notify_author.data = post.notify_author + form.nsfw.data = post.nsfw + form.nsfl.data = post.nsfl + form.sticky.data = post.sticky + if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()): + form.sticky.render_kw = {'disabled': True} + return render_template('post/post_edit_discussion.html', title=_('Edit post'), form=form, post=post, + markdown_editor=current_user.markdown_editor, mods=mod_list, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.get_id()), + inoculation=inoculation[randint(0, len(inoculation) - 1)] + ) + else: + abort(401) + + +@bp.route('/post//edit_image', methods=['GET', 'POST']) +@login_required +def post_edit_image_post(post_id: int): + post = Post.query.get_or_404(post_id) + form = CreateImageForm() del form.communities mods = post.community.moderators() @@ -678,11 +749,10 @@ def post_edit(post_id: int): form.nsfl.data = True form.nsfw.render_kw = {'disabled': True} - #form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] old_url = post.url if form.validate_on_submit(): - save_post(form, post) + save_post(form, post, 'image') post.community.last_active = utcnow() post.edited_at = utcnow() db.session.commit() @@ -714,104 +784,20 @@ def post_edit(post_id: int): # federate edit if not post.community.local_only: - page_json = { - 'type': 'Page', - 'id': post.ap_id, - 'attributedTo': current_user.ap_profile_id, - 'to': [ - post.community.ap_profile_id, - '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.ap_profile_id - } - update_json = { - 'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}", - 'type': 'Update', - 'actor': current_user.profile_id(), - 'audience': post.community.profile_id(), - 'to': [post.community.profile_id(), '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: - 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': 'Link', 'href': post.image.source_url}] # source_url is always a https link, no need for .replace() as done above - - 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.ap_profile_id + '#main-key') - if not success: - 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) + federate_post_update(post) return redirect(url_for('activitypub.post_ap', post_id=post.id)) else: - if post.type == constants.POST_TYPE_ARTICLE: - form.post_type.data = 'discussion' - form.discussion_title.data = post.title - form.discussion_body.data = post.body - elif post.type == constants.POST_TYPE_LINK: - form.post_type.data = 'link' - form.link_title.data = post.title - form.link_body.data = post.body - form.link_url.data = post.url - elif post.type == constants.POST_TYPE_IMAGE: - form.post_type.data = 'image' - form.image_title.data = post.title - form.image_body.data = post.body - form.image_alt_text.data = post.image.alt_text + form.image_title.data = post.title + form.image_body.data = post.body + form.image_alt_text.data = post.image.alt_text form.notify_author.data = post.notify_author form.nsfw.data = post.nsfw form.nsfl.data = post.nsfl form.sticky.data = post.sticky if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()): form.sticky.render_kw = {'disabled': True} - return render_template('post/post_edit.html', title=_('Edit post'), form=form, post=post, + return render_template('post/post_edit_image.html', title=_('Edit post'), form=form, post=post, markdown_editor=current_user.markdown_editor, mods=mod_list, moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()), @@ -821,6 +807,168 @@ def post_edit(post_id: int): abort(401) +@bp.route('/post//edit_link', methods=['GET', 'POST']) +@login_required +def post_edit_link_post(post_id: int): + post = Post.query.get_or_404(post_id) + form = CreateLinkForm() + del form.communities + + mods = post.community.moderators() + if post.community.private_mods: + mod_list = [] + else: + mod_user_ids = [mod.user_id for mod in mods] + mod_list = User.query.filter(User.id.in_(mod_user_ids)).all() + + if post.user_id == current_user.id or post.community.is_moderator() or current_user.is_admin(): + if g.site.enable_nsfl is False: + form.nsfl.render_kw = {'disabled': True} + if post.community.nsfw: + form.nsfw.data = True + form.nsfw.render_kw = {'disabled': True} + if post.community.nsfl: + form.nsfl.data = True + form.nsfw.render_kw = {'disabled': True} + + old_url = post.url + + if form.validate_on_submit(): + save_post(form, post, 'link') + post.community.last_active = utcnow() + post.edited_at = utcnow() + db.session.commit() + + if post.url != old_url: + if 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) + + new_cross_posts = Post.query.filter(Post.id != post.id, Post.url == post.url, + Post.posted_at > post.edited_at - timedelta(days=6)).all() + for ncp in new_cross_posts: + if ncp.cross_posts is None: + ncp.cross_posts = [post.id] + else: + ncp.cross_posts.append(post.id) + if post.cross_posts is None: + post.cross_posts = [ncp.id] + else: + post.cross_posts.append(ncp.id) + + db.session.commit() + + post.flush_cache() + flash(_('Your changes have been saved.'), 'success') + # federate edit + + if not post.community.local_only: + federate_post_update(post) + + return redirect(url_for('activitypub.post_ap', post_id=post.id)) + else: + form.link_title.data = post.title + form.link_body.data = post.body + form.link_url.data = post.url + form.notify_author.data = post.notify_author + form.nsfw.data = post.nsfw + form.nsfl.data = post.nsfl + form.sticky.data = post.sticky + if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()): + form.sticky.render_kw = {'disabled': True} + return render_template('post/post_edit_link.html', title=_('Edit post'), form=form, post=post, + markdown_editor=current_user.markdown_editor, mods=mod_list, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.get_id()), + inoculation=inoculation[randint(0, len(inoculation) - 1)] + ) + else: + abort(401) + + +def federate_post_update(post): + page_json = { + 'type': 'Page', + 'id': post.ap_id, + 'attributedTo': current_user.ap_profile_id, + 'to': [ + post.community.ap_profile_id, + '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.ap_profile_id + } + update_json = { + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}", + 'type': 'Update', + 'actor': current_user.profile_id(), + 'audience': post.community.profile_id(), + 'to': [post.community.profile_id(), '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: + 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': 'Link', + 'href': post.image.source_url}] # source_url is always a https link, no need for .replace() as done above + 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.ap_profile_id + '#main-key') + if not success: + 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) + + @bp.route('/post//delete', methods=['GET', 'POST']) @login_required def post_delete(post_id: int): diff --git a/app/static/structure.css b/app/static/structure.css index 5a631fc0..1b3a385b 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -501,6 +501,14 @@ fieldset legend { .form-group { margin-bottom: 1.1rem; } +.form-group.required label:after { + content: "*"; + color: red; + margin-left: 2px; + font-size: 80%; + position: relative; + top: -1px; +} .card { max-width: 350px; diff --git a/app/static/structure.scss b/app/static/structure.scss index 5c0951c6..2b2a047f 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -65,6 +65,19 @@ html { .form-group { margin-bottom: 1.1rem; + + &.required { + label { + &:after { + content: '*'; + color: red; + margin-left: 2px; + font-size: 80%; + position: relative; + top: -1px; + } + } + } } .card { diff --git a/app/templates/community/add_discussion_post.html b/app/templates/community/add_discussion_post.html new file mode 100644 index 00000000..2a352622 --- /dev/null +++ b/app/templates/community/add_discussion_post.html @@ -0,0 +1,95 @@ +{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %} + {% extends 'themes/' + theme() + '/base.html' %} +{% else %} + {% extends "base.html" %} +{% endif %} %} +{% from 'bootstrap/form.html' import render_field %} + +{% block app_content %} +
+
+

{{ _('Create post') }}

+
+ {{ form.csrf_token() }} +
+ + +
+ + {{ render_field(form.communities) }} + {{ render_field(form.discussion_title) }} + {{ render_field(form.discussion_body) }} + {% if not low_bandwidth %} + {% if markdown_editor %} + + {% else %} + + {% endif %} + {% endif %} + +
+
+ {{ render_field(form.notify_author) }} +
+
+ {{ render_field(form.sticky) }} +
+
+ {{ render_field(form.nsfw) }} +
+
+ {{ render_field(form.nsfl) }} +
+
+ +
+
+ + {{ render_field(form.submit) }} +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/community/add_image_post.html b/app/templates/community/add_image_post.html new file mode 100644 index 00000000..8ea4da26 --- /dev/null +++ b/app/templates/community/add_image_post.html @@ -0,0 +1,97 @@ +{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %} + {% extends 'themes/' + theme() + '/base.html' %} +{% else %} + {% extends "base.html" %} +{% endif %} %} +{% from 'bootstrap/form.html' import render_field %} + +{% block app_content %} +
+
+

{{ _('Create post') }}

+
+ {{ form.csrf_token() }} +
+ + +
+ {{ render_field(form.communities) }} + {{ render_field(form.image_title) }} + {{ render_field(form.image_file) }} + {{ render_field(form.image_alt_text) }} + {{ _('Describe the image, to help visually impaired people.') }} + {{ render_field(form.image_body) }} + {% if not low_bandwidth %} + {% if markdown_editor %} + + {% else %} + + {% endif %} + {% endif %} + +
+
+ {{ render_field(form.notify_author) }} +
+
+ {{ render_field(form.sticky) }} +
+
+ {{ render_field(form.nsfw) }} +
+
+ {{ render_field(form.nsfl) }} +
+
+ +
+
+ + {{ render_field(form.submit) }} +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/community/add_link_post.html b/app/templates/community/add_link_post.html new file mode 100644 index 00000000..e59c82df --- /dev/null +++ b/app/templates/community/add_link_post.html @@ -0,0 +1,96 @@ +{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %} + {% extends 'themes/' + theme() + '/base.html' %} +{% else %} + {% extends "base.html" %} +{% endif %} %} +{% from 'bootstrap/form.html' import render_field %} + +{% block app_content %} +
+
+

{{ _('Create post') }}

+
+ {{ form.csrf_token() }} +
+ + +
+ {{ render_field(form.communities) }} + + {{ render_field(form.link_title) }} + {{ render_field(form.link_url) }} + {{ render_field(form.link_body) }} + {% if not low_bandwidth %} + {% if markdown_editor %} + + {% else %} + + {% endif %} + {% endif %} + +
+
+ {{ render_field(form.notify_author) }} +
+
+ {{ render_field(form.sticky) }} +
+
+ {{ render_field(form.nsfw) }} +
+
+ {{ render_field(form.nsfl) }} +
+
+ +
+
+ + {{ render_field(form.submit) }} +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/community/add_post.html b/app/templates/community/add_post.html deleted file mode 100644 index f957d17a..00000000 --- a/app/templates/community/add_post.html +++ /dev/null @@ -1,145 +0,0 @@ -{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %} - {% extends 'themes/' + theme() + '/base.html' %} -{% else %} - {% extends "base.html" %} -{% endif %} %} -{% from 'bootstrap/form.html' import render_field %} - -{% block app_content %} -
-
-

{{ _('Create post') }}

-
- {{ form.csrf_token() }} - {{ render_field(form.communities) }} - -
-
- {{ render_field(form.discussion_title) }} - {{ render_field(form.discussion_body) }} - {% if not low_bandwidth %} - {% if markdown_editor %} - - {% else %} - - {% endif %} - {% endif %} -
- -
- {{ render_field(form.image_title) }} - {{ render_field(form.image_file) }} - {{ render_field(form.image_alt_text) }} - {{ _('Describe the image, to help visually impaired people.') }} - {{ render_field(form.image_body) }} - {% if not low_bandwidth %} - {% if markdown_editor %} - - {% else %} - - {% endif %} - {% endif %} -
-
- Poll -
-
- {{ render_field(form.post_type) }} -
-
- {{ render_field(form.notify_author) }} -
-
- {{ render_field(form.sticky) }} -
-
- {{ render_field(form.nsfw) }} -
-
- {{ render_field(form.nsfl) }} -
-
- -
-
- - {{ render_field(form.submit) }} -
-
- - -
-{% endblock %} \ No newline at end of file diff --git a/app/templates/post/post_edit.html b/app/templates/post/post_edit.html deleted file mode 100644 index 515993de..00000000 --- a/app/templates/post/post_edit.html +++ /dev/null @@ -1,164 +0,0 @@ -{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %} - {% extends 'themes/' + theme() + '/base.html' %} -{% else %} - {% extends "base.html" %} -{% endif %} %} -{% from 'bootstrap/form.html' import render_form, render_field %} - -{% block app_content %} - -
-
-

{{ _('Edit post') }}

-
- {{ form.csrf_token() }} - -
-
- {{ render_field(form.discussion_title) }} - {{ render_field(form.discussion_body) }} - {% if markdown_editor %} - - {% endif %} -
- -
- {{ render_field(form.image_title) }} - {{ render_field(form.image_file) }} - {{ render_field(form.image_alt_text) }} - {{ _('Describe the image, to help visually impaired people.') }} - {{ render_field(form.image_body) }} - {% if markdown_editor %} - - {% endif %} -
-
- Poll -
-
- {{ render_field(form.post_type) }} -
-
- {{ render_field(form.notify_author) }} -
-
- {{ render_field(form.sticky) }} -
-
- {{ render_field(form.nsfw) }} -
-
- {{ render_field(form.nsfl) }} -
- -
- -
-
- - {{ render_field(form.submit) }} -
-
- - - -
-{% endblock %} \ No newline at end of file diff --git a/app/templates/post/post_edit_discussion.html b/app/templates/post/post_edit_discussion.html new file mode 100644 index 00000000..d8cba771 --- /dev/null +++ b/app/templates/post/post_edit_discussion.html @@ -0,0 +1,75 @@ +{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %} + {% extends 'themes/' + theme() + '/base.html' %} +{% else %} + {% extends "base.html" %} +{% endif %} %} +{% from 'bootstrap/form.html' import render_form, render_field %} + +{% block app_content %} +
+
+

{{ _('Edit post') }}

+
+ {{ form.csrf_token() }} + {{ render_field(form.discussion_title) }} + {{ render_field(form.discussion_body) }} + {% if markdown_editor %} + + {% endif %} + +
+
+ {{ render_field(form.notify_author) }} +
+
+ {{ render_field(form.sticky) }} +
+
+ {{ render_field(form.nsfw) }} +
+
+ {{ render_field(form.nsfl) }} +
+ +
+ +
+
+ + {{ render_field(form.submit) }} +
+
+ + + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/post/post_edit_image.html b/app/templates/post/post_edit_image.html new file mode 100644 index 00000000..db0a9d9f --- /dev/null +++ b/app/templates/post/post_edit_image.html @@ -0,0 +1,78 @@ +{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %} + {% extends 'themes/' + theme() + '/base.html' %} +{% else %} + {% extends "base.html" %} +{% endif %} %} +{% from 'bootstrap/form.html' import render_form, render_field %} + +{% block app_content %} +
+
+

{{ _('Edit post') }}

+
+ {{ form.csrf_token() }} + {{ render_field(form.image_title) }} + {{ render_field(form.image_file) }} + {{ render_field(form.image_alt_text) }} + {{ _('Describe the image, to help visually impaired people.') }} + {{ render_field(form.image_body) }} + {% if markdown_editor %} + + {% endif %} + +
+
+ {{ render_field(form.notify_author) }} +
+
+ {{ render_field(form.sticky) }} +
+
+ {{ render_field(form.nsfw) }} +
+
+ {{ render_field(form.nsfl) }} +
+ +
+ +
+
+ + {{ render_field(form.submit) }} +
+
+ + + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/post/post_edit_link.html b/app/templates/post/post_edit_link.html new file mode 100644 index 00000000..82fa2781 --- /dev/null +++ b/app/templates/post/post_edit_link.html @@ -0,0 +1,75 @@ +{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %} + {% extends 'themes/' + theme() + '/base.html' %} +{% else %} + {% extends "base.html" %} +{% endif %} %} +{% from 'bootstrap/form.html' import render_form, render_field %} + +{% block app_content %} +
+
+

{{ _('Edit post') }}

+
+ {{ form.csrf_token() }} + {{ render_field(form.link_title) }} + {{ render_field(form.link_url) }} + {{ render_field(form.link_body) }} + {% if markdown_editor %} + + {% endif %} +
+
+ {{ render_field(form.notify_author) }} +
+
+ {{ render_field(form.sticky) }} +
+
+ {{ render_field(form.nsfw) }} +
+
+ {{ render_field(form.nsfl) }} +
+ +
+ +
+
+ + {{ render_field(form.submit) }} +
+
+ + + +
+{% endblock %} \ No newline at end of file