Merge remote-tracking branch 'upstream/main'

This commit is contained in:
rra 2024-03-18 14:37:07 +01:00
commit 47d8ef3019
34 changed files with 12086 additions and 63 deletions

View file

@ -19,6 +19,13 @@ from sqlalchemy_searchable import make_searchable
from config import Config
def get_locale():
try:
return request.accept_languages.best_match(current_app.config['LANGUAGES'])
except:
return 'en'
db = SQLAlchemy() # engine_options={'pool_size': 5, 'max_overflow': 10} # session_options={"autoflush": False}
migrate = Migrate()
login = LoginManager()
@ -27,7 +34,7 @@ login.login_message = _l('Please log in to access this page.')
mail = Mail()
bootstrap = Bootstrap5()
moment = Moment()
babel = Babel()
babel = Babel(locale_selector=get_locale)
cache = Cache()
celery = Celery(__name__, broker=Config.CELERY_BROKER_URL)
@ -130,11 +137,4 @@ def create_app(config_class=Config):
return app
def get_locale():
try:
return request.accept_languages.best_match(current_app.config['LANGUAGES'])
except:
return 'en_US'
from app import models

View file

@ -1104,7 +1104,10 @@ def post_ap(post_id):
def activities_json(type, id):
activity = ActivityPubLog.query.filter_by(activity_id=f"https://{current_app.config['SERVER_NAME']}/activities/{type}/{id}").first()
if activity:
activity_json = json.loads(activity.activity_json)
if activity.activity_json is not None:
activity_json = json.loads(activity.activity_json)
else:
activity_json = {}
resp = jsonify(activity_json)
resp.content_type = 'application/activity+json'
return resp

View file

