Compare commits

..

7 commits

Author SHA1 Message Date
Izalia Mae 189ac887a9 minor tweak to data used in admin pages 2024-03-11 03:24:44 -04:00
Izalia Mae 21e0e0a3ec add markdown support for the note setting 2024-03-11 03:17:38 -04:00
Izalia Mae 5b1f244703 actually use signed headers 2024-03-11 02:36:34 -04:00
Izalia Mae b3ab6e6d40 add message on home page when relay is in manual approval mode 2024-03-11 01:53:12 -04:00
Izalia Mae d5069d98a6 fix TypeError when a person tries joining 2024-03-11 01:52:28 -04:00
Izalia Mae c852867636 add option to require approval for new instances 2024-03-11 01:21:46 -04:00
Izalia Mae 1672f7a7eb add missing file 2024-03-10 19:15:59 -04:00
27 changed files with 527 additions and 120 deletions

View file

@ -111,6 +111,11 @@ class Application(web.Application):
self['signer'] = Signer(value, self.config.keyid) self['signer'] = Signer(value, self.config.keyid)
@property
def template(self) -> Template:
return self['template']
@property @property
def uptime(self) -> timedelta: def uptime(self) -> timedelta:
if not self['start_time']: if not self['start_time']:

View file

@ -23,17 +23,26 @@ SELECT * FROM inboxes WHERE domain = :value or inbox = :value or actor = :value;
-- name: put-inbox -- name: put-inbox
INSERT INTO inboxes (domain, actor, inbox, followid, software, created) INSERT INTO inboxes (domain, actor, inbox, followid, software, accepted, created)
VALUES (:domain, :actor, :inbox, :followid, :software, :created) VALUES (:domain, :actor, :inbox, :followid, :software, :accepted, :created)
ON CONFLICT (domain) DO UPDATE SET followid = :followid ON CONFLICT (domain) DO
UPDATE SET followid = :followid, inbox = :inbox, software = :software, created = :created
RETURNING *; RETURNING *;
-- name: put-inbox-accept
UPDATE inboxes SET accepted = :accepted WHERE domain = :domain RETURNING *;
-- name: del-inbox -- name: del-inbox
DELETE FROM inboxes DELETE FROM inboxes
WHERE domain = :value or inbox = :value or actor = :value; WHERE domain = :value or inbox = :value or actor = :value;
-- name: get-request
SELECT * FROM inboxes WHERE accepted = 0 and domain = :domain;
-- name: get-user -- name: get-user
SELECT * FROM users SELECT * FROM users
WHERE username = :value or handle = :value; WHERE username = :value or handle = :value;

View file

@ -285,6 +285,50 @@ paths:
schema: schema:
$ref: "#/definitions/Error" $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: /v1/domain_ban:
get: get:
tags: tags:
@ -672,6 +716,9 @@ definitions:
software: software:
description: Nodeinfo-formatted name of the instance's software description: Nodeinfo-formatted name of the instance's software
type: string type: string
accepted:
description: Whether or not the follow request has been accepted
type: boolean
created: created:
description: Date the instance joined or was added description: Date the instance joined or was added
type: string type: string

View file

@ -53,6 +53,7 @@ def get_database(config: Config, migrate: bool = True) -> bsql.Database:
if schema_ver < ver: if schema_ver < ver:
func(conn) func(conn)
conn.put_config('schema-version', ver) conn.put_config('schema-version', ver)
logging.info("Updated database to %i", ver)
if (privkey := conn.get_config('private-key')): if (privkey := conn.get_config('private-key')):
conn.app.signer = privkey conn.app.signer = privkey

View file

