diff --git a/MANIFEST.in b/MANIFEST.in index 83229d2..313c6af 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ include data/statements.sql +include data/swagger.yaml diff --git a/docs/index.md b/docs/index.md index e73db90..98e115e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,5 +7,3 @@ ActivityRelay is a small ActivityPub server that relays messages to subscribed i [Configuration](configuration.md) [Commands](commands.md) - -[API](api.md) diff --git a/relay/application.py b/relay/application.py index 2e8040d..dd6311b 100644 --- a/relay/application.py +++ b/relay/application.py @@ -9,6 +9,7 @@ import time import typing from aiohttp import web +from aiohttp_swagger import setup_swagger from aputils.signer import Signer from datetime import datetime, timedelta from threading import Event, Thread @@ -22,6 +23,12 @@ from .misc import check_open_port from .views import VIEWS 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: from tinysql import Database, Row from .cache import Cache @@ -61,6 +68,11 @@ class Application(web.Application): for path, view in VIEWS: self.router.add_view(path, view) + setup_swagger(self, + ui_version = 3, + swagger_from_file = pkgfiles('relay').joinpath('data', 'swagger.yaml') + ) + @property def cache(self) -> Cache: @@ -144,7 +156,9 @@ class Application(web.Application): '--bind', f'{self.config.listen}:{self.config.port}', '--worker-class', 'aiohttp.GunicornWebWorker', '--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: @@ -222,7 +236,7 @@ async def handle_access_log(request: web.Request, response: web.Response) -> Non request.method, request.path, response.status, - len(response.body), + response.content_length or 0, request.headers.get('User-Agent', 'n/a') ) diff --git a/relay/data/swagger.yaml b/relay/data/swagger.yaml new file mode 100644 index 0000000..9c313ae --- /dev/null +++ b/relay/data/swagger.yaml @@ -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 diff --git a/relay/views/api.py b/relay/views/api.py index 63a3208..7a49abc 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -34,7 +34,7 @@ PUBLIC_API_PATHS: tuple[tuple[str, str]] = ( 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 path.startswith('/api') @@ -42,6 +42,8 @@ def check_api_path(method: str, path: str) -> bool: @web.middleware async def handle_api_path(request: web.Request, handler: Coroutine) -> web.Response: + print("Authorization:", request.headers.get('Authorization')) + try: request['token'] = request.headers['Authorization'].replace('Bearer', '').strip() @@ -67,7 +69,7 @@ async def handle_api_path(request: web.Request, handler: Coroutine) -> web.Respo @register_route('/api/v1/token') class Login(View): async def get(self, request: Request) -> Response: - return Response.new({'message': 'Token valid'}) + return Response.new({'message': 'Token valid'}, ctype = 'json') async def post(self, request: Request) -> Response: @@ -213,28 +215,14 @@ class Inbox(View): return Response.new(row, ctype = 'json') -@register_route('/api/v1/instance/{domain}') -class InboxSingle(View): - async def get(self, request: Request, domain: str) -> Response: - with self.database.connection(False) as conn: - if not (row := conn.get_inbox(domain)): - return Response.new_error(404, 'Instance with domain not found', 'json') - - row['created'] = datetime.fromtimestamp(row['created'], tz = timezone.utc).isoformat() - return Response.new(row, ctype = 'json') - - - async def patch(self, request: Request, domain: str) -> Response: + async def patch(self, request: Request) -> Response: with self.database.connection(True) as conn: - if not conn.get_inbox(domain): - return Response.new_error(404, 'Instance with domain not found', 'json') - - data = await self.get_api_data([], ['actor', 'software', 'followid']) + data = await self.get_api_data(['domain'], ['actor', 'software', 'followid']) if isinstance(data, Response): 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') instance = conn.update_inbox(instance['inbox'], **data) @@ -244,10 +232,15 @@ class InboxSingle(View): async def delete(self, request: Request, domain: str) -> Response: 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') - conn.del_inbox(domain) + conn.del_inbox(data['domain']) return Response.new({'message': 'Deleted instance'}, ctype = 'json') @@ -276,40 +269,35 @@ class DomainBan(View): return Response.new(ban, ctype = 'json') -@register_route('/api/v1/domain_ban/{domain}') -class DomainBanSingle(View): - async def get(self, request: Request, domain: str) -> Response: - with self.database.connection(False) as conn: - if not (ban := conn.get_domain_ban(domain)): - return Response.new_error(404, 'Domain ban not found', 'json') - - return Response.new(ban, ctype = 'json') - - - async def patch(self, request: Request, domain: str) -> Response: + async def patch(self, request: Request) -> Response: with self.database.connection(True) as conn: - if not conn.get_domain_ban(domain): - return Response.new_error(404, 'Domain not banned', 'json') - - data = await self.get_api_data([], ['note', 'reason']) + 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(domain, **data) + ban = conn.update_domain_ban(data['domain'], **data) 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: - 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') - conn.del_domain_ban(domain) + conn.del_domain_ban(data['domain']) return Response.new({'message': 'Unbanned domain'}, ctype = 'json') @@ -338,40 +326,35 @@ class SoftwareBan(View): return Response.new(ban, ctype = 'json') -@register_route('/api/v1/software_ban/{name}') -class SoftwareBanSingle(View): - async def get(self, request: Request, name: str) -> Response: - with self.database.connection(False) as conn: - if not (ban := conn.get_software_ban(name)): - return Response.new_error(404, 'Software ban not found', 'json') + async def patch(self, request: Request) -> Response: + data = await self.get_api_data(['name'], ['note', 'reason']) - 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: - if not conn.get_software_ban(name): + if not conn.get_software_ban(data['name']): return Response.new_error(404, 'Software not banned', 'json') - data = await self.get_api_data([], ['note', 'reason']) - - if isinstance(data, Response): - return data - if not any([data.get('note'), data.get('reason')]): return Response.new_error(400, 'Must include note and/or reason parameters', 'json') - ban = conn.update_software_ban(name, **data) + ban = conn.update_software_ban(data['name'], **data) 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: - if not conn.get_software_ban(name): + if not conn.get_software_ban(data['name']): 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') @@ -400,21 +383,16 @@ class Whitelist(View): return Response.new(item, ctype = 'json') -@register_route('/api/v1/domain/{domain}') -class WhitelistSingle(View): - async def get(self, request: Request, domain: str) -> Response: + async def delete(self, request: Request) -> Response: + data = await self.get_api_data(['domain'], []) + + if isinstance(data, Response): + return data + 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(item, ctype = 'json') - - - async def delete(self, request: Request, domain: str) -> Response: - with self.database.connection(False) as conn: - if not conn.get_domain_whitelist(domain): - return Response.new_error(404, 'Domain not in whitelist', 'json') - - conn.del_domain_whitelist(domain) + conn.del_domain_whitelist(data['domain']) return Response.new({'message': 'Removed domain from whitelist'}, ctype = 'json') diff --git a/requirements.txt b/requirements.txt index 16bd9dc..88b32e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiohttp>=3.9.1 +aiohttp-swagger[performance]==1.0.16 aputils@https://git.barkshark.xyz/barkshark/aputils/archive/0.1.6a.tar.gz argon2-cffi==23.1.0 click>=8.1.2 diff --git a/setup.cfg b/setup.cfg index aa706fd..c0d9ae8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,7 @@ dev = file: dev-requirements.txt [options.package_data] relay = data/statements.sql + data/swagger.yaml [options.entry_points] console_scripts =