from __future__ import annotations import aputils import traceback from aiohttp import ClientConnectorError from aiohttp.web import Request from aputils import Signature, SignatureFailureError, Signer from blib import HttpError, HttpMethod from dataclasses import dataclass from typing import TYPE_CHECKING from .base import register_route from .. import logger as logging from ..database import schema from ..misc import Message, Response from ..processors import run_processor if TYPE_CHECKING: from ..application import Application try: from typing import Self except ImportError: from typing_extensions import Self @dataclass(slots = True) class InboxData: signature: Signature message: Message actor: Message signer: Signer instance: schema.Instance | None @classmethod async def parse(cls: type[Self], app: Application, request: Request) -> Self: signature: Signature | None = None message: Message | None = None actor: Message | None = None signer: Signer | None = None try: signature = Signature.parse(request.headers["signature"]) except KeyError: logging.verbose("Missing signature header") raise HttpError(400, "missing signature header") try: message = await request.json(loads = Message.parse) except Exception: traceback.print_exc() logging.verbose("Failed to parse message from actor: %s", signature.keyid) raise HttpError(400, "failed to parse message") if message is None: logging.verbose("empty message") raise HttpError(400, "missing message") if "actor" not in message: logging.verbose("actor not in message") raise HttpError(400, "no actor in message") actor_id: str try: actor_id = message.actor_id except AttributeError: actor_id = signature.keyid try: actor = await app.client.get(actor_id, True, Message) except HttpError as e: # ld signatures aren"t handled atm, so just ignore it if message.type == "Delete": logging.verbose("Instance sent a delete which cannot be handled") raise HttpError(202, "") logging.verbose("Failed to fetch actor: %s", 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", 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: signer = actor.signer except KeyError: logging.verbose("Actor missing public key: %s", signature.keyid) raise HttpError(400, "actor missing public key") try: await signer.validate_request_async(request) except SignatureFailureError as e: logging.verbose("signature validation failed for \"%s\": %s", actor.id, e) raise HttpError(401, str(e)) return cls(signature, message, actor, signer, None) @property def shared_inbox(self) -> str: if self.actor is None: raise AttributeError("Actor not set yet") try: return self.actor.shared_inbox except KeyError: return self.actor.inbox # type: ignore[no-any-return] @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") @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.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) @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": [] } ) return Response.new(msg, ctype = "activity") @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()] msg = aputils.Message.new( aputils.ObjectType.COLLECTION, { "id": f"https://{app.config.domain}{request.path}", "totalItems": len(inboxes), "items": inboxes } ) return Response.new(msg, ctype = "activity") @register_route(HttpMethod.GET, "/.well-known/webfinger") async def get(app: Application, request: Request) -> Response: try: subject = request.query["resource"] except KeyError: raise HttpError(400, "missing \"resource\" query key") if subject != f"acct:relay@{app.config.domain}": raise HttpError(404, "user not found") data = aputils.Webfinger.new( handle = "relay", domain = app.config.domain, actor = app.config.actor ) return Response.new(data, ctype = "json")