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 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.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.user.routes import show_profile
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, \
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, \
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, \
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, can_downvote, \
can_upvote, can_create
can_upvote, can_create, awaken_dormant_instance
import werkzeug.exceptions
@ -432,7 +434,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
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):
elif can_upvote(user, liked.community):
# insert into voted table
if liked is None:
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():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif can_downvote(user, disliked, site):
elif can_downvote(user, disliked.community, site):
# insert into voted table
if disliked is None:
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']
user_ap_id = request_json['actor']
user = find_actor_or_create(user_ap_id)
target_ap_id = request_json['object']
post = None
comment = None
if '/comment/' in target_ap_id:
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 and can_upvote(user, post):
upvote_post(post, user)
liked = find_liked_object(request_json['object'])
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.community):
# insert into voted table
if liked is None:
activity_log.exception_message = 'Liked object not found'
elif liked is not None and isinstance(liked, Post):
upvote_post(liked, user)
activity_log.result = 'success'
elif (user and not user.is_local()) and comment and can_upvote(user, comment):
upvote_post_reply(comment, user)
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 find user or content for vote'
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:
activity_log.exception_message = 'Cannot upvote this'
activity_log.result = 'ignored'
elif request_json['type'] == 'Dislike': # Downvote
if get_setting('allow_dislike', True) is False:
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 = find_actor_or_create(user_ap_id)
target_ap_id = request_json['object']
post = None
comment = None
if '/comment/' in target_ap_id:
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 and can_downvote(user, comment, site):
downvote_post_reply(comment, user)
activity_log.result = 'success'
elif (user and not user.is_local()) and post and can_downvote(user, post, site):
downvote_post(post, user)
disliked = find_liked_object(target_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.community, site):
# insert into voted table
if disliked is None:
activity_log.exception_message = 'Liked object not found'
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 find user or content for vote'
activity_log.exception_message = 'Could not detect type of like'
else:
activity_log.exception_message = 'Cannot downvote this'
activity_log.result = 'ignored'
# Flush the caches of any major object that was created. To be sure.
if 'user' in vars() and user is not None:
user.flush_cache()
if user.instance_id:
if user.instance_id and user.instance_id != 1:
user.instance.last_seen = utcnow()
user.instance.ip_address = ip_address
# if 'community' in vars() and community is not None:
user.instance.dormant = False
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:
post.flush_cache()
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':
activity_log.result = 'failure'
@ -728,6 +751,31 @@ def process_delete_request(request_json, activitypublog_id, ip_address):
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'])
def community_outbox(actor):
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,):
if '@context' not in body: # add a default json-ld context if necessary
body['@context'] = default_context()
log = ActivityPubLog(direction='out', activity_json=json.dumps(body),
result='success', activity_id=body['id'])
type = body['type'] if 'type' in body else ''
log = ActivityPubLog(direction='out', activity_json=json.dumps(body), activity_type=type,
result='success', activity_id=body['id'], exception_message='')
try:
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.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 ' +
str(result.status_code) + ' ' + result.text)
log.exception_message += uri
if result.status_code == 202:
log.exception_message += ' 202'
except Exception as e:
log.result = 'failure'
log.exception_message='could not send:' + str(e)

View file

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

View file

