communities that are local-only, w access control for posting and voting

This commit is contained in:
rimu 2024-01-02 19:41:00 +13:00
parent 520db4a924
commit 74cc2d17c0
13 changed files with 202 additions and 63 deletions

View file

@ -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:

View file

@ -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:]}",

View file

@ -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')

View file

@ -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

View file

@ -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

View file

@ -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'])

View file

@ -33,10 +33,13 @@
<small class="field_hint">Provide a wide image - letterbox orientation.</small>
{{ render_field(form.rules) }}
{{ render_field(form.nsfw) }}
{{ render_field(form.restricted_to_mods) }}
{% if not community.is_local() %}
<fieldset class="border pl-2 pt-2 mb-4">
<legend>{{ _('Will not be overwritten by remote server') }}</legend>
{% 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) }}

View file

@ -1,16 +1,20 @@
{% if current_user.is_authenticated and current_user.verified %}
<div class="upvote_button digits_{{ digits(post.up_votes) }} {{ upvoted_class }}"
hx-post="/post/{{ post.id }}/upvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons">
<span class="fe fe-arrow-up"></span>
{{ post.up_votes }}
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
</div>
<div class="downvote_button digits_{{ digits(post.down_votes) }} {{ downvoted_class }}"
hx-post="/post/{{ post.id }}/downvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons">
<span class="fe fe-arrow-down"></span>
{{ post.down_votes }}
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
</div>
{% if can_upvote(current_user, post) %}
<div class="upvote_button digits_{{ digits(post.up_votes) }} {{ upvoted_class }}"
hx-post="/post/{{ post.id }}/upvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons">
<span class="fe fe-arrow-up"></span>
{{ post.up_votes }}
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
</div>
{% endif %}
{% if can_downvote(current_user, post) %}
<div class="downvote_button digits_{{ digits(post.down_votes) }} {{ downvoted_class }}"
hx-post="/post/{{ post.id }}/downvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons">
<span class="fe fe-arrow-down"></span>
{{ post.down_votes }}
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
</div>
{% endif %}
{% else %}
<div class="upvote_button digits_{{ digits(post.up_votes) }} {{ upvoted_class }}">
<span class="fe fe-arrow-up"></span>

View file

@ -1,16 +1,20 @@
{% if current_user.is_authenticated and current_user.verified %}
<div class="upvote_button digits_{{ digits(comment.up_votes) }} {{ upvoted_class }}"
hx-post="/comment/{{ comment.id }}/upvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons">
<span class="fe fe-arrow-up"></span>
{{ comment.up_votes }}
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
</div>
<div class="downvote_button digits_{{ digits(comment.down_votes) }} {{ downvoted_class }}"
hx-post="/comment/{{ comment.id }}/downvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons">
<span class="fe fe-arrow-down"></span>
{{ comment.down_votes }}
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
</div>
{% if can_upvote(current_user, comment) %}
<div class="upvote_button digits_{{ digits(comment.up_votes) }} {{ upvoted_class }}"
hx-post="/comment/{{ comment.id }}/upvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons">
<span class="fe fe-arrow-up"></span>
{{ comment.up_votes }}
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
</div>
{% endif %}
{% if can_downvote(current_user, comment) %}
<div class="downvote_button digits_{{ digits(comment.down_votes) }} {{ downvoted_class }}"
hx-post="/comment/{{ comment.id }}/downvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons">
<span class="fe fe-arrow-down"></span>
{{ comment.down_votes }}
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
</div>
{% endif %}
{% else %}
<div class="upvote_button digits_{{ digits(comment.up_votes) }} {{ upvoted_class }}">
<span class="fe fe-arrow-up"></span>

View file

@ -10,6 +10,7 @@
<div class="card-body">
<p>{{ _('If you wish to de-escalate the discussion on your post and now feel like it was a mistake, click the button below.') }}</p>
<p>{{ _('No further comments will be posted and a message saying you made a mistake in this post will be displayed.') }}</p>
<!-- <p>{{ _('The effect of downvotes on your reputation score will be removed.') }}</p> -->
<p><a href="https://nickpunt.com/blog/deescalating-social-media/" target="_blank">More about this</a></p>
{{ render_form(form) }}
</div>

View file

@ -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

View file

@ -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 ###

View file

@ -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