From 74cc2d17c07dc2ad7bd65b26c6cb52dcbfd349d1 Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Tue, 2 Jan 2024 19:41:00 +1300 Subject: [PATCH] communities that are local-only, w access control for posting and voting --- app/activitypub/routes.py | 74 +++++++++++-------- app/activitypub/util.py | 2 + app/admin/forms.py | 3 + app/admin/routes.py | 6 ++ app/community/routes.py | 7 +- app/models.py | 7 +- app/templates/admin/edit_community.html | 3 + app/templates/post/_post_voting_buttons.html | 28 ++++--- app/templates/post/_voting_buttons.html | 28 ++++--- app/templates/post/post_mea_culpa.html | 1 + app/utils.py | 68 ++++++++++++++++- .../328c9990e53e_local_only_communities.py | 32 ++++++++ pyfedi.py | 6 +- 13 files changed, 202 insertions(+), 63 deletions(-) create mode 100644 migrations/versions/328c9990e53e_local_only_communities.py diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 7d12faea..9dbd60c8 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -18,7 +18,8 @@ from app.activitypub.util import public_key, users_total, active_half_year, acti user_removed_from_remote_server, create_post, create_post_reply, update_post_reply_from_activity, \ update_post_from_activity, undo_vote, undo_downvote from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \ - domain_from_url, markdown_to_html, community_membership, ap_datetime, markdown_to_text, ip_address + domain_from_url, markdown_to_html, community_membership, ap_datetime, markdown_to_text, ip_address, can_downvote, \ + can_upvote, can_create import werkzeug.exceptions @@ -247,7 +248,9 @@ def community_profile(actor): "moderators": f"https://{server}/c/{actor}/moderators", "featured": f"https://{server}/c/{actor}/featured", "attributedTo": f"https://{server}/c/{actor}/moderators", - "postingRestrictedToMods": community.restricted_to_mods, + "postingRestrictedToMods": community.restricted_to_mods or community.local_only, + "newModsWanted": community.new_mods_wanted, + "privateMods": community.private_mods, "url": f"https://{server}/c/{actor}", "publicKey": { "id": f"https://{server}/c/{actor}#main-key", @@ -403,10 +406,11 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): if object_type in new_content_types: # create a new post in_reply_to = request_json['object']['object']['inReplyTo'] if 'inReplyTo' in \ request_json['object']['object'] else None - if not in_reply_to: - post = create_post(activity_log, community, request_json['object'], user, announce_id=request_json['id']) - else: - post = create_post_reply(activity_log, community, in_reply_to, request_json['object'], user, announce_id=request_json['id']) + if can_create(user, community): + if not in_reply_to: + post = create_post(activity_log, community, request_json['object'], user, announce_id=request_json['id']) + else: + post = create_post_reply(activity_log, community, in_reply_to, request_json['object'], user, announce_id=request_json['id']) else: activity_log.exception_message = 'Unacceptable type: ' + object_type else: @@ -421,8 +425,14 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): user_ap_id = request_json['object']['actor'] liked_ap_id = request_json['object']['object'] user = find_actor_or_create(user_ap_id) - if user and not user.is_local(): - liked = find_liked_object(liked_ap_id) + liked = find_liked_object(liked_ap_id) + + if user is None: + activity_log.exception_message = 'Blocked or unfound user' + elif user.is_local(): + activity_log.exception_message = 'Activity about local content which is already present' + activity_log.result = 'ignored' + elif can_upvote(user, liked): # insert into voted table if liked is None: activity_log.exception_message = 'Liked object not found' @@ -439,11 +449,8 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): # todo: recalculate 'hotness' of liked post/reply # todo: if vote was on content in local community, federate the vote out to followers else: - if user is None: - activity_log.exception_message = 'Blocked or unfound user' - if user and user.is_local(): - activity_log.exception_message = 'Activity about local content which is already present' - activity_log.result = 'ignored' + activity_log.exception_message = 'Cannot upvote this' + activity_log.result = 'ignored' elif request_json['object']['type'] == 'Dislike': activity_log.activity_type = request_json['object']['type'] @@ -453,28 +460,29 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): user_ap_id = request_json['object']['actor'] liked_ap_id = request_json['object']['object'] user = find_actor_or_create(user_ap_id) - if user and not user.is_local(): - disliked = find_liked_object(liked_ap_id) + disliked = find_liked_object(liked_ap_id) + if user is None: + activity_log.exception_message = 'Blocked or unfound user' + elif user.is_local(): + activity_log.exception_message = 'Activity about local content which is already present' + activity_log.result = 'ignored' + elif can_downvote(user, disliked, site): # insert into voted table if disliked is None: activity_log.exception_message = 'Liked object not found' - elif disliked is not None and isinstance(disliked, Post): - downvote_post(disliked, user) - activity_log.result = 'success' - elif disliked is not None and isinstance(disliked, PostReply): - downvote_post_reply(disliked, user) + elif isinstance(disliked, (Post, PostReply)): + if isinstance(disliked, Post): + downvote_post(disliked, user) + elif isinstance(disliked, PostReply): + downvote_post_reply(disliked, user) activity_log.result = 'success' + # todo: recalculate 'hotness' of liked post/reply + # todo: if vote was on content in the local community, federate the vote out to followers else: activity_log.exception_message = 'Could not detect type of like' - if activity_log.result == 'success': - ... # todo: recalculate 'hotness' of liked post/reply - # todo: if vote was on content in local community, federate the vote out to followers else: - if user is None: - activity_log.exception_message = 'Blocked or unfound user' - if user and user.is_local(): - activity_log.exception_message = 'Activity about local content which is already present' - activity_log.result = 'ignored' + activity_log.exception_message = 'Cannot downvote this' + activity_log.result = 'ignored' elif request_json['object']['type'] == 'Delete': activity_log.activity_type = request_json['object']['type'] user_ap_id = request_json['object']['actor'] @@ -634,12 +642,14 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): comment = PostReply.query.filter_by(ap_id=target_ap_id).first() if '/post/' in target_ap_id: post = Post.query.filter_by(ap_id=target_ap_id).first() - if (user and not user.is_local()) and post: + if (user and not user.is_local()) and post and can_upvote(user, post): upvote_post(post, user) activity_log.result = 'success' - elif (user and not user.is_local()) and comment: + elif (user and not user.is_local()) and comment and can_upvote(user, comment): upvote_post_reply(comment, user) activity_log.result = 'success' + else: + activity_log.exception_message = 'Could not find user or content for vote' elif request_json['type'] == 'Dislike': # Downvote if get_setting('allow_dislike', True) is False: @@ -655,10 +665,10 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): comment = PostReply.query.filter_by(ap_id=target_ap_id).first() if '/post/' in target_ap_id: post = Post.query.filter_by(ap_id=target_ap_id).first() - if (user and not user.is_local()) and comment: + if (user and not user.is_local()) and comment and can_downvote(user, comment, site): downvote_post_reply(comment, user) activity_log.result = 'success' - elif (user and not user.is_local()) and post: + elif (user and not user.is_local()) and post and can_downvote(user, post, site): downvote_post(post, user) activity_log.result = 'success' else: diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 8200ee81..142dde7e 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -371,6 +371,8 @@ def actor_json_to_model(activity_json, address, server): rules_html=markdown_to_html(activity_json['rules'] if 'rules' in activity_json else ''), nsfw=activity_json['sensitive'], restricted_to_mods=activity_json['postingRestrictedToMods'], + new_mods_wanted=activity_json['newModsWanted'] if 'newModsWanted' in activity_json else False, + private_mods=activity_json['privateMods'] if 'privateMods' in activity_json else False, created_at=activity_json['published'] if 'published' in activity_json else utcnow(), last_active=activity_json['updated'] if 'updated' in activity_json else utcnow(), ap_id=f"{address[1:]}", diff --git a/app/admin/forms.py b/app/admin/forms.py index c81d3bca..b347a870 100644 --- a/app/admin/forms.py +++ b/app/admin/forms.py @@ -47,6 +47,9 @@ class EditCommunityForm(FlaskForm): banner_file = FileField(_('Banner image')) rules = TextAreaField(_l('Rules')) nsfw = BooleanField('Porn community') + local_only = BooleanField('Only accept posts from current instance') + restricted_to_mods = BooleanField('Only moderators can post') + new_mods_wanted = BooleanField('New moderators wanted') show_home = BooleanField('Posts show on home page') show_popular = BooleanField('Posts can be popular') show_all = BooleanField('Posts show in All list') diff --git a/app/admin/routes.py b/app/admin/routes.py index be4f539c..0aec1995 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -194,6 +194,9 @@ def admin_community_edit(community_id): community.description = form.description.data community.rules = form.rules.data community.nsfw = form.nsfw.data + community.local_only = form.local_only.data + community.restricted_to_mods = form.restricted_to_mods.data + community.new_mods_wanted = form.new_mods_wanted.data community.show_home = form.show_home.data community.show_popular = form.show_popular.data community.show_all = form.show_all.data @@ -224,6 +227,9 @@ def admin_community_edit(community_id): form.description.data = community.description form.rules.data = community.rules form.nsfw.data = community.nsfw + form.local_only.data = community.local_only + form.new_mods_wanted.data = community.new_mods_wanted + form.restricted_to_mods.data = community.restricted_to_mods form.show_home.data = community.show_home form.show_popular.data = community.show_popular form.show_all.data = community.show_all diff --git a/app/community/routes.py b/app/community/routes.py index 2820fd31..6cf6a707 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, instance_banned + request_etag_matches, return_304, instance_banned, can_create from feedgen.feed import FeedGenerator from datetime import timezone @@ -313,8 +313,13 @@ def add_post(actor): form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] + if not can_create(current_user, community): + abort(401) + if form.validate_on_submit(): community = Community.query.get_or_404(form.communities.data) + if not can_create(current_user, community): + abort(401) post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) save_post(form, post) community.post_count += 1 diff --git a/app/models.py b/app/models.py index b676b60e..bb774de6 100644 --- a/app/models.py +++ b/app/models.py @@ -105,6 +105,7 @@ class Community(db.Model): banned = db.Column(db.Boolean, default=False) restricted_to_mods = db.Column(db.Boolean, default=False) + local_only = db.Column(db.Boolean, default=False) # only users on this instance can post new_mods_wanted = db.Column(db.Boolean, default=False) searchable = db.Column(db.Boolean, default=True) private_mods = db.Column(db.Boolean, default=False) @@ -116,8 +117,8 @@ class Community(db.Model): search_vector = db.Column(TSVectorType('name', 'title', 'description', 'rules')) - posts = db.relationship('Post', backref='community', lazy='dynamic', cascade="all, delete-orphan") - replies = db.relationship('PostReply', backref='community', lazy='dynamic', cascade="all, delete-orphan") + posts = db.relationship('Post', lazy='dynamic', cascade="all, delete-orphan") + replies = db.relationship('PostReply', lazy='dynamic', cascade="all, delete-orphan") icon = db.relationship('File', foreign_keys=[icon_id], single_parent=True, backref='community', cascade="all, delete-orphan") image = db.relationship('File', foreign_keys=[image_id], single_parent=True, cascade="all, delete-orphan") @@ -574,6 +575,7 @@ class Post(db.Model): image = db.relationship(File, lazy='joined', foreign_keys=[image_id], cascade="all, delete") domain = db.relationship('Domain', lazy='joined', foreign_keys=[domain_id]) author = db.relationship('User', lazy='joined', overlaps='posts', foreign_keys=[user_id]) + community = db.relationship('Community', lazy='joined', overlaps='posts', foreign_keys=[community_id]) replies = db.relationship('PostReply', lazy='dynamic', backref='post') def is_local(self): @@ -647,6 +649,7 @@ class PostReply(db.Model): search_vector = db.Column(TSVectorType('body')) author = db.relationship('User', lazy='joined', foreign_keys=[user_id], single_parent=True, overlaps="post_replies") + community = db.relationship('Community', lazy='joined', overlaps='replies', foreign_keys=[community_id]) def is_local(self): return self.ap_id is None or self.ap_id.startswith('https://' + current_app.config['SERVER_NAME']) diff --git a/app/templates/admin/edit_community.html b/app/templates/admin/edit_community.html index 490e3cc2..ebc6b33b 100644 --- a/app/templates/admin/edit_community.html +++ b/app/templates/admin/edit_community.html @@ -33,10 +33,13 @@ Provide a wide image - letterbox orientation. {{ render_field(form.rules) }} {{ render_field(form.nsfw) }} + {{ render_field(form.restricted_to_mods) }} {% if not community.is_local() %}