Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Martynas Sklizmantas 2024-03-28 07:09:41 +01:00
commit f2ceb5752d
31 changed files with 577 additions and 190 deletions

View file

@ -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, \ 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, \ 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, \ user_removed_from_remote_server, create_post, create_post_reply, update_post_reply_from_activity, \
update_post_from_activity, undo_vote, undo_downvote update_post_from_activity, undo_vote, undo_downvote, post_to_page
from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \ from app.utils import gibberish, get_setting, is_image_url, allowlist_html, render_template, \
domain_from_url, markdown_to_html, community_membership, ap_datetime, markdown_to_text, ip_address, can_downvote, \ 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, \ can_upvote, can_create_post, awaken_dormant_instance, shorten_string, can_create_post_reply, sha256_digest, \
community_moderators community_moderators
import werkzeug.exceptions import werkzeug.exceptions
@ -175,12 +175,12 @@ def user_profile(actor):
actor = actor.strip() actor = actor.strip()
if current_user.is_authenticated and current_user.is_admin(): if current_user.is_authenticated and current_user.is_admin():
if '@' in actor: 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: else:
user: User = User.query.filter_by(user_name=actor, ap_id=None).first() user: User = User.query.filter_by(user_name=actor, ap_id=None).first()
else: else:
if '@' in actor: 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: else:
user: User = User.query.filter_by(user_name=actor, deleted=False, ap_id=None).first() 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'])), body_html=allowlist_html(markdown_to_html(request_json['object']['source']['content'])),
encrypted=encrypted) encrypted=encrypted)
db.session.add(new_message) db.session.add(new_message)
existing_conversation.updated_at = utcnow()
db.session.commit() db.session.commit()
# Notify recipient # Notify recipient
@ -432,20 +433,43 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
activity_log.result = 'success' activity_log.result = 'success'
else: else:
try: try:
community_ap_id = request_json['to'][0] community_ap_id = ''
if community_ap_id == 'https://www.w3.org/ns/activitystreams#Public': # kbin does this when posting a reply locations = ['audience', 'cc', 'to']
if 'to' in request_json['object'] and request_json['object']['to']: if 'object' in request_json:
community_ap_id = request_json['object']['to'][0] rjs = [ request_json, request_json['object'] ]
if community_ap_id == 'https://www.w3.org/ns/activitystreams#Public' and 'cc' in \ else:
request_json['object'] and request_json['object']['cc']: rjs = [ request_json ]
community_ap_id = request_json['object']['cc'][0] local_community_prefix = f"https://{current_app.config['SERVER_NAME']}/c/"
elif 'cc' in request_json['object'] and request_json['object']['cc']: followers_suffix = '/followers'
community_ap_id = request_json['object']['cc'][0] for rj in rjs:
if community_ap_id.endswith('/followers'): # mastodon for loc in locations:
if 'inReplyTo' in request_json['object']: if loc in rj:
post_being_replied_to = Post.query.filter_by(ap_id=request_json['object']['inReplyTo']).first() id = rj[loc]
if post_being_replied_to: if isinstance(id, str):
community_ap_id = post_being_replied_to.community.ap_profile_id 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: except:
activity_log.activity_type = 'exception' activity_log.activity_type = 'exception'
db.session.commit() db.session.commit()
@ -1029,6 +1053,27 @@ def community_outbox(actor):
return jsonify(community_data) return jsonify(community_data)
@bp.route('/c/<actor>/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/<actor>/moderators', methods=['GET']) @bp.route('/c/<actor>/moderators', methods=['GET'])
def community_moderators_route(actor): def community_moderators_route(actor):
actor = actor.strip() actor = actor.strip()
@ -1069,7 +1114,7 @@ def community_followers(actor):
if community is not None: if community is not None:
result = { result = {
"@context": default_context(), "@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", "type": "Collection",
"totalItems": community_members(community.id), "totalItems": community_members(community.id),
"items": [] "items": []

View file

@ -22,10 +22,10 @@ from PIL import Image, ImageOps
from io import BytesIO from io import BytesIO
import pytesseract 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, \ 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, \ 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(): def public_key():
@ -178,6 +178,43 @@ def post_to_activity(post: Post, community: Community):
return activity_data 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(): def banned_user_agents():
return [] # todo: finish this function 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 return None
if user is None: if user is None:
user = Community.query.filter(Community.ap_profile_id == actor).first() 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 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)): 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) community.description_html = markdown_to_html(community.description)
elif 'content' in activity_json: elif 'content' in activity_json:
community.description_html = allowlist_html(activity_json['content']) community.description_html = allowlist_html(activity_json['content'])
community.description = html_to_markdown(community.description_html) community.description = ''
icon_changed = cover_changed = False icon_changed = cover_changed = False
if 'icon' in activity_json: 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_followers_url=activity_json['followers'],
ap_inbox_url=activity_json['endpoints']['sharedInbox'], ap_inbox_url=activity_json['endpoints']['sharedInbox'],
ap_outbox_url=activity_json['outbox'], 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_moderators_url=mods_url,
ap_fetched_at=utcnow(), ap_fetched_at=utcnow(),
ap_domain=server, ap_domain=server,
@ -580,7 +622,7 @@ def actor_json_to_model(activity_json, address, server):
community.description_html = markdown_to_html(community.description) community.description_html = markdown_to_html(community.description)
elif 'content' in activity_json: elif 'content' in activity_json:
community.description_html = allowlist_html(activity_json['content']) community.description_html = allowlist_html(activity_json['content'])
community.description = html_to_markdown(community.description_html) community.description = ''
if 'icon' in activity_json: if 'icon' in activity_json:
icon = File(source_url=activity_json['icon']['url']) icon = File(source_url=activity_json['icon']['url'])
community.icon = icon 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) post.body_html = markdown_to_html(post.body)
elif 'content' in post_json: elif 'content' in post_json:
post.body_html = allowlist_html(post_json['content']) 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 'attachment' in post_json and len(post_json['attachment']) > 0 and 'type' in post_json['attachment'][0]:
if post_json['attachment'][0]['type'] == 'Link': if post_json['attachment'][0]['type'] == 'Link':
post.url = post_json['attachment'][0]['href'] 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': if 'source' in user_json and user_json['source'].get('mediaType') == 'text/markdown':
# Convert Markdown to HTML # Convert Markdown to HTML
markdown_text = user_json['source']['content'] 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 return html_content
elif 'summary' in user_json: elif 'summary' in user_json:
return allowlist_html(user_json['summary']) 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) post_reply.body_html = markdown_to_html(post_reply.body)
elif 'content' in request_json['object']: # Kbin elif 'content' in request_json['object']: # Kbin
post_reply.body_html = allowlist_html(request_json['object']['content']) 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: if post_id is not None:
# Discard post_reply if it contains certain phrases. Good for stopping spam floods. # Discard post_reply if it contains certain phrases. Good for stopping spam floods.
if post_reply.body: 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.exception_message = 'Community is local only, post discarded'
activity_log.result = 'ignored' activity_log.result = 'ignored'
return None 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. if 'name' not in request_json['object']: # Microblog posts
return None if 'content' in request_json['object'] and request_json['object']['content'] is not None:
nsfl_in_title = '[NSFL]' in request_json['object']['name'].upper() or '(NSFL)' in request_json['object']['name'].upper() 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, 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, 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, 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, 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) post.body_html = markdown_to_html(post.body)
elif 'content' in request_json['object'] and request_json['object']['content'] is not None: # Kbin 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 = 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. # Discard post if it contains certain phrases. Good for stopping spam floods.
blocked_phrases_list = blocked_phrases() blocked_phrases_list = blocked_phrases()
for blocked_phrase in blocked_phrases_list: 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 \ if 'attachment' in request_json['object'] and len(request_json['object']['attachment']) > 0 and \
'type' in request_json['object']['attachment'][0]: 'type' in request_json['object']['attachment'][0]:
if request_json['object']['attachment'][0]['type'] == 'Link': 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): if is_image_url(post.url):
post.type = POST_TYPE_IMAGE post.type = POST_TYPE_IMAGE
if 'image' in request_json['object'] and 'url' in request_json['object']['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) reply.body_html = markdown_to_html(reply.body)
elif 'content' in request_json['object']: elif 'content' in request_json['object']:
reply.body_html = allowlist_html(request_json['object']['content']) reply.body_html = allowlist_html(request_json['object']['content'])
reply.body = html_to_markdown(reply.body_html) reply.body = ''
reply.edited_at = utcnow() reply.edited_at = utcnow()
db.session.commit() 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) post.body_html = markdown_to_html(post.body)
elif 'content' in request_json['object']: elif 'content' in request_json['object']:
post.body_html = allowlist_html(request_json['object']['content']) 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']: if 'attachment' in request_json['object'] and 'href' in request_json['object']['attachment']:
post.url = request_json['object']['attachment']['href'] post.url = request_json['object']['attachment']['href']
if 'sensitive' in request_json['object']: if 'sensitive' in request_json['object']:

