use View class and make Message a subclass of aputils.message.Message

This commit is contained in:
Izalia Mae 2024-01-09 23:15:04 -05:00
parent 9f3e84f9e5
commit 4feaccaa53
5 changed files with 353 additions and 306 deletions

View file

@ -13,7 +13,7 @@ from .config import RelayConfig
from .database import RelayDatabase from .database import RelayDatabase
from .http_client import HttpClient from .http_client import HttpClient
from .misc import DotDict, check_open_port, set_app from .misc import DotDict, check_open_port, set_app
from .views import routes from .views import VIEWS
class Application(web.Application): class Application(web.Application):
@ -49,7 +49,8 @@ class Application(web.Application):
cache_size = self.config.json_cache cache_size = self.config.json_cache
) )
self.set_signal_handler() for path, view in VIEWS:
self.router.add_view(path, view)
@property @property
@ -90,10 +91,10 @@ class Application(web.Application):
self['last_worker'] = 0 self['last_worker'] = 0
def set_signal_handler(self): def set_signal_handler(self, startup):
for sig in {'SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGTERM'}: for sig in {'SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGTERM'}:
try: 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 # some signals don't exist in windows, so skip them
except AttributeError: except AttributeError:
@ -104,8 +105,8 @@ class Application(web.Application):
if not check_open_port(self.config.listen, self.config.port): 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}') return logging.error(f'A server is already running on port {self.config.port}')
for route in routes: for view in VIEWS:
self.router.add_route(*route) self.router.add_view(*view)
logging.info(f'Starting webserver at {self.config.host} ({self.config.listen}:{self.config.port})') logging.info(f'Starting webserver at {self.config.host} ({self.config.listen}:{self.config.port})')
asyncio.run(self.handle_run()) asyncio.run(self.handle_run())
@ -118,6 +119,8 @@ class Application(web.Application):
async def handle_run(self): async def handle_run(self):
self['running'] = True self['running'] = True
self.set_signal_handler(True)
if self.config.workers > 0: if self.config.workers > 0:
for i in range(self.config.workers): for i in range(self.config.workers):
worker = PushWorker(self) worker = PushWorker(self)
@ -141,6 +144,7 @@ class Application(web.Application):
await asyncio.sleep(0.25) await asyncio.sleep(0.25)
await site.stop() await site.stop()
await self.client.close()
self['starttime'] = None self['starttime'] = None
self['running'] = False self['running'] = False
@ -155,6 +159,10 @@ class PushWorker(threading.Thread):
def run(self): def run(self):
asyncio.run(self.handle_queue())
async def handle_queue(self):
self.client = HttpClient( self.client = HttpClient(
database = self.app.database, database = self.app.database,
limit = self.app.config.push_limit, limit = self.app.config.push_limit,
@ -162,10 +170,6 @@ class PushWorker(threading.Thread):
cache_size = self.app.config.json_cache cache_size = self.app.config.json_cache
) )
asyncio.run(self.handle_queue())
async def handle_queue(self):
while self.app['running']: while self.app['running']:
try: try:
inbox, message = self.queue.get(block=True, timeout=0.25) inbox, message = self.queue.get(block=True, timeout=0.25)
@ -181,36 +185,3 @@ class PushWorker(threading.Thread):
traceback.print_exc() traceback.print_exc()
await self.client.close() 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))

View file

@ -116,10 +116,10 @@ class HttpClient:
message = await resp.json(loads=loads) message = await resp.json(loads=loads)
elif resp.content_type == MIMETYPES['activity']: 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']: elif resp.content_type == MIMETYPES['json']:
message = await resp.json(loads=DotDict.new_from_json) message = await resp.json(loads=DotDict.parse)
else: else:
# todo: raise TypeError or something # todo: raise TypeError or something
@ -186,7 +186,7 @@ class HttpClient:
nodeinfo_url = None nodeinfo_url = None
wk_nodeinfo = await self.get( wk_nodeinfo = await self.get(
f'https://{domain}/.well-known/nodeinfo', f'https://{domain}/.well-known/nodeinfo',
loads = WellKnownNodeinfo.new_from_json loads = WellKnownNodeinfo.parse
) )
if not wk_nodeinfo: if not wk_nodeinfo:
@ -204,7 +204,7 @@ class HttpClient:
logging.verbose(f'Failed to fetch nodeinfo url for domain: {domain}') logging.verbose(f'Failed to fetch nodeinfo url for domain: {domain}')
return False 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): async def get(database, *args, **kwargs):

