Compare commits

..

3 commits

Author SHA1 Message Date
Izalia Mae b00daa5a78 convert activitypub routes to use register_route 2024-10-12 13:09:02 -04:00
Izalia Mae 091f8175b5 create Response.new_template method 2024-10-12 11:03:50 -04:00
Izalia Mae f9d6d7b18d route handler changes
* rename `register_route` to `register_view`
* add `register_route` function
* convert frontend and misc routes to use `register_route`
2024-10-12 10:28:22 -04:00
9 changed files with 522 additions and 473 deletions

View file

@ -29,7 +29,7 @@ from .database.schema import Instance
from .http_client import HttpClient from .http_client import HttpClient
from .misc import JSON_PATHS, TOKEN_PATHS, Message, Response from .misc import JSON_PATHS, TOKEN_PATHS, Message, Response
from .template import Template from .template import Template
from .views import VIEWS from .views import ROUTES, VIEWS
from .views.api import handle_api_path from .views.api import handle_api_path
from .views.frontend import handle_frontend_path from .views.frontend import handle_frontend_path
from .workers import PushWorkers from .workers import PushWorkers
@ -87,6 +87,9 @@ class Application(web.Application):
for path, view in VIEWS: for path, view in VIEWS:
self.router.add_view(path, view) self.router.add_view(path, view)
for method, path, handler in ROUTES:
self.router.add_route(method, path, handler)
setup_swagger( setup_swagger(
self, self,
ui_version = 3, ui_version = 3,
@ -295,14 +298,12 @@ class CacheCleanupThread(Thread):
def format_error(request: web.Request, error: HttpError) -> Response: def format_error(request: web.Request, error: HttpError) -> Response:
app: Application = request.app # type: ignore[assignment]
if request.path.startswith(JSON_PATHS) or 'json' in request.headers.get('accept', ''): if request.path.startswith(JSON_PATHS) or 'json' in request.headers.get('accept', ''):
return Response.new({'error': error.message}, error.status, ctype = 'json') return Response.new({'error': error.message}, error.status, ctype = 'json')
else: else:
body = app.template.render('page/error.haml', request, e = error) context = {"e": error}
return Response.new(body, error.status, ctype = 'html') return Response.new_template(error.status, "page/error.haml", request, context)
@web.middleware @web.middleware

View file

@ -5,7 +5,7 @@ import json
import os import os
import platform import platform
from aiohttp.web import Response as AiohttpResponse from aiohttp.web import Request, Response as AiohttpResponse
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any, TypedDict, TypeVar from typing import TYPE_CHECKING, Any, TypedDict, TypeVar
@ -207,6 +207,19 @@ class Response(AiohttpResponse):
return cls.new(body, status, {'Location': path}, ctype = 'html') return cls.new(body, status, {'Location': path}, ctype = 'html')
@classmethod
def new_template(cls: type[Self],
status: int,
path: str,
request: Request,
context: dict[str, Any] | None = None,
headers: dict[str, Any] | None = None,
ctype: str = "html") -> Self:
body = get_app().template.render(path, request, **(context or {}))
return cls.new(body, status, headers, ctype)
@property @property
def location(self) -> str: def location(self) -> str:
return self.headers.get('Location', '') return self.headers.get('Location', '')

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

@ -1,4 +1,4 @@
from __future__ import annotations from __future__ import annotations
from . import activitypub, api, frontend, misc from . import activitypub, api, frontend, misc
from .base import VIEWS, View from .base import ROUTES, VIEWS, View

View file

@ -1,112 +1,86 @@
from __future__ import annotations
import aputils import aputils
import traceback import traceback
from aiohttp import ClientConnectorError from aiohttp import ClientConnectorError
from aiohttp.web import Request from aiohttp.web import Request
from blib import HttpError from aputils import Signature, SignatureFailureError, Signer
from blib import HttpError, HttpMethod
from dataclasses import dataclass
from typing import TYPE_CHECKING
from .base import View, register_route from .base import register_route
from .. import logger as logging from .. import logger as logging
from ..database import schema from ..database import schema
from ..misc import Message, Response from ..misc import Message, Response
from ..processors import run_processor from ..processors import run_processor
if TYPE_CHECKING:
from ..application import Application
@register_route('/actor', '/inbox') try:
class ActorView(View): from typing import Self
signature: aputils.Signature
except ImportError:
from typing_extensions import Self
@dataclass(slots = True)
class InboxData:
signature: Signature
message: Message message: Message
actor: Message actor: Message
instance: schema.Instance signer: Signer
signer: aputils.Signer instance: schema.Instance | None
def __init__(self, request: Request): @classmethod
View.__init__(self, request) async def parse(cls: type[Self], app: Application, request: Request) -> Self:
signature: Signature | None = None
message: Message | None = None
actor: Message | None = None
signer: Signer | None = None
async def get(self, request: Request) -> Response:
with self.database.session(False) as conn:
config = conn.get_config_all()
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')
async def post(self, request: Request) -> Response:
await self.get_post_data()
with self.database.session() as conn:
self.instance = conn.get_inbox(self.actor.shared_inbox) # type: ignore[assignment]
# 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)
async def get_post_data(self) -> None:
try: try:
self.signature = aputils.Signature.parse(self.request.headers['signature']) signature = Signature.parse(request.headers['signature'])
except KeyError: except KeyError:
logging.verbose('Missing signature header') logging.verbose('Missing signature header')
raise HttpError(400, 'missing signature header') raise HttpError(400, 'missing signature header')
try: try:
message: Message | None = await self.request.json(loads = Message.parse) message = await request.json(loads = Message.parse)
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
logging.verbose('Failed to parse message from actor: %s', self.signature.keyid) logging.verbose('Failed to parse message from actor: %s', signature.keyid)
raise HttpError(400, 'failed to parse message') raise HttpError(400, 'failed to parse message')
if message is None: if message is None:
logging.verbose('empty message') logging.verbose('empty message')
raise HttpError(400, 'missing message') raise HttpError(400, 'missing message')
self.message = message if 'actor' not in message:
if 'actor' not in self.message:
logging.verbose('actor not in message') logging.verbose('actor not in message')
raise HttpError(400, 'no actor in message') raise HttpError(400, 'no actor in message')
try: try:
self.actor = await self.client.get(self.signature.keyid, True, Message) actor = await app.client.get(signature.keyid, True, Message)
except HttpError as e: except HttpError as e:
# ld signatures aren't handled atm, so just ignore it # ld signatures aren't handled atm, so just ignore it
if self.message.type == 'Delete': if message.type == 'Delete':
logging.verbose('Instance sent a delete which cannot be handled') logging.verbose('Instance sent a delete which cannot be handled')
raise HttpError(202, '') raise HttpError(202, '')
logging.verbose('Failed to fetch actor: %s', self.signature.keyid) logging.verbose('Failed to fetch actor: %s', signature.keyid)
logging.debug('HTTP Status %i: %s', e.status, e.message) logging.debug('HTTP Status %i: %s', e.status, e.message)
raise HttpError(400, 'failed to fetch actor') raise HttpError(400, 'failed to fetch actor')
except ClientConnectorError as e: except ClientConnectorError as e:
logging.warning('Error when trying to fetch actor: %s, %s', self.signature.keyid, str(e)) logging.warning('Error when trying to fetch actor: %s, %s', signature.keyid, str(e))
raise HttpError(400, 'failed to fetch actor') raise HttpError(400, 'failed to fetch actor')
except Exception: except Exception:
@ -114,69 +88,110 @@ class ActorView(View):
raise HttpError(500, 'unexpected error when fetching actor') raise HttpError(500, 'unexpected error when fetching actor')
try: try:
self.signer = self.actor.signer signer = actor.signer
except KeyError: except KeyError:
logging.verbose('Actor missing public key: %s', self.signature.keyid) logging.verbose('Actor missing public key: %s', signature.keyid)
raise HttpError(400, 'actor missing public key') raise HttpError(400, 'actor missing public key')
try: try:
await self.signer.validate_request_async(self.request) await signer.validate_request_async(request)
except aputils.SignatureFailureError as e: except SignatureFailureError as e:
logging.verbose('signature validation failed for "%s": %s', self.actor.id, e) logging.verbose('signature validation failed for "%s": %s', actor.id, e)
raise HttpError(401, str(e)) raise HttpError(401, str(e))
return cls(signature, message, actor, signer, None)
@register_route('/outbox')
class OutboxView(View): @register_route(HttpMethod.GET, "/actor", "/inbox")
async def get(self, request: Request) -> Response: async def handle_actor(app: Application, request: Request) -> Response:
with app.database.session(False) as conn:
config = conn.get_config_all()
data = Message.new_actor(
host = app.config.domain,
pubkey = app.signer.pubkey,
description = app.template.render_markdown(config.note),
approves = config.approval_required
)
return Response.new(data, ctype = "activity")
@register_route(HttpMethod.POST, "/actor", "/inbox")
async def handle_inbox(app: Application, request: Request) -> Response:
data = await InboxData.parse(app, request)
with app.database.session() as conn:
data.instance = conn.get_inbox(data.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)
@register_route(HttpMethod.GET, '/outbox')
async def handle_outbox(app: Application, request: Request) -> Response:
msg = aputils.Message.new( msg = aputils.Message.new(
aputils.ObjectType.ORDERED_COLLECTION, aputils.ObjectType.ORDERED_COLLECTION,
{ {
"id": f'https://{self.config.domain}/outbox', "id": f'https://{app.config.domain}/outbox',
"totalItems": 0, "totalItems": 0,
"orderedItems": [] "orderedItems": []
} }
) )
return Response.new(msg, ctype = 'activity') return Response.new(msg, ctype = "activity")
@register_route('/following', '/followers') @register_route(HttpMethod.GET, '/following', '/followers')
class RelationshipView(View): async def handle_follow(app: Application, request: Request) -> Response:
async def get(self, request: Request) -> Response: with app.database.session(False) as s:
with self.database.session(False) as s:
inboxes = [row['actor'] for row in s.get_inboxes()] inboxes = [row['actor'] for row in s.get_inboxes()]
msg = aputils.Message.new( msg = aputils.Message.new(
aputils.ObjectType.COLLECTION, aputils.ObjectType.COLLECTION,
{ {
"id": f'https://{self.config.domain}{request.path}', "id": f'https://{app.config.domain}{request.path}',
"totalItems": len(inboxes), "totalItems": len(inboxes),
"items": inboxes "items": inboxes
} }
) )
return Response.new(msg, ctype = 'activity') return Response.new(msg, ctype = "activity")
@register_route('/.well-known/webfinger') @register_route(HttpMethod.GET, '/.well-known/webfinger')
class WebfingerView(View): async def get(app: Application, request: Request) -> Response:
async def get(self, request: Request) -> Response:
try: try:
subject = request.query['resource'] subject = request.query['resource']
except KeyError: except KeyError:
raise HttpError(400, 'missing "resource" query key') raise HttpError(400, 'missing "resource" query key')
if subject != f'acct:relay@{self.config.domain}': if subject != f'acct:relay@{app.config.domain}':
raise HttpError(404, 'user not found') raise HttpError(404, 'user not found')
data = aputils.Webfinger.new( data = aputils.Webfinger.new(
handle = 'relay', handle = 'relay',
domain = self.config.domain, domain = app.config.domain,
actor = self.config.actor actor = app.config.actor
) )
return Response.new(data, ctype = 'json') return Response.new(data, ctype = 'json')

View file

@ -6,7 +6,7 @@ from blib import HttpError, convert_to_boolean
from collections.abc import Awaitable, Callable, Sequence from collections.abc import Awaitable, Callable, Sequence
from urllib.parse import urlparse from urllib.parse import urlparse
from .base import View, register_route from .base import View, register_view
from .. import __version__ from .. import __version__
from ..database import ConfigData, schema from ..database import ConfigData, schema
@ -57,8 +57,8 @@ async def handle_api_path(
return response return response
@register_route('/oauth/authorize') @register_view('/oauth/authorize')
@register_route('/api/oauth/authorize') @register_view('/api/oauth/authorize')
class OauthAuthorize(View): class OauthAuthorize(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
data = await self.get_api_data(['response_type', 'client_id', 'redirect_uri'], []) data = await self.get_api_data(['response_type', 'client_id', 'redirect_uri'], [])
@ -75,19 +75,16 @@ class OauthAuthorize(View):
raise HttpError(400, 'Application has already been authorized') raise HttpError(400, 'Application has already been authorized')
if app.auth_code is not None: if app.auth_code is not None:
context = {'application': app} page = "page/authorization_show.haml"
html = self.template.render(
'page/authorize_show.haml', self.request, **context
)
return Response.new(html, ctype = 'html') else:
page = "page/authorize_new.haml"
if data['redirect_uri'] != app.redirect_uri: if data['redirect_uri'] != app.redirect_uri:
raise HttpError(400, 'redirect_uri does not match application') raise HttpError(400, 'redirect_uri does not match application')
context = {'application': app} context = {'application': app}
html = self.template.render('page/authorize_new.haml', self.request, **context) return Response.new_template(200, page, request, context)
return Response.new(html, ctype = 'html')
async def post(self, request: Request) -> Response: async def post(self, request: Request) -> Response:
@ -108,11 +105,7 @@ class OauthAuthorize(View):
if app.redirect_uri == DEFAULT_REDIRECT: if app.redirect_uri == DEFAULT_REDIRECT:
context = {'application': app} context = {'application': app}
html = self.template.render( return Response.new_template(200, "page/authorize_show.haml", request, context)
'page/authorize_show.haml', self.request, **context
)
return Response.new(html, ctype = 'html')
return Response.new_redir(f'{app.redirect_uri}?code={app.auth_code}') return Response.new_redir(f'{app.redirect_uri}?code={app.auth_code}')
@ -122,8 +115,8 @@ class OauthAuthorize(View):
return Response.new_redir('/') return Response.new_redir('/')
@register_route('/oauth/token') @register_view('/oauth/token')
@register_route('/api/oauth/token') @register_view('/api/oauth/token')
class OauthToken(View): class OauthToken(View):
async def post(self, request: Request) -> Response: async def post(self, request: Request) -> Response:
data = await self.get_api_data( data = await self.get_api_data(
@ -148,8 +141,8 @@ class OauthToken(View):
return Response.new(app.get_api_data(True), ctype = 'json') return Response.new(app.get_api_data(True), ctype = 'json')
@register_route('/oauth/revoke') @register_view('/oauth/revoke')
@register_route('/api/oauth/revoke') @register_view('/api/oauth/revoke')
class OauthRevoke(View): class OauthRevoke(View):
async def post(self, request: Request) -> Response: async def post(self, request: Request) -> Response:
data = await self.get_api_data(['client_id', 'client_secret', 'token'], []) data = await self.get_api_data(['client_id', 'client_secret', 'token'], [])
@ -167,7 +160,7 @@ class OauthRevoke(View):
return Response.new({'msg': 'Token deleted'}, ctype = 'json') return Response.new({'msg': 'Token deleted'}, ctype = 'json')
@register_route('/api/v1/app') @register_view('/api/v1/app')
class App(View): class App(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
return Response.new(request['token'].get_api_data(), ctype = 'json') return Response.new(request['token'].get_api_data(), ctype = 'json')
@ -196,7 +189,7 @@ class App(View):
return Response.new({'msg': 'Token deleted'}, ctype = 'json') return Response.new({'msg': 'Token deleted'}, ctype = 'json')
@register_route('/api/v1/login') @register_view('/api/v1/login')
class Login(View): class Login(View):
async def post(self, request: Request) -> Response: async def post(self, request: Request) -> Response:
data = await self.get_api_data(['username', 'password'], []) data = await self.get_api_data(['username', 'password'], [])
@ -228,7 +221,7 @@ class Login(View):
return resp return resp
@register_route('/api/v1/relay') @register_view('/api/v1/relay')
class RelayInfo(View): class RelayInfo(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
with self.database.session() as conn: with self.database.session() as conn:
@ -250,7 +243,7 @@ class RelayInfo(View):
return Response.new(data, ctype = 'json') return Response.new(data, ctype = 'json')
@register_route('/api/v1/config') @register_view('/api/v1/config')
class Config(View): class Config(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
data = {} data = {}
@ -299,7 +292,7 @@ class Config(View):
return Response.new({'message': 'Updated config'}, ctype = 'json') return Response.new({'message': 'Updated config'}, ctype = 'json')
@register_route('/api/v1/instance') @register_view('/api/v1/instance')
class Inbox(View): class Inbox(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
with self.database.session() as conn: with self.database.session() as conn:
@ -378,7 +371,7 @@ class Inbox(View):
return Response.new({'message': 'Deleted instance'}, ctype = 'json') return Response.new({'message': 'Deleted instance'}, ctype = 'json')
@register_route('/api/v1/request') @register_view('/api/v1/request')
class RequestView(View): class RequestView(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
with self.database.session() as conn: with self.database.session() as conn:
@ -422,7 +415,7 @@ class RequestView(View):
return Response.new(resp_message, ctype = 'json') return Response.new(resp_message, ctype = 'json')
@register_route('/api/v1/domain_ban') @register_view('/api/v1/domain_ban')
class DomainBan(View): class DomainBan(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
with self.database.session() as conn: with self.database.session() as conn:
@ -482,7 +475,7 @@ class DomainBan(View):
return Response.new({'message': 'Unbanned domain'}, ctype = 'json') return Response.new({'message': 'Unbanned domain'}, ctype = 'json')
@register_route('/api/v1/software_ban') @register_view('/api/v1/software_ban')
class SoftwareBan(View): class SoftwareBan(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
with self.database.session() as conn: with self.database.session() as conn:
@ -538,7 +531,7 @@ class SoftwareBan(View):
return Response.new({'message': 'Unbanned software'}, ctype = 'json') return Response.new({'message': 'Unbanned software'}, ctype = 'json')
@register_route('/api/v1/user') @register_view('/api/v1/user')
class User(View): class User(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
with self.database.session() as conn: with self.database.session() as conn:
@ -594,7 +587,7 @@ class User(View):
return Response.new({'message': 'Deleted user'}, ctype = 'json') return Response.new({'message': 'Deleted user'}, ctype = 'json')
@register_route('/api/v1/whitelist') @register_view('/api/v1/whitelist')
class Whitelist(View): class Whitelist(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
with self.database.session() as conn: with self.database.session() as conn:

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from aiohttp.abc import AbstractView 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 Request from aiohttp.web import Request
from blib import HttpError from blib import HttpError, HttpMethod
from bsql import Database from bsql import Database
from collections.abc import Awaitable, Callable, Generator, Sequence, Mapping from collections.abc import Awaitable, Callable, Generator, Sequence, Mapping
from functools import cached_property from functools import cached_property
@ -21,16 +21,19 @@ if TYPE_CHECKING:
from ..application import Application from ..application import Application
from ..template import Template from ..template import Template
RouteHandler = Callable[[Application, Request], Awaitable[Response]]
HandlerCallback = Callable[[Request], Awaitable[Response]] HandlerCallback = Callable[[Request], Awaitable[Response]]
VIEWS: list[tuple[str, type[View]]] = [] VIEWS: list[tuple[str, type[View]]] = []
ROUTES: list[tuple[str, str, HandlerCallback]] = []
def convert_data(data: Mapping[str, Any]) -> dict[str, str]: def convert_data(data: Mapping[str, Any]) -> dict[str, str]:
return {key: str(value) for key, value in data.items()} return {key: str(value) for key, value in data.items()}
def register_route(*paths: str) -> Callable[[type[View]], type[View]]: def register_view(*paths: str) -> Callable[[type[View]], type[View]]:
def wrapper(view: type[View]) -> type[View]: def wrapper(view: type[View]) -> type[View]:
for path in paths: for path in paths:
VIEWS.append((path, view)) VIEWS.append((path, view))
@ -39,6 +42,20 @@ def register_route(*paths: str) -> Callable[[type[View]], type[View]]:
return wrapper return wrapper
def register_route(
method: HttpMethod | str, *paths: str) -> Callable[[RouteHandler], HandlerCallback]:
def wrapper(handler: RouteHandler) -> HandlerCallback:
async def inner(request: Request) -> Response:
return await handler(get_app(), request, **request.match_info)
for path in paths:
ROUTES.append((HttpMethod.parse(method), path, inner))
return inner
return wrapper
class View(AbstractView): class View(AbstractView):
def __await__(self) -> Generator[Any, None, Response]: def __await__(self) -> Generator[Any, None, Response]:
if self.request.method not in METHODS: if self.request.method not in METHODS:

View file

@ -1,19 +1,25 @@
from aiohttp import web from __future__ import annotations
from aiohttp.web import Request, middleware
from blib import HttpMethod
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import TYPE_CHECKING, Any
from urllib.parse import unquote from urllib.parse import unquote
from .base import View, register_route from .base import register_route
from ..database import THEMES from ..database import THEMES
from ..logger import LogLevel from ..logger import LogLevel
from ..misc import TOKEN_PATHS, Response from ..misc import TOKEN_PATHS, Response
if TYPE_CHECKING:
from ..application import Application
@web.middleware
@middleware
async def handle_frontend_path( async def handle_frontend_path(
request: web.Request, request: Request,
handler: Callable[[web.Request], Awaitable[Response]]) -> Response: handler: Callable[[Request], Awaitable[Response]]) -> Response:
if request['user'] is not None and request.path == '/login': if request['user'] is not None and request.path == '/login':
return Response.new_redir('/') return Response.new_redir('/')
@ -38,51 +44,45 @@ async def handle_frontend_path(
return response return response
@register_route('/') @register_route(HttpMethod.GET, "/")
class HomeView(View): async def handle_home(app: Application, request: Request) -> Response:
async def get(self, request: web.Request) -> Response: with app.database.session() as conn:
with self.database.session() as conn:
context: dict[str, Any] = { context: dict[str, Any] = {
'instances': tuple(conn.get_inboxes()) 'instances': tuple(conn.get_inboxes())
} }
data = self.template.render('page/home.haml', self.request, **context) return Response.new_template(200, "page/home.haml", request, context)
return Response.new(data, ctype='html')
@register_route('/login') @register_route(HttpMethod.GET, '/login')
class Login(View): async def handle_login(app: Application, request: Request) -> Response:
async def get(self, request: web.Request) -> Response: context = {"redir": unquote(request.query.get("redir", "/"))}
redir = unquote(request.query.get('redir', '/')) return Response.new_template(200, "page/login.haml", request, context)
data = self.template.render('page/login.haml', self.request, redir = redir)
return Response.new(data, ctype = 'html')
@register_route('/logout') @register_route(HttpMethod.GET, '/logout')
class Logout(View): async def handle_logout(app: Application, request: Request) -> Response:
async def get(self, request: web.Request) -> Response: with app.database.session(True) as conn:
with self.database.session(True) as conn:
conn.del_app(request['token'].client_id, request['token'].client_secret) conn.del_app(request['token'].client_id, request['token'].client_secret)
resp = Response.new_redir('/') resp = Response.new_redir('/')
resp.del_cookie('user-token', domain = self.config.domain, path = '/') resp.del_cookie('user-token', domain = app.config.domain, path = '/')
return resp return resp
@register_route('/admin') @register_route(HttpMethod.GET, '/admin')
class Admin(View): async def handle_admin(app: Application, request: Request) -> Response:
async def get(self, request: web.Request) -> Response:
return Response.new_redir(f'/login?redir={request.path}', 301) return Response.new_redir(f'/login?redir={request.path}', 301)
@register_route('/admin/instances') @register_route(HttpMethod.GET, '/admin/instances')
class AdminInstances(View): async def handle_admin_instances(
async def get(self, app: Application,
request: web.Request, request: Request,
error: str | None = None, error: str | None = None,
message: str | None = None) -> Response: message: str | None = None) -> Response:
with self.database.session() as conn: with app.database.session() as conn:
context: dict[str, Any] = { context: dict[str, Any] = {
'instances': tuple(conn.get_inboxes()), 'instances': tuple(conn.get_inboxes()),
'requests': tuple(conn.get_requests()) 'requests': tuple(conn.get_requests())
@ -94,18 +94,17 @@ class AdminInstances(View):
if message: if message:
context['message'] = message context['message'] = message
data = self.template.render('page/admin-instances.haml', self.request, **context) return Response.new_template(200, "page/admin-instances.haml", request, context)
return Response.new(data, ctype = 'html')
@register_route('/admin/whitelist') @register_route(HttpMethod.GET, '/admin/whitelist')
class AdminWhitelist(View): async def handle_admin_whitelist(
async def get(self, app: Application,
request: web.Request, request: Request,
error: str | None = None, error: str | None = None,
message: str | None = None) -> Response: message: str | None = None) -> Response:
with self.database.session() as conn: with app.database.session() as conn:
context: dict[str, Any] = { context: dict[str, Any] = {
'whitelist': tuple(conn.execute('SELECT * FROM whitelist ORDER BY domain ASC')) 'whitelist': tuple(conn.execute('SELECT * FROM whitelist ORDER BY domain ASC'))
} }
@ -116,18 +115,17 @@ class AdminWhitelist(View):
if message: if message:
context['message'] = message context['message'] = message
data = self.template.render('page/admin-whitelist.haml', self.request, **context) return Response.new_template(200, "page/admin-whitelist.haml", request, context)
return Response.new(data, ctype = 'html')
@register_route('/admin/domain_bans') @register_route(HttpMethod.GET, '/admin/domain_bans')
class AdminDomainBans(View): async def handle_admin_instance_bans(
async def get(self, app: Application,
request: web.Request, request: Request,
error: str | None = None, error: str | None = None,
message: str | None = None) -> Response: message: str | None = None) -> Response:
with self.database.session() as conn: with app.database.session() as conn:
context: dict[str, Any] = { context: dict[str, Any] = {
'bans': tuple(conn.execute('SELECT * FROM domain_bans ORDER BY domain ASC')) 'bans': tuple(conn.execute('SELECT * FROM domain_bans ORDER BY domain ASC'))
} }
@ -138,18 +136,17 @@ class AdminDomainBans(View):
if message: if message:
context['message'] = message context['message'] = message
data = self.template.render('page/admin-domain_bans.haml', self.request, **context) return Response.new_template(200, "page/admin-domain_bans.haml", request, context)
return Response.new(data, ctype = 'html')
@register_route('/admin/software_bans') @register_route(HttpMethod.GET, '/admin/software_bans')
class AdminSoftwareBans(View): async def handle_admin_software_bans(
async def get(self, app: Application,
request: web.Request, request: Request,
error: str | None = None, error: str | None = None,
message: str | None = None) -> Response: message: str | None = None) -> Response:
with self.database.session() as conn: with app.database.session() as conn:
context: dict[str, Any] = { context: dict[str, Any] = {
'bans': tuple(conn.execute('SELECT * FROM software_bans ORDER BY name ASC')) 'bans': tuple(conn.execute('SELECT * FROM software_bans ORDER BY name ASC'))
} }
@ -160,18 +157,17 @@ class AdminSoftwareBans(View):
if message: if message:
context['message'] = message context['message'] = message
data = self.template.render('page/admin-software_bans.haml', self.request, **context) return Response.new_template(200, "page/admin-software_bans.haml", request, context)
return Response.new(data, ctype = 'html')
@register_route('/admin/users') @register_route(HttpMethod.GET, '/admin/users')
class AdminUsers(View): async def handle_admin_users(
async def get(self, app: Application,
request: web.Request, request: Request,
error: str | None = None, error: str | None = None,
message: str | None = None) -> Response: message: str | None = None) -> Response:
with self.database.session() as conn: with app.database.session() as conn:
context: dict[str, Any] = { context: dict[str, Any] = {
'users': tuple(conn.execute('SELECT * FROM users ORDER BY username ASC')) 'users': tuple(conn.execute('SELECT * FROM users ORDER BY username ASC'))
} }
@ -182,13 +178,15 @@ class AdminUsers(View):
if message: if message:
context['message'] = message context['message'] = message
data = self.template.render('page/admin-users.haml', self.request, **context) return Response.new_template(200, "page/admin-users.haml", request, context)
return Response.new(data, ctype = 'html')
@register_route('/admin/config') @register_route(HttpMethod.GET, '/admin/config')
class AdminConfig(View): async def handle_admin_config(
async def get(self, request: web.Request, message: str | None = None) -> Response: app: Application,
request: Request,
message: str | None = None) -> Response:
context: dict[str, Any] = { context: dict[str, Any] = {
'themes': tuple(THEMES.keys()), 'themes': tuple(THEMES.keys()),
'levels': tuple(level.name for level in LogLevel), 'levels': tuple(level.name for level in LogLevel),
@ -206,14 +204,12 @@ class AdminConfig(View):
} }
} }
data = self.template.render('page/admin-config.haml', self.request, **context) return Response.new_template(200, "page/admin-config.haml", request, context)
return Response.new(data, ctype = 'html')
@register_route('/manifest.json') @register_route(HttpMethod.GET, '/manifest.json')
class ManifestJson(View): async def handle_manifest(app: Application, request: Request) -> Response:
async def get(self, request: web.Request) -> Response: with app.database.session(False) as conn:
with self.database.session(False) as conn:
config = conn.get_config_all() config = conn.get_config_all()
theme = THEMES[config.theme] theme = THEMES[config.theme]
@ -224,18 +220,17 @@ class ManifestJson(View):
'display': 'standalone', 'display': 'standalone',
'name': config['name'], 'name': config['name'],
'orientation': 'portrait', 'orientation': 'portrait',
'scope': f"https://{self.config.domain}/", 'scope': f"https://{app.config.domain}/",
'short_name': 'ActivityRelay', 'short_name': 'ActivityRelay',
'start_url': f"https://{self.config.domain}/", 'start_url': f"https://{app.config.domain}/",
'theme_color': theme['primary'] 'theme_color': theme['primary']
} }
return Response.new(data, ctype = 'webmanifest') return Response.new(data, ctype = 'webmanifest')
@register_route('/theme/{theme}.css') @register_route(HttpMethod.GET, '/theme/{theme}.css') # type: ignore[arg-type]
class ThemeCss(View): async def handle_theme(app: Application, request: Request, theme: str) -> Response:
async def get(self, request: web.Request, theme: str) -> Response:
try: try:
context: dict[str, Any] = { context: dict[str, Any] = {
'theme': THEMES[theme] 'theme': THEMES[theme]
@ -244,5 +239,4 @@ class ThemeCss(View):
except KeyError: except KeyError:
return Response.new('Invalid theme', 404) return Response.new('Invalid theme', 404)
data = self.template.render('variables.css', self.request, **context) return Response.new_template(200, "variables.css", request, context, ctype = "css")
return Response.new(data, ctype = 'css')

View file

@ -1,19 +1,25 @@
from __future__ import annotations
import aputils import aputils
import subprocess import subprocess
from aiohttp.web import Request from aiohttp.web import Request
from pathlib import Path from blib import File, HttpMethod
from typing import TYPE_CHECKING
from .base import View, register_route from .base import register_route
from .. import __version__ from .. import __version__
from ..misc import Response from ..misc import Response
if TYPE_CHECKING:
from ..application import Application
VERSION = __version__ VERSION = __version__
if Path(__file__).parent.parent.joinpath('.git').exists(): if File(__file__).join("../../../.git").resolve().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}'
@ -22,10 +28,15 @@ if Path(__file__).parent.parent.joinpath('.git').exists():
pass pass
@register_route('/nodeinfo/{niversion:\\d.\\d}.json', '/nodeinfo/{niversion:\\d.\\d}') NODEINFO_PATHS = [
class NodeinfoView(View): '/nodeinfo/{niversion:\\d.\\d}.json',
async def get(self, request: Request, niversion: str) -> Response: '/nodeinfo/{niversion:\\d.\\d}'
with self.database.session() as conn: ]
@register_route(HttpMethod.GET, *NODEINFO_PATHS) # type: ignore[arg-type]
async def handle_nodeinfo(app: Application, request: Request, niversion: str) -> Response:
with app.database.session() as conn:
inboxes = conn.get_inboxes() inboxes = conn.get_inboxes()
nodeinfo = aputils.Nodeinfo.new( nodeinfo = aputils.Nodeinfo.new(
@ -44,9 +55,8 @@ class NodeinfoView(View):
return Response.new(nodeinfo, ctype = 'json') return Response.new(nodeinfo, ctype = 'json')
@register_route('/.well-known/nodeinfo') @register_route(HttpMethod.GET, '/.well-known/nodeinfo')
class WellknownNodeinfoView(View): async def handle_wk_nodeinfo(app: Application, request: Request) -> Response:
async def get(self, request: Request) -> Response: data = aputils.WellKnownNodeinfo.new_template(app.config.domain)
data = aputils.WellKnownNodeinfo.new_template(self.config.domain)
return Response.new(data, ctype = 'json') return Response.new(data, ctype = 'json')