Add API endpoints for relay management

This commit is contained in:
Izalia Mae 2024-02-14 20:29:30 +00:00
parent b8e0641733
commit 097f3bc896
16 changed files with 974 additions and 235 deletions

View file

@ -20,6 +20,7 @@ from .database import get_database
from .http_client import HttpClient from .http_client import HttpClient
from .misc import check_open_port from .misc import check_open_port
from .views import VIEWS from .views import VIEWS
from .views.api import handle_api_path
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from collections.abc import Awaitable from collections.abc import Awaitable
@ -35,7 +36,11 @@ class Application(web.Application):
DEFAULT: Application = None DEFAULT: Application = None
def __init__(self, cfgpath: str, gunicorn: bool = False): def __init__(self, cfgpath: str, gunicorn: bool = False):
web.Application.__init__(self) web.Application.__init__(self,
middlewares = [
handle_api_path
]
)
Application.DEFAULT = self Application.DEFAULT = self
@ -219,6 +224,6 @@ async def main_gunicorn():
except KeyError: except KeyError:
logging.error('Failed to set "CONFIG_FILE" environment. Trying to run without gunicorn?') logging.error('Failed to set "CONFIG_FILE" environment. Trying to run without gunicorn?')
raise raise RuntimeError from None
return app return app

View file