@ -102,6 +102,8 @@ def register(app):
staff_role = Role(name='Staff', weight=2)
staff_role.permissions.append(RolePermission(permission='approve registrations'))
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)
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='change instance settings'))
admin_role.permissions.append(RolePermission(permission='administer all communities'))
admin_role.permissions.append(RolePermission(permission='administer all users'))
db.session.add(admin_role)
# 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_login import login_user, logout_user, current_user, login_required
from flask_login import current_user, login_required
from flask_babel import _
from sqlalchemy import or_, desc
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.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm, ReportCommunityForm, \
DeleteCommunityForm
@ -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, can_create
request_etag_matches, return_304, instance_banned, can_create, can_upvote, can_downvote
from feedgen.feed import FeedGenerator
from datetime import timezone
@ -376,7 +376,7 @@ def add_post(actor):
flash(_('Your post to %(name)s has been made.', name=community.title))
else:
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 = {
"id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}",
"type": 'Announce',
@ -393,8 +393,8 @@ def add_post(actor):
sent_to = 0
for instance in community.following_instances():
if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]):
send_to_remote_instance(instance[1], community.id, announce)
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
send_to_remote_instance(instance.id, community.id, announce)
sent_to += 1
if sent_to:
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 time import sleep
from typing import List
@ -12,9 +12,10 @@ from app import db, cache, celery
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.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, \
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
import os
from opengraph_parse import parse_page
@ -361,15 +362,26 @@ def save_banner_file(banner_file, directory='communities') -> 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:
send_to_remote_instance_task(inbox, community_id, payload)
send_to_remote_instance_task(instance_id, community_id, payload)
else:
send_to_remote_instance_task.delay(inbox, community_id, payload)
send_to_remote_instance_task.delay(instance_id, community_id, payload)
@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)
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
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):
id = db.Column(db.Integer, primary_key=True)
file_path = db.Column(db.String(255))
@ -209,11 +257,14 @@ class Community(db.Model):
else:
return f"https://{current_app.config['SERVER_NAME']}/c/{self.ap_id}"
# returns a list of tuples (instance.id, instance.inbox)
def following_instances(self):
sql = 'select distinct i.id, i.inbox from "instance" as i inner join "user" as u on u.instance_id = i.id inner join "community_member" as cm on cm.user_id = u.id '
sql += 'where cm.community_id = :community_id and cm.is_banned = false and i.id <> 1 and i.dormant = false and i.gone_forever = false'
return db.session.execute(text(sql), {'community_id': self.id})
# instances that have users which are members of this community. (excluding the current instance)
def following_instances(self, include_dormant=False) -> List[Instance]:
instances = Instance.query.join(User, User.instance_id == Instance.id).join(CommunityMember, CommunityMember.user_id == User.id)
instances = instances.filter(CommunityMember.community_id == self.id, CommunityMember.is_banned == False)
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):
for post in self.posts:
@ -316,6 +367,7 @@ class User(UserMixin, db.Model):
else:
return '[deleted]'
@cache.memoize(timeout=10)
def avatar_thumbnail(self) -> str:
if self.avatar_id 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 ''
@cache.memoize(timeout=10)
def avatar_image(self) -> str:
if self.avatar_id is not None:
if self.avatar.file_path is not None:
@ -358,6 +411,7 @@ class User(UserMixin, db.Model):
def is_local(self):
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):
for role in self.roles:
if role.name == 'Admin':
@ -748,54 +802,6 @@ class UserBlock(db.Model):
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):
name = db.Column(db.String(50), primary_key=True)
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.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
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.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, ap_datetime, return_304, \
request_etag_matches, ip_address, user_ip_banned, instance_banned
request_etag_matches, ip_address, user_ip_banned, instance_banned, can_downvote, can_upvote
def show_post(post_id: int):
post = Post.query.get_or_404(post_id)
community: Community = post.community
if post.community.banned:
if community.banned:
abort(404)
# 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:
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)
# 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))
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,
from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl,
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,
author_id=current_user.id, url=url_for('activitypub.post_ap', post_id=post.id))
db.session.add(notification)
post.last_active = post.community.last_active = utcnow()
post.last_active = community.last_active = utcnow()
post.reply_count += 1
post.community.post_reply_count += 1
community.post_reply_count += 1
db.session.add(reply)
db.session.commit()
@ -96,7 +97,7 @@ def show_post(post_id: int):
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
post.community.profile_id(),
community.profile_id(),
],
'content': reply.body_html,
'inReplyTo': post.profile_id(),
@ -107,23 +108,23 @@ def show_post(post_id: int):
},
'published': ap_datetime(utcnow()),
'distinguished': False,
'audience': post.community.profile_id()
'audience': community.profile_id()
}
create_json = {
'type': 'Create',
'actor': current_user.profile_id(),
'audience': post.community.profile_id(),
'audience': community.profile_id(),
'to': [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
post.community.ap_profile_id
community.ap_profile_id
],
'object': reply_json,
'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
success = post_request(post.community.ap_inbox_url, create_json, current_user.private_key,
if not community.is_local(): # this is a remote community, send it to the instance that hosts it
success = post_request(community.ap_inbox_url, create_json, current_user.private_key,
current_user.ap_profile_id + '#main-key')
if not success:
flash('Failed to send to remote instance', 'error')
@ -134,17 +135,17 @@ def show_post(post_id: int):
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"actor": post.community.ap_profile_id,
"actor": community.ap_profile_id,
"cc": [
post.community.ap_followers_url
community.ap_followers_url
],
'@context': default_context(),
'object': create_json
}
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]):
send_to_remote_instance(instance[1], post.community.id, announce)
for instance in community.following_instances():
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
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
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,
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,
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'])
@ -235,8 +236,8 @@ def post_vote(post_id: int, vote_direction):
'object': action_json
}
for instance in post.community.following_instances():
if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]):
send_to_remote_instance(instance[1], post.community.id, announce)
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
send_to_remote_instance(instance.id, post.community.id, announce)
else:
success = post_request(post.community.ap_inbox_url, action_json, current_user.private_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():
if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]):
send_to_remote_instance(instance[1], post.community.id, announce)
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
send_to_remote_instance(instance.id, post.community.id, announce)
if reply.depth <= constants.THREAD_CUTOFF_DEPTH:
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()
db.session.commit()
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))

View file

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

View file

