Merge remote-tracking branch 'upstream/main'

This commit is contained in:
rra 2024-04-16 11:54:01 +02:00
commit c3120bc3e6
52 changed files with 1089 additions and 272 deletions

View file

@ -458,7 +458,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
# Notify recipient # Notify recipient
notify = Notification(title=shorten_string('New message from ' + sender.display_name()), notify = Notification(title=shorten_string('New message from ' + sender.display_name()),
url=f'/chat/{existing_conversation.id}', user_id=recipient.id, url=f'/chat/{existing_conversation.id}#message_{new_message}', user_id=recipient.id,
author_id=sender.id) author_id=sender.id)
db.session.add(notify) db.session.add(notify)
recipient.unread_notifications += 1 recipient.unread_notifications += 1

View file

@ -12,7 +12,8 @@ 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, Report, Conversation PostVote, PostReplyVote, ActivityPubLog, Notification, Site, CommunityMember, InstanceRole, Report, Conversation, \
Language
import time import time
import base64 import base64
import requests import requests
@ -27,7 +28,7 @@ import pytesseract
from app.utils import get_request, allowlist_html, get_setting, ap_datetime, markdown_to_html, \ from app.utils import get_request, allowlist_html, get_setting, ap_datetime, markdown_to_html, \
is_image_url, domain_from_url, gibberish, ensure_directory_exists, markdown_to_text, head_request, post_ranking, \ is_image_url, domain_from_url, gibberish, ensure_directory_exists, markdown_to_text, head_request, post_ranking, \
shorten_string, reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, remove_tracking_from_link, \ shorten_string, reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, remove_tracking_from_link, \
blocked_phrases, microblog_content_to_title blocked_phrases, microblog_content_to_title, generate_image_from_video_url, is_video_url
def public_key(): def public_key():
@ -171,7 +172,7 @@ def post_to_activity(post: Post, community: Community):
activity_data["object"]["object"]["updated"] = ap_datetime(post.edited_at) activity_data["object"]["object"]["updated"] = ap_datetime(post.edited_at)
if post.language is not None: if post.language is not None:
activity_data["object"]["object"]["language"] = {"identifier": post.language} activity_data["object"]["object"]["language"] = {"identifier": post.language}
if post.type == POST_TYPE_LINK and post.url is not None: if (post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO) and post.url is not None:
activity_data["object"]["object"]["attachment"] = [{"href": post.url, "type": "Link"}] activity_data["object"]["object"]["attachment"] = [{"href": post.url, "type": "Link"}]
if post.image_id is not None: if post.image_id is not None:
activity_data["object"]["object"]["image"] = {"url": post.image.view_url(), "type": "Image"} activity_data["object"]["object"]["image"] = {"url": post.image.view_url(), "type": "Image"}
@ -208,7 +209,7 @@ def post_to_page(post: Post, community: Community):
activity_data["updated"] = ap_datetime(post.edited_at) activity_data["updated"] = ap_datetime(post.edited_at)
if post.language is not None: if post.language is not None:
activity_data["language"] = {"identifier": post.language} activity_data["language"] = {"identifier": post.language}
if post.type == POST_TYPE_LINK and post.url is not None: if (post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO) and post.url is not None:
activity_data["attachment"] = [{"href": post.url, "type": "Link"}] activity_data["attachment"] = [{"href": post.url, "type": "Link"}]
if post.image_id is not None: if post.image_id is not None:
activity_data["image"] = {"url": post.image.view_url(), "type": "Image"} activity_data["image"] = {"url": post.image.view_url(), "type": "Image"}
@ -342,6 +343,16 @@ def find_actor_or_create(actor: str, create_if_not_found=True, community_only=Fa
return None return None
def find_language_or_create(code: str, name: str) -> Language:
existing_language = Language.query.filter(Language.code == code).first()
if existing_language:
return existing_language
else:
new_language = Language(code=code, name=name)
db.session.add(new_language)
return new_language
def extract_domain_and_actor(url_string: str): def extract_domain_and_actor(url_string: str):
# Parse the URL # Parse the URL
parsed_url = urlparse(url_string) parsed_url = urlparse(url_string)
@ -637,6 +648,9 @@ def actor_json_to_model(activity_json, address, server):
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)
if 'language' in activity_json and isinstance(activity_json['language'], list):
for ap_language in activity_json['language']:
community.languages.append(find_language_or_create(ap_language['identifier'], ap_language['name']))
db.session.add(community) db.session.add(community)
db.session.commit() db.session.commit()
if community.icon_id: if community.icon_id:
@ -738,6 +752,45 @@ def make_image_sizes(file_id, thumbnail_width=50, medium_width=120, directory='p
def make_image_sizes_async(file_id, thumbnail_width, medium_width, directory): def make_image_sizes_async(file_id, thumbnail_width, medium_width, directory):
file = File.query.get(file_id) file = File.query.get(file_id)
if file and file.source_url: if file and file.source_url:
# Videos
if file.source_url.endswith('.mp4') or file.source_url.endswith('.webm'):
new_filename = gibberish(15)
# set up the storage directory
directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory)
# file path and names to store the resized images on disk
final_place = os.path.join(directory, new_filename + '.jpg')
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
generate_image_from_video_url(file.source_url, final_place)
image = Image.open(final_place)
img_width = image.width
# Resize the image to medium
if medium_width:
if img_width > medium_width:
image.thumbnail((medium_width, medium_width))
image.save(final_place)
file.file_path = final_place
file.width = image.width
file.height = image.height
# Resize the image to a thumbnail (webp)
if thumbnail_width:
if img_width > thumbnail_width:
image.thumbnail((thumbnail_width, thumbnail_width))
image.save(final_place_thumbnail, format="WebP", quality=93)
file.thumbnail_path = final_place_thumbnail
file.thumbnail_width = image.width
file.thumbnail_height = image.height
db.session.commit()
# Images
else:
try: try:
source_image_response = get_request(file.source_url) source_image_response = get_request(file.source_url)
except: except:
@ -1037,6 +1090,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 -= current_app.config['SPICY_UNDER_30']
elif post.up_votes + post.down_votes <= 60:
post.score -= current_app.config['SPICY_UNDER_60']
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)
@ -1139,10 +1198,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 * current_app.config['SPICY_UNDER_10']
elif post.up_votes + post.down_votes <= 30:
spicy_effect = effect * current_app.config['SPICY_UNDER_30']
elif post.up_votes + post.down_votes <= 60:
spicy_effect = effect * current_app.config['SPICY_UNDER_60']
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:
@ -1373,6 +1440,11 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json
image = File(source_url=post.url) image = File(source_url=post.url)
db.session.add(image) db.session.add(image)
post.image = image post.image = image
elif is_video_url(post.url):
post.type = POST_TYPE_VIDEO
image = File(source_url=post.url)
db.session.add(image)
post.image = image
else: else:
post.type = POST_TYPE_LINK post.type = POST_TYPE_LINK
post.url = remove_tracking_from_link(post.url) post.url = remove_tracking_from_link(post.url)
@ -1399,6 +1471,9 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json
else: else:
post = None post = None
activity_log.exception_message = domain.name + ' is blocked by admin' activity_log.exception_message = domain.name + ' is blocked by admin'
if 'language' in request_json['object'] and isinstance(request_json['object']['language'], dict):
language = find_language_or_create(request_json['object']['language']['identifier'], request_json['object']['language']['name'])
post.language_id = language.id
if post is not None: if post is not None:
if 'image' in request_json['object'] and post.image is None: if 'image' in request_json['object'] and post.image is None:
image = File(source_url=request_json['object']['image']['url']) image = File(source_url=request_json['object']['image']['url'])
@ -1483,6 +1558,11 @@ def update_post_from_activity(post: Post, request_json: dict):
name += ' ' + microblog_content_to_title(post.body_html) name += ' ' + microblog_content_to_title(post.body_html)
nsfl_in_title = '[NSFL]' in name.upper() or '(NSFL)' in name.upper() nsfl_in_title = '[NSFL]' in name.upper() or '(NSFL)' in name.upper()
post.title = name post.title = name
# Language
if 'language' in request_json['object'] and isinstance(request_json['object']['language'], dict):
language = find_language_or_create(request_json['object']['language']['identifier'], request_json['object']['language']['name'])
post.language_id = language.id
# Links
old_url = post.url old_url = post.url
old_image_id = post.image_id old_image_id = post.image_id
post.url = '' post.url = ''
@ -1510,6 +1590,11 @@ def update_post_from_activity(post: Post, request_json: dict):
image = File(source_url=post.url) image = File(source_url=post.url)
db.session.add(image) db.session.add(image)
post.image = image post.image = image
elif is_video_url(post.url):
post.type == POST_TYPE_VIDEO
image = File(source_url=post.url)
db.session.add(image)
post.image = image
else: else:
post.type = POST_TYPE_LINK post.type = POST_TYPE_LINK
post.url = remove_tracking_from_link(post.url) post.url = remove_tracking_from_link(post.url)
@ -1536,6 +1621,7 @@ def update_post_from_activity(post: Post, request_json: dict):
else: else:
post.url = old_url # don't change if url changed from non-banned domain to banned domain post.url = old_url # don't change if url changed from non-banned domain to banned domain
# Posts which link to the same url as other posts
new_cross_posts = Post.query.filter(Post.id != post.id, Post.url == post.url, new_cross_posts = Post.query.filter(Post.id != post.id, Post.url == post.url,
Post.posted_at > utcnow() - timedelta(days=6)).all() Post.posted_at > utcnow() - timedelta(days=6)).all()
for ncp in new_cross_posts: for ncp in new_cross_posts:

