announce incoming activites to all following instances

plus a whole lot more, i don't even know
This commit is contained in:
rimu 2024-01-03 16:29:58 +13:00
parent 74cc2d17c0
commit acfe35d98d
23 changed files with 345 additions and 179 deletions

View file

@ -1,3 +1,4 @@
from datetime import timedelta
from typing import Union from typing import Union
from app import db, constants, cache, celery from app import db, constants, cache, celery
@ -6,6 +7,7 @@ from flask import request, Response, current_app, abort, jsonify, json, g
from app.activitypub.signature import HttpSignature, post_request from app.activitypub.signature import HttpSignature, post_request
from app.community.routes import show_community from app.community.routes import show_community
from app.community.util import send_to_remote_instance
from app.post.routes import continue_discussion, show_post from app.post.routes import continue_discussion, show_post
from app.user.routes import show_profile from app.user.routes import show_profile
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, SUBSCRIPTION_MEMBER from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, SUBSCRIPTION_MEMBER
@ -14,12 +16,12 @@ from app.models import User, Community, CommunityJoinRequest, CommunityMember, C
from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \ from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \
post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object, \ post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object, \
lemmy_site_data, instance_weight, is_activitypub_request, downvote_post_reply, downvote_post, upvote_post_reply, \ lemmy_site_data, instance_weight, is_activitypub_request, downvote_post_reply, downvote_post, upvote_post_reply, \
upvote_post, activity_already_ingested, make_image_sizes, delete_post_or_comment, community_members, \ upvote_post, activity_already_ingested, delete_post_or_comment, community_members, \
user_removed_from_remote_server, create_post, create_post_reply, update_post_reply_from_activity, \ user_removed_from_remote_server, create_post, create_post_reply, update_post_reply_from_activity, \
update_post_from_activity, undo_vote, undo_downvote 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, \ 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, can_downvote, \ domain_from_url, markdown_to_html, community_membership, ap_datetime, markdown_to_text, ip_address, can_downvote, \
can_upvote, can_create can_upvote, can_create, awaken_dormant_instance
import werkzeug.exceptions import werkzeug.exceptions
@ -432,7 +434,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
elif user.is_local(): elif user.is_local():
activity_log.exception_message = 'Activity about local content which is already present' activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored' activity_log.result = 'ignored'
elif can_upvote(user, liked): elif can_upvote(user, liked.community):
# insert into voted table # insert into voted table
if liked is None: if liked is None:
activity_log.exception_message = 'Liked object not found' activity_log.exception_message = 'Liked object not found'
@ -466,7 +468,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
elif user.is_local(): elif user.is_local():
activity_log.exception_message = 'Activity about local content which is already present' activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored' activity_log.result = 'ignored'
elif can_downvote(user, disliked, site): elif can_downvote(user, disliked.community, site):
# insert into voted table # insert into voted table
if disliked is None: if disliked is None:
activity_log.exception_message = 'Liked object not found' activity_log.exception_message = 'Liked object not found'
@ -635,22 +637,31 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
activity_log.activity_type = request_json['type'] activity_log.activity_type = request_json['type']
user_ap_id = request_json['actor'] user_ap_id = request_json['actor']
user = find_actor_or_create(user_ap_id) user = find_actor_or_create(user_ap_id)
target_ap_id = request_json['object'] liked = find_liked_object(request_json['object'])
post = None if user is None:
comment = None activity_log.exception_message = 'Blocked or unfound user'
if '/comment/' in target_ap_id: elif user.is_local():
comment = PostReply.query.filter_by(ap_id=target_ap_id).first() activity_log.exception_message = 'Activity about local content which is already present'
if '/post/' in target_ap_id: activity_log.result = 'ignored'
post = Post.query.filter_by(ap_id=target_ap_id).first() elif can_upvote(user, liked.community):
if (user and not user.is_local()) and post and can_upvote(user, post): # insert into voted table
upvote_post(post, user) if liked is None:
activity_log.result = 'success' activity_log.exception_message = 'Liked object not found'
elif (user and not user.is_local()) and comment and can_upvote(user, comment): elif liked is not None and isinstance(liked, Post):
upvote_post_reply(comment, user) upvote_post(liked, user)
activity_log.result = 'success' activity_log.result = 'success'
elif liked is not None and isinstance(liked, PostReply):
upvote_post_reply(liked, user)
activity_log.result = 'success'
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: else:
activity_log.exception_message = 'Could not find user or content for vote' activity_log.exception_message = 'Cannot upvote this'
activity_log.result = 'ignored'
elif request_json['type'] == 'Dislike': # Downvote elif request_json['type'] == 'Dislike': # Downvote
if get_setting('allow_dislike', True) is False: if get_setting('allow_dislike', True) is False:
activity_log.exception_message = 'Dislike ignored because of allow_dislike setting' activity_log.exception_message = 'Dislike ignored because of allow_dislike setting'
@ -659,32 +670,44 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
user_ap_id = request_json['actor'] user_ap_id = request_json['actor']
user = find_actor_or_create(user_ap_id) user = find_actor_or_create(user_ap_id)
target_ap_id = request_json['object'] target_ap_id = request_json['object']
post = None disliked = find_liked_object(target_ap_id)
comment = None if user is None:
if '/comment/' in target_ap_id: activity_log.exception_message = 'Blocked or unfound user'
comment = PostReply.query.filter_by(ap_id=target_ap_id).first() elif user.is_local():
if '/post/' in target_ap_id: activity_log.exception_message = 'Activity about local content which is already present'
post = Post.query.filter_by(ap_id=target_ap_id).first() activity_log.result = 'ignored'
if (user and not user.is_local()) and comment and can_downvote(user, comment, site): elif can_downvote(user, disliked.community, site):
downvote_post_reply(comment, user) # insert into voted table
activity_log.result = 'success' if disliked is None:
elif (user and not user.is_local()) and post and can_downvote(user, post, site): activity_log.exception_message = 'Liked object not found'
downvote_post(post, user) elif isinstance(disliked, (Post, PostReply)):
activity_log.result = 'success' 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'
else: else:
activity_log.exception_message = 'Could not find user or content for vote' activity_log.exception_message = 'Cannot downvote this'
activity_log.result = 'ignored'
# Flush the caches of any major object that was created. To be sure. # Flush the caches of any major object that was created. To be sure.
if 'user' in vars() and user is not None: if 'user' in vars() and user is not None:
user.flush_cache() user.flush_cache()
if user.instance_id: if user.instance_id and user.instance_id != 1:
user.instance.last_seen = utcnow() user.instance.last_seen = utcnow()
user.instance.ip_address = ip_address user.instance.ip_address = ip_address
# if 'community' in vars() and community is not None: user.instance.dormant = False
# community.flush_cache() if 'community' in vars() and community is not None:
if community.is_local() and request_json['type'] not in ['Announce', 'Follow', 'Accept']:
announce_activity_to_followers(community, user, request_json)
# community.flush_cache()
if 'post' in vars() and post is not None: if 'post' in vars() and post is not None:
post.flush_cache() post.flush_cache()
else: else:
activity_log.exception_message = 'Instance banned' activity_log.exception_message = 'Instance blocked'
if activity_log.exception_message is not None and activity_log.result == 'processing': if activity_log.exception_message is not None and activity_log.result == 'processing':
activity_log.result = 'failure' activity_log.result = 'failure'
@ -728,6 +751,31 @@ def process_delete_request(request_json, activitypublog_id, ip_address):
db.session.commit() db.session.commit()
def announce_activity_to_followers(community, creator, activity):
announce_activity = {
'@context': default_context(),
"actor": community.profile_id(),
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"object": activity,
"cc": [
f"{community.profile_id()}/followers"
],
"type": "Announce",
}
for instance in community.following_instances(include_dormant=True):
# awaken dormant instances if they've been sleeping for long enough to be worth trying again
awaken_dormant_instance(instance)
# All good? Send!
if instance and instance.online() and not instance_blocked(instance.inbox):
if creator.instance_id != instance.id: # don't send it to the instance that hosts the creator as presumably they already have the content
announce_activity['id'] = f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}"
send_to_remote_instance(instance.id, community.id, announce_activity)
@bp.route('/c/<actor>/outbox', methods=['GET']) @bp.route('/c/<actor>/outbox', methods=['GET'])
def community_outbox(actor): def community_outbox(actor):
actor = actor.strip() actor = actor.strip()

