diff --git a/pyproject.toml b/pyproject.toml index 3982ec1..6790384 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "barkshark-lib >= 0.2.3, < 0.3.0", "barkshark-sql >= 0.2.0, < 0.3.0", "click == 8.1.2", + "docstring-parser == 0.16", "hiredis == 2.3.2", "idna == 3.4", "jinja2-haml == 0.3.5", diff --git a/relay/application.py b/relay/application.py index 6641b1d..6180792 100644 --- a/relay/application.py +++ b/relay/application.py @@ -9,7 +9,6 @@ import traceback from Crypto.Random import get_random_bytes from aiohttp import web from aiohttp.web import HTTPException, StaticResource -from aiohttp_swagger import setup_swagger from aputils.signer import Signer from base64 import b64encode from blib import File, HttpError, port_check @@ -29,8 +28,6 @@ from .database.schema import Instance from .http_client import HttpClient from .misc import JSON_PATHS, TOKEN_PATHS, Message, Response from .template import Template -from .views import ROUTES -from .views.frontend import handle_frontend_path from .workers import PushWorkers @@ -82,15 +79,6 @@ class Application(web.Application): self.cache.setup() self.on_cleanup.append(handle_cleanup) # type: ignore - for method, path, handler in ROUTES: - self.router.add_route(method, path, handler) - - setup_swagger( - self, - ui_version = 3, - swagger_from_file = File.from_resource('relay', 'data/swagger.yaml') - ) - @property def cache(self) -> Cache: @@ -312,7 +300,7 @@ async def handle_response_headers( app: Application = request.app # type: ignore[assignment] - if request.path == "/" or request.path.startswith(TOKEN_PATHS): + if request.path in {"/", "/docs"} or request.path.startswith(TOKEN_PATHS): with app.database.session() as conn: tokens = ( request.headers.get('Authorization', '').replace('Bearer', '').strip(), @@ -369,6 +357,34 @@ async def handle_response_headers( return resp +@web.middleware +async def handle_frontend_path( + request: web.Request, + handler: Callable[[web.Request], Awaitable[Response]]) -> Response: + + if request['user'] is not None and request.path == '/login': + return Response.new_redir('/') + + if request.path.startswith(TOKEN_PATHS[:2]) and request['user'] is None: + if request.path == '/logout': + return Response.new_redir('/') + + response = Response.new_redir(f'/login?redir={request.path}') + + if request['token'] is not None: + response.del_cookie('user-token') + + return response + + response = await handler(request) + + if not request.path.startswith('/api'): + if request['user'] is None and request['token'] is not None: + response.del_cookie('user-token') + + return response + + async def handle_cleanup(app: Application) -> None: await app.client.close() app.cache.close() diff --git a/relay/data/swagger.yaml b/relay/data/swagger.yaml deleted file mode 100644 index ac7b728..0000000 --- a/relay/data/swagger.yaml +++ /dev/null @@ -1,1038 +0,0 @@ -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: - Cookie: - type: apiKey - in: cookie - name: user-token - Bearer: - type: oauth2 - name: Authorization - in: header - flow: accessCode - authorizationUrl: /oauth/authorize - tokenUrl: /oauth/token - -paths: - /: - get: - tags: - - Global - description: Attributes that apply to all endpoints - responses: - "401": - description: Token is missing or invalid - schema: - $ref: "#/definitions/Error" - - /oauth/authorize: - get: - tags: - - OAuth - description: Get an authorization code - parameters: - - in: query - name: response-type - required: true - type: string - - in: query - name: client_id - required: true - type: string - - in: query - name: redirect_uri - required: true - type: string - - /oauth/token: - post: - tags: - - OAuth - description: Get a token for an authorized app - parameters: - - in: formData - name: grant_type - required: true - type: string - - in: formData - name: code - required: true - type: string - - in: formData - name: client_id - required: true - type: string - - in: formData - name: client_secret - required: true - type: string - - in: formData - name: redirect_uri - required: true - type: string - consumes: - - application/x-www-form-urlencoded - - application/json - - multipart/form-data - produces: - - application/json - responses: - "200": - description: Application - schema: - $ref: "#/definitions/Application" - - /oauth/revoke: - post: - tags: - - OAuth - description: Get a token for an authorized app - parameters: - - in: formData - name: client_id - required: true - type: string - - in: formData - name: client_secret - required: true - type: string - - in: formData - name: token - required: true - type: string - consumes: - - application/json - - multipart/form-data - - application/x-www-form-urlencoded - produces: - - application/json - responses: - "200": - description: Message confirming application deletion - schema: - $ref: "#/definitions/Message" - - /v1/app: - get: - tags: - - Applications - description: Verify the token is valid - produces: - - application/json - responses: - "200": - description: Application with the associated token - schema: - $ref: "#/definitions/Application" - - post: - tags: - - Applications - description: Create a new application - parameters: - - in: query - name: name - required: true - type: string - - in: query - name: redirect_uri - required: true - type: string - - in: query - name: website - required: false - type: string - format: url - consumes: - - application/json - - multipart/form-data - - application/x-www-form-urlencoded - produces: - - application/json - responses: - "200": - description: Newly created application - schema: - $ref: "#/definitions/Application" - - delete: - tags: - - Applications - description: Deletes an application - parameters: - - in: formData - name: client_id - required: true - type: string - - in: formData - name: client_secret - required: true - type: string - consumes: - - application/json - - multipart/form-data - - application/x-www-form-urlencoded - produces: - - application/json - responses: - "200": - description: Confirmation of application deletion - schema: - $ref: "#/definitions/Message" - - /v1/relay: - get: - tags: - - Relay - description: Info about the relay instance - produces: - - application/json - responses: - "200": - description: Relay info - schema: - $ref: "#/definitions/Info" - - /v1/login: - post: - tags: - - Login - description: Login with a username and password - parameters: - - in: formData - name: username - required: true - type: string - - in: formData - name: password - required: true - type: string - consumes: - - application/json - - multipart/form-data - - application/x-www-form-urlencoded - produces: - - application/json - responses: - "200": - description: A new Application - schema: - $ref: "#/definitions/Application" - - /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/request: - get: - tags: - - Follow Request - description: Get the list of follow requests - produces: - - application/json - responses: - "200": - description: List of instances - schema: - type: array - items: - $ref: "#/definitions/Instance" - - post: - tags: - - Follow Request - description: Approve or deny a follow request - parameters: - - in: formData - name: domain - required: true - type: string - - in: formData - name: accept - required: true - type: boolean - consumes: - - application/json - - multipart/form-data - - application/x-www-form-urlencoded - produces: - - application/json - responses: - "200": - description: Follow request successfully accepted or denied - schema: - $ref: "#/definitions/Message" - "500": - description: Follow request does not exist - schema: - $ref: "#/definitions/Error" - - /v1/domain_ban: - get: - tags: - - 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/user: - get: - tags: - - User - description: Get a list of all local users - produces: - - application/json - responses: - "200": - description: List of users - schema: - type: array - items: - $ref: "#/definitions/User" - - post: - tags: - - User - description: Create a new user - parameters: - - in: formData - name: username - required: true - type: string - - in: formData - name: password - required: true - type: string - format: password - - in: formData - name: handle - required: false - type: string - format: email - produces: - - application/json - responses: - "200": - description: Newly created user - schema: - $ref: "#/definitions/User" - "404": - description: User already exists - schema: - $ref: "#/definitions/Error" - - patch: - tags: - - User - description: Update a user's password or handle - parameters: - - in: formData - name: username - required: true - type: string - - in: formData - name: password - required: false - type: string - format: password - - in: formData - name: handle - required: false - type: string - format: email - produces: - - application/json - responses: - "200": - description: Updated user data - schema: - $ref: "#/definitions/User" - "404": - description: User does not exist - schema: - $ref: "#/definitions/Error" - - delete: - tags: - - User - description: Delete a user - parameters: - - in: formData - name: username - required: true - type: string - produces: - - application/json - responses: - "202": - description: Successfully deleted user - schema: - $ref: "#/definitions/Message" - "404": - description: User not found - schema: - $ref: "#/definitions/Error" - - /v1/whitelist: - get: - tags: - - 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 - - Application: - type: object - properties: - client_id: - description: Identifier for the application - type: string - client_secret: - description: Secret string for the application - type: string - name: - description: Human-readable name of the application - type: string - website: - description: Website for the application - type: string - format: url - redirect_uri: - description: URL to redirect to when authorizing an app - type: string - token: - description: String to use in the Authorization header for client requests - type: string - created: - description: Date the application was created - type: string - format: date-time - accessed: - description: Date the application was last used - type: string - format: date-time - - Config: - type: object - properties: - approval-required: - description: Require instances to be approved when following - type: bool - 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 - theme: - description: Name of the color scheme to use for the frontend - 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 - accepted: - description: Whether or not the follow request has been accepted - type: boolean - 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 - - User: - type: object - properties: - username: - description: Username of the account - type: string - handle: - description: Fediverse handle associated with the account - type: string - format: email - created: - description: Date the account was created - type: string - format: date-time - - Whitelist: - type: object - properties: - 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/frontend/page/docs.haml b/relay/frontend/page/docs.haml new file mode 100644 index 0000000..886b348 --- /dev/null +++ b/relay/frontend/page/docs.haml @@ -0,0 +1,23 @@ +-extends "base.haml" +-set page = "API Documentation" + +-block content + -for method in methods + .method.section + %a.endpoint(id="{{method.name.replace('handle_', '')}}" href="#{{method.name.replace('handle_', '')}}") + {{method.method.upper()}} {{method.path}} + + %span.description -> =method.docs + + -if method.parameters + .parameters + -for param in method.parameters + .parameter.section + .name + %pre << {{param.key}}: {{param.type_str}} + + -if not param.has_default + %span.required << *required + + .doc -> =param.docs + diff --git a/relay/frontend/static/style.css b/relay/frontend/static/style.css index ac4eaf5..b4a2914 100644 --- a/relay/frontend/static/style.css +++ b/relay/frontend/static/style.css @@ -379,6 +379,56 @@ textarea { margin: var(--spacing) 0; } +#content.page-api_documentation .method { + padding: 0px; +} + +#content.page-api_documentation .method > * { + padding: 5px; +} + +#content.page-api_documentation .method .endpoint { + background-color: #444444; + display: block; + font-weight: bold; +} + +#content.page-api_documentation .method .endpoint::after { + content: "\F470"; + font-family: "bootstrap-icons"; +/* margin-left: 5px; */ + vertical-align: middle; +} + +#content.page-api_documentation .parameter { + background-color: #222222; + padding: 5px; +} + +#content.page-api_documentation .parameter:not(:first-child) { + margin-top: 5px; + margin-bottom: 0px; +} + +#content.page-api_documentation .parameter:not(:last-child) { + margin-top: 0px; + margin-bottom: 5px; +} + +#content.page-api_documentation .parameter .required { + color: #F66; + font-size: 0.75em; +} + +#content.page-api_documentation .parameter .name pre { + color: #6F6; + display: inline-block; +} + +#content.page-api_documentation .parameter .name pre, #content.page-api_documentation .parameter .name span { + margin: 0px; +} + @keyframes show_toast { 0% { diff --git a/relay/manage.py b/relay/manage.py index eb8ba44..7322d44 100644 --- a/relay/manage.py +++ b/relay/manage.py @@ -19,6 +19,7 @@ from .compat import RelayConfig, RelayDatabase from .config import Config from .database import RELAY_SOFTWARE, get_database, schema from .misc import ACTOR_FORMATS, SOFTWARE, IS_DOCKER, Message +from .views import ROUTES def check_alphanumeric(text: str) -> str: @@ -179,6 +180,9 @@ def cli_run(ctx: click.Context, dev: bool = False) -> None: cli_setup.callback() # type: ignore return + for method, path, handler in ROUTES: + ctx.obj.router.add_route(method, path, handler) + ctx.obj['dev'] = dev ctx.obj.run() diff --git a/relay/views/api.py b/relay/views/api.py index fe78f4f..146afdc 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -5,18 +5,16 @@ import traceback from aiohttp.web import Request from argon2.exceptions import VerifyMismatchError from blib import HttpError, HttpMethod, convert_to_boolean -from typing import TYPE_CHECKING, Any +from typing import Any from urllib.parse import urlparse from .base import DEFAULT_REDIRECT, Route from .. import api_objects as objects, __version__ +from ..application import Application from ..database import ConfigData, schema from ..misc import Message, Response, idna_to_utf -if TYPE_CHECKING: - from ..application import Application - @Route(HttpMethod.GET, "/oauth/authorize", "Authorization", False) async def handle_authorize_get( @@ -25,6 +23,13 @@ async def handle_authorize_get( response_type: str, client_id: str, redirect_uri: str) -> Response: + """ + Authorize an application. Redirects to the application's redirect URI if accepted. + + :param response_type: What to respond with. Should always be set to ``code``. + :param client_id: Application identifier + :param redirect_uri: URI to redirect to on accept + """ if response_type != "code": raise HttpError(400, "Response type is not 'code'") @@ -82,7 +87,7 @@ async def handle_authorize_post( return Response.new_redir("/") -@Route(HttpMethod.POST, "/oauth/token", "Auth", False) +@Route(HttpMethod.POST, "/oauth/token", "Authorization", False) async def handle_new_token( app: Application, request: Request, @@ -91,6 +96,15 @@ async def handle_new_token( client_id: str, client_secret: str, redirect_uri: str) -> objects.Application: + """ + Get a new access token for an application + + :param grant_type: Access level for the application. Should be ``authorization_code`` + :param code: Authorization code obtained from ``/oauth/authorize`` + :param client_id: The application to create the token for + :param client_secret: Secret of the specified application + :param redirect_uri: URI to redirect to + """ if grant_type != "authorization_code": raise HttpError(400, "Invalid grant type") @@ -110,13 +124,20 @@ async def handle_new_token( return objects.Application.from_row(application) -@Route(HttpMethod.POST, "/api/oauth/revoke", "Auth", True) +@Route(HttpMethod.POST, "/api/oauth/revoke", "Authorization", True) async def handle_token_revoke( app: Application, request: Request, client_id: str, client_secret: str, token: str) -> objects.Message: + """ + Revoke and destroy a token + + :param client_id: Identifier of the application to revoke + :param client_secret: Secret of the application + :param token: Token associated with the application + """ with app.database.session(True) as conn: if (application := conn.get_app(client_id, client_secret, token)) is None: @@ -131,12 +152,20 @@ async def handle_token_revoke( return objects.Message("Token deleted") -@Route(HttpMethod.POST, "/api/v1/login", "Auth", False) +@Route(HttpMethod.POST, "/api/v1/login", "Authorization", False) async def handle_login( app: Application, request: Request, username: str, password: str) -> objects.Application: + """ + Create a new token via username and password. + + It is recommended to use oauth instead. + + :param username: Name of the user to login + :param password: Password of the user + """ with app.database.session(True) as s: if not (user := s.get_user(username)): @@ -155,6 +184,8 @@ async def handle_login( @Route(HttpMethod.GET, "/api/v1/app", "Application", True) async def handle_get_app(app: Application, request: Request) -> objects.Application: + "Get data for the application currently in use" + return objects.Application.from_row(request["application"]) @@ -165,6 +196,13 @@ async def handle_create_app( name: str, redirect_uri: str, website: str | None = None) -> objects.Application: + """ + Create a new application + + :param name: User-readable name of the application + :param redirect_uri: URI to redirect to on authorization + :param website: Homepage of the application + """ with app.database.session(True) as conn: application = conn.put_app( @@ -178,12 +216,16 @@ async def handle_create_app( @Route(HttpMethod.GET, "/api/v1/config", "Config", True) async def handle_config_get(app: Application, request: Request) -> objects.Config: + "Get all config options" + with app.database.session(False) as conn: return objects.Config.from_config(conn.get_config_all()) @Route(HttpMethod.GET, "/api/v2/config", "Config", True) async def handle_config_get_v2(app: Application, request: Request) -> list[objects.ConfigItem]: + "Get all config options including the type name for each" + data: list[objects.ConfigItem] = [] cfg = ConfigData() user_keys = ConfigData.USER_KEYS() @@ -203,7 +245,14 @@ async def handle_config_get_v2(app: Application, request: Request) -> list[objec async def handle_config_update( app: Application, request: Request, - key: str, value: Any) -> objects.Message: + key: str, + value: Any) -> objects.Message: + """ + Set a value for a config option + + :param key: Name of the config option to set + :param value: New value + """ if (field := ConfigData.FIELD(key)).name not in ConfigData.USER_KEYS(): raise HttpError(400, "Invalid key") @@ -219,6 +268,12 @@ async def handle_config_update( @Route(HttpMethod.DELETE, "/api/v1/config", "Config", True) async def handle_config_reset(app: Application, request: Request, key: str) -> objects.Message: + """ + Set a config option to the default value + + :param key: Name of the config option to reset + """ + if (field := ConfigData.FIELD(key)).name not in ConfigData.USER_KEYS(): raise HttpError(400, "Invalid key") @@ -233,6 +288,8 @@ async def handle_config_reset(app: Application, request: Request, key: str) -> o @Route(HttpMethod.GET, "/api/v1/relay", "Misc", False) async def get(app: Application, request: Request) -> objects.Relay: + "Get info about the relay instance" + with app.database.session() as s: config = s.get_config_all() inboxes = [row.domain for row in s.get_inboxes()] @@ -252,6 +309,8 @@ async def get(app: Application, request: Request) -> objects.Relay: @Route(HttpMethod.GET, "/api/v1/instance", "Instance", True) async def handle_instances_get(app: Application, request: Request) -> list[objects.Instance]: + "Get all subscribed instances" + data: list[objects.Instance] = [] with app.database.session(False) as s: @@ -269,6 +328,14 @@ async def handle_instance_add( inbox: str | None = None, software: str | None = None, followid: str | None = None) -> objects.Instance: + """ + Add an instance to the database + + :param actor: URL of the instance actor to add. Usually ``https://{domain}/actor``. + :param inbox: URL of the inbox for the instance actor + :param software: Name of the server software as displayed in nodeinfo + :param followid: URL to the ``Follow`` activity + """ domain = idna_to_utf(urlparse(actor).netloc) @@ -313,6 +380,15 @@ async def handle_instance_update( inbox: str | None = None, software: str | None = None, followid: str | None = None) -> objects.Instance: + """ + Update info for an instance + + :param domain: Hostname of the instance to modify + :param actor: URL of the instance actor to add. Usually ``https://{domain}/actor``. + :param inbox: URL of the inbox for the instance actor + :param software: Name of the server software as displayed in nodeinfo + :param followid: URL to the ``Follow`` activity + """ domain = idna_to_utf(domain) @@ -333,6 +409,12 @@ async def handle_instance_update( @Route(HttpMethod.DELETE, "/api/v1/instance", "Instance", True) async def handle_instance_del(app: Application, request: Request, domain: str) -> objects.Message: + """ + Remove an instance from the database + + :param domain: Hostname of the instance to remove + """ + domain = idna_to_utf(domain) with app.database.session(False) as s: @@ -346,6 +428,12 @@ async def handle_instance_del(app: Application, request: Request, domain: str) - @Route(HttpMethod.GET, "/api/v1/request", "Request", True) async def handle_requests_get(app: Application, request: Request) -> list[objects.Instance]: + """ + Get all follow requests. + + This feature only works when ``Approval Required`` is enabled. + """ + data: list[objects.Instance] = [] with app.database.session(False) as s: @@ -361,6 +449,12 @@ async def handle_request_response( request: Request, domain: str, accept: bool) -> objects.Message: + """ + Approve or reject a follow request + + :param domain: Hostname of the instance that requested to follow + :param accept: Accept (``True``) or reject (``False``) the request + """ try: with app.database.session(True) as conn: @@ -394,6 +488,8 @@ async def handle_request_response( @Route(HttpMethod.GET, "/api/v1/domain_ban", "Domain Ban", True) async def handle_domain_bans_get(app: Application, request: Request) -> list[objects.DomainBan]: + "Get all banned domains" + data: list[objects.DomainBan] = [] with app.database.session(False) as s: @@ -410,6 +506,16 @@ async def handle_domain_ban_add( domain: str, note: str | None = None, reason: str | None = None) -> objects.DomainBan: + """ + Ban a domain. + + Banned domains cannot follow the relay. Posts originating from a banned instance will be + ignored in a future update. + + :param domain: Hostname to ban + :param note: Additional details about the ban that can only be viewed by admins + :param reason: Publicly viewable details for the ban + """ with app.database.session(False) as s: if s.get_domain_ban(domain) is not None: @@ -420,12 +526,19 @@ async def handle_domain_ban_add( @Route(HttpMethod.PATCH, "/api/v1/domain_ban", "Domain Ban", True) -async def handle_domain_ban( +async def handle_domain_ban_update( app: Application, request: Request, domain: str, note: str | None = None, reason: str | None = None) -> objects.DomainBan: + """ + Update a domain ban + + :param domain: Hostname to ban + :param note: Additional details about the ban that can only be viewed by admins + :param reason: Publicly viewable details for the ban + """ with app.database.session(True) as s: if not any([note, reason]): @@ -438,8 +551,14 @@ async def handle_domain_ban( return objects.DomainBan.from_row(row) -@Route(HttpMethod.PATCH, "/api/v1/domain_ban", "Domain Ban", True) +@Route(HttpMethod.DELETE, "/api/v1/domain_ban", "Domain Ban", True) async def handle_domain_unban(app: Application, request: Request, domain: str) -> objects.Message: + """ + Unban a domain + + :param domain: Hostname to unban + """ + with app.database.session(True) as s: if s.get_domain_ban(domain) is None: raise HttpError(404, "Domain not banned") @@ -451,6 +570,8 @@ async def handle_domain_unban(app: Application, request: Request, domain: str) - @Route(HttpMethod.GET, "/api/v1/software_ban", "Software Ban", True) async def handle_software_bans_get(app: Application, request: Request) -> list[objects.SoftwareBan]: + "Get all banned software" + data: list[objects.SoftwareBan] = [] with app.database.session(False) as s: @@ -467,6 +588,13 @@ async def handle_software_ban_add( name: str, note: str | None = None, reason: str | None = None) -> objects.SoftwareBan: + """ + Ban all instanstances that use the specified software + + :param name: Nodeinfo name of the software to ban + :param note: Additional details about the ban that can only be viewed by admins + :param reason: Publicly viewable details for the ban + """ with app.database.session(True) as s: if s.get_software_ban(name) is not None: @@ -483,6 +611,13 @@ async def handle_software_ban( name: str, note: str | None = None, reason: str | None = None) -> objects.SoftwareBan: + """ + Update a software ban + + :param name: Nodeinfo name of the software ban to modify + :param note: Additional details about the ban that can only be viewed by admins + :param reason: Publicly viewable details for the ban + """ with app.database.session(True) as s: if not any([note, reason]): @@ -497,6 +632,12 @@ async def handle_software_ban( @Route(HttpMethod.PATCH, "/api/v1/software_ban", "Software Ban", True) async def handle_software_unban(app: Application, request: Request, name: str) -> objects.Message: + """ + Unban the specified software + + :param name: Nodeinfo name of the software to unban + """ + with app.database.session(True) as s: if s.get_software_ban(name) is None: raise HttpError(404, "Software not banned") @@ -506,6 +647,58 @@ async def handle_software_unban(app: Application, request: Request, name: str) - return objects.Message("Unbanned software") +@Route(HttpMethod.GET, "/api/v1/whitelist", "Whitelist", True) +async def handle_whitelist_get(app: Application, request: Request) -> list[objects.Whitelist]: + """ + Get all currently whitelisted domains + """ + + data: list[objects.Whitelist] = [] + + with app.database.session(False) as s: + for row in s.get_domains_whitelist(): + data.append(objects.Whitelist.from_row(row)) + + return data + + +@Route(HttpMethod.POST, "/api/v1/whitelist", "Whitelist", True) +async def handle_whitelist_add( + app: Application, + request: Request, + domain: str) -> objects.Whitelist: + """ + Add a domain to the whitelist + + :param domain: Hostname to allow + """ + + with app.database.session(True) as s: + if s.get_domain_whitelist(domain) is not None: + raise HttpError(400, "Domain already added to whitelist") + + row = s.put_domain_whitelist(domain) + return objects.Whitelist.from_row(row) + + +@Route(HttpMethod.DELETE, "/api/v1/whitelist", "Whitelist", True) +async def handle_whitelist_del(app: Application, request: Request, domain: str) -> objects.Message: + """ + Remove a domain from the whitelist + + :param domain: Hostname to remove from the whitelist + """ + + with app.database.session(True) as s: + if s.get_domain_whitelist(domain) is None: + raise HttpError(404, "Domain not in whitelist") + + s.del_domain_whitelist(domain) + + return objects.Message("Removed domain from whitelist") + + +# remove /api/v1/user endpoints? @Route(HttpMethod.GET, "/api/v1/user", "User", True) async def handle_users_get(app: Application, request: Request) -> list[objects.User]: with app.database.session(False) as s: @@ -518,7 +711,7 @@ async def handle_users_get(app: Application, request: Request) -> list[objects.U @Route(HttpMethod.POST, "/api/v1/user", "User", True) -async def post( +async def handle_user_add( app: Application, request: Request, username: str, @@ -534,7 +727,7 @@ async def post( @Route(HttpMethod.PATCH, "/api/v1/user", "User", True) -async def patch( +async def handle_user_update( app: Application, request: Request, username: str, @@ -550,7 +743,7 @@ async def patch( @Route(HttpMethod.DELETE, "/api/v1/user", "User", True) -async def delete(app: Application, request: Request, username: str) -> objects.Message: +async def handle_user_del(app: Application, request: Request, username: str) -> objects.Message: with app.database.session(True) as s: if s.get_user(username) is None: raise HttpError(404, "User does not exist") @@ -558,39 +751,3 @@ async def delete(app: Application, request: Request, username: str) -> objects.M s.del_user(username) return objects.Message("Deleted user") - - -@Route(HttpMethod.GET, "/api/v1/whitelist", "Whitelist", True) -async def handle_whitelist_get(app: Application, request: Request) -> list[objects.Whitelist]: - data: list[objects.Whitelist] = [] - - with app.database.session(False) as s: - for row in s.get_domains_whitelist(): - data.append(objects.Whitelist.from_row(row)) - - return data - - -@Route(HttpMethod.POST, "/api/v1/whitelist", "Whitelist", True) -async def handle_whitelist_add( - app: Application, - request: Request, - domain: str) -> objects.Whitelist: - - with app.database.session(True) as s: - if s.get_domain_whitelist(domain) is not None: - raise HttpError(400, "Domain already added to whitelist") - - row = s.put_domain_whitelist(domain) - return objects.Whitelist.from_row(row) - - -@Route(HttpMethod.DELETE, "/api/v1/whitelist", "Whitelist", True) -async def handle_whitelist_del(app: Application, request: Request, domain: str) -> objects.Message: - with app.database.session(True) as s: - if s.get_domain_whitelist(domain) is None: - raise HttpError(404, "Domain not in whitelist") - - s.del_domain_whitelist(domain) - - return objects.Message("Removed domain from whitelist") diff --git a/relay/views/base.py b/relay/views/base.py index b4007d0..ba9c3ba 100644 --- a/relay/views/base.py +++ b/relay/views/base.py @@ -1,17 +1,22 @@ from __future__ import annotations +import docstring_parser +import inspect + from aiohttp.web import Request, StreamResponse from blib import HttpError, HttpMethod -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass from json.decoder import JSONDecodeError -from typing import TYPE_CHECKING, Any, overload +from types import GenericAlias, UnionType +from typing import TYPE_CHECKING, Any, cast, get_origin, get_type_hints, overload +from .. import logger as logging from ..api_objects import ApiObject +from ..application import Application from ..misc import Response, get_app if TYPE_CHECKING: - from ..application import Application - try: from typing import Self @@ -23,6 +28,7 @@ if TYPE_CHECKING: HandlerCallback = Callable[[Request], Awaitable[Response]] +METHODS: dict[str, Method] = {} ROUTES: list[tuple[str, str, HandlerCallback]] = [] DEFAULT_REDIRECT: str = 'urn:ietf:wg:oauth:2.0:oob' @@ -33,8 +39,23 @@ ALLOWED_HEADERS: set[str] = { } -def convert_data(data: Mapping[str, Any]) -> dict[str, str]: - return {key: str(value) for key, value in data.items()} +def parse_docstring(docstring: str) -> tuple[str, dict[str, str]]: + params = {} + ds = docstring_parser.parse(docstring) + + for param in ds.params: + params[param.arg_name] = param.description or "n/a" + + if not ds.short_description and not ds.long_description: + body = "n/a" + + elif ds.long_description is None: + body = cast(str, ds.short_description) + + else: + body = "\n".join([ds.short_description, ds.long_description]) # type: ignore[list-item] + + return body, params def register_route( @@ -51,8 +72,114 @@ def register_route( return wrapper +@dataclass(slots = True, frozen = True) +class Method: + name: str + category: str + docs: str | None + method: HttpMethod + path: str + return_type: type[Any] + parameters: tuple[Parameter, ...] + + + @classmethod + def parse( + cls: type[Self], + func: ApiRouteHandler, + method: HttpMethod, + path: str, + category: str) -> Self: + + annotations = get_type_hints(func) + + if (return_type := annotations.get("return")) is None: + raise ValueError(f"Missing return type for {func.__name__}") + + if isinstance(return_type, GenericAlias): + return_type = get_origin(return_type) + + if not issubclass(return_type, (Response, ApiObject, list)): + raise ValueError(f"Invalid return type '{return_type.__name__}' for {func.__name__}") + + args = {key: value for key, value in inspect.signature(func).parameters.items()} + docstring, paramdocs = parse_docstring(func.__doc__ or "") + params = [] + + if func.__doc__ is None: + logging.warning(f"Missing docstring for '{func.__name__}'") + + for key, value in args.items(): + types: list[type[Any]] = [] + vtype = annotations[key] + + if isinstance(vtype, UnionType): + for subtype in vtype.__args__: + if subtype is type(None): + continue + + types.append(subtype) + + elif vtype.__name__ in {"Application", "Request"}: + continue + + else: + types.append(vtype) + + params.append(Parameter( + key = key, + docs = paramdocs.get(key, ""), + default = value.default, + types = tuple(types) + )) + + if not paramdocs.get(key): + logging.warning(f"Missing docs for '{key}' parameter in '{func.__name__}'") + + rtype = annotations.get("return") or type(None) + return cls(func.__name__, category, docstring, method, path, rtype, tuple(params)) + + + +@dataclass(slots = True, frozen = True) +class Parameter: + key: str + docs: str + default: Any + types: tuple[type[Any], ...] + + + @property + def has_default(self) -> bool: + # why tf do you make me do this mypy!? + return cast(bool, self.default != inspect.Parameter.empty) + + + @property + def key_str(self) -> str: + if not self.has_default: + return f"{self.key} *required" + + return self.key + + + @property + def type_str(self) -> str: + return " | ".join(v.__name__ for v in self.types) + + + def check_types(self, items: Sequence[type[Any]]) -> bool: + for item in items: + if isinstance(item, self.types): + return True + + return False + + + class Route: handler: ApiRouteHandler + docs: Method def __init__(self, method: HttpMethod, @@ -82,6 +209,10 @@ class Route: if isinstance(obj, Request): return self.handle_request(obj) + if (self.method, self.path) != (HttpMethod.POST, "/oauth/authorize"): + if self.path != "/api/v1/user": + METHODS[obj.__name__] = Method.parse(obj, self.method, self.path, self.category) + self.handler = obj return self diff --git a/relay/views/frontend.py b/relay/views/frontend.py index eff93c7..87c6424 100644 --- a/relay/views/frontend.py +++ b/relay/views/frontend.py @@ -1,49 +1,20 @@ from __future__ import annotations -from aiohttp.web import Request, middleware +from aiohttp.web import Request from blib import HttpMethod -from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any from urllib.parse import unquote -from .base import register_route +from .base import METHODS, register_route from ..database import THEMES from ..logger import LogLevel -from ..misc import TOKEN_PATHS, Response +from ..misc import Response if TYPE_CHECKING: from ..application import Application -@middleware -async def handle_frontend_path( - request: Request, - handler: Callable[[Request], Awaitable[Response]]) -> Response: - - if request['user'] is not None and request.path == '/login': - return Response.new_redir('/') - - if request.path.startswith(TOKEN_PATHS[:2]) and request['user'] is None: - if request.path == '/logout': - return Response.new_redir('/') - - response = Response.new_redir(f'/login?redir={request.path}') - - if request['token'] is not None: - response.del_cookie('user-token') - - return response - - response = await handler(request) - - if not request.path.startswith('/api'): - if request['user'] is None and request['token'] is not None: - response.del_cookie('user-token') - - return response - - @register_route(HttpMethod.GET, "/") async def handle_home(app: Application, request: Request) -> Response: with app.database.session() as conn: @@ -54,6 +25,15 @@ async def handle_home(app: Application, request: Request) -> Response: return Response.new_template(200, "page/home.haml", request, context) +@register_route(HttpMethod.GET, "/docs") +async def handle_api_doc(app: Application, request: Request) -> Response: + context: dict[str, Any] = { + "methods": sorted(METHODS.values(), key = lambda x: x.category) + } + + return Response.new_template(200, "page/docs.haml", request, context) + + @register_route(HttpMethod.GET, '/login') async def handle_login(app: Application, request: Request) -> Response: context = {"redir": unquote(request.query.get("redir", "/"))}