@ -60,8 +60,9 @@ THEMES = {
} }
CONFIG_DEFAULTS: dict[str, tuple[str, Any]] = { CONFIG_DEFAULTS: dict[str, tuple[str, Any]] = {
'schema-version': ('int', 20240206), 'schema-version': ('int', 20240310),
'private-key': ('str', None), 'private-key': ('str', None),
'approval-required': ('bool', False),
'log-level': ('loglevel', logging.LogLevel.INFO), 'log-level': ('loglevel', logging.LogLevel.INFO),
'name': ('str', 'ActivityRelay'), 'name': ('str', 'ActivityRelay'),
'note': ('str', 'Make a note about your instance here.'), 'note': ('str', 'Make a note about your instance here.'),

View file

@ -52,7 +52,7 @@ class Connection(SqlConnection):
urlparse(message.object_id).netloc 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: if inbox['domain'] not in src_domains:
yield inbox['inbox'] yield inbox['inbox']
@ -124,53 +124,45 @@ class Connection(SqlConnection):
return cur.one() 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, def put_inbox(self,
domain: str, domain: str,
inbox: str, inbox: str | None = None,
actor: str | None = None, actor: str | None = None,
followid: str | None = None, followid: str | None = None,
software: str | None = None) -> Row: software: str | None = None,
accepted: bool = True) -> Row:
params = { params = {
'domain': domain,
'inbox': inbox, 'inbox': inbox,
'actor': actor, 'actor': actor,
'followid': followid, 'followid': followid,
'software': software, '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() 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: def del_inbox(self, value: str) -> bool:
with self.run('del-inbox', {'value': value}) as cur: with self.run('del-inbox', {'value': value}) as cur:
if cur.row_count > 1: if cur.row_count > 1:
@ -179,6 +171,35 @@ class Connection(SqlConnection):
return cur.row_count == 1 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: def get_user(self, value: str) -> Row:
with self.run('get-user', {'value': value}) as cur: with self.run('get-user', {'value': value}) as cur:
return cur.one() return cur.one()

View file

@ -25,6 +25,7 @@ TABLES: Tables = Tables(
Column('inbox', 'text', unique = True, nullable = False), Column('inbox', 'text', unique = True, nullable = False),
Column('followid', 'text'), Column('followid', 'text'),
Column('software', 'text'), Column('software', 'text'),
Column('accepted', 'boolean'),
Column('created', 'timestamp', nullable = False) Column('created', 'timestamp', nullable = False)
), ),
Table( Table(
@ -76,3 +77,9 @@ def migrate_0(conn: Connection) -> None:
@migration @migration
def migrate_20240206(conn: Connection) -> None: def migrate_20240206(conn: Connection) -> None:
conn.create_tables() 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")

View file

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

View file

@ -1,5 +1,6 @@
-extends "base.haml" -extends "base.haml"
-set page="Config" -set page="Config"
-import "functions.haml" as func
-block content -block content
%form.section(action="/admin/config" method="POST") %form.section(action="/admin/config" method="POST")
.grid-2col .grid-2col
@ -10,28 +11,15 @@
%textarea(id="description" name="note" value="{{config.note}}") << {{config.note}} %textarea(id="description" name="note" value="{{config.note}}") << {{config.note}}
%label(for="theme") << Color Theme %label(for="theme") << Color Theme
%select(id="theme" name="theme") =func.new_select("theme", config.theme, themes)
-for theme in themes
-if theme == config.theme
%option(value="{{theme}}" selected) -> =theme.title()
-else
%option(value="{{theme}}") -> =theme.title()
%label(for="log-level") << Log Level %label(for="log-level") << Log Level
%select(id="log-level" name="log-level") =func.new_select("log-level", config["log-level"].name, levels)
-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()
%label(for="whitelist-enabled") << Whitelist %label(for="whitelist-enabled") << Whitelist
-if config["whitelist-enabled"] =func.new_checkbox("whitelist-enabled", config["whitelist-enabled"])
%input(id="whitelist-enabled" name="whitelist-enabled" type="checkbox" checked)
-else %label(for="approval-required") << Approval Required
%input(id="whitelist-enabled" name="whitelist-enabled" type="checkbox") =func.new_checkbox("approval-required", config["approval-required"])
%input(type="submit" value="Save") %input(type="submit" value="Save")

View file

@ -16,7 +16,7 @@
%input(type="submit" value="Ban Domain") %input(type="submit" value="Ban Domain")
#data-table.section .data-table.section
%table %table
%thead %thead
%tr %tr

View file

@ -19,7 +19,38 @@
%input(type="submit" value="Add Instance") %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") << &check;
%td.deny
%a(href="/admin/instances/deny/{{request.domain}}" title="Deny Request") << &#10006;
.data-table.section
.title << Instances
%table %table
%thead %thead
%tr %tr

View file

@ -16,7 +16,7 @@
%input(type="submit" value="Ban Software") %input(type="submit" value="Ban Software")
#data-table.section .data-table.section
%table %table
%thead %thead
%tr %tr

View file

@ -19,7 +19,7 @@
%input(type="submit" value="Add User") %input(type="submit" value="Add User")
#data-table.section .data-table.section
%table %table
%thead %thead
%tr %tr

View file

@ -10,7 +10,7 @@
%input(type="submit" value="Add Domain") %input(type="submit" value="Add Domain")
#data-table.section .data-table.section
%table %table
%thead %thead
%tr %tr

View file

@ -2,9 +2,7 @@
-set page = "Home" -set page = "Home"
-block content -block content
.section .section
-for line in config.note.splitlines() -markdown -> =config.note
-if line
%p -> =line
.section .section
%p %p
@ -14,12 +12,17 @@
You may subscribe to this relay with the address: You may subscribe to this relay with the address:
%a(href="https://{{domain}}/actor") << https://{{domain}}/actor</a> %a(href="https://{{domain}}/actor") << https://{{domain}}/actor</a>
-if config["whitelist-enabled"] -if config["approval-required"]
%p.section.message %p.section.message
Note: The whitelist is enabled on this instance. Ask the admin to add your instance Follow requests require approval. You will need to wait for an admin to accept or deny
before joining. your request.
#data-table.section -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 %table
%thead %thead
%tr %tr

View file

@ -91,6 +91,17 @@ textarea {
margin: 0px auto; 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 { #header {
display: grid; display: grid;
grid-template-columns: 50px auto 50px; grid-template-columns: 50px auto 50px;
@ -193,15 +204,6 @@ textarea {
align-items: center; align-items: center;
} }
#data-table td:first-child {
width: 100%;
}
#data-table .date {
width: max-content;
text-align: right;
}
.button { .button {
background-color: var(--primary); background-color: var(--primary);
border: 1px solid var(--primary); border: 1px solid var(--primary);
@ -220,6 +222,15 @@ textarea {
grid-template-columns: max-content auto; grid-template-columns: max-content auto;
} }
.data-table td:first-child {
width: 100%;
}
.data-table .date {
width: max-content;
text-align: right;
}
.error, .message { .error, .message {
text-align: center; text-align: center;
} }

View file

@ -0,0 +1,16 @@
:root {
--text: {{theme["text"]}};
--background: {{theme["background"]}};
--primary: {{theme["primary"]}};
--primary-hover: {{theme["primary-hover"]}};
--section-background: {{theme["section-background"]}};
--table-background: {{theme["table-background"]}};
--border: {{theme["border"]}};
--message-text: {{theme["message-text"]}};
--message-background: {{theme["message-background"]}};
--message-border: {{theme["message-border"]}};
--error-text: {{theme["error-text"]}};
--error-background: {{theme["error-background"]}};
--error-border: {{theme["error-border"]}};
--spacing: 10px;
}

View file

@ -116,7 +116,7 @@ class HttpClient:
headers = {} headers = {}
if sign_headers: if sign_headers:
self.signer.sign_headers('GET', url, algorithm = 'original') headers = self.signer.sign_headers('GET', url, algorithm = 'original')
try: try:
logging.debug('Fetching resource: %s', url) logging.debug('Fetching resource: %s', url)
@ -130,7 +130,7 @@ class HttpClient:
if resp.status != 200: if resp.status != 200:
logging.verbose('Received error when requesting %s: %i', url, resp.status) logging.verbose('Received error when requesting %s: %i', url, resp.status)
logging.debug(await resp.read()) logging.debug(data)
return None return None
message = loads(data) message = loads(data)

View file

@ -342,7 +342,7 @@ def cli_config_list(ctx: click.Context) -> None:
for key, value in conn.get_config_all().items(): for key, value in conn.get_config_all().items():
if key not in CONFIG_IGNORE: if key not in CONFIG_IGNORE:
key = f'{key}:'.ljust(20) key = f'{key}:'.ljust(20)
click.echo(f'- {key} {value}') click.echo(f'- {key} {repr(value)}')
@cli_config.command('set') @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:') click.echo('Connected to the following instances or relays:')
with ctx.obj.database.session() as conn: 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"]}') 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}') 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') @cli.group('instance')
def cli_instance() -> None: def cli_instance() -> None:
'Manage instance bans' 'Manage instance bans'

View file

@ -117,7 +117,8 @@ class Message(aputils.Message):
def new_actor(cls: type[Message], # pylint: disable=arguments-differ def new_actor(cls: type[Message], # pylint: disable=arguments-differ
host: str, host: str,
pubkey: str, pubkey: str,
description: str | None = None) -> Message: description: str | None = None,
approves: bool = False) -> Message:
return cls({ return cls({
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
@ -126,6 +127,7 @@ class Message(aputils.Message):
'preferredUsername': 'relay', 'preferredUsername': 'relay',
'name': 'ActivityRelay', 'name': 'ActivityRelay',
'summary': description or 'ActivityRelay bot', 'summary': description or 'ActivityRelay bot',
'manuallyApprovesFollowers': approves,
'followers': f'https://{host}/followers', 'followers': f'https://{host}/followers',
'following': f'https://{host}/following', 'following': f'https://{host}/following',
'inbox': f'https://{host}/inbox', 'inbox': f'https://{host}/inbox',

View file

@ -62,9 +62,12 @@ async def handle_forward(view: ActorView, conn: Connection) -> None:
async def handle_follow(view: ActorView, conn: Connection) -> None: async def handle_follow(view: ActorView, conn: Connection) -> None:
nodeinfo = await view.client.fetch_nodeinfo(view.actor.domain) nodeinfo = await view.client.fetch_nodeinfo(view.actor.domain)
software = nodeinfo.sw_name if nodeinfo else None software = nodeinfo.sw_name if nodeinfo else None
config = conn.get_config_all()
# reject if software used by actor is banned # reject if software used by actor is banned
if conn.get_software_ban(software): if conn.get_software_ban(software):
logging.verbose('Rejected banned actor: %s', view.actor.id)
view.app.push_message( view.app.push_message(
view.actor.shared_inbox, view.actor.shared_inbox,
Message.new_response( 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 ## reject if the actor is not an instance actor
if person_check(view.actor, software): if person_check(view.actor, software):
logging.verbose('Non-application actor tried to follow: %s', view.actor.id)
view.app.push_message( view.app.push_message(
view.actor.shared_inbox, view.actor.shared_inbox,
Message.new_response( Message.new_response(
@ -92,23 +97,53 @@ async def handle_follow(view: ActorView, conn: Connection) -> None:
actor = view.actor.id, actor = view.actor.id,
followid = view.message.id, followid = view.message.id,
accept = False accept = False
) ),
view.instance
) )
logging.verbose('Non-application actor tried to follow: %s', view.actor.id)
return return
with conn.transaction(): if not conn.get_domain_whitelist(view.actor.domain):
if conn.get_inbox(view.actor.shared_inbox): # add request if approval-required is enabled
view.instance = conn.update_inbox(view.actor.shared_inbox, followid = view.message.id) if config['approval-required']:
logging.verbose('New follow request fromm actor: %s', view.actor.id)
else: with conn.transaction():
view.instance = conn.put_inbox( view.instance = conn.put_inbox(
view.actor.domain, 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.shared_inbox,
view.actor.id, Message.new_response(
view.message.id, host = view.config.domain,
software 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( view.app.push_message(
@ -189,15 +224,15 @@ async def run_processor(view: ActorView) -> None:
if not view.instance['software']: if not view.instance['software']:
if (nodeinfo := await view.client.fetch_nodeinfo(view.instance['domain'])): if (nodeinfo := await view.client.fetch_nodeinfo(view.instance['domain'])):
with conn.transaction(): with conn.transaction():
view.instance = conn.update_inbox( view.instance = conn.put_inbox(
view.instance['inbox'], domain = view.instance['domain'],
software = nodeinfo.sw_name software = nodeinfo.sw_name
) )
if not view.instance['actor']: if not view.instance['actor']:
with conn.transaction(): with conn.transaction():
view.instance = conn.update_inbox( view.instance = conn.put_inbox(
view.instance['inbox'], domain = view.instance['domain'],
actor = view.actor.id actor = view.actor.id
) )

View file

@ -1,15 +1,22 @@
from __future__ import annotations from __future__ import annotations
import textwrap
import typing import typing
from hamlish_jinja.extension import HamlishExtension from collections.abc import Callable
from hamlish_jinja import HamlishExtension
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from jinja2.ext import Extension
from jinja2.nodes import CallBlock
from markdown import Markdown
from . import __version__ from . import __version__
from .database.config import THEMES
from .misc import get_resource from .misc import get_resource
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from jinja2.nodes import Node
from jinja2.parser import Parser
from typing import Any from typing import Any
from .application import Application from .application import Application
from .views.base import View from .views.base import View
@ -22,7 +29,8 @@ class Template(Environment):
trim_blocks = True, trim_blocks = True,
lstrip_blocks = True, lstrip_blocks = True,
extensions = [ extensions = [
HamlishExtension HamlishExtension,
MarkdownExtension
], ],
loader = FileSystemLoader([ loader = FileSystemLoader([
get_resource('frontend'), get_resource('frontend'),
@ -36,8 +44,8 @@ class Template(Environment):
def render(self, path: str, view: View | None = None, **context: Any) -> str: def render(self, path: str, view: View | None = None, **context: Any) -> str:
with self.app.database.session(False) as s: with self.app.database.session(False) as conn:
config = s.get_config_all() config = conn.get_config_all()
new_context = { new_context = {
'view': view, 'view': view,
@ -49,3 +57,36 @@ class Template(Environment):
} }
return self.get_template(path).render(new_context) 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')))

View file

@ -30,9 +30,14 @@ class ActorView(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
with self.database.session(False) as conn:
config = conn.get_config_all()
data = Message.new_actor( data = Message.new_actor(
host = self.config.domain, host = self.config.domain,
pubkey = self.app.signer.pubkey pubkey = self.app.signer.pubkey,
description = self.app.template.render_markdown(config['note']),
approves = config['approval-required']
) )
return Response.new(data, ctype='activity') return Response.new(data, ctype='activity')
@ -44,12 +49,6 @@ class ActorView(View):
with self.database.session() as conn: with self.database.session() as conn:
self.instance = conn.get_inbox(self.actor.shared_inbox) self.instance = conn.get_inbox(self.actor.shared_inbox)
config = conn.get_config_all()
## 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 ## reject if actor is banned
if conn.get_domain_ban(self.actor.domain): if conn.get_domain_ban(self.actor.domain):

View file

@ -212,7 +212,7 @@ class Inbox(View):
if not (instance := conn.get_inbox(data['domain'])): if not (instance := conn.get_inbox(data['domain'])):
return Response.new_error(404, 'Instance with domain not found', 'json') 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') return Response.new(instance, ctype = 'json')
@ -232,6 +232,50 @@ class Inbox(View):
return Response.new({'message': 'Deleted instance'}, ctype = 'json') 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') @register_route('/api/v1/domain_ban')
class DomainBan(View): class DomainBan(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:

View file

@ -56,7 +56,7 @@ class HomeView(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
with self.database.session() as conn: with self.database.session() as conn:
context = { context = {
'instances': tuple(conn.execute('SELECT * FROM inboxes').all()) 'instances': tuple(conn.get_inboxes())
} }
data = self.template.render('page/home.haml', self, **context) data = self.template.render('page/home.haml', self, **context)
@ -137,7 +137,8 @@ class AdminInstances(View):
with self.database.session() as conn: with self.database.session() as conn:
context = { context = {
'instances': tuple(conn.execute('SELECT * FROM inboxes').all()) 'instances': tuple(conn.get_inboxes()),
'requests': tuple(conn.get_requests())
} }
if error: if error:
@ -179,15 +180,66 @@ class AdminInstances(View):
@register_route('/admin/instances/delete/{domain}') @register_route('/admin/instances/delete/{domain}')
class AdminInstancesDelete(View): class AdminInstancesDelete(View):
async def get(self, request: Request, domain: str) -> Response: 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): 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) conn.del_inbox(domain)
return await AdminInstances(request).get(request, message = 'Removed instance') 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') @register_route('/admin/whitelist')
class AdminWhitelist(View): class AdminWhitelist(View):
async def get(self, async def get(self,
@ -197,7 +249,7 @@ class AdminWhitelist(View):
with self.database.session() as conn: with self.database.session() as conn:
context = { context = {
'whitelist': tuple(conn.execute('SELECT * FROM whitelist').all()) 'whitelist': tuple(conn.execute('SELECT * FROM whitelist ORDER BY domain ASC'))
} }
if error: if error:
@ -247,7 +299,7 @@ class AdminDomainBans(View):
with self.database.session() as conn: with self.database.session() as conn:
context = { 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: if error:
@ -305,7 +357,7 @@ class AdminSoftwareBans(View):
with self.database.session() as conn: with self.database.session() as conn:
context = { 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: if error:
@ -363,7 +415,7 @@ class AdminUsers(View):
with self.database.session() as conn: with self.database.session() as conn:
context = { context = {
'users': tuple(conn.execute('SELECT * FROM users').all()) 'users': tuple(conn.execute('SELECT * FROM users ORDER BY username ASC'))
} }
if error: if error:
@ -412,7 +464,7 @@ class AdminConfig(View):
async def get(self, request: Request, message: str | None = None) -> Response: async def get(self, request: Request, message: str | None = None) -> Response:
context = { context = {
'themes': tuple(THEMES.keys()), 'themes': tuple(THEMES.keys()),
'LogLevel': LogLevel, 'levels': tuple(level.name for level in LogLevel),
'message': message 'message': message
} }
data = self.template.render('page/admin-config.haml', self, **context) data = self.template.render('page/admin-config.haml', self, **context)

View file

@ -34,7 +34,7 @@ class NodeinfoView(View):
# pylint: disable=no-self-use # pylint: disable=no-self-use
async def get(self, request: Request, niversion: str) -> Response: async def get(self, request: Request, niversion: str) -> Response:
with self.database.session() as conn: with self.database.session() as conn:
inboxes = conn.execute('SELECT * FROM inboxes').all() inboxes = conn.get_inboxes()
data = { data = {
'name': 'activityrelay', 'name': 'activityrelay',
@ -42,7 +42,10 @@ class NodeinfoView(View):
'protocols': ['activitypub'], 'protocols': ['activitypub'],
'open_regs': not conn.get_config('whitelist-enabled'), 'open_regs': not conn.get_config('whitelist-enabled'),
'users': 1, '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': if niversion == '2.1':

View file

@ -6,6 +6,7 @@ barkshark-sql@https://git.barkshark.xyz/barkshark/bsql/archive/0.1.2.tar.gz
click>=8.1.2 click>=8.1.2
hamlish-jinja@https://git.barkshark.xyz/barkshark/hamlish-jinja/archive/0.3.5.tar.gz hamlish-jinja@https://git.barkshark.xyz/barkshark/hamlish-jinja/archive/0.3.5.tar.gz
hiredis==2.3.2 hiredis==2.3.2
markdown==3.5.2
platformdirs==4.2.0 platformdirs==4.2.0
pyyaml>=6.0 pyyaml>=6.0
redis==5.0.1 redis==5.0.1