diff --git a/LICENSE b/LICENSE index 0ad25db4..4813f7f9 100644 --- a/LICENSE +++ b/LICENSE @@ -629,8 +629,8 @@ to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - Copyright (C) + PieFed, a federated forum + Copyright (C) 2024 Rimu Atkinson This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 9e8879f5..8200ee81 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -893,10 +893,15 @@ def create_post_reply(activity_log: ActivityPubLog, community: Community, in_rep community.last_active = post.last_active = utcnow() activity_log.result = 'success' db.session.commit() - vote = PostReplyVote(user_id=user.id, author_id=post_reply.user_id, - post_reply_id=post_reply.id, - effect=instance_weight(user.ap_domain)) - db.session.add(vote) + if user.reputation > 100: + vote = PostReplyVote(user_id=1, author_id=post_reply.user_id, + post_reply_id=post_reply.id, + effect=instance_weight(user.ap_domain)) + db.session.add(vote) + post_reply.up_votes += 1 + post_reply.score += 1 + post_reply.ranking += 1 + db.session.commit() else: activity_log.exception_message = 'Comments disabled, reply discarded' activity_log.result = 'ignored' @@ -920,7 +925,8 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json ap_announce_id=announce_id, type=constants.POST_TYPE_ARTICLE, up_votes=1, - score=instance_weight(user.ap_domain) + score=instance_weight(user.ap_domain), + instance_id=user.instance_id ) if 'source' in request_json['object'] and request_json['object']['source']['mediaType'] == 'text/markdown': post.body = request_json['object']['source']['content'] @@ -972,11 +978,15 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json if post.image_id: make_image_sizes(post.image_id, 266, None, 'posts') - - vote = PostVote(user_id=user.id, author_id=post.user_id, - post_id=post.id, - effect=instance_weight(user.ap_domain)) - db.session.add(vote) + if user.reputation > 100: + vote = PostVote(user_id=1, author_id=post.user_id, + post_id=post.id, + effect=instance_weight(user.ap_domain)) + db.session.add(vote) + post.up_votes += 1 + post.score += 1 + post.ranking += 1 + db.session.commit() return post diff --git a/app/admin/routes.py b/app/admin/routes.py index 91e4c59c..be4f539c 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -11,9 +11,10 @@ from app.activitypub.routes import process_inbox_request, process_delete_request from app.activitypub.signature import post_request from app.activitypub.util import default_context from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm, EditCommunityForm, EditUserForm +from app.admin.util import unsubscribe_from_everything_then_delete, unsubscribe_from_community from app.community.util import save_icon_file, save_banner_file from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, \ - User, Instance, File + User, Instance, File, Report from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html from app.admin import bp @@ -277,13 +278,21 @@ def unsubscribe_everyone_then_delete_task(community_id): def admin_users(): page = request.args.get('page', 1, type=int) + search = request.args.get('search', '') + local_remote = request.args.get('local_remote', '') - users = User.query.filter_by(deleted=False).order_by(User.user_name).paginate(page=page, per_page=1000, error_out=False) + users = User.query.filter_by(deleted=False) + if local_remote == 'local': + users = users.filter_by(ap_id=None) + if local_remote == 'remote': + users = users.filter(User.ap_id != None) + users = users.order_by(User.user_name).paginate(page=page, per_page=1000, error_out=False) next_url = url_for('admin.admin_users', page=users.next_num) if users.has_next else None prev_url = url_for('admin.admin_users', page=users.prev_num) if users.has_prev and page != 1 else None - return render_template('admin/users.html', title=_('Users'), next_url=next_url, prev_url=prev_url, users=users) + return render_template('admin/users.html', title=_('Users'), next_url=next_url, prev_url=prev_url, users=users, + local_remote=local_remote, search=search) @bp.route('/user//edit', methods=['GET', 'POST']) @@ -370,68 +379,24 @@ def admin_user_delete(user_id): return redirect(url_for('admin.admin_users')) -def unsubscribe_from_everything_then_delete(user_id): - if current_app.debug: - unsubscribe_from_everything_then_delete_task(user_id) - else: - unsubscribe_from_everything_then_delete_task.delay(user_id) +@bp.route('/reports', methods=['GET']) +@login_required +@permission_required('administer all users') +def admin_reports(): + page = request.args.get('page', 1, type=int) + search = request.args.get('search', '') + local_remote = request.args.get('local_remote', '') -@celery.task -def unsubscribe_from_everything_then_delete_task(user_id): - user = User.query.get(user_id) - if user: + reports = Report.query.filter_by(status=0) + if local_remote == 'local': + reports = reports.filter_by(ap_id=None) + if local_remote == 'remote': + reports = reports.filter(Report.ap_id != None) + reports = reports.order_by(desc(Report.created_at)).paginate(page=page, per_page=1000, error_out=False) - # unsubscribe - communities = CommunityMember.query.filter_by(user_id=user_id).all() - for membership in communities: - community = Community.query.get(membership.community_id) - unsubscribe_from_community(community, user) + next_url = url_for('admin.admin_reports', page=reports.next_num) if reports.has_next else None + prev_url = url_for('admin.admin_reports', page=reports.prev_num) if reports.has_prev and page != 1 else None - # federate deletion of account - if user.is_local(): - instances = Instance.query.all() - site = Site.query.get(1) - payload = { - "@context": default_context(), - "actor": user.ap_profile_id, - "id": f"{user.ap_profile_id}#delete", - "object": user.ap_profile_id, - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "type": "Delete" - } - for instance in instances: - if instance.inbox and instance.id != 1: - post_request(instance.inbox, payload, site.private_key, - f"https://{current_app.config['SERVER_NAME']}#main-key") - - user.deleted = True - user.delete_dependencies() - db.session.commit() - - -def unsubscribe_from_community(community, user): - undo_id = f"https://{current_app.config['SERVER_NAME']}/activities/undo/" + gibberish(15) - follow = { - "actor": f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}", - "to": [community.ap_profile_id], - "object": community.ap_profile_id, - "type": "Follow", - "id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{gibberish(15)}" - } - undo = { - 'actor': user.profile_id(), - 'to': [community.ap_profile_id], - 'type': 'Undo', - 'id': undo_id, - 'object': follow - } - activity = ActivityPubLog(direction='out', activity_id=undo_id, activity_type='Undo', - activity_json=json.dumps(undo), result='processing') - db.session.add(activity) - db.session.commit() - post_request(community.ap_inbox_url, undo, user.private_key, user.profile_id() + '#main-key') - activity.result = 'success' - db.session.commit() + return render_template('admin/reports.html', title=_('Reports'), next_url=next_url, prev_url=prev_url, reports=reports, + local_remote=local_remote, search=search) \ No newline at end of file diff --git a/app/admin/util.py b/app/admin/util.py new file mode 100644 index 00000000..62de8fc5 --- /dev/null +++ b/app/admin/util.py @@ -0,0 +1,72 @@ +from flask import request, abort, g, current_app, json +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 +from app.utils import gibberish + + +def unsubscribe_from_everything_then_delete(user_id): + if current_app.debug: + unsubscribe_from_everything_then_delete_task(user_id) + else: + unsubscribe_from_everything_then_delete_task.delay(user_id) + + +@celery.task +def unsubscribe_from_everything_then_delete_task(user_id): + user = User.query.get(user_id) + if user: + # unsubscribe + communities = CommunityMember.query.filter_by(user_id=user_id).all() + for membership in communities: + community = Community.query.get(membership.community_id) + unsubscribe_from_community(community, user) + + # federate deletion of account + if user.is_local(): + instances = Instance.query.all() + site = Site.query.get(1) + payload = { + "@context": default_context(), + "actor": user.ap_profile_id, + "id": f"{user.ap_profile_id}#delete", + "object": user.ap_profile_id, + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Delete" + } + for instance in instances: + if instance.inbox and instance.id != 1: + post_request(instance.inbox, payload, site.private_key, + f"https://{current_app.config['SERVER_NAME']}#main-key") + + user.deleted = True + user.delete_dependencies() + db.session.commit() + + +def unsubscribe_from_community(community, user): + undo_id = f"https://{current_app.config['SERVER_NAME']}/activities/undo/" + gibberish(15) + follow = { + "actor": f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}", + "to": [community.ap_profile_id], + "object": community.ap_profile_id, + "type": "Follow", + "id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{gibberish(15)}" + } + undo = { + 'actor': user.profile_id(), + 'to': [community.ap_profile_id], + 'type': 'Undo', + 'id': undo_id, + 'object': follow + } + activity = ActivityPubLog(direction='out', activity_id=undo_id, activity_type='Undo', + activity_json=json.dumps(undo), result='processing') + db.session.add(activity) + db.session.commit() + post_request(community.ap_inbox_url, undo, user.private_key, user.profile_id() + '#main-key') + activity.result = 'success' + db.session.commit() diff --git a/app/community/routes.py b/app/community/routes.py index fa9020d9..2820fd31 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -17,7 +17,7 @@ from app.models import User, Community, CommunityMember, CommunityJoinRequest, C from app.community import bp from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, community_membership, ap_datetime, \ - request_etag_matches, return_304 + request_etag_matches, return_304, instance_banned from feedgen.feed import FeedGenerator from datetime import timezone @@ -360,6 +360,10 @@ def add_post(actor): "object": page, '@context': default_context() } + if post.type == POST_TYPE_LINK: + page.attachment = [{'href': post.url, 'type': 'Link'}] + if post.image_id: + page.image = [{'type': 'Image', 'url': post.image.source_url}] 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') @@ -384,7 +388,7 @@ def add_post(actor): sent_to = 0 for instance in community.following_instances(): - if instance[1] and not current_user.has_blocked_instance(instance[0]): + if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]): send_to_remote_instance(instance[1], community.id, announce) sent_to += 1 if sent_to: diff --git a/app/community/util.py b/app/community/util.py index 54609d03..7fe029c6 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -255,10 +255,17 @@ def save_post(form, post: Post): else: raise Exception('invalid post type') if post.id is None: - postvote = PostVote(user_id=current_user.id, author_id=current_user.id, post=post, effect=1.0) - post.up_votes = 1 - post.score = 1 - db.session.add(postvote) + if current_user.reputation > 100: + postvote = PostVote(user_id=1, author_id=current_user.id, post=post, effect=1.0) + post.up_votes = 1 + post.score = 1 + post.ranking = 1 + db.session.add(postvote) + if current_user.reputation < -100: + postvote = PostVote(user_id=1, author_id=current_user.id, post=post, effect=-1.0) + post.score = -1 + post.ranking = -1 + db.session.add(postvote) db.session.add(post) g.site.last_active = utcnow() diff --git a/app/models.py b/app/models.py index 800c4074..b676b60e 100644 --- a/app/models.py +++ b/app/models.py @@ -175,6 +175,7 @@ class Community(db.Model): else: return self.ap_id + @cache.memoize(timeout=30) def moderators(self): return CommunityMember.query.filter((CommunityMember.community_id == self.id) & (or_( @@ -210,7 +211,7 @@ class Community(db.Model): # returns a list of tuples (instance.id, instance.inbox) def following_instances(self): sql = 'select distinct i.id, i.inbox from "instance" as i inner join "user" as u on u.instance_id = i.id inner join "community_member" as cm on cm.user_id = u.id ' - sql += 'where cm.community_id = :community_id and cm.is_banned = false and i.id <> 1' + sql += 'where cm.community_id = :community_id and cm.is_banned = false and i.id <> 1 and i.dormant = false and i.gone_forever = false' return db.session.execute(text(sql), {'community_id': self.id}) def delete_dependencies(self): @@ -898,6 +899,17 @@ class Report(db.Model): created_at = db.Column(db.DateTime, default=utcnow) updated = db.Column(db.DateTime, default=utcnow) + # textual representation of self.type + def type_text(self): + types = ('User', 'Post', 'Comment', 'Community') + if self.type is None: + return '' + else: + return types[self.type] + + def is_local(self): + return True + class IpBan(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/app/post/routes.py b/app/post/routes.py index d7afd3ea..1571e390 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -18,7 +18,7 @@ from app.models import Post, PostReply, \ 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, domain_from_url, validate_image, gibberish, ap_datetime, return_304, \ - request_etag_matches, ip_address, user_ip_banned + request_etag_matches, ip_address, user_ip_banned, instance_banned def show_post(post_id: int): @@ -68,9 +68,19 @@ def show_post(post_id: int): db.session.add(reply) db.session.commit() reply.ap_id = reply.profile_id() - reply_vote = PostReplyVote(user_id=current_user.id, author_id=current_user.id, post_reply_id=reply.id, - effect=1.0) - db.session.add(reply_vote) + if current_user.reputation > 100: + reply_vote = PostReplyVote(user_id=1, author_id=current_user.id, post_reply_id=reply.id, + effect=1.0) + reply.up_votes += 1 + reply.score += 1 + reply.ranking += 1 + db.session.add(reply_vote) + elif current_user.reputation < -100: + reply_vote = PostReplyVote(user_id=1, author_id=current_user.id, post_reply_id=reply.id, + effect=-1.0) + reply.score -= 1 + reply.ranking -= 1 + db.session.add(reply_vote) db.session.commit() form.body.data = '' flash('Your comment has been added.') @@ -133,7 +143,7 @@ def show_post(post_id: int): } for instance in post.community.following_instances(): - if instance[1] and not current_user.has_blocked_instance(instance[0]): + if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]): send_to_remote_instance(instance[1], post.community.id, announce) return redirect(url_for('activitypub.post_ap', post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form @@ -225,7 +235,7 @@ def post_vote(post_id: int, vote_direction): 'object': action_json } for instance in post.community.following_instances(): - if instance[1] and not current_user.has_blocked_instance(instance[0]): + if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]): send_to_remote_instance(instance[1], post.community.id, announce) else: success = post_request(post.community.ap_inbox_url, action_json, current_user.private_key, @@ -368,9 +378,19 @@ def add_reply(post_id: int, comment_id: int): db.session.commit() reply.ap_id = reply.profile_id() db.session.commit() - reply_vote = PostReplyVote(user_id=current_user.id, author_id=current_user.id, post_reply_id=reply.id, - effect=1.0) - db.session.add(reply_vote) + if current_user.reputation > 100: + reply_vote = PostReplyVote(user_id=1, author_id=current_user.id, post_reply_id=reply.id, + effect=1.0) + reply.up_votes += 1 + reply.score += 1 + reply.ranking += 1 + db.session.add(reply_vote) + elif current_user.reputation < -100: + reply_vote = PostReplyVote(user_id=1, author_id=current_user.id, post_reply_id=reply.id, + effect=-1.0) + reply.score -= 1 + reply.ranking -= 1 + db.session.add(reply_vote) post.reply_count = post_reply_count(post.id) post.last_active = post.community.last_active = utcnow() db.session.commit() @@ -452,7 +472,7 @@ def add_reply(post_id: int, comment_id: int): } for instance in post.community.following_instances(): - if instance[1] and not current_user.has_blocked_instance(instance[0]): + if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]): send_to_remote_instance(instance[1], post.community.id, announce) if reply.depth <= constants.THREAD_CUTOFF_DEPTH: diff --git a/app/static/styles.css b/app/static/styles.css index e01fe1bc..d7f8f74a 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -570,6 +570,10 @@ nav.navbar { width: 96%; } +.reported { + background-color: antiquewhite; +} + @media (prefers-color-scheme: dark) { body { background-color: #777; diff --git a/app/static/styles.scss b/app/static/styles.scss index 24ff54ac..f92e45c7 100644 --- a/app/static/styles.scss +++ b/app/static/styles.scss @@ -254,6 +254,10 @@ nav.navbar { width: 96%; } +.reported { + background-color: antiquewhite; +} + @media (prefers-color-scheme: dark) { body { background-color: $dark-grey; diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html index 4757da5d..b02d2159 100644 --- a/app/templates/admin/_nav.html +++ b/app/templates/admin/_nav.html @@ -4,6 +4,7 @@ {{ _('Misc settings') }} | {{ _('Communities') }} | {{ _('Users') }} | + {{ _('Moderation') }} | {{ _('Federation') }} | {{ _('Activities') }} diff --git a/app/templates/admin/reports.html b/app/templates/admin/reports.html new file mode 100644 index 00000000..16b40677 --- /dev/null +++ b/app/templates/admin/reports.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} +
+
+ {% include 'admin/_nav.html' %} +
+
+ +
+
+
+ + + + +
+ + + + + + + + + + {% for report in reports %} + + + + + + + + + {% endfor %} +
Local/RemoteReasonsDescriptionTypeCreatedActions
{{ 'Local' if report.is_local() else 'Remote' }}{{ report.reasons }}{{ report.description }}{{ report.type_text() }}{{ report.created_at }} + {% if report.suspect_post_reply_id %} + View + {% elif report.suspect_post_id %} + View + {% elif report.suspect_user_id %} + View + {% elif report.suspect_community_id %} + View + {% endif %} +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html index 876937fe..4cc29dfb 100644 --- a/app/templates/admin/users.html +++ b/app/templates/admin/users.html @@ -11,9 +11,9 @@
- - - + + +
diff --git a/app/templates/post/_post_full.html b/app/templates/post/_post_full.html index 5d72c614..1a0cf3d2 100644 --- a/app/templates/post/_post_full.html +++ b/app/templates/post/_post_full.html @@ -18,7 +18,9 @@ External link

{% endif %} -

submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }} +

{% if post.reports and current_user.is_authenticated and post.community.is_moderator(current_user) %} + + {% endif %}submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }} {% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}

@@ -46,9 +48,11 @@ width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" />
{% endif %} -

submitted {{ moment(post.posted_at).fromNow() }} by +

{% if post.reports and current_user.is_authenticated and post.community.is_moderator(current_user) %} + + {% endif %}submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }} - {% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %} + {% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}

{% if post.type == POST_TYPE_LINK %}

{{ post.url|shorten_url }} diff --git a/app/templates/post/_post_teaser.html b/app/templates/post/_post_teaser.html index 1af40ef0..e2f280ce 100644 --- a/app/templates/post/_post_teaser.html +++ b/app/templates/post/_post_teaser.html @@ -1,4 +1,4 @@ -