diff --git a/relay/processors.py b/relay/processors.py index 6c04c12..f56ba48 100644 --- a/relay/processors.py +++ b/relay/processors.py @@ -4,10 +4,11 @@ import typing from . import logger as logging from .database import Connection -from .misc import Message +from .misc import Message, get_app if typing.TYPE_CHECKING: - from .views.activitypub import ActorView + from .app import Application + from .views.activitypub import InboxData def actor_type_check(actor: Message, software: str | None) -> bool: @@ -21,98 +22,98 @@ def actor_type_check(actor: Message, software: str | None) -> bool: return False -async def handle_relay(view: ActorView, conn: Connection) -> None: +async def handle_relay(app: Application, data: InboxData, conn: Connection) -> None: try: - view.cache.get('handle-relay', view.message.object_id) - logging.verbose('already relayed %s', view.message.object_id) + app.cache.get('handle-relay', data.message.object_id) + logging.verbose('already relayed %s', data.message.object_id) return except KeyError: pass - message = Message.new_announce(view.config.domain, view.message.object_id) + message = Message.new_announce(app.config.domain, data.message.object_id) logging.debug('>> relay: %s', message) - for instance in conn.distill_inboxes(view.message): - view.app.push_message(instance.inbox, message, instance) + for instance in conn.distill_inboxes(data.message): + app.push_message(instance.inbox, message, instance) - view.cache.set('handle-relay', view.message.object_id, message.id, 'str') + app.cache.set('handle-relay', data.message.object_id, message.id, 'str') -async def handle_forward(view: ActorView, conn: Connection) -> None: +async def handle_forward(app: Application, data: InboxData, conn: Connection) -> None: try: - view.cache.get('handle-relay', view.message.id) - logging.verbose('already forwarded %s', view.message.id) + app.cache.get('handle-relay', data.message.id) + logging.verbose('already forwarded %s', data.message.id) return except KeyError: pass - message = Message.new_announce(view.config.domain, view.message) + message = Message.new_announce(app.config.domain, data.message) logging.debug('>> forward: %s', message) - for instance in conn.distill_inboxes(view.message): - view.app.push_message(instance.inbox, view.message, instance) + for instance in conn.distill_inboxes(data.message): + app.push_message(instance.inbox, data.message, instance) - view.cache.set('handle-relay', view.message.id, message.id, 'str') + app.cache.set('handle-relay', data.message.id, message.id, 'str') -async def handle_follow(view: ActorView, conn: Connection) -> None: - nodeinfo = await view.client.fetch_nodeinfo(view.actor.domain, force = True) +async def handle_follow(app: Application, data: InboxData, conn: Connection) -> None: + nodeinfo = await app.client.fetch_nodeinfo(data.actor.domain, force = True) software = nodeinfo.sw_name if nodeinfo else None config = conn.get_config_all() # reject if software used by actor is banned if software and conn.get_software_ban(software): - logging.verbose('Rejected banned actor: %s', view.actor.id) + logging.verbose('Rejected banned actor: %s', data.actor.id) - view.app.push_message( - view.actor.shared_inbox, + app.push_message( + data.actor.shared_inbox, Message.new_response( - host = view.config.domain, - actor = view.actor.id, - followid = view.message.id, + host = app.config.domain, + actor = data.actor.id, + followid = data.message.id, accept = False ), - view.instance + data.instance ) logging.verbose( 'Rejected follow from actor for using specific software: actor=%s, software=%s', - view.actor.id, + data.actor.id, software ) return # reject if the actor is not an instance actor - if actor_type_check(view.actor, software): - logging.verbose('Non-application actor tried to follow: %s', view.actor.id) + if actor_type_check(data.actor, software): + logging.verbose('Non-application actor tried to follow: %s', data.actor.id) - view.app.push_message( - view.actor.shared_inbox, + app.push_message( + data.actor.shared_inbox, Message.new_response( - host = view.config.domain, - actor = view.actor.id, - followid = view.message.id, + host = app.config.domain, + actor = data.actor.id, + followid = data.message.id, accept = False ), - view.instance + data.instance ) return - if not conn.get_domain_whitelist(view.actor.domain): + if not conn.get_domain_whitelist(data.actor.domain): # add request if approval-required is enabled if config.approval_required: - logging.verbose('New follow request fromm actor: %s', view.actor.id) + logging.verbose('New follow request fromm actor: %s', data.actor.id) with conn.transaction(): - view.instance = conn.put_inbox( - domain = view.actor.domain, - inbox = view.actor.shared_inbox, - actor = view.actor.id, - followid = view.message.id, + data.instance = conn.put_inbox( + domain = data.actor.domain, + inbox = data.actor.shared_inbox, + actor = data.actor.id, + followid = data.message.id, software = software, accepted = False ) @@ -121,81 +122,84 @@ async def handle_follow(view: ActorView, conn: Connection) -> None: # reject if the actor isn't whitelisted while the whiltelist is enabled if config.whitelist_enabled: - logging.verbose('Rejected actor for not being in the whitelist: %s', view.actor.id) + logging.verbose('Rejected actor for not being in the whitelist: %s', data.actor.id) - view.app.push_message( - view.actor.shared_inbox, + app.push_message( + data.actor.shared_inbox, Message.new_response( - host = view.config.domain, - actor = view.actor.id, - followid = view.message.id, + host = app.config.domain, + actor = data.actor.id, + followid = data.message.id, accept = False ), - view.instance + data.instance ) return with conn.transaction(): - view.instance = conn.put_inbox( - domain = view.actor.domain, - inbox = view.actor.shared_inbox, - actor = view.actor.id, - followid = view.message.id, + data.instance = conn.put_inbox( + domain = data.actor.domain, + inbox = data.actor.shared_inbox, + actor = data.actor.id, + followid = data.message.id, software = software, accepted = True ) - view.app.push_message( - view.actor.shared_inbox, + app.push_message( + data.actor.shared_inbox, Message.new_response( - host = view.config.domain, - actor = view.actor.id, - followid = view.message.id, + host = app.config.domain, + actor = data.actor.id, + followid = data.message.id, accept = True ), - view.instance + data.instance ) # Are Akkoma and Pleroma the only two that expect a follow back? # Ignoring only Mastodon for now if software != 'mastodon': - view.app.push_message( - view.actor.shared_inbox, + app.push_message( + data.actor.shared_inbox, Message.new_follow( - host = view.config.domain, - actor = view.actor.id + host = app.config.domain, + actor = data.actor.id ), - view.instance + data.instance ) -async def handle_undo(view: ActorView, conn: Connection) -> None: - if view.message.object['type'] != 'Follow': +async def handle_undo(app: Application, data: InboxData, conn: Connection) -> None: + if data.message.object['type'] != 'Follow': # forwarding deletes does not work, so don't bother - # await handle_forward(view, conn) + # await handle_forward(app, data, conn) return + if data.instance is None: + raise ValueError(f"Actor not in database: {data.actor.id}") + # prevent past unfollows from removing an instance - if view.instance.followid and view.instance.followid != view.message.object_id: + if data.instance.followid and data.instance.followid != data.message.object_id: return with conn.transaction(): - if not conn.del_inbox(view.actor.id): + if not conn.del_inbox(data.actor.id): logging.verbose( 'Failed to delete "%s" with follow ID "%s"', - view.actor.id, - view.message.object_id + data.actor.id, + data.message.object_id ) - view.app.push_message( - view.actor.shared_inbox, + app.push_message( + data.actor.shared_inbox, Message.new_unfollow( - host = view.config.domain, - actor = view.actor.id, - follow = view.message + host = app.config.domain, + actor = data.actor.id, + follow = data.message ), - view.instance + data.instance ) @@ -209,32 +213,34 @@ processors = { } -async def run_processor(view: ActorView) -> None: - if view.message.type not in processors: +async def run_processor(data: InboxData) -> None: + if data.message.type not in processors: logging.verbose( 'Message type "%s" from actor cannot be handled: %s', - view.message.type, - view.actor.id + data.message.type, + data.actor.id ) return - with view.database.session() as conn: - if view.instance: - if not view.instance.software: - if (nodeinfo := await view.client.fetch_nodeinfo(view.instance.domain)): + app = get_app() + + with app.database.session() as conn: + if data.instance: + if not data.instance.software: + if (nodeinfo := await app.client.fetch_nodeinfo(data.instance.domain)): with conn.transaction(): - view.instance = conn.put_inbox( - domain = view.instance.domain, + data.instance = conn.put_inbox( + domain = data.instance.domain, software = nodeinfo.sw_name ) - if not view.instance.actor: + if not data.instance.actor: with conn.transaction(): - view.instance = conn.put_inbox( - domain = view.instance.domain, - actor = view.actor.id + data.instance = conn.put_inbox( + domain = data.instance.domain, + actor = data.actor.id ) - logging.verbose('New "%s" from actor: %s', view.message.type, view.actor.id) - await processors[view.message.type](view, conn) + logging.verbose('New "%s" from actor: %s', data.message.type, data.actor.id) + await processors[data.message.type](app, data, conn) diff --git a/relay/views/activitypub.py b/relay/views/activitypub.py index 6af132c..7f70901 100644 --- a/relay/views/activitypub.py +++ b/relay/views/activitypub.py @@ -6,11 +6,11 @@ import traceback from aiohttp import ClientConnectorError from aiohttp.web import Request from aputils import Signature, SignatureFailureError, Signer -from blib import HttpError +from blib import HttpError, HttpMethod from dataclasses import dataclass from typing import TYPE_CHECKING -from .base import View, register_view +from .base import register_route from .. import logger as logging from ..database import schema @@ -27,14 +27,6 @@ if TYPE_CHECKING: from typing_extensions import Self -# def route( -# method: HttpMethod | str, -# *path: str, -# activity: bool = True) -> Callable[[Application, Request], JsonBase[Any]]: -# -# def wrapper - - @dataclass(slots = True) class InboxData: signature: Signature @@ -45,7 +37,7 @@ class InboxData: @classmethod - async def parse_request(cls: type[Self], app: Application, request: Request) -> Self: + async def parse(cls: type[Self], app: Application, request: Request) -> Self: signature: Signature | None = None message: Message | None = None actor: Message | None = None @@ -112,169 +104,94 @@ class InboxData: return cls(signature, message, actor, signer, None) -class ActorView(View): - signature: aputils.Signature - message: Message - actor: Message - instance: schema.Instance - signer: aputils.Signer +@register_route(HttpMethod.GET, "/actor", "/inbox") +async def handle_actor(app: Application, request: Request) -> Response: + with app.database.session(False) as conn: + config = conn.get_config_all() + + data = Message.new_actor( + host = app.config.domain, + pubkey = app.signer.pubkey, + description = app.template.render_markdown(config.note), + approves = config.approval_required + ) + + return Response.new(data, ctype = "activity") - def __init__(self, request: Request): - View.__init__(self, request) +@register_route(HttpMethod.POST, "/actor", "/inbox") +async def handle_inbox(app: Application, request: Request) -> Response: + data = await InboxData.parse(app, request) + + with app.database.session() as conn: + data.instance = conn.get_inbox(data.actor.shared_inbox) + + # reject if actor is banned + if conn.get_domain_ban(data.actor.domain): + logging.verbose('Ignored request from banned actor: %s', data.actor.id) + raise HttpError(403, 'access denied') + + # reject if activity type isn't 'Follow' and the actor isn't following + if data.message.type != 'Follow' and not data.instance: + logging.verbose( + 'Rejected actor for trying to post while not following: %s', + data.actor.id + ) + + raise HttpError(401, 'access denied') + + logging.debug('>> payload %s', data.message.to_json(4)) + + await run_processor(data) + return Response.new(status = 202) - async def get(self, request: Request) -> Response: - with self.database.session(False) as conn: - config = conn.get_config_all() +@register_route(HttpMethod.GET, '/outbox') +async def handle_outbox(app: Application, request: Request) -> Response: + msg = aputils.Message.new( + aputils.ObjectType.ORDERED_COLLECTION, + { + "id": f'https://{app.config.domain}/outbox', + "totalItems": 0, + "orderedItems": [] + } + ) - data = Message.new_actor( - host = self.config.domain, - pubkey = self.app.signer.pubkey, - description = self.app.template.render_markdown(config.note), - approves = config.approval_required - ) - - return Response.new(data, ctype='activity') + return Response.new(msg, ctype = "activity") - async def post(self, request: Request) -> Response: - await self.get_post_data() +@register_route(HttpMethod.GET, '/following', '/followers') +async def handle_follow(app: Application, request: Request) -> Response: + with app.database.session(False) as s: + inboxes = [row['actor'] for row in s.get_inboxes()] - with self.database.session() as conn: - self.instance = conn.get_inbox(self.actor.shared_inbox) # type: ignore[assignment] + msg = aputils.Message.new( + aputils.ObjectType.COLLECTION, + { + "id": f'https://{app.config.domain}{request.path}', + "totalItems": len(inboxes), + "items": inboxes + } + ) - # reject if actor is banned - if conn.get_domain_ban(self.actor.domain): - logging.verbose('Ignored request from banned actor: %s', self.actor.id) - raise HttpError(403, 'access denied') - - # reject if activity type isn't 'Follow' and the actor isn't following - if self.message.type != 'Follow' and not self.instance: - logging.verbose( - 'Rejected actor for trying to post while not following: %s', - self.actor.id - ) - - raise HttpError(401, 'access denied') - - logging.debug('>> payload %s', self.message.to_json(4)) - - await run_processor(self) - return Response.new(status = 202) + return Response.new(msg, ctype = "activity") - async def get_post_data(self) -> None: - try: - self.signature = aputils.Signature.parse(self.request.headers['signature']) +@register_route(HttpMethod.GET, '/.well-known/webfinger') +async def get(app: Application, request: Request) -> Response: + try: + subject = request.query['resource'] - except KeyError: - logging.verbose('Missing signature header') - raise HttpError(400, 'missing signature header') + except KeyError: + raise HttpError(400, 'missing "resource" query key') - try: - message: Message | None = await self.request.json(loads = Message.parse) + if subject != f'acct:relay@{app.config.domain}': + raise HttpError(404, 'user not found') - except Exception: - traceback.print_exc() - logging.verbose('Failed to parse message from actor: %s', self.signature.keyid) - raise HttpError(400, 'failed to parse message') + data = aputils.Webfinger.new( + handle = 'relay', + domain = app.config.domain, + actor = app.config.actor + ) - if message is None: - logging.verbose('empty message') - raise HttpError(400, 'missing message') - - self.message = message - - if 'actor' not in self.message: - logging.verbose('actor not in message') - raise HttpError(400, 'no actor in message') - - try: - self.actor = await self.client.get(self.signature.keyid, True, Message) - - except HttpError as e: - # ld signatures aren't handled atm, so just ignore it - if self.message.type == 'Delete': - logging.verbose('Instance sent a delete which cannot be handled') - raise HttpError(202, '') - - logging.verbose('Failed to fetch actor: %s', self.signature.keyid) - logging.debug('HTTP Status %i: %s', e.status, e.message) - raise HttpError(400, 'failed to fetch actor') - - except ClientConnectorError as e: - logging.warning('Error when trying to fetch actor: %s, %s', self.signature.keyid, str(e)) - raise HttpError(400, 'failed to fetch actor') - - except Exception: - traceback.print_exc() - raise HttpError(500, 'unexpected error when fetching actor') - - try: - self.signer = self.actor.signer - - except KeyError: - logging.verbose('Actor missing public key: %s', self.signature.keyid) - raise HttpError(400, 'actor missing public key') - - try: - await self.signer.validate_request_async(self.request) - - except aputils.SignatureFailureError as e: - logging.verbose('signature validation failed for "%s": %s', self.actor.id, e) - raise HttpError(401, str(e)) - - -@register_view('/outbox') -class OutboxView(View): - async def get(self, request: Request) -> Response: - msg = aputils.Message.new( - aputils.ObjectType.ORDERED_COLLECTION, - { - "id": f'https://{self.config.domain}/outbox', - "totalItems": 0, - "orderedItems": [] - } - ) - - return Response.new(msg, ctype = 'activity') - - -@register_view('/following', '/followers') -class RelationshipView(View): - async def get(self, request: Request) -> Response: - with self.database.session(False) as s: - inboxes = [row['actor'] for row in s.get_inboxes()] - - msg = aputils.Message.new( - aputils.ObjectType.COLLECTION, - { - "id": f'https://{self.config.domain}{request.path}', - "totalItems": len(inboxes), - "items": inboxes - } - ) - - return Response.new(msg, ctype = 'activity') - - -@register_view('/.well-known/webfinger') -class WebfingerView(View): - async def get(self, request: Request) -> Response: - try: - subject = request.query['resource'] - - except KeyError: - raise HttpError(400, 'missing "resource" query key') - - if subject != f'acct:relay@{self.config.domain}': - raise HttpError(404, 'user not found') - - data = aputils.Webfinger.new( - handle = 'relay', - domain = self.config.domain, - actor = self.config.actor - ) - - return Response.new(data, ctype = 'json') + return Response.new(data, ctype = 'json')