Compare commits

...

13 commits

Author SHA1 Message Date
Izalia Mae 3391749800 use main branch of tinysql 2024-02-18 09:01:26 -05:00
Izalia Mae 091a36a3d8 fix linter warnings 2024-02-18 08:13:52 -05:00
Izalia Mae c704c1e72c version bump to 0.2.6 2024-02-18 08:08:46 -05:00
Izalia Mae be2c5b144a update docs 2024-02-18 08:02:43 -05:00
Izalia Mae 38ea8b390e ensure int config values are above 0 2024-02-18 08:02:29 -05:00
Izalia Mae 1eb93ab71a fix running via pyinstaller bin 2024-02-16 21:05:12 -05:00
Izalia Mae c3b4d9ca98 add swagger api docs 2024-02-16 20:25:50 -05:00
Izalia Mae af1caaf7c9 remove status code from json error message 2024-02-16 10:42:22 -05:00
Izalia Mae afad948f85 de-uwu api message 2024-02-16 10:39:58 -05:00
Izalia Mae 8f8f38cc4c add thread for cache cleanup 2024-02-15 22:49:55 -05:00
Izalia Mae aca34825b1 remove unused imports 2024-02-15 22:05:22 -05:00
Izalia Mae 8c85f23c86 cache changes
* add `delete_old`, `clear`, and `close` methods to Cache
* user iterator in `get_keys` and `get_namespace` of RedisCache
2024-02-15 21:57:19 -05:00
Izalia Mae 6f3a1db17d ensure cache, database, and http client are closed on quit 2024-02-15 21:53:46 -05:00
17 changed files with 992 additions and 129 deletions

View file

@ -1 +1,2 @@
include data/statements.sql include data/statements.sql
include data/swagger.yaml

View file

@ -9,7 +9,7 @@ use `python3 -m relay` if installed via pip or `~/.local/bin/activityrelay` if i
## Run ## Run
Run the relay. Run the relay. Optionally add `-d` or `--dev` to enable auto-reloading on code changes.
activityrelay run activityrelay run
@ -58,6 +58,50 @@ Set a value for a config option
activityrelay config set <key> <value> activityrelay config set <key> <value>
## User
### List
List all available users.
activityrelay user list
### Create
Create a new user. You will be prompted for the new password.
activityrelay user create <username> [associated ActivityPub handle]
### Delete
Delete a user.
activityrelay user delete <username>
### List Tokens
List all API tokens for a user.
activityrelay user list-tokens <username>
### Create Token
Generate a new API token for a user.
activityrelay user create-token <username>
### Delete Token
Delete an API token.
activityrelay user delete-token <code>
## Inbox ## Inbox
Manage the list of subscribed instances. Manage the list of subscribed instances.
@ -75,7 +119,7 @@ List the currently subscribed instances or relays.
Add an inbox to the database. If a domain is specified, it will default to `https://{domain}/inbox`. Add an inbox to the database. If a domain is specified, it will default to `https://{domain}/inbox`.
If the added instance is not following the relay, expect errors when pushing messages. If the added instance is not following the relay, expect errors when pushing messages.
activityrelay inbox add <inbox or domain> activityrelay inbox add <inbox or domain> --actor <actor url> --followid <follow activity ID> --software <nodeinfo software name>
### Remove ### Remove
@ -155,7 +199,7 @@ List the currently banned instances.
Add an instance to the ban list. If the instance is currently subscribed, it will be removed from Add an instance to the ban list. If the instance is currently subscribed, it will be removed from
the inbox list. the inbox list.
activityrelay instance ban <domain> activityrelay instance ban <domain> --reason <text> --note <text>
### Unban ### Unban
@ -167,9 +211,10 @@ Remove an instance from the ban list.
### Update ### Update
Update the ban reason or note for an instance ban. Update the ban reason or note for an instance ban. Either `--reason` and/or `--note` must be
specified.
activityrelay instance update bad.example.com --reason "the baddest reason" activityrelay instance update bad.example.com --reason <text> --note <text>
## Software ## Software
@ -194,7 +239,7 @@ name via nodeinfo.
If the name is `RELAYS` (case-sensitive), add all known relay software names to the list. If the name is `RELAYS` (case-sensitive), add all known relay software names to the list.
activityrelay software ban [-f/--fetch-nodeinfo] <name, domain, or RELAYS> activityrelay software ban [-f/--fetch-nodeinfo] <name, domain, or RELAYS> --reason <text> --note <text>
### Unban ### Unban
@ -214,4 +259,4 @@ If the name is `RELAYS` (case-sensitive), remove all known relay software names
Update the ban reason or note for a software ban. Either `--reason` and/or `--note` must be Update the ban reason or note for a software ban. Either `--reason` and/or `--note` must be
specified. specified.
activityrelay software update relay.example.com --reason "begone relay" activityrelay software update relay.example.com --reason <text> --note <text>