View file

@ -14,21 +14,20 @@ def send_message(message: str, conversation_id: int) -> ChatMessage:
reply = ChatMessage(sender_id=current_user.id, conversation_id=conversation.id, reply = ChatMessage(sender_id=current_user.id, conversation_id=conversation.id,
body=message, body_html=allowlist_html(markdown_to_html(message))) body=message, body_html=allowlist_html(markdown_to_html(message)))
conversation.updated_at = utcnow() conversation.updated_at = utcnow()
db.session.add(reply)
db.session.commit()
for recipient in conversation.members: for recipient in conversation.members:
if recipient.id != current_user.id: if recipient.id != current_user.id:
if recipient.is_local(): if recipient.is_local():
# Notify local recipient # Notify local recipient
notify = Notification(title=shorten_string('New message from ' + current_user.display_name()), notify = Notification(title=shorten_string('New message from ' + current_user.display_name()),
url='/chat/' + str(conversation_id), url=f'/chat/{conversation_id}#message_{reply.id}',
user_id=recipient.id, user_id=recipient.id,
author_id=current_user.id) author_id=current_user.id)
db.session.add(notify) db.session.add(notify)
recipient.unread_notifications += 1 recipient.unread_notifications += 1
db.session.add(reply)
db.session.commit() db.session.commit()
else: else:
db.session.add(reply)
db.session.commit()
# Federate reply # Federate reply
reply_json = { reply_json = {
"actor": current_user.profile_id(), "actor": current_user.profile_id(),

View file

@ -116,6 +116,26 @@ class CreateLinkForm(FlaskForm):
return True return True
class CreateVideoForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int)
video_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])
video_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
video_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'))
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:
domain = domain_from_url(self.video_url.data, create=False)
if domain and domain.banned:
self.video_url.errors.append(_("Videos from %(domain)s are not allowed.", domain=domain.name))
return False
return True
class CreateImageForm(FlaskForm): class CreateImageForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int)
image_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) image_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])

View file

@ -10,17 +10,18 @@ from sqlalchemy import or_, desc, text
from app import db, constants, cache from app import db, constants, cache
from app.activitypub.signature import RsaKeys, post_request from app.activitypub.signature import RsaKeys, post_request
from app.activitypub.util import default_context, notify_about_post, find_actor_or_create, make_image_sizes from app.activitypub.util import default_context, notify_about_post, make_image_sizes
from app.chat.util import send_message from app.chat.util import send_message
from app.community.forms import SearchRemoteCommunity, CreateDiscussionForm, CreateImageForm, CreateLinkForm, 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, CreateVideoForm
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 REPORT_STATE_DISCARDED, POST_TYPE_VIDEO
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
@ -30,7 +31,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, recently_upvoted_posts, recently_downvoted_posts 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
@ -181,10 +183,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':
@ -251,7 +258,7 @@ def show_community(community: Community):
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, POST_TYPE_VIDEO=POST_TYPE_VIDEO, 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,
@ -652,6 +659,79 @@ def add_link_post(actor):
) )
@bp.route('/<actor>/submit_video', methods=['GET', 'POST'])
@login_required
@validation_required
def add_video_post(actor):
if current_user.banned:
return show_ban_message()
community = actor_to_community(actor)
form = CreateVideoForm()
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, 'video')
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_video_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): def federate_post(community, post):
page = { page = {
'type': 'Page', 'type': 'Page',
@ -691,7 +771,7 @@ def federate_post(community, post):
"object": page, "object": page,
'@context': default_context() '@context': default_context()
} }
if post.type == POST_TYPE_LINK: if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO:
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:

View file

@ -11,7 +11,7 @@ from pillow_heif import register_heif_opener
from app import db, cache, celery 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 find_actor_or_create, actor_json_to_model, post_json_to_model, default_context from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model, default_context
from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_VIDEO
from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \ from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \
Instance, Notification, User, ActivityPubLog Instance, Notification, User, ActivityPubLog
from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, allowlist_html, \ from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, allowlist_html, \
@ -112,7 +112,8 @@ def retrieve_mods_and_backfill(community_id: int):
post.ranking = post_ranking(post.score, post.posted_at) post.ranking = post_ranking(post.score, post.posted_at)
if post.url: if post.url:
other_posts = Post.query.filter(Post.id != post.id, Post.url == post.url, other_posts = Post.query.filter(Post.id != post.id, Post.url == post.url,
Post.posted_at > post.posted_at - timedelta(days=3), Post.posted_at < post.posted_at + timedelta(days=3)).all() Post.posted_at > post.posted_at - timedelta(days=3),
Post.posted_at < post.posted_at + timedelta(days=3)).all()
for op in other_posts: for op in other_posts:
if op.cross_posts is None: if op.cross_posts is None:
op.cross_posts = [post.id] op.cross_posts = [post.id]
@ -223,6 +224,11 @@ def save_post(form, post: Post, type: str):
remove_old_file(post.image_id) remove_old_file(post.image_id)
post.image_id = None post.image_id = None
if post.url.endswith('.mp4') or post.url.endswith('.webm'):
file = File(source_url=form.link_url.data) # make_image_sizes() will take care of turning this into a still image
post.image = file
db.session.add(file)
else:
unused, file_extension = os.path.splitext(form.link_url.data) unused, file_extension = os.path.splitext(form.link_url.data)
# this url is a link to an image - turn it into a image post # this url is a link to an image - turn it into a image post
if file_extension.lower() in allowed_extensions: if file_extension.lower() in allowed_extensions:
@ -303,11 +309,31 @@ def save_post(form, post: Post, type: str):
source_url=final_place.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/")) source_url=final_place.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/"))
post.image = file post.image = file
db.session.add(file) db.session.add(file)
elif type == 'video':
post.title = form.video_title.data
post.body = form.video_body.data
post.body_html = markdown_to_html(post.body)
url_changed = post.id is None or form.video_url.data != post.url
post.url = remove_tracking_from_link(form.video_url.data.strip())
post.type = POST_TYPE_VIDEO
domain = domain_from_url(form.video_url.data)
domain.post_count += 1
post.domain = domain
if url_changed:
if post.image_id:
remove_old_file(post.image_id)
post.image_id = None
file = File(source_url=form.video_url.data) # make_image_sizes() will take care of turning this into a still image
post.image = file
db.session.add(file)
elif type == 'poll': elif type == 'poll':
... ...
else: else:
raise Exception('invalid post type') raise Exception('invalid post type')
if post.id is None: if post.id is None:
if current_user.reputation > 100: if current_user.reputation > 100:
post.up_votes = 1 post.up_votes = 1

View file