View file

@ -80,15 +80,19 @@ def post_request(uri: str, body: dict | None, private_key: str, key_id: str, con
method: Literal["get", "post"] = "post", timeout: int = 5,): method: Literal["get", "post"] = "post", timeout: int = 5,):
if '@context' not in body: # add a default json-ld context if necessary if '@context' not in body: # add a default json-ld context if necessary
body['@context'] = default_context() body['@context'] = default_context()
log = ActivityPubLog(direction='out', activity_json=json.dumps(body), type = body['type'] if 'type' in body else ''
result='success', activity_id=body['id']) log = ActivityPubLog(direction='out', activity_json=json.dumps(body), activity_type=type,
result='success', activity_id=body['id'], exception_message='')
try: try:
result = HttpSignature.signed_request(uri, body, private_key, key_id, content_type, method, timeout) result = HttpSignature.signed_request(uri, body, private_key, key_id, content_type, method, timeout)
if result.status_code != 200: if result.status_code != 200 and result.status_code != 202:
log.result = 'failure' log.result = 'failure'
log.exception_message = f'Response status code was {result.status_code}' log.exception_message += f' Response status code was {result.status_code}'
current_app.logger.error('Response code for post attempt was ' + current_app.logger.error('Response code for post attempt was ' +
str(result.status_code) + ' ' + result.text) str(result.status_code) + ' ' + result.text)
log.exception_message += uri
if result.status_code == 202:
log.exception_message += ' 202'
except Exception as e: except Exception as e:
log.result = 'failure' log.result = 'failure'
log.exception_message='could not send:' + str(e) log.exception_message='could not send:' + str(e)

View file

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
from datetime import timedelta from datetime import timedelta
from random import randint from random import randint
@ -161,7 +160,7 @@ def banned_user_agents():
@cache.memoize(150) @cache.memoize(150)
def instance_blocked(host: str) -> bool: def instance_blocked(host: str) -> bool: # see also utils.instance_banned()
host = host.lower() host = host.lower()
if 'https://' in host or 'http://' in host: if 'https://' in host or 'http://' in host:
host = urlparse(host).hostname host = urlparse(host).hostname
@ -299,19 +298,19 @@ def refresh_user_profile_task(user_id):
avatar_changed = cover_changed = False avatar_changed = cover_changed = False
if 'icon' in activity_json: if 'icon' in activity_json:
if activity_json['icon']['url'] != user.avatar.source_url: if user.avatar_id and activity_json['icon']['url'] != user.avatar.source_url:
user.avatar.delete_from_disk() user.avatar.delete_from_disk()
avatar = File(source_url=activity_json['icon']['url']) avatar = File(source_url=activity_json['icon']['url'])
user.avatar = avatar user.avatar = avatar
db.session.add(avatar) db.session.add(avatar)
avatar_changed = True avatar_changed = True
if 'image' in activity_json: if 'image' in activity_json:
if activity_json['image']['url'] != user.cover.source_url: if user.cover_id and activity_json['image']['url'] != user.cover.source_url:
user.cover.delete_from_disk() user.cover.delete_from_disk()
cover = File(source_url=activity_json['image']['url']) cover = File(source_url=activity_json['image']['url'])
user.cover = cover user.cover = cover
db.session.add(cover) db.session.add(cover)
cover_changed = True cover_changed = True
db.session.commit() db.session.commit()
if user.avatar_id and avatar_changed: if user.avatar_id and avatar_changed:
make_image_sizes(user.avatar_id, 40, 250, 'users') make_image_sizes(user.avatar_id, 40, 250, 'users')

