Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Martynas Sklizmantas 2024-04-17 20:20:39 +02:00
commit 6ecc14be3f
44 changed files with 990 additions and 215 deletions

View file

@ -79,7 +79,7 @@ sudo apt install tesseract-ocr
* Clone PyFedi * Clone PyFedi
```basg ```bash
git clone https://codeberg.org/rimu/pyfedi.git git clone https://codeberg.org/rimu/pyfedi.git
``` ```
@ -214,6 +214,13 @@ Once you have ngrok working, edit the `.env` file and change the `SERVER_NAME` v
## Running PieFed in production ## Running PieFed in production
Running PieFed in production relies on several additional packages that need to be installed.
```bash
source venv/bin/activate #if not already in virtual environment
pip3 install gunicorn celery
```
Copy `celery_worker.default.py` to `celery_worker.py`. Edit `DATABASE_URL` and `SERVER_NAME` to have the same values as in `.env`. Copy `celery_worker.default.py` to `celery_worker.py`. Edit `DATABASE_URL` and `SERVER_NAME` to have the same values as in `.env`.
Edit `gunicorn.conf.py` and change `worker_tmp_dir` if needed. Edit `gunicorn.conf.py` and change `worker_tmp_dir` if needed.

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
@ -672,30 +672,16 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
ocp.cross_posts.remove(post.id) ocp.cross_posts.remove(post.id)
delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id) delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id)
activity_log.result = 'success' activity_log.result = 'success'
elif request_json['object']['type'] == 'Page': # Editing a post elif request_json['object']['type'] == 'Page': # Sent for Mastodon's benefit
post = Post.query.filter_by(ap_id=request_json['object']['id']).first() activity_log.result = 'ignored'
if post: activity_log.exception_message = 'Intended for Mastodon'
try: db.session.add(activity_log)
update_post_from_activity(post, request_json) db.session.commit()
except KeyError: elif request_json['object']['type'] == 'Note': # Never sent?
activity_log.result = 'exception' activity_log.result = 'ignored'
db.session.commit() activity_log.exception_message = 'Intended for Mastodon'
return db.session.add(activity_log)
activity_log.result = 'success' db.session.commit()
else:
activity_log.exception_message = 'Post not found'
elif request_json['object']['type'] == 'Note': # Editing a reply
reply = PostReply.query.filter_by(ap_id=request_json['object']['id']).first()
if reply:
try:
update_post_reply_from_activity(reply, request_json)
except KeyError:
activity_log.result = 'exception'
db.session.commit()
return
activity_log.result = 'success'
else:
activity_log.exception_message = 'PostReply not found'
elif request_json['object']['type'] == 'Update': # Editing a post or comment elif request_json['object']['type'] == 'Update': # Editing a post or comment
if request_json['object']['object']['type'] == 'Page': if request_json['object']['object']['type'] == 'Page':
post = Post.query.filter_by(ap_id=request_json['object']['object']['id']).first() post = Post.query.filter_by(ap_id=request_json['object']['object']['id']).first()

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,78 +752,117 @@ 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:
try: # Videos
source_image_response = get_request(file.source_url) if file.source_url.endswith('.mp4') or file.source_url.endswith('.webm'):
except: new_filename = gibberish(15)
pass
# 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: else:
if source_image_response.status_code == 200: try:
content_type = source_image_response.headers.get('content-type') source_image_response = get_request(file.source_url)
if content_type and content_type.startswith('image'): except:
source_image = source_image_response.content pass
source_image_response.close() else:
if source_image_response.status_code == 200:
content_type = source_image_response.headers.get('content-type')
if content_type and content_type.startswith('image'):
source_image = source_image_response.content
source_image_response.close()
file_ext = os.path.splitext(file.source_url)[1] file_ext = os.path.splitext(file.source_url)[1]
# fall back to parsing the http content type if the url does not contain a file extension # fall back to parsing the http content type if the url does not contain a file extension
if file_ext == '': if file_ext == '':
content_type_parts = content_type.split('/') content_type_parts = content_type.split('/')
if content_type_parts: if content_type_parts:
file_ext = '.' + content_type_parts[-1] file_ext = '.' + content_type_parts[-1]
else: else:
if '?' in file_ext: if '?' in file_ext:
file_ext = file_ext.split('?')[0] file_ext = file_ext.split('?')[0]
new_filename = gibberish(15) new_filename = gibberish(15)
# set up the storage directory # set up the storage directory
directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4] directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory) ensure_directory_exists(directory)
# file path and names to store the resized images on disk # file path and names to store the resized images on disk
final_place = os.path.join(directory, new_filename + file_ext) final_place = os.path.join(directory, new_filename + file_ext)
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
# Load image data into Pillow # Load image data into Pillow
Image.MAX_IMAGE_PIXELS = 89478485 Image.MAX_IMAGE_PIXELS = 89478485
image = Image.open(BytesIO(source_image)) image = Image.open(BytesIO(source_image))
image = ImageOps.exif_transpose(image) image = ImageOps.exif_transpose(image)
img_width = image.width img_width = image.width
img_height = image.height img_height = image.height
# Resize the image to medium # Resize the image to medium
if medium_width: if medium_width:
if img_width > medium_width: if img_width > medium_width:
image.thumbnail((medium_width, medium_width)) image.thumbnail((medium_width, medium_width))
image.save(final_place) image.save(final_place)
file.file_path = final_place file.file_path = final_place
file.width = image.width file.width = image.width
file.height = image.height file.height = image.height
# Resize the image to a thumbnail (webp) # Resize the image to a thumbnail (webp)
if thumbnail_width: if thumbnail_width:
if img_width > thumbnail_width: if img_width > thumbnail_width:
image.thumbnail((thumbnail_width, thumbnail_width)) image.thumbnail((thumbnail_width, thumbnail_width))
image.save(final_place_thumbnail, format="WebP", quality=93) image.save(final_place_thumbnail, format="WebP", quality=93)
file.thumbnail_path = final_place_thumbnail file.thumbnail_path = final_place_thumbnail
file.thumbnail_width = image.width file.thumbnail_width = image.width
file.thumbnail_height = image.height file.thumbnail_height = image.height
db.session.commit() db.session.commit()
# Alert regarding fascist meme content # Alert regarding fascist meme content
if img_width < 2000: # images > 2000px tend to be real photos instead of 4chan screenshots. if img_width < 2000: # images > 2000px tend to be real photos instead of 4chan screenshots.
try: try:
image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30) image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30)
except FileNotFoundError as e: except FileNotFoundError as e:
image_text = '' image_text = ''
if 'Anonymous' in image_text and ('No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345' if 'Anonymous' in image_text and ('No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345'
post = Post.query.filter_by(image_id=file.id).first() post = Post.query.filter_by(image_id=file.id).first()
notification = Notification(title='Review this', notification = Notification(title='Review this',
user_id=1, user_id=1,
author_id=post.user_id, author_id=post.user_id,
url=url_for('activitypub.post_ap', post_id=post.id)) url=url_for('activitypub.post_ap', post_id=post.id))
db.session.add(notification) db.session.add(notification)
db.session.commit() db.session.commit()
# create a summary from markdown if present, otherwise use html if available # create a summary from markdown if present, otherwise use html if available
@ -1039,9 +1092,9 @@ def downvote_post(post, user):
post.down_votes += 1 post.down_votes += 1
# Make 'hot' sort more spicy by amplifying the effect of early downvotes # Make 'hot' sort more spicy by amplifying the effect of early downvotes
if post.up_votes + post.down_votes <= 30: if post.up_votes + post.down_votes <= 30:
post.score -= 5.0 post.score -= current_app.config['SPICY_UNDER_30']
elif post.up_votes + post.down_votes <= 60: elif post.up_votes + post.down_votes <= 60:
post.score -= 2.0 post.score -= current_app.config['SPICY_UNDER_60']
else: 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,
@ -1148,11 +1201,11 @@ def upvote_post(post, user):
# Make 'hot' sort more spicy by amplifying the effect of early upvotes # Make 'hot' sort more spicy by amplifying the effect of early upvotes
spicy_effect = effect spicy_effect = effect
if post.up_votes + post.down_votes <= 10: if post.up_votes + post.down_votes <= 10:
spicy_effect = effect * 10 spicy_effect = effect * current_app.config['SPICY_UNDER_10']
elif post.up_votes + post.down_votes <= 30: elif post.up_votes + post.down_votes <= 30:
spicy_effect = effect * 5 spicy_effect = effect * current_app.config['SPICY_UNDER_30']
elif post.up_votes + post.down_votes <= 60: elif post.up_votes + post.down_votes <= 60:
spicy_effect = effect * 2 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
@ -1387,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)
@ -1413,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'])
@ -1497,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 = ''
@ -1524,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)
@ -1550,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