View file

@ -19,11 +19,10 @@ proxy is on the same host.
port: 8080 port: 8080
### Push Workers ### Web Workers
The relay can be configured to use threads to push messages out. For smaller relays, this isn't The number of processes to spawn for handling web requests. Leave it at 0 to automatically detect
necessary, but bigger ones (>100 instances) will want to set this to the number of available cpu how many processes should be spawned.
threads.
workers: 0 workers: 0

View file

@ -1,7 +1,10 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
import importlib
from pathlib import Path
block_cipher = None block_cipher = None
aiohttp_swagger_path = Path(importlib.import_module('aiohttp_swagger').__file__).parent
a = Analysis( a = Analysis(
@ -9,9 +12,13 @@ a = Analysis(
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[ datas=[
('relay/data', 'relay/data') ('relay/data', 'relay/data'),
(aiohttp_swagger_path, 'aiohttp_swagger')
],
hiddenimports=[
'gunicorn',
'gunicorn.glogging'
], ],
hiddenimports=[],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],

View file

@ -1 +1 @@
__version__ = '0.2.5' __version__ = '0.2.6'

View file

@ -9,9 +9,10 @@ import time
import typing import typing
from aiohttp import web from aiohttp import web
from aiohttp_swagger import setup_swagger
from aputils.signer import Signer from aputils.signer import Signer
from datetime import datetime, timedelta from datetime import datetime, timedelta
from gunicorn.app.wsgiapp import WSGIApplication from threading import Event, Thread
from . import logger as logging from . import logger as logging
from .cache import get_cache from .cache import get_cache
@ -22,10 +23,14 @@ from .misc import check_open_port
from .views import VIEWS from .views import VIEWS
from .views.api import handle_api_path from .views.api import handle_api_path
try:
from importlib.resources import files as pkgfiles
except ImportError:
from importlib_resources import files as pkgfiles
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from collections.abc import Awaitable
from tinysql import Database, Row from tinysql import Database, Row
from typing import Any
from .cache import Cache from .cache import Cache
from .misc import Message from .misc import Message
@ -47,6 +52,7 @@ class Application(web.Application):
self['proc'] = None self['proc'] = None
self['signer'] = None self['signer'] = None
self['start_time'] = None self['start_time'] = None
self['cleanup_thread'] = None
self['config'] = Config(cfgpath, load = True) self['config'] = Config(cfgpath, load = True)
self['database'] = get_database(self.config) self['database'] = get_database(self.config)
@ -57,10 +63,16 @@ class Application(web.Application):
return return
self.on_response_prepare.append(handle_access_log) self.on_response_prepare.append(handle_access_log)
self.on_cleanup.append(handle_cleanup)
for path, view in VIEWS: for path, view in VIEWS:
self.router.add_view(path, view) self.router.add_view(path, view)
setup_swagger(self,
ui_version = 3,
swagger_from_file = pkgfiles('relay').joinpath('data', 'swagger.yaml')
)
@property @property
def cache(self) -> Cache: def cache(self) -> Cache:
@ -144,7 +156,9 @@ class Application(web.Application):
'--bind', f'{self.config.listen}:{self.config.port}', '--bind', f'{self.config.listen}:{self.config.port}',
'--worker-class', 'aiohttp.GunicornWebWorker', '--worker-class', 'aiohttp.GunicornWebWorker',
'--workers', str(self.config.workers), '--workers', str(self.config.workers),
'--env', f'CONFIG_FILE={self.config.path}' '--env', f'CONFIG_FILE={self.config.path}',
'--reload-extra-file', pkgfiles('relay').joinpath('data', 'swagger.yaml'),
'--reload-extra-file', pkgfiles('relay').joinpath('data', 'statements.sql')
] ]
if dev: if dev:
@ -152,12 +166,15 @@ class Application(web.Application):
self.set_signal_handler(True) self.set_signal_handler(True)
self['proc'] = subprocess.Popen(cmd) # pylint: disable=consider-using-with self['proc'] = subprocess.Popen(cmd) # pylint: disable=consider-using-with
self['cleanup_thread'] = CacheCleanupThread(self)
self['cleanup_thread'].start()
def stop(self, *_) -> None: def stop(self, *_) -> None:
if not self['proc']: if not self['proc']:
return return
self['cleanup_thread'].stop()
self['proc'].terminate() self['proc'].terminate()
time_wait = 0.0 time_wait = 0.0
@ -172,30 +189,36 @@ class Application(web.Application):
self.set_signal_handler(False) self.set_signal_handler(False)
self['proc'] = None self['proc'] = None
self.cache.close()
self.database.close()
# not used, but keeping just in case
class GunicornRunner(WSGIApplication): class CacheCleanupThread(Thread):
def __init__(self, app: Application): def __init__(self, app: Application):
Thread.__init__(self)
self.app = app self.app = app
self.app_uri = 'relay.application:main_gunicorn' self.running = Event()
self.options = {
'bind': f'{app.config.listen}:{app.config.port}',
'worker_class': 'aiohttp.GunicornWebWorker',
'workers': app.config.workers,
'raw_env': f'CONFIG_FILE={app.config.path}'
}
WSGIApplication.__init__(self)
def load_config(self): def run(self) -> None:
for key, value in self.options.items(): cache = get_cache(self.app)
self.cfg.set(key, value)
while self.running.is_set():
time.sleep(3600)
logging.verbose("Removing old cache items")
cache.delete_old(14)
cache.close()
def run(self): def start(self) -> None:
logging.info('Starting webserver for %s', self.app.config.domain) self.running.set()
WSGIApplication.run(self) Thread.start(self)
def stop(self) -> None:
self.running.clear()
async def handle_access_log(request: web.Request, response: web.Response) -> None: async def handle_access_log(request: web.Request, response: web.Response) -> None:
@ -213,11 +236,17 @@ async def handle_access_log(request: web.Request, response: web.Response) -> Non
request.method, request.method,
request.path, request.path,
response.status, response.status,
len(response.body), response.content_length or 0,
request.headers.get('User-Agent', 'n/a') request.headers.get('User-Agent', 'n/a')
) )
async def handle_cleanup(app: Application) -> None:
await app.client.close()
app.cache.close()
app.database.close()
async def main_gunicorn(): async def main_gunicorn():
try: try:
app = Application(os.environ['CONFIG_FILE'], gunicorn = True) app = Application(os.environ['CONFIG_FILE'], gunicorn = True)

