diff --git a/README.md b/README.md index 465b8dc0..68eb582c 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,4 @@ To build a federated discussion and link aggregation platform, similar to Reddit - [Screencast: overview of the PieFed codebase](https://join.piefed.social/2024/01/22/an-introduction-to-the-piefed-codebase/) - [Database / entity relationship diagram](https://join.piefed.social/wp-content/uploads/2024/02/PieFed-entity-relationships.png) - see [INSTALL.md](INSTALL.md) +- see docs/project_management/* for a project roadmap, contributing guide and much more. diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 7cad58e7..8a55f262 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -20,7 +20,8 @@ from app.activitypub.util import public_key, users_total, active_half_year, acti lemmy_site_data, instance_weight, is_activitypub_request, downvote_post_reply, downvote_post, upvote_post_reply, \ upvote_post, delete_post_or_comment, community_members, \ user_removed_from_remote_server, create_post, create_post_reply, update_post_reply_from_activity, \ - update_post_from_activity, undo_vote, undo_downvote, post_to_page, get_redis_connection + update_post_from_activity, undo_vote, undo_downvote, post_to_page, get_redis_connection, find_reported_object, \ + process_report from app.utils import gibberish, get_setting, is_image_url, allowlist_html, render_template, \ domain_from_url, markdown_to_html, community_membership, ap_datetime, ip_address, can_downvote, \ can_upvote, can_create_post, awaken_dormant_instance, shorten_string, can_create_post_reply, sha256_digest, \ @@ -373,6 +374,7 @@ def shared_inbox(): redis_client.set(request_json['id'], 1, ex=90) # Save the activity ID into redis, to avoid duplicate activities that Lemmy sometimes sends activity_log.activity_id = request_json['id'] + g.site = Site.query.get(1) # g.site is not initialized by @app.before_request when request.path == '/inbox' if g.site.log_activitypub_json: activity_log.activity_json = json.dumps(request_json) activity_log.result = 'processing' @@ -990,7 +992,19 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): else: activity_log.exception_message = 'Cannot downvote this' activity_log.result = 'ignored' - # Flush the caches of any major object that was created. To be sure. + elif request_json['type'] == 'Flag': # Reported content + activity_log.activity_type = 'Report' + user_ap_id = request_json['actor'] + user = find_actor_or_create(user_ap_id) + target_ap_id = request_json['object'] + reported = find_reported_object(target_ap_id) + if user and reported: + process_report(user, reported, request_json, activity_log) + activity_log.result = 'success' + else: + activity_log.exception_message = 'Report ignored due to missing user or content' + + # Flush the caches of any major object that was created. To be sure. if 'user' in vars() and user is not None: user.flush_cache() if user.instance_id and user.instance_id != 1: @@ -1053,6 +1067,9 @@ def process_delete_request(request_json, activitypublog_id, ip_address): def announce_activity_to_followers(community, creator, activity): + # remove context from what will be inner object + del activity["@context"] + announce_activity = { '@context': default_context(), "actor": community.profile_id(), diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 72820ba0..fd86d652 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -12,7 +12,7 @@ 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 + PostVote, PostReplyVote, ActivityPubLog, Notification, Site, CommunityMember, InstanceRole, Report, Conversation import time import base64 import requests @@ -223,6 +223,8 @@ def banned_user_agents(): @cache.memoize(150) def instance_blocked(host: str) -> bool: # see also utils.instance_banned() + if host is None or host == '': + return True host = host.lower() if 'https://' in host or 'http://' in host: host = urlparse(host).hostname @@ -232,6 +234,8 @@ def instance_blocked(host: str) -> bool: # see also utils.instance_banned @cache.memoize(150) def instance_allowed(host: str) -> bool: + if host is None or host == '': + return True host = host.lower() if 'https://' in host or 'http://' in host: host = urlparse(host).hostname @@ -561,11 +565,11 @@ def actor_json_to_model(activity_json, address, server): current_app.logger.error(f'KeyError for {address}@{server} while parsing ' + str(activity_json)) return None - if 'icon' in activity_json: + if 'icon' in activity_json and activity_json['icon'] is not None and 'url' in activity_json['icon']: avatar = File(source_url=activity_json['icon']['url']) user.avatar = avatar db.session.add(avatar) - if 'image' in activity_json: + if 'image' in activity_json and activity_json['image'] is not None and 'url' in activity_json['image']: cover = File(source_url=activity_json['image']['url']) user.cover = cover db.session.add(cover) @@ -625,11 +629,11 @@ def actor_json_to_model(activity_json, address, server): elif 'content' in activity_json: community.description_html = allowlist_html(activity_json['content']) community.description = '' - if 'icon' in activity_json: + if 'icon' in activity_json and activity_json['icon'] is not None and 'url' in activity_json['icon']: icon = File(source_url=activity_json['icon']['url']) community.icon = icon db.session.add(icon) - if 'image' in activity_json: + if 'image' in activity_json and activity_json['image'] is not None and 'url' in activity_json['image']: image = File(source_url=activity_json['image']['url']) community.image = image db.session.add(image) @@ -702,12 +706,12 @@ def post_json_to_model(activity_log, post_json, user, community) -> Post: if not domain.banned: domain.post_count += 1 post.domain = domain - if 'image' in post_json and post.image is None: - image = File(source_url=post_json['image']['url']) - db.session.add(image) - post.image = image if post is not None: + if 'image' in post_json and post.image is None: + image = File(source_url=post_json['image']['url']) + db.session.add(image) + post.image = image db.session.add(post) community.post_count += 1 activity_log.result = 'success' @@ -793,18 +797,19 @@ def make_image_sizes_async(file_id, thumbnail_width, medium_width, directory): db.session.commit() # Alert regarding fascist meme content - 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() + 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 @@ -895,6 +900,21 @@ def find_liked_object(ap_id) -> Union[Post, PostReply, None]: return None +def find_reported_object(ap_id) -> Union[User, Post, PostReply, None]: + post = Post.get_by_ap_id(ap_id) + if post: + return post + else: + post_reply = PostReply.get_by_ap_id(ap_id) + if post_reply: + return post_reply + else: + user = find_actor_or_create(ap_id, create_if_not_found=False) + if user: + return user + return None + + def find_instance_id(server): server = server.strip() instance = Instance.query.filter_by(domain=server).first() @@ -1017,7 +1037,13 @@ def downvote_post(post, user): if not existing_vote: effect = -1.0 post.down_votes += 1 - post.score -= 1.0 + # Make 'hot' sort more spicy by amplifying the effect of early downvotes + if post.up_votes + post.down_votes <= 30: + post.score -= 5.0 + elif post.up_votes + post.down_votes <= 60: + post.score -= 2.0 + else: + post.score -= 1.0 vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id, effect=effect) post.author.reputation += effect @@ -1119,10 +1145,18 @@ def upvote_post(post, user): user.last_seen = utcnow() user.recalculate_attitude() effect = instance_weight(user.ap_domain) + # Make 'hot' sort more spicy by amplifying the effect of early upvotes + spicy_effect = effect + if post.up_votes + post.down_votes <= 10: + spicy_effect = effect * 10 + elif post.up_votes + post.down_votes <= 30: + spicy_effect = effect * 5 + elif post.up_votes + post.down_votes <= 60: + spicy_effect = effect * 2 existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=post.id).first() if not existing_vote: post.up_votes += 1 - post.score += effect + post.score += spicy_effect vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id, effect=effect) if post.community.low_quality and effect > 0: @@ -1533,7 +1567,7 @@ def update_post_from_activity(post: Post, request_json: dict): old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all() post.cross_posts.clear() for ocp in old_cross_posts: - if ocp.cross_posts is not None: + if ocp.cross_posts is not None and post.id in ocp.cross_posts: ocp.cross_posts.remove(post.id) if post is not None: @@ -1623,6 +1657,77 @@ def undo_vote(activity_log, comment, post, target_ap_id, user): return post +def process_report(user, reported, request_json, activity_log): + if len(request_json['summary']) < 15: + reasons = request_json['summary'] + description = '' + else: + reasons = request_json['summary'][:15] + description = request_json['summary'][15:] + if isinstance(reported, User): + if reported.reports == -1: + return + type = 0 + report = Report(reasons=reasons, description=description, + type=type, reporter_id=user.id, suspect_user_id=reported.id, source_instance_id=user.instance_id) + db.session.add(report) + + # Notify site admin + already_notified = set() + for admin in Site.admins(): + if admin.id not in already_notified: + notify = Notification(title='Reported user', url='/admin/reports', user_id=admin.id, + author_id=user.id) + db.session.add(notify) + admin.unread_notifications += 1 + reported.reports += 1 + db.session.commit() + elif isinstance(reported, Post): + if reported.reports == -1: + return + type = 1 + report = Report(reasons=reasons, description=description, type=type, reporter_id=user.id, + suspect_user_id=reported.author.id, suspect_post_id=reported.id, + suspect_community_id=reported.community.id, in_community_id=reported.community.id, + source_instance_id=user.instance_id) + db.session.add(report) + + already_notified = set() + for mod in reported.community.moderators(): + notification = Notification(user_id=mod.user_id, title=_('A post has been reported'), + url=f"https://{current_app.config['SERVER_NAME']}/post/{reported.id}", + author_id=user.id) + db.session.add(notification) + already_notified.add(mod.user_id) + reported.reports += 1 + db.session.commit() + elif isinstance(reported, PostReply): + if reported.reports == -1: + return + type = 2 + post = Post.query.get(reported.post_id) + report = Report(reasons=reasons, description=description, type=type, reporter_id=user.id, suspect_post_id=post.id, + suspect_community_id=post.community.id, + suspect_user_id=reported.author.id, suspect_post_reply_id=reported.id, + in_community_id=post.community.id, + source_instance_id=user.instance_id) + db.session.add(report) + # Notify moderators + already_notified = set() + for mod in post.community.moderators(): + notification = Notification(user_id=mod.user_id, title=_('A comment has been reported'), + url=f"https://{current_app.config['SERVER_NAME']}/comment/{reported.id}", + author_id=user.id) + db.session.add(notification) + already_notified.add(mod.user_id) + reported.reports += 1 + db.session.commit() + elif isinstance(reported, Community): + ... + elif isinstance(reported, Conversation): + ... + + def get_redis_connection() -> redis.Redis: connection_string = current_app.config['CACHE_REDIS_URL'] if connection_string.startswith('unix://'): diff --git a/app/admin/routes.py b/app/admin/routes.py index 8075676e..eba8418e 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -4,6 +4,7 @@ from time import sleep from flask import request, flash, json, url_for, current_app, redirect, g from flask_login import login_required, current_user from flask_babel import _ +from slugify import slugify from sqlalchemy import text, desc, or_ from app import db, celery, cache @@ -13,13 +14,14 @@ from app.activitypub.util import default_context, instance_allowed, instance_blo from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm, EditCommunityForm, EditUserForm, \ EditTopicForm, SendNewsletterForm, AddUserForm from app.admin.util import unsubscribe_from_everything_then_delete, unsubscribe_from_community, send_newsletter, \ - topic_tree, topics_for_form + topics_for_form from app.community.util import save_icon_file, save_banner_file from app.constants import REPORT_STATE_NEW, REPORT_STATE_ESCALATED from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, \ User, Instance, File, Report, Topic, UserRegistration, Role, Post from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html, \ - moderating_communities, joined_communities, finalize_user_setup, theme_list, blocked_phrases, blocked_referrers + moderating_communities, joined_communities, finalize_user_setup, theme_list, blocked_phrases, blocked_referrers, \ + topic_tree from app.admin import bp @@ -357,7 +359,7 @@ def admin_topic_add(): form = EditTopicForm() form.parent_id.choices = topics_for_form(0) if form.validate_on_submit(): - topic = Topic(name=form.name.data, machine_name=form.machine_name.data, num_communities=0) + topic = Topic(name=form.name.data, machine_name=slugify(form.machine_name.data.strip()), num_communities=0) if form.parent_id.data: topic.parent_id = form.parent_id.data else: @@ -456,6 +458,7 @@ def admin_users_trash(): page = request.args.get('page', 1, type=int) search = request.args.get('search', '') local_remote = request.args.get('local_remote', '') + type = request.args.get('type', 'bad_rep') users = User.query.filter_by(deleted=False) if local_remote == 'local': @@ -464,14 +467,19 @@ def admin_users_trash(): users = users.filter(User.ap_id != None) if search: users = users.filter(User.email.ilike(f"%{search}%")) - users = users.filter(User.reputation < -10) - users = users.order_by(User.reputation).paginate(page=page, per_page=1000, error_out=False) + + if type == '' or type == 'bad_rep': + users = users.filter(User.reputation < -10) + users = users.order_by(User.reputation).paginate(page=page, per_page=1000, error_out=False) + elif type == 'bad_attitude': + users = users.filter(User.attitude < 0.0) + users = users.order_by(-User.attitude).paginate(page=page, per_page=1000, error_out=False) next_url = url_for('admin.admin_users_trash', page=users.next_num) if users.has_next else None prev_url = url_for('admin.admin_users_trash', page=users.prev_num) if users.has_prev and page != 1 else None return render_template('admin/users.html', title=_('Problematic users'), next_url=next_url, prev_url=prev_url, users=users, - local_remote=local_remote, search=search, + local_remote=local_remote, search=search, type=type, moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()), site=g.site diff --git a/app/admin/util.py b/app/admin/util.py index b1669ac1..70462b23 100644 --- a/app/admin/util.py +++ b/app/admin/util.py @@ -9,7 +9,7 @@ from app import db, cache, celery from app.activitypub.signature import post_request from app.activitypub.util import default_context from app.models import User, Community, Instance, Site, ActivityPubLog, CommunityMember, Topic -from app.utils import gibberish +from app.utils import gibberish, topic_tree def unsubscribe_from_everything_then_delete(user_id): @@ -106,21 +106,6 @@ def send_newsletter(form): break -# replies to a post, in a tree, sorted by a variety of methods -def topic_tree() -> List: - topics = Topic.query.order_by(Topic.name) - - topics_dict = {topic.id: {'topic': topic, 'children': []} for topic in topics.all()} - - for topic in topics: - if topic.parent_id is not None: - parent_comment = topics_dict.get(topic.parent_id) - if parent_comment: - parent_comment['children'].append(topics_dict[topic.id]) - - return [topic for topic in topics_dict.values() if topic['topic'].parent_id is None] - - def topics_for_form(current_topic: int) -> List[Tuple[int, str]]: result = [(0, _('None'))] topics = topic_tree() diff --git a/app/chat/routes.py b/app/chat/routes.py index f5185b7a..f2408d48 100644 --- a/app/chat/routes.py +++ b/app/chat/routes.py @@ -105,7 +105,7 @@ def empty(): @login_required def chat_options(conversation_id): conversation = Conversation.query.get_or_404(conversation_id) - if current_user.is_admin() or current_user.is_member(current_user): + if current_user.is_admin() or conversation.is_member(current_user): return render_template('chat/chat_options.html', conversation=conversation, moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()), diff --git a/app/community/forms.py b/app/community/forms.py index e843aa41..480ec26b 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -85,21 +85,23 @@ class BanUserCommunityForm(FlaskForm): submit = SubmitField(_l('Ban')) -class CreatePostForm(FlaskForm): +class CreateDiscussionForm(FlaskForm): communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) - post_type = HiddenField() # https://getbootstrap.com/docs/4.6/components/navs/#tabs - discussion_title = StringField(_l('Title'), validators=[Optional(), Length(min=3, max=255)]) - discussion_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'placeholder': 'Text (optional)'}) - link_title = StringField(_l('Title'), validators=[Optional(), Length(min=3, max=255)]) - link_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], - render_kw={'placeholder': 'Text (optional)'}) - link_url = StringField(_l('URL'), validators=[Optional(), Regexp(r'^https?://', message='Submitted links need to start with "http://"" or "https://"')], render_kw={'placeholder': 'https://...'}) - image_title = StringField(_l('Title'), validators=[Optional(), Length(min=3, max=255)]) - image_alt_text = StringField(_l('Alt text'), validators=[Optional(), Length(min=3, max=255)]) - image_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], - render_kw={'placeholder': 'Text (optional)'}) - image_file = FileField(_('Image')) - # flair = SelectField(_l('Flair'), coerce=int) + discussion_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) + discussion_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5}) + sticky = BooleanField(_l('Sticky')) + nsfw = BooleanField(_l('NSFW')) + nsfl = BooleanField(_l('Gore/gross')) + notify_author = BooleanField(_l('Notify about replies')) + submit = SubmitField(_l('Save')) + + +class CreateLinkForm(FlaskForm): + communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) + link_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) + link_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5}) + link_url = StringField(_l('URL'), validators=[DataRequired(), Regexp(r'^https?://', message='Submitted links need to start with "http://"" or "https://"')], + render_kw={'placeholder': 'https://...'}) sticky = BooleanField(_l('Sticky')) nsfw = BooleanField(_l('NSFW')) nsfl = BooleanField(_l('Gore/gross')) @@ -107,53 +109,45 @@ class CreatePostForm(FlaskForm): submit = SubmitField(_l('Save')) def validate(self, extra_validators=None) -> bool: - if not super().validate(): + domain = domain_from_url(self.link_url.data, create=False) + if domain and domain.banned: + self.link_url.errors.append(_("Links to %(domain)s are not allowed.", domain=domain.name)) return False - if self.post_type.data is None or self.post_type.data == '': - self.post_type.data = 'discussion' + return True - if self.post_type.data == 'discussion': - if self.discussion_title.data == '': - self.discussion_title.errors.append(_('Title is required.')) + +class CreateImageForm(FlaskForm): + communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) + image_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) + image_alt_text = StringField(_l('Alt text'), validators=[Optional(), Length(min=3, max=255)]) + image_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5}) + image_file = FileField(_('Image'), validators=[DataRequired()]) + sticky = BooleanField(_l('Sticky')) + nsfw = BooleanField(_l('NSFW')) + nsfl = BooleanField(_l('Gore/gross')) + notify_author = BooleanField(_l('Notify about replies')) + submit = SubmitField(_l('Save')) + + def validate(self, extra_validators=None) -> bool: + uploaded_file = request.files['image_file'] + if uploaded_file and uploaded_file.filename != '': + Image.MAX_IMAGE_PIXELS = 89478485 + # Do not allow fascist meme content + try: + image_text = pytesseract.image_to_string(Image.open(BytesIO(uploaded_file.read())).convert('L')) + 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' + self.image_file.errors.append( + f"This image is an invalid file type.") # deliberately misleading error message + current_user.reputation -= 1 + db.session.commit() return False - elif self.post_type.data == 'link': - if self.link_title.data == '': - self.link_title.errors.append(_('Title is required.')) - return False - if self.link_url.data == '': - self.link_url.errors.append(_('URL is required.')) - return False - domain = domain_from_url(self.link_url.data, create=False) - if domain and domain.banned: - self.link_url.errors.append(_("Links to %(domain)s are not allowed.", domain=domain.name)) - return False - elif self.post_type.data == 'image': - if self.image_title.data == '': - self.image_title.errors.append(_('Title is required.')) - return False - if self.image_file.data == '': - self.image_file.errors.append(_('File is required.')) - return False - uploaded_file = request.files['image_file'] - if uploaded_file and uploaded_file.filename != '': - Image.MAX_IMAGE_PIXELS = 89478485 - # Do not allow fascist meme content - try: - image_text = pytesseract.image_to_string(Image.open(BytesIO(uploaded_file.read())).convert('L')) - 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' - self.image_file.errors.append(f"This image is an invalid file type.") # deliberately misleading error message - current_user.reputation -= 1 - db.session.commit() - return False - if self.communities: - community = Community.query.get(self.communities.data) - if community.is_local() and g.site.allow_local_image_posts is False: - self.communities.errors.append(_('Images cannot be posted to local communities.')) - elif self.post_type.data == 'poll': - self.discussion_title.errors.append(_('Poll not implemented yet.')) - return False + if self.communities: + community = Community.query.get(self.communities.data) + if community.is_local() and g.site.allow_local_image_posts is False: + self.communities.errors.append(_('Images cannot be posted to local communities.')) return True diff --git a/app/community/routes.py b/app/community/routes.py index b32e987d..6af5df89 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -12,14 +12,15 @@ 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.chat.util import send_message -from app.community.forms import SearchRemoteCommunity, CreatePostForm, ReportCommunityForm, \ +from app.community.forms import SearchRemoteCommunity, CreateDiscussionForm, CreateImageForm, CreateLinkForm, ReportCommunityForm, \ DeleteCommunityForm, AddCommunityForm, EditCommunityForm, AddModeratorForm, BanUserCommunityForm, \ EscalateReportForm, ResolveReportForm 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 + SUBSCRIPTION_PENDING, SUBSCRIPTION_MODERATOR, REPORT_STATE_NEW, REPORT_STATE_ESCALATED, REPORT_STATE_RESOLVED, \ + REPORT_STATE_DISCARDED 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 @@ -29,7 +30,8 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_ shorten_string, gibberish, community_membership, ap_datetime, \ request_etag_matches, return_304, instance_banned, can_create_post, can_upvote, can_downvote, user_filters_posts, \ joined_communities, moderating_communities, blocked_domains, mimetype_from_url, blocked_instances, \ - community_moderators, communities_banned_from, show_ban_message + community_moderators, communities_banned_from, show_ban_message, recently_upvoted_posts, recently_downvoted_posts, \ + blocked_users from feedgen.feed import FeedGenerator from datetime import timezone, timedelta @@ -180,10 +182,15 @@ def show_community(community: Community): if instance_ids: posts = posts.filter(or_(Post.instance_id.not_in(instance_ids), Post.instance_id == None)) + # filter blocked users + blocked_accounts = blocked_users(current_user.id) + if blocked_accounts: + posts = posts.filter(Post.user_id.not_in(blocked_accounts)) + if sort == '' or sort == 'hot': posts = posts.order_by(desc(Post.sticky)).order_by(desc(Post.ranking)).order_by(desc(Post.posted_at)) elif sort == 'top': - posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=7)).order_by(desc(Post.sticky)).order_by(desc(Post.score)) + posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=7)).order_by(desc(Post.sticky)).order_by(desc(Post.up_votes - Post.down_votes)) elif sort == 'new': posts = posts.order_by(desc(Post.posted_at)) elif sort == 'active': @@ -240,12 +247,21 @@ def show_community(community: Community): prev_url = url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name, page=posts.prev_num, sort=sort, layout=post_layout) if posts.has_prev and page != 1 else None + # Voting history + if current_user.is_authenticated: + recently_upvoted = recently_upvoted_posts(current_user.id) + recently_downvoted = recently_downvoted_posts(current_user.id) + else: + recently_upvoted = [] + recently_downvoted = [] + return render_template('community/community.html', community=community, title=community.title, breadcrumbs=breadcrumbs, 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, 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, + recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted, rss_feed=f"https://{current_app.config['SERVER_NAME']}/community/{community.link()}/feed", rss_feed_name=f"{community.title} on PieFed", content_filters=content_filters, moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()), sort=sort, @@ -435,7 +451,7 @@ def join_then_add(actor): db.session.commit() flash('You joined ' + community.title) if not community.user_is_banned(current_user): - return redirect(url_for('community.add_post', actor=community.link())) + return redirect(url_for('community.add_discussion_post', actor=community.link())) else: abort(401) @@ -443,11 +459,13 @@ def join_then_add(actor): @bp.route('//submit', methods=['GET', 'POST']) @login_required @validation_required -def add_post(actor): +def add_discussion_post(actor): if current_user.banned: return show_ban_message() community = actor_to_community(actor) - form = CreatePostForm() + + form = CreateDiscussionForm() + if g.site.enable_nsfl is False: form.nsfl.render_kw = {'disabled': True} if community.nsfw: @@ -469,7 +487,63 @@ def add_post(actor): 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) + save_post(form, post, 'discussion') + community.post_count += 1 + community.last_active = g.site.last_active = utcnow() + db.session.commit() + post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}" + db.session.commit() + + notify_about_post(post) + + if not community.local_only: + federate_post(community, post) + + return redirect(f"/c/{community.link()}") + else: + form.communities.data = community.id + form.notify_author.data = True + + return render_template('community/add_discussion_post.html', title=_('Add post to community'), form=form, community=community, + markdown_editor=current_user.markdown_editor, low_bandwidth=False, actor=actor, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.id), + inoculation=inoculation[randint(0, len(inoculation) - 1)] + ) + + +@bp.route('//submit_image', methods=['GET', 'POST']) +@login_required +@validation_required +def add_image_post(actor): + if current_user.banned: + return show_ban_message() + community = actor_to_community(actor) + + form = CreateImageForm() + + if g.site.enable_nsfl is False: + form.nsfl.render_kw = {'disabled': True} + if community.nsfw: + form.nsfw.data = True + form.nsfw.render_kw = {'disabled': True} + if community.nsfl: + form.nsfl.data = True + form.nsfw.render_kw = {'disabled': True} + if not(community.is_moderator() or community.is_owner() or current_user.is_admin()): + form.sticky.render_kw = {'disabled': True} + + form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] + + if not can_create_post(current_user, community): + abort(401) + + if form.validate_on_submit(): + community = Community.query.get_or_404(form.communities.data) + if not can_create_post(current_user, community): + abort(401) + post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) + save_post(form, post, 'image') community.post_count += 1 community.last_active = g.site.last_active = utcnow() db.session.commit() @@ -496,105 +570,183 @@ def add_post(actor): notify_about_post(post) if not community.local_only: - page = { - 'type': 'Page', - 'id': post.ap_id, - 'attributedTo': current_user.ap_profile_id, - 'to': [ - community.ap_profile_id, - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'name': post.title, - 'cc': [], - 'content': post.body_html if post.body_html else '', - 'mediaType': 'text/html', - 'source': { - 'content': post.body if post.body else '', - 'mediaType': 'text/markdown' - }, - 'attachment': [], - 'commentsEnabled': post.comments_enabled, - 'sensitive': post.nsfw, - 'nsfl': post.nsfl, - 'stickied': post.sticky, - 'published': ap_datetime(utcnow()), - 'audience': community.ap_profile_id - } - create = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", - "actor": current_user.ap_profile_id, - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "cc": [ - community.ap_profile_id - ], - "type": "Create", - "audience": community.ap_profile_id, - "object": page, - '@context': default_context() - } - if post.type == POST_TYPE_LINK: - page['attachment'] = [{'href': post.url, 'type': 'Link'}] - elif post.image_id: - if post.image.file_path: - image_url = post.image.file_path.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/") - elif post.image.thumbnail_path: - image_url = post.image.thumbnail_path.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/") - else: - image_url = post.image.source_url - # NB image is a dict while attachment is a list of dicts (usually just one dict in the list) - page['image'] = {'type': 'Image', 'url': image_url} - if post.type == POST_TYPE_IMAGE: - page['attachment'] = [{'type': 'Link', 'href': post.image.source_url}] # source_url is always a https link, no need for .replace() as done above - if not community.is_local(): # this is a remote community - send the post to the instance that hosts it - success = post_request(community.ap_inbox_url, create, current_user.private_key, - current_user.ap_profile_id + '#main-key') - if success: - flash(_('Your post to %(name)s has been made.', name=community.title)) - else: - flash('There was a problem making your post to ' + community.title) - else: # local community - send (announce) post out to followers - announce = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", - "type": 'Announce', - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "actor": community.ap_profile_id, - "cc": [ - community.ap_followers_url - ], - '@context': default_context(), - 'object': create - } - - sent_to = 0 - for instance in community.following_instances(): - if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): - send_to_remote_instance(instance.id, community.id, announce) - sent_to += 1 - if sent_to: - flash(_('Your post to %(name)s has been made.', name=community.title)) - else: - flash(_('Your post to %(name)s has been made.', name=community.title)) + federate_post(community, post) return redirect(f"/c/{community.link()}") else: - # when request.form has some data in it, it means form validation failed. Set the post_type so the correct tab is shown. See setupPostTypeTabs() in scripts.js - if request.form.get('post_type', None): - form.post_type.data = request.form.get('post_type') form.communities.data = community.id form.notify_author.data = True - return render_template('community/add_post.html', title=_('Add post to community'), form=form, community=community, - markdown_editor=current_user.markdown_editor, low_bandwidth=False, + return render_template('community/add_image_post.html', title=_('Add post to community'), form=form, community=community, + markdown_editor=current_user.markdown_editor, low_bandwidth=False, actor=actor, moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.id), inoculation=inoculation[randint(0, len(inoculation) - 1)] ) +@bp.route('//submit_link', methods=['GET', 'POST']) +@login_required +@validation_required +def add_link_post(actor): + if current_user.banned: + return show_ban_message() + community = actor_to_community(actor) + + form = CreateLinkForm() + + if g.site.enable_nsfl is False: + form.nsfl.render_kw = {'disabled': True} + if community.nsfw: + form.nsfw.data = True + form.nsfw.render_kw = {'disabled': True} + if community.nsfl: + form.nsfl.data = True + form.nsfw.render_kw = {'disabled': True} + if not(community.is_moderator() or community.is_owner() or current_user.is_admin()): + form.sticky.render_kw = {'disabled': True} + + form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] + + if not can_create_post(current_user, community): + abort(401) + + if form.validate_on_submit(): + community = Community.query.get_or_404(form.communities.data) + if not can_create_post(current_user, community): + abort(401) + post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) + save_post(form, post, 'link') + community.post_count += 1 + community.last_active = g.site.last_active = utcnow() + db.session.commit() + post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}" + db.session.commit() + if post.image_id and post.image.file_path is None: + make_image_sizes(post.image_id, 150, 512, 'posts') # the 512 sized image is for masonry view + + # Update list of cross posts + if post.url: + other_posts = Post.query.filter(Post.id != post.id, Post.url == post.url, + Post.posted_at > post.posted_at - timedelta(days=6)).all() + for op in other_posts: + if op.cross_posts is None: + op.cross_posts = [post.id] + else: + op.cross_posts.append(post.id) + if post.cross_posts is None: + post.cross_posts = [op.id] + else: + post.cross_posts.append(op.id) + db.session.commit() + + notify_about_post(post) + + if not community.local_only: + federate_post(community, post) + + return redirect(f"/c/{community.link()}") + else: + form.communities.data = community.id + form.notify_author.data = True + + return render_template('community/add_link_post.html', title=_('Add post to community'), form=form, community=community, + markdown_editor=current_user.markdown_editor, low_bandwidth=False, actor=actor, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.id), + inoculation=inoculation[randint(0, len(inoculation) - 1)] + ) + + +def federate_post(community, post): + page = { + 'type': 'Page', + 'id': post.ap_id, + 'attributedTo': current_user.ap_profile_id, + 'to': [ + community.ap_profile_id, + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'name': post.title, + 'cc': [], + 'content': post.body_html if post.body_html else '', + 'mediaType': 'text/html', + 'source': { + 'content': post.body if post.body else '', + 'mediaType': 'text/markdown' + }, + 'attachment': [], + 'commentsEnabled': post.comments_enabled, + 'sensitive': post.nsfw, + 'nsfl': post.nsfl, + 'stickied': post.sticky, + 'published': ap_datetime(utcnow()), + 'audience': community.ap_profile_id + } + create = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", + "actor": current_user.ap_profile_id, + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + community.ap_profile_id + ], + "type": "Create", + "audience": community.ap_profile_id, + "object": page, + '@context': default_context() + } + if post.type == POST_TYPE_LINK: + page['attachment'] = [{'href': post.url, 'type': 'Link'}] + elif post.image_id: + if post.image.file_path: + image_url = post.image.file_path.replace('app/static/', + f"https://{current_app.config['SERVER_NAME']}/static/") + elif post.image.thumbnail_path: + image_url = post.image.thumbnail_path.replace('app/static/', + f"https://{current_app.config['SERVER_NAME']}/static/") + else: + image_url = post.image.source_url + # NB image is a dict while attachment is a list of dicts (usually just one dict in the list) + page['image'] = {'type': 'Image', 'url': image_url} + if post.type == POST_TYPE_IMAGE: + page['attachment'] = [{'type': 'Link', + 'href': post.image.source_url}] # source_url is always a https link, no need for .replace() as done above + if not community.is_local(): # this is a remote community - send the post to the instance that hosts it + success = post_request(community.ap_inbox_url, create, current_user.private_key, + current_user.ap_profile_id + '#main-key') + if success: + flash(_('Your post to %(name)s has been made.', name=community.title)) + else: + flash('There was a problem making your post to ' + community.title) + else: # local community - send (announce) post out to followers + announce = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", + "type": 'Announce', + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "actor": community.ap_profile_id, + "cc": [ + community.ap_followers_url + ], + '@context': default_context(), + 'object': create + } + + sent_to = 0 + for instance in community.following_instances(): + if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned( + instance.domain): + send_to_remote_instance(instance.id, community.id, announce) + sent_to += 1 + if sent_to: + flash(_('Your post to %(name)s has been made.', name=community.title)) + else: + flash(_('Your post to %(name)s has been made.', name=community.title)) + + @bp.route('/community//report', methods=['GET', 'POST']) @login_required def community_report(community_id: int): @@ -1051,7 +1203,19 @@ def community_moderate_report_resolve(community_id, report_id): form = ResolveReportForm() if form.validate_on_submit(): report.status = REPORT_STATE_RESOLVED + + # Reset the 'reports' counter on the comment, post or user + if report.suspect_post_reply_id: + post_reply = PostReply.query.get(report.suspect_post_reply_id) + post_reply.reports = 0 + elif report.suspect_post_id: + post = Post.query.get(report.suspect_post_id) + post.reports = 0 + elif report.suspect_user_id: + user = User.query.get(report.suspect_user_id) + user.reports = 0 db.session.commit() + # todo: remove unread notifications about this report # todo: append to mod log if form.also_resolve_others.data: @@ -1072,6 +1236,44 @@ def community_moderate_report_resolve(community_id, report_id): return render_template('community/community_moderate_report_resolve.html', form=form) +@bp.route('/community//moderate_report//ignore', methods=['GET', 'POST']) +@login_required +def community_moderate_report_ignore(community_id, report_id): + community = Community.query.get_or_404(community_id) + if community.is_moderator() or current_user.is_admin(): + report = Report.query.filter_by(in_community_id=community.id, id=report_id).first() + if report: + # Set the 'reports' counter on the comment, post or user to -1 to ignore all future reports + if report.suspect_post_reply_id: + post_reply = PostReply.query.get(report.suspect_post_reply_id) + post_reply.reports = -1 + elif report.suspect_post_id: + post = Post.query.get(report.suspect_post_id) + post.reports = -1 + elif report.suspect_user_id: + user = User.query.get(report.suspect_user_id) + user.reports = -1 + db.session.commit() + + # todo: append to mod log + + if report.suspect_post_reply_id: + db.session.execute(text('UPDATE "report" SET status = :new_status WHERE suspect_post_reply_id = :suspect_post_reply_id'), + {'new_status': REPORT_STATE_DISCARDED, + 'suspect_post_reply_id': report.suspect_post_reply_id}) + # todo: remove unread notifications about these reports + elif report.suspect_post_id: + db.session.execute(text('UPDATE "report" SET status = :new_status WHERE suspect_post_id = :suspect_post_id'), + {'new_status': REPORT_STATE_DISCARDED, + 'suspect_post_id': report.suspect_post_id}) + # todo: remove unread notifications about these reports + db.session.commit() + flash(_('Report ignored.')) + return redirect(url_for('community.community_moderate', actor=community.link())) + else: + abort(404) + + @bp.route('/lookup//') def lookup(community, domain): if domain == current_app.config['SERVER_NAME']: diff --git a/app/community/util.py b/app/community/util.py index 37875a42..c58dcad9 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -196,18 +196,18 @@ def url_to_thumbnail_file(filename) -> File: source_url=filename) -def save_post(form, post: Post): +def save_post(form, post: Post, type: str): post.indexable = current_user.indexable post.sticky = form.sticky.data post.nsfw = form.nsfw.data post.nsfl = form.nsfl.data post.notify_author = form.notify_author.data - if form.post_type.data == '' or form.post_type.data == 'discussion': + if type == '' or type == 'discussion': post.title = form.discussion_title.data post.body = form.discussion_body.data post.body_html = markdown_to_html(post.body) post.type = POST_TYPE_ARTICLE - elif form.post_type.data == 'link': + elif type == 'link': post.title = form.link_title.data post.body = form.link_body.data post.body_html = markdown_to_html(post.body) @@ -244,7 +244,7 @@ def save_post(form, post: Post): post.image = file db.session.add(file) - elif form.post_type.data == 'image': + elif type == 'image': post.title = form.image_title.data post.body = form.image_body.data post.body_html = markdown_to_html(post.body) @@ -304,7 +304,7 @@ def save_post(form, post: Post): post.image = file db.session.add(file) - elif form.post_type.data == 'poll': + elif type == 'poll': ... else: raise Exception('invalid post type') diff --git a/app/errors/handlers.py b/app/errors/handlers.py index 4a40ad9e..eaa52bc1 100644 --- a/app/errors/handlers.py +++ b/app/errors/handlers.py @@ -3,9 +3,11 @@ from app import db from app.errors import bp -@bp.app_errorhandler(404) -def not_found_error(error): - return render_template('errors/404.html'), 404 +# 404 error handler removed because a lot of 404s are just images in /static/* and it doesn't make sense to waste cpu cycles presenting a nice page. +# Also rendering a page requires populating g.site which means hitting the DB. +# @bp.app_errorhandler(404) +# def not_found_error(error): +# return render_template('errors/404.html'), 404 @bp.app_errorhandler(500) diff --git a/app/main/routes.py b/app/main/routes.py index dfaf1317..d90f55d3 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -25,7 +25,7 @@ from sqlalchemy_searchable import search from app.utils import render_template, get_setting, gibberish, request_etag_matches, return_304, blocked_domains, \ 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 + blocked_instances, communities_banned_from, topic_tree, recently_upvoted_posts, recently_downvoted_posts from app.models import Community, CommunityMember, Post, Site, User, utcnow, Domain, Topic, File, Instance, \ InstanceRole, Notification from PIL import Image @@ -114,7 +114,7 @@ def home_page(type, sort): if sort == 'hot': posts = posts.order_by(desc(Post.ranking)).order_by(desc(Post.posted_at)) elif sort == 'top': - posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=1)).order_by(desc(Post.score)) + posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=1)).order_by(desc(Post.up_votes - Post.down_votes)) elif sort == 'new': posts = posts.order_by(desc(Post.posted_at)) elif sort == 'active': @@ -140,9 +140,18 @@ def home_page(type, sort): active_communities = active_communities.filter(Community.id.not_in(banned_from)) active_communities = active_communities.order_by(desc(Community.last_active)).limit(5).all() + # Voting history + if current_user.is_authenticated: + recently_upvoted = recently_upvoted_posts(current_user.id) + recently_downvoted = recently_downvoted_posts(current_user.id) + else: + recently_upvoted = [] + recently_downvoted = [] + return render_template('index.html', posts=posts, active_communities=active_communities, show_post_community=True, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, - low_bandwidth=low_bandwidth, + low_bandwidth=low_bandwidth, recently_upvoted=recently_upvoted, + recently_downvoted=recently_downvoted, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, etag=f"{type}_{sort}_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url, #rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed", @@ -158,7 +167,7 @@ def home_page(type, sort): @bp.route('/topics', methods=['GET']) def list_topics(): verification_warning() - topics = Topic.query.filter_by(parent_id=None).order_by(Topic.name).all() + topics = topic_tree() return render_template('list_topics.html', topics=topics, title=_('Browse by topic'), low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1', @@ -294,7 +303,9 @@ def list_files(directory): @bp.route('/test') def test(): - return '' + md = "::: spoiler I'm all for ya having fun and your right to hurt yourself.\n\nI am a former racer, commuter, and professional Buyer for a chain of bike shops. I'm also disabled from the crash involving the 6th and 7th cars that have hit me in the last 170k+ miles of riding. I only barely survived what I simplify as a \"broken neck and back.\" Cars making U-turns are what will get you if you ride long enough, \n\nespecially commuting. It will look like just another person turning in front of you, you'll compensate like usual, and before your brain can even register what is really happening, what was your normal escape route will close and you're going to crash really hard. It is the only kind of crash that your intuition is useless against.\n:::" + + return markdown_to_html(md) users_to_notify = User.query.join(Notification, User.id == Notification.user_id).filter( User.ap_id == None, @@ -349,6 +360,36 @@ def test_email(): return f'Email sent to {current_user.email}.' +@bp.route('/find_voters') +def find_voters(): + user_ids = db.session.execute(text('SELECT id from "user" ORDER BY last_seen DESC LIMIT 5000')).scalars() + voters = {} + for user_id in user_ids: + recently_downvoted = recently_downvoted_posts(user_id) + if len(recently_downvoted) > 10: + voters[user_id] = str(recently_downvoted) + + return str(find_duplicate_values(voters)) + + +def find_duplicate_values(dictionary): + # Create a dictionary to store the keys for each value + value_to_keys = {} + + # Iterate through the input dictionary + for key, value in dictionary.items(): + # If the value is not already in the dictionary, add it + if value not in value_to_keys: + value_to_keys[value] = [key] + else: + # If the value is already in the dictionary, append the key to the list + value_to_keys[value].append(key) + + # Filter out the values that have only one key (i.e., unique values) + duplicates = {value: keys for value, keys in value_to_keys.items() if len(keys) > 1} + + return duplicates + def verification_warning(): if hasattr(current_user, 'verified') and current_user.verified is False: flash(_('Please click the link in your email inbox to verify your account.'), 'warning') diff --git a/app/models.py b/app/models.py index f2e610ab..30f2506a 100644 --- a/app/models.py +++ b/app/models.py @@ -208,14 +208,23 @@ class File(db.Model): def delete_from_disk(self): purge_from_cache = [] if self.file_path and os.path.isfile(self.file_path): - os.unlink(self.file_path) + try: + os.unlink(self.file_path) + except FileNotFoundError as e: + ... purge_from_cache.append(self.file_path.replace('app/', f"https://{current_app.config['SERVER_NAME']}/")) if self.thumbnail_path and os.path.isfile(self.thumbnail_path): - os.unlink(self.thumbnail_path) + try: + os.unlink(self.thumbnail_path) + except FileNotFoundError as e: + ... purge_from_cache.append(self.thumbnail_path.replace('app/', f"https://{current_app.config['SERVER_NAME']}/")) if self.source_url and self.source_url.startswith('http') and current_app.config['SERVER_NAME'] in self.source_url: # self.source_url is always a url rather than a file path, which makes deleting the file a bit fiddly - os.unlink(self.source_url.replace(f"https://{current_app.config['SERVER_NAME']}/", 'app/')) + try: + os.unlink(self.source_url.replace(f"https://{current_app.config['SERVER_NAME']}/", 'app/')) + except FileNotFoundError as e: + ... purge_from_cache.append(self.source_url) # otoh it makes purging the cdn cache super easy. if purge_from_cache: @@ -283,6 +292,18 @@ class Topic(db.Model): parent_id = db.Column(db.Integer) communities = db.relationship('Community', lazy='dynamic', backref='topic', cascade="all, delete-orphan") + def path(self): + return_value = [self.machine_name] + parent_id = self.parent_id + while parent_id is not None: + parent_topic = Topic.query.get(parent_id) + if parent_topic is None: + break + return_value.append(parent_topic.machine_name) + parent_id = parent_topic.parent_id + return_value = list(reversed(return_value)) + return '/'.join(return_value) + class Community(db.Model): query_class = FullTextSearchQuery @@ -405,8 +426,8 @@ class Community(db.Model): (or_( CommunityMember.is_owner, CommunityMember.is_moderator - )) & CommunityMember.is_banned == False - ).all() + )) + ).filter(CommunityMember.is_banned == False).all() def is_moderator(self, user=None): if user is None: @@ -1257,6 +1278,7 @@ class Site(db.Model): last_active = db.Column(db.DateTime, default=utcnow) log_activitypub_json = db.Column(db.Boolean, default=False) default_theme = db.Column(db.String(20), default='') + contact_email = db.Column(db.String(255), default='') @staticmethod def admins() -> List[User]: diff --git a/app/post/forms.py b/app/post/forms.py index bf85d6c7..dde138d9 100644 --- a/app/post/forms.py +++ b/app/post/forms.py @@ -7,7 +7,7 @@ from app.utils import MultiCheckboxField class NewReplyForm(FlaskForm): - body = TextAreaField(_l('Body'), render_kw={'placeholder': 'What are your thoughts?', 'rows': 3}, validators={DataRequired(), Length(min=3, max=5000)}) + body = TextAreaField(_l('Body'), render_kw={'placeholder': 'What are your thoughts?', 'rows': 5}, validators={DataRequired(), Length(min=3, max=5000)}) notify_author = BooleanField(_l('Notify about replies')) submit = SubmitField(_l('Comment')) diff --git a/app/post/routes.py b/app/post/routes.py index b54b4457..f80ab8b3 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -13,18 +13,19 @@ 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 CreatePostForm +from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm from app.post.util import post_replies, get_comment_branch, post_reply_count -from app.constants import SUBSCRIPTION_MEMBER, POST_TYPE_LINK, POST_TYPE_IMAGE +from app.constants import SUBSCRIPTION_MEMBER, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE from app.models import Post, PostReply, \ PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, \ - Topic, User + Topic, User, Instance from app.post import bp from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ shorten_string, markdown_to_text, gibberish, ap_datetime, return_304, \ request_etag_matches, ip_address, user_ip_banned, instance_banned, can_downvote, can_upvote, post_ranking, \ reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, moderating_communities, joined_communities, \ - blocked_instances, blocked_domains, community_moderators, blocked_phrases, show_ban_message + blocked_instances, blocked_domains, community_moderators, blocked_phrases, show_ban_message, recently_upvoted_posts, \ + recently_downvoted_posts, recently_upvoted_post_replies, recently_downvoted_post_replies def show_post(post_id: int): @@ -239,12 +240,26 @@ def show_post(post_id: int): breadcrumb.url = '/communities' breadcrumbs.append(breadcrumb) + # Voting history + if current_user.is_authenticated: + recently_upvoted = recently_upvoted_posts(current_user.id) + recently_downvoted = recently_downvoted_posts(current_user.id) + recently_upvoted_replies = recently_upvoted_post_replies(current_user.id) + recently_downvoted_replies = recently_downvoted_post_replies(current_user.id) + else: + recently_upvoted = [] + recently_downvoted = [] + recently_upvoted_replies = [] + recently_downvoted_replies = [] + response = render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, community=post.community, breadcrumbs=breadcrumbs, related_communities=related_communities, mods=mod_list, 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, 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, moderating_communities=moderating_communities(current_user.get_id()), @@ -259,7 +274,6 @@ def show_post(post_id: int): @login_required @validation_required def post_vote(post_id: int, vote_direction): - upvoted_class = downvoted_class = '' post = Post.query.get_or_404(post_id) existing_vote = PostVote.query.filter_by(user_id=current_user.id, post_id=post.id).first() if existing_vote: @@ -275,7 +289,6 @@ def post_vote(post_id: int, vote_direction): post.up_votes -= 1 post.down_votes += 1 post.score -= 2 - downvoted_class = 'voted_down' else: # previous vote was down if vote_direction == 'downvote': # new vote is also down, so remove it db.session.delete(existing_vote) @@ -286,18 +299,28 @@ def post_vote(post_id: int, vote_direction): post.up_votes += 1 post.down_votes -= 1 post.score += 2 - upvoted_class = 'voted_up' else: if vote_direction == 'upvote': effect = 1 post.up_votes += 1 - post.score += 1 - upvoted_class = 'voted_up' + # Make 'hot' sort more spicy by amplifying the effect of early upvotes + if post.up_votes + post.down_votes <= 10: + post.score += 10 + elif post.up_votes + post.down_votes <= 30: + post.score += 5 + elif post.up_votes + post.down_votes <= 60: + post.score += 2 + else: + post.score += 1 else: effect = -1 post.down_votes += 1 - post.score -= 1 - downvoted_class = 'voted_down' + if post.up_votes + post.down_votes <= 30: + post.score -= 5 + elif post.up_votes + post.down_votes <= 60: + post.score -= 2 + else: + post.score -= 1 vote = PostVote(user_id=current_user.id, post_id=post.id, author_id=post.author.id, effect=effect) # upvotes do not increase reputation in low quality communities @@ -346,17 +369,25 @@ def post_vote(post_id: int, vote_direction): current_user.recalculate_attitude() db.session.commit() post.flush_cache() + + recently_upvoted = [] + recently_downvoted = [] + if vote_direction == 'upvote': + recently_upvoted = [post_id] + elif vote_direction == 'downvote': + recently_downvoted = [post_id] + cache.delete_memoized(recently_upvoted_posts, current_user.id) + cache.delete_memoized(recently_downvoted_posts, current_user.id) + template = 'post/_post_voting_buttons.html' if request.args.get('style', '') == '' else 'post/_post_voting_buttons_masonry.html' - return render_template(template, post=post, community=post.community, - upvoted_class=upvoted_class, - downvoted_class=downvoted_class) + return render_template(template, post=post, community=post.community, recently_upvoted=recently_upvoted, + recently_downvoted=recently_downvoted) @bp.route('/comment//', methods=['POST']) @login_required @validation_required def comment_vote(comment_id, vote_direction): - upvoted_class = downvoted_class = '' comment = PostReply.query.get_or_404(comment_id) existing_vote = PostReplyVote.query.filter_by(user_id=current_user.id, post_reply_id=comment.id).first() if existing_vote: @@ -423,9 +454,20 @@ def comment_vote(comment_id, vote_direction): db.session.commit() comment.post.flush_cache() + + recently_upvoted = [] + recently_downvoted = [] + if vote_direction == 'upvote': + recently_upvoted = [comment_id] + elif vote_direction == 'downvote': + recently_downvoted = [comment_id] + cache.delete_memoized(recently_upvoted_post_replies, current_user.id) + cache.delete_memoized(recently_downvoted_post_replies, current_user.id) + return render_template('post/_comment_voting_buttons.html', comment=comment, - upvoted_class=upvoted_class, - downvoted_class=downvoted_class, community=comment.community) + recently_upvoted_replies=recently_upvoted, + recently_downvoted_replies=recently_downvoted, + community=comment.community) @bp.route('/post//comment/') @@ -654,11 +696,82 @@ def post_reply_options(post_id: int, comment_id: int): ) -@bp.route('/post//edit', methods=['GET', 'POST']) +@bp.route('/post//edit', methods=['GET']) @login_required def post_edit(post_id: int): post = Post.query.get_or_404(post_id) - form = CreatePostForm() + if post.type == POST_TYPE_ARTICLE: + return redirect(url_for('post.post_edit_discussion_post', post_id=post_id)) + elif post.type == POST_TYPE_LINK: + return redirect(url_for('post.post_edit_link_post', post_id=post_id)) + elif post.type == POST_TYPE_IMAGE: + return redirect(url_for('post.post_edit_image_post', post_id=post_id)) + else: + abort(404) + + +@bp.route('/post//edit_discussion', methods=['GET', 'POST']) +@login_required +def post_edit_discussion_post(post_id: int): + post = Post.query.get_or_404(post_id) + form = CreateDiscussionForm() + del form.communities + + mods = post.community.moderators() + if post.community.private_mods: + mod_list = [] + else: + mod_user_ids = [mod.user_id for mod in mods] + mod_list = User.query.filter(User.id.in_(mod_user_ids)).all() + + if post.user_id == current_user.id or post.community.is_moderator() or current_user.is_admin(): + if g.site.enable_nsfl is False: + form.nsfl.render_kw = {'disabled': True} + if post.community.nsfw: + form.nsfw.data = True + form.nsfw.render_kw = {'disabled': True} + if post.community.nsfl: + form.nsfl.data = True + form.nsfw.render_kw = {'disabled': True} + + if form.validate_on_submit(): + save_post(form, post, 'discussion') + post.community.last_active = utcnow() + post.edited_at = utcnow() + db.session.commit() + + post.flush_cache() + flash(_('Your changes have been saved.'), 'success') + + # federate edit + if not post.community.local_only: + federate_post_update(post) + + return redirect(url_for('activitypub.post_ap', post_id=post.id)) + else: + form.discussion_title.data = post.title + form.discussion_body.data = post.body + form.notify_author.data = post.notify_author + form.nsfw.data = post.nsfw + form.nsfl.data = post.nsfl + form.sticky.data = post.sticky + if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()): + form.sticky.render_kw = {'disabled': True} + return render_template('post/post_edit_discussion.html', title=_('Edit post'), form=form, post=post, + markdown_editor=current_user.markdown_editor, mods=mod_list, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.get_id()), + inoculation=inoculation[randint(0, len(inoculation) - 1)] + ) + else: + abort(401) + + +@bp.route('/post//edit_image', methods=['GET', 'POST']) +@login_required +def post_edit_image_post(post_id: int): + post = Post.query.get_or_404(post_id) + form = CreateImageForm() del form.communities mods = post.community.moderators() @@ -678,11 +791,10 @@ def post_edit(post_id: int): form.nsfl.data = True form.nsfw.render_kw = {'disabled': True} - #form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] old_url = post.url if form.validate_on_submit(): - save_post(form, post) + save_post(form, post, 'image') post.community.last_active = utcnow() post.edited_at = utcnow() db.session.commit() @@ -714,104 +826,20 @@ def post_edit(post_id: int): # federate edit if not post.community.local_only: - page_json = { - 'type': 'Page', - 'id': post.ap_id, - 'attributedTo': current_user.ap_profile_id, - 'to': [ - post.community.ap_profile_id, - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'name': post.title, - 'cc': [], - 'content': post.body_html if post.body_html else '', - 'mediaType': 'text/html', - 'source': { - 'content': post.body if post.body else '', - 'mediaType': 'text/markdown' - }, - 'attachment': [], - 'commentsEnabled': post.comments_enabled, - 'sensitive': post.nsfw, - 'nsfl': post.nsfl, - 'stickied': post.sticky, - 'published': ap_datetime(post.posted_at), - 'updated': ap_datetime(post.edited_at), - 'audience': post.community.ap_profile_id - } - update_json = { - 'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}", - 'type': 'Update', - 'actor': current_user.profile_id(), - 'audience': post.community.profile_id(), - 'to': [post.community.profile_id(), 'https://www.w3.org/ns/activitystreams#Public'], - 'published': ap_datetime(utcnow()), - 'cc': [ - current_user.followers_url() - ], - 'object': page_json, - } - if post.type == POST_TYPE_LINK: - page_json['attachment'] = [{'href': post.url, 'type': 'Link'}] - elif post.image_id: - if post.image.file_path: - image_url = post.image.file_path.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/") - elif post.image.thumbnail_path: - image_url = post.image.thumbnail_path.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/") - else: - image_url = post.image.source_url - # NB image is a dict while attachment is a list of dicts (usually just one dict in the list) - page_json['image'] = {'type': 'Image', 'url': image_url} - if post.type == POST_TYPE_IMAGE: - page_json['attachment'] = [{'type': 'Link', 'href': post.image.source_url}] # source_url is always a https link, no need for .replace() as done above - - if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it - success = post_request(post.community.ap_inbox_url, update_json, current_user.private_key, - current_user.ap_profile_id + '#main-key') - if not success: - flash('Failed to send edit to remote server', 'error') - else: # local community - send it to followers on remote instances - announce = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", - "type": 'Announce', - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "actor": post.community.ap_profile_id, - "cc": [ - post.community.ap_followers_url - ], - '@context': default_context(), - 'object': update_json - } - - for instance in post.community.following_instances(): - if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): - send_to_remote_instance(instance.id, post.community.id, announce) + federate_post_update(post) return redirect(url_for('activitypub.post_ap', post_id=post.id)) else: - if post.type == constants.POST_TYPE_ARTICLE: - form.post_type.data = 'discussion' - form.discussion_title.data = post.title - form.discussion_body.data = post.body - elif post.type == constants.POST_TYPE_LINK: - form.post_type.data = 'link' - form.link_title.data = post.title - form.link_body.data = post.body - form.link_url.data = post.url - elif post.type == constants.POST_TYPE_IMAGE: - form.post_type.data = 'image' - form.image_title.data = post.title - form.image_body.data = post.body - form.image_alt_text.data = post.image.alt_text + form.image_title.data = post.title + form.image_body.data = post.body + form.image_alt_text.data = post.image.alt_text form.notify_author.data = post.notify_author form.nsfw.data = post.nsfw form.nsfl.data = post.nsfl form.sticky.data = post.sticky if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()): form.sticky.render_kw = {'disabled': True} - return render_template('post/post_edit.html', title=_('Edit post'), form=form, post=post, + return render_template('post/post_edit_image.html', title=_('Edit post'), form=form, post=post, markdown_editor=current_user.markdown_editor, mods=mod_list, moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()), @@ -821,6 +849,168 @@ def post_edit(post_id: int): abort(401) +@bp.route('/post//edit_link', methods=['GET', 'POST']) +@login_required +def post_edit_link_post(post_id: int): + post = Post.query.get_or_404(post_id) + form = CreateLinkForm() + del form.communities + + mods = post.community.moderators() + if post.community.private_mods: + mod_list = [] + else: + mod_user_ids = [mod.user_id for mod in mods] + mod_list = User.query.filter(User.id.in_(mod_user_ids)).all() + + if post.user_id == current_user.id or post.community.is_moderator() or current_user.is_admin(): + if g.site.enable_nsfl is False: + form.nsfl.render_kw = {'disabled': True} + if post.community.nsfw: + form.nsfw.data = True + form.nsfw.render_kw = {'disabled': True} + if post.community.nsfl: + form.nsfl.data = True + form.nsfw.render_kw = {'disabled': True} + + old_url = post.url + + if form.validate_on_submit(): + save_post(form, post, 'link') + post.community.last_active = utcnow() + post.edited_at = utcnow() + db.session.commit() + + if post.url != old_url: + if post.cross_posts is not None: + old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all() + post.cross_posts.clear() + for ocp in old_cross_posts: + if ocp.cross_posts is not None: + ocp.cross_posts.remove(post.id) + + new_cross_posts = Post.query.filter(Post.id != post.id, Post.url == post.url, + Post.posted_at > post.edited_at - timedelta(days=6)).all() + for ncp in new_cross_posts: + if ncp.cross_posts is None: + ncp.cross_posts = [post.id] + else: + ncp.cross_posts.append(post.id) + if post.cross_posts is None: + post.cross_posts = [ncp.id] + else: + post.cross_posts.append(ncp.id) + + db.session.commit() + + post.flush_cache() + flash(_('Your changes have been saved.'), 'success') + # federate edit + + if not post.community.local_only: + federate_post_update(post) + + return redirect(url_for('activitypub.post_ap', post_id=post.id)) + else: + form.link_title.data = post.title + form.link_body.data = post.body + form.link_url.data = post.url + form.notify_author.data = post.notify_author + form.nsfw.data = post.nsfw + form.nsfl.data = post.nsfl + form.sticky.data = post.sticky + if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()): + form.sticky.render_kw = {'disabled': True} + return render_template('post/post_edit_link.html', title=_('Edit post'), form=form, post=post, + markdown_editor=current_user.markdown_editor, mods=mod_list, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.get_id()), + inoculation=inoculation[randint(0, len(inoculation) - 1)] + ) + else: + abort(401) + + +def federate_post_update(post): + page_json = { + 'type': 'Page', + 'id': post.ap_id, + 'attributedTo': current_user.ap_profile_id, + 'to': [ + post.community.ap_profile_id, + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'name': post.title, + 'cc': [], + 'content': post.body_html if post.body_html else '', + 'mediaType': 'text/html', + 'source': { + 'content': post.body if post.body else '', + 'mediaType': 'text/markdown' + }, + 'attachment': [], + 'commentsEnabled': post.comments_enabled, + 'sensitive': post.nsfw, + 'nsfl': post.nsfl, + 'stickied': post.sticky, + 'published': ap_datetime(post.posted_at), + 'updated': ap_datetime(post.edited_at), + 'audience': post.community.ap_profile_id + } + update_json = { + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}", + 'type': 'Update', + 'actor': current_user.profile_id(), + 'audience': post.community.profile_id(), + 'to': [post.community.profile_id(), 'https://www.w3.org/ns/activitystreams#Public'], + 'published': ap_datetime(utcnow()), + 'cc': [ + current_user.followers_url() + ], + 'object': page_json, + } + if post.type == POST_TYPE_LINK: + page_json['attachment'] = [{'href': post.url, 'type': 'Link'}] + elif post.image_id: + if post.image.file_path: + image_url = post.image.file_path.replace('app/static/', + f"https://{current_app.config['SERVER_NAME']}/static/") + elif post.image.thumbnail_path: + image_url = post.image.thumbnail_path.replace('app/static/', + f"https://{current_app.config['SERVER_NAME']}/static/") + else: + image_url = post.image.source_url + # NB image is a dict while attachment is a list of dicts (usually just one dict in the list) + page_json['image'] = {'type': 'Image', 'url': image_url} + if post.type == POST_TYPE_IMAGE: + page_json['attachment'] = [{'type': 'Link', + 'href': post.image.source_url}] # source_url is always a https link, no need for .replace() as done above + if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it + success = post_request(post.community.ap_inbox_url, update_json, current_user.private_key, + current_user.ap_profile_id + '#main-key') + if not success: + flash('Failed to send edit to remote server', 'error') + else: # local community - send it to followers on remote instances + announce = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", + "type": 'Announce', + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "actor": post.community.ap_profile_id, + "cc": [ + post.community.ap_followers_url + ], + '@context': default_context(), + 'object': update_json + } + + for instance in post.community.following_instances(): + if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned( + instance.domain): + send_to_remote_instance(instance.id, post.community.id, announce) + + @bp.route('/post//delete', methods=['GET', 'POST']) @login_required def post_delete(post_id: int): @@ -888,7 +1078,12 @@ def post_delete(post_id: int): def post_report(post_id: int): post = Post.query.get_or_404(post_id) form = ReportPostForm() + if post.reports == -1: # When a mod decides to ignore future reports, post.reports is set to -1 + flash(_('Moderators have already assessed reports regarding this post, no further reports are necessary.'), 'warning') if form.validate_on_submit(): + if post.reports == -1: + flash(_('Post has already been reported, thank you!')) + return redirect(post.community.local_url()) report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, type=1, reporter_id=current_user.id, suspect_user_id=post.author.id, suspect_post_id=post.id, suspect_community_id=post.community.id, in_community_id=post.community.id, source_instance_id=1) @@ -911,9 +1106,29 @@ def post_report(post_id: int): admin.unread_notifications += 1 db.session.commit() - # todo: federate report to originating instance + # federate report to community instance if not post.community.is_local() and form.report_remote.data: - ... + summary = form.reasons_to_string(form.reasons.data) + if form.description.data: + summary += ' - ' + form.description.data + report_json = { + "actor": current_user.profile_id(), + "audience": post.community.profile_id(), + "content": None, + "id": f"https://{current_app.config['SERVER_NAME']}/activities/flag/{gibberish(15)}", + "object": post.ap_id, + "summary": summary, + "to": [ + post.community.profile_id() + ], + "type": "Flag" + } + instance = Instance.query.get(post.community.instance_id) + if post.community.ap_inbox_url and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): + success = post_request(post.community.ap_inbox_url, report_json, current_user.private_key, + current_user.ap_profile_id + '#main-key') + if not success: + flash('Failed to send report to remote server', 'error') flash(_('Post has been reported, thank you!')) return redirect(post.community.local_url()) @@ -992,7 +1207,16 @@ def post_reply_report(post_id: int, comment_id: int): post = Post.query.get_or_404(post_id) post_reply = PostReply.query.get_or_404(comment_id) form = ReportPostForm() + + if post_reply.reports == -1: # When a mod decides to ignore future reports, post_reply.reports is set to -1 + flash(_('Moderators have already assessed reports regarding this comment, no further reports are necessary.'), 'warning') + if form.validate_on_submit(): + + if post_reply.reports == -1: + flash(_('Comment has already been reported, thank you!')) + return redirect(post.community.local_url()) + report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, type=2, reporter_id=current_user.id, suspect_post_id=post.id, suspect_community_id=post.community.id, suspect_user_id=post_reply.author.id, suspect_post_reply_id=post_reply.id, in_community_id=post.community.id, @@ -1006,7 +1230,7 @@ def post_reply_report(post_id: int, comment_id: int): url=f"https://{current_app.config['SERVER_NAME']}/comment/{post_reply.id}", author_id=current_user.id) db.session.add(notification) - already_notified.add(mod.id) + already_notified.add(mod.user_id) post_reply.reports += 1 # todo: only notify admins for certain types of report for admin in Site.admins(): @@ -1016,9 +1240,30 @@ def post_reply_report(post_id: int, comment_id: int): admin.unread_notifications += 1 db.session.commit() - # todo: federate report to originating instance + # federate report to originating instance if not post.community.is_local() and form.report_remote.data: - ... + summary = form.reasons_to_string(form.reasons.data) + if form.description.data: + summary += ' - ' + form.description.data + report_json = { + "actor": current_user.profile_id(), + "audience": post.community.profile_id(), + "content": None, + "id": f"https://{current_app.config['SERVER_NAME']}/activities/flag/{gibberish(15)}", + "object": post_reply.ap_id, + "summary": summary, + "to": [ + post.community.profile_id() + ], + "type": "Flag" + } + instance = Instance.query.get(post.community.instance_id) + if post.community.ap_inbox_url and not current_user.has_blocked_instance( + instance.id) and not instance_banned(instance.domain): + success = post_request(post.community.ap_inbox_url, report_json, current_user.private_key, + current_user.ap_profile_id + '#main-key') + if not success: + flash('Failed to send report to remote server', 'error') flash(_('Comment has been reported, thank you!')) return redirect(post.community.local_url()) diff --git a/app/post/util.py b/app/post/util.py index 99e7f6cb..c7dd5b73 100644 --- a/app/post/util.py +++ b/app/post/util.py @@ -5,7 +5,7 @@ from sqlalchemy import desc, text, or_ from app import db from app.models import PostReply -from app.utils import blocked_instances +from app.utils import blocked_instances, blocked_users # replies to a post, in a tree, sorted by a variety of methods @@ -17,6 +17,9 @@ def post_replies(post_id: int, sort_by: str, show_first: int = 0) -> List[PostRe comments = comments.filter(or_(PostReply.instance_id.not_in(instance_ids), PostReply.instance_id == None)) if current_user.ignore_bots: comments = comments.filter(PostReply.from_bot == False) + blocked_accounts = blocked_users(current_user.id) + if blocked_accounts: + comments = comments.filter(PostReply.user_id.not_in(blocked_accounts)) if sort_by == 'hot': comments = comments.order_by(desc(PostReply.ranking)) elif sort_by == 'top': diff --git a/app/search/routes.py b/app/search/routes.py index 654be6fe..4705e82a 100644 --- a/app/search/routes.py +++ b/app/search/routes.py @@ -6,7 +6,7 @@ from sqlalchemy import or_ from app.models import Post from app.search import bp from app.utils import moderating_communities, joined_communities, render_template, blocked_domains, blocked_instances, \ - communities_banned_from + communities_banned_from, recently_upvoted_posts, recently_downvoted_posts, blocked_users @bp.route('/search', methods=['GET', 'POST']) @@ -30,6 +30,10 @@ def run_search(): instance_ids = blocked_instances(current_user.id) if instance_ids: posts = posts.filter(or_(Post.instance_id.not_in(instance_ids), Post.instance_id == None)) + # filter blocked users + blocked_accounts = blocked_users(current_user.id) + if blocked_accounts: + posts = posts.filter(Post.user_id.not_in(blocked_accounts)) banned_from = communities_banned_from(current_user.id) if banned_from: posts = posts.filter(Post.community_id.not_in(banned_from)) @@ -46,8 +50,18 @@ def run_search(): next_url = url_for('search.run_search', page=posts.next_num, q=q) if posts.has_next else None prev_url = url_for('search.run_search', page=posts.prev_num, q=q) if posts.has_prev and page != 1 else None + # Voting history + if current_user.is_authenticated: + recently_upvoted = recently_upvoted_posts(current_user.id) + recently_downvoted = recently_downvoted_posts(current_user.id) + else: + recently_upvoted = [] + recently_downvoted = [] + return render_template('search/results.html', title=_('Search results for %(q)s', q=q), posts=posts, q=q, next_url=next_url, prev_url=prev_url, show_post_community=True, + recently_upvoted=recently_upvoted, + recently_downvoted=recently_downvoted, moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()), site=g.site) diff --git a/app/static/structure.css b/app/static/structure.css index 5a631fc0..0971865c 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -501,6 +501,14 @@ fieldset legend { .form-group { margin-bottom: 1.1rem; } +.form-group.required label:after { + content: "*"; + color: red; + margin-left: 2px; + font-size: 80%; + position: relative; + top: -1px; +} .card { max-width: 350px; @@ -977,7 +985,8 @@ fieldset legend { } .voting_buttons_new .upvote_button, .voting_buttons_new .downvote_button { display: inline-block; - padding: 5px 15px; + padding: 5px 0 5px 3px; + text-align: center; position: relative; cursor: pointer; color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); @@ -1004,11 +1013,6 @@ fieldset legend { .voting_buttons_new .upvote_button { top: 1px; } -@media (min-width: 1280px) { - .voting_buttons_new .upvote_button { - padding-right: 5px; - } -} .voting_buttons_new .upvote_button .htmx-indicator { left: 13px; top: 7px; @@ -1018,14 +1022,7 @@ fieldset legend { } .voting_buttons_new .downvote_button .htmx-indicator { left: 12px; -} -@media (min-width: 1280px) { - .voting_buttons_new .downvote_button { - padding-left: 5px; - } - .voting_buttons_new .downvote_button .htmx-indicator { - left: 2px; - } + top: 5px; } .voting_buttons_new .htmx-indicator { position: absolute; @@ -1117,7 +1114,6 @@ fieldset legend { .comment { clear: both; - margin-bottom: 10px; margin-left: 15px; padding-top: 8px; } @@ -1170,7 +1166,7 @@ fieldset legend { } .comment .comment_actions a { text-decoration: none; - padding: 5px 0; + padding: 0; } .comment .comment_actions .hide_button { display: inline-block; diff --git a/app/static/structure.scss b/app/static/structure.scss index 5c0951c6..a51099c8 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -65,6 +65,19 @@ html { .form-group { margin-bottom: 1.1rem; + + &.required { + label { + &:after { + content: '*'; + color: red; + margin-left: 2px; + font-size: 80%; + position: relative; + top: -1px; + } + } + } } .card { @@ -610,7 +623,8 @@ html { .upvote_button, .downvote_button { display: inline-block; - padding: 5px 15px; + padding: 5px 0 5px 3px; + text-align: center; position: relative; cursor: pointer; color: rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1)); @@ -641,9 +655,7 @@ html { .upvote_button { top: 1px; - @include breakpoint(laptop) { - padding-right: 5px; - } + .htmx-indicator { left: 13px; top: 7px; @@ -654,14 +666,8 @@ html { top: 1px; .htmx-indicator { left: 12px; + top: 5px; } - @include breakpoint(laptop) { - padding-left: 5px; - .htmx-indicator { - left: 2px; - } - } - } .htmx-indicator{ @@ -763,7 +769,6 @@ html { .comment { clear: both; - margin-bottom: 10px; margin-left: 15px; padding-top: 8px; @@ -823,7 +828,7 @@ html { position: relative; a { text-decoration: none; - padding: 5px 0; + padding: 0; } .hide_button { diff --git a/app/static/styles.css b/app/static/styles.css index fc3a6609..6039ec53 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -693,7 +693,7 @@ div.navbar { .comment_actions_link { display: block; position: absolute; - bottom: 0; + top: 3px; right: -16px; width: 41px; text-decoration: none; diff --git a/app/static/styles.scss b/app/static/styles.scss index 77e79e04..9fca4211 100644 --- a/app/static/styles.scss +++ b/app/static/styles.scss @@ -284,7 +284,7 @@ div.navbar { .comment_actions_link { display: block; position: absolute; - bottom: 0; + top: 3px; right: -16px; width: 41px; text-decoration: none; diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html index 03485e9e..507cd364 100644 --- a/app/templates/admin/_nav.html +++ b/app/templates/admin/_nav.html @@ -1,4 +1,4 @@ -