diff --git a/README.md b/README.md index e469088d..ce31763d 100644 --- a/README.md +++ b/README.md @@ -233,10 +233,10 @@ Working endpoints are: - [ ] `/api/v1/trends/tags` - [ ] `/api/v2/filters/:filter_id/keywords` (`GET`, `POST`) - [ ] `/api/v2/filters/:filter_id/statuses` (`GET`, `POST`) -- [ ] `/api/v2/filters/:id` (`GET`, `PUT`, `DELETE`) +- [x] `/api/v2/filters/:id` (`GET`, `PUT`, `DELETE`) - [ ] `/api/v2/filters/keywords/:id` (`GET`, `PUT`, `DELETE`) - [ ] `/api/v2/filters/statuses/:id` (`GET`, `DELETE`) -- [ ] `/api/v2/filters` (`GET`, `POST`) +- [x] `/api/v2/filters` (`GET`, `POST`) - [x] `/api/v2/instance` - [x] `/api/v2/media` - [x] `/api/v2/search` @@ -248,7 +248,6 @@ Working endpoints are: ### Main work to do - [ ] Announcements -- [ ] Filters - [ ] Polls - [ ] Tags - [ ] Lists diff --git a/bun.lockb b/bun.lockb index 7d085a70..32cfcf0c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/drizzle.config.ts b/drizzle.config.ts index a49f235d..3c43500f 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -6,16 +6,16 @@ export default { out: "./drizzle", schema: "./drizzle/schema.ts", dbCredentials: { - /* host: "localhost", + host: "localhost", port: 40000, user: "lysand", password: "lysand", - database: "lysand", */ - host: config.database.host, + database: "lysand", + /* host: config.database.host, port: Number(config.database.port), user: config.database.username, password: config.database.password, - database: config.database.database, + database: config.database.database, */ }, // Print all statements verbose: true, diff --git a/drizzle/0014_wonderful_sandman.sql b/drizzle/0014_wonderful_sandman.sql new file mode 100644 index 00000000..f6aa1f7d --- /dev/null +++ b/drizzle/0014_wonderful_sandman.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS "FilterKeywords" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v7() NOT NULL, + "filterId" uuid NOT NULL, + "keyword" text NOT NULL, + "whole_word" boolean NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "Filters" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v7() NOT NULL, + "userId" uuid NOT NULL, + "context" text[], + "title" text NOT NULL, + "filter_action" text NOT NULL, + "expires_at" timestamp(3), + "created_at" timestamp(3) DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "FilterKeywords" ADD CONSTRAINT "FilterKeywords_filterId_Filters_id_fk" FOREIGN KEY ("filterId") REFERENCES "Filters"("id") ON DELETE cascade ON UPDATE cascade; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "Filters" ADD CONSTRAINT "Filters_userId_Users_id_fk" FOREIGN KEY ("userId") REFERENCES "Users"("id") ON DELETE cascade ON UPDATE cascade; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle/meta/0014_snapshot.json b/drizzle/meta/0014_snapshot.json new file mode 100644 index 00000000..c68a8ac8 --- /dev/null +++ b/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1965 @@ +{ + "id": "7e3ced41-62d0-4afd-acd8-f988d138fe98", + "prevId": "3a5d3182-563a-4d3f-b3be-70811fae42b2", + "version": "5", + "dialect": "pg", + "tables": { + "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_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "Applications_client_id_index": { + "name": "Applications_client_id_index", + "columns": [ + "client_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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": {} + }, + "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": [ + "emojiId", + "noteId" + ], + "isUnique": true + }, + "EmojiToNote_noteId_index": { + "name": "EmojiToNote_noteId_index", + "columns": [ + "noteId" + ], + "isUnique": false + } + }, + "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": {} + }, + "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": [ + "emojiId", + "userId" + ], + "isUnique": true + }, + "EmojiToUser_userId_index": { + "name": "EmojiToUser_userId_index", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "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": {} + }, + "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 + } + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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": [ + "remote_id" + ], + "isUnique": true + }, + "LysandObject_uri_index": { + "name": "LysandObject_uri_index", + "columns": [ + "uri" + ], + "isUnique": true + } + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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": [ + "noteId", + "userId" + ], + "isUnique": true + }, + "NoteToMentions_userId_index": { + "name": "NoteToMentions_userId_index", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "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": {} + }, + "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": { + "Notes_uri_index": { + "name": "Notes_uri_index", + "columns": [ + "uri" + ], + "isUnique": true + } + }, + "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_applicationId_Applications_id_fk": { + "name": "Notes_applicationId_Applications_id_fk", + "tableFrom": "Notes", + "tableTo": "Applications", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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": "set null", + "onUpdate": "cascade" + }, + "Notes_quoteId_Notes_id_fk": { + "name": "Notes_quoteId_Notes_id_fk", + "tableFrom": "Notes", + "tableTo": "Notes", + "columnsFrom": [ + "quoteId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "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": {} + }, + "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": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "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": [ + "userId", + "noteId" + ], + "isUnique": true + }, + "UserToPinnedNotes_noteId_index": { + "name": "UserToPinnedNotes_noteId_index", + "columns": [ + "noteId" + ], + "isUnique": false + } + }, + "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": {} + }, + "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 + }, + "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": [ + "uri" + ], + "isUnique": true + }, + "Users_username_index": { + "name": "Users_username_index", + "columns": [ + "username" + ], + "isUnique": true + }, + "Users_email_index": { + "name": "Users_email_index", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 44f81811..c76ec305 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,104 +1,111 @@ { - "version": "5", - "dialect": "pg", - "entries": [ - { - "idx": 0, - "version": "5", - "when": 1712805159664, - "tag": "0000_illegal_living_lightning", - "breakpoints": true - }, - { - "idx": 1, - "version": "5", - "when": 1713055774123, - "tag": "0001_salty_night_thrasher", - "breakpoints": true - }, - { - "idx": 2, - "version": "5", - "when": 1713056370431, - "tag": "0002_stiff_ares", - "breakpoints": true - }, - { - "idx": 3, - "version": "5", - "when": 1713056528340, - "tag": "0003_spicy_arachne", - "breakpoints": true - }, - { - "idx": 4, - "version": "5", - "when": 1713056712218, - "tag": "0004_burly_lockjaw", - "breakpoints": true - }, - { - "idx": 5, - "version": "5", - "when": 1713056917973, - "tag": "0005_sleepy_puma", - "breakpoints": true - }, - { - "idx": 6, - "version": "5", - "when": 1713057159867, - "tag": "0006_messy_network", - "breakpoints": true - }, - { - "idx": 7, - "version": "5", - "when": 1713227918208, - "tag": "0007_naive_sleeper", - "breakpoints": true - }, - { - "idx": 8, - "version": "5", - "when": 1713246700119, - "tag": "0008_flawless_brother_voodoo", - "breakpoints": true - }, - { - "idx": 9, - "version": "5", - "when": 1713327832438, - "tag": "0009_easy_slyde", - "breakpoints": true - }, - { - "idx": 10, - "version": "5", - "when": 1713327880929, - "tag": "0010_daffy_frightful_four", - "breakpoints": true - }, - { - "idx": 11, - "version": "5", - "when": 1713333611707, - "tag": "0011_special_the_fury", - "breakpoints": true - }, - { - "idx": 12, - "version": "5", - "when": 1713336108114, - "tag": "0012_certain_thor_girl", - "breakpoints": true - }, - { - "idx": 13, - "version": "5", - "when": 1713336611301, - "tag": "0013_wandering_celestials", - "breakpoints": true - } - ] -} + "version": "5", + "dialect": "pg", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1712805159664, + "tag": "0000_illegal_living_lightning", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1713055774123, + "tag": "0001_salty_night_thrasher", + "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1713056370431, + "tag": "0002_stiff_ares", + "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1713056528340, + "tag": "0003_spicy_arachne", + "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1713056712218, + "tag": "0004_burly_lockjaw", + "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1713056917973, + "tag": "0005_sleepy_puma", + "breakpoints": true + }, + { + "idx": 6, + "version": "5", + "when": 1713057159867, + "tag": "0006_messy_network", + "breakpoints": true + }, + { + "idx": 7, + "version": "5", + "when": 1713227918208, + "tag": "0007_naive_sleeper", + "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1713246700119, + "tag": "0008_flawless_brother_voodoo", + "breakpoints": true + }, + { + "idx": 9, + "version": "5", + "when": 1713327832438, + "tag": "0009_easy_slyde", + "breakpoints": true + }, + { + "idx": 10, + "version": "5", + "when": 1713327880929, + "tag": "0010_daffy_frightful_four", + "breakpoints": true + }, + { + "idx": 11, + "version": "5", + "when": 1713333611707, + "tag": "0011_special_the_fury", + "breakpoints": true + }, + { + "idx": 12, + "version": "5", + "when": 1713336108114, + "tag": "0012_certain_thor_girl", + "breakpoints": true + }, + { + "idx": 13, + "version": "5", + "when": 1713336611301, + "tag": "0013_wandering_celestials", + "breakpoints": true + }, + { + "idx": 14, + "version": "5", + "when": 1713389937821, + "tag": "0014_wonderful_sandman", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 5aedb237..c92d3b90 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -26,6 +26,50 @@ export const Emojis = pgTable("Emojis", { }), }); +export const Filters = pgTable("Filters", { + id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(), + userId: uuid("userId") + .notNull() + .references(() => Users.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + context: text("context") + .array() + .$type< + ("home" | "notifications" | "public" | "thread" | "account")[] + >(), + title: text("title").notNull(), + filterAction: text("filter_action").notNull().$type<"warn" | "hide">(), + expireAt: timestamp("expires_at", { precision: 3, mode: "string" }), + createdAt: timestamp("created_at", { precision: 3, mode: "string" }) + .defaultNow() + .notNull(), +}); + +export const FilterKeywords = pgTable("FilterKeywords", { + id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(), + filterId: uuid("filterId") + .notNull() + .references(() => Filters.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + keyword: text("keyword").notNull(), + wholeWord: boolean("whole_word").notNull(), +}); + +export const FilterRelations = relations(Filters, ({ many }) => ({ + keywords: many(FilterKeywords), +})); + +export const FilterKeywordsRelations = relations(FilterKeywords, ({ one }) => ({ + filter: one(Filters, { + fields: [FilterKeywords.filterId], + references: [Filters.id], + }), +})); + export const Markers = pgTable("Markers", { id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(), noteId: uuid("noteId").references(() => Notes.id, { diff --git a/packages/request-parser/index.ts b/packages/request-parser/index.ts index b0740abb..d45eee98 100644 --- a/packages/request-parser/index.ts +++ b/packages/request-parser/index.ts @@ -1,9 +1,4 @@ -/** - * RequestParser - * @file index.ts - * @module request-parser - * @description Parses Request object into a JavaScript object based on the content type - */ +import { parse } from "qs"; /** * RequestParser @@ -98,19 +93,38 @@ export class RequestParser { const formData = await this.request.formData(); const result: Partial = {}; - for (const [key, value] of formData.entries()) { - if (value instanceof Blob) { - result[key as keyof T] = value as T[keyof T]; - } else if (key.endsWith("[]")) { - const arrayKey = key.slice(0, -2) as keyof T; - if (!result[arrayKey]) { - result[arrayKey] = [] as T[keyof T]; - } + // Check if there are any files in the FormData + if ( + Array.from(formData.values()).some((value) => value instanceof Blob) + ) { + for (const [key, value] of formData.entries()) { + if (value instanceof Blob) { + result[key as keyof T] = value as T[keyof T]; + } else if (key.endsWith("[]")) { + const arrayKey = key.slice(0, -2) as keyof T; + if (!result[arrayKey]) { + result[arrayKey] = [] as T[keyof T]; + } - (result[arrayKey] as FormDataEntryValue[]).push(value); - } else { - result[key as keyof T] = value as T[keyof T]; + (result[arrayKey] as FormDataEntryValue[]).push(value); + } else { + result[key as keyof T] = value as T[keyof T]; + } } + } else { + // Convert to URLSearchParams and parse as query + const searchParams = new URLSearchParams([ + ...formData.entries(), + ] as [string, string][]); + + const parsed = parse(searchParams.toString(), { + parseArrays: true, + interpretNumericEntities: true, + }); + + return castBooleanObject( + parsed as PossiblyRecursiveObject, + ) as Partial; } return result; @@ -159,29 +173,49 @@ export class RequestParser { * @returns JavaScript object of type T */ private parseQuery(): Partial { - const result: Partial = {}; - const url = new URL(this.request.url); + const parsed = parse( + new URL(this.request.url).searchParams.toString(), + { + parseArrays: true, + interpretNumericEntities: true, + }, + ); - for (const [key, value] of url.searchParams.entries()) { - if (decodeURIComponent(key).endsWith("[]")) { - const arrayKey = decodeURIComponent(key).slice( - 0, - -2, - ) as keyof T; - if (!result[arrayKey]) { - result[arrayKey] = [] as T[keyof T]; - } - (result[arrayKey] as string[]).push(decodeURIComponent(value)); - } else { - result[key as keyof T] = castBoolean( - decodeURIComponent(value), - ) as T[keyof T]; - } - } - return result; + return castBooleanObject( + parsed as PossiblyRecursiveObject, + ) as Partial; } } +interface PossiblyRecursiveObject { + [key: string]: + | PossiblyRecursiveObject[] + | PossiblyRecursiveObject + | string + | string[] + | boolean; +} + +// Recursive +const castBooleanObject = (value: PossiblyRecursiveObject | string) => { + if (typeof value === "string") { + return castBoolean(value); + } + + for (const key in value) { + const child = value[key]; + if (Array.isArray(child)) { + value[key] = child.map((v) => castBooleanObject(v)) as string[]; + } else if (typeof child === "object") { + value[key] = castBooleanObject(child); + } else { + value[key] = castBoolean(child as string); + } + } + + return value; +}; + const castBoolean = (value: string) => { if (["true"].includes(value)) { return true; diff --git a/packages/request-parser/package.json b/packages/request-parser/package.json index 2f2d3fdb..22afee91 100644 --- a/packages/request-parser/package.json +++ b/packages/request-parser/package.json @@ -1,6 +1,9 @@ { - "name": "request-parser", - "version": "0.0.0", - "main": "index.ts", - "dependencies": {} + "name": "request-parser", + "version": "0.0.0", + "main": "index.ts", + "dependencies": { "qs": "^6.12.1" }, + "devDependencies": { + "@types/qs": "^6.9.15" + } } diff --git a/packages/request-parser/tests/request-parser.test.ts b/packages/request-parser/tests/request-parser.test.ts index bfa82ea4..e724b9da 100644 --- a/packages/request-parser/tests/request-parser.test.ts +++ b/packages/request-parser/tests/request-parser.test.ts @@ -24,6 +24,28 @@ describe("RequestParser", () => { expect(result.test).toEqual(["value1", "value2"]); }); + test("With Array of objects", async () => { + const request = new Request( + "http://localhost?test[][key]=value1&test[][value]=value2", + ); + const result = await new RequestParser(request).toObject<{ + test: { key: string; value: string }[]; + }>(); + expect(result.test).toEqual([{ key: "value1", value: "value2" }]); + }); + + test("With Array of multiple objects", async () => { + const request = new Request( + "http://localhost?test[][key]=value1&test[][value]=value2&test[][key]=value3&test[][value]=value4", + ); + const result = await new RequestParser(request).toObject<{ + test: { key: string[]; value: string[] }[]; + }>(); + expect(result.test).toEqual([ + { key: ["value1", "value3"], value: ["value2", "value4"] }, + ]); + }); + test("With both at once", async () => { const request = new Request( "http://localhost?param1=value1¶m2=value2&test[]=value1&test[]=value2", diff --git a/packages/server-handler/index.ts b/packages/server-handler/index.ts index b3eedf0e..fab64556 100644 --- a/packages/server-handler/index.ts +++ b/packages/server-handler/index.ts @@ -86,14 +86,12 @@ export const processRoute = async ( return errorResponse("Method not allowed", 405); } - let auth: AuthData | null = null; + const auth: AuthData = await getFromRequest(request); if ( route.meta.auth.required || route.meta.auth.requiredOnMethods?.includes(request.method as HttpVerb) ) { - auth = await getFromRequest(request); - if (!auth.user) { return errorResponse( "Unauthorized: access to this method requires an authenticated user", @@ -112,7 +110,7 @@ export const processRoute = async ( } } - const parsedRequest = await new RequestParser(request) + const parsedRequest = await new RequestParser(request.clone()) .toObject() .catch(async (err) => { await logger.logError( diff --git a/server/api/api/v1/notifications/index.test.ts b/server/api/api/v1/notifications/index.test.ts index 0c0a0ee3..ea0350df 100644 --- a/server/api/api/v1/notifications/index.test.ts +++ b/server/api/api/v1/notifications/index.test.ts @@ -107,4 +107,71 @@ describe(meta.route, () => { ); } }); + + test("should not return notifications with filtered keywords", async () => { + const formData = new FormData(); + + formData.append("title", "Test Filter"); + formData.append("context[]", "notifications"); + formData.append("filter_action", "hide"); + formData.append( + "keywords_attributes[0][keyword]", + timeline[0].content.slice(4, 20), + ); + formData.append("keywords_attributes[0][whole_word]", "false"); + + const filterResponse = await sendTestRequest( + new Request(new URL("/api/v2/filters", config.http.base_url), { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: formData, + }), + ); + + expect(filterResponse.status).toBe(200); + + const response = await sendTestRequest( + new Request( + new URL(`${meta.route}?limit=20`, config.http.base_url), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const objects = (await response.json()) as APINotification[]; + + expect(objects.length).toBe(3); + // There should be no element with a status with id of timeline[0].id + expect(objects).not.toContainEqual( + expect.objectContaining({ + status: expect.objectContaining({ id: timeline[0].id }), + }), + ); + + // Delete filter + const filterDeleteResponse = await sendTestRequest( + new Request( + new URL( + `/api/v2/filters/${(await filterResponse.json()).id}`, + config.http.base_url, + ), + { + method: "DELETE", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(filterDeleteResponse.status).toBe(200); + }); }); diff --git a/server/api/api/v1/notifications/index.ts b/server/api/api/v1/notifications/index.ts index f26c0f38..b3e44079 100644 --- a/server/api/api/v1/notifications/index.ts +++ b/server/api/api/v1/notifications/index.ts @@ -1,6 +1,7 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; +import { sql } from "drizzle-orm"; import { z } from "zod"; import { findManyNotifications, @@ -127,6 +128,24 @@ export default apiRoute( exclude_types ? not(inArray(notification.type, exclude_types)) : undefined, + // Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId) + // Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE) + // Filters table has a userId and a context which is an array + sql`NOT EXISTS ( + SELECT 1 + FROM "Filters" + WHERE "Filters"."userId" = ${user.id} + AND "Filters"."filter_action" = 'hide' + AND EXISTS ( + SELECT 1 + FROM "FilterKeywords", "Notifications" as "n_inner", "Notes" + WHERE "FilterKeywords"."filterId" = "Filters"."id" + AND "n_inner"."noteId" = "Notes"."id" + AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%' + AND "n_inner"."id" = "Notifications"."id" + ) + AND "Filters"."context" @> ARRAY['notifications'] + )`, ), limit, // @ts-expect-error Yes I KNOW the types are wrong diff --git a/server/api/api/v1/timelines/home.test.ts b/server/api/api/v1/timelines/home.test.ts index f8f5370c..30fa6320 100644 --- a/server/api/api/v1/timelines/home.test.ts +++ b/server/api/api/v1/timelines/home.test.ts @@ -172,5 +172,72 @@ describe(meta.route, () => { expect(status.id).toBe(timeline[index].id); } }); + + test("should not return statuses with filtered keywords", async () => { + const formData = new FormData(); + + formData.append("title", "Test Filter"); + formData.append("context[]", "home"); + formData.append("filter_action", "hide"); + formData.append( + "keywords_attributes[0][keyword]", + timeline[0].content.slice(4, 20), + ); + formData.append("keywords_attributes[0][whole_word]", "false"); + + const filterResponse = await sendTestRequest( + new Request(new URL("/api/v2/filters", config.http.base_url), { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: formData, + }), + ); + + expect(filterResponse.status).toBe(200); + + const response = await sendTestRequest( + new Request( + new URL(`${meta.route}?limit=20`, config.http.base_url), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const objects = (await response.json()) as APIStatus[]; + + expect(objects.length).toBe(20); + // There should be no element with id of timeline[0].id + expect(objects).not.toContainEqual( + expect.objectContaining({ id: timeline[0].id }), + ); + + // Delete filter + const filterDeleteResponse = await sendTestRequest( + new Request( + new URL( + `/api/v2/filters/${(await filterResponse.json()).id}`, + config.http.base_url, + ), + { + method: "DELETE", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(filterDeleteResponse.status).toBe(200); + }); }); }); diff --git a/server/api/api/v1/timelines/home.ts b/server/api/api/v1/timelines/home.ts index 9b4be191..e7f5b623 100644 --- a/server/api/api/v1/timelines/home.ts +++ b/server/api/api/v1/timelines/home.ts @@ -49,6 +49,10 @@ export default apiRoute( // WHERE format (... = ...) sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`, ), + // Don't show statuses that have filtered words in them + // Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE) + // Filters table has a userId and a context which is an array + sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['home'])`, ), limit, req.url, diff --git a/server/api/api/v1/timelines/public.test.ts b/server/api/api/v1/timelines/public.test.ts index 902d5213..e8814eba 100644 --- a/server/api/api/v1/timelines/public.test.ts +++ b/server/api/api/v1/timelines/public.test.ts @@ -218,4 +218,69 @@ describe(meta.route, () => { } }); }); + + test("should not return statuses with filtered keywords", async () => { + const formData = new FormData(); + + formData.append("title", "Test Filter"); + formData.append("context[]", "public"); + formData.append("filter_action", "hide"); + formData.append( + "keywords_attributes[0][keyword]", + timeline[0].content.slice(4, 20), + ); + formData.append("keywords_attributes[0][whole_word]", "false"); + + const filterResponse = await sendTestRequest( + new Request(new URL("/api/v2/filters", config.http.base_url), { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: formData, + }), + ); + + expect(filterResponse.status).toBe(200); + + const response = await sendTestRequest( + new Request( + new URL(`${meta.route}?limit=20`, config.http.base_url), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const objects = (await response.json()) as APIStatus[]; + + expect(objects.length).toBe(20); + // There should be no element with id of timeline[0].id + expect(objects).not.toContainEqual( + expect.objectContaining({ id: timeline[0].id }), + ); + + // Delete filter + const filterDeleteResponse = await sendTestRequest( + new Request( + new URL( + `/api/v2/filters/${(await filterResponse.json()).id}`, + config.http.base_url, + ), + { + method: "DELETE", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(filterDeleteResponse.status).toBe(200); + }); }); diff --git a/server/api/api/v1/timelines/public.ts b/server/api/api/v1/timelines/public.ts index 44a027cb..ed0886ba 100644 --- a/server/api/api/v1/timelines/public.ts +++ b/server/api/api/v1/timelines/public.ts @@ -52,6 +52,9 @@ export default apiRoute( only_media ? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})` : undefined, + user + ? sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['public'])` + : undefined, ), limit, req.url, diff --git a/server/api/api/v2/filters/[id]/index.test.ts b/server/api/api/v2/filters/[id]/index.test.ts new file mode 100644 index 00000000..6526f4bb --- /dev/null +++ b/server/api/api/v2/filters/[id]/index.test.ts @@ -0,0 +1,203 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { config } from "config-manager"; +import { getTestUsers, sendTestRequest } from "~tests/utils"; +import { meta } from "./index"; + +const { users, tokens, deleteUsers } = await getTestUsers(2); + +const formData = new FormData(); + +formData.append("title", "Test Filter"); +formData.append("context[]", "home"); +formData.append("filter_action", "warn"); +formData.append("expires_in", "86400"); +formData.append("keywords_attributes[0][keyword]", "test"); +formData.append("keywords_attributes[0][whole_word]", "true"); + +const response = await sendTestRequest( + new Request(new URL("/api/v2/filters", config.http.base_url), { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: formData, + }), +); + +expect(response.status).toBe(200); + +const filter = await response.json(); +expect(filter).toBeObject(); + +afterAll(async () => { + await deleteUsers(); +}); + +// /api/v2/filters/:id +describe(meta.route, () => { + test("should return 401 if not authenticated", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url)), + ); + + expect(response.status).toBe(401); + }); + + test("should get that filter", async () => { + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", filter.id), + config.http.base_url, + ), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); + + const json = await response.json(); + expect(json).toBeObject(); + expect(json).toContainKeys(["id", "title"]); + expect(json.title).toBe("Test Filter"); + expect(json.context).toEqual(["home"]); + expect(json.filter_action).toBe("warn"); + expect(json.expires_at).toBeString(); + expect(json.keywords).toBeArray(); + expect(json.keywords).not.toBeEmpty(); + expect(json.keywords[0]).toContainKeys(["keyword", "whole_word"]); + expect(json.keywords[0].keyword).toEqual("test"); + }); + + test("should edit that filter", async () => { + const formData = new FormData(); + + formData.append("title", "New Filter"); + formData.append("context[]", "notifications"); + formData.append("filter_action", "hide"); + formData.append("expires_in", "86400"); + formData.append("keywords_attributes[0][keyword]", "new"); + formData.append("keywords_attributes[0][id]", filter.keywords[0].id); + formData.append("keywords_attributes[0][whole_word]", "false"); + + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", filter.id), + config.http.base_url, + ), + { + method: "PUT", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: formData, + }, + ), + ); + + expect(response.status).toBe(200); + + const json = await response.json(); + expect(json).toBeObject(); + expect(json).toContainKeys(["id", "title"]); + expect(json.title).toBe("New Filter"); + expect(json.context).toEqual(["notifications"]); + expect(json.filter_action).toBe("hide"); + expect(json.expires_at).toBeString(); + expect(json.keywords).toBeArray(); + expect(json.keywords).not.toBeEmpty(); + expect(json.keywords[0]).toContainKeys(["keyword", "whole_word"]); + expect(json.keywords[0].keyword).toEqual("new"); + }); + + test("should delete keyword", async () => { + const formData = new FormData(); + + formData.append("keywords_attributes[0][id]", filter.keywords[0].id); + formData.append("keywords_attributes[0][_destroy]", "true"); + + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", filter.id), + config.http.base_url, + ), + { + method: "PUT", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: formData, + }, + ), + ); + + expect(response.status).toBe(200); + + const json = await response.json(); + expect(json).toBeObject(); + expect(json.keywords).toBeEmpty(); + + // Get the filter again and check + const getResponse = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", filter.id), + config.http.base_url, + ), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(getResponse.status).toBe(200); + expect((await getResponse.json()).keywords).toBeEmpty(); + }); + + test("should delete filter", async () => { + const formData = new FormData(); + + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", filter.id), + config.http.base_url, + ), + { + method: "DELETE", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: formData, + }, + ), + ); + + expect(response.status).toBe(200); + + // Try to GET the filter again + const getResponse = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", filter.id), + config.http.base_url, + ), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(getResponse.status).toBe(404); + }); +}); diff --git a/server/api/api/v2/filters/[id]/index.ts b/server/api/api/v2/filters/[id]/index.ts new file mode 100644 index 00000000..0998c359 --- /dev/null +++ b/server/api/api/v2/filters/[id]/index.ts @@ -0,0 +1,178 @@ +import { apiRoute, applyConfig, idValidator } from "@api"; +import { errorResponse, jsonResponse } from "@response"; +import { and, eq, inArray, type InferSelectModel } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "~drizzle/db"; +import { FilterKeywords, Filters } from "~drizzle/schema"; + +export const meta = applyConfig({ + allowedMethods: ["GET", "PUT", "DELETE"], + route: "/api/v2/filters/:id", + ratelimits: { + max: 60, + duration: 60, + }, + auth: { + required: true, + }, +}); + +export const schema = z.object({ + title: z.string().min(1).max(100).optional(), + context: z + .array(z.enum(["home", "notifications", "public", "thread", "account"])) + .optional(), + filter_action: z.enum(["warn", "hide"]).optional().default("warn"), + expires_in: z.coerce + .number() + .int() + .min(60) + .max(60 * 60 * 24 * 365 * 5) + .optional(), + keywords_attributes: z + .array( + z.object({ + keyword: z.string().min(1).max(100).optional(), + id: z.string().regex(idValidator).optional(), + whole_word: z.boolean().optional(), + _destroy: z.boolean().optional(), + }), + ) + .optional(), +}); + +export default apiRoute( + async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; + const id = matchedRoute.params.id; + if (!id.match(idValidator)) return errorResponse("Invalid ID", 400); + + if (!user) return errorResponse("Unauthorized", 401); + + const userFilter = await db.query.Filters.findFirst({ + where: (filter, { eq, and }) => + and(eq(filter.userId, user.id), eq(filter.id, id)), + with: { + keywords: true, + }, + }); + + if (!userFilter) return errorResponse("Filter not found", 404); + + switch (req.method) { + case "GET": { + return jsonResponse({ + id: userFilter.id, + title: userFilter.title, + context: userFilter.context, + expires_at: userFilter.expireAt + ? new Date(userFilter.expireAt).toISOString() + : null, + filter_action: userFilter.filterAction, + keywords: userFilter.keywords.map((keyword) => ({ + id: keyword.id, + keyword: keyword.keyword, + whole_word: keyword.wholeWord, + })), + statuses: [], + }); + } + case "PUT": { + const { + title, + context, + filter_action, + expires_in, + keywords_attributes, + } = extraData.parsedRequest; + + await db + .update(Filters) + .set({ + title, + context, + filterAction: filter_action, + expireAt: new Date( + Date.now() + (expires_in ?? 0), + ).toISOString(), + }) + .where( + and(eq(Filters.userId, user.id), eq(Filters.id, id)), + ); + + const toUpdate = keywords_attributes + ?.filter((keyword) => keyword.id && !keyword._destroy) + .map((keyword) => ({ + keyword: keyword.keyword, + wholeWord: keyword.whole_word ?? false, + id: keyword.id, + })); + + const toDelete = keywords_attributes + ?.filter((keyword) => keyword._destroy && keyword.id) + .map((keyword) => keyword.id ?? ""); + + if (toUpdate && toUpdate.length > 0) { + for (const keyword of toUpdate) { + await db + .update(FilterKeywords) + .set(keyword) + .where( + and( + eq(FilterKeywords.filterId, id), + eq(FilterKeywords.id, keyword.id ?? ""), + ), + ); + } + } + + if (toDelete && toDelete.length > 0) { + await db + .delete(FilterKeywords) + .where( + and( + eq(FilterKeywords.filterId, id), + inArray(FilterKeywords.id, toDelete), + ), + ); + } + + const updatedFilter = await db.query.Filters.findFirst({ + where: (filter, { eq, and }) => + and(eq(filter.userId, user.id), eq(filter.id, id)), + with: { + keywords: true, + }, + }); + + if (!updatedFilter) + return errorResponse("Failed to update filter", 500); + + return jsonResponse({ + id: updatedFilter.id, + title: updatedFilter.title, + context: updatedFilter.context, + expires_at: updatedFilter.expireAt + ? new Date(updatedFilter.expireAt).toISOString() + : null, + filter_action: updatedFilter.filterAction, + keywords: updatedFilter.keywords.map((keyword) => ({ + id: keyword.id, + keyword: keyword.keyword, + whole_word: keyword.wholeWord, + })), + statuses: [], + }); + } + case "DELETE": { + await db + .delete(Filters) + .where( + and(eq(Filters.userId, user.id), eq(Filters.id, id)), + ); + + return jsonResponse({}); + } + } + }, +); diff --git a/server/api/api/v2/filters/index.test.ts b/server/api/api/v2/filters/index.test.ts new file mode 100644 index 00000000..cac5d44c --- /dev/null +++ b/server/api/api/v2/filters/index.test.ts @@ -0,0 +1,72 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { config } from "config-manager"; +import { getTestUsers, sendTestRequest } from "~tests/utils"; +import { meta } from "./index"; + +const { users, tokens, deleteUsers } = await getTestUsers(2); + +afterAll(async () => { + await deleteUsers(); +}); + +// /api/v2/filters +describe(meta.route, () => { + test("should return 401 if not authenticated", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url)), + ); + + expect(response.status).toBe(401); + }); + + test("should return user filters (none)", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }), + ); + + expect(response.status).toBe(200); + + const json = await response.json(); + expect(json).toBeArray(); + expect(json).toBeEmpty(); + }); + + test("should create a new filter", async () => { + const formData = new FormData(); + + formData.append("title", "Test Filter"); + formData.append("context[]", "home"); + formData.append("filter_action", "warn"); + formData.append("expires_in", "86400"); + formData.append("keywords_attributes[0][keyword]", "test"); + formData.append("keywords_attributes[0][whole_word]", "true"); + + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: formData, + }), + ); + + expect(response.status).toBe(200); + + const json = await response.json(); + expect(json).toBeObject(); + expect(json).toContainKeys(["id", "title"]); + expect(json.title).toBe("Test Filter"); + expect(json.context).toEqual(["home"]); + expect(json.filter_action).toBe("warn"); + expect(json.expires_at).toBeString(); + expect(json.keywords).toBeArray(); + expect(json.keywords).not.toBeEmpty(); + expect(json.keywords[0]).toContainKeys(["keyword", "whole_word"]); + expect(json.keywords[0].keyword).toEqual("test"); + }); +}); diff --git a/server/api/api/v2/filters/index.ts b/server/api/api/v2/filters/index.ts new file mode 100644 index 00000000..b5dd0698 --- /dev/null +++ b/server/api/api/v2/filters/index.ts @@ -0,0 +1,155 @@ +import { apiRoute, applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; +import type { InferSelectModel } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "~drizzle/db"; +import { FilterKeywords, Filters } from "~drizzle/schema"; + +export const meta = applyConfig({ + allowedMethods: ["GET", "POST"], + route: "/api/v2/filters", + ratelimits: { + max: 60, + duration: 60, + }, + auth: { + required: true, + }, +}); + +export const schema = z.object({ + title: z.string().min(1).max(100).optional(), + context: z + .array(z.enum(["home", "notifications", "public", "thread", "account"])) + .optional(), + filter_action: z.enum(["warn", "hide"]).optional().default("warn"), + expires_in: z.coerce + .number() + .int() + .min(60) + .max(60 * 60 * 24 * 365 * 5) + .optional(), + keywords_attributes: z + .array( + z.object({ + keyword: z.string().min(1).max(100), + whole_word: z.boolean().optional(), + }), + ) + .optional(), +}); + +export default apiRoute( + async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; + + if (!user) return errorResponse("Unauthorized", 401); + + switch (req.method) { + case "GET": { + const userFilters = await db.query.Filters.findMany({ + where: (filter, { eq }) => eq(filter.userId, user.id), + with: { + keywords: true, + }, + }); + + return jsonResponse( + userFilters.map((filter) => ({ + id: filter.id, + title: filter.title, + context: filter.context, + expires_at: filter.expireAt + ? new Date( + Date.now() + filter.expireAt, + ).toISOString() + : null, + filter_action: filter.filterAction, + keywords: filter.keywords.map((keyword) => ({ + id: keyword.id, + keyword: keyword.keyword, + whole_word: keyword.wholeWord, + })), + statuses: [], + })), + ); + } + case "POST": { + const { + title, + context, + filter_action, + expires_in, + keywords_attributes, + } = extraData.parsedRequest; + + if (!title || context?.length === 0) { + return errorResponse( + "Missing required fields (title and context)", + 422, + ); + } + + const newFilter = ( + await db + .insert(Filters) + .values({ + title: title ?? "", + context: context ?? [], + filterAction: filter_action, + expireAt: new Date( + Date.now() + (expires_in ?? 0), + ).toISOString(), + userId: user.id, + }) + .returning() + )[0]; + + if (!newFilter) + return errorResponse("Failed to create filter", 500); + + const insertedKeywords = + keywords_attributes && keywords_attributes.length > 0 + ? await db + .insert(FilterKeywords) + .values( + keywords_attributes?.map((keyword) => ({ + filterId: newFilter.id, + keyword: keyword.keyword, + wholeWord: keyword.whole_word ?? false, + })) ?? [], + ) + .returning() + : []; + + return jsonResponse({ + id: newFilter.id, + title: newFilter.title, + context: newFilter.context, + expires_at: expires_in + ? new Date(Date.now() + expires_in).toISOString() + : null, + filter_action: newFilter.filterAction, + keywords: insertedKeywords.map((keyword) => ({ + id: keyword.id, + keyword: keyword.keyword, + whole_word: keyword.wholeWord, + })), + statuses: [], + } as { + id: string; + title: string; + context: string[]; + expires_at: string; + filter_action: "warn" | "hide"; + keywords: { + id: string; + keyword: string; + whole_word: boolean; + }[]; + statuses: []; + }); + } + } + }, +);