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() %}
{{ _('Will not be overwritten by remote server') }} {% endif %} + {{ render_field(form.local_only) }} + {{ render_field(form.new_mods_wanted) }} {{ render_field(form.show_home) }} {{ render_field(form.show_popular) }} {{ render_field(form.show_all) }} diff --git a/app/templates/post/_post_voting_buttons.html b/app/templates/post/_post_voting_buttons.html index aae632bc..22ee2aa9 100644 --- a/app/templates/post/_post_voting_buttons.html +++ b/app/templates/post/_post_voting_buttons.html @@ -1,16 +1,20 @@ {% if current_user.is_authenticated and current_user.verified %} -
- - {{ post.up_votes }} - -
-
- - {{ post.down_votes }} - -
+ {% if can_upvote(current_user, post) %} +
+ + {{ post.up_votes }} + +
+ {% endif %} + {% if can_downvote(current_user, post) %} +
+ + {{ post.down_votes }} + +
+ {% endif %} {% else %}
diff --git a/app/templates/post/_voting_buttons.html b/app/templates/post/_voting_buttons.html index 79967583..9c83384b 100644 --- a/app/templates/post/_voting_buttons.html +++ b/app/templates/post/_voting_buttons.html @@ -1,16 +1,20 @@ {% if current_user.is_authenticated and current_user.verified %} -
- - {{ comment.up_votes }} - -
-
- - {{ comment.down_votes }} - -
+ {% if can_upvote(current_user, comment) %} +
+ + {{ comment.up_votes }} + +
+ {% endif %} + {% if can_downvote(current_user, comment) %} +
+ + {{ comment.down_votes }} + +
+ {% endif %} {% else %}
diff --git a/app/templates/post/post_mea_culpa.html b/app/templates/post/post_mea_culpa.html index f70d73e7..3a630fe9 100644 --- a/app/templates/post/post_mea_culpa.html +++ b/app/templates/post/post_mea_culpa.html @@ -10,6 +10,7 @@

{{ _('If you wish to de-escalate the discussion on your post and now feel like it was a mistake, click the button below.') }}

{{ _('No further comments will be posted and a message saying you made a mistake in this post will be displayed.') }}

+

More about this

{{ render_form(form) }}
diff --git a/app/utils.py b/app/utils.py index 81e33564..0be0a7f3 100644 --- a/app/utils.py +++ b/app/utils.py @@ -2,7 +2,7 @@ from __future__ import annotations import random from datetime import datetime -from typing import List, Literal +from typing import List, Literal, Union import markdown2 import math @@ -14,14 +14,15 @@ from bs4 import BeautifulSoup import requests import os import imghdr -from flask import current_app, json, redirect, url_for, request, make_response, Response +from flask import current_app, json, redirect, url_for, request, make_response, Response, g from flask_login import current_user from sqlalchemy import text from wtforms.fields import SelectField, SelectMultipleField from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput from app import db, cache -from app.models import Settings, Domain, Instance, BannedInstances, User, Community, DomainBlock, ActivityPubLog, IpBan +from app.models import Settings, Domain, Instance, BannedInstances, User, Community, DomainBlock, ActivityPubLog, IpBan, \ + Site, Post, PostReply # Flask's render_template function, with support for themes added @@ -374,3 +375,64 @@ def user_cookie_banned() -> bool: def banned_ip_addresses() -> List[str]: ips = IpBan.query.all() return [ip.ip_address for ip in ips] + + +def can_downvote(user, content: Union[Post, PostReply], site=None) -> bool: + if user is None or content is None or user.banned: + return False + + if site is None: + try: + site = g.site + except: + site = Site.query.get(1) + + if not site.enable_downvotes and content.community.is_local(): + return False + + if content.community.is_moderator(user) or user.is_admin(): + return True + + if content.community.local_only and not user.is_local(): + return False + + return True + + +def can_upvote(user, content: Union[Post, PostReply]) -> bool: + if user is None or content is None or user.banned: + return False + + if content.community.is_moderator(user) or user.is_admin(): + return True + + return True + + +def can_create(user, content: Union[Community, Post, PostReply]) -> bool: + if user is None or content is None or user.banned: + return False + + if isinstance(content, Community): + if content.is_moderator(user) or user.is_admin(): + return True + + if content.restricted_to_mods: + return False + + if content.local_only and not user.is_local(): + return False + else: + if content.community.is_moderator(user) or user.is_admin(): + return True + + if content.community.restricted_to_mods and isinstance(content, Post): + return False + + if content.community.local_only and not user.is_local(): + return False + + if isinstance(content, PostReply) and content.post.comments_enabled is False: + return False + + return True diff --git a/migrations/versions/328c9990e53e_local_only_communities.py b/migrations/versions/328c9990e53e_local_only_communities.py new file mode 100644 index 00000000..ec81d25b --- /dev/null +++ b/migrations/versions/328c9990e53e_local_only_communities.py @@ -0,0 +1,32 @@ +"""local only communities + +Revision ID: 328c9990e53e +Revises: b18ea0b841fe +Create Date: 2024-01-02 16:10:04.201183 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '328c9990e53e' +down_revision = 'b18ea0b841fe' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('community', schema=None) as batch_op: + batch_op.add_column(sa.Column('local_only', sa.Boolean(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('community', schema=None) as batch_op: + batch_op.drop_column('local_only') + + # ### end Alembic commands ### diff --git a/pyfedi.py b/pyfedi.py index b5438e33..0da039e0 100644 --- a/pyfedi.py +++ b/pyfedi.py @@ -7,7 +7,8 @@ import os, click from flask import session, g, json from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE from app.models import Site -from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits, user_access, community_membership +from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits, user_access, community_membership, \ + can_create, can_upvote, can_downvote app = create_app() cli.register(app) @@ -33,6 +34,9 @@ with app.app_context(): app.jinja_env.globals['community_membership'] = community_membership app.jinja_env.globals['json_loads'] = json.loads app.jinja_env.globals['user_access'] = user_access + app.jinja_env.globals['can_create'] = can_create + app.jinja_env.globals['can_upvote'] = can_upvote + app.jinja_env.globals['can_downvote'] = can_downvote app.jinja_env.filters['shorten'] = shorten_string app.jinja_env.filters['shorten_url'] = shorten_url