View file

@ -6,7 +6,7 @@ import typing
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from redis import Redis from redis import Redis
from .database import get_database from .database import get_database
@ -15,7 +15,6 @@ from .misc import Message, boolean
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from typing import Any from typing import Any
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from tinysql import Database
from .application import Application from .application import Application
@ -94,6 +93,7 @@ class Cache(ABC):
self.app = app self.app = app
self.setup() self.setup()
@abstractmethod @abstractmethod
def get(self, namespace: str, key: str) -> Item: def get(self, namespace: str, key: str) -> Item:
... ...
@ -119,11 +119,26 @@ class Cache(ABC):
... ...
@abstractmethod
def delete_old(self, days: int = 14) -> None:
...
@abstractmethod
def clear(self) -> None:
...
@abstractmethod @abstractmethod
def setup(self) -> None: def setup(self) -> None:
... ...
@abstractmethod
def close(self) -> None:
...
def set_item(self, item: Item) -> Item: def set_item(self, item: Item) -> Item:
return self.set( return self.set(
item.namespace, item.namespace,
@ -201,12 +216,32 @@ class SqlCache(Cache):
pass pass
def delete_old(self, days: int = 14) -> None:
limit = datetime.now(tz = timezone.utc) - timedelta(days = days)
params = {"limit": limit.timestamp()}
with self._db.connection() as conn:
with conn.execute("DELETE FROM cache WHERE updated < :limit", params):
pass
def clear(self) -> None:
with self._db.connection() as conn:
with conn.execute("DELETE FROM cache"):
pass
def setup(self) -> None: def setup(self) -> None:
with self._db.connection() as conn: with self._db.connection() as conn:
with conn.exec_statement(f'create-cache-table-{self._db.type.name.lower()}', None): with conn.exec_statement(f'create-cache-table-{self._db.type.name.lower()}', None):
pass pass
def close(self) -> None:
self._db.close()
self._db = None
@register_cache @register_cache
class RedisCache(Cache): class RedisCache(Cache):
name: str = 'redis' name: str = 'redis'
@ -239,7 +274,7 @@ class RedisCache(Cache):
def get_keys(self, namespace: str) -> Iterator[str]: def get_keys(self, namespace: str) -> Iterator[str]:
for key in self._rd.keys(self.get_key_name(namespace, '*')): for key in self._rd.scan_iter(self.get_key_name(namespace, '*')):
*_, key_name = key.split(':', 2) *_, key_name = key.split(':', 2)
yield key_name yield key_name
@ -247,7 +282,7 @@ class RedisCache(Cache):
def get_namespaces(self) -> Iterator[str]: def get_namespaces(self) -> Iterator[str]:
namespaces = [] namespaces = []
for key in self._rd.keys(f'{self.prefix}:*'): for key in self._rd.scan_iter(f'{self.prefix}:*'):
_, namespace, _ = key.split(':', 2) _, namespace, _ = key.split(':', 2)
if namespace not in namespaces: if namespace not in namespaces:
@ -269,6 +304,21 @@ class RedisCache(Cache):
self._rd.delete(self.get_key_name(namespace, key)) self._rd.delete(self.get_key_name(namespace, key))
def delete_old(self, days: int = 14) -> None:
limit = datetime.now(tz = timezone.utc) - timedelta(days = days)
for full_key in self._rd.scan_iter(f'{self.prefix}:*'):
_, namespace, key = full_key.split(':', 2)
item = self.get(namespace, key)
if item.updated < limit:
self.delete_item(item)
def clear(self) -> None:
self._rd.delete(f"{self.prefix}:*")
def setup(self) -> None: def setup(self) -> None:
options = { options = {
'client_name': f'ActivityRelay_{self.app.config.domain}', 'client_name': f'ActivityRelay_{self.app.config.domain}',
@ -286,3 +336,8 @@ class RedisCache(Cache):
options['port'] = self.app.config.rd_port options['port'] = self.app.config.rd_port
self._rd = Redis(**options) self._rd = Redis(**options)
def close(self) -> None:
self._rd.close()
self._rd = None

View file

@ -188,6 +188,14 @@ class Config:
raise KeyError(key) raise KeyError(key)
if key in {'port', 'pg_port', 'workers'} and not isinstance(value, int): if key in {'port', 'pg_port', 'workers'} and not isinstance(value, int):
value = int(value) 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))
setattr(self, key, value) setattr(self, key, value)

713
relay/data/swagger.yaml Normal file
View file

@ -0,0 +1,713 @@
swagger: "2.0"
info:
description: |
ActivityRelay API
version: "0.2.5"
title: ActivityRelay API
license:
name: AGPL 3
url: https://www.gnu.org/licenses/agpl-3.0.en.html
basePath: /api
schemes:
- https
securityDefinitions:
Bearer:
type: apiKey
name: Authorization
in: header
description: "Enter the token with the `Bearer ` prefix"
paths:
/:
get:
tags:
- Global
description: Attributes that apply to all endpoints
responses:
"401":
description: Token is missing or invalid
schema:
$ref: "#/definitions/Error"
/v1/relay:
get:
tags:
- Relay
description: Info about the relay instance
produces:
- application/json
responses:
"200":
description: Relay info
schema:
$ref: "#/definitions/Info"
/v1/token:
get:
tags:
- Token
description: Verify API token
produces:
- application/json
responses:
"200":
description: Valid token
schema:
$ref: "#/definitions/Message"
post:
tags:
- Token
description: Get a new token
parameters:
- in: formData
name: username
required: true
type: string
- in: formData
name: password
required: true
type: string
format: password
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Created token
schema:
$ref: "#/definitions/Token"
delete:
tags:
- Token
description: Revoke a token
produces:
- application/json
responses:
"200":
description: Revoked token
schema:
$ref: "#/definitions/Message"
/v1/config:
get:
tags:
- Config
description: Get the current config values
produces:
- application/json
responses:
"200":
description: Config values
schema:
$ref: "#/definitions/Config"
post:
tags:
- Config
description: Set a config value
parameters:
- in: formData
name: key
required: true
type: string
- in: formData
name: value
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Value was set
schema:
$ref: "#/definitions/Message"
"400":
description: Key is invalid
schema:
$ref: "#/definitions/Error"
delete:
tags:
- Config
description: Set a config option to default
parameters:
- in: formData
name: key
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Value was reset
schema:
$ref: "#/definitions/Message"
"400":
description: Key is invalid
schema:
$ref: "#/definitions/Error"
/v1/instance:
get:
tags:
- Instance
description: Get the list of subscribed instances
produces:
- application/json
responses:
"200":
description: List of instances
schema:
type: array
items:
$ref: "#/definitions/Instance"
post:
tags:
- Instance
description: Add an instance
parameters:
- in: formData
name: actor
required: true
type: string
- in: formData
name: inbox
required: false
type: string
format: url
- in: formData
name: software
required: false
type: string
- in: formData
name: followid
required: false
type: string
format: url
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Newly added instance
schema:
$ref: "#/definitions/Instance"
"500":
description: Failed to fetch actor
schema:
$ref: "#/definitions/Error"
patch:
tags:
- Instance
description: Update an instance
parameters:
- in: formData
name: domain
required: true
type: string
- in: formData
name: actor
required: false
type: string
- in: formData
name: inbox
required: false
type: string
format: url
- in: formData
name: software
required: false
type: string
- in: formData
name: followid
required: false
type: string
format: url
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Updated instance data
schema:
$ref: "#/definitions/Instance"
"404":
description: Instance with the specified domain does not exist
schema:
$ref: "#/definitions/Error"
delete:
tags:
- Instance
description: Remove an instance from the relay
parameters:
- in: formData
name: domain
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Instance was removed
schema:
$ref: "#/definitions/Message"
"404":
description: Instance with the specified domain does not exist
schema:
$ref: "#/definitions/Error"
/v1/domain_ban:
get:
tags:
- Domain Ban
description: Get a list of all banned domains
produces:
- application/json
responses:
"200":
description: List of banned domains
schema:
type: array
items:
$ref: "#/definitions/DomainBan"
post:
tags:
- Domain Ban
description: Ban a domain
parameters:
- in: formData
name: domain
required: true
type: string
- in: formData
name: reason
required: false
type: string
- in: formData
name: note
required: false
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: New domain ban
schema:
$ref: "#/definitions/DomainBan"
"404":
description: Domain ban already exists
schema:
$ref: "#/definitions/Error"
patch:
tags:
- Domain Ban
description: Update a banned domain
parameters:
- in: formData
name: domain
required: true
type: string
- in: formData
name: reason
required: false
type: string
- in: formData
name: note
required: false
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Updated domain ban
schema:
$ref: "#/definitions/DomainBan"
"400":
description: A reason or note was not specified
schema:
$ref: "#/definitions/Error"
"404":
description: Domain ban doesn't exist
schema:
$ref: "#/definitions/Error"
delete:
tags:
- Domain Ban
description: Unban a domain
parameters:
- in: formData
name: domain
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Unbanned domain
schema:
$ref: "#/definitions/Message"
"404":
description: Domain ban doesn't exist
schema:
$ref: "#/definitions/Error"
/v1/software_ban:
get:
tags:
- Software Ban
description: Get a list of all banned software
produces:
- application/json
responses:
"200":
description: List of banned software
schema:
type: array
items:
$ref: "#/definitions/SoftwareBan"
post:
tags:
- Software Ban
description: Ban software
parameters:
- in: formData
name: name
required: true
type: string
- in: formData
name: reason
required: false
type: string
- in: formData
name: note
required: false
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: New software ban
schema:
$ref: "#/definitions/SoftwareBan"
"404":
description: Software ban already exists
schema:
$ref: "#/definitions/Error"
patch:
tags:
- Software Ban
description: Update banned software
parameters:
- in: formData
name: name
required: true
type: string
- in: formData
name: reason
required: false
type: string
- in: formData
name: note
required: false
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Updated software ban
schema:
$ref: "#/definitions/SoftwareBan"
"400":
description: A reason or note was not specified
schema:
$ref: "#/definitions/Error"
"404":
description: Software ban doesn't exist
schema:
$ref: "#/definitions/Error"
delete:
tags:
- Software Ban
description: Unban software
parameters:
- in: formData
name: name
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Unbanned software
schema:
$ref: "#/definitions/Message"
"404":
description: Software ban doesn't exist
schema:
$ref: "#/definitions/Error"
/v1/whitelist:
get:
tags:
- Whitelist
description: Get a list of all whitelisted domains
produces:
- application/json
responses:
"200":
description: List of whitelisted domains
schema:
type: array
items:
$ref: "#/definitions/Whitelist"
post:
tags:
- Whitelist
description: Add a domain to the whitelist
parameters:
- in: formData
name: domain
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: New whitelisted domain
schema:
$ref: "#/definitions/Whitelist"
"404":
description: Domain already whitelisted
schema:
$ref: "#/definitions/Error"
delete:
tags:
- Whitelist
description: Remove domain from the whitelist
parameters:
- in: formData
name: domain
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Domain removed from the whitelist
schema:
$ref: "#/definitions/Message"
"404":
description: Domain was not in the whitelist
schema:
$ref: "#/definitions/Error"
definitions:
Message:
type: object
properties:
message:
description: Human-readable message text
type: string
Error:
type: object
properties:
error:
description: Human-readable message text
type: string
Config:
type: object
properties:
log-level:
description: Maximum level of log messages to print to the console
type: string
name:
description: Name of the relay
type: string
note:
description: Blurb to display on the home page
type: string
whitelist-enabled:
description: Only allow specific instances to join the relay when enabled
type: boolean
DomainBan:
type: object
properties:
domain:
description: Banned domain
type: string
reason:
description: Public reason for the domain ban
type: string
note:
description: Private note for the software ban
type: string
created:
description: Date the ban was added
type: string
format: date-time
Info:
type: object
properties:
domain:
description: Domain the relay is hosted on
type: string
name:
description: Name of the relay
type: string
description:
description: Short blurb that describes the relay
type: string
version:
description: Version of the relay
type: string
whitelist_enabled:
description: Only allow specific instances to join the relay when enabled
type: boolean
email:
description: E-Mail address used to contact the admin
type: string
admin:
description: Fediverse account of the admin
type: string
icon:
description: Image for the relay instance
type: string
instances:
description: List of currently subscribed Fediverse instances
type: array
items:
type: string
Instance:
type: object
properties:
domain:
description: Domain the instance is hosted on
type: string
actor:
description: ActivityPub actor of the instance that joined
type: string
format: url
inbox:
description: Inbox (usually shared) of the instance to post to
type: string
format: url
followid:
description: Url to the Follow activity of the instance
type: string
format: url
software:
description: Nodeinfo-formatted name of the instance's software
type: string
created:
description: Date the instance joined or was added
type: string
format: date-time
SoftwareBan:
type: object
properties:
name:
type: string
description: Nodeinfo-formatted software name
reason:
description: Public reason for the software ban
type: string
note:
description: Private note for the software ban
type: string
created:
description: Date the ban was added
type: string
format: date-time
Token:
type: object
properties:
token:
description: Character string used for authenticating with the api
type: string
Whitelist:
type: object
properties:
domain:
type: string
description: Whitelisted domain
created:
description: Date the domain was added to the whitelist
type: string
format: date-time

View file

@ -4,9 +4,12 @@ import Crypto
import asyncio import asyncio
import click import click
import platform import platform
import subprocess
import sys
import typing import typing
from aputils.signer import Signer from aputils.signer import Signer
from gunicorn.app.wsgiapp import WSGIApplication
from pathlib import Path from pathlib import Path
from shutil import copyfile from shutil import copyfile
from urllib.parse import urlparse from urllib.parse import urlparse
@ -234,9 +237,21 @@ def cli_run(ctx: click.Context, dev: bool = False) -> None:
click.echo(pip_command) click.echo(pip_command)
return return
if getattr(sys, 'frozen', False):
subprocess.run([sys.executable, 'run-gunicorn'], check = False)
return
ctx.obj.run(dev) ctx.obj.run(dev)
@cli.command('run-gunicorn')
@click.pass_context
def cli_run_gunicorn(ctx: click.Context) -> None:
runner = GunicornRunner(ctx.obj)
runner.run()
@cli.command('convert') @cli.command('convert')
@click.option('--old-config', '-o', help = 'Path to the config file to convert from') @click.option('--old-config', '-o', help = 'Path to the config file to convert from')
@click.pass_context @click.pass_context
@ -903,6 +918,31 @@ def cli_whitelist_import(ctx: click.Context) -> None:
click.echo('Imported whitelist from inboxes') click.echo('Imported whitelist from inboxes')
class GunicornRunner(WSGIApplication):
def __init__(self, app: Application):
self.app = app
self.app_uri = 'relay.application:main_gunicorn'
self.options = {
'bind': f'{app.config.listen}:{app.config.port}',
'worker_class': 'aiohttp.GunicornWebWorker',
'workers': app.config.workers,
'raw_env': f'CONFIG_FILE={app.config.path}'
}
WSGIApplication.__init__(self)
def load_config(self):
for key, value in self.options.items():
self.cfg.set(key, value)
def run(self):
logging.info('Starting webserver for %s', self.app.config.domain)
WSGIApplication.run(self)
def main() -> None: def main() -> None:
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
cli(prog_name='relay') cli(prog_name='relay')

View file

@ -10,14 +10,8 @@ from aputils.message import Message as ApMessage
from uuid import uuid4 from uuid import uuid4
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from collections.abc import Awaitable, Coroutine, Generator
from tinysql import Connection
from typing import Any from typing import Any
from .application import Application from .application import Application
from .cache import Cache
from .config import Config
from .database import Database
from .http_client import HttpClient
IS_DOCKER = bool(os.environ.get('DOCKER_RUNNING')) IS_DOCKER = bool(os.environ.get('DOCKER_RUNNING'))
@ -215,7 +209,7 @@ class Response(AiohttpResponse):
ctype: str = 'text') -> Response: ctype: str = 'text') -> Response:
if ctype == 'json': if ctype == 'json':
body = json.dumps({'status': status, 'error': body}) body = json.dumps({'error': body})
return cls.new(body=body, status=status, ctype=ctype) return cls.new(body=body, status=status, ctype=ctype)

