From c85286763679b3122cc96adc5447a33bea239488 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Mon, 11 Mar 2024 01:21:46 -0400 Subject: [PATCH 01/57] add option to require approval for new instances --- relay/data/statements.sql | 15 +++- relay/data/swagger.yaml | 47 +++++++++++ relay/database/__init__.py | 1 + relay/database/config.py | 3 +- relay/database/connection.py | 87 ++++++++++++-------- relay/database/schema.py | 7 ++ relay/frontend/functions.haml | 16 ++++ relay/frontend/page/admin-config.haml | 24 ++---- relay/frontend/page/admin-domain_bans.haml | 2 +- relay/frontend/page/admin-instances.haml | 33 +++++++- relay/frontend/page/admin-software_bans.haml | 2 +- relay/frontend/page/admin-users.haml | 2 +- relay/frontend/page/admin-whitelist.haml | 2 +- relay/frontend/page/home.haml | 2 +- relay/frontend/style.css | 29 +++++-- relay/manage.py | 78 +++++++++++++++++- relay/misc.py | 4 +- relay/processors.py | 62 ++++++++++---- relay/template.py | 7 +- relay/views/activitypub.py | 13 ++- relay/views/api.py | 46 ++++++++++- relay/views/frontend.py | 62 ++++++++++++-- relay/views/misc.py | 7 +- 23 files changed, 445 insertions(+), 106 deletions(-) create mode 100644 relay/frontend/functions.haml diff --git a/relay/data/statements.sql b/relay/data/statements.sql index bc06d25..f06d4b5 100644 --- a/relay/data/statements.sql +++ b/relay/data/statements.sql @@ -23,17 +23,26 @@ SELECT * FROM inboxes WHERE domain = :value or inbox = :value or actor = :value; -- name: put-inbox -INSERT INTO inboxes (domain, actor, inbox, followid, software, created) -VALUES (:domain, :actor, :inbox, :followid, :software, :created) -ON CONFLICT (domain) DO UPDATE SET followid = :followid +INSERT INTO inboxes (domain, actor, inbox, followid, software, accepted, created) +VALUES (:domain, :actor, :inbox, :followid, :software, :accepted, :created) +ON CONFLICT (domain) DO +UPDATE SET followid = :followid, inbox = :inbox, software = :software, created = :created RETURNING *; +-- name: put-inbox-accept +UPDATE inboxes SET accepted = :accepted WHERE domain = :domain RETURNING *; + + -- name: del-inbox DELETE FROM inboxes WHERE domain = :value or inbox = :value or actor = :value; +-- name: get-request +SELECT * FROM inboxes WHERE accepted = 0 and domain = :domain; + + -- name: get-user SELECT * FROM users WHERE username = :value or handle = :value; diff --git a/relay/data/swagger.yaml b/relay/data/swagger.yaml index 9c313ae..b4136ec 100644 --- a/relay/data/swagger.yaml +++ b/relay/data/swagger.yaml @@ -285,6 +285,50 @@ paths: schema: $ref: "#/definitions/Error" + /v1/request: + get: + tags: + - Follow Request + description: Get the list of follow requests + produces: + - application/json + responses: + "200": + description: List of instances + schema: + type: array + items: + $ref: "#/definitions/Instance" + + post: + tags: + - Follow Request + description: Approve or deny a follow request + parameters: + - in: formData + name: domain + required: true + type: string + - in: formData + name: accept + required: true + type: boolean + consumes: + - application/json + - multipart/form-data + - application/x-www-form-urlencoded + produces: + - application/json + responses: + "200": + description: Follow request successfully accepted or denied + schema: + $ref: "#/definitions/Message" + "500": + description: Follow request does not exist + schema: + $ref: "#/definitions/Error" + /v1/domain_ban: get: tags: @@ -672,6 +716,9 @@ definitions: software: description: Nodeinfo-formatted name of the instance's software type: string + accepted: + description: Whether or not the follow request has been accepted + type: boolean created: description: Date the instance joined or was added type: string diff --git a/relay/database/__init__.py b/relay/database/__init__.py index d248713..02b0bc9 100644 --- a/relay/database/__init__.py +++ b/relay/database/__init__.py @@ -53,6 +53,7 @@ def get_database(config: Config, migrate: bool = True) -> bsql.Database: if schema_ver < ver: func(conn) conn.put_config('schema-version', ver) + logging.info("Updated database to %i", ver) if (privkey := conn.get_config('private-key')): conn.app.signer = privkey diff --git a/relay/database/config.py b/relay/database/config.py index 82e2e69..7d2abd4 100644 --- a/relay/database/config.py +++ b/relay/database/config.py @@ -60,8 +60,9 @@ THEMES = { } CONFIG_DEFAULTS: dict[str, tuple[str, Any]] = { - 'schema-version': ('int', 20240206), + 'schema-version': ('int', 20240310), 'private-key': ('str', None), + 'approval-required': ('bool', False), 'log-level': ('loglevel', logging.LogLevel.INFO), 'name': ('str', 'ActivityRelay'), 'note': ('str', 'Make a note about your instance here.'), diff --git a/relay/database/connection.py b/relay/database/connection.py index 2792111..ca98e6a 100644 --- a/relay/database/connection.py +++ b/relay/database/connection.py @@ -52,7 +52,7 @@ class Connection(SqlConnection): urlparse(message.object_id).netloc } - for inbox in self.execute('SELECT * FROM inboxes'): + for inbox in self.get_inboxes(): if inbox['domain'] not in src_domains: yield inbox['inbox'] @@ -124,53 +124,45 @@ class Connection(SqlConnection): return cur.one() + def get_inboxes(self) -> tuple[Row]: + with self.execute("SELECT * FROM inboxes WHERE accepted = 1") as cur: + return tuple(cur.all()) + + def put_inbox(self, domain: str, - inbox: str, + inbox: str | None = None, actor: str | None = None, followid: str | None = None, - software: str | None = None) -> Row: + software: str | None = None, + accepted: bool = True) -> Row: params = { - 'domain': domain, 'inbox': inbox, 'actor': actor, 'followid': followid, 'software': software, - 'created': datetime.now(tz = timezone.utc) + 'accepted': accepted } - with self.run('put-inbox', params) as cur: + if not self.get_inbox(domain): + if not inbox: + raise ValueError("Missing inbox") + + params['domain'] = domain + params['created'] = datetime.now(tz = timezone.utc) + + with self.run('put-inbox', params) as cur: + return cur.one() + + for key, value in tuple(params.items()): + if value is None: + del params[key] + + with self.update('inboxes', params, domain = domain) as cur: return cur.one() - def update_inbox(self, - inbox: str, - actor: str | None = None, - followid: str | None = None, - software: str | None = None) -> Row: - - if not (actor or followid or software): - raise ValueError('Missing "actor", "followid", and/or "software"') - - data = {} - - if actor: - data['actor'] = actor - - if followid: - data['followid'] = followid - - if software: - data['software'] = software - - statement = Update('inboxes', data) - statement.set_where("inbox", inbox) - - with self.query(statement): - return self.get_inbox(inbox) - - def del_inbox(self, value: str) -> bool: with self.run('del-inbox', {'value': value}) as cur: if cur.row_count > 1: @@ -179,6 +171,35 @@ class Connection(SqlConnection): return cur.row_count == 1 + def get_request(self, domain: str) -> Row: + with self.run('get-request', {'domain': domain}) as cur: + if not (row := cur.one()): + raise KeyError(domain) + + return row + + + def get_requests(self) -> tuple[Row]: + with self.execute('SELECT * FROM inboxes WHERE accepted = 0') as cur: + return tuple(cur.all()) + + + def put_request_response(self, domain: str, accepted: bool) -> Row: + instance = self.get_request(domain) + + if not accepted: + self.del_inbox(domain) + return instance + + params = { + 'domain': domain, + 'accepted': accepted + } + + with self.run('put-inbox-accept', params) as cur: + return cur.one() + + def get_user(self, value: str) -> Row: with self.run('get-user', {'value': value}) as cur: return cur.one() diff --git a/relay/database/schema.py b/relay/database/schema.py index e3a0303..d965348 100644 --- a/relay/database/schema.py +++ b/relay/database/schema.py @@ -25,6 +25,7 @@ TABLES: Tables = Tables( Column('inbox', 'text', unique = True, nullable = False), Column('followid', 'text'), Column('software', 'text'), + Column('accepted', 'boolean'), Column('created', 'timestamp', nullable = False) ), Table( @@ -76,3 +77,9 @@ def migrate_0(conn: Connection) -> None: @migration def migrate_20240206(conn: Connection) -> None: conn.create_tables() + + +@migration +def migrate_20240310(conn: Connection) -> None: + conn.execute("ALTER TABLE inboxes ADD COLUMN accepted BOOLEAN") + conn.execute("UPDATE inboxes SET accepted = 1") diff --git a/relay/frontend/functions.haml b/relay/frontend/functions.haml new file mode 100644 index 0000000..68fbd2c --- /dev/null +++ b/relay/frontend/functions.haml @@ -0,0 +1,16 @@ +-macro new_checkbox(name, checked) + -if checked + %input(id="{{name}}" name="{{name}}" type="checkbox" checked) + + -else + %input(id="{{name}}" name="{{name}}" type="checkbox") + + +-macro new_select(name, selected, items) + %select(id="{{name}}" name="{{name}}") + -for item in items + -if item == selected + %option(value="{{item}}" selected) -> =item.title() + + -else + %option(value="{{item}}") -> =item.title() diff --git a/relay/frontend/page/admin-config.haml b/relay/frontend/page/admin-config.haml index 4028eb1..ef77d3f 100644 --- a/relay/frontend/page/admin-config.haml +++ b/relay/frontend/page/admin-config.haml @@ -1,5 +1,6 @@ -extends "base.haml" -set page="Config" +-import "functions.haml" as func -block content %form.section(action="/admin/config" method="POST") .grid-2col @@ -10,28 +11,15 @@ %textarea(id="description" name="note" value="{{config.note}}") << {{config.note}} %label(for="theme") << Color Theme - %select(id="theme" name="theme") - -for theme in themes - -if theme == config.theme - %option(value="{{theme}}" selected) -> =theme.title() - - -else - %option(value="{{theme}}") -> =theme.title() + =func.new_select("theme", config.theme, themes) %label(for="log-level") << Log Level - %select(id="log-level" name="log-level") - -for level in LogLevel - -if level == config["log-level"] - %option(value="{{level.name}}" selected) -> =level.name.title() - - -else - %option(value="{{level.name}}") -> =level.name.title() + =func.new_select("log-level", config["log-level"].name, levels) %label(for="whitelist-enabled") << Whitelist - -if config["whitelist-enabled"] - %input(id="whitelist-enabled" name="whitelist-enabled" type="checkbox" checked) + =func.new_checkbox("whitelist-enabled", config["whitelist-enabled"]) - -else - %input(id="whitelist-enabled" name="whitelist-enabled" type="checkbox") + %label(for="approval-required") << Approval Required + =func.new_checkbox("approval-required", config["approval-required"]) %input(type="submit" value="Save") diff --git a/relay/frontend/page/admin-domain_bans.haml b/relay/frontend/page/admin-domain_bans.haml index fbee683..f874499 100644 --- a/relay/frontend/page/admin-domain_bans.haml +++ b/relay/frontend/page/admin-domain_bans.haml @@ -16,7 +16,7 @@ %input(type="submit" value="Ban Domain") - #data-table.section + .data-table.section %table %thead %tr diff --git a/relay/frontend/page/admin-instances.haml b/relay/frontend/page/admin-instances.haml index 106e31d..770ccc1 100644 --- a/relay/frontend/page/admin-instances.haml +++ b/relay/frontend/page/admin-instances.haml @@ -19,7 +19,38 @@ %input(type="submit" value="Add Instance") - #data-table.section + -if requests + .data-table.section + .title << Requests + %table + %thead + %tr + %td.instance << Instance + %td.software << Software + %td.date << Joined + %td.approve + %td.deny + + %tbody + -for request in requests + %tr + %td.instance + %a(href="https://{{request.domain}}" target="_new") -> =request.domain + + %td.software + =request.software or "n/a" + + %td.date + =request.created.strftime("%Y-%m-%d") + + %td.approve + %a(href="/admin/instances/approve/{{request.domain}}" title="Approve Request") << ✓ + + %td.deny + %a(href="/admin/instances/deny/{{request.domain}}" title="Deny Request") << ✖ + + .data-table.section + .title << Instances %table %thead %tr diff --git a/relay/frontend/page/admin-software_bans.haml b/relay/frontend/page/admin-software_bans.haml index 9490405..7ac9d07 100644 --- a/relay/frontend/page/admin-software_bans.haml +++ b/relay/frontend/page/admin-software_bans.haml @@ -16,7 +16,7 @@ %input(type="submit" value="Ban Software") - #data-table.section + .data-table.section %table %thead %tr diff --git a/relay/frontend/page/admin-users.haml b/relay/frontend/page/admin-users.haml index 65c268e..a87c0db 100644 --- a/relay/frontend/page/admin-users.haml +++ b/relay/frontend/page/admin-users.haml @@ -19,7 +19,7 @@ %input(type="submit" value="Add User") - #data-table.section + .data-table.section %table %thead %tr diff --git a/relay/frontend/page/admin-whitelist.haml b/relay/frontend/page/admin-whitelist.haml index b294552..9126297 100644 --- a/relay/frontend/page/admin-whitelist.haml +++ b/relay/frontend/page/admin-whitelist.haml @@ -10,7 +10,7 @@ %input(type="submit" value="Add Domain") - #data-table.section + .data-table.section %table %thead %tr diff --git a/relay/frontend/page/home.haml b/relay/frontend/page/home.haml index 7f09644..527f33a 100644 --- a/relay/frontend/page/home.haml +++ b/relay/frontend/page/home.haml @@ -19,7 +19,7 @@ Note: The whitelist is enabled on this instance. Ask the admin to add your instance before joining. - #data-table.section + .data-table.section %table %thead %tr diff --git a/relay/frontend/style.css b/relay/frontend/style.css index f2a6fe1..4104519 100644 --- a/relay/frontend/style.css +++ b/relay/frontend/style.css @@ -91,6 +91,17 @@ textarea { margin: 0px auto; } +#content .title { + font-size: 24px; + text-align: center; + font-weight: bold; + margin-bottom: 10px; +} + +#content .title:not(:first-child) { + margin-top: 10px; +} + #header { display: grid; grid-template-columns: 50px auto 50px; @@ -193,15 +204,6 @@ textarea { align-items: center; } -#data-table td:first-child { - width: 100%; -} - -#data-table .date { - width: max-content; - text-align: right; -} - .button { background-color: var(--primary); border: 1px solid var(--primary); @@ -220,6 +222,15 @@ textarea { grid-template-columns: max-content auto; } +.data-table td:first-child { + width: 100%; +} + +.data-table .date { + width: max-content; + text-align: right; +} + .error, .message { text-align: center; } diff --git a/relay/manage.py b/relay/manage.py index 796ec0b..cb95748 100644 --- a/relay/manage.py +++ b/relay/manage.py @@ -342,7 +342,7 @@ def cli_config_list(ctx: click.Context) -> None: for key, value in conn.get_config_all().items(): if key not in CONFIG_IGNORE: key = f'{key}:'.ljust(20) - click.echo(f'- {key} {value}') + click.echo(f'- {key} {repr(value)}') @cli_config.command('set') @@ -477,7 +477,7 @@ def cli_inbox_list(ctx: click.Context) -> None: click.echo('Connected to the following instances or relays:') with ctx.obj.database.session() as conn: - for inbox in conn.execute('SELECT * FROM inboxes'): + for inbox in conn.get_inboxes(): click.echo(f'- {inbox["inbox"]}') @@ -618,6 +618,80 @@ def cli_inbox_remove(ctx: click.Context, inbox: str) -> None: click.echo(f'Removed inbox from the database: {inbox}') +@cli.group('request') +def cli_request() -> None: + 'Manage follow requests' + + +@cli_request.command('list') +@click.pass_context +def cli_request_list(ctx: click.Context) -> None: + 'List all current follow requests' + + click.echo('Follow requests:') + + with ctx.obj.database.session() as conn: + for instance in conn.get_requests(): + date = instance['created'].strftime('%Y-%m-%d') + click.echo(f'- [{date}] {instance["domain"]}') + + +@cli_request.command('accept') +@click.argument('domain') +@click.pass_context +def cli_request_accept(ctx: click.Context, domain: str) -> None: + 'Accept a follow request' + + try: + with ctx.obj.database.session() as conn: + instance = conn.put_request_response(domain, True) + + except KeyError: + click.echo('Request not found') + return + + message = Message.new_response( + host = ctx.obj.config.domain, + actor = instance['actor'], + followid = instance['followid'], + accept = True + ) + + asyncio.run(http.post(instance['inbox'], message, instance)) + + if instance['software'] != 'mastodon': + message = Message.new_follow( + host = ctx.obj.config.domain, + actor = instance['actor'] + ) + + asyncio.run(http.post(instance['inbox'], message, instance)) + + +@cli_request.command('deny') +@click.argument('domain') +@click.pass_context +def cli_request_deny(ctx: click.Context, domain: str) -> None: + 'Accept a follow request' + + try: + with ctx.obj.database.session() as conn: + instance = conn.put_request_response(domain, False) + + except KeyError: + click.echo('Request not found') + return + + response = Message.new_response( + host = ctx.obj.config.domain, + actor = instance['actor'], + followid = instance['followid'], + accept = False + ) + + asyncio.run(http.post(instance['inbox'], response, instance)) + + @cli.group('instance') def cli_instance() -> None: 'Manage instance bans' diff --git a/relay/misc.py b/relay/misc.py index 33e7a06..9fe4b83 100644 --- a/relay/misc.py +++ b/relay/misc.py @@ -117,7 +117,8 @@ class Message(aputils.Message): def new_actor(cls: type[Message], # pylint: disable=arguments-differ host: str, pubkey: str, - description: str | None = None) -> Message: + description: str | None = None, + approves: bool = False) -> Message: return cls({ '@context': 'https://www.w3.org/ns/activitystreams', @@ -126,6 +127,7 @@ class Message(aputils.Message): 'preferredUsername': 'relay', 'name': 'ActivityRelay', 'summary': description or 'ActivityRelay bot', + 'manuallyApprovesFollowers': approves, 'followers': f'https://{host}/followers', 'following': f'https://{host}/following', 'inbox': f'https://{host}/inbox', diff --git a/relay/processors.py b/relay/processors.py index 824a975..04aa4a5 100644 --- a/relay/processors.py +++ b/relay/processors.py @@ -62,9 +62,12 @@ async def handle_forward(view: ActorView, conn: Connection) -> None: async def handle_follow(view: ActorView, conn: Connection) -> None: nodeinfo = await view.client.fetch_nodeinfo(view.actor.domain) software = nodeinfo.sw_name if nodeinfo else None + config = conn.get_config_all() # reject if software used by actor is banned if conn.get_software_ban(software): + logging.verbose('Rejected banned actor: %s', view.actor.id) + view.app.push_message( view.actor.shared_inbox, Message.new_response( @@ -85,6 +88,8 @@ async def handle_follow(view: ActorView, conn: Connection) -> None: ## reject if the actor is not an instance actor if person_check(view.actor, software): + logging.verbose('Non-application actor tried to follow: %s', view.actor.id) + view.app.push_message( view.actor.shared_inbox, Message.new_response( @@ -95,20 +100,49 @@ async def handle_follow(view: ActorView, conn: Connection) -> None: ) ) - logging.verbose('Non-application actor tried to follow: %s', view.actor.id) return - with conn.transaction(): - if conn.get_inbox(view.actor.shared_inbox): - view.instance = conn.update_inbox(view.actor.shared_inbox, followid = view.message.id) + 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', view.actor.id) - else: - view.instance = conn.put_inbox( - view.actor.domain, + with conn.transaction(): + view.instance = conn.put_inbox( + domain = view.actor.domain, + inbox = view.actor.shared_inbox, + actor = view.actor.id, + followid = view.message.id, + software = software, + accepted = False + ) + + return + + # reject if the actor isn't whitelisted while the whiltelist is enabled + if config['whitelist-enabled']: + logging.verbose('Rejected actor for not being in the whitelist: %s', view.actor.id) + + view.app.push_message( view.actor.shared_inbox, - view.actor.id, - view.message.id, - software + Message.new_response( + host = view.config.domain, + actor = view.actor.id, + followid = view.message.id, + accept = False + ) + ) + + return + + with conn.transaction(): + view.instance = conn.put_inbox( + domain = view.actor.domain, + inbox = view.actor.shared_inbox, + actor = view.actor.id, + followid = view.message.id, + software = software, + accepted = True ) view.app.push_message( @@ -189,15 +223,15 @@ async def run_processor(view: ActorView) -> None: if not view.instance['software']: if (nodeinfo := await view.client.fetch_nodeinfo(view.instance['domain'])): with conn.transaction(): - view.instance = conn.update_inbox( - view.instance['inbox'], + view.instance = conn.put_inbox( + domain = view.instance['domain'], software = nodeinfo.sw_name ) if not view.instance['actor']: with conn.transaction(): - view.instance = conn.update_inbox( - view.instance['inbox'], + view.instance = conn.put_inbox( + domain = view.instance['domain'], actor = view.actor.id ) diff --git a/relay/template.py b/relay/template.py index 64738e0..4699d01 100644 --- a/relay/template.py +++ b/relay/template.py @@ -2,11 +2,10 @@ from __future__ import annotations import typing -from hamlish_jinja.extension import HamlishExtension +from hamlish_jinja import HamlishExtension from jinja2 import Environment, FileSystemLoader from . import __version__ -from .database.config import THEMES from .misc import get_resource if typing.TYPE_CHECKING: @@ -36,8 +35,8 @@ class Template(Environment): def render(self, path: str, view: View | None = None, **context: Any) -> str: - with self.app.database.session(False) as s: - config = s.get_config_all() + with self.app.database.session(False) as conn: + config = conn.get_config_all() new_context = { 'view': view, diff --git a/relay/views/activitypub.py b/relay/views/activitypub.py index 31266f6..df3085f 100644 --- a/relay/views/activitypub.py +++ b/relay/views/activitypub.py @@ -30,9 +30,14 @@ class ActorView(View): 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 + pubkey = self.app.signer.pubkey, + description = ''.join(f"

{line}

" for line in config['note'].splitlines()), + approves = config['approval-required'] ) return Response.new(data, ctype='activity') @@ -44,12 +49,6 @@ class ActorView(View): with self.database.session() as conn: self.instance = conn.get_inbox(self.actor.shared_inbox) - config = conn.get_config_all() - - ## reject if the actor isn't whitelisted while the whiltelist is enabled - if config['whitelist-enabled'] and not conn.get_domain_whitelist(self.actor.domain): - logging.verbose('Rejected actor for not being in the whitelist: %s', self.actor.id) - return Response.new_error(403, 'access denied', 'json') ## reject if actor is banned if conn.get_domain_ban(self.actor.domain): diff --git a/relay/views/api.py b/relay/views/api.py index 5a32cac..5a12e95 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -212,7 +212,7 @@ class Inbox(View): if not (instance := conn.get_inbox(data['domain'])): return Response.new_error(404, 'Instance with domain not found', 'json') - instance = conn.update_inbox(instance['inbox'], **data) + instance = conn.put_inbox(instance['domain'], **data) return Response.new(instance, ctype = 'json') @@ -232,6 +232,50 @@ class Inbox(View): return Response.new({'message': 'Deleted instance'}, ctype = 'json') +@register_route('/api/v1/request') +class RequestView(View): + async def get(self, request: Request) -> Response: + with self.database.session() as conn: + instances = conn.get_requests() + + return Response.new(instances, ctype = 'json') + + + async def post(self, request: Request) -> Response: + data = await self.get_api_data(['domain', 'accept'], []) + + if not isinstance(data['accept'], bool): + atype = type(data['accept']).__name__ + return Response.new_error(400, f'Invalid type for "accept": {atype}', 'json') + + try: + with self.database.session(True) as conn: + instance = conn.put_request_response(data['domain'], data['accept']) + + except KeyError: + return Response.new_error(404, 'Request not found', 'json') + + message = Message.new_response( + host = self.config.domain, + actor = instance['actor'], + followid = instance['followid'], + accept = data['accept'] + ) + + self.app.push_message(instance['inbox'], message, instance) + + if data['accept'] and instance['software'] != 'mastodon': + message = Message.new_follow( + host = self.config.domain, + actor = instance['actor'] + ) + + self.app.push_message(instance['inbox'], message, instance) + + resp_message = {'message': 'Request accepted' if data['accept'] else 'Request denied'} + return Response.new(resp_message, ctype = 'json') + + @register_route('/api/v1/domain_ban') class DomainBan(View): async def get(self, request: Request) -> Response: diff --git a/relay/views/frontend.py b/relay/views/frontend.py index bd63417..5367de5 100644 --- a/relay/views/frontend.py +++ b/relay/views/frontend.py @@ -56,7 +56,7 @@ class HomeView(View): async def get(self, request: Request) -> Response: with self.database.session() as conn: context = { - 'instances': tuple(conn.execute('SELECT * FROM inboxes').all()) + 'instances': tuple(conn.get_inboxes()) } data = self.template.render('page/home.haml', self, **context) @@ -137,7 +137,8 @@ class AdminInstances(View): with self.database.session() as conn: context = { - 'instances': tuple(conn.execute('SELECT * FROM inboxes').all()) + 'instances': tuple(conn.get_inboxes()), + 'requests': tuple(conn.get_requests()) } if error: @@ -179,15 +180,66 @@ class AdminInstances(View): @register_route('/admin/instances/delete/{domain}') class AdminInstancesDelete(View): async def get(self, request: Request, domain: str) -> Response: - with self.database.session() as conn: + with self.database.session(True) as conn: if not conn.get_inbox(domain): - return await AdminInstances(request).get(request, message = 'Instance not found') + return await AdminInstances(request).get(request, error = 'Instance not found') conn.del_inbox(domain) return await AdminInstances(request).get(request, message = 'Removed instance') +@register_route('/admin/instances/approve/{domain}') +class AdminInstancesApprove(View): + async def get(self, request: Request, domain: str) -> Response: + try: + with self.database.session(True) as conn: + instance = conn.put_request_response(domain, True) + + except KeyError: + return await AdminInstances(request).get(request, error = 'Instance not found') + + message = Message.new_response( + host = self.config.domain, + actor = instance['actor'], + followid = instance['followid'], + accept = True + ) + + self.app.push_message(instance['inbox'], message, instance) + + if instance['software'] != 'mastodon': + message = Message.new_follow( + host = self.config.domain, + actor = instance['actor'] + ) + + self.app.push_message(instance['inbox'], message, instance) + + return await AdminInstances(request).get(request, message = 'Request accepted') + + +@register_route('/admin/instances/deny/{domain}') +class AdminInstancesDeny(View): + async def get(self, request: Request, domain: str) -> Response: + try: + with self.database.session(True) as conn: + instance = conn.put_request_response(domain, False) + + except KeyError: + return await AdminInstances(request).get(request, error = 'Instance not found') + + message = Message.new_response( + host = self.config.domain, + actor = instance['actor'], + followid = instance['followid'], + accept = False + ) + + self.app.push_message(instance['inbox'], message, instance) + return await AdminInstances(request).get(request, message = 'Request denied') + + @register_route('/admin/whitelist') class AdminWhitelist(View): async def get(self, @@ -412,7 +464,7 @@ class AdminConfig(View): async def get(self, request: Request, message: str | None = None) -> Response: context = { 'themes': tuple(THEMES.keys()), - 'LogLevel': LogLevel, + 'levels': tuple(level.name for level in LogLevel), 'message': message } data = self.template.render('page/admin-config.haml', self, **context) diff --git a/relay/views/misc.py b/relay/views/misc.py index 65025e3..ff4a6a4 100644 --- a/relay/views/misc.py +++ b/relay/views/misc.py @@ -34,7 +34,7 @@ class NodeinfoView(View): # pylint: disable=no-self-use async def get(self, request: Request, niversion: str) -> Response: with self.database.session() as conn: - inboxes = conn.execute('SELECT * FROM inboxes').all() + inboxes = conn.get_inboxes() data = { 'name': 'activityrelay', @@ -42,7 +42,10 @@ class NodeinfoView(View): 'protocols': ['activitypub'], 'open_regs': not conn.get_config('whitelist-enabled'), 'users': 1, - 'metadata': {'peers': [inbox['domain'] for inbox in inboxes]} + 'metadata': { + 'approval_required': conn.get_config('approval-required'), + 'peers': [inbox['domain'] for inbox in inboxes] + } } if niversion == '2.1': From d5069d98a6f7bc8fe860a7be483398b1c804b748 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Mon, 11 Mar 2024 01:52:28 -0400 Subject: [PATCH 02/57] fix `TypeError` when a person tries joining --- relay/processors.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/relay/processors.py b/relay/processors.py index 04aa4a5..4d19bf9 100644 --- a/relay/processors.py +++ b/relay/processors.py @@ -97,7 +97,8 @@ async def handle_follow(view: ActorView, conn: Connection) -> None: actor = view.actor.id, followid = view.message.id, accept = False - ) + ), + view.instance ) return From b3ab6e6d401a468b882f9c5d3c79520bc82ae03a Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Mon, 11 Mar 2024 01:53:12 -0400 Subject: [PATCH 03/57] add message on home page when relay is in manual approval mode --- relay/frontend/page/home.haml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/relay/frontend/page/home.haml b/relay/frontend/page/home.haml index 527f33a..9c3e063 100644 --- a/relay/frontend/page/home.haml +++ b/relay/frontend/page/home.haml @@ -14,10 +14,15 @@ You may subscribe to this relay with the address: %a(href="https://{{domain}}/actor") << https://{{domain}}/actor - -if config["whitelist-enabled"] + -if config["approval-required"] %p.section.message - Note: The whitelist is enabled on this instance. Ask the admin to add your instance - before joining. + Follow requests require approval. You will need to wait for an admin to accept or deny + your request. + + -elif config["whitelist-enabled"] + %p.section.message + The whitelist is enabled on this instance. Ask the admin to add your instance before + joining. .data-table.section %table From 5b1f24470362d0f690f1825818096946a63b7533 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Mon, 11 Mar 2024 02:36:34 -0400 Subject: [PATCH 04/57] actually use signed headers --- relay/http_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/relay/http_client.py b/relay/http_client.py index 7e7bbd9..eb84362 100644 --- a/relay/http_client.py +++ b/relay/http_client.py @@ -116,7 +116,7 @@ class HttpClient: headers = {} if sign_headers: - self.signer.sign_headers('GET', url, algorithm = 'original') + headers = self.signer.sign_headers('GET', url, algorithm = 'original') try: logging.debug('Fetching resource: %s', url) @@ -130,7 +130,7 @@ class HttpClient: if resp.status != 200: logging.verbose('Received error when requesting %s: %i', url, resp.status) - logging.debug(await resp.read()) + logging.debug(data) return None message = loads(data) From 21e0e0a3ec19fff7613b39c62667d2c42159a9f5 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Mon, 11 Mar 2024 03:17:38 -0400 Subject: [PATCH 05/57] add markdown support for the `note` setting --- relay/application.py | 5 ++++ relay/frontend/page/home.haml | 4 +--- relay/template.py | 44 ++++++++++++++++++++++++++++++++++- relay/views/activitypub.py | 2 +- requirements.txt | 1 + 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/relay/application.py b/relay/application.py index dfa3861..6c59546 100644 --- a/relay/application.py +++ b/relay/application.py @@ -111,6 +111,11 @@ class Application(web.Application): self['signer'] = Signer(value, self.config.keyid) + @property + def template(self) -> Template: + return self['template'] + + @property def uptime(self) -> timedelta: if not self['start_time']: diff --git a/relay/frontend/page/home.haml b/relay/frontend/page/home.haml index 9c3e063..2aa745f 100644 --- a/relay/frontend/page/home.haml +++ b/relay/frontend/page/home.haml @@ -2,9 +2,7 @@ -set page = "Home" -block content .section - -for line in config.note.splitlines() - -if line - %p -> =line + -markdown -> =config.note .section %p diff --git a/relay/template.py b/relay/template.py index 4699d01..e951424 100644 --- a/relay/template.py +++ b/relay/template.py @@ -1,14 +1,22 @@ from __future__ import annotations +import textwrap import typing +from collections.abc import Callable from hamlish_jinja import HamlishExtension from jinja2 import Environment, FileSystemLoader +from jinja2.ext import Extension +from jinja2.nodes import CallBlock +from markdown import Markdown + from . import __version__ from .misc import get_resource if typing.TYPE_CHECKING: + from jinja2.nodes import Node + from jinja2.parser import Parser from typing import Any from .application import Application from .views.base import View @@ -21,7 +29,8 @@ class Template(Environment): trim_blocks = True, lstrip_blocks = True, extensions = [ - HamlishExtension + HamlishExtension, + MarkdownExtension ], loader = FileSystemLoader([ get_resource('frontend'), @@ -48,3 +57,36 @@ class Template(Environment): } return self.get_template(path).render(new_context) + + +class MarkdownExtension(Extension): + tags = {'markdown'} + extensions = { + 'attr_list', + 'smarty', + 'tables' + } + + + def __init__(self, environment: Environment): + Extension.__init__(self, environment) + self._markdown = Markdown(extensions = MarkdownExtension.extensions) + environment.extend( + render_markdown = self._render_markdown + ) + + + def parse(self, parser: Parser) -> Node | list[Node]: + lineno = next(parser.stream).lineno + body = parser.parse_statements( + ['name:endmarkdown'], + drop_needle = True + ) + + output = CallBlock(self.call_method('_render_markdown'), [], [], body) + return output.set_lineno(lineno) + + + def _render_markdown(self, caller: Callable[[], str] | str) -> str: + text = caller() if isinstance(caller, Callable) else caller + return self._markdown.convert(textwrap.dedent(text.strip('\n'))) diff --git a/relay/views/activitypub.py b/relay/views/activitypub.py index df3085f..6e392db 100644 --- a/relay/views/activitypub.py +++ b/relay/views/activitypub.py @@ -36,7 +36,7 @@ class ActorView(View): data = Message.new_actor( host = self.config.domain, pubkey = self.app.signer.pubkey, - description = ''.join(f"

{line}