@ -44,6 +44,7 @@ def show_domain(domain_id):
prev_url = url_for('domain.show_domain', domain_id=domain_id, page=posts.prev_num) if posts.has_prev and page != 1 else None prev_url = url_for('domain.show_domain', domain_id=domain_id, page=posts.prev_num) if posts.has_prev and page != 1 else None
return render_template('domain/domain.html', domain=domain, title=domain.name, posts=posts, return render_template('domain/domain.html', domain=domain, title=domain.name, posts=posts,
POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE, POST_TYPE_LINK=constants.POST_TYPE_LINK, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE, POST_TYPE_LINK=constants.POST_TYPE_LINK,
POST_TYPE_VIDEO=constants.POST_TYPE_VIDEO,
next_url=next_url, prev_url=prev_url, next_url=next_url, prev_url=prev_url,
content_filters=content_filters, content_filters=content_filters,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),

View file

@ -12,7 +12,7 @@ from app import db, cache
from app.activitypub.util import default_context, make_image_sizes_async, refresh_user_profile, find_actor_or_create, \ from app.activitypub.util import default_context, make_image_sizes_async, refresh_user_profile, find_actor_or_create, \
refresh_community_profile_task, users_total, active_month, local_posts, local_communities, local_comments refresh_community_profile_task, users_total, active_month, local_posts, local_communities, local_comments
from app.constants import SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER, POST_TYPE_IMAGE, POST_TYPE_LINK, \ from app.constants import SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER, POST_TYPE_IMAGE, POST_TYPE_LINK, \
SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_VIDEO
from app.email import send_email, send_welcome_email from app.email import send_email, send_welcome_email
from app.inoculation import inoculation from app.inoculation import inoculation
from app.main import bp from app.main import bp
@ -25,7 +25,8 @@ 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, topic_tree, recently_upvoted_posts, recently_downvoted_posts blocked_instances, communities_banned_from, topic_tree, recently_upvoted_posts, recently_downvoted_posts, \
generate_image_from_video_url
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
@ -113,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':
@ -148,7 +149,7 @@ def home_page(type, sort):
recently_downvoted = [] 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, POST_TYPE_VIDEO=POST_TYPE_VIDEO,
low_bandwidth=low_bandwidth, recently_upvoted=recently_upvoted, low_bandwidth=low_bandwidth, recently_upvoted=recently_upvoted,
recently_downvoted=recently_downvoted, recently_downvoted=recently_downvoted,
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,

View file

@ -169,6 +169,28 @@ class ChatMessage(db.Model):
sender = db.relationship('User', foreign_keys=[sender_id]) sender = db.relationship('User', foreign_keys=[sender_id])
class Tag(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(256))
class Language(db.Model):
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(5), index=True)
name = db.Column(db.String(50))
community_language = db.Table('community_language', db.Column('community_id', db.Integer, db.ForeignKey('community.id')),
db.Column('language_id', db.Integer, db.ForeignKey('language.id')),
db.PrimaryKeyConstraint('community_id', 'language_id')
)
post_tag = db.Table('post_tag', db.Column('post_id', db.Integer, db.ForeignKey('post.id')),
db.Column('tag_id', db.Integer, db.ForeignKey('tag.id')),
db.PrimaryKeyConstraint('post_id', 'tag_id')
)
class File(db.Model): class File(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
file_path = db.Column(db.String(255)) file_path = db.Column(db.String(255))
@ -365,6 +387,7 @@ class Community(db.Model):
replies = db.relationship('PostReply', lazy='dynamic', cascade="all, delete-orphan") replies = db.relationship('PostReply', lazy='dynamic', cascade="all, delete-orphan")
icon = db.relationship('File', foreign_keys=[icon_id], single_parent=True, backref='community', cascade="all, delete-orphan") icon = db.relationship('File', foreign_keys=[icon_id], single_parent=True, backref='community', cascade="all, delete-orphan")
image = db.relationship('File', foreign_keys=[image_id], single_parent=True, cascade="all, delete-orphan") image = db.relationship('File', foreign_keys=[image_id], single_parent=True, cascade="all, delete-orphan")
languages = db.relationship('Language', lazy='dynamic', secondary=community_language, backref=db.backref('communities', lazy='dynamic'))
@cache.memoize(timeout=500) @cache.memoize(timeout=500)
def icon_image(self, size='default') -> str: def icon_image(self, size='default') -> str:
@ -838,9 +861,11 @@ class User(UserMixin, db.Model):
post.delete_dependencies() post.delete_dependencies()
post.flush_cache() post.flush_cache()
db.session.delete(post) db.session.delete(post)
db.session.commit()
post_replies = PostReply.query.filter_by(user_id=self.id).all() post_replies = PostReply.query.filter_by(user_id=self.id).all()
for reply in post_replies: for reply in post_replies:
reply.body = reply.body_html = '' reply.delete_dependencies()
db.session.delete(reply)
db.session.commit() db.session.commit()
def mention_tag(self): def mention_tag(self):
@ -893,7 +918,9 @@ class Post(db.Model):
language = db.Column(db.String(10)) language = db.Column(db.String(10))
edited_at = db.Column(db.DateTime) edited_at = db.Column(db.DateTime)
reports = db.Column(db.Integer, default=0) # how many times this post has been reported. Set to -1 to ignore reports reports = db.Column(db.Integer, default=0) # how many times this post has been reported. Set to -1 to ignore reports
language_id = db.Column(db.Integer, index=True)
cross_posts = db.Column(MutableList.as_mutable(ARRAY(db.Integer))) cross_posts = db.Column(MutableList.as_mutable(ARRAY(db.Integer)))
tags = db.relationship('Tag', lazy='dynamic', secondary=post_tag, backref=db.backref('posts', lazy='dynamic'))
ap_id = db.Column(db.String(255), index=True) ap_id = db.Column(db.String(255), index=True)
ap_create_id = db.Column(db.String(100)) ap_create_id = db.Column(db.String(100))
@ -1018,6 +1045,10 @@ class PostReply(db.Model):
return parent.author.profile_id() return parent.author.profile_id()
def delete_dependencies(self): def delete_dependencies(self):
for child_reply in self.child_replies():
child_reply.delete_dependencies()
db.session.delete(child_reply)
db.session.query(Report).filter(Report.suspect_post_reply_id == self.id).delete() db.session.query(Report).filter(Report.suspect_post_reply_id == self.id).delete()
db.session.execute(text('DELETE FROM post_reply_vote WHERE post_reply_id = :post_reply_id'), db.session.execute(text('DELETE FROM post_reply_vote WHERE post_reply_id = :post_reply_id'),
{'post_reply_id': self.id}) {'post_reply_id': self.id})
@ -1025,6 +1056,9 @@ class PostReply(db.Model):
file = File.query.get(self.image_id) file = File.query.get(self.image_id)
file.delete_from_disk() file.delete_from_disk()
def child_replies(self):
return PostReply.query.filter_by(parent_id=self.id).all()
def has_replies(self): def has_replies(self):
reply = PostReply.query.filter_by(parent_id=self.id).first() reply = PostReply.query.filter_by(parent_id=self.id).first()
return reply is not None return reply is not None

View file

@ -13,9 +13,9 @@ 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 CreateLinkForm, CreateImageForm, CreateDiscussionForm from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm, CreateVideoForm
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, POST_TYPE_ARTICLE from app.constants import SUBSCRIPTION_MEMBER, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE, POST_TYPE_VIDEO
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, Instance Topic, User, Instance
@ -257,6 +257,7 @@ def show_post(post_id: int):
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,
POST_TYPE_VIDEO=constants.POST_TYPE_VIDEO, autoplay=request.args.get('autoplay', False),
noindex=not post.author.indexable, noindex=not post.author.indexable,
recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted, recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted,
recently_upvoted_replies=recently_upvoted_replies, recently_downvoted_replies=recently_downvoted_replies, recently_upvoted_replies=recently_upvoted_replies, recently_downvoted_replies=recently_downvoted_replies,
@ -303,10 +304,23 @@ def post_vote(post_id: int, vote_direction):
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 += current_app.config['SPICY_UNDER_10']
elif post.up_votes + post.down_votes <= 30:
post.score += current_app.config['SPICY_UNDER_30']
elif post.up_votes + post.down_votes <= 60:
post.score += current_app.config['SPICY_UNDER_60']
else:
post.score += 1 post.score += 1
else: else:
effect = -1 effect = -1
post.down_votes += 1 post.down_votes += 1
if post.up_votes + post.down_votes <= 30:
post.score -= current_app.config['SPICY_UNDER_30']
elif post.up_votes + post.down_votes <= 60:
post.score -= current_app.config['SPICY_UNDER_60']
else:
post.score -= 1 post.score -= 1
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)
@ -693,6 +707,8 @@ def post_edit(post_id: int):
return redirect(url_for('post.post_edit_link_post', post_id=post_id)) return redirect(url_for('post.post_edit_link_post', post_id=post_id))
elif post.type == POST_TYPE_IMAGE: elif post.type == POST_TYPE_IMAGE:
return redirect(url_for('post.post_edit_image_post', post_id=post_id)) return redirect(url_for('post.post_edit_image_post', post_id=post_id))
elif post.type == POST_TYPE_VIDEO:
return redirect(url_for('post.post_edit_video_post', post_id=post_id))
else: else:
abort(404) abort(404)
@ -918,6 +934,87 @@ def post_edit_link_post(post_id: int):
abort(401) abort(401)
@bp.route('/post/<int:post_id>/edit_video', methods=['GET', 'POST'])
@login_required
def post_edit_video_post(post_id: int):
post = Post.query.get_or_404(post_id)
form = CreateVideoForm()
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, 'video')
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.video_title.data = post.title
form.video_body.data = post.body
form.video_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_video.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): def federate_post_update(post):
page_json = { page_json = {
'type': 'Page', 'type': 'Page',
@ -956,7 +1053,7 @@ def federate_post_update(post):
], ],
'object': page_json, 'object': page_json,
} }
if post.type == POST_TYPE_LINK: if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO:
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:
@ -1429,7 +1526,7 @@ def post_reply_delete(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)
community = post.community community = post.community
if post_reply.user_id == current_user.id or community.is_moderator(): if post_reply.user_id == current_user.id or community.is_moderator() or current_user.is_admin():
if post_reply.has_replies(): if post_reply.has_replies():
post_reply.body = 'Deleted by author' if post_reply.author.id == current_user.id else 'Deleted by moderator' post_reply.body = 'Deleted by author' if post_reply.author.id == current_user.id else 'Deleted by moderator'
post_reply.body_html = markdown_to_html(post_reply.body) post_reply.body_html = markdown_to_html(post_reply.body)

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, recently_upvoted_posts, recently_downvoted_posts 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))

