diff --git a/INSTALL.md b/INSTALL.md index 1c896b18..1f654bf0 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -79,7 +79,7 @@ sudo apt install tesseract-ocr * Clone PyFedi -```basg +```bash 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 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`. Edit `gunicorn.conf.py` and change `worker_tmp_dir` if needed. diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 8a55f262..a9cf8d65 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -458,7 +458,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): # Notify recipient 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) db.session.add(notify) recipient.unread_notifications += 1 @@ -672,30 +672,16 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): ocp.cross_posts.remove(post.id) delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id) activity_log.result = 'success' - elif request_json['object']['type'] == 'Page': # Editing a post - post = Post.query.filter_by(ap_id=request_json['object']['id']).first() - if post: - try: - update_post_from_activity(post, request_json) - except KeyError: - activity_log.result = 'exception' - db.session.commit() - return - activity_log.result = 'success' - 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'] == 'Page': # Sent for Mastodon's benefit + activity_log.result = 'ignored' + activity_log.exception_message = 'Intended for Mastodon' + db.session.add(activity_log) + db.session.commit() + elif request_json['object']['type'] == 'Note': # Never sent? + activity_log.result = 'ignored' + activity_log.exception_message = 'Intended for Mastodon' + db.session.add(activity_log) + db.session.commit() elif request_json['object']['type'] == 'Update': # Editing a post or comment if request_json['object']['object']['type'] == 'Page': post = Post.query.filter_by(ap_id=request_json['object']['object']['id']).first() diff --git a/app/activitypub/util.py b/app/activitypub/util.py index fd86d652..e110608b 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -12,7 +12,8 @@ from flask_babel import _ from sqlalchemy import text, func from app import db, cache, constants, celery 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 base64 import requests @@ -27,7 +28,7 @@ import pytesseract from app.utils import get_request, allowlist_html, get_setting, ap_datetime, markdown_to_html, \ is_image_url, domain_from_url, gibberish, ensure_directory_exists, markdown_to_text, head_request, post_ranking, \ shorten_string, reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, remove_tracking_from_link, \ - blocked_phrases, microblog_content_to_title + blocked_phrases, microblog_content_to_title, generate_image_from_video_url, is_video_url 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) if post.language is not None: 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"}] if post.image_id is not None: 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) if post.language is not None: 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"}] if post.image_id is not None: 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 +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): # Parse the URL 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']) community.image = 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.commit() 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): file = File.query.get(file_id) if file and file.source_url: - try: - source_image_response = get_request(file.source_url) - except: - pass + # Videos + if file.source_url.endswith('.mp4') or file.source_url.endswith('.webm'): + new_filename = gibberish(15) + + # set up the storage directory + directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4] + ensure_directory_exists(directory) + + # file path and names to store the resized images on disk + final_place = os.path.join(directory, new_filename + '.jpg') + final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') + + generate_image_from_video_url(file.source_url, final_place) + + image = Image.open(final_place) + img_width = image.width + + # Resize the image to medium + if medium_width: + if img_width > medium_width: + image.thumbnail((medium_width, medium_width)) + image.save(final_place) + file.file_path = final_place + file.width = image.width + file.height = image.height + + # Resize the image to a thumbnail (webp) + if thumbnail_width: + if img_width > thumbnail_width: + image.thumbnail((thumbnail_width, thumbnail_width)) + image.save(final_place_thumbnail, format="WebP", quality=93) + file.thumbnail_path = final_place_thumbnail + file.thumbnail_width = image.width + file.thumbnail_height = image.height + + db.session.commit() + + # Images else: - 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() + try: + source_image_response = get_request(file.source_url) + except: + pass + 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] - # fall back to parsing the http content type if the url does not contain a file extension - if file_ext == '': - content_type_parts = content_type.split('/') - if content_type_parts: - file_ext = '.' + content_type_parts[-1] - else: - if '?' in file_ext: - file_ext = file_ext.split('?')[0] + file_ext = os.path.splitext(file.source_url)[1] + # fall back to parsing the http content type if the url does not contain a file extension + if file_ext == '': + content_type_parts = content_type.split('/') + if content_type_parts: + file_ext = '.' + content_type_parts[-1] + else: + if '?' in file_ext: + file_ext = file_ext.split('?')[0] - new_filename = gibberish(15) + new_filename = gibberish(15) - # set up the storage directory - directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4] - ensure_directory_exists(directory) + # 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 + file_ext) - final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') + # file path and names to store the resized images on disk + final_place = os.path.join(directory, new_filename + file_ext) + final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') - # Load image data into Pillow - Image.MAX_IMAGE_PIXELS = 89478485 - image = Image.open(BytesIO(source_image)) - image = ImageOps.exif_transpose(image) - img_width = image.width - img_height = image.height + # Load image data into Pillow + Image.MAX_IMAGE_PIXELS = 89478485 + image = Image.open(BytesIO(source_image)) + image = ImageOps.exif_transpose(image) + img_width = image.width + img_height = image.height - # 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 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 + # 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() + db.session.commit() - # Alert regarding fascist meme content - if img_width < 2000: # images > 2000px tend to be real photos instead of 4chan screenshots. - try: - image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30) - except FileNotFoundError as e: - 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' - post = Post.query.filter_by(image_id=file.id).first() - notification = Notification(title='Review this', - user_id=1, - author_id=post.user_id, - url=url_for('activitypub.post_ap', post_id=post.id)) - db.session.add(notification) - db.session.commit() + # Alert regarding fascist meme content + if img_width < 2000: # images > 2000px tend to be real photos instead of 4chan screenshots. + try: + image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30) + except FileNotFoundError as e: + 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' + post = Post.query.filter_by(image_id=file.id).first() + notification = Notification(title='Review this', + user_id=1, + author_id=post.user_id, + url=url_for('activitypub.post_ap', post_id=post.id)) + db.session.add(notification) + db.session.commit() # 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 # Make 'hot' sort more spicy by amplifying the effect of early downvotes 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: - post.score -= 2.0 + post.score -= current_app.config['SPICY_UNDER_60'] else: post.score -= 1.0 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 spicy_effect = effect 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: - spicy_effect = effect * 5 + spicy_effect = effect * current_app.config['SPICY_UNDER_30'] 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() if not existing_vote: post.up_votes += 1 @@ -1387,6 +1440,11 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json image = File(source_url=post.url) db.session.add(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: post.type = POST_TYPE_LINK post.url = remove_tracking_from_link(post.url) @@ -1413,6 +1471,9 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json else: post = None 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 'image' in request_json['object'] and post.image is None: 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) nsfl_in_title = '[NSFL]' in name.upper() or '(NSFL)' in name.upper() 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_image_id = post.image_id post.url = '' @@ -1524,6 +1590,11 @@ def update_post_from_activity(post: Post, request_json: dict): image = File(source_url=post.url) db.session.add(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: post.type = POST_TYPE_LINK post.url = remove_tracking_from_link(post.url) @@ -1550,6 +1621,7 @@ def update_post_from_activity(post: Post, request_json: dict): else: 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, Post.posted_at > utcnow() - timedelta(days=6)).all() for ncp in new_cross_posts: diff --git a/app/admin/forms.py b/app/admin/forms.py index 43c48e51..59abaac6 100644 --- a/app/admin/forms.py +++ b/app/admin/forms.py @@ -1,7 +1,7 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileRequired, FileAllowed 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 from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional from flask_babel import _, lazy_gettext as _l @@ -17,6 +17,7 @@ class SiteProfileForm(FlaskForm): ]) sidebar = TextAreaField(_l('Sidebar')) 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')) diff --git a/app/admin/routes.py b/app/admin/routes.py index eba8418e..2d3d2030 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -48,6 +48,7 @@ def admin_site(): site.sidebar = form.sidebar.data site.legal_information = form.legal_information.data site.updated = utcnow() + site.contact_email = form.contact_email.data if site.id is None: db.session.add(site) db.session.commit() diff --git a/app/chat/util.py b/app/chat/util.py index 8b149641..4f5114b9 100644 --- a/app/chat/util.py +++ b/app/chat/util.py @@ -14,21 +14,20 @@ def send_message(message: str, conversation_id: int) -> ChatMessage: reply = ChatMessage(sender_id=current_user.id, conversation_id=conversation.id, body=message, body_html=allowlist_html(markdown_to_html(message))) conversation.updated_at = utcnow() + db.session.add(reply) + db.session.commit() for recipient in conversation.members: if recipient.id != current_user.id: if recipient.is_local(): # Notify local recipient 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, author_id=current_user.id) db.session.add(notify) recipient.unread_notifications += 1 - db.session.add(reply) db.session.commit() else: - db.session.add(reply) - db.session.commit() # Federate reply reply_json = { "actor": current_user.profile_id(), diff --git a/app/community/forms.py b/app/community/forms.py index 480ec26b..caace83a 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -116,6 +116,26 @@ class CreateLinkForm(FlaskForm): 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): communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) image_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) diff --git a/app/community/routes.py b/app/community/routes.py index 6af5df89..eb9c3b54 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -10,17 +10,18 @@ from sqlalchemy import or_, desc, text from app import db, constants, cache from app.activitypub.signature import RsaKeys, post_request -from app.activitypub.util import default_context, notify_about_post, find_actor_or_create, make_image_sizes +from app.activitypub.util import default_context, notify_about_post, make_image_sizes 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, \ - EscalateReportForm, ResolveReportForm + EscalateReportForm, ResolveReportForm, CreateVideoForm from app.community.util import search_for_community, community_url_exists, actor_to_community, \ opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance, \ delete_post_from_community, delete_post_reply_from_community from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \ SUBSCRIPTION_PENDING, SUBSCRIPTION_MODERATOR, 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.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ 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, 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, 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, @@ -499,7 +500,7 @@ def add_discussion_post(actor): if not community.local_only: federate_post(community, post) - return redirect(f"/c/{community.link()}") + return redirect(f"/post/{post.id}") else: form.communities.data = community.id form.notify_author.data = True @@ -572,7 +573,7 @@ def add_image_post(actor): if not community.local_only: federate_post(community, post) - return redirect(f"/c/{community.link()}") + return redirect(f"/post/{post.id}") else: form.communities.data = community.id form.notify_author.data = True @@ -645,7 +646,7 @@ def add_link_post(actor): if not community.local_only: federate_post(community, post) - return redirect(f"/c/{community.link()}") + return redirect(f"/post/{post.id}") else: form.communities.data = community.id form.notify_author.data = True @@ -658,6 +659,79 @@ def add_link_post(actor): ) +@bp.route('//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): page = { 'type': 'Page', @@ -697,7 +771,7 @@ def federate_post(community, post): "object": page, '@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'}] elif post.image_id: if post.image.file_path: diff --git a/app/community/util.py b/app/community/util.py index c58dcad9..80d88132 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -11,7 +11,7 @@ from pillow_heif import register_heif_opener from app import db, cache, celery from app.activitypub.signature import post_request from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model, default_context -from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE +from app.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, \ Instance, Notification, User, ActivityPubLog 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) if 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: if op.cross_posts is None: op.cross_posts = [post.id] @@ -223,26 +224,31 @@ def save_post(form, post: Post, type: str): remove_old_file(post.image_id) post.image_id = None - unused, file_extension = os.path.splitext(form.link_url.data) - # this url is a link to an image - turn it into a image post - if file_extension.lower() in allowed_extensions: - file = File(source_url=form.link_url.data) + if post.url.endswith('.mp4') or post.url.endswith('.webm'): + file = File(source_url=form.link_url.data) # make_image_sizes() will take care of turning this into a still image post.image = file db.session.add(file) - post.type = POST_TYPE_IMAGE 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.link_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) + unused, file_extension = os.path.splitext(form.link_url.data) + # this url is a link to an image - turn it into a image post + if file_extension.lower() in allowed_extensions: + file = File(source_url=form.link_url.data) + post.image = file + db.session.add(file) + post.type = POST_TYPE_IMAGE + 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.link_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 == 'image': 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/")) post.image = 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': ... else: raise Exception('invalid post type') + if post.id is None: if current_user.reputation > 100: post.up_votes = 1 diff --git a/app/domain/routes.py b/app/domain/routes.py index 874f6118..7141408c 100644 --- a/app/domain/routes.py +++ b/app/domain/routes.py @@ -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 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_VIDEO=constants.POST_TYPE_VIDEO, next_url=next_url, prev_url=prev_url, content_filters=content_filters, moderating_communities=moderating_communities(current_user.get_id()), diff --git a/app/main/routes.py b/app/main/routes.py index d90f55d3..bdc8a796 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -10,9 +10,9 @@ from sqlalchemy.sql.operators import or_, and_ from app import db, cache 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, \ - SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR + SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_VIDEO from app.email import send_email, send_welcome_email from app.inoculation import inoculation 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, \ 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, \ - 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, \ InstanceRole, Notification from PIL import Image @@ -48,7 +49,6 @@ def index(sort=None): def popular(sort=None): return home_page('popular', sort) - @bp.route('/all', methods=['GET']) @bp.route('/all/', methods=['GET']) def all_posts(sort=None): @@ -149,7 +149,7 @@ def home_page(type, sort): recently_downvoted = [] 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, recently_downvoted=recently_downvoted, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, @@ -241,19 +241,17 @@ def donate(): @bp.route('/about') def about_page(): - users = User.query.filter_by(ap_id=None, deleted=False, banned=False).all() - user_amount = len(users) - # Todo, figure out how to filter the user list with the list of user_role user_id == 4 - #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()) - community_amount = len(Community.query.all()) + user_amount = users_total() + MAU = active_month() + posts_amount = local_posts() + + 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() + 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() - - return render_template('about.html', user_amount=user_amount, domains_amount=domains_amount, community_amount=community_amount, instance=instance)#, admins=admins) + 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) @bp.route('/privacy') @@ -303,6 +301,7 @@ def list_files(directory): @bp.route('/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:::" return markdown_to_html(md) diff --git a/app/models.py b/app/models.py index 30f2506a..07913593 100644 --- a/app/models.py +++ b/app/models.py @@ -169,6 +169,28 @@ class ChatMessage(db.Model): 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): id = db.Column(db.Integer, primary_key=True) 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") 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") + languages = db.relationship('Language', lazy='dynamic', secondary=community_language, backref=db.backref('communities', lazy='dynamic')) @cache.memoize(timeout=500) def icon_image(self, size='default') -> str: @@ -838,9 +861,11 @@ class User(UserMixin, db.Model): post.delete_dependencies() post.flush_cache() db.session.delete(post) + db.session.commit() post_replies = PostReply.query.filter_by(user_id=self.id).all() for reply in post_replies: - reply.body = reply.body_html = '' + reply.delete_dependencies() + db.session.delete(reply) db.session.commit() def mention_tag(self): @@ -893,7 +918,9 @@ class Post(db.Model): language = db.Column(db.String(10)) 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 + language_id = db.Column(db.Integer, index=True) 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_create_id = db.Column(db.String(100)) @@ -1018,6 +1045,10 @@ class PostReply(db.Model): return parent.author.profile_id() 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.execute(text('DELETE FROM post_reply_vote WHERE post_reply_id = :post_reply_id'), {'post_reply_id': self.id}) @@ -1025,6 +1056,9 @@ class PostReply(db.Model): file = File.query.get(self.image_id) file.delete_from_disk() + def child_replies(self): + return PostReply.query.filter_by(parent_id=self.id).all() + def has_replies(self): reply = PostReply.query.filter_by(parent_id=self.id).first() return reply is not None diff --git a/app/post/routes.py b/app/post/routes.py index f80ab8b3..3e4bbd3e 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -13,9 +13,10 @@ from app.activitypub.util import default_context from app.community.util import save_post, send_to_remote_instance from app.inoculation import inoculation 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.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, \ PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, \ 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, 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_VIDEO=constants.POST_TYPE_VIDEO, autoplay=request.args.get('autoplay', False), noindex=not post.author.indexable, recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted, recently_upvoted_replies=recently_upvoted_replies, recently_downvoted_replies=recently_downvoted_replies, etag=f"{post.id}{sort}_{hash(post.last_active)}", markdown_editor=current_user.is_authenticated and current_user.markdown_editor, 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()), joined_communities=joined_communities(current_user.get_id()), inoculation=inoculation[randint(0, len(inoculation) - 1)] @@ -305,20 +308,20 @@ def post_vote(post_id: int, vote_direction): post.up_votes += 1 # Make 'hot' sort more spicy by amplifying the effect of early upvotes if post.up_votes + post.down_votes <= 10: - post.score += 10 + post.score += current_app.config['SPICY_UNDER_10'] 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: - post.score += 2 + post.score += current_app.config['SPICY_UNDER_60'] else: post.score += 1 else: effect = -1 post.down_votes += 1 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: - post.score -= 2 + post.score -= current_app.config['SPICY_UNDER_60'] else: post.score -= 1 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, moderating_communities=moderating_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)]) response.headers.set('Vary', 'Accept, Cookie, Accept-Language') 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, 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, - 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)]) @@ -706,6 +711,8 @@ def post_edit(post_id: int): return redirect(url_for('post.post_edit_link_post', post_id=post_id)) elif post.type == POST_TYPE_IMAGE: return redirect(url_for('post.post_edit_image_post', post_id=post_id)) + elif post.type == POST_TYPE_VIDEO: + return redirect(url_for('post.post_edit_video_post', post_id=post_id)) else: abort(404) @@ -931,6 +938,87 @@ def post_edit_link_post(post_id: int): abort(401) +@bp.route('/post//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): page_json = { 'type': 'Page', @@ -969,7 +1057,7 @@ def federate_post_update(post): ], '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'}] elif post.image_id: 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 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()), - 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)]) else: 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_reply = PostReply.query.get_or_404(comment_id) 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(): 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) diff --git a/app/static/structure.css b/app/static/structure.css index 0971865c..c70b721a 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -707,6 +707,10 @@ fieldset legend { border-radius: 2px; top: 0; } +.post_list .post_teaser .thumbnail .fe-video { + left: 121px; + top: 0; +} .post_list .post_teaser .thumbnail img { height: 60px; width: 60px; @@ -932,6 +936,11 @@ fieldset legend { border-top: solid 1px #bbb; margin-right: 8px; } +.comments > .comment .comment_body hr { + margin-left: 15px; + margin-right: 15px; + opacity: 0.1; +} .comments > .comment:first-child { border-top: none; padding-top: 0; @@ -1379,4 +1388,9 @@ h1 .warning_badge { max-width: 100%; } +.responsive-video { + max-width: 100%; + max-height: 90vh; +} + /*# sourceMappingURL=structure.css.map */ diff --git a/app/static/structure.scss b/app/static/structure.scss index a51099c8..a4296260 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -308,6 +308,11 @@ html { top: 0; } + .fe-video { + left: 121px; + top: 0; + } + img { height: 60px; width: 60px; @@ -552,6 +557,14 @@ html { border-top: solid 1px $grey; margin-right: 8px; + .comment_body { + hr { + margin-left: 15px; + margin-right: 15px; + opacity: 0.1; + } + } + &:first-child { border-top: none; padding-top: 0; @@ -1049,4 +1062,9 @@ h1 .warning_badge { line-height: initial; max-width: 100%; } +} + +.responsive-video { + max-width: 100%; + max-height: 90vh; } \ No newline at end of file diff --git a/app/templates/about.html b/app/templates/about.html index e0595232..a7a7c018 100644 --- a/app/templates/about.html +++ b/app/templates/about.html @@ -8,16 +8,18 @@

{{ _('About %(site_name)s', site_name=g.site.name) }}

- -

{{g.site.name}} is a pyfedi instance created on {{instance.created_at}}. It is home to {{user_amount}} users, {{community_amount}} communities who discussed {{domains_amount}} domains. This instance is administerred and staffed by $PLACEHOLDER_ADMINS and $PLACEHOLDER_STAFF.

+

{{g.site.name}} is a pyfedi instance created on {{instance.created_at.strftime('%d-%m-%Y')}}. It is home to {{user_amount}} users (of which {{mau}} active in the last month). In the {{community_amount}} communities we discussed {{domains_amount}} domains and made {{posts_amount}} posts.

+

Team

+

This instance is administerred by {% for admin in admins %}{{ admin.user_name }}{{ ", " if not loop.last }}{% endfor %}.

+

It is moderated by {% for s in staff %}{{ s.user_name }}{{ ", " if not loop.last }}{% endfor %}.

Contact

-

Placeholder Admin email

+

{{g.site.contact_email | safe }}

About Us

-

{{g.site.description | safe}}

-

{{g.site.sidebar}}

+

{{g.site.description | safe }}

+

{{g.site.sidebar | safe }}

{% if g.site.legal_information %}

Legal Information

-

{{g.site.legal_information}}

+

{{g.site.legal_information | safe }}

Our Privacy Policy

{% endif %}
diff --git a/app/templates/admin/site.html b/app/templates/admin/site.html index 31bfa7be..57496d52 100644 --- a/app/templates/admin/site.html +++ b/app/templates/admin/site.html @@ -20,6 +20,7 @@

HTML is allowed in this field.

{{ render_field(form.legal_information) }}

HTML is allowed in this field.

+ {{ render_field(form.contact_email) }} {{ render_field(form.submit) }}
diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index b37e5712..cb6c2a91 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -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 %} {% block app_content %} diff --git a/app/templates/base.html b/app/templates/base.html index df9183fe..37a58779 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -270,7 +270,7 @@ {% endif %} {% if theme() and file_exists('app/templates/themes/' + theme() + '/scripts.js') %} - {% endif %} {% block end_scripts %} diff --git a/app/templates/community/_community_nav.html b/app/templates/community/_community_nav.html index 860913b4..a49a9010 100644 --- a/app/templates/community/_community_nav.html +++ b/app/templates/community/_community_nav.html @@ -1,7 +1,9 @@ {% if community %} -
- {{ _('Create post') }} -
+ {% 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]) %} +
+ {{ _('Create post') }} +
+ {% endif %} {% endif %} -{% endif %} \ No newline at end of file +{% endif %} diff --git a/app/templates/community/add_discussion_post.html b/app/templates/community/add_discussion_post.html index 2a352622..c9e3a4a0 100644 --- a/app/templates/community/add_discussion_post.html +++ b/app/templates/community/add_discussion_post.html @@ -19,6 +19,7 @@ {{ _('Discussion') }} {{ _('Link') }} {{ _('Image') }} + {{ _('Video') }} diff --git a/app/templates/community/add_image_post.html b/app/templates/community/add_image_post.html index 8ea4da26..24c5701f 100644 --- a/app/templates/community/add_image_post.html +++ b/app/templates/community/add_image_post.html @@ -19,6 +19,7 @@ {{ _('Discussion') }} {{ _('Link') }} {{ _('Image') }} + {{ _('Video') }} diff --git a/app/templates/community/add_link_post.html b/app/templates/community/add_link_post.html index e59c82df..02a9990c 100644 --- a/app/templates/community/add_link_post.html +++ b/app/templates/community/add_link_post.html @@ -19,6 +19,7 @@ {{ _('Discussion') }} {{ _('Link') }} {{ _('Image') }} + {{ _('Video') }} diff --git a/app/templates/community/add_video_post.html b/app/templates/community/add_video_post.html new file mode 100644 index 00000000..31dca5d3 --- /dev/null +++ b/app/templates/community/add_video_post.html @@ -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 %} +
+
+

{{ _('Create post') }}

+
+ {{ form.csrf_token() }} +
+ + +
+ {{ render_field(form.communities) }} + + {{ render_field(form.video_title) }} + {{ render_field(form.video_url) }} +

{{ _('Provide a URL ending with .mp4 or .webm.') }}

+ {{ render_field(form.video_body) }} + {% if not low_bandwidth %} + {% if markdown_editor %} + + {% else %} + + {% endif %} + {% endif %} + +
+
+ {{ render_field(form.notify_author) }} +
+
+ {{ render_field(form.sticky) }} +
+
+ {{ render_field(form.nsfw) }} +
+
+ {{ render_field(form.nsfl) }} +
+
+ +
+
+ + {{ render_field(form.submit) }} +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/community/community.html b/app/templates/community/community.html index 712758bf..b2cc9852 100644 --- a/app/templates/community/community.html +++ b/app/templates/community/community.html @@ -104,9 +104,11 @@
- + {% 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]) %} + + {% endif %}
{% if current_user.is_authenticated and community_membership(current_user, community) in [SUBSCRIPTION_MEMBER, SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER] %} {{ _('Leave') }} diff --git a/app/templates/post/_post_full.html b/app/templates/post/_post_full.html index b0d1c618..1304a4e5 100644 --- a/app/templates/post/_post_full.html +++ b/app/templates/post/_post_full.html @@ -83,6 +83,40 @@

{% if post.url.endswith('.mp3') %}

+ {% elif post.url.endswith('.mp4') or post.url.endswith('.webm') %} +

+

+ {% elif post.url.startswith('https://streamable.com') %} +
+ {% elif post.url.startswith('https://www.redgifs.com/watch/') %} +
+ {% endif %} + {% if 'youtube.com' in post.url %} +

{{ _('Watch on piped.video') }}

+
+ {% endif %} + {% elif post.type == POST_TYPE_VIDEO %} +

{{ post.url|shorten_url }} +

+ {% if post.url.endswith('.mp4') or post.url.endswith('.webm') %} +

+

+ {% elif post.url.startswith('https://streamable.com') %} +
+ {% elif post.url.startswith('https://www.redgifs.com/watch/') %} +
{% endif %} {% if 'youtube.com' in post.url %}

{{ _('Watch on piped.video') }}

diff --git a/app/templates/post/_post_teaser.html b/app/templates/post/_post_teaser.html index 633053a3..36c201ff 100644 --- a/app/templates/post/_post_teaser.html +++ b/app/templates/post/_post_teaser.html @@ -16,15 +16,18 @@ {% if post.image_id %} {% 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 %} @@ -47,8 +50,8 @@ {% endif %}

{% if post.sticky %}{% endif %} {% if post.type == POST_TYPE_IMAGE %}{% endif %} - {% if post.type == POST_TYPE_LINK and post.domain_id %} - {% if post.url and 'youtube.com' in post.url %} + {% if (post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO) and post.domain_id %} + {% if post.url and (post.type == POST_TYPE_VIDEO or 'youtube.com' in post.url) %} {% elif post.url.endswith('.mp3') %} diff --git a/app/templates/post/_post_teaser_masonry.html b/app/templates/post/_post_teaser_masonry.html index b84f6ded..7dbedb91 100644 --- a/app/templates/post/_post_teaser_masonry.html +++ b/app/templates/post/_post_teaser_masonry.html @@ -11,7 +11,7 @@ {% set thumbnail = post.image.view_url() %} {% endif %}
- {% if post.type == POST_TYPE_LINK %} + {% if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO %} {% if post.image.medium_url() %} {{ post.image.alt_text if post.image.alt_text else '' }}{{ _('Join') }} {% endif %}
- + {% 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]) %} + + {% endif %}