View file

@ -102,6 +102,8 @@ def register(app):
staff_role = Role(name='Staff', weight=2) staff_role = Role(name='Staff', weight=2)
staff_role.permissions.append(RolePermission(permission='approve registrations')) staff_role.permissions.append(RolePermission(permission='approve registrations'))
staff_role.permissions.append(RolePermission(permission='ban users')) staff_role.permissions.append(RolePermission(permission='ban users'))
staff_role.permissions.append(RolePermission(permission='administer all communities'))
staff_role.permissions.append(RolePermission(permission='administer all users'))
db.session.add(staff_role) db.session.add(staff_role)
admin_role = Role(name='Admin', weight=3) admin_role = Role(name='Admin', weight=3)
@ -111,6 +113,7 @@ def register(app):
admin_role.permissions.append(RolePermission(permission='manage users')) admin_role.permissions.append(RolePermission(permission='manage users'))
admin_role.permissions.append(RolePermission(permission='change instance settings')) admin_role.permissions.append(RolePermission(permission='change instance settings'))
admin_role.permissions.append(RolePermission(permission='administer all communities')) admin_role.permissions.append(RolePermission(permission='administer all communities'))
admin_role.permissions.append(RolePermission(permission='administer all users'))
db.session.add(admin_role) db.session.add(admin_role)
# Admin user # Admin user

View file

@ -1,10 +1,10 @@
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort, g, json from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort, g, json
from flask_login import login_user, logout_user, current_user, login_required from flask_login import current_user, login_required
from flask_babel import _ from flask_babel import _
from sqlalchemy import or_, desc from sqlalchemy import or_, desc
from app import db, constants, cache from app import db, constants, cache
from app.activitypub.signature import RsaKeys, HttpSignature, post_request from app.activitypub.signature import RsaKeys, post_request
from app.activitypub.util import default_context from app.activitypub.util import default_context
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm, ReportCommunityForm, \ from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm, ReportCommunityForm, \
DeleteCommunityForm DeleteCommunityForm
@ -17,7 +17,7 @@ from app.models import User, Community, CommunityMember, CommunityJoinRequest, C
from app.community import bp from app.community import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ 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, \ shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, community_membership, ap_datetime, \
request_etag_matches, return_304, instance_banned, can_create request_etag_matches, return_304, instance_banned, can_create, can_upvote, can_downvote
from feedgen.feed import FeedGenerator from feedgen.feed import FeedGenerator
from datetime import timezone from datetime import timezone
@ -376,7 +376,7 @@ def add_post(actor):
flash(_('Your post to %(name)s has been made.', name=community.title)) flash(_('Your post to %(name)s has been made.', name=community.title))
else: else:
flash('There was a problem making your post to ' + community.title) flash('There was a problem making your post to ' + community.title)
else: # local community - send post out to followers else: # local community - send (announce) post out to followers
announce = { announce = {
"id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}",
"type": 'Announce', "type": 'Announce',
@ -393,8 +393,8 @@ def add_post(actor):
sent_to = 0 sent_to = 0
for instance in community.following_instances(): for instance in community.following_instances():
if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]): if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
send_to_remote_instance(instance[1], community.id, announce) send_to_remote_instance(instance.id, community.id, announce)
sent_to += 1 sent_to += 1
if sent_to: if sent_to:
flash(_('Your post to %(name)s has been made.', name=community.title)) flash(_('Your post to %(name)s has been made.', name=community.title))

View file

@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timedelta
from threading import Thread from threading import Thread
from time import sleep from time import sleep
from typing import List from typing import List
@ -12,9 +12,10 @@ from app import db, cache, celery
from app.activitypub.signature import post_request from app.activitypub.signature import post_request
from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model
from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE
from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \
Instance
from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, validate_image, allowlist_html, \ from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, validate_image, allowlist_html, \
html_to_markdown, is_image_url, ensure_directory_exists html_to_markdown, is_image_url, ensure_directory_exists, inbox_domain
from sqlalchemy import desc, text from sqlalchemy import desc, text
import os import os
from opengraph_parse import parse_page from opengraph_parse import parse_page
@ -361,15 +362,26 @@ def save_banner_file(banner_file, directory='communities') -> File:
return file return file
def send_to_remote_instance(inbox, community_id, payload): # NB this always signs POSTs as the community so is only suitable for Announce activities
def send_to_remote_instance(instance_id: int, community_id: int, payload):
if current_app.debug: if current_app.debug:
send_to_remote_instance_task(inbox, community_id, payload) send_to_remote_instance_task(instance_id, community_id, payload)
else: else:
send_to_remote_instance_task.delay(inbox, community_id, payload) send_to_remote_instance_task.delay(instance_id, community_id, payload)
@celery.task @celery.task
def send_to_remote_instance_task(inbox, community_id, payload): def send_to_remote_instance_task(instance_id: int, community_id: int, payload):
community = Community.query.get(community_id) community = Community.query.get(community_id)
if community: if community:
post_request(inbox, payload, community.private_key, community.ap_profile_id + '#main-key') instance = Instance.query.get(instance_id)
if post_request(instance.inbox, payload, community.private_key, community.ap_profile_id + '#main-key'):
instance.last_successful_send = utcnow()
instance.failures = 0
else:
instance.failures += 1
instance.most_recent_attempt = utcnow()
instance.start_trying_again = utcnow() + timedelta(seconds=instance.failures ** 4)
if instance.failures > 2:
instance.dormant = True
db.session.commit()

View file