@ -1,7 +1,7 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.file import FileRequired, FileAllowed from flask_wtf.file import FileRequired, FileAllowed
from sqlalchemy import func from sqlalchemy import func
from wtforms import StringField, PasswordField, SubmitField, HiddenField, BooleanField, TextAreaField, SelectField, \ from wtforms import StringField, PasswordField, SubmitField, EmailField, HiddenField, BooleanField, TextAreaField, SelectField, \
FileField, IntegerField FileField, IntegerField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional
from flask_babel import _, lazy_gettext as _l from flask_babel import _, lazy_gettext as _l
@ -17,6 +17,7 @@ class SiteProfileForm(FlaskForm):
]) ])
sidebar = TextAreaField(_l('Sidebar')) sidebar = TextAreaField(_l('Sidebar'))
legal_information = TextAreaField(_l('Legal information')) legal_information = TextAreaField(_l('Legal information'))
contact_email = EmailField(_l('General instance contact email address'), validators=[Email(), DataRequired(), Length(min=5, max=255)])
submit = SubmitField(_l('Save')) submit = SubmitField(_l('Save'))

View file

@ -48,6 +48,7 @@ def admin_site():
site.sidebar = form.sidebar.data site.sidebar = form.sidebar.data
site.legal_information = form.legal_information.data site.legal_information = form.legal_information.data
site.updated = utcnow() site.updated = utcnow()
site.contact_email = form.contact_email.data
if site.id is None: if site.id is None:
db.session.add(site) db.session.add(site)
db.session.commit() db.session.commit()

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
@ -257,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,
@ -499,7 +500,7 @@ def add_discussion_post(actor):
if not community.local_only: if not community.local_only:
federate_post(community, post) federate_post(community, post)
return redirect(f"/c/{community.link()}") return redirect(f"/post/{post.id}")
else: else:
form.communities.data = community.id form.communities.data = community.id
form.notify_author.data = True form.notify_author.data = True
@ -572,7 +573,7 @@ def add_image_post(actor):
if not community.local_only: if not community.local_only:
federate_post(community, post) federate_post(community, post)
return redirect(f"/c/{community.link()}") return redirect(f"/post/{post.id}")
else: else:
form.communities.data = community.id form.communities.data = community.id
form.notify_author.data = True form.notify_author.data = True
@ -645,7 +646,7 @@ def add_link_post(actor):
if not community.local_only: if not community.local_only:
federate_post(community, post) federate_post(community, post)
return redirect(f"/c/{community.link()}") return redirect(f"/post/{post.id}")
else: else:
form.communities.data = community.id form.communities.data = community.id
form.notify_author.data = True form.notify_author.data = True
@ -658,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"/post/{post.id}")
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',
@ -697,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,26 +224,31 @@ 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
unused, file_extension = os.path.splitext(form.link_url.data) if post.url.endswith('.mp4') or post.url.endswith('.webm'):
# this url is a link to an image - turn it into a image post file = File(source_url=form.link_url.data) # make_image_sizes() will take care of turning this into a still image
if file_extension.lower() in allowed_extensions:
file = File(source_url=form.link_url.data)
post.image = file post.image = file
db.session.add(file) db.session.add(file)
post.type = POST_TYPE_IMAGE
else: else:
# check opengraph tags on the page and make a thumbnail if an image is available in the og:image meta tag unused, file_extension = os.path.splitext(form.link_url.data)
opengraph = opengraph_parse(form.link_url.data) # this url is a link to an image - turn it into a image post
if opengraph and (opengraph.get('og:image', '') != '' or opengraph.get('og:image:url', '') != ''): if file_extension.lower() in allowed_extensions:
filename = opengraph.get('og:image') or opengraph.get('og:image:url') file = File(source_url=form.link_url.data)
filename_for_extension = filename.split('?')[0] if '?' in filename else filename post.image = file
unused, file_extension = os.path.splitext(filename_for_extension) db.session.add(file)
if file_extension.lower() in allowed_extensions and not filename.startswith('/'): post.type = POST_TYPE_IMAGE
file = url_to_thumbnail_file(filename) else:
if file: # check opengraph tags on the page and make a thumbnail if an image is available in the og:image meta tag
file.alt_text = shorten_string(opengraph.get('og:title'), 295) opengraph = opengraph_parse(form.link_url.data)
post.image = file if opengraph and (opengraph.get('og:image', '') != '' or opengraph.get('og:image:url', '') != ''):
db.session.add(file) filename = opengraph.get('og:image') or opengraph.get('og:image:url')
filename_for_extension = filename.split('?')[0] if '?' in filename else filename
unused, file_extension = os.path.splitext(filename_for_extension)
if file_extension.lower() in allowed_extensions and not filename.startswith('/'):
file = url_to_thumbnail_file(filename)
if file:
file.alt_text = shorten_string(opengraph.get('og:title'), 295)
post.image = file
db.session.add(file)
elif type == 'image': elif type == 'image':
post.title = form.image_title.data post.title = form.image_title.data
@ -303,11 +309,45 @@ 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':
form.video_url.data = form.video_url.data.strip()
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
if form.video_url.data.endswith('.mp4') or form.video_url.data.endswith('.webm'):
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)
else:
# check opengraph tags on the page and make a thumbnail if an image is available in the og:image meta tag
opengraph = opengraph_parse(form.video_url.data)
if opengraph and (opengraph.get('og:image', '') != '' or opengraph.get('og:image:url', '') != ''):
filename = opengraph.get('og:image') or opengraph.get('og:image:url')
filename_for_extension = filename.split('?')[0] if '?' in filename else filename
unused, file_extension = os.path.splitext(filename_for_extension)
if file_extension.lower() in allowed_extensions and not filename.startswith('/'):
file = url_to_thumbnail_file(filename)
if file:
file.alt_text = shorten_string(opengraph.get('og:title'), 295)
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