@ -39,7 +39,7 @@ import arrow
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from flask import Request, current_app, g
from flask import Request, current_app
from datetime import datetime
from dateutil import parser
from pyld import jsonld
@ -82,8 +82,7 @@ def post_request(uri: str, body: dict | None, private_key: str, key_id: str, con
body['@context'] = default_context()
type = body['type'] if 'type' in body else ''
log = ActivityPubLog(direction='out', activity_type=type, result='processing', activity_id=body['id'], exception_message='')
if g.site.log_activitypub_json:
log.activity_json=json.dumps(body)
log.activity_json=json.dumps(body)
db.session.add(log)
db.session.commit()
try:

View file

@ -200,6 +200,7 @@ def instance_allowed(host: str) -> bool:
def find_actor_or_create(actor: str, create_if_not_found=True, community_only=False) -> Union[User, Community, None]:
actor_url = actor.strip()
actor = actor.strip().lower()
user = None
# actor parameter must be formatted as https://server/u/actor or https://server/c/actor
@ -244,10 +245,15 @@ def find_actor_or_create(actor: str, create_if_not_found=True, community_only=Fa
if create_if_not_found:
if actor.startswith('https://'):
try:
actor_data = get_request(actor, headers={'Accept': 'application/activity+json'})
actor_data = get_request(actor_url, headers={'Accept': 'application/activity+json'})
except requests.exceptions.ReadTimeout:
time.sleep(randint(3, 10))
actor_data = get_request(actor, headers={'Accept': 'application/activity+json'})
try:
actor_data = get_request(actor_url, headers={'Accept': 'application/activity+json'})
except requests.exceptions.ReadTimeout:
return None
except requests.exceptions.ConnectionError:
return None
if actor_data.status_code == 200:
actor_json = actor_data.json()
actor_data.close()
@ -589,21 +595,22 @@ def actor_json_to_model(activity_json, address, server):
return community
def post_json_to_model(post_json, user, community) -> Post:
def post_json_to_model(activity_log, post_json, user, community) -> Post:
try:
nsfl_in_title = '[NSFL]' in post_json['name'].upper() or '(NSFL)' in post_json['name'].upper()
post = Post(user_id=user.id, community_id=community.id,
title=html.unescape(post_json['name']),
comments_enabled=post_json['commentsEnabled'],
comments_enabled=post_json['commentsEnabled'] if 'commentsEnabled' in post_json else True,
sticky=post_json['stickied'] if 'stickied' in post_json else False,
nsfw=post_json['sensitive'],
nsfl=post_json['nsfl'] if 'nsfl' in post_json else False,
nsfl=post_json['nsfl'] if 'nsfl' in post_json else nsfl_in_title,
ap_id=post_json['id'],
type=constants.POST_TYPE_ARTICLE,
posted_at=post_json['published'],
last_active=post_json['published'],
instance_id=user.instance_id
instance_id=user.instance_id,
indexable = user.indexable
)
post.indexable = user.indexable
if 'source' in post_json and \
post_json['source']['mediaType'] == 'text/markdown':
post.body = post_json['source']['content']
@ -616,8 +623,15 @@ def post_json_to_model(post_json, user, community) -> Post:
post.url = post_json['attachment'][0]['href']
if is_image_url(post.url):
post.type = POST_TYPE_IMAGE
if 'image' in post_json and 'url' in post_json['image']:
image = File(source_url=post_json['image']['url'])
else:
image = File(source_url=post.url)
db.session.add(image)
post.image = image
else:
post.type = POST_TYPE_LINK
post.url = remove_tracking_from_link(post.url)
domain = domain_from_url(post.url)
# notify about links to banned websites.
@ -637,10 +651,11 @@ def post_json_to_model(post_json, user, community) -> Post:
admin.unread_notifications += 1
if domain.banned:
post = None
activity_log.exception_message = domain.name + ' is blocked by admin'
if not domain.banned:
domain.post_count += 1
post.domain = domain
if 'image' in post_json and post:
if 'image' in post_json and post.image is None:
image = File(source_url=post_json['image']['url'])
db.session.add(image)
post.image = image
@ -648,7 +663,12 @@ def post_json_to_model(post_json, user, community) -> Post:
if post is not None:
db.session.add(post)
community.post_count += 1
activity_log.result = 'success'
db.session.commit()
if post.image_id:
make_image_sizes(post.image_id, 150, 512, 'posts') # the 512 sized image is for masonry view
return post
except KeyError as e:
current_app.logger.error(f'KeyError in post_json_to_model: ' + str(post_json))
@ -679,6 +699,11 @@ def make_image_sizes_async(file_id, thumbnail_width, medium_width, directory):
source_image_response.close()
file_ext = os.path.splitext(file.source_url)[1]
# fall back to parsing the http content type if the url does not contain a file extension
if file_ext == '':
content_type_parts = content_type.split('/')
if content_type_parts:
file_ext = '.' + content_type_parts[-1]
new_filename = gibberish(15)
@ -849,7 +874,7 @@ def refresh_instance_profile(instance_id: int):
@celery.task
def refresh_instance_profile_task(instance_id: int):
instance = Instance.query.get(instance_id)
if instance.updated_at < utcnow() - timedelta(days=7):
if instance.inbox is None or instance.updated_at < utcnow() - timedelta(days=7):
try:
instance_data = get_request(f"https://{instance.domain}", headers={'Accept': 'application/activity+json'})
except:
@ -911,6 +936,11 @@ def refresh_instance_profile_task(instance_id: int):
InstanceRole.instance_id == instance.id,
InstanceRole.role == 'admin').delete()
db.session.commit()
elif instance_data.status_code == 406: # Mastodon does this
instance.software = 'Mastodon'
instance.inbox = f"https://{instance.domain}/inbox"
instance.updated_at = utcnow()
db.session.commit()
# alter the effect of upvotes based on their instance. Default to 1.0
@ -1133,6 +1163,7 @@ def create_post_reply(activity_log: ActivityPubLog, community: Community, in_rep
instance_id=user.instance_id)
# Get comment content. Lemmy and Kbin put this in different places.
if 'source' in request_json['object'] and isinstance(request_json['object']['source'], dict) and \
'mediaType' in request_json['object']['source'] and \
request_json['object']['source']['mediaType'] == 'text/markdown':
post_reply.body = request_json['object']['source']['content']
post_reply.body_html = markdown_to_html(post_reply.body)
@ -1214,7 +1245,7 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json
title=html.unescape(request_json['object']['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'],
nsfw=request_json['object']['sensitive'] if 'sensitive' in request_json['object'] else False,
nsfl=request_json['object']['nsfl'] if 'nsfl' in request_json['object'] else nsfl_in_title,
ap_id=request_json['object']['id'],
ap_create_id=request_json['id'],
@ -1264,17 +1295,17 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json
url=post.ap_id, user_id=admin.id,
author_id=user.id)
db.session.add(notify)
if domain.banned:
post = None
activity_log.exception_message = domain.name + ' is blocked by admin'
if not domain.banned:
domain.post_count += 1
post.domain = domain
if 'image' in request_json['object'] and post.image is None:
image = File(source_url=request_json['object']['image']['url'])
db.session.add(image)
post.image = image
else:
post = None
activity_log.exception_message = domain.name + ' is blocked by admin'
if post is not None:
if 'image' in request_json['object'] and post.image is None:
image = File(source_url=request_json['object']['image']['url'])
db.session.add(image)
post.image = image
db.session.add(post)
post.ranking = post_ranking(post.score, post.posted_at)
community.post_count += 1
@ -1331,7 +1362,8 @@ def update_post_from_activity(post: Post, request_json: dict):
post.body = html_to_markdown(post.body_html)
if 'attachment' in request_json['object'] and 'href' in request_json['object']['attachment']:
post.url = request_json['object']['attachment']['href']
post.nsfw = request_json['object']['sensitive']
if 'sensitive' in request_json['object']:
post.nsfw = request_json['object']['sensitive']
nsfl_in_title = '[NSFL]' in request_json['object']['name'].upper() or '(NSFL)' in request_json['object']['name'].upper()
if 'nsfl' in request_json['object'] or nsfl_in_title:
post.nsfl = request_json['object']['nsfl']

View file

@ -86,7 +86,11 @@ def register(app):
db.session.add(Settings(name='registration_open', value=json.dumps(True)))
db.session.add(Settings(name='approve_registrations', value=json.dumps(False)))
db.session.add(Settings(name='federation', value=json.dumps(True)))
banned_instances = ['anonib.al','lemmygrad.ml', 'gab.com', 'rqd2.net', 'exploding-heads.com', 'hexbear.net', 'threads.net', 'pieville.net', 'noauthority.social', 'pieville.net', 'links.hackliberty.org']
banned_instances = ['anonib.al','lemmygrad.ml', 'gab.com', 'rqd2.net', 'exploding-heads.com', 'hexbear.net',
'threads.net', 'noauthority.social', 'pieville.net', 'links.hackliberty.org',
'poa.st', 'freespeechextremist.com', 'bae.st', 'nicecrew.digital', 'detroitriotcity.com',
'pawoo.net', 'shitposter.club', 'spinster.xyz', 'catgirl.life', 'gameliberty.club',
'yggdrasil.social', 'beefyboys.win', 'brighteon.social', 'cum.salon']
for bi in banned_instances:
db.session.add(BannedInstances(domain=bi))
print("Added banned instance", bi)

View file

@ -1,7 +1,6 @@
from flask import request, g
from flask_login import current_user
from flask_wtf import FlaskForm
from validators import Min
from wtforms import StringField, SubmitField, TextAreaField, BooleanField, HiddenField, SelectField, FileField, \
DateField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional

View file

@ -121,7 +121,13 @@ def show_community(community: Community):
page = request.args.get('page', 1, type=int)
sort = request.args.get('sort', '' if current_user.is_anonymous else current_user.default_sort)
low_bandwidth = request.cookies.get('low_bandwidth', '0') == '1'
post_layout = request.args.get('layout', community.default_layout if not low_bandwidth else None)
if low_bandwidth:
post_layout = None
else:
if community.default_layout is not None:
post_layout = request.args.get('layout', community.default_layout)
else:
post_layout = request.args.get('layout', 'list')
# If nothing has changed since their last visit, return HTTP 304
current_etag = f"{community.id}{sort}{post_layout}_{hash(community.last_active)}"
@ -846,3 +852,59 @@ def community_notification(community_id: int):
db.session.commit()
return render_template('community/_notification_toggle.html', community=community)
@bp.route('/<actor>/moderate', methods=['GET'])
@login_required
def community_moderate(actor):
community = actor_to_community(actor)
if community is not None:
if community.is_moderator() or current_user.is_admin():
page = request.args.get('page', 1, type=int)
search = request.args.get('search', '')
local_remote = request.args.get('local_remote', '')
reports = Report.query.filter_by(status=0, in_community_id=community.id)
if local_remote == 'local':
reports = reports.filter_by(ap_id=None)
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)
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
return render_template('community/community_moderate.html', title=_('Moderation of %(community)s', community=community.display_name()),
community=community, reports=reports, current='reports',
next_url=next_url, prev_url=prev_url,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
else:
abort(401)
else:
abort(404)
@bp.route('/<actor>/moderate/banned', methods=['GET'])
@login_required
def community_moderate_banned(actor):
community = actor_to_community(actor)
if community is not None:
if community.is_moderator() or current_user.is_admin():
banned_people = User.query.join(CommunityBan, CommunityBan.user_id == User.id).filter(CommunityBan.community_id == community.id).all()
return render_template('community/community_moderate_banned.html',
title=_('People banned from of %(community)s', community=community.display_name()),
community=community, banned_people=banned_people, current='banned',
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
else:
abort(401)
else:
abort(404)

View file

@ -4,7 +4,7 @@ from time import sleep
from typing import List
import requests
from PIL import Image, ImageOps
from flask import request, abort, g, current_app
from flask import request, abort, g, current_app, json
from flask_login import current_user
from pillow_heif import register_heif_opener
@ -13,11 +13,11 @@ from app.activitypub.signature import post_request
from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model, default_context
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
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, \
remove_tracking_from_link, ap_datetime, instance_banned
from sqlalchemy import func
from sqlalchemy import func, desc
import os
@ -100,19 +100,26 @@ def retrieve_mods_and_backfill(community_id: int):
activities_processed = 0
for activity in outbox_data['orderedItems']:
user = find_actor_or_create(activity['object']['actor'])
activity_log = ActivityPubLog(direction='in', activity_id=activity['id'], activity_type='Announce', result='failure')
if site.log_activitypub_json:
activity_log.activity_json = json.dumps(activity)
db.session.add(activity_log)
if user:
post = post_json_to_model(activity['object']['object'], user, community)
post = post_json_to_model(activity_log, activity['object']['object'], user, community)
post.ap_create_id = activity['object']['id']
post.ap_announce_id = activity['id']
post.ranking = post_ranking(post.score, post.posted_at)
db.session.commit()
else:
activity_log.exception_message = 'Could not find or create actor'
db.session.commit()
activities_processed += 1
if activities_processed >= 50:
break
c = Community.query.get(community.id)
c.post_count = activities_processed
c.last_active = site.last_active = utcnow()
if c.post_count > 0:
c.last_active = Post.query.filter(Post.community_id == community_id).order_by(desc(Post.posted_at)).first().posted_at
db.session.commit()

View file

@ -374,7 +374,7 @@ class Community(db.Model):
def user_is_banned(self, user):
membership = CommunityMember.query.filter(CommunityMember.community_id == self.id, CommunityMember.user_id == user.id).first()
if membership.is_banned:
if membership and membership.is_banned:
return True
banned = CommunityBan.query.filter(CommunityBan.community_id == self.id, CommunityBan.user_id == user.id).first()
if banned:
@ -1120,11 +1120,12 @@ class Report(db.Model):
status = db.Column(db.Integer, default=0)
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('user.id'))
suspect_community_id = db.Column(db.Integer, db.ForeignKey('community.id'))
suspect_user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
suspect_post_id = db.Column(db.Integer, db.ForeignKey('post.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'))
in_community_id = db.Column(db.Integer, db.ForeignKey('community.id'))
created_at = db.Column(db.DateTime, default=utcnow)
updated = db.Column(db.DateTime, default=utcnow)

View file

@ -224,7 +224,7 @@ def show_post(post_id: int):
joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
response.headers.set('Vary', 'Accept, Cookie')
response.headers.set('Vary', 'Accept, Cookie, Accept-Language')
return response
@ -416,7 +416,7 @@ def continue_discussion(post_id, comment_id):
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), community=post.community,
inoculation=inoculation[randint(0, len(inoculation) - 1)])
response.headers.set('Vary', 'Accept, Cookie')
response.headers.set('Vary', 'Accept, Cookie, Accept-Language')
return response
@ -795,7 +795,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)
suspect_community_id=post.community.id, in_community_id=post.community.id)
db.session.add(report)
# Notify moderators