@ -68,6 +68,10 @@ nav, etc which are used site-wide */
content: "\ea03";
}
.fe-external::before {
content: "\e95b";
}
.fe-circle::before {
content: "\e937";
}
@ -544,6 +548,18 @@ fieldset legend {
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 {
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 {
scroll-margin-top: 5em;
}

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<div class="row position-relative">
{% 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">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
@ -12,7 +12,7 @@
<div class="voting_buttons">
{% include "post/_post_voting_buttons.html" %}
</div>
<h1 class="mt-2">{{ post.title }}</h1>
<h1 class="mt-2 post_title">{{ post.title }}</h1>
{% if post.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" />
@ -29,7 +29,7 @@
</div>
</div>
{% else %}
<div class="col">
<div class="col post_col post_type_normal">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
@ -41,7 +41,7 @@
<div class="voting_buttons">
{% include "post/_post_voting_buttons.html" %}
</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) %}
<div class="url_thumbnail">
<a href="{{ post.url }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
@ -72,8 +72,10 @@
width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" /></a>
{% endif %}
{% endif %}
<div class="post_body">
{{ post.body_html|safe if post.body_html else '' }}
</div>
</div>
{% 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>
</div>

View file

@ -3,7 +3,7 @@
<div class="col-12">
<div class="row main_row">
<div class="col">
<h3>
<h3 class="post_teaser_title">
<div class="voting_buttons">
{% include "post/_post_voting_buttons.html" %}
</div>
@ -18,7 +18,7 @@
{% endif %}
</div>
{% 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_LINK and post.domain_id %}
{% 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 can_upvote(current_user, post) %}
{% if can_upvote(current_user, post.community) %}
<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>
@ -7,7 +7,7 @@
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
</div>
{% 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 }}"
hx-post="/post/{{ post.id }}/downvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons">
<span class="fe fe-arrow-down"></span>

View file

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

View file

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

View file

@ -83,15 +83,15 @@
<h2>{{ _('About community') }}</h2>
</div>
<div class="card-body">
<p>{{ post.community.description|safe }}</p>
<p>{{ post.community.rules|safe }}</p>
<p>{{ post.community.description_html|safe if post.community.description_html else '' }}</p>
<p>{{ post.community.rules_html|safe if post.community.rules_html else '' }}</p>
{% if len(mods) > 0 and not post.community.private_mods %}
<h3>Moderators</h3>
<ol>
<ul class="moderator_list">
{% for mod in mods %}
<li><a href="/u/{{ mod.user_name }}">{{ mod.display_name() }}</a></li>
{% endfor %}
</ol>
</ul>
{% endif %}
</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>
{{ _("Hide every post from author's instance: %(name)s", name=post.instance.domain) }}</a></li>
{% 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 %}
<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):
reason_choices = [('1', _l('Breaks community rules')), ('7', _l('Spam')), ('2', _l('Harassment')),
('3', _l('Threatening violence')), ('4', _l('Hate / genocide')),
reason_choices = [('1', _l('Breaks community rules')),
('7', _l('Spam')),
('2', _l('Harassment')),
('3', _l('Threatening violence')),
('4', _l('Promoting hate / genocide')),
('15', _l('Misinformation / disinformation')),
('16', _l('Racism, sexism, transphobia')),
('17', _l('Malicious reporting')),
('6', _l('Sharing personal info - doxing')),
('5', _l('Minor abuse or sexualization')),
('8', _l('Non-consensual intimate media')),

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import random
from datetime import datetime
from datetime import datetime, timedelta
from typing import List, Literal, Union
import markdown2
@ -22,7 +22,7 @@ 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, \
Site, Post, PostReply
Site, Post, PostReply, utcnow
# 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
def allowlist_html(html: str) -> str:
allowed_tags = ['p', 'strong', 'a', 'ul', 'ol', 'li', 'em', 'blockquote', 'cite', 'br', 'h3', 'h4', 'h5', 'pre',
'code']
'code', 'img']
# Parse the HTML using BeautifulSoup
soup = BeautifulSoup(html, 'html.parser')
@ -164,7 +164,7 @@ def allowlist_html(html: str) -> str:
else:
# Filter and sanitize attributes
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]
# Encode the HTML to prevent script execution
@ -361,7 +361,8 @@ def user_ip_banned() -> bool:
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()
return banned is not None
@ -377,8 +378,8 @@ def banned_ip_addresses() -> List[str]:
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:
def can_downvote(user, community: Community, site=None) -> bool:
if user is None or community is None or user.banned:
return False
if site is None:
@ -387,25 +388,22 @@ def can_downvote(user, content: Union[Post, PostReply], site=None) -> bool:
except:
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
if content.community.is_moderator(user) or user.is_admin():
return True
if community.local_only and not user.is_local():
return False
if content.community.local_only and not user.is_local():
if user.attitude < 0.33:
return False
return True
def can_upvote(user, content: Union[Post, PostReply]) -> bool:
if user is None or content is None or user.banned:
def can_upvote(user, community: Community) -> bool:
if user is None or community is None or user.banned:
return False
if content.community.is_moderator(user) or user.is_admin():
return True
return True
@ -436,3 +434,23 @@ def can_create(user, content: Union[Community, Post, PostReply]) -> bool:
return False
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']
FULL_AP_CONTEXT = os.environ.get('FULL_AP_CONTEXT') or True
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_DEFAULT_TIMEOUT = 300
CACHE_THRESHOLD = 1000