@ -10,9 +10,9 @@ from sqlalchemy.sql.operators import or_, and_
from app import db, cache 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 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
@ -48,7 +49,6 @@ def index(sort=None):
def popular(sort=None): def popular(sort=None):
return home_page('popular', sort) return home_page('popular', sort)
@bp.route('/all', methods=['GET']) @bp.route('/all', methods=['GET'])
@bp.route('/all/<sort>', methods=['GET']) @bp.route('/all/<sort>', methods=['GET'])
def all_posts(sort=None): def all_posts(sort=None):
@ -149,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,
@ -241,19 +241,17 @@ def donate():
@bp.route('/about') @bp.route('/about')
def about_page(): def about_page():
users = User.query.filter_by(ap_id=None, deleted=False, banned=False).all() user_amount = users_total()
user_amount = len(users) MAU = active_month()
# Todo, figure out how to filter the user list with the list of user_role user_id == 4 posts_amount = local_posts()
#admins = users.filter()
# Todo, figure out how to filter the user list with the list of user_role user_id == 4
#staff = users.filter()
domains_amount = len(Domain.query.filter_by(banned=False).all()) admins = db.session.execute(text('SELECT user_name, email FROM "user" WHERE "id" IN (SELECT "user_id" FROM "user_role" WHERE "role_id" = 4) ORDER BY id')).all()
community_amount = len(Community.query.all()) staff = db.session.execute(text('SELECT user_name FROM "user" WHERE "id" IN (SELECT "user_id" FROM "user_role" WHERE "role_id" = 2) ORDER BY id')).all()
domains_amount = db.session.execute(text('SELECT COUNT(id) as c FROM "domain" WHERE "banned" IS false')).scalar()
community_amount = local_communities()
instance = Instance.query.filter_by(id=1).first() instance = Instance.query.filter_by(id=1).first()
return render_template('about.html', user_amount=user_amount, mau=MAU, posts_amount=posts_amount, domains_amount=domains_amount, community_amount=community_amount, instance=instance, admins=admins, staff=staff)
return render_template('about.html', user_amount=user_amount, domains_amount=domains_amount, community_amount=community_amount, instance=instance)#, admins=admins)
@bp.route('/privacy') @bp.route('/privacy')
@ -303,6 +301,7 @@ def list_files(directory):
@bp.route('/test') @bp.route('/test')
def test(): def test():
x = find_actor_or_create('https://lemmy.ml/u/const_void')
md = "::: spoiler I'm all for ya having fun and your right to hurt yourself.\n\nI am a former racer, commuter, and professional Buyer for a chain of bike shops. I'm also disabled from the crash involving the 6th and 7th cars that have hit me in the last 170k+ miles of riding. I only barely survived what I simplify as a \"broken neck and back.\" Cars making U-turns are what will get you if you ride long enough, \n\nespecially commuting. It will look like just another person turning in front of you, you'll compensate like usual, and before your brain can even register what is really happening, what was your normal escape route will close and you're going to crash really hard. It is the only kind of crash that your intuition is useless against.\n:::" md = "::: spoiler I'm all for ya having fun and your right to hurt yourself.\n\nI am a former racer, commuter, and professional Buyer for a chain of bike shops. I'm also disabled from the crash involving the 6th and 7th cars that have hit me in the last 170k+ miles of riding. I only barely survived what I simplify as a \"broken neck and back.\" Cars making U-turns are what will get you if you ride long enough, \n\nespecially commuting. It will look like just another person turning in front of you, you'll compensate like usual, and before your brain can even register what is really happening, what was your normal escape route will close and you're going to crash really hard. It is the only kind of crash that your intuition is useless against.\n:::"
return markdown_to_html(md) return markdown_to_html(md)

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,10 @@ 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, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, 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,11 +258,13 @@ 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,
etag=f"{post.id}{sort}_{hash(post.last_active)}", markdown_editor=current_user.is_authenticated and current_user.markdown_editor, etag=f"{post.id}{sort}_{hash(post.last_active)}", markdown_editor=current_user.is_authenticated and current_user.markdown_editor,
low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1', SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1', SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
SUBSCRIPTION_OWNER=SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR=SUBSCRIPTION_MODERATOR,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)] inoculation=inoculation[randint(0, len(inoculation) - 1)]
@ -305,20 +308,20 @@ def post_vote(post_id: int, vote_direction):
post.up_votes += 1 post.up_votes += 1
# Make 'hot' sort more spicy by amplifying the effect of early upvotes # Make 'hot' sort more spicy by amplifying the effect of early upvotes
if post.up_votes + post.down_votes <= 10: if post.up_votes + post.down_votes <= 10:
post.score += 10 post.score += current_app.config['SPICY_UNDER_10']
elif post.up_votes + post.down_votes <= 30: elif post.up_votes + post.down_votes <= 30:
post.score += 5 post.score += current_app.config['SPICY_UNDER_30']
elif post.up_votes + post.down_votes <= 60: elif post.up_votes + post.down_votes <= 60:
post.score += 2 post.score += current_app.config['SPICY_UNDER_60']
else: 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: if post.up_votes + post.down_votes <= 30:
post.score -= 5 post.score -= current_app.config['SPICY_UNDER_30']
elif post.up_votes + post.down_votes <= 60: elif post.up_votes + post.down_votes <= 60:
post.score -= 2 post.score -= current_app.config['SPICY_UNDER_60']
else: 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,
@ -489,6 +492,7 @@ def continue_discussion(post_id, comment_id):
is_moderator=is_moderator, comment=comment, replies=replies, markdown_editor=current_user.is_authenticated and current_user.markdown_editor, is_moderator=is_moderator, comment=comment, replies=replies, markdown_editor=current_user.is_authenticated and current_user.markdown_editor,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), community=post.community, joined_communities=joined_communities(current_user.get_id()), community=post.community,
SUBSCRIPTION_OWNER=SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR=SUBSCRIPTION_MODERATOR,
inoculation=inoculation[randint(0, len(inoculation) - 1)]) inoculation=inoculation[randint(0, len(inoculation) - 1)])
response.headers.set('Vary', 'Accept, Cookie, Accept-Language') response.headers.set('Vary', 'Accept, Cookie, Accept-Language')
return response return response
@ -674,7 +678,8 @@ def add_reply(post_id: int, comment_id: int):
return render_template('post/add_reply.html', title=_('Discussing %(title)s', title=post.title), post=post, return render_template('post/add_reply.html', title=_('Discussing %(title)s', title=post.title), post=post,
is_moderator=is_moderator, form=form, comment=in_reply_to, markdown_editor=current_user.is_authenticated and current_user.markdown_editor, is_moderator=is_moderator, form=form, comment=in_reply_to, markdown_editor=current_user.is_authenticated and current_user.markdown_editor,
moderating_communities=moderating_communities(current_user.get_id()), mods=mod_list, moderating_communities=moderating_communities(current_user.get_id()), mods=mod_list,
joined_communities = joined_communities(current_user.id), joined_communities = joined_communities(current_user.id), community=post.community,
SUBSCRIPTION_OWNER=SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR=SUBSCRIPTION_MODERATOR,
inoculation=inoculation[randint(0, len(inoculation) - 1)]) inoculation=inoculation[randint(0, len(inoculation) - 1)])
@ -706,6 +711,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)
@ -931,6 +938,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',
@ -969,7 +1057,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:
@ -1430,7 +1518,8 @@ def post_reply_edit(post_id: int, comment_id: int):
form.notify_author.data = post_reply.notify_author form.notify_author.data = post_reply.notify_author
return render_template('post/post_reply_edit.html', title=_('Edit comment'), form=form, post=post, post_reply=post_reply, return render_template('post/post_reply_edit.html', title=_('Edit comment'), form=form, post=post, post_reply=post_reply,
comment=comment, markdown_editor=current_user.markdown_editor, moderating_communities=moderating_communities(current_user.get_id()), comment=comment, markdown_editor=current_user.markdown_editor, moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()), community=post.community,
SUBSCRIPTION_OWNER=SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR=SUBSCRIPTION_MODERATOR,
inoculation=inoculation[randint(0, len(inoculation) - 1)]) inoculation=inoculation[randint(0, len(inoculation) - 1)])
else: else:
abort(401) abort(401)
@ -1442,7 +1531,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