@ -28,6 +28,54 @@ class FullTextSearchQuery(BaseQuery, SearchQueryMixin):
pass pass
class BannedInstances(db.Model):
id = db.Column(db.Integer, primary_key=True)
domain = db.Column(db.String(256), index=True)
reason = db.Column(db.String(256))
initiator = db.Column(db.String(256))
created_at = db.Column(db.DateTime, default=utcnow)
class AllowedInstances(db.Model):
id = db.Column(db.Integer, primary_key=True)
domain = db.Column(db.String(256), index=True)
created_at = db.Column(db.DateTime, default=utcnow)
class Instance(db.Model):
id = db.Column(db.Integer, primary_key=True)
domain = db.Column(db.String(256), index=True)
inbox = db.Column(db.String(256))
shared_inbox = db.Column(db.String(256))
outbox = db.Column(db.String(256))
vote_weight = db.Column(db.Float, default=1.0)
software = db.Column(db.String(50))
version = db.Column(db.String(50))
created_at = db.Column(db.DateTime, default=utcnow)
updated_at = db.Column(db.DateTime, default=utcnow)
last_seen = db.Column(db.DateTime, default=utcnow) # When an Activity was received from them
last_successful_send = db.Column(db.DateTime) # When we successfully sent them an Activity
failures = db.Column(db.Integer, default=0) # How many times we failed to send (reset to 0 after every successful send)
most_recent_attempt = db.Column(db.DateTime) # When the most recent failure was
dormant = db.Column(db.Boolean, default=False, index=True) # True once this instance is considered offline and not worth sending to any more
start_trying_again = db.Column(db.DateTime) # When to start trying again. Should grow exponentially with each failure.
gone_forever = db.Column(db.Boolean, default=False, index=True) # True once this instance is considered offline forever - never start trying again
ip_address = db.Column(db.String(50))
posts = db.relationship('Post', backref='instance', lazy='dynamic')
post_replies = db.relationship('PostReply', backref='instance', lazy='dynamic')
communities = db.relationship('Community', backref='instance', lazy='dynamic')
def online(self):
return not self.dormant and not self.gone_forever
class InstanceBlock(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
instance_id = db.Column(db.Integer, db.ForeignKey('instance.id'), primary_key=True)
created_at = db.Column(db.DateTime, default=utcnow)
class File(db.Model): class File(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
file_path = db.Column(db.String(255)) file_path = db.Column(db.String(255))
@ -209,11 +257,14 @@ class Community(db.Model):
else: else:
return f"https://{current_app.config['SERVER_NAME']}/c/{self.ap_id}" return f"https://{current_app.config['SERVER_NAME']}/c/{self.ap_id}"
# returns a list of tuples (instance.id, instance.inbox) # instances that have users which are members of this community. (excluding the current instance)
def following_instances(self): def following_instances(self, include_dormant=False) -> List[Instance]:
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 ' instances = Instance.query.join(User, User.instance_id == Instance.id).join(CommunityMember, CommunityMember.user_id == User.id)
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' instances = instances.filter(CommunityMember.community_id == self.id, CommunityMember.is_banned == False)
return db.session.execute(text(sql), {'community_id': self.id}) if not include_dormant:
instances = instances.filter(Instance.dormant == False)
instances = instances.filter(Instance.id != 1, Instance.gone_forever == False)
return instances.all()
def delete_dependencies(self): def delete_dependencies(self):
for post in self.posts: for post in self.posts:
@ -316,6 +367,7 @@ class User(UserMixin, db.Model):
else: else:
return '[deleted]' return '[deleted]'
@cache.memoize(timeout=10)
def avatar_thumbnail(self) -> str: def avatar_thumbnail(self) -> str:
if self.avatar_id is not None: if self.avatar_id is not None:
if self.avatar.thumbnail_path is not None: if self.avatar.thumbnail_path is not None:
@ -327,6 +379,7 @@ class User(UserMixin, db.Model):
return self.avatar_image() return self.avatar_image()
return '' return ''
@cache.memoize(timeout=10)
def avatar_image(self) -> str: def avatar_image(self) -> str:
if self.avatar_id is not None: if self.avatar_id is not None:
if self.avatar.file_path is not None: if self.avatar.file_path is not None:
@ -358,6 +411,7 @@ class User(UserMixin, db.Model):
def is_local(self): def is_local(self):
return self.ap_id is None or self.ap_profile_id.startswith('https://' + current_app.config['SERVER_NAME']) return self.ap_id is None or self.ap_profile_id.startswith('https://' + current_app.config['SERVER_NAME'])
@cache.memoize(timeout=30)
def is_admin(self): def is_admin(self):
for role in self.roles: for role in self.roles:
if role.name == 'Admin': if role.name == 'Admin':
@ -748,54 +802,6 @@ class UserBlock(db.Model):
created_at = db.Column(db.DateTime, default=utcnow) created_at = db.Column(db.DateTime, default=utcnow)
class BannedInstances(db.Model):
id = db.Column(db.Integer, primary_key=True)
domain = db.Column(db.String(256), index=True)
reason = db.Column(db.String(256))
initiator = db.Column(db.String(256))
created_at = db.Column(db.DateTime, default=utcnow)
class AllowedInstances(db.Model):
id = db.Column(db.Integer, primary_key=True)
domain = db.Column(db.String(256), index=True)
created_at = db.Column(db.DateTime, default=utcnow)
class Instance(db.Model):
id = db.Column(db.Integer, primary_key=True)
domain = db.Column(db.String(256), index=True)
inbox = db.Column(db.String(256))
shared_inbox = db.Column(db.String(256))
outbox = db.Column(db.String(256))
vote_weight = db.Column(db.Float, default=1.0)
software = db.Column(db.String(50))
version = db.Column(db.String(50))
created_at = db.Column(db.DateTime, default=utcnow)
updated_at = db.Column(db.DateTime, default=utcnow)
last_seen = db.Column(db.DateTime, default=utcnow) # When an Activity was received from them
last_successful_send = db.Column(db.DateTime) # When we successfully sent them an Activity
failures = db.Column(db.Integer, default=0) # How many times we failed to send (reset to 0 after every successful send)
most_recent_attempt = db.Column(db.DateTime) # When the most recent failure was
dormant = db.Column(db.Boolean, default=False) # True once this instance is considered offline and not worth sending to any more
start_trying_again = db.Column(db.DateTime) # When to start trying again. Should grow exponentially with each failure.
gone_forever = db.Column(db.Boolean, default=False) # True once this instance is considered offline forever - never start trying again
ip_address = db.Column(db.String(50))
posts = db.relationship('Post', backref='instance', lazy='dynamic')
post_replies = db.relationship('PostReply', backref='instance', lazy='dynamic')
communities = db.relationship('Community', backref='instance', lazy='dynamic')
def online(self):
return not self.dormant and not self.gone_forever
class InstanceBlock(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
instance_id = db.Column(db.Integer, db.ForeignKey('instance.id'), primary_key=True)
created_at = db.Column(db.DateTime, default=utcnow)
class Settings(db.Model): class Settings(db.Model):
name = db.Column(db.String(50), primary_key=True) name = db.Column(db.String(50), primary_key=True)
value = db.Column(db.String(1024)) value = db.Column(db.String(1024))

View file

@ -14,17 +14,18 @@ from app.community.forms import CreatePostForm
from app.post.util import post_replies, get_comment_branch, post_reply_count from app.post.util import post_replies, get_comment_branch, post_reply_count
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
from app.models import Post, PostReply, \ from app.models import Post, PostReply, \
PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community
from app.post import bp from app.post import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ 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, \ shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, ap_datetime, return_304, \
request_etag_matches, ip_address, user_ip_banned, instance_banned request_etag_matches, ip_address, user_ip_banned, instance_banned, can_downvote, can_upvote
def show_post(post_id: int): def show_post(post_id: int):
post = Post.query.get_or_404(post_id) post = Post.query.get_or_404(post_id)
community: Community = post.community
if post.community.banned: if community.banned:
abort(404) abort(404)
# If nothing has changed since their last visit, return HTTP 304 # If nothing has changed since their last visit, return HTTP 304
@ -35,7 +36,7 @@ def show_post(post_id: int):
if post.mea_culpa: if post.mea_culpa:
flash(_('%(name)s has indicated they made a mistake in this post.', name=post.author.user_name), 'warning') flash(_('%(name)s has indicated they made a mistake in this post.', name=post.author.user_name), 'warning')
mods = post.community.moderators() mods = community.moderators()
is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods) is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods)
# handle top-level comments/replies # handle top-level comments/replies
@ -53,7 +54,7 @@ def show_post(post_id: int):
resp.set_cookie('sesion', '17489047567495', expires=datetime(year=2099, month=12, day=30)) resp.set_cookie('sesion', '17489047567495', expires=datetime(year=2099, month=12, day=30))
return resp return resp
reply = PostReply(user_id=current_user.id, post_id=post.id, community_id=post.community.id, body=form.body.data, reply = PostReply(user_id=current_user.id, post_id=post.id, community_id=community.id, body=form.body.data,
body_html=markdown_to_html(form.body.data), body_html_safe=True, body_html=markdown_to_html(form.body.data), body_html_safe=True,
from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl, from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl,
notify_author=form.notify_author.data) notify_author=form.notify_author.data)
@ -61,9 +62,9 @@ def show_post(post_id: int):
notification = Notification(title=_('Reply: ') + shorten_string(form.body.data, 42), user_id=post.user_id, notification = Notification(title=_('Reply: ') + shorten_string(form.body.data, 42), user_id=post.user_id,
author_id=current_user.id, url=url_for('activitypub.post_ap', post_id=post.id)) author_id=current_user.id, url=url_for('activitypub.post_ap', post_id=post.id))
db.session.add(notification) db.session.add(notification)
post.last_active = post.community.last_active = utcnow() post.last_active = community.last_active = utcnow()
post.reply_count += 1 post.reply_count += 1
post.community.post_reply_count += 1 community.post_reply_count += 1
db.session.add(reply) db.session.add(reply)
db.session.commit() db.session.commit()
@ -96,7 +97,7 @@ def show_post(post_id: int):
'https://www.w3.org/ns/activitystreams#Public' 'https://www.w3.org/ns/activitystreams#Public'
], ],
'cc': [ 'cc': [
post.community.profile_id(), community.profile_id(),
], ],
'content': reply.body_html, 'content': reply.body_html,
'inReplyTo': post.profile_id(), 'inReplyTo': post.profile_id(),
@ -107,23 +108,23 @@ def show_post(post_id: int):
}, },
'published': ap_datetime(utcnow()), 'published': ap_datetime(utcnow()),
'distinguished': False, 'distinguished': False,
'audience': post.community.profile_id() 'audience': community.profile_id()
} }
create_json = { create_json = {
'type': 'Create', 'type': 'Create',
'actor': current_user.profile_id(), 'actor': current_user.profile_id(),
'audience': post.community.profile_id(), 'audience': community.profile_id(),
'to': [ 'to': [
'https://www.w3.org/ns/activitystreams#Public' 'https://www.w3.org/ns/activitystreams#Public'
], ],
'cc': [ 'cc': [
post.community.ap_profile_id community.ap_profile_id
], ],
'object': reply_json, 'object': reply_json,
'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}" 'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}"
} }
if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it if not community.is_local(): # this is a remote community, send it to the instance that hosts it
success = post_request(post.community.ap_inbox_url, create_json, current_user.private_key, success = post_request(community.ap_inbox_url, create_json, current_user.private_key,
current_user.ap_profile_id + '#main-key') current_user.ap_profile_id + '#main-key')
if not success: if not success:
flash('Failed to send to remote instance', 'error') flash('Failed to send to remote instance', 'error')
@ -134,17 +135,17 @@ def show_post(post_id: int):
"to": [ "to": [
"https://www.w3.org/ns/activitystreams#Public" "https://www.w3.org/ns/activitystreams#Public"
], ],
"actor": post.community.ap_profile_id, "actor": community.ap_profile_id,
"cc": [ "cc": [
post.community.ap_followers_url community.ap_followers_url
], ],
'@context': default_context(), '@context': default_context(),
'object': create_json 'object': create_json
} }
for instance in post.community.following_instances(): for instance in community.following_instances():
if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]): if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
send_to_remote_instance(instance[1], post.community.id, announce) send_to_remote_instance(instance.id, community.id, announce)
return redirect(url_for('activitypub.post_ap', post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form return redirect(url_for('activitypub.post_ap', post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form
else: else:
@ -158,7 +159,7 @@ def show_post(post_id: int):
canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH, 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, 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, POST_TYPE_LINK=constants.POST_TYPE_LINK, POST_TYPE_ARTICLE=constants.POST_TYPE_ARTICLE,
etag=f"{post.id}_{hash(post.last_active)}", markdown_editor=True) etag=f"{post.id}_{hash(post.last_active)}", markdown_editor=True, community=community)
@bp.route('/post/<int:post_id>/<vote_direction>', methods=['GET', 'POST']) @bp.route('/post/<int:post_id>/<vote_direction>', methods=['GET', 'POST'])
@ -235,8 +236,8 @@ def post_vote(post_id: int, vote_direction):
'object': action_json 'object': action_json
} }
for instance in post.community.following_instances(): for instance in post.community.following_instances():
if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]): if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
send_to_remote_instance(instance[1], post.community.id, announce) send_to_remote_instance(instance.id, post.community.id, announce)
else: else:
success = post_request(post.community.ap_inbox_url, action_json, current_user.private_key, success = post_request(post.community.ap_inbox_url, action_json, current_user.private_key,
current_user.ap_profile_id + '#main-key') current_user.ap_profile_id + '#main-key')
@ -472,8 +473,8 @@ def add_reply(post_id: int, comment_id: int):
} }
for instance in post.community.following_instances(): for instance in post.community.following_instances():
if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]): if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
send_to_remote_instance(instance[1], post.community.id, announce) send_to_remote_instance(instance.id, post.community.id, announce)
if reply.depth <= constants.THREAD_CUTOFF_DEPTH: if reply.depth <= constants.THREAD_CUTOFF_DEPTH:
return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.parent_id}')) return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.parent_id}'))
@ -552,6 +553,27 @@ def post_delete(post_id: int):
g.site.last_active = community.last_active = utcnow() g.site.last_active = community.last_active = utcnow()
db.session.commit() db.session.commit()
flash(_('Post deleted.')) flash(_('Post deleted.'))
if community.is_local():
delete_activity = {
'@context': default_context(),
'actor': current_user.profile_id(),
'to': [
'https://www.w3.org/ns/activitystreams#Public'
],
'object': post.ap_id,
'type': 'Delete',
'id': f"https://{current_app.config['SERVER_NAME']}/activities/delete/{gibberish(15)}",
'audience': community.profile_id(),
'cc': [
community.profile_id()
]
}
for instance in post.community.following_instances():
if instance.inbox and not instance_banned(instance.domain):
send_to_remote_instance(instance.id, post.community.id, announce)
return redirect(url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name)) return redirect(url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name))