@ -1,82 +1,127 @@
-- name: get-config -- name: get-config
SELECT * FROM config WHERE key = :key SELECT * FROM config WHERE key = :key;
-- name: get-config-all -- name: get-config-all
SELECT * FROM config SELECT * FROM config;
-- name: put-config -- name: put-config
INSERT INTO config (key, value, type) INSERT INTO config (key, value, type)
VALUES (:key, :value, :type) VALUES (:key, :value, :type)
ON CONFLICT (key) DO UPDATE SET value = :value ON CONFLICT (key) DO UPDATE SET value = :value
RETURNING * RETURNING *;
-- name: del-config -- name: del-config
DELETE FROM config DELETE FROM config
WHERE key = :key WHERE key = :key;
-- name: get-inbox -- name: get-inbox
SELECT * FROM inboxes WHERE domain = :value or inbox = :value or actor = :value SELECT * FROM inboxes WHERE domain = :value or inbox = :value or actor = :value;
-- name: put-inbox -- name: put-inbox
INSERT INTO inboxes (domain, actor, inbox, followid, software, created) INSERT INTO inboxes (domain, actor, inbox, followid, software, created)
VALUES (:domain, :actor, :inbox, :followid, :software, :created) VALUES (:domain, :actor, :inbox, :followid, :software, :created)
ON CONFLICT (domain) DO UPDATE SET followid = :followid ON CONFLICT (domain) DO UPDATE SET followid = :followid
RETURNING * RETURNING *;
-- name: del-inbox -- name: del-inbox
DELETE FROM inboxes DELETE FROM inboxes
WHERE domain = :value or inbox = :value or actor = :value WHERE domain = :value or inbox = :value or actor = :value;
-- name: get-user
SELECT * FROM users
WHERE username = :value or handle = :value;
-- name: get-user-by-token
SELECT * FROM users
WHERE username = (
SELECT user FROM tokens
WHERE code = :code
);
-- name: put-user
INSERT INTO users (username, hash, handle, created)
VALUES (:username, :hash, :handle, :created)
RETURNING *;
-- name: del-user
DELETE FROM users
WHERE username = :value or handle = :value;
-- name: get-token
SELECT * FROM tokens
WHERE code = :code;
-- name: put-token
INSERT INTO tokens (code, user, created)
VALUES (:code, :user, :created)
RETURNING *;
-- name: del-token
DELETE FROM tokens
WHERE code = :code;
-- name: del-token-user
DELETE FROM tokens
WHERE user = :username;
-- name: get-software-ban -- name: get-software-ban
SELECT * FROM software_bans WHERE name = :name SELECT * FROM software_bans WHERE name = :name;
-- name: put-software-ban -- name: put-software-ban
INSERT INTO software_bans (name, reason, note, created) INSERT INTO software_bans (name, reason, note, created)
VALUES (:name, :reason, :note, :created) VALUES (:name, :reason, :note, :created)
RETURNING * RETURNING *;
-- name: del-software-ban -- name: del-software-ban
DELETE FROM software_bans DELETE FROM software_bans
WHERE name = :name WHERE name = :name;
-- name: get-domain-ban -- name: get-domain-ban
SELECT * FROM domain_bans WHERE domain = :domain SELECT * FROM domain_bans WHERE domain = :domain;
-- name: put-domain-ban -- name: put-domain-ban
INSERT INTO domain_bans (domain, reason, note, created) INSERT INTO domain_bans (domain, reason, note, created)
VALUES (:domain, :reason, :note, :created) VALUES (:domain, :reason, :note, :created)
RETURNING * RETURNING *;
-- name: del-domain-ban -- name: del-domain-ban
DELETE FROM domain_bans DELETE FROM domain_bans
WHERE domain = :domain WHERE domain = :domain;
-- name: get-domain-whitelist -- name: get-domain-whitelist
SELECT * FROM whitelist WHERE domain = :domain SELECT * FROM whitelist WHERE domain = :domain;
-- name: put-domain-whitelist -- name: put-domain-whitelist
INSERT INTO whitelist (domain, created) INSERT INTO whitelist (domain, created)
VALUES (:domain, :created) VALUES (:domain, :created)
RETURNING * RETURNING *;
-- name: del-domain-whitelist -- name: del-domain-whitelist
DELETE FROM whitelist DELETE FROM whitelist
WHERE domain = :domain WHERE domain = :domain;
-- cache functions -- -- cache functions --
@ -90,7 +135,7 @@ CREATE TABLE IF NOT EXISTS cache (
type TEXT DEFAULT 'str', type TEXT DEFAULT 'str',
updated TIMESTAMP NOT NULL, updated TIMESTAMP NOT NULL,
UNIQUE(namespace, key) UNIQUE(namespace, key)
) );
-- name: create-cache-table-postgres -- name: create-cache-table-postgres
CREATE TABLE IF NOT EXISTS cache ( CREATE TABLE IF NOT EXISTS cache (
@ -101,21 +146,21 @@ CREATE TABLE IF NOT EXISTS cache (
type TEXT DEFAULT 'str', type TEXT DEFAULT 'str',
updated TIMESTAMP NOT NULL, updated TIMESTAMP NOT NULL,
UNIQUE(namespace, key) UNIQUE(namespace, key)
) );
-- name: get-cache-item -- name: get-cache-item
SELECT * FROM cache SELECT * FROM cache
WHERE namespace = :namespace and key = :key WHERE namespace = :namespace and key = :key;
-- name: get-cache-keys -- name: get-cache-keys
SELECT key FROM cache SELECT key FROM cache
WHERE namespace = :namespace WHERE namespace = :namespace;
-- name: get-cache-namespaces -- name: get-cache-namespaces
SELECT DISTINCT namespace FROM cache SELECT DISTINCT namespace FROM cache;
-- name: set-cache-item -- name: set-cache-item
@ -123,18 +168,18 @@ INSERT INTO cache (namespace, key, value, type, updated)
VALUES (:namespace, :key, :value, :type, :date) VALUES (:namespace, :key, :value, :type, :date)
ON CONFLICT (namespace, key) DO ON CONFLICT (namespace, key) DO
UPDATE SET value = :value, type = :type, updated = :date UPDATE SET value = :value, type = :type, updated = :date
RETURNING * RETURNING *;
-- name: del-cache-item -- name: del-cache-item
DELETE FROM cache DELETE FROM cache
WHERE namespace = :namespace and key = :key WHERE namespace = :namespace and key = :key;
-- name: del-cache-namespace -- name: del-cache-namespace
DELETE FROM cache DELETE FROM cache
WHERE namespace = :namespace WHERE namespace = :namespace;
-- name: del-cache-all -- name: del-cache-all
DELETE FROM cache DELETE FROM cache;

View file

@ -52,14 +52,10 @@ def get_database(config: Config, migrate: bool = True) -> tinysql.Database:
if (schema_ver := conn.get_config('schema-version')) < get_default_value('schema-version'): if (schema_ver := conn.get_config('schema-version')) < get_default_value('schema-version'):
logging.info("Migrating database from version '%i'", schema_ver) logging.info("Migrating database from version '%i'", schema_ver)
for ver, func in VERSIONS: for ver, func in VERSIONS.items():
if schema_ver < ver: if schema_ver < ver:
conn.begin()
func(conn) func(conn)
conn.put_config('schema-version', ver) conn.put_config('schema-version', ver)
conn.commit()
if (privkey := conn.get_config('private-key')): if (privkey := conn.get_config('private-key')):
conn.app.signer = privkey conn.app.signer = privkey

View file

@ -11,8 +11,9 @@ if typing.TYPE_CHECKING:
CONFIG_DEFAULTS: dict[str, tuple[str, Any]] = { CONFIG_DEFAULTS: dict[str, tuple[str, Any]] = {
'schema-version': ('int', 20240119), 'schema-version': ('int', 20240206),
'log-level': ('loglevel', logging.LogLevel.INFO), 'log-level': ('loglevel', logging.LogLevel.INFO),
'name': ('str', 'ActivityRelay'),
'note': ('str', 'Make a note about your instance here.'), 'note': ('str', 'Make a note about your instance here.'),
'private-key': ('str', None), 'private-key': ('str', None),
'whitelist-enabled': ('bool', False) 'whitelist-enabled': ('bool', False)

View file

@ -3,8 +3,10 @@ from __future__ import annotations
import tinysql import tinysql
import typing import typing
from argon2 import PasswordHasher
from datetime import datetime, timezone from datetime import datetime, timezone
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import uuid4
from .config import CONFIG_DEFAULTS, get_default_type, get_default_value, serialize, deserialize from .config import CONFIG_DEFAULTS, get_default_type, get_default_value, serialize, deserialize
@ -28,6 +30,10 @@ RELAY_SOFTWARE = [
class Connection(tinysql.Connection): class Connection(tinysql.Connection):
hasher = PasswordHasher(
encoding = 'utf-8'
)
@property @property
def app(self) -> Application: def app(self) -> Application:
return get_app() return get_app()
@ -162,6 +168,59 @@ class Connection(tinysql.Connection):
return cur.modified_row_count == 1 return cur.modified_row_count == 1
def get_user(self, value: str) -> Row:
with self.exec_statement('get-user', {'value': value}) as cur:
return cur.one()
def get_user_by_token(self, code: str) -> Row:
with self.exec_statement('get-user-by-token', {'code': code}) as cur:
return cur.one()
def put_user(self, username: str, password: str, handle: str | None = None) -> Row:
data = {
'username': username,
'hash': self.hasher.hash(password),
'handle': handle,
'created': datetime.now(tz = timezone.utc)
}
with self.exec_statement('put-user', data) as cur:
return cur.one()
def del_user(self, username: str) -> None:
user = self.get_user(username)
with self.exec_statement('del-user', {'value': user['username']}):
pass
with self.exec_statement('del-token-user', {'username': user['username']}):
pass
def get_token(self, code: str) -> Row:
with self.exec_statement('get-token', {'code': code}) as cur:
return cur.one()
def put_token(self, username: str) -> Row:
data = {
'code': uuid4().hex,
'user': username,
'created': datetime.now(tz = timezone.utc)
}
with self.exec_statement('put-token', data) as cur:
return cur.one()
def del_token(self, code: str) -> None:
with self.exec_statement('del-token', {'code': code}):
pass
def get_domain_ban(self, domain: str) -> Row: def get_domain_ban(self, domain: str) -> Row:
if domain.startswith('http'): if domain.startswith('http'):
domain = urlparse(domain).netloc domain = urlparse(domain).netloc

View file

@ -10,7 +10,7 @@ if typing.TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
VERSIONS: list[Callable] = [] VERSIONS: dict[int, Callable] = {}
TABLES: list[Table] = [ TABLES: list[Table] = [
Table( Table(
'config', 'config',
@ -45,12 +45,25 @@ TABLES: list[Table] = [
Column('reason', 'text'), Column('reason', 'text'),
Column('note', 'text'), Column('note', 'text'),
Column('created', 'timestamp', nullable = False) Column('created', 'timestamp', nullable = False)
),
Table(
'users',
Column('username', 'text', primary_key = True, unique = True, nullable = False),
Column('hash', 'text', nullable = False),
Column('handle', 'text'),
Column('created', 'timestamp', nullable = False)
),
Table(
'tokens',
Column('code', 'text', primary_key = True, unique = True, nullable = False),
Column('user', 'text', nullable = False),
Column('created', 'timestmap', nullable = False)
) )
] ]
def version(func: Callable) -> Callable: def migration(func: Callable) -> Callable:
ver = int(func.replace('migrate_', '')) ver = int(func.__name__.replace('migrate_', ''))
VERSIONS[ver] = func VERSIONS[ver] = func
return func return func
@ -58,3 +71,8 @@ def version(func: Callable) -> Callable:
def migrate_0(conn: Connection) -> None: def migrate_0(conn: Connection) -> None:
conn.create_tables(TABLES) conn.create_tables(TABLES)
conn.put_config('schema-version', get_default_value('schema-version')) conn.put_config('schema-version', get_default_value('schema-version'))
@migration
def migrate_20240206(conn: Connection) -> None:
conn.create_tables(TABLES)

View file

@ -370,6 +370,112 @@ def cli_config_set(ctx: click.Context, key: str, value: Any) -> None:
print(f'{key}: {repr(new_value)}') print(f'{key}: {repr(new_value)}')
@cli.group('user')
def cli_user() -> None:
'Manage local users'
@cli_user.command('list')
@click.pass_context
def cli_user_list(ctx: click.Context) -> None:
'List all local users'
click.echo('Users:')
with ctx.obj.database.connection() as conn:
for user in conn.execute('SELECT * FROM users'):
click.echo(f'- {user["username"]}')
@cli_user.command('create')
@click.argument('username')
@click.argument('handle', required = False)
@click.pass_context
def cli_user_create(ctx: click.Context, username: str, handle: str) -> None:
'Create a new local user'
with ctx.obj.database.connection() as conn:
if conn.get_user(username):
click.echo(f'User already exists: {username}')
return
while True:
if not (password := click.prompt('New password', hide_input = True)):
click.echo('No password provided')
continue
if password != click.prompt('New password again', hide_input = True):
click.echo('Passwords do not match')
continue
break
conn.put_user(username, password, handle)
click.echo(f'Created user "{username}"')
@cli_user.command('delete')
@click.argument('username')
@click.pass_context
def cli_user_delete(ctx: click.Context, username: str) -> None:
'Delete a local user'
with ctx.obj.database.connection() as conn:
if not conn.get_user(username):
click.echo(f'User does not exist: {username}')
return
conn.del_user(username)
click.echo(f'Deleted user "{username}"')
@cli_user.command('list-tokens')
@click.argument('username')
@click.pass_context
def cli_user_list_tokens(ctx: click.Context, username: str) -> None:
'List all API tokens for a user'
click.echo(f'Tokens for "{username}":')
with ctx.obj.database.connection() as conn:
for token in conn.execute('SELECT * FROM tokens WHERE user = :user', {'user': username}):
click.echo(f'- {token["code"]}')
@cli_user.command('create-token')
@click.argument('username')
@click.pass_context
def cli_user_create_token(ctx: click.Context, username: str) -> None:
'Create a new API token for a user'
with ctx.obj.database.connection() as conn:
if not (user := conn.get_user(username)):
click.echo(f'User does not exist: {username}')
return
token = conn.put_token(user['username'])
click.echo(f'New token for "{username}": {token["code"]}')
@cli_user.command('delete-token')
@click.argument('code')
@click.pass_context
def cli_user_delete_token(ctx: click.Context, code: str) -> None:
'Delete an API token'
with ctx.obj.database.connection() as conn:
if not conn.get_token(code):
click.echo('Token does not exist')
return
conn.del_token(code)
click.echo('Deleted token')
@cli.group('inbox') @cli.group('inbox')
def cli_inbox() -> None: def cli_inbox() -> None:
'Manage the inboxes in the database' 'Manage the inboxes in the database'

View file

@ -5,12 +5,8 @@ import os
import socket import socket
import typing import typing
from aiohttp.abc import AbstractView
from aiohttp.hdrs import METH_ALL as METHODS
from aiohttp.web import Response as AiohttpResponse from aiohttp.web import Response as AiohttpResponse
from aiohttp.web_exceptions import HTTPMethodNotAllowed
from aputils.message import Message as ApMessage from aputils.message import Message as ApMessage
from functools import cached_property
from uuid import uuid4 from uuid import uuid4
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
@ -203,7 +199,7 @@ class Response(AiohttpResponse):
if isinstance(body, bytes): if isinstance(body, bytes):
kwargs['body'] = body kwargs['body'] = body
elif isinstance(body, dict) and ctype in {'json', 'activity'}: elif isinstance(body, (dict, list, tuple, set)) and ctype in {'json', 'activity'}:
kwargs['text'] = json.dumps(body) kwargs['text'] = json.dumps(body)
else: else:
@ -232,67 +228,3 @@ class Response(AiohttpResponse):
@location.setter @location.setter
def location(self, value: str) -> None: def location(self, value: str) -> None:
self.headers['Location'] = value self.headers['Location'] = value
class View(AbstractView):
def __await__(self) -> Generator[Response]:
if self.request.method not in METHODS:
raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods)
if not (handler := self.handlers.get(self.request.method)):
raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods) from None
return self._run_handler(handler).__await__()
async def _run_handler(self, handler: Awaitable) -> Response:
with self.database.config.connection_class(self.database) as conn:
# todo: remove on next tinysql release
conn.open()
return await handler(self.request, conn, **self.request.match_info)
@cached_property
def allowed_methods(self) -> tuple[str]:
return tuple(self.handlers.keys())
@cached_property
def handlers(self) -> dict[str, Coroutine]:
data = {}
for method in METHODS:
try:
data[method] = getattr(self, method.lower())
except AttributeError:
continue
return data
# app components
@property
def app(self) -> Application:
return self.request.app
@property
def cache(self) -> Cache:
return self.app.cache
@property
def client(self) -> HttpClient:
return self.app.client
@property
def config(self) -> Config:
return self.app.config
@property
def database(self) -> Database:
return self.app.database

View file

@ -170,7 +170,7 @@ processors = {
} }
async def run_processor(view: ActorView, conn: Connection) -> None: async def run_processor(view: ActorView) -> None:
if view.message.type not in processors: if view.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',
@ -180,8 +180,8 @@ async def run_processor(view: ActorView, conn: Connection) -> None:
return return
with view.database.connection(False) as conn:
if view.instance: if view.instance:
with conn.transaction():
if not view.instance['software']: if not view.instance['software']:
if (nodeinfo := await view.client.fetch_nodeinfo(view.instance['domain'])): if (nodeinfo := await view.client.fetch_nodeinfo(view.instance['domain'])):
view.instance = conn.update_inbox( view.instance = conn.update_inbox(

4
relay/views/__init__.py Normal file
View file

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

View file

@ -1,95 +1,27 @@
from __future__ import annotations from __future__ import annotations
import subprocess
import traceback import traceback
import typing import typing
from aputils.errors import SignatureFailureError from aputils.errors import SignatureFailureError
from aputils.misc import Digest, HttpDate, Signature from aputils.misc import Digest, HttpDate, Signature
from aputils.objects import Nodeinfo, Webfinger, WellKnownNodeinfo from aputils.objects import Webfinger
from pathlib import Path
from . import __version__ from .base import View, register_route
from . import logger as logging
from .database.connection import Connection from .. import logger as logging
from .misc import Message, Response, View from ..misc import Message, Response
from .processors import run_processor from ..processors import run_processor
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from aiohttp.web import Request from aiohttp.web import Request
from aputils.signer import Signer from aputils.signer import Signer
from collections.abc import Callable
from tinysql import Row from tinysql import Row
from ..database.connection import Connection
VIEWS = []
VERSION = __version__
HOME_TEMPLATE = """
<html><head>
<title>ActivityPub Relay at {host}</title>
<style>
p {{ color: #FFFFFF; font-family: monospace, arial; font-size: 100%; }}
body {{ background-color: #000000; }}
a {{ color: #26F; }}
a:visited {{ color: #46C; }}
a:hover {{ color: #8AF; }}
</style>
</head>
<body>
<p>This is an Activity Relay for fediverse instances.</p>
<p>{note}</p>
<p>
You may subscribe to this relay with the address:
<a href="https://{host}/actor">https://{host}/actor</a>
</p>
<p>
To host your own relay, you may download the code at this address:
<a href="https://git.pleroma.social/pleroma/relay">
https://git.pleroma.social/pleroma/relay
</a>
</p>
<br><p>List of {count} registered instances:<br>{targets}</p>
</body></html>
"""
if Path(__file__).parent.parent.joinpath('.git').exists():
try:
commit_label = subprocess.check_output(["git", "rev-parse", "HEAD"]).strip().decode('ascii')
VERSION = f'{__version__} {commit_label}'
except Exception:
pass
def register_route(*paths: str) -> Callable:
def wrapper(view: View) -> View:
for path in paths:
VIEWS.append([path, view])
return View
return wrapper
# pylint: disable=unused-argument # pylint: disable=unused-argument
@register_route('/')
class HomeView(View):
async def get(self, request: Request, conn: Connection) -> Response:
config = conn.get_config_all()
inboxes = conn.execute('SELECT * FROM inboxes').all()
text = HOME_TEMPLATE.format(
host = self.config.domain,
note = config['note'],
count = len(inboxes),
targets = '<br>'.join(inbox['domain'] for inbox in inboxes)
)
return Response.new(text, ctype='html')
@register_route('/actor', '/inbox') @register_route('/actor', '/inbox')
class ActorView(View): class ActorView(View):
def __init__(self, request: Request): def __init__(self, request: Request):
@ -102,7 +34,7 @@ class ActorView(View):
self.signer: Signer = None self.signer: Signer = None
async def get(self, request: Request, conn: Connection) -> Response: async def get(self, request: Request) -> Response:
data = Message.new_actor( data = Message.new_actor(
host = self.config.domain, host = self.config.domain,
pubkey = self.app.signer.pubkey pubkey = self.app.signer.pubkey
@ -111,10 +43,11 @@ class ActorView(View):
return Response.new(data, ctype='activity') return Response.new(data, ctype='activity')
async def post(self, request: Request, conn: Connection) -> Response: async def post(self, request: Request) -> Response:
if response := await self.get_post_data(): if response := await self.get_post_data():
return response return response
with self.database.connection(False) as conn:
self.instance = conn.get_inbox(self.actor.shared_inbox) self.instance = conn.get_inbox(self.actor.shared_inbox)
config = conn.get_config_all() config = conn.get_config_all()
@ -139,7 +72,7 @@ class ActorView(View):
logging.debug('>> payload %s', self.message.to_json(4)) logging.debug('>> payload %s', self.message.to_json(4))
await run_processor(self, conn) await run_processor(self)
return Response.new(status = 202) return Response.new(status = 202)
@ -230,7 +163,7 @@ class ActorView(View):
@register_route('/.well-known/webfinger') @register_route('/.well-known/webfinger')
class WebfingerView(View): class WebfingerView(View):
async def get(self, request: Request, conn: Connection) -> Response: async def get(self, request: Request) -> Response:
try: try:
subject = request.query['resource'] subject = request.query['resource']
@ -247,31 +180,3 @@ class WebfingerView(View):
) )
return Response.new(data, ctype = 'json') return Response.new(data, ctype = 'json')
@register_route('/nodeinfo/{niversion:\\d.\\d}.json', '/nodeinfo/{niversion:\\d.\\d}')
class NodeinfoView(View):
# pylint: disable=no-self-use
async def get(self, request: Request, conn: Connection, niversion: str) -> Response:
inboxes = conn.execute('SELECT * FROM inboxes').all()
data = {
'name': 'activityrelay',
'version': VERSION,
'protocols': ['activitypub'],
'open_regs': not conn.get_config('whitelist-enabled'),
'users': 1,
'metadata': {'peers': [inbox['domain'] for inbox in inboxes]}
}
if niversion == '2.1':
data['repo'] = 'https://git.pleroma.social/pleroma/relay'
return Response.new(Nodeinfo.new(**data), ctype = 'json')
@register_route('/.well-known/nodeinfo')
class WellknownNodeinfoView(View):
async def get(self, request: Request, conn: Connection) -> Response:
data = WellKnownNodeinfo.new_template(self.config.domain)
return Response.new(data, ctype = 'json')

421
relay/views/api.py Normal file
View file

@ -0,0 +1,421 @@
from __future__ import annotations
import typing
from aiohttp import web
from argon2.exceptions import VerifyMismatchError
from datetime import datetime, timezone
from urllib.parse import urlparse
from .base import View, register_route
from .. import __version__
from .. import logger as logging
from ..database.config import CONFIG_DEFAULTS
from ..misc import Message, Response
if typing.TYPE_CHECKING:
from aiohttp.web import Request
from collections.abc import Coroutine
from ..database.connection import Connection
CONFIG_IGNORE = (
'schema-version',
'private-key'
)
CONFIG_VALID = {key for key in CONFIG_DEFAULTS if key not in CONFIG_IGNORE}
PUBLIC_API_PATHS: tuple[tuple[str, str]] = (
('GET', '/api/v1/relay'),
('GET', '/api/v1/instance'),
('POST', '/api/v1/token')
)
def check_api_path(method: str, path: str) -> bool:
if (method, path) in PUBLIC_API_PATHS:
return False
return path.startswith('/api')
@web.middleware
async def handle_api_path(request: web.Request, handler: Coroutine) -> web.Response:
try:
request['token'] = request.headers['Authorization'].replace('Bearer', '').strip()
with request.app.database.connection() as conn:
request['user'] = conn.get_user_by_token(request['token'])
except (KeyError, ValueError):
request['token'] = None
request['user'] = None
if check_api_path(request.method, request.path):
if not request['token']:
return Response.new_error(401, 'Missing token', 'json')
if not request['user']:
return Response.new_error(401, 'Invalid token', 'json')
return await handler(request)
# pylint: disable=no-self-use,unused-argument
@register_route('/api/v1/token')
class Login(View):
async def get(self, request: Request) -> Response:
return Response.new({'message': 'Token valid :3'})
async def post(self, request: Request) -> Response:
data = await self.get_api_data(['username', 'password'], [])
if isinstance(data, Response):
return data
with self.database.connction(True) as conn:
if not (user := conn.get_user(data['username'])):
return Response.new_error(401, 'User not found', 'json')
try:
conn.hasher.verify(user['hash'], data['password'])
except VerifyMismatchError:
return Response.new_error(401, 'Invalid password', 'json')
token = conn.put_token(data['username'])
return Response.new({'token': token['code']}, ctype = 'json')
async def delete(self, request: Request) -> Response:
with self.database.connection(True) as conn:
conn.del_token(request['token'])
return Response.new({'message': 'Token revoked'}, ctype = 'json')
@register_route('/api/v1/relay')
class RelayInfo(View):
async def get(self, request: Request) -> Response:
with self.database.connection(False) as conn:
config = conn.get_config_all()
inboxes = [row['domain'] for row in conn.execute('SELECT * FROM inboxes')]
data = {
'domain': self.config.domain,
'name': config['name'],
'description': config['note'],
'version': __version__,
'whitelist_enabled': config['whitelist-enabled'],
'email': None,
'admin': None,
'icon': None,
'instances': inboxes
}
return Response.new(data, ctype = 'json')
@register_route('/api/v1/config')
class Config(View):
async def get(self, request: Request) -> Response:
with self.database.connection(False) as conn:
data = conn.get_config_all()
data['log-level'] = data['log-level'].name
for key in CONFIG_IGNORE:
del data[key]
return Response.new(data, ctype = 'json')
async def post(self, request: Request) -> Response:
data = await self.get_api_data(['key', 'value'], [])
if isinstance(data, Response):
return data
if data['key'] not in CONFIG_VALID:
return Response.new_error(400, 'Invalid key', 'json')
with self.database.connection(True) as conn:
conn.put_config(data['key'], data['value'])
return Response.new({'message': 'Updated config'}, ctype = 'json')
async def delete(self, request: Request) -> Response:
data = await self.get_api_data(['key'], [])
if isinstance(data, Response):
return data
if data['key'] not in CONFIG_VALID:
return Response.new_error(400, 'Invalid key', 'json')
with self.database.connection(True) as conn:
conn.put_config(data['key'], CONFIG_DEFAULTS[data['key']][1])
return Response.new({'message': 'Updated config'}, ctype = 'json')
@register_route('/api/v1/instance')
class Inbox(View):
async def get(self, request: Request) -> Response:
data = []
with self.database.connection(False) as conn:
for inbox in conn.execute('SELECT * FROM inboxes'):
try:
created = datetime.fromtimestamp(inbox['created'], tz = timezone.utc)
except TypeError:
created = datetime.fromisoformat(inbox['created'])
inbox['created'] = created.isoformat()
data.append(inbox)
return Response.new(data, ctype = 'json')
async def post(self, request: Request) -> Response:
data = await self.get_api_data(['actor'], ['inbox', 'software', 'followid'])
if isinstance(data, Response):
return data
data['domain'] = urlparse(data["actor"]).netloc
with self.database.connection(True) as conn:
if conn.get_inbox(data['domain']):
return Response.new_error(404, 'Instance already in database', 'json')
if not data.get('inbox'):
try:
actor_data = await self.client.get(
data['actor'],
sign_headers = True,
loads = Message.parse
)
data['inbox'] = actor_data.shared_inbox
except Exception as e:
logging.error('Failed to fetch actor: %s', str(e))
return Response.new_error(500, 'Failed to fetch actor', 'json')
row = conn.put_inbox(**data)
return Response.new(row, ctype = 'json')
@register_route('/api/v1/instance/{domain}')
class InboxSingle(View):
async def get(self, request: Request, domain: str) -> Response:
with self.database.connection(False) as conn:
if not (row := conn.get_inbox(domain)):
return Response.new_error(404, 'Instance with domain not found', 'json')
row['created'] = datetime.fromtimestamp(row['created'], tz = timezone.utc).isoformat()
return Response.new(row, ctype = 'json')
async def patch(self, request: Request, domain: str) -> Response:
with self.database.connection(True) as conn:
if not conn.get_inbox(domain):
return Response.new_error(404, 'Instance with domain not found', 'json')
data = await self.get_api_data([], ['actor', 'software', 'followid'])
if isinstance(data, Response):
return data
if not (instance := conn.get_inbox(domain)):
return Response.new_error(404, 'Instance with domain not found', 'json')
instance = conn.update_inbox(instance['inbox'], **data)
return Response.new(instance, ctype = 'json')
async def delete(self, request: Request, domain: str) -> Response:
with self.database.connection(True) as conn:
if not conn.get_inbox(domain):
return Response.new_error(404, 'Instance with domain not found', 'json')
conn.del_inbox(domain)
return Response.new({'message': 'Deleted instance'}, ctype = 'json')
@register_route('/api/v1/domain_ban')
class DomainBan(View):
async def get(self, request: Request) -> Response:
with self.database.connection(False) as conn:
bans = conn.execute('SELECT * FROM domain_bans').all()
return Response.new(bans, ctype = 'json')
async def post(self, request: Request) -> Response:
data = await self.get_api_data(['domain'], ['note', 'reason'])
if isinstance(data, Response):
return data
with self.database.connection(True) as conn:
if conn.get_domain_ban(data['domain']):
return Response.new_error(400, 'Domain already banned', 'json')
ban = conn.put_domain_ban(**data)
return Response.new(ban, ctype = 'json')
@register_route('/api/v1/domain_ban/{domain}')
class DomainBanSingle(View):
async def get(self, request: Request, domain: str) -> Response:
with self.database.connection(False) as conn:
if not (ban := conn.get_domain_ban(domain)):
return Response.new_error(404, 'Domain ban not found', 'json')
return Response.new(ban, ctype = 'json')
async def patch(self, request: Request, domain: str) -> Response:
with self.database.connection(True) as conn:
if not conn.get_domain_ban(domain):
return Response.new_error(404, 'Domain not banned', 'json')
data = await self.get_api_data([], ['note', 'reason'])
if isinstance(data, Response):
return data
if not any([data.get('note'), data.get('reason')]):
return Response.new_error(400, 'Must include note and/or reason parameters', 'json')
ban = conn.update_domain_ban(domain, **data)
return Response.new(ban, ctype = 'json')
async def delete(self, request: Request, domain: str) -> Response:
with self.database.connection(True) as conn:
if not conn.get_domain_ban(domain):
return Response.new_error(404, 'Domain not banned', 'json')
conn.del_domain_ban(domain)
return Response.new({'message': 'Unbanned domain'}, ctype = 'json')
@register_route('/api/v1/software_ban')
class SoftwareBan(View):
async def get(self, request: Request) -> Response:
with self.database.connection(False) as conn:
bans = conn.execute('SELECT * FROM software_bans').all()
return Response.new(bans, ctype = 'json')
async def post(self, request: Request) -> Response:
data = await self.get_api_data(['name'], ['note', 'reason'])
if isinstance(data, Response):
return data
with self.database.connection(True) as conn:
if conn.get_software_ban(data['name']):
return Response.new_error(400, 'Domain already banned', 'json')
ban = conn.put_software_ban(**data)
return Response.new(ban, ctype = 'json')
@register_route('/api/v1/software_ban/{name}')
class SoftwareBanSingle(View):
async def get(self, request: Request, name: str) -> Response:
with self.database.connection(False) as conn:
if not (ban := conn.get_software_ban(name)):
return Response.new_error(404, 'Software ban not found', 'json')
return Response.new(ban, ctype = 'json')
async def patch(self, request: Request, name: str) -> Response:
with self.database.connection(True) as conn:
if not conn.get_software_ban(name):
return Response.new_error(404, 'Software not banned', 'json')
data = await self.get_api_data([], ['note', 'reason'])
if isinstance(data, Response):
return data
if not any([data.get('note'), data.get('reason')]):
return Response.new_error(400, 'Must include note and/or reason parameters', 'json')
ban = conn.update_software_ban(name, **data)
return Response.new(ban, ctype = 'json')
async def delete(self, request: Request, name: str) -> Response:
with self.database.connection(True) as conn:
if not conn.get_software_ban(name):
return Response.new_error(404, 'Software not banned', 'json')
conn.del_software_ban(name)
return Response.new({'message': 'Unbanned software'}, ctype = 'json')
@register_route('/api/v1/whitelist')
class Whitelist(View):
async def get(self, request: Request) -> Response:
with self.database.connection(False) as conn:
items = conn.execute('SELECT * FROM whitelist').all()
return Response.new(items, ctype = 'json')
async def post(self, request: Request) -> Response:
data = await self.get_api_data(['domain'], [])
if isinstance(data, Response):
return data
with self.database.connection(True) as conn:
if conn.get_domain_whitelist(data['domain']):
return Response.new_error(400, 'Domain already added to whitelist', 'json')
item = conn.put_domain_whitelist(**data)
return Response.new(item, ctype = 'json')
@register_route('/api/v1/domain/{domain}')
class WhitelistSingle(View):
async def get(self, request: Request, domain: str) -> Response:
with self.database.connection(False) as conn:
if not (item := conn.get_domain_whitelist(domain)):
return Response.new_error(404, 'Domain not in whitelist', 'json')
return Response.new(item, ctype = 'json')
async def delete(self, request: Request, domain: str) -> Response:
with self.database.connection(False) as conn:
if not conn.get_domain_whitelist(domain):
return Response.new_error(404, 'Domain not in whitelist', 'json')
conn.del_domain_whitelist(domain)
return Response.new({'message': 'Removed domain from whitelist'}, ctype = 'json')

123
relay/views/base.py Normal file
View file

@ -0,0 +1,123 @@
from __future__ import annotations
import typing
from aiohttp.abc import AbstractView
from aiohttp.hdrs import METH_ALL as METHODS
from aiohttp.web import HTTPMethodNotAllowed
from functools import cached_property
from json.decoder import JSONDecodeError
from ..misc import Response
if typing.TYPE_CHECKING:
from collections.abc import Callable, Coroutine, Generator
from tinysql import Database
from ..application import Application
from ..cache import Cache
from ..config import Config
from ..http_client import HttpClient
VIEWS = []
def register_route(*paths: str) -> Callable:
def wrapper(view: View) -> View:
for path in paths:
VIEWS.append([path, view])
return View
return wrapper
class View(AbstractView):
def __await__(self) -> Generator[Response]:
if self.request.method not in METHODS:
raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods)
if not (handler := self.handlers.get(self.request.method)):
raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods)
return self._run_handler(handler).__await__()
async def _run_handler(self, handler: Coroutine) -> Response:
return await handler(self.request, **self.request.match_info)
@cached_property
def allowed_methods(self) -> tuple[str]:
return tuple(self.handlers.keys())
@cached_property
def handlers(self) -> dict[str, Coroutine]:
data = {}
for method in METHODS:
try:
data[method] = getattr(self, method.lower())
except AttributeError:
continue
return data
# app components
@property
def app(self) -> Application:
return self.request.app
@property
def cache(self) -> Cache:
return self.app.cache
@property
def client(self) -> HttpClient:
return self.app.client
@property
def config(self) -> Config:
return self.app.config
@property
def database(self) -> Database:
return self.app.database
async def get_api_data(self,
required: list[str],
optional: list[str]) -> dict[str, str] | Response:
if self.request.content_type in {'x-www-form-urlencoded', 'multipart/form-data'}:
post_data = await self.request.post()
elif self.request.content_type == 'application/json':
try:
post_data = await self.request.json()
except JSONDecodeError:
return Response.new_error(400, 'Invalid JSON data', 'json')
else:
post_data = self.request.query
data = {}
try:
for key in required:
data[key] = post_data[key]
except KeyError as e:
return Response.new_error(400, f'Missing {str(e)} pararmeter', 'json')
for key in optional:
data[key] = post_data.get(key)
return data

63
relay/views/frontend.py Normal file
View file

@ -0,0 +1,63 @@
from __future__ import annotations
import typing
from .base import View, register_route
from .. import __version__
from ..misc import Response
if typing.TYPE_CHECKING:
from aiohttp.web import Request
from aputils.signer import Signer
from collections.abc import Callable
from tinysql import Row
from ..database.connection import Connection
HOME_TEMPLATE = """
<html><head>
<title>ActivityPub Relay at {host}</title>
<style>
p {{ color: #FFFFFF; font-family: monospace, arial; font-size: 100%; }}
body {{ background-color: #000000; }}
a {{ color: #26F; }}
a:visited {{ color: #46C; }}
a:hover {{ color: #8AF; }}
</style>
</head>
<body>
<p>This is an Activity Relay for fediverse instances.</p>
<p>{note}</p>
<p>
You may subscribe to this relay with the address:
<a href="https://{host}/actor">https://{host}/actor</a>
</p>
<p>
To host your own relay, you may download the code at this address:
<a href="https://git.pleroma.social/pleroma/relay">
https://git.pleroma.social/pleroma/relay
</a>
</p>
<br><p>List of {count} registered instances:<br>{targets}</p>
</body></html>
"""
# pylint: disable=unused-argument
@register_route('/')
class HomeView(View):
async def get(self, request: Request) -> Response:
with self.database.connection(False) as conn:
config = conn.get_config_all()
inboxes = conn.execute('SELECT * FROM inboxes').all()
text = HOME_TEMPLATE.format(
host = self.config.domain,
note = config['note'],
count = len(inboxes),
targets = '<br>'.join(inbox['domain'] for inbox in inboxes)
)
return Response.new(text, ctype='html')

60
relay/views/misc.py Normal file
View file

@ -0,0 +1,60 @@
from __future__ import annotations
import subprocess
import typing
from aputils.objects import Nodeinfo, WellKnownNodeinfo
from pathlib import Path
from .base import View, register_route
from .. import __version__
from ..misc import Response
if typing.TYPE_CHECKING:
from aiohttp.web import Request
from ..database.connection import Connection
VERSION = __version__
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}'
except Exception:
pass
# pylint: disable=unused-argument
@register_route('/nodeinfo/{niversion:\\d.\\d}.json', '/nodeinfo/{niversion:\\d.\\d}')
class NodeinfoView(View):
# pylint: disable=no-self-use
async def get(self, request: Request, niversion: str) -> Response:
with self.database.connection(False) as conn:
inboxes = conn.execute('SELECT * FROM inboxes').all()
data = {
'name': 'activityrelay',
'version': VERSION,
'protocols': ['activitypub'],
'open_regs': not conn.get_config('whitelist-enabled'),
'users': 1,
'metadata': {'peers': [inbox['domain'] for inbox in inboxes]}
}
if niversion == '2.1':
data['repo'] = 'https://git.pleroma.social/pleroma/relay'
return Response.new(Nodeinfo.new(**data), ctype = 'json')
@register_route('/.well-known/nodeinfo')
class WellknownNodeinfoView(View):
async def get(self, request: Request) -> Response:
data = WellKnownNodeinfo.new_template(self.config.domain)
return Response.new(data, ctype = 'json')

View file

@ -1,10 +1,11 @@
aiohttp>=3.9.1 aiohttp>=3.9.1
aputils@https://git.barkshark.xyz/barkshark/aputils/archive/0.1.6a.tar.gz aputils@https://git.barkshark.xyz/barkshark/aputils/archive/0.1.6a.tar.gz
argon2-cffi==23.1.0
click>=8.1.2 click>=8.1.2
gunicorn==21.1.0 gunicorn==21.1.0
hiredis==2.3.2 hiredis==2.3.2
pyyaml>=6.0 pyyaml>=6.0
redis==5.0.1 redis==5.0.1
tinysql[postgres]@https://git.barkshark.xyz/barkshark/tinysql/archive/0.2.4.tar.gz tinysql[postgres]@https://git.barkshark.xyz/barkshark/tinysql/archive/f8db814084dded0a46bd3a9576e09fca860f2166.tar.gz
importlib_resources==6.1.1;python_version<'3.9' importlib_resources==6.1.1;python_version<'3.9'