@ -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;
@ -1379,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;
@ -1050,3 +1063,8 @@ h1 .warning_badge {
max-width: 100%; max-width: 100%;
} }
} }
.responsive-video {
max-width: 100%;
max-height: 90vh;
}

View file

@ -8,16 +8,18 @@
<div class="row"> <div class="row">
<div class="col-12 col-md-8 position-relative main_pane"> <div class="col-12 col-md-8 position-relative main_pane">
<h1>{{ _('About %(site_name)s', site_name=g.site.name) }}</h1> <h1>{{ _('About %(site_name)s', site_name=g.site.name) }}</h1>
<p> {{g.site.name}} is a <a href="https://join.piefed.social/">pyfedi</a> instance created on {{instance.created_at.strftime('%d-%m-%Y')}}. It is home to <a href="/people">{{user_amount}} users</a> (of which {{mau}} active in the last month). In the <a href="/communities/local"> {{community_amount}} communities</a> we discussed <a href="/domains">{{domains_amount}} domains</a> and made {{posts_amount}} posts.</p>
<p> {{g.site.name}} is a <a href="https://join.piefed.social/">pyfedi</a> instance created on {{instance.created_at}}. It is home to <a href="/people">{{user_amount}} users</a>, <a href="/communities/local"> {{community_amount}} communities</a> who discussed <a href="/domains">{{domains_amount}} domains</a>. This instance is administerred and staffed by $PLACEHOLDER_ADMINS and $PLACEHOLDER_STAFF.</p> <h2> Team </h2>
<p>This instance is administerred by {% for admin in admins %}<a href="/u/{{ admin.user_name }}">{{ admin.user_name }}</a>{{ ", " if not loop.last }}{% endfor %}.</p>
<p>It is moderated by {% for s in staff %}<a href="/u/{{ s.user_name }}">{{ s.user_name }}</a>{{ ", " if not loop.last }}{% endfor %}.</p>
<h2>Contact</h2> <h2>Contact</h2>
<p>Placeholder Admin email</p> <p>{{g.site.contact_email | safe }}</p>
<h2> About Us </h2> <h2> About Us </h2>
<p> {{g.site.description | safe}} </p> <p> {{g.site.description | safe }} </p>
<p> {{g.site.sidebar}} </p> <p> {{g.site.sidebar | safe }} </p>
{% if g.site.legal_information %} {% if g.site.legal_information %}
<h2> Legal Information </h2> <h2> Legal Information </h2>
<p> {{g.site.legal_information}} </p> <p> {{g.site.legal_information | safe }} </p>
<p> <a href="/privacy"> Our Privacy Policy </a> </p> <p> <a href="/privacy"> Our Privacy Policy </a> </p>
{% endif %} {% endif %}
</div> </div>