View file

@ -0,0 +1,198 @@
/*!
* baguetteBox.js
* @author feimosi
* @version 1.11.1
* @url https://github.com/feimosi/baguetteBox.js
*/
#baguetteBox-overlay {
display: none;
opacity: 0;
position: fixed;
overflow: hidden;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000000;
background-color: #222;
background-color: rgba(0, 0, 0, 0.8);
-webkit-transition: opacity .5s ease;
transition: opacity .5s ease; }
#baguetteBox-overlay.visible {
opacity: 1; }
#baguetteBox-overlay .full-image {
display: inline-block;
position: relative;
width: 100%;
height: 100%;
text-align: center; }
#baguetteBox-overlay .full-image figure {
display: inline;
margin: 0;
height: 100%; }
#baguetteBox-overlay .full-image img {
display: inline-block;
width: auto;
height: auto;
max-height: 100%;
max-width: 100%;
vertical-align: middle;
-webkit-box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
-moz-box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.6); }
#baguetteBox-overlay .full-image figcaption {
display: block;
position: absolute;
bottom: 0;
width: 100%;
text-align: center;
line-height: 1.8;
white-space: normal;
color: #ccc;
background-color: #000;
background-color: rgba(0, 0, 0, 0.6);
font-family: sans-serif; }
#baguetteBox-overlay .full-image:before {
content: "";
display: inline-block;
height: 50%;
width: 1px;
margin-right: -1px; }
#baguetteBox-slider {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
white-space: nowrap;
-webkit-transition: left .4s ease, -webkit-transform .4s ease;
transition: left .4s ease, -webkit-transform .4s ease;
transition: left .4s ease, transform .4s ease;
transition: left .4s ease, transform .4s ease, -webkit-transform .4s ease, -moz-transform .4s ease; }
#baguetteBox-slider.bounce-from-right {
-webkit-animation: bounceFromRight .4s ease-out;
animation: bounceFromRight .4s ease-out; }
#baguetteBox-slider.bounce-from-left {
-webkit-animation: bounceFromLeft .4s ease-out;
animation: bounceFromLeft .4s ease-out; }
@-webkit-keyframes bounceFromRight {
0% {
margin-left: 0; }
50% {
margin-left: -30px; }
100% {
margin-left: 0; } }
@keyframes bounceFromRight {
0% {
margin-left: 0; }
50% {
margin-left: -30px; }
100% {
margin-left: 0; } }
@-webkit-keyframes bounceFromLeft {
0% {
margin-left: 0; }
50% {
margin-left: 30px; }
100% {
margin-left: 0; } }
@keyframes bounceFromLeft {
0% {
margin-left: 0; }
50% {
margin-left: 30px; }
100% {
margin-left: 0; } }
.baguetteBox-button#next-button, .baguetteBox-button#previous-button {
top: 50%;
top: calc(50% - 30px);
width: 44px;
height: 60px; }
.baguetteBox-button {
position: absolute;
cursor: pointer;
outline: none;
padding: 0;
margin: 0;
border: 0;
-moz-border-radius: 15%;
border-radius: 15%;
background-color: #323232;
background-color: rgba(50, 50, 50, 0.5);
color: #ddd;
font: 1.6em sans-serif;
-webkit-transition: background-color .4s ease;
transition: background-color .4s ease; }
.baguetteBox-button:focus, .baguetteBox-button:hover {
background-color: rgba(50, 50, 50, 0.9); }
.baguetteBox-button#next-button {
right: 2%; }
.baguetteBox-button#previous-button {
left: 2%; }
.baguetteBox-button#close-button {
top: 20px;
right: 2%;
right: calc(2% + 6px);
width: 30px;
height: 30px; }
.baguetteBox-button svg {
position: absolute;
left: 0;
top: 0; }
/*
Preloader
Borrowed from http://tobiasahlin.com/spinkit/
*/
.baguetteBox-spinner {
width: 40px;
height: 40px;
display: inline-block;
position: absolute;
top: 50%;
left: 50%;
margin-top: -20px;
margin-left: -20px; }
.baguetteBox-double-bounce1,
.baguetteBox-double-bounce2 {
width: 100%;
height: 100%;
-moz-border-radius: 50%;
border-radius: 50%;
background-color: #fff;
opacity: .6;
position: absolute;
top: 0;
left: 0;
-webkit-animation: bounce 2s infinite ease-in-out;
animation: bounce 2s infinite ease-in-out; }
.baguetteBox-double-bounce2 {
-webkit-animation-delay: -1s;
animation-delay: -1s; }
@-webkit-keyframes bounce {
0%, 100% {
-webkit-transform: scale(0);
transform: scale(0); }
50% {
-webkit-transform: scale(1);
transform: scale(1); } }
@keyframes bounce {
0%, 100% {
-webkit-transform: scale(0);
-moz-transform: scale(0);
transform: scale(0); }
50% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
transform: scale(1); } }

View file