View file

@ -41,6 +41,10 @@
content: "\ea03"; content: "\ea03";
} }
.fe-external::before {
content: "\e95b";
}
.fe-circle::before { .fe-circle::before {
content: "\e937"; content: "\e937";
} }

View file

@ -68,6 +68,10 @@ nav, etc which are used site-wide */
content: "\ea03"; content: "\ea03";
} }
.fe-external::before {
content: "\e95b";
}
.fe-circle::before { .fe-circle::before {
content: "\e937"; content: "\e937";
} }
@ -544,6 +548,18 @@ fieldset legend {
padding-top: 0; padding-top: 0;
} }
.post_body img {
max-height: 40vh;
max-width: 100%;
height: auto;
}
.comment_body img {
max-height: 40vh;
max-width: 100%;
height: auto;
}
#replies { #replies {
scroll-margin-top: 5em; scroll-margin-top: 5em;
} }

View file

@ -224,6 +224,23 @@ nav, etc which are used site-wide */
} }
} }
.post_body {
img {
max-height: 40vh;
max-width: 100%;
height: auto;
}
}
.comment_body {
img {
max-height: 40vh;
max-width: 100%;
height: auto;
}
}
#replies { #replies {
scroll-margin-top: 5em; scroll-margin-top: 5em;
} }