View file

@ -707,6 +707,10 @@ fieldset legend {
border-radius: 2px; border-radius: 2px;
top: 0; top: 0;
} }
.post_list .post_teaser .thumbnail .fe-video {
left: 121px;
top: 0;
}
.post_list .post_teaser .thumbnail img { .post_list .post_teaser .thumbnail img {
height: 60px; height: 60px;
width: 60px; width: 60px;
@ -932,6 +936,11 @@ fieldset legend {
border-top: solid 1px #bbb; border-top: solid 1px #bbb;
margin-right: 8px; margin-right: 8px;
} }
.comments > .comment .comment_body hr {
margin-left: 15px;
margin-right: 15px;
opacity: 0.1;
}
.comments > .comment:first-child { .comments > .comment:first-child {
border-top: none; border-top: none;
padding-top: 0; padding-top: 0;
@ -985,7 +994,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));
@ -1012,11 +1022,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;
@ -1026,14 +1031,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;
@ -1125,7 +1123,6 @@ fieldset legend {
.comment { .comment {
clear: both; clear: both;
margin-bottom: 10px;
margin-left: 15px; margin-left: 15px;
padding-top: 8px; padding-top: 8px;
} }
@ -1178,7 +1175,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;
@ -1391,4 +1388,9 @@ h1 .warning_badge {
max-width: 100%; max-width: 100%;
} }
.responsive-video {
max-width: 100%;
max-height: 90vh;
}
/*# sourceMappingURL=structure.css.map */ /*# sourceMappingURL=structure.css.map */

View file

@ -308,6 +308,11 @@ html {
top: 0; top: 0;
} }
.fe-video {
left: 121px;
top: 0;
}
img { img {
height: 60px; height: 60px;
width: 60px; width: 60px;
@ -552,6 +557,14 @@ html {
border-top: solid 1px $grey; border-top: solid 1px $grey;
margin-right: 8px; margin-right: 8px;
.comment_body {
hr {
margin-left: 15px;
margin-right: 15px;
opacity: 0.1;
}
}
&:first-child { &:first-child {
border-top: none; border-top: none;
padding-top: 0; padding-top: 0;
@ -623,7 +636,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));
@ -654,9 +668,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;
@ -667,14 +679,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{
@ -776,7 +782,6 @@ html {
.comment { .comment {
clear: both; clear: both;
margin-bottom: 10px;
margin-left: 15px; margin-left: 15px;
padding-top: 8px; padding-top: 8px;
@ -836,7 +841,7 @@ html {
position: relative; position: relative;
a { a {
text-decoration: none; text-decoration: none;
padding: 5px 0; padding: 0;
} }
.hide_button { .hide_button {
@ -1058,3 +1063,8 @@ h1 .warning_badge {
max-width: 100%; max-width: 100%;
} }
} }
.responsive-video {
max-width: 100%;
max-height: 90vh;
}

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,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_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 }}">
@ -79,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

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

@ -19,6 +19,7 @@
<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_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_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="{{ url_for('community.add_image_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share an image') }}">{{ _('Image') }}</a>
<a href="{{ url_for('community.add_video_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share a video') }}">{{ _('Video') }}</a>
<!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a> <!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a>
<a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> --> <a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> -->
</div> </div>

View file

@ -19,6 +19,7 @@
<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_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_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="{{ url_for('community.add_image_post', actor=actor) }}" class="btn btn-primary" aria-label="{{ _('Share an image') }}">{{ _('Image') }}</a>
<a href="{{ url_for('community.add_video_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share a video') }}">{{ _('Video') }}</a>
<!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a> <!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a>
<a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> --> <a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> -->
</div> </div>

View file

@ -19,6 +19,7 @@
<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_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_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="{{ url_for('community.add_image_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share an image') }}">{{ _('Image') }}</a>
<a href="{{ url_for('community.add_video_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share a video') }}">{{ _('Video') }}</a>
<!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a> <!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a>
<a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> --> <a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> -->
</div> </div>

View file

@ -0,0 +1,98 @@
{% 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-outline-secondary" aria-label="{{ _('Share an image') }}">{{ _('Image') }}</a>
<a href="{{ url_for('community.add_video_post', actor=actor) }}" class="btn btn-primary" aria-label="{{ _('Share a video') }}">{{ _('Video') }}</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.video_title) }}
{{ render_field(form.video_url) }}
<p class="small field_hint">{{ _('Provide a URL ending with .mp4 or .webm.') }}</p>
{{ render_field(form.video_body) }}
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#video_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('video_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

@ -83,6 +83,36 @@
<span class="fe fe-external"></span></a></p> <span class="fe fe-external"></span></a></p>
{% if post.url.endswith('.mp3') %} {% if post.url.endswith('.mp3') %}
<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>
{% elif post.url.endswith('.mp4') or post.url.endswith('.webm') %}
<p>
<video class="responsive-video" controls preload="{{ 'metadata' if low_bandwidth else 'auto' }}">
{% if post.url.endswith('.mp4') %}
<source src="{{ post.url }}" media="video/mp4" />
{% elif post.url.endswith('.webm') %}
<source src="{{ post.url }}" media="video/webm" />
{% endif %}
</video></p>
{% elif post.url.startswith('https://streamable.com') %}
<div style="padding-bottom: 56.25%; position: relative;"><iframe style="position: absolute; top: 0px; left: 0px; width: 100%; height: 100%;" src="{{ post.url.replace('streamable.com/', 'streamable.com/e/') }}" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen" width="100%" height="100%" frameborder="0"></iframe></div>
{% elif post.url.startswith('https://www.redgifs.com/watch/') %}
<div style="padding-bottom: 56.25%; position: relative;"><iframe style="position: absolute; top: 0px; left: 0px; width: 100%; height: 100%;" src="{{ post.url.replace('redgifs.com/watch/', 'redgifs.com/ifr/') }}" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen" width="100%" height="100%" frameborder="0"></iframe></div>
{% endif %}
{% 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>
{% endif %}
{% elif post.type == POST_TYPE_VIDEO %}
<p><a href="{{ post.url }}" rel="nofollow ugc" target="_blank" class="post_link" aria-label="Go to post url">{{ post.url|shorten_url }}
<span class="fe fe-external"></span></a></p>
{% if post.url.endswith('.mp4') or post.url.endswith('.webm') %}
<p>
<video class="responsive-video" controls preload="{{ 'none' if low_bandwidth else 'auto' }}" {{ 'autoplay muted' if autoplay }}>
{% if post.url.endswith('.mp4') %}
<source src="{{ post.url }}" media="video/mp4" />
{% elif post.url.endswith('.webm') %}
<source src="{{ post.url }}" media="video/webm" />
{% endif %}
</video></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> <p><a href="https://piped.video/watch?v={{ post.youtube_embed() }}">{{ _('Watch on piped.video') }} <span class="fe fe-external"></span></a></p>

View file

@ -16,15 +16,18 @@
{% 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 %}
{% if post.type == POST_TYPE_LINK %} {% if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO %}
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('Read article') }}"><span class="fe fe-external"></span></a> <a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('Follow link') }}"><span class="fe fe-external"></span></a>
{% elif post.type == POST_TYPE_IMAGE %} {% elif post.type == POST_TYPE_IMAGE %}
<a href="{{ post.image.view_url() }}" rel="nofollow ugc" aria-label="{{ _('View image') }}" target="_blank"><span class="fe fe-magnify"></span></a> <a href="{{ post.image.view_url() }}" rel="nofollow ugc" aria-label="{{ _('View image') }}" target="_blank"><span class="fe fe-magnify"></span></a>
{% else %} {% else %}
<a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}" aria-label="{{ _('Read post') }}"><span class="fe fe-reply"></span></a> <a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}" aria-label="{{ _('Read post') }}"><span class="fe fe-reply"></span></a>
{% endif %} {% endif %}
{% else %} {% else %}
{% if post.type == POST_TYPE_LINK %} {% if post.type == POST_TYPE_VIDEO %}
<a href="{{ url_for('activitypub.post_ap', post_id=post.id, sort='new' if sort == 'active' else None, autoplay='true') }}" rel="nofollow ugc" aria-label="{{ _('Read article') }}"><span class="fe fe-video"></span><img src="{{ post.image.thumbnail_url() }}"
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" loading="lazy" class="{{ 'blur' if (post.nsfw and not post.community.nsfw) or (post.nsfl and not post.community.nsfl) }}" /></a>
{% elif post.type == POST_TYPE_LINK %}
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('Read article') }}"><span class="fe fe-external"></span><img src="{{ post.image.thumbnail_url() }}" <a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('Read article') }}"><span class="fe fe-external"></span><img src="{{ post.image.thumbnail_url() }}"
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" loading="lazy" class="{{ 'blur' if (post.nsfw and not post.community.nsfw) or (post.nsfl and not post.community.nsfl) }}" /></a> alt="{{ post.image.alt_text if post.image.alt_text else '' }}" loading="lazy" class="{{ 'blur' if (post.nsfw and not post.community.nsfw) or (post.nsfl and not post.community.nsfl) }}" /></a>
{% elif post.type == POST_TYPE_IMAGE %} {% elif post.type == POST_TYPE_IMAGE %}
@ -39,7 +42,7 @@
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}
{% if post.type == POST_TYPE_LINK and post.domain_id %} {% if (post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO) and post.domain_id %}
<div class="thumbnail{{ ' lbw' if low_bandwidth }} missing_thumbnail" aria-hidden="true"> <div class="thumbnail{{ ' lbw' if low_bandwidth }} missing_thumbnail" aria-hidden="true">
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('Read article') }}"><span class="fe fe-external"></span></a> <a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('Read article') }}"><span class="fe fe-external"></span></a>
</div> </div>
@ -47,8 +50,8 @@
{% endif %} {% endif %}
<h3>{% if post.sticky %}<span class="fe fe-sticky-left"></span>{% endif %}<a href="{{ url_for('activitypub.post_ap', post_id=post.id, sort='new' if sort == 'active' else None) }}" class="post_teaser_title_a">{{ post.title }}</a> <h3>{% if post.sticky %}<span class="fe fe-sticky-left"></span>{% endif %}<a href="{{ url_for('activitypub.post_ap', post_id=post.id, sort='new' if sort == 'active' else None) }}" class="post_teaser_title_a">{{ post.title }}</a>
{% if post.type == POST_TYPE_IMAGE %}<span class="fe fe-image" aria-hidden="true"> </span>{% endif %} {% if post.type == POST_TYPE_IMAGE %}<span class="fe fe-image" aria-hidden="true"> </span>{% endif %}
{% if post.type == POST_TYPE_LINK and post.domain_id %} {% if (post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO) and post.domain_id %}
{% if post.url and 'youtube.com' in post.url %} {% if post.url and (post.type == POST_TYPE_VIDEO or 'youtube.com' in post.url) %}
<span class="fe fe-video" aria-hidden="true"></span> <span class="fe fe-video" aria-hidden="true"></span>
{% elif post.url.endswith('.mp3') %} {% elif post.url.endswith('.mp3') %}
<span class="fe fe-audio" aria-hidden="true"></span> <span class="fe fe-audio" aria-hidden="true"></span>