View file

@ -20,6 +20,7 @@
<p class="small field_hint">HTML is allowed in this field.</p> <p class="small field_hint">HTML is allowed in this field.</p>
{{ render_field(form.legal_information) }} {{ render_field(form.legal_information) }}
<p class="small field_hint">HTML is allowed in this field.</p> <p class="small field_hint">HTML is allowed in this field.</p>
{{ render_field(form.contact_email) }}
{{ render_field(form.submit) }} {{ render_field(form.submit) }}
</form> </form>
</div> </div>

View file

@ -1,4 +1,8 @@
{% extends 'base.html' %} {% 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 %} {% from 'bootstrap/form.html' import render_form %}
{% block app_content %} {% block app_content %}

View file

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

View file

@ -1,7 +1,9 @@
{% if community %} {% if community %}
<div class="mobile_create_post d-md-none mt-1"> {% if not community.restricted_to_mods or (community.restricted_to_mods and current_user.is_authenticated and community_membership(current_user, community) in [SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER]) %}
<a class="btn btn-primary" href="/community/{{ community.link() }}/submit">{{ _('Create post') }}</a> <div class="mobile_create_post d-md-none mt-1">
</div> <a class="btn btn-primary" href="/community/{{ community.link() }}/submit">{{ _('Create post') }}</a>
</div>
{% endif %}
{% endif %} {% endif %}
<div class="btn-group mt-1 mb-2"> <div class="btn-group mt-1 mb-2">
<a href="?sort=hot&layout={{ post_layout }}" aria-label="{{ _('Sort by hot') }}" class="btn {{ 'btn-primary' if sort == '' or sort == 'hot' else 'btn-outline-secondary' }}" rel="nofollow noindex"> <a href="?sort=hot&layout={{ post_layout }}" aria-label="{{ _('Sort by hot') }}" class="btn {{ 'btn-primary' if sort == '' or sort == 'hot' else 'btn-outline-secondary' }}" rel="nofollow noindex">

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

