From 4feaccaa5309f4324279f970e131e88d3df8dc2c Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Tue, 9 Jan 2024 23:15:04 -0500 Subject: [PATCH] use View class and make Message a subclass of aputils.message.Message --- relay/application.py | 57 +++------- relay/http_client.py | 8 +- relay/misc.py | 204 +++++++++++++++++++++++++---------- relay/processors.py | 139 +++++++++++++----------- relay/views.py | 251 +++++++++++++++++++------------------------ 5 files changed, 353 insertions(+), 306 deletions(-) diff --git a/relay/application.py b/relay/application.py index dbe464f..076a113 100644 --- a/relay/application.py +++ b/relay/application.py @@ -13,7 +13,7 @@ from .config import RelayConfig from .database import RelayDatabase from .http_client import HttpClient from .misc import DotDict, check_open_port, set_app -from .views import routes +from .views import VIEWS class Application(web.Application): @@ -49,7 +49,8 @@ class Application(web.Application): cache_size = self.config.json_cache ) - self.set_signal_handler() + for path, view in VIEWS: + self.router.add_view(path, view) @property @@ -90,10 +91,10 @@ class Application(web.Application): self['last_worker'] = 0 - def set_signal_handler(self): + def set_signal_handler(self, startup): for sig in {'SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGTERM'}: try: - signal.signal(getattr(signal, sig), self.stop) + signal.signal(getattr(signal, sig), self.stop if startup else signal.SIG_DFL) # some signals don't exist in windows, so skip them except AttributeError: @@ -104,8 +105,8 @@ class Application(web.Application): if not check_open_port(self.config.listen, self.config.port): return logging.error(f'A server is already running on port {self.config.port}') - for route in routes: - self.router.add_route(*route) + for view in VIEWS: + self.router.add_view(*view) logging.info(f'Starting webserver at {self.config.host} ({self.config.listen}:{self.config.port})') asyncio.run(self.handle_run()) @@ -118,6 +119,8 @@ class Application(web.Application): async def handle_run(self): self['running'] = True + self.set_signal_handler(True) + if self.config.workers > 0: for i in range(self.config.workers): worker = PushWorker(self) @@ -141,6 +144,7 @@ class Application(web.Application): await asyncio.sleep(0.25) await site.stop() + await self.client.close() self['starttime'] = None self['running'] = False @@ -155,6 +159,10 @@ class PushWorker(threading.Thread): def run(self): + asyncio.run(self.handle_queue()) + + + async def handle_queue(self): self.client = HttpClient( database = self.app.database, limit = self.app.config.push_limit, @@ -162,10 +170,6 @@ class PushWorker(threading.Thread): cache_size = self.app.config.json_cache ) - asyncio.run(self.handle_queue()) - - - async def handle_queue(self): while self.app['running']: try: inbox, message = self.queue.get(block=True, timeout=0.25) @@ -181,36 +185,3 @@ class PushWorker(threading.Thread): traceback.print_exc() await self.client.close() - - -## Can't sub-class web.Request, so let's just add some properties -def request_actor(self): - try: return self['actor'] - except KeyError: pass - - -def request_instance(self): - try: return self['instance'] - except KeyError: pass - - -def request_message(self): - try: return self['message'] - except KeyError: pass - - -def request_signature(self): - if 'signature' not in self._state: - try: self['signature'] = DotDict.new_from_signature(self.headers['signature']) - except KeyError: return - - return self['signature'] - - -setattr(web.Request, 'actor', property(request_actor)) -setattr(web.Request, 'instance', property(request_instance)) -setattr(web.Request, 'message', property(request_message)) -setattr(web.Request, 'signature', property(request_signature)) - -setattr(web.Request, 'config', property(lambda self: self.app.config)) -setattr(web.Request, 'database', property(lambda self: self.app.database)) diff --git a/relay/http_client.py b/relay/http_client.py index 81fcd46..c58974c 100644 --- a/relay/http_client.py +++ b/relay/http_client.py @@ -116,10 +116,10 @@ class HttpClient: message = await resp.json(loads=loads) elif resp.content_type == MIMETYPES['activity']: - message = await resp.json(loads=Message.new_from_json) + message = await resp.json(loads=Message.parse) elif resp.content_type == MIMETYPES['json']: - message = await resp.json(loads=DotDict.new_from_json) + message = await resp.json(loads=DotDict.parse) else: # todo: raise TypeError or something @@ -186,7 +186,7 @@ class HttpClient: nodeinfo_url = None wk_nodeinfo = await self.get( f'https://{domain}/.well-known/nodeinfo', - loads = WellKnownNodeinfo.new_from_json + loads = WellKnownNodeinfo.parse ) if not wk_nodeinfo: @@ -204,7 +204,7 @@ class HttpClient: logging.verbose(f'Failed to fetch nodeinfo url for domain: {domain}') return False - return await self.get(nodeinfo_url, loads=Nodeinfo.new_from_json) or False + return await self.get(nodeinfo_url, loads=Nodeinfo.parse) or False async def get(database, *args, **kwargs): diff --git a/relay/misc.py b/relay/misc.py index a98088f..2e28cea 100644 --- a/relay/misc.py +++ b/relay/misc.py @@ -1,19 +1,27 @@ -import aputils -import asyncio -import base64 +from __future__ import annotations + import json import logging import socket import traceback -import uuid +import typing +from aiohttp.abc import AbstractView from aiohttp.hdrs import METH_ALL as METHODS -from aiohttp.web import Response as AiohttpResponse, View as AiohttpView +from aiohttp.web import Request as AiohttpRequest, Response as AiohttpResponse +from aiohttp.web_exceptions import HTTPMethodNotAllowed +from aputils.errors import SignatureFailureError +from aputils.misc import Digest, HttpDate, Signature +from aputils.message import Message as ApMessage from datetime import datetime +from functools import cached_property from json.decoder import JSONDecodeError from urllib.parse import urlparse from uuid import uuid4 +if typing.TYPE_CHECKING: + from typing import Coroutine, Generator + app = None @@ -161,7 +169,7 @@ class DotDict(dict): self[key] = value -class Message(DotDict): +class Message(ApMessage): @classmethod def new_actor(cls, host, pubkey, description=None): return cls({ @@ -190,7 +198,7 @@ class Message(DotDict): def new_announce(cls, host, object): return cls({ '@context': 'https://www.w3.org/ns/activitystreams', - 'id': f'https://{host}/activities/{uuid.uuid4()}', + 'id': f'https://{host}/activities/{uuid4()}', 'type': 'Announce', 'to': [f'https://{host}/followers'], 'actor': f'https://{host}/actor', @@ -205,7 +213,7 @@ class Message(DotDict): 'type': 'Follow', 'to': [actor], 'object': actor, - 'id': f'https://{host}/activities/{uuid.uuid4()}', + 'id': f'https://{host}/activities/{uuid4()}', 'actor': f'https://{host}/actor' }) @@ -214,7 +222,7 @@ class Message(DotDict): def new_unfollow(cls, host, actor, follow): return cls({ '@context': 'https://www.w3.org/ns/activitystreams', - 'id': f'https://{host}/activities/{uuid.uuid4()}', + 'id': f'https://{host}/activities/{uuid4()}', 'type': 'Undo', 'to': [actor], 'actor': f'https://{host}/actor', @@ -226,7 +234,7 @@ class Message(DotDict): def new_response(cls, host, actor, followid, accept): return cls({ '@context': 'https://www.w3.org/ns/activitystreams', - 'id': f'https://{host}/activities/{uuid.uuid4()}', + 'id': f'https://{host}/activities/{uuid4()}', 'type': 'Accept' if accept else 'Reject', 'to': [actor], 'actor': f'https://{host}/actor', @@ -239,40 +247,6 @@ class Message(DotDict): }) - # misc properties - @property - def domain(self): - return urlparse(self.id).hostname - - - # actor properties - @property - def shared_inbox(self): - return self.get('endpoints', {}).get('sharedInbox', self.inbox) - - - # activity properties - @property - def actorid(self): - if isinstance(self.actor, dict): - return self.actor.id - - return self.actor - - - @property - def objectid(self): - if isinstance(self.object, dict): - return self.object.id - - return self.object - - - @property - def signer(self): - return aputils.Signer.new_from_actor(self) - - class Response(AiohttpResponse): @classmethod def new(cls, body='', status=200, headers=None, ctype='text'): @@ -312,29 +286,147 @@ class Response(AiohttpResponse): self.headers['Location'] = value -class View(AiohttpView): - async def _iter(self): - if self.request.method not in METHODS: - self._raise_allowed_methods() +class View(AbstractView): + def __init__(self, request: AiohttpRequest): + AbstractView.__init__(self, request) - method = getattr(self, self.request.method.lower(), None) + self.signature: Signature = None + self.message: Message = None + self.actor: Message = None + self.instance: dict[str, str] = None - if method is None: - self._raise_allowed_methods() - return await method(**self.request.match_info) + def __await__(self) -> Generator[Response]: + method = self.request.method.upper() + + if method not in METHODS: + raise HTTPMethodNotAllowed(method, self.allowed_methods) + + if not (handler := self.handlers.get(method)): + raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods) from None + + return handler(self.request, **self.request.match_info).__await__() + + + @cached_property + def allowed_methods(self) -> tuple[str]: + return tuple(self.handlers.keys()) + + + @cached_property + def handlers(self) -> dict[str, Coroutine]: + data = {} + + for method in METHODS: + try: + data[method] = getattr(self, method.lower()) + + except AttributeError: + continue + + return data + + + # app components + @property + def app(self) -> Application: + return self.request.app @property - def app(self): - return self._request.app + def client(self) -> Client: + return self.app.client @property - def config(self): + def config(self) -> RelayConfig: return self.app.config @property - def database(self): + def database(self) -> RelayDatabase: return self.app.database + + + async def get_post_data(self) -> Response | None: + try: + self.signature = Signature.new_from_signature(self.request.headers['signature']) + + except KeyError: + logging.verbose('Missing signature header') + return Response.new_error(400, 'missing signature header', 'json') + + try: + self.message = await self.request.json(loads = Message.parse) + + except Exception: + traceback.print_exc() + logging.verbose('Failed to parse inbox message') + return Response.new_error(400, 'failed to parse message', 'json') + + if self.message is None: + logging.verbose('empty message') + return Response.new_error(400, 'missing message', 'json') + + if 'actor' not in self.message: + logging.verbose('actor not in message') + return Response.new_error(400, 'no actor in message', 'json') + + self.actor = await self.client.get(self.signature.keyid, sign_headers = True) + + if self.actor is None: + ## ld signatures aren't handled atm, so just ignore it + if self.message.type == 'Delete': + logging.verbose(f'Instance sent a delete which cannot be handled') + return Response.new(status=202) + + logging.verbose(f'Failed to fetch actor: {self.signature.keyid}') + return Response.new_error(400, 'failed to fetch actor', 'json') + + try: + self.signer = self.actor.signer + + except KeyError: + logging.verbose('Actor missing public key: %s', self.signature.keyid) + return Response.new_error(400, 'actor missing public key', 'json') + + try: + self.validate_signature(await self.request.read()) + + except SignatureFailureError as e: + logging.verbose(f'signature validation failed for "{self.actor.id}": {e}') + return Response.new_error(401, str(e), 'json') + + self.instance = self.database.get_inbox(self.actor.inbox) + + + # aputils.Signer.validate_signature is broken atm, so reimplement it + def validate_signature(self, body: bytes) -> None: + headers = {key.lower(): value for key, value in self.request.headers.items()} + headers["(request-target)"] = " ".join([self.request.method.lower(), self.request.path]) + + # if (digest := Digest.new_from_digest(headers.get("digest"))): + # if not body: + # raise SignatureFailureError("Missing body for digest verification") + # + # if not digest.validate(body): + # raise SignatureFailureError("Body digest does not match") + + if self.signature.algorithm_type == "hs2019": + if "(created)" not in self.signature.headers: + raise SignatureFailureError("'(created)' header not used") + + current_timestamp = HttpDate.new_utc().timestamp() + + if self.signature.created > current_timestamp: + raise SignatureFailureError("Creation date after current date") + + if current_timestamp > self.signature.expires: + raise SignatureFailureError("Expiration date before current date") + + headers["(created)"] = self.signature.created + headers["(expires)"] = self.signature.expires + + # pylint: disable=protected-access + if not self.actor.signer._validate_signature(headers, self.signature): + raise SignatureFailureError("Signature does not match") diff --git a/relay/processors.py b/relay/processors.py index 1dca6c6..cbe8c1c 100644 --- a/relay/processors.py +++ b/relay/processors.py @@ -1,11 +1,17 @@ +from __future__ import annotations + import asyncio import logging +import typing from cachetools import LRUCache from uuid import uuid4 from .misc import Message +if typing.TYPE_CHECKING: + from .misc import View + cache = LRUCache(1024) @@ -20,85 +26,86 @@ def person_check(actor, software): return True -async def handle_relay(request): - if request.message.objectid in cache: - logging.verbose(f'already relayed {request.message.objectid}') +async def handle_relay(view: View) -> None: + if view.message.objectid in cache: + logging.verbose(f'already relayed {view.message.objectid}') return message = Message.new_announce( - host = request.config.host, - object = request.message.objectid + host = view.config.host, + object = view.message.objectid ) - cache[request.message.objectid] = message.id + cache[view.message.objectid] = message.id logging.debug(f'>> relay: {message}') - inboxes = request.database.distill_inboxes(request.message) + inboxes = view.database.distill_inboxes(message) for inbox in inboxes: - request.app.push_message(inbox, message) + view.app.push_message(inbox, message) -async def handle_forward(request): - if request.message.id in cache: - logging.verbose(f'already forwarded {request.message.id}') +async def handle_forward(view: View) -> None: + if view.message.id in cache: + logging.verbose(f'already forwarded {view.message.id}') return message = Message.new_announce( - host = request.config.host, - object = request.message + host = view.config.host, + object = view.message ) - cache[request.message.id] = message.id + cache[view.message.id] = message.id logging.debug(f'>> forward: {message}') - inboxes = request.database.distill_inboxes(request.message) + inboxes = view.database.distill_inboxes(message.message) for inbox in inboxes: - request.app.push_message(inbox, message) + view.app.push_message(inbox, message) -async def handle_follow(request): - nodeinfo = await request.app.client.fetch_nodeinfo(request.actor.domain) +async def handle_follow(view: View) -> None: + nodeinfo = await view.client.fetch_nodeinfo(view.actor.domain) software = nodeinfo.sw_name if nodeinfo else None ## reject if software used by actor is banned - if request.config.is_banned_software(software): - request.app.push_message( - request.actor.shared_inbox, + if view.config.is_banned_software(software): + view.app.push_message( + view.actor.shared_inbox, Message.new_response( - host = request.config.host, - actor = request.actor.id, - followid = request.message.id, + host = view.config.host, + actor = view.actor.id, + followid = view.message.id, accept = False ) ) - return logging.verbose(f'Rejected follow from actor for using specific software: actor={request.actor.id}, software={software}') + return logging.verbose(f'Rejected follow from actor for using specific software: actor={view.actor.id}, software={software}') ## reject if the actor is not an instance actor - if person_check(request.actor, software): - request.app.push_message( - request.actor.shared_inbox, + if person_check(view.actor, software): + view.app.push_message( + view.actor.shared_inbox, Message.new_response( - host = request.config.host, - actor = request.actor.id, - followid = request.message.id, + host = view.config.host, + actor = view.actor.id, + followid = view.message.id, accept = False ) ) - return logging.verbose(f'Non-application actor tried to follow: {request.actor.id}') + logging.verbose(f'Non-application actor tried to follow: {view.actor.id}') + return - request.database.add_inbox(request.actor.shared_inbox, request.message.id, software) - request.database.save() + view.database.add_inbox(view.actor.shared_inbox, view.message.id, software) + view.database.save() - request.app.push_message( - request.actor.shared_inbox, + view.app.push_message( + view.actor.shared_inbox, Message.new_response( - host = request.config.host, - actor = request.actor.id, - followid = request.message.id, + host = view.config.host, + actor = view.actor.id, + followid = view.message.id, accept = True ) ) @@ -106,31 +113,37 @@ async def handle_follow(request): # Are Akkoma and Pleroma the only two that expect a follow back? # Ignoring only Mastodon for now if software != 'mastodon': - request.app.push_message( - request.actor.shared_inbox, + view.app.push_message( + view.actor.shared_inbox, Message.new_follow( - host = request.config.host, - actor = request.actor.id + host = view.config.host, + actor = view.actor.id ) ) -async def handle_undo(request): +async def handle_undo(view: View) -> None: ## If the object is not a Follow, forward it - if request.message.object.type != 'Follow': - return await handle_forward(request) + if view.message.object['type'] != 'Follow': + return await handle_forward(view) + + if not view.database.del_inbox(view.actor.domain, view.message.object['id']): + logging.verbose( + 'Failed to delete "%s" with follow ID "%s"', + view.actor.id, + view.message.object['id'] + ) - if not request.database.del_inbox(request.actor.domain, request.message.id): return - request.database.save() + view.database.save() - request.app.push_message( - request.actor.shared_inbox, + view.app.push_message( + view.actor.shared_inbox, Message.new_unfollow( - host = request.config.host, - actor = request.actor.id, - follow = request.message + host = view.config.host, + actor = view.actor.id, + follow = view.message ) ) @@ -145,16 +158,20 @@ processors = { } -async def run_processor(request): - if request.message.type not in processors: +async def run_processor(view: View): + if view.message.type not in processors: + logging.verbose( + f'Message type "{view.message.type}" from actor cannot be handled: {view.actor.id}' + ) + return - if request.instance and not request.instance.get('software'): - nodeinfo = await request.app.client.fetch_nodeinfo(request.instance['domain']) + if view.instance and not view.instance.get('software'): + nodeinfo = await view.client.fetch_nodeinfo(view.instance['domain']) if nodeinfo: - request.instance['software'] = nodeinfo.sw_name - request.database.save() + view.instance['software'] = nodeinfo.sw_name + view.database.save() - logging.verbose(f'New "{request.message.type}" from actor: {request.actor.id}') - return await processors[request.message.type](request) + logging.verbose(f'New "{view.message.type}" from actor: {view.actor.id}') + return await processors[view.message.type](view) diff --git a/relay/views.py b/relay/views.py index 9cea1ef..7968208 100644 --- a/relay/views.py +++ b/relay/views.py @@ -1,191 +1,158 @@ +from __future__ import annotations + import aputils import asyncio import logging import subprocess import traceback +import typing +from aputils.objects import Nodeinfo, Webfinger, WellKnownNodeinfo from pathlib import Path from . import __version__, misc -from .misc import DotDict, Message, Response +from .misc import Message, Response, View from .processors import run_processor +if typing.TYPE_CHECKING: + from aiohttp.web import Request + from typing import Callable -routes = [] -version = __version__ + +VIEWS = [] +VERSION = __version__ +HOME_TEMPLATE = """ + + ActivityPub Relay at {host} + + + +