View file

@ -11,7 +11,7 @@
{% set thumbnail = post.image.view_url() %} {% set thumbnail = post.image.view_url() %}
{% endif %} {% endif %}
<div class="masonry_thumb" title="{{ post.title }}"> <div class="masonry_thumb" title="{{ post.title }}">
{% if post.type == POST_TYPE_LINK %} {% if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO %}
{% if post.image.medium_url() %} {% if post.image.medium_url() %}
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('View image') }}"><img src="{{ post.image.medium_url() }}" <a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="{{ _('View image') }}"><img src="{{ post.image.medium_url() }}"
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" title="{{ post.title }}" alt="{{ post.image.alt_text if post.image.alt_text else '' }}" title="{{ post.title }}"

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.video_title) }}
{{ render_field(form.video_url) }}
{{ render_field(form.video_body) }}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#video_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
value: {{ form.link_body.data | tojson | safe }},
});
setupAutoResize('video_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

@ -13,7 +13,7 @@
<div class="card-title">{{ _('Options for comment on "%(post_title)s"', post_title=post.title) }}</div> <div class="card-title">{{ _('Options for comment on "%(post_title)s"', post_title=post.title) }}</div>
<ul class="option_list"> <ul class="option_list">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{% if post_reply.user_id == current_user.id or post.community.is_moderator() %} {% if post_reply.user_id == current_user.id or post.community.is_moderator() or current_user.is_admin() %}
<li><a href="{{ url_for('post.post_reply_edit', post_id=post.id, comment_id=post_reply.id) }}" class="no-underline" rel="nofollow"><span class="fe fe-edit"></span> <li><a href="{{ url_for('post.post_reply_edit', post_id=post.id, comment_id=post_reply.id) }}" class="no-underline" rel="nofollow"><span class="fe fe-edit"></span>
{{ _('Edit') }}</a></li> {{ _('Edit') }}</a></li>
<li><a href="{{ url_for('post.post_reply_delete', post_id=post.id, comment_id=post_reply.id) }}" class="no-underline confirm_first" rel="nofollow"><span class="fe fe-delete"></span> <li><a href="{{ url_for('post.post_reply_delete', post_id=post.id, comment_id=post_reply.id) }}" class="no-underline confirm_first" rel="nofollow"><span class="fe fe-delete"></span>

View file

@ -9,7 +9,7 @@ from flask_babel import _
from sqlalchemy import text, desc, or_ from sqlalchemy import text, desc, or_
from app.activitypub.signature import post_request from app.activitypub.signature import post_request
from app.constants import SUBSCRIPTION_NONMEMBER, POST_TYPE_IMAGE, POST_TYPE_LINK from app.constants import SUBSCRIPTION_NONMEMBER, POST_TYPE_IMAGE, POST_TYPE_LINK, POST_TYPE_VIDEO
from app.inoculation import inoculation from app.inoculation import inoculation
from app.models import Topic, Community, Post, utcnow, CommunityMember, CommunityJoinRequest, User from app.models import Topic, Community, Post, utcnow, CommunityMember, CommunityJoinRequest, User
from app.topic import bp from app.topic import bp
@ -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':
@ -111,7 +117,8 @@ def show_topic(topic_path):
show_post_community=True, moderating_communities=moderating_communities(current_user.get_id()), show_post_community=True, moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)], inoculation=inoculation[randint(0, len(inoculation) - 1)],
POST_TYPE_LINK=POST_TYPE_LINK, POST_TYPE_IMAGE=POST_TYPE_IMAGE) POST_TYPE_LINK=POST_TYPE_LINK, POST_TYPE_IMAGE=POST_TYPE_IMAGE,
POST_TYPE_VIDEO=POST_TYPE_VIDEO)
else: else:
abort(404) abort(404)

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, recently_upvoted_posts, recently_downvoted_posts 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
@ -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)

