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, \
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/<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'])
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": []

View file

@ -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']:

View file

@ -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':

View file

@ -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()

View file

@ -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

View file

@ -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():

View file

@ -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)

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_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)
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, \
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/"))

View file

@ -16,4 +16,10 @@ SUBSCRIPTION_NONMEMBER = 0
SUBSCRIPTION_PENDING = -1
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}"
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):

View file

@ -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

View file

@ -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);

View file

@ -246,7 +246,7 @@
{% if post_layout == 'masonry' or post_layout == 'masonry_wide' %}
<!-- -->
{% 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 %}
{% if theme() and file_exists('app/templates/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">
{{ _('Reports') }}
</a>
<a href="/community/{{ community.link() }}/moderate/banned" class="btn {{ 'btn-primary' if current == 'banned' else 'btn-outline-secondary' }}" rel="nofollow noindex">
{{ _('Banned people') }}
</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') }}
</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') }}
</a>
</div>

View file

@ -175,14 +175,10 @@
<h2>{{ _('Community Settings') }}</h2>
</div>
<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 %}
<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>
<p><a href="{{ url_for('community.community_edit', community_id=community.id) }}" class="btn btn-primary">{{ _('Settings & Moderation') }}</a></p>
{% elif is_moderator %}
<p><a href="/community/{{ community.link() }}/moderate" class="btn btn-primary">{{ _('Moderation') }}</a></p>
{% endif %}
</div>
</div>

View file

@ -12,7 +12,9 @@
<div class="card-body p-6">
<div class="card-title">{{ _('Delete "%(community_title)s"', community_title=community.title) }}</div>
<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) }}
<a class="btn btn-primary mt-2" href="/c/{{ community.link() }}">Go back</a>
</div>
</div>
</div>

View file

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

View file

@ -16,10 +16,12 @@
<li class="breadcrumb-item active">{{ _('Moderators') }}</li>
</ol>
</nav>
{% include "community/_community_moderation_nav.html" %}
<div class="row">
<div class="col-12 col-md-10">
<h1 class="mt-2">{{ _('Moderators for %(community)s', community=community.display_name()) }}</h1>
</div>
<p>{{ _('See and change who moderates this community') }}</p>
<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>
</div>

View file

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

View file

@ -15,16 +15,17 @@
<li class="breadcrumb-item active">{{ _('Moderation') }}</li>
</ol>
</nav>
{% include "community/_community_moderation_nav.html" %}
<div class="row">
<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 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> -->
</div>
</div>
{% include "community/_community_moderation_nav.html" %}
<h2>{{ _('Banned people') }}</h2>
<p>{{ _('See and manage who is banned from %(community)s', community=community.display_name()) }}</p>
<h2></h2>
{% if banned_people %}
<form method="get">
<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>
</div>
<div class="card-body">
<p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p>
<p><a href="#" class="btn btn-primary">{{ _('Settings') }}</a></p>
{% if is_moderator or is_owner or is_admin %}
<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>
{% endif %}

View file

@ -133,8 +133,15 @@
<h2>{{ _('Community Settings') }}</h2>
</div>
<div class="card-body">
<p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p>
<p><a href="#" class="btn btn-primary">{{ _('Settings') }}</a></p>
{% 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 %}
<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>
{% endif %}

View file

@ -227,8 +227,15 @@
<h2>{{ _('Community Settings') }}</h2>
</div>
<div class="card-body">
<p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p>
<p><a href="#" class="btn btn-primary">{{ _('Settings') }}</a></p>
{% 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 %}
<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>
{% endif %}

View file

@ -78,8 +78,15 @@
<h2>{{ _('Community Settings') }}</h2>
</div>
<div class="card-body">
<p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p>
<p><a href="#" class="btn btn-primary">{{ _('Settings') }}</a></p>
{% 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 %}
<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>
{% 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 %}
<div class="card mb-3">
<div class="card-header">
<h2>{{ _('Crush') }}</h2>
<h2>{{ _('Moderate user') }}</h2>
</div>
<div class="card-body">
<div class="row">

View file

@ -34,6 +34,12 @@ def show_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):
if (user.deleted or user.banned) and current_user.is_anonymous:
abort(404)
@ -332,7 +338,7 @@ def report_profile(actor):
if user and not user.banned:
if form.validate_on_submit():
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)
# Notify site admin

View file

@ -214,41 +214,6 @@ def allowlist_html(html: str) -> str:
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:
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}))
@ -262,6 +227,31 @@ def markdown_to_text(markdown_text) -> str:
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:
parsed_url = urlparse(url.lower().replace('www.', ''))
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()
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"
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 ###