View file

@ -4,7 +4,7 @@ from time import sleep
from flask import request, flash, json, url_for, current_app, redirect, g from flask import request, flash, json, url_for, current_app, redirect, g
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_babel import _ from flask_babel import _
from sqlalchemy import text, desc from sqlalchemy import text, desc, or_
from app import db, celery, cache from app import db, celery, cache
from app.activitypub.routes import process_inbox_request, process_delete_request 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, \ from app.admin.util import unsubscribe_from_everything_then_delete, unsubscribe_from_community, send_newsletter, \
topic_tree, topics_for_form topic_tree, topics_for_form
from app.community.util import save_icon_file, save_banner_file 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, \ from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, \
User, Instance, File, Report, Topic, UserRegistration, Role, Post User, Instance, File, Report, Topic, UserRegistration, Role, Post
from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html, \ 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() private_key, public_key = RsaKeys.generate_keypair()
user.private_key = private_key user.private_key = private_key
user.public_key = public_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_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.ap_inbox_url = f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}/inbox"
user.roles.append(Role.query.get(form.role.data)) user.roles.append(Role.query.get(form.role.data))
@ -674,7 +675,7 @@ def admin_reports():
search = request.args.get('search', '') search = request.args.get('search', '')
local_remote = request.args.get('local_remote', '') 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': if local_remote == 'local':
reports = reports.filter_by(ap_id=None) reports = reports.filter_by(ap_id=None)
if local_remote == 'remote': if local_remote == 'remote':

View file

@ -99,7 +99,7 @@ def register():
flash(_('Sorry, you cannot use that user name'), 'error') flash(_('Sorry, you cannot use that user name'), 'error')
else: else:
for referrer in blocked_referrers(): 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 = make_response(redirect(url_for('auth.please_wait')))
resp.set_cookie('sesion', '17489047567495', expires=datetime(year=2099, month=12, day=30)) resp.set_cookie('sesion', '17489047567495', expires=datetime(year=2099, month=12, day=30))
return resp 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, 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(), verification_token=verification_token, instance_id=1, ip_address=ip_address(),
banned=user_ip_banned() or user_cookie_banned(), email_unread_sent=False, 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) user.set_password(form.password.data)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()

View file

@ -145,7 +145,7 @@ def chat_report(conversation_id):
if form.validate_on_submit(): if form.validate_on_submit():
report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, 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) db.session.add(report)
# Notify site admin # Notify site admin

View file

@ -13,6 +13,7 @@ def send_message(message: str, conversation_id: int) -> ChatMessage:
conversation = Conversation.query.get(conversation_id) conversation = Conversation.query.get(conversation_id)
reply = ChatMessage(sender_id=current_user.id, conversation_id=conversation.id, reply = ChatMessage(sender_id=current_user.id, conversation_id=conversation.id,
body=message, body_html=allowlist_html(markdown_to_html(message))) body=message, body_html=allowlist_html(markdown_to_html(message)))
conversation.updated_at = utcnow()
for recipient in conversation.members: for recipient in conversation.members:
if recipient.id != current_user.id: if recipient.id != current_user.id:
if recipient.is_local(): if recipient.is_local():

View file