View file

@ -4,6 +4,7 @@ import bisect
import hashlib import hashlib
import mimetypes import mimetypes
import random import random
import tempfile
import urllib import urllib
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
@ -14,7 +15,7 @@ import math
from urllib.parse import urlparse, parse_qs, urlencode from urllib.parse import urlparse, parse_qs, urlencode
from functools import wraps from functools import wraps
import flask import flask
from bs4 import BeautifulSoup, NavigableString, MarkupResemblesLocatorWarning from bs4 import BeautifulSoup, MarkupResemblesLocatorWarning
import warnings import warnings
warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning) warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning)
import requests import requests
@ -26,10 +27,12 @@ from wtforms.fields import SelectField, SelectMultipleField
from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput
from app import db, cache from app import db, cache
import re import re
from moviepy.editor import VideoFileClip
from PIL import Image
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, Topic 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
@ -165,6 +168,13 @@ def is_image_url(url):
return any(path.endswith(extension) for extension in common_image_extensions) return any(path.endswith(extension) for extension in common_image_extensions)
def is_video_url(url):
parsed_url = urlparse(url)
path = parsed_url.path.lower()
common_video_extensions = ['.mp4', '.webm']
return any(path.endswith(extension) for extension in common_video_extensions)
# sanitise HTML using an allow list # sanitise HTML using an allow list
def allowlist_html(html: str) -> str: def allowlist_html(html: str) -> str:
if html is None or html == '': if html is None or html == '':
@ -284,10 +294,13 @@ def domain_from_url(url: str, create=True) -> Domain:
def shorten_string(input_str, max_length=50): def shorten_string(input_str, max_length=50):
if input_str:
if len(input_str) <= max_length: if len(input_str) <= max_length:
return input_str return input_str
else: else:
return input_str[:max_length - 3] + '' return input_str[:max_length - 3] + ''
else:
return ''
def shorten_url(input: str, max_length=20): def shorten_url(input: str, max_length=20):
@ -335,6 +348,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)
@ -872,6 +891,44 @@ def in_sorted_list(arr, target):
return index < len(arr) and arr[index] == target return index < len(arr) and arr[index] == target
# Makes a still image from a video url, without downloading the whole video file
def generate_image_from_video_url(video_url, output_path, length=2):
response = requests.get(video_url, stream=True)
content_type = response.headers.get('Content-Type')
if content_type:
if 'video/mp4' in content_type:
temp_file_extension = '.mp4'
elif 'video/webm' in content_type:
temp_file_extension = '.webm'
else:
raise ValueError("Unsupported video format")
else:
raise ValueError("Content-Type not found in response headers")
# Generate a random temporary file name
temp_file_name = gibberish(15) + temp_file_extension
temp_file_path = os.path.join(tempfile.gettempdir(), temp_file_name)
# Write the downloaded data to a temporary file
with open(temp_file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=4096):
f.write(chunk)
if os.path.getsize(temp_file_path) >= length * 1024 * 1024:
break
# Generate thumbnail from the temporary file
clip = VideoFileClip(temp_file_path)
thumbnail = clip.get_frame(0)
clip.close()
# Save the image
thumbnail_image = Image.fromarray(thumbnail)
thumbnail_image.save(output_path)
os.remove(temp_file_path)
@cache.memoize(timeout=600) @cache.memoize(timeout=600)
def recently_upvoted_posts(user_id) -> List[int]: 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'), 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'),