This is an Activity Relay for fediverse instances.

+

{note}

+

You may subscribe to this relay with the address: https://{host}/actor

+

To host your own relay, you may download the code at this address: https://git.pleroma.social/pleroma/relay

+

List of {count} registered instances:
{targets}

+ +""" if Path(__file__).parent.parent.joinpath('.git').exists(): try: commit_label = subprocess.check_output(["git", "rev-parse", "HEAD"]).strip().decode('ascii') - version = f'{__version__} {commit_label}' + VERSION = f'{__version__} {commit_label}' - except: + except Exception: pass -def register_route(method, path): - def wrapper(func): - routes.append([method, path, func]) - return func +def register_route(*paths: str) -> Callable: + def wrapper(view: View) -> View: + for path in paths: + VIEWS.append([path, view]) + return View return wrapper -@register_route('GET', '/') -async def home(request): - targets = '
'.join(request.database.hostnames) - note = request.config.note - count = len(request.database.hostnames) - host = request.config.host +@register_route('/') +class HomeView(View): + async def get(self, request: Request) -> Response: + text = HOME_TEMPLATE.format( + host = self.config.host, + note = self.config.note, + count = len(self.database.hostnames), + targets = '
'.join(self.database.hostnames) + ) - text = f""" - -ActivityPub Relay at {host} - - - -

