diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 6bd0a8cf..4d6aaa42 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -18,9 +18,9 @@ from app.activitypub.util import public_key, users_total, active_half_year, acti lemmy_site_data, instance_weight, is_activitypub_request, downvote_post_reply, downvote_post, upvote_post_reply, \ upvote_post, activity_already_ingested, delete_post_or_comment, community_members, \ user_removed_from_remote_server, create_post, create_post_reply, update_post_reply_from_activity, \ - update_post_from_activity, undo_vote, undo_downvote -from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \ - domain_from_url, markdown_to_html, community_membership, ap_datetime, markdown_to_text, ip_address, can_downvote, \ + update_post_from_activity, undo_vote, undo_downvote, post_to_page +from app.utils import gibberish, get_setting, is_image_url, allowlist_html, render_template, \ + domain_from_url, markdown_to_html, 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 import werkzeug.exceptions @@ -175,12 +175,12 @@ def user_profile(actor): actor = actor.strip() if current_user.is_authenticated and current_user.is_admin(): if '@' in actor: - user: User = User.query.filter_by(ap_id=actor).first() + user: User = User.query.filter_by(ap_id=actor.lower()).first() else: user: User = User.query.filter_by(user_name=actor, ap_id=None).first() else: if '@' in actor: - user: User = User.query.filter_by(ap_id=actor, deleted=False, banned=False).first() + user: User = User.query.filter_by(ap_id=actor.lower(), deleted=False, banned=False).first() else: user: User = User.query.filter_by(user_name=actor, deleted=False, ap_id=None).first() @@ -419,6 +419,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): body_html=allowlist_html(markdown_to_html(request_json['object']['source']['content'])), encrypted=encrypted) db.session.add(new_message) + existing_conversation.updated_at = utcnow() db.session.commit() # Notify recipient @@ -432,20 +433,43 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): activity_log.result = 'success' else: try: - community_ap_id = request_json['to'][0] - if community_ap_id == 'https://www.w3.org/ns/activitystreams#Public': # kbin does this when posting a reply - if 'to' in request_json['object'] and request_json['object']['to']: - community_ap_id = request_json['object']['to'][0] - if community_ap_id == 'https://www.w3.org/ns/activitystreams#Public' and 'cc' in \ - request_json['object'] and request_json['object']['cc']: - community_ap_id = request_json['object']['cc'][0] - elif 'cc' in request_json['object'] and request_json['object']['cc']: - community_ap_id = request_json['object']['cc'][0] - if community_ap_id.endswith('/followers'): # mastodon - if 'inReplyTo' in request_json['object']: - post_being_replied_to = Post.query.filter_by(ap_id=request_json['object']['inReplyTo']).first() - if post_being_replied_to: - community_ap_id = post_being_replied_to.community.ap_profile_id + community_ap_id = '' + locations = ['audience', 'cc', 'to'] + if 'object' in request_json: + rjs = [ request_json, request_json['object'] ] + else: + rjs = [ request_json ] + local_community_prefix = f"https://{current_app.config['SERVER_NAME']}/c/" + followers_suffix = '/followers' + for rj in rjs: + for loc in locations: + if loc in rj: + id = rj[loc] + if isinstance(id, str): + if id.startswith(local_community_prefix) and not id.endswith(followers_suffix): + community_ap_id = id + if isinstance(id, list): + for c in id: + if c.startswith(local_community_prefix) and not c.endswith(followers_suffix): + community_ap_id = c + break + if community_ap_id: + break + if community_ap_id: + break + if not community_ap_id and 'object' in request_json and 'inReplyTo' in request_json['object']: + post_being_replied_to = Post.query.filter_by(ap_id=request_json['object']['inReplyTo']).first() + if post_being_replied_to: + community_ap_id = 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: + community_ap_id = comment_being_replied_to.community.ap_profile_id + if not community_ap_id: + activity_log.result = 'failure' + activity_log.exception_message = 'Unable to extract community' + db.session.commit() + return except: activity_log.activity_type = 'exception' db.session.commit() @@ -1029,6 +1053,27 @@ def community_outbox(actor): return jsonify(community_data) +@bp.route('/c//featured', methods=['GET']) +def community_featured(actor): + actor = actor.strip() + community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() + if community is not None: + posts = Post.query.filter_by(community_id=community.id, sticky=True).all() + + community_data = { + "@context": default_context(), + "type": "OrderedCollection", + "id": f"https://{current_app.config['SERVER_NAME']}/c/{actor}/featured", + "totalItems": len(posts), + "orderedItems": [] + } + + for post in posts: + community_data['orderedItems'].append(post_to_page(post, community)) + + return jsonify(community_data) + + @bp.route('/c//moderators', methods=['GET']) def community_moderators_route(actor): actor = actor.strip() @@ -1069,7 +1114,7 @@ def community_followers(actor): if community is not None: result = { "@context": default_context(), - "id": f'https://{current_app.config["SERVER_NAME"]}/c/actor/followers', + "id": f'https://{current_app.config["SERVER_NAME"]}/c/{actor}/followers', "type": "Collection", "totalItems": community_members(community.id), "items": [] diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 3ea9040c..7bee1724 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -22,10 +22,10 @@ from PIL import Image, ImageOps from io import BytesIO import pytesseract -from app.utils import get_request, allowlist_html, html_to_markdown, get_setting, ap_datetime, markdown_to_html, \ +from app.utils import get_request, allowlist_html, get_setting, ap_datetime, markdown_to_html, \ is_image_url, domain_from_url, gibberish, ensure_directory_exists, markdown_to_text, head_request, post_ranking, \ shorten_string, reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, remove_tracking_from_link, \ - blocked_phrases + blocked_phrases, microblog_content_to_title def public_key(): @@ -178,6 +178,43 @@ def post_to_activity(post: Post, community: Community): return activity_data +def post_to_page(post: Post, community: Community): + activity_data = { + "type": "Page", + "id": post.ap_id, + "attributedTo": post.author.ap_public_url, + "to": [ + f"https://{current_app.config['SERVER_NAME']}/c/{community.name}", + "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 or post.nsfl, + "published": ap_datetime(post.created_at), + "stickied": post.sticky, + "audience": f"https://{current_app.config['SERVER_NAME']}/c/{community.name}" + } + if post.edited_at is not None: + activity_data["updated"] = ap_datetime(post.edited_at) + if post.language is not None: + activity_data["language"] = {"identifier": post.language} + if post.type == POST_TYPE_LINK and post.url is not None: + activity_data["attachment"] = [{"href": post.url, "type": "Link"}] + if post.image_id is not None: + activity_data["image"] = {"url": post.image.view_url(), "type": "Image"} + if post.image.alt_text: + activity_data["image"]['altText'] = post.image.alt_text + return activity_data + + def banned_user_agents(): return [] # todo: finish this function @@ -227,6 +264,11 @@ def find_actor_or_create(actor: str, create_if_not_found=True, community_only=Fa return None if user is None: user = Community.query.filter(Community.ap_profile_id == actor).first() + if user and user.banned: + # Try to find a non-banned copy of the community. Sometimes duplicates happen and one copy is banned. + user = Community.query.filter(Community.ap_profile_id == actor).filter(Community.banned == False).first() + if user is None: # no un-banned version of this community exists, only the banned one. So it was banned for being bad, not for being a duplicate. + return None if user is not None: if not user.is_local() and (user.ap_fetched_at is None or user.ap_fetched_at < utcnow() - timedelta(days=7)): @@ -429,7 +471,7 @@ def refresh_community_profile_task(community_id): community.description_html = markdown_to_html(community.description) elif 'content' in activity_json: community.description_html = allowlist_html(activity_json['content']) - community.description = html_to_markdown(community.description_html) + community.description = '' icon_changed = cover_changed = False if 'icon' in activity_json: @@ -564,7 +606,7 @@ def actor_json_to_model(activity_json, address, server): ap_followers_url=activity_json['followers'], ap_inbox_url=activity_json['endpoints']['sharedInbox'], ap_outbox_url=activity_json['outbox'], - ap_featured_url=activity_json['featured'], + ap_featured_url=activity_json['featured'] if 'featured' in activity_json else '', ap_moderators_url=mods_url, ap_fetched_at=utcnow(), ap_domain=server, @@ -580,7 +622,7 @@ def actor_json_to_model(activity_json, address, server): community.description_html = markdown_to_html(community.description) elif 'content' in activity_json: community.description_html = allowlist_html(activity_json['content']) - community.description = html_to_markdown(community.description_html) + community.description = '' if 'icon' in activity_json: icon = File(source_url=activity_json['icon']['url']) community.icon = icon @@ -620,7 +662,7 @@ def post_json_to_model(activity_log, post_json, user, community) -> Post: post.body_html = markdown_to_html(post.body) elif 'content' in post_json: post.body_html = allowlist_html(post_json['content']) - post.body = html_to_markdown(post.body_html) + post.body = '' if 'attachment' in post_json and len(post_json['attachment']) > 0 and 'type' in post_json['attachment'][0]: if post_json['attachment'][0]['type'] == 'Link': post.url = post_json['attachment'][0]['href'] @@ -768,7 +810,7 @@ def parse_summary(user_json) -> str: if 'source' in user_json and user_json['source'].get('mediaType') == 'text/markdown': # Convert Markdown to HTML markdown_text = user_json['source']['content'] - html_content = html_to_markdown(markdown_text) + html_content = allowlist_html(markdown_to_html(markdown_text)) return html_content elif 'summary' in user_json: return allowlist_html(user_json['summary']) @@ -1179,7 +1221,7 @@ def create_post_reply(activity_log: ActivityPubLog, community: Community, in_rep post_reply.body_html = markdown_to_html(post_reply.body) elif 'content' in request_json['object']: # Kbin post_reply.body_html = allowlist_html(request_json['object']['content']) - post_reply.body = html_to_markdown(post_reply.body_html) + post_reply.body = '' if post_id is not None: # Discard post_reply if it contains certain phrases. Good for stopping spam floods. if post_reply.body: @@ -1253,11 +1295,17 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json activity_log.exception_message = 'Community is local only, post discarded' activity_log.result = 'ignored' return None - if 'name' not in request_json['object']: # Microblog posts sometimes get Announced by lemmy. They don't have a title, so we can't use them. - return None - nsfl_in_title = '[NSFL]' in request_json['object']['name'].upper() or '(NSFL)' in request_json['object']['name'].upper() + if 'name' not in request_json['object']: # Microblog posts + if 'content' in request_json['object'] and request_json['object']['content'] is not None: + name = "[Microblog]" + else: + return None + else: + name = request_json['object']['name'] + + nsfl_in_title = '[NSFL]' in name.upper() or '(NSFL)' in name.upper() post = Post(user_id=user.id, community_id=community.id, - title=html.unescape(request_json['object']['name']), + title=html.unescape(name), comments_enabled=request_json['object']['commentsEnabled'] if 'commentsEnabled' in request_json['object'] else True, sticky=request_json['object']['stickied'] if 'stickied' in request_json['object'] else False, nsfw=request_json['object']['sensitive'] if 'sensitive' in request_json['object'] else False, @@ -1278,7 +1326,12 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json post.body_html = markdown_to_html(post.body) elif 'content' in request_json['object'] and request_json['object']['content'] is not None: # Kbin post.body_html = allowlist_html(request_json['object']['content']) - post.body = html_to_markdown(post.body_html) + post.body = '' + if name == "[Microblog]": + name += ' ' + microblog_content_to_title(post.body_html) + if '[NSFL]' in name.upper() or '(NSFL)' in name.upper(): + post.nsfl = True + post.title = name # Discard post if it contains certain phrases. Good for stopping spam floods. blocked_phrases_list = blocked_phrases() for blocked_phrase in blocked_phrases_list: @@ -1291,7 +1344,10 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json if 'attachment' in request_json['object'] and len(request_json['object']['attachment']) > 0 and \ 'type' in request_json['object']['attachment'][0]: if request_json['object']['attachment'][0]['type'] == 'Link': - post.url = request_json['object']['attachment'][0]['href'] + post.url = request_json['object']['attachment'][0]['href'] # Lemmy + if request_json['object']['attachment'][0]['type'] == 'Document': + post.url = request_json['object']['attachment'][0]['url'] # Mastodon + if post.url: if is_image_url(post.url): post.type = POST_TYPE_IMAGE if 'image' in request_json['object'] and 'url' in request_json['object']['image']: @@ -1370,7 +1426,7 @@ def update_post_reply_from_activity(reply: PostReply, request_json: dict): reply.body_html = markdown_to_html(reply.body) elif 'content' in request_json['object']: reply.body_html = allowlist_html(request_json['object']['content']) - reply.body = html_to_markdown(reply.body_html) + reply.body = '' reply.edited_at = utcnow() db.session.commit() @@ -1384,7 +1440,7 @@ def update_post_from_activity(post: Post, request_json: dict): post.body_html = markdown_to_html(post.body) elif 'content' in request_json['object']: post.body_html = allowlist_html(request_json['object']['content']) - post.body = html_to_markdown(post.body_html) + post.body = '' if 'attachment' in request_json['object'] and 'href' in request_json['object']['attachment']: post.url = request_json['object']['attachment']['href'] if 'sensitive' in request_json['object']: diff --git a/app/admin/routes.py b/app/admin/routes.py index 2ecc2c54..8075676e 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -4,7 +4,7 @@ from time import sleep from flask import request, flash, json, url_for, current_app, redirect, g from flask_login import login_required, current_user from flask_babel import _ -from sqlalchemy import text, desc +from sqlalchemy import text, desc, or_ from app import db, celery, cache from app.activitypub.routes import process_inbox_request, process_delete_request @@ -15,6 +15,7 @@ from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm, EditC from app.admin.util import unsubscribe_from_everything_then_delete, unsubscribe_from_community, send_newsletter, \ topic_tree, topics_for_form from app.community.util import save_icon_file, save_banner_file +from app.constants import REPORT_STATE_NEW, REPORT_STATE_ESCALATED from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, \ User, Instance, File, Report, Topic, UserRegistration, Role, Post from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html, \ @@ -627,7 +628,7 @@ def admin_users_add(): private_key, public_key = RsaKeys.generate_keypair() user.private_key = private_key user.public_key = public_key - user.ap_profile_id = f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}" + user.ap_profile_id = f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}".lower() user.ap_public_url = f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}" user.ap_inbox_url = f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}/inbox" user.roles.append(Role.query.get(form.role.data)) @@ -674,7 +675,7 @@ def admin_reports(): search = request.args.get('search', '') local_remote = request.args.get('local_remote', '') - reports = Report.query.filter_by(status=0) + reports = Report.query.filter(or_(Report.status == REPORT_STATE_NEW, Report.status == REPORT_STATE_ESCALATED)) if local_remote == 'local': reports = reports.filter_by(ap_id=None) if local_remote == 'remote': diff --git a/app/auth/routes.py b/app/auth/routes.py index 4117c02c..6a3a06ce 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -99,7 +99,7 @@ def register(): flash(_('Sorry, you cannot use that user name'), 'error') else: for referrer in blocked_referrers(): - if referrer in session.get('Referer'): + if referrer in session.get('Referer', ''): resp = make_response(redirect(url_for('auth.please_wait'))) resp.set_cookie('sesion', '17489047567495', expires=datetime(year=2099, month=12, day=30)) return resp @@ -112,7 +112,7 @@ def register(): user = User(user_name=form.user_name.data, title=form.user_name.data, email=form.real_email.data, verification_token=verification_token, instance_id=1, ip_address=ip_address(), banned=user_ip_banned() or user_cookie_banned(), email_unread_sent=False, - referrer=session.get('Referer')) + referrer=session.get('Referer', '')) user.set_password(form.password.data) db.session.add(user) db.session.commit() diff --git a/app/chat/routes.py b/app/chat/routes.py index f859c236..f5185b7a 100644 --- a/app/chat/routes.py +++ b/app/chat/routes.py @@ -145,7 +145,7 @@ def chat_report(conversation_id): if form.validate_on_submit(): report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, - type=4, reporter_id=current_user.id, suspect_conversation_id=conversation_id) + type=4, reporter_id=current_user.id, suspect_conversation_id=conversation_id, source_instance_id=1) db.session.add(report) # Notify site admin diff --git a/app/chat/util.py b/app/chat/util.py index ee78e4a6..8b149641 100644 --- a/app/chat/util.py +++ b/app/chat/util.py @@ -13,6 +13,7 @@ def send_message(message: str, conversation_id: int) -> ChatMessage: conversation = Conversation.query.get(conversation_id) reply = ChatMessage(sender_id=current_user.id, conversation_id=conversation.id, body=message, body_html=allowlist_html(markdown_to_html(message))) + conversation.updated_at = utcnow() for recipient in conversation.members: if recipient.id != current_user.id: if recipient.is_local(): diff --git a/app/community/forms.py b/app/community/forms.py index ca80ba6c..e843aa41 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -3,7 +3,7 @@ from flask_login import current_user from flask_wtf import FlaskForm from wtforms import StringField, SubmitField, TextAreaField, BooleanField, HiddenField, SelectField, FileField, \ DateField -from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional +from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Regexp, Optional from flask_babel import _, lazy_gettext as _l from app import db @@ -61,6 +61,17 @@ class AddModeratorForm(FlaskForm): submit = SubmitField(_l('Add')) +class EscalateReportForm(FlaskForm): + reason = StringField(_l('Amend the report description if necessary'), validators=[DataRequired()]) + submit = SubmitField(_l('Escalate report')) + + +class ResolveReportForm(FlaskForm): + note = StringField(_l('Note for mod log'), validators=[Optional()]) + also_resolve_others = BooleanField(_l('Also resolve all other reports about the same thing.'), default=True) + submit = SubmitField(_l('Resolve report')) + + class SearchRemoteCommunity(FlaskForm): address = StringField(_l('Community address'), render_kw={'placeholder': 'e.g. !name@server', 'autofocus': True}, validators=[DataRequired()]) submit = SubmitField(_l('Search')) @@ -77,15 +88,15 @@ class BanUserCommunityForm(FlaskForm): class CreatePostForm(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)}, + 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'), 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)}, + 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) diff --git a/app/community/routes.py b/app/community/routes.py index 37049fcb..6c32031b 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -5,19 +5,20 @@ from random import randint from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort, g, json from flask_login import current_user, login_required from flask_babel import _ -from sqlalchemy import or_, desc +from sqlalchemy import or_, desc, text 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, \ - DeleteCommunityForm, AddCommunityForm, EditCommunityForm, AddModeratorForm, BanUserCommunityForm + DeleteCommunityForm, AddCommunityForm, EditCommunityForm, AddModeratorForm, BanUserCommunityForm, \ + EscalateReportForm, ResolveReportForm from app.community.util import search_for_community, community_url_exists, actor_to_community, \ opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance, \ delete_post_from_community, delete_post_reply_from_community from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \ - SUBSCRIPTION_PENDING, SUBSCRIPTION_MODERATOR + SUBSCRIPTION_PENDING, SUBSCRIPTION_MODERATOR, REPORT_STATE_NEW, REPORT_STATE_ESCALATED, REPORT_STATE_RESOLVED from app.inoculation import inoculation from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ File, PostVote, utcnow, Report, Notification, InstanceBlock, ActivityPubLog, Topic, Conversation, PostReply @@ -49,7 +50,8 @@ def add_local(): rules=form.rules.data, nsfw=form.nsfw.data, private_key=private_key, public_key=public_key, description_html=markdown_to_html(form.description.data), rules_html=markdown_to_html(form.rules.data), local_only=form.local_only.data, - ap_profile_id='https://' + current_app.config['SERVER_NAME'] + '/c/' + form.url.data, + ap_profile_id='https://' + current_app.config['SERVER_NAME'] + '/c/' + form.url.data.lower(), + ap_public_url='https://' + current_app.config['SERVER_NAME'] + '/c/' + form.url.data, ap_followers_url='https://' + current_app.config['SERVER_NAME'] + '/c/' + form.url.data + '/followers', ap_domain=current_app.config['SERVER_NAME'], subscriptions_count=1, instance_id=1, low_quality='memes' in form.url.data) @@ -102,9 +104,9 @@ def add_remote(): flash(_('Community not found.'), 'warning') else: flash(_('Community not found. If you are searching for a nsfw community it is blocked by this instance.'), 'warning') - - if new_community.banned: - flash(_('That community is banned from %(site)s.', site=g.site.name), 'warning') + else: + if new_community.banned: + flash(_('That community is banned from %(site)s.', site=g.site.name), 'warning') return render_template('community/add_remote.html', title=_('Add remote community'), form=form, new_community=new_community, @@ -577,7 +579,7 @@ def community_report(community_id: int): form = ReportCommunityForm() if form.validate_on_submit(): report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, - type=1, reporter_id=current_user.id, suspect_community_id=community.id) + type=1, reporter_id=current_user.id, suspect_community_id=community.id, source_instance_id=1) db.session.add(report) # Notify admin @@ -638,7 +640,8 @@ def community_edit(community_id: int): community.image = file db.session.commit() - community.topic.num_communities = community.topic.communities.count() + if community.topic: + community.topic.num_communities = community.topic.communities.count() db.session.commit() flash(_('Saved')) return redirect(url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name)) @@ -653,7 +656,7 @@ def community_edit(community_id: int): form.topic.data = community.topic_id if community.topic_id else None form.default_layout.data = community.default_layout return render_template('community/community_edit.html', title=_('Edit community'), form=form, - current_app=current_app, + current_app=current_app, current="edit_settings", community=community, moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id())) else: @@ -694,7 +697,7 @@ def community_mod_list(community_id: int): filter(CommunityMember.community_id == community_id, or_(CommunityMember.is_moderator == True, CommunityMember.is_owner == True)).all() return render_template('community/community_mod_list.html', title=_('Moderators for %(community)s', community=community.display_name()), - moderators=moderators, community=community, + moderators=moderators, community=community, current="moderators", moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()) ) @@ -923,13 +926,13 @@ def community_moderate(actor): reports = Report.query.filter_by(status=0, in_community_id=community.id) if local_remote == 'local': - reports = reports.filter_by(ap_id=None) + reports = reports.filter(Report.source_instance_id == 1) if local_remote == 'remote': - reports = reports.filter(Report.ap_id != None) - reports = reports.order_by(desc(Report.created_at)).paginate(page=page, per_page=1000, error_out=False) + reports = reports.filter(Report.source_instance_id != 1) + reports = reports.filter(Report.status >= 0).order_by(desc(Report.created_at)).paginate(page=page, per_page=1000, error_out=False) - next_url = url_for('admin.admin_reports', page=reports.next_num) if reports.has_next else None - prev_url = url_for('admin.admin_reports', page=reports.prev_num) if reports.has_prev and page != 1 else None + next_url = url_for('community.community_moderate', page=reports.next_num) if reports.has_next else None + prev_url = url_for('community.community_moderate', page=reports.prev_num) if reports.has_prev and page != 1 else None return render_template('community/community_moderate.html', title=_('Moderation of %(community)s', community=community.display_name()), community=community, reports=reports, current='reports', @@ -962,4 +965,61 @@ def community_moderate_banned(actor): else: abort(401) else: - abort(404) \ No newline at end of file + abort(404) + + +@bp.route('/community//moderate_report//escalate', methods=['GET', 'POST']) +@login_required +def community_moderate_report_escalate(community_id, report_id): + community = Community.query.get_or_404(community_id) + if community.is_moderator() or current_user.is_admin(): + report = Report.query.filter_by(in_community_id=community.id, id=report_id, status=REPORT_STATE_NEW).first() + if report: + form = EscalateReportForm() + if form.validate_on_submit(): + notify = Notification(title='Escalated report', url='/admin/reports', user_id=1, + author_id=current_user.id) + db.session.add(notify) + report.description = form.reason.data + report.status = REPORT_STATE_ESCALATED + db.session.commit() + flash(_('Admin has been notified about this report.')) + # todo: remove unread notifications about this report + # todo: append to mod log + return redirect(url_for('community.community_moderate', actor=community.link())) + else: + form.reason.data = report.description + return render_template('community/community_moderate_report_escalate.html', form=form) + else: + abort(401) + + +@bp.route('/community//moderate_report//resolve', methods=['GET', 'POST']) +@login_required +def community_moderate_report_resolve(community_id, report_id): + community = Community.query.get_or_404(community_id) + if community.is_moderator() or current_user.is_admin(): + report = Report.query.filter_by(in_community_id=community.id, id=report_id).first() + if report: + form = ResolveReportForm() + if form.validate_on_submit(): + report.status = REPORT_STATE_RESOLVED + db.session.commit() + # todo: remove unread notifications about this report + # todo: append to mod log + if form.also_resolve_others.data: + if report.suspect_post_reply_id: + db.session.execute(text('UPDATE "report" SET status = :new_status WHERE suspect_post_reply_id = :suspect_post_reply_id'), + {'new_status': REPORT_STATE_RESOLVED, + 'suspect_post_reply_id': report.suspect_post_reply_id}) + # todo: remove unread notifications about these reports + elif report.suspect_post_id: + db.session.execute(text('UPDATE "report" SET status = :new_status WHERE suspect_post_id = :suspect_post_id'), + {'new_status': REPORT_STATE_RESOLVED, + 'suspect_post_id': report.suspect_post_id}) + # todo: remove unread notifications about these reports + db.session.commit() + flash(_('Report resolved.')) + return redirect(url_for('community.community_moderate', actor=community.link())) + else: + return render_template('community/community_moderate_report_resolve.html', form=form) diff --git a/app/community/util.py b/app/community/util.py index b97b7661..f58c067c 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -15,7 +15,7 @@ from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \ Instance, Notification, User, ActivityPubLog from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, allowlist_html, \ - html_to_markdown, is_image_url, ensure_directory_exists, inbox_domain, post_ranking, shorten_string, parse_page, \ + is_image_url, ensure_directory_exists, inbox_domain, post_ranking, shorten_string, parse_page, \ remove_tracking_from_link, ap_datetime, instance_banned, blocked_phrases from sqlalchemy import func, desc import os @@ -96,7 +96,7 @@ def retrieve_mods_and_backfill(community_id: int): if outbox_request.status_code == 200: outbox_data = outbox_request.json() outbox_request.close() - if outbox_data['type'] == 'OrderedCollection' and 'orderedItems' in outbox_data: + if 'type' in outbox_data and outbox_data['type'] == 'OrderedCollection' and 'orderedItems' in outbox_data: activities_processed = 0 for activity in outbox_data['orderedItems']: user = find_actor_or_create(activity['object']['actor']) @@ -255,6 +255,7 @@ def save_post(form, post: Post): # 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) @@ -270,9 +271,11 @@ def save_post(form, post: Post): img = ImageOps.exif_transpose(img) img_width = img.width img_height = img.height + img.thumbnail((2000, 2000)) + img.save(final_place) if img.width > 512 or img.height > 512: img.thumbnail((512, 512)) - img.save(final_place) + img.save(final_place_medium, format="WebP", quality=93) img_width = img.width img_height = img.height # save a second, smaller, version as a thumbnail @@ -281,7 +284,7 @@ def save_post(form, post: Post): thumbnail_width = img.width thumbnail_height = img.height - file = File(file_path=final_place, file_name=new_filename + file_ext, alt_text=alt_text, + 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/")) diff --git a/app/constants.py b/app/constants.py index 97001e31..a6e64f05 100644 --- a/app/constants.py +++ b/app/constants.py @@ -16,4 +16,10 @@ SUBSCRIPTION_NONMEMBER = 0 SUBSCRIPTION_PENDING = -1 SUBSCRIPTION_BANNED = -2 -THREAD_CUTOFF_DEPTH = 4 \ No newline at end of file +THREAD_CUTOFF_DEPTH = 4 + +REPORT_STATE_NEW = 0 +REPORT_STATE_ESCALATED = 1 +REPORT_STATE_APPEALED = 2 +REPORT_STATE_RESOLVED = 3 +REPORT_STATE_DISCARDED = -1 diff --git a/app/models.py b/app/models.py index 2c999d45..d3b44a99 100644 --- a/app/models.py +++ b/app/models.py @@ -440,6 +440,10 @@ class Community(db.Model): retval = self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/c/{self.name}" return retval.lower() + def public_url(self): + result = self.ap_public_url if self.ap_public_url else f"https://{current_app.config['SERVER_NAME']}/c/{self.name}" + return result + def is_local(self): return self.ap_id is None or self.profile_id().startswith('https://' + current_app.config['SERVER_NAME']) @@ -466,6 +470,14 @@ class Community(db.Model): instances = instances.filter(Instance.id != 1, Instance.gone_forever == False) return instances.all() + def has_followers_from_domain(self, domain: str) -> bool: + instances = Instance.query.join(User, User.instance_id == Instance.id).join(CommunityMember, CommunityMember.user_id == User.id) + instances = instances.filter(CommunityMember.community_id == self.id, CommunityMember.is_banned == False) + for instance in instances: + if instance.domain == domain: + return True + return False + def delete_dependencies(self): for post in self.posts: post.delete_dependencies() @@ -750,6 +762,10 @@ class User(UserMixin, db.Model): result = self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/u/{self.user_name}" return result + def public_url(self): + result = self.ap_public_url if self.ap_public_url else f"https://{current_app.config['SERVER_NAME']}/u/{self.user_name}" + return result + def created_recently(self): return self.created and self.created > utcnow() - timedelta(days=7) @@ -803,6 +819,12 @@ class User(UserMixin, db.Model): reply.body = reply.body_html = '' db.session.commit() + def mention_tag(self): + if self.ap_domain is None: + return '@' + self.user_name + '@' + current_app.config['SERVER_NAME'] + else: + return '@' + self.user_name + '@' + self.ap_domain + class ActivityLog(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -1171,7 +1193,7 @@ class Report(db.Model): id = db.Column(db.Integer, primary_key=True) reasons = db.Column(db.String(256)) description = db.Column(db.String(256)) - status = db.Column(db.Integer, default=0) + status = db.Column(db.Integer, default=0) # 0 = new, 1 = escalated to admin, 2 = being appealed, 3 = resolved, 4 = discarded type = db.Column(db.Integer, default=0) # 0 = user, 1 = post, 2 = reply, 3 = community, 4 = conversation reporter_id = db.Column(db.Integer, db.ForeignKey('user.id')) suspect_community_id = db.Column(db.Integer, db.ForeignKey('community.id')) @@ -1180,6 +1202,7 @@ class Report(db.Model): suspect_post_reply_id = db.Column(db.Integer, db.ForeignKey('post_reply.id')) suspect_conversation_id = db.Column(db.Integer, db.ForeignKey('conversation.id')) in_community_id = db.Column(db.Integer, db.ForeignKey('community.id')) + source_instance_id = db.Column(db.Integer, db.ForeignKey('instance.id')) # the instance of the reporter. mostly used to distinguish between local (instance 1) and remote reports created_at = db.Column(db.DateTime, default=utcnow) updated = db.Column(db.DateTime, default=utcnow) @@ -1192,7 +1215,7 @@ class Report(db.Model): return types[self.type] def is_local(self): - return True + return self.source_instance_id == 1 class IpBan(db.Model): diff --git a/app/post/routes.py b/app/post/routes.py index 8af417ef..57082fcf 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -118,12 +118,12 @@ def show_post(post_id: int): reply_json = { 'type': 'Note', 'id': reply.profile_id(), - 'attributedTo': current_user.profile_id(), + 'attributedTo': current_user.public_url(), 'to': [ 'https://www.w3.org/ns/activitystreams#Public' ], 'cc': [ - community.profile_id(), + community.public_url(), post.author.public_url() ], 'content': reply.body_html, 'inReplyTo': post.profile_id(), @@ -134,20 +134,30 @@ def show_post(post_id: int): }, 'published': ap_datetime(utcnow()), 'distinguished': False, - 'audience': community.profile_id() + 'audience': community.public_url(), + 'tag': [{ + 'href': post.author.public_url(), + 'name': post.author.mention_tag(), + 'type': 'Mention' + }] } create_json = { 'type': 'Create', - 'actor': current_user.profile_id(), - 'audience': community.profile_id(), + 'actor': current_user.public_url(), + 'audience': community.public_url(), 'to': [ 'https://www.w3.org/ns/activitystreams#Public' ], 'cc': [ - community.ap_profile_id + community.public_url(), post.author.public_url() ], 'object': reply_json, - 'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}" + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", + 'tag': [{ + 'href': post.author.public_url(), + 'name': post.author.mention_tag(), + 'type': 'Mention' + }] } if not community.is_local(): # this is a remote community, send it to the instance that hosts it success = post_request(community.ap_inbox_url, create_json, current_user.private_key, @@ -161,7 +171,7 @@ def show_post(post_id: int): "to": [ "https://www.w3.org/ns/activitystreams#Public" ], - "actor": community.ap_profile_id, + "actor": community.public_url(), "cc": [ community.ap_followers_url ], @@ -173,6 +183,17 @@ def show_post(post_id: int): 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) + # send copy of Note to post author (who won't otherwise get it if no-one else on their instance is subscribed to the community) + if not post.author.is_local() and post.author.ap_domain != community.ap_domain: + if not community.is_local() or (community.is_local and not community.has_followers_from_domain(post.author.ap_domain)): + success = post_request(post.author.ap_inbox_url, create_json, current_user.private_key, + current_user.ap_profile_id + '#main-key') + if not success: + # sending to shared inbox is good enough for Mastodon, but Lemmy will reject it the local community has no followers + personal_inbox = post.author.public_url() + '/inbox' + post_request(personal_inbox, create_json, current_user.private_key, + current_user.ap_profile_id + '#main-key') + return redirect(url_for('activitypub.post_ap', post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form else: replies = post_replies(post.id, sort) @@ -518,14 +539,13 @@ def add_reply(post_id: int, comment_id: int): reply_json = { 'type': 'Note', 'id': reply.profile_id(), - 'attributedTo': current_user.profile_id(), + 'attributedTo': current_user.public_url(), 'to': [ - 'https://www.w3.org/ns/activitystreams#Public', - in_reply_to.author.profile_id() + 'https://www.w3.org/ns/activitystreams#Public' ], 'cc': [ - post.community.profile_id(), - current_user.followers_url() + post.community.public_url(), + in_reply_to.author.public_url() ], 'content': reply.body_html, 'inReplyTo': in_reply_to.profile_id(), @@ -537,7 +557,7 @@ def add_reply(post_id: int, comment_id: int): }, 'published': ap_datetime(utcnow()), 'distinguished': False, - 'audience': post.community.profile_id(), + 'audience': post.community.public_url(), 'contentMap': { 'en': reply.body_html } @@ -545,15 +565,14 @@ def add_reply(post_id: int, comment_id: int): create_json = { '@context': default_context(), 'type': 'Create', - 'actor': current_user.profile_id(), - 'audience': post.community.profile_id(), + 'actor': current_user.public_url(), + 'audience': post.community.public_url(), 'to': [ - 'https://www.w3.org/ns/activitystreams#Public', - in_reply_to.author.profile_id() + 'https://www.w3.org/ns/activitystreams#Public' ], 'cc': [ - post.community.profile_id(), - current_user.followers_url() + post.community.public_url(), + in_reply_to.author.public_url() ], 'object': reply_json, 'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}" @@ -561,8 +580,15 @@ def add_reply(post_id: int, comment_id: int): if in_reply_to.notify_author and in_reply_to.author.ap_id is not None: reply_json['tag'] = [ { - 'href': in_reply_to.author.ap_profile_id, - 'name': '@' + in_reply_to.author.ap_id, + 'href': in_reply_to.author.public_url(), + 'name': in_reply_to.author.mention_tag(), + 'type': 'Mention' + } + ] + create_json['tag'] = [ + { + 'href': in_reply_to.author.public_url(), + 'name': in_reply_to.author.mention_tag(), 'type': 'Mention' } ] @@ -578,7 +604,7 @@ def add_reply(post_id: int, comment_id: int): "to": [ "https://www.w3.org/ns/activitystreams#Public" ], - "actor": post.community.ap_profile_id, + "actor": post.community.public_url(), "cc": [ post.community.ap_followers_url ], @@ -590,6 +616,17 @@ def add_reply(post_id: int, comment_id: int): 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) + # send copy of Note to comment author (who won't otherwise get it if no-one else on their instance is subscribed to the community) + if not in_reply_to.author.is_local() and in_reply_to.author.ap_domain != reply.community.ap_domain: + if not post.community.is_local() or (post.community.is_local and not post.community.has_followers_from_domain(in_reply_to.author.ap_domain)): + success = post_request(in_reply_to.author.ap_inbox_url, create_json, current_user.private_key, + current_user.ap_profile_id + '#main-key') + if not success: + # sending to shared inbox is good enough for Mastodon, but Lemmy will reject it the local community has no followers + personal_inbox = in_reply_to.author.public_url() + '/inbox' + post_request(personal_inbox, create_json, current_user.private_key, + current_user.ap_profile_id + '#main-key') + if reply.depth <= constants.THREAD_CUTOFF_DEPTH: return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.id}')) else: @@ -827,7 +864,7 @@ def post_report(post_id: int): if form.validate_on_submit(): report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, type=1, reporter_id=current_user.id, suspect_user_id=post.author.id, suspect_post_id=post.id, - suspect_community_id=post.community.id, in_community_id=post.community.id) + suspect_community_id=post.community.id, in_community_id=post.community.id, source_instance_id=1) db.session.add(report) # Notify moderators @@ -931,7 +968,8 @@ def post_reply_report(post_id: int, comment_id: int): if form.validate_on_submit(): report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, type=2, reporter_id=current_user.id, suspect_post_id=post.id, suspect_community_id=post.community.id, - suspect_user_id=post_reply.author.id, suspect_post_reply_id=post_reply.id) + suspect_user_id=post_reply.author.id, suspect_post_reply_id=post_reply.id, in_community_id=post.community.id, + source_instance_id=1) db.session.add(report) # Notify moderators @@ -1025,14 +1063,13 @@ def post_reply_edit(post_id: int, comment_id: int): reply_json = { 'type': 'Note', 'id': post_reply.profile_id(), - 'attributedTo': current_user.profile_id(), + 'attributedTo': current_user.public_url(), 'to': [ - 'https://www.w3.org/ns/activitystreams#Public', - in_reply_to.author.profile_id() + 'https://www.w3.org/ns/activitystreams#Public' ], 'cc': [ - post.community.profile_id(), - current_user.followers_url() + post.community.public_url(), + in_reply_to.author.public_url() ], 'content': post_reply.body_html, 'inReplyTo': in_reply_to.profile_id(), @@ -1045,37 +1082,54 @@ def post_reply_edit(post_id: int, comment_id: int): 'published': ap_datetime(post_reply.posted_at), 'updated': ap_datetime(post_reply.edited_at), 'distinguished': False, - 'audience': post.community.profile_id(), + 'audience': post.community.public_url(), 'contentMap': { 'en': post_reply.body_html } } update_json = { - 'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}", + '@context': default_context(), '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()), + 'actor': current_user.public_url(), + 'audience': post.community.public_url(), + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], 'cc': [ - current_user.followers_url() + post.community.public_url(), + in_reply_to.author.public_url() ], 'object': reply_json, + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}" } - - if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it + if in_reply_to.notify_author and in_reply_to.author.ap_id is not None: + reply_json['tag'] = [ + { + 'href': in_reply_to.author.public_url(), + 'name': in_reply_to.author.mention_tag(), + 'type': 'Mention' + } + ] + update_json['tag'] = [ + { + 'href': in_reply_to.author.public_url(), + 'name': in_reply_to.author.mention_tag(), + 'type': 'Mention' + } + ] + if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it success = post_request(post.community.ap_inbox_url, update_json, current_user.private_key, - current_user.ap_profile_id + '#main-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 + flash('Failed to send 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, + "actor": post.community.public_url(), "cc": [ post.community.ap_followers_url ], @@ -1084,9 +1138,20 @@ def post_reply_edit(post_id: int, comment_id: int): } 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): + 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) + + # send copy of Note to post author (who won't otherwise get it if no-one else on their instance is subscribed to the community) + if not in_reply_to.author.is_local() and in_reply_to.author.ap_domain != post_reply.community.ap_domain: + if not post.community.is_local() or (post.community.is_local and not post.community.has_followers_from_domain(in_reply_to.author.ap_domain)): + success = post_request(in_reply_to.author.ap_inbox_url, update_json, current_user.private_key, + current_user.ap_profile_id + '#main-key') + if not success: + # sending to shared inbox is good enough for Mastodon, but Lemmy will reject it the local community has no followers + personal_inbox = in_reply_to.author.public_url() + '/inbox' + post_request(personal_inbox, update_json, current_user.private_key, + current_user.ap_profile_id + '#main-key') + return redirect(url_for('activitypub.post_ap', post_id=post.id)) else: form.body.data = post_reply.body diff --git a/app/static/js/markdown/downarea.js b/app/static/js/markdown/downarea.js index 7420ecb0..4a38630c 100644 --- a/app/static/js/markdown/downarea.js +++ b/app/static/js/markdown/downarea.js @@ -582,7 +582,11 @@ var DownArea = (function () { if (self.textarea.selectionStart != self.textarea.selectionEnd) { end = self.textarea.value.substr(self.textarea.selectionEnd); var range = self.textarea.value.slice(self.textarea.selectionStart, self.textarea.selectionEnd); - blockquote = "".concat(blockquote).concat(range.trim()); + var lines = range.trim().split('\n'); + var modifiedLines = lines.map(function (line) { + return "> " + line.trim(); + }); + blockquote = modifiedLines.join('\n') + '\n'; } if (start.length && start[start.length - 1] != '\n') { blockquote = "\n".concat(blockquote); diff --git a/app/templates/base.html b/app/templates/base.html index f7b34d82..988a2aae 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -246,7 +246,7 @@ {% if post_layout == 'masonry' or post_layout == 'masonry_wide' %} {% endif %} - + {% endif %} {% if theme() and file_exists('app/templates/themes/' + theme() + '/scripts.js') %}