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 .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)

View file

@ -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')