This is an Activity Relay for fediverse instances.

-

{note}

-

You may subscribe to this relay with the address: https://{host}/actor

-

To host your own relay, you may download the code at this address: https://git.pleroma.social/pleroma/relay

-

List of {count} registered instances:
{targets}

-""" - - return Response.new(text, ctype='html') + return Response.new(text, ctype='html') -@register_route('GET', '/inbox') -@register_route('GET', '/actor') -async def actor(request): - data = Message.new_actor( - host = request.config.host, - pubkey = request.database.signer.pubkey - ) - return Response.new(data, ctype='activity') +@register_route('/actor', '/inbox') +class ActorView(View): + async def get(self, request: Request) -> Response: + data = Message.new_actor( + host = self.config.host, + pubkey = self.database.signer.pubkey + ) + + return Response.new(data, ctype='activity') -@register_route('POST', '/inbox') -@register_route('POST', '/actor') -async def inbox(request): - config = request.config - database = request.database + async def post(self, request: Request) -> Response: + response = await self.get_post_data() - ## reject if missing signature header - if not request.signature: - logging.verbose('Actor missing signature header') - raise HTTPUnauthorized(body='missing signature') + if response is not None: + return response - try: - request['message'] = await request.json(loads=Message.new_from_json) + ## reject if the actor isn't whitelisted while the whiltelist is enabled + if self.config.whitelist_enabled and not self.config.is_whitelisted(self.actor.domain): + logging.verbose(f'Rejected actor for not being in the whitelist: {self.actor.id}') + return Response.new_error(403, 'access denied', 'json') - ## reject if there is no message - if not request.message: - logging.verbose('empty message') - return Response.new_error(400, 'missing message', 'json') + ## reject if actor is banned + if self.config.is_banned(self.actor.domain): + logging.verbose(f'Ignored request from banned actor: {self.actor.id}') + return Response.new_error(403, 'access denied', 'json') - ## reject if there is no actor in the message - if 'actor' not in request.message: - logging.verbose('actor not in message') - return Response.new_error(400, 'no actor in message', 'json') + ## reject if activity type isn't 'Follow' and the actor isn't following + if self.message.type != 'Follow' and not self.database.get_inbox(self.actor.domain): + logging.verbose(f'Rejected actor for trying to post while not following: {self.actor.id}') + return Response.new_error(401, 'access denied', 'json') - except: - ## this code should hopefully never get called - traceback.print_exc() - logging.verbose('Failed to parse inbox message') - return Response.new_error(400, 'failed to parse message', 'json') + logging.debug(f">> payload {self.message.to_json(4)}") - request['actor'] = await request.app.client.get(request.signature.keyid, sign_headers=True) - - ## reject if actor is empty - if not request.actor: - ## ld signatures aren't handled atm, so just ignore it - if request['message'].type == 'Delete': - logging.verbose(f'Instance sent a delete which cannot be handled') - return Response.new(status=202) - - logging.verbose(f'Failed to fetch actor: {request.signature.keyid}') - return Response.new_error(400, 'failed to fetch actor', 'json') - - request['instance'] = request.database.get_inbox(request['actor'].inbox) - - ## reject if the actor isn't whitelisted while the whiltelist is enabled - if config.whitelist_enabled and not config.is_whitelisted(request.actor.domain): - logging.verbose(f'Rejected actor for not being in the whitelist: {request.actor.id}') - return Response.new_error(403, 'access denied', 'json') - - ## reject if actor is banned - if request.config.is_banned(request.actor.domain): - logging.verbose(f'Ignored request from banned actor: {actor.id}') - return Response.new_error(403, 'access denied', 'json') - - ## reject if the signature is invalid - try: - await request.actor.signer.validate_aiohttp_request(request) - - except aputils.SignatureValidationError as e: - logging.verbose(f'signature validation failed for: {actor.id}') - logging.debug(str(e)) - return Response.new_error(401, str(e), 'json') - - ## reject if activity type isn't 'Follow' and the actor isn't following - if request.message.type != 'Follow' and not database.get_inbox(request.actor.domain): - logging.verbose(f'Rejected actor for trying to post while not following: {request.actor.id}') - return Response.new_error(401, 'access denied', 'json') - - logging.debug(f">> payload {request.message.to_json(4)}") - - asyncio.ensure_future(run_processor(request)) - return Response.new(status=202) + asyncio.ensure_future(run_processor(self)) + return Response.new(status = 202) -@register_route('GET', '/.well-known/webfinger') -async def webfinger(request): - try: - subject = request.query['resource'] +@register_route('/.well-known/webfinger') +class WebfingerView(View): + async def get(self, request: Request) -> Response: + try: + subject = request.query['resource'] - except KeyError: - return Response.new_error(400, 'missing \'resource\' query key', 'json') + except KeyError: + return Response.new_error(400, 'missing "resource" query key', 'json') - if subject != f'acct:relay@{request.config.host}': - return Response.new_error(404, 'user not found', 'json') + if subject != f'acct:relay@{self.config.host}': + return Response.new_error(404, 'user not found', 'json') - data = aputils.Webfinger.new( - handle = 'relay', - domain = request.config.host, - actor = request.config.actor - ) + data = Webfinger.new( + handle = 'relay', + domain = self.config.host, + actor = self.config.actor + ) - return Response.new(data, ctype='json') + return Response.new(data, ctype = 'json') -@register_route('GET', '/nodeinfo/{version:\d.\d\.json}') -async def nodeinfo(request): - niversion = request.match_info['version'][:3] +@register_route('/nodeinfo/{niversion:\\d.\\d}.json', '/nodeinfo/{niversion:\\d.\\d}') +class NodeinfoView(View): + async def get(self, request: Request, niversion: str) -> Response: + data = dict( + name = 'activityrelay', + version = VERSION, + protocols = ['activitypub'], + open_regs = not self.config.whitelist_enabled, + users = 1, + metadata = {'peers': self.database.hostnames} + ) - data = dict( - name = 'activityrelay', - version = version, - protocols = ['activitypub'], - open_regs = not request.config.whitelist_enabled, - users = 1, - metadata = {'peers': request.database.hostnames} - ) + if niversion == '2.1': + data['repo'] = 'https://git.pleroma.social/pleroma/relay' - if niversion == '2.1': - data['repo'] = 'https://git.pleroma.social/pleroma/relay' - - return Response.new(aputils.Nodeinfo.new(**data), ctype='json') + return Response.new(Nodeinfo.new(**data), ctype = 'json') -@register_route('GET', '/.well-known/nodeinfo') -async def nodeinfo_wellknown(request): - data = aputils.WellKnownNodeinfo.new_template(request.config.host) - return Response.new(data, ctype='json') +@register_route('/.well-known/nodeinfo') +class WellknownNodeinfoView(View): + async def get(self, request: Request) -> Response: + data = WellKnownNodeinfo.new_template(self.config.host) + return Response.new(data, ctype = 'json')