convert activitypub routes to use register_route

This commit is contained in:
Izalia Mae 2024-10-12 13:09:02 -04:00
parent 091f8175b5
commit b00daa5a78
2 changed files with 178 additions and 255 deletions

View file

@ -4,10 +4,11 @@ import typing
from . import logger as logging from . import logger as logging
from .database import Connection from .database import Connection
from .misc import Message from .misc import Message, get_app
if typing.TYPE_CHECKING: 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: 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 return False
async def handle_relay(view: ActorView, conn: Connection) -> None: async def handle_relay(app: Application, data: InboxData, conn: Connection) -> None:
try: try:
view.cache.get('handle-relay', view.message.object_id) app.cache.get('handle-relay', data.message.object_id)
logging.verbose('already relayed %s', view.message.object_id) logging.verbose('already relayed %s', data.message.object_id)
return return
except KeyError: except KeyError:
pass 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) logging.debug('>> relay: %s', message)
for instance in conn.distill_inboxes(view.message): for instance in conn.distill_inboxes(data.message):
view.app.push_message(instance.inbox, message, instance) 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: try:
view.cache.get('handle-relay', view.message.id) app.cache.get('handle-relay', data.message.id)
logging.verbose('already forwarded %s', view.message.id) logging.verbose('already forwarded %s', data.message.id)
return return
except KeyError: except KeyError:
pass pass
message = Message.new_announce(view.config.domain, view.message) message = Message.new_announce(app.config.domain, data.message)
logging.debug('>> forward: %s', message) logging.debug('>> forward: %s', message)
for instance in conn.distill_inboxes(view.message): for instance in conn.distill_inboxes(data.message):
view.app.push_message(instance.inbox, view.message, instance) 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: async def handle_follow(app: Application, data: InboxData, conn: Connection) -> None:
nodeinfo = await view.client.fetch_nodeinfo(view.actor.domain, force = True) nodeinfo = await app.client.fetch_nodeinfo(data.actor.domain, force = True)
software = nodeinfo.sw_name if nodeinfo else None software = nodeinfo.sw_name if nodeinfo else None
config = conn.get_config_all() config = conn.get_config_all()
# reject if software used by actor is banned # reject if software used by actor is banned
if software and conn.get_software_ban(software): 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( app.push_message(
view.actor.shared_inbox, data.actor.shared_inbox,
Message.new_response( Message.new_response(
host = view.config.domain, host = app.config.domain,
actor = view.actor.id, actor = data.actor.id,
followid = view.message.id, followid = data.message.id,
accept = False accept = False
), ),
view.instance data.instance
) )
logging.verbose( logging.verbose(
'Rejected follow from actor for using specific software: actor=%s, software=%s', 'Rejected follow from actor for using specific software: actor=%s, software=%s',
view.actor.id, data.actor.id,
software software
) )
return return
# reject if the actor is not an instance actor # reject if the actor is not an instance actor
if actor_type_check(view.actor, software): if actor_type_check(data.actor, software):
logging.verbose('Non-application actor tried to follow: %s', view.actor.id) logging.verbose('Non-application actor tried to follow: %s', data.actor.id)
view.app.push_message( app.push_message(
view.actor.shared_inbox, data.actor.shared_inbox,
Message.new_response( Message.new_response(
host = view.config.domain, host = app.config.domain,
actor = view.actor.id, actor = data.actor.id,
followid = view.message.id, followid = data.message.id,
accept = False accept = False
), ),
view.instance data.instance
) )
return 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 # add request if approval-required is enabled
if config.approval_required: 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(): with conn.transaction():
view.instance = conn.put_inbox( data.instance = conn.put_inbox(
domain = view.actor.domain, domain = data.actor.domain,
inbox = view.actor.shared_inbox, inbox = data.actor.shared_inbox,
actor = view.actor.id, actor = data.actor.id,
followid = view.message.id, followid = data.message.id,
software = software, software = software,
accepted = False 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 # reject if the actor isn't whitelisted while the whiltelist is enabled
if config.whitelist_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( app.push_message(
view.actor.shared_inbox, data.actor.shared_inbox,
Message.new_response( Message.new_response(
host = view.config.domain, host = app.config.domain,
actor = view.actor.id, actor = data.actor.id,
followid = view.message.id, followid = data.message.id,
accept = False accept = False
), ),
view.instance data.instance
) )
return return
with conn.transaction(): with conn.transaction():
view.instance = conn.put_inbox( data.instance = conn.put_inbox(
domain = view.actor.domain, domain = data.actor.domain,
inbox = view.actor.shared_inbox, inbox = data.actor.shared_inbox,
actor = view.actor.id, actor = data.actor.id,
followid = view.message.id, followid = data.message.id,
software = software, software = software,
accepted = True accepted = True
) )
view.app.push_message( app.push_message(
view.actor.shared_inbox, data.actor.shared_inbox,
Message.new_response( Message.new_response(
host = view.config.domain, host = app.config.domain,
actor = view.actor.id, actor = data.actor.id,
followid = view.message.id, followid = data.message.id,
accept = True accept = True
), ),
view.instance data.instance
) )
# 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':
view.app.push_message( app.push_message(
view.actor.shared_inbox, data.actor.shared_inbox,
Message.new_follow( Message.new_follow(
host = view.config.domain, host = app.config.domain,
actor = view.actor.id actor = data.actor.id
), ),
view.instance data.instance
) )
async def handle_undo(view: ActorView, conn: Connection) -> None: async def handle_undo(app: Application, data: InboxData, conn: Connection) -> None:
if view.message.object['type'] != 'Follow': if data.message.object['type'] != 'Follow':
# forwarding deletes does not work, so don't bother # forwarding deletes does not work, so don't bother
# await handle_forward(view, conn) # await handle_forward(app, data, conn)
return return
if data.instance is None:
raise ValueError(f"Actor not in database: {data.actor.id}")
# prevent past unfollows from removing an instance # 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 return
with conn.transaction(): with conn.transaction():
if not conn.del_inbox(view.actor.id): if not conn.del_inbox(data.actor.id):
logging.verbose( logging.verbose(
'Failed to delete "%s" with follow ID "%s"', 'Failed to delete "%s" with follow ID "%s"',
view.actor.id, data.actor.id,
view.message.object_id data.message.object_id
) )
view.app.push_message( app.push_message(
view.actor.shared_inbox, data.actor.shared_inbox,
Message.new_unfollow( Message.new_unfollow(
host = view.config.domain, host = app.config.domain,
actor = view.actor.id, actor = data.actor.id,
follow = view.message follow = data.message
), ),
view.instance data.instance
) )
@ -209,32 +213,34 @@ processors = {
} }
async def run_processor(view: ActorView) -> None: async def run_processor(data: InboxData) -> None:
if view.message.type not in processors: if data.message.type not in processors:
logging.verbose( logging.verbose(
'Message type "%s" from actor cannot be handled: %s', 'Message type "%s" from actor cannot be handled: %s',
view.message.type, data.message.type,
view.actor.id data.actor.id
) )
return return
with view.database.session() as conn: app = get_app()
if view.instance:
if not view.instance.software: with app.database.session() as conn:
if (nodeinfo := await view.client.fetch_nodeinfo(view.instance.domain)): if data.instance:
if not data.instance.software:
if (nodeinfo := await app.client.fetch_nodeinfo(data.instance.domain)):
with conn.transaction(): with conn.transaction():
view.instance = conn.put_inbox( data.instance = conn.put_inbox(
domain = view.instance.domain, domain = data.instance.domain,
software = nodeinfo.sw_name software = nodeinfo.sw_name
) )
if not view.instance.actor: if not data.instance.actor:
with conn.transaction(): with conn.transaction():
view.instance = conn.put_inbox( data.instance = conn.put_inbox(
domain = view.instance.domain, domain = data.instance.domain,
actor = view.actor.id actor = data.actor.id
) )
logging.verbose('New "%s" from actor: %s', view.message.type, view.actor.id) logging.verbose('New "%s" from actor: %s', data.message.type, data.actor.id)
await processors[view.message.type](view, conn) await processors[data.message.type](app, data, conn)

View file

@ -6,11 +6,11 @@ import traceback
from aiohttp import ClientConnectorError from aiohttp import ClientConnectorError
from aiohttp.web import Request from aiohttp.web import Request
from aputils import Signature, SignatureFailureError, Signer from aputils import Signature, SignatureFailureError, Signer
from blib import HttpError from blib import HttpError, HttpMethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .base import View, register_view from .base import register_route
from .. import logger as logging from .. import logger as logging
from ..database import schema from ..database import schema
@ -27,14 +27,6 @@ if TYPE_CHECKING:
from typing_extensions import Self 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) @dataclass(slots = True)
class InboxData: class InboxData:
signature: Signature signature: Signature
@ -45,7 +37,7 @@ class InboxData:
@classmethod @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 signature: Signature | None = None
message: Message | None = None message: Message | None = None
actor: Message | None = None actor: Message | None = None
@ -112,169 +104,94 @@ class InboxData:
return cls(signature, message, actor, signer, None) return cls(signature, message, actor, signer, None)
class ActorView(View): @register_route(HttpMethod.GET, "/actor", "/inbox")
signature: aputils.Signature async def handle_actor(app: Application, request: Request) -> Response:
message: Message with app.database.session(False) as conn:
actor: Message config = conn.get_config_all()
instance: schema.Instance
signer: aputils.Signer 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): @register_route(HttpMethod.POST, "/actor", "/inbox")
View.__init__(self, request) 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: @register_route(HttpMethod.GET, '/outbox')
with self.database.session(False) as conn: async def handle_outbox(app: Application, request: Request) -> Response:
config = conn.get_config_all() msg = aputils.Message.new(
aputils.ObjectType.ORDERED_COLLECTION,
{
"id": f'https://{app.config.domain}/outbox',
"totalItems": 0,
"orderedItems": []
}
)
data = Message.new_actor( return Response.new(msg, ctype = "activity")
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')
async def post(self, request: Request) -> Response: @register_route(HttpMethod.GET, '/following', '/followers')
await self.get_post_data() 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: msg = aputils.Message.new(
self.instance = conn.get_inbox(self.actor.shared_inbox) # type: ignore[assignment] aputils.ObjectType.COLLECTION,
{
"id": f'https://{app.config.domain}{request.path}',
"totalItems": len(inboxes),
"items": inboxes
}
)
# reject if actor is banned return Response.new(msg, ctype = "activity")
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)
async def get_post_data(self) -> None: @register_route(HttpMethod.GET, '/.well-known/webfinger')
try: async def get(app: Application, request: Request) -> Response:
self.signature = aputils.Signature.parse(self.request.headers['signature']) try:
subject = request.query['resource']
except KeyError: except KeyError:
logging.verbose('Missing signature header') raise HttpError(400, 'missing "resource" query key')
raise HttpError(400, 'missing signature header')
try: if subject != f'acct:relay@{app.config.domain}':
message: Message | None = await self.request.json(loads = Message.parse) raise HttpError(404, 'user not found')
except Exception: data = aputils.Webfinger.new(
traceback.print_exc() handle = 'relay',
logging.verbose('Failed to parse message from actor: %s', self.signature.keyid) domain = app.config.domain,
raise HttpError(400, 'failed to parse message') actor = app.config.actor
)
if message is None: return Response.new(data, ctype = 'json')
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')