Compare commits

..

No commits in common. "b00daa5a7857fea86b580d33a872786a003fda9d" and "e0ca93ab930ad9c38f310df97879ccb5cf05bd5a" have entirely different histories.

9 changed files with 473 additions and 522 deletions

View file

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

View file

@ -5,7 +5,7 @@ import json
import os
import platform
from aiohttp.web import Request, Response as AiohttpResponse
from aiohttp.web import Response as AiohttpResponse
from collections.abc import Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any, TypedDict, TypeVar
@ -207,19 +207,6 @@ class Response(AiohttpResponse):
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
def location(self) -> str:
return self.headers.get('Location', '')

View file

@ -4,11 +4,10 @@ import typing
from . import logger as logging
from .database import Connection
from .misc import Message, get_app
from .misc import Message
if typing.TYPE_CHECKING:
from .app import Application
from .views.activitypub import InboxData
from .views.activitypub import ActorView
def actor_type_check(actor: Message, software: str | None) -> bool:
@ -22,98 +21,98 @@ def actor_type_check(actor: Message, software: str | None) -> bool:
return False
async def handle_relay(app: Application, data: InboxData, conn: Connection) -> None:
async def handle_relay(view: ActorView, conn: Connection) -> None:
try:
app.cache.get('handle-relay', data.message.object_id)
logging.verbose('already relayed %s', data.message.object_id)
view.cache.get('handle-relay', view.message.object_id)
logging.verbose('already relayed %s', view.message.object_id)
return
except KeyError:
pass
message = Message.new_announce(app.config.domain, data.message.object_id)
message = Message.new_announce(view.config.domain, view.message.object_id)
logging.debug('>> relay: %s', message)
for instance in conn.distill_inboxes(data.message):
app.push_message(instance.inbox, message, instance)
for instance in conn.distill_inboxes(view.message):
view.app.push_message(instance.inbox, message, instance)
app.cache.set('handle-relay', data.message.object_id, message.id, 'str')
view.cache.set('handle-relay', view.message.object_id, message.id, 'str')
async def handle_forward(app: Application, data: InboxData, conn: Connection) -> None:
async def handle_forward(view: ActorView, conn: Connection) -> None:
try:
app.cache.get('handle-relay', data.message.id)
logging.verbose('already forwarded %s', data.message.id)
view.cache.get('handle-relay', view.message.id)
logging.verbose('already forwarded %s', view.message.id)
return
except KeyError:
pass
message = Message.new_announce(app.config.domain, data.message)
message = Message.new_announce(view.config.domain, view.message)
logging.debug('>> forward: %s', message)
for instance in conn.distill_inboxes(data.message):
app.push_message(instance.inbox, data.message, instance)
for instance in conn.distill_inboxes(view.message):
view.app.push_message(instance.inbox, view.message, instance)
app.cache.set('handle-relay', data.message.id, message.id, 'str')
view.cache.set('handle-relay', view.message.id, message.id, 'str')
async def handle_follow(app: Application, data: InboxData, conn: Connection) -> None:
nodeinfo = await app.client.fetch_nodeinfo(data.actor.domain, force = True)
async def handle_follow(view: ActorView, conn: Connection) -> None:
nodeinfo = await view.client.fetch_nodeinfo(view.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', data.actor.id)
logging.verbose('Rejected banned actor: %s', view.actor.id)
app.push_message(
data.actor.shared_inbox,
view.app.push_message(
view.actor.shared_inbox,
Message.new_response(
host = app.config.domain,
actor = data.actor.id,
followid = data.message.id,
host = view.config.domain,
actor = view.actor.id,
followid = view.message.id,
accept = False
),
data.instance
view.instance
)
logging.verbose(
'Rejected follow from actor for using specific software: actor=%s, software=%s',
data.actor.id,
view.actor.id,
software
)
return
# reject if the actor is not an instance actor
if actor_type_check(data.actor, software):
logging.verbose('Non-application actor tried to follow: %s', data.actor.id)
if actor_type_check(view.actor, software):
logging.verbose('Non-application actor tried to follow: %s', view.actor.id)
app.push_message(
data.actor.shared_inbox,
view.app.push_message(
view.actor.shared_inbox,
Message.new_response(
host = app.config.domain,
actor = data.actor.id,
followid = data.message.id,
host = view.config.domain,
actor = view.actor.id,
followid = view.message.id,
accept = False
),
data.instance
view.instance
)
return
if not conn.get_domain_whitelist(data.actor.domain):
if not conn.get_domain_whitelist(view.actor.domain):
# add request if approval-required is enabled
if config.approval_required:
logging.verbose('New follow request fromm actor: %s', data.actor.id)
logging.verbose('New follow request fromm actor: %s', view.actor.id)
with conn.transaction():
data.instance = conn.put_inbox(
domain = data.actor.domain,
inbox = data.actor.shared_inbox,
actor = data.actor.id,
followid = data.message.id,
view.instance = conn.put_inbox(
domain = view.actor.domain,
inbox = view.actor.shared_inbox,
actor = view.actor.id,
followid = view.message.id,
software = software,
accepted = False
)
@ -122,84 +121,81 @@ async def handle_follow(app: Application, data: InboxData, conn: Connection) ->
# 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', data.actor.id)
logging.verbose('Rejected actor for not being in the whitelist: %s', view.actor.id)
app.push_message(
data.actor.shared_inbox,
view.app.push_message(
view.actor.shared_inbox,
Message.new_response(
host = app.config.domain,
actor = data.actor.id,
followid = data.message.id,
host = view.config.domain,
actor = view.actor.id,
followid = view.message.id,
accept = False
),
data.instance
view.instance
)
return
with conn.transaction():
data.instance = conn.put_inbox(
domain = data.actor.domain,
inbox = data.actor.shared_inbox,
actor = data.actor.id,
followid = data.message.id,
view.instance = conn.put_inbox(
domain = view.actor.domain,
inbox = view.actor.shared_inbox,
actor = view.actor.id,
followid = view.message.id,
software = software,
accepted = True
)
app.push_message(
data.actor.shared_inbox,
view.app.push_message(
view.actor.shared_inbox,
Message.new_response(
host = app.config.domain,
actor = data.actor.id,
followid = data.message.id,
host = view.config.domain,
actor = view.actor.id,
followid = view.message.id,
accept = True
),
data.instance
view.instance
)
# Are Akkoma and Pleroma the only two that expect a follow back?
# Ignoring only Mastodon for now
if software != 'mastodon':
app.push_message(
data.actor.shared_inbox,
view.app.push_message(
view.actor.shared_inbox,
Message.new_follow(
host = app.config.domain,
actor = data.actor.id
host = view.config.domain,
actor = view.actor.id
),
data.instance
view.instance
)
async def handle_undo(app: Application, data: InboxData, conn: Connection) -> None:
if data.message.object['type'] != 'Follow':
async def handle_undo(view: ActorView, conn: Connection) -> None:
if view.message.object['type'] != 'Follow':
# forwarding deletes does not work, so don't bother
# await handle_forward(app, data, conn)
# await handle_forward(view, 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 data.instance.followid and data.instance.followid != data.message.object_id:
if view.instance.followid and view.instance.followid != view.message.object_id:
return
with conn.transaction():
if not conn.del_inbox(data.actor.id):
if not conn.del_inbox(view.actor.id):
logging.verbose(
'Failed to delete "%s" with follow ID "%s"',
data.actor.id,
data.message.object_id
view.actor.id,
view.message.object_id
)
app.push_message(
data.actor.shared_inbox,
view.app.push_message(
view.actor.shared_inbox,
Message.new_unfollow(
host = app.config.domain,
actor = data.actor.id,
follow = data.message
host = view.config.domain,
actor = view.actor.id,
follow = view.message
),
data.instance
view.instance
)
@ -213,34 +209,32 @@ processors = {
}
async def run_processor(data: InboxData) -> None:
if data.message.type not in processors:
async def run_processor(view: ActorView) -> None:
if view.message.type not in processors:
logging.verbose(
'Message type "%s" from actor cannot be handled: %s',
data.message.type,
data.actor.id
view.message.type,
view.actor.id
)
return
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 view.database.session() as conn:
if view.instance:
if not view.instance.software:
if (nodeinfo := await view.client.fetch_nodeinfo(view.instance.domain)):
with conn.transaction():
data.instance = conn.put_inbox(
domain = data.instance.domain,
view.instance = conn.put_inbox(
domain = view.instance.domain,
software = nodeinfo.sw_name
)
if not data.instance.actor:
if not view.instance.actor:
with conn.transaction():
data.instance = conn.put_inbox(
domain = data.instance.domain,
actor = data.actor.id
view.instance = conn.put_inbox(
domain = view.instance.domain,
actor = view.actor.id
)
logging.verbose('New "%s" from actor: %s', data.message.type, data.actor.id)
await processors[data.message.type](app, data, conn)
logging.verbose('New "%s" from actor: %s', view.message.type, view.actor.id)
await processors[view.message.type](view, conn)

View file

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

View file

@ -1,86 +1,112 @@
from __future__ import annotations
import aputils
import traceback
from aiohttp import ClientConnectorError
from aiohttp.web import Request
from aputils import Signature, SignatureFailureError, Signer
from blib import HttpError, HttpMethod
from dataclasses import dataclass
from typing import TYPE_CHECKING
from blib import HttpError
from .base import register_route
from .base import View, register_route
from .. import logger as logging
from ..database import schema
from ..misc import Message, Response
from ..processors import run_processor
if TYPE_CHECKING:
from ..application import Application
try:
from typing import Self
except ImportError:
from typing_extensions import Self
@dataclass(slots = True)
class InboxData:
signature: Signature
@register_route('/actor', '/inbox')
class ActorView(View):
signature: aputils.Signature
message: Message
actor: Message
signer: Signer
instance: schema.Instance | None
instance: schema.Instance
signer: aputils.Signer
@classmethod
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
def __init__(self, request: Request):
View.__init__(self, request)
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:
signature = Signature.parse(request.headers['signature'])
self.signature = aputils.Signature.parse(self.request.headers['signature'])
except KeyError:
logging.verbose('Missing signature header')
raise HttpError(400, 'missing signature header')
try:
message = await request.json(loads = Message.parse)
message: Message | None = await self.request.json(loads = Message.parse)
except Exception:
traceback.print_exc()
logging.verbose('Failed to parse message from actor: %s', signature.keyid)
logging.verbose('Failed to parse message from actor: %s', self.signature.keyid)
raise HttpError(400, 'failed to parse message')
if message is None:
logging.verbose('empty message')
raise HttpError(400, 'missing message')
if 'actor' not in message:
self.message = message
if 'actor' not in self.message:
logging.verbose('actor not in message')
raise HttpError(400, 'no actor in message')
try:
actor = await app.client.get(signature.keyid, True, Message)
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 message.type == 'Delete':
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', signature.keyid)
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', signature.keyid, str(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:
@ -88,110 +114,69 @@ class InboxData:
raise HttpError(500, 'unexpected error when fetching actor')
try:
signer = actor.signer
self.signer = self.actor.signer
except KeyError:
logging.verbose('Actor missing public key: %s', signature.keyid)
logging.verbose('Actor missing public key: %s', self.signature.keyid)
raise HttpError(400, 'actor missing public key')
try:
await signer.validate_request_async(request)
await self.signer.validate_request_async(self.request)
except SignatureFailureError as e:
logging.verbose('signature validation failed for "%s": %s', actor.id, e)
except aputils.SignatureFailureError as e:
logging.verbose('signature validation failed for "%s": %s', self.actor.id, e)
raise HttpError(401, str(e))
return cls(signature, message, actor, signer, None)
@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")
@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:
@register_route('/outbox')
class OutboxView(View):
async def get(self, request: Request) -> Response:
msg = aputils.Message.new(
aputils.ObjectType.ORDERED_COLLECTION,
{
"id": f'https://{app.config.domain}/outbox',
"id": f'https://{self.config.domain}/outbox',
"totalItems": 0,
"orderedItems": []
}
)
return Response.new(msg, ctype = "activity")
return Response.new(msg, ctype = 'activity')
@register_route(HttpMethod.GET, '/following', '/followers')
async def handle_follow(app: Application, request: Request) -> Response:
with app.database.session(False) as s:
@register_route('/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://{app.config.domain}{request.path}',
"id": f'https://{self.config.domain}{request.path}',
"totalItems": len(inboxes),
"items": inboxes
}
)
return Response.new(msg, ctype = "activity")
return Response.new(msg, ctype = 'activity')
@register_route(HttpMethod.GET, '/.well-known/webfinger')
async def get(app: Application, request: Request) -> Response:
@register_route('/.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@{app.config.domain}':
if subject != f'acct:relay@{self.config.domain}':
raise HttpError(404, 'user not found')
data = aputils.Webfinger.new(
handle = 'relay',
domain = app.config.domain,
actor = app.config.actor
domain = self.config.domain,
actor = self.config.actor
)
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 urllib.parse import urlparse
from .base import View, register_view
from .base import View, register_route
from .. import __version__
from ..database import ConfigData, schema
@ -57,8 +57,8 @@ async def handle_api_path(
return response
@register_view('/oauth/authorize')
@register_view('/api/oauth/authorize')
@register_route('/oauth/authorize')
@register_route('/api/oauth/authorize')
class OauthAuthorize(View):
async def get(self, request: Request) -> Response:
data = await self.get_api_data(['response_type', 'client_id', 'redirect_uri'], [])
@ -75,16 +75,19 @@ class OauthAuthorize(View):
raise HttpError(400, 'Application has already been authorized')
if app.auth_code is not None:
page = "page/authorization_show.haml"
context = {'application': app}
html = self.template.render(
'page/authorize_show.haml', self.request, **context
)
else:
page = "page/authorize_new.haml"
return Response.new(html, ctype = 'html')
if data['redirect_uri'] != app.redirect_uri:
raise HttpError(400, 'redirect_uri does not match application')
context = {'application': app}
return Response.new_template(200, page, request, context)
html = self.template.render('page/authorize_new.haml', self.request, **context)
return Response.new(html, ctype = 'html')
async def post(self, request: Request) -> Response:
@ -105,7 +108,11 @@ class OauthAuthorize(View):
if app.redirect_uri == DEFAULT_REDIRECT:
context = {'application': app}
return Response.new_template(200, "page/authorize_show.haml", request, context)
html = self.template.render(
'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}')
@ -115,8 +122,8 @@ class OauthAuthorize(View):
return Response.new_redir('/')
@register_view('/oauth/token')
@register_view('/api/oauth/token')
@register_route('/oauth/token')
@register_route('/api/oauth/token')
class OauthToken(View):
async def post(self, request: Request) -> Response:
data = await self.get_api_data(
@ -141,8 +148,8 @@ class OauthToken(View):
return Response.new(app.get_api_data(True), ctype = 'json')
@register_view('/oauth/revoke')
@register_view('/api/oauth/revoke')
@register_route('/oauth/revoke')
@register_route('/api/oauth/revoke')
class OauthRevoke(View):
async def post(self, request: Request) -> Response:
data = await self.get_api_data(['client_id', 'client_secret', 'token'], [])
@ -160,7 +167,7 @@ class OauthRevoke(View):
return Response.new({'msg': 'Token deleted'}, ctype = 'json')
@register_view('/api/v1/app')
@register_route('/api/v1/app')
class App(View):
async def get(self, request: Request) -> Response:
return Response.new(request['token'].get_api_data(), ctype = 'json')
@ -189,7 +196,7 @@ class App(View):
return Response.new({'msg': 'Token deleted'}, ctype = 'json')
@register_view('/api/v1/login')
@register_route('/api/v1/login')
class Login(View):
async def post(self, request: Request) -> Response:
data = await self.get_api_data(['username', 'password'], [])
@ -221,7 +228,7 @@ class Login(View):
return resp
@register_view('/api/v1/relay')
@register_route('/api/v1/relay')
class RelayInfo(View):
async def get(self, request: Request) -> Response:
with self.database.session() as conn:
@ -243,7 +250,7 @@ class RelayInfo(View):
return Response.new(data, ctype = 'json')
@register_view('/api/v1/config')
@register_route('/api/v1/config')
class Config(View):
async def get(self, request: Request) -> Response:
data = {}
@ -292,7 +299,7 @@ class Config(View):
return Response.new({'message': 'Updated config'}, ctype = 'json')
@register_view('/api/v1/instance')
@register_route('/api/v1/instance')
class Inbox(View):
async def get(self, request: Request) -> Response:
with self.database.session() as conn:
@ -371,7 +378,7 @@ class Inbox(View):
return Response.new({'message': 'Deleted instance'}, ctype = 'json')
@register_view('/api/v1/request')
@register_route('/api/v1/request')
class RequestView(View):
async def get(self, request: Request) -> Response:
with self.database.session() as conn:
@ -415,7 +422,7 @@ class RequestView(View):
return Response.new(resp_message, ctype = 'json')
@register_view('/api/v1/domain_ban')
@register_route('/api/v1/domain_ban')
class DomainBan(View):
async def get(self, request: Request) -> Response:
with self.database.session() as conn:
@ -475,7 +482,7 @@ class DomainBan(View):
return Response.new({'message': 'Unbanned domain'}, ctype = 'json')
@register_view('/api/v1/software_ban')
@register_route('/api/v1/software_ban')
class SoftwareBan(View):
async def get(self, request: Request) -> Response:
with self.database.session() as conn:
@ -531,7 +538,7 @@ class SoftwareBan(View):
return Response.new({'message': 'Unbanned software'}, ctype = 'json')
@register_view('/api/v1/user')
@register_route('/api/v1/user')
class User(View):
async def get(self, request: Request) -> Response:
with self.database.session() as conn:
@ -587,7 +594,7 @@ class User(View):
return Response.new({'message': 'Deleted user'}, ctype = 'json')
@register_view('/api/v1/whitelist')
@register_route('/api/v1/whitelist')
class Whitelist(View):
async def get(self, request: Request) -> Response:
with self.database.session() as conn:

View file

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

View file

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

View file

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