Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Martynas Sklizmantas 2024-04-14 17:54:56 +02:00
commit 373eade750
75 changed files with 13520 additions and 2493 deletions

View file

@ -16,3 +16,4 @@ To build a federated discussion and link aggregation platform, similar to Reddit
- [Screencast: overview of the PieFed codebase](https://join.piefed.social/2024/01/22/an-introduction-to-the-piefed-codebase/) - [Screencast: overview of the PieFed codebase](https://join.piefed.social/2024/01/22/an-introduction-to-the-piefed-codebase/)
- [Database / entity relationship diagram](https://join.piefed.social/wp-content/uploads/2024/02/PieFed-entity-relationships.png) - [Database / entity relationship diagram](https://join.piefed.social/wp-content/uploads/2024/02/PieFed-entity-relationships.png)
- see [INSTALL.md](INSTALL.md) - see [INSTALL.md](INSTALL.md)
- see docs/project_management/* for a project roadmap, contributing guide and much more.

View file

@ -20,7 +20,8 @@ from app.activitypub.util import public_key, users_total, active_half_year, acti
lemmy_site_data, instance_weight, is_activitypub_request, downvote_post_reply, downvote_post, upvote_post_reply, \ lemmy_site_data, instance_weight, is_activitypub_request, downvote_post_reply, downvote_post, upvote_post_reply, \
upvote_post, delete_post_or_comment, community_members, \ upvote_post, delete_post_or_comment, community_members, \
user_removed_from_remote_server, create_post, create_post_reply, update_post_reply_from_activity, \ user_removed_from_remote_server, create_post, create_post_reply, update_post_reply_from_activity, \
update_post_from_activity, undo_vote, undo_downvote, post_to_page, get_redis_connection update_post_from_activity, undo_vote, undo_downvote, post_to_page, get_redis_connection, find_reported_object, \
process_report
from app.utils import gibberish, get_setting, is_image_url, allowlist_html, render_template, \ from app.utils import gibberish, get_setting, is_image_url, allowlist_html, render_template, \
domain_from_url, markdown_to_html, community_membership, ap_datetime, ip_address, can_downvote, \ domain_from_url, markdown_to_html, community_membership, ap_datetime, ip_address, can_downvote, \
can_upvote, can_create_post, awaken_dormant_instance, shorten_string, can_create_post_reply, sha256_digest, \ can_upvote, can_create_post, awaken_dormant_instance, shorten_string, can_create_post_reply, sha256_digest, \
@ -373,6 +374,7 @@ def shared_inbox():
redis_client.set(request_json['id'], 1, ex=90) # Save the activity ID into redis, to avoid duplicate activities that Lemmy sometimes sends redis_client.set(request_json['id'], 1, ex=90) # Save the activity ID into redis, to avoid duplicate activities that Lemmy sometimes sends
activity_log.activity_id = request_json['id'] activity_log.activity_id = request_json['id']
g.site = Site.query.get(1) # g.site is not initialized by @app.before_request when request.path == '/inbox'
if g.site.log_activitypub_json: if g.site.log_activitypub_json:
activity_log.activity_json = json.dumps(request_json) activity_log.activity_json = json.dumps(request_json)
activity_log.result = 'processing' activity_log.result = 'processing'
@ -990,6 +992,18 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
else: else:
activity_log.exception_message = 'Cannot downvote this' activity_log.exception_message = 'Cannot downvote this'
activity_log.result = 'ignored' activity_log.result = 'ignored'
elif request_json['type'] == 'Flag': # Reported content
activity_log.activity_type = 'Report'
user_ap_id = request_json['actor']
user = find_actor_or_create(user_ap_id)
target_ap_id = request_json['object']
reported = find_reported_object(target_ap_id)
if user and reported:
process_report(user, reported, request_json, activity_log)
activity_log.result = 'success'
else:
activity_log.exception_message = 'Report ignored due to missing user or content'
# Flush the caches of any major object that was created. To be sure. # Flush the caches of any major object that was created. To be sure.
if 'user' in vars() and user is not None: if 'user' in vars() and user is not None:
user.flush_cache() user.flush_cache()
@ -1053,6 +1067,9 @@ def process_delete_request(request_json, activitypublog_id, ip_address):
def announce_activity_to_followers(community, creator, activity): def announce_activity_to_followers(community, creator, activity):
# remove context from what will be inner object
del activity["@context"]
announce_activity = { announce_activity = {
'@context': default_context(), '@context': default_context(),
"actor": community.profile_id(), "actor": community.profile_id(),

View file

@ -12,7 +12,7 @@ from flask_babel import _
from sqlalchemy import text, func from sqlalchemy import text, func
from app import db, cache, constants, celery from app import db, cache, constants, celery
from app.models import User, Post, Community, BannedInstances, File, PostReply, AllowedInstances, Instance, utcnow, \ from app.models import User, Post, Community, BannedInstances, File, PostReply, AllowedInstances, Instance, utcnow, \
PostVote, PostReplyVote, ActivityPubLog, Notification, Site, CommunityMember, InstanceRole PostVote, PostReplyVote, ActivityPubLog, Notification, Site, CommunityMember, InstanceRole, Report, Conversation
import time import time
import base64 import base64
import requests import requests
@ -223,6 +223,8 @@ def banned_user_agents():
@cache.memoize(150) @cache.memoize(150)
def instance_blocked(host: str) -> bool: # see also utils.instance_banned() def instance_blocked(host: str) -> bool: # see also utils.instance_banned()
if host is None or host == '':
return True
host = host.lower() host = host.lower()
if 'https://' in host or 'http://' in host: if 'https://' in host or 'http://' in host:
host = urlparse(host).hostname host = urlparse(host).hostname
@ -232,6 +234,8 @@ def instance_blocked(host: str) -> bool: # see also utils.instance_banned
@cache.memoize(150) @cache.memoize(150)
def instance_allowed(host: str) -> bool: def instance_allowed(host: str) -> bool:
if host is None or host == '':
return True
host = host.lower() host = host.lower()
if 'https://' in host or 'http://' in host: if 'https://' in host or 'http://' in host:
host = urlparse(host).hostname host = urlparse(host).hostname
@ -561,11 +565,11 @@ def actor_json_to_model(activity_json, address, server):
current_app.logger.error(f'KeyError for {address}@{server} while parsing ' + str(activity_json)) current_app.logger.error(f'KeyError for {address}@{server} while parsing ' + str(activity_json))
return None return None
if 'icon' in activity_json: if 'icon' in activity_json and activity_json['icon'] is not None and 'url' in activity_json['icon']:
avatar = File(source_url=activity_json['icon']['url']) avatar = File(source_url=activity_json['icon']['url'])
user.avatar = avatar user.avatar = avatar
db.session.add(avatar) db.session.add(avatar)
if 'image' in activity_json: if 'image' in activity_json and activity_json['image'] is not None and 'url' in activity_json['image']:
cover = File(source_url=activity_json['image']['url']) cover = File(source_url=activity_json['image']['url'])
user.cover = cover user.cover = cover
db.session.add(cover) db.session.add(cover)
@ -625,11 +629,11 @@ def actor_json_to_model(activity_json, address, server):
elif 'content' in activity_json: elif 'content' in activity_json:
community.description_html = allowlist_html(activity_json['content']) community.description_html = allowlist_html(activity_json['content'])
community.description = '' community.description = ''
if 'icon' in activity_json: if 'icon' in activity_json and activity_json['icon'] is not None and 'url' in activity_json['icon']:
icon = File(source_url=activity_json['icon']['url']) icon = File(source_url=activity_json['icon']['url'])
community.icon = icon community.icon = icon
db.session.add(icon) db.session.add(icon)
if 'image' in activity_json: if 'image' in activity_json and activity_json['image'] is not None and 'url' in activity_json['image']:
image = File(source_url=activity_json['image']['url']) image = File(source_url=activity_json['image']['url'])
community.image = image community.image = image
db.session.add(image) db.session.add(image)
@ -702,12 +706,12 @@ def post_json_to_model(activity_log, post_json, user, community) -> Post:
if not domain.banned: if not domain.banned:
domain.post_count += 1 domain.post_count += 1
post.domain = domain post.domain = domain
if post is not None:
if 'image' in post_json and post.image is None: if 'image' in post_json and post.image is None:
image = File(source_url=post_json['image']['url']) image = File(source_url=post_json['image']['url'])
db.session.add(image) db.session.add(image)
post.image = image post.image = image
if post is not None:
db.session.add(post) db.session.add(post)
community.post_count += 1 community.post_count += 1
activity_log.result = 'success' activity_log.result = 'success'
@ -793,6 +797,7 @@ def make_image_sizes_async(file_id, thumbnail_width, medium_width, directory):
db.session.commit() db.session.commit()
# Alert regarding fascist meme content # Alert regarding fascist meme content
if img_width < 2000: # images > 2000px tend to be real photos instead of 4chan screenshots.
try: try:
image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30) image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30)
except FileNotFoundError as e: except FileNotFoundError as e:
@ -895,6 +900,21 @@ def find_liked_object(ap_id) -> Union[Post, PostReply, None]:
return None return None
def find_reported_object(ap_id) -> Union[User, Post, PostReply, None]:
post = Post.get_by_ap_id(ap_id)
if post:
return post
else:
post_reply = PostReply.get_by_ap_id(ap_id)
if post_reply:
return post_reply
else:
user = find_actor_or_create(ap_id, create_if_not_found=False)
if user:
return user
return None
def find_instance_id(server): def find_instance_id(server):
server = server.strip() server = server.strip()
instance = Instance.query.filter_by(domain=server).first() instance = Instance.query.filter_by(domain=server).first()
@ -1017,6 +1037,12 @@ def downvote_post(post, user):
if not existing_vote: if not existing_vote:
effect = -1.0 effect = -1.0
post.down_votes += 1 post.down_votes += 1
# Make 'hot' sort more spicy by amplifying the effect of early downvotes
if post.up_votes + post.down_votes <= 30:
post.score -= 5.0
elif post.up_votes + post.down_votes <= 60:
post.score -= 2.0
else:
post.score -= 1.0 post.score -= 1.0
vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id, vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id,
effect=effect) effect=effect)
@ -1119,10 +1145,18 @@ def upvote_post(post, user):
user.last_seen = utcnow() user.last_seen = utcnow()
user.recalculate_attitude() user.recalculate_attitude()
effect = instance_weight(user.ap_domain) effect = instance_weight(user.ap_domain)
# Make 'hot' sort more spicy by amplifying the effect of early upvotes
spicy_effect = effect
if post.up_votes + post.down_votes <= 10:
spicy_effect = effect * 10
elif post.up_votes + post.down_votes <= 30:
spicy_effect = effect * 5
elif post.up_votes + post.down_votes <= 60:
spicy_effect = effect * 2
existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=post.id).first() existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=post.id).first()
if not existing_vote: if not existing_vote:
post.up_votes += 1 post.up_votes += 1
post.score += effect post.score += spicy_effect
vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id, vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id,
effect=effect) effect=effect)
if post.community.low_quality and effect > 0: if post.community.low_quality and effect > 0:
@ -1533,7 +1567,7 @@ def update_post_from_activity(post: Post, request_json: dict):
old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all() old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all()
post.cross_posts.clear() post.cross_posts.clear()
for ocp in old_cross_posts: for ocp in old_cross_posts:
if ocp.cross_posts is not None: if ocp.cross_posts is not None and post.id in ocp.cross_posts:
ocp.cross_posts.remove(post.id) ocp.cross_posts.remove(post.id)
if post is not None: if post is not None:
@ -1623,6 +1657,77 @@ def undo_vote(activity_log, comment, post, target_ap_id, user):
return post return post
def process_report(user, reported, request_json, activity_log):
if len(request_json['summary']) < 15:
reasons = request_json['summary']
description = ''
else:
reasons = request_json['summary'][:15]
description = request_json['summary'][15:]
if isinstance(reported, User):
if reported.reports == -1:
return
type = 0
report = Report(reasons=reasons, description=description,
type=type, reporter_id=user.id, suspect_user_id=reported.id, source_instance_id=user.instance_id)
db.session.add(report)
# Notify site admin
already_notified = set()
for admin in Site.admins():
if admin.id not in already_notified:
notify = Notification(title='Reported user', url='/admin/reports', user_id=admin.id,
author_id=user.id)
db.session.add(notify)
admin.unread_notifications += 1
reported.reports += 1
db.session.commit()
elif isinstance(reported, Post):
if reported.reports == -1:
return
type = 1
report = Report(reasons=reasons, description=description, type=type, reporter_id=user.id,
suspect_user_id=reported.author.id, suspect_post_id=reported.id,
suspect_community_id=reported.community.id, in_community_id=reported.community.id,
source_instance_id=user.instance_id)
db.session.add(report)
already_notified = set()
for mod in reported.community.moderators():
notification = Notification(user_id=mod.user_id, title=_('A post has been reported'),
url=f"https://{current_app.config['SERVER_NAME']}/post/{reported.id}",
author_id=user.id)
db.session.add(notification)
already_notified.add(mod.user_id)
reported.reports += 1
db.session.commit()
elif isinstance(reported, PostReply):
if reported.reports == -1:
return
type = 2
post = Post.query.get(reported.post_id)
report = Report(reasons=reasons, description=description, type=type, reporter_id=user.id, suspect_post_id=post.id,
suspect_community_id=post.community.id,
suspect_user_id=reported.author.id, suspect_post_reply_id=reported.id,
in_community_id=post.community.id,
source_instance_id=user.instance_id)
db.session.add(report)
# Notify moderators
already_notified = set()
for mod in post.community.moderators():
notification = Notification(user_id=mod.user_id, title=_('A comment has been reported'),
url=f"https://{current_app.config['SERVER_NAME']}/comment/{reported.id}",
author_id=user.id)
db.session.add(notification)
already_notified.add(mod.user_id)
reported.reports += 1
db.session.commit()
elif isinstance(reported, Community):
...
elif isinstance(reported, Conversation):
...
def get_redis_connection() -> redis.Redis: def get_redis_connection() -> redis.Redis:
connection_string = current_app.config['CACHE_REDIS_URL'] connection_string = current_app.config['CACHE_REDIS_URL']
if connection_string.startswith('unix://'): if connection_string.startswith('unix://'):

View file

@ -4,6 +4,7 @@ from time import sleep
from flask import request, flash, json, url_for, current_app, redirect, g from flask import request, flash, json, url_for, current_app, redirect, g
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_babel import _ from flask_babel import _
from slugify import slugify
from sqlalchemy import text, desc, or_ from sqlalchemy import text, desc, or_
from app import db, celery, cache from app import db, celery, cache
@ -13,13 +14,14 @@ from app.activitypub.util import default_context, instance_allowed, instance_blo
from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm, EditCommunityForm, EditUserForm, \ from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm, EditCommunityForm, EditUserForm, \
EditTopicForm, SendNewsletterForm, AddUserForm EditTopicForm, SendNewsletterForm, AddUserForm
from app.admin.util import unsubscribe_from_everything_then_delete, unsubscribe_from_community, send_newsletter, \ from app.admin.util import unsubscribe_from_everything_then_delete, unsubscribe_from_community, send_newsletter, \
topic_tree, topics_for_form topics_for_form
from app.community.util import save_icon_file, save_banner_file from app.community.util import save_icon_file, save_banner_file
from app.constants import REPORT_STATE_NEW, REPORT_STATE_ESCALATED from app.constants import REPORT_STATE_NEW, REPORT_STATE_ESCALATED
from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, \ from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, \
User, Instance, File, Report, Topic, UserRegistration, Role, Post User, Instance, File, Report, Topic, UserRegistration, Role, Post
from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html, \ from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html, \
moderating_communities, joined_communities, finalize_user_setup, theme_list, blocked_phrases, blocked_referrers moderating_communities, joined_communities, finalize_user_setup, theme_list, blocked_phrases, blocked_referrers, \
topic_tree
from app.admin import bp from app.admin import bp
@ -357,7 +359,7 @@ def admin_topic_add():
form = EditTopicForm() form = EditTopicForm()
form.parent_id.choices = topics_for_form(0) form.parent_id.choices = topics_for_form(0)
if form.validate_on_submit(): if form.validate_on_submit():
topic = Topic(name=form.name.data, machine_name=form.machine_name.data, num_communities=0) topic = Topic(name=form.name.data, machine_name=slugify(form.machine_name.data.strip()), num_communities=0)
if form.parent_id.data: if form.parent_id.data:
topic.parent_id = form.parent_id.data topic.parent_id = form.parent_id.data
else: else:
@ -456,6 +458,7 @@ def admin_users_trash():
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
search = request.args.get('search', '') search = request.args.get('search', '')
local_remote = request.args.get('local_remote', '') local_remote = request.args.get('local_remote', '')
type = request.args.get('type', 'bad_rep')
users = User.query.filter_by(deleted=False) users = User.query.filter_by(deleted=False)
if local_remote == 'local': if local_remote == 'local':
@ -464,14 +467,19 @@ def admin_users_trash():
users = users.filter(User.ap_id != None) users = users.filter(User.ap_id != None)
if search: if search:
users = users.filter(User.email.ilike(f"%{search}%")) users = users.filter(User.email.ilike(f"%{search}%"))
if type == '' or type == 'bad_rep':
users = users.filter(User.reputation < -10) users = users.filter(User.reputation < -10)
users = users.order_by(User.reputation).paginate(page=page, per_page=1000, error_out=False) users = users.order_by(User.reputation).paginate(page=page, per_page=1000, error_out=False)
elif type == 'bad_attitude':
users = users.filter(User.attitude < 0.0)
users = users.order_by(-User.attitude).paginate(page=page, per_page=1000, error_out=False)
next_url = url_for('admin.admin_users_trash', page=users.next_num) if users.has_next else None next_url = url_for('admin.admin_users_trash', page=users.next_num) if users.has_next else None
prev_url = url_for('admin.admin_users_trash', page=users.prev_num) if users.has_prev and page != 1 else None prev_url = url_for('admin.admin_users_trash', page=users.prev_num) if users.has_prev and page != 1 else None
return render_template('admin/users.html', title=_('Problematic users'), next_url=next_url, prev_url=prev_url, users=users, return render_template('admin/users.html', title=_('Problematic users'), next_url=next_url, prev_url=prev_url, users=users,
local_remote=local_remote, search=search, local_remote=local_remote, search=search, type=type,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()),
site=g.site site=g.site

View file

@ -9,7 +9,7 @@ from app import db, cache, celery
from app.activitypub.signature import post_request from app.activitypub.signature import post_request
from app.activitypub.util import default_context from app.activitypub.util import default_context
from app.models import User, Community, Instance, Site, ActivityPubLog, CommunityMember, Topic from app.models import User, Community, Instance, Site, ActivityPubLog, CommunityMember, Topic
from app.utils import gibberish from app.utils import gibberish, topic_tree
def unsubscribe_from_everything_then_delete(user_id): def unsubscribe_from_everything_then_delete(user_id):
@ -106,21 +106,6 @@ def send_newsletter(form):
break break
# replies to a post, in a tree, sorted by a variety of methods
def topic_tree() -> List:
topics = Topic.query.order_by(Topic.name)
topics_dict = {topic.id: {'topic': topic, 'children': []} for topic in topics.all()}
for topic in topics:
if topic.parent_id is not None:
parent_comment = topics_dict.get(topic.parent_id)
if parent_comment:
parent_comment['children'].append(topics_dict[topic.id])
return [topic for topic in topics_dict.values() if topic['topic'].parent_id is None]
def topics_for_form(current_topic: int) -> List[Tuple[int, str]]: def topics_for_form(current_topic: int) -> List[Tuple[int, str]]:
result = [(0, _('None'))] result = [(0, _('None'))]
topics = topic_tree() topics = topic_tree()

View file

@ -105,7 +105,7 @@ def empty():
@login_required @login_required
def chat_options(conversation_id): def chat_options(conversation_id):
conversation = Conversation.query.get_or_404(conversation_id) conversation = Conversation.query.get_or_404(conversation_id)
if current_user.is_admin() or current_user.is_member(current_user): if current_user.is_admin() or conversation.is_member(current_user):
return render_template('chat/chat_options.html', conversation=conversation, return render_template('chat/chat_options.html', conversation=conversation,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()),

View file

@ -85,21 +85,23 @@ class BanUserCommunityForm(FlaskForm):
submit = SubmitField(_l('Ban')) submit = SubmitField(_l('Ban'))
class CreatePostForm(FlaskForm): class CreateDiscussionForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int)
post_type = HiddenField() # https://getbootstrap.com/docs/4.6/components/navs/#tabs discussion_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])
discussion_title = StringField(_l('Title'), validators=[Optional(), Length(min=3, max=255)]) discussion_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
discussion_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'placeholder': 'Text (optional)'}) sticky = BooleanField(_l('Sticky'))
link_title = StringField(_l('Title'), validators=[Optional(), Length(min=3, max=255)]) nsfw = BooleanField(_l('NSFW'))
link_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], nsfl = BooleanField(_l('Gore/gross'))
render_kw={'placeholder': 'Text (optional)'}) notify_author = BooleanField(_l('Notify about replies'))
link_url = StringField(_l('URL'), validators=[Optional(), Regexp(r'^https?://', message='Submitted links need to start with "http://"" or "https://"')], render_kw={'placeholder': 'https://...'}) submit = SubmitField(_l('Save'))
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)], class CreateLinkForm(FlaskForm):
render_kw={'placeholder': 'Text (optional)'}) communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int)
image_file = FileField(_('Image')) link_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])
# flair = SelectField(_l('Flair'), coerce=int) link_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
link_url = StringField(_l('URL'), validators=[DataRequired(), Regexp(r'^https?://', message='Submitted links need to start with "http://"" or "https://"')],
render_kw={'placeholder': 'https://...'})
sticky = BooleanField(_l('Sticky')) sticky = BooleanField(_l('Sticky'))
nsfw = BooleanField(_l('NSFW')) nsfw = BooleanField(_l('NSFW'))
nsfl = BooleanField(_l('Gore/gross')) nsfl = BooleanField(_l('Gore/gross'))
@ -107,33 +109,26 @@ class CreatePostForm(FlaskForm):
submit = SubmitField(_l('Save')) submit = SubmitField(_l('Save'))
def validate(self, extra_validators=None) -> bool: def validate(self, extra_validators=None) -> bool:
if not super().validate():
return False
if self.post_type.data is None or self.post_type.data == '':
self.post_type.data = 'discussion'
if self.post_type.data == 'discussion':
if self.discussion_title.data == '':
self.discussion_title.errors.append(_('Title is required.'))
return False
elif self.post_type.data == 'link':
if self.link_title.data == '':
self.link_title.errors.append(_('Title is required.'))
return False
if self.link_url.data == '':
self.link_url.errors.append(_('URL is required.'))
return False
domain = domain_from_url(self.link_url.data, create=False) domain = domain_from_url(self.link_url.data, create=False)
if domain and domain.banned: if domain and domain.banned:
self.link_url.errors.append(_("Links to %(domain)s are not allowed.", domain=domain.name)) self.link_url.errors.append(_("Links to %(domain)s are not allowed.", domain=domain.name))
return False return False
elif self.post_type.data == 'image': return True
if self.image_title.data == '':
self.image_title.errors.append(_('Title is required.'))
return False class CreateImageForm(FlaskForm):
if self.image_file.data == '': communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int)
self.image_file.errors.append(_('File is required.')) image_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])
return False 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={'rows': 5})
image_file = FileField(_('Image'), validators=[DataRequired()])
sticky = BooleanField(_l('Sticky'))
nsfw = BooleanField(_l('NSFW'))
nsfl = BooleanField(_l('Gore/gross'))
notify_author = BooleanField(_l('Notify about replies'))
submit = SubmitField(_l('Save'))
def validate(self, extra_validators=None) -> bool:
uploaded_file = request.files['image_file'] uploaded_file = request.files['image_file']
if uploaded_file and uploaded_file.filename != '': if uploaded_file and uploaded_file.filename != '':
Image.MAX_IMAGE_PIXELS = 89478485 Image.MAX_IMAGE_PIXELS = 89478485
@ -142,8 +137,10 @@ class CreatePostForm(FlaskForm):
image_text = pytesseract.image_to_string(Image.open(BytesIO(uploaded_file.read())).convert('L')) image_text = pytesseract.image_to_string(Image.open(BytesIO(uploaded_file.read())).convert('L'))
except FileNotFoundError as e: except FileNotFoundError as e:
image_text = '' image_text = ''
if 'Anonymous' in image_text and ('No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345' if 'Anonymous' in image_text and (
self.image_file.errors.append(f"This image is an invalid file type.") # deliberately misleading error message 'No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345'
self.image_file.errors.append(
f"This image is an invalid file type.") # deliberately misleading error message
current_user.reputation -= 1 current_user.reputation -= 1
db.session.commit() db.session.commit()
return False return False
@ -151,9 +148,6 @@ class CreatePostForm(FlaskForm):
community = Community.query.get(self.communities.data) community = Community.query.get(self.communities.data)
if community.is_local() and g.site.allow_local_image_posts is False: if community.is_local() and g.site.allow_local_image_posts is False:
self.communities.errors.append(_('Images cannot be posted to local communities.')) self.communities.errors.append(_('Images cannot be posted to local communities.'))
elif self.post_type.data == 'poll':
self.discussion_title.errors.append(_('Poll not implemented yet.'))
return False
return True return True

View file

@ -12,14 +12,15 @@ from app import db, constants, cache
from app.activitypub.signature import RsaKeys, post_request from app.activitypub.signature import RsaKeys, post_request
from app.activitypub.util import default_context, notify_about_post, find_actor_or_create, make_image_sizes from app.activitypub.util import default_context, notify_about_post, find_actor_or_create, make_image_sizes
from app.chat.util import send_message from app.chat.util import send_message
from app.community.forms import SearchRemoteCommunity, CreatePostForm, ReportCommunityForm, \ from app.community.forms import SearchRemoteCommunity, CreateDiscussionForm, CreateImageForm, CreateLinkForm, ReportCommunityForm, \
DeleteCommunityForm, AddCommunityForm, EditCommunityForm, AddModeratorForm, BanUserCommunityForm, \ DeleteCommunityForm, AddCommunityForm, EditCommunityForm, AddModeratorForm, BanUserCommunityForm, \
EscalateReportForm, ResolveReportForm EscalateReportForm, ResolveReportForm
from app.community.util import search_for_community, community_url_exists, actor_to_community, \ from app.community.util import search_for_community, community_url_exists, actor_to_community, \
opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance, \ opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance, \
delete_post_from_community, delete_post_reply_from_community delete_post_from_community, delete_post_reply_from_community
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \ from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \
SUBSCRIPTION_PENDING, SUBSCRIPTION_MODERATOR, REPORT_STATE_NEW, REPORT_STATE_ESCALATED, REPORT_STATE_RESOLVED SUBSCRIPTION_PENDING, SUBSCRIPTION_MODERATOR, REPORT_STATE_NEW, REPORT_STATE_ESCALATED, REPORT_STATE_RESOLVED, \
REPORT_STATE_DISCARDED
from app.inoculation import inoculation from app.inoculation import inoculation
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \
File, PostVote, utcnow, Report, Notification, InstanceBlock, ActivityPubLog, Topic, Conversation, PostReply File, PostVote, utcnow, Report, Notification, InstanceBlock, ActivityPubLog, Topic, Conversation, PostReply
@ -29,7 +30,8 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_
shorten_string, gibberish, community_membership, ap_datetime, \ shorten_string, gibberish, community_membership, ap_datetime, \
request_etag_matches, return_304, instance_banned, can_create_post, can_upvote, can_downvote, user_filters_posts, \ request_etag_matches, return_304, instance_banned, can_create_post, can_upvote, can_downvote, user_filters_posts, \
joined_communities, moderating_communities, blocked_domains, mimetype_from_url, blocked_instances, \ joined_communities, moderating_communities, blocked_domains, mimetype_from_url, blocked_instances, \
community_moderators, communities_banned_from, show_ban_message community_moderators, communities_banned_from, show_ban_message, recently_upvoted_posts, recently_downvoted_posts, \
blocked_users
from feedgen.feed import FeedGenerator from feedgen.feed import FeedGenerator
from datetime import timezone, timedelta from datetime import timezone, timedelta
@ -180,10 +182,15 @@ def show_community(community: Community):
if instance_ids: if instance_ids:
posts = posts.filter(or_(Post.instance_id.not_in(instance_ids), Post.instance_id == None)) posts = posts.filter(or_(Post.instance_id.not_in(instance_ids), Post.instance_id == None))
# filter blocked users
blocked_accounts = blocked_users(current_user.id)
if blocked_accounts:
posts = posts.filter(Post.user_id.not_in(blocked_accounts))
if sort == '' or sort == 'hot': if sort == '' or sort == 'hot':
posts = posts.order_by(desc(Post.sticky)).order_by(desc(Post.ranking)).order_by(desc(Post.posted_at)) posts = posts.order_by(desc(Post.sticky)).order_by(desc(Post.ranking)).order_by(desc(Post.posted_at))
elif sort == 'top': elif sort == 'top':
posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=7)).order_by(desc(Post.sticky)).order_by(desc(Post.score)) posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=7)).order_by(desc(Post.sticky)).order_by(desc(Post.up_votes - Post.down_votes))
elif sort == 'new': elif sort == 'new':
posts = posts.order_by(desc(Post.posted_at)) posts = posts.order_by(desc(Post.posted_at))
elif sort == 'active': elif sort == 'active':
@ -240,12 +247,21 @@ def show_community(community: Community):
prev_url = url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name, prev_url = url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name,
page=posts.prev_num, sort=sort, layout=post_layout) if posts.has_prev and page != 1 else None page=posts.prev_num, sort=sort, layout=post_layout) if posts.has_prev and page != 1 else None
# Voting history
if current_user.is_authenticated:
recently_upvoted = recently_upvoted_posts(current_user.id)
recently_downvoted = recently_downvoted_posts(current_user.id)
else:
recently_upvoted = []
recently_downvoted = []
return render_template('community/community.html', community=community, title=community.title, breadcrumbs=breadcrumbs, return render_template('community/community.html', community=community, title=community.title, breadcrumbs=breadcrumbs,
is_moderator=is_moderator, is_owner=is_owner, is_admin=is_admin, mods=mod_list, posts=posts, description=description, is_moderator=is_moderator, is_owner=is_owner, is_admin=is_admin, mods=mod_list, posts=posts, description=description,
og_image=og_image, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, og_image=og_image, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING,
SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER=SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR=SUBSCRIPTION_MODERATOR, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER=SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR=SUBSCRIPTION_MODERATOR,
etag=f"{community.id}{sort}{post_layout}_{hash(community.last_active)}", related_communities=related_communities, etag=f"{community.id}{sort}{post_layout}_{hash(community.last_active)}", related_communities=related_communities,
next_url=next_url, prev_url=prev_url, low_bandwidth=low_bandwidth, next_url=next_url, prev_url=prev_url, low_bandwidth=low_bandwidth,
recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted,
rss_feed=f"https://{current_app.config['SERVER_NAME']}/community/{community.link()}/feed", rss_feed_name=f"{community.title} on PieFed", rss_feed=f"https://{current_app.config['SERVER_NAME']}/community/{community.link()}/feed", rss_feed_name=f"{community.title} on PieFed",
content_filters=content_filters, moderating_communities=moderating_communities(current_user.get_id()), content_filters=content_filters, moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), sort=sort, joined_communities=joined_communities(current_user.get_id()), sort=sort,
@ -435,7 +451,7 @@ def join_then_add(actor):
db.session.commit() db.session.commit()
flash('You joined ' + community.title) flash('You joined ' + community.title)
if not community.user_is_banned(current_user): if not community.user_is_banned(current_user):
return redirect(url_for('community.add_post', actor=community.link())) return redirect(url_for('community.add_discussion_post', actor=community.link()))
else: else:
abort(401) abort(401)
@ -443,11 +459,13 @@ def join_then_add(actor):
@bp.route('/<actor>/submit', methods=['GET', 'POST']) @bp.route('/<actor>/submit', methods=['GET', 'POST'])
@login_required @login_required
@validation_required @validation_required
def add_post(actor): def add_discussion_post(actor):
if current_user.banned: if current_user.banned:
return show_ban_message() return show_ban_message()
community = actor_to_community(actor) community = actor_to_community(actor)
form = CreatePostForm()
form = CreateDiscussionForm()
if g.site.enable_nsfl is False: if g.site.enable_nsfl is False:
form.nsfl.render_kw = {'disabled': True} form.nsfl.render_kw = {'disabled': True}
if community.nsfw: if community.nsfw:
@ -469,7 +487,63 @@ def add_post(actor):
if not can_create_post(current_user, community): if not can_create_post(current_user, community):
abort(401) abort(401)
post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1)
save_post(form, post) save_post(form, post, 'discussion')
community.post_count += 1
community.last_active = g.site.last_active = utcnow()
db.session.commit()
post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}"
db.session.commit()
notify_about_post(post)
if not community.local_only:
federate_post(community, post)
return redirect(f"/c/{community.link()}")
else:
form.communities.data = community.id
form.notify_author.data = True
return render_template('community/add_discussion_post.html', title=_('Add post to community'), form=form, community=community,
markdown_editor=current_user.markdown_editor, low_bandwidth=False, actor=actor,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.id),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
@bp.route('/<actor>/submit_image', methods=['GET', 'POST'])
@login_required
@validation_required
def add_image_post(actor):
if current_user.banned:
return show_ban_message()
community = actor_to_community(actor)
form = CreateImageForm()
if g.site.enable_nsfl is False:
form.nsfl.render_kw = {'disabled': True}
if community.nsfw:
form.nsfw.data = True
form.nsfw.render_kw = {'disabled': True}
if community.nsfl:
form.nsfl.data = True
form.nsfw.render_kw = {'disabled': True}
if not(community.is_moderator() or community.is_owner() or current_user.is_admin()):
form.sticky.render_kw = {'disabled': True}
form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()]
if not can_create_post(current_user, community):
abort(401)
if form.validate_on_submit():
community = Community.query.get_or_404(form.communities.data)
if not can_create_post(current_user, community):
abort(401)
post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1)
save_post(form, post, 'image')
community.post_count += 1 community.post_count += 1
community.last_active = g.site.last_active = utcnow() community.last_active = g.site.last_active = utcnow()
db.session.commit() db.session.commit()
@ -496,6 +570,95 @@ def add_post(actor):
notify_about_post(post) notify_about_post(post)
if not community.local_only: if not community.local_only:
federate_post(community, post)
return redirect(f"/c/{community.link()}")
else:
form.communities.data = community.id
form.notify_author.data = True
return render_template('community/add_image_post.html', title=_('Add post to community'), form=form, community=community,
markdown_editor=current_user.markdown_editor, low_bandwidth=False, actor=actor,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.id),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
@bp.route('/<actor>/submit_link', methods=['GET', 'POST'])
@login_required
@validation_required
def add_link_post(actor):
if current_user.banned:
return show_ban_message()
community = actor_to_community(actor)
form = CreateLinkForm()
if g.site.enable_nsfl is False:
form.nsfl.render_kw = {'disabled': True}
if community.nsfw:
form.nsfw.data = True
form.nsfw.render_kw = {'disabled': True}
if community.nsfl:
form.nsfl.data = True
form.nsfw.render_kw = {'disabled': True}
if not(community.is_moderator() or community.is_owner() or current_user.is_admin()):
form.sticky.render_kw = {'disabled': True}
form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()]
if not can_create_post(current_user, community):
abort(401)
if form.validate_on_submit():
community = Community.query.get_or_404(form.communities.data)
if not can_create_post(current_user, community):
abort(401)
post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1)
save_post(form, post, 'link')
community.post_count += 1
community.last_active = g.site.last_active = utcnow()
db.session.commit()
post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}"
db.session.commit()
if post.image_id and post.image.file_path is None:
make_image_sizes(post.image_id, 150, 512, 'posts') # the 512 sized image is for masonry view
# Update list of cross posts
if post.url:
other_posts = Post.query.filter(Post.id != post.id, Post.url == post.url,
Post.posted_at > post.posted_at - timedelta(days=6)).all()
for op in other_posts:
if op.cross_posts is None:
op.cross_posts = [post.id]
else:
op.cross_posts.append(post.id)
if post.cross_posts is None:
post.cross_posts = [op.id]
else:
post.cross_posts.append(op.id)
db.session.commit()
notify_about_post(post)
if not community.local_only:
federate_post(community, post)
return redirect(f"/c/{community.link()}")
else:
form.communities.data = community.id
form.notify_author.data = True
return render_template('community/add_link_post.html', title=_('Add post to community'), form=form, community=community,
markdown_editor=current_user.markdown_editor, low_bandwidth=False, actor=actor,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.id),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
def federate_post(community, post):
page = { page = {
'type': 'Page', 'type': 'Page',
'id': post.ap_id, 'id': post.ap_id,
@ -538,15 +701,18 @@ def add_post(actor):
page['attachment'] = [{'href': post.url, 'type': 'Link'}] page['attachment'] = [{'href': post.url, 'type': 'Link'}]
elif post.image_id: elif post.image_id:
if post.image.file_path: if post.image.file_path:
image_url = post.image.file_path.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/") image_url = post.image.file_path.replace('app/static/',
f"https://{current_app.config['SERVER_NAME']}/static/")
elif post.image.thumbnail_path: elif post.image.thumbnail_path:
image_url = post.image.thumbnail_path.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/") image_url = post.image.thumbnail_path.replace('app/static/',
f"https://{current_app.config['SERVER_NAME']}/static/")
else: else:
image_url = post.image.source_url image_url = post.image.source_url
# NB image is a dict while attachment is a list of dicts (usually just one dict in the list) # NB image is a dict while attachment is a list of dicts (usually just one dict in the list)
page['image'] = {'type': 'Image', 'url': image_url} page['image'] = {'type': 'Image', 'url': image_url}
if post.type == POST_TYPE_IMAGE: if post.type == POST_TYPE_IMAGE:
page['attachment'] = [{'type': 'Link', 'href': post.image.source_url}] # source_url is always a https link, no need for .replace() as done above page['attachment'] = [{'type': 'Link',
'href': post.image.source_url}] # source_url is always a https link, no need for .replace() as done above
if not community.is_local(): # this is a remote community - send the post to the instance that hosts it if not community.is_local(): # this is a remote community - send the post to the instance that hosts it
success = post_request(community.ap_inbox_url, create, current_user.private_key, success = post_request(community.ap_inbox_url, create, current_user.private_key,
current_user.ap_profile_id + '#main-key') current_user.ap_profile_id + '#main-key')
@ -571,7 +737,8 @@ def add_post(actor):
sent_to = 0 sent_to = 0
for instance in community.following_instances(): for instance in 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, community.id, announce) send_to_remote_instance(instance.id, community.id, announce)
sent_to += 1 sent_to += 1
if sent_to: if sent_to:
@ -579,21 +746,6 @@ def add_post(actor):
else: else:
flash(_('Your post to %(name)s has been made.', name=community.title)) flash(_('Your post to %(name)s has been made.', name=community.title))
return redirect(f"/c/{community.link()}")
else:
# when request.form has some data in it, it means form validation failed. Set the post_type so the correct tab is shown. See setupPostTypeTabs() in scripts.js
if request.form.get('post_type', None):
form.post_type.data = request.form.get('post_type')
form.communities.data = community.id
form.notify_author.data = True
return render_template('community/add_post.html', title=_('Add post to community'), form=form, community=community,
markdown_editor=current_user.markdown_editor, low_bandwidth=False,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.id),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
@bp.route('/community/<int:community_id>/report', methods=['GET', 'POST']) @bp.route('/community/<int:community_id>/report', methods=['GET', 'POST'])
@login_required @login_required
@ -1051,7 +1203,19 @@ def community_moderate_report_resolve(community_id, report_id):
form = ResolveReportForm() form = ResolveReportForm()
if form.validate_on_submit(): if form.validate_on_submit():
report.status = REPORT_STATE_RESOLVED report.status = REPORT_STATE_RESOLVED
# Reset the 'reports' counter on the comment, post or user
if report.suspect_post_reply_id:
post_reply = PostReply.query.get(report.suspect_post_reply_id)
post_reply.reports = 0
elif report.suspect_post_id:
post = Post.query.get(report.suspect_post_id)
post.reports = 0
elif report.suspect_user_id:
user = User.query.get(report.suspect_user_id)
user.reports = 0
db.session.commit() db.session.commit()
# todo: remove unread notifications about this report # todo: remove unread notifications about this report
# todo: append to mod log # todo: append to mod log
if form.also_resolve_others.data: if form.also_resolve_others.data:
@ -1072,6 +1236,44 @@ def community_moderate_report_resolve(community_id, report_id):
return render_template('community/community_moderate_report_resolve.html', form=form) return render_template('community/community_moderate_report_resolve.html', form=form)
@bp.route('/community/<int:community_id>/moderate_report/<int:report_id>/ignore', methods=['GET', 'POST'])
@login_required
def community_moderate_report_ignore(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:
# Set the 'reports' counter on the comment, post or user to -1 to ignore all future reports
if report.suspect_post_reply_id:
post_reply = PostReply.query.get(report.suspect_post_reply_id)
post_reply.reports = -1
elif report.suspect_post_id:
post = Post.query.get(report.suspect_post_id)
post.reports = -1
elif report.suspect_user_id:
user = User.query.get(report.suspect_user_id)
user.reports = -1
db.session.commit()
# todo: append to mod log
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_DISCARDED,
'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_DISCARDED,
'suspect_post_id': report.suspect_post_id})
# todo: remove unread notifications about these reports
db.session.commit()
flash(_('Report ignored.'))
return redirect(url_for('community.community_moderate', actor=community.link()))
else:
abort(404)
@bp.route('/lookup/<community>/<domain>') @bp.route('/lookup/<community>/<domain>')
def lookup(community, domain): def lookup(community, domain):
if domain == current_app.config['SERVER_NAME']: if domain == current_app.config['SERVER_NAME']:

View file

@ -196,18 +196,18 @@ def url_to_thumbnail_file(filename) -> File:
source_url=filename) source_url=filename)
def save_post(form, post: Post): def save_post(form, post: Post, type: str):
post.indexable = current_user.indexable post.indexable = current_user.indexable
post.sticky = form.sticky.data post.sticky = form.sticky.data
post.nsfw = form.nsfw.data post.nsfw = form.nsfw.data
post.nsfl = form.nsfl.data post.nsfl = form.nsfl.data
post.notify_author = form.notify_author.data post.notify_author = form.notify_author.data
if form.post_type.data == '' or form.post_type.data == 'discussion': if type == '' or type == 'discussion':
post.title = form.discussion_title.data post.title = form.discussion_title.data
post.body = form.discussion_body.data post.body = form.discussion_body.data
post.body_html = markdown_to_html(post.body) post.body_html = markdown_to_html(post.body)
post.type = POST_TYPE_ARTICLE post.type = POST_TYPE_ARTICLE
elif form.post_type.data == 'link': elif type == 'link':
post.title = form.link_title.data post.title = form.link_title.data
post.body = form.link_body.data post.body = form.link_body.data
post.body_html = markdown_to_html(post.body) post.body_html = markdown_to_html(post.body)
@ -244,7 +244,7 @@ def save_post(form, post: Post):
post.image = file post.image = file
db.session.add(file) db.session.add(file)
elif form.post_type.data == 'image': elif type == 'image':
post.title = form.image_title.data post.title = form.image_title.data
post.body = form.image_body.data post.body = form.image_body.data
post.body_html = markdown_to_html(post.body) post.body_html = markdown_to_html(post.body)
@ -304,7 +304,7 @@ def save_post(form, post: Post):
post.image = file post.image = file
db.session.add(file) db.session.add(file)
elif form.post_type.data == 'poll': elif type == 'poll':
... ...
else: else:
raise Exception('invalid post type') raise Exception('invalid post type')

View file

@ -3,9 +3,11 @@ from app import db
from app.errors import bp from app.errors import bp
@bp.app_errorhandler(404) # 404 error handler removed because a lot of 404s are just images in /static/* and it doesn't make sense to waste cpu cycles presenting a nice page.
def not_found_error(error): # Also rendering a page requires populating g.site which means hitting the DB.
return render_template('errors/404.html'), 404 # @bp.app_errorhandler(404)
# def not_found_error(error):
# return render_template('errors/404.html'), 404
@bp.app_errorhandler(500) @bp.app_errorhandler(500)

View file

@ -25,7 +25,7 @@ from sqlalchemy_searchable import search
from app.utils import render_template, get_setting, gibberish, request_etag_matches, return_304, blocked_domains, \ from app.utils import render_template, get_setting, gibberish, request_etag_matches, return_304, blocked_domains, \
ap_datetime, ip_address, retrieve_block_list, shorten_string, markdown_to_text, user_filters_home, \ ap_datetime, ip_address, retrieve_block_list, shorten_string, markdown_to_text, user_filters_home, \
joined_communities, moderating_communities, parse_page, theme_list, get_request, markdown_to_html, allowlist_html, \ joined_communities, moderating_communities, parse_page, theme_list, get_request, markdown_to_html, allowlist_html, \
blocked_instances, communities_banned_from blocked_instances, communities_banned_from, topic_tree, recently_upvoted_posts, recently_downvoted_posts
from app.models import Community, CommunityMember, Post, Site, User, utcnow, Domain, Topic, File, Instance, \ from app.models import Community, CommunityMember, Post, Site, User, utcnow, Domain, Topic, File, Instance, \
InstanceRole, Notification InstanceRole, Notification
from PIL import Image from PIL import Image
@ -114,7 +114,7 @@ def home_page(type, sort):
if sort == 'hot': if sort == 'hot':
posts = posts.order_by(desc(Post.ranking)).order_by(desc(Post.posted_at)) posts = posts.order_by(desc(Post.ranking)).order_by(desc(Post.posted_at))
elif sort == 'top': elif sort == 'top':
posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=1)).order_by(desc(Post.score)) posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=1)).order_by(desc(Post.up_votes - Post.down_votes))
elif sort == 'new': elif sort == 'new':
posts = posts.order_by(desc(Post.posted_at)) posts = posts.order_by(desc(Post.posted_at))
elif sort == 'active': elif sort == 'active':
@ -140,9 +140,18 @@ def home_page(type, sort):
active_communities = active_communities.filter(Community.id.not_in(banned_from)) active_communities = active_communities.filter(Community.id.not_in(banned_from))
active_communities = active_communities.order_by(desc(Community.last_active)).limit(5).all() active_communities = active_communities.order_by(desc(Community.last_active)).limit(5).all()
# Voting history
if current_user.is_authenticated:
recently_upvoted = recently_upvoted_posts(current_user.id)
recently_downvoted = recently_downvoted_posts(current_user.id)
else:
recently_upvoted = []
recently_downvoted = []
return render_template('index.html', posts=posts, active_communities=active_communities, show_post_community=True, return render_template('index.html', posts=posts, active_communities=active_communities, show_post_community=True,
POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK,
low_bandwidth=low_bandwidth, low_bandwidth=low_bandwidth, recently_upvoted=recently_upvoted,
recently_downvoted=recently_downvoted,
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
etag=f"{type}_{sort}_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url, etag=f"{type}_{sort}_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url,
#rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed", #rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed",
@ -158,7 +167,7 @@ def home_page(type, sort):
@bp.route('/topics', methods=['GET']) @bp.route('/topics', methods=['GET'])
def list_topics(): def list_topics():
verification_warning() verification_warning()
topics = Topic.query.filter_by(parent_id=None).order_by(Topic.name).all() topics = topic_tree()
return render_template('list_topics.html', topics=topics, title=_('Browse by topic'), return render_template('list_topics.html', topics=topics, title=_('Browse by topic'),
low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1', low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1',
@ -294,7 +303,9 @@ def list_files(directory):
@bp.route('/test') @bp.route('/test')
def test(): def test():
return '' md = "::: spoiler I'm all for ya having fun and your right to hurt yourself.\n\nI am a former racer, commuter, and professional Buyer for a chain of bike shops. I'm also disabled from the crash involving the 6th and 7th cars that have hit me in the last 170k+ miles of riding. I only barely survived what I simplify as a \"broken neck and back.\" Cars making U-turns are what will get you if you ride long enough, \n\nespecially commuting. It will look like just another person turning in front of you, you'll compensate like usual, and before your brain can even register what is really happening, what was your normal escape route will close and you're going to crash really hard. It is the only kind of crash that your intuition is useless against.\n:::"
return markdown_to_html(md)
users_to_notify = User.query.join(Notification, User.id == Notification.user_id).filter( users_to_notify = User.query.join(Notification, User.id == Notification.user_id).filter(
User.ap_id == None, User.ap_id == None,
@ -349,6 +360,36 @@ def test_email():
return f'Email sent to {current_user.email}.' return f'Email sent to {current_user.email}.'
@bp.route('/find_voters')
def find_voters():
user_ids = db.session.execute(text('SELECT id from "user" ORDER BY last_seen DESC LIMIT 5000')).scalars()
voters = {}
for user_id in user_ids:
recently_downvoted = recently_downvoted_posts(user_id)
if len(recently_downvoted) > 10:
voters[user_id] = str(recently_downvoted)
return str(find_duplicate_values(voters))
def find_duplicate_values(dictionary):
# Create a dictionary to store the keys for each value
value_to_keys = {}
# Iterate through the input dictionary
for key, value in dictionary.items():
# If the value is not already in the dictionary, add it
if value not in value_to_keys:
value_to_keys[value] = [key]
else:
# If the value is already in the dictionary, append the key to the list
value_to_keys[value].append(key)
# Filter out the values that have only one key (i.e., unique values)
duplicates = {value: keys for value, keys in value_to_keys.items() if len(keys) > 1}
return duplicates
def verification_warning(): def verification_warning():
if hasattr(current_user, 'verified') and current_user.verified is False: if hasattr(current_user, 'verified') and current_user.verified is False:
flash(_('Please click the link in your email inbox to verify your account.'), 'warning') flash(_('Please click the link in your email inbox to verify your account.'), 'warning')

View file

@ -208,14 +208,23 @@ class File(db.Model):
def delete_from_disk(self): def delete_from_disk(self):
purge_from_cache = [] purge_from_cache = []
if self.file_path and os.path.isfile(self.file_path): if self.file_path and os.path.isfile(self.file_path):
try:
os.unlink(self.file_path) os.unlink(self.file_path)
except FileNotFoundError as e:
...
purge_from_cache.append(self.file_path.replace('app/', f"https://{current_app.config['SERVER_NAME']}/")) purge_from_cache.append(self.file_path.replace('app/', f"https://{current_app.config['SERVER_NAME']}/"))
if self.thumbnail_path and os.path.isfile(self.thumbnail_path): if self.thumbnail_path and os.path.isfile(self.thumbnail_path):
try:
os.unlink(self.thumbnail_path) os.unlink(self.thumbnail_path)
except FileNotFoundError as e:
...
purge_from_cache.append(self.thumbnail_path.replace('app/', f"https://{current_app.config['SERVER_NAME']}/")) purge_from_cache.append(self.thumbnail_path.replace('app/', f"https://{current_app.config['SERVER_NAME']}/"))
if self.source_url and self.source_url.startswith('http') and current_app.config['SERVER_NAME'] in self.source_url: if self.source_url and self.source_url.startswith('http') and current_app.config['SERVER_NAME'] in self.source_url:
# self.source_url is always a url rather than a file path, which makes deleting the file a bit fiddly # self.source_url is always a url rather than a file path, which makes deleting the file a bit fiddly
try:
os.unlink(self.source_url.replace(f"https://{current_app.config['SERVER_NAME']}/", 'app/')) os.unlink(self.source_url.replace(f"https://{current_app.config['SERVER_NAME']}/", 'app/'))
except FileNotFoundError as e:
...
purge_from_cache.append(self.source_url) # otoh it makes purging the cdn cache super easy. purge_from_cache.append(self.source_url) # otoh it makes purging the cdn cache super easy.
if purge_from_cache: if purge_from_cache:
@ -283,6 +292,18 @@ class Topic(db.Model):
parent_id = db.Column(db.Integer) parent_id = db.Column(db.Integer)
communities = db.relationship('Community', lazy='dynamic', backref='topic', cascade="all, delete-orphan") communities = db.relationship('Community', lazy='dynamic', backref='topic', cascade="all, delete-orphan")
def path(self):
return_value = [self.machine_name]
parent_id = self.parent_id
while parent_id is not None:
parent_topic = Topic.query.get(parent_id)
if parent_topic is None:
break
return_value.append(parent_topic.machine_name)
parent_id = parent_topic.parent_id
return_value = list(reversed(return_value))
return '/'.join(return_value)
class Community(db.Model): class Community(db.Model):
query_class = FullTextSearchQuery query_class = FullTextSearchQuery
@ -405,8 +426,8 @@ class Community(db.Model):
(or_( (or_(
CommunityMember.is_owner, CommunityMember.is_owner,
CommunityMember.is_moderator CommunityMember.is_moderator
)) & CommunityMember.is_banned == False ))
).all() ).filter(CommunityMember.is_banned == False).all()
def is_moderator(self, user=None): def is_moderator(self, user=None):
if user is None: if user is None:
@ -1257,6 +1278,7 @@ class Site(db.Model):
last_active = db.Column(db.DateTime, default=utcnow) last_active = db.Column(db.DateTime, default=utcnow)
log_activitypub_json = db.Column(db.Boolean, default=False) log_activitypub_json = db.Column(db.Boolean, default=False)
default_theme = db.Column(db.String(20), default='') default_theme = db.Column(db.String(20), default='')
contact_email = db.Column(db.String(255), default='')
@staticmethod @staticmethod
def admins() -> List[User]: def admins() -> List[User]:

View file

@ -7,7 +7,7 @@ from app.utils import MultiCheckboxField
class NewReplyForm(FlaskForm): class NewReplyForm(FlaskForm):
body = TextAreaField(_l('Body'), render_kw={'placeholder': 'What are your thoughts?', 'rows': 3}, validators={DataRequired(), Length(min=3, max=5000)}) body = TextAreaField(_l('Body'), render_kw={'placeholder': 'What are your thoughts?', 'rows': 5}, validators={DataRequired(), Length(min=3, max=5000)})
notify_author = BooleanField(_l('Notify about replies')) notify_author = BooleanField(_l('Notify about replies'))
submit = SubmitField(_l('Comment')) submit = SubmitField(_l('Comment'))

View file

@ -13,18 +13,19 @@ from app.activitypub.util import default_context
from app.community.util import save_post, send_to_remote_instance from app.community.util import save_post, send_to_remote_instance
from app.inoculation import inoculation from app.inoculation import inoculation
from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm
from app.community.forms import CreatePostForm from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm
from app.post.util import post_replies, get_comment_branch, post_reply_count from app.post.util import post_replies, get_comment_branch, post_reply_count
from app.constants import SUBSCRIPTION_MEMBER, POST_TYPE_LINK, POST_TYPE_IMAGE from app.constants import SUBSCRIPTION_MEMBER, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE
from app.models import Post, PostReply, \ from app.models import Post, PostReply, \
PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, \ PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, \
Topic, User Topic, User, Instance
from app.post import bp from app.post import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, markdown_to_text, gibberish, ap_datetime, return_304, \ shorten_string, markdown_to_text, gibberish, ap_datetime, return_304, \
request_etag_matches, ip_address, user_ip_banned, instance_banned, can_downvote, can_upvote, post_ranking, \ request_etag_matches, ip_address, user_ip_banned, instance_banned, can_downvote, can_upvote, post_ranking, \
reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, moderating_communities, joined_communities, \ reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, moderating_communities, joined_communities, \
blocked_instances, blocked_domains, community_moderators, blocked_phrases, show_ban_message blocked_instances, blocked_domains, community_moderators, blocked_phrases, show_ban_message, recently_upvoted_posts, \
recently_downvoted_posts, recently_upvoted_post_replies, recently_downvoted_post_replies
def show_post(post_id: int): def show_post(post_id: int):
@ -239,12 +240,26 @@ def show_post(post_id: int):
breadcrumb.url = '/communities' breadcrumb.url = '/communities'
breadcrumbs.append(breadcrumb) breadcrumbs.append(breadcrumb)
# Voting history
if current_user.is_authenticated:
recently_upvoted = recently_upvoted_posts(current_user.id)
recently_downvoted = recently_downvoted_posts(current_user.id)
recently_upvoted_replies = recently_upvoted_post_replies(current_user.id)
recently_downvoted_replies = recently_downvoted_post_replies(current_user.id)
else:
recently_upvoted = []
recently_downvoted = []
recently_upvoted_replies = []
recently_downvoted_replies = []
response = render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, community=post.community, response = render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, community=post.community,
breadcrumbs=breadcrumbs, related_communities=related_communities, mods=mod_list, breadcrumbs=breadcrumbs, related_communities=related_communities, mods=mod_list,
canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH, canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH,
description=description, og_image=og_image, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE, description=description, og_image=og_image, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE,
POST_TYPE_LINK=constants.POST_TYPE_LINK, POST_TYPE_ARTICLE=constants.POST_TYPE_ARTICLE, POST_TYPE_LINK=constants.POST_TYPE_LINK, POST_TYPE_ARTICLE=constants.POST_TYPE_ARTICLE,
noindex=not post.author.indexable, noindex=not post.author.indexable,
recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted,
recently_upvoted_replies=recently_upvoted_replies, recently_downvoted_replies=recently_downvoted_replies,
etag=f"{post.id}{sort}_{hash(post.last_active)}", markdown_editor=current_user.is_authenticated and current_user.markdown_editor, etag=f"{post.id}{sort}_{hash(post.last_active)}", markdown_editor=current_user.is_authenticated and current_user.markdown_editor,
low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1', SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1', SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),
@ -259,7 +274,6 @@ def show_post(post_id: int):
@login_required @login_required
@validation_required @validation_required
def post_vote(post_id: int, vote_direction): def post_vote(post_id: int, vote_direction):
upvoted_class = downvoted_class = ''
post = Post.query.get_or_404(post_id) post = Post.query.get_or_404(post_id)
existing_vote = PostVote.query.filter_by(user_id=current_user.id, post_id=post.id).first() existing_vote = PostVote.query.filter_by(user_id=current_user.id, post_id=post.id).first()
if existing_vote: if existing_vote:
@ -275,7 +289,6 @@ def post_vote(post_id: int, vote_direction):
post.up_votes -= 1 post.up_votes -= 1
post.down_votes += 1 post.down_votes += 1
post.score -= 2 post.score -= 2
downvoted_class = 'voted_down'
else: # previous vote was down else: # previous vote was down
if vote_direction == 'downvote': # new vote is also down, so remove it if vote_direction == 'downvote': # new vote is also down, so remove it
db.session.delete(existing_vote) db.session.delete(existing_vote)
@ -286,18 +299,28 @@ def post_vote(post_id: int, vote_direction):
post.up_votes += 1 post.up_votes += 1
post.down_votes -= 1 post.down_votes -= 1
post.score += 2 post.score += 2
upvoted_class = 'voted_up'
else: else:
if vote_direction == 'upvote': if vote_direction == 'upvote':
effect = 1 effect = 1
post.up_votes += 1 post.up_votes += 1
# Make 'hot' sort more spicy by amplifying the effect of early upvotes
if post.up_votes + post.down_votes <= 10:
post.score += 10
elif post.up_votes + post.down_votes <= 30:
post.score += 5
elif post.up_votes + post.down_votes <= 60:
post.score += 2
else:
post.score += 1 post.score += 1
upvoted_class = 'voted_up'
else: else:
effect = -1 effect = -1
post.down_votes += 1 post.down_votes += 1
if post.up_votes + post.down_votes <= 30:
post.score -= 5
elif post.up_votes + post.down_votes <= 60:
post.score -= 2
else:
post.score -= 1 post.score -= 1
downvoted_class = 'voted_down'
vote = PostVote(user_id=current_user.id, post_id=post.id, author_id=post.author.id, vote = PostVote(user_id=current_user.id, post_id=post.id, author_id=post.author.id,
effect=effect) effect=effect)
# upvotes do not increase reputation in low quality communities # upvotes do not increase reputation in low quality communities
@ -346,17 +369,25 @@ def post_vote(post_id: int, vote_direction):
current_user.recalculate_attitude() current_user.recalculate_attitude()
db.session.commit() db.session.commit()
post.flush_cache() post.flush_cache()
recently_upvoted = []
recently_downvoted = []
if vote_direction == 'upvote':
recently_upvoted = [post_id]
elif vote_direction == 'downvote':
recently_downvoted = [post_id]
cache.delete_memoized(recently_upvoted_posts, current_user.id)
cache.delete_memoized(recently_downvoted_posts, current_user.id)
template = 'post/_post_voting_buttons.html' if request.args.get('style', '') == '' else 'post/_post_voting_buttons_masonry.html' template = 'post/_post_voting_buttons.html' if request.args.get('style', '') == '' else 'post/_post_voting_buttons_masonry.html'
return render_template(template, post=post, community=post.community, return render_template(template, post=post, community=post.community, recently_upvoted=recently_upvoted,
upvoted_class=upvoted_class, recently_downvoted=recently_downvoted)
downvoted_class=downvoted_class)
@bp.route('/comment/<int:comment_id>/<vote_direction>', methods=['POST']) @bp.route('/comment/<int:comment_id>/<vote_direction>', methods=['POST'])
@login_required @login_required
@validation_required @validation_required
def comment_vote(comment_id, vote_direction): def comment_vote(comment_id, vote_direction):
upvoted_class = downvoted_class = ''
comment = PostReply.query.get_or_404(comment_id) comment = PostReply.query.get_or_404(comment_id)
existing_vote = PostReplyVote.query.filter_by(user_id=current_user.id, post_reply_id=comment.id).first() existing_vote = PostReplyVote.query.filter_by(user_id=current_user.id, post_reply_id=comment.id).first()
if existing_vote: if existing_vote:
@ -423,9 +454,20 @@ def comment_vote(comment_id, vote_direction):
db.session.commit() db.session.commit()
comment.post.flush_cache() comment.post.flush_cache()
recently_upvoted = []
recently_downvoted = []
if vote_direction == 'upvote':
recently_upvoted = [comment_id]
elif vote_direction == 'downvote':
recently_downvoted = [comment_id]
cache.delete_memoized(recently_upvoted_post_replies, current_user.id)
cache.delete_memoized(recently_downvoted_post_replies, current_user.id)
return render_template('post/_comment_voting_buttons.html', comment=comment, return render_template('post/_comment_voting_buttons.html', comment=comment,
upvoted_class=upvoted_class, recently_upvoted_replies=recently_upvoted,
downvoted_class=downvoted_class, community=comment.community) recently_downvoted_replies=recently_downvoted,
community=comment.community)
@bp.route('/post/<int:post_id>/comment/<int:comment_id>') @bp.route('/post/<int:post_id>/comment/<int:comment_id>')
@ -654,11 +696,82 @@ def post_reply_options(post_id: int, comment_id: int):
) )
@bp.route('/post/<int:post_id>/edit', methods=['GET', 'POST']) @bp.route('/post/<int:post_id>/edit', methods=['GET'])
@login_required @login_required
def post_edit(post_id: int): def post_edit(post_id: int):
post = Post.query.get_or_404(post_id) post = Post.query.get_or_404(post_id)
form = CreatePostForm() if post.type == POST_TYPE_ARTICLE:
return redirect(url_for('post.post_edit_discussion_post', post_id=post_id))
elif post.type == POST_TYPE_LINK:
return redirect(url_for('post.post_edit_link_post', post_id=post_id))
elif post.type == POST_TYPE_IMAGE:
return redirect(url_for('post.post_edit_image_post', post_id=post_id))
else:
abort(404)
@bp.route('/post/<int:post_id>/edit_discussion', methods=['GET', 'POST'])
@login_required
def post_edit_discussion_post(post_id: int):
post = Post.query.get_or_404(post_id)
form = CreateDiscussionForm()
del form.communities
mods = post.community.moderators()
if post.community.private_mods:
mod_list = []
else:
mod_user_ids = [mod.user_id for mod in mods]
mod_list = User.query.filter(User.id.in_(mod_user_ids)).all()
if post.user_id == current_user.id or post.community.is_moderator() or current_user.is_admin():
if g.site.enable_nsfl is False:
form.nsfl.render_kw = {'disabled': True}
if post.community.nsfw:
form.nsfw.data = True
form.nsfw.render_kw = {'disabled': True}
if post.community.nsfl:
form.nsfl.data = True
form.nsfw.render_kw = {'disabled': True}
if form.validate_on_submit():
save_post(form, post, 'discussion')
post.community.last_active = utcnow()
post.edited_at = utcnow()
db.session.commit()
post.flush_cache()
flash(_('Your changes have been saved.'), 'success')
# federate edit
if not post.community.local_only:
federate_post_update(post)
return redirect(url_for('activitypub.post_ap', post_id=post.id))
else:
form.discussion_title.data = post.title
form.discussion_body.data = post.body
form.notify_author.data = post.notify_author
form.nsfw.data = post.nsfw
form.nsfl.data = post.nsfl
form.sticky.data = post.sticky
if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()):
form.sticky.render_kw = {'disabled': True}
return render_template('post/post_edit_discussion.html', title=_('Edit post'), form=form, post=post,
markdown_editor=current_user.markdown_editor, mods=mod_list,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
else:
abort(401)
@bp.route('/post/<int:post_id>/edit_image', methods=['GET', 'POST'])
@login_required
def post_edit_image_post(post_id: int):
post = Post.query.get_or_404(post_id)
form = CreateImageForm()
del form.communities del form.communities
mods = post.community.moderators() mods = post.community.moderators()
@ -678,11 +791,10 @@ def post_edit(post_id: int):
form.nsfl.data = True form.nsfl.data = True
form.nsfw.render_kw = {'disabled': True} form.nsfw.render_kw = {'disabled': True}
#form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()]
old_url = post.url old_url = post.url
if form.validate_on_submit(): if form.validate_on_submit():
save_post(form, post) save_post(form, post, 'image')
post.community.last_active = utcnow() post.community.last_active = utcnow()
post.edited_at = utcnow() post.edited_at = utcnow()
db.session.commit() db.session.commit()
@ -714,6 +826,112 @@ def post_edit(post_id: int):
# federate edit # federate edit
if not post.community.local_only: if not post.community.local_only:
federate_post_update(post)
return redirect(url_for('activitypub.post_ap', post_id=post.id))
else:
form.image_title.data = post.title
form.image_body.data = post.body
form.image_alt_text.data = post.image.alt_text
form.notify_author.data = post.notify_author
form.nsfw.data = post.nsfw
form.nsfl.data = post.nsfl
form.sticky.data = post.sticky
if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()):
form.sticky.render_kw = {'disabled': True}
return render_template('post/post_edit_image.html', title=_('Edit post'), form=form, post=post,
markdown_editor=current_user.markdown_editor, mods=mod_list,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
else:
abort(401)
@bp.route('/post/<int:post_id>/edit_link', methods=['GET', 'POST'])
@login_required
def post_edit_link_post(post_id: int):
post = Post.query.get_or_404(post_id)
form = CreateLinkForm()
del form.communities
mods = post.community.moderators()
if post.community.private_mods:
mod_list = []
else:
mod_user_ids = [mod.user_id for mod in mods]
mod_list = User.query.filter(User.id.in_(mod_user_ids)).all()
if post.user_id == current_user.id or post.community.is_moderator() or current_user.is_admin():
if g.site.enable_nsfl is False:
form.nsfl.render_kw = {'disabled': True}
if post.community.nsfw:
form.nsfw.data = True
form.nsfw.render_kw = {'disabled': True}
if post.community.nsfl:
form.nsfl.data = True
form.nsfw.render_kw = {'disabled': True}
old_url = post.url
if form.validate_on_submit():
save_post(form, post, 'link')
post.community.last_active = utcnow()
post.edited_at = utcnow()
db.session.commit()
if post.url != old_url:
if post.cross_posts is not None:
old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all()
post.cross_posts.clear()
for ocp in old_cross_posts:
if ocp.cross_posts is not None:
ocp.cross_posts.remove(post.id)
new_cross_posts = Post.query.filter(Post.id != post.id, Post.url == post.url,
Post.posted_at > post.edited_at - timedelta(days=6)).all()
for ncp in new_cross_posts:
if ncp.cross_posts is None:
ncp.cross_posts = [post.id]
else:
ncp.cross_posts.append(post.id)
if post.cross_posts is None:
post.cross_posts = [ncp.id]
else:
post.cross_posts.append(ncp.id)
db.session.commit()
post.flush_cache()
flash(_('Your changes have been saved.'), 'success')
# federate edit
if not post.community.local_only:
federate_post_update(post)
return redirect(url_for('activitypub.post_ap', post_id=post.id))
else:
form.link_title.data = post.title
form.link_body.data = post.body
form.link_url.data = post.url
form.notify_author.data = post.notify_author
form.nsfw.data = post.nsfw
form.nsfl.data = post.nsfl
form.sticky.data = post.sticky
if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()):
form.sticky.render_kw = {'disabled': True}
return render_template('post/post_edit_link.html', title=_('Edit post'), form=form, post=post,
markdown_editor=current_user.markdown_editor, mods=mod_list,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
else:
abort(401)
def federate_post_update(post):
page_json = { page_json = {
'type': 'Page', 'type': 'Page',
'id': post.ap_id, 'id': post.ap_id,
@ -755,16 +973,18 @@ def post_edit(post_id: int):
page_json['attachment'] = [{'href': post.url, 'type': 'Link'}] page_json['attachment'] = [{'href': post.url, 'type': 'Link'}]
elif post.image_id: elif post.image_id:
if post.image.file_path: if post.image.file_path:
image_url = post.image.file_path.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/") image_url = post.image.file_path.replace('app/static/',
f"https://{current_app.config['SERVER_NAME']}/static/")
elif post.image.thumbnail_path: elif post.image.thumbnail_path:
image_url = post.image.thumbnail_path.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/") image_url = post.image.thumbnail_path.replace('app/static/',
f"https://{current_app.config['SERVER_NAME']}/static/")
else: else:
image_url = post.image.source_url image_url = post.image.source_url
# NB image is a dict while attachment is a list of dicts (usually just one dict in the list) # NB image is a dict while attachment is a list of dicts (usually just one dict in the list)
page_json['image'] = {'type': 'Image', 'url': image_url} page_json['image'] = {'type': 'Image', 'url': image_url}
if post.type == POST_TYPE_IMAGE: if post.type == POST_TYPE_IMAGE:
page_json['attachment'] = [{'type': 'Link', 'href': post.image.source_url}] # source_url is always a https link, no need for .replace() as done above page_json['attachment'] = [{'type': 'Link',
'href': post.image.source_url}] # source_url is always a https link, no need for .replace() as done above
if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it
success = post_request(post.community.ap_inbox_url, update_json, current_user.private_key, success = post_request(post.community.ap_inbox_url, update_json, current_user.private_key,
current_user.ap_profile_id + '#main-key') current_user.ap_profile_id + '#main-key')
@ -786,40 +1006,10 @@ def post_edit(post_id: int):
} }
for instance in post.community.following_instances(): for instance in post.community.following_instances():
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(
instance.domain):
send_to_remote_instance(instance.id, post.community.id, announce) send_to_remote_instance(instance.id, post.community.id, announce)
return redirect(url_for('activitypub.post_ap', post_id=post.id))
else:
if post.type == constants.POST_TYPE_ARTICLE:
form.post_type.data = 'discussion'
form.discussion_title.data = post.title
form.discussion_body.data = post.body
elif post.type == constants.POST_TYPE_LINK:
form.post_type.data = 'link'
form.link_title.data = post.title
form.link_body.data = post.body
form.link_url.data = post.url
elif post.type == constants.POST_TYPE_IMAGE:
form.post_type.data = 'image'
form.image_title.data = post.title
form.image_body.data = post.body
form.image_alt_text.data = post.image.alt_text
form.notify_author.data = post.notify_author
form.nsfw.data = post.nsfw
form.nsfl.data = post.nsfl
form.sticky.data = post.sticky
if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()):
form.sticky.render_kw = {'disabled': True}
return render_template('post/post_edit.html', title=_('Edit post'), form=form, post=post,
markdown_editor=current_user.markdown_editor, mods=mod_list,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
else:
abort(401)
@bp.route('/post/<int:post_id>/delete', methods=['GET', 'POST']) @bp.route('/post/<int:post_id>/delete', methods=['GET', 'POST'])
@login_required @login_required
@ -888,7 +1078,12 @@ def post_delete(post_id: int):
def post_report(post_id: int): def post_report(post_id: int):
post = Post.query.get_or_404(post_id) post = Post.query.get_or_404(post_id)
form = ReportPostForm() form = ReportPostForm()
if post.reports == -1: # When a mod decides to ignore future reports, post.reports is set to -1
flash(_('Moderators have already assessed reports regarding this post, no further reports are necessary.'), 'warning')
if form.validate_on_submit(): if form.validate_on_submit():
if post.reports == -1:
flash(_('Post has already been reported, thank you!'))
return redirect(post.community.local_url())
report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data,
type=1, reporter_id=current_user.id, suspect_user_id=post.author.id, suspect_post_id=post.id, type=1, reporter_id=current_user.id, suspect_user_id=post.author.id, suspect_post_id=post.id,
suspect_community_id=post.community.id, in_community_id=post.community.id, source_instance_id=1) suspect_community_id=post.community.id, in_community_id=post.community.id, source_instance_id=1)
@ -911,9 +1106,29 @@ def post_report(post_id: int):
admin.unread_notifications += 1 admin.unread_notifications += 1
db.session.commit() db.session.commit()
# todo: federate report to originating instance # federate report to community instance
if not post.community.is_local() and form.report_remote.data: if not post.community.is_local() and form.report_remote.data:
... summary = form.reasons_to_string(form.reasons.data)
if form.description.data:
summary += ' - ' + form.description.data
report_json = {
"actor": current_user.profile_id(),
"audience": post.community.profile_id(),
"content": None,
"id": f"https://{current_app.config['SERVER_NAME']}/activities/flag/{gibberish(15)}",
"object": post.ap_id,
"summary": summary,
"to": [
post.community.profile_id()
],
"type": "Flag"
}
instance = Instance.query.get(post.community.instance_id)
if post.community.ap_inbox_url and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
success = post_request(post.community.ap_inbox_url, report_json, current_user.private_key,
current_user.ap_profile_id + '#main-key')
if not success:
flash('Failed to send report to remote server', 'error')
flash(_('Post has been reported, thank you!')) flash(_('Post has been reported, thank you!'))
return redirect(post.community.local_url()) return redirect(post.community.local_url())
@ -992,7 +1207,16 @@ def post_reply_report(post_id: int, comment_id: int):
post = Post.query.get_or_404(post_id) post = Post.query.get_or_404(post_id)
post_reply = PostReply.query.get_or_404(comment_id) post_reply = PostReply.query.get_or_404(comment_id)
form = ReportPostForm() form = ReportPostForm()
if post_reply.reports == -1: # When a mod decides to ignore future reports, post_reply.reports is set to -1
flash(_('Moderators have already assessed reports regarding this comment, no further reports are necessary.'), 'warning')
if form.validate_on_submit(): if form.validate_on_submit():
if post_reply.reports == -1:
flash(_('Comment has already been reported, thank you!'))
return redirect(post.community.local_url())
report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data,
type=2, reporter_id=current_user.id, suspect_post_id=post.id, suspect_community_id=post.community.id, type=2, reporter_id=current_user.id, suspect_post_id=post.id, suspect_community_id=post.community.id,
suspect_user_id=post_reply.author.id, suspect_post_reply_id=post_reply.id, in_community_id=post.community.id, suspect_user_id=post_reply.author.id, suspect_post_reply_id=post_reply.id, in_community_id=post.community.id,
@ -1006,7 +1230,7 @@ def post_reply_report(post_id: int, comment_id: int):
url=f"https://{current_app.config['SERVER_NAME']}/comment/{post_reply.id}", url=f"https://{current_app.config['SERVER_NAME']}/comment/{post_reply.id}",
author_id=current_user.id) author_id=current_user.id)
db.session.add(notification) db.session.add(notification)
already_notified.add(mod.id) already_notified.add(mod.user_id)
post_reply.reports += 1 post_reply.reports += 1
# todo: only notify admins for certain types of report # todo: only notify admins for certain types of report
for admin in Site.admins(): for admin in Site.admins():
@ -1016,9 +1240,30 @@ def post_reply_report(post_id: int, comment_id: int):
admin.unread_notifications += 1 admin.unread_notifications += 1
db.session.commit() db.session.commit()
# todo: federate report to originating instance # federate report to originating instance
if not post.community.is_local() and form.report_remote.data: if not post.community.is_local() and form.report_remote.data:
... summary = form.reasons_to_string(form.reasons.data)
if form.description.data:
summary += ' - ' + form.description.data
report_json = {
"actor": current_user.profile_id(),
"audience": post.community.profile_id(),
"content": None,
"id": f"https://{current_app.config['SERVER_NAME']}/activities/flag/{gibberish(15)}",
"object": post_reply.ap_id,
"summary": summary,
"to": [
post.community.profile_id()
],
"type": "Flag"
}
instance = Instance.query.get(post.community.instance_id)
if post.community.ap_inbox_url and not current_user.has_blocked_instance(
instance.id) and not instance_banned(instance.domain):
success = post_request(post.community.ap_inbox_url, report_json, current_user.private_key,
current_user.ap_profile_id + '#main-key')
if not success:
flash('Failed to send report to remote server', 'error')
flash(_('Comment has been reported, thank you!')) flash(_('Comment has been reported, thank you!'))
return redirect(post.community.local_url()) return redirect(post.community.local_url())

View file

@ -5,7 +5,7 @@ from sqlalchemy import desc, text, or_
from app import db from app import db
from app.models import PostReply from app.models import PostReply
from app.utils import blocked_instances from app.utils import blocked_instances, blocked_users
# replies to a post, in a tree, sorted by a variety of methods # replies to a post, in a tree, sorted by a variety of methods
@ -17,6 +17,9 @@ def post_replies(post_id: int, sort_by: str, show_first: int = 0) -> List[PostRe
comments = comments.filter(or_(PostReply.instance_id.not_in(instance_ids), PostReply.instance_id == None)) comments = comments.filter(or_(PostReply.instance_id.not_in(instance_ids), PostReply.instance_id == None))
if current_user.ignore_bots: if current_user.ignore_bots:
comments = comments.filter(PostReply.from_bot == False) comments = comments.filter(PostReply.from_bot == False)
blocked_accounts = blocked_users(current_user.id)
if blocked_accounts:
comments = comments.filter(PostReply.user_id.not_in(blocked_accounts))
if sort_by == 'hot': if sort_by == 'hot':
comments = comments.order_by(desc(PostReply.ranking)) comments = comments.order_by(desc(PostReply.ranking))
elif sort_by == 'top': elif sort_by == 'top':

View file

@ -6,7 +6,7 @@ from sqlalchemy import or_
from app.models import Post from app.models import Post
from app.search import bp from app.search import bp
from app.utils import moderating_communities, joined_communities, render_template, blocked_domains, blocked_instances, \ from app.utils import moderating_communities, joined_communities, render_template, blocked_domains, blocked_instances, \
communities_banned_from communities_banned_from, recently_upvoted_posts, recently_downvoted_posts, blocked_users
@bp.route('/search', methods=['GET', 'POST']) @bp.route('/search', methods=['GET', 'POST'])
@ -30,6 +30,10 @@ def run_search():
instance_ids = blocked_instances(current_user.id) instance_ids = blocked_instances(current_user.id)
if instance_ids: if instance_ids:
posts = posts.filter(or_(Post.instance_id.not_in(instance_ids), Post.instance_id == None)) posts = posts.filter(or_(Post.instance_id.not_in(instance_ids), Post.instance_id == None))
# filter blocked users
blocked_accounts = blocked_users(current_user.id)
if blocked_accounts:
posts = posts.filter(Post.user_id.not_in(blocked_accounts))
banned_from = communities_banned_from(current_user.id) banned_from = communities_banned_from(current_user.id)
if banned_from: if banned_from:
posts = posts.filter(Post.community_id.not_in(banned_from)) posts = posts.filter(Post.community_id.not_in(banned_from))
@ -46,8 +50,18 @@ def run_search():
next_url = url_for('search.run_search', page=posts.next_num, q=q) if posts.has_next else None next_url = url_for('search.run_search', page=posts.next_num, q=q) if posts.has_next else None
prev_url = url_for('search.run_search', page=posts.prev_num, q=q) if posts.has_prev and page != 1 else None prev_url = url_for('search.run_search', page=posts.prev_num, q=q) if posts.has_prev and page != 1 else None
# Voting history
if current_user.is_authenticated:
recently_upvoted = recently_upvoted_posts(current_user.id)
recently_downvoted = recently_downvoted_posts(current_user.id)
else:
recently_upvoted = []
recently_downvoted = []
return render_template('search/results.html', title=_('Search results for %(q)s', q=q), posts=posts, q=q, return render_template('search/results.html', title=_('Search results for %(q)s', q=q), posts=posts, q=q,
next_url=next_url, prev_url=prev_url, show_post_community=True, next_url=next_url, prev_url=prev_url, show_post_community=True,
recently_upvoted=recently_upvoted,
recently_downvoted=recently_downvoted,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()),
site=g.site) site=g.site)

View file

@ -501,6 +501,14 @@ fieldset legend {
.form-group { .form-group {
margin-bottom: 1.1rem; margin-bottom: 1.1rem;
} }
.form-group.required label:after {
content: "*";
color: red;
margin-left: 2px;
font-size: 80%;
position: relative;
top: -1px;
}
.card { .card {
max-width: 350px; max-width: 350px;
@ -977,7 +985,8 @@ fieldset legend {
} }
.voting_buttons_new .upvote_button, .voting_buttons_new .downvote_button { .voting_buttons_new .upvote_button, .voting_buttons_new .downvote_button {
display: inline-block; display: inline-block;
padding: 5px 15px; padding: 5px 0 5px 3px;
text-align: center;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
@ -1004,11 +1013,6 @@ fieldset legend {
.voting_buttons_new .upvote_button { .voting_buttons_new .upvote_button {
top: 1px; top: 1px;
} }
@media (min-width: 1280px) {
.voting_buttons_new .upvote_button {
padding-right: 5px;
}
}
.voting_buttons_new .upvote_button .htmx-indicator { .voting_buttons_new .upvote_button .htmx-indicator {
left: 13px; left: 13px;
top: 7px; top: 7px;
@ -1018,14 +1022,7 @@ fieldset legend {
} }
.voting_buttons_new .downvote_button .htmx-indicator { .voting_buttons_new .downvote_button .htmx-indicator {
left: 12px; left: 12px;
} top: 5px;
@media (min-width: 1280px) {
.voting_buttons_new .downvote_button {
padding-left: 5px;
}
.voting_buttons_new .downvote_button .htmx-indicator {
left: 2px;
}
} }
.voting_buttons_new .htmx-indicator { .voting_buttons_new .htmx-indicator {
position: absolute; position: absolute;
@ -1117,7 +1114,6 @@ fieldset legend {
.comment { .comment {
clear: both; clear: both;
margin-bottom: 10px;
margin-left: 15px; margin-left: 15px;
padding-top: 8px; padding-top: 8px;
} }
@ -1170,7 +1166,7 @@ fieldset legend {
} }
.comment .comment_actions a { .comment .comment_actions a {
text-decoration: none; text-decoration: none;
padding: 5px 0; padding: 0;
} }
.comment .comment_actions .hide_button { .comment .comment_actions .hide_button {
display: inline-block; display: inline-block;

View file

@ -65,6 +65,19 @@ html {
.form-group { .form-group {
margin-bottom: 1.1rem; margin-bottom: 1.1rem;
&.required {
label {
&:after {
content: '*';
color: red;
margin-left: 2px;
font-size: 80%;
position: relative;
top: -1px;
}
}
}
} }
.card { .card {
@ -610,7 +623,8 @@ html {
.upvote_button, .downvote_button { .upvote_button, .downvote_button {
display: inline-block; display: inline-block;
padding: 5px 15px; padding: 5px 0 5px 3px;
text-align: center;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
color: rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1)); color: rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));
@ -641,9 +655,7 @@ html {
.upvote_button { .upvote_button {
top: 1px; top: 1px;
@include breakpoint(laptop) {
padding-right: 5px;
}
.htmx-indicator { .htmx-indicator {
left: 13px; left: 13px;
top: 7px; top: 7px;
@ -654,14 +666,8 @@ html {
top: 1px; top: 1px;
.htmx-indicator { .htmx-indicator {
left: 12px; left: 12px;
top: 5px;
} }
@include breakpoint(laptop) {
padding-left: 5px;
.htmx-indicator {
left: 2px;
}
}
} }
.htmx-indicator{ .htmx-indicator{
@ -763,7 +769,6 @@ html {
.comment { .comment {
clear: both; clear: both;
margin-bottom: 10px;
margin-left: 15px; margin-left: 15px;
padding-top: 8px; padding-top: 8px;
@ -823,7 +828,7 @@ html {
position: relative; position: relative;
a { a {
text-decoration: none; text-decoration: none;
padding: 5px 0; padding: 0;
} }
.hide_button { .hide_button {

View file

@ -693,7 +693,7 @@ div.navbar {
.comment_actions_link { .comment_actions_link {
display: block; display: block;
position: absolute; position: absolute;
bottom: 0; top: 3px;
right: -16px; right: -16px;
width: 41px; width: 41px;
text-decoration: none; text-decoration: none;

View file

@ -284,7 +284,7 @@ div.navbar {
.comment_actions_link { .comment_actions_link {
display: block; display: block;
position: absolute; position: absolute;
bottom: 0; top: 3px;
right: -16px; right: -16px;
width: 41px; width: 41px;
text-decoration: none; text-decoration: none;

View file

@ -1,4 +1,4 @@
<nav class="mb-4"> <nav class="mb-1">
<h2 class="visually-hidden">{{ _('Admin navigation') }}</h2> <h2 class="visually-hidden">{{ _('Admin navigation') }}</h2>
<a href="{{ url_for('admin.admin_home') }}">{{ _('Admin home') }}</a> | <a href="{{ url_for('admin.admin_home') }}">{{ _('Admin home') }}</a> |
<a href="{{ url_for('admin.admin_site') }}">{{ _('Site profile') }}</a> | <a href="{{ url_for('admin.admin_site') }}">{{ _('Site profile') }}</a> |

View file

@ -4,16 +4,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_activities' %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include 'admin/_nav.html' %} <h1>{{ _('Activities') }}</h1>
</div>
</div>
<div class="row">
<div class="col">
<table class="table"> <table class="table">
<tr> <tr>
<th>When</th> <th>When</th>
@ -62,4 +58,11 @@
</nav> </nav>
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,20 +4,24 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_activities' %}
{% block app_content %} {% block app_content %}
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h1>{{ _('Activity JSON') }}</h1>
<code><pre>{{ activity_json_data | tojson(indent=2) }}</pre></code> <code><pre>{{ activity_json_data | tojson(indent=2) }}</pre></code>
{% if current_app.debug %} {% if current_app.debug %}
<p><a class="btn btn-warning" href="{{ url_for('admin.activity_replay', activity_id=activity.id) }}">Re-submit this activity</a></p> <p><a class="btn btn-warning" href="{{ url_for('admin.activity_replay', activity_id=activity.id) }}">Re-submit this activity</a></p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,17 +4,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_field %} {% from 'bootstrap/form.html' import render_field %}
{% set active_child = 'admin_users' %}
{% block app_content %} {% block app_content %}
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<div class="row"> <div class="row">
<div class="col col-login mx-auto"> <div class="col col-login mx-auto">
<h3>{{ _('Add new user') }}</h3> <h1>{{ _('Add new user') }}</h1>
<form method="post" enctype="multipart/form-data" id="add_local_user_form"> <form method="post" enctype="multipart/form-data" id="add_local_user_form">
{{ form.csrf_token() }} {{ form.csrf_token() }}
{{ render_field(form.user_name) }} {{ render_field(form.user_name) }}
@ -44,4 +39,11 @@
</form> </form>
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,16 +4,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_approve_registrations' %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include 'admin/_nav.html' %} <h1>{{ _('Registrations') }}</h1>
</div>
</div>
<div class="row">
<div class="col">
{% if registrations %} {% if registrations %}
<p>{{ _('When registering, people are asked "%(question)s".', question=site.application_question) }} </p> <p>{{ _('When registering, people are asked "%(question)s".', question=site.application_question) }} </p>
<form method="get"> <form method="get">
@ -52,4 +48,11 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,16 +4,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_communities' %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include 'admin/_nav.html' %} <h1>{{ _('Communities') }}</h1>
</div>
</div>
<div class="row">
<div class="col">
<form method="get"> <form method="get">
<input type="search" name="search"> <input type="submit" name="submit" value="Search"> <input type="search" name="search"> <input type="submit" name="submit" value="Search">
</form> </form>
@ -59,4 +55,11 @@
</nav> </nav>
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,17 +4,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_field %} {% from 'bootstrap/form.html' import render_field %}
{% set active_child = 'admin_communities' %}
{% block app_content %} {% block app_content %}
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<div class="row"> <div class="row">
<div class="col col-login mx-auto"> <div class="col col-login mx-auto">
<h3>{{ _('Edit %(community_name)s', community_name=community.display_name()) }}</h3> <h1>{{ _('Edit %(community_name)s', community_name=community.display_name()) }}</h1>
<form method="post" enctype="multipart/form-data" id="add_local_community_form"> <form method="post" enctype="multipart/form-data" id="add_local_community_form">
{{ form.csrf_token() }} {{ form.csrf_token() }}
{{ render_field(form.title) }} {{ render_field(form.title) }}
@ -59,4 +54,11 @@
</form> </form>
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,20 +4,22 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_topics' %}
{% block app_content %} {% block app_content %}
<div class="row">
<div class="col col-login mx-auto">
{% if topic %}
<h1>{{ _('Edit %(topic_name)s', topic_name=topic.name) }}</h1>
{% endif %}
{{ render_form(form) }}
</div>
</div>
<hr />
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include 'admin/_nav.html' %} {% include 'admin/_nav.html' %}
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col col-login mx-auto">
{% if topic %}
<h3>{{ _('Edit %(topic_name)s', topic_name=topic.name) }}</h3>
{% endif %}
{{ render_form(form) }}
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -4,17 +4,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_field %} {% from 'bootstrap/form.html' import render_field %}
{% set active_child = 'admin_users' %}
{% block app_content %} {% block app_content %}
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<div class="row"> <div class="row">
<div class="col col-login mx-auto"> <div class="col col-login mx-auto">
<h3>{{ _('Edit %(user_name)s (%(display_name)s)', user_name=user.user_name, display_name=user.display_name()) }}</h3> <h1>{{ _('Edit %(user_name)s (%(display_name)s)', user_name=user.user_name, display_name=user.display_name()) }}</h1>
<form method="post" enctype="multipart/form-data" id="add_local_user_form"> <form method="post" enctype="multipart/form-data" id="add_local_user_form">
{{ form.csrf_token() }} {{ form.csrf_token() }}
{{ user.about_html|safe if user.about_html }} {{ user.about_html|safe if user.about_html }}
@ -48,4 +43,11 @@
</p> </p>
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,19 +4,21 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_federation' %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include 'admin/_nav.html' %} <h1>{{ _('Federation') }}</h1>
</div>
</div>
<div class="row">
<div class="col">
{{ render_form(form) }} {{ render_form(form) }}
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,18 +4,20 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_misc' %}
{% block app_content %} {% block app_content %}
<div class="row">
<div class="col">
<h1>{{ _('Misc settings') }}</h1>
{{ render_form(form) }}
</div>
</div>
<hr />
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include 'admin/_nav.html' %} {% include 'admin/_nav.html' %}
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{{ render_form(form) }}
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -1,17 +1,20 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_newsletter' %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include 'admin/_nav.html' %} <h1>{{ _('Newsletter') }}</h1>
</div>
</div>
<div class="row">
<div class="col">
{{ render_form(form) }} {{ render_form(form) }}
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,17 +4,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_content_trash' %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include 'admin/_nav.html' %} <h1>{{ _('Most downvoted posts in the last 3 days') }}</h1>
</div>
</div>
<div class="row">
<div class="col">
<h1>{{ _('Most downvoted in the last 3 days') }}</h1>
<div class="post_list"> <div class="post_list">
{% for post in posts.items %} {% for post in posts.items %}
{% include 'post/_post_teaser.html' %} {% include 'post/_post_teaser.html' %}
@ -34,4 +29,11 @@
</nav> </nav>
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,16 +4,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_reports' %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include 'admin/_nav.html' %} <h1>{{ _('Reports') }}</h1>
</div>
</div>
<div class="row">
<div class="col">
<form method="get"> <form method="get">
<input type="search" name="search" value="{{ search }}"> <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="local" id="local_remote_local" {{ 'checked' if local_remote == 'local' }}><label for="local_remote_local"> Local</label>
@ -66,4 +62,11 @@
</nav> </nav>
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,17 +4,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_field %} {% from 'bootstrap/form.html' import render_field %}
{% set active_child = 'admin_site' %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include 'admin/_nav.html' %} <h1>{{ _('Site profile') }}</h1>
</div>
</div>
<div class="row">
<div class="col">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{{ form.csrf_token() }} {{ form.csrf_token() }}
{{ render_field(form.name) }} {{ render_field(form.name) }}
@ -28,4 +24,11 @@
</form> </form>
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,13 +4,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_topics' %}
{% block app_content %} {% block app_content %}
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
{% macro render_topic(topic, depth) %} {% macro render_topic(topic, depth) %}
<tr> <tr>
<td nowrap="nowrap">{{ '--' * depth }} {{ topic['topic'].name }}</td> <td nowrap="nowrap">{{ '--' * depth }} {{ topic['topic'].name }}</td>
@ -32,7 +28,7 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<p><a href="{{ url_for('admin.admin_topic_add') }}" class="btn btn-primary">{{ _('Add topic') }}</a></p> <h1><a href="{{ url_for('admin.admin_topic_add') }}" class="btn btn-primary" style="float: right;">{{ _('Add topic') }}</a>{{ _('Topics') }}</h1>
<table class="table table-striped"> <table class="table table-striped">
<tr> <tr>
<th>Name</th> <th>Name</th>
@ -45,4 +41,11 @@
</table> </table>
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -4,21 +4,19 @@
{% extends "base.html" %} {% extends "base.html" %}
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_users' %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include 'admin/_nav.html' %} <h1>{{ _('Users') }}</h1>
</div>
</div>
<div class="row">
<div class="col">
<a class="btn btn-primary" href="{{ url_for('admin.admin_users_add') }}" style="float: right;">{{ _('Add local user') }}</a> <a class="btn btn-primary" href="{{ url_for('admin.admin_users_add') }}" style="float: right;">{{ _('Add local user') }}</a>
<form method="get"> <form method="get">
<input type="search" name="search" value="{{ search }}"> <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="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="radio" name="local_remote" value="remote" id="local_remote_remote" {{ 'checked' if local_remote == 'remote' }}><label for="local_remote_remote"> Remote</label>
<input type="radio" name="type" value="bad_rep" id="type_bad_rep" {{ 'checked' if type == 'bad_rep' }}><label for="type_bad_rep"> Bad rep</label>
<input type="radio" name="type" value="bad_attitude" id="type_bad_attitude" {{ 'checked' if type == 'bad_attitude' }}><label for="type_bad_attitude"> Bad attitude</label>
<input type="submit" name="submit" value="Search" class="btn btn-primary"> <input type="submit" name="submit" value="Search" class="btn btn-primary">
</form> </form>
<table class="table table-striped mt-1"> <table class="table table-striped mt-1">
@ -77,4 +75,11 @@
</nav> </nav>
</div> </div>
</div> </div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %} {% endblock %}

View file

@ -136,8 +136,8 @@
<li class="nav-item dropdown{% if active_parent == 'communities' %} active{% endif %}"> <li class="nav-item dropdown{% if active_parent == 'communities' %} active{% endif %}">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="/topics" aria-haspopup="true" aria-expanded="false">{{ _('Topics') }}</a> <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="/topics" aria-haspopup="true" aria-expanded="false">{{ _('Topics') }}</a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item{% if active_child == 'list_communities' %} active{% endif %}" href="/topics">{{ _('Browse by topic') }}</a></li> <li><a class="dropdown-item{% if active_child == 'list_topics' %} active{% endif %}" href="/topics">{{ _('Browse by topic') }}</a></li>
<li><a class="dropdown-item{% if active_child == 'list_topics' %} active{% endif %}" href="/communities">{{ _('All communities') }}</a></li> <li><a class="dropdown-item{% if active_child == 'list_communities' %} active{% endif %}" href="/communities">{{ _('All communities') }}</a></li>
</ul> </ul>
</li> </li>
<li class="nav-item"><a class="nav-link" href="/auth/login">{{ _('Log in') }}</a></li> <li class="nav-item"><a class="nav-link" href="/auth/login">{{ _('Log in') }}</a></li>
@ -182,7 +182,26 @@
</li> </li>
<li class="nav-item"><a class="nav-link" href="/donate">{{ _('Donate') }}</a></li> <li class="nav-item"><a class="nav-link" href="/donate">{{ _('Donate') }}</a></li>
{% if user_access('change instance settings', current_user.id) %} {% if user_access('change instance settings', current_user.id) %}
<li class="nav-item"><a class="nav-link" href="/admin/">{{ _('Admin') }}</a></li> <li class="nav-item dropdown{% if active_parent == 'admin' %} active{% endif %}">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="/admin/" aria-haspopup="true" aria-expanded="false">{{ _('Admin') }}</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_site' }}" href="{{ url_for('admin.admin_site') }}">{{ _('Site profile') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_misc' }}" href="{{ url_for('admin.admin_misc') }}">{{ _('Misc settings') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_communities' }}" href="{{ url_for('admin.admin_communities') }}">{{ _('Communities') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_topics' }}" href="{{ url_for('admin.admin_topics') }}">{{ _('Topics') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_users' }}" href="{{ url_for('admin.admin_users', local_remote='local') }}">{{ _('Users') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_users_trash' }}" href="{{ url_for('admin.admin_users_trash', local_remote='local') }}">{{ _('Monitoring - users') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_content_trash' }}" href="{{ url_for('admin.admin_content_trash') }}">{{ _('Monitoring - content') }}</a></li>
{% if g.site.registration_mode == 'RequireApplication' %}
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_approve_registrations' }}" href="{{ url_for('admin.admin_approve_registrations') }}">{{ _('Registration applications') }}</a></li>
{% endif %}
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_reports' }}" href="{{ url_for('admin.admin_reports') }}">{{ _('Moderation') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_federation' }}" href="{{ url_for('admin.admin_federation') }}">{{ _('Federation') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_newsletter' }}" href="{{ url_for('admin.newsletter') }}">{{ _('Newsletter') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_activities' }}" href="{{ url_for('admin.admin_activities') }}">{{ _('Activities') }}</a></li>
</ul>
</li>
{% endif %} {% endif %}
<li class="nav-item"><a class="nav-link" href="/auth/logout">{{ _('Log out') }}</a></li> <li class="nav-item"><a class="nav-link" href="/auth/logout">{{ _('Log out') }}</a></li>
<li class="nav-item d-none d-md-inline-block"> <li class="nav-item d-none d-md-inline-block">

View file

@ -0,0 +1,95 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_field %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<h1>{{ _('Create post') }}</h1>
<form method="post" enctype="multipart/form-data" role="form">
{{ form.csrf_token() }}
<div class="form-group">
<label class="form-control-label" for="type_of_post">
{{ _('Type of post') }}
</label>
<div id="type_of_post" class="btn-group flex-wrap" role="navigation">
<a href="{{ url_for('community.add_discussion_post', actor=actor) }}" class="btn btn-primary" aria-label="{{ _('Start a discussion') }}">{{ _('Discussion') }}</a>
<a href="{{ url_for('community.add_link_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share a link') }}">{{ _('Link') }}</a>
<a href="{{ url_for('community.add_image_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share an image') }}">{{ _('Image') }}</a>
<!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a>
<a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> -->
</div>
</div>
{{ render_field(form.communities) }}
{{ render_field(form.discussion_title) }}
{{ render_field(form.discussion_body) }}
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#discussion_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('discussion_body');
});
</script>
{% else %}
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="discussion_body">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
<div class="row mt-4">
<div class="col-md-3">
{{ render_field(form.notify_author) }}
</div>
<div class="col-md-1">
{{ render_field(form.sticky) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfw) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfl) }}
</div>
<div class="col">
</div>
</div>
{{ render_field(form.submit) }}
</form>
</div>
<aside id="side_pane" class="col-12 col-md-4 side_pane" role="complementary">
<div class="card mb-3">
<div class="card-header">
<h2>{{ community.title }}</h2>
</div>
<div class="card-body">
<p>{{ community.description_html|safe if community.description_html else '' }}</p>
<p>{{ community.rules_html|safe if community.rules_html else '' }}</p>
{% if len(mods) > 0 and not community.private_mods %}
<h3>Moderators</h3>
<ul class="moderator_list">
{% for mod in mods %}
<li>{{ render_username(mod) }}</li>
{% endfor %}
</ul>
{% endif %}
{% if rss_feed %}
<p class="mt-4">
<a class="no-underline" href="{{ rss_feed }}" rel="nofollow"><span class="fe fe-rss"></span> RSS feed</a>
</p>
{% endif %}
</div>
</div>
{% include "_inoculation_links.html" %}
</aside>
</div>
{% endblock %}

View file

@ -0,0 +1,97 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_field %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<h1>{{ _('Create post') }}</h1>
<form method="post" enctype="multipart/form-data" role="form">
{{ form.csrf_token() }}
<div class="form-group">
<label class="form-control-label" for="type_of_post">
{{ _('Type of post') }}
</label>
<div id="type_of_post" class="btn-group flex-wrap" role="navigation">
<a href="{{ url_for('community.add_discussion_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Start a discussion') }}">{{ _('Discussion') }}</a>
<a href="{{ url_for('community.add_link_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share a link') }}">{{ _('Link') }}</a>
<a href="{{ url_for('community.add_image_post', actor=actor) }}" class="btn btn-primary" aria-label="{{ _('Share an image') }}">{{ _('Image') }}</a>
<!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a>
<a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> -->
</div>
</div>
{{ render_field(form.communities) }}
{{ render_field(form.image_title) }}
{{ render_field(form.image_file) }}
{{ render_field(form.image_alt_text) }}
<small class="field_hint">{{ _('Describe the image, to help visually impaired people.') }}</small>
{{ render_field(form.image_body) }}
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#image_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('image_body');
});
</script>
{% else %}
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="image_body">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
<div class="row mt-4">
<div class="col-md-3">
{{ render_field(form.notify_author) }}
</div>
<div class="col-md-1">
{{ render_field(form.sticky) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfw) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfl) }}
</div>
<div class="col">
</div>
</div>
{{ render_field(form.submit) }}
</form>
</div>
<aside id="side_pane" class="col-12 col-md-4 side_pane" role="complementary">
<div class="card mb-3">
<div class="card-header">
<h2>{{ community.title }}</h2>
</div>
<div class="card-body">
<p>{{ community.description_html|safe if community.description_html else '' }}</p>
<p>{{ community.rules_html|safe if community.rules_html else '' }}</p>
{% if len(mods) > 0 and not community.private_mods %}
<h3>Moderators</h3>
<ul class="moderator_list">
{% for mod in mods %}
<li>{{ render_username(mod) }}</li>
{% endfor %}
</ul>
{% endif %}
{% if rss_feed %}
<p class="mt-4">
<a class="no-underline" href="{{ rss_feed }}" rel="nofollow"><span class="fe fe-rss"></span> RSS feed</a>
</p>
{% endif %}
</div>
</div>
{% include "_inoculation_links.html" %}
</aside>
</div>
{% endblock %}

View file

@ -0,0 +1,96 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_field %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<h1>{{ _('Create post') }}</h1>
<form method="post" enctype="multipart/form-data" role="form">
{{ form.csrf_token() }}
<div class="form-group">
<label class="form-control-label" for="type_of_post">
{{ _('Type of post') }}
</label>
<div id="type_of_post" class="btn-group flex-wrap" role="navigation">
<a href="{{ url_for('community.add_discussion_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Start a discussion') }}">{{ _('Discussion') }}</a>
<a href="{{ url_for('community.add_link_post', actor=actor) }}" class="btn btn-primary" aria-label="{{ _('Share a link') }}">{{ _('Link') }}</a>
<a href="{{ url_for('community.add_image_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share an image') }}">{{ _('Image') }}</a>
<!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a>
<a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> -->
</div>
</div>
{{ render_field(form.communities) }}
{{ render_field(form.link_title) }}
{{ render_field(form.link_url) }}
{{ render_field(form.link_body) }}
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#link_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('link_body');
});
</script>
{% else %}
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="link_body">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
<div class="row mt-4">
<div class="col-md-3">
{{ render_field(form.notify_author) }}
</div>
<div class="col-md-1">
{{ render_field(form.sticky) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfw) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfl) }}
</div>
<div class="col">
</div>
</div>
{{ render_field(form.submit) }}
</form>
</div>
<aside id="side_pane" class="col-12 col-md-4 side_pane" role="complementary">
<div class="card mb-3">
<div class="card-header">
<h2>{{ community.title }}</h2>
</div>
<div class="card-body">
<p>{{ community.description_html|safe if community.description_html else '' }}</p>
<p>{{ community.rules_html|safe if community.rules_html else '' }}</p>
{% if len(mods) > 0 and not community.private_mods %}
<h3>Moderators</h3>
<ul class="moderator_list">
{% for mod in mods %}
<li>{{ render_username(mod) }}</li>
{% endfor %}
</ul>
{% endif %}
{% if rss_feed %}
<p class="mt-4">
<a class="no-underline" href="{{ rss_feed }}" rel="nofollow"><span class="fe fe-rss"></span> RSS feed</a>
</p>
{% endif %}
</div>
</div>
{% include "_inoculation_links.html" %}
</aside>
</div>
{% endblock %}

View file

@ -1,145 +0,0 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_field %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<h1>{{ _('Create post') }}</h1>
<form method="post" enctype="multipart/form-data" role="form">
{{ form.csrf_token() }}
{{ render_field(form.communities) }}
<nav id="post_type_chooser">
<div class="nav nav-tabs nav-justified" id="typeTab" role="tablist">
<button class="nav-link active" id="discussion-tab" data-bs-toggle="tab" data-bs-target="#discussion-tab-pane"
type="button" role="tab" aria-controls="discussion-tab-pane" aria-selected="true">Discussion</button>
<button class="nav-link" id="link-tab" data-bs-toggle="tab" data-bs-target="#link-tab-pane"
type="button" role="tab" aria-controls="link-tab-pane" aria-selected="false">Link</button>
<button class="nav-link" id="image-tab" data-bs-toggle="tab" data-bs-target="#image-tab-pane"
type="button" role="tab" aria-controls="image-tab-pane" aria-selected="false">Image</button>
<button class="nav-link" id="poll-tab" data-bs-toggle="tab" data-bs-target="#poll-tab-pane"
type="button" role="tab" aria-controls="poll-tab-pane" aria-selected="false" disabled>Poll</button>
</div>
</nav>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="discussion-tab-pane" role="tabpanel" aria-labelledby="home-tab" tabindex="0">
{{ render_field(form.discussion_title) }}
{{ render_field(form.discussion_body) }}
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#discussion_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('discussion_body');
});
</script>
{% else %}
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="discussion_body">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
</div>
<div class="tab-pane fade" id="link-tab-pane" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">
{{ render_field(form.link_title) }}
{{ render_field(form.link_url) }}
{{ render_field(form.link_body) }}
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#link_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('link_body');
});
</script>
{% else %}
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="link_body">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
</div>
<div class="tab-pane fade" id="image-tab-pane" role="tabpanel" aria-labelledby="contact-tab" tabindex="0">
{{ render_field(form.image_title) }}
{{ render_field(form.image_file) }}
{{ render_field(form.image_alt_text) }}
<small class="field_hint">{{ _('Describe the image, to help visually impaired people.') }}</small>
{{ render_field(form.image_body) }}
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#image_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('image_body');
});
</script>
{% else %}
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="image_body">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
</div>
<div class="tab-pane fade" id="poll-tab-pane" role="tabpanel" aria-labelledby="disabled-tab" tabindex="0">
Poll
</div>
</div>
{{ render_field(form.post_type) }}
<div class="row mt-4">
<div class="col-md-3">
{{ render_field(form.notify_author) }}
</div>
<div class="col-md-1">
{{ render_field(form.sticky) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfw) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfl) }}
</div>
<div class="col">
</div>
</div>
{{ render_field(form.submit) }}
</form>
</div>
<aside id="side_pane" class="col-12 col-md-4 side_pane" role="complementary">
<div class="card mb-3">
<div class="card-header">
<h2>{{ community.title }}</h2>
</div>
<div class="card-body">
<p>{{ community.description_html|safe if community.description_html else '' }}</p>
<p>{{ community.rules_html|safe if community.rules_html else '' }}</p>
{% if len(mods) > 0 and not community.private_mods %}
<h3>Moderators</h3>
<ul class="moderator_list">
{% for mod in mods %}
<li>{{ render_username(mod) }}</li>
{% endfor %}
</ul>
{% endif %}
{% if rss_feed %}
<p class="mt-4">
<a class="no-underline" href="{{ rss_feed }}" rel="nofollow"><span class="fe fe-rss"></span> RSS feed</a>
</p>
{% endif %}
</div>
</div>
{% include "_inoculation_links.html" %}
</aside>
</div>
{% endblock %}

View file

@ -22,7 +22,7 @@
<h1 class="mt-2">{{ _('Moderators for %(community)s', community=community.display_name()) }}</h1> <h1 class="mt-2">{{ _('Moderators for %(community)s', community=community.display_name()) }}</h1>
</div> </div>
<p>{{ _('See and change who moderates this community') }}</p> <p>{{ _('See and change who moderates this community') }}</p>
<div class="col-12 col-md-2 text-right"> <div class="col-12 text-right">
<a class="btn btn-primary" href="{{ url_for('community.community_add_moderator', community_id=community.id) }}">{{ _('Add moderator') }}</a> <a class="btn btn-primary" href="{{ url_for('community.community_add_moderator', community_id=community.id) }}">{{ _('Add moderator') }}</a>
</div> </div>
</div> </div>

View file

@ -30,7 +30,7 @@
<input type="search" name="search" value="{{ search }}"> <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="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="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"> <input type="submit" name="submit" value="Search" class="btn btn-primary btn-sm">
</form> </form>
<table class="table table-striped"> <table class="table table-striped">
<tr> <tr>
@ -49,17 +49,27 @@
<td>{{ report.type_text() }}</td> <td>{{ report.type_text() }}</td>
<td>{{ moment(report.created_at).fromNow() }}</td> <td>{{ moment(report.created_at).fromNow() }}</td>
<td> <td>
<div class="dropdown">
<button class="btn btn-primary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu">
{% if report.suspect_conversation_id %} {% if report.suspect_conversation_id %}
<a href="/chat/{{ report.suspect_conversation_id }}#message">View</a> <li><a class="dropdown-item" href="/chat/{{ report.suspect_conversation_id }}#message">View</a></li>
{% elif report.suspect_post_reply_id %} {% elif report.suspect_post_reply_id %}
<a href="/post/{{ report.suspect_post_id }}#comment_{{ report.suspect_post_reply_id }}">View</a> <li><a class="dropdown-item" href="/post/{{ report.suspect_post_id }}#comment_{{ report.suspect_post_reply_id }}">View</a></li>
{% elif report.suspect_post_id %} {% elif report.suspect_post_id %}
<a href="/post/{{ report.suspect_post_id }}">View</a> <li><a class="dropdown-item" href="/post/{{ report.suspect_post_id }}">View</a></li>
{% elif report.suspect_user_id %} {% elif report.suspect_user_id %}
<a href="/user/{{ report.suspect_user_id }}">View</a> <li><a class="dropdown-item" href="/user/{{ report.suspect_user_id }}">View</a></li>
{% endif %} | {% endif %}
<a href="{{ url_for('community.community_moderate_report_escalate', community_id=community.id, report_id=report.id) }}">{{ _('Escalate') }}</a> | <div class="dropdown-divider"></div>
<a href="{{ url_for('community.community_moderate_report_resolve', community_id=community.id, report_id=report.id) }}">{{ _('Resolve') }}</a> <li><a class="dropdown-item" href="{{ url_for('community.community_moderate_report_escalate', community_id=community.id, report_id=report.id) }}">{{ _('Escalate') }}</a></li>
<li><a class="dropdown-item" href="{{ url_for('community.community_moderate_report_resolve', community_id=community.id, report_id=report.id) }}">{{ _('Resolve') }}</a></li>
<div class="dropdown-divider"></div>
<li><a class="confirm_first dropdown-item" href="{{ url_for('community.community_moderate_report_ignore', community_id=community.id, report_id=report.id) }}">{{ _('Ignore') }}</a></li>
</ul>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View file

@ -8,14 +8,26 @@
{% block app_content %} {% block app_content %}
{% if len(topics) > 0 %} {% if len(topics) > 0 %}
{% macro render_topic(topic, depth) %}
<tr>
<td nowrap="nowrap">{{ '--' * depth }}
{% if depth == 0 %}<strong>{% endif %}
<a href="/topic/{{ topic['topic'].path() }}">{{ topic['topic'].name }}</a>
{% if depth == 0 %}</strong>{% endif %}
</td>
</tr>
{% if topic['children'] %}
{% for topic in topic['children'] %}
{{ render_topic(topic, depth + 1)|safe }}
{% endfor %}
{% endif %}
{% endmacro %}
<h1>{{ _('Choose a topic') }}</h1> <h1>{{ _('Choose a topic') }}</h1>
<div class="table-responsive-md mt-4"> <div class="table-responsive-md mt-4">
<table class="communities_table table table-striped table-hover w-100"> <table class="communities_table table table-striped table-hover w-100">
<tbody> <tbody>
{% for topic in topics %} {% for topic in topics %}
<tr> {{ render_topic(topic, 0)|safe }}
<th class="pl-2"><a href="/topic/{{ topic.machine_name }}">{{ topic.name }}</a></th>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -1,6 +1,6 @@
{% if current_user.is_authenticated and current_user.verified %} {% if current_user.is_authenticated and current_user.verified %}
{% if can_upvote(current_user, community) %} {% if can_upvote(current_user, community) %}
<div class="upvote_button {{ upvoted_class }}" role="button" aria-label="{{ _('UpVote button.') }}" aria-live="assertive" <div class="upvote_button {{ 'voted_up' if in_sorted_list(recently_upvoted_replies, comment.id) }}" role="button" aria-label="{{ _('UpVote button.') }}" aria-live="assertive"
hx-post="/comment/{{ comment.id }}/upvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons_new" tabindex="0"> hx-post="/comment/{{ comment.id }}/upvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons_new" tabindex="0">
<span class="fe fe-arrow-up"></span> <span class="fe fe-arrow-up"></span>
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;"> <img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
@ -8,7 +8,7 @@
{% endif %} {% endif %}
<span title="{{ comment.up_votes }}, {{ comment.down_votes }}" aria-live="assertive" aria-label="{{ _('Score: ') }}{{ comment.up_votes - comment.down_votes }}.">{{ comment.up_votes - comment.down_votes }}</span> <span title="{{ comment.up_votes }}, {{ comment.down_votes }}" aria-live="assertive" aria-label="{{ _('Score: ') }}{{ comment.up_votes - comment.down_votes }}.">{{ comment.up_votes - comment.down_votes }}</span>
{% if can_downvote(current_user, community) %} {% if can_downvote(current_user, community) %}
<div class="downvote_button {{ downvoted_class }}" role="button" aria-label="{{ _('DownVote button.') }}" aria-live="assertive" <div class="downvote_button {{ 'voted_down' if in_sorted_list(recently_downvoted_replies, comment.id) }}" role="button" aria-label="{{ _('DownVote button.') }}" aria-live="assertive"
hx-post="/comment/{{ comment.id }}/downvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons_new" tabindex="0"> hx-post="/comment/{{ comment.id }}/downvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons_new" tabindex="0">
<span class="fe fe-arrow-down"></span> <span class="fe fe-arrow-down"></span>
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;"> <img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">

View file

@ -24,7 +24,7 @@
<p><a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="Go to image">{{ post.url|shorten_url }} <p><a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="Go to image">{{ post.url|shorten_url }}
<span class="fe fe-external"></span></a></p> <span class="fe fe-external"></span></a></p>
{% endif %} {% endif %}
<p>{% if post.reports and current_user.is_authenticated and post.community.is_moderator(current_user) %} <p>{% if post.reports > 0 and current_user.is_authenticated and post.community.is_moderator(current_user) %}
<span class="red fe fe-report" title="{{ _('Reported. Check post for issues.') }}"></span> <span class="red fe fe-report" title="{{ _('Reported. Check post for issues.') }}"></span>
{% endif %}<small>submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }} {% endif %}<small>submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }}
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %} {% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
@ -72,7 +72,7 @@
width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" loading="lazy" /></a> width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" loading="lazy" /></a>
</div> </div>
{% endif %} {% endif %}
<p>{% if post.reports and current_user.is_authenticated and post.community.is_moderator(current_user) %} <p>{% if post.reports > 0 and current_user.is_authenticated and post.community.is_moderator(current_user) %}
<span class="red fe fe-report" title="{{ _('Reported. Check post for issues.') }}"></span> <span class="red fe fe-report" title="{{ _('Reported. Check post for issues.') }}"></span>
{% endif %}<small>submitted {{ moment(post.posted_at).fromNow() }} by {% endif %}<small>submitted {{ moment(post.posted_at).fromNow() }} by
{{ render_username(post.author) }} {{ render_username(post.author) }}
@ -85,6 +85,7 @@
<p><audio controls preload="{{ 'none' if low_bandwidth else 'metadata' }}" src="{{ post.url }}"></audio></p> <p><audio controls preload="{{ 'none' if low_bandwidth else 'metadata' }}" src="{{ post.url }}"></audio></p>
{% endif %} {% endif %}
{% if 'youtube.com' in post.url %} {% if 'youtube.com' in post.url %}
<p><a href="https://piped.video/watch?v={{ post.youtube_embed() }}">{{ _('Watch on piped.video') }} <span class="fe fe-external"></span></a></p>
<div style="padding-bottom: 56.25%; position: relative;"><iframe style="position: absolute; top: 0px; left: 0px; width: 100%; height: 100%;" src="https://www.youtube.com/embed/{{ post.youtube_embed() }}?rel=0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen" width="100%" height="100%" frameborder="0"></iframe></div> <div style="padding-bottom: 56.25%; position: relative;"><iframe style="position: absolute; top: 0px; left: 0px; width: 100%; height: 100%;" src="https://www.youtube.com/embed/{{ post.youtube_embed() }}?rel=0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen" width="100%" height="100%" frameborder="0"></iframe></div>
{% endif %} {% endif %}
{% elif post.type == POST_TYPE_IMAGE %} {% elif post.type == POST_TYPE_IMAGE %}

View file

@ -2,15 +2,17 @@
{% if content_blocked and content_blocked == '-1' %} {% if content_blocked and content_blocked == '-1' %}
{# do nothing - blocked by keyword filter #} {# do nothing - blocked by keyword filter #}
{% else %} {% else %}
<div class="post_teaser type_{{ post.type }}{{ ' reported' if post.reports and current_user.is_authenticated and post.community.is_moderator() }}{{ ' blocked' if content_blocked }}" <div class="post_teaser type_{{ post.type }}{{ ' reported' if post.reports > 0 and current_user.is_authenticated and post.community.is_moderator() }}{{ ' blocked' if content_blocked }}"
{% if content_blocked %} title="{{ _('Filtered: ') }}{{ content_blocked }}"{% endif %} tabindex="0"> {% if content_blocked %} title="{{ _('Filtered: ') }}{{ content_blocked }}"{% endif %} tabindex="0">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="row main_row"> <div class="row main_row">
<div class="col"> <div class="col">
{% if not hide_vote_buttons %}
<div class="voting_buttons" aria-hidden="true"> <div class="voting_buttons" aria-hidden="true">
{% include "post/_post_voting_buttons.html" %} {% include "post/_post_voting_buttons.html" %}
</div> </div>
{% endif %}
{% if post.image_id %} {% if post.image_id %}
<div class="thumbnail{{ ' lbw' if low_bandwidth }}" aria-hidden="true"> <div class="thumbnail{{ ' lbw' if low_bandwidth }}" aria-hidden="true">
{% if low_bandwidth %} {% if low_bandwidth %}
@ -55,7 +57,7 @@
{% endif %} {% endif %}
{% if post.nsfw %}<span class="warning_badge nsfw" title="{{ _('Not safe for work') }}">nsfw</span>{% endif %} {% if post.nsfw %}<span class="warning_badge nsfw" title="{{ _('Not safe for work') }}">nsfw</span>{% endif %}
{% if post.nsfl %}<span class="warning_badge nsfl" title="{{ _('Potentially emotionally scarring content') }}">nsfl</span>{% endif %} {% if post.nsfl %}<span class="warning_badge nsfl" title="{{ _('Potentially emotionally scarring content') }}">nsfl</span>{% endif %}
{% if post.reports and current_user.is_authenticated and post.community.is_moderator(current_user) %} {% if post.reports > 0 and current_user.is_authenticated and post.community.is_moderator(current_user) %}
<span class="red fe fe-report" title="{{ _('Reported. Check post for issues.') }}"></span> <span class="red fe fe-report" title="{{ _('Reported. Check post for issues.') }}"></span>
{% endif %} {% endif %}
{% if post.sticky %}<span class="fe fe-sticky-right"></span>{% endif %} {% if post.sticky %}<span class="fe fe-sticky-right"></span>{% endif %}

View file

@ -2,7 +2,7 @@
{% if content_blocked and content_blocked == '-1' %} {% if content_blocked and content_blocked == '-1' %}
{# do nothing - blocked by keyword filter #} {# do nothing - blocked by keyword filter #}
{% else %} {% else %}
<div class="post_teaser{{ ' reported' if post.reports and current_user.is_authenticated and post.community.is_moderator() }}{{ ' blocked' if content_blocked }}" <div class="post_teaser{{ ' reported' if post.reports > 0 and current_user.is_authenticated and post.community.is_moderator() }}{{ ' blocked' if content_blocked }}"
{% if content_blocked %} title="{{ _('Filtered: ') }}{{ content_blocked }}"{% endif %}> {% if content_blocked %} title="{{ _('Filtered: ') }}{{ content_blocked }}"{% endif %}>
{% if post.image_id %} {% if post.image_id %}
{% if post_layout == 'masonry' or low_bandwidth %} {% if post_layout == 'masonry' or low_bandwidth %}

View file

@ -1,6 +1,6 @@
{% if current_user.is_authenticated and current_user.verified %} {% if current_user.is_authenticated and current_user.verified %}
{% if can_upvote(current_user, post.community) %} {% if can_upvote(current_user, post.community) %}
<div class="upvote_button {{ upvoted_class }}" role="button" aria-label="{{ _('UpVote button, %(count)d upvotes so far.', count=post.up_votes) }}" aria-live="assertive" <div class="upvote_button {{ 'voted_up' if in_sorted_list(recently_upvoted, post.id) }}" role="button" aria-label="{{ _('UpVote button, %(count)d upvotes so far.', count=post.up_votes) }}" aria-live="assertive"
hx-post="/post/{{ post.id }}/upvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons" tabindex="0"> hx-post="/post/{{ post.id }}/upvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons" tabindex="0">
<span class="fe fe-arrow-up"></span> <span class="fe fe-arrow-up"></span>
{{ shorten_number(post.up_votes) }} {{ shorten_number(post.up_votes) }}
@ -8,7 +8,7 @@
</div> </div>
{% endif %} {% endif %}
{% if can_downvote(current_user, post.community) %} {% if can_downvote(current_user, post.community) %}
<div class="downvote_button {{ downvoted_class }}" role="button" aria-label="{{ _('DownVote button, %(count)d downvotes so far.', count=post.down_votes) }}" aria-live="assertive" <div class="downvote_button {{ 'voted_down' if in_sorted_list(recently_downvoted, post.id) }}" role="button" aria-label="{{ _('DownVote button, %(count)d downvotes so far.', count=post.down_votes) }}" aria-live="assertive"
hx-post="/post/{{ post.id }}/downvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons" tabindex="0"> hx-post="/post/{{ post.id }}/downvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons" tabindex="0">
<span class="fe fe-arrow-down"></span> <span class="fe fe-arrow-down"></span>
{{ shorten_number(post.down_votes) }} {{ shorten_number(post.down_votes) }}

View file

@ -1,13 +1,13 @@
{% if current_user.is_authenticated and current_user.verified %} {% if current_user.is_authenticated and current_user.verified %}
{% if can_upvote(current_user, post.community) %} {% if can_upvote(current_user, post.community) %}
<div class="upvote_button {{ upvoted_class }}" role="button" aria-label="{{ _('UpVote') }}" aria-live="assertive" <div class="upvote_button {{ 'voted_up' if in_sorted_list(recently_upvoted, post.id) }}" role="button" aria-label="{{ _('UpVote') }}" aria-live="assertive"
hx-post="/post/{{ post.id }}/upvote?style=masonry" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons_masonry" tabindex="0" title="{{ post.up_votes }} upvotes"> hx-post="/post/{{ post.id }}/upvote?style=masonry" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons_masonry" tabindex="0" title="{{ post.up_votes }} upvotes">
<span class="fe fe-arrow-up"></span> <span class="fe fe-arrow-up"></span>
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;"> <img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
</div> </div>
{% endif %} {% endif %}
{% if can_downvote(current_user, post.community) %} {% if can_downvote(current_user, post.community) %}
<div class="downvote_button {{ downvoted_class }}" role="button" aria-label="{{ _('DownVote') }}" aria-live="assertive" <div class="downvote_button {{ 'voted_down' if in_sorted_list(recently_downvoted, post.id) }}" role="button" aria-label="{{ _('DownVote') }}" aria-live="assertive"
hx-post="/post/{{ post.id }}/downvote?style=masonry" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons_masonry" tabindex="0" title="{{ post.down_votes }} downvotes"> hx-post="/post/{{ post.id }}/downvote?style=masonry" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons_masonry" tabindex="0" title="{{ post.down_votes }} downvotes">
<span class="fe fe-arrow-down"></span> <span class="fe fe-arrow-down"></span>
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;"> <img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">

View file

@ -1,164 +0,0 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_form, render_field %}
{% block app_content %}
<script nonce="{{ session['nonce'] }}" type="text/javascript">
window.addEventListener("load", function () {
var type = document.forms[0].elements['type'].value;
var toClick = undefined;
switch(type) {
case '':
case 'discussion':
toClick = document.getElementById('discussion-tab');
break;
case 'link':
toClick = document.getElementById('link-tab');
break;
case 'image':
toClick = document.getElementById('image-tab');
break;
case 'poll':
toClick = document.getElementById('poll-tab');
break;
}
if(toClick) {
toClick.click();
}
var downarea = new DownArea({
elem: document.querySelector('#discussion_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
value: {{ form.discussion_body.data | tojson | safe }}
});
setupAutoResize('discussion_body');
});
</script>
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<h1>{{ _('Edit post') }}</h1>
<form method="post" enctype="multipart/form-data" role="form">
{{ form.csrf_token() }}
<nav id="post_type_chooser">
<div class="nav nav-tabs nav-justified" id="typeTab" role="tablist">
<button class="nav-link active" id="discussion-tab" data-bs-toggle="tab" data-bs-target="#discussion-tab-pane"
type="button" role="tab" aria-controls="discussion-tab-pane" aria-selected="true">Discussion</button>
<button class="nav-link" id="link-tab" data-bs-toggle="tab" data-bs-target="#link-tab-pane"
type="button" role="tab" aria-controls="link-tab-pane" aria-selected="false">Link</button>
<button class="nav-link" id="image-tab" data-bs-toggle="tab" data-bs-target="#image-tab-pane"
type="button" role="tab" aria-controls="image-tab-pane" aria-selected="false">Image</button>
<button class="nav-link" id="poll-tab" data-bs-toggle="tab" data-bs-target="#poll-tab-pane"
type="button" role="tab" aria-controls="poll-tab-pane" aria-selected="false" disabled>Poll</button>
</div>
</nav>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="discussion-tab-pane" role="tabpanel" aria-labelledby="home-tab" tabindex="0">
{{ render_field(form.discussion_title) }}
{{ render_field(form.discussion_body) }}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#discussion_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
value: {{ form.discussion_body.data | tojson | safe }},
});
setupAutoResize('discussion_body');
});
</script>
{% endif %}
</div>
<div class="tab-pane fade" id="link-tab-pane" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">
{{ render_field(form.link_title) }}
{{ render_field(form.link_url) }}
{{ render_field(form.link_body) }}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#link_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
value: {{ form.link_body.data | tojson | safe }},
});
setupAutoResize('link_body');
});
</script>
{% endif %}
</div>
<div class="tab-pane fade" id="image-tab-pane" role="tabpanel" aria-labelledby="contact-tab" tabindex="0">
{{ render_field(form.image_title) }}
{{ render_field(form.image_file) }}
{{ render_field(form.image_alt_text) }}
<small class="field_hint">{{ _('Describe the image, to help visually impaired people.') }}</small>
{{ render_field(form.image_body) }}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#image_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
value: {{ form.image_body.data | tojson | safe }},
});
setupAutoResize('image_body');
});
</script>
{% endif %}
</div>
<div class="tab-pane fade" id="poll-tab-pane" role="tabpanel" aria-labelledby="disabled-tab" tabindex="0">
Poll
</div>
</div>
{{ render_field(form.post_type) }}
<div class="row mt-4">
<div class="col-md-3">
{{ render_field(form.notify_author) }}
</div>
<div class="col-md-1">
{{ render_field(form.sticky) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfw) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfl) }}
</div>
<div class="col">
</div>
</div>
{{ render_field(form.submit) }}
</form>
</div>
<aside id="side_pane" class="col-12 col-md-4 side_pane" role="complementary">
<div class="card mt-3">
<div class="card-header">
<h2>{{ post.community.title }}</h2>
</div>
<div class="card-body">
<p>{{ post.community.description_html|safe if post.community.description_html else '' }}</p>
<p>{{ post.community.rules_html|safe if post.community.rules_html else '' }}</p>
{% if len(mods) > 0 and not post.community.private_mods %}
<h3>Moderators</h3>
<ul class="moderator_list">
{% for mod in mods %}
<li><a href="/u/{{ mod.link() }}">{{ mod.display_name() }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% include "_inoculation_links.html" %}
</aside>
</div>
{% endblock %}

View file

@ -0,0 +1,75 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_form, render_field %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<h1>{{ _('Edit post') }}</h1>
<form method="post" role="form">
{{ form.csrf_token() }}
{{ render_field(form.discussion_title) }}
{{ render_field(form.discussion_body) }}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#discussion_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
value: {{ form.discussion_body.data | tojson | safe }},
});
setupAutoResize('discussion_body');
});
</script>
{% endif %}
<div class="row mt-4">
<div class="col-md-3">
{{ render_field(form.notify_author) }}
</div>
<div class="col-md-1">
{{ render_field(form.sticky) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfw) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfl) }}
</div>
<div class="col">
</div>
</div>
{{ render_field(form.submit) }}
</form>
</div>
<aside id="side_pane" class="col-12 col-md-4 side_pane" role="complementary">
<div class="card mt-3">
<div class="card-header">
<h2>{{ post.community.title }}</h2>
</div>
<div class="card-body">
<p>{{ post.community.description_html|safe if post.community.description_html else '' }}</p>
<p>{{ post.community.rules_html|safe if post.community.rules_html else '' }}</p>
{% if len(mods) > 0 and not post.community.private_mods %}
<h3>Moderators</h3>
<ul class="moderator_list">
{% for mod in mods %}
<li><a href="/u/{{ mod.link() }}">{{ mod.display_name() }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% include "_inoculation_links.html" %}
</aside>
</div>
{% endblock %}

View file

@ -0,0 +1,78 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_form, render_field %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<h1>{{ _('Edit post') }}</h1>
<form method="post" enctype="multipart/form-data" role="form">
{{ form.csrf_token() }}
{{ render_field(form.image_title) }}
{{ render_field(form.image_file) }}
{{ render_field(form.image_alt_text) }}
<small class="field_hint">{{ _('Describe the image, to help visually impaired people.') }}</small>
{{ render_field(form.image_body) }}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#image_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
value: {{ form.image_body.data | tojson | safe }},
});
setupAutoResize('image_body');
});
</script>
{% endif %}
<div class="row mt-4">
<div class="col-md-3">
{{ render_field(form.notify_author) }}
</div>
<div class="col-md-1">
{{ render_field(form.sticky) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfw) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfl) }}
</div>
<div class="col">
</div>
</div>
{{ render_field(form.submit) }}
</form>
</div>
<aside id="side_pane" class="col-12 col-md-4 side_pane" role="complementary">
<div class="card mt-3">
<div class="card-header">
<h2>{{ post.community.title }}</h2>
</div>
<div class="card-body">
<p>{{ post.community.description_html|safe if post.community.description_html else '' }}</p>
<p>{{ post.community.rules_html|safe if post.community.rules_html else '' }}</p>
{% if len(mods) > 0 and not post.community.private_mods %}
<h3>Moderators</h3>
<ul class="moderator_list">
{% for mod in mods %}
<li><a href="/u/{{ mod.link() }}">{{ mod.display_name() }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% include "_inoculation_links.html" %}
</aside>
</div>
{% endblock %}

View file

@ -0,0 +1,75 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_form, render_field %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<h1>{{ _('Edit post') }}</h1>
<form method="post" enctype="multipart/form-data" role="form">
{{ form.csrf_token() }}
{{ render_field(form.link_title) }}
{{ render_field(form.link_url) }}
{{ render_field(form.link_body) }}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#link_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
value: {{ form.link_body.data | tojson | safe }},
});
setupAutoResize('link_body');
});
</script>
{% endif %}
<div class="row mt-4">
<div class="col-md-3">
{{ render_field(form.notify_author) }}
</div>
<div class="col-md-1">
{{ render_field(form.sticky) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfw) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfl) }}
</div>
<div class="col">
</div>
</div>
{{ render_field(form.submit) }}
</form>
</div>
<aside id="side_pane" class="col-12 col-md-4 side_pane" role="complementary">
<div class="card mt-3">
<div class="card-header">
<h2>{{ post.community.title }}</h2>
</div>
<div class="card-body">
<p>{{ post.community.description_html|safe if post.community.description_html else '' }}</p>
<p>{{ post.community.rules_html|safe if post.community.rules_html else '' }}</p>
{% if len(mods) > 0 and not post.community.private_mods %}
<h3>Moderators</h3>
<ul class="moderator_list">
{% for mod in mods %}
<li><a href="/u/{{ mod.link() }}">{{ mod.display_name() }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% include "_inoculation_links.html" %}
</aside>
</div>
{% endblock %}

View file

@ -12,3 +12,18 @@ User-Agent: *
Disallow: /d/ Disallow: /d/
Disallow: /static/media/users/ Disallow: /static/media/users/
Disallow: /post/*/options Disallow: /post/*/options
User-agent: GPTBot
Disallow: /
User-agent: AhrefsBot
Disallow: /
User-agent: SemrushBot
Disallow: /
User-agent: DotBot
Disallow: /
User-agent: SeznamBot
Disallow: /

View file

@ -36,6 +36,10 @@
--bs-btn-color: white; --bs-btn-color: white;
} }
.post_body a, .comment_body a {
text-decoration: underline;
}
.post_list .post_teaser { .post_list .post_teaser {
border-bottom: solid 1px black; border-bottom: solid 1px black;
} }

View file

@ -17,7 +17,7 @@ from app import db, celery, cache
from app.topic.forms import ChooseTopicsForm from app.topic.forms import ChooseTopicsForm
from app.utils import render_template, user_filters_posts, moderating_communities, joined_communities, \ from app.utils import render_template, user_filters_posts, moderating_communities, joined_communities, \
community_membership, blocked_domains, validation_required, mimetype_from_url, blocked_instances, \ community_membership, blocked_domains, validation_required, mimetype_from_url, blocked_instances, \
communities_banned_from communities_banned_from, blocked_users
@bp.route('/topic/<path:topic_path>', methods=['GET']) @bp.route('/topic/<path:topic_path>', methods=['GET'])
@ -63,12 +63,18 @@ def show_topic(topic_path):
posts = posts.filter(Post.nsfw == False) posts = posts.filter(Post.nsfw == False)
content_filters = user_filters_posts(current_user.id) content_filters = user_filters_posts(current_user.id)
# filter blocked domains and instances
domains_ids = blocked_domains(current_user.id) domains_ids = blocked_domains(current_user.id)
if domains_ids: if domains_ids:
posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None)) posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None))
instance_ids = blocked_instances(current_user.id) instance_ids = blocked_instances(current_user.id)
if instance_ids: if instance_ids:
posts = posts.filter(or_(Post.instance_id.not_in(instance_ids), Post.instance_id == None)) posts = posts.filter(or_(Post.instance_id.not_in(instance_ids), Post.instance_id == None))
# filter blocked users
blocked_accounts = blocked_users(current_user.id)
if blocked_accounts:
posts = posts.filter(Post.user_id.not_in(blocked_accounts))
banned_from = communities_banned_from(current_user.id) banned_from = communities_banned_from(current_user.id)
if banned_from: if banned_from:
posts = posts.filter(Post.community_id.not_in(banned_from)) posts = posts.filter(Post.community_id.not_in(banned_from))
@ -77,7 +83,7 @@ def show_topic(topic_path):
if sort == '' or sort == 'hot': if sort == '' or sort == 'hot':
posts = posts.order_by(desc(Post.ranking)).order_by(desc(Post.posted_at)) posts = posts.order_by(desc(Post.ranking)).order_by(desc(Post.posted_at))
elif sort == 'top': elif sort == 'top':
posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=7)).order_by(desc(Post.score)) posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=7)).order_by(desc(Post.up_votes - Post.down_votes))
elif sort == 'new': elif sort == 'new':
posts = posts.order_by(desc(Post.posted_at)) posts = posts.order_by(desc(Post.posted_at))
elif sort == 'active': elif sort == 'active':

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

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

@ -14,7 +14,7 @@ class ProfileForm(FlaskForm):
email = EmailField(_l('Email address'), validators=[Email(), DataRequired(), Length(min=5, max=255)]) email = EmailField(_l('Email address'), validators=[Email(), DataRequired(), Length(min=5, max=255)])
password_field = PasswordField(_l('Set new password'), validators=[Optional(), Length(min=1, max=50)], password_field = PasswordField(_l('Set new password'), validators=[Optional(), Length(min=1, max=50)],
render_kw={"autocomplete": 'new-password'}) render_kw={"autocomplete": 'new-password'})
about = TextAreaField(_l('Bio'), validators=[Optional(), Length(min=3, max=5000)]) about = TextAreaField(_l('Bio'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
matrixuserid = StringField(_l('Matrix User ID'), validators=[Optional(), Length(max=255)], render_kw={'autocomplete': 'off'}) matrixuserid = StringField(_l('Matrix User ID'), validators=[Optional(), Length(max=255)], render_kw={'autocomplete': 'off'})
profile_file = FileField(_('Avatar image')) profile_file = FileField(_('Avatar image'))
banner_file = FileField(_('Top banner image')) banner_file = FileField(_('Top banner image'))

View file

@ -19,7 +19,7 @@ from app.user.utils import purge_user_then_delete
from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string, \ from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string, \
is_image_url, ensure_directory_exists, gibberish, file_get_contents, community_membership, user_filters_home, \ is_image_url, ensure_directory_exists, gibberish, file_get_contents, community_membership, user_filters_home, \
user_filters_posts, user_filters_replies, moderating_communities, joined_communities, theme_list, blocked_instances, \ user_filters_posts, user_filters_replies, moderating_communities, joined_communities, theme_list, blocked_instances, \
allowlist_html allowlist_html, recently_upvoted_posts, recently_downvoted_posts, blocked_users
from sqlalchemy import desc, or_, text from sqlalchemy import desc, or_, text
import os import os
@ -85,7 +85,7 @@ def show_profile(user):
description=description, subscribed=subscribed, upvoted=upvoted, description=description, subscribed=subscribed, upvoted=upvoted,
post_next_url=post_next_url, post_prev_url=post_prev_url, post_next_url=post_next_url, post_prev_url=post_prev_url,
replies_next_url=replies_next_url, replies_prev_url=replies_prev_url, replies_next_url=replies_next_url, replies_prev_url=replies_prev_url,
noindex=not user.indexable, show_post_community=True, noindex=not user.indexable, show_post_community=True, hide_vote_buttons=True,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()) joined_communities=joined_communities(current_user.get_id())
) )
@ -294,6 +294,7 @@ def block_profile(actor):
# federate block # federate block
flash(f'{actor} has been blocked.') flash(f'{actor} has been blocked.')
cache.delete_memoized(blocked_users, current_user.id)
goto = request.args.get('redirect') if 'redirect' in request.args else f'/u/{actor}' goto = request.args.get('redirect') if 'redirect' in request.args else f'/u/{actor}'
return redirect(goto) return redirect(goto)
@ -322,6 +323,7 @@ def unblock_profile(actor):
# federate unblock # federate unblock
flash(f'{actor} has been unblocked.') flash(f'{actor} has been unblocked.')
cache.delete_memoized(blocked_users, current_user.id)
goto = request.args.get('redirect') if 'redirect' in request.args else f'/u/{actor}' goto = request.args.get('redirect') if 'redirect' in request.args else f'/u/{actor}'
return redirect(goto) return redirect(goto)
@ -335,8 +337,18 @@ def report_profile(actor):
else: else:
user: User = User.query.filter_by(user_name=actor, deleted=False, ap_id=None).first() user: User = User.query.filter_by(user_name=actor, deleted=False, ap_id=None).first()
form = ReportUserForm() form = ReportUserForm()
if user and user.reports == -1: # When a mod decides to ignore future reports, user.reports is set to -1
flash(_('Moderators have already assessed reports regarding this person, no further reports are necessary.'), 'warning')
if user and not user.banned: if user and not user.banned:
if form.validate_on_submit(): if form.validate_on_submit():
if user.reports == -1:
flash(_('%(user_name)s has already been reported, thank you!', user_name=actor))
goto = request.args.get('redirect') if 'redirect' in request.args else f'/u/{actor}'
return redirect(goto)
report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data,
type=0, reporter_id=current_user.id, suspect_user_id=user.id, source_instance_id=1) type=0, reporter_id=current_user.id, suspect_user_id=user.id, source_instance_id=1)
db.session.add(report) db.session.add(report)

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import bisect
import hashlib import hashlib
import mimetypes import mimetypes
import random import random
@ -28,7 +29,7 @@ import re
from app.email import send_welcome_email from app.email import send_welcome_email
from app.models import Settings, Domain, Instance, BannedInstances, User, Community, DomainBlock, ActivityPubLog, IpBan, \ from app.models import Settings, Domain, Instance, BannedInstances, User, Community, DomainBlock, ActivityPubLog, IpBan, \
Site, Post, PostReply, utcnow, Filter, CommunityMember, InstanceBlock, CommunityBan Site, Post, PostReply, utcnow, Filter, CommunityMember, InstanceBlock, CommunityBan, Topic, UserBlock
# Flask's render_template function, with support for themes added # Flask's render_template function, with support for themes added
@ -169,7 +170,7 @@ def allowlist_html(html: str) -> str:
if html is None or html == '': if html is None or html == '':
return '' return ''
allowed_tags = ['p', 'strong', 'a', 'ul', 'ol', 'li', 'em', 'blockquote', 'cite', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', allowed_tags = ['p', 'strong', 'a', 'ul', 'ol', 'li', 'em', 'blockquote', 'cite', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre',
'code', 'img', 'details', 'summary', 'table', 'tr', 'td', 'th', 'tbody', 'thead'] 'code', 'img', 'details', 'summary', 'table', 'tr', 'td', 'th', 'tbody', 'thead', 'hr']
# Parse the HTML using BeautifulSoup # Parse the HTML using BeautifulSoup
soup = BeautifulSoup(html, 'html.parser') soup = BeautifulSoup(html, 'html.parser')
@ -211,13 +212,17 @@ def allowlist_html(html: str) -> str:
if tag.name == 'table': if tag.name == 'table':
tag.attrs['class'] = 'table' tag.attrs['class'] = 'table'
return str(soup) # avoid returning empty anchors
re_empty_anchor = re.compile(r'<a href="(.*?)" rel="nofollow ugc" target="_blank"><\/a>')
return re_empty_anchor.sub(r'<a href="\1" rel="nofollow ugc" target="_blank">\1</a>', str(soup))
def markdown_to_html(markdown_text) -> str: def markdown_to_html(markdown_text) -> str:
if markdown_text: if markdown_text:
raw_html = markdown2.markdown(markdown_text, safe_mode=True, extras={'middle-word-em': False, 'tables': True, 'fenced-code-blocks': True, 'strike': True}) raw_html = markdown2.markdown(markdown_text, safe_mode=True, extras={'middle-word-em': False, 'tables': True, 'fenced-code-blocks': True, 'strike': True})
# todo: in raw_html, replace lemmy spoiler tokens with appropriate html tags instead. # replace lemmy spoiler tokens with appropriate html tags instead. (until possibly added as extra to markdown2)
re_spoiler = re.compile(r':{3} spoiler\s+?(\S.+?)(?:\n|</p>)(.+?)(?:\n|<p>):{3}', re.S)
raw_html = re_spoiler.sub(r'<details><summary>\1</summary><p>\2</p></details>', raw_html)
return allowlist_html(raw_html) return allowlist_html(raw_html)
else: else:
return '' return ''
@ -240,6 +245,8 @@ def microblog_content_to_title(html: str) -> str:
continue continue
else: else:
tag = tag.extract() tag = tag.extract()
else:
tag = tag.extract()
if title_found: if title_found:
result = soup.text result = soup.text
@ -328,6 +335,12 @@ def blocked_instances(user_id) -> List[int]:
return [block.instance_id for block in blocks] return [block.instance_id for block in blocks]
@cache.memoize(timeout=86400)
def blocked_users(user_id) -> List[int]:
blocks = UserBlock.query.filter_by(blocker_id=user_id)
return [block.blocked_id for block in blocks]
@cache.memoize(timeout=86400) @cache.memoize(timeout=86400)
def blocked_phrases() -> List[str]: def blocked_phrases() -> List[str]:
site = Site.query.get(1) site = Site.query.get(1)
@ -447,6 +460,8 @@ def user_ip_banned() -> bool:
@cache.memoize(timeout=30) @cache.memoize(timeout=30)
def instance_banned(domain: str) -> bool: # see also activitypub.util.instance_blocked() def instance_banned(domain: str) -> bool: # see also activitypub.util.instance_blocked()
if domain is None or domain == '':
return False
banned = BannedInstances.query.filter_by(domain=domain).first() banned = BannedInstances.query.filter_by(domain=domain).first()
return banned is not None return banned is not None
@ -675,6 +690,21 @@ def finalize_user_setup(user, application_required=False):
send_welcome_email(user, application_required) send_welcome_email(user, application_required)
# topics, in a tree
def topic_tree() -> List:
topics = Topic.query.order_by(Topic.name)
topics_dict = {topic.id: {'topic': topic, 'children': []} for topic in topics.all()}
for topic in topics:
if topic.parent_id is not None:
parent_comment = topics_dict.get(topic.parent_id)
if parent_comment:
parent_comment['children'].append(topics_dict[topic.id])
return [topic for topic in topics_dict.values() if topic['topic'].parent_id is None]
# All the following post/comment ranking math is explained at https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9 # All the following post/comment ranking math is explained at https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9
epoch = datetime(1970, 1, 1) epoch = datetime(1970, 1, 1)
@ -775,9 +805,13 @@ def current_theme():
if current_user.theme is not None and current_user.theme != '': if current_user.theme is not None and current_user.theme != '':
return current_user.theme return current_user.theme
else: else:
return g.site.default_theme if g.site.default_theme is not None else '' if hasattr(g, 'site'):
site = g.site
else: else:
return g.site.default_theme if g.site.default_theme is not None else '' site = Site.query.get(1)
return site.default_theme if site.default_theme is not None else ''
else:
return ''
def theme_list(): def theme_list():
@ -836,3 +870,37 @@ def show_ban_message():
resp = make_response(redirect(url_for('main.index'))) resp = make_response(redirect(url_for('main.index')))
resp.set_cookie('sesion', '17489047567495', expires=datetime(year=2099, month=12, day=30)) resp.set_cookie('sesion', '17489047567495', expires=datetime(year=2099, month=12, day=30))
return resp return resp
# search a sorted list using a binary search. Faster than using 'in' with a unsorted list.
def in_sorted_list(arr, target):
index = bisect.bisect_left(arr, target)
return index < len(arr) and arr[index] == target
@cache.memoize(timeout=600)
def recently_upvoted_posts(user_id) -> List[int]:
post_ids = db.session.execute(text('SELECT post_id FROM "post_vote" WHERE user_id = :user_id AND effect > 0 ORDER BY id DESC LIMIT 1000'),
{'user_id': user_id}).scalars()
return sorted(post_ids) # sorted so that in_sorted_list can be used
@cache.memoize(timeout=600)
def recently_downvoted_posts(user_id) -> List[int]:
post_ids = db.session.execute(text('SELECT post_id FROM "post_vote" WHERE user_id = :user_id AND effect < 0 ORDER BY id DESC LIMIT 1000'),
{'user_id': user_id}).scalars()
return sorted(post_ids)
@cache.memoize(timeout=600)
def recently_upvoted_post_replies(user_id) -> List[int]:
reply_ids = db.session.execute(text('SELECT post_reply_id FROM "post_reply_vote" WHERE user_id = :user_id AND effect > 0 ORDER BY id DESC LIMIT 1000'),
{'user_id': user_id}).scalars()
return sorted(reply_ids) # sorted so that in_sorted_list can be used
@cache.memoize(timeout=600)
def recently_downvoted_post_replies(user_id) -> List[int]:
reply_ids = db.session.execute(text('SELECT post_reply_id FROM "post_reply_vote" WHERE user_id = :user_id AND effect < 0 ORDER BY id DESC LIMIT 1000'),
{'user_id': user_id}).scalars()
return sorted(reply_ids)

View file

@ -26,7 +26,7 @@ Mailing list, Matrix channel, etc still to come.
- Redis - Redis
Python developers with no Flask experience can quickly learn Flask by exploring the Python developers with no Flask experience can quickly learn Flask by exploring the
[Flask Mega Tutorial](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world) [Flask Mega Tutorial](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world-2018)
which will guide them through the process of building a simple social media app. Django is which will guide them through the process of building a simple social media app. Django is
very similar to Flask so developers familiar with that framework will have an easier very similar to Flask so developers familiar with that framework will have an easier
time of things. time of things.

View file

@ -0,0 +1,6 @@
# Decision log
This document helps prevent rearguing decisions after they have been made. It can also help new contributors come up to
speed by providing a summary of how we got to the present state.
[Switch to using HTML while federating content instead of Markdown](https://codeberg.org/rimu/pyfedi/issues/133#issuecomment-1756067)

View file

@ -29,15 +29,15 @@ The following are the goals for a 1.0 release, good enough for production use. I
### Moderation ### Moderation
- community moderation - community moderation
- ✅ blocking - users, communities, domains, instances. bi-directional. - ✅ blocking - users, communities, domains, instances. bi-directional.
- import/export of block lists - import/export of block lists
### Onboarding ### Onboarding
- ✅ choose interests to auto-subscribe new accounts - ✅ choose topics to auto-subscribe new accounts
### Performance and scaling ### Performance and scaling
- ✅ background task runner - ✅ background task runner
- send queue for federation

View file

@ -0,0 +1,11 @@
# People involved in PieFed
This document gives the community a shared understanding of who is responsible for what and helps to make sure no one
is inadvertently left out of a decision.
### Rimu
- https://piefed.social/u/rimu, https://mastodon.nzoss.nz/@rimu
Founder and lead developer. Approver of PRs, payer of bills.

View file

@ -0,0 +1,32 @@
"""contact email
Revision ID: 91a931afd6d9
Revises: 08b3f718df5d
Create Date: 2024-04-12 16:22:32.137053
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '91a931afd6d9'
down_revision = '08b3f718df5d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('site', schema=None) as batch_op:
batch_op.add_column(sa.Column('contact_email', sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('site', schema=None) as batch_op:
batch_op.drop_column('contact_email')
# ### end Alembic commands ###

View file

@ -11,7 +11,8 @@ from flask import session, g, json, request, current_app
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE
from app.models import Site from app.models import Site
from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits, user_access, community_membership, \ from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits, user_access, community_membership, \
can_create_post, can_upvote, can_downvote, shorten_number, ap_datetime, current_theme, community_link_to_href can_create_post, can_upvote, can_downvote, shorten_number, ap_datetime, current_theme, community_link_to_href, \
in_sorted_list
app = create_app() app = create_app()
cli.register(app) cli.register(app)
@ -42,6 +43,7 @@ with app.app_context():
app.jinja_env.globals['can_create'] = can_create_post app.jinja_env.globals['can_create'] = can_create_post
app.jinja_env.globals['can_upvote'] = can_upvote app.jinja_env.globals['can_upvote'] = can_upvote
app.jinja_env.globals['can_downvote'] = can_downvote app.jinja_env.globals['can_downvote'] = can_downvote
app.jinja_env.globals['in_sorted_list'] = in_sorted_list
app.jinja_env.globals['theme'] = current_theme app.jinja_env.globals['theme'] = current_theme
app.jinja_env.globals['file_exists'] = os.path.exists app.jinja_env.globals['file_exists'] = os.path.exists
app.jinja_env.filters['community_links'] = community_link_to_href app.jinja_env.filters['community_links'] = community_link_to_href
@ -53,6 +55,7 @@ with app.app_context():
def before_request(): def before_request():
session['nonce'] = gibberish() session['nonce'] = gibberish()
g.locale = str(get_locale()) g.locale = str(get_locale())
if request.path != '/inbox' and not request.path.startswith('/static/'): # do not load g.site on shared inbox, to increase chance of duplicate detection working properly
g.site = Site.query.get(1) g.site = Site.query.get(1)
if current_user.is_authenticated: if current_user.is_authenticated:
current_user.last_seen = datetime.utcnow() current_user.last_seen = datetime.utcnow()