View file

@ -1,19 +1,27 @@
import aputils from __future__ import annotations
import asyncio
import base64
import json import json
import logging import logging
import socket import socket
import traceback import traceback
import uuid import typing
from aiohttp.abc import AbstractView
from aiohttp.hdrs import METH_ALL as METHODS 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 datetime import datetime
from functools import cached_property
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import uuid4 from uuid import uuid4
if typing.TYPE_CHECKING:
from typing import Coroutine, Generator
app = None app = None
@ -161,7 +169,7 @@ class DotDict(dict):
self[key] = value self[key] = value
class Message(DotDict): class Message(ApMessage):
@classmethod @classmethod
def new_actor(cls, host, pubkey, description=None): def new_actor(cls, host, pubkey, description=None):
return cls({ return cls({
@ -190,7 +198,7 @@ class Message(DotDict):
def new_announce(cls, host, object): def new_announce(cls, host, object):
return cls({ return cls({
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
'id': f'https://{host}/activities/{uuid.uuid4()}', 'id': f'https://{host}/activities/{uuid4()}',
'type': 'Announce', 'type': 'Announce',
'to': [f'https://{host}/followers'], 'to': [f'https://{host}/followers'],
'actor': f'https://{host}/actor', 'actor': f'https://{host}/actor',
@ -205,7 +213,7 @@ class Message(DotDict):
'type': 'Follow', 'type': 'Follow',
'to': [actor], 'to': [actor],
'object': actor, 'object': actor,
'id': f'https://{host}/activities/{uuid.uuid4()}', 'id': f'https://{host}/activities/{uuid4()}',
'actor': f'https://{host}/actor' 'actor': f'https://{host}/actor'
}) })
@ -214,7 +222,7 @@ class Message(DotDict):
def new_unfollow(cls, host, actor, follow): def new_unfollow(cls, host, actor, follow):
return cls({ return cls({
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
'id': f'https://{host}/activities/{uuid.uuid4()}', 'id': f'https://{host}/activities/{uuid4()}',
'type': 'Undo', 'type': 'Undo',
'to': [actor], 'to': [actor],
'actor': f'https://{host}/actor', 'actor': f'https://{host}/actor',
@ -226,7 +234,7 @@ class Message(DotDict):
def new_response(cls, host, actor, followid, accept): def new_response(cls, host, actor, followid, accept):
return cls({ return cls({
'@context': 'https://www.w3.org/ns/activitystreams', '@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', 'type': 'Accept' if accept else 'Reject',
'to': [actor], 'to': [actor],
'actor': f'https://{host}/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): class Response(AiohttpResponse):
@classmethod @classmethod
def new(cls, body='', status=200, headers=None, ctype='text'): def new(cls, body='', status=200, headers=None, ctype='text'):
@ -312,29 +286,147 @@ class Response(AiohttpResponse):
self.headers['Location'] = value self.headers['Location'] = value
class View(AiohttpView): class View(AbstractView):
async def _iter(self): def __init__(self, request: AiohttpRequest):
if self.request.method not in METHODS: AbstractView.__init__(self, request)
self._raise_allowed_methods()
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 @property
def app(self): def client(self) -> Client:
return self._request.app return self.app.client
@property @property
def config(self): def config(self) -> RelayConfig:
return self.app.config return self.app.config
@property @property
def database(self): def database(self) -> RelayDatabase:
return self.app.database 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")

View file

@ -1,11 +1,17 @@
from __future__ import annotations
import asyncio import asyncio
import logging import logging
import typing
from cachetools import LRUCache from cachetools import LRUCache
from uuid import uuid4 from uuid import uuid4
from .misc import Message from .misc import Message
if typing.TYPE_CHECKING:
from .misc import View
cache = LRUCache(1024) cache = LRUCache(1024)
@ -20,85 +26,86 @@ def person_check(actor, software):
return True return True
async def handle_relay(request): async def handle_relay(view: View) -> None:
if request.message.objectid in cache: if view.message.objectid in cache:
logging.verbose(f'already relayed {request.message.objectid}') logging.verbose(f'already relayed {view.message.objectid}')
return return
message = Message.new_announce( message = Message.new_announce(
host = request.config.host, host = view.config.host,
object = request.message.objectid object = view.message.objectid
) )
cache[request.message.objectid] = message.id cache[view.message.objectid] = message.id
logging.debug(f'>> relay: {message}') logging.debug(f'>> relay: {message}')
inboxes = request.database.distill_inboxes(request.message) inboxes = view.database.distill_inboxes(message)
for inbox in inboxes: for inbox in inboxes:
request.app.push_message(inbox, message) view.app.push_message(inbox, message)
async def handle_forward(request): async def handle_forward(view: View) -> None:
if request.message.id in cache: if view.message.id in cache:
logging.verbose(f'already forwarded {request.message.id}') logging.verbose(f'already forwarded {view.message.id}')
return return
message = Message.new_announce( message = Message.new_announce(
host = request.config.host, host = view.config.host,
object = request.message object = view.message
) )
cache[request.message.id] = message.id cache[view.message.id] = message.id
logging.debug(f'>> forward: {message}') logging.debug(f'>> forward: {message}')
inboxes = request.database.distill_inboxes(request.message) inboxes = view.database.distill_inboxes(message.message)
for inbox in inboxes: for inbox in inboxes:
request.app.push_message(inbox, message) view.app.push_message(inbox, message)
async def handle_follow(request): async def handle_follow(view: View) -> None:
nodeinfo = await request.app.client.fetch_nodeinfo(request.actor.domain) nodeinfo = await view.client.fetch_nodeinfo(view.actor.domain)
software = nodeinfo.sw_name if nodeinfo else None software = nodeinfo.sw_name if nodeinfo else None
## reject if software used by actor is banned ## reject if software used by actor is banned
if request.config.is_banned_software(software): if view.config.is_banned_software(software):
request.app.push_message( view.app.push_message(
request.actor.shared_inbox, view.actor.shared_inbox,
Message.new_response( Message.new_response(
host = request.config.host, host = view.config.host,
actor = request.actor.id, actor = view.actor.id,
followid = request.message.id, followid = view.message.id,
accept = False 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 ## reject if the actor is not an instance actor
if person_check(request.actor, software): if person_check(view.actor, software):
request.app.push_message( view.app.push_message(
request.actor.shared_inbox, view.actor.shared_inbox,
Message.new_response( Message.new_response(
host = request.config.host, host = view.config.host,
actor = request.actor.id, actor = view.actor.id,
followid = request.message.id, followid = view.message.id,
accept = False 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) view.database.add_inbox(view.actor.shared_inbox, view.message.id, software)
request.database.save() view.database.save()
request.app.push_message( view.app.push_message(
request.actor.shared_inbox, view.actor.shared_inbox,
Message.new_response( Message.new_response(
host = request.config.host, host = view.config.host,
actor = request.actor.id, actor = view.actor.id,
followid = request.message.id, followid = view.message.id,
accept = True accept = True
) )
) )
@ -106,31 +113,37 @@ async def handle_follow(request):
# Are Akkoma and Pleroma the only two that expect a follow back? # Are Akkoma and Pleroma the only two that expect a follow back?
# Ignoring only Mastodon for now # Ignoring only Mastodon for now
if software != 'mastodon': if software != 'mastodon':
request.app.push_message( view.app.push_message(
request.actor.shared_inbox, view.actor.shared_inbox,
Message.new_follow( Message.new_follow(
host = request.config.host, host = view.config.host,
actor = request.actor.id 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 the object is not a Follow, forward it
if request.message.object.type != 'Follow': if view.message.object['type'] != 'Follow':
return await handle_forward(request) 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 return
request.database.save() view.database.save()
request.app.push_message( view.app.push_message(
request.actor.shared_inbox, view.actor.shared_inbox,
Message.new_unfollow( Message.new_unfollow(
host = request.config.host, host = view.config.host,
actor = request.actor.id, actor = view.actor.id,
follow = request.message follow = view.message
) )
) )
@ -145,16 +158,20 @@ processors = {
} }
async def run_processor(request): async def run_processor(view: View):
if request.message.type not in processors: if view.message.type not in processors:
logging.verbose(
f'Message type "{view.message.type}" from actor cannot be handled: {view.actor.id}'
)
return return
if request.instance and not request.instance.get('software'): if view.instance and not view.instance.get('software'):
nodeinfo = await request.app.client.fetch_nodeinfo(request.instance['domain']) nodeinfo = await view.client.fetch_nodeinfo(view.instance['domain'])
if nodeinfo: if nodeinfo:
request.instance['software'] = nodeinfo.sw_name view.instance['software'] = nodeinfo.sw_name
request.database.save() view.database.save()
logging.verbose(f'New "{request.message.type}" from actor: {request.actor.id}') logging.verbose(f'New "{view.message.type}" from actor: {view.actor.id}')
return await processors[request.message.type](request) return await processors[view.message.type](view)

View file

@ -1,191 +1,158 @@
from __future__ import annotations
import aputils import aputils
import asyncio import asyncio
import logging import logging
import subprocess import subprocess
import traceback import traceback
import typing
from aputils.objects import Nodeinfo, Webfinger, WellKnownNodeinfo
from pathlib import Path from pathlib import Path
from . import __version__, misc from . import __version__, misc
from .misc import DotDict, Message, Response from .misc import Message, Response, View
from .processors import run_processor 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 = """
<html><head>
<title>ActivityPub Relay at {host}</title>
<style>
p {{ color: #FFFFFF; font-family: monospace, arial; font-size: 100%; }}
body {{ background-color: #000000; }}
a {{ color: #26F; }}
a:visited {{ color: #46C; }}
a:hover {{ color: #8AF; }}
</style>
</head>
<body>
<p>This is an Activity Relay for fediverse instances.</p>
<p>{note}</p>
<p>You may subscribe to this relay with the address: <a href="https://{host}/actor">https://{host}/actor</a></p>
<p>To host your own relay, you may download the code at this address: <a href="https://git.pleroma.social/pleroma/relay">https://git.pleroma.social/pleroma/relay</a></p>
<br><p>List of {count} registered instances:<br>{targets}</p>
</body></html>
"""
if Path(__file__).parent.parent.joinpath('.git').exists(): if Path(__file__).parent.parent.joinpath('.git').exists():
try: try:
commit_label = subprocess.check_output(["git", "rev-parse", "HEAD"]).strip().decode('ascii') 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 pass
def register_route(method, path): def register_route(*paths: str) -> Callable:
def wrapper(func): def wrapper(view: View) -> View:
routes.append([method, path, func]) for path in paths:
return func VIEWS.append([path, view])
return View
return wrapper return wrapper
@register_route('GET', '/') @register_route('/')
async def home(request): class HomeView(View):
targets = '<br>'.join(request.database.hostnames) async def get(self, request: Request) -> Response:
note = request.config.note text = HOME_TEMPLATE.format(
count = len(request.database.hostnames) host = self.config.host,
host = request.config.host note = self.config.note,
count = len(self.database.hostnames),
text = f""" targets = '<br>'.join(self.database.hostnames)
<html><head> )
<title>ActivityPub Relay at {host}</title>
<style>
p {{ color: #FFFFFF; font-family: monospace, arial; font-size: 100%; }}
body {{ background-color: #000000; }}
a {{ color: #26F; }}
a:visited {{ color: #46C; }}
a:hover {{ color: #8AF; }}
</style>
</head>
<body>
<p>This is an Activity Relay for fediverse instances.</p>
<p>{note}</p>
<p>You may subscribe to this relay with the address: <a href="https://{host}/actor">https://{host}/actor</a></p>
<p>To host your own relay, you may download the code at this address: <a href="https://git.pleroma.social/pleroma/relay">https://git.pleroma.social/pleroma/relay</a></p>
<br><p>List of {count} registered instances:<br>{targets}</p>
</body></html>"""
return Response.new(text, ctype='html') return Response.new(text, ctype='html')
@register_route('GET', '/inbox')
@register_route('GET', '/actor') @register_route('/actor', '/inbox')
async def actor(request): class ActorView(View):
async def get(self, request: Request) -> Response:
data = Message.new_actor( data = Message.new_actor(
host = request.config.host, host = self.config.host,
pubkey = request.database.signer.pubkey pubkey = self.database.signer.pubkey
) )
return Response.new(data, ctype='activity') return Response.new(data, ctype='activity')
@register_route('POST', '/inbox') async def post(self, request: Request) -> Response:
@register_route('POST', '/actor') response = await self.get_post_data()
async def inbox(request):
config = request.config
database = request.database
## reject if missing signature header if response is not None:
if not request.signature: return response
logging.verbose('Actor missing signature header')
raise HTTPUnauthorized(body='missing signature')
try:
request['message'] = await request.json(loads=Message.new_from_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 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')
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')
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 ## reject if the actor isn't whitelisted while the whiltelist is enabled
if config.whitelist_enabled and not config.is_whitelisted(request.actor.domain): 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: {request.actor.id}') logging.verbose(f'Rejected actor for not being in the whitelist: {self.actor.id}')
return Response.new_error(403, 'access denied', 'json') return Response.new_error(403, 'access denied', 'json')
## reject if actor is banned ## reject if actor is banned
if request.config.is_banned(request.actor.domain): if self.config.is_banned(self.actor.domain):
logging.verbose(f'Ignored request from banned actor: {actor.id}') logging.verbose(f'Ignored request from banned actor: {self.actor.id}')
return Response.new_error(403, 'access denied', 'json') 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 ## 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): 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: {request.actor.id}') logging.verbose(f'Rejected actor for trying to post while not following: {self.actor.id}')
return Response.new_error(401, 'access denied', 'json') return Response.new_error(401, 'access denied', 'json')
logging.debug(f">> payload {request.message.to_json(4)}") logging.debug(f">> payload {self.message.to_json(4)}")
asyncio.ensure_future(run_processor(request)) asyncio.ensure_future(run_processor(self))
return Response.new(status=202) return Response.new(status = 202)
@register_route('GET', '/.well-known/webfinger') @register_route('/.well-known/webfinger')
async def webfinger(request): class WebfingerView(View):
async def get(self, request: Request) -> Response:
try: try:
subject = request.query['resource'] subject = request.query['resource']
except KeyError: except KeyError:
return Response.new_error(400, 'missing \'resource\' query key', 'json') return Response.new_error(400, 'missing "resource" query key', 'json')
if subject != f'acct:relay@{request.config.host}': if subject != f'acct:relay@{self.config.host}':
return Response.new_error(404, 'user not found', 'json') return Response.new_error(404, 'user not found', 'json')
data = aputils.Webfinger.new( data = Webfinger.new(
handle = 'relay', handle = 'relay',
domain = request.config.host, domain = self.config.host,
actor = request.config.actor actor = self.config.actor
) )
return Response.new(data, ctype='json') return Response.new(data, ctype = 'json')
@register_route('GET', '/nodeinfo/{version:\d.\d\.json}') @register_route('/nodeinfo/{niversion:\\d.\\d}.json', '/nodeinfo/{niversion:\\d.\\d}')
async def nodeinfo(request): class NodeinfoView(View):
niversion = request.match_info['version'][:3] async def get(self, request: Request, niversion: str) -> Response:
data = dict( data = dict(
name = 'activityrelay', name = 'activityrelay',
version = version, version = VERSION,
protocols = ['activitypub'], protocols = ['activitypub'],
open_regs = not request.config.whitelist_enabled, open_regs = not self.config.whitelist_enabled,
users = 1, users = 1,
metadata = {'peers': request.database.hostnames} metadata = {'peers': self.database.hostnames}
) )
if niversion == '2.1': if niversion == '2.1':
data['repo'] = 'https://git.pleroma.social/pleroma/relay' 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') @register_route('/.well-known/nodeinfo')
async def nodeinfo_wellknown(request): class WellknownNodeinfoView(View):
data = aputils.WellKnownNodeinfo.new_template(request.config.host) async def get(self, request: Request) -> Response:
return Response.new(data, ctype='json') data = WellKnownNodeinfo.new_template(self.config.host)
return Response.new(data, ctype = 'json')