diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index b85f3c42..7775d019 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -124,7 +124,7 @@ def lemmy_site(): @bp.route('/api/v3/federated_instances') @cache.cached(timeout=600) def lemmy_federated_instances(): - instances = Instance.query.all() + instances = Instance.query.filter(Instance.id != 1).all() linked = [] allowed = [] blocked = [] diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 66daad04..89b9a37f 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -10,7 +10,7 @@ from flask_babel import _ from sqlalchemy import text, func from app import db, cache, constants, celery from app.models import User, Post, Community, BannedInstances, File, PostReply, AllowedInstances, Instance, utcnow, \ - PostVote, PostReplyVote, ActivityPubLog, Notification, Site, CommunityMember + PostVote, PostReplyVote, ActivityPubLog, Notification, Site, CommunityMember, InstanceRole import time import base64 import requests @@ -211,14 +211,18 @@ def find_actor_or_create(actor: str) -> Union[User, Community, None]: user = Community.query.filter(Community.ap_profile_id == actor).first() if user is not None: - if not user.is_local() and user.ap_fetched_at < utcnow() - timedelta(days=7): + if not user.is_local() and (user.ap_fetched_at is None or user.ap_fetched_at < utcnow() - timedelta(days=7)): # To reduce load on remote servers, refreshing the user profile happens after a delay of 1 to 10 seconds. Meanwhile, subsequent calls to # find_actor_or_create() which happen to be for the same actor might queue up refreshes of the same user. To avoid this, set a flag to # indicate that user is currently being refreshed. refresh_in_progress = cache.get(f'refreshing_{user.id}') if not refresh_in_progress: cache.set(f'refreshing_{user.id}', True, timeout=300) - refresh_user_profile(user.id) + if isinstance(user, User): + refresh_user_profile(user.id) + elif isinstance(user, Community): + # todo: refresh community profile also, not just instance_profile + refresh_instance_profile(user.instance_id) return user else: # User does not exist in the DB, it's going to need to be created from it's remote home instance if actor.startswith('https://'): @@ -687,41 +691,76 @@ def find_instance_id(server): def refresh_instance_profile(instance_id: int): - if current_app.debug: - refresh_instance_profile_task(instance_id) - else: - refresh_instance_profile_task.apply_async(args=(instance_id,), countdown=randint(1, 10)) + if instance_id: + if current_app.debug: + refresh_instance_profile_task(instance_id) + else: + refresh_instance_profile_task.apply_async(args=(instance_id,), countdown=randint(1, 10)) @celery.task def refresh_instance_profile_task(instance_id: int): instance = Instance.query.get(instance_id) - try: - instance_data = get_request(f"https://{instance.domain}", headers={'Accept': 'application/activity+json'}) - except: - return - if instance_data.status_code == 200: + if instance.updated_at < utcnow() - timedelta(days=7): try: - instance_json = instance_data.json() - instance_data.close() - except requests.exceptions.JSONDecodeError as ex: - instance_json = {} - if 'type' in instance_json and instance_json['type'] == 'Application': - if instance_json['name'].lower() == 'kbin': - software = 'Kbin' - elif instance_json['name'].lower() == 'mbin': - software = 'Mbin' - else: - software = 'Lemmy' - instance.inbox = instance_json['inbox'] - instance.outbox = instance_json['outbox'] - instance.software = software - if instance.inbox.endswith('/site_inbox'): # Lemmy provides a /site_inbox but it always returns 400 when trying to POST to it. wtf. - instance.inbox = instance.inbox.replace('/site_inbox', '/inbox') - else: # it's pretty much always /inbox so just assume that it is for whatever this instance is running (mostly likely Mastodon) - instance.inbox = f"https://{instance.domain}/inbox" - instance.updated_at = utcnow() - db.session.commit() + instance_data = get_request(f"https://{instance.domain}", headers={'Accept': 'application/activity+json'}) + except: + return + if instance_data.status_code == 200: + try: + instance_json = instance_data.json() + instance_data.close() + except requests.exceptions.JSONDecodeError as ex: + instance_json = {} + if 'type' in instance_json and instance_json['type'] == 'Application': + if instance_json['name'].lower() == 'kbin': + software = 'Kbin' + elif instance_json['name'].lower() == 'mbin': + software = 'Mbin' + else: + software = 'Lemmy' + instance.inbox = instance_json['inbox'] + instance.outbox = instance_json['outbox'] + instance.software = software + if instance.inbox.endswith('/site_inbox'): # Lemmy provides a /site_inbox but it always returns 400 when trying to POST to it. wtf. + instance.inbox = instance.inbox.replace('/site_inbox', '/inbox') + else: # it's pretty much always /inbox so just assume that it is for whatever this instance is running (mostly likely Mastodon) + instance.inbox = f"https://{instance.domain}/inbox" + instance.updated_at = utcnow() + db.session.commit() + + # retrieve list of Admins from /api/v3/site, update InstanceRole + try: + response = get_request(f'https://{instance.domain}/api/v3/site') + except: + response = None + + if response and response.status_code == 200: + try: + instance_data = response.json() + except: + instance_data = None + finally: + response.close() + + if instance_data: + if 'admins' in instance_data: + admin_profile_ids = [] + for admin in instance_data['admins']: + admin_profile_ids.append(admin['person']['actor_id'].lower()) + user = find_actor_or_create(admin['person']['actor_id']) + if user and not instance.user_is_admin(user.id): + new_instance_role = InstanceRole(instance_id=instance.id, user_id=user.id, role='admin') + db.session.add(new_instance_role) + db.session.commit() + # remove any InstanceRoles that are no longer part of instance-data['admins'] + for instance_admin in InstanceRole.query.filter_by(instance_id=instance.id): + if instance_admin.user.profile_id() not in admin_profile_ids: + db.session.query(InstanceRole).filter( + InstanceRole.user_id == instance_admin.user.id, + InstanceRole.instance_id == instance.id, + InstanceRole.role == 'admin').delete() + db.session.commit() # alter the effect of upvotes based on their instance. Default to 1.0 @@ -897,7 +936,7 @@ def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id to_delete = find_liked_object(to_be_deleted_ap_id) if deletor and community and to_delete: - if deletor.is_admin() or community.is_moderator(deletor) or to_delete.author.id == deletor.id: + if deletor.is_admin() or community.is_moderator(deletor) or community.is_instance_admin(deletor) or to_delete.author.id == deletor.id: if isinstance(to_delete, Post): to_delete.delete_dependencies() to_delete.flush_cache() diff --git a/app/main/routes.py b/app/main/routes.py index d238593b..51c9c18a 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -6,7 +6,7 @@ from random import randint from sqlalchemy.sql.operators import or_ from app import db, cache -from app.activitypub.util import default_context, make_image_sizes_async, refresh_user_profile +from app.activitypub.util import default_context, make_image_sizes_async, refresh_user_profile, find_actor_or_create from app.constants import SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER, POST_TYPE_IMAGE, POST_TYPE_LINK, \ SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR from app.inoculation import inoculation @@ -19,8 +19,8 @@ from sqlalchemy import select, desc, text from sqlalchemy_searchable import search from app.utils import render_template, get_setting, gibberish, request_etag_matches, return_304, blocked_domains, \ ap_datetime, ip_address, retrieve_block_list, shorten_string, markdown_to_text, user_filters_home, \ - joined_communities, moderating_communities, parse_page, theme_list -from app.models import Community, CommunityMember, Post, Site, User, utcnow, Domain, Topic, File + joined_communities, moderating_communities, parse_page, theme_list, get_request +from app.models import Community, CommunityMember, Post, Site, User, utcnow, Domain, Topic, File, Instance, InstanceRole from PIL import Image import pytesseract @@ -257,6 +257,42 @@ def list_files(directory): @bp.route('/test') def test(): + instance = Instance.query.get(3) + if instance.updated_at < utcnow() - timedelta(days=7): + try: + response = get_request(f'https://{instance.domain}/api/v3/site') + except: + response = None + + if response and response.status_code == 200: + try: + instance_data = response.json() + except: + instance_data = None + finally: + response.close() + + if instance_data: + if 'admins' in instance_data: + admin_profile_ids = [] + for admin in instance_data['admins']: + admin_profile_ids.append(admin['person']['actor_id'].lower()) + user = find_actor_or_create(admin['person']['actor_id']) + if user and not instance.user_is_admin(user.id): + new_instance_role = InstanceRole(instance_id=instance.id, user_id=user.id, role='admin') + db.session.add(new_instance_role) + db.session.commit() + # remove any InstanceRoles that are no longer part of instance-data['admins'] + for instance_admin in InstanceRole.query.filter_by(instance_id=instance.id): + if instance_admin.user.profile_id() not in admin_profile_ids: + db.session.query(InstanceRole).filter( + InstanceRole.user_id == instance_admin.user.id, + InstanceRole.instance_id == instance.id, + InstanceRole.role == 'admin').delete() + db.session.commit() + + return 'Ok' + return '' retval = '' for user in User.query.all(): diff --git a/app/models.py b/app/models.py index 0258bb3e..92cca8ad 100644 --- a/app/models.py +++ b/app/models.py @@ -69,6 +69,18 @@ class Instance(db.Model): def online(self): return not self.dormant and not self.gone_forever + def user_is_admin(self, user_id): + role = InstanceRole.query.filter_by(instance_id=self.id, user_id=user_id).first() + return role and role.role == 'admin' + + +class InstanceRole(db.Model): + instance_id = db.Column(db.Integer, db.ForeignKey('instance.id'), primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) + role = db.Column(db.String(50), default='admin') + + user = db.relationship('User', lazy='joined') + class InstanceBlock(db.Model): user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) @@ -269,6 +281,15 @@ class Community(db.Model): else: return any(moderator.user_id == user.id and moderator.is_owner for moderator in self.moderators()) + def is_instance_admin(self, user): + if self.instance_id: + instance_role = InstanceRole.query.filter(InstanceRole.instance_id == self.instance_id, + InstanceRole.user_id == user.id, + InstanceRole.role == 'admin').first() + return instance_role is not None + else: + return False + def user_is_banned(self, user): membership = CommunityMember.query.filter(CommunityMember.community_id == self.id, CommunityMember.user_id == user.id).first() return membership.is_banned if membership else False diff --git a/migrations/versions/75f5b458c2f9_instance_admins.py b/migrations/versions/75f5b458c2f9_instance_admins.py new file mode 100644 index 00000000..fe07be59 --- /dev/null +++ b/migrations/versions/75f5b458c2f9_instance_admins.py @@ -0,0 +1,35 @@ +"""instance admins + +Revision ID: 75f5b458c2f9 +Revises: a8fc7f7ba539 +Create Date: 2024-02-14 11:12:09.271117 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '75f5b458c2f9' +down_revision = 'a8fc7f7ba539' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('instance_role', + sa.Column('instance_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('role', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['instance_id'], ['instance.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('instance_id', 'user_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('instance_role') + # ### end Alembic commands ###