View file

@ -67,6 +67,10 @@
content: "\ea03"; content: "\ea03";
} }
.fe-external::before {
content: "\e95b";
}
.fe-circle::before { .fe-circle::before {
content: "\e937"; content: "\e937";
} }

View file

@ -23,6 +23,7 @@
<th>Attitude</th> <th>Attitude</th>
<th>Banned</th> <th>Banned</th>
<th>Reports</th> <th>Reports</th>
<th>IP</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
{% for user in users %} {% for user in users %}
@ -33,6 +34,7 @@
<td>{{ user.attitude * 100 }}</td> <td>{{ user.attitude * 100 }}</td>
<td>{{ '<span class="red">Banned</span>'|safe if user.banned }} </td> <td>{{ '<span class="red">Banned</span>'|safe if user.banned }} </td>
<td>{{ user.reports if user.reports > 0 }} </td> <td>{{ user.reports if user.reports > 0 }} </td>
<td>{{ user.ip_address if user.ip_address }} </td>
<td><a href="/u/{{ user.link() }}">View local</a> | <td><a href="/u/{{ user.link() }}">View local</a> |
{% if not user.is_local() %} {% if not user.is_local() %}
<a href="{{ user.ap_profile_id }}">View remote</a> | <a href="{{ user.ap_profile_id }}">View remote</a> |

View file