View file

@ -17,7 +17,6 @@ if typing.TYPE_CHECKING:
from aiohttp.web import Request from aiohttp.web import Request
from aputils.signer import Signer from aputils.signer import Signer
from tinysql import Row from tinysql import Row
from ..database.connection import Connection
# pylint: disable=unused-argument # pylint: disable=unused-argument

View file

@ -17,7 +17,6 @@ from ..misc import Message, Response
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from aiohttp.web import Request from aiohttp.web import Request
from collections.abc import Coroutine from collections.abc import Coroutine
from ..database.connection import Connection
CONFIG_IGNORE = ( CONFIG_IGNORE = (
@ -35,7 +34,7 @@ PUBLIC_API_PATHS: tuple[tuple[str, str]] = (
def check_api_path(method: str, path: str) -> bool: def check_api_path(method: str, path: str) -> bool:
if (method, path) in PUBLIC_API_PATHS: if path.startswith('/api/doc') or (method, path) in PUBLIC_API_PATHS:
return False return False
return path.startswith('/api') return path.startswith('/api')
@ -68,7 +67,7 @@ async def handle_api_path(request: web.Request, handler: Coroutine) -> web.Respo
@register_route('/api/v1/token') @register_route('/api/v1/token')
class Login(View): class Login(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
return Response.new({'message': 'Token valid :3'}) return Response.new({'message': 'Token valid'}, ctype = 'json')
async def post(self, request: Request) -> Response: async def post(self, request: Request) -> Response:
@ -214,28 +213,14 @@ class Inbox(View):
return Response.new(row, ctype = 'json') return Response.new(row, ctype = 'json')
@register_route('/api/v1/instance/{domain}') async def patch(self, request: Request) -> Response:
class InboxSingle(View):
async def get(self, request: Request, domain: str) -> Response:
with self.database.connection(False) as conn:
if not (row := conn.get_inbox(domain)):
return Response.new_error(404, 'Instance with domain not found', 'json')
row['created'] = datetime.fromtimestamp(row['created'], tz = timezone.utc).isoformat()
return Response.new(row, ctype = 'json')
async def patch(self, request: Request, domain: str) -> Response:
with self.database.connection(True) as conn: with self.database.connection(True) as conn:
if not conn.get_inbox(domain): data = await self.get_api_data(['domain'], ['actor', 'software', 'followid'])
return Response.new_error(404, 'Instance with domain not found', 'json')
data = await self.get_api_data([], ['actor', 'software', 'followid'])
if isinstance(data, Response): if isinstance(data, Response):
return data return data
if not (instance := conn.get_inbox(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.update_inbox(instance['inbox'], **data)
@ -245,10 +230,15 @@ class InboxSingle(View):
async def delete(self, request: Request, domain: str) -> Response: async def delete(self, request: Request, domain: str) -> Response:
with self.database.connection(True) as conn: with self.database.connection(True) as conn:
if not conn.get_inbox(domain): data = await self.get_api_data(['domain'], [])
if isinstance(data, Response):
return data
if not 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')
conn.del_inbox(domain) conn.del_inbox(data['domain'])
return Response.new({'message': 'Deleted instance'}, ctype = 'json') return Response.new({'message': 'Deleted instance'}, ctype = 'json')
@ -277,40 +267,35 @@ class DomainBan(View):
return Response.new(ban, ctype = 'json') return Response.new(ban, ctype = 'json')
@register_route('/api/v1/domain_ban/{domain}') async def patch(self, request: Request) -> Response:
class DomainBanSingle(View):
async def get(self, request: Request, domain: str) -> Response:
with self.database.connection(False) as conn:
if not (ban := conn.get_domain_ban(domain)):
return Response.new_error(404, 'Domain ban not found', 'json')
return Response.new(ban, ctype = 'json')
async def patch(self, request: Request, domain: str) -> Response:
with self.database.connection(True) as conn: with self.database.connection(True) as conn:
if not conn.get_domain_ban(domain): data = await self.get_api_data(['domain'], ['note', 'reason'])
return Response.new_error(404, 'Domain not banned', 'json')
data = await self.get_api_data([], ['note', 'reason'])
if isinstance(data, Response): if isinstance(data, Response):
return data 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')]): if not any([data.get('note'), data.get('reason')]):
return Response.new_error(400, 'Must include note and/or reason parameters', 'json') return Response.new_error(400, 'Must include note and/or reason parameters', 'json')
ban = conn.update_domain_ban(domain, **data) ban = conn.update_domain_ban(data['domain'], **data)
return Response.new(ban, ctype = 'json') return Response.new(ban, ctype = 'json')
async def delete(self, request: Request, domain: str) -> Response: async def delete(self, request: Request) -> Response:
with self.database.connection(True) as conn: with self.database.connection(True) as conn:
if not conn.get_domain_ban(domain): 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') return Response.new_error(404, 'Domain not banned', 'json')
conn.del_domain_ban(domain) conn.del_domain_ban(data['domain'])
return Response.new({'message': 'Unbanned domain'}, ctype = 'json') return Response.new({'message': 'Unbanned domain'}, ctype = 'json')
@ -339,40 +324,35 @@ class SoftwareBan(View):
return Response.new(ban, ctype = 'json') return Response.new(ban, ctype = 'json')
@register_route('/api/v1/software_ban/{name}') async def patch(self, request: Request) -> Response:
class SoftwareBanSingle(View): data = await self.get_api_data(['name'], ['note', 'reason'])
async def get(self, request: Request, name: str) -> Response:
with self.database.connection(False) as conn:
if not (ban := conn.get_software_ban(name)):
return Response.new_error(404, 'Software ban not found', 'json')
return Response.new(ban, ctype = 'json') if isinstance(data, Response):
return data
async def patch(self, request: Request, name: str) -> Response:
with self.database.connection(True) as conn: with self.database.connection(True) as conn:
if not conn.get_software_ban(name): if not conn.get_software_ban(data['name']):
return Response.new_error(404, 'Software not banned', 'json') return Response.new_error(404, 'Software not banned', 'json')
data = await self.get_api_data([], ['note', 'reason'])
if isinstance(data, Response):
return data
if not any([data.get('note'), data.get('reason')]): if not any([data.get('note'), data.get('reason')]):
return Response.new_error(400, 'Must include note and/or reason parameters', 'json') return Response.new_error(400, 'Must include note and/or reason parameters', 'json')
ban = conn.update_software_ban(name, **data) ban = conn.update_software_ban(data['name'], **data)
return Response.new(ban, ctype = 'json') return Response.new(ban, ctype = 'json')
async def delete(self, request: Request, name: str) -> Response: async def delete(self, request: Request) -> Response:
data = await self.get_api_data(['name'], [])
if isinstance(data, Response):
return data
with self.database.connection(True) as conn: with self.database.connection(True) as conn:
if not conn.get_software_ban(name): if not conn.get_software_ban(data['name']):
return Response.new_error(404, 'Software not banned', 'json') return Response.new_error(404, 'Software not banned', 'json')
conn.del_software_ban(name) conn.del_software_ban(data['name'])
return Response.new({'message': 'Unbanned software'}, ctype = 'json') return Response.new({'message': 'Unbanned software'}, ctype = 'json')
@ -401,21 +381,16 @@ class Whitelist(View):
return Response.new(item, ctype = 'json') return Response.new(item, ctype = 'json')
@register_route('/api/v1/domain/{domain}') async def delete(self, request: Request) -> Response:
class WhitelistSingle(View): data = await self.get_api_data(['domain'], [])
async def get(self, request: Request, domain: str) -> Response:
if isinstance(data, Response):
return data
with self.database.connection(False) as conn: with self.database.connection(False) as conn:
if not (item := conn.get_domain_whitelist(domain)): if not conn.get_domain_whitelist(data['domain']):
return Response.new_error(404, 'Domain not in whitelist', 'json') return Response.new_error(404, 'Domain not in whitelist', 'json')
return Response.new(item, ctype = 'json') conn.del_domain_whitelist(data['domain'])
async def delete(self, request: Request, domain: str) -> Response:
with self.database.connection(False) as conn:
if not conn.get_domain_whitelist(domain):
return Response.new_error(404, 'Domain not in whitelist', 'json')
conn.del_domain_whitelist(domain)
return Response.new({'message': 'Removed domain from whitelist'}, ctype = 'json') return Response.new({'message': 'Removed domain from whitelist'}, ctype = 'json')

View file

@ -4,15 +4,10 @@ import typing
from .base import View, register_route from .base import View, register_route
from .. import __version__
from ..misc import Response from ..misc import Response
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from aiohttp.web import Request from aiohttp.web import Request
from aputils.signer import Signer
from collections.abc import Callable
from tinysql import Row
from ..database.connection import Connection
HOME_TEMPLATE = """ HOME_TEMPLATE = """

View file

@ -13,7 +13,6 @@ from ..misc import Response
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from aiohttp.web import Request from aiohttp.web import Request
from ..database.connection import Connection
VERSION = __version__ VERSION = __version__

View file

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

View file

@ -34,6 +34,7 @@ dev = file: dev-requirements.txt
[options.package_data] [options.package_data]
relay = relay =
data/statements.sql data/statements.sql
data/swagger.yaml
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =
@ -42,3 +43,5 @@ console_scripts =
[flake8] [flake8]
select = F401 select = F401
per-file-ignores =
relay/views/__init__.py: F401