From acfe35d98d21c91d9d5321f3dac29ef101ba47aa Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:29:58 +1300 Subject: [PATCH] announce incoming activites to all following instances plus a whole lot more, i don't even know --- app/activitypub/routes.py | 120 +++++++++++++------ app/activitypub/signature.py | 12 +- app/activitypub/util.py | 23 ++-- app/cli.py | 3 + app/community/routes.py | 12 +- app/community/util.py | 28 +++-- app/models.py | 112 +++++++++-------- app/post/routes.py | 68 +++++++---- app/static/scss/_typography.scss | 4 + app/static/structure.css | 16 +++ app/static/structure.scss | 17 +++ app/static/styles.css | 4 + app/templates/admin/users.html | 2 + app/templates/post/_post_full.html | 12 +- app/templates/post/_post_teaser.html | 4 +- app/templates/post/_post_voting_buttons.html | 4 +- app/templates/post/_voting_buttons.html | 4 +- app/templates/post/add_reply.html | 8 +- app/templates/post/continue_discussion.html | 8 +- app/templates/post/post_options.html | 4 + app/user/forms.py | 8 +- app/utils.py | 50 +++++--- config.py | 1 + 23 files changed, 345 insertions(+), 179 deletions(-) diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 9dbd60c8..52277978 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -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) - activity_log.result = 'success' - elif (user and not user.is_local()) and comment and can_upvote(user, comment): - upvote_post_reply(comment, user) - activity_log.result = 'success' + 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 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: - 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 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) - activity_log.result = 'success' + 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 detect type of like' 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. 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: - # community.flush_cache() + 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//outbox', methods=['GET']) def community_outbox(actor): actor = actor.strip() diff --git a/app/activitypub/signature.py b/app/activitypub/signature.py index 17260b7f..60b6f15c 100644 --- a/app/activitypub/signature.py +++ b/app/activitypub/signature.py @@ -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) diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 142dde7e..08e28c4d 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -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,19 +298,19 @@ 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 + 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 - db.session.add(cover) - cover_changed = True + cover = File(source_url=activity_json['image']['url']) + user.cover = cover + db.session.add(cover) + cover_changed = True db.session.commit() if user.avatar_id and avatar_changed: make_image_sizes(user.avatar_id, 40, 250, 'users') diff --git a/app/cli.py b/app/cli.py index cc7aa35d..2d1ca73f 100644 --- a/app/cli.py +++ b/app/cli.py @@ -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 diff --git a/app/community/routes.py b/app/community/routes.py index 6cf6a707..df3c08e9 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -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)) diff --git a/app/community/util.py b/app/community/util.py index 7fe029c6..4ad58264 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -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() \ No newline at end of file diff --git a/app/models.py b/app/models.py index bb774de6..2d142b0a 100644 --- a/app/models.py +++ b/app/models.py @@ -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)) diff --git a/app/post/routes.py b/app/post/routes.py index 1571e390..584d3ce9 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -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//', 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)) diff --git a/app/static/scss/_typography.scss b/app/static/scss/_typography.scss index 78846d5a..f98f2916 100644 --- a/app/static/scss/_typography.scss +++ b/app/static/scss/_typography.scss @@ -41,6 +41,10 @@ content: "\ea03"; } +.fe-external::before { + content: "\e95b"; +} + .fe-circle::before { content: "\e937"; } diff --git a/app/static/structure.css b/app/static/structure.css index d4214bc1..3a294846 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -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; } diff --git a/app/static/structure.scss b/app/static/structure.scss index 02d5eb68..2571659a 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -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; } diff --git a/app/static/styles.css b/app/static/styles.css index d7f8f74a..71c67130 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -67,6 +67,10 @@ content: "\ea03"; } +.fe-external::before { + content: "\e95b"; +} + .fe-circle::before { content: "\e937"; } diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html index 4cc29dfb..e3240113 100644 --- a/app/templates/admin/users.html +++ b/app/templates/admin/users.html @@ -23,6 +23,7 @@ Attitude Banned Reports + IP Actions {% for user in users %} @@ -33,6 +34,7 @@ {{ user.attitude * 100 }} {{ 'Banned'|safe if user.banned }} {{ user.reports if user.reports > 0 }} + {{ user.ip_address if user.ip_address }} View local | {% if not user.is_local() %} View remote | diff --git a/app/templates/post/_post_full.html b/app/templates/post/_post_full.html index 1a0cf3d2..e57a3c2c 100644 --- a/app/templates/post/_post_full.html +++ b/app/templates/post/_post_full.html @@ -1,6 +1,6 @@
{% if post.type == POST_TYPE_IMAGE %} -
+
{% else %} -
+