View file

@ -49,3 +49,7 @@ class Config(object):
CLOUDFLARE_API_TOKEN = os.environ.get('CLOUDFLARE_API_TOKEN') or '' CLOUDFLARE_API_TOKEN = os.environ.get('CLOUDFLARE_API_TOKEN') or ''
CLOUDFLARE_ZONE_ID = os.environ.get('CLOUDFLARE_ZONE_ID') or '' CLOUDFLARE_ZONE_ID = os.environ.get('CLOUDFLARE_ZONE_ID') or ''
SPICY_UNDER_10 = float(os.environ.get('SPICY_UNDER_10')) or 1.0
SPICY_UNDER_30 = float(os.environ.get('SPICY_UNDER_30')) or 1.0
SPICY_UNDER_60 = float(os.environ.get('SPICY_UNDER_60')) or 1.0

View file

@ -34,9 +34,10 @@ time of things.
# Coding Standards / Guidelines # Coding Standards / Guidelines
**[PEP 8](https://peps.python.org/pep-0008/)** covers the basics. PyCharm encourages this by default - **[PEP 8](https://peps.python.org/pep-0008/)** covers the basics. PyCharm encourages this by default -
VS Code coders are encouraged to try the free community edition of PyCharm but it is by no means required. VS Code coders may like to try the free community edition of PyCharm but it is by no means required.
Use PEP 8 conventions for line length, naming, indentation. Use descriptive commit messages. Use PEP 8 conventions for naming, indentation. Use descriptive commit messages. Try to limit lines of code
to a length of roughly 120 characters.
Database model classes are singular. As in "Car", not "Cars". Database model classes are singular. As in "Car", not "Cars".

View file

@ -0,0 +1,42 @@
"""post one language
Revision ID: 980966fba5f4
Revises: fd2af23f4b1f
Create Date: 2024-04-16 21:23:34.642869
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '980966fba5f4'
down_revision = 'fd2af23f4b1f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('post_language')
with op.batch_alter_table('post', schema=None) as batch_op:
batch_op.add_column(sa.Column('language_id', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_post_language_id'), ['language_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('post', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_post_language_id'))
batch_op.drop_column('language_id')
op.create_table('post_language',
sa.Column('post_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('language_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['language_id'], ['language.id'], name='post_language_language_id_fkey'),
sa.ForeignKeyConstraint(['post_id'], ['post.id'], name='post_language_post_id_fkey'),
sa.PrimaryKeyConstraint('post_id', 'language_id', name='post_language_pkey')
)
# ### end Alembic commands ###

View file

@ -0,0 +1,69 @@
"""tags and languages
Revision ID: fd2af23f4b1f
Revises: 91a931afd6d9
Create Date: 2024-04-16 21:15:07.225254
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fd2af23f4b1f'
down_revision = '91a931afd6d9'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('language',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=5), nullable=True),
sa.Column('name', sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('language', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_language_code'), ['code'], unique=False)
op.create_table('tag',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=256), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('community_language',
sa.Column('community_id', sa.Integer(), nullable=False),
sa.Column('language_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['community_id'], ['community.id'], ),
sa.ForeignKeyConstraint(['language_id'], ['language.id'], ),
sa.PrimaryKeyConstraint('community_id', 'language_id')
)
op.create_table('post_language',
sa.Column('post_id', sa.Integer(), nullable=False),
sa.Column('language_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['language_id'], ['language.id'], ),
sa.ForeignKeyConstraint(['post_id'], ['post.id'], ),
sa.PrimaryKeyConstraint('post_id', 'language_id')
)
op.create_table('post_tag',
sa.Column('post_id', sa.Integer(), nullable=False),
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['post_id'], ['post.id'], ),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ),
sa.PrimaryKeyConstraint('post_id', 'tag_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('post_tag')
op.drop_table('post_language')
op.drop_table('community_language')
op.drop_table('tag')
with op.batch_alter_table('language', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_language_code'))
op.drop_table('language')
# ### end Alembic commands ###

View file

@ -8,7 +8,7 @@ from flask_login import current_user
from app import create_app, db, cli from app import create_app, db, cli
import os, click import os, click
from flask import session, g, json, request, current_app 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, POST_TYPE_VIDEO
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, \
@ -22,7 +22,8 @@ cli.register(app)
def app_context_processor(): def app_context_processor():
def getmtime(filename): def getmtime(filename):
return os.path.getmtime('app/static/' + filename) return os.path.getmtime('app/static/' + filename)
return dict(getmtime=getmtime, post_type_link=POST_TYPE_LINK, post_type_image=POST_TYPE_IMAGE, post_type_article=POST_TYPE_ARTICLE) return dict(getmtime=getmtime, post_type_link=POST_TYPE_LINK, post_type_image=POST_TYPE_IMAGE,
post_type_article=POST_TYPE_ARTICLE, post_type_video=POST_TYPE_VIDEO)
@app.shell_context_processor @app.shell_context_processor

View file

@ -32,3 +32,4 @@ Werkzeug==2.3.3
pytesseract==0.3.10 pytesseract==0.3.10
sentry-sdk==1.40.6 sentry-sdk==1.40.6
python-slugify==8.0.4 python-slugify==8.0.4
moviepy==1.0.3