@ -3,7 +3,7 @@ from flask_login import current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField, BooleanField, HiddenField, SelectField, FileField, \ from wtforms import StringField, SubmitField, TextAreaField, BooleanField, HiddenField, SelectField, FileField, \
DateField 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 flask_babel import _, lazy_gettext as _l
from app import db from app import db
@ -61,6 +61,17 @@ class AddModeratorForm(FlaskForm):
submit = SubmitField(_l('Add')) 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): class SearchRemoteCommunity(FlaskForm):
address = StringField(_l('Community address'), render_kw={'placeholder': 'e.g. !name@server', 'autofocus': True}, validators=[DataRequired()]) address = StringField(_l('Community address'), render_kw={'placeholder': 'e.g. !name@server', 'autofocus': True}, validators=[DataRequired()])
submit = SubmitField(_l('Search')) submit = SubmitField(_l('Search'))
@ -77,15 +88,15 @@ class BanUserCommunityForm(FlaskForm):
class CreatePostForm(FlaskForm): class CreatePostForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int)
post_type = HiddenField() # https://getbootstrap.com/docs/4.6/components/navs/#tabs 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_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)'}) 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_title = StringField(_l('Title'), validators=[Optional(), Length(min=3, max=255)])
link_body = TextAreaField(_l('Body'), validators={Optional(), Length(min=3, max=5000)}, link_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)],
render_kw={'placeholder': 'Text (optional)'}) render_kw={'placeholder': 'Text (optional)'})
link_url = StringField(_l('URL'), render_kw={'placeholder': 'https://...'}) 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_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_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_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)],
render_kw={'placeholder': 'Text (optional)'}) render_kw={'placeholder': 'Text (optional)'})
image_file = FileField(_('Image')) image_file = FileField(_('Image'))
# flair = SelectField(_l('Flair'), coerce=int) # flair = SelectField(_l('Flair'), coerce=int)

View file

@ -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 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_login import current_user, login_required
from flask_babel import _ from flask_babel import _
from sqlalchemy import or_, desc from sqlalchemy import or_, desc, text
from app import db, constants, cache from app import db, constants, cache
from app.activitypub.signature import RsaKeys, post_request 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.activitypub.util import default_context, notify_about_post, find_actor_or_create, make_image_sizes
from app.chat.util import send_message from app.chat.util import send_message
from app.community.forms import SearchRemoteCommunity, CreatePostForm, ReportCommunityForm, \ 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, \ 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, \ 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 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, \ 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.inoculation import inoculation
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \
File, PostVote, utcnow, Report, Notification, InstanceBlock, ActivityPubLog, Topic, Conversation, PostReply 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, rules=form.rules.data, nsfw=form.nsfw.data, private_key=private_key,
public_key=public_key, description_html=markdown_to_html(form.description.data), 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, 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_followers_url='https://' + current_app.config['SERVER_NAME'] + '/c/' + form.url.data + '/followers',
ap_domain=current_app.config['SERVER_NAME'], ap_domain=current_app.config['SERVER_NAME'],
subscriptions_count=1, instance_id=1, low_quality='memes' in form.url.data) 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') flash(_('Community not found.'), 'warning')
else: else:
flash(_('Community not found. If you are searching for a nsfw community it is blocked by this instance.'), 'warning') flash(_('Community not found. If you are searching for a nsfw community it is blocked by this instance.'), 'warning')
else:
if new_community.banned: if new_community.banned:
flash(_('That community is banned from %(site)s.', site=g.site.name), 'warning') flash(_('That community is banned from %(site)s.', site=g.site.name), 'warning')
return render_template('community/add_remote.html', return render_template('community/add_remote.html',
title=_('Add remote community'), form=form, new_community=new_community, title=_('Add remote community'), form=form, new_community=new_community,
@ -577,7 +579,7 @@ def community_report(community_id: int):
form = ReportCommunityForm() form = ReportCommunityForm()
if form.validate_on_submit(): if form.validate_on_submit():
report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, 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) db.session.add(report)
# Notify admin # Notify admin
@ -638,7 +640,8 @@ def community_edit(community_id: int):
community.image = file community.image = file
db.session.commit() 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() db.session.commit()
flash(_('Saved')) flash(_('Saved'))
return redirect(url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name)) 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.topic.data = community.topic_id if community.topic_id else None
form.default_layout.data = community.default_layout form.default_layout.data = community.default_layout
return render_template('community/community_edit.html', title=_('Edit community'), form=form, 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()), community=community, moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id())) joined_communities=joined_communities(current_user.get_id()))
else: 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() 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()), 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()), moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_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) reports = Report.query.filter_by(status=0, in_community_id=community.id)
if local_remote == 'local': if local_remote == 'local':
reports = reports.filter_by(ap_id=None) reports = reports.filter(Report.source_instance_id == 1)
if local_remote == 'remote': if local_remote == 'remote':
reports = reports.filter(Report.ap_id != None) reports = reports.filter(Report.source_instance_id != 1)
reports = reports.order_by(desc(Report.created_at)).paginate(page=page, per_page=1000, error_out=False) 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 next_url = url_for('community.community_moderate', 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 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()), return render_template('community/community_moderate.html', title=_('Moderation of %(community)s', community=community.display_name()),
community=community, reports=reports, current='reports', community=community, reports=reports, current='reports',
@ -962,4 +965,61 @@ def community_moderate_banned(actor):
else: else:
abort(401) abort(401)
else: else:
abort(404) abort(404)
@bp.route('/community/<int:community_id>/moderate_report/<int:report_id>/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/<int:community_id>/moderate_report/<int:report_id>/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)

View file

@ -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, \ from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \
Instance, Notification, User, ActivityPubLog Instance, Notification, User, ActivityPubLog
from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, allowlist_html, \ 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 remove_tracking_from_link, ap_datetime, instance_banned, blocked_phrases
from sqlalchemy import func, desc from sqlalchemy import func, desc
import os import os
@ -96,7 +96,7 @@ def retrieve_mods_and_backfill(community_id: int):
if outbox_request.status_code == 200: if outbox_request.status_code == 200:
outbox_data = outbox_request.json() outbox_data = outbox_request.json()
outbox_request.close() 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 activities_processed = 0
for activity in outbox_data['orderedItems']: for activity in outbox_data['orderedItems']:
user = find_actor_or_create(activity['object']['actor']) user = find_actor_or_create(activity['object']['actor'])
@ -255,6 +255,7 @@ def save_post(form, post: Post):
# save the file # save the file
final_place = os.path.join(directory, new_filename + file_ext) 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') final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
uploaded_file.seek(0) uploaded_file.seek(0)
uploaded_file.save(final_place) uploaded_file.save(final_place)
@ -270,9 +271,11 @@ def save_post(form, post: Post):
img = ImageOps.exif_transpose(img) img = ImageOps.exif_transpose(img)
img_width = img.width img_width = img.width
img_height = img.height img_height = img.height
img.thumbnail((2000, 2000))
img.save(final_place)
if img.width > 512 or img.height > 512: if img.width > 512 or img.height > 512:
img.thumbnail((512, 512)) img.thumbnail((512, 512))
img.save(final_place) img.save(final_place_medium, format="WebP", quality=93)
img_width = img.width img_width = img.width
img_height = img.height img_height = img.height
# save a second, smaller, version as a thumbnail # save a second, smaller, version as a thumbnail
@ -281,7 +284,7 @@ def save_post(form, post: Post):
thumbnail_width = img.width thumbnail_width = img.width
thumbnail_height = img.height 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, width=img_width, height=img_height, thumbnail_width=thumbnail_width,
thumbnail_height=thumbnail_height, thumbnail_path=final_place_thumbnail, thumbnail_height=thumbnail_height, thumbnail_path=final_place_thumbnail,
source_url=final_place.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/")) source_url=final_place.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/"))