" for line in config['note'].splitlines()), + description = self.app.template.render_markdown(config['note']), approves = config['approval-required'] ) diff --git a/requirements.txt b/requirements.txt index 4c43b87..5ca0df6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ barkshark-sql@https://git.barkshark.xyz/barkshark/bsql/archive/0.1.2.tar.gz click>=8.1.2 hamlish-jinja@https://git.barkshark.xyz/barkshark/hamlish-jinja/archive/0.3.5.tar.gz hiredis==2.3.2 +markdown==3.5.2 platformdirs==4.2.0 pyyaml>=6.0 redis==5.0.1 From 189ac887a98b6eec39314338bd9d4bcb5733a39b Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Mon, 11 Mar 2024 03:24:44 -0400 Subject: [PATCH 06/57] minor tweak to data used in admin pages --- relay/views/frontend.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/relay/views/frontend.py b/relay/views/frontend.py index 5367de5..cd43a65 100644 --- a/relay/views/frontend.py +++ b/relay/views/frontend.py @@ -249,7 +249,7 @@ class AdminWhitelist(View): with self.database.session() as conn: context = { - 'whitelist': tuple(conn.execute('SELECT * FROM whitelist').all()) + 'whitelist': tuple(conn.execute('SELECT * FROM whitelist ORDER BY domain ASC')) } if error: @@ -299,7 +299,7 @@ class AdminDomainBans(View): with self.database.session() as conn: context = { - 'bans': tuple(conn.execute('SELECT * FROM domain_bans ORDER BY domain ASC').all()) + 'bans': tuple(conn.execute('SELECT * FROM domain_bans ORDER BY domain ASC')) } if error: @@ -357,7 +357,7 @@ class AdminSoftwareBans(View): with self.database.session() as conn: context = { - 'bans': tuple(conn.execute('SELECT * FROM software_bans ORDER BY name ASC').all()) + 'bans': tuple(conn.execute('SELECT * FROM software_bans ORDER BY name ASC')) } if error: @@ -415,7 +415,7 @@ class AdminUsers(View): with self.database.session() as conn: context = { - 'users': tuple(conn.execute('SELECT * FROM users').all()) + 'users': tuple(conn.execute('SELECT * FROM users ORDER BY username ASC')) } if error: From 9fe6d8ad96a5609db66f4218dfe34463e24f0332 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Wed, 13 Mar 2024 17:43:57 -0400 Subject: [PATCH 07/57] replace pylint with mypy and other minor changes * ignore test*.py files * format requirements.txt to be more readable * only show note on home page if it is set * allow flake8 to check for more than just unused imports * remove a bunch of unused methods in `compat.RelayDatabase` * turn `Config` into a dataclass * replace database config methods with `RelayData` dataclass * rename `loads` to `cls` in `HttpClient.get` --- .gitignore | 2 + dev-requirements.txt | 2 +- pyproject.toml | 61 ++----- relay/application.py | 27 ++-- relay/cache.py | 23 +-- relay/compat.py | 126 +-------------- relay/config.py | 221 ++++++++++++-------------- relay/database/__init__.py | 6 +- relay/database/config.py | 117 ++++++++++---- relay/database/connection.py | 96 +++++------ relay/database/schema.py | 7 +- relay/dev.py | 15 +- relay/frontend/base.haml | 2 +- relay/frontend/page/admin-config.haml | 8 +- relay/frontend/page/home.haml | 9 +- relay/frontend/style.css | 8 + relay/http_client.py | 82 +++++----- relay/logger.py | 16 +- relay/manage.py | 48 +++--- relay/misc.py | 57 ++++--- relay/processors.py | 22 +-- relay/template.py | 15 +- relay/views/__init__.py | 2 +- relay/views/activitypub.py | 44 ++--- relay/views/api.py | 65 ++++---- relay/views/base.py | 35 ++-- relay/views/frontend.py | 66 ++++---- relay/views/misc.py | 25 ++- requirements.txt | 26 +-- setup.cfg | 4 +- 30 files changed, 568 insertions(+), 669 deletions(-) diff --git a/.gitignore b/.gitignore index 737b9a4..eeebd0a 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,5 @@ ENV/ *.yaml *.jsonld *.sqlite3 + +test*.py diff --git a/dev-requirements.txt b/dev-requirements.txt index f0fb91f..6285aa4 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,4 @@ flake8 == 7.0.0 +mypy == 1.9.0 pyinstaller == 6.3.0 -pylint == 3.0 watchdog == 4.0.0 diff --git a/pyproject.toml b/pyproject.toml index d98eab8..b1bde52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,54 +3,13 @@ requires = ["setuptools","wheel"] build-backend = 'setuptools.build_meta' -[tool.pylint.main] -jobs = 0 -persistent = true -load-plugins = [ - "pylint.extensions.code_style", - "pylint.extensions.comparison_placement", - "pylint.extensions.confusing_elif", - "pylint.extensions.for_any_all", - "pylint.extensions.consider_ternary_expression", - "pylint.extensions.bad_builtin", - "pylint.extensions.dict_init_mutate", - "pylint.extensions.check_elif", - "pylint.extensions.empty_comment", - "pylint.extensions.private_import", - "pylint.extensions.redefined_variable_type", - "pylint.extensions.no_self_use", - "pylint.extensions.overlapping_exceptions", - "pylint.extensions.set_membership", - "pylint.extensions.typing" -] - - -[tool.pylint.design] -max-args = 10 -max-attributes = 100 - - -[tool.pylint.format] -indent-str = "\t" -indent-after-paren = 1 -max-line-length = 100 -single-line-if-stmt = true - - -[tool.pylint.messages_control] -disable = [ - "fixme", - "broad-exception-caught", - "cyclic-import", - "global-statement", - "invalid-name", - "missing-module-docstring", - "too-few-public-methods", - "too-many-public-methods", - "too-many-return-statements", - "wrong-import-order", - "missing-function-docstring", - "missing-class-docstring", - "consider-using-namedtuple-or-dataclass", - "confusing-consecutive-elif" -] +[tool.mypy] +show_traceback = true +install_types = true +pretty = true +disallow_untyped_decorators = true +warn_redundant_casts = true +warn_unreachable = true +warn_unused_ignores = true +ignore_missing_imports = true +follow_imports = "silent" diff --git a/relay/application.py b/relay/application.py index 6c59546..c5f9aaf 100644 --- a/relay/application.py +++ b/relay/application.py @@ -26,16 +26,15 @@ from .views.api import handle_api_path from .views.frontend import handle_frontend_path if typing.TYPE_CHECKING: - from collections.abc import Coroutine - from tinysql import Database, Row + from collections.abc import Callable + from bsql import Database, Row from .cache import Cache from .misc import Message, Response -# pylint: disable=unsubscriptable-object - class Application(web.Application): - DEFAULT: Application = None + DEFAULT: Application | None = None + def __init__(self, cfgpath: str | None, dev: bool = False): web.Application.__init__(self, @@ -64,14 +63,13 @@ class Application(web.Application): self['workers'] = [] self.cache.setup() - - # self.on_response_prepare.append(handle_access_log) - self.on_cleanup.append(handle_cleanup) + self.on_cleanup.append(handle_cleanup) # type: ignore for path, view in VIEWS: self.router.add_view(path, view) - setup_swagger(self, + setup_swagger( + self, ui_version = 3, swagger_from_file = get_resource('data/swagger.yaml') ) @@ -165,6 +163,7 @@ class Application(web.Application): self.set_signal_handler(True) + self['client'].open() self['database'].connect() self['cache'].setup() self['cleanup_thread'] = CacheCleanupThread(self) @@ -179,7 +178,8 @@ class Application(web.Application): runner = web.AppRunner(self, access_log_format='%{X-Forwarded-For}i "%r" %s %b "%{User-Agent}i"') await runner.setup() - site = web.TCPSite(runner, + site = web.TCPSite( + runner, host = self.config.listen, port = self.config.port, reuse_address = True @@ -193,7 +193,7 @@ class Application(web.Application): await site.stop() - for worker in self['workers']: # pylint: disable=not-an-iterable + for worker in self['workers']: worker.stop() self.set_signal_handler(False) @@ -247,6 +247,7 @@ class PushWorker(multiprocessing.Process): async def handle_queue(self) -> None: client = HttpClient() + client.open() while not self.shutdown.is_set(): try: @@ -256,7 +257,7 @@ class PushWorker(multiprocessing.Process): except Empty: pass - ## make sure an exception doesn't bring down the worker + # make sure an exception doesn't bring down the worker except Exception: traceback.print_exc() @@ -264,7 +265,7 @@ class PushWorker(multiprocessing.Process): @web.middleware -async def handle_response_headers(request: web.Request, handler: Coroutine) -> Response: +async def handle_response_headers(request: web.Request, handler: Callable) -> Response: resp = await handler(request) resp.headers['Server'] = 'ActivityRelay' diff --git a/relay/cache.py b/relay/cache.py index 5647106..9ea3d2b 100644 --- a/relay/cache.py +++ b/relay/cache.py @@ -13,15 +13,16 @@ from .database import get_database from .misc import Message, boolean if typing.TYPE_CHECKING: - from typing import Any + from blib import Database from collections.abc import Callable, Iterator + from typing import Any from .application import Application # todo: implement more caching backends -BACKENDS: dict[str, Cache] = {} +BACKENDS: dict[str, type[Cache]] = {} CONVERTERS: dict[str, tuple[Callable, Callable]] = { 'str': (str, str), 'int': (str, int), @@ -71,7 +72,7 @@ class Item: data.value = deserialize_value(data.value, data.value_type) if not isinstance(data.updated, datetime): - data.updated = datetime.fromtimestamp(data.updated, tz = timezone.utc) + data.updated = datetime.fromtimestamp(data.updated, tz = timezone.utc) # type: ignore return data @@ -143,7 +144,7 @@ class Cache(ABC): item.namespace, item.key, item.value, - item.type + item.value_type ) @@ -158,7 +159,7 @@ class SqlCache(Cache): def __init__(self, app: Application): Cache.__init__(self, app) - self._db = None + self._db: Database = None def get(self, namespace: str, key: str) -> Item: @@ -257,7 +258,7 @@ class RedisCache(Cache): def __init__(self, app: Application): Cache.__init__(self, app) - self._rd = None + self._rd: Redis = None # type: ignore @property @@ -275,7 +276,7 @@ class RedisCache(Cache): if not (raw_value := self._rd.get(key_name)): raise KeyError(f'{namespace}:{key}') - value_type, updated, value = raw_value.split(':', 2) + value_type, updated, value = raw_value.split(':', 2) # type: ignore return Item.from_data( namespace, key, @@ -302,7 +303,7 @@ class RedisCache(Cache): yield namespace - def set(self, namespace: str, key: str, value: Any, value_type: str = 'key') -> None: + def set(self, namespace: str, key: str, value: Any, value_type: str = 'key') -> Item: date = datetime.now(tz = timezone.utc).timestamp() value = serialize_value(value, value_type) @@ -311,6 +312,8 @@ class RedisCache(Cache): f'{value_type}:{date}:{value}' ) + return self.get(namespace, key) + def delete(self, namespace: str, key: str) -> None: self._rd.delete(self.get_key_name(namespace, key)) @@ -350,7 +353,7 @@ class RedisCache(Cache): options['host'] = self.app.config.rd_host options['port'] = self.app.config.rd_port - self._rd = Redis(**options) + self._rd = Redis(**options) # type: ignore def close(self) -> None: @@ -358,4 +361,4 @@ class RedisCache(Cache): return self._rd.close() - self._rd = None + self._rd = None # type: ignore diff --git a/relay/compat.py b/relay/compat.py index cc19226..9884b25 100644 --- a/relay/compat.py +++ b/relay/compat.py @@ -9,16 +9,12 @@ from functools import cached_property from pathlib import Path from urllib.parse import urlparse -from . import logger as logging -from .misc import Message, boolean +from .misc import boolean if typing.TYPE_CHECKING: - from collections.abc import Iterator from typing import Any -# pylint: disable=duplicate-code - class RelayConfig(dict): def __init__(self, path: str): dict.__init__(self, {}) @@ -46,7 +42,7 @@ class RelayConfig(dict): @property - def db(self) -> RelayDatabase: + def db(self) -> Path: return Path(self['db']).expanduser().resolve() @@ -184,121 +180,3 @@ class RelayDatabase(dict): except json.decoder.JSONDecodeError as e: if self.config.db.stat().st_size > 0: raise e from None - - - def save(self) -> None: - with self.config.db.open('w', encoding = 'UTF-8') as fd: - json.dump(self, fd, indent=4) - - - def get_inbox(self, domain: str, fail: bool = False) -> dict[str, str] | None: - if domain.startswith('http'): - domain = urlparse(domain).hostname - - if (inbox := self['relay-list'].get(domain)): - return inbox - - if fail: - raise KeyError(domain) - - return None - - - def add_inbox(self, - inbox: str, - followid: str | None = None, - software: str | None = None) -> dict[str, str]: - - assert inbox.startswith('https'), 'Inbox must be a url' - domain = urlparse(inbox).hostname - - if (instance := self.get_inbox(domain)): - if followid: - instance['followid'] = followid - - if software: - instance['software'] = software - - return instance - - self['relay-list'][domain] = { - 'domain': domain, - 'inbox': inbox, - 'followid': followid, - 'software': software - } - - logging.verbose('Added inbox to database: %s', inbox) - return self['relay-list'][domain] - - - def del_inbox(self, - domain: str, - followid: str = None, - fail: bool = False) -> bool: - - if not (data := self.get_inbox(domain, fail=False)): - if fail: - raise KeyError(domain) - - return False - - if not data['followid'] or not followid or data['followid'] == followid: - del self['relay-list'][data['domain']] - logging.verbose('Removed inbox from database: %s', data['inbox']) - return True - - if fail: - raise ValueError('Follow IDs do not match') - - logging.debug('Follow ID does not match: db = %s, object = %s', data['followid'], followid) - return False - - - def get_request(self, domain: str, fail: bool = True) -> dict[str, str] | None: - if domain.startswith('http'): - domain = urlparse(domain).hostname - - try: - return self['follow-requests'][domain] - - except KeyError as e: - if fail: - raise e - - return None - - - def add_request(self, actor: str, inbox: str, followid: str) -> None: - domain = urlparse(inbox).hostname - - try: - request = self.get_request(domain) - request['followid'] = followid - - except KeyError: - pass - - self['follow-requests'][domain] = { - 'actor': actor, - 'inbox': inbox, - 'followid': followid - } - - - def del_request(self, domain: str) -> None: - if domain.startswith('http'): - domain = urlparse(domain).hostname - - del self['follow-requests'][domain] - - - def distill_inboxes(self, message: Message) -> Iterator[str]: - src_domains = { - message.domain, - urlparse(message.object_id).netloc - } - - for domain, instance in self['relay-list'].items(): - if domain not in src_domains: - yield instance['inbox'] diff --git a/relay/config.py b/relay/config.py index 84faab1..74758aa 100644 --- a/relay/config.py +++ b/relay/config.py @@ -6,13 +6,14 @@ import platform import typing import yaml +from dataclasses import asdict, dataclass, fields from pathlib import Path from platformdirs import user_config_dir from .misc import IS_DOCKER if typing.TYPE_CHECKING: - from typing import Any + from typing import Any, Self if platform.system() == 'Windows': @@ -23,61 +24,44 @@ else: CORE_COUNT = len(os.sched_getaffinity(0)) -DEFAULTS: dict[str, Any] = { +DOCKER_VALUES = { 'listen': '0.0.0.0', 'port': 8080, - 'domain': 'relay.example.com', - 'workers': CORE_COUNT, - 'db_type': 'sqlite', - 'ca_type': 'database', - 'sq_path': 'relay.sqlite3', - - 'pg_host': '/var/run/postgresql', - 'pg_port': 5432, - 'pg_user': getpass.getuser(), - 'pg_pass': None, - 'pg_name': 'activityrelay', - - 'rd_host': 'localhost', - 'rd_port': 6379, - 'rd_user': None, - 'rd_pass': None, - 'rd_database': 0, - 'rd_prefix': 'activityrelay' + 'sq_path': '/data/relay.jsonld' } -if IS_DOCKER: - DEFAULTS['sq_path'] = '/data/relay.jsonld' + +class NOVALUE: + pass +@dataclass(init = False) class Config: - def __init__(self, path: str, load: bool = False): - if path: - self.path = Path(path).expanduser().resolve() + listen: str = '0.0.0.0' + port: int = 8080 + domain: str = 'relay.example.com' + workers: int = CORE_COUNT + db_type: str = 'sqlite' + ca_type: str = 'database' + sq_path: str = 'relay.sqlite3' - else: - self.path = Config.get_config_dir() + pg_host: str = '/var/run/postgresql' + pg_port: int = 5432 + pg_user: str = getpass.getuser() + pg_pass: str | None = None + pg_name: str = 'activityrelay' - self.listen = None - self.port = None - self.domain = None - self.workers = None - self.db_type = None - self.ca_type = None - self.sq_path = None + rd_host: str = 'localhost' + rd_port: int = 6470 + rd_user: str | None = None + rd_pass: str | None = None + rd_database: int = 0 + rd_prefix: str = 'activityrelay' - self.pg_host = None - self.pg_port = None - self.pg_user = None - self.pg_pass = None - self.pg_name = None - self.rd_host = None - self.rd_port = None - self.rd_user = None - self.rd_pass = None - self.rd_database = None - self.rd_prefix = None + def __init__(self, path: str | None = None, load: bool = False): + self.path = Config.get_config_dir(path) + self.reset() if load: try: @@ -87,22 +71,36 @@ class Config: self.save() + @classmethod + def KEYS(cls: type[Self]) -> list[str]: + return list(cls.__dataclass_fields__) + + + @classmethod + def DEFAULT(cls: type[Self], key: str) -> str | int | None: + for field in fields(cls): + if field.name == key: + return field.default # type: ignore + + raise KeyError(key) + + @staticmethod def get_config_dir(path: str | None = None) -> Path: if path: return Path(path).expanduser().resolve() - dirs = ( + paths = ( Path("relay.yaml").resolve(), Path(user_config_dir("activityrelay"), "relay.yaml"), Path("/etc/activityrelay/relay.yaml") ) - for directory in dirs: - if directory.exists(): - return directory + for cfgfile in paths: + if cfgfile.exists(): + return cfgfile - return dirs[0] + return paths[0] @property @@ -130,7 +128,6 @@ class Config: def load(self) -> None: self.reset() - options = {} try: @@ -141,95 +138,85 @@ class Config: with self.path.open('r', encoding = 'UTF-8') as fd: config = yaml.load(fd, **options) - pgcfg = config.get('postgresql', {}) - rdcfg = config.get('redis', {}) if not config: raise ValueError('Config is empty') - if IS_DOCKER: - self.listen = '0.0.0.0' - self.port = 8080 - self.sq_path = '/data/relay.jsonld' + pgcfg = config.get('postgresql', {}) + rdcfg = config.get('redis', {}) - else: - self.set('listen', config.get('listen', DEFAULTS['listen'])) - self.set('port', config.get('port', DEFAULTS['port'])) - self.set('sq_path', config.get('sqlite_path', DEFAULTS['sq_path'])) + for key in type(self).KEYS(): + if IS_DOCKER and key in {'listen', 'port', 'sq_path'}: + self.set(key, DOCKER_VALUES[key]) + continue - self.set('workers', config.get('workers', DEFAULTS['workers'])) - self.set('domain', config.get('domain', DEFAULTS['domain'])) - self.set('db_type', config.get('database_type', DEFAULTS['db_type'])) - self.set('ca_type', config.get('cache_type', DEFAULTS['ca_type'])) - - for key in DEFAULTS: if key.startswith('pg'): - try: - self.set(key, pgcfg[key[3:]]) - - except KeyError: - continue + self.set(key, pgcfg.get(key[3:], NOVALUE)) + continue elif key.startswith('rd'): - try: - self.set(key, rdcfg[key[3:]]) + self.set(key, rdcfg.get(key[3:], NOVALUE)) + continue - except KeyError: - continue + cfgkey = key + + if key == 'db_type': + cfgkey = 'database_type' + + elif key == 'ca_type': + cfgkey = 'cache_type' + + elif key == 'sq_path': + cfgkey = 'sqlite_path' + + self.set(key, config.get(cfgkey, NOVALUE)) def reset(self) -> None: - for key, value in DEFAULTS.items(): - setattr(self, key, value) + for field in fields(self): + setattr(self, field.name, field.default) def save(self) -> None: self.path.parent.mkdir(exist_ok = True, parents = True) + data: dict[str, Any] = {} + + for key, value in asdict(self).items(): + if key.startswith('pg_'): + if 'postgres' not in data: + data['postgres'] = {} + + data['postgres'][key[3:]] = value + continue + + if key.startswith('rd_'): + if 'redis' not in data: + data['redis'] = {} + + data['redis'][key[3:]] = value + continue + + if key == 'db_type': + key = 'database_type' + + elif key == 'ca_type': + key = 'cache_type' + + elif key == 'sq_path': + key = 'sqlite_path' + + data[key] = value + with self.path.open('w', encoding = 'utf-8') as fd: - yaml.dump(self.to_dict(), fd, sort_keys = False) + yaml.dump(data, fd, sort_keys = False) def set(self, key: str, value: Any) -> None: - if key not in DEFAULTS: + if key not in type(self).KEYS(): raise KeyError(key) - if key in {'port', 'pg_port', 'workers'} and not isinstance(value, int): - if (value := int(value)) < 1: - if key == 'port': - value = 8080 - - elif key == 'pg_port': - value = 5432 - - elif key == 'workers': - value = len(os.sched_getaffinity(0)) + if value is NOVALUE: + return setattr(self, key, value) - - - def to_dict(self) -> dict[str, Any]: - return { - 'listen': self.listen, - 'port': self.port, - 'domain': self.domain, - 'workers': self.workers, - 'database_type': self.db_type, - 'cache_type': self.ca_type, - 'sqlite_path': self.sq_path, - 'postgres': { - 'host': self.pg_host, - 'port': self.pg_port, - 'user': self.pg_user, - 'pass': self.pg_pass, - 'name': self.pg_name - }, - 'redis': { - 'host': self.rd_host, - 'port': self.rd_port, - 'user': self.rd_user, - 'pass': self.rd_pass, - 'database': self.rd_database, - 'refix': self.rd_prefix - } - } diff --git a/relay/database/__init__.py b/relay/database/__init__.py index 02b0bc9..08dbec6 100644 --- a/relay/database/__init__.py +++ b/relay/database/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import bsql import typing -from .config import CONFIG_DEFAULTS, THEMES, get_default_value +from .config import THEMES, ConfigData from .connection import RELAY_SOFTWARE, Connection from .schema import TABLES, VERSIONS, migrate_0 @@ -11,7 +11,7 @@ from .. import logger as logging from ..misc import get_resource if typing.TYPE_CHECKING: - from .config import Config + from ..config import Config def get_database(config: Config, migrate: bool = True) -> bsql.Database: @@ -46,7 +46,7 @@ def get_database(config: Config, migrate: bool = True) -> bsql.Database: migrate_0(conn) return db - if (schema_ver := conn.get_config('schema-version')) < get_default_value('schema-version'): + if (schema_ver := conn.get_config('schema-version')) < ConfigData.DEFAULT('schema-version'): logging.info("Migrating database from version '%i'", schema_ver) for ver, func in VERSIONS.items(): diff --git a/relay/database/config.py b/relay/database/config.py index 7d2abd4..306cf4e 100644 --- a/relay/database/config.py +++ b/relay/database/config.py @@ -1,14 +1,16 @@ from __future__ import annotations -import json import typing +from dataclasses import Field, asdict, dataclass, fields + from .. import logger as logging from ..misc import boolean if typing.TYPE_CHECKING: - from collections.abc import Callable - from typing import Any + from bsql import Row + from collections.abc import Callable, Sequence + from typing import Any, Self THEMES = { @@ -59,40 +61,101 @@ THEMES = { } } -CONFIG_DEFAULTS: dict[str, tuple[str, Any]] = { - 'schema-version': ('int', 20240310), - 'private-key': ('str', None), - 'approval-required': ('bool', False), - 'log-level': ('loglevel', logging.LogLevel.INFO), - 'name': ('str', 'ActivityRelay'), - 'note': ('str', 'Make a note about your instance here.'), - 'theme': ('str', 'default'), - 'whitelist-enabled': ('bool', False) -} - # serializer | deserializer -CONFIG_CONVERT: dict[str, tuple[Callable, Callable]] = { +CONFIG_CONVERT: dict[str, tuple[Callable[[Any], str], Callable[[str], Any]]] = { 'str': (str, str), 'int': (str, int), 'bool': (str, boolean), - 'json': (json.dumps, json.loads), - 'loglevel': (lambda x: x.name, logging.LogLevel.parse) + 'logging.LogLevel': (lambda x: x.name, logging.LogLevel.parse) } -def get_default_value(key: str) -> Any: - return CONFIG_DEFAULTS[key][1] +@dataclass() +class ConfigData: + schema_version: int = 20240310 + private_key: str = '' + approval_required: bool = False + log_level: logging.LogLevel = logging.LogLevel.INFO + name: str = 'ActivityRelay' + note: str = '' + theme: str = 'default' + whitelist_enabled: bool = False -def get_default_type(key: str) -> str: - return CONFIG_DEFAULTS[key][0] + def __getitem__(self, key: str) -> Any: + if (value := getattr(self, key.replace('-', '_'), None)) is None: + raise KeyError(key) + + return value -def serialize(key: str, value: Any) -> str: - type_name = get_default_type(key) - return CONFIG_CONVERT[type_name][0](value) + def __setitem__(self, key: str, value: Any) -> None: + self.set(key, value) -def deserialize(key: str, value: str) -> Any: - type_name = get_default_type(key) - return CONFIG_CONVERT[type_name][1](value) + @classmethod + def KEYS(cls: type[Self]) -> Sequence[str]: + return list(cls.__dataclass_fields__) + + + @staticmethod + def SYSTEM_KEYS() -> Sequence[str]: + return ('schema-version', 'schema_version', 'private-key', 'private_key') + + + @classmethod + def USER_KEYS(cls: type[Self]) -> Sequence[str]: + return tuple(key for key in cls.KEYS() if key not in cls.SYSTEM_KEYS()) + + + @classmethod + def DEFAULT(cls: type[Self], key: str) -> str | int | bool: + return cls.FIELD(key.replace('-', '_')).default # type: ignore + + + @classmethod + def FIELD(cls: type[Self], key: str) -> Field: + for field in fields(cls): + if field.name == key.replace('-', '_'): + return field + + raise KeyError(key) + + + @classmethod + def from_rows(cls: type[Self], rows: Sequence[Row]) -> Self: + data = cls() + set_schema_version = False + + for row in rows: + data.set(row['key'], row['value']) + + if row['key'] == 'schema-version': + set_schema_version = True + + if not set_schema_version: + data.schema_version = 0 + + return data + + + def get(self, key: str, default: Any = None, serialize: bool = False) -> Any: + field = type(self).FIELD(key) + value = getattr(self, field.name, None) + + if not serialize: + return value + + converter = CONFIG_CONVERT[str(field.type)][0] + return converter(value) + + + def set(self, key: str, value: Any) -> None: + field = type(self).FIELD(key) + converter = CONFIG_CONVERT[str(field.type)][1] + + setattr(self, field.name, converter(value)) + + + def to_dict(self) -> dict[str, Any]: + return {key.replace('_', '-'): value for key, value in asdict(self).items()} diff --git a/relay/database/connection.py b/relay/database/connection.py index ca98e6a..6f77c31 100644 --- a/relay/database/connection.py +++ b/relay/database/connection.py @@ -9,22 +9,18 @@ from urllib.parse import urlparse from uuid import uuid4 from .config import ( - CONFIG_DEFAULTS, THEMES, - get_default_type, - get_default_value, - serialize, - deserialize + ConfigData ) from .. import logger as logging from ..misc import boolean, get_app if typing.TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Sequence from bsql import Row from typing import Any - from .application import Application + from ..application import Application from ..misc import Message @@ -58,73 +54,57 @@ class Connection(SqlConnection): def get_config(self, key: str) -> Any: - if key not in CONFIG_DEFAULTS: - raise KeyError(key) - with self.run('get-config', {'key': key}) as cur: if not (row := cur.one()): - return get_default_value(key) + return ConfigData.DEFAULT(key) - if row['value']: - return deserialize(row['key'], row['value']) - - return None + data = ConfigData() + data.set(row['key'], row['value']) + return data.get(key) - def get_config_all(self) -> dict[str, Any]: + def get_config_all(self) -> ConfigData: with self.run('get-config-all', None) as cur: - db_config = {row['key']: row['value'] for row in cur} - - config = {} - - for key, data in CONFIG_DEFAULTS.items(): - try: - config[key] = deserialize(key, db_config[key]) - - except KeyError: - if key == 'schema-version': - config[key] = 0 - - else: - config[key] = data[1] - - return config + return ConfigData.from_rows(tuple(cur.all())) def put_config(self, key: str, value: Any) -> Any: - if key not in CONFIG_DEFAULTS: - raise KeyError(key) + field = ConfigData.FIELD(key) + key = field.name.replace('_', '-') - if key == 'private-key': + if key == 'private_key': self.app.signer = value - elif key == 'log-level': + elif key == 'log_level': value = logging.LogLevel.parse(value) logging.set_level(value) - elif key == 'whitelist-enabled': + elif key in {'approval-required', 'whitelist-enabled'}: value = boolean(value) elif key == 'theme': if value not in THEMES: raise ValueError(f'"{value}" is not a valid theme') + data = ConfigData() + data.set(key, value) + params = { 'key': key, - 'value': serialize(key, value) if value is not None else None, - 'type': get_default_type(key) + 'value': data.get(key, serialize = True), + 'type': 'LogLevel' if field.type == 'logging.LogLevel' else field.type } with self.run('put-config', params): - return value + pass def get_inbox(self, value: str) -> Row: with self.run('get-inbox', {'value': value}) as cur: - return cur.one() + return cur.one() # type: ignore - def get_inboxes(self) -> tuple[Row]: + def get_inboxes(self) -> Sequence[Row]: with self.execute("SELECT * FROM inboxes WHERE accepted = 1") as cur: return tuple(cur.all()) @@ -137,7 +117,7 @@ class Connection(SqlConnection): software: str | None = None, accepted: bool = True) -> Row: - params = { + params: dict[str, Any] = { 'inbox': inbox, 'actor': actor, 'followid': followid, @@ -153,14 +133,14 @@ class Connection(SqlConnection): params['created'] = datetime.now(tz = timezone.utc) with self.run('put-inbox', params) as cur: - return cur.one() + return cur.one() # type: ignore for key, value in tuple(params.items()): if value is None: del params[key] with self.update('inboxes', params, domain = domain) as cur: - return cur.one() + return cur.one() # type: ignore def del_inbox(self, value: str) -> bool: @@ -179,7 +159,7 @@ class Connection(SqlConnection): return row - def get_requests(self) -> tuple[Row]: + def get_requests(self) -> Sequence[Row]: with self.execute('SELECT * FROM inboxes WHERE accepted = 0') as cur: return tuple(cur.all()) @@ -197,17 +177,17 @@ class Connection(SqlConnection): } with self.run('put-inbox-accept', params) as cur: - return cur.one() + return cur.one() # type: ignore def get_user(self, value: str) -> Row: with self.run('get-user', {'value': value}) as cur: - return cur.one() + return cur.one() # type: ignore def get_user_by_token(self, code: str) -> Row: with self.run('get-user-by-token', {'code': code}) as cur: - return cur.one() + return cur.one() # type: ignore def put_user(self, username: str, password: str, handle: str | None = None) -> Row: @@ -219,7 +199,7 @@ class Connection(SqlConnection): } with self.run('put-user', data) as cur: - return cur.one() + return cur.one() # type: ignore def del_user(self, username: str) -> None: @@ -234,7 +214,7 @@ class Connection(SqlConnection): def get_token(self, code: str) -> Row: with self.run('get-token', {'code': code}) as cur: - return cur.one() + return cur.one() # type: ignore def put_token(self, username: str) -> Row: @@ -245,7 +225,7 @@ class Connection(SqlConnection): } with self.run('put-token', data) as cur: - return cur.one() + return cur.one() # type: ignore def del_token(self, code: str) -> None: @@ -258,7 +238,7 @@ class Connection(SqlConnection): domain = urlparse(domain).netloc with self.run('get-domain-ban', {'domain': domain}) as cur: - return cur.one() + return cur.one() # type: ignore def put_domain_ban(self, @@ -274,7 +254,7 @@ class Connection(SqlConnection): } with self.run('put-domain-ban', params) as cur: - return cur.one() + return cur.one() # type: ignore def update_domain_ban(self, @@ -313,7 +293,7 @@ class Connection(SqlConnection): def get_software_ban(self, name: str) -> Row: with self.run('get-software-ban', {'name': name}) as cur: - return cur.one() + return cur.one() # type: ignore def put_software_ban(self, @@ -329,7 +309,7 @@ class Connection(SqlConnection): } with self.run('put-software-ban', params) as cur: - return cur.one() + return cur.one() # type: ignore def update_software_ban(self, @@ -368,7 +348,7 @@ class Connection(SqlConnection): def get_domain_whitelist(self, domain: str) -> Row: with self.run('get-domain-whitelist', {'domain': domain}) as cur: - return cur.one() + return cur.one() # type: ignore def put_domain_whitelist(self, domain: str) -> Row: @@ -378,7 +358,7 @@ class Connection(SqlConnection): } with self.run('put-domain-whitelist', params) as cur: - return cur.one() + return cur.one() # type: ignore def del_domain_whitelist(self, domain: str) -> bool: diff --git a/relay/database/schema.py b/relay/database/schema.py index d965348..ba39ed2 100644 --- a/relay/database/schema.py +++ b/relay/database/schema.py @@ -2,12 +2,13 @@ from __future__ import annotations import typing -from bsql import Column, Connection, Table, Tables +from bsql import Column, Table, Tables -from .config import get_default_value +from .config import ConfigData if typing.TYPE_CHECKING: from collections.abc import Callable + from .connection import Connection VERSIONS: dict[int, Callable] = {} @@ -71,7 +72,7 @@ def migration(func: Callable) -> Callable: def migrate_0(conn: Connection) -> None: conn.create_tables() - conn.put_config('schema-version', get_default_value('schema-version')) + conn.put_config('schema-version', ConfigData.DEFAULT('schema-version')) @migration diff --git a/relay/dev.py b/relay/dev.py index 6407068..0f89f73 100644 --- a/relay/dev.py +++ b/relay/dev.py @@ -15,7 +15,7 @@ try: from watchdog.events import PatternMatchingEventHandler except ImportError: - class PatternMatchingEventHandler: + class PatternMatchingEventHandler: # type: ignore pass @@ -45,9 +45,15 @@ def cli_install(): @cli.command('lint') @click.argument('path', required = False, default = 'relay') -def cli_lint(path): - subprocess.run([sys.executable, '-m', 'flake8', path], check = False) - subprocess.run([sys.executable, '-m', 'pylint', path], check = False) +@click.option('--strict', '-s', is_flag = True, help = 'Enable strict mode for mypy') +def cli_lint(path: str, strict: bool) -> None: + cmd: list[str] = [sys.executable, '-m', 'mypy'] + + if strict: + cmd.append('--strict') + + subprocess.run([*cmd, path], check = False) + subprocess.run([sys.executable, '-m', 'flake8', path]) @cli.command('build') @@ -146,7 +152,6 @@ class WatchHandler(PatternMatchingEventHandler): self.kill_proc() - # pylint: disable=consider-using-with self.proc = subprocess.Popen(self.cmd, stdin = subprocess.PIPE) self.last_restart = timestamp diff --git a/relay/frontend/base.haml b/relay/frontend/base.haml index e1da33c..9d08ad7 100644 --- a/relay/frontend/base.haml +++ b/relay/frontend/base.haml @@ -11,7 +11,7 @@ %title << {{config.name}}: {{page}} %meta(charset="UTF-8") %meta(name="viewport" content="width=device-width, initial-scale=1") - %link(rel="stylesheet" type="text/css" href="/theme/{{theme_name}}.css") + %link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css") %link(rel="stylesheet" type="text/css" href="/style.css") -block head diff --git a/relay/frontend/page/admin-config.haml b/relay/frontend/page/admin-config.haml index ef77d3f..08c16c7 100644 --- a/relay/frontend/page/admin-config.haml +++ b/relay/frontend/page/admin-config.haml @@ -8,18 +8,18 @@ %input(id = "name" name="name" placeholder="Relay Name" value="{{config.name or ''}}") %label(for="description") << Description - %textarea(id="description" name="note" value="{{config.note}}") << {{config.note}} + %textarea(id="description" name="note" value="{{config.note or ''}}") << {{config.note}} %label(for="theme") << Color Theme =func.new_select("theme", config.theme, themes) %label(for="log-level") << Log Level - =func.new_select("log-level", config["log-level"].name, levels) + =func.new_select("log-level", config.log_level.name, levels) %label(for="whitelist-enabled") << Whitelist - =func.new_checkbox("whitelist-enabled", config["whitelist-enabled"]) + =func.new_checkbox("whitelist-enabled", config.whitelist_enabled) %label(for="approval-required") << Approval Required - =func.new_checkbox("approval-required", config["approval-required"]) + =func.new_checkbox("approval-required", config.approval_required) %input(type="submit" value="Save") diff --git a/relay/frontend/page/home.haml b/relay/frontend/page/home.haml index 2aa745f..b59e5b5 100644 --- a/relay/frontend/page/home.haml +++ b/relay/frontend/page/home.haml @@ -1,8 +1,9 @@ -extends "base.haml" -set page = "Home" -block content - .section - -markdown -> =config.note + -if config.note + .section + -markdown -> =config.note .section %p @@ -12,12 +13,12 @@ You may subscribe to this relay with the address: %a(href="https://{{domain}}/actor") << https://{{domain}}/actor - -if config["approval-required"] + -if config.approval_required %p.section.message Follow requests require approval. You will need to wait for an admin to accept or deny your request. - -elif config["whitelist-enabled"] + -elif config.whitelist_enabled %p.section.message The whitelist is enabled on this instance. Ask the admin to add your instance before joining. diff --git a/relay/frontend/style.css b/relay/frontend/style.css index 4104519..1e7b90e 100644 --- a/relay/frontend/style.css +++ b/relay/frontend/style.css @@ -28,6 +28,14 @@ form input[type="submit"] { margin: 0 auto; } +legend { + background-color: var(--section-background); + padding: 5px; + border: 1px solid var(--border); + border-radius: 5px; + font-size: 10pt; +} + p { line-height: 1em; margin: 0px; diff --git a/relay/http_client.py b/relay/http_client.py index eb84362..b51caf8 100644 --- a/relay/http_client.py +++ b/relay/http_client.py @@ -7,7 +7,7 @@ import typing from aiohttp import ClientSession, ClientTimeout, TCPConnector from aiohttp.client_exceptions import ClientConnectionError, ClientSSLError from asyncio.exceptions import TimeoutError as AsyncTimeoutError -from aputils.objects import Nodeinfo, WellKnownNodeinfo +from aputils import JsonBase, Nodeinfo, WellKnownNodeinfo from json.decoder import JSONDecodeError from urllib.parse import urlparse @@ -17,12 +17,13 @@ from .misc import MIMETYPES, Message, get_app if typing.TYPE_CHECKING: from aputils import Signer - from tinysql import Row + from bsql import Row from typing import Any from .application import Application from .cache import Cache +T = typing.TypeVar('T', bound = JsonBase) HEADERS = { 'Accept': f'{MIMETYPES["activity"]}, {MIMETYPES["json"]};q=0.9', 'User-Agent': f'ActivityRelay/{__version__}' @@ -33,12 +34,12 @@ class HttpClient: def __init__(self, limit: int = 100, timeout: int = 10): self.limit = limit self.timeout = timeout - self._conn = None - self._session = None + self._conn: TCPConnector | None = None + self._session: ClientSession | None = None async def __aenter__(self) -> HttpClient: - await self.open() + self.open() return self @@ -61,7 +62,7 @@ class HttpClient: return self.app.signer - async def open(self) -> None: + def open(self) -> None: if self._session: return @@ -79,23 +80,19 @@ class HttpClient: async def close(self) -> None: - if not self._session: - return + if self._session: + await self._session.close() - await self._session.close() - await self._conn.close() + if self._conn: + await self._conn.close() self._conn = None self._session = None - async def get(self, # pylint: disable=too-many-branches - url: str, - sign_headers: bool = False, - loads: callable = json.loads, - force: bool = False) -> dict | None: - - await self.open() + async def _get(self, url: str, sign_headers: bool, force: bool) -> dict[str, Any] | None: + if not self._session: + raise RuntimeError('Client not open') try: url, _ = url.split('#', 1) @@ -105,10 +102,8 @@ class HttpClient: if not force: try: - item = self.cache.get('request', url) - - if not item.older_than(48): - return loads(item.value) + if not (item := self.cache.get('request', url)).older_than(48): + return json.loads(item.value) except KeyError: logging.verbose('No cached data for url: %s', url) @@ -121,23 +116,22 @@ class HttpClient: try: logging.debug('Fetching resource: %s', url) - async with self._session.get(url, headers=headers) as resp: - ## Not expecting a response with 202s, so just return + async with self._session.get(url, headers = headers) as resp: + # Not expecting a response with 202s, so just return if resp.status == 202: return None - data = await resp.read() + data = await resp.text() if resp.status != 200: logging.verbose('Received error when requesting %s: %i', url, resp.status) logging.debug(data) return None - message = loads(data) - self.cache.set('request', url, data.decode('utf-8'), 'str') - logging.debug('%s >> resp %s', url, json.dumps(message, indent = 4)) + self.cache.set('request', url, data, 'str') + logging.debug('%s >> resp %s', url, json.dumps(json.loads(data), indent = 4)) - return message + return json.loads(data) except JSONDecodeError: logging.verbose('Failed to parse JSON') @@ -155,17 +149,26 @@ class HttpClient: return None - async def post(self, url: str, message: Message, instance: Row | None = None) -> None: - await self.open() + async def get(self, url: str, sign_headers: bool, cls: type[T], force: bool = False) -> T | None: + if not issubclass(cls, JsonBase): + raise TypeError('cls must be a sub-class of "aputils.JsonBase"') - ## Using the old algo by default is probably a better idea right now - # pylint: disable=consider-ternary-expression + if (data := (await self._get(url, sign_headers, force))) is None: + return None + + return cls.parse(data) + + + async def post(self, url: str, message: Message, instance: Row | None = None) -> None: + if not self._session: + raise RuntimeError('Client not open') + + # Using the old algo by default is probably a better idea right now if instance and instance['software'] in {'mastodon'}: algorithm = 'hs2019' else: algorithm = 'original' - # pylint: enable=consider-ternary-expression headers = {'Content-Type': 'application/activity+json'} headers.update(get_app().signer.sign_headers('POST', url, message, algorithm=algorithm)) @@ -173,7 +176,7 @@ class HttpClient: try: logging.verbose('Sending "%s" to %s', message.type, url) - async with self._session.post(url, headers=headers, data=message.to_json()) as resp: + async with self._session.post(url, headers = headers, data = message.to_json()) as resp: # Not expecting a response, so just return if resp.status in {200, 202}: logging.verbose('Successfully sent "%s" to %s', message.type, url) @@ -198,10 +201,11 @@ class HttpClient: nodeinfo_url = None wk_nodeinfo = await self.get( f'https://{domain}/.well-known/nodeinfo', - loads = WellKnownNodeinfo.parse + False, + WellKnownNodeinfo ) - if not wk_nodeinfo: + if wk_nodeinfo is None: logging.verbose('Failed to fetch well-known nodeinfo url for %s', domain) return None @@ -212,14 +216,14 @@ class HttpClient: except KeyError: pass - if not nodeinfo_url: + if nodeinfo_url is None: logging.verbose('Failed to fetch nodeinfo url for %s', domain) return None - return await self.get(nodeinfo_url, loads = Nodeinfo.parse) or None + return await self.get(nodeinfo_url, False, Nodeinfo) -async def get(*args: Any, **kwargs: Any) -> Message | dict | None: +async def get(*args: Any, **kwargs: Any) -> Any: async with HttpClient() as client: return await client.get(*args, **kwargs) diff --git a/relay/logger.py b/relay/logger.py index 8aff62d..ca9d76d 100644 --- a/relay/logger.py +++ b/relay/logger.py @@ -9,7 +9,7 @@ from pathlib import Path if typing.TYPE_CHECKING: from collections.abc import Callable - from typing import Any + from typing import Any, Self class LogLevel(IntEnum): @@ -26,7 +26,13 @@ class LogLevel(IntEnum): @classmethod - def parse(cls: type[IntEnum], data: object) -> IntEnum: + def parse(cls: type[Self], data: Any) -> Self: + try: + data = int(data) + + except ValueError: + pass + if isinstance(data, cls): return data @@ -70,15 +76,15 @@ error: Callable = logging.error critical: Callable = logging.critical -env_log_level = os.environ.get('LOG_LEVEL', 'INFO').upper() +env_log_level: Path | str | None = os.environ.get('LOG_LEVEL', 'INFO').upper() try: - env_log_file = Path(os.environ['LOG_FILE']).expanduser().resolve() + env_log_file: Path | None = Path(os.environ['LOG_FILE']).expanduser().resolve() except KeyError: env_log_file = None -handlers = [logging.StreamHandler()] +handlers: list[Any] = [logging.StreamHandler()] if env_log_file: handlers.append(logging.FileHandler(env_log_file)) diff --git a/relay/manage.py b/relay/manage.py index cb95748..d768284 100644 --- a/relay/manage.py +++ b/relay/manage.py @@ -21,19 +21,10 @@ from .database import RELAY_SOFTWARE, get_database from .misc import ACTOR_FORMATS, SOFTWARE, IS_DOCKER, Message if typing.TYPE_CHECKING: - from tinysql import Row + from bsql import Row from typing import Any -# pylint: disable=unsubscriptable-object,unsupported-assignment-operation - - -CONFIG_IGNORE = ( - 'schema-version', - 'private-key' -) - - def check_alphanumeric(text: str) -> str: if not text.isalnum(): raise click.BadParameter('String not alphanumeric') @@ -50,7 +41,7 @@ def cli(ctx: click.Context, config: str | None) -> None: if not ctx.invoked_subcommand: if ctx.obj.config.domain.endswith('example.com'): - cli_setup.callback() + cli_setup.callback() # type: ignore else: click.echo( @@ -58,7 +49,7 @@ def cli(ctx: click.Context, config: str | None) -> None: 'future.' ) - cli_run.callback() + cli_run.callback() # type: ignore @cli.command('setup') @@ -184,7 +175,7 @@ def cli_setup(ctx: click.Context) -> None: conn.put_config(key, value) if not IS_DOCKER and click.confirm('Relay all setup! Would you like to run it now?'): - cli_run.callback() + cli_run.callback() # type: ignore @cli.command('run') @@ -257,7 +248,7 @@ def cli_convert(ctx: click.Context, old_config: str) -> None: conn.put_config('note', config['note']) conn.put_config('whitelist-enabled', config['whitelist_enabled']) - with click.progressbar( + with click.progressbar( # type: ignore database['relay-list'].values(), label = 'Inboxes'.ljust(15), width = 0 @@ -281,7 +272,7 @@ def cli_convert(ctx: click.Context, old_config: str) -> None: software = inbox['software'] ) - with click.progressbar( + with click.progressbar( # type: ignore config['blocked_software'], label = 'Banned software'.ljust(15), width = 0 @@ -293,7 +284,7 @@ def cli_convert(ctx: click.Context, old_config: str) -> None: reason = 'relay' if software in RELAY_SOFTWARE else None ) - with click.progressbar( + with click.progressbar( # type: ignore config['blocked_instances'], label = 'Banned domains'.ljust(15), width = 0 @@ -302,7 +293,7 @@ def cli_convert(ctx: click.Context, old_config: str) -> None: for domain in banned_software: conn.put_domain_ban(domain) - with click.progressbar( + with click.progressbar( # type: ignore config['whitelist'], label = 'Whitelist'.ljust(15), width = 0 @@ -339,10 +330,17 @@ def cli_config_list(ctx: click.Context) -> None: click.echo('Relay Config:') with ctx.obj.database.session() as conn: - for key, value in conn.get_config_all().items(): - if key not in CONFIG_IGNORE: - key = f'{key}:'.ljust(20) - click.echo(f'- {key} {repr(value)}') + config = conn.get_config_all() + + for key, value in config.to_dict().items(): + if key in type(config).SYSTEM_KEYS(): + continue + + if key == 'log-level': + value = value.name + + key_str = f'{key}:'.ljust(20) + click.echo(f'- {key_str} {repr(value)}') @cli_config.command('set') @@ -520,7 +518,7 @@ def cli_inbox_follow(ctx: click.Context, actor: str) -> None: def cli_inbox_unfollow(ctx: click.Context, actor: str) -> None: 'Unfollow an actor (Relay must be running)' - inbox_data: Row = None + inbox_data: Row | None = None with ctx.obj.database.session() as conn: if conn.get_domain_ban(actor): @@ -540,6 +538,11 @@ def cli_inbox_unfollow(ctx: click.Context, actor: str) -> None: actor = f'https://{actor}/actor' actor_data = asyncio.run(http.get(actor, sign_headers = True)) + + if not actor_data: + click.echo("Failed to fetch actor") + return + inbox = actor_data.shared_inbox message = Message.new_unfollow( host = ctx.obj.config.domain, @@ -967,7 +970,6 @@ def cli_whitelist_import(ctx: click.Context) -> None: def main() -> None: - # pylint: disable=no-value-for-parameter cli(prog_name='relay') diff --git a/relay/misc.py b/relay/misc.py index 9fe4b83..cb901bc 100644 --- a/relay/misc.py +++ b/relay/misc.py @@ -8,21 +8,31 @@ import typing from aiohttp.web import Response as AiohttpResponse from datetime import datetime +from pathlib import Path from uuid import uuid4 try: from importlib.resources import files as pkgfiles except ImportError: - from importlib_resources import files as pkgfiles + from importlib_resources import files as pkgfiles # type: ignore if typing.TYPE_CHECKING: - from pathlib import Path - from typing import Any + from typing import Any, Self from .application import Application +T = typing.TypeVar('T') +ResponseType = typing.TypedDict('ResponseType', { + 'status': int, + 'headers': dict[str, typing.Any] | None, + 'content_type': str, + 'body': bytes | None, + 'text': str | None +}) + IS_DOCKER = bool(os.environ.get('DOCKER_RUNNING')) + MIMETYPES = { 'activity': 'application/activity+json', 'css': 'text/css', @@ -92,7 +102,7 @@ def check_open_port(host: str, port: int) -> bool: def get_app() -> Application: - from .application import Application # pylint: disable=import-outside-toplevel + from .application import Application if not Application.DEFAULT: raise ValueError('No default application set') @@ -101,7 +111,7 @@ def get_app() -> Application: def get_resource(path: str) -> Path: - return pkgfiles('relay').joinpath(path) + return Path(str(pkgfiles('relay'))).joinpath(path) class JsonEncoder(json.JSONEncoder): @@ -114,11 +124,11 @@ class JsonEncoder(json.JSONEncoder): class Message(aputils.Message): @classmethod - def new_actor(cls: type[Message], # pylint: disable=arguments-differ + def new_actor(cls: type[Self], # type: ignore host: str, pubkey: str, description: str | None = None, - approves: bool = False) -> Message: + approves: bool = False) -> Self: return cls({ '@context': 'https://www.w3.org/ns/activitystreams', @@ -144,7 +154,7 @@ class Message(aputils.Message): @classmethod - def new_announce(cls: type[Message], host: str, obj: str) -> Message: + def new_announce(cls: type[Self], host: str, obj: str | dict[str, Any]) -> Self: return cls({ '@context': 'https://www.w3.org/ns/activitystreams', 'id': f'https://{host}/activities/{uuid4()}', @@ -156,7 +166,7 @@ class Message(aputils.Message): @classmethod - def new_follow(cls: type[Message], host: str, actor: str) -> Message: + def new_follow(cls: type[Self], host: str, actor: str) -> Self: return cls({ '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Follow', @@ -168,7 +178,7 @@ class Message(aputils.Message): @classmethod - def new_unfollow(cls: type[Message], host: str, actor: str, follow: str) -> Message: + def new_unfollow(cls: type[Self], host: str, actor: str, follow: dict[str, str]) -> Self: return cls({ '@context': 'https://www.w3.org/ns/activitystreams', 'id': f'https://{host}/activities/{uuid4()}', @@ -180,12 +190,7 @@ class Message(aputils.Message): @classmethod - def new_response(cls: type[Message], - host: str, - actor: str, - followid: str, - accept: bool) -> Message: - + def new_response(cls: type[Self], host: str, actor: str, followid: str, accept: bool) -> Self: return cls({ '@context': 'https://www.w3.org/ns/activitystreams', 'id': f'https://{host}/activities/{uuid4()}', @@ -208,16 +213,18 @@ class Response(AiohttpResponse): @classmethod - def new(cls: type[Response], - body: str | bytes | dict = '', + def new(cls: type[Self], + body: str | bytes | dict | tuple | list | set = '', status: int = 200, headers: dict[str, str] | None = None, - ctype: str = 'text') -> Response: + ctype: str = 'text') -> Self: - kwargs = { + kwargs: ResponseType = { 'status': status, 'headers': headers, - 'content_type': MIMETYPES[ctype] + 'content_type': MIMETYPES[ctype], + 'body': None, + 'text': None } if isinstance(body, bytes): @@ -233,10 +240,10 @@ class Response(AiohttpResponse): @classmethod - def new_error(cls: type[Response], + def new_error(cls: type[Self], status: int, body: str | bytes | dict, - ctype: str = 'text') -> Response: + ctype: str = 'text') -> Self: if ctype == 'json': body = {'error': body} @@ -245,14 +252,14 @@ class Response(AiohttpResponse): @classmethod - def new_redir(cls: type[Response], path: str) -> Response: + def new_redir(cls: type[Self], path: str) -> Self: body = f'Redirect to {path}' return cls.new(body, 302, {'Location': path}) @property def location(self) -> str: - return self.headers.get('Location') + return self.headers.get('Location', '') @location.setter diff --git a/relay/processors.py b/relay/processors.py index 4d19bf9..5d2634b 100644 --- a/relay/processors.py +++ b/relay/processors.py @@ -7,10 +7,10 @@ from .database import Connection from .misc import Message if typing.TYPE_CHECKING: - from .views import ActorView + from .views.activitypub import ActorView -def person_check(actor: str, software: str) -> bool: +def person_check(actor: Message, software: str | None) -> bool: # pleroma and akkoma may use Person for the actor type for some reason # akkoma changed this in 3.6.0 if software in {'akkoma', 'pleroma'} and actor.id == f'https://{actor.domain}/relay': @@ -65,7 +65,7 @@ async def handle_follow(view: ActorView, conn: Connection) -> None: config = conn.get_config_all() # reject if software used by actor is banned - if conn.get_software_ban(software): + if software and conn.get_software_ban(software): logging.verbose('Rejected banned actor: %s', view.actor.id) view.app.push_message( @@ -75,7 +75,8 @@ async def handle_follow(view: ActorView, conn: Connection) -> None: actor = view.actor.id, followid = view.message.id, accept = False - ) + ), + view.instance ) logging.verbose( @@ -86,7 +87,7 @@ async def handle_follow(view: ActorView, conn: Connection) -> None: return - ## reject if the actor is not an instance actor + # reject if the actor is not an instance actor if person_check(view.actor, software): logging.verbose('Non-application actor tried to follow: %s', view.actor.id) @@ -105,7 +106,7 @@ async def handle_follow(view: ActorView, conn: Connection) -> None: if not conn.get_domain_whitelist(view.actor.domain): # 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) with conn.transaction(): @@ -121,7 +122,7 @@ async def handle_follow(view: ActorView, conn: Connection) -> None: return # 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) view.app.push_message( @@ -131,7 +132,8 @@ async def handle_follow(view: ActorView, conn: Connection) -> None: actor = view.actor.id, followid = view.message.id, accept = False - ) + ), + view.instance ) return @@ -171,7 +173,7 @@ async def handle_follow(view: ActorView, conn: Connection) -> None: async def handle_undo(view: ActorView, conn: Connection) -> None: - ## If the object is not a Follow, forward it + # If the object is not a Follow, forward it if view.message.object['type'] != 'Follow': await handle_forward(view, conn) return @@ -185,7 +187,7 @@ async def handle_undo(view: ActorView, conn: Connection) -> None: logging.verbose( 'Failed to delete "%s" with follow ID "%s"', view.actor.id, - view.message.object['id'] + view.message.object_id ) view.app.push_message( diff --git a/relay/template.py b/relay/template.py index e951424..1335fab 100644 --- a/relay/template.py +++ b/relay/template.py @@ -52,34 +52,37 @@ class Template(Environment): 'domain': self.app.config.domain, 'version': __version__, 'config': config, - 'theme_name': config['theme'] or 'Default', **(context or {}) } return self.get_template(path).render(new_context) + def render_markdown(self, text: str) -> str: + return self._render_markdown(text) # type: ignore + + class MarkdownExtension(Extension): tags = {'markdown'} - extensions = { + extensions = ( 'attr_list', 'smarty', 'tables' - } + ) def __init__(self, environment: Environment): Extension.__init__(self, environment) self._markdown = Markdown(extensions = MarkdownExtension.extensions) environment.extend( - render_markdown = self._render_markdown + _render_markdown = self._render_markdown ) def parse(self, parser: Parser) -> Node | list[Node]: lineno = next(parser.stream).lineno body = parser.parse_statements( - ['name:endmarkdown'], + ('name:endmarkdown',), drop_needle = True ) @@ -88,5 +91,5 @@ class MarkdownExtension(Extension): def _render_markdown(self, caller: Callable[[], str] | str) -> str: - text = caller() if isinstance(caller, Callable) else caller + text = caller if isinstance(caller, str) else caller() return self._markdown.convert(textwrap.dedent(text.strip('\n'))) diff --git a/relay/views/__init__.py b/relay/views/__init__.py index 6366592..25a7a62 100644 --- a/relay/views/__init__.py +++ b/relay/views/__init__.py @@ -1,4 +1,4 @@ from __future__ import annotations from . import activitypub, api, frontend, misc -from .base import VIEWS +from .base import VIEWS, View diff --git a/relay/views/activitypub.py b/relay/views/activitypub.py index 6e392db..68f1c23 100644 --- a/relay/views/activitypub.py +++ b/relay/views/activitypub.py @@ -12,22 +12,21 @@ from ..processors import run_processor if typing.TYPE_CHECKING: from aiohttp.web import Request - from tinysql import Row + from bsql import Row -# pylint: disable=unused-argument - @register_route('/actor', '/inbox') class ActorView(View): + signature: aputils.Signature + message: Message + actor: Message + instancce: Row + signer: aputils.Signer + + def __init__(self, request: Request): View.__init__(self, request) - self.signature: aputils.Signature = None - self.message: Message = None - self.actor: Message = None - self.instance: Row = None - self.signer: aputils.Signer = None - async def get(self, request: Request) -> Response: with self.database.session(False) as conn: @@ -36,8 +35,8 @@ class ActorView(View): 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'] + description = self.app.template.render_markdown(config.note), + approves = config.approval_required ) return Response.new(data, ctype='activity') @@ -50,12 +49,12 @@ class ActorView(View): with self.database.session() as conn: self.instance = conn.get_inbox(self.actor.shared_inbox) - ## reject if actor is banned + # reject if actor is banned if conn.get_domain_ban(self.actor.domain): logging.verbose('Ignored request from banned actor: %s', self.actor.id) return Response.new_error(403, 'access denied', 'json') - ## reject if activity type isn't 'Follow' and the actor isn't following + # 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', @@ -79,28 +78,26 @@ class ActorView(View): return Response.new_error(400, 'missing signature header', 'json') try: - self.message = await self.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 inbox message') return Response.new_error(400, 'failed to parse message', 'json') - if self.message is None: + if message is None: logging.verbose('empty message') return Response.new_error(400, 'missing message', 'json') + self.message = message + if 'actor' not in self.message: logging.verbose('actor not in message') return Response.new_error(400, 'no actor in message', 'json') - self.actor = await self.client.get( - self.signature.keyid, - sign_headers = True, - loads = Message.parse - ) + actor: Message | None = await self.client.get(self.signature.keyid, True, Message) - if not self.actor: + if actor is None: # ld signatures aren't handled atm, so just ignore it if self.message.type == 'Delete': logging.verbose('Instance sent a delete which cannot be handled') @@ -109,6 +106,8 @@ class ActorView(View): logging.verbose(f'Failed to fetch actor: {self.signature.keyid}') return Response.new_error(400, 'failed to fetch actor', 'json') + self.actor = actor + try: self.signer = self.actor.signer @@ -123,6 +122,8 @@ class ActorView(View): logging.verbose('signature validation failed for "%s": %s', self.actor.id, e) return Response.new_error(401, str(e), 'json') + return None + def validate_signature(self, body: bytes) -> None: headers = {key.lower(): value for key, value in self.request.headers.items()} @@ -150,7 +151,6 @@ class ActorView(View): headers["(created)"] = self.signature.created headers["(expires)"] = self.signature.expires - # pylint: disable=protected-access if not self.signer._validate_signature(headers, self.signature): raise aputils.SignatureFailureError("Signature does not match") diff --git a/relay/views/api.py b/relay/views/api.py index 5a12e95..0435555 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -9,23 +9,15 @@ 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 +from ..database import ConfigData +from ..misc import Message, Response, get_app if typing.TYPE_CHECKING: from aiohttp.web import Request - from collections.abc import Coroutine + from collections.abc import Callable, Sequence -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]] = ( +PUBLIC_API_PATHS: Sequence[tuple[str, str]] = ( ('GET', '/api/v1/relay'), ('GET', '/api/v1/instance'), ('POST', '/api/v1/token') @@ -40,11 +32,11 @@ def check_api_path(method: str, path: str) -> bool: @web.middleware -async def handle_api_path(request: web.Request, handler: Coroutine) -> web.Response: +async def handle_api_path(request: Request, handler: Callable) -> Response: try: request['token'] = request.headers['Authorization'].replace('Bearer', '').strip() - with request.app.database.session() as conn: + with get_app().database.session() as conn: request['user'] = conn.get_user_by_token(request['token']) except (KeyError, ValueError): @@ -61,8 +53,6 @@ async def handle_api_path(request: web.Request, handler: Coroutine) -> web.Respo 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: @@ -102,14 +92,14 @@ class RelayInfo(View): async def get(self, request: Request) -> Response: with self.database.session() as conn: config = conn.get_config_all() - inboxes = [row['domain'] for row in conn.execute('SELECT * FROM inboxes')] + inboxes = [row['domain'] for row in conn.get_inboxes()] data = { 'domain': self.config.domain, - 'name': config['name'], - 'description': config['note'], + 'name': config.name, + 'description': config.note, 'version': __version__, - 'whitelist_enabled': config['whitelist-enabled'], + 'whitelist_enabled': config.whitelist_enabled, 'email': None, 'admin': None, 'icon': None, @@ -122,12 +112,17 @@ class RelayInfo(View): @register_route('/api/v1/config') class Config(View): async def get(self, request: Request) -> Response: - with self.database.session() as conn: - data = conn.get_config_all() - data['log-level'] = data['log-level'].name + data = {} - for key in CONFIG_IGNORE: - del data[key] + with self.database.session() as conn: + for key, value in conn.get_config_all().to_dict().items(): + if key in ConfigData.SYSTEM_KEYS(): + continue + + if key == 'log-level': + value = value.name + + data[key] = value return Response.new(data, ctype = 'json') @@ -138,7 +133,7 @@ class Config(View): if isinstance(data, Response): return data - if data['key'] not in CONFIG_VALID: + if data['key'] not in ConfigData.USER_KEYS(): return Response.new_error(400, 'Invalid key', 'json') with self.database.session() as conn: @@ -153,11 +148,11 @@ class Config(View): if isinstance(data, Response): return data - if data['key'] not in CONFIG_VALID: + if data['key'] not in ConfigData.USER_KEYS(): return Response.new_error(400, 'Invalid key', 'json') with self.database.session() as conn: - conn.put_config(data['key'], CONFIG_DEFAULTS[data['key']][1]) + conn.put_config(data['key'], ConfigData.DEFAULT(data['key'])) return Response.new({'message': 'Updated config'}, ctype = 'json') @@ -184,19 +179,13 @@ class Inbox(View): 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 - ) + actor_data: Message | None = await self.client.get(data['actor'], True, Message) - data['inbox'] = actor_data.shared_inbox - - except Exception as e: - logging.error('Failed to fetch actor: %s', str(e)) + if actor_data is None: return Response.new_error(500, 'Failed to fetch actor', 'json') + data['inbox'] = actor_data.shared_inbox + row = conn.put_inbox(**data) return Response.new(row, ctype = 'json') diff --git a/relay/views/base.py b/relay/views/base.py index f568525..d293f62 100644 --- a/relay/views/base.py +++ b/relay/views/base.py @@ -8,11 +8,11 @@ from aiohttp.web import HTTPMethodNotAllowed from functools import cached_property from json.decoder import JSONDecodeError -from ..misc import Response +from ..misc import Response, get_app if typing.TYPE_CHECKING: from aiohttp.web import Request - from collections.abc import Callable, Coroutine, Generator + from collections.abc import Callable, Generator, Sequence, Mapping from bsql import Database from typing import Any, Self from ..application import Application @@ -22,20 +22,24 @@ if typing.TYPE_CHECKING: from ..template import Template -VIEWS = [] +VIEWS: list[tuple[str, type[View]]] = [] + + +def convert_data(data: Mapping[str, Any]) -> dict[str, str]: + return {key: str(value) for key, value in data.items()} def register_route(*paths: str) -> Callable: - def wrapper(view: View) -> View: + def wrapper(view: type[View]) -> type[View]: for path in paths: - VIEWS.append([path, view]) + VIEWS.append((path, view)) return view return wrapper class View(AbstractView): - def __await__(self) -> Generator[Response]: + def __await__(self) -> Generator[Any, None, Response]: if self.request.method not in METHODS: raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods) @@ -46,22 +50,22 @@ class View(AbstractView): @classmethod - async def run(cls: type[Self], method: str, request: Request, **kwargs: Any) -> Self: + async def run(cls: type[Self], method: str, request: Request, **kwargs: Any) -> Response: view = cls(request) return await view.handlers[method](request, **kwargs) - async def _run_handler(self, handler: Coroutine, **kwargs: Any) -> Response: + async def _run_handler(self, handler: Callable[..., Any], **kwargs: Any) -> Response: return await handler(self.request, **self.request.match_info, **kwargs) @cached_property - def allowed_methods(self) -> tuple[str]: + def allowed_methods(self) -> Sequence[str]: return tuple(self.handlers.keys()) @cached_property - def handlers(self) -> dict[str, Coroutine]: + def handlers(self) -> dict[str, Callable[..., Any]]: data = {} for method in METHODS: @@ -74,10 +78,9 @@ class View(AbstractView): return data - # app components @property def app(self) -> Application: - return self.request.app + return get_app() @property @@ -110,17 +113,17 @@ class View(AbstractView): 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() + post_data = convert_data(await self.request.post()) elif self.request.content_type == 'application/json': try: - post_data = await self.request.json() + post_data = convert_data(await self.request.json()) except JSONDecodeError: return Response.new_error(400, 'Invalid JSON data', 'json') else: - post_data = self.request.query + post_data = convert_data(await self.request.query) # type: ignore data = {} @@ -132,6 +135,6 @@ class View(AbstractView): return Response.new_error(400, f'Missing {str(e)} pararmeter', 'json') for key in optional: - data[key] = post_data.get(key) + data[key] = post_data.get(key, '') return data diff --git a/relay/views/frontend.py b/relay/views/frontend.py index cd43a65..4e10e83 100644 --- a/relay/views/frontend.py +++ b/relay/views/frontend.py @@ -8,36 +8,30 @@ from urllib.parse import urlparse from .base import View, register_route -from ..database import CONFIG_DEFAULTS, THEMES +from ..database import THEMES, ConfigData from ..logger import LogLevel -from ..misc import ACTOR_FORMATS, Message, Response +from ..misc import ACTOR_FORMATS, Message, Response, get_app if typing.TYPE_CHECKING: from aiohttp.web import Request - from collections.abc import Coroutine + from collections.abc import Callable + from typing import Any -# pylint: disable=no-self-use - UNAUTH_ROUTES = { '/', '/login' } -CONFIG_IGNORE = ( - 'schema-version', - 'private-key' -) - @web.middleware -async def handle_frontend_path(request: web.Request, handler: Coroutine) -> Response: +async def handle_frontend_path(request: web.Request, handler: Callable) -> Response: if request.path in UNAUTH_ROUTES or request.path.startswith('/admin'): request['token'] = request.cookies.get('user-token') request['user'] = None if request['token']: - with request.app.database.session(False) as conn: + with get_app().database.session(False) as conn: request['user'] = conn.get_user_by_token(request['token']) if request['user'] and request.path == '/login': @@ -49,13 +43,11 @@ async def handle_frontend_path(request: web.Request, handler: Coroutine) -> Resp return await handler(request) -# pylint: disable=unused-argument - @register_route('/') class HomeView(View): async def get(self, request: Request) -> Response: with self.database.session() as conn: - context = { + context: dict[str, Any] = { 'instances': tuple(conn.get_inboxes()) } @@ -136,7 +128,7 @@ class AdminInstances(View): message: str | None = None) -> Response: with self.database.session() as conn: - context = { + context: dict[str, Any] = { 'instances': tuple(conn.get_inboxes()), 'requests': tuple(conn.get_requests()) } @@ -152,7 +144,8 @@ class AdminInstances(View): async def post(self, request: Request) -> Response: - data = await request.post() + post = await request.post() + data: dict[str, str] = {key: value for key, value in post.items()} # type: ignore if not data.get('actor') and not data.get('domain'): return await self.get(request, error = 'Missing actor and/or domain') @@ -162,13 +155,21 @@ class AdminInstances(View): if not data.get('software'): nodeinfo = await self.client.fetch_nodeinfo(data['domain']) + + if nodeinfo is None: + return await self.get(request, error = 'Failed to fetch nodeinfo') + data['software'] = nodeinfo.sw_name if not data.get('actor') and data['software'] in ACTOR_FORMATS: data['actor'] = ACTOR_FORMATS[data['software']].format(domain = data['domain']) if not data.get('inbox') and data['actor']: - actor = await self.client.get(data['actor'], sign_headers = True, loads = Message.parse) + actor: Message | None = await self.client.get(data['actor'], True, Message) + + if actor is None: + return await self.get(request, error = 'Failed to fetch actor') + data['inbox'] = actor.shared_inbox with self.database.session(True) as conn: @@ -248,7 +249,7 @@ class AdminWhitelist(View): message: str | None = None) -> Response: with self.database.session() as conn: - context = { + context: dict[str, Any] = { 'whitelist': tuple(conn.execute('SELECT * FROM whitelist ORDER BY domain ASC')) } @@ -298,7 +299,7 @@ class AdminDomainBans(View): message: str | None = None) -> Response: with self.database.session() as conn: - context = { + context: dict[str, Any] = { 'bans': tuple(conn.execute('SELECT * FROM domain_bans ORDER BY domain ASC')) } @@ -356,7 +357,7 @@ class AdminSoftwareBans(View): message: str | None = None) -> Response: with self.database.session() as conn: - context = { + context: dict[str, Any] = { 'bans': tuple(conn.execute('SELECT * FROM software_bans ORDER BY name ASC')) } @@ -414,7 +415,7 @@ class AdminUsers(View): message: str | None = None) -> Response: with self.database.session() as conn: - context = { + context: dict[str, Any] = { 'users': tuple(conn.execute('SELECT * FROM users ORDER BY username ASC')) } @@ -462,29 +463,26 @@ class AdminUsersDelete(View): @register_route('/admin/config') class AdminConfig(View): async def get(self, request: Request, message: str | None = None) -> Response: - context = { + context: dict[str, Any] = { 'themes': tuple(THEMES.keys()), 'levels': tuple(level.name for level in LogLevel), 'message': message } + data = self.template.render('page/admin-config.haml', self, **context) return Response.new(data, ctype = 'html') async def post(self, request: Request) -> Response: form = dict(await request.post()) + data = ConfigData() + + for key in ConfigData.USER_KEYS(): + data.set(key, form.get(key.replace('_', '-'))) with self.database.session(True) as conn: - for key in CONFIG_DEFAULTS: - value = form.get(key) - - if key == 'whitelist-enabled': - value = bool(value) - - elif key.lower() in CONFIG_IGNORE: - continue - - if value is None: + for key, value in data.to_dict().items(): + if key in ConfigData.SYSTEM_KEYS(): continue conn.put_config(key, value) @@ -503,7 +501,7 @@ class StyleCss(View): class ThemeCss(View): async def get(self, request: Request, theme: str) -> Response: try: - context = { + context: dict[str, Any] = { 'theme': THEMES[theme] } diff --git a/relay/views/misc.py b/relay/views/misc.py index ff4a6a4..f10a877 100644 --- a/relay/views/misc.py +++ b/relay/views/misc.py @@ -27,31 +27,26 @@ if Path(__file__).parent.parent.joinpath('.git').exists(): 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.session() as conn: inboxes = conn.get_inboxes() - data = { - 'name': 'activityrelay', - 'version': VERSION, - 'protocols': ['activitypub'], - 'open_regs': not conn.get_config('whitelist-enabled'), - 'users': 1, - 'metadata': { + nodeinfo = aputils.Nodeinfo.new( + name = 'activityrelay', + version = VERSION, + protocols = ['activitypub'], + open_regs = not conn.get_config('whitelist-enabled'), + users = 1, + repo = 'https://git.pleroma.social/pleroma/relay' if niversion == '2.1' else None, + metadata = { 'approval_required': conn.get_config('approval-required'), 'peers': [inbox['domain'] for inbox in inboxes] } - } + ) - if niversion == '2.1': - data['repo'] = 'https://git.pleroma.social/pleroma/relay' - - return Response.new(aputils.Nodeinfo.new(**data), ctype = 'json') + return Response.new(nodeinfo, ctype = 'json') @register_route('/.well-known/nodeinfo') diff --git a/requirements.txt b/requirements.txt index 5ca0df6..a0da5af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,14 @@ -aiohttp>=3.9.1 -aiohttp-swagger[performance]==1.0.16 -aputils@https://git.barkshark.xyz/barkshark/aputils/archive/0.1.7.tar.gz -argon2-cffi==23.1.0 -barkshark-sql@https://git.barkshark.xyz/barkshark/bsql/archive/0.1.2.tar.gz -click>=8.1.2 -hamlish-jinja@https://git.barkshark.xyz/barkshark/hamlish-jinja/archive/0.3.5.tar.gz -hiredis==2.3.2 -markdown==3.5.2 -platformdirs==4.2.0 -pyyaml>=6.0 -redis==5.0.1 +aiohttp >= 3.9.1 +aiohttp-swagger[performance] == 1.0.16 +aputils @ https://git.barkshark.xyz/barkshark/aputils/archive/0.1.7.tar.gz +argon2-cffi == 23.1.0 +barkshark-sql @ https://git.barkshark.xyz/barkshark/bsql/archive/0.1.2.tar.gz +click >= 8.1.2 +hamlish-jinja @ https://git.barkshark.xyz/barkshark/hamlish-jinja/archive/0.3.5.tar.gz +hiredis == 2.3.2 +markdown == 3.5.2 +platformdirs == 4.2.0 +pyyaml >= 6.0 +redis == 5.0.1 -importlib_resources==6.1.1;python_version<'3.9' +importlib_resources == 6.1.1; python_version < '3.9' diff --git a/setup.cfg b/setup.cfg index 41c2a30..b7d4fdc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,6 +44,8 @@ console_scripts = [flake8] -select = F401 +extend-ignore = E128,E251,E261,E303,W191 +max-line-length = 100 +indent-size = 4 per-file-ignores = __init__.py: F401 From 7f1c0d939d4cfadca26fa1559d0721c65e385aee Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Wed, 13 Mar 2024 18:37:00 -0400 Subject: [PATCH 08/57] add database config options to docs --- docs/configuration.md | 92 +++++++++++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 20 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 2fad0af..8a6fd7c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,15 +1,19 @@ # Configuration -## General +## Config File -### Domain +These options are stored in the configuration file (usually relay.yaml) + +### General + +#### Domain Hostname the relay will be hosted on. domain: relay.example.com -### Listener +#### Listener The address and port the relay will listen on. If the reverse proxy (nginx, apache, caddy, etc) is running on the same host, it is recommended to change `listen` to `localhost` if the reverse @@ -19,7 +23,7 @@ proxy is on the same host. port: 8080 -### Push Workers +#### Push Workers The number of processes to spawn for pushing messages to subscribed instances. Leave it at 0 to automatically detect how many processes should be spawned. @@ -27,21 +31,21 @@ automatically detect how many processes should be spawned. workers: 0 -### Database type +#### Database type SQL database backend to use. Valid values are `sqlite` or `postgres`. database_type: sqlite -### Cache type +#### Cache type Cache backend to use. Valid values are `database` or `redis` cache_type: database -### Sqlite File Path +#### Sqlite File Path Path to the sqlite database file. If the path is not absolute, it is relative to the config file. directory. @@ -49,7 +53,7 @@ directory. sqlite_path: relay.jsonld -## Postgresql +### Postgresql In order to use the Postgresql backend, the user and database need to be created first. @@ -57,80 +61,128 @@ In order to use the Postgresql backend, the user and database need to be created sudo -u postgres psql -c "CREATE DATABASE activityrelay OWNER activityrelay" -### Database Name +#### Database Name Name of the database to use. name: activityrelay -### Host +#### Host Hostname, IP address, or unix socket the server is hosted on. host: /var/run/postgresql -### Port +#### Port Port number the server is listening on. port: 5432 -### Username +#### Username User to use when logging into the server. user: null -### Password +#### Password Password for the specified user. pass: null -## Redis +### Redis -### Host +#### Host Hostname, IP address, or unix socket the server is hosted on. host: /var/run/postgresql -### Port +#### Port Port number the server is listening on. port: 5432 -### Username +#### Username User to use when logging into the server. user: null -### Password +#### Password Password for the specified user. pass: null -### Database Number +#### Database Number Number of the database to use. database: 0 -### Prefix +#### Prefix Text to prefix every key with. It cannot contain a `:` character. prefix: activityrelay + +## Database Config + +These options are stored in the database and can be changed via CLI, API, or the web interface. + +### Approval Required + +When enabled, instances that try to follow the relay will have to be manually approved by an admin. + + approval-required: false + + +### Log Level + +Maximum level of messages to log. + +Valid values: `DEBUG`, `VERBOSE`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` + + log-level: INFO + +### Name + +Name of your relay's instance. It will be displayed at the top of web pages and in API endpoints. + + name: ActivityRelay + + +### Note + +Short blurb that will be displayed on the relay's home and in API endpoints if set. + + note: null + + +### Theme + +Color theme to use for the web pages. + +Valid values: `Default`, `Pink`, `Blue` + + theme: Default + + +### Whitelist Enabled + +When enabled, only instances on the whitelist can join. Any instances currently subscribed and not in the whitelist when this is enabled can still post. + + whitelist-enabled: False From 49917fcc4e40d2ce34b21c4aeaea5ebe5f261198 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Wed, 13 Mar 2024 18:52:22 -0400 Subject: [PATCH 09/57] minor doc change --- docs/configuration.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8a6fd7c..9cc8cdb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -167,7 +167,8 @@ Name of your relay's instance. It will be displayed at the top of web pages and ### Note -Short blurb that will be displayed on the relay's home and in API endpoints if set. +Short blurb that will be displayed on the relay's home and in API endpoints if set. Can be in +markdown format. note: null @@ -183,6 +184,7 @@ Valid values: `Default`, `Pink`, `Blue` ### Whitelist Enabled -When enabled, only instances on the whitelist can join. Any instances currently subscribed and not in the whitelist when this is enabled can still post. +When enabled, only instances on the whitelist can join. Any instances currently subscribed and not +in the whitelist when this is enabled can still post. whitelist-enabled: False From 10ba03993828d373b602436eb639632684db94b6 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Thu, 14 Mar 2024 20:58:16 -0400 Subject: [PATCH 10/57] minor frontend tweaks and use javascript for managing domain bans --- relay/application.py | 39 +++++++++ relay/frontend/base.haml | 44 +++------- relay/frontend/page/admin-config.haml | 32 +++---- relay/frontend/page/admin-domain_bans.haml | 65 +++++++------- relay/frontend/page/admin-instances.haml | 79 ++++++++--------- relay/frontend/page/admin-software_bans.haml | 57 +++++++------ relay/frontend/page/admin-users.haml | 43 +++++----- relay/frontend/page/admin-whitelist.haml | 4 +- relay/frontend/page/home.haml | 35 +++++--- relay/frontend/page/login.haml | 17 ++-- relay/frontend/static/api.js | 90 ++++++++++++++++++++ relay/frontend/static/domain_ban.js | 85 ++++++++++++++++++ relay/frontend/static/menu.js | 21 +++++ relay/frontend/{ => static}/style.css | 12 ++- relay/views/api.py | 48 ----------- relay/views/base.py | 3 + relay/views/frontend.py | 32 ++++--- 17 files changed, 461 insertions(+), 245 deletions(-) create mode 100644 relay/frontend/static/api.js create mode 100644 relay/frontend/static/domain_ban.js create mode 100644 relay/frontend/static/menu.js rename relay/frontend/{ => static}/style.css (96%) diff --git a/relay/application.py b/relay/application.py index c5f9aaf..3743612 100644 --- a/relay/application.py +++ b/relay/application.py @@ -68,6 +68,8 @@ class Application(web.Application): for path, view in VIEWS: self.router.add_view(path, view) + self.add_routes([web.static('/static', get_resource('frontend/static'))]) + setup_swagger( self, ui_version = 3, @@ -124,6 +126,40 @@ class Application(web.Application): return timedelta(seconds=uptime.seconds) + def get_csp(self, request: Request) -> str: + data = [ + "default-src 'none'", + f"script-src 'nonce-{request['hash']}'", + f"style-src 'nonce-{request['hash']}'", + "form-action 'self'", + "connect-src 'self'", + "img-src 'self'", + "object-src 'none'", + "frame-ancestors 'none'" + ] + + return '; '.join(data) + ';' + + # data = { + # 'base-uri': '\'none\'', + # 'default-src': '\'none\'', + # 'frame-ancestors': '\'none\'', + # 'font-src': f'\'self\' https://{self.config.domain}', + # 'img-src': f'\'self\' https://{self.config.domain}', + # 'style-src': f'\'self\' https://{self.config.domain} \'nonce-randomstringhere\'', + # 'media-src': f'\'self\' https://{self.config.domain}', + # 'frame-src': f'\'self\' https:', + # 'manifest-src': f'\'self\' https://{self.config.domain}', + # 'form-action': f'\'self\'', + # 'child-src': f'\'self\' https://{self.config.domain}', + # 'worker-src': f'\'self\' https://{self.config.domain}', + # 'connect-src': f'\'self\' https://{self.config.domain} wss://{self.config.domain}', + # 'script-src': f'\'self\' https://{self.config.domain}' + # } + # + # return '; '.join(f'{key} {value}' for key, value in data.items()) + ';' + + def push_message(self, inbox: str, message: Message, instance: Row) -> None: self['push_queue'].put((inbox, message, instance)) @@ -269,6 +305,9 @@ async def handle_response_headers(request: web.Request, handler: Callable) -> Re resp = await handler(request) resp.headers['Server'] = 'ActivityRelay' + # if resp.content_type == 'text/html': + # resp.headers['Content-Security-Policy'] = Application.DEFAULT.get_csp(request) + if not request.app['dev'] and request.path.endswith(('.css', '.js')): # cache for 2 weeks resp.headers['Cache-Control'] = 'public,max-age=1209600,immutable' diff --git a/relay/frontend/base.haml b/relay/frontend/base.haml index 9d08ad7..992cfe7 100644 --- a/relay/frontend/base.haml +++ b/relay/frontend/base.haml @@ -11,8 +11,10 @@ %title << {{config.name}}: {{page}} %meta(charset="UTF-8") %meta(name="viewport" content="width=device-width, initial-scale=1") - %link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css") - %link(rel="stylesheet" type="text/css" href="/style.css") + %link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css" nonce="{{view.request['hash']}}") + %link(rel="stylesheet" type="text/css" href="/static/style.css" nonce="{{view.request['hash']}}") + %script(type="application/javascript" src="/static/menu.js" nonce="{{view.request['hash']}}", defer) + %script(type="application/javascript" src="/static/api.js" nonce="{{view.request['hash']}}", defer) -block head %body @@ -38,19 +40,18 @@ #container #header.section %span#menu-open << ⁞ - %span.title-container - %a.title(href="/") -> =config.name - - -if view.request.path not in ["/", "/login"] - .page -> =page - + %a.title(href="/") -> =config.name .empty -if error - .error.section -> =error + %fieldset.error.section + %legend << Error + =error -if message - .message.section -> =message + %fieldset.message.section + %legend << Message + =message #content(class="page-{{page.lower().replace(' ', '_')}}") -block content @@ -69,26 +70,3 @@ .version %a(href="https://git.pleroma.social/pleroma/relay") ActivityRelay/{{version}} - - %script(type="application/javascript") - const body = document.getElementById("container") - const menu = document.getElementById("menu"); - const menu_open = document.getElementById("menu-open"); - const menu_close = document.getElementById("menu-close"); - - menu_open.addEventListener("click", (event) => { - var new_value = menu.attributes.visible.nodeValue === "true" ? "false" : "true"; - menu.attributes.visible.nodeValue = new_value; - }); - - menu_close.addEventListener("click", (event) => { - menu.attributes.visible.nodeValue = "false" - }); - - body.addEventListener("click", (event) => { - if (event.target === menu_open) { - return; - } - - menu.attributes.visible.nodeValue = "false"; - }); diff --git a/relay/frontend/page/admin-config.haml b/relay/frontend/page/admin-config.haml index 08c16c7..ff6c4f6 100644 --- a/relay/frontend/page/admin-config.haml +++ b/relay/frontend/page/admin-config.haml @@ -2,24 +2,26 @@ -set page="Config" -import "functions.haml" as func -block content - %form.section(action="/admin/config" method="POST") - .grid-2col - %label(for="name") << Name - %input(id = "name" name="name" placeholder="Relay Name" value="{{config.name or ''}}") + %fieldset.section + %legend << Config + %form(action="/admin/config" method="POST") + .grid-2col + %label(for="name") << Name + %input(id = "name" name="name" placeholder="Relay Name" value="{{config.name or ''}}") - %label(for="description") << Description - %textarea(id="description" name="note" value="{{config.note or ''}}") << {{config.note}} + %label(for="description") << Description + %textarea(id="description" name="note" value="{{config.note or ''}}") << {{config.note}} - %label(for="theme") << Color Theme - =func.new_select("theme", config.theme, themes) + %label(for="theme") << Color Theme + =func.new_select("theme", config.theme, themes) - %label(for="log-level") << Log Level - =func.new_select("log-level", config.log_level.name, levels) + %label(for="log-level") << Log Level + =func.new_select("log-level", config.log_level.name, levels) - %label(for="whitelist-enabled") << Whitelist - =func.new_checkbox("whitelist-enabled", config.whitelist_enabled) + %label(for="whitelist-enabled") << Whitelist + =func.new_checkbox("whitelist-enabled", config.whitelist_enabled) - %label(for="approval-required") << Approval Required - =func.new_checkbox("approval-required", config.approval_required) + %label(for="approval-required") << Approval Required + =func.new_checkbox("approval-required", config.approval_required) - %input(type="submit" value="Save") + %input(type="submit" value="Save") diff --git a/relay/frontend/page/admin-domain_bans.haml b/relay/frontend/page/admin-domain_bans.haml index f874499..104ce15 100644 --- a/relay/frontend/page/admin-domain_bans.haml +++ b/relay/frontend/page/admin-domain_bans.haml @@ -1,48 +1,53 @@ -extends "base.haml" -set page="Domain Bans" + +-block head + %script(type="application/javascript" src="/static/domain_ban.js" nonce="{{view.request['hash']}}", defer) + -block content %details.section %summary << Ban Domain - %form(action="/admin/domain_bans" method="POST") - #add-item - %label(for="domain") << Domain - %input(type="domain" id="domain" name="domain" placeholder="Domain") + #add-item + %label(for="new-domain") << Domain + %input(type="domain" id="new-domain" name="domain" placeholder="Domain") - %label(for="reason") << Ban Reason - %textarea(id="reason" name="reason") << {{""}} + %label(for="new-reason") << Ban Reason + %textarea(id="new-reason" name="new") << {{""}} - %label(for="note") << Admin Note - %textarea(id="note" name="note") << {{""}} + %label(for="new-note") << Admin Note + %textarea(id="new-note" name="note") << {{""}} - %input(type="submit" value="Ban Domain") + %input(type="button" value="Ban Domain" onclick="ban();") - .data-table.section - %table - %thead - %tr - %td.domain << Instance - %td << Date - %td.remove + %fieldset.section + %legend << Domain Bans - %tbody - -for ban in bans + .data-table + %table#table + %thead %tr - %td.domain - %details - %summary -> =ban.domain - %form(action="/admin/domain_bans" method="POST") + %td.domain << Domain + %td << Date + %td.remove + + %tbody + -for ban in bans + %tr(id="{{ban.domain}}") + %td.domain + %details + %summary -> =ban.domain + .grid-2col .reason << Reason - %textarea.reason(id="reason" name="reason") << {{ban.reason or ""}} + %textarea.reason(id="{{ban.domain}}-reason" name="reason") << {{ban.reason or ""}} .note << Note - %textarea.note(id="note" name="note") << {{ban.note or ""}} + %textarea.note(id="{{ban.domain}}-note" name="note") << {{ban.note or ""}} - %input(type="hidden" name="domain" value="{{ban.domain}}") - %input(type="submit" value="Update") + %input(type="button" value="Update" onclick="update_ban('{{ban.domain}}')") - %td.date - =ban.created.strftime("%Y-%m-%d") + %td.date + =ban.created.strftime("%Y-%m-%d") - %td.remove - %a(href="/admin/domain_bans/delete/{{ban.domain}}" title="Unban domain") << ✖ + %td.remove + %a(href="#", onclick="unban('{{ban.domain}}')" title="Unban domain") << ✖ diff --git a/relay/frontend/page/admin-instances.haml b/relay/frontend/page/admin-instances.haml index 770ccc1..aaab1c9 100644 --- a/relay/frontend/page/admin-instances.haml +++ b/relay/frontend/page/admin-instances.haml @@ -20,56 +20,59 @@ %input(type="submit" value="Add Instance") -if requests - .data-table.section - .title << Requests + %fieldset.section + %legend << Follow Requests + .data-table + %table + %thead + %tr + %td.instance << Instance + %td.software << Software + %td.date << Joined + %td.approve + %td.deny + + %tbody + -for request in requests + %tr + %td.instance + %a(href="https://{{request.domain}}" target="_new") -> =request.domain + + %td.software + =request.software or "n/a" + + %td.date + =request.created.strftime("%Y-%m-%d") + + %td.approve + %a(href="/admin/instances/approve/{{request.domain}}" title="Approve Request") << ✓ + + %td.deny + %a(href="/admin/instances/deny/{{request.domain}}" title="Deny Request") << ✖ + + %fieldset.section + %legend << Instances + + .data-table %table %thead %tr %td.instance << Instance %td.software << Software %td.date << Joined - %td.approve - %td.deny + %td.remove %tbody - -for request in requests + -for instance in instances %tr %td.instance - %a(href="https://{{request.domain}}" target="_new") -> =request.domain + %a(href="https://{{instance.domain}}/" target="_new") -> =instance.domain %td.software - =request.software or "n/a" + =instance.software or "n/a" %td.date - =request.created.strftime("%Y-%m-%d") + =instance.created.strftime("%Y-%m-%d") - %td.approve - %a(href="/admin/instances/approve/{{request.domain}}" title="Approve Request") << ✓ - - %td.deny - %a(href="/admin/instances/deny/{{request.domain}}" title="Deny Request") << ✖ - - .data-table.section - .title << Instances - %table - %thead - %tr - %td.instance << Instance - %td.software << Software - %td.date << Joined - %td.remove - - %tbody - -for instance in instances - %tr - %td.instance - %a(href="https://{{instance.domain}}/" target="_new") -> =instance.domain - - %td.software - =instance.software or "n/a" - - %td.date - =instance.created.strftime("%Y-%m-%d") - - %td.remove - %a(href="/admin/instances/delete/{{instance.domain}}" title="Remove Instance") << ✖ + %td.remove + %a(href="/admin/instances/delete/{{instance.domain}}" title="Remove Instance") << ✖ diff --git a/relay/frontend/page/admin-software_bans.haml b/relay/frontend/page/admin-software_bans.haml index 7ac9d07..1ce5664 100644 --- a/relay/frontend/page/admin-software_bans.haml +++ b/relay/frontend/page/admin-software_bans.haml @@ -16,33 +16,36 @@ %input(type="submit" value="Ban Software") - .data-table.section - %table - %thead - %tr - %td.name << Instance - %td << Date - %td.remove + %fieldset.section + %legend << Software Bans - %tbody - -for ban in bans + .data-table + %table + %thead %tr - %td.name - %details - %summary -> =ban.name - %form(action="/admin/software_bans" method="POST") - .grid-2col - .reason << Reason - %textarea.reason(id="reason" name="reason") << {{ban.reason or ""}} - - .note << Note - %textarea.note(id="note" name="note") << {{ban.note or ""}} - - %input(type="hidden" name="name" value="{{ban.name}}") - %input(type="submit" value="Update") - - %td.date - =ban.created.strftime("%Y-%m-%d") - + %td.name << Name + %td << Date %td.remove - %a(href="/admin/software_bans/delete/{{ban.name}}" title="Unban software") << ✖ + + %tbody + -for ban in bans + %tr + %td.name + %details + %summary -> =ban.name + %form(action="/admin/software_bans" method="POST") + .grid-2col + .reason << Reason + %textarea.reason(id="reason" name="reason") << {{ban.reason or ""}} + + .note << Note + %textarea.note(id="note" name="note") << {{ban.note or ""}} + + %input(type="hidden" name="name" value="{{ban.name}}") + %input(type="submit" value="Update") + + %td.date + =ban.created.strftime("%Y-%m-%d") + + %td.remove + %a(href="/admin/software_bans/delete/{{ban.name}}" title="Unban software") << ✖ diff --git a/relay/frontend/page/admin-users.haml b/relay/frontend/page/admin-users.haml index a87c0db..67ab7b1 100644 --- a/relay/frontend/page/admin-users.haml +++ b/relay/frontend/page/admin-users.haml @@ -19,26 +19,29 @@ %input(type="submit" value="Add User") - .data-table.section - %table - %thead - %tr - %td.username << Username - %td.handle << Handle - %td.date << Joined - %td.remove + %fieldset.section + %legend << Users - %tbody - -for user in users + .data-table + %table + %thead %tr - %td.username - =user.username - - %td.handle - =user.handle or "n/a" - - %td.date - =user.created.strftime("%Y-%m-%d") - + %td.username << Username + %td.handle << Handle + %td.date << Joined %td.remove - %a(href="/admin/users/delete/{{user.username}}" title="Remove User") << ✖ + + %tbody + -for user in users + %tr + %td.username + =user.username + + %td.handle + =user.handle or "n/a" + + %td.date + =user.created.strftime("%Y-%m-%d") + + %td.remove + %a(href="/admin/users/delete/{{user.username}}" title="Remove User") << ✖ diff --git a/relay/frontend/page/admin-whitelist.haml b/relay/frontend/page/admin-whitelist.haml index 9126297..e0dffd6 100644 --- a/relay/frontend/page/admin-whitelist.haml +++ b/relay/frontend/page/admin-whitelist.haml @@ -10,7 +10,9 @@ %input(type="submit" value="Add Domain") - .data-table.section + %fieldset.data-table.section + %legend << Whitelist + %table %thead %tr diff --git a/relay/frontend/page/home.haml b/relay/frontend/page/home.haml index b59e5b5..f9618fc 100644 --- a/relay/frontend/page/home.haml +++ b/relay/frontend/page/home.haml @@ -14,27 +14,34 @@ %a(href="https://{{domain}}/actor") << https://{{domain}}/actor -if config.approval_required - %p.section.message + %fieldset.section.message + %legend << Require Approval + Follow requests require approval. You will need to wait for an admin to accept or deny your request. -elif config.whitelist_enabled - %p.section.message + %fieldset.section.message + %legend << Whitelist Enabled + The whitelist is enabled on this instance. Ask the admin to add your instance before joining. - .data-table.section - %table - %thead - %tr - %td.instance << Instance - %td.date << Joined + %fieldset.section + %legend << Instances - %tbody - -for instance in instances + .data-table + %table + %thead %tr - %td.instance -> %a(href="https://{{instance.domain}}/" target="_new") - =instance.domain + %td.instance << Instance + %td.date << Joined - %td.date - =instance.created.strftime("%Y-%m-%d") + %tbody + -for instance in instances + %tr + %td.instance -> %a(href="https://{{instance.domain}}/" target="_new") + =instance.domain + + %td.date + =instance.created.strftime("%Y-%m-%d") diff --git a/relay/frontend/page/login.haml b/relay/frontend/page/login.haml index 1e08185..ab177b6 100644 --- a/relay/frontend/page/login.haml +++ b/relay/frontend/page/login.haml @@ -1,12 +1,15 @@ -extends "base.haml" -set page="Login" -block content - %form.section(action="/login" method="POST") - .grid-2col - %label(for="username") << Username - %input(id="username" name="username" placeholder="Username" value="{{username or ''}}") + %fieldset.section + %legend << Login - %label(for="password") << Password - %input(id="password" name="password" placeholder="Password" type="password") + %form(action="/login" method="POST") + .grid-2col + %label(for="username") << Username + %input(id="username" name="username" placeholder="Username" value="{{username or ''}}") - %input(type="submit" value="Login") + %label(for="password") << Password + %input(id="password" name="password" placeholder="Password" type="password") + + %input(type="submit" value="Login") diff --git a/relay/frontend/static/api.js b/relay/frontend/static/api.js new file mode 100644 index 0000000..a2d3fbb --- /dev/null +++ b/relay/frontend/static/api.js @@ -0,0 +1,90 @@ +function get_cookie(name) { + const regex = new RegExp(`(^| )` + name + `=([^;]+)`); + const match = document.cookie.match(regex); + + if (match) { + return match[2] + } + + return null; +} + + +function get_date_string(date) { + var year = date.getFullYear().toString(); + var month = date.getMonth().toString(); + var day = date.getDay().toString(); + + if (month.length === 1) { + month = "0" + month; + } + + if (day.length === 1) { + day = "0" + day + } + + return `${year}-${month}-${day}`; +} + + +class Client { + constructor() { + this.token = get_cookie("user-token"); + } + + + async request(method, path, body = null) { + var headers = { + "Accept": "application/json" + } + + if (body !== null) { + headers["Content-Type"] = "application/json" + body = JSON.stringify(body) + } + + if (this.token !== null) { + headers["Authorization"] = "Bearer " + this.token; + } + + const response = await fetch("/api/" + path, { + method: method, + mode: "cors", + cache: "no-store", + redirect: "follow", + body: body, + headers: headers + }); + + const message = await response.json(); + + if (Object.hasOwn(message, "error")) { + throw new Error(message.error); + } + + if (Object.hasOwn(message, "created")) { + message.created = new Date(message.created); + } + + return message; + } + + async ban(domain, reason, note) { + const params = { + "domain": domain, + "reason": reason, + "note": note + } + + return await this.request("POST", "v1/domain_ban", params); + } + + + async unban(domain) { + const params = {"domain": domain} + return await this.request("DELETE", "v1/domain_ban", params); + } +} + + +client = new Client(); diff --git a/relay/frontend/static/domain_ban.js b/relay/frontend/static/domain_ban.js new file mode 100644 index 0000000..82434af --- /dev/null +++ b/relay/frontend/static/domain_ban.js @@ -0,0 +1,85 @@ +function create_ban_object(domain, reason, note) { + var text = '
\n'; + text += `${domain}\n`; + text += '
\n'; + text += `\n`; + text += `\n`; + text += `\n`; + text += `\n`; + text += ``; + text += '
'; + + return text; +} + + +async function ban() { + var table = document.getElementById("table"); + var row = table.insertRow(-1); + + var elems = { + domain: document.getElementById("new-domain"), + reason: document.getElementById("new-reason"), + note: document.getElementById("new-note") + } + + var values = { + domain: elems.domain.value.trim(), + reason: elems.reason.value, + note: elems.note.value + } + + if (values.domain === "") { + alert("Domain is required"); + return; + } + + try { + var ban = await client.ban(values.domain, values.reason, values.note); + + } catch (err) { + alert(err); + return + } + + row.id = ban.domain; + var new_domain = row.insertCell(0); + var new_date = row.insertCell(1); + var new_remove = row.insertCell(2); + + new_domain.className = "domain"; + new_date.className = "date"; + new_remove.className = "remove"; + + new_domain.innerHTML = create_ban_object(ban.domain, ban.reason, ban.note); + new_date.innerHTML = get_date_string(ban.created); + new_remove.innerHTML = ``; + + elems.domain.value = null; + elems.reason.value = null; + elems.note.value = null; + + document.querySelectorAll("details.section").forEach((elem) => { + elem.open = false; + }); +} + + +async function update_ban(domain) { + var row = document.getElementById(domain); +} + + +async function unban(domain) { + console.log(domain); + + try { + await client.unban(domain); + + } catch (err) { + alert(err); + return; + } + + document.getElementById(domain).remove(); +} diff --git a/relay/frontend/static/menu.js b/relay/frontend/static/menu.js new file mode 100644 index 0000000..ebd494f --- /dev/null +++ b/relay/frontend/static/menu.js @@ -0,0 +1,21 @@ +const body = document.getElementById("container") +const menu = document.getElementById("menu"); +const menu_open = document.getElementById("menu-open"); +const menu_close = document.getElementById("menu-close"); + +menu_open.addEventListener("click", (event) => { + var new_value = menu.attributes.visible.nodeValue === "true" ? "false" : "true"; + menu.attributes.visible.nodeValue = new_value; +}); + +menu_close.addEventListener("click", (event) => { + menu.attributes.visible.nodeValue = "false" +}); + +body.addEventListener("click", (event) => { + if (event.target === menu_open) { + return; + } + + menu.attributes.visible.nodeValue = "false"; +}); diff --git a/relay/frontend/style.css b/relay/frontend/static/style.css similarity index 96% rename from relay/frontend/style.css rename to relay/frontend/static/style.css index 1e7b90e..e1ac3eb 100644 --- a/relay/frontend/style.css +++ b/relay/frontend/static/style.css @@ -23,17 +23,27 @@ details summary { cursor: pointer; } +fieldset { + margin-left: 0px; + margin-right: 0px; +} + +fieldset > *:nth-child(2) { + margin-top: 0px !important; +} + form input[type="submit"] { display: block; margin: 0 auto; } legend { - background-color: var(--section-background); + background-color: var(--table-background); padding: 5px; border: 1px solid var(--border); border-radius: 5px; font-size: 10pt; + font-weight: bold; } p { diff --git a/relay/views/api.py b/relay/views/api.py index 0435555..4b9dc6c 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -274,54 +274,6 @@ class DomainBan(View): 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.session() 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') - - - async def patch(self, request: Request) -> Response: - with self.database.session() as conn: - data = await self.get_api_data(['domain'], ['note', 'reason']) - - if isinstance(data, Response): - return data - - if not conn.get_domain_ban(data['domain']): - return Response.new_error(404, 'Domain not banned', 'json') - - 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(data['domain'], **data) - - return Response.new(ban, ctype = 'json') - - - async def delete(self, request: Request) -> Response: - with self.database.session() as conn: - data = await self.get_api_data(['domain'], []) - - if isinstance(data, Response): - return data - - if not conn.get_domain_ban(data['domain']): - return Response.new_error(404, 'Domain not banned', 'json') - - conn.del_domain_ban(data['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: diff --git a/relay/views/base.py b/relay/views/base.py index d293f62..2e035f6 100644 --- a/relay/views/base.py +++ b/relay/views/base.py @@ -2,9 +2,11 @@ from __future__ import annotations import typing +from Crypto.Random import get_random_bytes from aiohttp.abc import AbstractView from aiohttp.hdrs import METH_ALL as METHODS from aiohttp.web import HTTPMethodNotAllowed +from base64 import b64encode from functools import cached_property from json.decoder import JSONDecodeError @@ -56,6 +58,7 @@ class View(AbstractView): async def _run_handler(self, handler: Callable[..., Any], **kwargs: Any) -> Response: + self.request['hash'] = b64encode(get_random_bytes(16)).decode('ascii') return await handler(self.request, **self.request.match_info, **kwargs) diff --git a/relay/views/frontend.py b/relay/views/frontend.py index 4e10e83..448b834 100644 --- a/relay/views/frontend.py +++ b/relay/views/frontend.py @@ -26,21 +26,31 @@ UNAUTH_ROUTES = { @web.middleware async def handle_frontend_path(request: web.Request, handler: Callable) -> Response: + app = get_app() + if request.path in UNAUTH_ROUTES or request.path.startswith('/admin'): request['token'] = request.cookies.get('user-token') request['user'] = None if request['token']: - with get_app().database.session(False) as conn: + with app.database.session(False) as conn: request['user'] = conn.get_user_by_token(request['token']) if request['user'] and request.path == '/login': return Response.new('', 302, {'Location': '/'}) if not request['user'] and request.path.startswith('/admin'): - return Response.new('', 302, {'Location': f'/login?redir={request.path}'}) + response = Response.new('', 302, {'Location': f'/login?redir={request.path}'}) + response.del_cookie('user-token') + return response - return await handler(request) + response = await handler(request) + + if not request['user'] and request['token']: + print("del token") + response.del_cookie('user-token') + + return response @register_route('/') @@ -96,8 +106,8 @@ class Login(View): domain = self.config.domain, path = '/', secure = True, - httponly = True, - samesite = 'Strict' + httponly = False, + samesite = 'lax' ) return resp @@ -271,7 +281,7 @@ class AdminWhitelist(View): with self.database.session(True) as conn: if conn.get_domain_whitelist(data['domain']): - return await self.get(request, message = "Domain already in whitelist") + return await self.get(request, error = "Domain already in whitelist") conn.put_domain_whitelist(data['domain']) @@ -284,7 +294,7 @@ class AdminWhitlistDelete(View): with self.database.session() as conn: if not conn.get_domain_whitelist(domain): msg = 'Whitelisted domain not found' - return await AdminWhitelist.run("GET", request, message = msg) + return await AdminWhitelist.run("GET", request, error = msg) conn.del_domain_whitelist(domain) @@ -342,7 +352,7 @@ class AdminDomainBansDelete(View): async def get(self, request: Request, domain: str) -> Response: with self.database.session() as conn: if not conn.get_domain_ban(domain): - return await AdminDomainBans.run("GET", request, message = 'Domain ban not found') + return await AdminDomainBans.run("GET", request, error = 'Domain ban not found') conn.del_domain_ban(domain) @@ -400,7 +410,7 @@ class AdminSoftwareBansDelete(View): async def get(self, request: Request, name: str) -> Response: with self.database.session() as conn: if not conn.get_software_ban(name): - return await AdminSoftwareBans.run("GET", request, message = 'Software ban not found') + return await AdminSoftwareBans.run("GET", request, error = 'Software ban not found') conn.del_software_ban(name) @@ -441,7 +451,7 @@ class AdminUsers(View): with self.database.session(True) as conn: if conn.get_user(data['username']): - return await self.get(request, message = "User already exists") + return await self.get(request, error = "User already exists") conn.put_user(data['username'], data['password'], data['handle']) @@ -453,7 +463,7 @@ class AdminUsersDelete(View): async def get(self, request: Request, name: str) -> Response: with self.database.session() as conn: if not conn.get_user(name): - return await AdminUsers.run("GET", request, message = 'User not found') + return await AdminUsers.run("GET", request, error = 'User not found') conn.del_user(name) From 17690268bccaf9bf9f6e2cf280b9ac04c7f02fcb Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Thu, 14 Mar 2024 21:36:47 -0400 Subject: [PATCH 11/57] add ability to update domain bans and re-add domain ban api endpoints --- relay/application.py | 20 +------- relay/frontend/page/admin-domain_bans.haml | 14 +++--- relay/frontend/page/admin-software_bans.haml | 4 ++ relay/frontend/static/domain_ban.js | 37 +++++++++++---- relay/views/api.py | 48 ++++++++++++++++++++ 5 files changed, 87 insertions(+), 36 deletions(-) diff --git a/relay/application.py b/relay/application.py index 3743612..b3da393 100644 --- a/relay/application.py +++ b/relay/application.py @@ -140,25 +140,6 @@ class Application(web.Application): return '; '.join(data) + ';' - # data = { - # 'base-uri': '\'none\'', - # 'default-src': '\'none\'', - # 'frame-ancestors': '\'none\'', - # 'font-src': f'\'self\' https://{self.config.domain}', - # 'img-src': f'\'self\' https://{self.config.domain}', - # 'style-src': f'\'self\' https://{self.config.domain} \'nonce-randomstringhere\'', - # 'media-src': f'\'self\' https://{self.config.domain}', - # 'frame-src': f'\'self\' https:', - # 'manifest-src': f'\'self\' https://{self.config.domain}', - # 'form-action': f'\'self\'', - # 'child-src': f'\'self\' https://{self.config.domain}', - # 'worker-src': f'\'self\' https://{self.config.domain}', - # 'connect-src': f'\'self\' https://{self.config.domain} wss://{self.config.domain}', - # 'script-src': f'\'self\' https://{self.config.domain}' - # } - # - # return '; '.join(f'{key} {value}' for key, value in data.items()) + ';' - def push_message(self, inbox: str, message: Message, instance: Row) -> None: self['push_queue'].put((inbox, message, instance)) @@ -305,6 +286,7 @@ async def handle_response_headers(request: web.Request, handler: Callable) -> Re resp = await handler(request) resp.headers['Server'] = 'ActivityRelay' + # Still have to figure out how csp headers work # if resp.content_type == 'text/html': # resp.headers['Content-Security-Policy'] = Application.DEFAULT.get_csp(request) diff --git a/relay/frontend/page/admin-domain_bans.haml b/relay/frontend/page/admin-domain_bans.haml index 104ce15..43636ae 100644 --- a/relay/frontend/page/admin-domain_bans.haml +++ b/relay/frontend/page/admin-domain_bans.haml @@ -2,7 +2,7 @@ -set page="Domain Bans" -block head - %script(type="application/javascript" src="/static/domain_ban.js" nonce="{{view.request['hash']}}", defer) + %script(type="application/javascript" src="/static/domain_ban.js" nonce="{{view.request['hash']}}") -block content %details.section @@ -23,7 +23,7 @@ %legend << Domain Bans .data-table - %table#table + %table %thead %tr %td.domain << Domain @@ -38,11 +38,11 @@ %summary -> =ban.domain .grid-2col - .reason << Reason - %textarea.reason(id="{{ban.domain}}-reason" name="reason") << {{ban.reason or ""}} + %label.reason(for="{{ban.domain}}-reason") << Reason + %textarea.reason(id="{{ban.domain}}-reason") << {{ban.reason or ""}} - .note << Note - %textarea.note(id="{{ban.domain}}-note" name="note") << {{ban.note or ""}} + %label.note(for="{{ban.domain}}-note") << Note + %textarea.note(id="{{ban.domain}}-note") << {{ban.note or ""}} %input(type="button" value="Update" onclick="update_ban('{{ban.domain}}')") @@ -50,4 +50,4 @@ =ban.created.strftime("%Y-%m-%d") %td.remove - %a(href="#", onclick="unban('{{ban.domain}}')" title="Unban domain") << ✖ + %a(href="#" onclick="unban('{{ban.domain}}')" title="Unban domain") << ✖ diff --git a/relay/frontend/page/admin-software_bans.haml b/relay/frontend/page/admin-software_bans.haml index 1ce5664..cf1576c 100644 --- a/relay/frontend/page/admin-software_bans.haml +++ b/relay/frontend/page/admin-software_bans.haml @@ -1,5 +1,9 @@ -extends "base.haml" -set page="Software Bans" + +-block head + %script(type="application/javascript" src="/static/software_ban.js" nonce="{{view.request['hash']}}") + -block content %details.section %summary << Ban Software diff --git a/relay/frontend/static/domain_ban.js b/relay/frontend/static/domain_ban.js index 82434af..b7ae6b6 100644 --- a/relay/frontend/static/domain_ban.js +++ b/relay/frontend/static/domain_ban.js @@ -3,9 +3,9 @@ function create_ban_object(domain, reason, note) { text += `${domain}\n`; text += '
\n'; text += `\n`; - text += `\n`; + text += `\n`; text += `\n`; - text += `\n`; + text += `\n`; text += ``; text += ''; @@ -14,7 +14,7 @@ function create_ban_object(domain, reason, note) { async function ban() { - var table = document.getElementById("table"); + var table = document.querySelector("table"); var row = table.insertRow(-1); var elems = { @@ -59,25 +59,42 @@ async function ban() { elems.reason.value = null; elems.note.value = null; - document.querySelectorAll("details.section").forEach((elem) => { - elem.open = false; - }); + row.querySelector("details").open = false; } async function update_ban(domain) { var row = document.getElementById(domain); + + var elems = { + "reason": row.querySelector("textarea.reason"), + "note": row.querySelector("textarea.note") + } + + var values = { + "domain": domain, + "reason": elems.reason.value, + "note": elems.note.value + } + + try { + await client.request("PATCH", "v1/domain_ban", values) + + } catch (error) { + alert(error); + return; + } + + row.querySelector("details").open = false; } async function unban(domain) { - console.log(domain); - try { await client.unban(domain); - } catch (err) { - alert(err); + } catch (error) { + alert(error); return; } diff --git a/relay/views/api.py b/relay/views/api.py index 4b9dc6c..9428d47 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -274,6 +274,54 @@ class DomainBan(View): 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.session() 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') + + + async def patch(self, request: Request) -> Response: + with self.database.session() as conn: + data = await self.get_api_data(['domain'], ['note', 'reason']) + + if isinstance(data, Response): + return data + + if not conn.get_domain_ban(data['domain']): + return Response.new_error(404, 'Domain not banned', 'json') + + 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(**data) + + return Response.new(ban, ctype = 'json') + + + async def delete(self, request: Request) -> Response: + with self.database.session() as conn: + data = await self.get_api_data(['domain'], []) + + if isinstance(data, Response): + return data + + if not conn.get_domain_ban(data['domain']): + return Response.new_error(404, 'Domain not banned', 'json') + + conn.del_domain_ban(data['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: From aeb84d7a72737a6ecf1f5a3845d75bc4f62c2e39 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Thu, 14 Mar 2024 21:58:40 -0400 Subject: [PATCH 12/57] use api for software bans admin page --- relay/frontend/page/admin-domain_bans.haml | 6 +- relay/frontend/page/admin-software_bans.haml | 36 ++++--- relay/frontend/static/api.js | 16 --- relay/frontend/static/domain_ban.js | 6 +- relay/frontend/static/software_ban.js | 102 +++++++++++++++++++ relay/views/api.py | 2 +- 6 files changed, 126 insertions(+), 42 deletions(-) create mode 100644 relay/frontend/static/software_ban.js diff --git a/relay/frontend/page/admin-domain_bans.haml b/relay/frontend/page/admin-domain_bans.haml index 43636ae..a9fdbe0 100644 --- a/relay/frontend/page/admin-domain_bans.haml +++ b/relay/frontend/page/admin-domain_bans.haml @@ -9,13 +9,13 @@ %summary << Ban Domain #add-item %label(for="new-domain") << Domain - %input(type="domain" id="new-domain" name="domain" placeholder="Domain") + %input(type="domain" id="new-domain" placeholder="Domain") %label(for="new-reason") << Ban Reason - %textarea(id="new-reason" name="new") << {{""}} + %textarea(id="new-reason") << {{""}} %label(for="new-note") << Admin Note - %textarea(id="new-note" name="note") << {{""}} + %textarea(id="new-note") << {{""}} %input(type="button" value="Ban Domain" onclick="ban();") diff --git a/relay/frontend/page/admin-software_bans.haml b/relay/frontend/page/admin-software_bans.haml index cf1576c..00f4043 100644 --- a/relay/frontend/page/admin-software_bans.haml +++ b/relay/frontend/page/admin-software_bans.haml @@ -7,18 +7,17 @@ -block content %details.section %summary << Ban Software - %form(action="/admin/software_bans" method="POST") - #add-item - %label(for="name") << Name - %input(id="name" name="name" placeholder="Name") + #add-item + %label(for="new-name") << Domain + %input(type="name" id="new-name" placeholder="Domain") - %label(for="reason") << Ban Reason - %textarea(id="reason" name="reason") << {{""}} + %label(for="new-reason") << Ban Reason + %textarea(id="new-reason") << {{""}} - %label(for="note") << Admin Note - %textarea(id="note" name="note") << {{""}} + %label(for="new-note") << Admin Note + %textarea(id="new-note") << {{""}} - %input(type="submit" value="Ban Software") + %input(type="submit" value="Ban Software" onclick="ban()") %fieldset.section %legend << Software Bans @@ -33,23 +32,22 @@ %tbody -for ban in bans - %tr + %tr(id="{{ban.name}}") %td.name %details %summary -> =ban.name - %form(action="/admin/software_bans" method="POST") - .grid-2col - .reason << Reason - %textarea.reason(id="reason" name="reason") << {{ban.reason or ""}} - .note << Note - %textarea.note(id="note" name="note") << {{ban.note or ""}} + .grid-2col + %label.reason(for="{{ban.name}}-reason") << Reason + %textarea.reason(id="{{ban.name}}-reason") << {{ban.reason or ""}} - %input(type="hidden" name="name" value="{{ban.name}}") - %input(type="submit" value="Update") + %label.note(for="{{ban.name}}-note") << Note + %textarea.note(id="{{ban.name}}-note") << {{ban.note or ""}} + + %input(type="button" value="Update" onclick="update_ban('{{ban.name}}')") %td.date =ban.created.strftime("%Y-%m-%d") %td.remove - %a(href="/admin/software_bans/delete/{{ban.name}}" title="Unban software") << ✖ + %a(href="#" onclick="unban('{{ban.name}}')" title="Unban name") << ✖ diff --git a/relay/frontend/static/api.js b/relay/frontend/static/api.js index a2d3fbb..43dd433 100644 --- a/relay/frontend/static/api.js +++ b/relay/frontend/static/api.js @@ -68,22 +68,6 @@ class Client { return message; } - - async ban(domain, reason, note) { - const params = { - "domain": domain, - "reason": reason, - "note": note - } - - return await this.request("POST", "v1/domain_ban", params); - } - - - async unban(domain) { - const params = {"domain": domain} - return await this.request("DELETE", "v1/domain_ban", params); - } } diff --git a/relay/frontend/static/domain_ban.js b/relay/frontend/static/domain_ban.js index b7ae6b6..f01f675 100644 --- a/relay/frontend/static/domain_ban.js +++ b/relay/frontend/static/domain_ban.js @@ -35,7 +35,7 @@ async function ban() { } try { - var ban = await client.ban(values.domain, values.reason, values.note); + var ban = await client.request("POST", "v1/domain_ban", values); } catch (err) { alert(err); @@ -59,7 +59,7 @@ async function ban() { elems.reason.value = null; elems.note.value = null; - row.querySelector("details").open = false; + document.querySelector("details.section").open = false; } @@ -91,7 +91,7 @@ async function update_ban(domain) { async function unban(domain) { try { - await client.unban(domain); + await client.request("DELETE", "v1/domain_ban", {"domain": domain}); } catch (error) { alert(error); diff --git a/relay/frontend/static/software_ban.js b/relay/frontend/static/software_ban.js new file mode 100644 index 0000000..72e775d --- /dev/null +++ b/relay/frontend/static/software_ban.js @@ -0,0 +1,102 @@ +function create_ban_object(name, reason, note) { + var text = '
\n'; + text += `${name}\n`; + text += '
\n'; + text += `\n`; + text += `\n`; + text += `\n`; + text += `\n`; + text += ``; + text += '
'; + + return text; +} + + +async function ban() { + var table = document.querySelector("table"); + var row = table.insertRow(-1); + + var elems = { + name: document.getElementById("new-name"), + reason: document.getElementById("new-reason"), + note: document.getElementById("new-note") + } + + var values = { + name: elems.name.value.trim(), + reason: elems.reason.value, + note: elems.note.value + } + + if (values.name === "") { + alert("Domain is required"); + return; + } + + try { + var ban = await client.request("POST", "v1/software_ban", values); + + } catch (err) { + alert(err); + return + } + + row.id = ban.name; + var new_name = row.insertCell(0); + var new_date = row.insertCell(1); + var new_remove = row.insertCell(2); + + new_name.className = "name"; + new_date.className = "date"; + new_remove.className = "remove"; + + new_name.innerHTML = create_ban_object(ban.name, ban.reason, ban.note); + new_date.innerHTML = get_date_string(ban.created); + new_remove.innerHTML = ``; + + elems.name.value = null; + elems.reason.value = null; + elems.note.value = null; + + document.querySelector("details.section").open = false; +} + + +async function update_ban(name) { + var row = document.getElementById(name); + + var elems = { + "reason": row.querySelector("textarea.reason"), + "note": row.querySelector("textarea.note") + } + + var values = { + "name": name, + "reason": elems.reason.value, + "note": elems.note.value + } + + try { + await client.request("PATCH", "v1/software_ban", values) + + } catch (error) { + alert(error); + return; + } + + row.querySelector("details").open = false; +} + + +async function unban(name) { + try { + await client.request("DELETE", "v1/software_ban", {"name": name}); + + } catch (error) { + alert(error); + return; + } + + document.getElementById(name).remove(); +} diff --git a/relay/views/api.py b/relay/views/api.py index 9428d47..db7ac7a 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -359,7 +359,7 @@ class SoftwareBan(View): 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(data['name'], **data) + ban = conn.update_software_ban(**data) return Response.new(ban, ctype = 'json') From f775335e80ffc0beae303c9d507fe246b27dfb66 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Thu, 14 Mar 2024 23:48:22 -0400 Subject: [PATCH 13/57] use api for instances admin page --- relay/frontend/page/admin-instances.haml | 43 +++++----- relay/frontend/static/api.js | 13 ++- relay/frontend/static/domain_ban.js | 4 +- relay/frontend/static/instance.js | 105 +++++++++++++++++++++++ relay/views/api.py | 17 ++-- 5 files changed, 151 insertions(+), 31 deletions(-) create mode 100644 relay/frontend/static/instance.js diff --git a/relay/frontend/page/admin-instances.haml b/relay/frontend/page/admin-instances.haml index aaab1c9..fcb0630 100644 --- a/relay/frontend/page/admin-instances.haml +++ b/relay/frontend/page/admin-instances.haml @@ -1,29 +1,32 @@ -extends "base.haml" -set page="Instances" + +-block head + %script(type="application/javascript" src="/static/instance.js" nonce="{{view.request['hash']}}") + -block content %details.section %summary << Add Instance - %form(action="/admin/instances" method="POST") - #add-item - %label(for="domain") << Domain - %input(type="domain" id="domain" name="domain" placeholder="Domain") + #add-item + %label(for="new-actor") << Actor + %input(type="url" id="new-actor" placeholder="Actor URL") - %label(for="actor") << Actor URL - %input(type="url" id="actor" name="actor" placeholder="Actor URL") + %label(for="new-inbox") << Inbox + %input(type="url" id="new-inbox" placeholder="Inbox URL") - %label(for="inbox") << Inbox URL - %input(type="url" id="inbox" name="inbox" placeholder="Inbox URL") + %label(for="new-followid") << Follow ID + %input(type="url" id="new-followid" placeholder="Follow ID URL") - %label(for="software") << Software - %input(name="software" id="software" placeholder="software") + %label(for="new-software") << Software + %input(id="new-software" placeholder="software") - %input(type="submit" value="Add Instance") + %input(type="button" value="Add Instance", onclick="add_instance()") -if requests - %fieldset.section + %fieldset.section.requests %legend << Follow Requests .data-table - %table + %table#requests %thead %tr %td.instance << Instance @@ -34,7 +37,7 @@ %tbody -for request in requests - %tr + %tr(id="{{request.domain}}") %td.instance %a(href="https://{{request.domain}}" target="_new") -> =request.domain @@ -45,16 +48,16 @@ =request.created.strftime("%Y-%m-%d") %td.approve - %a(href="/admin/instances/approve/{{request.domain}}" title="Approve Request") << ✓ + %a(href="#" onclick="req_response('{{request.domain}}', true)" title="Approve Request") << ✓ %td.deny - %a(href="/admin/instances/deny/{{request.domain}}" title="Deny Request") << ✖ + %a(href="#" onclick="req_response('{{request.domain}}', false)" title="Deny Request") << ✖ - %fieldset.section + %fieldset.section.instances %legend << Instances .data-table - %table + %table#instances %thead %tr %td.instance << Instance @@ -64,7 +67,7 @@ %tbody -for instance in instances - %tr + %tr(id="{{instance.domain}}") %td.instance %a(href="https://{{instance.domain}}/" target="_new") -> =instance.domain @@ -75,4 +78,4 @@ =instance.created.strftime("%Y-%m-%d") %td.remove - %a(href="/admin/instances/delete/{{instance.domain}}" title="Remove Instance") << ✖ + %a(href="#" onclick="del_instance('{{instance.domain}}')" title="Remove Instance") << ✖ diff --git a/relay/frontend/static/api.js b/relay/frontend/static/api.js index 43dd433..cbdb1c9 100644 --- a/relay/frontend/static/api.js +++ b/relay/frontend/static/api.js @@ -62,8 +62,17 @@ class Client { throw new Error(message.error); } - if (Object.hasOwn(message, "created")) { - message.created = new Date(message.created); + if (Array.isArray(message)) { + message.forEach((msg) => { + if (Object.hasOwn(msg, "created")) { + msg.created = new Date(msg.created); + } + }); + + } else { + if (Object.hasOwn(message, "created")) { + message.created = new Date(message.created); + } } return message; diff --git a/relay/frontend/static/domain_ban.js b/relay/frontend/static/domain_ban.js index f01f675..ae92420 100644 --- a/relay/frontend/static/domain_ban.js +++ b/relay/frontend/static/domain_ban.js @@ -15,8 +15,6 @@ function create_ban_object(domain, reason, note) { async function ban() { var table = document.querySelector("table"); - var row = table.insertRow(-1); - var elems = { domain: document.getElementById("new-domain"), reason: document.getElementById("new-reason"), @@ -42,7 +40,9 @@ async function ban() { return } + var row = table.insertRow(-1); row.id = ban.domain; + var new_domain = row.insertCell(0); var new_date = row.insertCell(1); var new_remove = row.insertCell(2); diff --git a/relay/frontend/static/instance.js b/relay/frontend/static/instance.js new file mode 100644 index 0000000..e5f422c --- /dev/null +++ b/relay/frontend/static/instance.js @@ -0,0 +1,105 @@ +function append_table_row(table, instance) { + var row = table.insertRow(-1); + row.id = instance.domain; + + var domain = row.insertCell(0); + domain.className = "domain"; + domain.innerHTML = `${instance.domain}`; + + var software = row.insertCell(1); + software.className = "software"; + software.innerHTML = instance.software + + var date = row.insertCell(2); + date.className = "date"; + date.innerHTML = get_date_string(instance.created); + + var remove = row.insertCell(3); + remove.className = "remove"; + remove.innerHTML = ``; +} + + +async function add_instance() { + var elems = { + actor: document.getElementById("new-actor"), + inbox: document.getElementById("new-inbox"), + followid: document.getElementById("new-followid"), + software: document.getElementById("new-software") + } + + var values = { + actor: elems.actor.value.trim(), + inbox: elems.inbox.value.trim(), + followid: elems.followid.value.trim(), + software: elems.software.value.trim() + } + + if (values.actor === "") { + alert("Domain, actor, and inbox are required"); + return; + } + + try { + var instance = await client.request("POST", "v1/instance", values); + + } catch (err) { + alert(err); + return + } + + append_table_row(document.getElementById("instances"), instance); + + elems.actor.value = null; + elems.inbox.value = null; + elems.followid.value = null; + elems.software.value = null; + + document.querySelector("details.section").open = false; +} + + +async function del_instance(domain) { + try { + await client.request("DELETE", "v1/instance", {"domain": domain}); + + } catch (error) { + alert(error); + return; + } + + document.getElementById(domain).remove(); +} + + +async function req_response(domain, accept) { + params = { + "domain": domain, + "accept": accept + } + + try { + await client.request("POST", "v1/request", params); + + } catch (error) { + alert(error); + return; + } + + document.getElementById(domain).remove(); + + if (document.getElementById("requests").rows.length < 2) { + document.querySelector("fieldset.requests").remove() + } + + if (!accept) { + return; + } + + instances = await client.request("GET", `v1/instance`, null); + instances.forEach((instance) => { + if (instance.domain === domain) { + append_table_row(document.getElementById("instances"), instance); + } + }); +} diff --git a/relay/views/api.py b/relay/views/api.py index db7ac7a..ca0470c 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -10,7 +10,7 @@ from .base import View, register_route from .. import __version__ from ..database import ConfigData -from ..misc import Message, Response, get_app +from ..misc import Message, Response, boolean, get_app if typing.TYPE_CHECKING: from aiohttp.web import Request @@ -161,7 +161,7 @@ class Config(View): class Inbox(View): async def get(self, request: Request) -> Response: with self.database.session() as conn: - data = tuple(conn.execute('SELECT * FROM inboxes').all()) + data = conn.get_inboxes() return Response.new(data, ctype = 'json') @@ -186,6 +186,12 @@ class Inbox(View): data['inbox'] = actor_data.shared_inbox + if not data.get('software'): + nodeinfo = await self.client.fetch_nodeinfo(data['domain']) + + if nodeinfo is not None: + data['software'] = nodeinfo.sw_name + row = conn.put_inbox(**data) return Response.new(row, ctype = 'json') @@ -206,7 +212,7 @@ class Inbox(View): return Response.new(instance, ctype = 'json') - async def delete(self, request: Request, domain: str) -> Response: + async def delete(self, request: Request) -> Response: with self.database.session() as conn: data = await self.get_api_data(['domain'], []) @@ -232,10 +238,7 @@ class RequestView(View): async def post(self, request: Request) -> Response: data = await self.get_api_data(['domain', 'accept'], []) - - if not isinstance(data['accept'], bool): - atype = type(data['accept']).__name__ - return Response.new_error(400, f'Invalid type for "accept": {atype}', 'json') + data['accept'] = boolean(data['accept']) try: with self.database.session(True) as conn: From ad17fb64f11c849cd533455fc6897f7104c1bae8 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 00:02:24 -0400 Subject: [PATCH 14/57] use api for whitelist admin page --- relay/frontend/page/admin-whitelist.haml | 19 +++++---- relay/frontend/static/instance.js | 2 +- relay/frontend/static/whitelist.js | 49 ++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 relay/frontend/static/whitelist.js diff --git a/relay/frontend/page/admin-whitelist.haml b/relay/frontend/page/admin-whitelist.haml index e0dffd6..77fad7a 100644 --- a/relay/frontend/page/admin-whitelist.haml +++ b/relay/frontend/page/admin-whitelist.haml @@ -1,19 +1,22 @@ -extends "base.haml" -set page="Whitelist" + +-block head + %script(type="application/javascript" src="/static/whitelist.js" nonce="{{view.request['hash']}}") + -block content %details.section %summary << Add Domain - %form(action="/admin/whitelist" method="POST") - #add-item - %label(for="domain") << Domain - %input(type="domain" id="domain" name="domain" placeholder="Domain") + #add-item + %label(for="new-domain") << Domain + %input(type="domain" id="new-domain" placeholder="Domain") - %input(type="submit" value="Add Domain") + %input(type="button" value="Add Domain", onclick="add_whitelist()") %fieldset.data-table.section %legend << Whitelist - %table + %table#whitelist %thead %tr %td.domain << Domain @@ -22,7 +25,7 @@ %tbody -for item in whitelist - %tr + %tr(id="{{item.domain}}") %td.domain =item.domain @@ -30,4 +33,4 @@ =item.created.strftime("%Y-%m-%d") %td.remove - %a(href="/admin/whitelist/delete/{{item.domain}}" title="Remove whitlisted domain") << ✖ + %a(href="#" onclick="del_whitelist('{{item.domain}}')" title="Remove whitlisted domain") << ✖ diff --git a/relay/frontend/static/instance.js b/relay/frontend/static/instance.js index e5f422c..27351eb 100644 --- a/relay/frontend/static/instance.js +++ b/relay/frontend/static/instance.js @@ -36,7 +36,7 @@ async function add_instance() { } if (values.actor === "") { - alert("Domain, actor, and inbox are required"); + alert("Actor is required"); return; } diff --git a/relay/frontend/static/whitelist.js b/relay/frontend/static/whitelist.js new file mode 100644 index 0000000..86f02fb --- /dev/null +++ b/relay/frontend/static/whitelist.js @@ -0,0 +1,49 @@ +async function add_whitelist() { + var domain_elem = document.getElementById("new-domain"); + var domain = domain_elem.value.trim(); + + if (domain === "") { + alert("Domain is required"); + return; + } + + try { + var item = await client.request("POST", "v1/whitelist", {"domain": domain}); + + } catch (err) { + alert(err); + return + } + + var table = document.getElementById("whitelist"); + var row = table.insertRow(-1); + row.id = item.domain; + + var domain = row.insertCell(0); + domain.className = "domain"; + domain.innerHTML = item.domain; + + var date = row.insertCell(1); + date.className = "date"; + date.innerHTML = get_date_string(item.created); + + var remove = row.insertCell(2); + remove.className = "remove"; + remove.innerHTML = ``; + + domain_elem.value = null; + document.querySelector("details.section").open = false; +} + + +async function del_whitelist(domain) { + try { + await client.request("DELETE", "v1/whitelist", {"domain": domain}); + + } catch (error) { + alert(error); + return; + } + + document.getElementById(domain).remove(); +} From 31f5decc4a79f47b11f345e750bf68482be2f7b0 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 00:30:04 -0400 Subject: [PATCH 15/57] create append_table_row js function --- relay/frontend/static/api.js | 18 ++++++++++++++ relay/frontend/static/domain_ban.js | 19 ++++---------- relay/frontend/static/instance.js | 36 +++++++++------------------ relay/frontend/static/software_ban.js | 17 ++++--------- relay/frontend/static/whitelist.js | 20 ++++----------- 5 files changed, 45 insertions(+), 65 deletions(-) diff --git a/relay/frontend/static/api.js b/relay/frontend/static/api.js index cbdb1c9..e4a1219 100644 --- a/relay/frontend/static/api.js +++ b/relay/frontend/static/api.js @@ -27,6 +27,24 @@ function get_date_string(date) { } +function append_table_row(table, row_name, row) { + var table_row = table.insertRow(-1); + table_row.id = row_name; + + index = 0; + + for (var prop in row) { + if (Object.prototype.hasOwnProperty.call(row, prop)) { + var cell = table_row.insertCell(index); + cell.className = prop; + cell.innerHTML = row[prop]; + + index += 1; + } + } +} + + class Client { constructor() { this.token = get_cookie("user-token"); diff --git a/relay/frontend/static/domain_ban.js b/relay/frontend/static/domain_ban.js index ae92420..a86c7ba 100644 --- a/relay/frontend/static/domain_ban.js +++ b/relay/frontend/static/domain_ban.js @@ -40,20 +40,11 @@ async function ban() { return } - var row = table.insertRow(-1); - row.id = ban.domain; - - var new_domain = row.insertCell(0); - var new_date = row.insertCell(1); - var new_remove = row.insertCell(2); - - new_domain.className = "domain"; - new_date.className = "date"; - new_remove.className = "remove"; - - new_domain.innerHTML = create_ban_object(ban.domain, ban.reason, ban.note); - new_date.innerHTML = get_date_string(ban.created); - new_remove.innerHTML = ``; + append_table_row(document.getElementById("instances"), ban.domain, { + domain: create_ban_object(ban.domain, ban.reason, ban.note), + date: get_date_string(ban.created), + remove: `` + }); elems.domain.value = null; elems.reason.value = null; diff --git a/relay/frontend/static/instance.js b/relay/frontend/static/instance.js index 27351eb..e05d2bd 100644 --- a/relay/frontend/static/instance.js +++ b/relay/frontend/static/instance.js @@ -1,25 +1,3 @@ -function append_table_row(table, instance) { - var row = table.insertRow(-1); - row.id = instance.domain; - - var domain = row.insertCell(0); - domain.className = "domain"; - domain.innerHTML = `${instance.domain}`; - - var software = row.insertCell(1); - software.className = "software"; - software.innerHTML = instance.software - - var date = row.insertCell(2); - date.className = "date"; - date.innerHTML = get_date_string(instance.created); - - var remove = row.insertCell(3); - remove.className = "remove"; - remove.innerHTML = ``; -} - - async function add_instance() { var elems = { actor: document.getElementById("new-actor"), @@ -48,7 +26,12 @@ async function add_instance() { return } - append_table_row(document.getElementById("instances"), instance); + append_table_row(document.getElementById("instances"), instance.domain, { + domain: `${instance.domain}`, + software: instance.software, + date: get_date_string(instance.created), + remove: `` + }); elems.actor.value = null; elems.inbox.value = null; @@ -99,7 +82,12 @@ async function req_response(domain, accept) { instances = await client.request("GET", `v1/instance`, null); instances.forEach((instance) => { if (instance.domain === domain) { - append_table_row(document.getElementById("instances"), instance); + append_table_row(document.getElementById("instances"), instance.domain, { + domain: `${instance.domain}`, + software: instance.software, + date: get_date_string(instance.created), + remove: `` + }); } }); } diff --git a/relay/frontend/static/software_ban.js b/relay/frontend/static/software_ban.js index 72e775d..2e0796d 100644 --- a/relay/frontend/static/software_ban.js +++ b/relay/frontend/static/software_ban.js @@ -42,18 +42,11 @@ async function ban() { return } - row.id = ban.name; - var new_name = row.insertCell(0); - var new_date = row.insertCell(1); - var new_remove = row.insertCell(2); - - new_name.className = "name"; - new_date.className = "date"; - new_remove.className = "remove"; - - new_name.innerHTML = create_ban_object(ban.name, ban.reason, ban.note); - new_date.innerHTML = get_date_string(ban.created); - new_remove.innerHTML = ``; + append_table_row(document.getElementById("instances"), ban.name, { + name: create_ban_object(ban.name, ban.reason, ban.note), + date: get_date_string(ban.created), + remove: `` + }); elems.name.value = null; elems.reason.value = null; diff --git a/relay/frontend/static/whitelist.js b/relay/frontend/static/whitelist.js index 86f02fb..bf6bf5d 100644 --- a/relay/frontend/static/whitelist.js +++ b/relay/frontend/static/whitelist.js @@ -15,21 +15,11 @@ async function add_whitelist() { return } - var table = document.getElementById("whitelist"); - var row = table.insertRow(-1); - row.id = item.domain; - - var domain = row.insertCell(0); - domain.className = "domain"; - domain.innerHTML = item.domain; - - var date = row.insertCell(1); - date.className = "date"; - date.innerHTML = get_date_string(item.created); - - var remove = row.insertCell(2); - remove.className = "remove"; - remove.innerHTML = ``; + append_table_row(document.getElementById("instances"), item.domain, { + domain: item.domain, + date: get_date_string(ban.created), + remove: `` + }); domain_elem.value = null; document.querySelector("details.section").open = false; From 08f4f0e72dd9da92dbbce7551d9d048bf4158bc5 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 04:53:43 -0400 Subject: [PATCH 16/57] use api for admin config page --- relay/frontend/page/admin-config.haml | 32 +++++++++++++------------ relay/frontend/static/config.js | 34 +++++++++++++++++++++++++++ relay/views/api.py | 2 ++ 3 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 relay/frontend/static/config.js diff --git a/relay/frontend/page/admin-config.haml b/relay/frontend/page/admin-config.haml index ff6c4f6..6519c0e 100644 --- a/relay/frontend/page/admin-config.haml +++ b/relay/frontend/page/admin-config.haml @@ -1,27 +1,29 @@ -extends "base.haml" -set page="Config" + +-block head + %script(type="application/javascript" src="/static/config.js" nonce="{{view.request['hash']}}" defer) + -import "functions.haml" as func -block content %fieldset.section %legend << Config - %form(action="/admin/config" method="POST") - .grid-2col - %label(for="name") << Name - %input(id = "name" name="name" placeholder="Relay Name" value="{{config.name or ''}}") - %label(for="description") << Description - %textarea(id="description" name="note" value="{{config.note or ''}}") << {{config.note}} + .grid-2col + %label(for="name") << Name + %input(id = "name" name="name" placeholder="Relay Name" value="{{config.name or ''}}") - %label(for="theme") << Color Theme - =func.new_select("theme", config.theme, themes) + %label(for="description") << Description + %textarea(id="description" name="note" value="{{config.note or ''}}") << {{config.note}} - %label(for="log-level") << Log Level - =func.new_select("log-level", config.log_level.name, levels) + %label(for="theme") << Color Theme + =func.new_select("theme", config.theme, themes) - %label(for="whitelist-enabled") << Whitelist - =func.new_checkbox("whitelist-enabled", config.whitelist_enabled) + %label(for="log-level") << Log Level + =func.new_select("log-level", config.log_level.name, levels) - %label(for="approval-required") << Approval Required - =func.new_checkbox("approval-required", config.approval_required) + %label(for="whitelist-enabled") << Whitelist + =func.new_checkbox("whitelist-enabled", config.whitelist_enabled) - %input(type="submit" value="Save") + %label(for="approval-required") << Approval Required + =func.new_checkbox("approval-required", config.approval_required) diff --git a/relay/frontend/static/config.js b/relay/frontend/static/config.js new file mode 100644 index 0000000..5d7b52f --- /dev/null +++ b/relay/frontend/static/config.js @@ -0,0 +1,34 @@ +const elems = [ + document.querySelector("#name"), + document.querySelector("#description"), + document.querySelector("#theme"), + document.querySelector("#log-level"), + document.querySelector("#whitelist-enabled"), + document.querySelector("#approval-required") +] + + +async function handle_config_change(event) { + params = { + key: event.target.id, + value: event.target.type === "checkbox" ? event.target.checked : event.target.value + } + + try { + await client.request("POST", "v1/config", params); + + } catch (error) { + alert(error); + return; + } + + if (params.key === "name") { + document.querySelector("#header .title").innerHTML = params.value; + document.querySelector("title").innerHTML = params.value; + } +} + + +for (const elem of elems) { + elem.addEventListener("change", handle_config_change); +} diff --git a/relay/views/api.py b/relay/views/api.py index ca0470c..ddd80e4 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -133,6 +133,8 @@ class Config(View): if isinstance(data, Response): return data + data['key'] = data['key'].replace('-', '_'); + if data['key'] not in ConfigData.USER_KEYS(): return Response.new_error(400, 'Invalid key', 'json') From 1ffc6090580b45bac4cadc0560f0cb8c14204842 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 06:46:31 -0400 Subject: [PATCH 17/57] add user management api endpoints and allow cookie for api auth --- relay/data/swagger.yaml | 117 +++++++++++++++++++++++++++++++++++ relay/database/connection.py | 29 ++++++--- relay/views/api.py | 63 ++++++++++++++++++- relay/views/base.py | 2 +- 4 files changed, 202 insertions(+), 9 deletions(-) diff --git a/relay/data/swagger.yaml b/relay/data/swagger.yaml index b4136ec..a2a51dc 100644 --- a/relay/data/swagger.yaml +++ b/relay/data/swagger.yaml @@ -13,6 +13,10 @@ schemes: - https securityDefinitions: + Cookie: + type: apiKey + in: cookie + name: user-token Bearer: type: apiKey name: Authorization @@ -549,6 +553,104 @@ paths: schema: $ref: "#/definitions/Error" + /v1/user: + get: + tags: + - User + description: Get a list of all local users + produces: + - application/json + responses: + "200": + description: List of users + schema: + type: array + items: + $ref: "#/definitions/User" + + post: + tags: + - User + description: Create a new user + parameters: + - in: formData + name: username + required: true + type: string + - in: formData + name: password + required: true + type: string + format: password + - in: formData + name: handle + required: false + type: string + format: email + produces: + - application/json + responses: + "200": + description: Newly created user + schema: + $ref: "#/definitions/User" + "404": + description: User already exists + schema: + $ref: "#/definitions/Error" + + patch: + tags: + - User + description: Update a user's password or handle + parameters: + - in: formData + name: username + required: true + type: string + - in: formData + name: password + required: false + type: string + format: password + - in: formData + name: handle + required: false + type: string + format: email + produces: + - application/json + responses: + "200": + description: Updated user data + schema: + $ref: "#/definitions/User" + "404": + description: User does not exist + schema: + $ref: "#/definitions/Error" + + delete: + tags: + - User + description: Delete a user + parameters: + - in: formData + name: username + required: true + type: string + produces: + - application/json + responses: + "202": + description: Successfully deleted user + schema: + $ref: "#/definitions/Message" + "404": + description: User not found + schema: + $ref: "#/definitions/Error" + /v1/whitelist: get: tags: @@ -748,6 +850,21 @@ definitions: description: Character string used for authenticating with the api type: string + User: + type: object + properties: + username: + description: Username of the account + type: string + handle: + description: Fediverse handle associated with the account + type: string + format: email + created: + description: Date the account was created + type: string + format: date-time + Whitelist: type: object properties: diff --git a/relay/database/connection.py b/relay/database/connection.py index 6f77c31..4f9ff21 100644 --- a/relay/database/connection.py +++ b/relay/database/connection.py @@ -190,13 +190,28 @@ class Connection(SqlConnection): return cur.one() # type: ignore - 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) - } + def put_user(self, username: str, password: str | None, handle: str | None = None) -> Row: + if self.get_user(username): + data = { + 'username': username + } + + if password: + data['password'] = password + + if handle: + data['handler'] = handle + + else: + if password is None: + raise ValueError('Password cannot be empty') + + data = { + 'username': username, + 'hash': self.hasher.hash(password), + 'handle': handle, + 'created': datetime.now(tz = timezone.utc) + } with self.run('put-user', data) as cur: return cur.one() # type: ignore diff --git a/relay/views/api.py b/relay/views/api.py index ddd80e4..96f42fa 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -34,7 +34,11 @@ def check_api_path(method: str, path: str) -> bool: @web.middleware async def handle_api_path(request: Request, handler: Callable) -> Response: try: - request['token'] = request.headers['Authorization'].replace('Bearer', '').strip() + if (token := request.cookies.get('user-token')): + request['token'] = token + + else: + request['token'] = request.headers['Authorization'].replace('Bearer', '').strip() with get_app().database.session() as conn: request['user'] = conn.get_user_by_token(request['token']) @@ -384,6 +388,63 @@ class SoftwareBan(View): return Response.new({'message': 'Unbanned software'}, ctype = 'json') +@register_route('/api/v1/user') +class User(View): + async def get(self, request: Request) -> Response: + with self.database.session() as conn: + items = [] + + for row in conn.execute('SELECT * FROM users'): + del row['hash'] + items.append(row) + + return Response.new(items, ctype = 'json') + + + async def post(self, request: Request) -> Response: + data = await self.get_api_data(['username', 'password'], ['handle']) + + if isinstance(data, Response): + return data + + with self.database.session() as conn: + if conn.get_user(data['username']): + return Response.new_error(404, 'User already exists', 'json') + + user = conn.put_user(**data) + del user['hash'] + + return Response.new(user, ctype = 'json') + + + async def patch(self, request: Request) -> Response: + data = await self.get_api_data(['username'], ['password', ['handle']]) + + if isinstance(data, Response): + return data + + with self.database.session(True) as conn: + user = conn.put_user(**data) + del user['hash'] + + return Response.new(user, ctype = 'json') + + + async def delete(self, request: Request) -> Response: + data = await self.get_api_data(['username'], []) + + if isinstance(data, Response): + return data + + with self.database.session(True) as conn: + if not conn.get_user(data['username']): + return Response.new_error(404, 'User does not exist', 'json') + + conn.del_user(data['username']) + + return Response.new({'message': 'Deleted user'}, ctype = 'json') + + @register_route('/api/v1/whitelist') class Whitelist(View): async def get(self, request: Request) -> Response: diff --git a/relay/views/base.py b/relay/views/base.py index 2e035f6..9709e34 100644 --- a/relay/views/base.py +++ b/relay/views/base.py @@ -126,7 +126,7 @@ class View(AbstractView): return Response.new_error(400, 'Invalid JSON data', 'json') else: - post_data = convert_data(await self.request.query) # type: ignore + post_data = convert_data(self.request.query) # type: ignore data = {} From 50c323ba1e2113030d261b0a88a18cabb2ef1271 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 06:46:55 -0400 Subject: [PATCH 18/57] use api for users admin page --- relay/frontend/page/admin-users.haml | 31 +++++++------- relay/frontend/static/user.js | 60 ++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 relay/frontend/static/user.js diff --git a/relay/frontend/page/admin-users.haml b/relay/frontend/page/admin-users.haml index 67ab7b1..f872dfb 100644 --- a/relay/frontend/page/admin-users.haml +++ b/relay/frontend/page/admin-users.haml @@ -1,29 +1,32 @@ -extends "base.haml" -set page="Users" + +-block head + %script(type="application/javascript" src="/static/user.js" nonce="{{view.request['hash']}}") + -block content %details.section %summary << Add User - %form(action="/admin/users", method="POST") - #add-item - %label(for="username") << Username - %input(id="username" name="username" placeholder="Username") + #add-item + %label(for="new-username") << Username + %input(id="new-username" name="username" placeholder="Username") - %label(for="password") << Password - %input(type="password" id="password" name="password" placeholder="Password") + %label(for="new-password") << Password + %input(id="new-password" type="password" placeholder="Password") - %label(for="password2") << Password Again - %input(type="password" id="password2" name="password2" placeholder="Password Again") + %label(for="new-password2") << Password Again + %input(id="new-password2" type="password" placeholder="Password Again") - %label(for="handle") << Handle - %input(type="email" name="handle" id="handle" placeholder="handle") + %label(for="new-handle") << Handle + %input(id="new-handle" type="email" placeholder="handle") - %input(type="submit" value="Add User") + %input(type="button" value="Add User" onclick="add_user()") %fieldset.section %legend << Users .data-table - %table + %table#users %thead %tr %td.username << Username @@ -33,7 +36,7 @@ %tbody -for user in users - %tr + %tr(id="{{user.username}}") %td.username =user.username @@ -44,4 +47,4 @@ =user.created.strftime("%Y-%m-%d") %td.remove - %a(href="/admin/users/delete/{{user.username}}" title="Remove User") << ✖ + %a(href="#" onclick="del_user('{{user.username}}')" title="Remove User") << ✖ diff --git a/relay/frontend/static/user.js b/relay/frontend/static/user.js new file mode 100644 index 0000000..7cfdeee --- /dev/null +++ b/relay/frontend/static/user.js @@ -0,0 +1,60 @@ +async function add_user() { + var elems = { + username: document.getElementById("new-username"), + password: document.getElementById("new-password"), + password2: document.getElementById("new-password2"), + handle: document.getElementById("new-handle") + } + + var values = { + username: elems.username.value.trim(), + password: elems.password.value.trim(), + password2: elems.password2.value.trim(), + handle: elems.handle.value.trim() + } + + if (values.username === "" | values.password === "" | values.password2 === "") { + alert("Username, password, and password2 are required"); + return; + } + + if (values.password !== values.password2) { + alert("Passwords do not match"); + return; + } + + try { + var user = await client.request("POST", "v1/user", values); + + } catch (err) { + alert(err); + return + } + + append_table_row(document.getElementById("users"), user.username, { + domain: user.username, + handle: user.handle, + date: get_date_string(user.created), + remove: `` + }); + + elems.username.value = null; + elems.password.value = null; + elems.password2.value = null; + elems.handle.value = null; + + document.querySelector("details.section").open = false; +} + + +async function del_user(username) { + try { + await client.request("DELETE", "v1/user", {"username": username}); + + } catch (error) { + alert(error); + return; + } + + document.getElementById(username).remove(); +} From 0f3b72830bd25023a384e4bd9f81fef0ffdf9e7e Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 06:50:08 -0400 Subject: [PATCH 19/57] use cookie auth for frontend --- relay/frontend/static/api.js | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/relay/frontend/static/api.js b/relay/frontend/static/api.js index e4a1219..b0a2bff 100644 --- a/relay/frontend/static/api.js +++ b/relay/frontend/static/api.js @@ -1,15 +1,3 @@ -function get_cookie(name) { - const regex = new RegExp(`(^| )` + name + `=([^;]+)`); - const match = document.cookie.match(regex); - - if (match) { - return match[2] - } - - return null; -} - - function get_date_string(date) { var year = date.getFullYear().toString(); var month = date.getMonth().toString(); @@ -46,11 +34,6 @@ function append_table_row(table, row_name, row) { class Client { - constructor() { - this.token = get_cookie("user-token"); - } - - async request(method, path, body = null) { var headers = { "Accept": "application/json" @@ -61,10 +44,6 @@ class Client { body = JSON.stringify(body) } - if (this.token !== null) { - headers["Authorization"] = "Bearer " + this.token; - } - const response = await fetch("/api/" + path, { method: method, mode: "cors", From b068f4f91e1569e184a5349d85c67c78213be381 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 06:55:52 -0400 Subject: [PATCH 20/57] move request method out of client class --- relay/frontend/static/api.js | 71 +++++++++++++-------------- relay/frontend/static/config.js | 2 +- relay/frontend/static/domain_ban.js | 6 +-- relay/frontend/static/instance.js | 8 +-- relay/frontend/static/software_ban.js | 6 +-- relay/frontend/static/user.js | 4 +- relay/frontend/static/whitelist.js | 8 +-- 7 files changed, 50 insertions(+), 55 deletions(-) diff --git a/relay/frontend/static/api.js b/relay/frontend/static/api.js index b0a2bff..e5a3c9b 100644 --- a/relay/frontend/static/api.js +++ b/relay/frontend/static/api.js @@ -33,48 +33,43 @@ function append_table_row(table, row_name, row) { } -class Client { - async request(method, path, body = null) { - var headers = { - "Accept": "application/json" - } +async function request(method, path, body = null) { + var headers = { + "Accept": "application/json" + } - if (body !== null) { - headers["Content-Type"] = "application/json" - body = JSON.stringify(body) - } + if (body !== null) { + headers["Content-Type"] = "application/json" + body = JSON.stringify(body) + } - const response = await fetch("/api/" + path, { - method: method, - mode: "cors", - cache: "no-store", - redirect: "follow", - body: body, - headers: headers + const response = await fetch("/api/" + path, { + method: method, + mode: "cors", + cache: "no-store", + redirect: "follow", + body: body, + headers: headers + }); + + const message = await response.json(); + + if (Object.hasOwn(message, "error")) { + throw new Error(message.error); + } + + if (Array.isArray(message)) { + message.forEach((msg) => { + if (Object.hasOwn(msg, "created")) { + msg.created = new Date(msg.created); + } }); - const message = await response.json(); - - if (Object.hasOwn(message, "error")) { - throw new Error(message.error); + } else { + if (Object.hasOwn(message, "created")) { + message.created = new Date(message.created); } - - if (Array.isArray(message)) { - message.forEach((msg) => { - if (Object.hasOwn(msg, "created")) { - msg.created = new Date(msg.created); - } - }); - - } else { - if (Object.hasOwn(message, "created")) { - message.created = new Date(message.created); - } - } - - return message; } + + return message; } - - -client = new Client(); diff --git a/relay/frontend/static/config.js b/relay/frontend/static/config.js index 5d7b52f..5f592bf 100644 --- a/relay/frontend/static/config.js +++ b/relay/frontend/static/config.js @@ -15,7 +15,7 @@ async function handle_config_change(event) { } try { - await client.request("POST", "v1/config", params); + await request("POST", "v1/config", params); } catch (error) { alert(error); diff --git a/relay/frontend/static/domain_ban.js b/relay/frontend/static/domain_ban.js index a86c7ba..443ccce 100644 --- a/relay/frontend/static/domain_ban.js +++ b/relay/frontend/static/domain_ban.js @@ -33,7 +33,7 @@ async function ban() { } try { - var ban = await client.request("POST", "v1/domain_ban", values); + var ban = await request("POST", "v1/domain_ban", values); } catch (err) { alert(err); @@ -69,7 +69,7 @@ async function update_ban(domain) { } try { - await client.request("PATCH", "v1/domain_ban", values) + await request("PATCH", "v1/domain_ban", values) } catch (error) { alert(error); @@ -82,7 +82,7 @@ async function update_ban(domain) { async function unban(domain) { try { - await client.request("DELETE", "v1/domain_ban", {"domain": domain}); + await request("DELETE", "v1/domain_ban", {"domain": domain}); } catch (error) { alert(error); diff --git a/relay/frontend/static/instance.js b/relay/frontend/static/instance.js index e05d2bd..6b822d1 100644 --- a/relay/frontend/static/instance.js +++ b/relay/frontend/static/instance.js @@ -19,7 +19,7 @@ async function add_instance() { } try { - var instance = await client.request("POST", "v1/instance", values); + var instance = await request("POST", "v1/instance", values); } catch (err) { alert(err); @@ -44,7 +44,7 @@ async function add_instance() { async function del_instance(domain) { try { - await client.request("DELETE", "v1/instance", {"domain": domain}); + await request("DELETE", "v1/instance", {"domain": domain}); } catch (error) { alert(error); @@ -62,7 +62,7 @@ async function req_response(domain, accept) { } try { - await client.request("POST", "v1/request", params); + await request("POST", "v1/request", params); } catch (error) { alert(error); @@ -79,7 +79,7 @@ async function req_response(domain, accept) { return; } - instances = await client.request("GET", `v1/instance`, null); + instances = await request("GET", `v1/instance`, null); instances.forEach((instance) => { if (instance.domain === domain) { append_table_row(document.getElementById("instances"), instance.domain, { diff --git a/relay/frontend/static/software_ban.js b/relay/frontend/static/software_ban.js index 2e0796d..b316df9 100644 --- a/relay/frontend/static/software_ban.js +++ b/relay/frontend/static/software_ban.js @@ -35,7 +35,7 @@ async function ban() { } try { - var ban = await client.request("POST", "v1/software_ban", values); + var ban = await request("POST", "v1/software_ban", values); } catch (err) { alert(err); @@ -71,7 +71,7 @@ async function update_ban(name) { } try { - await client.request("PATCH", "v1/software_ban", values) + await request("PATCH", "v1/software_ban", values) } catch (error) { alert(error); @@ -84,7 +84,7 @@ async function update_ban(name) { async function unban(name) { try { - await client.request("DELETE", "v1/software_ban", {"name": name}); + await request("DELETE", "v1/software_ban", {"name": name}); } catch (error) { alert(error); diff --git a/relay/frontend/static/user.js b/relay/frontend/static/user.js index 7cfdeee..3d23b2b 100644 --- a/relay/frontend/static/user.js +++ b/relay/frontend/static/user.js @@ -24,7 +24,7 @@ async function add_user() { } try { - var user = await client.request("POST", "v1/user", values); + var user = await request("POST", "v1/user", values); } catch (err) { alert(err); @@ -49,7 +49,7 @@ async function add_user() { async function del_user(username) { try { - await client.request("DELETE", "v1/user", {"username": username}); + await request("DELETE", "v1/user", {"username": username}); } catch (error) { alert(error); diff --git a/relay/frontend/static/whitelist.js b/relay/frontend/static/whitelist.js index bf6bf5d..71f883c 100644 --- a/relay/frontend/static/whitelist.js +++ b/relay/frontend/static/whitelist.js @@ -8,16 +8,16 @@ async function add_whitelist() { } try { - var item = await client.request("POST", "v1/whitelist", {"domain": domain}); + var item = await request("POST", "v1/whitelist", {"domain": domain}); } catch (err) { alert(err); return } - append_table_row(document.getElementById("instances"), item.domain, { + append_table_row(document.getElementById("whitelist"), item.domain, { domain: item.domain, - date: get_date_string(ban.created), + date: get_date_string(item.created), remove: `` }); @@ -28,7 +28,7 @@ async function add_whitelist() { async function del_whitelist(domain) { try { - await client.request("DELETE", "v1/whitelist", {"domain": domain}); + await request("DELETE", "v1/whitelist", {"domain": domain}); } catch (error) { alert(error); From 3114e4837110ccb6c4398d69d4525315cac036bd Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 19:47:05 -0400 Subject: [PATCH 21/57] enable csp headers --- relay/application.py | 6 +-- relay/frontend/page/admin-domain_bans.haml | 8 +-- relay/frontend/page/admin-instances.haml | 10 ++-- relay/frontend/page/admin-software_bans.haml | 10 ++-- relay/frontend/page/admin-users.haml | 6 +-- relay/frontend/page/admin-whitelist.haml | 6 +-- relay/frontend/static/api.js | 2 + relay/frontend/static/domain_ban.js | 39 ++++++++++++-- relay/frontend/static/instance.js | 56 ++++++++++++++++++-- relay/frontend/static/software_ban.js | 36 ++++++++++--- relay/frontend/static/user.js | 30 +++++++++-- relay/frontend/static/whitelist.js | 27 +++++++++- 12 files changed, 193 insertions(+), 43 deletions(-) diff --git a/relay/application.py b/relay/application.py index b3da393..58668a8 100644 --- a/relay/application.py +++ b/relay/application.py @@ -130,7 +130,7 @@ class Application(web.Application): data = [ "default-src 'none'", f"script-src 'nonce-{request['hash']}'", - f"style-src 'nonce-{request['hash']}'", + f"style-src 'self' 'nonce-{request['hash']}'", "form-action 'self'", "connect-src 'self'", "img-src 'self'", @@ -287,8 +287,8 @@ async def handle_response_headers(request: web.Request, handler: Callable) -> Re resp.headers['Server'] = 'ActivityRelay' # Still have to figure out how csp headers work - # if resp.content_type == 'text/html': - # resp.headers['Content-Security-Policy'] = Application.DEFAULT.get_csp(request) + if resp.content_type == 'text/html': + resp.headers['Content-Security-Policy'] = Application.DEFAULT.get_csp(request) if not request.app['dev'] and request.path.endswith(('.css', '.js')): # cache for 2 weeks diff --git a/relay/frontend/page/admin-domain_bans.haml b/relay/frontend/page/admin-domain_bans.haml index a9fdbe0..19ae4ae 100644 --- a/relay/frontend/page/admin-domain_bans.haml +++ b/relay/frontend/page/admin-domain_bans.haml @@ -2,7 +2,7 @@ -set page="Domain Bans" -block head - %script(type="application/javascript" src="/static/domain_ban.js" nonce="{{view.request['hash']}}") + %script(type="application/javascript" src="/static/domain_ban.js" nonce="{{view.request['hash']}}" defer) -block content %details.section @@ -17,7 +17,7 @@ %label(for="new-note") << Admin Note %textarea(id="new-note") << {{""}} - %input(type="button" value="Ban Domain" onclick="ban();") + %input#new-ban(type="button" value="Ban Domain") %fieldset.section %legend << Domain Bans @@ -44,10 +44,10 @@ %label.note(for="{{ban.domain}}-note") << Note %textarea.note(id="{{ban.domain}}-note") << {{ban.note or ""}} - %input(type="button" value="Update" onclick="update_ban('{{ban.domain}}')") + %input.update-ban(type="button" value="Update") %td.date =ban.created.strftime("%Y-%m-%d") %td.remove - %a(href="#" onclick="unban('{{ban.domain}}')" title="Unban domain") << ✖ + %a(href="#" title="Unban domain") << ✖ diff --git a/relay/frontend/page/admin-instances.haml b/relay/frontend/page/admin-instances.haml index fcb0630..2e43f48 100644 --- a/relay/frontend/page/admin-instances.haml +++ b/relay/frontend/page/admin-instances.haml @@ -2,7 +2,7 @@ -set page="Instances" -block head - %script(type="application/javascript" src="/static/instance.js" nonce="{{view.request['hash']}}") + %script(type="application/javascript" src="/static/instance.js" nonce="{{view.request['hash']}}" defer) -block content %details.section @@ -20,7 +20,7 @@ %label(for="new-software") << Software %input(id="new-software" placeholder="software") - %input(type="button" value="Add Instance", onclick="add_instance()") + %input#add-instance(type="button" value="Add Instance") -if requests %fieldset.section.requests @@ -48,10 +48,10 @@ =request.created.strftime("%Y-%m-%d") %td.approve - %a(href="#" onclick="req_response('{{request.domain}}', true)" title="Approve Request") << ✓ + %a(href="#" title="Approve Request") << ✓ %td.deny - %a(href="#" onclick="req_response('{{request.domain}}', false)" title="Deny Request") << ✖ + %a(href="#" title="Deny Request") << ✖ %fieldset.section.instances %legend << Instances @@ -78,4 +78,4 @@ =instance.created.strftime("%Y-%m-%d") %td.remove - %a(href="#" onclick="del_instance('{{instance.domain}}')" title="Remove Instance") << ✖ + %a(href="#" title="Remove Instance") << ✖ diff --git a/relay/frontend/page/admin-software_bans.haml b/relay/frontend/page/admin-software_bans.haml index 00f4043..9bda3be 100644 --- a/relay/frontend/page/admin-software_bans.haml +++ b/relay/frontend/page/admin-software_bans.haml @@ -2,7 +2,7 @@ -set page="Software Bans" -block head - %script(type="application/javascript" src="/static/software_ban.js" nonce="{{view.request['hash']}}") + %script(type="application/javascript" src="/static/software_ban.js" nonce="{{view.request['hash']}}" defer) -block content %details.section @@ -17,13 +17,13 @@ %label(for="new-note") << Admin Note %textarea(id="new-note") << {{""}} - %input(type="submit" value="Ban Software" onclick="ban()") + %input#new-ban(type="button" value="Ban Software") %fieldset.section %legend << Software Bans .data-table - %table + %table#bans %thead %tr %td.name << Name @@ -44,10 +44,10 @@ %label.note(for="{{ban.name}}-note") << Note %textarea.note(id="{{ban.name}}-note") << {{ban.note or ""}} - %input(type="button" value="Update" onclick="update_ban('{{ban.name}}')") + %input.update-ban(type="button" value="Update") %td.date =ban.created.strftime("%Y-%m-%d") %td.remove - %a(href="#" onclick="unban('{{ban.name}}')" title="Unban name") << ✖ + %a(href="#" title="Unban name") << ✖ diff --git a/relay/frontend/page/admin-users.haml b/relay/frontend/page/admin-users.haml index f872dfb..2043ba8 100644 --- a/relay/frontend/page/admin-users.haml +++ b/relay/frontend/page/admin-users.haml @@ -2,7 +2,7 @@ -set page="Users" -block head - %script(type="application/javascript" src="/static/user.js" nonce="{{view.request['hash']}}") + %script(type="application/javascript" src="/static/user.js" nonce="{{view.request['hash']}}" defer) -block content %details.section @@ -20,7 +20,7 @@ %label(for="new-handle") << Handle %input(id="new-handle" type="email" placeholder="handle") - %input(type="button" value="Add User" onclick="add_user()") + %input#new-user(type="button" value="Add User") %fieldset.section %legend << Users @@ -47,4 +47,4 @@ =user.created.strftime("%Y-%m-%d") %td.remove - %a(href="#" onclick="del_user('{{user.username}}')" title="Remove User") << ✖ + %a(href="#" title="Remove User") << ✖ diff --git a/relay/frontend/page/admin-whitelist.haml b/relay/frontend/page/admin-whitelist.haml index 77fad7a..f494aef 100644 --- a/relay/frontend/page/admin-whitelist.haml +++ b/relay/frontend/page/admin-whitelist.haml @@ -2,7 +2,7 @@ -set page="Whitelist" -block head - %script(type="application/javascript" src="/static/whitelist.js" nonce="{{view.request['hash']}}") + %script(type="application/javascript" src="/static/whitelist.js" nonce="{{view.request['hash']}}" defer) -block content %details.section @@ -11,7 +11,7 @@ %label(for="new-domain") << Domain %input(type="domain" id="new-domain" placeholder="Domain") - %input(type="button" value="Add Domain", onclick="add_whitelist()") + %input#new-item(type="button" value="Add Domain") %fieldset.data-table.section %legend << Whitelist @@ -33,4 +33,4 @@ =item.created.strftime("%Y-%m-%d") %td.remove - %a(href="#" onclick="del_whitelist('{{item.domain}}')" title="Remove whitlisted domain") << ✖ + %a(href="#" title="Remove whitlisted domain") << ✖ diff --git a/relay/frontend/static/api.js b/relay/frontend/static/api.js index e5a3c9b..d064d7b 100644 --- a/relay/frontend/static/api.js +++ b/relay/frontend/static/api.js @@ -30,6 +30,8 @@ function append_table_row(table, row_name, row) { index += 1; } } + + return table_row; } diff --git a/relay/frontend/static/domain_ban.js b/relay/frontend/static/domain_ban.js index 443ccce..e84bf52 100644 --- a/relay/frontend/static/domain_ban.js +++ b/relay/frontend/static/domain_ban.js @@ -6,13 +6,25 @@ function create_ban_object(domain, reason, note) { text += `\n`; text += `\n`; text += `\n`; - text += ``; + text += ``; text += ''; return text; } +function add_row_listeners(row) { + row.querySelector(".update-ban").addEventListener("click", async (event) => { + await update_ban(row.id); + }); + + row.querySelector(".remove a").addEventListener("click", async (event) => { + event.preventDefault(); + await unban(row.id); + }); +} + + async function ban() { var table = document.querySelector("table"); var elems = { @@ -23,8 +35,8 @@ async function ban() { var values = { domain: elems.domain.value.trim(), - reason: elems.reason.value, - note: elems.note.value + reason: elems.reason.value.trim(), + note: elems.note.value.trim() } if (values.domain === "") { @@ -40,12 +52,16 @@ async function ban() { return } - append_table_row(document.getElementById("instances"), ban.domain, { + var row = append_table_row(document.querySelector("table"), ban.domain, { domain: create_ban_object(ban.domain, ban.reason, ban.note), date: get_date_string(ban.created), - remove: `` + remove: `` }); + console.log(row.querySelector(".update-ban")); + console.log(row.querySelector(".remove a")); + add_row_listeners(row); + elems.domain.value = null; elems.reason.value = null; elems.note.value = null; @@ -91,3 +107,16 @@ async function unban(domain) { document.getElementById(domain).remove(); } + + +document.querySelector("#new-ban").addEventListener("click", async (event) => { + await ban(); +}); + +for (var row of document.querySelector("fieldset.section table").rows) { + if (!row.querySelector(".update-ban")) { + continue; + } + + add_row_listeners(row); +} diff --git a/relay/frontend/static/instance.js b/relay/frontend/static/instance.js index 6b822d1..8987b25 100644 --- a/relay/frontend/static/instance.js +++ b/relay/frontend/static/instance.js @@ -1,3 +1,24 @@ +function add_instance_listeners(row) { + row.querySelector(".remove a").addEventListener("click", async (event) => { + event.preventDefault(); + await del_instance(row.id); + }); +} + + +function add_request_listeners(row) { + row.querySelector(".approve a").addEventListener("click", async (event) => { + event.preventDefault(); + await req_response(row.id, true); + }); + + row.querySelector(".deny a").addEventListener("click", async (event) => { + event.preventDefault(); + await req_response(row.id, false); + }); +} + + async function add_instance() { var elems = { actor: document.getElementById("new-actor"), @@ -26,13 +47,15 @@ async function add_instance() { return } - append_table_row(document.getElementById("instances"), instance.domain, { + row = append_table_row(document.getElementById("instances"), instance.domain, { domain: `${instance.domain}`, software: instance.software, date: get_date_string(instance.created), - remove: `` + remove: `` }); + add_instance_listeners(row); + elems.actor.value = null; elems.inbox.value = null; elems.followid.value = null; @@ -82,12 +105,37 @@ async function req_response(domain, accept) { instances = await request("GET", `v1/instance`, null); instances.forEach((instance) => { if (instance.domain === domain) { - append_table_row(document.getElementById("instances"), instance.domain, { + row = append_table_row(document.getElementById("instances"), instance.domain, { domain: `${instance.domain}`, software: instance.software, date: get_date_string(instance.created), - remove: `` + remove: `` }); + + add_instance_listeners(row); } }); } + + +document.querySelector("#add-instance").addEventListener("click", async (event) => { + await add_instance(); +}) + +for (var row of document.querySelector("#instances").rows) { + if (!row.querySelector(".remove a")) { + continue; + } + + add_instance_listeners(row); +} + +if (document.querySelector("#requests")) { + for (var row of document.querySelector("#requests").rows) { + if (!row.querySelector(".approve a")) { + continue; + } + + add_request_listeners(row); + } +} diff --git a/relay/frontend/static/software_ban.js b/relay/frontend/static/software_ban.js index b316df9..510f796 100644 --- a/relay/frontend/static/software_ban.js +++ b/relay/frontend/static/software_ban.js @@ -6,17 +6,26 @@ function create_ban_object(name, reason, note) { text += `\n`; text += `\n`; text += `\n`; - text += ``; + text += ``; text += ''; return text; } -async function ban() { - var table = document.querySelector("table"); - var row = table.insertRow(-1); +function add_row_listeners(row) { + row.querySelector(".update-ban").addEventListener("click", async (event) => { + await update_ban(row.id); + }); + row.querySelector(".remove a").addEventListener("click", async (event) => { + event.preventDefault(); + await unban(row.id); + }); +} + + +async function ban() { var elems = { name: document.getElementById("new-name"), reason: document.getElementById("new-reason"), @@ -42,12 +51,14 @@ async function ban() { return } - append_table_row(document.getElementById("instances"), ban.name, { + var row = append_table_row(document.getElementById("bans"), ban.name, { name: create_ban_object(ban.name, ban.reason, ban.note), date: get_date_string(ban.created), - remove: `` + remove: `` }); + add_row_listeners(row); + elems.name.value = null; elems.reason.value = null; elems.note.value = null; @@ -93,3 +104,16 @@ async function unban(name) { document.getElementById(name).remove(); } + + +document.querySelector("#new-ban").addEventListener("click", async (event) => { + await ban(); +}); + +for (var row of document.querySelector("#bans").rows) { + if (!row.querySelector(".update-ban")) { + continue; + } + + add_row_listeners(row); +} diff --git a/relay/frontend/static/user.js b/relay/frontend/static/user.js index 3d23b2b..6f63334 100644 --- a/relay/frontend/static/user.js +++ b/relay/frontend/static/user.js @@ -1,3 +1,12 @@ +function add_row_listeners(row) { + console.log(row); + row.querySelector(".remove a").addEventListener("click", async (event) => { + event.preventDefault(); + await del_user(row.id); + }); +} + + async function add_user() { var elems = { username: document.getElementById("new-username"), @@ -31,13 +40,15 @@ async function add_user() { return } - append_table_row(document.getElementById("users"), user.username, { + var row = append_table_row(document.querySelector("fieldset.section table"), user.username, { domain: user.username, - handle: user.handle, + handle: user.handle ? self.handle : "n/a", date: get_date_string(user.created), - remove: `` + remove: `` }); + add_row_listeners(row); + elems.username.value = null; elems.password.value = null; elems.password2.value = null; @@ -58,3 +69,16 @@ async function del_user(username) { document.getElementById(username).remove(); } + + +document.querySelector("#new-user").addEventListener("click", async (event) => { + await add_user(); +}); + +for (var row of document.querySelector("#users").rows) { + if (!row.querySelector(".remove a")) { + continue; + } + + add_row_listeners(row); +} diff --git a/relay/frontend/static/whitelist.js b/relay/frontend/static/whitelist.js index 71f883c..e04204d 100644 --- a/relay/frontend/static/whitelist.js +++ b/relay/frontend/static/whitelist.js @@ -1,3 +1,11 @@ +function add_row_listeners(row) { + row.querySelector(".remove a").addEventListener("click", async (event) => { + event.preventDefault(); + await del_whitelist(row.id); + }); +} + + async function add_whitelist() { var domain_elem = document.getElementById("new-domain"); var domain = domain_elem.value.trim(); @@ -15,12 +23,14 @@ async function add_whitelist() { return } - append_table_row(document.getElementById("whitelist"), item.domain, { + var row = append_table_row(document.getElementById("whitelist"), item.domain, { domain: item.domain, date: get_date_string(item.created), - remove: `` + remove: `` }); + add_row_listeners(row); + domain_elem.value = null; document.querySelector("details.section").open = false; } @@ -37,3 +47,16 @@ async function del_whitelist(domain) { document.getElementById(domain).remove(); } + + +document.querySelector("#new-item").addEventListener("click", async (event) => { + await add_whitelist(); +}); + +for (var row of document.querySelector("fieldset.section table").rows) { + if (!row.querySelector(".remove a")) { + continue; + } + + add_row_listeners(row); +} From 8b738dd365a52d45c3b163c48a3e04db8795fe1d Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 19:52:12 -0400 Subject: [PATCH 22/57] removed unused endpoints --- relay/views/frontend.py | 246 ---------------------------------------- 1 file changed, 246 deletions(-) diff --git a/relay/views/frontend.py b/relay/views/frontend.py index 448b834..2215d5e 100644 --- a/relay/views/frontend.py +++ b/relay/views/frontend.py @@ -153,104 +153,6 @@ class AdminInstances(View): return Response.new(data, ctype = 'html') - async def post(self, request: Request) -> Response: - post = await request.post() - data: dict[str, str] = {key: value for key, value in post.items()} # type: ignore - - if not data.get('actor') and not data.get('domain'): - return await self.get(request, error = 'Missing actor and/or domain') - - if not data.get('domain'): - data['domain'] = urlparse(data['actor']).netloc - - if not data.get('software'): - nodeinfo = await self.client.fetch_nodeinfo(data['domain']) - - if nodeinfo is None: - return await self.get(request, error = 'Failed to fetch nodeinfo') - - data['software'] = nodeinfo.sw_name - - if not data.get('actor') and data['software'] in ACTOR_FORMATS: - data['actor'] = ACTOR_FORMATS[data['software']].format(domain = data['domain']) - - if not data.get('inbox') and data['actor']: - actor: Message | None = await self.client.get(data['actor'], True, Message) - - if actor is None: - return await self.get(request, error = 'Failed to fetch actor') - - data['inbox'] = actor.shared_inbox - - with self.database.session(True) as conn: - conn.put_inbox(**data) - - return await self.get(request, message = "Added new inbox") - - -@register_route('/admin/instances/delete/{domain}') -class AdminInstancesDelete(View): - async def get(self, request: Request, domain: str) -> Response: - with self.database.session(True) as conn: - if not conn.get_inbox(domain): - return await AdminInstances(request).get(request, error = 'Instance not found') - - conn.del_inbox(domain) - - return await AdminInstances(request).get(request, message = 'Removed instance') - - -@register_route('/admin/instances/approve/{domain}') -class AdminInstancesApprove(View): - async def get(self, request: Request, domain: str) -> Response: - try: - with self.database.session(True) as conn: - instance = conn.put_request_response(domain, True) - - except KeyError: - return await AdminInstances(request).get(request, error = 'Instance not found') - - message = Message.new_response( - host = self.config.domain, - actor = instance['actor'], - followid = instance['followid'], - accept = True - ) - - self.app.push_message(instance['inbox'], message, instance) - - if instance['software'] != 'mastodon': - message = Message.new_follow( - host = self.config.domain, - actor = instance['actor'] - ) - - self.app.push_message(instance['inbox'], message, instance) - - return await AdminInstances(request).get(request, message = 'Request accepted') - - -@register_route('/admin/instances/deny/{domain}') -class AdminInstancesDeny(View): - async def get(self, request: Request, domain: str) -> Response: - try: - with self.database.session(True) as conn: - instance = conn.put_request_response(domain, False) - - except KeyError: - return await AdminInstances(request).get(request, error = 'Instance not found') - - message = Message.new_response( - host = self.config.domain, - actor = instance['actor'], - followid = instance['followid'], - accept = False - ) - - self.app.push_message(instance['inbox'], message, instance) - return await AdminInstances(request).get(request, message = 'Request denied') - - @register_route('/admin/whitelist') class AdminWhitelist(View): async def get(self, @@ -273,34 +175,6 @@ class AdminWhitelist(View): return Response.new(data, ctype = 'html') - async def post(self, request: Request) -> Response: - data = await request.post() - - if not data['domain']: - return await self.get(request, error = 'Missing domain') - - with self.database.session(True) as conn: - if conn.get_domain_whitelist(data['domain']): - return await self.get(request, error = "Domain already in whitelist") - - conn.put_domain_whitelist(data['domain']) - - return await self.get(request, message = "Added/updated domain ban") - - -@register_route('/admin/whitelist/delete/{domain}') -class AdminWhitlistDelete(View): - async def get(self, request: Request, domain: str) -> Response: - with self.database.session() as conn: - if not conn.get_domain_whitelist(domain): - msg = 'Whitelisted domain not found' - return await AdminWhitelist.run("GET", request, error = msg) - - conn.del_domain_whitelist(domain) - - return await AdminWhitelist.run("GET", request, message = 'Removed domain from whitelist') - - @register_route('/admin/domain_bans') class AdminDomainBans(View): async def get(self, @@ -323,42 +197,6 @@ class AdminDomainBans(View): return Response.new(data, ctype = 'html') - async def post(self, request: Request) -> Response: - data = await request.post() - - if not data['domain']: - return await self.get(request, error = 'Missing domain') - - with self.database.session(True) as conn: - if conn.get_domain_ban(data['domain']): - conn.update_domain_ban( - data['domain'], - data.get('reason'), - data.get('note') - ) - - else: - conn.put_domain_ban( - data['domain'], - data.get('reason'), - data.get('note') - ) - - return await self.get(request, message = "Added/updated domain ban") - - -@register_route('/admin/domain_bans/delete/{domain}') -class AdminDomainBansDelete(View): - async def get(self, request: Request, domain: str) -> Response: - with self.database.session() as conn: - if not conn.get_domain_ban(domain): - return await AdminDomainBans.run("GET", request, error = 'Domain ban not found') - - conn.del_domain_ban(domain) - - return await AdminDomainBans.run("GET", request, message = 'Unbanned domain') - - @register_route('/admin/software_bans') class AdminSoftwareBans(View): async def get(self, @@ -381,42 +219,6 @@ class AdminSoftwareBans(View): return Response.new(data, ctype = 'html') - async def post(self, request: Request) -> Response: - data = await request.post() - - if not data['name']: - return await self.get(request, error = 'Missing name') - - with self.database.session(True) as conn: - if conn.get_software_ban(data['name']): - conn.update_software_ban( - data['name'], - data.get('reason'), - data.get('note') - ) - - else: - conn.put_software_ban( - data['name'], - data.get('reason'), - data.get('note') - ) - - return await self.get(request, message = "Added/updated software ban") - - -@register_route('/admin/software_bans/delete/{name}') -class AdminSoftwareBansDelete(View): - async def get(self, request: Request, name: str) -> Response: - with self.database.session() as conn: - if not conn.get_software_ban(name): - return await AdminSoftwareBans.run("GET", request, error = 'Software ban not found') - - conn.del_software_ban(name) - - return await AdminSoftwareBans.run("GET", request, message = 'Unbanned software') - - @register_route('/admin/users') class AdminUsers(View): async def get(self, @@ -439,37 +241,6 @@ class AdminUsers(View): return Response.new(data, ctype = 'html') - async def post(self, request: Request) -> Response: - data = await request.post() - required_fields = {'username', 'password', 'password2'} - - if not all(data.get(field) for field in required_fields): - return await self.get(request, error = 'Missing username and/or password') - - if data['password'] != data['password2']: - return await self.get(request, error = 'Passwords do not match') - - with self.database.session(True) as conn: - if conn.get_user(data['username']): - return await self.get(request, error = "User already exists") - - conn.put_user(data['username'], data['password'], data['handle']) - - return await self.get(request, message = "Added user") - - -@register_route('/admin/users/delete/{name}') -class AdminUsersDelete(View): - async def get(self, request: Request, name: str) -> Response: - with self.database.session() as conn: - if not conn.get_user(name): - return await AdminUsers.run("GET", request, error = 'User not found') - - conn.del_user(name) - - return await AdminUsers.run("GET", request, message = 'User deleted') - - @register_route('/admin/config') class AdminConfig(View): async def get(self, request: Request, message: str | None = None) -> Response: @@ -483,23 +254,6 @@ class AdminConfig(View): return Response.new(data, ctype = 'html') - async def post(self, request: Request) -> Response: - form = dict(await request.post()) - data = ConfigData() - - for key in ConfigData.USER_KEYS(): - data.set(key, form.get(key.replace('_', '-'))) - - with self.database.session(True) as conn: - for key, value in data.to_dict().items(): - if key in ConfigData.SYSTEM_KEYS(): - continue - - conn.put_config(key, value) - - return await self.get(request, message = 'Updated config') - - @register_route('/style.css') class StyleCss(View): async def get(self, request: Request) -> Response: From f488d661d74d860aeb12339cdfc9391516fa3069 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 21:15:54 -0400 Subject: [PATCH 23/57] load static files into memory when not in dev mode --- relay/application.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/relay/application.py b/relay/application.py index 58668a8..3560573 100644 --- a/relay/application.py +++ b/relay/application.py @@ -8,9 +8,12 @@ import traceback import typing from aiohttp import web +from aiohttp.web import StaticResource from aiohttp_swagger import setup_swagger from aputils.signer import Signer from datetime import datetime, timedelta +from mimetypes import guess_type +from pathlib import Path from queue import Empty from threading import Event, Thread @@ -68,7 +71,13 @@ class Application(web.Application): for path, view in VIEWS: self.router.add_view(path, view) - self.add_routes([web.static('/static', get_resource('frontend/static'))]) + if dev: + static = StaticResource('/static', get_resource('frontend/static')) + + else: + static = CachedStaticResource('/static', get_resource('frontend/static')) + + self.router.register_resource(static) setup_swagger( self, @@ -223,6 +232,39 @@ class Application(web.Application): self['cache'].close() +class CachedStaticResource(StaticResource): + def __init__(self, prefix: str, path: Path): + StaticResource.__init__(self, prefix, path) + + self.cache: dict[Path, bytes] = {} + + for filename in path.rglob('*'): + if filename.is_dir(): + continue + + rel_path = str(filename.relative_to(path)) + + with filename.open('rb') as fd: + logging.debug('Loading static resource "%s"', rel_path) + self.cache[rel_path] = fd.read() + + + async def _handle(self, request: web.Request) -> web.StreamResponse: + rel_url = request.match_info['filename'] + + if Path(rel_url).anchor: + raise web.HTTPForbidden() + + try: + return web.Response( + body = self.cache[rel_url], + content_type = guess_type(path)[0] + ) + + except KeyError: + raise web.HTTPNotFound() + + class CacheCleanupThread(Thread): def __init__(self, app: Application): Thread.__init__(self) From 94f4d32314c5fed86f2b50941530ba1f04364206 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 21:17:20 -0400 Subject: [PATCH 24/57] don't run relay via dev script in dev mode by default --- relay/dev.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/relay/dev.py b/relay/dev.py index 0f89f73..52eeadc 100644 --- a/relay/dev.py +++ b/relay/dev.py @@ -86,10 +86,11 @@ def cli_build(): @cli.command('run') -def cli_run(): +@click.option('--dev', '-d', is_flag = True) +def cli_run(dev: bool): print('Starting process watcher') - handler = WatchHandler() + handler = WatchHandler(dev) handler.run_proc() watcher = Observer() @@ -112,12 +113,13 @@ def cli_run(): class WatchHandler(PatternMatchingEventHandler): patterns = ['*.py'] - cmd = [sys.executable, '-m', 'relay', 'run', '-d'] + cmd = [sys.executable, '-m', 'relay', 'run'] - def __init__(self): + def __init__(self, dev: bool): PatternMatchingEventHandler.__init__(self) + self.dev: bool = dev self.proc = None self.last_restart = None @@ -152,7 +154,12 @@ class WatchHandler(PatternMatchingEventHandler): self.kill_proc() - self.proc = subprocess.Popen(self.cmd, stdin = subprocess.PIPE) + if self.dev: + self.proc = subprocess.Popen([*self.cmd, '-d'], stdin = subprocess.PIPE) + + else: + self.proc = subprocess.Popen(self.cmd, stdin = subprocess.PIPE) + self.last_restart = timestamp print(f'Started process with PID {self.proc.pid}') From c961fadc9ae4bbe4cc20cca78685093e2fddf239 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 21:23:57 -0400 Subject: [PATCH 25/57] don't run relay via dev script in dev mode by default --- relay/dev.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/relay/dev.py b/relay/dev.py index 52eeadc..f145d24 100644 --- a/relay/dev.py +++ b/relay/dev.py @@ -9,6 +9,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from . import __version__ +from . import logger as logging try: from watchdog.observers import Observer @@ -128,7 +129,7 @@ class WatchHandler(PatternMatchingEventHandler): if self.proc.poll() is not None: return - print(f'Terminating process {self.proc.pid}') + logging.info(f'Terminating process {self.proc.pid}') self.proc.terminate() sec = 0.0 @@ -137,11 +138,11 @@ class WatchHandler(PatternMatchingEventHandler): sec += 0.1 if sec >= 5: - print('Failed to terminate. Killing process...') + logging.error('Failed to terminate. Killing process...') self.proc.kill() break - print('Process terminated') + logging.info('Process terminated') def run_proc(self, restart=False): @@ -154,15 +155,13 @@ class WatchHandler(PatternMatchingEventHandler): self.kill_proc() - if self.dev: - self.proc = subprocess.Popen([*self.cmd, '-d'], stdin = subprocess.PIPE) - - else: - self.proc = subprocess.Popen(self.cmd, stdin = subprocess.PIPE) + cmd = [*self.cmd, '-d'] if self.dev else self.cmd + self.proc = subprocess.Popen(cmd, stdin = subprocess.PIPE) self.last_restart = timestamp - print(f'Started process with PID {self.proc.pid}') + logging.info('Started process with PID %i', self.proc.pid) + logging.info('Command: %s', ' '.join(cmd)) def on_any_event(self, event): From 6018af1e68282b57c5614b5c62b7ef2d5e582f83 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 21:24:17 -0400 Subject: [PATCH 26/57] load static files into memory when not in dev mode --- relay/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/application.py b/relay/application.py index 3560573..4d67826 100644 --- a/relay/application.py +++ b/relay/application.py @@ -71,7 +71,7 @@ class Application(web.Application): for path, view in VIEWS: self.router.add_view(path, view) - if dev: + if self['dev']: static = StaticResource('/static', get_resource('frontend/static')) else: From 2be96a8ca5bfa2a2177fd67bbc496399c1d95c1d Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 21:28:40 -0400 Subject: [PATCH 27/57] register static route on run --- relay/application.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/relay/application.py b/relay/application.py index 4d67826..ee5b465 100644 --- a/relay/application.py +++ b/relay/application.py @@ -71,14 +71,6 @@ class Application(web.Application): for path, view in VIEWS: self.router.add_view(path, view) - if self['dev']: - static = StaticResource('/static', get_resource('frontend/static')) - - else: - static = CachedStaticResource('/static', get_resource('frontend/static')) - - self.router.register_resource(static) - setup_swagger( self, ui_version = 3, @@ -154,6 +146,16 @@ class Application(web.Application): self['push_queue'].put((inbox, message, instance)) + def register_static_routes(self) -> None: + if self['dev']: + static = StaticResource('/static', get_resource('frontend/static')) + + else: + static = CachedStaticResource('/static', get_resource('frontend/static')) + + self.router.register_resource(static) + + def run(self) -> None: if self["running"]: return @@ -166,6 +168,8 @@ class Application(web.Application): logging.error(f'A server is already running on {host}:{port}') return + self.register_static_routes() + logging.info(f'Starting webserver at {domain} ({host}:{port})') asyncio.run(self.handle_run()) From a966f9c1cf0ca85cad36d48189205a5505f4b976 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 21:57:44 -0400 Subject: [PATCH 28/57] replace alerts with in-page toast messages --- relay/frontend/base.haml | 4 ++ relay/frontend/static/config.js | 4 +- relay/frontend/static/domain_ban.js | 13 ++--- relay/frontend/static/instance.js | 11 +++-- relay/frontend/static/software_ban.js | 11 +++-- relay/frontend/static/toast.css | 68 +++++++++++++++++++++++++++ relay/frontend/static/toast.js | 26 ++++++++++ relay/frontend/static/user.js | 11 +++-- relay/frontend/static/whitelist.js | 10 ++-- 9 files changed, 134 insertions(+), 24 deletions(-) create mode 100644 relay/frontend/static/toast.css create mode 100644 relay/frontend/static/toast.js diff --git a/relay/frontend/base.haml b/relay/frontend/base.haml index 992cfe7..873a8e2 100644 --- a/relay/frontend/base.haml +++ b/relay/frontend/base.haml @@ -13,7 +13,9 @@ %meta(name="viewport" content="width=device-width, initial-scale=1") %link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css" nonce="{{view.request['hash']}}") %link(rel="stylesheet" type="text/css" href="/static/style.css" nonce="{{view.request['hash']}}") + %link(rel="stylesheet" type="text/css" href="/static/toast.css" nonce="{{view.request['hash']}}") %script(type="application/javascript" src="/static/menu.js" nonce="{{view.request['hash']}}", defer) + %script(type="application/javascript" src="/static/toast.js" nonce="{{view.request['hash']}}", defer) %script(type="application/javascript" src="/static/api.js" nonce="{{view.request['hash']}}", defer) -block head @@ -37,6 +39,8 @@ -else {{menu_item("Login", "/login")}} + %ul#notifications + #container #header.section %span#menu-open << ⁞ diff --git a/relay/frontend/static/config.js b/relay/frontend/static/config.js index 5f592bf..edb63f5 100644 --- a/relay/frontend/static/config.js +++ b/relay/frontend/static/config.js @@ -18,7 +18,7 @@ async function handle_config_change(event) { await request("POST", "v1/config", params); } catch (error) { - alert(error); + toast(error); return; } @@ -26,6 +26,8 @@ async function handle_config_change(event) { document.querySelector("#header .title").innerHTML = params.value; document.querySelector("title").innerHTML = params.value; } + + toast("Updated config", "message"); } diff --git a/relay/frontend/static/domain_ban.js b/relay/frontend/static/domain_ban.js index e84bf52..4de2ebf 100644 --- a/relay/frontend/static/domain_ban.js +++ b/relay/frontend/static/domain_ban.js @@ -40,7 +40,7 @@ async function ban() { } if (values.domain === "") { - alert("Domain is required"); + toast("Domain is required"); return; } @@ -48,7 +48,7 @@ async function ban() { var ban = await request("POST", "v1/domain_ban", values); } catch (err) { - alert(err); + toast(err); return } @@ -58,8 +58,6 @@ async function ban() { remove: `` }); - console.log(row.querySelector(".update-ban")); - console.log(row.querySelector(".remove a")); add_row_listeners(row); elems.domain.value = null; @@ -67,6 +65,7 @@ async function ban() { elems.note.value = null; document.querySelector("details.section").open = false; + toast("Banned domain", "message"); } @@ -88,11 +87,12 @@ async function update_ban(domain) { await request("PATCH", "v1/domain_ban", values) } catch (error) { - alert(error); + toast(error); return; } row.querySelector("details").open = false; + toast("Updated baned domain", "message"); } @@ -101,11 +101,12 @@ async function unban(domain) { await request("DELETE", "v1/domain_ban", {"domain": domain}); } catch (error) { - alert(error); + toast(error); return; } document.getElementById(domain).remove(); + toast("Unbanned domain", "message"); } diff --git a/relay/frontend/static/instance.js b/relay/frontend/static/instance.js index 8987b25..8a4956f 100644 --- a/relay/frontend/static/instance.js +++ b/relay/frontend/static/instance.js @@ -35,7 +35,7 @@ async function add_instance() { } if (values.actor === "") { - alert("Actor is required"); + toast("Actor is required"); return; } @@ -43,7 +43,7 @@ async function add_instance() { var instance = await request("POST", "v1/instance", values); } catch (err) { - alert(err); + toast(err); return } @@ -62,6 +62,7 @@ async function add_instance() { elems.software.value = null; document.querySelector("details.section").open = false; + toast("Added instance", "message"); } @@ -70,7 +71,7 @@ async function del_instance(domain) { await request("DELETE", "v1/instance", {"domain": domain}); } catch (error) { - alert(error); + toast(error); return; } @@ -88,7 +89,7 @@ async function req_response(domain, accept) { await request("POST", "v1/request", params); } catch (error) { - alert(error); + toast(error); return; } @@ -115,6 +116,8 @@ async function req_response(domain, accept) { add_instance_listeners(row); } }); + + toast("Removed instance", "message"); } diff --git a/relay/frontend/static/software_ban.js b/relay/frontend/static/software_ban.js index 510f796..663929a 100644 --- a/relay/frontend/static/software_ban.js +++ b/relay/frontend/static/software_ban.js @@ -39,7 +39,7 @@ async function ban() { } if (values.name === "") { - alert("Domain is required"); + toast("Domain is required"); return; } @@ -47,7 +47,7 @@ async function ban() { var ban = await request("POST", "v1/software_ban", values); } catch (err) { - alert(err); + toast(err); return } @@ -64,6 +64,7 @@ async function ban() { elems.note.value = null; document.querySelector("details.section").open = false; + toast("Banned software", "message"); } @@ -85,11 +86,12 @@ async function update_ban(name) { await request("PATCH", "v1/software_ban", values) } catch (error) { - alert(error); + toast(error); return; } row.querySelector("details").open = false; + toast("Updated software ban", "message"); } @@ -98,11 +100,12 @@ async function unban(name) { await request("DELETE", "v1/software_ban", {"name": name}); } catch (error) { - alert(error); + toast(error); return; } document.getElementById(name).remove(); + toast("Unbanned software", "message"); } diff --git a/relay/frontend/static/toast.css b/relay/frontend/static/toast.css new file mode 100644 index 0000000..e544dcd --- /dev/null +++ b/relay/frontend/static/toast.css @@ -0,0 +1,68 @@ +#notifications { + position: fixed; + top: 40px; + left: 50%; + transform: translateX(-50%); +} + +#notifications li { + position: relative; + overflow: hidden; + list-style: none; + border-radius: 5px; + padding: 5px;; + margin-bottom: var(--spacing); + animation: show_toast 0.3s ease forwards; + display: grid; + grid-template-columns: auto max-content; + grid-gap: 5px; + align-items: center; +} + +#notifications a { + font-size: 1.5em; + line-height: 1em; + text-decoration: none; +} + +#notifications li.hide { + animation: hide_toast 0.3s ease forwards; +} + + +@keyframes show_toast { + 0% { + transform: translateX(100%); + } + + 40% { + transform: translateX(-5%); + } + + 80% { + transform: translateX(0%); + } + + 100% { + transform: translateX(-10px); + } +} + + +@keyframes hide_toast { + 0% { + transform: translateX(-10px); + } + + 40% { + transform: translateX(0%); + } + + 80% { + transform: translateX(-5%); + } + + 100% { + transform: translateX(calc(100% + 20px)); + } +} diff --git a/relay/frontend/static/toast.js b/relay/frontend/static/toast.js new file mode 100644 index 0000000..e4ca8cc --- /dev/null +++ b/relay/frontend/static/toast.js @@ -0,0 +1,26 @@ +const notifications = document.querySelector("#notifications") + + +function remove_toast(toast) { + toast.classList.add("hide"); + + if (toast.timeoutId) { + clearTimeout(toast.timeoutId); + } + + setTimeout(() => toast.remove(), 300); +} + +function toast(text, type="error", timeout=5) { + const toast = document.createElement("li"); + toast.className = `section ${type}` + toast.innerHTML = `${text}✖` + + toast.querySelector("a").addEventListener("click", async (event) => { + event.preventDefault(); + await remove_toast(toast); + }); + + notifications.appendChild(toast); + toast.timeoutId = setTimeout(() => remove_toast(toast), timeout * 1000); +} diff --git a/relay/frontend/static/user.js b/relay/frontend/static/user.js index 6f63334..9c74359 100644 --- a/relay/frontend/static/user.js +++ b/relay/frontend/static/user.js @@ -1,5 +1,4 @@ function add_row_listeners(row) { - console.log(row); row.querySelector(".remove a").addEventListener("click", async (event) => { event.preventDefault(); await del_user(row.id); @@ -23,12 +22,12 @@ async function add_user() { } if (values.username === "" | values.password === "" | values.password2 === "") { - alert("Username, password, and password2 are required"); + toast("Username, password, and password2 are required"); return; } if (values.password !== values.password2) { - alert("Passwords do not match"); + toast("Passwords do not match"); return; } @@ -36,7 +35,7 @@ async function add_user() { var user = await request("POST", "v1/user", values); } catch (err) { - alert(err); + toast(err); return } @@ -55,6 +54,7 @@ async function add_user() { elems.handle.value = null; document.querySelector("details.section").open = false; + toast("Created user", "message"); } @@ -63,11 +63,12 @@ async function del_user(username) { await request("DELETE", "v1/user", {"username": username}); } catch (error) { - alert(error); + toast(error); return; } document.getElementById(username).remove(); + toast("Deleted user", "message"); } diff --git a/relay/frontend/static/whitelist.js b/relay/frontend/static/whitelist.js index e04204d..70d4db1 100644 --- a/relay/frontend/static/whitelist.js +++ b/relay/frontend/static/whitelist.js @@ -11,7 +11,7 @@ async function add_whitelist() { var domain = domain_elem.value.trim(); if (domain === "") { - alert("Domain is required"); + toast("Domain is required"); return; } @@ -19,8 +19,8 @@ async function add_whitelist() { var item = await request("POST", "v1/whitelist", {"domain": domain}); } catch (err) { - alert(err); - return + toast(err); + return; } var row = append_table_row(document.getElementById("whitelist"), item.domain, { @@ -33,6 +33,7 @@ async function add_whitelist() { domain_elem.value = null; document.querySelector("details.section").open = false; + toast("Added domain", "message"); } @@ -41,11 +42,12 @@ async function del_whitelist(domain) { await request("DELETE", "v1/whitelist", {"domain": domain}); } catch (error) { - alert(error); + toast(error); return; } document.getElementById(domain).remove(); + toast("Removed domain", "message"); } From e1ab01e4e29e4de75fc6bb4a27b9c62d0bc0f602 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 22:14:37 -0400 Subject: [PATCH 29/57] use api on login page --- relay/frontend/page/admin-users.haml | 8 +++--- relay/frontend/page/login.haml | 17 +++++++----- relay/frontend/static/login.js | 29 ++++++++++++++++++++ relay/views/api.py | 14 +++++++++- relay/views/frontend.py | 41 ---------------------------- 5 files changed, 56 insertions(+), 53 deletions(-) create mode 100644 relay/frontend/static/login.js diff --git a/relay/frontend/page/admin-users.haml b/relay/frontend/page/admin-users.haml index 2043ba8..50058d7 100644 --- a/relay/frontend/page/admin-users.haml +++ b/relay/frontend/page/admin-users.haml @@ -9,16 +9,16 @@ %summary << Add User #add-item %label(for="new-username") << Username - %input(id="new-username" name="username" placeholder="Username") + %input(id="new-username" name="username" placeholder="Username" autocomplete="off") %label(for="new-password") << Password - %input(id="new-password" type="password" placeholder="Password") + %input(id="new-password" type="password" placeholder="Password" autocomplete="off") %label(for="new-password2") << Password Again - %input(id="new-password2" type="password" placeholder="Password Again") + %input(id="new-password2" type="password" placeholder="Password Again" autocomplete="off") %label(for="new-handle") << Handle - %input(id="new-handle" type="email" placeholder="handle") + %input(id="new-handle" type="email" placeholder="handle" autocomplete="off") %input#new-user(type="button" value="Add User") diff --git a/relay/frontend/page/login.haml b/relay/frontend/page/login.haml index ab177b6..bf1ab1c 100644 --- a/relay/frontend/page/login.haml +++ b/relay/frontend/page/login.haml @@ -1,15 +1,18 @@ -extends "base.haml" -set page="Login" + +-block head + %script(type="application/javascript" src="/static/login.js" nonce="{{view.request['hash']}}" defer) + -block content %fieldset.section %legend << Login - %form(action="/login" method="POST") - .grid-2col - %label(for="username") << Username - %input(id="username" name="username" placeholder="Username" value="{{username or ''}}") + .grid-2col + %label(for="username") << Username + %input(id="username" name="username" placeholder="Username" value="{{username or ''}}") - %label(for="password") << Password - %input(id="password" name="password" placeholder="Password" type="password") + %label(for="password") << Password + %input(id="password" name="password" placeholder="Password" type="password") - %input(type="submit" value="Login") + %input.submit(type="button" value="Login") diff --git a/relay/frontend/static/login.js b/relay/frontend/static/login.js new file mode 100644 index 0000000..9c68f17 --- /dev/null +++ b/relay/frontend/static/login.js @@ -0,0 +1,29 @@ +async function login(event) { + fields = { + username: document.querySelector("#username"), + password: document.querySelector("#password") + } + + values = { + username: fields.username.value.trim(), + password: fields.password.value.trim() + } + + if (values.username === "" | values.password === "") { + toast("Username and/or password field is blank"); + return; + } + + try { + await request("POST", "v1/token", values); + + } catch (error) { + toast(error); + return; + } + + document.location = "/"; +} + + +document.querySelector(".submit").addEventListener("click", login); diff --git a/relay/views/api.py b/relay/views/api.py index 96f42fa..1789f72 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -81,7 +81,19 @@ class Login(View): token = conn.put_token(data['username']) - return Response.new({'token': token['code']}, ctype = 'json') + resp = Response.new({'token': token['code']}, ctype = 'json') + resp.set_cookie( + 'user-token', + token['code'], + max_age = 60 * 60 * 24 * 365, + domain = self.config.domain, + path = '/', + secure = True, + httponly = False, + samesite = 'lax' + ) + + return resp async def delete(self, request: Request) -> Response: diff --git a/relay/views/frontend.py b/relay/views/frontend.py index 2215d5e..206482b 100644 --- a/relay/views/frontend.py +++ b/relay/views/frontend.py @@ -72,47 +72,6 @@ class Login(View): return Response.new(data, ctype = 'html') - async def post(self, request: Request) -> Response: - form = await request.post() - params = {} - - with self.database.session(True) as conn: - if not (user := conn.get_user(form['username'])): - params = { - 'username': form['username'], - 'error': 'User not found' - } - - else: - try: - conn.hasher.verify(user['hash'], form['password']) - - except VerifyMismatchError: - params = { - 'username': form['username'], - 'error': 'Invalid password' - } - - if params: - data = self.template.render('page/login.haml', self, **params) - return Response.new(data, ctype = 'html') - - token = conn.put_token(user['username']) - resp = Response.new_redir(request.query.getone('redir', '/')) - resp.set_cookie( - 'user-token', - token['code'], - max_age = 60 * 60 * 24 * 365, - domain = self.config.domain, - path = '/', - secure = True, - httponly = False, - samesite = 'lax' - ) - - return resp - - @register_route('/logout') class Logout(View): async def get(self, request: Request) -> Response: From 02564c7295724de16752ae30a4a94af2f310ccde Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 22:36:15 -0400 Subject: [PATCH 30/57] update caddy file for v2 --- installation/relay.caddy | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/installation/relay.caddy b/installation/relay.caddy index 8cd9b28..94fdd53 100644 --- a/installation/relay.caddy +++ b/installation/relay.caddy @@ -1,6 +1,3 @@ -relay.example.org { - gzip - proxy / 127.0.0.1:8080 { - transparent - } +relay.example.com { + reverse_proxy / http://localhost:8080 } From d579ec634d47b246d37d2e8b6fa5dc224d3bcf90 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 22:56:16 -0400 Subject: [PATCH 31/57] fix NameError in CachedStaticResource._handle --- relay/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/application.py b/relay/application.py index ee5b465..cb32357 100644 --- a/relay/application.py +++ b/relay/application.py @@ -262,7 +262,7 @@ class CachedStaticResource(StaticResource): try: return web.Response( body = self.cache[rel_url], - content_type = guess_type(path)[0] + content_type = guess_type(rel_url)[0] ) except KeyError: From 98b7e33288818b2f5d6b95af0b556cc6b7b2c120 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 23:00:54 -0400 Subject: [PATCH 32/57] fix editing note in config admin page --- relay/frontend/functions.haml | 6 +++--- relay/frontend/page/admin-config.haml | 6 +++--- relay/frontend/static/config.js | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/relay/frontend/functions.haml b/relay/frontend/functions.haml index 68fbd2c..fe44db6 100644 --- a/relay/frontend/functions.haml +++ b/relay/frontend/functions.haml @@ -1,13 +1,13 @@ -macro new_checkbox(name, checked) -if checked - %input(id="{{name}}" name="{{name}}" type="checkbox" checked) + %input(id="{{name}}" type="checkbox" checked) -else - %input(id="{{name}}" name="{{name}}" type="checkbox") + %input(id="{{name}}" type="checkbox") -macro new_select(name, selected, items) - %select(id="{{name}}" name="{{name}}") + %select(id="{{name}}") -for item in items -if item == selected %option(value="{{item}}" selected) -> =item.title() diff --git a/relay/frontend/page/admin-config.haml b/relay/frontend/page/admin-config.haml index 6519c0e..e5df986 100644 --- a/relay/frontend/page/admin-config.haml +++ b/relay/frontend/page/admin-config.haml @@ -11,10 +11,10 @@ .grid-2col %label(for="name") << Name - %input(id = "name" name="name" placeholder="Relay Name" value="{{config.name or ''}}") + %input(id = "name" placeholder="Relay Name" value="{{config.name or ''}}") - %label(for="description") << Description - %textarea(id="description" name="note" value="{{config.note or ''}}") << {{config.note}} + %label(for="note") << Description + %textarea(id="note" value="{{config.note or ''}}") << {{config.note}} %label(for="theme") << Color Theme =func.new_select("theme", config.theme, themes) diff --git a/relay/frontend/static/config.js b/relay/frontend/static/config.js index edb63f5..3a13104 100644 --- a/relay/frontend/static/config.js +++ b/relay/frontend/static/config.js @@ -1,6 +1,6 @@ const elems = [ document.querySelector("#name"), - document.querySelector("#description"), + document.querySelector("#note"), document.querySelector("#theme"), document.querySelector("#log-level"), document.querySelector("#whitelist-enabled"), From b8b6dda131e5d85214938a8848a8210ce7503cb2 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 15 Mar 2024 23:04:41 -0400 Subject: [PATCH 33/57] update theme on change in config admin page --- relay/frontend/base.haml | 2 +- relay/frontend/static/config.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/relay/frontend/base.haml b/relay/frontend/base.haml index 873a8e2..4b4a3d1 100644 --- a/relay/frontend/base.haml +++ b/relay/frontend/base.haml @@ -11,7 +11,7 @@ %title << {{config.name}}: {{page}} %meta(charset="UTF-8") %meta(name="viewport" content="width=device-width, initial-scale=1") - %link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css" nonce="{{view.request['hash']}}") + %link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css" nonce="{{view.request['hash']}}" class="theme") %link(rel="stylesheet" type="text/css" href="/static/style.css" nonce="{{view.request['hash']}}") %link(rel="stylesheet" type="text/css" href="/static/toast.css" nonce="{{view.request['hash']}}") %script(type="application/javascript" src="/static/menu.js" nonce="{{view.request['hash']}}", defer) diff --git a/relay/frontend/static/config.js b/relay/frontend/static/config.js index 3a13104..417c48a 100644 --- a/relay/frontend/static/config.js +++ b/relay/frontend/static/config.js @@ -27,6 +27,10 @@ async function handle_config_change(event) { document.querySelector("title").innerHTML = params.value; } + if (params.key === "theme") { + document.querySelector("link.theme").href = `/theme/${params.value}.css`; + } + toast("Updated config", "message"); } From 5c210dc20f4c3dd12472f44e31228fa1f4a96b79 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sat, 16 Mar 2024 01:36:20 -0400 Subject: [PATCH 34/57] fix linting issues --- relay/application.py | 34 +++++++++++++++++----------------- relay/database/connection.py | 2 +- relay/dev.py | 10 +++++----- relay/views/api.py | 7 ++++--- relay/views/base.py | 2 +- relay/views/frontend.py | 12 +++++------- 6 files changed, 33 insertions(+), 34 deletions(-) diff --git a/relay/application.py b/relay/application.py index cb32357..abe8258 100644 --- a/relay/application.py +++ b/relay/application.py @@ -35,6 +35,21 @@ if typing.TYPE_CHECKING: from .misc import Message, Response +def get_csp(request: web.Request) -> str: + data = [ + "default-src 'none'", + f"script-src 'nonce-{request['hash']}'", + f"style-src 'self' 'nonce-{request['hash']}'", + "form-action 'self'", + "connect-src 'self'", + "img-src 'self'", + "object-src 'none'", + "frame-ancestors 'none'" + ] + + return '; '.join(data) + ';' + + class Application(web.Application): DEFAULT: Application | None = None @@ -127,21 +142,6 @@ class Application(web.Application): return timedelta(seconds=uptime.seconds) - def get_csp(self, request: Request) -> str: - data = [ - "default-src 'none'", - f"script-src 'nonce-{request['hash']}'", - f"style-src 'self' 'nonce-{request['hash']}'", - "form-action 'self'", - "connect-src 'self'", - "img-src 'self'", - "object-src 'none'", - "frame-ancestors 'none'" - ] - - return '; '.join(data) + ';' - - def push_message(self, inbox: str, message: Message, instance: Row) -> None: self['push_queue'].put((inbox, message, instance)) @@ -240,7 +240,7 @@ class CachedStaticResource(StaticResource): def __init__(self, prefix: str, path: Path): StaticResource.__init__(self, prefix, path) - self.cache: dict[Path, bytes] = {} + self.cache: dict[str, bytes] = {} for filename in path.rglob('*'): if filename.is_dir(): @@ -334,7 +334,7 @@ async def handle_response_headers(request: web.Request, handler: Callable) -> Re # Still have to figure out how csp headers work if resp.content_type == 'text/html': - resp.headers['Content-Security-Policy'] = Application.DEFAULT.get_csp(request) + resp.headers['Content-Security-Policy'] = get_csp(request) if not request.app['dev'] and request.path.endswith(('.css', '.js')): # cache for 2 weeks diff --git a/relay/database/connection.py b/relay/database/connection.py index 4f9ff21..67df706 100644 --- a/relay/database/connection.py +++ b/relay/database/connection.py @@ -192,7 +192,7 @@ class Connection(SqlConnection): def put_user(self, username: str, password: str | None, handle: str | None = None) -> Row: if self.get_user(username): - data = { + data: dict[str, str | datetime | None] = { 'username': username } diff --git a/relay/dev.py b/relay/dev.py index f145d24..c03becd 100644 --- a/relay/dev.py +++ b/relay/dev.py @@ -100,8 +100,8 @@ def cli_run(dev: bool): try: while True: - handler.proc.stdin.write(sys.stdin.read().encode('UTF-8')) - handler.proc.stdin.flush() + handler.proc.stdin.write(sys.stdin.read().encode('UTF-8')) # type: ignore + handler.proc.stdin.flush() # type: ignore except KeyboardInterrupt: pass @@ -121,12 +121,12 @@ class WatchHandler(PatternMatchingEventHandler): PatternMatchingEventHandler.__init__(self) self.dev: bool = dev - self.proc = None - self.last_restart = None + self.proc: subprocess.Popen | None = None + self.last_restart: datetime | None = None def kill_proc(self): - if self.proc.poll() is not None: + if not self.proc or self.proc.poll() is not None: return logging.info(f'Terminating process {self.proc.pid}') diff --git a/relay/views/api.py b/relay/views/api.py index 1789f72..92e29d8 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -15,6 +15,7 @@ from ..misc import Message, Response, boolean, get_app if typing.TYPE_CHECKING: from aiohttp.web import Request from collections.abc import Callable, Sequence + from typing import Any PUBLIC_API_PATHS: Sequence[tuple[str, str]] = ( @@ -149,7 +150,7 @@ class Config(View): if isinstance(data, Response): return data - data['key'] = data['key'].replace('-', '_'); + data['key'] = data['key'].replace('-', '_') if data['key'] not in ConfigData.USER_KEYS(): return Response.new_error(400, 'Invalid key', 'json') @@ -255,7 +256,7 @@ class RequestView(View): async def post(self, request: Request) -> Response: - data = await self.get_api_data(['domain', 'accept'], []) + data: dict[str, Any] | Response = await self.get_api_data(['domain', 'accept'], []) data['accept'] = boolean(data['accept']) try: @@ -430,7 +431,7 @@ class User(View): async def patch(self, request: Request) -> Response: - data = await self.get_api_data(['username'], ['password', ['handle']]) + data = await self.get_api_data(['username'], ['password', 'handle']) if isinstance(data, Response): return data diff --git a/relay/views/base.py b/relay/views/base.py index 9709e34..3d7d718 100644 --- a/relay/views/base.py +++ b/relay/views/base.py @@ -126,7 +126,7 @@ class View(AbstractView): return Response.new_error(400, 'Invalid JSON data', 'json') else: - post_data = convert_data(self.request.query) # type: ignore + post_data = convert_data(self.request.query) data = {} diff --git a/relay/views/frontend.py b/relay/views/frontend.py index 206482b..91af960 100644 --- a/relay/views/frontend.py +++ b/relay/views/frontend.py @@ -3,14 +3,12 @@ from __future__ import annotations import typing from aiohttp import web -from argon2.exceptions import VerifyMismatchError -from urllib.parse import urlparse from .base import View, register_route -from ..database import THEMES, ConfigData +from ..database import THEMES from ..logger import LogLevel -from ..misc import ACTOR_FORMATS, Message, Response, get_app +from ..misc import Response, get_app if typing.TYPE_CHECKING: from aiohttp.web import Request @@ -40,9 +38,9 @@ async def handle_frontend_path(request: web.Request, handler: Callable) -> Respo return Response.new('', 302, {'Location': '/'}) if not request['user'] and request.path.startswith('/admin'): - response = Response.new('', 302, {'Location': f'/login?redir={request.path}'}) - response.del_cookie('user-token') - return response + response = Response.new('', 302, {'Location': f'/login?redir={request.path}'}) + response.del_cookie('user-token') + return response response = await handler(request) From ea0658e2ead5b1d22a18c36ff9eae6328b2787a8 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sat, 16 Mar 2024 06:10:58 -0400 Subject: [PATCH 35/57] don't set csp header on /api routes --- relay/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/application.py b/relay/application.py index abe8258..e312692 100644 --- a/relay/application.py +++ b/relay/application.py @@ -333,7 +333,7 @@ async def handle_response_headers(request: web.Request, handler: Callable) -> Re resp.headers['Server'] = 'ActivityRelay' # Still have to figure out how csp headers work - if resp.content_type == 'text/html': + if resp.content_type == 'text/html' and not request.path.startswith("/api"): resp.headers['Content-Security-Policy'] = get_csp(request) if not request.app['dev'] and request.path.endswith(('.css', '.js')): From a6f1738b73b7715f21a25f03ec9a9b193559718e Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sat, 16 Mar 2024 06:11:47 -0400 Subject: [PATCH 36/57] fix user updating --- relay/database/connection.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/relay/database/connection.py b/relay/database/connection.py index 67df706..4e575f6 100644 --- a/relay/database/connection.py +++ b/relay/database/connection.py @@ -192,26 +192,29 @@ class Connection(SqlConnection): def put_user(self, username: str, password: str | None, handle: str | None = None) -> Row: if self.get_user(username): - data: dict[str, str | datetime | None] = { - 'username': username - } + data: dict[str, str] = {} if password: - data['password'] = password + data['hash'] = self.hasher.hash(password) if handle: - data['handler'] = handle + data['handle'] = handle - else: - if password is None: - raise ValueError('Password cannot be empty') + stmt = Update("users", data) + stmt.set_where("username", username) - data = { - 'username': username, - 'hash': self.hasher.hash(password), - 'handle': handle, - 'created': datetime.now(tz = timezone.utc) - } + with self.query(stmt) as cur: + return cur.one() + + if password is None: + raise ValueError('Password cannot be empty') + + data = { + 'username': username, + 'hash': self.hasher.hash(password), + 'handle': handle, + 'created': datetime.now(tz = timezone.utc) + } with self.run('put-user', data) as cur: return cur.one() # type: ignore From 491f19a9cbecbf89979d1857a0d459db22237aa2 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Thu, 21 Mar 2024 22:50:22 -0400 Subject: [PATCH 37/57] don't include date in logging when running via systemd --- relay/logger.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/relay/logger.py b/relay/logger.py index ca9d76d..b8ff918 100644 --- a/relay/logger.py +++ b/relay/logger.py @@ -63,10 +63,10 @@ def set_level(level: LogLevel | str) -> None: def verbose(message: str, *args: Any, **kwargs: Any) -> None: - if not logging.root.isEnabledFor(LogLevel['VERBOSE']): + if not logging.root.isEnabledFor(LogLevel.VERBOSE): return - logging.log(LogLevel['VERBOSE'], message, *args, **kwargs) + logging.log(LogLevel.VERBOSE, message, *args, **kwargs) debug: Callable = logging.debug @@ -76,8 +76,6 @@ error: Callable = logging.error critical: Callable = logging.critical -env_log_level: Path | str | None = os.environ.get('LOG_LEVEL', 'INFO').upper() - try: env_log_file: Path | None = Path(os.environ['LOG_FILE']).expanduser().resolve() @@ -89,10 +87,16 @@ handlers: list[Any] = [logging.StreamHandler()] if env_log_file: handlers.append(logging.FileHandler(env_log_file)) -logging.addLevelName(LogLevel['VERBOSE'], 'VERBOSE') +if os.environ.get('INVOCATION_ID'): + logging_format = '%(levelname)s: %(message)s' + +else: + logging_format = '[%(asctime)s] %(levelname)s: %(message)s' + +logging.addLevelName(LogLevel.VERBOSE, 'VERBOSE') logging.basicConfig( level = LogLevel.INFO, - format = '[%(asctime)s] %(levelname)s: %(message)s', + format = logging_format, datefmt = '%Y-%m-%d %H:%M:%S', handlers = handlers ) From 6112734b2f62c86bd022ca5a49a740b6d4b0c62e Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 22 Mar 2024 03:24:23 -0400 Subject: [PATCH 38/57] update aputils to 0.1.9 --- relay/database/connection.py | 6 ++++-- relay/http_client.py | 11 ++++++++--- relay/misc.py | 22 ++++++---------------- relay/views/activitypub.py | 14 +++++++------- requirements.txt | 2 +- 5 files changed, 26 insertions(+), 29 deletions(-) diff --git a/relay/database/connection.py b/relay/database/connection.py index 4e575f6..8c64641 100644 --- a/relay/database/connection.py +++ b/relay/database/connection.py @@ -98,6 +98,8 @@ class Connection(SqlConnection): with self.run('put-config', params): pass + return data.get(key) + def get_inbox(self, value: str) -> Row: with self.run('get-inbox', {'value': value}) as cur: @@ -192,7 +194,7 @@ class Connection(SqlConnection): def put_user(self, username: str, password: str | None, handle: str | None = None) -> Row: if self.get_user(username): - data: dict[str, str] = {} + data: dict[str, str | datetime | None] = {} if password: data['hash'] = self.hasher.hash(password) @@ -204,7 +206,7 @@ class Connection(SqlConnection): stmt.set_where("username", username) with self.query(stmt) as cur: - return cur.one() + return cur.one() # type: ignore if password is None: raise ValueError('Password cannot be empty') diff --git a/relay/http_client.py b/relay/http_client.py index b51caf8..a475a5a 100644 --- a/relay/http_client.py +++ b/relay/http_client.py @@ -149,7 +149,12 @@ class HttpClient: return None - async def get(self, url: str, sign_headers: bool, cls: type[T], force: bool = False) -> T | None: + async def get(self, + url: str, + sign_headers: bool, + cls: type[T], + force: bool = False) -> T | None: + if not issubclass(cls, JsonBase): raise TypeError('cls must be a sub-class of "aputils.JsonBase"') @@ -174,12 +179,12 @@ class HttpClient: headers.update(get_app().signer.sign_headers('POST', url, message, algorithm=algorithm)) try: - logging.verbose('Sending "%s" to %s', message.type, url) + logging.verbose('Sending "%s" to %s', message.type.value, url) async with self._session.post(url, headers = headers, data = message.to_json()) as resp: # Not expecting a response, so just return if resp.status in {200, 202}: - logging.verbose('Successfully sent "%s" to %s', message.type, url) + logging.verbose('Successfully sent "%s" to %s', message.type.value, url) return logging.verbose('Received error when pushing to %s: %i', url, resp.status) diff --git a/relay/misc.py b/relay/misc.py index cb901bc..5af6e60 100644 --- a/relay/misc.py +++ b/relay/misc.py @@ -130,10 +130,8 @@ class Message(aputils.Message): description: str | None = None, approves: bool = False) -> Self: - return cls({ - '@context': 'https://www.w3.org/ns/activitystreams', + return cls.new(aputils.ObjectType.APPLICATION, { 'id': f'https://{host}/actor', - 'type': 'Application', 'preferredUsername': 'relay', 'name': 'ActivityRelay', 'summary': description or 'ActivityRelay bot', @@ -155,10 +153,8 @@ class Message(aputils.Message): @classmethod def new_announce(cls: type[Self], host: str, obj: str | dict[str, Any]) -> Self: - return cls({ - '@context': 'https://www.w3.org/ns/activitystreams', + return cls.new(aputils.ObjectType.ANNOUNCE, { 'id': f'https://{host}/activities/{uuid4()}', - 'type': 'Announce', 'to': [f'https://{host}/followers'], 'actor': f'https://{host}/actor', 'object': obj @@ -167,22 +163,18 @@ class Message(aputils.Message): @classmethod def new_follow(cls: type[Self], host: str, actor: str) -> Self: - return cls({ - '@context': 'https://www.w3.org/ns/activitystreams', - 'type': 'Follow', + return cls.new(aputils.ObjectType.FOLLOW, { + 'id': f'https://{host}/activities/{uuid4()}', 'to': [actor], 'object': actor, - 'id': f'https://{host}/activities/{uuid4()}', 'actor': f'https://{host}/actor' }) @classmethod def new_unfollow(cls: type[Self], host: str, actor: str, follow: dict[str, str]) -> Self: - return cls({ - '@context': 'https://www.w3.org/ns/activitystreams', + return cls.new(aputils.ObjectType.UNDO, { 'id': f'https://{host}/activities/{uuid4()}', - 'type': 'Undo', 'to': [actor], 'actor': f'https://{host}/actor', 'object': follow @@ -191,10 +183,8 @@ class Message(aputils.Message): @classmethod def new_response(cls: type[Self], host: str, actor: str, followid: str, accept: bool) -> Self: - return cls({ - '@context': 'https://www.w3.org/ns/activitystreams', + return cls.new(aputils.ObjectType.ACCEPT if accept else aputils.ObjectType.REJECT, { 'id': f'https://{host}/activities/{uuid4()}', - 'type': 'Accept' if accept else 'Reject', 'to': [actor], 'actor': f'https://{host}/actor', 'object': { diff --git a/relay/views/activitypub.py b/relay/views/activitypub.py index 68f1c23..9f8afb0 100644 --- a/relay/views/activitypub.py +++ b/relay/views/activitypub.py @@ -136,20 +136,20 @@ class ActorView(View): if not digest.validate(body): raise aputils.SignatureFailureError("Body digest does not match") - if self.signature.algorithm_type == "hs2019": - if "(created)" not in self.signature.headers: - raise aputils.SignatureFailureError("'(created)' header not used") + if self.signature.algorithm_type == aputils.AlgorithmType.HS2019: + if self.signature.created is None or self.signature.expires is None: + raise aputils.SignatureFailureError("Missing 'created' or 'expireds' parameter") current_timestamp = aputils.HttpDate.new_utc().timestamp() if self.signature.created > current_timestamp: raise aputils.SignatureFailureError("Creation date after current date") - if current_timestamp > self.signature.expires: - raise aputils.SignatureFailureError("Expiration date before current date") + if self.signature.expires < current_timestamp: + raise aputils.SignatureFailureError("Signature has expired") - headers["(created)"] = self.signature.created - headers["(expires)"] = self.signature.expires + headers["(created)"] = str(self.signature.created) + headers["(expires)"] = str(self.signature.expires) if not self.signer._validate_signature(headers, self.signature): raise aputils.SignatureFailureError("Signature does not match") diff --git a/requirements.txt b/requirements.txt index a0da5af..3b2d667 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ +activitypub-utils == 0.1.9 aiohttp >= 3.9.1 aiohttp-swagger[performance] == 1.0.16 -aputils @ https://git.barkshark.xyz/barkshark/aputils/archive/0.1.7.tar.gz argon2-cffi == 23.1.0 barkshark-sql @ https://git.barkshark.xyz/barkshark/bsql/archive/0.1.2.tar.gz click >= 8.1.2 From 938d3f419ee2d41d65e8ba0bc39d5a5ec1a512b2 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Wed, 27 Mar 2024 11:48:05 -0400 Subject: [PATCH 39/57] properly detect if running via systemd --- installation/relay.service | 1 + relay/logger.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/installation/relay.service b/installation/relay.service index 0325316..cf9fa6a 100644 --- a/installation/relay.service +++ b/installation/relay.service @@ -4,6 +4,7 @@ Description=ActivityPub Relay [Service] WorkingDirectory=/home/relay/relay ExecStart=/usr/bin/python3 -m relay run +Environment="IS_SYSTEMD=1" [Install] WantedBy=multi-user.target diff --git a/relay/logger.py b/relay/logger.py index b8ff918..9729eb2 100644 --- a/relay/logger.py +++ b/relay/logger.py @@ -87,7 +87,7 @@ handlers: list[Any] = [logging.StreamHandler()] if env_log_file: handlers.append(logging.FileHandler(env_log_file)) -if os.environ.get('INVOCATION_ID'): +if os.environ.get('IS_SYSTEMD'): logging_format = '%(levelname)s: %(message)s' else: From e700f9baa2c883205e558db59754829f75cc8b48 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Wed, 27 Mar 2024 12:48:22 -0400 Subject: [PATCH 40/57] use right algorithm for inbox pushes * return instance rows from distill_inboxes * pass the row of the instance being POSTed to the instance parameter of HttpClient.post --- relay/database/connection.py | 8 ++++---- relay/processors.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/relay/database/connection.py b/relay/database/connection.py index 8c64641..67ef5c0 100644 --- a/relay/database/connection.py +++ b/relay/database/connection.py @@ -42,15 +42,15 @@ class Connection(SqlConnection): return get_app() - def distill_inboxes(self, message: Message) -> Iterator[str]: + def distill_inboxes(self, message: Message) -> Iterator[Row]: src_domains = { message.domain, urlparse(message.object_id).netloc } - for inbox in self.get_inboxes(): - if inbox['domain'] not in src_domains: - yield inbox['inbox'] + for instance in self.get_inboxes(): + if instance['domain'] not in src_domains: + yield instance def get_config(self, key: str) -> Any: diff --git a/relay/processors.py b/relay/processors.py index 5d2634b..910ecf3 100644 --- a/relay/processors.py +++ b/relay/processors.py @@ -35,8 +35,8 @@ async def handle_relay(view: ActorView, conn: Connection) -> None: message = Message.new_announce(view.config.domain, view.message.object_id) logging.debug('>> relay: %s', message) - for inbox in conn.distill_inboxes(view.message): - view.app.push_message(inbox, message, view.instance) + for instance in conn.distill_inboxes(view.message): + view.app.push_message(instance["inbox"], message, instance) view.cache.set('handle-relay', view.message.object_id, message.id, 'str') @@ -53,8 +53,8 @@ async def handle_forward(view: ActorView, conn: Connection) -> None: message = Message.new_announce(view.config.domain, view.message) logging.debug('>> forward: %s', message) - for inbox in conn.distill_inboxes(view.message): - view.app.push_message(inbox, message, view.instance) + for instance in conn.distill_inboxes(view.message): + view.app.push_message(instance["inbox"], await view.request.read(), instance) view.cache.set('handle-relay', view.message.id, message.id, 'str') From a1ceb0cb4bd494bd18532fc1fc87e540d70631af Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Wed, 27 Mar 2024 12:50:07 -0400 Subject: [PATCH 41/57] update aputils to 0.2.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3b2d667..658ae2d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -activitypub-utils == 0.1.9 +activitypub-utils == 0.2.0 aiohttp >= 3.9.1 aiohttp-swagger[performance] == 1.0.16 argon2-cffi == 23.1.0 From 8ed1daeae57c207607729cffaeb49a94bcf993f3 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Wed, 27 Mar 2024 12:50:53 -0400 Subject: [PATCH 42/57] allow HttpClient.post to accept bytes objects --- relay/http_client.py | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/relay/http_client.py b/relay/http_client.py index a475a5a..ad42331 100644 --- a/relay/http_client.py +++ b/relay/http_client.py @@ -7,7 +7,7 @@ import typing from aiohttp import ClientSession, ClientTimeout, TCPConnector from aiohttp.client_exceptions import ClientConnectionError, ClientSSLError from asyncio.exceptions import TimeoutError as AsyncTimeoutError -from aputils import JsonBase, Nodeinfo, WellKnownNodeinfo +from aputils import AlgorithmType, JsonBase, Nodeinfo, ObjectType, WellKnownNodeinfo from json.decoder import JSONDecodeError from urllib.parse import urlparse @@ -111,7 +111,7 @@ class HttpClient: headers = {} if sign_headers: - headers = self.signer.sign_headers('GET', url, algorithm = 'original') + headers = self.signer.sign_headers('GET', url, algorithm = AlgorithmType.HS2019) try: logging.debug('Fetching resource: %s', url) @@ -164,31 +164,50 @@ class HttpClient: return cls.parse(data) - async def post(self, url: str, message: Message, instance: Row | None = None) -> None: + async def post(self, url: str, data: Message | bytes, instance: Row | None = None) -> None: if not self._session: raise RuntimeError('Client not open') - # Using the old algo by default is probably a better idea right now + # akkoma and pleroma do not support HS2019 and other software still needs to be tested if instance and instance['software'] in {'mastodon'}: - algorithm = 'hs2019' + algorithm = AlgorithmType.HS2019 else: - algorithm = 'original' + algorithm = AlgorithmType.RSASHA256 - headers = {'Content-Type': 'application/activity+json'} - headers.update(get_app().signer.sign_headers('POST', url, message, algorithm=algorithm)) + body: bytes + message: Message + + if isinstance(data, bytes): + body = data + message = Message.parse(data) + + else: + body = data.to_json().encode("utf-8") + message = data + + mtype = message.type.value if isinstance(message.type, ObjectType) else message.type + headers = self.signer.sign_headers( + 'POST', + url, + body, + headers = {'Content-Type': 'application/activity+json'}, + algorithm = algorithm + ) try: - logging.verbose('Sending "%s" to %s', message.type.value, url) + logging.verbose('Sending "%s" to %s', mtype, url) - async with self._session.post(url, headers = headers, data = message.to_json()) as resp: + async with self._session.post(url, headers = headers, data = body) as resp: # Not expecting a response, so just return if resp.status in {200, 202}: - logging.verbose('Successfully sent "%s" to %s', message.type.value, url) + logging.verbose('Successfully sent "%s" to %s', mtype, url) return logging.verbose('Received error when pushing to %s: %i', url, resp.status) logging.debug(await resp.read()) + logging.debug("message: %s", body.decode("utf-8")) + logging.debug("headers: %s", json.dumps(headers, indent = 4)) return except ClientSSLError: From 0709d8deb9145f65540c530d884c17b6114ade9b Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Wed, 27 Mar 2024 12:52:58 -0400 Subject: [PATCH 43/57] remove unnecessary ActorView.validate_signature method --- relay/application.py | 4 ++-- relay/views/activitypub.py | 35 +++-------------------------------- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/relay/application.py b/relay/application.py index e312692..cd8f89a 100644 --- a/relay/application.py +++ b/relay/application.py @@ -65,7 +65,7 @@ class Application(web.Application): Application.DEFAULT = self - self['running'] = None + self['running'] = False self['signer'] = None self['start_time'] = None self['cleanup_thread'] = None @@ -142,7 +142,7 @@ class Application(web.Application): return timedelta(seconds=uptime.seconds) - def push_message(self, inbox: str, message: Message, instance: Row) -> None: + def push_message(self, inbox: str, message: Message | bytes, instance: Row) -> None: self['push_queue'].put((inbox, message, instance)) diff --git a/relay/views/activitypub.py b/relay/views/activitypub.py index 9f8afb0..fe87181 100644 --- a/relay/views/activitypub.py +++ b/relay/views/activitypub.py @@ -3,6 +3,7 @@ from __future__ import annotations import aputils import traceback import typing +import json from .base import View, register_route @@ -71,7 +72,7 @@ class ActorView(View): async def get_post_data(self) -> Response | None: try: - self.signature = aputils.Signature.new_from_signature(self.request.headers['signature']) + self.signature = aputils.Signature.parse(self.request.headers['signature']) except KeyError: logging.verbose('Missing signature header') @@ -116,7 +117,7 @@ class ActorView(View): return Response.new_error(400, 'actor missing public key', 'json') try: - self.validate_signature(await self.request.read()) + await self.signer.validate_aiohttp_request(self.request) except aputils.SignatureFailureError as e: logging.verbose('signature validation failed for "%s": %s', self.actor.id, e) @@ -125,36 +126,6 @@ class ActorView(View): return None - def validate_signature(self, body: bytes) -> None: - headers = {key.lower(): value for key, value in self.request.headers.items()} - headers["(request-target)"] = " ".join([self.request.method.lower(), self.request.path]) - - if (digest := aputils.Digest.new_from_digest(headers.get("digest"))): - if not body: - raise aputils.SignatureFailureError("Missing body for digest verification") - - if not digest.validate(body): - raise aputils.SignatureFailureError("Body digest does not match") - - if self.signature.algorithm_type == aputils.AlgorithmType.HS2019: - if self.signature.created is None or self.signature.expires is None: - raise aputils.SignatureFailureError("Missing 'created' or 'expireds' parameter") - - current_timestamp = aputils.HttpDate.new_utc().timestamp() - - if self.signature.created > current_timestamp: - raise aputils.SignatureFailureError("Creation date after current date") - - if self.signature.expires < current_timestamp: - raise aputils.SignatureFailureError("Signature has expired") - - headers["(created)"] = str(self.signature.created) - headers["(expires)"] = str(self.signature.expires) - - if not self.signer._validate_signature(headers, self.signature): - raise aputils.SignatureFailureError("Signature does not match") - - @register_route('/.well-known/webfinger') class WebfingerView(View): async def get(self, request: Request) -> Response: From 014c6896b1b66e9e653a783d6502bf0413a649ed Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Thu, 28 Mar 2024 05:57:28 -0400 Subject: [PATCH 44/57] some minor changes * version bump to 0.3.1 * remove debug print in frontend endpoints * remove unused json import * fix toast messages for accepting/denying follow requests --- relay/__init__.py | 2 +- relay/frontend/static/instance.js | 3 ++- relay/views/activitypub.py | 1 - relay/views/frontend.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/relay/__init__.py b/relay/__init__.py index 0404d81..e1424ed 100644 --- a/relay/__init__.py +++ b/relay/__init__.py @@ -1 +1 @@ -__version__ = '0.3.0' +__version__ = '0.3.1' diff --git a/relay/frontend/static/instance.js b/relay/frontend/static/instance.js index 8a4956f..a07b647 100644 --- a/relay/frontend/static/instance.js +++ b/relay/frontend/static/instance.js @@ -100,6 +100,7 @@ async function req_response(domain, accept) { } if (!accept) { + toast("Denied instance request", "message"); return; } @@ -117,7 +118,7 @@ async function req_response(domain, accept) { } }); - toast("Removed instance", "message"); + toast("Accepted instance request", "message"); } diff --git a/relay/views/activitypub.py b/relay/views/activitypub.py index fe87181..dc2115f 100644 --- a/relay/views/activitypub.py +++ b/relay/views/activitypub.py @@ -3,7 +3,6 @@ from __future__ import annotations import aputils import traceback import typing -import json from .base import View, register_route diff --git a/relay/views/frontend.py b/relay/views/frontend.py index 91af960..ae1043a 100644 --- a/relay/views/frontend.py +++ b/relay/views/frontend.py @@ -45,7 +45,6 @@ async def handle_frontend_path(request: web.Request, handler: Callable) -> Respo response = await handler(request) if not request['user'] and request['token']: - print("del token") response.del_cookie('user-token') return response From ec7e254740a925a9d5c932051f474561be024f81 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Thu, 28 Mar 2024 06:04:17 -0400 Subject: [PATCH 45/57] merge menu and toast resources into api.js and style.css --- relay/frontend/base.haml | 3 -- relay/frontend/static/api.js | 58 +++++++++++++++++++++++++++ relay/frontend/static/menu.js | 21 ---------- relay/frontend/static/style.css | 69 +++++++++++++++++++++++++++++++++ relay/frontend/static/toast.css | 68 -------------------------------- relay/frontend/static/toast.js | 26 ------------- 6 files changed, 127 insertions(+), 118 deletions(-) delete mode 100644 relay/frontend/static/menu.js delete mode 100644 relay/frontend/static/toast.css delete mode 100644 relay/frontend/static/toast.js diff --git a/relay/frontend/base.haml b/relay/frontend/base.haml index 4b4a3d1..d58c98b 100644 --- a/relay/frontend/base.haml +++ b/relay/frontend/base.haml @@ -13,9 +13,6 @@ %meta(name="viewport" content="width=device-width, initial-scale=1") %link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css" nonce="{{view.request['hash']}}" class="theme") %link(rel="stylesheet" type="text/css" href="/static/style.css" nonce="{{view.request['hash']}}") - %link(rel="stylesheet" type="text/css" href="/static/toast.css" nonce="{{view.request['hash']}}") - %script(type="application/javascript" src="/static/menu.js" nonce="{{view.request['hash']}}", defer) - %script(type="application/javascript" src="/static/toast.js" nonce="{{view.request['hash']}}", defer) %script(type="application/javascript" src="/static/api.js" nonce="{{view.request['hash']}}", defer) -block head diff --git a/relay/frontend/static/api.js b/relay/frontend/static/api.js index d064d7b..65423ba 100644 --- a/relay/frontend/static/api.js +++ b/relay/frontend/static/api.js @@ -1,3 +1,61 @@ +// toast notifications + +const notifications = document.querySelector("#notifications") + + +function remove_toast(toast) { + toast.classList.add("hide"); + + if (toast.timeoutId) { + clearTimeout(toast.timeoutId); + } + + setTimeout(() => toast.remove(), 300); +} + +function toast(text, type="error", timeout=5) { + const toast = document.createElement("li"); + toast.className = `section ${type}` + toast.innerHTML = `${text}✖` + + toast.querySelector("a").addEventListener("click", async (event) => { + event.preventDefault(); + await remove_toast(toast); + }); + + notifications.appendChild(toast); + toast.timeoutId = setTimeout(() => remove_toast(toast), timeout * 1000); +} + + +// menu + +const body = document.getElementById("container") +const menu = document.getElementById("menu"); +const menu_open = document.getElementById("menu-open"); +const menu_close = document.getElementById("menu-close"); + + +menu_open.addEventListener("click", (event) => { + var new_value = menu.attributes.visible.nodeValue === "true" ? "false" : "true"; + menu.attributes.visible.nodeValue = new_value; +}); + +menu_close.addEventListener("click", (event) => { + menu.attributes.visible.nodeValue = "false" +}); + +body.addEventListener("click", (event) => { + if (event.target === menu_open) { + return; + } + + menu.attributes.visible.nodeValue = "false"; +}); + + +// misc + function get_date_string(date) { var year = date.getFullYear().toString(); var month = date.getMonth().toString(); diff --git a/relay/frontend/static/menu.js b/relay/frontend/static/menu.js deleted file mode 100644 index ebd494f..0000000 --- a/relay/frontend/static/menu.js +++ /dev/null @@ -1,21 +0,0 @@ -const body = document.getElementById("container") -const menu = document.getElementById("menu"); -const menu_open = document.getElementById("menu-open"); -const menu_close = document.getElementById("menu-close"); - -menu_open.addEventListener("click", (event) => { - var new_value = menu.attributes.visible.nodeValue === "true" ? "false" : "true"; - menu.attributes.visible.nodeValue = new_value; -}); - -menu_close.addEventListener("click", (event) => { - menu.attributes.visible.nodeValue = "false" -}); - -body.addEventListener("click", (event) => { - if (event.target === menu_open) { - return; - } - - menu.attributes.visible.nodeValue = "false"; -}); diff --git a/relay/frontend/static/style.css b/relay/frontend/static/style.css index e1ac3eb..635aa55 100644 --- a/relay/frontend/static/style.css +++ b/relay/frontend/static/style.css @@ -204,6 +204,37 @@ textarea { text-align: center; } +#notifications { + position: fixed; + top: 40px; + left: 50%; + transform: translateX(-50%); +} + +#notifications li { + position: relative; + overflow: hidden; + list-style: none; + border-radius: 5px; + padding: 5px;; + margin-bottom: var(--spacing); + animation: show_toast 0.3s ease forwards; + display: grid; + grid-template-columns: auto max-content; + grid-gap: 5px; + align-items: center; +} + +#notifications a { + font-size: 1.5em; + line-height: 1em; + text-decoration: none; +} + +#notifications li.hide { + animation: hide_toast 0.3s ease forwards; +} + #footer { display: grid; grid-template-columns: auto auto; @@ -296,6 +327,44 @@ textarea { } +@keyframes show_toast { + 0% { + transform: translateX(100%); + } + + 40% { + transform: translateX(-5%); + } + + 80% { + transform: translateX(0%); + } + + 100% { + transform: translateX(-10px); + } +} + + +@keyframes hide_toast { + 0% { + transform: translateX(-10px); + } + + 40% { + transform: translateX(0%); + } + + 80% { + transform: translateX(-5%); + } + + 100% { + transform: translateX(calc(100% + 20px)); + } +} + + @media (max-width: 1026px) { body { margin: 0px; diff --git a/relay/frontend/static/toast.css b/relay/frontend/static/toast.css deleted file mode 100644 index e544dcd..0000000 --- a/relay/frontend/static/toast.css +++ /dev/null @@ -1,68 +0,0 @@ -#notifications { - position: fixed; - top: 40px; - left: 50%; - transform: translateX(-50%); -} - -#notifications li { - position: relative; - overflow: hidden; - list-style: none; - border-radius: 5px; - padding: 5px;; - margin-bottom: var(--spacing); - animation: show_toast 0.3s ease forwards; - display: grid; - grid-template-columns: auto max-content; - grid-gap: 5px; - align-items: center; -} - -#notifications a { - font-size: 1.5em; - line-height: 1em; - text-decoration: none; -} - -#notifications li.hide { - animation: hide_toast 0.3s ease forwards; -} - - -@keyframes show_toast { - 0% { - transform: translateX(100%); - } - - 40% { - transform: translateX(-5%); - } - - 80% { - transform: translateX(0%); - } - - 100% { - transform: translateX(-10px); - } -} - - -@keyframes hide_toast { - 0% { - transform: translateX(-10px); - } - - 40% { - transform: translateX(0%); - } - - 80% { - transform: translateX(-5%); - } - - 100% { - transform: translateX(calc(100% + 20px)); - } -} diff --git a/relay/frontend/static/toast.js b/relay/frontend/static/toast.js deleted file mode 100644 index e4ca8cc..0000000 --- a/relay/frontend/static/toast.js +++ /dev/null @@ -1,26 +0,0 @@ -const notifications = document.querySelector("#notifications") - - -function remove_toast(toast) { - toast.classList.add("hide"); - - if (toast.timeoutId) { - clearTimeout(toast.timeoutId); - } - - setTimeout(() => toast.remove(), 300); -} - -function toast(text, type="error", timeout=5) { - const toast = document.createElement("li"); - toast.className = `section ${type}` - toast.innerHTML = `${text}✖` - - toast.querySelector("a").addEventListener("click", async (event) => { - event.preventDefault(); - await remove_toast(toast); - }); - - notifications.appendChild(toast); - toast.timeoutId = setTimeout(() => remove_toast(toast), timeout * 1000); -} From beb9d9c3e5bbb805f00f856526b57752a342d22f Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Thu, 28 Mar 2024 07:12:27 -0400 Subject: [PATCH 46/57] add web manifest to frontend --- relay/application.py | 3 ++- relay/frontend/base.haml | 1 + relay/misc.py | 3 ++- relay/views/frontend.py | 24 ++++++++++++++++++++---- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/relay/application.py b/relay/application.py index cd8f89a..aba9bc7 100644 --- a/relay/application.py +++ b/relay/application.py @@ -44,7 +44,8 @@ def get_csp(request: web.Request) -> str: "connect-src 'self'", "img-src 'self'", "object-src 'none'", - "frame-ancestors 'none'" + "frame-ancestors 'none'", + f"manifest-src 'self' https://{request.app.config.domain}" ] return '; '.join(data) + ';' diff --git a/relay/frontend/base.haml b/relay/frontend/base.haml index d58c98b..7b366ab 100644 --- a/relay/frontend/base.haml +++ b/relay/frontend/base.haml @@ -13,6 +13,7 @@ %meta(name="viewport" content="width=device-width, initial-scale=1") %link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css" nonce="{{view.request['hash']}}" class="theme") %link(rel="stylesheet" type="text/css" href="/static/style.css" nonce="{{view.request['hash']}}") + %link(rel="manifest" href="/manifest.json") %script(type="application/javascript" src="/static/api.js" nonce="{{view.request['hash']}}", defer) -block head diff --git a/relay/misc.py b/relay/misc.py index 5af6e60..ae18b2d 100644 --- a/relay/misc.py +++ b/relay/misc.py @@ -38,7 +38,8 @@ MIMETYPES = { 'css': 'text/css', 'html': 'text/html', 'json': 'application/json', - 'text': 'text/plain' + 'text': 'text/plain', + 'webmanifest': 'application/manifest+json' } NODEINFO_NS = { diff --git a/relay/views/frontend.py b/relay/views/frontend.py index ae1043a..4aed045 100644 --- a/relay/views/frontend.py +++ b/relay/views/frontend.py @@ -210,11 +210,27 @@ class AdminConfig(View): return Response.new(data, ctype = 'html') -@register_route('/style.css') -class StyleCss(View): +@register_route('/manifest.json') +class ManifestJson(View): async def get(self, request: Request) -> Response: - data = self.template.render('style.css', self) - return Response.new(data, ctype = 'css') + with self.database.session(False) as conn: + config = conn.get_config_all() + theme = THEMES[config.theme] + + data = { + 'background_color': theme['background'], + 'categories': ['activitypub'], + 'description': 'Message relay for the ActivityPub network', + 'display': 'standalone', + 'name': config['name'], + 'orientation': 'portrait', + 'scope': f"https://{self.config.domain}/", + 'short_name': 'ActivityRelay', + 'start_url': f"https://{self.config.domain}/", + 'theme_color': theme['primary'] + } + + return Response.new(data, ctype = 'webmanifest') @register_route('/theme/{theme}.css') From 587b9483d77f55dd9bc2303d46812902ac154889 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sat, 30 Mar 2024 05:33:06 -0400 Subject: [PATCH 47/57] create a task instead of waiting for client posts in workers --- relay/application.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/relay/application.py b/relay/application.py index aba9bc7..e48c292 100644 --- a/relay/application.py +++ b/relay/application.py @@ -315,11 +315,11 @@ class PushWorker(multiprocessing.Process): while not self.shutdown.is_set(): try: - inbox, message, instance = self.queue.get(block=True, timeout=0.25) - await client.post(inbox, message, instance) + inbox, message, instance = self.queue.get(block=True, timeout=0.1) + asyncio.create_task(client.post(inbox, message, instance)) except Empty: - pass + await asyncio.sleep(0) # make sure an exception doesn't bring down the worker except Exception: From f7e31d9387c163480b9c32bd7c183d87cc67104e Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sat, 30 Mar 2024 05:33:26 -0400 Subject: [PATCH 48/57] add cors headers to api routes --- relay/frontend/base.haml | 2 +- relay/http_client.py | 12 ++++++++---- relay/views/activitypub.py | 2 +- relay/views/api.py | 16 ++++++++++++++-- relay/views/base.py | 4 ++++ relay/views/frontend.py | 2 +- 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/relay/frontend/base.haml b/relay/frontend/base.haml index 7b366ab..5c4bc1b 100644 --- a/relay/frontend/base.haml +++ b/relay/frontend/base.haml @@ -14,7 +14,7 @@ %link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css" nonce="{{view.request['hash']}}" class="theme") %link(rel="stylesheet" type="text/css" href="/static/style.css" nonce="{{view.request['hash']}}") %link(rel="manifest" href="/manifest.json") - %script(type="application/javascript" src="/static/api.js" nonce="{{view.request['hash']}}", defer) + %script(type="application/javascript" src="/static/api.js" nonce="{{view.request['hash']}}" defer) -block head %body diff --git a/relay/http_client.py b/relay/http_client.py index ad42331..04533c5 100644 --- a/relay/http_client.py +++ b/relay/http_client.py @@ -137,11 +137,13 @@ class HttpClient: logging.verbose('Failed to parse JSON') return None - except ClientSSLError: + except ClientSSLError as e: logging.verbose('SSL error when connecting to %s', urlparse(url).netloc) + logging.warning(str(e)) - except (AsyncTimeoutError, ClientConnectionError): + except (AsyncTimeoutError, ClientConnectionError) as e: logging.verbose('Failed to connect to %s', urlparse(url).netloc) + logging.warning(str(e)) except Exception: traceback.print_exc() @@ -210,11 +212,13 @@ class HttpClient: logging.debug("headers: %s", json.dumps(headers, indent = 4)) return - except ClientSSLError: + except ClientSSLError as e: logging.warning('SSL error when pushing to %s', urlparse(url).netloc) + logging.warning(str(e)) - except (AsyncTimeoutError, ClientConnectionError): + except (AsyncTimeoutError, ClientConnectionError) as e: logging.warning('Failed to connect to %s for message push', urlparse(url).netloc) + logging.warning(str(e)) # prevent workers from being brought down except Exception: diff --git a/relay/views/activitypub.py b/relay/views/activitypub.py index dc2115f..f2eff48 100644 --- a/relay/views/activitypub.py +++ b/relay/views/activitypub.py @@ -116,7 +116,7 @@ class ActorView(View): return Response.new_error(400, 'actor missing public key', 'json') try: - await self.signer.validate_aiohttp_request(self.request) + await self.signer.validate_request_async(self.request) except aputils.SignatureFailureError as e: logging.verbose('signature validation failed for "%s": %s', self.actor.id, e) diff --git a/relay/views/api.py b/relay/views/api.py index 92e29d8..04b9af8 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -18,6 +18,12 @@ if typing.TYPE_CHECKING: from typing import Any +ALLOWED_HEADERS = { + 'accept', + 'authorization', + 'content-type' +} + PUBLIC_API_PATHS: Sequence[tuple[str, str]] = ( ('GET', '/api/v1/relay'), ('GET', '/api/v1/instance'), @@ -48,14 +54,20 @@ async def handle_api_path(request: Request, handler: Callable) -> Response: request['token'] = None request['user'] = None - if check_api_path(request.method, request.path): + if request.method != "OPTIONS" and 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) + response = await handler(request) + + if request.path.startswith('/api'): + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Headers'] = ', '.join(ALLOWED_HEADERS) + + return response @register_route('/api/v1/token') diff --git a/relay/views/base.py b/relay/views/base.py index 3d7d718..7cf8311 100644 --- a/relay/views/base.py +++ b/relay/views/base.py @@ -62,6 +62,10 @@ class View(AbstractView): return await handler(self.request, **self.request.match_info, **kwargs) + async def options(self, request: Request) -> Response: + return Response.new() + + @cached_property def allowed_methods(self) -> Sequence[str]: return tuple(self.handlers.keys()) diff --git a/relay/views/frontend.py b/relay/views/frontend.py index 4aed045..2b5bec0 100644 --- a/relay/views/frontend.py +++ b/relay/views/frontend.py @@ -44,7 +44,7 @@ async def handle_frontend_path(request: web.Request, handler: Callable) -> Respo response = await handler(request) - if not request['user'] and request['token']: + if not request.path.startswith('/api') and not request['user'] and request['token']: response.del_cookie('user-token') return response From 34218461115313f220fce34ab645223a060f2977 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sun, 31 Mar 2024 11:00:31 -0400 Subject: [PATCH 49/57] add --watch option to lint dev command --- relay/dev.py | 109 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/relay/dev.py b/relay/dev.py index c03becd..7517946 100644 --- a/relay/dev.py +++ b/relay/dev.py @@ -4,9 +4,10 @@ import subprocess import sys import time -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from tempfile import TemporaryDirectory +from typing import Sequence from . import __version__ from . import logger as logging @@ -47,14 +48,24 @@ def cli_install(): @cli.command('lint') @click.argument('path', required = False, default = 'relay') @click.option('--strict', '-s', is_flag = True, help = 'Enable strict mode for mypy') -def cli_lint(path: str, strict: bool) -> None: - cmd: list[str] = [sys.executable, '-m', 'mypy'] +@click.option('--watch', '-w', is_flag = True, + help = 'Automatically, re-run the linters on source change') +def cli_lint(path: str, strict: bool, watch: bool) -> None: + flake8 = [sys.executable, '-m', 'flake8', path] + mypy = [sys.executable, '-m', 'mypy', path] if strict: - cmd.append('--strict') + mypy.append('--strict') - subprocess.run([*cmd, path], check = False) - subprocess.run([sys.executable, '-m', 'flake8', path]) + if watch: + handle_run_watcher(mypy, flake8, wait = True) + return + + click.echo('----- flake8 -----') + subprocess.run(flake8) + + click.echo('\n\n----- mypy -----') + subprocess.run(mypy) @cli.command('build') @@ -91,8 +102,17 @@ def cli_build(): def cli_run(dev: bool): print('Starting process watcher') - handler = WatchHandler(dev) - handler.run_proc() + cmd = [sys.executable, '-m', 'relay', 'run'] + + if dev: + cmd.append('-d') + + handle_run_watcher(cmd) + + +def handle_run_watcher(*commands: Sequence[str], wait: bool = False): + handler = WatchHandler(*commands, wait = wait) + handler.run_procs() watcher = Observer() watcher.schedule(handler, str(SCRIPT), recursive=True) @@ -100,13 +120,12 @@ def cli_run(dev: bool): try: while True: - handler.proc.stdin.write(sys.stdin.read().encode('UTF-8')) # type: ignore - handler.proc.stdin.flush() # type: ignore + time.sleep(1) except KeyboardInterrupt: pass - handler.kill_proc() + handler.kill_procs() watcher.stop() watcher.join() @@ -114,61 +133,65 @@ def cli_run(dev: bool): class WatchHandler(PatternMatchingEventHandler): patterns = ['*.py'] - cmd = [sys.executable, '-m', 'relay', 'run'] - def __init__(self, dev: bool): + def __init__(self, *commands: Sequence[str], wait: bool = False): PatternMatchingEventHandler.__init__(self) - self.dev: bool = dev - self.proc: subprocess.Popen | None = None - self.last_restart: datetime | None = None + self.commands: Sequence[Sequence[str]] = commands + self.wait: bool = wait + self.procs: list[subprocess.Popen] = [] + self.last_restart: datetime = datetime.now() - def kill_proc(self): - if not self.proc or self.proc.poll() is not None: - return + def kill_procs(self): + for proc in self.procs: + if proc.poll() is not None: + continue - logging.info(f'Terminating process {self.proc.pid}') - self.proc.terminate() - sec = 0.0 + logging.info(f'Terminating process {proc.pid}') + proc.terminate() + sec = 0.0 - while self.proc.poll() is None: - time.sleep(0.1) - sec += 0.1 + while proc.poll() is None: + time.sleep(0.1) + sec += 0.1 - if sec >= 5: - logging.error('Failed to terminate. Killing process...') - self.proc.kill() - break + if sec >= 5: + logging.error('Failed to terminate. Killing process...') + proc.kill() + break - logging.info('Process terminated') + logging.info('Process terminated') - def run_proc(self, restart=False): - timestamp = datetime.timestamp(datetime.now()) - self.last_restart = timestamp if not self.last_restart else 0 - - if restart and self.proc.pid != '': - if timestamp - 3 < self.last_restart: + def run_procs(self, restart: bool = False): + if restart: + if datetime.now() - timedelta(seconds = 3) < self.last_restart: return - self.kill_proc() + self.kill_procs() - cmd = [*self.cmd, '-d'] if self.dev else self.cmd + self.last_restart = datetime.now() - self.proc = subprocess.Popen(cmd, stdin = subprocess.PIPE) - self.last_restart = timestamp + if self.wait: + self.procs = [] - logging.info('Started process with PID %i', self.proc.pid) - logging.info('Command: %s', ' '.join(cmd)) + for cmd in self.commands: + logging.info('Running command: %s', ' '.join(cmd)) + subprocess.run(cmd) + + else: + self.procs = list(subprocess.Popen(cmd) for cmd in self.commands) + pids = (str(proc.pid) for proc in self.procs) + logging.info('Started processes with PIDs: %s', ', '.join(pids)) def on_any_event(self, event): if event.event_type not in ['modified', 'created', 'deleted']: return - self.run_proc(restart = True) + self.run_procs(restart = True) if __name__ == '__main__': From 6e914fb50c66209fb96c448725a0698562647e32 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sun, 31 Mar 2024 11:35:33 -0400 Subject: [PATCH 50/57] fix linter warning --- relay/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/application.py b/relay/application.py index e48c292..628d9e5 100644 --- a/relay/application.py +++ b/relay/application.py @@ -45,7 +45,7 @@ def get_csp(request: web.Request) -> str: "img-src 'self'", "object-src 'none'", "frame-ancestors 'none'", - f"manifest-src 'self' https://{request.app.config.domain}" + f"manifest-src 'self' https://{request.app['config'].domain}" ] return '; '.join(data) + ';' From 2bb44d8e37093ab150610dd23667faf28f52c028 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sun, 31 Mar 2024 11:51:48 -0400 Subject: [PATCH 51/57] use typing_extensions module for python < 3.11 --- dev-requirements.txt | 2 ++ relay/config.py | 8 +++++++- relay/database/config.py | 8 +++++++- relay/logger.py | 8 +++++++- relay/misc.py | 8 +++++++- relay/views/base.py | 8 +++++++- 6 files changed, 37 insertions(+), 5 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index 6285aa4..aa8a793 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,3 +2,5 @@ flake8 == 7.0.0 mypy == 1.9.0 pyinstaller == 6.3.0 watchdog == 4.0.0 + +typing_extensions >= 4.10.0; python_version < '3.11.0' diff --git a/relay/config.py b/relay/config.py index 74758aa..7e95c29 100644 --- a/relay/config.py +++ b/relay/config.py @@ -13,7 +13,13 @@ from platformdirs import user_config_dir from .misc import IS_DOCKER if typing.TYPE_CHECKING: - from typing import Any, Self + from typing import Any + + try: + from typing import Self + + except ImportError: + from typing_extensions import Self if platform.system() == 'Windows': diff --git a/relay/database/config.py b/relay/database/config.py index 306cf4e..3922f62 100644 --- a/relay/database/config.py +++ b/relay/database/config.py @@ -10,7 +10,13 @@ from ..misc import boolean if typing.TYPE_CHECKING: from bsql import Row from collections.abc import Callable, Sequence - from typing import Any, Self + from typing import Any + + try: + from typing import Self + + except ImportError: + from typing_extensions import Self THEMES = { diff --git a/relay/logger.py b/relay/logger.py index 9729eb2..916fa71 100644 --- a/relay/logger.py +++ b/relay/logger.py @@ -9,7 +9,13 @@ from pathlib import Path if typing.TYPE_CHECKING: from collections.abc import Callable - from typing import Any, Self + from typing import Any + + try: + from typing import Self + + except ImportError: + from typing_extensions import Self class LogLevel(IntEnum): diff --git a/relay/misc.py b/relay/misc.py index ae18b2d..82b1fd2 100644 --- a/relay/misc.py +++ b/relay/misc.py @@ -18,9 +18,15 @@ except ImportError: from importlib_resources import files as pkgfiles # type: ignore if typing.TYPE_CHECKING: - from typing import Any, Self + from typing import Any from .application import Application + try: + from typing import Self + + except ImportError: + from typing_extensions import Self + T = typing.TypeVar('T') ResponseType = typing.TypedDict('ResponseType', { diff --git a/relay/views/base.py b/relay/views/base.py index 7cf8311..93b3e3b 100644 --- a/relay/views/base.py +++ b/relay/views/base.py @@ -16,13 +16,19 @@ if typing.TYPE_CHECKING: from aiohttp.web import Request from collections.abc import Callable, Generator, Sequence, Mapping from bsql import Database - from typing import Any, Self + from typing import Any from ..application import Application from ..cache import Cache from ..config import Config from ..http_client import HttpClient from ..template import Template + try: + from typing import Self + + except ImportError: + from typing_extensions import Self + VIEWS: list[tuple[str, type[View]]] = [] From 134afe24f76931b163f2b885dc92ce9f53a77e87 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sun, 31 Mar 2024 11:52:05 -0400 Subject: [PATCH 52/57] update aputils to 0.2.1 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 658ae2d..5649873 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -activitypub-utils == 0.2.0 +activitypub-utils == 0.2.1 aiohttp >= 3.9.1 aiohttp-swagger[performance] == 1.0.16 argon2-cffi == 23.1.0 From c200c295e71bdd43db5f0bb10a34e3998da37e13 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sun, 31 Mar 2024 18:23:35 -0400 Subject: [PATCH 53/57] ensure config key is formatted properly --- relay/database/connection.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/relay/database/connection.py b/relay/database/connection.py index 67ef5c0..f8de1c0 100644 --- a/relay/database/connection.py +++ b/relay/database/connection.py @@ -54,6 +54,8 @@ class Connection(SqlConnection): def get_config(self, key: str) -> Any: + key = key.replace('_', '-') + with self.run('get-config', {'key': key}) as cur: if not (row := cur.one()): return ConfigData.DEFAULT(key) @@ -72,10 +74,10 @@ class Connection(SqlConnection): field = ConfigData.FIELD(key) key = field.name.replace('_', '-') - if key == 'private_key': + if key == 'private-key': self.app.signer = value - elif key == 'log_level': + elif key == 'log-level': value = logging.LogLevel.parse(value) logging.set_level(value) From 673175b80b4745bc358af768898719c8a2e14c02 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Mon, 1 Apr 2024 07:18:10 -0400 Subject: [PATCH 54/57] add clean dev command --- relay/dev.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/relay/dev.py b/relay/dev.py index 7517946..e4c4809 100644 --- a/relay/dev.py +++ b/relay/dev.py @@ -1,5 +1,6 @@ import click import platform +import shutil import subprocess import sys import time @@ -46,13 +47,14 @@ def cli_install(): @cli.command('lint') -@click.argument('path', required = False, default = 'relay') +@click.argument('path', required = False, type = Path, default = REPO.joinpath('relay')) @click.option('--strict', '-s', is_flag = True, help = 'Enable strict mode for mypy') @click.option('--watch', '-w', is_flag = True, help = 'Automatically, re-run the linters on source change') -def cli_lint(path: str, strict: bool, watch: bool) -> None: - flake8 = [sys.executable, '-m', 'flake8', path] - mypy = [sys.executable, '-m', 'mypy', path] +def cli_lint(path: Path, strict: bool, watch: bool) -> None: + path = path.expanduser().resolve() + flake8 = [sys.executable, '-m', 'flake8', str(path)] + mypy = [sys.executable, '-m', 'mypy', str(path)] if strict: mypy.append('--strict') @@ -68,6 +70,24 @@ def cli_lint(path: str, strict: bool, watch: bool) -> None: subprocess.run(mypy) +@cli.command('clean') +def cli_clean(): + dirs = { + 'dist', + 'build', + 'dist-pypi' + } + + for directory in dirs: + shutil.rmtree(directory, ignore_errors = True) + + for path in REPO.glob('*.egg-info'): + shutil.rmtree(path) + + for path in REPO.glob('*.spec'): + path.unlink() + + @cli.command('build') def cli_build(): with TemporaryDirectory() as tmp: From 9708d526a92298d5ddc36f31c9106761fa4713df Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Mon, 1 Apr 2024 13:04:41 -0400 Subject: [PATCH 55/57] use correct license name --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index b7d4fdc..11fefe1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,7 +9,7 @@ license = AGPLv3 license_file = LICENSE classifiers = Environment :: Console - License :: OSI Approved :: AGPLv3 License + License :: OSI Approved :: GNU Affero General Public License v3 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 From 12b9ee317a434d72f1f8d2a3036e623c53d89590 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Mon, 1 Apr 2024 13:05:32 -0400 Subject: [PATCH 56/57] simplify dev lint command with watch flag --- relay/dev.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/relay/dev.py b/relay/dev.py index e4c4809..7ab0a4f 100644 --- a/relay/dev.py +++ b/relay/dev.py @@ -53,16 +53,17 @@ def cli_install(): help = 'Automatically, re-run the linters on source change') def cli_lint(path: Path, strict: bool, watch: bool) -> None: path = path.expanduser().resolve() + + if watch: + handle_run_watcher([sys.executable, "-m", "relay.dev", "lint", str(path)], wait = True) + return + flake8 = [sys.executable, '-m', 'flake8', str(path)] mypy = [sys.executable, '-m', 'mypy', str(path)] if strict: mypy.append('--strict') - if watch: - handle_run_watcher(mypy, flake8, wait = True) - return - click.echo('----- flake8 -----') subprocess.run(flake8) From 22bba6e85ee54a6cee86f19aa53e18c710667a4e Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Mon, 1 Apr 2024 13:09:54 -0400 Subject: [PATCH 57/57] use jinja2-haml from pypi jinja2-haml is the renamed hamlish-jinja fork at https://git.barkshark.xyz/barkshark/hamlish-jinja --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5649873..af9f1da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,8 @@ aiohttp-swagger[performance] == 1.0.16 argon2-cffi == 23.1.0 barkshark-sql @ https://git.barkshark.xyz/barkshark/bsql/archive/0.1.2.tar.gz click >= 8.1.2 -hamlish-jinja @ https://git.barkshark.xyz/barkshark/hamlish-jinja/archive/0.3.5.tar.gz hiredis == 2.3.2 +jinja2-haml == 0.3.5 markdown == 3.5.2 platformdirs == 4.2.0 pyyaml >= 6.0