@ -1,6 +1,6 @@
<div class="row position-relative"> <div class="row position-relative">
{% if post.type == POST_TYPE_IMAGE %} {% if post.type == POST_TYPE_IMAGE %}
<div class="col post_type_image"> <div class="col post_col post_type_image">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation"> <nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li> <li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
@ -12,7 +12,7 @@
<div class="voting_buttons"> <div class="voting_buttons">
{% include "post/_post_voting_buttons.html" %} {% include "post/_post_voting_buttons.html" %}
</div> </div>
<h1 class="mt-2">{{ post.title }}</h1> <h1 class="mt-2 post_title">{{ post.title }}</h1>
{% if post.url %} {% if post.url %}
<p><small><a href="{{ post.url }}" rel="nofollow ugc">{{ post.url|shorten_url }} <p><small><a href="{{ post.url }}" rel="nofollow ugc">{{ post.url|shorten_url }}
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" /> <img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" />
@ -29,7 +29,7 @@
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="col"> <div class="col post_col post_type_normal">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation"> <nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li> <li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
@ -41,7 +41,7 @@
<div class="voting_buttons"> <div class="voting_buttons">
{% include "post/_post_voting_buttons.html" %} {% include "post/_post_voting_buttons.html" %}
</div> </div>
<h1 class="mt-2">{{ post.title }}</h1> <h1 class="mt-2 post_title">{{ post.title }}</h1>
{% if post.type == POST_TYPE_LINK and post.image_id and not (post.url and 'youtube.com' in post.url) %} {% if post.type == POST_TYPE_LINK and post.image_id and not (post.url and 'youtube.com' in post.url) %}
<div class="url_thumbnail"> <div class="url_thumbnail">
<a href="{{ post.url }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}" <a href="{{ post.url }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
@ -72,7 +72,9 @@
width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" /></a> width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" /></a>
{% endif %} {% endif %}
{% endif %} {% endif %}
{{ post.body_html|safe if post.body_html else '' }} <div class="post_body">
{{ post.body_html|safe if post.body_html else '' }}
</div>
</div> </div>
{% endif %} {% endif %}
<a href="{{ url_for('post.post_options', post_id=post.id) }}" class="post_options_link" rel="nofollow"><span class="fe fe-options" title="Options"> </span></a> <a href="{{ url_for('post.post_options', post_id=post.id) }}" class="post_options_link" rel="nofollow"><span class="fe fe-options" title="Options"> </span></a>

View file

@ -3,7 +3,7 @@
<div class="col-12"> <div class="col-12">
<div class="row main_row"> <div class="row main_row">
<div class="col"> <div class="col">
<h3> <h3 class="post_teaser_title">
<div class="voting_buttons"> <div class="voting_buttons">
{% include "post/_post_voting_buttons.html" %} {% include "post/_post_voting_buttons.html" %}
</div> </div>
@ -18,7 +18,7 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
<a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}">{{ post.title }}</a> <a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}" class="post_teaser_title_a">{{ post.title }}</a>
{% if post.type == POST_TYPE_IMAGE %}<span class="fe fe-image"> </span>{% endif %} {% if post.type == POST_TYPE_IMAGE %}<span class="fe fe-image"> </span>{% endif %}
{% if post.type == POST_TYPE_LINK and post.domain_id %} {% if post.type == POST_TYPE_LINK and post.domain_id %}
{% if post.url and 'youtube.com' in post.url %} {% if post.url and 'youtube.com' in post.url %}

View file

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

View file

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

View file