View file

@ -16,4 +16,10 @@ SUBSCRIPTION_NONMEMBER = 0
SUBSCRIPTION_PENDING = -1 SUBSCRIPTION_PENDING = -1
SUBSCRIPTION_BANNED = -2 SUBSCRIPTION_BANNED = -2
THREAD_CUTOFF_DEPTH = 4 THREAD_CUTOFF_DEPTH = 4
REPORT_STATE_NEW = 0
REPORT_STATE_ESCALATED = 1
REPORT_STATE_APPEALED = 2
REPORT_STATE_RESOLVED = 3
REPORT_STATE_DISCARDED = -1

View file

@ -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}" retval = self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/c/{self.name}"
return retval.lower() 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): def is_local(self):
return self.ap_id is None or self.profile_id().startswith('https://' + current_app.config['SERVER_NAME']) 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) instances = instances.filter(Instance.id != 1, Instance.gone_forever == False)
return instances.all() 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): def delete_dependencies(self):
for post in self.posts: for post in self.posts:
post.delete_dependencies() 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}" result = self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/u/{self.user_name}"
return result 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): def created_recently(self):
return self.created and self.created > utcnow() - timedelta(days=7) return self.created and self.created > utcnow() - timedelta(days=7)
@ -803,6 +819,12 @@ class User(UserMixin, db.Model):
reply.body = reply.body_html = '' reply.body = reply.body_html = ''
db.session.commit() 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): class ActivityLog(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -1171,7 +1193,7 @@ class Report(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
reasons = db.Column(db.String(256)) reasons = db.Column(db.String(256))
description = 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 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')) reporter_id = db.Column(db.Integer, db.ForeignKey('user.id'))
suspect_community_id = db.Column(db.Integer, db.ForeignKey('community.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_post_reply_id = db.Column(db.Integer, db.ForeignKey('post_reply.id'))
suspect_conversation_id = db.Column(db.Integer, db.ForeignKey('conversation.id')) suspect_conversation_id = db.Column(db.Integer, db.ForeignKey('conversation.id'))
in_community_id = db.Column(db.Integer, db.ForeignKey('community.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) created_at = db.Column(db.DateTime, default=utcnow)
updated = 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] return types[self.type]
def is_local(self): def is_local(self):
return True return self.source_instance_id == 1
class IpBan(db.Model): class IpBan(db.Model):

View file

@ -118,12 +118,12 @@ def show_post(post_id: int):
reply_json = { reply_json = {
'type': 'Note', 'type': 'Note',
'id': reply.profile_id(), 'id': reply.profile_id(),
'attributedTo': current_user.profile_id(), 'attributedTo': current_user.public_url(),
'to': [ 'to': [
'https://www.w3.org/ns/activitystreams#Public' 'https://www.w3.org/ns/activitystreams#Public'
], ],
'cc': [ 'cc': [
community.profile_id(), community.public_url(), post.author.public_url()
], ],
'content': reply.body_html, 'content': reply.body_html,
'inReplyTo': post.profile_id(), 'inReplyTo': post.profile_id(),
@ -134,20 +134,30 @@ def show_post(post_id: int):
}, },
'published': ap_datetime(utcnow()), 'published': ap_datetime(utcnow()),
'distinguished': False, '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 = { create_json = {
'type': 'Create', 'type': 'Create',
'actor': current_user.profile_id(), 'actor': current_user.public_url(),
'audience': community.profile_id(), 'audience': community.public_url(),
'to': [ 'to': [
'https://www.w3.org/ns/activitystreams#Public' 'https://www.w3.org/ns/activitystreams#Public'
], ],
'cc': [ 'cc': [
community.ap_profile_id community.public_url(), post.author.public_url()
], ],
'object': reply_json, '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 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, success = post_request(community.ap_inbox_url, create_json, current_user.private_key,
@ -161,7 +171,7 @@ def show_post(post_id: int):
"to": [ "to": [
"https://www.w3.org/ns/activitystreams#Public" "https://www.w3.org/ns/activitystreams#Public"
], ],
"actor": community.ap_profile_id, "actor": community.public_url(),
"cc": [ "cc": [
community.ap_followers_url 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): 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_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 return redirect(url_for('activitypub.post_ap', post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form
else: else:
replies = post_replies(post.id, sort) replies = post_replies(post.id, sort)
@ -518,14 +539,13 @@ def add_reply(post_id: int, comment_id: int):
reply_json = { reply_json = {
'type': 'Note', 'type': 'Note',
'id': reply.profile_id(), 'id': reply.profile_id(),
'attributedTo': current_user.profile_id(), 'attributedTo': current_user.public_url(),
'to': [ 'to': [
'https://www.w3.org/ns/activitystreams#Public', 'https://www.w3.org/ns/activitystreams#Public'
in_reply_to.author.profile_id()
], ],
'cc': [ 'cc': [
post.community.profile_id(), post.community.public_url(),
current_user.followers_url() in_reply_to.author.public_url()
], ],
'content': reply.body_html, 'content': reply.body_html,
'inReplyTo': in_reply_to.profile_id(), 'inReplyTo': in_reply_to.profile_id(),
@ -537,7 +557,7 @@ def add_reply(post_id: int, comment_id: int):
}, },
'published': ap_datetime(utcnow()), 'published': ap_datetime(utcnow()),
'distinguished': False, 'distinguished': False,
'audience': post.community.profile_id(), 'audience': post.community.public_url(),
'contentMap': { 'contentMap': {
'en': reply.body_html 'en': reply.body_html
} }
@ -545,15 +565,14 @@ def add_reply(post_id: int, comment_id: int):
create_json = { create_json = {
'@context': default_context(), '@context': default_context(),
'type': 'Create', 'type': 'Create',
'actor': current_user.profile_id(), 'actor': current_user.public_url(),
'audience': post.community.profile_id(), 'audience': post.community.public_url(),
'to': [ 'to': [
'https://www.w3.org/ns/activitystreams#Public', 'https://www.w3.org/ns/activitystreams#Public'
in_reply_to.author.profile_id()
], ],
'cc': [ 'cc': [
post.community.profile_id(), post.community.public_url(),
current_user.followers_url() in_reply_to.author.public_url()
], ],
'object': reply_json, '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)}"
@ -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: if in_reply_to.notify_author and in_reply_to.author.ap_id is not None:
reply_json['tag'] = [ reply_json['tag'] = [
{ {
'href': in_reply_to.author.ap_profile_id, 'href': in_reply_to.author.public_url(),
'name': '@' + in_reply_to.author.ap_id, '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' 'type': 'Mention'
} }
] ]
@ -578,7 +604,7 @@ def add_reply(post_id: int, comment_id: int):
"to": [ "to": [
"https://www.w3.org/ns/activitystreams#Public" "https://www.w3.org/ns/activitystreams#Public"
], ],
"actor": post.community.ap_profile_id, "actor": post.community.public_url(),
"cc": [ "cc": [
post.community.ap_followers_url 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): 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_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: if reply.depth <= constants.THREAD_CUTOFF_DEPTH:
return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.id}')) return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.id}'))
else: else:
@ -827,7 +864,7 @@ def post_report(post_id: int):
if form.validate_on_submit(): if form.validate_on_submit():
report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, 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, 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) db.session.add(report)
# Notify moderators # Notify moderators
@ -931,7 +968,8 @@ def post_reply_report(post_id: int, comment_id: int):
if form.validate_on_submit(): if form.validate_on_submit():
report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, 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, 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) db.session.add(report)
# Notify moderators # Notify moderators
@ -1025,14 +1063,13 @@ def post_reply_edit(post_id: int, comment_id: int):
reply_json = { reply_json = {
'type': 'Note', 'type': 'Note',
'id': post_reply.profile_id(), 'id': post_reply.profile_id(),
'attributedTo': current_user.profile_id(), 'attributedTo': current_user.public_url(),
'to': [ 'to': [
'https://www.w3.org/ns/activitystreams#Public', 'https://www.w3.org/ns/activitystreams#Public'
in_reply_to.author.profile_id()
], ],
'cc': [ 'cc': [
post.community.profile_id(), post.community.public_url(),
current_user.followers_url() in_reply_to.author.public_url()
], ],
'content': post_reply.body_html, 'content': post_reply.body_html,
'inReplyTo': in_reply_to.profile_id(), '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), 'published': ap_datetime(post_reply.posted_at),
'updated': ap_datetime(post_reply.edited_at), 'updated': ap_datetime(post_reply.edited_at),
'distinguished': False, 'distinguished': False,
'audience': post.community.profile_id(), 'audience': post.community.public_url(),
'contentMap': { 'contentMap': {
'en': post_reply.body_html 'en': post_reply.body_html
} }
} }
update_json = { update_json = {
'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}", '@context': default_context(),
'type': 'Update', 'type': 'Update',
'actor': current_user.profile_id(), 'actor': current_user.public_url(),
'audience': post.community.profile_id(), 'audience': post.community.public_url(),
'to': [post.community.profile_id(), 'https://www.w3.org/ns/activitystreams#Public'], 'to': [
'published': ap_datetime(utcnow()), 'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [ 'cc': [
current_user.followers_url() post.community.public_url(),
in_reply_to.author.public_url()
], ],
'object': reply_json, 'object': reply_json,
'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}"
} }
if in_reply_to.notify_author and in_reply_to.author.ap_id is not None:
if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it 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, 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: if not success:
flash('Failed to send edit to remote server', 'error') flash('Failed to send send edit to remote server', 'error')
else: # local community - send it to followers on remote instances else: # local community - send it to followers on remote instances
announce = { announce = {
"id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}",
"type": 'Announce', "type": 'Announce',
"to": [ "to": [
"https://www.w3.org/ns/activitystreams#Public" "https://www.w3.org/ns/activitystreams#Public"
], ],
"actor": post.community.ap_profile_id, "actor": post.community.public_url(),
"cc": [ "cc": [
post.community.ap_followers_url 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(): for instance in post.community.following_instances():
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned( if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
instance.domain):
send_to_remote_instance(instance.id, post.community.id, announce) 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)) return redirect(url_for('activitypub.post_ap', post_id=post.id))
else: else:
form.body.data = post_reply.body form.body.data = post_reply.body

View file

@ -582,7 +582,11 @@ var DownArea = (function () {
if (self.textarea.selectionStart != self.textarea.selectionEnd) { if (self.textarea.selectionStart != self.textarea.selectionEnd) {
end = self.textarea.value.substr(self.textarea.selectionEnd); end = self.textarea.value.substr(self.textarea.selectionEnd);
var range = self.textarea.value.slice(self.textarea.selectionStart, 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') { if (start.length && start[start.length - 1] != '\n') {
blockquote = "\n".concat(blockquote); blockquote = "\n".concat(blockquote);

View file

@ -246,7 +246,7 @@
{% if post_layout == 'masonry' or post_layout == 'masonry_wide' %} {% if post_layout == 'masonry' or post_layout == 'masonry_wide' %}
<!-- --> <!-- -->
{% endif %} {% endif %}
<script type="text/javascript" src="{{ url_for('static', filename='js/markdown/downarea.js') }}"></script> <script type="text/javascript" src="{{ url_for('static', filename='js/markdown/downarea.js', changed=getmtime('js/markdown/downarea.js')) }}"></script>
{% endif %} {% endif %}
{% if theme() and file_exists('app/templates/themes/' + theme() + '/scripts.js') %} {% if theme() and file_exists('app/templates/themes/' + theme() + '/scripts.js') %}
<script src="{{ url_for('static', filename='themes/' + theme() + '/scripts.js') }}" /> <script src="{{ url_for('static', filename='themes/' + theme() + '/scripts.js') }}" />

View file

@ -1,14 +1,22 @@
<div class="btn-group mt-1 mb-2"> <div class="btn-group mt-3 mb-2">
{% if community.is_owner() or current_user.is_admin() %}
<a href="{{ url_for('community.community_edit', community_id=community.id) }}" class="btn {{ 'btn-primary' if current == '' or current == 'edit_settings' else 'btn-outline-secondary' }}" rel="nofollow noindex">
{{ _('Settings') }}
</a>
<a href="{{ url_for('community.community_mod_list', community_id=community.id) }}" class="btn {{ 'btn-primary' if current == 'moderators' else 'btn-outline-secondary' }}" rel="nofollow noindex">
{{ _('Moderators') }}
</a>
{% endif %}
<a href="/community/{{ community.link() }}/moderate" aria-label="{{ _('Sort by hot') }}" class="btn {{ 'btn-primary' if current == '' or current == 'reports' else 'btn-outline-secondary' }}" rel="nofollow noindex"> <a href="/community/{{ community.link() }}/moderate" aria-label="{{ _('Sort by hot') }}" class="btn {{ 'btn-primary' if current == '' or current == 'reports' else 'btn-outline-secondary' }}" rel="nofollow noindex">
{{ _('Reports') }} {{ _('Reports') }}
</a> </a>
<a href="/community/{{ community.link() }}/moderate/banned" class="btn {{ 'btn-primary' if current == 'banned' else 'btn-outline-secondary' }}" rel="nofollow noindex"> <a href="/community/{{ community.link() }}/moderate/banned" class="btn {{ 'btn-primary' if current == 'banned' else 'btn-outline-secondary' }}" rel="nofollow noindex">
{{ _('Banned people') }} {{ _('Banned people') }}
</a> </a>
<a href="/community/{{ community.link() }}/moderate/appeals" class="btn {{ 'btn-primary' if current == 'appeals' else 'btn-outline-secondary' }}" rel="nofollow noindex"> <a href="/community/{{ community.link() }}/moderate/appeals" class="btn {{ 'btn-primary' if current == 'appeals' else 'btn-outline-secondary' }} disabled" rel="nofollow noindex" >
{{ _('Appeals') }} {{ _('Appeals') }}
</a> </a>
<a href="/community/{{ community.link() }}/moderate/modlog" class="btn {{ 'btn-primary' if current == 'modlog' else 'btn-outline-secondary' }}" rel="nofollow noindex"> <a href="/community/{{ community.link() }}/moderate/modlog" class="btn {{ 'btn-primary' if current == 'modlog' else 'btn-outline-secondary' }} disabled" rel="nofollow noindex" >
{{ _('Mod log') }} {{ _('Mod log') }}
</a> </a>
</div> </div>

View file

@ -175,14 +175,10 @@
<h2>{{ _('Community Settings') }}</h2> <h2>{{ _('Community Settings') }}</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if is_moderator or is_owner or is_admin %}
<p><a href="/community/{{ community.link() }}/moderate" class="btn btn-primary">{{ _('Moderate') }}</a></p>
{% endif %}
{% if is_owner or is_admin %} {% if is_owner or is_admin %}
<p><a href="{{ url_for('community.community_edit', community_id=community.id) }}" class="btn btn-primary">{{ _('Settings') }}</a></p> <p><a href="{{ url_for('community.community_edit', community_id=community.id) }}" class="btn btn-primary">{{ _('Settings & Moderation') }}</a></p>
{% endif %} {% elif is_moderator %}
{% if community.is_local() and (community.is_owner() or current_user.is_admin()) %} <p><a href="/community/{{ community.link() }}/moderate" class="btn btn-primary">{{ _('Moderation') }}</a></p>
<p><a class="btn btn-primary btn-warning" href="{{ url_for('community.community_delete', community_id=community.id) }}" rel="nofollow">Delete community</a></p>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -12,7 +12,9 @@
<div class="card-body p-6"> <div class="card-body p-6">
<div class="card-title">{{ _('Delete "%(community_title)s"', community_title=community.title) }}</div> <div class="card-title">{{ _('Delete "%(community_title)s"', community_title=community.title) }}</div>
<div class="card-body"> <div class="card-body">
<p class="card-text"> Are you sure you want to delete this community? This is irreversible and will delete all posts and comments associated with it.</p>
{{ render_form(form) }} {{ render_form(form) }}
<a class="btn btn-primary mt-2" href="/c/{{ community.link() }}">Go back</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -15,13 +15,17 @@
<li class="breadcrumb-item active">{{ _('Settings') }}</li> <li class="breadcrumb-item active">{{ _('Settings') }}</li>
</ol> </ol>
</nav> </nav>
{% if community %}
{% include "community/_community_moderation_nav.html" %}
{% endif %}
<h1 class="mt-2"> <h1 class="mt-2">
{% if community %} {% if community %}
{{ _('Edit community') }} {{ _('Edit %(community)s', community=community.display_name()) }}
{% else %} {% else %}
{{ _('Create community') }} {{ _('Create community') }}
{% endif %} {% endif %}
</h1> </h1>
<p>{{ _('Edit and configure this community') }}</p>
<form method="post" enctype="multipart/form-data" id="add_local_community_form" role="form"> <form method="post" enctype="multipart/form-data" id="add_local_community_form" role="form">
{{ form.csrf_token() }} {{ form.csrf_token() }}
{{ render_field(form.title) }} {{ render_field(form.title) }}
@ -48,10 +52,12 @@
{{ render_field(form.submit) }} {{ render_field(form.submit) }}
</div> </div>
<div class="col-auto"> <div class="col-auto">
<a class="btn btn-outline-secondary" href="{{ url_for('community.community_mod_list', community_id=community.id) }}">{{ _('Moderators') }}</a> {% if community.is_local() and (community.is_owner() or current_user.is_admin()) %}
</div> <p><a class="btn btn-primary btn-warning" href="{{ url_for('community.community_delete', community_id=community.id) }}" rel="nofollow">Delete community</a></p>
{% endif %}
</div> </div>
</form> </form>
</div> </div>
</div> </div>

View file

@ -16,10 +16,12 @@
<li class="breadcrumb-item active">{{ _('Moderators') }}</li> <li class="breadcrumb-item active">{{ _('Moderators') }}</li>
</ol> </ol>
</nav> </nav>
{% include "community/_community_moderation_nav.html" %}
<div class="row"> <div class="row">
<div class="col-12 col-md-10"> <div class="col-12 col-md-10">
<h1 class="mt-2">{{ _('Moderators for %(community)s', community=community.display_name()) }}</h1> <h1 class="mt-2">{{ _('Moderators for %(community)s', community=community.display_name()) }}</h1>
</div> </div>
<p>{{ _('See and change who moderates this community') }}</p>
<div class="col-12 col-md-2 text-right"> <div class="col-12 col-md-2 text-right">
<a class="btn btn-primary" href="{{ url_for('community.community_add_moderator', community_id=community.id) }}">{{ _('Add moderator') }}</a> <a class="btn btn-primary" href="{{ url_for('community.community_add_moderator', community_id=community.id) }}">{{ _('Add moderator') }}</a>
</div> </div>

View file

@ -15,16 +15,16 @@
<li class="breadcrumb-item active">{{ _('Moderation') }}</li> <li class="breadcrumb-item active">{{ _('Moderation') }}</li>
</ol> </ol>
</nav> </nav>
{% include "community/_community_moderation_nav.html" %}
<div class="row"> <div class="row">
<div class="col-12 col-md-10"> <div class="col-12 col-md-10">
<h1 class="mt-2">{{ _('Moderation of %(community)s', community=community.display_name()) }}</h1> <h1 class="mt-2">{{ _('Reports') }}</h1>
</div> </div>
<div class="col-12 col-md-2 text-right"> <div class="col-12 col-md-2 text-right">
<!-- <a class="btn btn-primary" href="{{ url_for('community.community_add_moderator', community_id=community.id) }}">{{ _('Add moderator') }}</a> --> <!-- <a class="btn btn-primary" href="{{ url_for('community.community_add_moderator', community_id=community.id) }}">{{ _('Add moderator') }}</a> -->
</div> </div>
</div> </div>
{% include "community/_community_moderation_nav.html" %} <p>{{ _('See and handle all reports made about %(community)s', community=community.display_name()) }}</p>
<h2>{{ _('Reports') }}</h2>
{% if reports.items %} {% if reports.items %}
<form method="get"> <form method="get">
<input type="search" name="search" value="{{ search }}"> <input type="search" name="search" value="{{ search }}">
@ -57,9 +57,9 @@
<a href="/post/{{ report.suspect_post_id }}">View</a> <a href="/post/{{ report.suspect_post_id }}">View</a>
{% elif report.suspect_user_id %} {% elif report.suspect_user_id %}
<a href="/user/{{ report.suspect_user_id }}">View</a> <a href="/user/{{ report.suspect_user_id }}">View</a>
{% elif report.suspect_community_id %} {% endif %} |
<a href="/user/{{ report.suspect_community_id }}">View</a> <a href="{{ url_for('community.community_moderate_report_escalate', community_id=community.id, report_id=report.id) }}">{{ _('Escalate') }}</a> |
{% endif %} <a href="{{ url_for('community.community_moderate_report_resolve', community_id=community.id, report_id=report.id) }}">{{ _('Resolve') }}</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View file

@ -15,16 +15,17 @@
<li class="breadcrumb-item active">{{ _('Moderation') }}</li> <li class="breadcrumb-item active">{{ _('Moderation') }}</li>
</ol> </ol>
</nav> </nav>
{% include "community/_community_moderation_nav.html" %}
<div class="row"> <div class="row">
<div class="col-12 col-md-10"> <div class="col-12 col-md-10">
<h1 class="mt-2">{{ _('Moderation of %(community)s', community=community.display_name()) }}</h1> <h1 class="mt-2">{{ _('Banned people') }}</h1>
</div> </div>
<div class="col-12 col-md-2 text-right"> <div class="col-12 col-md-2 text-right">
<!-- <a class="btn btn-primary" href="{{ url_for('community.community_add_moderator', community_id=community.id) }}">{{ _('Add moderator') }}</a> --> <!-- <a class="btn btn-primary" href="{{ url_for('community.community_add_moderator', community_id=community.id) }}">{{ _('Add moderator') }}</a> -->
</div> </div>
</div> </div>
{% include "community/_community_moderation_nav.html" %} <p>{{ _('See and manage who is banned from %(community)s', community=community.display_name()) }}</p>
<h2>{{ _('Banned people') }}</h2> <h2></h2>
{% if banned_people %} {% if banned_people %}
<form method="get"> <form method="get">
<input type="search" name="search" value="{{ search }}"> <input type="search" name="search" value="{{ search }}">

View file

@ -0,0 +1,20 @@
{% 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 %}
{% block app_content %}
<div class="row">
<div class="col col-login mx-auto">
<div class="card mt-5">
<div class="card-body p-6">
<div class="card-title">{{ _('Escalate report to admins') }}</div>
<p>{{ _('For reports that could potentially involve legal issues or where you are unsure how to respond, you may prefer to let admins handle it.') }}</p>
{{ render_form(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,19 @@
{% 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 %}
{% block app_content %}
<div class="row">
<div class="col col-login mx-auto">
<div class="card mt-5">
<div class="card-body p-6">
<div class="card-title">{{ _('Resolve report') }}</div>
{{ render_form(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -82,8 +82,15 @@
<h2>{{ _('Community Settings') }}</h2> <h2>{{ _('Community Settings') }}</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p> {% if is_moderator or is_owner or is_admin %}
<p><a href="#" class="btn btn-primary">{{ _('Settings') }}</a></p> <p><a href="/community/{{ post.community.link() }}/moderate" class="btn btn-primary">{{ _('Moderate') }}</a></p>
{% endif %}
{% if is_owner or is_admin %}
<p><a href="{{ url_for('community.community_edit', community_id=community.id) }}" class="btn btn-primary">{{ _('Settings') }}</a></p>
{% endif %}
{% if post.community.is_local() and (post.community.is_owner() or current_user.is_admin()) %}
<p><a class="btn btn-primary btn-warning" href="{{ url_for('community.community_delete', community_id=post.community.id) }}" rel="nofollow">Delete community</a></p>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View file

@ -133,8 +133,15 @@
<h2>{{ _('Community Settings') }}</h2> <h2>{{ _('Community Settings') }}</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p> {% if is_moderator or is_owner or is_admin %}
<p><a href="#" class="btn btn-primary">{{ _('Settings') }}</a></p> <p><a href="/community/{{ community.link() }}/moderate" class="btn btn-primary">{{ _('Moderate') }}</a></p>
{% endif %}
{% if is_owner or is_admin %}
<p><a href="{{ url_for('community.community_edit', community_id=community.id) }}" class="btn btn-primary">{{ _('Settings') }}</a></p>
{% endif %}
{% if community.is_local() and (community.is_owner() or current_user.is_admin()) %}
<p><a class="btn btn-primary btn-warning" href="{{ url_for('community.community_delete', community_id=community.id) }}" rel="nofollow">Delete community</a></p>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View file

@ -227,8 +227,15 @@
<h2>{{ _('Community Settings') }}</h2> <h2>{{ _('Community Settings') }}</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p> {% if is_moderator or is_owner or is_admin %}
<p><a href="#" class="btn btn-primary">{{ _('Settings') }}</a></p> <p><a href="/community/{{ community.link() }}/moderate" class="btn btn-primary">{{ _('Moderate') }}</a></p>
{% endif %}
{% if is_owner or is_admin %}
<p><a href="{{ url_for('community.community_edit', community_id=community.id) }}" class="btn btn-primary">{{ _('Settings') }}</a></p>
{% endif %}
{% if community.is_local() and (community.is_owner() or current_user.is_admin()) %}
<p><a class="btn btn-primary btn-warning" href="{{ url_for('community.community_delete', community_id=community.id) }}" rel="nofollow">Delete community</a></p>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View file

@ -78,8 +78,15 @@
<h2>{{ _('Community Settings') }}</h2> <h2>{{ _('Community Settings') }}</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p> {% if is_moderator or is_owner or is_admin %}
<p><a href="#" class="btn btn-primary">{{ _('Settings') }}</a></p> <p><a href="/community/{{ community.link() }}/moderate" class="btn btn-primary">{{ _('Moderate') }}</a></p>
{% endif %}
{% if is_owner or is_admin %}
<p><a href="{{ url_for('community.community_edit', community_id=community.id) }}" class="btn btn-primary">{{ _('Settings') }}</a></p>
{% endif %}
{% if community.is_local() and (community.is_owner() or current_user.is_admin()) %}
<p><a class="btn btn-primary btn-warning" href="{{ url_for('community.community_delete', community_id=community.id) }}" rel="nofollow">Delete community</a></p>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View file

@ -159,7 +159,7 @@
{% if current_user.is_authenticated and (user_access('ban users', current_user.id) or user_access('manage users', current_user.id)) and user.id != current_user.id %} {% if current_user.is_authenticated and (user_access('ban users', current_user.id) or user_access('manage users', current_user.id)) and user.id != current_user.id %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<h2>{{ _('Crush') }}</h2> <h2>{{ _('Moderate user') }}</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">

View file

@ -34,6 +34,12 @@ def show_people():
joined_communities=joined_communities(current_user.get_id()), title=_('People')) joined_communities=joined_communities(current_user.get_id()), title=_('People'))
@bp.route('/user/<int:user_id>', methods=['GET'])
def show_profile_by_id(user_id):
user = User.query.get_or_404(user_id)
return show_profile(user)
def show_profile(user): def show_profile(user):
if (user.deleted or user.banned) and current_user.is_anonymous: if (user.deleted or user.banned) and current_user.is_anonymous:
abort(404) abort(404)
@ -332,7 +338,7 @@ def report_profile(actor):
if user and not user.banned: if user and not user.banned:
if form.validate_on_submit(): if form.validate_on_submit():
report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data,
type=0, reporter_id=current_user.id, suspect_user_id=user.id) type=0, reporter_id=current_user.id, suspect_user_id=user.id, source_instance_id=1)
db.session.add(report) db.session.add(report)
# Notify site admin # Notify site admin

View file

@ -214,41 +214,6 @@ def allowlist_html(html: str) -> str:
return str(soup) return str(soup)
# convert basic HTML to Markdown
def html_to_markdown(html: str) -> str:
soup = BeautifulSoup(html, 'html.parser')
return html_to_markdown_worker(soup)
def html_to_markdown_worker(element, indent_level=0):
formatted_text = ''
for item in element.contents:
if isinstance(item, str):
formatted_text += item
elif item.name == 'p':
formatted_text += '\n\n'
elif item.name == 'br':
formatted_text += ' \n' # Double space at the end for line break
elif item.name == 'strong':
formatted_text += '**' + html_to_markdown_worker(item) + '**'
elif item.name == 'ul':
formatted_text += '\n'
formatted_text += html_to_markdown_worker(item, indent_level + 1)
formatted_text += '\n'
elif item.name == 'ol':
formatted_text += '\n'
formatted_text += html_to_markdown_worker(item, indent_level + 1)
formatted_text += '\n'
elif item.name == 'li':
bullet = '-' if item.find_parent(['ul', 'ol']) and item.find_previous_sibling() is None else ''
formatted_text += ' ' * indent_level + bullet + ' ' + html_to_markdown_worker(item).strip() + '\n'
elif item.name == 'blockquote':
formatted_text += ' ' * indent_level + '> ' + html_to_markdown_worker(item).strip() + '\n'
elif item.name == 'code':
formatted_text += '`' + html_to_markdown_worker(item) + '`'
return formatted_text
def markdown_to_html(markdown_text) -> str: def markdown_to_html(markdown_text) -> str:
if markdown_text: if markdown_text:
return allowlist_html(markdown2.markdown(markdown_text, safe_mode=True, extras={'middle-word-em': False, 'tables': True, 'fenced-code-blocks': True, 'strike': True})) return allowlist_html(markdown2.markdown(markdown_text, safe_mode=True, extras={'middle-word-em': False, 'tables': True, 'fenced-code-blocks': True, 'strike': True}))
@ -262,6 +227,31 @@ def markdown_to_text(markdown_text) -> str:
return markdown_text.replace("# ", '') return markdown_text.replace("# ", '')
def microblog_content_to_title(html: str) -> str:
soup = BeautifulSoup(html, 'html.parser')
title_found = False
for tag in soup.find_all():
if tag.name == 'p':
if not title_found:
title_found = True
continue
else:
tag = tag.extract()
if title_found:
result = soup.text
if len(result) > 150:
for i in range(149, -1, -1):
if result[i] == ' ':
break;
result = result[:i] + ' ...' if i > 0 else ''
else:
result = ''
return result
def domain_from_url(url: str, create=True) -> Domain: def domain_from_url(url: str, create=True) -> Domain:
parsed_url = urlparse(url.lower().replace('www.', '')) parsed_url = urlparse(url.lower().replace('www.', ''))
if parsed_url and parsed_url.hostname: if parsed_url and parsed_url.hostname:
@ -670,7 +660,7 @@ def finalize_user_setup(user, application_required=False):
private_key, public_key = RsaKeys.generate_keypair() private_key, public_key = RsaKeys.generate_keypair()
user.private_key = private_key user.private_key = private_key
user.public_key = public_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_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.ap_inbox_url = f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}/inbox"
db.session.commit() db.session.commit()

View file

@ -0,0 +1,34 @@
"""report source
Revision ID: 04697ae91fac
Revises: 2b028a70bd7a
Create Date: 2024-03-26 22:13:16.749010
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '04697ae91fac'
down_revision = '2b028a70bd7a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('report', schema=None) as batch_op:
batch_op.add_column(sa.Column('source_instance_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_report_source_instance_id', 'instance', ['source_instance_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('report', schema=None) as batch_op:
batch_op.drop_constraint('fk_report_source_instance_id', type_='foreignkey')
batch_op.drop_column('source_instance_id')
# ### end Alembic commands ###