@ -0,0 +1,793 @@
/*!
* baguetteBox.js
* @author feimosi
* @version 1.11.1
* @url https://github.com/feimosi/baguetteBox.js
*/
/* global define, module */
(function (root, factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
define(factory);
} else if (typeof exports === 'object') {
module.exports = factory();
} else {
root.baguetteBox = factory();
}
}(this, function () {
'use strict';
// SVG shapes used on the buttons
var leftArrow = '<svg width="44" height="60">' +
'<polyline points="30 10 10 30 30 50" stroke="rgba(255,255,255,0.5)" stroke-width="4"' +
'stroke-linecap="butt" fill="none" stroke-linejoin="round"/>' +
'</svg>',
rightArrow = '<svg width="44" height="60">' +
'<polyline points="14 10 34 30 14 50" stroke="rgba(255,255,255,0.5)" stroke-width="4"' +
'stroke-linecap="butt" fill="none" stroke-linejoin="round"/>' +
'</svg>',
closeX = '<svg width="30" height="30">' +
'<g stroke="rgb(160,160,160)" stroke-width="4">' +
'<line x1="5" y1="5" x2="25" y2="25"/>' +
'<line x1="5" y1="25" x2="25" y2="5"/>' +
'</g></svg>';
// Global options and their defaults
var options = {},
defaults = {
captions: true,
buttons: 'auto',
fullScreen: false,
noScrollbars: false,
bodyClass: 'baguetteBox-open',
titleTag: false,
async: false,
preload: 2,
animation: 'slideIn',
afterShow: null,
afterHide: null,
onChange: null,
overlayBackgroundColor: 'rgba(0,0,0,.8)'
};
// Object containing information about features compatibility
var supports = {};
// DOM Elements references
var overlay, slider, previousButton, nextButton, closeButton;
// An array with all images in the current gallery
var currentGallery = [];
// Current image index inside the slider
var currentIndex = 0;
// Visibility of the overlay
var isOverlayVisible = false;
// Touch event start position (for slide gesture)
var touch = {};
// If set to true ignore touch events because animation was already fired
var touchFlag = false;
// Regex pattern to match image files
var regex = /.+\.(gif|jpe?g|png|webp)/i;
// Object of all used galleries
var data = {};
// Array containing temporary images DOM elements
var imagesElements = [];
// The last focused element before opening the overlay
var documentLastFocus = null;
var overlayClickHandler = function(event) {
// Close the overlay when user clicks directly on the background
if (event.target.id.indexOf('baguette-img') !== -1) {
hideOverlay();
}
};
var previousButtonClickHandler = function(event) {
event.stopPropagation ? event.stopPropagation() : event.cancelBubble = true; // eslint-disable-line no-unused-expressions
showPreviousImage();
};
var nextButtonClickHandler = function(event) {
event.stopPropagation ? event.stopPropagation() : event.cancelBubble = true; // eslint-disable-line no-unused-expressions
showNextImage();
};
var closeButtonClickHandler = function(event) {
event.stopPropagation ? event.stopPropagation() : event.cancelBubble = true; // eslint-disable-line no-unused-expressions
hideOverlay();
};
var touchstartHandler = function(event) {
touch.count++;
if (touch.count > 1) {
touch.multitouch = true;
}
// Save x and y axis position
touch.startX = event.changedTouches[0].pageX;
touch.startY = event.changedTouches[0].pageY;
};
var touchmoveHandler = function(event) {
// If action was already triggered or multitouch return
if (touchFlag || touch.multitouch) {
return;
}
event.preventDefault ? event.preventDefault() : event.returnValue = false; // eslint-disable-line no-unused-expressions
var touchEvent = event.touches[0] || event.changedTouches[0];
// Move at least 40 pixels to trigger the action
if (touchEvent.pageX - touch.startX > 40) {
touchFlag = true;
showPreviousImage();
} else if (touchEvent.pageX - touch.startX < -40) {
touchFlag = true;
showNextImage();
// Move 100 pixels up to close the overlay
} else if (touch.startY - touchEvent.pageY > 100) {
hideOverlay();
}
};
var touchendHandler = function() {
touch.count--;
if (touch.count <= 0) {
touch.multitouch = false;
}
touchFlag = false;
};
var contextmenuHandler = function() {
touchendHandler();
};
var trapFocusInsideOverlay = function(event) {
if (overlay.style.display === 'block' && (overlay.contains && !overlay.contains(event.target))) {
event.stopPropagation();
initFocus();
}
};
// forEach polyfill for IE8
// http://stackoverflow.com/a/14827443/1077846
/* eslint-disable */
if (![].forEach) {
Array.prototype.forEach = function(callback, thisArg) {
for (var i = 0; i < this.length; i++) {
callback.call(thisArg, this[i], i, this);
}
};
}
// filter polyfill for IE8
// https://gist.github.com/eliperelman/1031656
if (![].filter) {
Array.prototype.filter = function(a, b, c, d, e) {
c = this;
d = [];
for (e = 0; e < c.length; e++)
a.call(b, c[e], e, c) && d.push(c[e]);
return d;
};
}
/* eslint-enable */
// Script entry point
function run(selector, userOptions) {
// Fill supports object
supports.transforms = testTransformsSupport();
supports.svg = testSvgSupport();
supports.passiveEvents = testPassiveEventsSupport();
buildOverlay();
removeFromCache(selector);
return bindImageClickListeners(selector, userOptions);
}
function bindImageClickListeners(selector, userOptions) {
// For each gallery bind a click event to every image inside it
var galleryNodeList = document.querySelectorAll(selector);
var selectorData = {
galleries: [],
nodeList: galleryNodeList
};
data[selector] = selectorData;
[].forEach.call(galleryNodeList, function(galleryElement) {
if (userOptions && userOptions.filter) {
regex = userOptions.filter;
}
// Get nodes from gallery elements or single-element galleries
var tagsNodeList = [];
if (galleryElement.tagName === 'A') {
tagsNodeList = [galleryElement];
} else {
tagsNodeList = galleryElement.getElementsByTagName('a');
}
// Filter 'a' elements from those not linking to images
tagsNodeList = [].filter.call(tagsNodeList, function(element) {
if (element.className.indexOf(userOptions && userOptions.ignoreClass) === -1) {
return regex.test(element.href);
}
});
if (tagsNodeList.length === 0) {
return;
}
var gallery = [];
[].forEach.call(tagsNodeList, function(imageElement, imageIndex) {
var imageElementClickHandler = function(event) {
event.preventDefault ? event.preventDefault() : event.returnValue = false; // eslint-disable-line no-unused-expressions
prepareOverlay(gallery, userOptions);
showOverlay(imageIndex);
};
var imageItem = {
eventHandler: imageElementClickHandler,
imageElement: imageElement
};
bind(imageElement, 'click', imageElementClickHandler);
gallery.push(imageItem);
});
selectorData.galleries.push(gallery);
});
return selectorData.galleries;
}
function clearCachedData() {
for (var selector in data) {
if (data.hasOwnProperty(selector)) {
removeFromCache(selector);
}
}
}
function removeFromCache(selector) {
if (!data.hasOwnProperty(selector)) {
return;
}
var galleries = data[selector].galleries;
[].forEach.call(galleries, function(gallery) {
[].forEach.call(gallery, function(imageItem) {
unbind(imageItem.imageElement, 'click', imageItem.eventHandler);
});
if (currentGallery === gallery) {
currentGallery = [];
}
});
delete data[selector];
}
function buildOverlay() {
overlay = getByID('baguetteBox-overlay');
// Check if the overlay already exists
if (overlay) {
slider = getByID('baguetteBox-slider');
previousButton = getByID('previous-button');
nextButton = getByID('next-button');
closeButton = getByID('close-button');
return;
}
// Create overlay element
overlay = create('div');
overlay.setAttribute('role', 'dialog');
overlay.id = 'baguetteBox-overlay';
document.getElementsByTagName('body')[0].appendChild(overlay);
// Create gallery slider element
slider = create('div');
slider.id = 'baguetteBox-slider';
overlay.appendChild(slider);
// Create all necessary buttons
previousButton = create('button');
previousButton.setAttribute('type', 'button');
previousButton.id = 'previous-button';
previousButton.setAttribute('aria-label', 'Previous');
previousButton.innerHTML = supports.svg ? leftArrow : '&lt;';
overlay.appendChild(previousButton);
nextButton = create('button');
nextButton.setAttribute('type', 'button');
nextButton.id = 'next-button';
nextButton.setAttribute('aria-label', 'Next');
nextButton.innerHTML = supports.svg ? rightArrow : '&gt;';
overlay.appendChild(nextButton);
closeButton = create('button');
closeButton.setAttribute('type', 'button');
closeButton.id = 'close-button';
closeButton.setAttribute('aria-label', 'Close');
closeButton.innerHTML = supports.svg ? closeX : '&times;';
overlay.appendChild(closeButton);
previousButton.className = nextButton.className = closeButton.className = 'baguetteBox-button';
bindEvents();
}
function keyDownHandler(event) {
switch (event.keyCode) {
case 37: // Left arrow
showPreviousImage();
break;
case 39: // Right arrow
showNextImage();
break;
case 27: // Esc
hideOverlay();
break;
case 36: // Home
showFirstImage(event);
break;
case 35: // End
showLastImage(event);
break;
}
}
function bindEvents() {
var passiveEvent = supports.passiveEvents ? { passive: false } : null;
var nonPassiveEvent = supports.passiveEvents ? { passive: true } : null;
bind(overlay, 'click', overlayClickHandler);
bind(previousButton, 'click', previousButtonClickHandler);
bind(nextButton, 'click', nextButtonClickHandler);
bind(closeButton, 'click', closeButtonClickHandler);
bind(slider, 'contextmenu', contextmenuHandler);
bind(overlay, 'touchstart', touchstartHandler, nonPassiveEvent);
bind(overlay, 'touchmove', touchmoveHandler, passiveEvent);
bind(overlay, 'touchend', touchendHandler);
bind(document, 'focus', trapFocusInsideOverlay, true);
}
function unbindEvents() {
var passiveEvent = supports.passiveEvents ? { passive: false } : null;
var nonPassiveEvent = supports.passiveEvents ? { passive: true } : null;
unbind(overlay, 'click', overlayClickHandler);
unbind(previousButton, 'click', previousButtonClickHandler);
unbind(nextButton, 'click', nextButtonClickHandler);
unbind(closeButton, 'click', closeButtonClickHandler);
unbind(slider, 'contextmenu', contextmenuHandler);
unbind(overlay, 'touchstart', touchstartHandler, nonPassiveEvent);
unbind(overlay, 'touchmove', touchmoveHandler, passiveEvent);
unbind(overlay, 'touchend', touchendHandler);
unbind(document, 'focus', trapFocusInsideOverlay, true);
}
function prepareOverlay(gallery, userOptions) {
// If the same gallery is being opened prevent from loading it once again
if (currentGallery === gallery) {
return;
}
currentGallery = gallery;
// Update gallery specific options
setOptions(userOptions);
// Empty slider of previous contents (more effective than .innerHTML = "")
while (slider.firstChild) {
slider.removeChild(slider.firstChild);
}
imagesElements.length = 0;
var imagesFiguresIds = [];
var imagesCaptionsIds = [];
// Prepare and append images containers and populate figure and captions IDs arrays
for (var i = 0, fullImage; i < gallery.length; i++) {
fullImage = create('div');
fullImage.className = 'full-image';
fullImage.id = 'baguette-img-' + i;
imagesElements.push(fullImage);
imagesFiguresIds.push('baguetteBox-figure-' + i);
imagesCaptionsIds.push('baguetteBox-figcaption-' + i);
slider.appendChild(imagesElements[i]);
}
overlay.setAttribute('aria-labelledby', imagesFiguresIds.join(' '));
overlay.setAttribute('aria-describedby', imagesCaptionsIds.join(' '));
}
function setOptions(newOptions) {
if (!newOptions) {
newOptions = {};
}
// Fill options object
for (var item in defaults) {
options[item] = defaults[item];
if (typeof newOptions[item] !== 'undefined') {
options[item] = newOptions[item];
}
}
/* Apply new options */
// Change transition for proper animation
slider.style.transition = slider.style.webkitTransition = (options.animation === 'fadeIn' ? 'opacity .4s ease' :
options.animation === 'slideIn' ? '' : 'none');
// Hide buttons if necessary
if (options.buttons === 'auto' && ('ontouchstart' in window || currentGallery.length === 1)) {
options.buttons = false;
}
// Set buttons style to hide or display them
previousButton.style.display = nextButton.style.display = (options.buttons ? '' : 'none');
// Set overlay color
try {
overlay.style.backgroundColor = options.overlayBackgroundColor;
} catch (e) {
// Silence the error and continue
}
}
function showOverlay(chosenImageIndex) {
if (options.noScrollbars) {
document.documentElement.style.overflowY = 'hidden';
document.body.style.overflowY = 'scroll';
}
if (overlay.style.display === 'block') {
return;
}
bind(document, 'keydown', keyDownHandler);
currentIndex = chosenImageIndex;
touch = {
count: 0,
startX: null,
startY: null
};
loadImage(currentIndex, function() {
preloadNext(currentIndex);
preloadPrev(currentIndex);
});
updateOffset();
overlay.style.display = 'block';
if (options.fullScreen) {
enterFullScreen();
}
// Fade in overlay
setTimeout(function() {
overlay.className = 'visible';
if (options.bodyClass && document.body.classList) {
document.body.classList.add(options.bodyClass);
}
if (options.afterShow) {
options.afterShow();
}
}, 50);
if (options.onChange) {
options.onChange(currentIndex, imagesElements.length);
}
documentLastFocus = document.activeElement;
initFocus();
isOverlayVisible = true;
}
function initFocus() {
if (options.buttons) {
previousButton.focus();
} else {
closeButton.focus();
}
}
function enterFullScreen() {
if (overlay.requestFullscreen) {
overlay.requestFullscreen();
} else if (overlay.webkitRequestFullscreen) {
overlay.webkitRequestFullscreen();
} else if (overlay.mozRequestFullScreen) {
overlay.mozRequestFullScreen();
}
}
function exitFullscreen() {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
}
function hideOverlay() {
if (options.noScrollbars) {
document.documentElement.style.overflowY = 'auto';
document.body.style.overflowY = 'auto';
}
if (overlay.style.display === 'none') {
return;
}
unbind(document, 'keydown', keyDownHandler);
// Fade out and hide the overlay
overlay.className = '';
setTimeout(function() {
overlay.style.display = 'none';
if (document.fullscreen) {
exitFullscreen();
}
if (options.bodyClass && document.body.classList) {
document.body.classList.remove(options.bodyClass);
}
if (options.afterHide) {
options.afterHide();
}
documentLastFocus && documentLastFocus.focus();
isOverlayVisible = false;
}, 500);
}
function loadImage(index, callback) {
var imageContainer = imagesElements[index];
var galleryItem = currentGallery[index];
// Return if the index exceeds prepared images in the overlay
// or if the current gallery has been changed / closed
if (typeof imageContainer === 'undefined' || typeof galleryItem === 'undefined') {
return;
}
// If image is already loaded run callback and return
if (imageContainer.getElementsByTagName('img')[0]) {
if (callback) {
callback();
}
return;
}
// Get element reference, optional caption and source path
var imageElement = galleryItem.imageElement;
var thumbnailElement = imageElement.getElementsByTagName('img')[0];
var imageCaption = typeof options.captions === 'function' ?
options.captions.call(currentGallery, imageElement) :
imageElement.getAttribute('data-caption') || imageElement.title;
var imageSrc = getImageSrc(imageElement);
// Prepare figure element
var figure = create('figure');
figure.id = 'baguetteBox-figure-' + index;
figure.innerHTML = '<div class="baguetteBox-spinner">' +
'<div class="baguetteBox-double-bounce1"></div>' +
'<div class="baguetteBox-double-bounce2"></div>' +
'</div>';
// Insert caption if available
if (options.captions && imageCaption) {
var figcaption = create('figcaption');
figcaption.id = 'baguetteBox-figcaption-' + index;
figcaption.innerHTML = imageCaption;
figure.appendChild(figcaption);
}
imageContainer.appendChild(figure);
// Prepare gallery img element
var image = create('img');
image.onload = function() {
// Remove loader element
var spinner = document.querySelector('#baguette-img-' + index + ' .baguetteBox-spinner');
figure.removeChild(spinner);
if (!options.async && callback) {
callback();
}
};
image.setAttribute('src', imageSrc);
image.alt = thumbnailElement ? thumbnailElement.alt || '' : '';
if (options.titleTag && imageCaption) {
image.title = imageCaption;
}
figure.appendChild(image);
// Run callback
if (options.async && callback) {
callback();
}
}
// Get image source location, mostly used for responsive images
function getImageSrc(image) {
// Set default image path from href
var result = image.href;
// If dataset is supported find the most suitable image
if (image.dataset) {
var srcs = [];
// Get all possible image versions depending on the resolution
for (var item in image.dataset) {
if (item.substring(0, 3) === 'at-' && !isNaN(item.substring(3))) {
srcs[item.replace('at-', '')] = image.dataset[item];
}
}
// Sort resolutions ascending
var keys = Object.keys(srcs).sort(function(a, b) {
return parseInt(a, 10) < parseInt(b, 10) ? -1 : 1;
});
// Get real screen resolution
var width = window.innerWidth * window.devicePixelRatio;
// Find the first image bigger than or equal to the current width
var i = 0;
while (i < keys.length - 1 && keys[i] < width) {
i++;
}
result = srcs[keys[i]] || result;
}
return result;
}
// Return false at the right end of the gallery
function showNextImage() {
return show(currentIndex + 1);
}
// Return false at the left end of the gallery
function showPreviousImage() {
return show(currentIndex - 1);
}
// Return false at the left end of the gallery
function showFirstImage(event) {
if (event) {
event.preventDefault();
}
return show(0);
}
// Return false at the right end of the gallery
function showLastImage(event) {
if (event) {
event.preventDefault();
}
return show(currentGallery.length - 1);
}
/**
* Move the gallery to a specific index
* @param `index` {number} - the position of the image
* @param `gallery` {array} - gallery which should be opened, if omitted assumes the currently opened one
* @return {boolean} - true on success or false if the index is invalid
*/
function show(index, gallery) {
if (!isOverlayVisible && index >= 0 && index < gallery.length) {
prepareOverlay(gallery, options);
showOverlay(index);
return true;
}
if (index < 0) {
if (options.animation) {
bounceAnimation('left');
}
return false;
}
if (index >= imagesElements.length) {
if (options.animation) {
bounceAnimation('right');
}
return false;
}
currentIndex = index;
loadImage(currentIndex, function() {
preloadNext(currentIndex);
preloadPrev(currentIndex);
});
updateOffset();
if (options.onChange) {
options.onChange(currentIndex, imagesElements.length);
}
return true;
}
/**
* Triggers the bounce animation
* @param {('left'|'right')} direction - Direction of the movement
*/
function bounceAnimation(direction) {
slider.className = 'bounce-from-' + direction;
setTimeout(function() {
slider.className = '';
}, 400);
}
function updateOffset() {
var offset = -currentIndex * 100 + '%';
if (options.animation === 'fadeIn') {
slider.style.opacity = 0;
setTimeout(function() {
supports.transforms ?
slider.style.transform = slider.style.webkitTransform = 'translate3d(' + offset + ',0,0)'
: slider.style.left = offset;
slider.style.opacity = 1;
}, 400);
} else {
supports.transforms ?
slider.style.transform = slider.style.webkitTransform = 'translate3d(' + offset + ',0,0)'
: slider.style.left = offset;
}
}
// CSS 3D Transforms test
function testTransformsSupport() {
var div = create('div');
return typeof div.style.perspective !== 'undefined' || typeof div.style.webkitPerspective !== 'undefined';
}
// Inline SVG test
function testSvgSupport() {
var div = create('div');
div.innerHTML = '<svg/>';
return (div.firstChild && div.firstChild.namespaceURI) === 'http://www.w3.org/2000/svg';
}
// Borrowed from https://github.com/seiyria/bootstrap-slider/pull/680/files
/* eslint-disable getter-return */
function testPassiveEventsSupport() {
var passiveEvents = false;
try {
var opts = Object.defineProperty({}, 'passive', {
get: function() {
passiveEvents = true;
}
});
window.addEventListener('test', null, opts);
} catch (e) { /* Silence the error and continue */ }
return passiveEvents;
}
/* eslint-enable getter-return */
function preloadNext(index) {
if (index - currentIndex >= options.preload) {
return;
}
loadImage(index + 1, function() {
preloadNext(index + 1);
});
}
function preloadPrev(index) {
if (currentIndex - index >= options.preload) {
return;
}
loadImage(index - 1, function() {
preloadPrev(index - 1);
});
}
function bind(element, event, callback, options) {
if (element.addEventListener) {
element.addEventListener(event, callback, options);
} else {
// IE8 fallback
element.attachEvent('on' + event, function(event) {
// `event` and `event.target` are not provided in IE8
event = event || window.event;
event.target = event.target || event.srcElement;
callback(event);
});
}
}
function unbind(element, event, callback, options) {
if (element.removeEventListener) {
element.removeEventListener(event, callback, options);
} else {
// IE8 fallback
element.detachEvent('on' + event, callback);
}
}
function getByID(id) {
return document.getElementById(id);
}
function create(element) {
return document.createElement(element);
}
function destroyPlugin() {
unbindEvents();
clearCachedData();
unbind(document, 'keydown', keyDownHandler);
document.getElementsByTagName('body')[0].removeChild(document.getElementById('baguetteBox-overlay'));
data = {};
currentGallery = [];
currentIndex = 0;
}
return {
run: run,
show: show,
showNext: showNextImage,
showPrevious: showPreviousImage,
hide: hideOverlay,
destroy: destroyPlugin
};
}));