@ -104,9 +104,11 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-6"> {% if not community.restricted_to_mods or (community.restricted_to_mods and current_user.is_authenticated and community_membership(current_user, community) in [SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER]) %}
<a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/submit" rel="nofollow">{{ _('Create post') }}</a> <div class="col-6">
</div> <a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/submit" rel="nofollow">{{ _('Create post') }}</a>
</div>
{% endif %}
<div class="col-6"> <div class="col-6">
{% if current_user.is_authenticated and community_membership(current_user, community) in [SUBSCRIPTION_MEMBER, SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER] %} {% if current_user.is_authenticated and community_membership(current_user, community) in [SUBSCRIPTION_MEMBER, SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER] %}
<a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/unsubscribe" rel="nofollow">{{ _('Leave') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/unsubscribe" rel="nofollow">{{ _('Leave') }}</a>

View file

@ -83,6 +83,40 @@
<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>
{% 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 %} {% 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

@ -50,9 +50,11 @@
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe" rel="nofollow">{{ _('Join') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe" rel="nofollow">{{ _('Join') }}</a>
{% endif %} {% endif %}
</div> </div>
<div class="col-6"> {% if not community.restricted_to_mods or (community.restricted_to_mods and current_user.is_authenticated and community_membership(current_user, community) in [SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER]) %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/submit">{{ _('Create post') }}</a> <div class="col-6">
</div> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/submit">{{ _('Create post') }}</a>
</div>
{% endif %}
</div> </div>
<!-- <form method="get"> <!-- <form method="get">
<input type="search" name="search" class="form-control mt-2" placeholder="{{ _('Search this community') }}" /> <input type="search" name="search" class="form-control mt-2" placeholder="{{ _('Search this community') }}" />

View file

@ -21,7 +21,7 @@
{% else %} {% else %}
{% if comment['comment'].author.avatar_id and comment['comment'].score > -10 and not low_bandwidth %} {% if comment['comment'].author.avatar_id and comment['comment'].score > -10 and not low_bandwidth %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.ap_id }}"> <a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.ap_id }}">
<img src="{{ comment['comment'].author.avatar_image() }}" alt="Avatar" loading="lazy" /></a> <img src="{{ comment['comment'].author.avatar_thumbnail() }}" alt="Avatar" loading="lazy" /></a>
{% endif %} {% endif %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.link() }}"> <a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.link() }}">
{{ comment['comment'].author.display_name() }}</a> {{ comment['comment'].author.display_name() }}</a>
@ -101,9 +101,11 @@
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe" rel="nofollow">{{ _('Join') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe" rel="nofollow">{{ _('Join') }}</a>
{% endif %} {% endif %}
</div> </div>
<div class="col-6"> {% if not community.restricted_to_mods or (community.restricted_to_mods and current_user.is_authenticated and community_membership(current_user, community) in [SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER]) %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/submit">{{ _('Create post') }}</a> <div class="col-6">
</div> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/submit">{{ _('Create post') }}</a>
</div>
{% endif %}
</div> </div>
<!-- <form method="get"> <!-- <form method="get">
<input type="search" name="search" class="form-control mt-2" placeholder="{{ _('Search this community') }}" /> <input type="search" name="search" class="form-control mt-2" placeholder="{{ _('Search this community') }}" />

View file

@ -82,7 +82,7 @@
{% else %} {% else %}
{% if comment['comment'].author.avatar_id and comment['comment'].score > -10 and not low_bandwidth %} {% if comment['comment'].author.avatar_id and comment['comment'].score > -10 and not low_bandwidth %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.ap_id }}"> <a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.ap_id }}">
<img src="{{ comment['comment'].author.avatar_image() }}" alt="" loading="lazy" /></a> <img src="{{ comment['comment'].author.avatar_thumbnail() }}" alt="" loading="lazy" /></a>
{% endif %} {% endif %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.link() }}" aria-label="{{ _('Author') }}"> <a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.link() }}" aria-label="{{ _('Author') }}">
{{ comment['comment'].author.display_name() }}</a> {{ comment['comment'].author.display_name() }}</a>
@ -169,9 +169,11 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-6"> {% if not community.restricted_to_mods or (community.restricted_to_mods and current_user.is_authenticated and community_membership(current_user, community) in [SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER]) %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/submit">{{ _('Create post') }}</a> <div class="col-6">
</div> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/submit">{{ _('Create post') }}</a>
</div>
{% endif %}
<div class="col-6"> <div class="col-6">
{% if current_user.is_authenticated and community_membership(current_user, post.community) >= SUBSCRIPTION_MEMBER %} {% if current_user.is_authenticated and community_membership(current_user, post.community) >= SUBSCRIPTION_MEMBER %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/unsubscribe" rel="nofollow">{{ _('Leave') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/unsubscribe" rel="nofollow">{{ _('Leave') }}</a>

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

@ -41,14 +41,16 @@
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-6">
{% if current_user.is_authenticated and community_membership(current_user, post.community) %} {% if current_user.is_authenticated and community_membership(current_user, post.community) %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/unsubscribe" rel="nofollow">{{ _('Unsubscribe') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/unsubscribe" rel="nofollow">{{ _('Leave') }}</a>
{% else %} {% else %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe" rel="nofollow">{{ _('Subscribe') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe" rel="nofollow">{{ _('Join') }}</a>
{% endif %} {% endif %}
</div> </div>
<div class="col-6"> {% if not community.restricted_to_mods or (community.restricted_to_mods and current_user.is_authenticated and community_membership(current_user, community) in [SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER]) %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/submit">{{ _('Create post') }}</a> <div class="col-6">
</div> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/submit">{{ _('Create post') }}</a>
</div>
{% endif %}
</div> </div>
<!-- <form method="get"> <!-- <form method="get">
<input type="search" name="search" class="form-control mt-2" placeholder="{{ _('Search this community') }}" /> <input type="search" name="search" class="form-control mt-2" placeholder="{{ _('Search this community') }}" />

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

@ -13,10 +13,17 @@
<tbody> <tbody>
{% for community in communities %} {% for community in communities %}
{% if not community.user_is_banned(current_user) %} {% if not community.user_is_banned(current_user) %}
<tr> {% if not community.restricted_to_mods or (community.restricted_to_mods and community_membership(current_user, community) in [SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER]) %}
<th class="pl-2"><a class="choose_topic_for_post" data-id="{{ community.id }}" href="/community/{{ community.link() }}/submit" rel="nofollow" aria-label="{{ _('Post in %(name)s', name=community.display_name()) }}"><img src="{{ community.icon_image('tiny') }}" class="community_icon rounded-circle" loading="lazy" alt="" /> <tr>
{{ community.display_name() }}</a></th> <th class="pl-2"><a class="choose_topic_for_post" data-id="{{ community.id }}" href="/community/{{ community.link() }}/submit" rel="nofollow" aria-label="{{ _('Post in %(name)s', name=community.display_name()) }}"><img src="{{ community.icon_image('tiny') }}" class="community_icon rounded-circle" loading="lazy" alt="" />
</tr> {{ community.display_name() }}</a></th><th></th>
</tr>
{% else %}
<tr>
<th class="pl-2"><a class="choose_topic" data-id="{{ community.id }}" href="/c/{{ community.link() }}" rel="nofollow" aria-label="{{ _('View %(name)s', name=community.display_name()) }}"><img src="{{ community.icon_image('tiny') }}" class="community_icon rounded-circle" loading="lazy" alt="" />
{{ community.display_name() }}</a></th><th>(Posts are restricted to Mods)</th>
</tr>
{% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -26,7 +26,17 @@
{% elif user.avatar_image() != '' %} {% elif user.avatar_image() != '' %}
<div class="row"> <div class="row">
<div class="col-2"> <div class="col-2">
<img class="community_icon_big rounded-circle" src="{{ user.avatar_image() }}" alt="{{ _('Profile pic') }}" /> {% if low_bandwidth %}
<img class="community_icon_big rounded-circle" src="{{ user.avatar_image() }}" alt="{{ _('Profile pic') }}" />
{% else %}
{% if user.avatar.source_url and user.avatar.source_url.endswith('.mp4') %}
<video autoplay disablepictureinpicture loop muted class="community_icon_big rounded-circle">
<source src="{{ user.avatar.source_url }}" type="video/mp4">
</video>
{% else %}
<img class="community_icon_big rounded-circle" src="{{ user.avatar_image() }}" alt="{{ _('Profile pic') }}" />
{% endif %}
{% endif %}
</div> </div>
<div class="col-10"> <div class="col-10">
<h1 class="mt-3">{{ user.display_name() if user.is_local() else user.display_name() + ', ' + user.ap_id }}</h1> <h1 class="mt-3">{{ user.display_name() if user.is_local() else user.display_name() + ', ' + user.ap_id }}</h1>

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, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, 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
@ -117,7 +117,10 @@ 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,
SUBSCRIPTION_OWNER=SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR=SUBSCRIPTION_MODERATOR,
)
else: else:
abort(404) abort(404)
@ -200,8 +203,7 @@ def topic_create_post(topic_name):
community = Community.query.get_or_404(int(request.form.get('community_id'))) community = Community.query.get_or_404(int(request.form.get('community_id')))
return redirect(url_for('community.join_then_add', actor=community.link())) return redirect(url_for('community.join_then_add', actor=community.link()))
return render_template('topic/topic_create_post.html', communities=communities, topic=topic, return render_template('topic/topic_create_post.html', communities=communities, topic=topic,
moderating_communities=moderating_communities(current_user.get_id()), SUBSCRIPTION_OWNER=SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR=SUBSCRIPTION_MODERATOR)
joined_communities=joined_communities(current_user.get_id()))
def topics_for_form(): def topics_for_form():

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,6 +27,8 @@ 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, \
@ -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 == '':
@ -221,7 +231,7 @@ def markdown_to_html(markdown_text) -> str:
if markdown_text: if markdown_text:
raw_html = markdown2.markdown(markdown_text, safe_mode=True, extras={'middle-word-em': False, 'tables': True, 'fenced-code-blocks': True, 'strike': True}) raw_html = markdown2.markdown(markdown_text, safe_mode=True, extras={'middle-word-em': False, 'tables': True, 'fenced-code-blocks': True, 'strike': True})
# replace lemmy spoiler tokens with appropriate html tags instead. (until possibly added as extra to markdown2) # replace lemmy spoiler tokens with appropriate html tags instead. (until possibly added as extra to markdown2)
re_spoiler = re.compile(r':{3} spoiler\s+?(\S.+?)(?:\n|</p>)(.+?)(?:\n|<p>):{3}', re.S) re_spoiler = re.compile(r':{3}\s*?spoiler\s+?(\S.+?)(?:\n|</p>)(.+?)(?:\n|<p>):{3}', re.S)
raw_html = re_spoiler.sub(r'<details><summary>\1</summary><p>\2</p></details>', raw_html) raw_html = re_spoiler.sub(r'<details><summary>\1</summary><p>\2</p></details>', raw_html)
return allowlist_html(raw_html) return allowlist_html(raw_html)
else: else:
@ -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 len(input_str) <= max_length: if input_str:
return input_str if len(input_str) <= max_length:
return input_str
else:
return input_str[:max_length - 3] + ''
else: else:
return input_str[:max_length - 3] + '' return ''
def shorten_url(input: str, max_length=20): def shorten_url(input: str, max_length=20):
@ -878,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', 1.0))
SPICY_UNDER_30 = float(os.environ.get('SPICY_UNDER_30', 1.0))
SPICY_UNDER_60 = float(os.environ.get('SPICY_UNDER_60', 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