From 8f9472b221fdb654ca4cf40111da67f206ca55c4 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Thu, 13 Jun 2024 22:03:51 -1000 Subject: [PATCH] feat(api): :sparkles: Implement Challenges API --- .github/config.workflow.toml | 13 + README.md | 1 + config/config.example.toml | 13 + docs/api/challenges.md | 55 + docs/api/index.md | 4 + drizzle/migrations/0027_peaceful_whistler.sql | 1 + drizzle/migrations/meta/0027_snapshot.json | 2137 +++++++++++++++++ drizzle/migrations/meta/_journal.json | 7 + drizzle/schema.ts | 12 +- index.ts | 38 + packages/config-manager/config.type.ts | 19 + server/api/api/auth/login/index.test.ts | 4 +- server/api/api/auth/login/index.ts | 50 +- server/api/api/auth/reset/index.test.ts | 4 +- server/api/api/v1/accounts/:id/statuses.ts | 2 +- server/api/api/v1/accounts/index.test.ts | 13 +- server/api/api/v1/accounts/index.ts | 5 +- .../v1/accounts/update_credentials/index.ts | 76 +- server/api/api/v1/challenges/index.test.ts | 28 + server/api/api/v1/challenges/index.ts | 39 + server/api/api/v2/search/index.ts | 6 +- tests/utils.ts | 33 + types/api.ts | 6 + utils/api.ts | 148 +- utils/challenges.ts | 39 + utils/response.ts | 7 +- 26 files changed, 2656 insertions(+), 104 deletions(-) create mode 100644 docs/api/challenges.md create mode 100644 drizzle/migrations/0027_peaceful_whistler.sql create mode 100644 drizzle/migrations/meta/0027_snapshot.json create mode 100644 server/api/api/v1/challenges/index.test.ts create mode 100644 server/api/api/v1/challenges/index.ts create mode 100644 utils/challenges.ts diff --git a/.github/config.workflow.toml b/.github/config.workflow.toml index 7dbe3c2e..447a42f6 100644 --- a/.github/config.workflow.toml +++ b/.github/config.workflow.toml @@ -183,6 +183,19 @@ allowed_mime_types = [ "video/x-ms-asf", ] +[validation.challenges] +# "Challenges" (aka captchas) are a way to verify that a user is human +# Lysand's challenges use no external services, and are Proof of Work based +# This means that they do not require any user interaction, instead +# they require the user's computer to do a small amount of work +enabled = true +# The difficulty of the challenge, higher is harder +difficulty = 50000 +# Challenge expiration time in seconds +expiration = 300 # 5 minutes +# Leave this empty to generate a new key +key = "YBpAV0KZOeM/MZ4kOb2E9moH9gCUr00Co9V7ncGRJ3wbd/a9tLDKKFdI0BtOcnlpfx0ZBh0+w3WSvsl0TsesTg==" + [defaults] # Default visibility for new notes visibility = "public" diff --git a/README.md b/README.md index 730df027..0ab11f06 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ - [x] Fully written in TypeScript and thoroughly unit tested - [x] Automatic signed container builds for easy deployment - [x] Docker and Podman supported +- [x] Invisible, Proof-of-Work local CAPTCHA for API requests ## Screenshots diff --git a/config/config.example.toml b/config/config.example.toml index 6c44bed2..979c1bf9 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -252,6 +252,19 @@ enforce_mime_types = false # Defaults to all valid MIME types # allowed_mime_types = [] +[validation.challenges] +# "Challenges" (aka captchas) are a way to verify that a user is human +# Lysand's challenges use no external services, and are Proof of Work based +# This means that they do not require any user interaction, instead +# they require the user's computer to do a small amount of work +enabled = false +# The difficulty of the challenge, higher is will take more time to solve +difficulty = 50000 +# Challenge expiration time in seconds +expiration = 300 # 5 minutes +# Leave this empty to generate a new key +key = "" + [defaults] # Default visibility for new notes # Can be public, unlisted, private or direct diff --git a/docs/api/challenges.md b/docs/api/challenges.md new file mode 100644 index 00000000..aef5bcc6 --- /dev/null +++ b/docs/api/challenges.md @@ -0,0 +1,55 @@ +# Challenges API + +Some API routes may require a cryptographic challenge to be solved before the request can be made. This is to prevent abuse of the API by bots and other malicious actors. The challenge is a simple mathematical problem that can be solved by any client. + +This is a form of proof of work CAPTCHA, and should be mostly invisible to users. The challenge is generated by the server and sent to the client, which must solve it and send the solution back to the server. + +## Solving a Challenge + +Challenges are powered by the [Altcha](https://altcha.org/) library. You may either reimplement their solution code (which is very simple), or use [`altcha-lib`](https://github.com/altcha-org/altcha-lib) to solve the challenges. + +## Request Challenge + +```http +POST /api/v1/challenges +``` + +Generates a new challenge for the client to solve. + +### Response + +```ts +// 200 OK +{ + id: string, + algorithm: "SHA-256" | "SHA-384" | "SHA-512", + challenge: string; + maxnumber?: number; + salt: string; + signature: string; +} +``` + +## Sending a Solution + +To send a solution with any request, add the following headers: + - `X-Challenge-Solution`: A base64 encoded string of the following JSON object: + ```ts + { + number: number; // Solution to the challenge + algorithm: "SHA-256" | "SHA-384" | "SHA-512"; + challenge: string; + salt: string, + signature: string, + } + ``` + Example: `{"number": 42, "algorithm": "SHA-256", "challenge": "xxxx", "salt": "abc", "signature": "def"}` -> `eyJudW1iZXIiOjQyLCJhbGdvcml0aG0iOiJTSEEtMjU2IiwiY2hhbGxlbmdlIjoieHh4eCIsInNhbHQiOiJhYmMiLCJzaWduYXR1cmUiOiJkZWYifQ==` + +A challenge solution is valid for 5 minutes (configurable) after the challenge is generated. No solved challenge may be used more than once. + +## Routes Requiring Challenges + +If challenges are enabled, the following routes will require a challenge to be solved before the request can be made: +- `POST /api/v1/accounts` + +Which routes require challenges may eventually be expanded or made configurable. \ No newline at end of file diff --git a/docs/api/index.md b/docs/api/index.md index 168a736b..dc079f72 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -12,6 +12,10 @@ For client developers. Please read [the documentation](./emojis.md). For client developers. Please read [the documentation](./roles.md). +## Challenges API + +For client developers. Please read [the documentation](./challenges.md). + ## Moderation API > [!WARNING] diff --git a/drizzle/migrations/0027_peaceful_whistler.sql b/drizzle/migrations/0027_peaceful_whistler.sql new file mode 100644 index 00000000..bb853ba5 --- /dev/null +++ b/drizzle/migrations/0027_peaceful_whistler.sql @@ -0,0 +1 @@ +ALTER TABLE "CaptchaChallenges" RENAME TO "Challenges"; \ No newline at end of file diff --git a/drizzle/migrations/meta/0027_snapshot.json b/drizzle/migrations/meta/0027_snapshot.json new file mode 100644 index 00000000..59fe06d8 --- /dev/null +++ b/drizzle/migrations/meta/0027_snapshot.json @@ -0,0 +1,2137 @@ +{ + "id": "e760240a-1b48-4b82-99a1-9b7df57e820c", + "prevId": "76cee222-4cfd-4c1d-b25e-65a032f86b7e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Applications": { + "name": "Applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vapid_key": { + "name": "vapid_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "Applications_client_id_index": { + "name": "Applications_client_id_index", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Attachments": { + "name": "Attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blurhash": { + "name": "blurhash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Attachments_noteId_Notes_id_fk": { + "name": "Attachments_noteId_Notes_id_fk", + "tableFrom": "Attachments", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Challenges": { + "name": "Challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "challenge": { + "name": "challenge", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "default": "NOW() + INTERVAL '5 minutes'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.EmojiToNote": { + "name": "EmojiToNote", + "schema": "", + "columns": { + "emojiId": { + "name": "emojiId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "EmojiToNote_emojiId_noteId_index": { + "name": "EmojiToNote_emojiId_noteId_index", + "columns": [ + { + "expression": "emojiId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "EmojiToNote_noteId_index": { + "name": "EmojiToNote_noteId_index", + "columns": [ + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "EmojiToNote_emojiId_Emojis_id_fk": { + "name": "EmojiToNote_emojiId_Emojis_id_fk", + "tableFrom": "EmojiToNote", + "tableTo": "Emojis", + "columnsFrom": ["emojiId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "EmojiToNote_noteId_Notes_id_fk": { + "name": "EmojiToNote_noteId_Notes_id_fk", + "tableFrom": "EmojiToNote", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.EmojiToUser": { + "name": "EmojiToUser", + "schema": "", + "columns": { + "emojiId": { + "name": "emojiId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "EmojiToUser_emojiId_userId_index": { + "name": "EmojiToUser_emojiId_userId_index", + "columns": [ + { + "expression": "emojiId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "EmojiToUser_userId_index": { + "name": "EmojiToUser_userId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "EmojiToUser_emojiId_Emojis_id_fk": { + "name": "EmojiToUser_emojiId_Emojis_id_fk", + "tableFrom": "EmojiToUser", + "tableTo": "Emojis", + "columnsFrom": ["emojiId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "EmojiToUser_userId_Users_id_fk": { + "name": "EmojiToUser_userId_Users_id_fk", + "tableFrom": "EmojiToUser", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Emojis": { + "name": "Emojis", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "shortcode": { + "name": "shortcode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visible_in_picker": { + "name": "visible_in_picker", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instanceId": { + "name": "instanceId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ownerId": { + "name": "ownerId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Emojis_instanceId_Instances_id_fk": { + "name": "Emojis_instanceId_Instances_id_fk", + "tableFrom": "Emojis", + "tableTo": "Instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Emojis_ownerId_Users_id_fk": { + "name": "Emojis_ownerId_Users_id_fk", + "tableFrom": "Emojis", + "tableTo": "Users", + "columnsFrom": ["ownerId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.FilterKeywords": { + "name": "FilterKeywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "filterId": { + "name": "filterId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "whole_word": { + "name": "whole_word", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "FilterKeywords_filterId_Filters_id_fk": { + "name": "FilterKeywords_filterId_Filters_id_fk", + "tableFrom": "FilterKeywords", + "tableTo": "Filters", + "columnsFrom": ["filterId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Filters": { + "name": "Filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter_action": { + "name": "filter_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Filters_userId_Users_id_fk": { + "name": "Filters_userId_Users_id_fk", + "tableFrom": "Filters", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Flags": { + "name": "Flags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "flag_type": { + "name": "flag_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'other'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Flags_noteId_Notes_id_fk": { + "name": "Flags_noteId_Notes_id_fk", + "tableFrom": "Flags", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Flags_userId_Users_id_fk": { + "name": "Flags_userId_Users_id_fk", + "tableFrom": "Flags", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Instances": { + "name": "Instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "disable_automoderation": { + "name": "disable_automoderation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Likes": { + "name": "Likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "likerId": { + "name": "likerId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "likedId": { + "name": "likedId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Likes_likerId_Users_id_fk": { + "name": "Likes_likerId_Users_id_fk", + "tableFrom": "Likes", + "tableTo": "Users", + "columnsFrom": ["likerId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Likes_likedId_Notes_id_fk": { + "name": "Likes_likedId_Notes_id_fk", + "tableFrom": "Likes", + "tableTo": "Notes", + "columnsFrom": ["likedId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.LysandObject": { + "name": "LysandObject", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "remote_id": { + "name": "remote_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "authorId": { + "name": "authorId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "extra_data": { + "name": "extra_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "extensions": { + "name": "extensions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "LysandObject_remote_id_index": { + "name": "LysandObject_remote_id_index", + "columns": [ + { + "expression": "remote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "LysandObject_uri_index": { + "name": "LysandObject_uri_index", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "LysandObject_authorId_LysandObject_id_fk": { + "name": "LysandObject_authorId_LysandObject_id_fk", + "tableFrom": "LysandObject", + "tableTo": "LysandObject", + "columnsFrom": ["authorId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Markers": { + "name": "Markers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "notificationId": { + "name": "notificationId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "timeline": { + "name": "timeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Markers_noteId_Notes_id_fk": { + "name": "Markers_noteId_Notes_id_fk", + "tableFrom": "Markers", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Markers_notificationId_Notifications_id_fk": { + "name": "Markers_notificationId_Notifications_id_fk", + "tableFrom": "Markers", + "tableTo": "Notifications", + "columnsFrom": ["notificationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Markers_userId_Users_id_fk": { + "name": "Markers_userId_Users_id_fk", + "tableFrom": "Markers", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ModNotes": { + "name": "ModNotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "modId": { + "name": "modId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ModNotes_noteId_Notes_id_fk": { + "name": "ModNotes_noteId_Notes_id_fk", + "tableFrom": "ModNotes", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ModNotes_userId_Users_id_fk": { + "name": "ModNotes_userId_Users_id_fk", + "tableFrom": "ModNotes", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ModNotes_modId_Users_id_fk": { + "name": "ModNotes_modId_Users_id_fk", + "tableFrom": "ModNotes", + "tableTo": "Users", + "columnsFrom": ["modId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ModTags": { + "name": "ModTags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "modId": { + "name": "modId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ModTags_noteId_Notes_id_fk": { + "name": "ModTags_noteId_Notes_id_fk", + "tableFrom": "ModTags", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ModTags_userId_Users_id_fk": { + "name": "ModTags_userId_Users_id_fk", + "tableFrom": "ModTags", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ModTags_modId_Users_id_fk": { + "name": "ModTags_modId_Users_id_fk", + "tableFrom": "ModTags", + "tableTo": "Users", + "columnsFrom": ["modId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.NoteToMentions": { + "name": "NoteToMentions", + "schema": "", + "columns": { + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "NoteToMentions_noteId_userId_index": { + "name": "NoteToMentions_noteId_userId_index", + "columns": [ + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "NoteToMentions_userId_index": { + "name": "NoteToMentions_userId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "NoteToMentions_noteId_Notes_id_fk": { + "name": "NoteToMentions_noteId_Notes_id_fk", + "tableFrom": "NoteToMentions", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "NoteToMentions_userId_Users_id_fk": { + "name": "NoteToMentions_userId_Users_id_fk", + "tableFrom": "NoteToMentions", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Notes": { + "name": "Notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorId": { + "name": "authorId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reblogId": { + "name": "reblogId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text/plain'" + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replyId": { + "name": "replyId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quoteId": { + "name": "quoteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "spoiler_text": { + "name": "spoiler_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "applicationId": { + "name": "applicationId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content_source": { + "name": "content_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": { + "Notes_authorId_Users_id_fk": { + "name": "Notes_authorId_Users_id_fk", + "tableFrom": "Notes", + "tableTo": "Users", + "columnsFrom": ["authorId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notes_reblogId_Notes_id_fk": { + "name": "Notes_reblogId_Notes_id_fk", + "tableFrom": "Notes", + "tableTo": "Notes", + "columnsFrom": ["reblogId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notes_replyId_Notes_id_fk": { + "name": "Notes_replyId_Notes_id_fk", + "tableFrom": "Notes", + "tableTo": "Notes", + "columnsFrom": ["replyId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notes_quoteId_Notes_id_fk": { + "name": "Notes_quoteId_Notes_id_fk", + "tableFrom": "Notes", + "tableTo": "Notes", + "columnsFrom": ["quoteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notes_applicationId_Applications_id_fk": { + "name": "Notes_applicationId_Applications_id_fk", + "tableFrom": "Notes", + "tableTo": "Applications", + "columnsFrom": ["applicationId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Notes_uri_unique": { + "name": "Notes_uri_unique", + "nullsNotDistinct": false, + "columns": ["uri"] + } + } + }, + "public.Notifications": { + "name": "Notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notifiedId": { + "name": "notifiedId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dismissed": { + "name": "dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "Notifications_notifiedId_Users_id_fk": { + "name": "Notifications_notifiedId_Users_id_fk", + "tableFrom": "Notifications", + "tableTo": "Users", + "columnsFrom": ["notifiedId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notifications_accountId_Users_id_fk": { + "name": "Notifications_accountId_Users_id_fk", + "tableFrom": "Notifications", + "tableTo": "Users", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notifications_noteId_Notes_id_fk": { + "name": "Notifications_noteId_Notes_id_fk", + "tableFrom": "Notifications", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.OpenIdAccounts": { + "name": "OpenIdAccounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer_id": { + "name": "issuer_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "OpenIdAccounts_userId_Users_id_fk": { + "name": "OpenIdAccounts_userId_Users_id_fk", + "tableFrom": "OpenIdAccounts", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.OpenIdLoginFlows": { + "name": "OpenIdLoginFlows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issuer_id": { + "name": "issuer_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "OpenIdLoginFlows_applicationId_Applications_id_fk": { + "name": "OpenIdLoginFlows_applicationId_Applications_id_fk", + "tableFrom": "OpenIdLoginFlows", + "tableTo": "Applications", + "columnsFrom": ["applicationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Relationships": { + "name": "Relationships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "ownerId": { + "name": "ownerId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subjectId": { + "name": "subjectId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "following": { + "name": "following", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "showing_reblogs": { + "name": "showing_reblogs", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "notifying": { + "name": "notifying", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "followed_by": { + "name": "followed_by", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "blocking": { + "name": "blocking", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "blocked_by": { + "name": "blocked_by", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "muting": { + "name": "muting", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "muting_notifications": { + "name": "muting_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "requested": { + "name": "requested", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain_blocking": { + "name": "domain_blocking", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "endorsed": { + "name": "endorsed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "languages": { + "name": "languages", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Relationships_ownerId_Users_id_fk": { + "name": "Relationships_ownerId_Users_id_fk", + "tableFrom": "Relationships", + "tableTo": "Users", + "columnsFrom": ["ownerId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Relationships_subjectId_Users_id_fk": { + "name": "Relationships_subjectId_Users_id_fk", + "tableFrom": "Relationships", + "tableTo": "Users", + "columnsFrom": ["subjectId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.RoleToUsers": { + "name": "RoleToUsers", + "schema": "", + "columns": { + "roleId": { + "name": "roleId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "RoleToUsers_roleId_Roles_id_fk": { + "name": "RoleToUsers_roleId_Roles_id_fk", + "tableFrom": "RoleToUsers", + "tableTo": "Roles", + "columnsFrom": ["roleId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "RoleToUsers_userId_Users_id_fk": { + "name": "RoleToUsers_userId_Users_id_fk", + "tableFrom": "RoleToUsers", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Roles": { + "name": "Roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Tokens": { + "name": "Tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Tokens_userId_Users_id_fk": { + "name": "Tokens_userId_Users_id_fk", + "tableFrom": "Tokens", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Tokens_applicationId_Applications_id_fk": { + "name": "Tokens_applicationId_Applications_id_fk", + "tableFrom": "Tokens", + "tableTo": "Applications", + "columnsFrom": ["applicationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.UserToPinnedNotes": { + "name": "UserToPinnedNotes", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UserToPinnedNotes_userId_noteId_index": { + "name": "UserToPinnedNotes_userId_noteId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UserToPinnedNotes_noteId_index": { + "name": "UserToPinnedNotes_noteId_index", + "columns": [ + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "UserToPinnedNotes_userId_Users_id_fk": { + "name": "UserToPinnedNotes_userId_Users_id_fk", + "tableFrom": "UserToPinnedNotes", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "UserToPinnedNotes_noteId_Notes_id_fk": { + "name": "UserToPinnedNotes_noteId_Notes_id_fk", + "tableFrom": "UserToPinnedNotes", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Users": { + "name": "Users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verification_token": { + "name": "email_verification_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_reset_token": { + "name": "password_reset_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "endpoints": { + "name": "endpoints", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header": { + "name": "header", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_discoverable": { + "name": "is_discoverable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sanctions": { + "name": "sanctions", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instanceId": { + "name": "instanceId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "disable_automoderation": { + "name": "disable_automoderation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "Users_uri_index": { + "name": "Users_uri_index", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Users_username_index": { + "name": "Users_username_index", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Users_email_index": { + "name": "Users_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Users_instanceId_Instances_id_fk": { + "name": "Users_instanceId_Instances_id_fk", + "tableFrom": "Users", + "tableTo": "Instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 50a2afe8..a5f86c59 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1718234302625, "tag": "0026_neat_stranger", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1718327596823, + "tag": "0027_peaceful_whistler", + "breakpoints": true } ] } diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 0709fb42..d3c7af69 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -16,16 +16,18 @@ import { } from "drizzle-orm/pg-core"; import type { Source as apiSource } from "~/types/mastodon/source"; -export const CaptchaChallenges = pgTable("CaptchaChallenges", { +export const Challenges = pgTable("Challenges", { id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(), challenge: jsonb("challenge").notNull().$type(), expiresAt: timestamp("expires_at", { precision: 3, mode: "string", - }).default( - // 5 minutes - sql`NOW() + INTERVAL '5 minutes'`, - ), + }) + .default( + // 5 minutes + sql`NOW() + INTERVAL '5 minutes'`, + ) + .notNull(), createdAt: timestamp("created_at", { precision: 3, mode: "string" }) .defaultNow() .notNull(), diff --git a/index.ts b/index.ts index f07a2750..e728ecb7 100644 --- a/index.ts +++ b/index.ts @@ -106,6 +106,44 @@ if (isEntry) { ); process.exit(1); } + + if ( + config.validation.challenges.enabled && + !config.validation.challenges.key + ) { + await dualServerLogger.log( + LogLevel.Critical, + "Server", + "Challenges are enabled, but the challenge key is not set in the config", + ); + + await dualServerLogger.log( + LogLevel.Critical, + "Server", + "Below is a generated key for you to copy in the config at validation.challenges.key", + ); + + const key = await crypto.subtle.generateKey( + { + name: "HMAC", + hash: "SHA-256", + }, + true, + ["sign"], + ); + + const exported = await crypto.subtle.exportKey("raw", key); + + const base64 = Buffer.from(exported).toString("base64"); + + await dualServerLogger.log( + LogLevel.Critical, + "Server", + `Generated key: ${chalk.gray(base64)}`, + ); + + process.exit(1); + } } const app = new Hono({ diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index e6a6378a..6f2726af 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -330,6 +330,19 @@ export const configValidator = z.object({ allowed_mime_types: z .array(z.string()) .default(Object.values(mimeTypes)), + challenges: z + .object({ + enabled: z.boolean().default(true), + difficulty: z.number().int().positive().default(50000), + expiration: z.number().int().positive().default(300), + key: z.string().default(""), + }) + .default({ + enabled: true, + difficulty: 50000, + expiration: 300, + key: "", + }), }) .default({ max_displayname_size: 50, @@ -399,6 +412,12 @@ export const configValidator = z.object({ ], enforce_mime_types: false, allowed_mime_types: Object.values(mimeTypes), + challenges: { + enabled: true, + difficulty: 50000, + expiration: 300, + key: "", + }, }), defaults: z .object({ diff --git a/server/api/api/auth/login/index.test.ts b/server/api/api/auth/login/index.test.ts index 99f5fab5..2c888d5f 100644 --- a/server/api/api/auth/login/index.test.ts +++ b/server/api/api/auth/login/index.test.ts @@ -53,7 +53,7 @@ describe(meta.route, () => { expect(response.headers.get("location")).toBeDefined(); const locationHeader = new URL( response.headers.get("Location") ?? "", - "", + config.http.base_url, ); expect(locationHeader.pathname).toBe("/oauth/consent"); @@ -92,7 +92,7 @@ describe(meta.route, () => { expect(response.headers.get("location")).toBeDefined(); const locationHeader = new URL( response.headers.get("Location") ?? "", - "", + config.http.base_url, ); expect(locationHeader.pathname).toBe("/oauth/consent"); diff --git a/server/api/api/auth/login/index.ts b/server/api/api/auth/login/index.ts index 41573072..fa83a531 100644 --- a/server/api/api/auth/login/index.ts +++ b/server/api/api/auth/login/index.ts @@ -1,5 +1,5 @@ import { applyConfig, handleZodError } from "@/api"; -import { errorResponse, response } from "@/response"; +import { errorResponse, redirect } from "@/response"; import { zValidator } from "@hono/zod-validator"; import { eq, or } from "drizzle-orm"; import type { Hono } from "hono"; @@ -73,12 +73,12 @@ const returnError = (query: object, error: string, description: string) => { searchParams.append("error", error); searchParams.append("error_description", description); - return response(null, 302, { - Location: new URL( + return redirect( + new URL( `${config.frontend.routes.login}?${searchParams.toString()}`, config.http.base_url, - ).toString(), - }); + ), + ); }; export default (app: Hono) => @@ -124,17 +124,14 @@ export default (app: Hono) => } if (user.data.passwordResetToken) { - return response(null, 302, { - Location: new URL( - `${ - config.frontend.routes.password_reset - }?${new URLSearchParams({ - token: user.data.passwordResetToken ?? "", - login_reset: "true", - }).toString()}`, - config.http.base_url, - ).toString(), - }); + return redirect( + `${ + config.frontend.routes.password_reset + }?${new URLSearchParams({ + token: user.data.passwordResetToken ?? "", + login_reset: "true", + }).toString()}`, + ); } // Try and import the key @@ -186,17 +183,14 @@ export default (app: Hono) => } // Redirect to OAuth authorize with JWT - return response(null, 302, { - Location: new URL( - `${ - config.frontend.routes.consent - }?${searchParams.toString()}`, - config.http.base_url, - ).toString(), - // Set cookie with JWT - "Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${ - 60 * 60 - }`, - }); + return redirect( + `${config.frontend.routes.consent}?${searchParams.toString()}`, + 302, + { + "Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${ + 60 * 60 + }`, + }, + ); }, ); diff --git a/server/api/api/auth/reset/index.test.ts b/server/api/api/auth/reset/index.test.ts index 4e8c65cb..32a4ed5c 100644 --- a/server/api/api/auth/reset/index.test.ts +++ b/server/api/api/auth/reset/index.test.ts @@ -82,7 +82,7 @@ describe(meta.route, () => { expect(response.headers.get("location")).toBeDefined(); const locationHeader = new URL( response.headers.get("Location") ?? "", - "", + config.http.base_url, ); expect(locationHeader.pathname).toBe("/oauth/reset"); @@ -128,7 +128,7 @@ describe(meta.route, () => { expect(loginResponse.headers.get("location")).toBeDefined(); const locationHeader = new URL( loginResponse.headers.get("Location") ?? "", - "", + config.http.base_url, ); expect(locationHeader.pathname).toBe("/oauth/consent"); diff --git a/server/api/api/v1/accounts/:id/statuses.ts b/server/api/api/v1/accounts/:id/statuses.ts index 14fc8692..c42d0b8a 100644 --- a/server/api/api/v1/accounts/:id/statuses.ts +++ b/server/api/api/v1/accounts/:id/statuses.ts @@ -108,7 +108,7 @@ export default (app: Hono) => await Promise.all(objects.map((note) => note.toApi(otherUser))), 200, { - Link: link, + link, }, ); }, diff --git a/server/api/api/v1/accounts/index.test.ts b/server/api/api/v1/accounts/index.test.ts index ff73c326..a6969c57 100644 --- a/server/api/api/v1/accounts/index.test.ts +++ b/server/api/api/v1/accounts/index.test.ts @@ -4,7 +4,7 @@ import { config } from "config-manager"; import { eq } from "drizzle-orm"; import { db } from "~/drizzle/db"; import { Users } from "~/drizzle/schema"; -import { sendTestRequest } from "~/tests/utils"; +import { getSolvedChallenge, sendTestRequest } from "~/tests/utils"; import { meta } from "./index"; const username = randomString(10, "hex"); @@ -23,6 +23,7 @@ describe(meta.route, () => { method: "POST", headers: { "Content-Type": "application/json", + "X-Challenge-Solution": await getSolvedChallenge(), }, body: JSON.stringify({ username: username, @@ -44,6 +45,7 @@ describe(meta.route, () => { method: "POST", headers: { "Content-Type": "application/json", + "X-Challenge-Solution": await getSolvedChallenge(), }, body: JSON.stringify({ username: username, @@ -65,6 +67,7 @@ describe(meta.route, () => { method: "POST", headers: { "Content-Type": "application/json", + "X-Challenge-Solution": await getSolvedChallenge(), }, body: JSON.stringify({ username: username, @@ -85,6 +88,7 @@ describe(meta.route, () => { method: "POST", headers: { "Content-Type": "application/json", + "X-Challenge-Solution": await getSolvedChallenge(), }, body: JSON.stringify({ username: username, @@ -102,6 +106,7 @@ describe(meta.route, () => { method: "POST", headers: { "Content-Type": "application/json", + "X-Challenge-Solution": await getSolvedChallenge(), }, body: JSON.stringify({ username: username2, @@ -123,6 +128,7 @@ describe(meta.route, () => { method: "POST", headers: { "Content-Type": "application/json", + "X-Challenge-Solution": await getSolvedChallenge(), }, body: JSON.stringify({ username: username, @@ -140,6 +146,7 @@ describe(meta.route, () => { method: "POST", headers: { "Content-Type": "application/json", + "X-Challenge-Solution": await getSolvedChallenge(), }, body: JSON.stringify({ username: username2, @@ -161,6 +168,7 @@ describe(meta.route, () => { method: "POST", headers: { "Content-Type": "application/json", + "X-Challenge-Solution": await getSolvedChallenge(), }, body: JSON.stringify({ username: "bob$", @@ -180,6 +188,7 @@ describe(meta.route, () => { method: "POST", headers: { "Content-Type": "application/json", + "X-Challenge-Solution": await getSolvedChallenge(), }, body: JSON.stringify({ username: "bob-markey", @@ -199,6 +208,7 @@ describe(meta.route, () => { method: "POST", headers: { "Content-Type": "application/json", + "X-Challenge-Solution": await getSolvedChallenge(), }, body: JSON.stringify({ username: "bob markey", @@ -218,6 +228,7 @@ describe(meta.route, () => { method: "POST", headers: { "Content-Type": "application/json", + "X-Challenge-Solution": await getSolvedChallenge(), }, body: JSON.stringify({ username: "BOB", diff --git a/server/api/api/v1/accounts/index.ts b/server/api/api/v1/accounts/index.ts index 1930e02c..9ce5bd79 100644 --- a/server/api/api/v1/accounts/index.ts +++ b/server/api/api/v1/accounts/index.ts @@ -21,6 +21,9 @@ export const meta = applyConfig({ required: false, oauthPermissions: ["write:accounts"], }, + challenge: { + required: true, + }, }); export const schemas = { @@ -41,9 +44,9 @@ export default (app: Hono) => app.on( meta.allowedMethods, meta.route, + auth(meta.auth, meta.permissions, meta.challenge), jsonOrForm(), zValidator("form", schemas.form, handleZodError), - auth(meta.auth, meta.permissions), async (context) => { const form = context.req.valid("form"); const { username, email, password, agreement, locale } = diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index 109505db..a5b7ef59 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -39,15 +39,49 @@ export const schemas = { .min(3) .trim() .max(config.validation.max_displayname_size) + .refine( + (s) => + !config.filters.displayname.some((filter) => + s.match(filter), + ), + "Display name contains blocked words", + ) + .optional(), + username: z + .string() + .min(3) + .trim() + .max(config.validation.max_username_size) + .refine( + (s) => + !config.filters.username.some((filter) => s.match(filter)), + "Username contains blocked words", + ) .optional(), note: z .string() .min(0) .max(config.validation.max_bio_size) .trim() + .refine( + (s) => !config.filters.bio.some((filter) => s.match(filter)), + "Bio contains blocked words", + ) + .optional(), + avatar: z + .instanceof(File) + .refine( + (v) => v.size <= config.validation.max_avatar_size, + `Avatar must be less than ${config.validation.max_avatar_size} bytes`, + ) + .optional(), + header: z + .instanceof(File) + .refine( + (v) => v.size <= config.validation.max_header_size, + `Header must be less than ${config.validation.max_header_size} bytes`, + ) .optional(), - avatar: z.instanceof(File).optional(), - header: z.instanceof(File).optional(), locked: z .string() .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) @@ -105,6 +139,7 @@ export default (app: Hono) => const { user } = context.req.valid("header"); const { display_name, + username, note, avatar, header, @@ -131,27 +166,10 @@ export default (app: Hono) => ); if (display_name) { - // Check if display name doesnt match filters - if ( - config.filters.displayname.some((filter) => - sanitizedDisplayName.match(filter), - ) - ) { - return errorResponse( - "Display name contains blocked words", - 422, - ); - } - self.displayName = sanitizedDisplayName; } if (note && self.source) { - // Check if bio doesnt match filters - if (config.filters.bio.some((filter) => note.match(filter))) { - return errorResponse("Bio contains blocked words", 422); - } - self.source.note = note; self.note = await contentToHtml({ "text/markdown": { @@ -172,29 +190,17 @@ export default (app: Hono) => self.source.language = source.language; } - if (avatar) { - // Check if within allowed avatar length (avatar is an image) - if (avatar.size > config.validation.max_avatar_size) { - return errorResponse( - `Avatar must be less than ${config.validation.max_avatar_size} bytes`, - 422, - ); - } + if (username) { + self.username = username; + } + if (avatar) { const { path } = await mediaManager.addFile(avatar); self.avatar = getUrl(path, config); } if (header) { - // Check if within allowed header length (header is an image) - if (header.size > config.validation.max_header_size) { - return errorResponse( - `Header must be less than ${config.validation.max_avatar_size} bytes`, - 422, - ); - } - const { path } = await mediaManager.addFile(header); self.header = getUrl(path, config); diff --git a/server/api/api/v1/challenges/index.test.ts b/server/api/api/v1/challenges/index.test.ts new file mode 100644 index 00000000..2df94862 --- /dev/null +++ b/server/api/api/v1/challenges/index.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { config } from "config-manager"; +import { sendTestRequest } from "~/tests/utils"; +import { meta } from "./index"; + +// /api/v1/challenges +describe(meta.route, () => { + test("should get a challenge", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + }), + ); + + expect(response.status).toBe(200); + + const body = await response.json(); + + expect(body).toMatchObject({ + id: expect.any(String), + algorithm: expect.any(String), + challenge: expect.any(String), + maxnumber: expect.any(Number), + salt: expect.any(String), + signature: expect.any(String), + }); + }); +}); diff --git a/server/api/api/v1/challenges/index.ts b/server/api/api/v1/challenges/index.ts new file mode 100644 index 00000000..0591393d --- /dev/null +++ b/server/api/api/v1/challenges/index.ts @@ -0,0 +1,39 @@ +import { applyConfig, auth } from "@/api"; +import { generateChallenge } from "@/challenges"; +import { errorResponse, jsonResponse } from "@/response"; +import type { Hono } from "hono"; +import { config } from "~/packages/config-manager"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + route: "/api/v1/challenges", + ratelimits: { + max: 10, + duration: 60, + }, + auth: { + required: false, + }, + permissions: { + required: [], + }, +}); + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + auth(meta.auth, meta.permissions), + async (_context) => { + if (!config.validation.challenges.enabled) { + return errorResponse("Challenges are disabled in config", 400); + } + + const result = await generateChallenge(); + + return jsonResponse({ + id: result.id, + ...result.challenge, + }); + }, + ); diff --git a/server/api/api/v2/search/index.ts b/server/api/api/v2/search/index.ts index 1e831979..34210893 100644 --- a/server/api/api/v2/search/index.ts +++ b/server/api/api/v2/search/index.ts @@ -1,4 +1,4 @@ -import { applyConfig, auth, handleZodError } from "@/api"; +import { applyConfig, auth, handleZodError, userAddressValidator } from "@/api"; import { dualLogger } from "@/loggers"; import { MeiliIndexType, meilisearch } from "@/meilisearch"; import { errorResponse, jsonResponse } from "@/response"; @@ -75,9 +75,7 @@ export default (app: Hono) => if (!type || type === "accounts") { // Check if q is matching format username@domain.com or @username@domain.com - const accountMatches = q - ?.trim() - .match(/@?[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)/g); + const accountMatches = q?.trim().match(userAddressValidator); if (accountMatches) { // Remove leading @ if it exists if (accountMatches[0].startsWith("@")) { diff --git a/tests/utils.ts b/tests/utils.ts index 95cc3198..4e82a490 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,5 +1,7 @@ +import { generateChallenge } from "@/challenges"; import { consoleLogger } from "@/loggers"; import { randomString } from "@/math"; +import { solveChallenge } from "altcha-lib"; import { asc, inArray, like } from "drizzle-orm"; import type { Status } from "~/database/entities/status"; import { db } from "~/drizzle/db"; @@ -118,3 +120,34 @@ export const getTestStatuses = async ( ) ).map((n) => n.data); }; + +/** + * Generates a solved challenge (with tiny difficulty) + * + * Only to be used in tests + * @returns Base64 encoded payload + */ +export const getSolvedChallenge = async () => { + const { challenge } = await generateChallenge(100); + + const solution = await solveChallenge( + challenge.challenge, + challenge.salt, + challenge.algorithm, + challenge.maxnumber, + ).promise; + + if (!solution) { + throw new Error("Failed to solve challenge"); + } + + return Buffer.from( + JSON.stringify({ + number: solution.number, + algorithm: challenge.algorithm, + challenge: challenge.challenge, + salt: challenge.salt, + signature: challenge.signature, + }), + ).toString("base64"); +}; diff --git a/types/api.ts b/types/api.ts index 4e33e5f2..03f3e35b 100644 --- a/types/api.ts +++ b/types/api.ts @@ -18,6 +18,12 @@ export interface ApiRouteMetadata { }; oauthPermissions?: string[]; }; + challenge?: { + required: boolean; + methodOverrides?: { + [Key in HttpVerb]?: boolean; + }; + }; permissions?: { required: RolePermissions[]; methodOverrides?: { diff --git a/utils/api.ts b/utils/api.ts index 2a2cfecd..d8320e96 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -1,8 +1,11 @@ import { errorResponse } from "@/response"; +import { extractParams, verifySolution } from "altcha-lib"; import chalk from "chalk"; import { config } from "config-manager"; +import { eq } from "drizzle-orm"; import type { Context } from "hono"; import { createMiddleware } from "hono/factory"; +import type { StatusCode } from "hono/utils/http-status"; import { validator } from "hono/validator"; import { anyOf, @@ -21,6 +24,8 @@ import type { z } from "zod"; import { fromZodError } from "zod-validation-error"; import type { Application } from "~/database/entities/application"; import { type AuthData, getFromHeader } from "~/database/entities/user"; +import { db } from "~/drizzle/db"; +import { Challenges } from "~/drizzle/schema"; import type { User } from "~/packages/database-interface/user"; import { LogLevel, LogManager } from "~/packages/log-manager"; import type { ApiRouteMetadata, HttpVerb } from "~/types/api"; @@ -79,6 +84,18 @@ export const mentionValidator = createRegExp( [global], ); +export const userAddressValidator = createRegExp( + maybe("@"), + oneOrMore(anyOf(letter.lowercase, digit, charIn("-"))).groupedAs( + "username", + ), + maybe( + exactly("@"), + oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs("domain"), + ), + [global], +); + export const webfingerMention = createRegExp( exactly("acct:"), oneOrMore(anyOf(letter, digit, charIn("-"))).groupedAs("username"), @@ -106,6 +123,22 @@ const getAuth = async (value: Record) => { : null; }; +const returnContextError = ( + context: Context, + error: string, + code?: StatusCode, +) => { + const templateError = errorResponse(error, code); + + return context.json( + { + error, + }, + code, + templateError.headers.toJSON(), + ); +}; + const checkPermissions = ( auth: AuthData | null, permissionData: ApiRouteMetadata["permissions"], @@ -118,18 +151,15 @@ const checkPermissions = ( permissionData?.methodOverrides?.[context.req.method as HttpVerb] ?? permissionData?.required ?? []; - const error = errorResponse("Unauthorized", 401); if (!requiredPerms.every((perm) => userPerms.includes(perm))) { const missingPerms = requiredPerms.filter( (perm) => !userPerms.includes(perm), ); - return context.json( - { - error: `You do not have the required permissions to access this route. Missing: ${missingPerms.join(", ")}`, - }, + return returnContextError( + context, + `You do not have the required permissions to access this route. Missing: ${missingPerms.join(", ")}`, 403, - error.headers.toJSON(), ); } }; @@ -139,8 +169,6 @@ const checkRouteNeedsAuth = ( authData: ApiRouteMetadata["auth"], context: Context, ) => { - const error = errorResponse("Unauthorized", 401); - if (auth?.user) { return { user: auth.user as User, @@ -148,23 +176,14 @@ const checkRouteNeedsAuth = ( application: auth.application as Application | null, }; } - if (authData.required) { - return context.json( - { - error: "Unauthorized", - }, + if ( + authData.required || + authData.methodOverrides?.[context.req.method as HttpVerb] + ) { + return returnContextError( + context, + "This route requires authentication.", 401, - error.headers.toJSON(), - ); - } - - if (authData.methodOverrides?.[context.req.method as HttpVerb]) { - return context.json( - { - error: "Unauthorized", - }, - 401, - error.headers.toJSON(), ); } @@ -175,9 +194,80 @@ const checkRouteNeedsAuth = ( }; }; +export const checkRouteNeedsChallenge = async ( + challengeData: ApiRouteMetadata["challenge"], + context: Context, +) => { + if (!challengeData) { + return true; + } + + const challengeSolution = context.req.header("X-Challenge-Solution"); + + if (!challengeSolution) { + return returnContextError( + context, + "This route requires a challenge solution to be sent to it via the X-Challenge-Solution header. Please check the documentation for more information.", + 401, + ); + } + + const { challenge_id } = extractParams(challengeSolution); + + if (!challenge_id) { + return returnContextError( + context, + "The challenge solution provided is invalid.", + 401, + ); + } + + const challenge = await db.query.Challenges.findFirst({ + where: (c, { eq }) => eq(c.id, challenge_id), + }); + + if (!challenge) { + return returnContextError( + context, + "The challenge solution provided is invalid.", + 401, + ); + } + + if (new Date(challenge.expiresAt) < new Date()) { + return returnContextError( + context, + "The challenge provided has expired.", + 401, + ); + } + + const isValid = await verifySolution( + challengeSolution, + config.validation.challenges.key, + ); + + if (!isValid) { + return returnContextError( + context, + "The challenge solution provided is incorrect.", + 401, + ); + } + + // Expire the challenge + await db + .update(Challenges) + .set({ expiresAt: new Date().toISOString() }) + .where(eq(Challenges.id, challenge_id)); + + return true; +}; + export const auth = ( authData: ApiRouteMetadata["auth"], permissionData?: ApiRouteMetadata["permissions"], + challengeData?: ApiRouteMetadata["challenge"], ) => validator("header", async (value, context) => { const auth = await getAuth(value); @@ -194,6 +284,16 @@ export const auth = ( } } + if (challengeData) { + const challengeCheck = await checkRouteNeedsChallenge( + challengeData, + context, + ); + if (challengeCheck !== true) { + return challengeCheck; + } + } + return checkRouteNeedsAuth(auth, authData, context); }); diff --git a/utils/challenges.ts b/utils/challenges.ts new file mode 100644 index 00000000..55d93ab2 --- /dev/null +++ b/utils/challenges.ts @@ -0,0 +1,39 @@ +import { createChallenge } from "altcha-lib"; +import { sql } from "drizzle-orm"; +import { db } from "~/drizzle/db"; +import { Challenges } from "~/drizzle/schema"; +import { config } from "~/packages/config-manager"; + +export const generateChallenge = async ( + maxNumber = config.validation.challenges.difficulty, +) => { + const expirationDate = new Date( + Date.now() + config.validation.challenges.expiration * 1000, + ); + + const uuid = (await db.execute(sql`SELECT uuid_generate_v7()`)) + .rows[0].uuid_generate_v7 as string; + + const challenge = await createChallenge({ + hmacKey: config.validation.challenges.key, + expires: expirationDate, + maxNumber, + algorithm: "SHA-256", + params: { + challenge_id: uuid, + }, + }); + + const result = ( + await db + .insert(Challenges) + .values({ + id: uuid, + challenge, + expiresAt: expirationDate.toISOString(), + }) + .returning() + )[0]; + + return result; +}; diff --git a/utils/response.ts b/utils/response.ts index 8c6f8543..d1376af3 100644 --- a/utils/response.ts +++ b/utils/response.ts @@ -53,9 +53,14 @@ export const errorResponse = (error: string, status = 500) => { ); }; -export const redirect = (url: string | URL, status = 302) => { +export const redirect = ( + url: string | URL, + status = 302, + extraHeaders: Record = {}, +) => { return response(null, status, { Location: url.toString(), + ...extraHeaders, }); };