View file

@ -12,8 +12,37 @@ document.addEventListener("DOMContentLoaded", function () {
setupTopicChooser();
setupConversationChooser();
setupMarkdownEditorEnabler();
setupLightboxGallery();
});
function setupLightboxGallery() {
// Check if there are elements with either "post_list_masonry_wide" or "post_list_masonry" class
var widePosts = document.querySelectorAll('.post_list_masonry_wide');
var regularPosts = document.querySelectorAll('.post_list_masonry');
// Enable lightbox on masonry images
if (widePosts.length > 0) {
baguetteBox.run('.post_list_masonry_wide', {
fullScreen: false,
titleTag: true,
preload: 5,
captions: function(element) {
return element.getElementsByTagName('img')[0].title;
}
});
}
if (regularPosts.length > 0) {
baguetteBox.run('.post_list_masonry', {
fullScreen: false,
titleTag: true,
preload: 5,
captions: function(element) {
return element.getElementsByTagName('img')[0].title;
}
});
}
}
// fires after all resources have loaded, including stylesheets and js files
window.addEventListener("load", function () {

View file

@ -825,6 +825,7 @@ fieldset legend {
.post_image img {
max-width: 100%;
max-height: 90vh;
}
.render_username {

View file

@ -459,6 +459,7 @@ html {
.post_image {
img {
max-width: 100%;
max-height: 90vh;
}
}

View file

@ -43,6 +43,7 @@
<link href="{{ url_for('static', filename='themes/high_contrast/styles.css') }}" type="text/css" rel="alternate stylesheet" title="High contrast" />
{% if not low_bandwidth %}
<link href="{{ url_for('static', filename='js/markdown/downarea.css') }}" type="text/css" rel="stylesheet" />
<link href="{{ url_for('static', filename='js/lightbox/baguetteBox.css') }}" type="text/css" rel="stylesheet" />
{% endif %}
{% if theme() and file_exists('app/templates/themes/' + theme() + '/styles.css') %}
<link href="{{ url_for('static', filename='themes/' + theme() + '/styles.css') }}" type="text/css" rel="stylesheet" />
@ -234,10 +235,14 @@
{% endblock %}
{% if not low_bandwidth %}
{{ str(bootstrap.load_js()).replace('<script ', '<script nonce="' + session['nonce'] + '" ')|safe }}
<script src="{{ url_for('static', filename='js/lightbox/baguetteBox.js') }}" nonce="{{ session['nonce'] }}"></script>
{% endif %}
<script type="text/javascript" src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/scripts.js', changed=getmtime('js/scripts.js')) }}"></script>
{% if not low_bandwidth %}
{% if post_layout == 'masonry' or post_layout == 'masonry_wide' %}
<!-- -->
{% endif %}
<script type="text/javascript" src="{{ url_for('static', filename='js/markdown/downarea.js') }}"></script>
{% endif %}
{% if theme() and file_exists('app/templates/themes/' + theme() + '/scripts.js') %}

View file

@ -0,0 +1,14 @@
<div class="btn-group mt-1 mb-2">
<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">
{{ _('Appeals') }}
</a>
<a href="/community/{{ community.link() }}/moderate/modlog" class="btn {{ 'btn-primary' if current == 'modlog' else 'btn-outline-secondary' }}" rel="nofollow noindex">
{{ _('Mod log') }}
</a>
</div>

View file

@ -175,7 +175,9 @@
<h2>{{ _('Community Settings') }}</h2>
</div>
<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="/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 %}

View file

@ -0,0 +1,84 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_field %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not none else community.name) }}">{{ (community.title + '@' + community.ap_domain)|shorten }}</a></li>
<li class="breadcrumb-item active">{{ _('Moderation') }}</li>
</ol>
</nav>
<div class="row">
<div class="col-12 col-md-10">
<h1 class="mt-2">{{ _('Moderation of %(community)s', community=community.display_name()) }}</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>
{% if reports.items %}
<form method="get">
<input type="search" name="search" value="{{ search }}">
<input type="radio" name="local_remote" value="local" id="local_remote_local" {{ 'checked' if local_remote == 'local' }}><label for="local_remote_local"> Local</label>
<input type="radio" name="local_remote" value="remote" id="local_remote_remote" {{ 'checked' if local_remote == 'remote' }}><label for="local_remote_remote"> Remote</label>
<input type="submit" name="submit" value="Search" class="btn btn-primary">
</form>
<table class="table table-striped">
<tr>
<th>Local/Remote</th>
<th>Reasons</th>
<th>Description</th>
<th>Type</th>
<th>Created</th>
<th>Actions</th>
</tr>
{% for report in reports.items %}
<tr>
<td>{{ 'Local' if report.is_local() else 'Remote' }}</td>
<td>{{ report.reasons }}</td>
<td>{{ report.description }}</td>
<td>{{ report.type_text() }}</td>
<td>{{ moment(report.created_at).fromNow() }}</td>
<td>
{% if report.suspect_conversation_id %}
<a href="/chat/{{ report.suspect_conversation_id }}#message">View</a>
{% elif report.suspect_post_reply_id %}
<a href="/post/{{ report.suspect_post_id }}#comment_{{ report.suspect_post_reply_id }}">View</a>
{% elif report.suspect_post_id %}
<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 %}
</td>
</tr>
{% endfor %}
</table>
<nav aria-label="Pagination" class="mt-4" role="navigation">
{% if prev_url %}
<a href="{{ prev_url }}" class="btn btn-primary">
<span aria-hidden="true">&larr;</span> {{ _('Previous page') }}
</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}" class="btn btn-primary">
{{ _('Next page') }} <span aria-hidden="true">&rarr;</span>
</a>
{% endif %}
</nav>
{% else %}
<p>{{ _('No reports yet') }}</p>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,65 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_field %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not none else community.name) }}">{{ (community.title + '@' + community.ap_domain)|shorten }}</a></li>
<li class="breadcrumb-item active">{{ _('Moderation') }}</li>
</ol>
</nav>
<div class="row">
<div class="col-12 col-md-10">
<h1 class="mt-2">{{ _('Moderation of %(community)s', community=community.display_name()) }}</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>
{% if banned_people %}
<form method="get">
<input type="search" name="search" value="{{ search }}">
<input type="radio" name="local_remote" value="local" id="local_remote_local" {{ 'checked' if local_remote == 'local' }}><label for="local_remote_local"> Local</label>
<input type="radio" name="local_remote" value="remote" id="local_remote_remote" {{ 'checked' if local_remote == 'remote' }}><label for="local_remote_remote"> Remote</label>
<input type="submit" name="submit" value="Search" class="btn btn-primary">
</form>
<table class="table table-striped mt-1">
<tr>
<th>Name</th>
<th>Local/Remote</th>
<th>Reports</th>
<th>IP</th>
<th>Actions</th>
</tr>
{% for user in banned_people %}
<tr>
<td><img src="{{ user.avatar_thumbnail() }}" class="community_icon rounded-circle" loading="lazy" />
{{ user.display_name() }}</td>
<td>{{ 'Local' if user.is_local() else 'Remote' }}</td>
<td>{{ user.reports if user.reports > 0 }} </td>
<td>{{ user.ip_address if user.ip_address }} </td>
<td>{% if user.is_local() %}
<a href="/u/{{ user.link() }}">View local</a>
{% else %}
<a href="{{ user.ap_profile_id }}">View remote</a>
{% endif %}
| <a href="#" class="confirm_first">{{ _('Un ban') }}</a>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>{{ _('No banned people yet') }}</p>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -32,11 +32,10 @@
<div class="post_image">
{% if post.image_id %}
{% if low_bandwidth %}
<a href="{{ post.image.view_url() }}" rel="nofollow ugc"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text if post.image.alt_text else post.title }}"
<a href="{{ post.image.view_url() }}" rel="nofollow ugc"><img src="{{ post.image.medium_url() }}" alt="{{ post.image.alt_text if post.image.alt_text else post.title }}"
width="{{ post.image.width }}" height="{{ post.image.height }}" /></a>
{% else %}
<a href="{{ post.image.view_url() }}" rel="nofollow ugc"><img src="{{ post.image.view_url() }}" alt="{{ post.image.alt_text if post.image.alt_text else post.title }}"
width="{{ post.image.width }}" height="{{ post.image.height }}" /></a>
<a href="{{ post.image.view_url() }}" rel="nofollow ugc"><img src="{{ post.image.view_url() }}" alt="{{ post.image.alt_text if post.image.alt_text else post.title }}"></a>
{% endif %}
{% else %}
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="Go to image"><img src="{{ post.url }}" style="max-width: 100%; height: auto;" /></a>