@ -53,15 +53,15 @@
<h2>{{ _('About community') }}</h2> <h2>{{ _('About community') }}</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<p>{{ post.community.description|safe }}</p> <p>{{ post.community.description_html|safe if post.community.description_html else '' }}</p>
<p>{{ post.community.rules|safe }}</p> <p>{{ post.community.rules_html|safe if post.community.rules_html else '' }}</p>
{% if len(mods) > 0 and not post.community.private_mods %} {% if len(mods) > 0 and not post.community.private_mods %}
<h3>Moderators</h3> <h3>Moderators</h3>
<ol> <ul class="moderator_list">
{% for mod in mods %} {% for mod in mods %}
<li><a href="/u/{{ mod.user_name }}">{{ mod.display_name() }}</a></li> <li><a href="/u/{{ mod.user_name }}">{{ mod.display_name() }}</a></li>
{% endfor %} {% endfor %}
</ol> </ul>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -83,15 +83,15 @@
<h2>{{ _('About community') }}</h2> <h2>{{ _('About community') }}</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<p>{{ post.community.description|safe }}</p> <p>{{ post.community.description_html|safe if post.community.description_html else '' }}</p>
<p>{{ post.community.rules|safe }}</p> <p>{{ post.community.rules_html|safe if post.community.rules_html else '' }}</p>
{% if len(mods) > 0 and not post.community.private_mods %} {% if len(mods) > 0 and not post.community.private_mods %}
<h3>Moderators</h3> <h3>Moderators</h3>
<ol> <ul class="moderator_list">
{% for mod in mods %} {% for mod in mods %}
<li><a href="/u/{{ mod.user_name }}">{{ mod.display_name() }}</a></li> <li><a href="/u/{{ mod.user_name }}">{{ mod.display_name() }}</a></li>
{% endfor %} {% endfor %}
</ol> </ul>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -30,6 +30,10 @@
<li><a href="{{ url_for('post.post_block_instance', post_id=post.id) }}" class="no-underline"><span class="fe fe-block"></span> <li><a href="{{ url_for('post.post_block_instance', post_id=post.id) }}" class="no-underline"><span class="fe fe-block"></span>
{{ _("Hide every post from author's instance: %(name)s", name=post.instance.domain) }}</a></li> {{ _("Hide every post from author's instance: %(name)s", name=post.instance.domain) }}</a></li>
{% endif %} {% endif %}
{% if post.ap_id %}
<li><a href="{{ post.ap_id }}" rel="nofollow" class="no-underline"><span class="fe fe-external"></span>
{{ _('View original on %(domain)s', domain=post.instance.domain) }}</a></li>
{% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
<li><a href="{{ url_for('post.post_report', post_id=post.id) }}" class="no-underline" rel="nofollow"><span class="fe fe-report"></span> <li><a href="{{ url_for('post.post_report', post_id=post.id) }}" class="no-underline" rel="nofollow"><span class="fe fe-report"></span>

View file

@ -41,10 +41,14 @@ class DeleteAccountForm(FlaskForm):
class ReportUserForm(FlaskForm): class ReportUserForm(FlaskForm):
reason_choices = [('1', _l('Breaks community rules')), ('7', _l('Spam')), ('2', _l('Harassment')), reason_choices = [('1', _l('Breaks community rules')),
('3', _l('Threatening violence')), ('4', _l('Hate / genocide')), ('7', _l('Spam')),
('2', _l('Harassment')),
('3', _l('Threatening violence')),
('4', _l('Promoting hate / genocide')),
('15', _l('Misinformation / disinformation')), ('15', _l('Misinformation / disinformation')),
('16', _l('Racism, sexism, transphobia')), ('16', _l('Racism, sexism, transphobia')),
('17', _l('Malicious reporting')),
('6', _l('Sharing personal info - doxing')), ('6', _l('Sharing personal info - doxing')),
('5', _l('Minor abuse or sexualization')), ('5', _l('Minor abuse or sexualization')),
('8', _l('Non-consensual intimate media')), ('8', _l('Non-consensual intimate media')),

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import random import random
from datetime import datetime from datetime import datetime, timedelta
from typing import List, Literal, Union from typing import List, Literal, Union
import markdown2 import markdown2
@ -22,7 +22,7 @@ from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput
from app import db, cache 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 Site, Post, PostReply, utcnow
# Flask's render_template function, with support for themes added # Flask's render_template function, with support for themes added
@ -152,7 +152,7 @@ def is_image_url(url):
# sanitise HTML using an allow list # sanitise HTML using an allow list
def allowlist_html(html: str) -> str: def allowlist_html(html: str) -> str:
allowed_tags = ['p', 'strong', 'a', 'ul', 'ol', 'li', 'em', 'blockquote', 'cite', 'br', 'h3', 'h4', 'h5', 'pre', allowed_tags = ['p', 'strong', 'a', 'ul', 'ol', 'li', 'em', 'blockquote', 'cite', 'br', 'h3', 'h4', 'h5', 'pre',
'code'] 'code', 'img']
# Parse the HTML using BeautifulSoup # Parse the HTML using BeautifulSoup
soup = BeautifulSoup(html, 'html.parser') soup = BeautifulSoup(html, 'html.parser')
@ -164,7 +164,7 @@ def allowlist_html(html: str) -> str:
else: else:
# Filter and sanitize attributes # Filter and sanitize attributes
for attr in list(tag.attrs): for attr in list(tag.attrs):
if attr not in ['href', 'src']: # Add allowed attributes here if attr not in ['href', 'src', 'alt']: # Add allowed attributes here
del tag[attr] del tag[attr]
# Encode the HTML to prevent script execution # Encode the HTML to prevent script execution
@ -361,7 +361,8 @@ def user_ip_banned() -> bool:
return current_ip_address in banned_ip_addresses() return current_ip_address in banned_ip_addresses()
def instance_banned(domain: str) -> bool: @cache.memoize(timeout=30)
def instance_banned(domain: str) -> bool: # see also activitypub.util.instance_blocked()
banned = BannedInstances.query.filter_by(domain=domain).first() banned = BannedInstances.query.filter_by(domain=domain).first()
return banned is not None return banned is not None
@ -377,8 +378,8 @@ def banned_ip_addresses() -> List[str]:
return [ip.ip_address for ip in ips] return [ip.ip_address for ip in ips]
def can_downvote(user, content: Union[Post, PostReply], site=None) -> bool: def can_downvote(user, community: Community, site=None) -> bool:
if user is None or content is None or user.banned: if user is None or community is None or user.banned:
return False return False
if site is None: if site is None:
@ -387,25 +388,22 @@ def can_downvote(user, content: Union[Post, PostReply], site=None) -> bool:
except: except:
site = Site.query.get(1) site = Site.query.get(1)
if not site.enable_downvotes and content.community.is_local(): if not site.enable_downvotes and community.is_local():
return False return False
if content.community.is_moderator(user) or user.is_admin(): if community.local_only and not user.is_local():
return True return False
if content.community.local_only and not user.is_local(): if user.attitude < 0.33:
return False return False
return True return True
def can_upvote(user, content: Union[Post, PostReply]) -> bool: def can_upvote(user, community: Community) -> bool:
if user is None or content is None or user.banned: if user is None or community is None or user.banned:
return False return False
if content.community.is_moderator(user) or user.is_admin():
return True
return True return True
@ -436,3 +434,23 @@ def can_create(user, content: Union[Community, Post, PostReply]) -> bool:
return False return False
return True return True
def inbox_domain(inbox: str) -> str:
inbox = inbox.lower()
if 'https://' in inbox or 'http://' in inbox:
inbox = urlparse(inbox).hostname
return inbox
def awaken_dormant_instance(instance):
if instance and not instance.gone_forever:
if instance.dormant:
if instance.start_trying_again < utcnow():
instance.dormant = False
db.session.commit()
# give up after ~5 days of trying
if instance.start_trying_again and utcnow() + timedelta(days=5) < instance.start_trying_again:
instance.gone_forever = True
instance.dormant = True
db.session.commit()

View file

@ -23,6 +23,7 @@ class Config(object):
LANGUAGES = ['en'] LANGUAGES = ['en']
FULL_AP_CONTEXT = os.environ.get('FULL_AP_CONTEXT') or True FULL_AP_CONTEXT = os.environ.get('FULL_AP_CONTEXT') or True
CACHE_TYPE = os.environ.get('CACHE_TYPE') or 'FileSystemCache' CACHE_TYPE = os.environ.get('CACHE_TYPE') or 'FileSystemCache'
CACHE_REDIS_URL = os.environ.get('CACHE_REDIS_URL') or 'redis://localhost:6379/1'
CACHE_DIR = os.environ.get('CACHE_DIR') or '/dev/shm/pyfedi' CACHE_DIR = os.environ.get('CACHE_DIR') or '/dev/shm/pyfedi'
CACHE_DEFAULT_TIMEOUT = 300 CACHE_DEFAULT_TIMEOUT = 300
CACHE_THRESHOLD = 1000 CACHE_THRESHOLD = 1000