View file

@ -14,20 +14,23 @@
{% if post.type == POST_TYPE_LINK %}
{% if post.image.medium_url() %}
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('View image') }}"><img src="{{ post.image.medium_url() }}"
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" loading="lazy" width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" /></a>
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" title="{{ post.title }}"
loading="lazy" width="{{ post.image.width }}" height="{{ post.image.height }}" /></a>
{% elif post.image.source_url %}
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('View image') }}"><img src="{{ post.image.source_url }}"
alt="{{ post.title }}" loading="lazy" /></a>
alt="{{ post.title }}" title="{{ post.title }}" loading="{{ 'lazy' if low_bandwidth else 'eager' }}" /></a>
{% else %}
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('View image') }}"><img src="{{ post.url }}"
alt="{{ post.title }}" loading="{{ 'lazy' if low_bandwidth else 'eager' }}" /></a>
alt="{{ post.title }}" title="{{ post.title }}"
loading="{{ 'lazy' if low_bandwidth else 'eager' }}" /></a>
{% endif %}
{% elif post.type == POST_TYPE_IMAGE %}
<a href="{{ post.image.view_url() }}" rel="nofollow ugc" target="_blank"><img src="{{ post.image.medium_url() }}"
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" loading="lazy" width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" /></a>
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" title="{{ post.title }}"
loading="lazy" width="{{ post.image.width }}" height="{{ post.image.height }}" /></a>
{% else %}
<a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}"><img src="{{ post.image.thumbnail_url() }}"
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" loading="lazy" /></a>
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" loading="{{ 'lazy' if low_bandwidth else 'eager' }}" /></a>
{% endif %}
</div>
<div class="masonry_info">
@ -49,8 +52,9 @@
{% else %}
{% if post.url and (post.url.endswith('.jpg') or post.url.endswith('.webp') or post.url.endswith('.png') or post.url.endswith('.gif') or post.url.endswith('.avif') or post.url.endswith('.jpeg')) %}
<div class="masonry_thumb" title="{{ post.title }}">
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('See image') }}"><img src="{{ post.url }}"
alt="{{ post.title }}" loading="{{ 'lazy' if low_bandwidth else 'eager' }}" /></a>
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('View image') }}"><img src="{{ post.url }}"
alt="{{ post.title }}" title="{{ post.title }}"
loading="{{ 'lazy' if low_bandwidth else 'eager' }}" /></a>
</div>
<div class="masonry_info">
<div class="row">

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,9 @@ import math
from urllib.parse import urlparse, parse_qs, urlencode
from functools import wraps
import flask
from bs4 import BeautifulSoup, NavigableString
from bs4 import BeautifulSoup, NavigableString, MarkupResemblesLocatorWarning
import warnings
warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning)
import requests
import os
from flask import current_app, json, redirect, url_for, request, make_response, Response, g
@ -57,7 +59,7 @@ def return_304(etag, content_type=None):
resp = make_response('', 304)
resp.headers.add_header('ETag', request.headers['If-None-Match'])
resp.headers.add_header('Cache-Control', 'no-cache, max-age=600, must-revalidate')
resp.headers.add_header('Vary', 'Accept, Cookie')
resp.headers.add_header('Vary', 'Accept, Cookie, Accept-Language')
if content_type:
resp.headers.set('Content-Type', content_type)
return resp
@ -91,6 +93,9 @@ def get_request(uri, params=None, headers=None) -> requests.Response:
except requests.exceptions.ReadTimeout as read_timeout:
current_app.logger.info(f"{uri} {read_timeout}")
raise requests.exceptions.ReadTimeout from read_timeout
except requests.exceptions.ConnectionError as connection_error:
current_app.logger.info(f"{uri} {connection_error}")
raise requests.exceptions.ConnectionError from connection_error
return response
@ -194,7 +199,7 @@ def allowlist_html(html: str) -> str:
else:
# Filter and sanitize attributes
for attr in list(tag.attrs):
if attr not in ['href', 'src', 'alt']:
if attr not in ['href', 'src', 'alt', 'class']:
del tag[attr]
# Add nofollow and target=_blank to anchors
if tag.name == 'a':
@ -246,7 +251,7 @@ def html_to_markdown_worker(element, indent_level=0):
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}))
return allowlist_html(markdown2.markdown(markdown_text, safe_mode=True, extras={'middle-word-em': False, 'tables': True, 'fenced-code-blocks': True, 'spoiler': True}))
else:
return ''

View file

@ -1,3 +1,2 @@
[python: app/**.py]
[jinja2: app/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_
[jinja2: app/templates/**.html]

6
celery_worker_docker.py Normal file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env python
import os
from app import celery, create_app
app = create_app()
app.app_context().push()

View file

@ -22,7 +22,7 @@ class Config(object):
RECAPTCHA_PUBLIC_KEY = os.environ.get("RECAPTCHA_PUBLIC_KEY")
RECAPTCHA_PRIVATE_KEY = os.environ.get("RECAPTCHA_PRIVATE_KEY")
MODE = os.environ.get('MODE') or 'development'
LANGUAGES = ['en']
LANGUAGES = ['de', 'en']
FULL_AP_CONTEXT = bool(int(os.environ.get('FULL_AP_CONTEXT', 0)))
CACHE_TYPE = os.environ.get('CACHE_TYPE') or 'FileSystemCache'
CACHE_REDIS_URL = os.environ.get('CACHE_REDIS_URL') or 'redis://localhost:6379/1'

View file

@ -4,6 +4,8 @@ date > updated.txt
sudo systemctl stop celery.service
git pull
source venv/bin/activate
export FLASK_APP=pyfedi.py
flask db upgrade
pybabel compile -d app/translations
sudo systemctl start celery.service
sudo systemctl restart pyfedi.service

View file

@ -16,3 +16,18 @@ python profile_app.py
instead of
flask run
translations:
See https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-xiii-i18n-and-l10n-2018
to add a new language which can be worked on:
pybabel init -i messages.pot -d app/translations -l <language code>
after changes to the files in app/translations/* are mode, they need to be compiled
pybabel compile -d app/translations

View file

@ -1,4 +1,4 @@
#!/bin/sh
celery -A celery_worker.celery worker --autoscale=5,1
celery -A celery_worker_docker.celery worker --autoscale=5,1

View file

@ -0,0 +1,38 @@
"""report in community
Revision ID: 81175e11c083
Revises: e72aa356e4d0
Create Date: 2024-03-18 20:37:43.216482
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '81175e11c083'
down_revision = 'e72aa356e4d0'
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('in_community_id', sa.Integer(), nullable=True))
batch_op.drop_constraint('report_suspect_community_id_fkey', type_='foreignkey')
batch_op.create_foreign_key(None, 'community', ['suspect_community_id'], ['id'])
batch_op.create_foreign_key(None, 'community', ['in_community_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(None, type_='foreignkey')
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.create_foreign_key('report_suspect_community_id_fkey', 'user', ['suspect_community_id'], ['id'])
batch_op.drop_column('in_community_id')
# ### end Alembic commands ###