From af0d627f19c3626871231ab42fb676173f3ab6cc Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sat, 6 Apr 2024 19:30:49 -1000 Subject: [PATCH] Replace eslint and prettier with Biome --- .eslintrc.cjs | 40 +- .vscode/launch.json | 7 +- .vscode/settings.json | 6 +- benchmarks/fetch.ts | 10 +- benchmarks/timelines.ts | 68 +- biome.json | 33 +- build.ts | 52 +- bun.lockb | Bin 437688 -> 330080 bytes classes/activitypub.ts | 64 - cli.ts | 3540 +++++----- database/datasource.ts | 2 +- database/entities/Application.ts | 32 +- database/entities/Attachment.ts | 115 +- database/entities/Emoji.ts | 124 +- database/entities/Instance.ts | 54 +- database/entities/Like.ts | 110 +- database/entities/Notification.ts | 24 +- database/entities/Object.ts | 114 +- database/entities/Queue.ts | 102 +- database/entities/Relationship.ts | 116 +- database/entities/Status.ts | 867 ++- database/entities/Token.ts | 2 +- database/entities/User.ts | 678 +- database/entities/relations.ts | 208 +- index.ts | 56 +- package.json | 244 +- packages/cli-parser/cli-builder.type.ts | 36 +- packages/cli-parser/index.ts | 725 +- packages/cli-parser/package.json | 10 +- packages/cli-parser/tests/cli-builder.test.ts | 875 +-- packages/config-manager/config.type.ts | 945 +-- packages/config-manager/index.ts | 20 +- packages/config-manager/package.json | 10 +- packages/log-manager/index.ts | 292 +- packages/log-manager/package.json | 4 +- .../log-manager/tests/log-manager.test.ts | 362 +- packages/media-manager/backends/local.ts | 95 +- packages/media-manager/backends/s3.ts | 111 +- packages/media-manager/index.ts | 164 +- packages/media-manager/media-converter.ts | 136 +- packages/media-manager/package.json | 16 +- .../tests/media-backends.test.ts | 455 +- .../media-manager/tests/media-manager.test.ts | 104 +- packages/protocol-translator/index.ts | 65 +- packages/protocol-translator/package.json | 16 +- .../protocols/activitypub.ts | 10 +- packages/request-parser/index.ts | 274 +- packages/request-parser/package.json | 2 +- .../tests/request-parser.test.ts | 290 +- pages/App.vue | 2 +- pages/components/LoginInput.vue | 4 +- pages/main.ts | 8 +- pages/pages/index.vue | 2 +- pages/pages/oauth/authorize.vue | 29 +- pages/pages/oauth/redirect.vue | 54 +- pages/pages/register/index.vue | 54 +- pages/routes.ts | 10 +- pages/vite.config.ts | 60 +- plugins/test.plugin.ts | 10 +- plugins/types.ts | 280 +- prisma.ts | 2 +- routes.ts | 188 +- server.ts | 427 +- server/api/.well-known/host-meta/index.ts | 27 +- server/api/.well-known/lysand.ts | 62 +- server/api/.well-known/nodeinfo/index.ts | 33 +- server/api/.well-known/webfinger/index.ts | 104 +- server/api/[...404].ts | 20 +- server/api/api/v1/accounts/[id]/block.ts | 112 +- server/api/api/v1/accounts/[id]/follow.ts | 144 +- server/api/api/v1/accounts/[id]/followers.ts | 122 +- server/api/api/v1/accounts/[id]/following.ts | 122 +- server/api/api/v1/accounts/[id]/index.ts | 58 +- server/api/api/v1/accounts/[id]/mute.ts | 130 +- server/api/api/v1/accounts/[id]/note.ts | 112 +- server/api/api/v1/accounts/[id]/pin.ts | 112 +- .../v1/accounts/[id]/remove_from_followers.ts | 138 +- server/api/api/v1/accounts/[id]/statuses.ts | 222 +- server/api/api/v1/accounts/[id]/unblock.ts | 112 +- server/api/api/v1/accounts/[id]/unfollow.ts | 112 +- server/api/api/v1/accounts/[id]/unmute.ts | 114 +- server/api/api/v1/accounts/[id]/unpin.ts | 112 +- .../v1/accounts/familiar_followers/index.ts | 96 +- server/api/api/v1/accounts/index.ts | 346 +- .../api/v1/accounts/relationships/index.ts | 91 +- server/api/api/v1/accounts/search/index.ts | 116 +- .../v1/accounts/update_credentials/index.ts | 411 +- .../v1/accounts/verify_credentials/index.ts | 34 +- server/api/api/v1/apps/index.ts | 98 +- .../api/v1/apps/verify_credentials/index.ts | 40 +- server/api/api/v1/blocks/index.ts | 50 +- server/api/api/v1/custom_emojis/index.ts | 34 +- server/api/api/v1/favourites/index.ts | 116 +- .../follow_requests/[account_id]/authorize.ts | 108 +- .../v1/follow_requests/[account_id]/reject.ts | 86 +- server/api/api/v1/follow_requests/index.ts | 114 +- server/api/api/v1/instance/index.ts | 282 +- server/api/api/v1/media/[id]/index.ts | 155 +- server/api/api/v1/media/index.ts | 194 +- server/api/api/v1/mutes/index.ts | 50 +- server/api/api/v1/notifications/index.ts | 164 +- server/api/api/v1/profile/avatar.ts | 44 +- server/api/api/v1/profile/header.ts | 44 +- server/api/api/v1/statuses/[id]/context.ts | 64 +- server/api/api/v1/statuses/[id]/favourite.ts | 66 +- .../api/api/v1/statuses/[id]/favourited_by.ts | 158 +- server/api/api/v1/statuses/[id]/index.ts | 344 +- server/api/api/v1/statuses/[id]/pin.ts | 72 +- server/api/api/v1/statuses/[id]/reblog.ts | 140 +- .../api/api/v1/statuses/[id]/reblogged_by.ts | 160 +- server/api/api/v1/statuses/[id]/source.ts | 40 +- .../api/api/v1/statuses/[id]/unfavourite.ts | 50 +- server/api/api/v1/statuses/[id]/unpin.ts | 72 +- server/api/api/v1/statuses/[id]/unreblog.ts | 72 +- server/api/api/v1/statuses/index.ts | 385 +- server/api/api/v1/timelines/home.ts | 156 +- server/api/api/v1/timelines/public.ts | 144 +- server/api/api/v2/media/index.ts | 218 +- server/api/api/v2/search/index.ts | 228 +- server/api/auth/login/index.ts | 158 +- server/api/auth/redirect/index.ts | 81 +- server/api/media/[id]/index.ts | 64 +- server/api/nodeinfo/2.0/index.ts | 44 +- server/api/oauth/authorize-external/index.ts | 132 +- server/api/oauth/callback/[issuer]/index.ts | 320 +- server/api/oauth/providers/index.ts | 34 +- server/api/oauth/token/index.ts | 92 +- server/api/object/[uuid]/index.ts | 20 +- server/api/routes.type.ts | 18 +- server/api/users/[uuid]/inbox/index.ts | 655 +- server/api/users/[uuid]/index.ts | 40 +- server/api/users/[uuid]/outbox/index.ts | 100 +- tests/api.test.ts | 256 +- tests/api/accounts.test.ts | 1458 ++-- tests/api/statuses.test.ts | 902 +-- tests/cli.skip-test.ts | 154 +- tests/oauth-scopes.test.ts | 198 +- tests/oauth.test.ts | 232 +- tests/utils.ts | 4 +- tsconfig.json | 80 +- types.d.ts | 42 +- types/activitypub.ts | 212 +- types/api.ts | 22 +- types/entities/account.ts | 56 +- types/entities/activity.ts | 8 +- types/entities/announcement.ts | 50 +- types/entities/application.ts | 6 +- types/entities/async_attachment.ts | 18 +- types/entities/attachment.ts | 68 +- types/entities/card.ts | 28 +- types/entities/context.ts | 4 +- types/entities/conversation.ts | 8 +- types/entities/emoji.ts | 10 +- types/entities/featured_tag.ts | 8 +- types/entities/field.ts | 6 +- types/entities/filter.ts | 12 +- types/entities/history.ts | 6 +- types/entities/identity_proof.ts | 10 +- types/entities/instance.ts | 78 +- types/entities/list.ts | 6 +- types/entities/marker.ts | 20 +- types/entities/mention.ts | 8 +- types/entities/notification.ts | 10 +- types/entities/poll.ts | 14 +- types/entities/poll_option.ts | 4 +- types/entities/preferences.ts | 10 +- types/entities/push_subscription.ts | 18 +- types/entities/relationship.ts | 28 +- types/entities/report.ts | 18 +- types/entities/results.ts | 6 +- types/entities/role.ts | 2 +- types/entities/scheduled_status.ts | 8 +- types/entities/source.ts | 10 +- types/entities/stats.ts | 6 +- types/entities/status.ts | 66 +- types/entities/status_params.ts | 16 +- types/entities/status_source.ts | 6 +- types/entities/tag.ts | 8 +- types/entities/token.ts | 8 +- types/entities/urls.ts | 2 +- types/lysand/Extension.ts | 4 +- types/lysand/Object.ts | 226 +- .../extensions/org.lysand/custom_emojis.ts | 6 +- types/lysand/extensions/org.lysand/polls.ts | 14 +- .../lysand/extensions/org.lysand/reactions.ts | 8 +- uno.config.ts | 36 +- utils/api.ts | 20 +- utils/constants.ts | 2 +- utils/content_types.ts | 32 +- utils/formatting.ts | 30 +- utils/meilisearch.ts | 248 +- utils/merge.ts | 25 +- utils/module.ts | 16 +- utils/oauth.ts | 82 +- utils/redis.ts | 80 +- utils/response.ts | 78 +- utils/sanitization.ts | 132 +- utils/temp.ts | 22 +- utils/tempmail.ts | 6000 ++++++++--------- 199 files changed, 16493 insertions(+), 16361 deletions(-) delete mode 100644 classes/activitypub.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index fe8ec85f..03a4d58d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,22 +1,22 @@ module.exports = { - extends: [ - "eslint:recommended", - "plugin:@typescript-eslint/strict-type-checked", - "plugin:@typescript-eslint/stylistic", - "plugin:prettier/recommended", - ], - parser: "@typescript-eslint/parser", - parserOptions: { - project: "./tsconfig.json", - }, - ignorePatterns: ["node_modules/", "dist/", ".eslintrc.cjs"], - plugins: ["@typescript-eslint"], - root: true, - rules: { - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/consistent-type-exports": "error", - "@typescript-eslint/consistent-type-imports": "error" - }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/strict-type-checked", + "plugin:@typescript-eslint/stylistic", + "plugin:prettier/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { + project: "./tsconfig.json", + }, + ignorePatterns: ["node_modules/", "dist/", ".eslintrc.cjs"], + plugins: ["@typescript-eslint"], + root: true, + rules: { + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/consistent-type-exports": "error", + "@typescript-eslint/consistent-type-imports": "error", + }, }; diff --git a/.vscode/launch.json b/.vscode/launch.json index 980184cd..aeb0b090 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,10 +4,7 @@ "type": "node", "name": "vscode-jest-tests.v2.lysand", "request": "launch", - "args": [ - "test", - "${jest.testFile}" - ], + "args": ["test", "${jest.testFile}"], "cwd": "/home/jessew/Dev/lysand", "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", @@ -15,4 +12,4 @@ "program": "/home/jessew/.bun/bin/bun" } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 6286bcf3..758b85e9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "typescript.tsdk": "node_modules/typescript/lib", - "jest.jestCommandLine": "/home/jessew/.bun/bin/bun test", - "jest.rootPath": "." + "typescript.tsdk": "node_modules/typescript/lib", + "jest.jestCommandLine": "/home/jessew/.bun/bin/bun test", + "jest.rootPath": "." } diff --git a/benchmarks/fetch.ts b/benchmarks/fetch.ts index 19612fc1..0e5798f5 100644 --- a/benchmarks/fetch.ts +++ b/benchmarks/fetch.ts @@ -4,11 +4,11 @@ const requests: Promise[] = []; // Repeat 1000 times for (let i = 0; i < 1000; i++) { - requests.push( - fetch(`https://mastodon.social`, { - method: "GET", - }) - ); + requests.push( + fetch("https://mastodon.social", { + method: "GET", + }), + ); } await Promise.all(requests); diff --git a/benchmarks/timelines.ts b/benchmarks/timelines.ts index c12fe3e6..ac8e8958 100644 --- a/benchmarks/timelines.ts +++ b/benchmarks/timelines.ts @@ -9,46 +9,46 @@ const token = process.env.TOKEN; const requestCount = Number(process.argv[2]) || 100; if (!token) { - console.log( - `${chalk.red( - "✗" - )} No token provided. Provide one via the TOKEN environment variable.` - ); - process.exit(1); + console.log( + `${chalk.red( + "✗", + )} No token provided. Provide one via the TOKEN environment variable.`, + ); + process.exit(1); } const fetchTimeline = () => - fetch(`${config.http.base_url}/api/v1/timelines/home`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }).then(res => res.ok); + fetch(`${config.http.base_url}/api/v1/timelines/home`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }).then((res) => res.ok); const timeNow = performance.now(); const requests = Array.from({ length: requestCount }, () => fetchTimeline()); Promise.all(requests) - .then(results => { - const timeTaken = performance.now() - timeNow; - if (results.every(t => t)) { - console.log(`${chalk.green("✓")} All requests succeeded`); - } else { - console.log( - `${chalk.red("✗")} ${ - results.filter(t => !t).length - } requests failed` - ); - } - console.log( - `${chalk.green("✓")} ${ - requests.length - } requests fulfilled in ${chalk.bold( - (timeTaken / 1000).toFixed(5) - )}s` - ); - }) - .catch(err => { - console.log(`${chalk.red("✗")} ${err}`); - process.exit(1); - }); + .then((results) => { + const timeTaken = performance.now() - timeNow; + if (results.every((t) => t)) { + console.log(`${chalk.green("✓")} All requests succeeded`); + } else { + console.log( + `${chalk.red("✗")} ${ + results.filter((t) => !t).length + } requests failed`, + ); + } + console.log( + `${chalk.green("✓")} ${ + requests.length + } requests fulfilled in ${chalk.bold( + (timeTaken / 1000).toFixed(5), + )}s`, + ); + }) + .catch((err) => { + console.log(`${chalk.red("✗")} ${err}`); + process.exit(1); + }); diff --git a/biome.json b/biome.json index c1dabfd9..77ecea9f 100644 --- a/biome.json +++ b/biome.json @@ -1,17 +1,20 @@ { - "$schema": "https://biomejs.dev/schemas/1.6.4/schema.json", - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 4 - } + "$schema": "https://biomejs.dev/schemas/1.6.4/schema.json", + "organizeImports": { + "enabled": true, + "ignore": ["node_modules/**/*", "dist/**/*"] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + }, + "ignore": ["node_modules/**/*", "dist/**/*"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4, + "ignore": ["node_modules/**/*", "dist/**/*"] + } } diff --git a/build.ts b/build.ts index 5937c19c..1f177970 100644 --- a/build.ts +++ b/build.ts @@ -1,52 +1,52 @@ // Delete dist directory -import { rm, cp, mkdir, exists } from "fs/promises"; +import { cp, exists, mkdir, rm } from "node:fs/promises"; import { rawRoutes } from "~routes"; if (!(await exists("./pages/dist"))) { - console.log("Please build the Vite server first, or use `bun prod-build`"); - process.exit(1); + console.log("Please build the Vite server first, or use `bun prod-build`"); + process.exit(1); } console.log(`Building at ${process.cwd()}`); await rm("./dist", { recursive: true }); -await mkdir(process.cwd() + "/dist"); +await mkdir(`${process.cwd()}/dist`); //bun build --entrypoints ./index.ts ./prisma.ts ./cli.ts --outdir dist --target bun --splitting --minify --external bullmq,@prisma/client await Bun.build({ - entrypoints: [ - process.cwd() + "/index.ts", - process.cwd() + "/prisma.ts", - process.cwd() + "/cli.ts", - // Force Bun to include endpoints - ...Object.values(rawRoutes), - ], - outdir: process.cwd() + "/dist", - target: "bun", - splitting: true, - minify: true, - external: ["bullmq"], -}).then(output => { - if (!output.success) { - console.log(output.logs); - } + entrypoints: [ + `${process.cwd()}/index.ts`, + `${process.cwd()}/prisma.ts`, + `${process.cwd()}/cli.ts`, + // Force Bun to include endpoints + ...Object.values(rawRoutes), + ], + outdir: `${process.cwd()}/dist`, + target: "bun", + splitting: true, + minify: true, + external: ["bullmq"], +}).then((output) => { + if (!output.success) { + console.log(output.logs); + } }); // Create pages directory // mkdir ./dist/pages -await mkdir(process.cwd() + "/dist/pages"); +await mkdir(`${process.cwd()}/dist/pages`); // Copy Vite build output to dist // cp -r ./pages/dist ./dist/pages -await cp(process.cwd() + "/pages/dist", process.cwd() + "/dist/pages/", { - recursive: true, +await cp(`${process.cwd()}/pages/dist`, `${process.cwd()}/dist/pages/`, { + recursive: true, }); // Copy the Bee Movie script from pages await cp( - process.cwd() + "/pages/beemovie.txt", - process.cwd() + "/dist/pages/beemovie.txt" + `${process.cwd()}/pages/beemovie.txt`, + `${process.cwd()}/dist/pages/beemovie.txt`, ); -console.log(`Built!`); +console.log("Built!"); diff --git a/bun.lockb b/bun.lockb index 30c3fd31cdfae32517012e0e535855371ffd378a..2170054d2d574e1e52985ed6b8aef02eef45750f 100755 GIT binary patch delta 52254 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򫗫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 delta 116886 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

3rrz>t^mSlWB-Fg$Vq|Bc_$Z(N? z)2#UIZ2U|Jb^QI)3pO!Jn5p(dBIyvrJ9o90Ob!SYmi-V%WbU+CVZxUC3?bv)KYbra zZ+t(*^hGP{vfgp1UP7oS>W8>*wOeLvk-hpHgiJ^Obe_%364LXb_G$@f7&CT1UWib! zdAj{(@F3g9JxrN!73Cd;4n=##ED|oPa>Q-iB z#Y}CvRsKS3Wrx;v4CCkV0vK$od^K zmy7d)C$3CDZoV^p?sjHL87w6cXjah>JpO-aJF^z!w&{PjGv||-r9~9M#Wza_WG&6s z|8G8VNu5#!XB4oELdW!;oy-z4_RvhpR&psN=iFx|go@aX=?8W)OPFHK*I+YdcRS?7I9iISQfr&-AssnI+Bmq4|+l z+~wuGb!Ep93b5sWh@qDK)Bo&dmXPs==G5EGX>NwoZXHDEjqjgsu!mW~6kBeGn9

tags - formattedContent = formattedContent - .split("\n") - .map(line => `

${line}

`) - .join("\n"); - } + // Split by newline and add

tags + formattedContent = formattedContent + .split("\n") + .map((line) => `

${line}

`) + .join("\n"); + } - let status = await client.status.create({ - data: { - authorId: data.account.id, - applicationId: data.application?.id, - content: formattedContent, - contentSource: data.content, - contentType: data.content_type, - visibility: data.visibility, - sensitive: data.sensitive, - spoilerText: data.spoiler_text, - emojis: { - connect: data.emojis.map(emoji => { - return { - id: emoji.id, - }; - }), - }, - attachments: data.media_attachments - ? { - connect: data.media_attachments.map(attachment => { - return { - id: attachment, - }; - }), - } - : undefined, - inReplyToPostId: data.reply?.status.id, - quotingPostId: data.quote?.id, - instanceId: data.account.instanceId || undefined, - isReblog: false, - uri: - data.uri || - `${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`, - mentions: { - connect: mentions.map(mention => { - return { - id: mention.id, - }; - }), - }, - }, - include: statusAndUserRelations, - }); + let status = await client.status.create({ + data: { + authorId: data.account.id, + applicationId: data.application?.id, + content: formattedContent, + contentSource: data.content, + contentType: data.content_type, + visibility: data.visibility, + sensitive: data.sensitive, + spoilerText: data.spoiler_text, + emojis: { + connect: data.emojis.map((emoji) => { + return { + id: emoji.id, + }; + }), + }, + attachments: data.media_attachments + ? { + connect: data.media_attachments.map((attachment) => { + return { + id: attachment, + }; + }), + } + : undefined, + inReplyToPostId: data.reply?.status.id, + quotingPostId: data.quote?.id, + instanceId: data.account.instanceId || undefined, + isReblog: false, + uri: + data.uri || + `${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`, + mentions: { + connect: mentions.map((mention) => { + return { + id: mention.id, + }; + }), + }, + }, + include: statusAndUserRelations, + }); - // Update URI - status = await client.status.update({ - where: { - id: status.id, - }, - data: { - uri: data.uri || `${config.http.base_url}/statuses/${status.id}`, - }, - include: statusAndUserRelations, - }); + // Update URI + status = await client.status.update({ + where: { + id: status.id, + }, + data: { + uri: data.uri || `${config.http.base_url}/statuses/${status.id}`, + }, + include: statusAndUserRelations, + }); - // Create notification - if (status.inReplyToPost) { - await client.notification.create({ - data: { - notifiedId: status.inReplyToPost.authorId, - accountId: status.authorId, - type: "mention", - statusId: status.id, - }, - }); - } + // Create notification + if (status.inReplyToPost) { + await client.notification.create({ + data: { + notifiedId: status.inReplyToPost.authorId, + accountId: status.authorId, + type: "mention", + statusId: status.id, + }, + }); + } - // Add to search index - await addStausToMeilisearch(status); + // Add to search index + await addStausToMeilisearch(status); - return status; + return status; }; export const editStatus = async ( - status: StatusWithRelations, - data: { - content: string; - visibility?: APIStatus["visibility"]; - sensitive: boolean; - spoiler_text: string; - emojis?: Emoji[]; - content_type?: string; - uri?: string; - mentions?: User[]; - media_attachments?: string[]; - } + status: StatusWithRelations, + data: { + content: string; + visibility?: APIStatus["visibility"]; + sensitive: boolean; + spoiler_text: string; + emojis?: Emoji[]; + content_type?: string; + uri?: string; + mentions?: User[]; + media_attachments?: string[]; + }, ) => { - // Get people mentioned in the content (match @username or @username@domain.com mentions - const mentionedPeople = - data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? []; + // Get people mentioned in the content (match @username or @username@domain.com mentions + const mentionedPeople = + data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? []; - let mentions = data.mentions || []; + let mentions = data.mentions || []; - // Parse emojis - const emojis = await parseEmojis(data.content); + // Parse emojis + const emojis = await parseEmojis(data.content); - data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis; + data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis; - // Get list of mentioned users - if (mentions.length === 0) { - mentions = await client.user.findMany({ - where: { - OR: mentionedPeople.map(person => ({ - username: person.split("@")[1], - instance: { - base_url: person.split("@")[2], - }, - })), - }, - include: userRelations, - }); - } + // Get list of mentioned users + if (mentions.length === 0) { + mentions = await client.user.findMany({ + where: { + OR: mentionedPeople.map((person) => ({ + username: person.split("@")[1], + instance: { + base_url: person.split("@")[2], + }, + })), + }, + include: userRelations, + }); + } - let formattedContent; + let formattedContent = ""; - // Get HTML version of content - if (data.content_type === "text/markdown") { - formattedContent = linkifyHtml( - await sanitizeHtml(await parse(data.content)) - ); - } else if (data.content_type === "text/x.misskeymarkdown") { - // Parse as MFM - } else { - // Parse as plaintext - formattedContent = linkifyStr(data.content); + // Get HTML version of content + if (data.content_type === "text/markdown") { + formattedContent = linkifyHtml( + await sanitizeHtml(await parse(data.content)), + ); + } else if (data.content_type === "text/x.misskeymarkdown") { + // Parse as MFM + } else { + // Parse as plaintext + formattedContent = linkifyStr(data.content); - // Split by newline and add

tags - formattedContent = formattedContent - .split("\n") - .map(line => `

${line}

`) - .join("\n"); - } + // Split by newline and add

tags + formattedContent = formattedContent + .split("\n") + .map((line) => `

${line}

`) + .join("\n"); + } - const newStatus = await client.status.update({ - where: { - id: status.id, - }, - data: { - content: formattedContent, - contentSource: data.content, - contentType: data.content_type, - visibility: data.visibility, - sensitive: data.sensitive, - spoilerText: data.spoiler_text, - emojis: { - connect: data.emojis.map(emoji => { - return { - id: emoji.id, - }; - }), - }, - attachments: data.media_attachments - ? { - connect: data.media_attachments.map(attachment => { - return { - id: attachment, - }; - }), - } - : undefined, - mentions: { - connect: mentions.map(mention => { - return { - id: mention.id, - }; - }), - }, - }, - include: statusAndUserRelations, - }); + const newStatus = await client.status.update({ + where: { + id: status.id, + }, + data: { + content: formattedContent, + contentSource: data.content, + contentType: data.content_type, + visibility: data.visibility, + sensitive: data.sensitive, + spoilerText: data.spoiler_text, + emojis: { + connect: data.emojis.map((emoji) => { + return { + id: emoji.id, + }; + }), + }, + attachments: data.media_attachments + ? { + connect: data.media_attachments.map((attachment) => { + return { + id: attachment, + }; + }), + } + : undefined, + mentions: { + connect: mentions.map((mention) => { + return { + id: mention.id, + }; + }), + }, + }, + include: statusAndUserRelations, + }); - return newStatus; + return newStatus; }; export const isFavouritedBy = async (status: Status, user: User) => { - return !!(await client.like.findFirst({ - where: { - likerId: user.id, - likedId: status.id, - }, - })); + return !!(await client.like.findFirst({ + where: { + likerId: user.id, + likedId: status.id, + }, + })); }; /** @@ -452,67 +451,67 @@ export const isFavouritedBy = async (status: Status, user: User) => { * @returns A promise that resolves with the API status. */ export const statusToAPI = async ( - status: StatusWithRelations, - user?: UserWithRelations + status: StatusWithRelations, + user?: UserWithRelations, ): Promise => { - return { - id: status.id, - in_reply_to_id: status.inReplyToPostId || null, - in_reply_to_account_id: status.inReplyToPost?.authorId || null, - // @ts-expect-error Prisma TypeScript types dont include relations - account: userToAPI(status.author), - created_at: new Date(status.createdAt).toISOString(), - application: status.application - ? applicationToAPI(status.application) - : null, - card: null, - content: status.content, - emojis: status.emojis.map(emoji => emojiToAPI(emoji)), - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - favourited: !!(status.likes ?? []).find( - like => like.likerId === user?.id - ), - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - favourites_count: (status.likes ?? []).length, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - media_attachments: (status.attachments ?? []).map( - a => attachmentToAPI(a) as APIAttachment - ), - // @ts-expect-error Prisma TypeScript types dont include relations - mentions: status.mentions.map(mention => userToAPI(mention)), - language: null, - muted: user - ? user.relationships.find(r => r.subjectId == status.authorId) - ?.muting || false - : false, - pinned: status.pinnedBy.find(u => u.id === user?.id) ? true : false, - // TODO: Add pols - poll: null, - reblog: status.reblog - ? await statusToAPI(status.reblog as unknown as StatusWithRelations) - : null, - reblogged: !!(await client.status.findFirst({ - where: { - authorId: user?.id, - reblogId: status.id, - }, - })), - reblogs_count: status._count.reblogs, - replies_count: status._count.replies, - sensitive: status.sensitive, - spoiler_text: status.spoilerText, - tags: [], - uri: `${config.http.base_url}/statuses/${status.id}`, - visibility: "public", - url: `${config.http.base_url}/statuses/${status.id}`, - bookmarked: false, - quote: status.quotingPost - ? await statusToAPI( - status.quotingPost as unknown as StatusWithRelations - ) - : null, - quote_id: status.quotingPost?.id || undefined, - }; + return { + id: status.id, + in_reply_to_id: status.inReplyToPostId || null, + in_reply_to_account_id: status.inReplyToPost?.authorId || null, + // @ts-expect-error Prisma TypeScript types dont include relations + account: userToAPI(status.author), + created_at: new Date(status.createdAt).toISOString(), + application: status.application + ? applicationToAPI(status.application) + : null, + card: null, + content: status.content, + emojis: status.emojis.map((emoji) => emojiToAPI(emoji)), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + favourited: !!(status.likes ?? []).find( + (like) => like.likerId === user?.id, + ), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + favourites_count: (status.likes ?? []).length, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + media_attachments: (status.attachments ?? []).map( + (a) => attachmentToAPI(a) as APIAttachment, + ), + // @ts-expect-error Prisma TypeScript types dont include relations + mentions: status.mentions.map((mention) => userToAPI(mention)), + language: null, + muted: user + ? user.relationships.find((r) => r.subjectId === status.authorId) + ?.muting || false + : false, + pinned: status.pinnedBy.find((u) => u.id === user?.id) ? true : false, + // TODO: Add pols + poll: null, + reblog: status.reblog + ? await statusToAPI(status.reblog as unknown as StatusWithRelations) + : null, + reblogged: !!(await client.status.findFirst({ + where: { + authorId: user?.id, + reblogId: status.id, + }, + })), + reblogs_count: status._count.reblogs, + replies_count: status._count.replies, + sensitive: status.sensitive, + spoiler_text: status.spoilerText, + tags: [], + uri: `${config.http.base_url}/statuses/${status.id}`, + visibility: "public", + url: `${config.http.base_url}/statuses/${status.id}`, + bookmarked: false, + quote: status.quotingPost + ? await statusToAPI( + status.quotingPost as unknown as StatusWithRelations, + ) + : null, + quote_id: status.quotingPost?.id || undefined, + }; }; /* export const statusToActivityPub = async ( @@ -563,35 +562,35 @@ export const statusToAPI = async ( }; */ export const statusToLysand = (status: StatusWithRelations): Note => { - return { - type: "Note", - created_at: new Date(status.createdAt).toISOString(), - id: status.id, - author: status.authorId, - uri: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}`, - contents: [ - { - content: status.content, - content_type: "text/html", - }, - { - // Content converted to plaintext - content: htmlToText(status.content), - content_type: "text/plain", - }, - ], - // TODO: Add attachments - attachments: [], - is_sensitive: status.sensitive, - mentions: status.mentions.map(mention => mention.uri), - quotes: status.quotingPost ? [status.quotingPost.uri] : [], - replies_to: status.inReplyToPostId ? [status.inReplyToPostId] : [], - subject: status.spoilerText, - extensions: { - "org.lysand:custom_emojis": { - emojis: status.emojis.map(emoji => emojiToLysand(emoji)), - }, - // TODO: Add polls and reactions - }, - }; + return { + type: "Note", + created_at: new Date(status.createdAt).toISOString(), + id: status.id, + author: status.authorId, + uri: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}`, + contents: [ + { + content: status.content, + content_type: "text/html", + }, + { + // Content converted to plaintext + content: htmlToText(status.content), + content_type: "text/plain", + }, + ], + // TODO: Add attachments + attachments: [], + is_sensitive: status.sensitive, + mentions: status.mentions.map((mention) => mention.uri), + quotes: status.quotingPost ? [status.quotingPost.uri] : [], + replies_to: status.inReplyToPostId ? [status.inReplyToPostId] : [], + subject: status.spoilerText, + extensions: { + "org.lysand:custom_emojis": { + emojis: status.emojis.map((emoji) => emojiToLysand(emoji)), + }, + // TODO: Add polls and reactions + }, + }; }; diff --git a/database/entities/Token.ts b/database/entities/Token.ts index a46b9ab2..3a07a0de 100644 --- a/database/entities/Token.ts +++ b/database/entities/Token.ts @@ -2,5 +2,5 @@ * The type of token. */ export enum TokenType { - BEARER = "Bearer", + BEARER = "Bearer", } diff --git a/database/entities/User.ts b/database/entities/User.ts index 141ab851..0879eb98 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -1,20 +1,20 @@ -import type { APIAccount } from "~types/entities/account"; -import type { LysandUser } from "~types/lysand/Object"; -import { htmlToText } from "html-to-text"; +import { addUserToMeilisearch } from "@meilisearch"; import type { User } from "@prisma/client"; import { Prisma } from "@prisma/client"; +import { type Config, config } from "config-manager"; +import { htmlToText } from "html-to-text"; import { client } from "~database/datasource"; +import { MediaBackendType } from "~packages/media-manager"; +import type { APIAccount } from "~types/entities/account"; +import type { APISource } from "~types/entities/source"; +import type { LysandUser } from "~types/lysand/Object"; import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji"; import { addInstanceIfNotExists } from "./Instance"; -import type { APISource } from "~types/entities/source"; -import { addUserToMeilisearch } from "@meilisearch"; -import { config, type Config } from "config-manager"; import { userRelations } from "./relations"; -import { MediaBackendType } from "~packages/media-manager"; export interface AuthData { - user: UserWithRelations | null; - token: string; + user: UserWithRelations | null; + token: string; } /** @@ -23,7 +23,7 @@ export interface AuthData { */ const userRelations2 = Prisma.validator()({ - include: userRelations, + include: userRelations, }); export type UserWithRelations = Prisma.UserGetPayload; @@ -34,14 +34,15 @@ export type UserWithRelations = Prisma.UserGetPayload; * @returns The raw URL for the user's avatar */ export const getAvatarUrl = (user: User, config: Config) => { - if (!user.avatar) return config.defaults.avatar; - if (config.media.backend === MediaBackendType.LOCAL) { - return `${config.http.base_url}/media/${user.avatar}`; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - } else if (config.media.backend === MediaBackendType.S3) { - return `${config.s3.public_url}/${user.avatar}`; - } - return ""; + if (!user.avatar) return config.defaults.avatar; + if (config.media.backend === MediaBackendType.LOCAL) { + return `${config.http.base_url}/media/${user.avatar}`; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } + if (config.media.backend === MediaBackendType.S3) { + return `${config.s3.public_url}/${user.avatar}`; + } + return ""; }; /** @@ -50,128 +51,129 @@ export const getAvatarUrl = (user: User, config: Config) => { * @returns The raw URL for the user's header */ export const getHeaderUrl = (user: User, config: Config) => { - if (!user.header) return config.defaults.header; - if (config.media.backend === MediaBackendType.LOCAL) { - return `${config.http.base_url}/media/${user.header}`; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - } else if (config.media.backend === MediaBackendType.S3) { - return `${config.s3.public_url}/${user.header}`; - } - return ""; + if (!user.header) return config.defaults.header; + if (config.media.backend === MediaBackendType.LOCAL) { + return `${config.http.base_url}/media/${user.header}`; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } + if (config.media.backend === MediaBackendType.S3) { + return `${config.s3.public_url}/${user.header}`; + } + return ""; }; export const getFromRequest = async (req: Request): Promise => { - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || ""; + // Check auth token + const token = req.headers.get("Authorization")?.split(" ")[1] || ""; - return { user: await retrieveUserFromToken(token), token }; + return { user: await retrieveUserFromToken(token), token }; }; export const fetchRemoteUser = async (uri: string) => { - // Check if user not already in database - const foundUser = await client.user.findUnique({ - where: { - uri, - }, - include: userRelations, - }); + // Check if user not already in database + const foundUser = await client.user.findUnique({ + where: { + uri, + }, + include: userRelations, + }); - if (foundUser) return foundUser; + if (foundUser) return foundUser; - const response = await fetch(uri, { - method: "GET", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - }); + const response = await fetch(uri, { + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }); - const data = (await response.json()) as Partial; + const data = (await response.json()) as Partial; - if ( - !( - data.id && - data.username && - data.uri && - data.created_at && - data.disliked && - data.featured && - data.liked && - data.followers && - data.following && - data.inbox && - data.outbox && - data.public_key - ) - ) { - throw new Error("Invalid user data"); - } + if ( + !( + data.id && + data.username && + data.uri && + data.created_at && + data.disliked && + data.featured && + data.liked && + data.followers && + data.following && + data.inbox && + data.outbox && + data.public_key + ) + ) { + throw new Error("Invalid user data"); + } - // Parse emojis and add them to database - const userEmojis = - data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? []; + // Parse emojis and add them to database + const userEmojis = + data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? []; - const user = await client.user.create({ - data: { - username: data.username, - uri: data.uri, - createdAt: new Date(data.created_at), - endpoints: { - disliked: data.disliked, - featured: data.featured, - liked: data.liked, - followers: data.followers, - following: data.following, - inbox: data.inbox, - outbox: data.outbox, - }, - avatar: (data.avatar && data.avatar[0].content) || "", - header: (data.header && data.header[0].content) || "", - displayName: data.display_name ?? "", - note: data.bio?.[0].content ?? "", - publicKey: data.public_key.public_key, - source: { - language: null, - note: "", - privacy: "public", - sensitive: false, - fields: [], - }, - }, - }); + const user = await client.user.create({ + data: { + username: data.username, + uri: data.uri, + createdAt: new Date(data.created_at), + endpoints: { + disliked: data.disliked, + featured: data.featured, + liked: data.liked, + followers: data.followers, + following: data.following, + inbox: data.inbox, + outbox: data.outbox, + }, + avatar: data.avatar?.[0].content || "", + header: data.header?.[0].content || "", + displayName: data.display_name ?? "", + note: data.bio?.[0].content ?? "", + publicKey: data.public_key.public_key, + source: { + language: null, + note: "", + privacy: "public", + sensitive: false, + fields: [], + }, + }, + }); - // Add to Meilisearch - await addUserToMeilisearch(user); + // Add to Meilisearch + await addUserToMeilisearch(user); - const emojis = []; + const emojis = []; - for (const emoji of userEmojis) { - emojis.push(await addEmojiIfNotExists(emoji)); - } + for (const emoji of userEmojis) { + emojis.push(await addEmojiIfNotExists(emoji)); + } - const uriData = new URL(data.uri); + const uriData = new URL(data.uri); - return await client.user.update({ - where: { - id: user.id, - }, - data: { - emojis: { - connect: emojis.map(emoji => ({ - id: emoji.id, - })), - }, - instanceId: (await addInstanceIfNotExists(uriData.origin)).id, - }, - include: userRelations, - }); + return await client.user.update({ + where: { + id: user.id, + }, + data: { + emojis: { + connect: emojis.map((emoji) => ({ + id: emoji.id, + })), + }, + instanceId: (await addInstanceIfNotExists(uriData.origin)).id, + }, + include: userRelations, + }); }; /** * Fetches the list of followers associated with the actor and updates the user's followers */ export const fetchFollowers = () => { - // + // }; /** @@ -180,75 +182,75 @@ export const fetchFollowers = () => { * @returns The newly created user. */ export const createNewLocalUser = async (data: { - username: string; - display_name?: string; - password: string; - email: string; - bio?: string; - avatar?: string; - header?: string; - admin?: boolean; + username: string; + display_name?: string; + password: string; + email: string; + bio?: string; + avatar?: string; + header?: string; + admin?: boolean; }) => { - const keys = await generateUserKeys(); + const keys = await generateUserKeys(); - const user = await client.user.create({ - data: { - username: data.username, - displayName: data.display_name ?? data.username, - password: await Bun.password.hash(data.password), - email: data.email, - note: data.bio ?? "", - avatar: data.avatar ?? config.defaults.avatar, - header: data.header ?? config.defaults.avatar, - isAdmin: data.admin ?? false, - uri: "", - publicKey: keys.public_key, - privateKey: keys.private_key, - source: { - language: null, - note: "", - privacy: "public", - sensitive: false, - fields: [], - }, - }, - }); + const user = await client.user.create({ + data: { + username: data.username, + displayName: data.display_name ?? data.username, + password: await Bun.password.hash(data.password), + email: data.email, + note: data.bio ?? "", + avatar: data.avatar ?? config.defaults.avatar, + header: data.header ?? config.defaults.avatar, + isAdmin: data.admin ?? false, + uri: "", + publicKey: keys.public_key, + privateKey: keys.private_key, + source: { + language: null, + note: "", + privacy: "public", + sensitive: false, + fields: [], + }, + }, + }); - // Add to Meilisearch - await addUserToMeilisearch(user); + // Add to Meilisearch + await addUserToMeilisearch(user); - return await client.user.update({ - where: { - id: user.id, - }, - data: { - uri: `${config.http.base_url}/users/${user.id}`, - endpoints: { - disliked: `${config.http.base_url}/users/${user.id}/disliked`, - featured: `${config.http.base_url}/users/${user.id}/featured`, - liked: `${config.http.base_url}/users/${user.id}/liked`, - followers: `${config.http.base_url}/users/${user.id}/followers`, - following: `${config.http.base_url}/users/${user.id}/following`, - inbox: `${config.http.base_url}/users/${user.id}/inbox`, - outbox: `${config.http.base_url}/users/${user.id}/outbox`, - }, - }, - include: userRelations, - }); + return await client.user.update({ + where: { + id: user.id, + }, + data: { + uri: `${config.http.base_url}/users/${user.id}`, + endpoints: { + disliked: `${config.http.base_url}/users/${user.id}/disliked`, + featured: `${config.http.base_url}/users/${user.id}/featured`, + liked: `${config.http.base_url}/users/${user.id}/liked`, + followers: `${config.http.base_url}/users/${user.id}/followers`, + following: `${config.http.base_url}/users/${user.id}/following`, + inbox: `${config.http.base_url}/users/${user.id}/inbox`, + outbox: `${config.http.base_url}/users/${user.id}/outbox`, + }, + }, + include: userRelations, + }); }; /** * Parses mentions from a list of URIs */ export const parseMentionsUris = async (mentions: string[]) => { - return await client.user.findMany({ - where: { - uri: { - in: mentions, - }, - }, - include: userRelations, - }); + return await client.user.findMany({ + where: { + uri: { + in: mentions, + }, + }, + include: userRelations, + }); }; /** @@ -257,22 +259,22 @@ export const parseMentionsUris = async (mentions: string[]) => { * @returns The user associated with the given access token. */ export const retrieveUserFromToken = async (access_token: string) => { - if (!access_token) return null; + if (!access_token) return null; - const token = await client.token.findFirst({ - where: { - access_token, - }, - include: { - user: { - include: userRelations, - }, - }, - }); + const token = await client.token.findFirst({ + where: { + access_token, + }, + include: { + user: { + include: userRelations, + }, + }, + }); - if (!token) return null; + if (!token) return null; - return token.user; + return token.user; }; /** @@ -281,174 +283,174 @@ export const retrieveUserFromToken = async (access_token: string) => { * @returns The relationship to the other user. */ export const getRelationshipToOtherUser = async ( - user: UserWithRelations, - other: User + user: UserWithRelations, + other: User, ) => { - return await client.relationship.findFirst({ - where: { - ownerId: user.id, - subjectId: other.id, - }, - }); + return await client.relationship.findFirst({ + where: { + ownerId: user.id, + subjectId: other.id, + }, + }); }; /** * Generates keys for the user. */ export const generateUserKeys = async () => { - const keys = await crypto.subtle.generateKey("Ed25519", true, [ - "sign", - "verify", - ]); + const keys = await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ]); - const privateKey = btoa( - String.fromCharCode.apply(null, [ - ...new Uint8Array( - // jesus help me what do these letters mean - await crypto.subtle.exportKey("pkcs8", keys.privateKey) - ), - ]) - ); - const publicKey = btoa( - String.fromCharCode( - ...new Uint8Array( - // why is exporting a key so hard - await crypto.subtle.exportKey("spki", keys.publicKey) - ) - ) - ); + const privateKey = btoa( + String.fromCharCode.apply(null, [ + ...new Uint8Array( + // jesus help me what do these letters mean + await crypto.subtle.exportKey("pkcs8", keys.privateKey), + ), + ]), + ); + const publicKey = btoa( + String.fromCharCode( + ...new Uint8Array( + // why is exporting a key so hard + await crypto.subtle.exportKey("spki", keys.publicKey), + ), + ), + ); - // Add header, footer and newlines later on - // These keys are base64 encrypted - return { - private_key: privateKey, - public_key: publicKey, - }; + // Add header, footer and newlines later on + // These keys are base64 encrypted + return { + private_key: privateKey, + public_key: publicKey, + }; }; export const userToAPI = ( - user: UserWithRelations, - isOwnAccount = false + user: UserWithRelations, + isOwnAccount = false, ): APIAccount => { - return { - id: user.id, - username: user.username, - display_name: user.displayName, - note: user.note, - url: user.uri, - avatar: getAvatarUrl(user, config), - header: getHeaderUrl(user, config), - locked: user.isLocked, - created_at: new Date(user.createdAt).toISOString(), - followers_count: user.relationshipSubjects.filter(r => r.following) - .length, - following_count: user.relationships.filter(r => r.following).length, - statuses_count: user._count.statuses, - emojis: user.emojis.map(emoji => emojiToAPI(emoji)), - // TODO: Add fields - fields: [], - bot: user.isBot, - source: - isOwnAccount && user.source - ? (user.source as APISource) - : undefined, - // TODO: Add static avatar and header - avatar_static: "", - header_static: "", - acct: - user.instance === null - ? user.username - : `${user.username}@${user.instance.base_url}`, - // TODO: Add these fields - limited: false, - moved: null, - noindex: false, - suspended: false, - discoverable: undefined, - mute_expires_at: undefined, - group: false, - pleroma: { - is_admin: user.isAdmin, - is_moderator: user.isAdmin, - }, - }; + return { + id: user.id, + username: user.username, + display_name: user.displayName, + note: user.note, + url: user.uri, + avatar: getAvatarUrl(user, config), + header: getHeaderUrl(user, config), + locked: user.isLocked, + created_at: new Date(user.createdAt).toISOString(), + followers_count: user.relationshipSubjects.filter((r) => r.following) + .length, + following_count: user.relationships.filter((r) => r.following).length, + statuses_count: user._count.statuses, + emojis: user.emojis.map((emoji) => emojiToAPI(emoji)), + // TODO: Add fields + fields: [], + bot: user.isBot, + source: + isOwnAccount && user.source + ? (user.source as APISource) + : undefined, + // TODO: Add static avatar and header + avatar_static: "", + header_static: "", + acct: + user.instance === null + ? user.username + : `${user.username}@${user.instance.base_url}`, + // TODO: Add these fields + limited: false, + moved: null, + noindex: false, + suspended: false, + discoverable: undefined, + mute_expires_at: undefined, + group: false, + pleroma: { + is_admin: user.isAdmin, + is_moderator: user.isAdmin, + }, + }; }; /** * Should only return local users */ export const userToLysand = (user: UserWithRelations): LysandUser => { - if (user.instanceId !== null) { - throw new Error("Cannot convert remote user to Lysand format"); - } + if (user.instanceId !== null) { + throw new Error("Cannot convert remote user to Lysand format"); + } - return { - id: user.id, - type: "User", - uri: user.uri, - bio: [ - { - content: user.note, - content_type: "text/html", - }, - { - content: htmlToText(user.note), - content_type: "text/plain", - }, - ], - created_at: new Date(user.createdAt).toISOString(), - disliked: `${user.uri}/disliked`, - featured: `${user.uri}/featured`, - liked: `${user.uri}/liked`, - followers: `${user.uri}/followers`, - following: `${user.uri}/following`, - inbox: `${user.uri}/inbox`, - outbox: `${user.uri}/outbox`, - indexable: false, - username: user.username, - avatar: [ - { - content: getAvatarUrl(user, config) || "", - content_type: `image/${user.avatar.split(".")[1]}`, - }, - ], - header: [ - { - content: getHeaderUrl(user, config) || "", - content_type: `image/${user.header.split(".")[1]}`, - }, - ], - display_name: user.displayName, - fields: (user.source as any as APISource).fields.map(field => ({ - key: [ - { - content: field.name, - content_type: "text/html", - }, - { - content: htmlToText(field.name), - content_type: "text/plain", - }, - ], - value: [ - { - content: field.value, - content_type: "text/html", - }, - { - content: htmlToText(field.value), - content_type: "text/plain", - }, - ], - })), - public_key: { - actor: `${config.http.base_url}/users/${user.id}`, - public_key: user.publicKey, - }, - extensions: { - "org.lysand:custom_emojis": { - emojis: user.emojis.map(emoji => emojiToLysand(emoji)), - }, - }, - }; + return { + id: user.id, + type: "User", + uri: user.uri, + bio: [ + { + content: user.note, + content_type: "text/html", + }, + { + content: htmlToText(user.note), + content_type: "text/plain", + }, + ], + created_at: new Date(user.createdAt).toISOString(), + disliked: `${user.uri}/disliked`, + featured: `${user.uri}/featured`, + liked: `${user.uri}/liked`, + followers: `${user.uri}/followers`, + following: `${user.uri}/following`, + inbox: `${user.uri}/inbox`, + outbox: `${user.uri}/outbox`, + indexable: false, + username: user.username, + avatar: [ + { + content: getAvatarUrl(user, config) || "", + content_type: `image/${user.avatar.split(".")[1]}`, + }, + ], + header: [ + { + content: getHeaderUrl(user, config) || "", + content_type: `image/${user.header.split(".")[1]}`, + }, + ], + display_name: user.displayName, + fields: (user.source as APISource).fields.map((field) => ({ + key: [ + { + content: field.name, + content_type: "text/html", + }, + { + content: htmlToText(field.name), + content_type: "text/plain", + }, + ], + value: [ + { + content: field.value, + content_type: "text/html", + }, + { + content: htmlToText(field.value), + content_type: "text/plain", + }, + ], + })), + public_key: { + actor: `${config.http.base_url}/users/${user.id}`, + public_key: user.publicKey, + }, + extensions: { + "org.lysand:custom_emojis": { + emojis: user.emojis.map((emoji) => emojiToLysand(emoji)), + }, + }, + }; }; diff --git a/database/entities/relations.ts b/database/entities/relations.ts index 65e061f3..284e1d6f 100644 --- a/database/entities/relations.ts +++ b/database/entities/relations.ts @@ -1,111 +1,111 @@ import type { Prisma } from "@prisma/client"; export const userRelations: Prisma.UserInclude = { - emojis: true, - instance: true, - likes: true, - relationships: true, - relationshipSubjects: true, - pinnedNotes: true, - _count: { - select: { - statuses: true, - likes: true, - }, - }, + emojis: true, + instance: true, + likes: true, + relationships: true, + relationshipSubjects: true, + pinnedNotes: true, + _count: { + select: { + statuses: true, + likes: true, + }, + }, }; export const statusAndUserRelations: Prisma.StatusInclude = { - author: { - include: userRelations, - }, - application: true, - emojis: true, - inReplyToPost: { - include: { - author: { - include: userRelations, - }, - application: true, - emojis: true, - inReplyToPost: { - include: { - author: true, - }, - }, - instance: true, - mentions: true, - pinnedBy: true, - _count: { - select: { - replies: true, - }, - }, - }, - }, - reblogs: true, - attachments: true, - instance: true, - mentions: { - include: userRelations, - }, - pinnedBy: true, - _count: { - select: { - replies: true, - likes: true, - reblogs: true, - }, - }, - reblog: { - include: { - author: { - include: userRelations, - }, - application: true, - emojis: true, - inReplyToPost: { - include: { - author: true, - }, - }, - instance: true, - mentions: { - include: userRelations, - }, - pinnedBy: true, - _count: { - select: { - replies: true, - }, - }, - }, - }, - quotingPost: { - include: { - author: { - include: userRelations, - }, - application: true, - emojis: true, - inReplyToPost: { - include: { - author: true, - }, - }, - instance: true, - mentions: true, - pinnedBy: true, - _count: { - select: { - replies: true, - }, - }, - }, - }, - likes: { - include: { - liker: true, - }, - }, + author: { + include: userRelations, + }, + application: true, + emojis: true, + inReplyToPost: { + include: { + author: { + include: userRelations, + }, + application: true, + emojis: true, + inReplyToPost: { + include: { + author: true, + }, + }, + instance: true, + mentions: true, + pinnedBy: true, + _count: { + select: { + replies: true, + }, + }, + }, + }, + reblogs: true, + attachments: true, + instance: true, + mentions: { + include: userRelations, + }, + pinnedBy: true, + _count: { + select: { + replies: true, + likes: true, + reblogs: true, + }, + }, + reblog: { + include: { + author: { + include: userRelations, + }, + application: true, + emojis: true, + inReplyToPost: { + include: { + author: true, + }, + }, + instance: true, + mentions: { + include: userRelations, + }, + pinnedBy: true, + _count: { + select: { + replies: true, + }, + }, + }, + }, + quotingPost: { + include: { + author: { + include: userRelations, + }, + application: true, + emojis: true, + inReplyToPost: { + include: { + author: true, + }, + }, + instance: true, + mentions: true, + pinnedBy: true, + _count: { + select: { + replies: true, + }, + }, + }, + }, + likes: { + include: { + liker: true, + }, + }, }; diff --git a/index.ts b/index.ts index f27cc66c..462bfcd7 100644 --- a/index.ts +++ b/index.ts @@ -1,73 +1,75 @@ +import { exists, mkdir } from "node:fs/promises"; +import { connectMeili } from "@meilisearch"; +import { moduleIsEntry } from "@module"; import type { PrismaClientInitializationError } from "@prisma/client/runtime/library"; import { initializeRedisCache } from "@redis"; -import { connectMeili } from "@meilisearch"; import { config } from "config-manager"; -import { client } from "~database/datasource"; import { LogLevel, LogManager, MultiLogManager } from "log-manager"; -import { moduleIsEntry } from "@module"; +import { client } from "~database/datasource"; import { createServer } from "~server"; -import { exists, mkdir } from "fs/promises"; const timeAtStart = performance.now(); -const requests_log = Bun.file(process.cwd() + "/logs/requests.log"); +const requests_log = Bun.file(`${process.cwd()}/logs/requests.log`); const isEntry = moduleIsEntry(import.meta.url); // If imported as a module, redirect logs to /dev/null to not pollute console (e.g. in tests) -const logger = new LogManager(isEntry ? requests_log : Bun.file(`/dev/null`)); +const logger = new LogManager(isEntry ? requests_log : Bun.file("/dev/null")); const consoleLogger = new LogManager( - isEntry ? Bun.stdout : Bun.file(`/dev/null`) + isEntry ? Bun.stdout : Bun.file("/dev/null"), ); const dualLogger = new MultiLogManager([logger, consoleLogger]); if (!(await exists(config.logging.storage.requests))) { - await consoleLogger.log( - LogLevel.WARNING, - "Lysand", - `Creating logs directory at ${process.cwd()}/logs/` - ); + await consoleLogger.log( + LogLevel.WARNING, + "Lysand", + `Creating logs directory at ${process.cwd()}/logs/`, + ); - await mkdir(process.cwd() + "/logs/"); + await mkdir(`${process.cwd()}/logs/`); } await dualLogger.log(LogLevel.INFO, "Lysand", "Starting Lysand..."); // NODE_ENV seems to be broken and output `development` even when set to production, so use the flag instead const isProd = - process.env.NODE_ENV === "production" || process.argv.includes("--prod"); + process.env.NODE_ENV === "production" || process.argv.includes("--prod"); const redisCache = await initializeRedisCache(); if (config.meilisearch.enabled) { - await connectMeili(dualLogger); + await connectMeili(dualLogger); } if (redisCache) { - client.$use(redisCache); + client.$use(redisCache); } // Check if database is reachable let postCount = 0; try { - postCount = await client.status.count(); + postCount = await client.status.count(); } catch (e) { - const error = e as PrismaClientInitializationError; - await logger.logError(LogLevel.CRITICAL, "Database", error); - await consoleLogger.logError(LogLevel.CRITICAL, "Database", error); - process.exit(1); + const error = e as PrismaClientInitializationError; + await logger.logError(LogLevel.CRITICAL, "Database", error); + await consoleLogger.logError(LogLevel.CRITICAL, "Database", error); + process.exit(1); } const server = createServer(config, dualLogger, isProd); await dualLogger.log( - LogLevel.INFO, - "Server", - `Lysand started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms` + LogLevel.INFO, + "Server", + `Lysand started at ${config.http.bind}:${config.http.bind_port} in ${( + performance.now() - timeAtStart + ).toFixed(0)}ms`, ); await dualLogger.log( - LogLevel.INFO, - "Database", - `Database is online, now serving ${postCount} posts` + LogLevel.INFO, + "Database", + `Database is online, now serving ${postCount} posts`, ); export { config, server }; diff --git a/package.json b/package.json index 08662d7f..4d7aef87 100644 --- a/package.json +++ b/package.json @@ -1,130 +1,118 @@ { - "name": "lysand", - "module": "index.ts", - "type": "module", - "version": "0.3.0", - "description": "A project to build a federated social network", - "author": { - "email": "contact@cpluspatch.com", - "name": "CPlusPatch", - "url": "https://cpluspatch.com" - }, - "bugs": { - "url": "https://github.com/lysand-org/lysand/issues" - }, - "icon": "https://github.com/lysand-org/lysand", - "license": "AGPL-3.0", - "keywords": [ - "federated", - "activitypub", - "bun" - ], - "workspaces": ["packages/*"], - "maintainers": [ - { - "email": "contact@cpluspatch.com", - "name": "CPlusPatch", - "url": "https://cpluspatch.com" + "name": "lysand", + "module": "index.ts", + "type": "module", + "version": "0.3.0", + "description": "A project to build a federated social network", + "author": { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + }, + "bugs": { + "url": "https://github.com/lysand-org/lysand/issues" + }, + "icon": "https://github.com/lysand-org/lysand", + "license": "AGPL-3.0", + "keywords": ["federated", "activitypub", "bun"], + "workspaces": ["packages/*"], + "maintainers": [ + { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + } + ], + "repository": { + "type": "git", + "url": "git+https://github.com/lysand-org/lysand.git" + }, + "private": true, + "scripts": { + "dev": "bun run --watch index.ts", + "vite:dev": "bunx --bun vite pages", + "vite:build": "bunx --bun vite build pages", + "start": "NODE_ENV=production bun run dist/index.js --prod", + "migrate-dev": "bun prisma migrate dev", + "migrate": "bun prisma migrate deploy", + "lint": "bunx --bun eslint --config .eslintrc.cjs --ext .ts .", + "prod-build": "bunx --bun vite build pages && bun run build.ts", + "prisma": "DATABASE_URL=$(bun run prisma.ts) bunx prisma", + "generate": "bun prisma generate", + "benchmark:timeline": "bun run benchmarks/timelines.ts", + "cloc": "cloc . --exclude-dir node_modules,dist", + "cli": "bun run cli.ts" + }, + "trustedDependencies": [ + "@biomejs/biome", + "@prisma/client", + "@prisma/engines", + "esbuild", + "prisma", + "sharp" + ], + "devDependencies": { + "@biomejs/biome": "1.6.4", + "@julr/unocss-preset-forms": "^0.1.0", + "@types/cli-table": "^0.3.4", + "@types/html-to-text": "^9.0.4", + "@types/ioredis": "^5.0.0", + "@types/jsonld": "^1.5.13", + "@typescript-eslint/eslint-plugin": "latest", + "@unocss/cli": "latest", + "@vitejs/plugin-vue": "latest", + "@vueuse/head": "^2.0.0", + "activitypub-types": "^1.0.3", + "bun-types": "latest", + "typescript": "latest", + "unocss": "latest", + "untyped": "^1.4.2", + "vite": "latest", + "vite-ssr": "^0.17.1", + "vue": "^3.3.9", + "vue-router": "^4.2.5", + "vue-tsc": "latest" + }, + "peerDependencies": { + "typescript": "^5.3.2" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.461.0", + "@iarna/toml": "^2.2.5", + "@json2csv/plainjs": "^7.0.6", + "@prisma/client": "^5.6.0", + "blurhash": "^2.0.5", + "bullmq": "latest", + "c12": "^1.10.0", + "chalk": "^5.3.0", + "cli-parser": "workspace:*", + "cli-table": "^0.3.11", + "config-manager": "workspace:*", + "eventemitter3": "^5.0.1", + "extract-zip": "^2.0.1", + "html-to-text": "^9.0.5", + "ioredis": "^5.3.2", + "ip-matching": "^2.1.2", + "iso-639-1": "^3.1.0", + "isomorphic-dompurify": "latest", + "jsonld": "^8.3.1", + "linkify-html": "^4.1.3", + "linkify-string": "^4.1.3", + "linkifyjs": "^4.1.3", + "log-manager": "workspace:*", + "marked": "latest", + "media-manager": "workspace:*", + "megalodon": "^10.0.0", + "meilisearch": "latest", + "merge-deep-ts": "^1.2.6", + "next-route-matcher": "^1.0.1", + "oauth4webapi": "^2.4.0", + "prisma": "^5.6.0", + "prisma-json-types-generator": "^3.0.4", + "prisma-redis-middleware": "^4.8.0", + "request-parser": "workspace:*", + "semver": "^7.5.4", + "sharp": "^0.33.0-rc.2", + "strip-ansi": "^7.1.0" } - ], - "repository": { - "type": "git", - "url": "git+https://github.com/lysand-org/lysand.git" - }, - "private": true, - "scripts": { - "dev": "bun run --watch index.ts", - "vite:dev": "bunx --bun vite pages", - "vite:build": "bunx --bun vite build pages", - "start": "NODE_ENV=production bun run dist/index.js --prod", - "migrate-dev": "bun prisma migrate dev", - "migrate": "bun prisma migrate deploy", - "lint": "bunx --bun eslint --config .eslintrc.cjs --ext .ts .", - "prod-build": "bunx --bun vite build pages && bun run build.ts", - "prisma": "DATABASE_URL=$(bun run prisma.ts) bunx prisma", - "generate": "bun prisma generate", - "benchmark:timeline": "bun run benchmarks/timelines.ts", - "cloc": "cloc . --exclude-dir node_modules,dist", - "cli": "bun run cli.ts" - }, - "trustedDependencies": [ - "@biomejs/biome", - "@prisma/client", - "@prisma/engines", - "esbuild", - "prisma", - "sharp" - ], - "devDependencies": { - "@biomejs/biome": "1.6.4", - "@julr/unocss-preset-forms": "^0.1.0", - "@microsoft/eslint-formatter-sarif": "^3.0.0", - "@types/cli-table": "^0.3.4", - "@types/html-to-text": "^9.0.4", - "@types/ioredis": "^5.0.0", - "@types/jsonld": "^1.5.13", - "@typescript-eslint/eslint-plugin": "latest", - "@typescript-eslint/parser": "latest", - "@unocss/cli": "latest", - "@vitejs/plugin-vue": "latest", - "@vueuse/head": "^2.0.0", - "activitypub-types": "^1.0.3", - "bun-types": "latest", - "eslint": "^8.54.0", - "eslint-config-prettier": "^9.0.0", - "eslint-formatter-pretty": "^6.0.0", - "eslint-formatter-summary": "^1.1.0", - "eslint-plugin-prettier": "^5.0.1", - "prettier": "^3.1.0", - "typescript": "latest", - "unocss": "latest", - "untyped": "^1.4.2", - "vite": "latest", - "vite-ssr": "^0.17.1", - "vue": "^3.3.9", - "vue-router": "^4.2.5", - "vue-tsc": "latest" - }, - "peerDependencies": { - "typescript": "^5.3.2" - }, - "dependencies": { - "@aws-sdk/client-s3": "^3.461.0", - "@iarna/toml": "^2.2.5", - "@json2csv/plainjs": "^7.0.6", - "@prisma/client": "^5.6.0", - "blurhash": "^2.0.5", - "bullmq": "latest", - "c12": "^1.10.0", - "chalk": "^5.3.0", - "cli-parser": "workspace:*", - "cli-table": "^0.3.11", - "config-manager": "workspace:*", - "eventemitter3": "^5.0.1", - "extract-zip": "^2.0.1", - "html-to-text": "^9.0.5", - "ioredis": "^5.3.2", - "ip-matching": "^2.1.2", - "iso-639-1": "^3.1.0", - "isomorphic-dompurify": "latest", - "jsonld": "^8.3.1", - "linkify-html": "^4.1.3", - "linkify-string": "^4.1.3", - "linkifyjs": "^4.1.3", - "log-manager": "workspace:*", - "marked": "latest", - "media-manager": "workspace:*", - "megalodon": "^10.0.0", - "meilisearch": "latest", - "merge-deep-ts": "^1.2.6", - "next-route-matcher": "^1.0.1", - "oauth4webapi": "^2.4.0", - "prisma": "^5.6.0", - "prisma-json-types-generator": "^3.0.4", - "prisma-redis-middleware": "^4.8.0", - "request-parser": "workspace:*", - "semver": "^7.5.4", - "sharp": "^0.33.0-rc.2", - "strip-ansi": "^7.1.0" - } -} \ No newline at end of file +} diff --git a/packages/cli-parser/cli-builder.type.ts b/packages/cli-parser/cli-builder.type.ts index 89c6dece..94553dbb 100644 --- a/packages/cli-parser/cli-builder.type.ts +++ b/packages/cli-parser/cli-builder.type.ts @@ -1,23 +1,23 @@ export interface CliParameter { - name: string; - /* Like -v for --version */ - shortName?: string; - /** - * If not positioned, the argument will need to be called with --name value instead of just value - * @default true - */ - positioned?: boolean; - /* Whether the argument needs a value (requires positioned to be false) */ - needsValue?: boolean; - optional?: true; - type: CliParameterType; - description?: string; + name: string; + /* Like -v for --version */ + shortName?: string; + /** + * If not positioned, the argument will need to be called with --name value instead of just value + * @default true + */ + positioned?: boolean; + /* Whether the argument needs a value (requires positioned to be false) */ + needsValue?: boolean; + optional?: true; + type: CliParameterType; + description?: string; } export enum CliParameterType { - STRING = "string", - NUMBER = "number", - BOOLEAN = "boolean", - ARRAY = "array", - EMPTY = "empty", + STRING = "string", + NUMBER = "number", + BOOLEAN = "boolean", + ARRAY = "array", + EMPTY = "empty", } diff --git a/packages/cli-parser/index.ts b/packages/cli-parser/index.ts index 4554f096..2dbb65c9 100644 --- a/packages/cli-parser/index.ts +++ b/packages/cli-parser/index.ts @@ -1,18 +1,18 @@ -import { CliParameterType, type CliParameter } from "./cli-builder.type"; import chalk from "chalk"; import strip from "strip-ansi"; +import { type CliParameter, CliParameterType } from "./cli-builder.type"; -export function startsWithArray(fullArray: any[], startArray: any[]) { - if (startArray.length > fullArray.length) { - return false; - } - return fullArray - .slice(0, startArray.length) - .every((value, index) => value === startArray[index]); +export function startsWithArray(fullArray: string[], startArray: string[]) { + if (startArray.length > fullArray.length) { + return false; + } + return fullArray + .slice(0, startArray.length) + .every((value, index) => value === startArray[index]); } interface TreeType { - [key: string]: CliCommand | TreeType; + [key: string]: CliCommand | TreeType; } /** @@ -20,178 +20,186 @@ interface TreeType { * @param commands Array of commands to register */ export class CliBuilder { - constructor(public commands: CliCommand[] = []) {} + constructor(public commands: CliCommand[] = []) {} - /** - * Add command to the CLI - * @throws Error if command already exists - * @param command Command to add - */ - registerCommand(command: CliCommand) { - if (this.checkIfCommandAlreadyExists(command)) { - throw new Error( - `Command category '${command.categories.join(" ")}' already exists` - ); - } - this.commands.push(command); - } + /** + * Add command to the CLI + * @throws Error if command already exists + * @param command Command to add + */ + registerCommand(command: CliCommand) { + if (this.checkIfCommandAlreadyExists(command)) { + throw new Error( + `Command category '${command.categories.join( + " ", + )}' already exists`, + ); + } + this.commands.push(command); + } - /** - * Add multiple commands to the CLI - * @throws Error if command already exists - * @param commands Commands to add - */ - registerCommands(commands: CliCommand[]) { - const existingCommand = commands.find(command => - this.checkIfCommandAlreadyExists(command) - ); - if (existingCommand) { - throw new Error( - `Command category '${existingCommand.categories.join(" ")}' already exists` - ); - } - this.commands.push(...commands); - } + /** + * Add multiple commands to the CLI + * @throws Error if command already exists + * @param commands Commands to add + */ + registerCommands(commands: CliCommand[]) { + const existingCommand = commands.find((command) => + this.checkIfCommandAlreadyExists(command), + ); + if (existingCommand) { + throw new Error( + `Command category '${existingCommand.categories.join( + " ", + )}' already exists`, + ); + } + this.commands.push(...commands); + } - /** - * Remove command from the CLI - * @param command Command to remove - */ - deregisterCommand(command: CliCommand) { - this.commands = this.commands.filter( - registeredCommand => registeredCommand !== command - ); - } + /** + * Remove command from the CLI + * @param command Command to remove + */ + deregisterCommand(command: CliCommand) { + this.commands = this.commands.filter( + (registeredCommand) => registeredCommand !== command, + ); + } - /** - * Remove multiple commands from the CLI - * @param commands Commands to remove - */ - deregisterCommands(commands: CliCommand[]) { - this.commands = this.commands.filter( - registeredCommand => !commands.includes(registeredCommand) - ); - } + /** + * Remove multiple commands from the CLI + * @param commands Commands to remove + */ + deregisterCommands(commands: CliCommand[]) { + this.commands = this.commands.filter( + (registeredCommand) => !commands.includes(registeredCommand), + ); + } - checkIfCommandAlreadyExists(command: CliCommand) { - return this.commands.some( - registeredCommand => - registeredCommand.categories.length == - command.categories.length && - registeredCommand.categories.every( - (category, index) => category === command.categories[index] - ) - ); - } + checkIfCommandAlreadyExists(command: CliCommand) { + return this.commands.some( + (registeredCommand) => + registeredCommand.categories.length === + command.categories.length && + registeredCommand.categories.every( + (category, index) => category === command.categories[index], + ), + ); + } - /** - * Get relevant args for the command (without executable or runtime) - * @param args Arguments passed to the CLI - */ - private getRelevantArgs(args: string[]) { - if (args[0].startsWith("./")) { - // Formatted like ./cli.ts [command] - return args.slice(1); - } else if (args[0].includes("bun")) { - // Formatted like bun cli.ts [command] - return args.slice(2); - } else { - return args; - } - } + /** + * Get relevant args for the command (without executable or runtime) + * @param args Arguments passed to the CLI + */ + private getRelevantArgs(args: string[]) { + if (args[0].startsWith("./")) { + // Formatted like ./cli.ts [command] + return args.slice(1); + } + if (args[0].includes("bun")) { + // Formatted like bun cli.ts [command] + return args.slice(2); + } + return args; + } - /** - * Turn raw system args into a CLI command and run it - * @param args Args directly from process.argv - */ - async processArgs(args: string[]) { - const revelantArgs = this.getRelevantArgs(args); + /** + * Turn raw system args into a CLI command and run it + * @param args Args directly from process.argv + */ + async processArgs(args: string[]) { + const revelantArgs = this.getRelevantArgs(args); - // Handle "-h", "--help" and "help" commands as special cases - if (revelantArgs.length === 1) { - if (["-h", "--help", "help"].includes(revelantArgs[0])) { - this.displayHelp(); - return; - } - } + // Handle "-h", "--help" and "help" commands as special cases + if (revelantArgs.length === 1) { + if (["-h", "--help", "help"].includes(revelantArgs[0])) { + this.displayHelp(); + return; + } + } - // Find revelant command - // Search for a command with as many categories matching args as possible - const matchingCommands = this.commands.filter(command => - startsWithArray(revelantArgs, command.categories) - ); + // Find revelant command + // Search for a command with as many categories matching args as possible + const matchingCommands = this.commands.filter((command) => + startsWithArray(revelantArgs, command.categories), + ); - if (matchingCommands.length === 0) { - console.log( - `Invalid command "${revelantArgs.join(" ")}". Please use the ${chalk.bold("help")} command to see a list of commands` - ); - return 0; - } + if (matchingCommands.length === 0) { + console.log( + `Invalid command "${revelantArgs.join( + " ", + )}". Please use the ${chalk.bold( + "help", + )} command to see a list of commands`, + ); + return 0; + } - // Get command with largest category size - const command = matchingCommands.reduce((prev, current) => - prev.categories.length > current.categories.length ? prev : current - ); + // Get command with largest category size + const command = matchingCommands.reduce((prev, current) => + prev.categories.length > current.categories.length ? prev : current, + ); - const argsWithoutCategories = revelantArgs.slice( - command.categories.length - ); + const argsWithoutCategories = revelantArgs.slice( + command.categories.length, + ); - return await command.run(argsWithoutCategories); - } + return await command.run(argsWithoutCategories); + } - /** - * Recursively urns the commands into a tree where subcategories mark each sub-branch - * @example - * ```txt - * user verify - * user delete - * user new admin - * user new - * -> - * user - * verify - * delete - * new - * admin - * "" - * ``` - */ - getCommandTree(commands: CliCommand[]): TreeType { - const tree: TreeType = {}; + /** + * Recursively urns the commands into a tree where subcategories mark each sub-branch + * @example + * ```txt + * user verify + * user delete + * user new admin + * user new + * -> + * user + * verify + * delete + * new + * admin + * "" + * ``` + */ + getCommandTree(commands: CliCommand[]): TreeType { + const tree: TreeType = {}; - for (const command of commands) { - let currentLevel = tree; // Start at the root + for (const command of commands) { + let currentLevel = tree; // Start at the root - // Split the command into parts and iterate over them - for (const part of command.categories) { - // If this part doesn't exist in the current level of the tree, add it (__proto__ check to prevent prototype pollution) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!currentLevel[part] && part !== "__proto__") { - // If this is the last part of the command, add the command itself - if ( - part === - command.categories[command.categories.length - 1] - ) { - currentLevel[part] = command; - break; - } - currentLevel[part] = {}; - } + // Split the command into parts and iterate over them + for (const part of command.categories) { + // If this part doesn't exist in the current level of the tree, add it (__proto__ check to prevent prototype pollution) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!currentLevel[part] && part !== "__proto__") { + // If this is the last part of the command, add the command itself + if ( + part === + command.categories[command.categories.length - 1] + ) { + currentLevel[part] = command; + break; + } + currentLevel[part] = {}; + } - // Move down to the next level of the tree - currentLevel = currentLevel[part] as TreeType; - } - } + // Move down to the next level of the tree + currentLevel = currentLevel[part] as TreeType; + } + } - return tree; - } + return tree; + } - /** - * Display help for every command in a tree manner - */ - displayHelp() { - /* + /** + * Display help for every command in a tree manner + */ + displayHelp() { + /* user set admin: List of admin commands @@ -204,217 +212,242 @@ export class CliBuilder { verify ... */ - const tree = this.getCommandTree(this.commands); - let writeBuffer = ""; + const tree = this.getCommandTree(this.commands); + let writeBuffer = ""; - const displayTree = (tree: TreeType, depth = 0) => { - for (const [key, value] of Object.entries(tree)) { - if (value instanceof CliCommand) { - writeBuffer += `${" ".repeat(depth)}${chalk.blue(key)}|${chalk.underline(value.description)}\n`; - const positionedArgs = value.argTypes.filter( - arg => arg.positioned ?? true - ); - const unpositionedArgs = value.argTypes.filter( - arg => !(arg.positioned ?? true) - ); + const displayTree = (tree: TreeType, depth = 0) => { + for (const [key, value] of Object.entries(tree)) { + if (value instanceof CliCommand) { + writeBuffer += `${" ".repeat(depth)}${chalk.blue( + key, + )}|${chalk.underline(value.description)}\n`; + const positionedArgs = value.argTypes.filter( + (arg) => arg.positioned ?? true, + ); + const unpositionedArgs = value.argTypes.filter( + (arg) => !(arg.positioned ?? true), + ); - for (const arg of positionedArgs) { - writeBuffer += `${" ".repeat(depth + 1)}${chalk.green( - arg.name - )}|${ - arg.description ?? "(no description)" - } ${arg.optional ? chalk.gray("(optional)") : ""}\n`; - } - for (const arg of unpositionedArgs) { - writeBuffer += `${" ".repeat(depth + 1)}${chalk.yellow("--" + arg.name)}${arg.shortName ? ", " + chalk.yellow("-" + arg.shortName) : ""}|${ - arg.description ?? "(no description)" - } ${arg.optional ? chalk.gray("(optional)") : ""}\n`; - } + for (const arg of positionedArgs) { + writeBuffer += `${" ".repeat( + depth + 1, + )}${chalk.green(arg.name)}|${ + arg.description ?? "(no description)" + } ${arg.optional ? chalk.gray("(optional)") : ""}\n`; + } + for (const arg of unpositionedArgs) { + writeBuffer += `${" ".repeat( + depth + 1, + )}${chalk.yellow(`--${arg.name}`)}${ + arg.shortName + ? `, ${chalk.yellow(`-${arg.shortName}`)}` + : "" + }|${arg.description ?? "(no description)"} ${ + arg.optional ? chalk.gray("(optional)") : "" + }\n`; + } - if (value.example) { - writeBuffer += `${" ".repeat(depth + 1)}${chalk.bold("Example:")} ${chalk.bgGray( - value.example - )}\n`; - } - } else { - writeBuffer += `${" ".repeat(depth)}${chalk.blue(key)}\n`; - displayTree(value, depth + 1); - } - } - }; + if (value.example) { + writeBuffer += `${" ".repeat(depth + 1)}${chalk.bold( + "Example:", + )} ${chalk.bgGray(value.example)}\n`; + } + } else { + writeBuffer += `${" ".repeat(depth)}${chalk.blue( + key, + )}\n`; + displayTree(value, depth + 1); + } + } + }; - displayTree(tree); + displayTree(tree); - // Replace all "|" with enough dots so that the text on the left + the dots = the same length - const optimal_length = Number( - // @ts-expect-error Slightly hacky but works - writeBuffer.split("\n").reduce((prev, current) => { - // If previousValue is empty - if (!prev) - return current.includes("|") - ? current.split("|")[0].length - : 0; - if (!current.includes("|")) return prev; - const [left] = current.split("|"); - // Strip ANSI color codes or they mess up the length - return Math.max(Number(prev), strip(left).length); - }) - ); + // Replace all "|" with enough dots so that the text on the left + the dots = the same length + const optimal_length = Number( + writeBuffer + .split("\n") + // @ts-expect-error I don't know how this works and I don't want to know + .reduce((prev, current) => { + // If previousValue is empty + if (!prev) + return current.includes("|") + ? current.split("|")[0].length + : 0; + if (!current.includes("|")) return prev; + const [left] = current.split("|"); + // Strip ANSI color codes or they mess up the length + return Math.max(Number(prev), Bun.stringWidth(left)); + }), + ); - for (const line of writeBuffer.split("\n")) { - const [left, right] = line.split("|"); - if (!right) { - console.log(left); - continue; - } - // Strip ANSI color codes or they mess up the length - const dots = ".".repeat(optimal_length + 5 - strip(left).length); - console.log(`${left}${dots}${right}`); - } - } + for (const line of writeBuffer.split("\n")) { + const [left, right] = line.split("|"); + if (!right) { + console.log(left); + continue; + } + // Strip ANSI color codes or they mess up the length + const dots = ".".repeat(optimal_length + 5 - Bun.stringWidth(left)); + console.log(`${left}${dots}${right}`); + } + } } type ExecuteFunction = ( - instance: CliCommand, - args: Partial - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + instance: CliCommand, + args: Partial, + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type ) => Promise | Promise | number | void; /** * A command that can be executed from the command line * @param categories Example: `["user", "create"]` for the command `./cli user create --name John` */ -export class CliCommand { - constructor( - public categories: string[], - public argTypes: CliParameter[], - private execute: ExecuteFunction, - public description?: string, - public example?: string - ) {} - /** - * Display help message for the command - * formatted with Chalk and with emojis - */ - displayHelp() { - const positionedArgs = this.argTypes.filter( - arg => arg.positioned ?? true - ); - const unpositionedArgs = this.argTypes.filter( - arg => !(arg.positioned ?? true) - ); - const helpMessage = ` +// biome-ignore lint/suspicious/noExplicitAny: +export class CliCommand { + constructor( + public categories: string[], + public argTypes: CliParameter[], + private execute: ExecuteFunction, + public description?: string, + public example?: string, + ) {} + + /** + * Display help message for the command + * formatted with Chalk and with emojis + */ + displayHelp() { + const positionedArgs = this.argTypes.filter( + (arg) => arg.positioned ?? true, + ); + const unpositionedArgs = this.argTypes.filter( + (arg) => !(arg.positioned ?? true), + ); + const helpMessage = ` ${chalk.green("📚 Command:")} ${chalk.yellow(this.categories.join(" "))} ${this.description ? `${chalk.cyan(this.description)}\n` : ""} ${chalk.magenta("🔧 Arguments:")} ${positionedArgs - .map( - arg => - `${chalk.bold(arg.name)}: ${chalk.blue(arg.description ?? "(no description)")} ${ - arg.optional ? chalk.gray("(optional)") : "" - }` - ) - .join("\n")} + .map( + (arg) => + `${chalk.bold(arg.name)}: ${chalk.blue( + arg.description ?? "(no description)", + )} ${arg.optional ? chalk.gray("(optional)") : ""}`, + ) + .join("\n")} ${unpositionedArgs - .map( - arg => - `--${chalk.bold(arg.name)}${arg.shortName ? `, -${arg.shortName}` : ""}: ${chalk.blue(arg.description ?? "(no description)")} ${ - arg.optional ? chalk.gray("(optional)") : "" - }` - ) - .join( - "\n" - )}${this.example ? `\n${chalk.magenta("🚀 Example:")}\n${chalk.bgGray(this.example)}` : ""} + .map( + (arg) => + `--${chalk.bold(arg.name)}${ + arg.shortName ? `, -${arg.shortName}` : "" + }: ${chalk.blue(arg.description ?? "(no description)")} ${ + arg.optional ? chalk.gray("(optional)") : "" + }`, + ) + .join("\n")}${ + this.example + ? `\n${chalk.magenta("🚀 Example:")}\n${chalk.bgGray(this.example)}` + : "" +} `; - console.log(helpMessage); - } + console.log(helpMessage); + } - /** - * Parses string array arguments into a full JavaScript object - * @param argsWithoutCategories - * @returns - */ - private parseArgs(argsWithoutCategories: string[]): Record { - const parsedArgs: Record = {}; - let currentParameter: CliParameter | null = null; + /** + * Parses string array arguments into a full JavaScript object + * @param argsWithoutCategories + * @returns + */ + private parseArgs( + argsWithoutCategories: string[], + ): Record { + const parsedArgs: Record = + {}; + let currentParameter: CliParameter | null = null; - for (let i = 0; i < argsWithoutCategories.length; i++) { - const arg = argsWithoutCategories[i]; + for (let i = 0; i < argsWithoutCategories.length; i++) { + const arg = argsWithoutCategories[i]; - if (arg.startsWith("--")) { - const argName = arg.substring(2); - currentParameter = - this.argTypes.find(argType => argType.name === argName) || - null; - if (currentParameter && !currentParameter.needsValue) { - parsedArgs[argName] = true; - currentParameter = null; - } else if (currentParameter && currentParameter.needsValue) { - parsedArgs[argName] = this.castArgValue( - argsWithoutCategories[i + 1], - currentParameter.type - ); - i++; - currentParameter = null; - } - } else if (arg.startsWith("-")) { - const shortName = arg.substring(1); - const argType = this.argTypes.find( - argType => argType.shortName === shortName - ); - if (argType && !argType.needsValue) { - parsedArgs[argType.name] = true; - } else if (argType && argType.needsValue) { - parsedArgs[argType.name] = this.castArgValue( - argsWithoutCategories[i + 1], - argType.type - ); - i++; - } - } else if (currentParameter) { - parsedArgs[currentParameter.name] = this.castArgValue( - arg, - currentParameter.type - ); - currentParameter = null; - } else { - const positionedArgType = this.argTypes.find( - argType => argType.positioned && !parsedArgs[argType.name] - ); - if (positionedArgType) { - parsedArgs[positionedArgType.name] = this.castArgValue( - arg, - positionedArgType.type - ); - } - } - } + if (arg.startsWith("--")) { + const argName = arg.substring(2); + currentParameter = + this.argTypes.find((argType) => argType.name === argName) || + null; + if (currentParameter && !currentParameter.needsValue) { + parsedArgs[argName] = true; + currentParameter = null; + } else if (currentParameter?.needsValue) { + parsedArgs[argName] = this.castArgValue( + argsWithoutCategories[i + 1], + currentParameter.type, + ); + i++; + currentParameter = null; + } + } else if (arg.startsWith("-")) { + const shortName = arg.substring(1); + const argType = this.argTypes.find( + (argType) => argType.shortName === shortName, + ); + if (argType && !argType.needsValue) { + parsedArgs[argType.name] = true; + } else if (argType?.needsValue) { + parsedArgs[argType.name] = this.castArgValue( + argsWithoutCategories[i + 1], + argType.type, + ); + i++; + } + } else if (currentParameter) { + parsedArgs[currentParameter.name] = this.castArgValue( + arg, + currentParameter.type, + ); + currentParameter = null; + } else { + const positionedArgType = this.argTypes.find( + (argType) => + argType.positioned && !parsedArgs[argType.name], + ); + if (positionedArgType) { + parsedArgs[positionedArgType.name] = this.castArgValue( + arg, + positionedArgType.type, + ); + } + } + } - return parsedArgs; - } + return parsedArgs; + } - private castArgValue(value: string, type: CliParameter["type"]): any { - switch (type) { - case CliParameterType.STRING: - return value; - case CliParameterType.NUMBER: - return Number(value); - case CliParameterType.BOOLEAN: - return value === "true"; - case CliParameterType.ARRAY: - return value.split(","); - default: - return value; - } - } + private castArgValue( + value: string, + type: CliParameter["type"], + ): string | number | boolean | string[] { + switch (type) { + case CliParameterType.STRING: + return value; + case CliParameterType.NUMBER: + return Number(value); + case CliParameterType.BOOLEAN: + return value === "true"; + case CliParameterType.ARRAY: + return value.split(","); + default: + return value; + } + } - /** - * Runs the execute function with the parsed parameters as an argument - */ - async run(argsWithoutCategories: string[]) { - const args = this.parseArgs(argsWithoutCategories); - return await this.execute(this, args as any); - } + /** + * Runs the execute function with the parsed parameters as an argument + */ + async run(argsWithoutCategories: string[]) { + const args = this.parseArgs(argsWithoutCategories); + return await this.execute(this, args as T); + } } diff --git a/packages/cli-parser/package.json b/packages/cli-parser/package.json index 50e28772..83f27af5 100644 --- a/packages/cli-parser/package.json +++ b/packages/cli-parser/package.json @@ -1,6 +1,6 @@ { - "name": "cli-parser", - "version": "0.0.0", - "main": "index.ts", - "dependencies": { "chalk": "^5.3.0", "strip-ansi": "^7.1.0" } -} \ No newline at end of file + "name": "cli-parser", + "version": "0.0.0", + "main": "index.ts", + "dependencies": { "chalk": "^5.3.0", "strip-ansi": "^7.1.0" } +} diff --git a/packages/cli-parser/tests/cli-builder.test.ts b/packages/cli-parser/tests/cli-builder.test.ts index 96ffdb08..e8fe2c92 100644 --- a/packages/cli-parser/tests/cli-builder.test.ts +++ b/packages/cli-parser/tests/cli-builder.test.ts @@ -1,485 +1,488 @@ -// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts -import { CliCommand, CliBuilder, startsWithArray } from ".."; -import { describe, beforeEach, it, expect, jest, spyOn } from "bun:test"; +import { beforeEach, describe, expect, it, jest, spyOn } from "bun:test"; import stripAnsi from "strip-ansi"; +// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts +import { CliBuilder, CliCommand, startsWithArray } from ".."; import { CliParameterType } from "../cli-builder.type"; describe("startsWithArray", () => { - it("should return true when fullArray starts with startArray", () => { - const fullArray = ["a", "b", "c", "d", "e"]; - const startArray = ["a", "b", "c"]; - expect(startsWithArray(fullArray, startArray)).toBe(true); - }); + it("should return true when fullArray starts with startArray", () => { + const fullArray = ["a", "b", "c", "d", "e"]; + const startArray = ["a", "b", "c"]; + expect(startsWithArray(fullArray, startArray)).toBe(true); + }); - it("should return false when fullArray does not start with startArray", () => { - const fullArray = ["a", "b", "c", "d", "e"]; - const startArray = ["b", "c", "d"]; - expect(startsWithArray(fullArray, startArray)).toBe(false); - }); + it("should return false when fullArray does not start with startArray", () => { + const fullArray = ["a", "b", "c", "d", "e"]; + const startArray = ["b", "c", "d"]; + expect(startsWithArray(fullArray, startArray)).toBe(false); + }); - it("should return true when startArray is empty", () => { - const fullArray = ["a", "b", "c", "d", "e"]; - const startArray: any[] = []; - expect(startsWithArray(fullArray, startArray)).toBe(true); - }); + it("should return true when startArray is empty", () => { + const fullArray = ["a", "b", "c", "d", "e"]; + const startArray: string[] = []; + expect(startsWithArray(fullArray, startArray)).toBe(true); + }); - it("should return false when fullArray is shorter than startArray", () => { - const fullArray = ["a", "b", "c"]; - const startArray = ["a", "b", "c", "d", "e"]; - expect(startsWithArray(fullArray, startArray)).toBe(false); - }); + it("should return false when fullArray is shorter than startArray", () => { + const fullArray = ["a", "b", "c"]; + const startArray = ["a", "b", "c", "d", "e"]; + expect(startsWithArray(fullArray, startArray)).toBe(false); + }); }); describe("CliCommand", () => { - let cliCommand: CliCommand; + let cliCommand: CliCommand; - beforeEach(() => { - cliCommand = new CliCommand( - ["category1", "category2"], - [ - { - name: "arg1", - type: CliParameterType.STRING, - needsValue: true, - }, - { - name: "arg2", - shortName: "a", - type: CliParameterType.NUMBER, - needsValue: true, - }, - { - name: "arg3", - type: CliParameterType.BOOLEAN, - needsValue: false, - }, - { - name: "arg4", - type: CliParameterType.ARRAY, - needsValue: true, - }, - ], - () => { - // Do nothing - } - ); - }); + beforeEach(() => { + cliCommand = new CliCommand( + ["category1", "category2"], + [ + { + name: "arg1", + type: CliParameterType.STRING, + needsValue: true, + }, + { + name: "arg2", + shortName: "a", + type: CliParameterType.NUMBER, + needsValue: true, + }, + { + name: "arg3", + type: CliParameterType.BOOLEAN, + needsValue: false, + }, + { + name: "arg4", + type: CliParameterType.ARRAY, + needsValue: true, + }, + ], + () => { + // Do nothing + }, + ); + }); - it("should parse string arguments correctly", () => { - const args = cliCommand["parseArgs"]([ - "--arg1", - "value1", - "--arg2", - "42", - "--arg3", - "--arg4", - "value1,value2", - ]); - expect(args).toEqual({ - arg1: "value1", - arg2: 42, - arg3: true, - arg4: ["value1", "value2"], - }); - }); + it("should parse string arguments correctly", () => { + // @ts-expect-error Testing private method + const args = cliCommand.parseArgs([ + "--arg1", + "value1", + "--arg2", + "42", + "--arg3", + "--arg4", + "value1,value2", + ]); + expect(args).toEqual({ + arg1: "value1", + arg2: 42, + arg3: true, + arg4: ["value1", "value2"], + }); + }); - it("should parse short names for arguments too", () => { - const args = cliCommand["parseArgs"]([ - "--arg1", - "value1", - "-a", - "42", - "--arg3", - "--arg4", - "value1,value2", - ]); - expect(args).toEqual({ - arg1: "value1", - arg2: 42, - arg3: true, - arg4: ["value1", "value2"], - }); - }); + it("should parse short names for arguments too", () => { + // @ts-expect-error Testing private method + const args = cliCommand.parseArgs([ + "--arg1", + "value1", + "-a", + "42", + "--arg3", + "--arg4", + "value1,value2", + ]); + expect(args).toEqual({ + arg1: "value1", + arg2: 42, + arg3: true, + arg4: ["value1", "value2"], + }); + }); - it("should cast argument values correctly", () => { - expect(cliCommand["castArgValue"]("42", CliParameterType.NUMBER)).toBe( - 42 - ); - expect( - cliCommand["castArgValue"]("true", CliParameterType.BOOLEAN) - ).toBe(true); - expect( - cliCommand["castArgValue"]("value1,value2", CliParameterType.ARRAY) - ).toEqual(["value1", "value2"]); - }); + it("should cast argument values correctly", () => { + // @ts-expect-error Testing private method + expect(cliCommand.castArgValue("42", CliParameterType.NUMBER)).toBe(42); + // @ts-expect-error Testing private method + expect(cliCommand.castArgValue("true", CliParameterType.BOOLEAN)).toBe( + true, + ); + expect( + // @ts-expect-error Testing private method + cliCommand.castArgValue("value1,value2", CliParameterType.ARRAY), + ).toEqual(["value1", "value2"]); + }); - it("should run the execute function with the parsed parameters", async () => { - const mockExecute = jest.fn(); - cliCommand = new CliCommand( - ["category1", "category2"], - [ - { - name: "arg1", - type: CliParameterType.STRING, - needsValue: true, - }, - { - name: "arg2", - type: CliParameterType.NUMBER, - needsValue: true, - }, - { - name: "arg3", - type: CliParameterType.BOOLEAN, - needsValue: false, - }, - { - name: "arg4", - type: CliParameterType.ARRAY, - needsValue: true, - }, - ], - mockExecute - ); + it("should run the execute function with the parsed parameters", async () => { + const mockExecute = jest.fn(); + cliCommand = new CliCommand( + ["category1", "category2"], + [ + { + name: "arg1", + type: CliParameterType.STRING, + needsValue: true, + }, + { + name: "arg2", + type: CliParameterType.NUMBER, + needsValue: true, + }, + { + name: "arg3", + type: CliParameterType.BOOLEAN, + needsValue: false, + }, + { + name: "arg4", + type: CliParameterType.ARRAY, + needsValue: true, + }, + ], + mockExecute, + ); - await cliCommand.run([ - "--arg1", - "value1", - "--arg2", - "42", - "--arg3", - "--arg4", - "value1,value2", - ]); - expect(mockExecute).toHaveBeenCalledWith(cliCommand, { - arg1: "value1", - arg2: 42, - arg3: true, - arg4: ["value1", "value2"], - }); - }); + await cliCommand.run([ + "--arg1", + "value1", + "--arg2", + "42", + "--arg3", + "--arg4", + "value1,value2", + ]); + expect(mockExecute).toHaveBeenCalledWith(cliCommand, { + arg1: "value1", + arg2: 42, + arg3: true, + arg4: ["value1", "value2"], + }); + }); - it("should work with a mix of positioned and non-positioned arguments", async () => { - const mockExecute = jest.fn(); - cliCommand = new CliCommand( - ["category1", "category2"], - [ - { - name: "arg1", - type: CliParameterType.STRING, - needsValue: true, - }, - { - name: "arg2", - type: CliParameterType.NUMBER, - needsValue: true, - }, - { - name: "arg3", - type: CliParameterType.BOOLEAN, - needsValue: false, - }, - { - name: "arg4", - type: CliParameterType.ARRAY, - needsValue: true, - }, - { - name: "arg5", - type: CliParameterType.STRING, - needsValue: true, - positioned: true, - }, - ], - mockExecute - ); + it("should work with a mix of positioned and non-positioned arguments", async () => { + const mockExecute = jest.fn(); + cliCommand = new CliCommand( + ["category1", "category2"], + [ + { + name: "arg1", + type: CliParameterType.STRING, + needsValue: true, + }, + { + name: "arg2", + type: CliParameterType.NUMBER, + needsValue: true, + }, + { + name: "arg3", + type: CliParameterType.BOOLEAN, + needsValue: false, + }, + { + name: "arg4", + type: CliParameterType.ARRAY, + needsValue: true, + }, + { + name: "arg5", + type: CliParameterType.STRING, + needsValue: true, + positioned: true, + }, + ], + mockExecute, + ); - await cliCommand.run([ - "--arg1", - "value1", - "--arg2", - "42", - "--arg3", - "--arg4", - "value1,value2", - "value5", - ]); + await cliCommand.run([ + "--arg1", + "value1", + "--arg2", + "42", + "--arg3", + "--arg4", + "value1,value2", + "value5", + ]); - expect(mockExecute).toHaveBeenCalledWith(cliCommand, { - arg1: "value1", - arg2: 42, - arg3: true, - arg4: ["value1", "value2"], - arg5: "value5", - }); - }); + expect(mockExecute).toHaveBeenCalledWith(cliCommand, { + arg1: "value1", + arg2: 42, + arg3: true, + arg4: ["value1", "value2"], + arg5: "value5", + }); + }); - it("should display help message correctly", () => { - const consoleLogSpy = spyOn(console, "log").mockImplementation(() => { - // Do nothing - }); + it("should display help message correctly", () => { + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => { + // Do nothing + }); - cliCommand = new CliCommand( - ["category1", "category2"], - [ - { - name: "arg1", - type: CliParameterType.STRING, - needsValue: true, - description: "Argument 1", - optional: true, - }, - { - name: "arg2", - type: CliParameterType.NUMBER, - needsValue: true, - description: "Argument 2", - }, - { - name: "arg3", - type: CliParameterType.BOOLEAN, - needsValue: false, - description: "Argument 3", - optional: true, - positioned: false, - }, - { - name: "arg4", - type: CliParameterType.ARRAY, - needsValue: true, - description: "Argument 4", - positioned: false, - }, - ], - () => { - // Do nothing - }, - "This is a test command", - "category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2" - ); + cliCommand = new CliCommand( + ["category1", "category2"], + [ + { + name: "arg1", + type: CliParameterType.STRING, + needsValue: true, + description: "Argument 1", + optional: true, + }, + { + name: "arg2", + type: CliParameterType.NUMBER, + needsValue: true, + description: "Argument 2", + }, + { + name: "arg3", + type: CliParameterType.BOOLEAN, + needsValue: false, + description: "Argument 3", + optional: true, + positioned: false, + }, + { + name: "arg4", + type: CliParameterType.ARRAY, + needsValue: true, + description: "Argument 4", + positioned: false, + }, + ], + () => { + // Do nothing + }, + "This is a test command", + "category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2", + ); - cliCommand.displayHelp(); + cliCommand.displayHelp(); - const loggedString = consoleLogSpy.mock.calls.map(call => - stripAnsi(call[0]) - )[0]; + const loggedString = consoleLogSpy.mock.calls.map((call) => + stripAnsi(call[0]), + )[0]; - consoleLogSpy.mockRestore(); + consoleLogSpy.mockRestore(); - expect(loggedString).toContain("📚 Command: category1 category2"); - expect(loggedString).toContain("🔧 Arguments:"); - expect(loggedString).toContain("arg1: Argument 1 (optional)"); - expect(loggedString).toContain("arg2: Argument 2"); - expect(loggedString).toContain("--arg3: Argument 3 (optional)"); - expect(loggedString).toContain("--arg4: Argument 4"); - expect(loggedString).toContain("🚀 Example:"); - expect(loggedString).toContain( - "category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2" - ); - }); + expect(loggedString).toContain("📚 Command: category1 category2"); + expect(loggedString).toContain("🔧 Arguments:"); + expect(loggedString).toContain("arg1: Argument 1 (optional)"); + expect(loggedString).toContain("arg2: Argument 2"); + expect(loggedString).toContain("--arg3: Argument 3 (optional)"); + expect(loggedString).toContain("--arg4: Argument 4"); + expect(loggedString).toContain("🚀 Example:"); + expect(loggedString).toContain( + "category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2", + ); + }); }); describe("CliBuilder", () => { - let cliBuilder: CliBuilder; - let mockCommand1: CliCommand; - let mockCommand2: CliCommand; + let cliBuilder: CliBuilder; + let mockCommand1: CliCommand; + let mockCommand2: CliCommand; - beforeEach(() => { - mockCommand1 = new CliCommand(["category1"], [], jest.fn()); - mockCommand2 = new CliCommand(["category2"], [], jest.fn()); - cliBuilder = new CliBuilder([mockCommand1]); - }); + beforeEach(() => { + mockCommand1 = new CliCommand(["category1"], [], jest.fn()); + mockCommand2 = new CliCommand(["category2"], [], jest.fn()); + cliBuilder = new CliBuilder([mockCommand1]); + }); - it("should register a command correctly", () => { - cliBuilder.registerCommand(mockCommand2); - expect(cliBuilder.commands).toContain(mockCommand2); - }); + it("should register a command correctly", () => { + cliBuilder.registerCommand(mockCommand2); + expect(cliBuilder.commands).toContain(mockCommand2); + }); - it("should register multiple commands correctly", () => { - const mockCommand3 = new CliCommand(["category3"], [], jest.fn()); - cliBuilder.registerCommands([mockCommand2, mockCommand3]); - expect(cliBuilder.commands).toContain(mockCommand2); - expect(cliBuilder.commands).toContain(mockCommand3); - }); + it("should register multiple commands correctly", () => { + const mockCommand3 = new CliCommand(["category3"], [], jest.fn()); + cliBuilder.registerCommands([mockCommand2, mockCommand3]); + expect(cliBuilder.commands).toContain(mockCommand2); + expect(cliBuilder.commands).toContain(mockCommand3); + }); - it("should error when adding duplicates", () => { - expect(() => { - cliBuilder.registerCommand(mockCommand1); - }).toThrow(); + it("should error when adding duplicates", () => { + expect(() => { + cliBuilder.registerCommand(mockCommand1); + }).toThrow(); - expect(() => { - cliBuilder.registerCommands([mockCommand1]); - }).toThrow(); - }); + expect(() => { + cliBuilder.registerCommands([mockCommand1]); + }).toThrow(); + }); - it("should deregister a command correctly", () => { - cliBuilder.deregisterCommand(mockCommand1); - expect(cliBuilder.commands).not.toContain(mockCommand1); - }); + it("should deregister a command correctly", () => { + cliBuilder.deregisterCommand(mockCommand1); + expect(cliBuilder.commands).not.toContain(mockCommand1); + }); - it("should deregister multiple commands correctly", () => { - cliBuilder.registerCommand(mockCommand2); - cliBuilder.deregisterCommands([mockCommand1, mockCommand2]); - expect(cliBuilder.commands).not.toContain(mockCommand1); - expect(cliBuilder.commands).not.toContain(mockCommand2); - }); + it("should deregister multiple commands correctly", () => { + cliBuilder.registerCommand(mockCommand2); + cliBuilder.deregisterCommands([mockCommand1, mockCommand2]); + expect(cliBuilder.commands).not.toContain(mockCommand1); + expect(cliBuilder.commands).not.toContain(mockCommand2); + }); - it("should process args correctly", async () => { - const mockExecute = jest.fn(); - const mockCommand = new CliCommand( - ["category1", "sub1"], - [ - { - name: "arg1", - type: CliParameterType.STRING, - needsValue: true, - positioned: false, - }, - ], - mockExecute - ); - cliBuilder.registerCommand(mockCommand); - await cliBuilder.processArgs([ - "./cli.ts", - "category1", - "sub1", - "--arg1", - "value1", - ]); - expect(mockExecute).toHaveBeenCalledWith(expect.anything(), { - arg1: "value1", - }); - }); + it("should process args correctly", async () => { + const mockExecute = jest.fn(); + const mockCommand = new CliCommand( + ["category1", "sub1"], + [ + { + name: "arg1", + type: CliParameterType.STRING, + needsValue: true, + positioned: false, + }, + ], + mockExecute, + ); + cliBuilder.registerCommand(mockCommand); + await cliBuilder.processArgs([ + "./cli.ts", + "category1", + "sub1", + "--arg1", + "value1", + ]); + expect(mockExecute).toHaveBeenCalledWith(expect.anything(), { + arg1: "value1", + }); + }); - describe("should build command tree", () => { - let cliBuilder: CliBuilder; - let mockCommand1: CliCommand; - let mockCommand2: CliCommand; - let mockCommand3: CliCommand; - let mockCommand4: CliCommand; - let mockCommand5: CliCommand; + describe("should build command tree", () => { + let cliBuilder: CliBuilder; + let mockCommand1: CliCommand; + let mockCommand2: CliCommand; + let mockCommand3: CliCommand; + let mockCommand4: CliCommand; + let mockCommand5: CliCommand; - beforeEach(() => { - mockCommand1 = new CliCommand(["user", "verify"], [], jest.fn()); - mockCommand2 = new CliCommand(["user", "delete"], [], jest.fn()); - mockCommand3 = new CliCommand( - ["user", "new", "admin"], - [], - jest.fn() - ); - mockCommand4 = new CliCommand(["user", "new"], [], jest.fn()); - mockCommand5 = new CliCommand(["admin", "delete"], [], jest.fn()); - cliBuilder = new CliBuilder([ - mockCommand1, - mockCommand2, - mockCommand3, - mockCommand4, - mockCommand5, - ]); - }); + beforeEach(() => { + mockCommand1 = new CliCommand(["user", "verify"], [], jest.fn()); + mockCommand2 = new CliCommand(["user", "delete"], [], jest.fn()); + mockCommand3 = new CliCommand( + ["user", "new", "admin"], + [], + jest.fn(), + ); + mockCommand4 = new CliCommand(["user", "new"], [], jest.fn()); + mockCommand5 = new CliCommand(["admin", "delete"], [], jest.fn()); + cliBuilder = new CliBuilder([ + mockCommand1, + mockCommand2, + mockCommand3, + mockCommand4, + mockCommand5, + ]); + }); - it("should build the command tree correctly", () => { - const tree = cliBuilder.getCommandTree(cliBuilder.commands); - expect(tree).toEqual({ - user: { - verify: mockCommand1, - delete: mockCommand2, - new: { - admin: mockCommand3, - }, - }, - admin: { - delete: mockCommand5, - }, - }); - }); + it("should build the command tree correctly", () => { + const tree = cliBuilder.getCommandTree(cliBuilder.commands); + expect(tree).toEqual({ + user: { + verify: mockCommand1, + delete: mockCommand2, + new: { + admin: mockCommand3, + }, + }, + admin: { + delete: mockCommand5, + }, + }); + }); - it("should build the command tree correctly when there are no commands", () => { - cliBuilder = new CliBuilder([]); - const tree = cliBuilder.getCommandTree(cliBuilder.commands); - expect(tree).toEqual({}); - }); + it("should build the command tree correctly when there are no commands", () => { + cliBuilder = new CliBuilder([]); + const tree = cliBuilder.getCommandTree(cliBuilder.commands); + expect(tree).toEqual({}); + }); - it("should build the command tree correctly when there is only one command", () => { - cliBuilder = new CliBuilder([mockCommand1]); - const tree = cliBuilder.getCommandTree(cliBuilder.commands); - expect(tree).toEqual({ - user: { - verify: mockCommand1, - }, - }); - }); - }); + it("should build the command tree correctly when there is only one command", () => { + cliBuilder = new CliBuilder([mockCommand1]); + const tree = cliBuilder.getCommandTree(cliBuilder.commands); + expect(tree).toEqual({ + user: { + verify: mockCommand1, + }, + }); + }); + }); - it("should show help menu", () => { - const consoleLogSpy = spyOn(console, "log").mockImplementation(() => { - // Do nothing - }); + it("should show help menu", () => { + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => { + // Do nothing + }); - const cliBuilder = new CliBuilder(); + const cliBuilder = new CliBuilder(); - const cliCommand = new CliCommand( - ["category1", "category2"], - [ - { - name: "name", - type: CliParameterType.STRING, - needsValue: true, - description: "Name of new item", - }, - { - name: "delete-previous", - type: CliParameterType.NUMBER, - needsValue: false, - positioned: false, - optional: true, - description: "Also delete the previous item", - }, - { - name: "arg3", - type: CliParameterType.BOOLEAN, - needsValue: false, - }, - { - name: "arg4", - type: CliParameterType.ARRAY, - needsValue: true, - }, - ], - () => { - // Do nothing - }, - "I love sussy sauces", - "emoji add --url https://site.com/image.png" - ); + const cliCommand = new CliCommand( + ["category1", "category2"], + [ + { + name: "name", + type: CliParameterType.STRING, + needsValue: true, + description: "Name of new item", + }, + { + name: "delete-previous", + type: CliParameterType.NUMBER, + needsValue: false, + positioned: false, + optional: true, + description: "Also delete the previous item", + }, + { + name: "arg3", + type: CliParameterType.BOOLEAN, + needsValue: false, + }, + { + name: "arg4", + type: CliParameterType.ARRAY, + needsValue: true, + }, + ], + () => { + // Do nothing + }, + "I love sussy sauces", + "emoji add --url https://site.com/image.png", + ); - cliBuilder.registerCommand(cliCommand); - cliBuilder.displayHelp(); + cliBuilder.registerCommand(cliCommand); + cliBuilder.displayHelp(); - const loggedString = consoleLogSpy.mock.calls - .map(call => stripAnsi(call[0])) - .join("\n"); + const loggedString = consoleLogSpy.mock.calls + .map((call) => stripAnsi(call[0])) + .join("\n"); - consoleLogSpy.mockRestore(); + consoleLogSpy.mockRestore(); - expect(loggedString).toContain("category1"); - expect(loggedString).toContain( - " category2.................I love sussy sauces" - ); - expect(loggedString).toContain( - " name..................Name of new item" - ); - expect(loggedString).toContain( - " arg3..................(no description)" - ); - expect(loggedString).toContain( - " arg4..................(no description)" - ); - expect(loggedString).toContain( - " --delete-previous.....Also delete the previous item (optional)" - ); - expect(loggedString).toContain( - " Example: emoji add --url https://site.com/image.png" - ); - }); + expect(loggedString).toContain("category1"); + expect(loggedString).toContain( + " category2.................I love sussy sauces", + ); + expect(loggedString).toContain( + " name..................Name of new item", + ); + expect(loggedString).toContain( + " arg3..................(no description)", + ); + expect(loggedString).toContain( + " arg4..................(no description)", + ); + expect(loggedString).toContain( + " --delete-previous.....Also delete the previous item (optional)", + ); + expect(loggedString).toContain( + " Example: emoji add --url https://site.com/image.png", + ); + }); }); diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index 8a0fc162..0b004ef5 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -1,579 +1,586 @@ import { MediaBackendType } from "~packages/media-manager"; export interface Config { - database: { - /** @default "localhost" */ - host: string; + database: { + /** @default "localhost" */ + host: string; - /** @default 5432 */ - port: number; + /** @default 5432 */ + port: number; - /** @default "lysand" */ - username: string; + /** @default "lysand" */ + username: string; - /** @default "lysand" */ - password: string; + /** @default "lysand" */ + password: string; - /** @default "lysand" */ - database: string; - }; + /** @default "lysand" */ + database: string; + }; - redis: { - queue: { - /** @default "localhost" */ - host: string; + redis: { + queue: { + /** @default "localhost" */ + host: string; - /** @default 6379 */ - port: number; + /** @default 6379 */ + port: number; - /** @default "" */ - password: string; + /** @default "" */ + password: string; - /** @default 0 */ - database: number; - }; + /** @default 0 */ + database: number; + }; - cache: { - /** @default "localhost" */ - host: string; + cache: { + /** @default "localhost" */ + host: string; - /** @default 6379 */ - port: number; + /** @default 6379 */ + port: number; - /** @default "" */ - password: string; + /** @default "" */ + password: string; - /** @default 1 */ - database: number; + /** @default 1 */ + database: number; - /** @default false */ - enabled: boolean; - }; - }; + /** @default false */ + enabled: boolean; + }; + }; - meilisearch: { - /** @default "localhost" */ - host: string; + meilisearch: { + /** @default "localhost" */ + host: string; - /** @default 7700 */ - port: number; + /** @default 7700 */ + port: number; - /** @default "______________________________" */ - api_key: string; + /** @default "______________________________" */ + api_key: string; - /** @default false */ - enabled: boolean; - }; + /** @default false */ + enabled: boolean; + }; - signups: { - /** @default "https://my-site.com/tos" */ - tos_url: string; + signups: { + /** @default "https://my-site.com/tos" */ + tos_url: string; - /** @default true */ - registration: boolean; + /** @default true */ + registration: boolean; - /** @default ["Do not harass others","Be nice to people","Don't spam","Don't post illegal content"] */ - rules: string[]; - }; + /** @default ["Do not harass others","Be nice to people","Don't spam","Don't post illegal content"] */ + rules: string[]; + }; - oidc: { - /** @default [] */ - providers: Record[]; - }; + oidc: { + /** @default [] */ + providers: { + name: string; + id: string; + url: string; + client_id: string; + client_secret: string; + icon: string; + }[]; + }; - http: { - /** @default "https://lysand.social" */ - base_url: string; + http: { + /** @default "https://lysand.social" */ + base_url: string; - /** @default "0.0.0.0" */ - bind: string; + /** @default "0.0.0.0" */ + bind: string; - /** @default "8080" */ - bind_port: string; + /** @default "8080" */ + bind_port: string; - banned_ips: any[]; + banned_ips: string[]; - banned_user_agents: any[]; + banned_user_agents: string[]; - bait: { - /** @default false */ - enabled: boolean; + bait: { + /** @default false */ + enabled: boolean; - /** @default "" */ - send_file: string; + /** @default "" */ + send_file: string; - /** @default ["127.0.0.1","::1"] */ - bait_ips: string[]; + /** @default ["127.0.0.1","::1"] */ + bait_ips: string[]; - /** @default ["curl","wget"] */ - bait_user_agents: string[]; - }; - }; + /** @default ["curl","wget"] */ + bait_user_agents: string[]; + }; + }; - smtp: { - /** @default "smtp.example.com" */ - server: string; + smtp: { + /** @default "smtp.example.com" */ + server: string; - /** @default 465 */ - port: number; + /** @default 465 */ + port: number; - /** @default "test@example.com" */ - username: string; + /** @default "test@example.com" */ + username: string; - /** @default "____________" */ - password: string; + /** @default "____________" */ + password: string; - /** @default true */ - tls: boolean; + /** @default true */ + tls: boolean; - /** @default false */ - enabled: boolean; - }; + /** @default false */ + enabled: boolean; + }; - media: { - /** @default "local" */ - backend: MediaBackendType; + media: { + /** @default "local" */ + backend: MediaBackendType; - /** @default true */ - deduplicate_media: boolean; + /** @default true */ + deduplicate_media: boolean; - /** @default "uploads" */ - local_uploads_folder: string; + /** @default "uploads" */ + local_uploads_folder: string; - conversion: { - /** @default false */ - convert_images: boolean; + conversion: { + /** @default false */ + convert_images: boolean; - /** @default "webp" */ - convert_to: string; - }; - }; + /** @default "webp" */ + convert_to: string; + }; + }; - s3: { - /** @default "myhostname.banana.com" */ - endpoint: string; + s3: { + /** @default "myhostname.banana.com" */ + endpoint: string; - /** @default "_____________" */ - access_key: string; + /** @default "_____________" */ + access_key: string; - /** @default "_________________" */ - secret_access_key: string; + /** @default "_________________" */ + secret_access_key: string; - /** @default "" */ - region: string; + /** @default "" */ + region: string; - /** @default "lysand" */ - bucket_name: string; + /** @default "lysand" */ + bucket_name: string; - /** @default "https://cdn.test.com" */ - public_url: string; - }; + /** @default "https://cdn.test.com" */ + public_url: string; + }; - email: { - /** @default false */ - send_on_report: boolean; + email: { + /** @default false */ + send_on_report: boolean; - /** @default false */ - send_on_suspend: boolean; + /** @default false */ + send_on_suspend: boolean; - /** @default false */ - send_on_unsuspend: boolean; + /** @default false */ + send_on_unsuspend: boolean; - /** @default false */ - verify_email: boolean; - }; + /** @default false */ + verify_email: boolean; + }; - validation: { - /** @default 50 */ - max_displayname_size: number; + validation: { + /** @default 50 */ + max_displayname_size: number; - /** @default 160 */ - max_bio_size: number; + /** @default 160 */ + max_bio_size: number; - /** @default 5000 */ - max_note_size: number; + /** @default 5000 */ + max_note_size: number; - /** @default 5000000 */ - max_avatar_size: number; + /** @default 5000000 */ + max_avatar_size: number; - /** @default 5000000 */ - max_header_size: number; + /** @default 5000000 */ + max_header_size: number; - /** @default 40000000 */ - max_media_size: number; + /** @default 40000000 */ + max_media_size: number; - /** @default 10 */ - max_media_attachments: number; + /** @default 10 */ + max_media_attachments: number; - /** @default 1000 */ - max_media_description_size: number; + /** @default 1000 */ + max_media_description_size: number; - /** @default 20 */ - max_poll_options: number; + /** @default 20 */ + max_poll_options: number; - /** @default 500 */ - max_poll_option_size: number; + /** @default 500 */ + max_poll_option_size: number; - /** @default 60 */ - min_poll_duration: number; + /** @default 60 */ + min_poll_duration: number; - /** @default 1893456000 */ - max_poll_duration: number; + /** @default 1893456000 */ + max_poll_duration: number; - /** @default 30 */ - max_username_size: number; + /** @default 30 */ + max_username_size: number; - /** @default [".well-known","~","about","activities","api","auth","dev","inbox","internal","main","media","nodeinfo","notice","oauth","objects","proxy","push","registration","relay","settings","status","tag","users","web","search","mfa"] */ - username_blacklist: string[]; + /** @default [".well-known","~","about","activities","api","auth","dev","inbox","internal","main","media","nodeinfo","notice","oauth","objects","proxy","push","registration","relay","settings","status","tag","users","web","search","mfa"] */ + username_blacklist: string[]; - /** @default false */ - blacklist_tempmail: boolean; + /** @default false */ + blacklist_tempmail: boolean; - email_blacklist: any[]; + email_blacklist: string[]; - /** @default ["http","https","ftp","dat","dweb","gopher","hyper","ipfs","ipns","irc","xmpp","ircs","magnet","mailto","mumble","ssb","gemini"] */ - url_scheme_whitelist: string[]; + /** @default ["http","https","ftp","dat","dweb","gopher","hyper","ipfs","ipns","irc","xmpp","ircs","magnet","mailto","mumble","ssb","gemini"] */ + url_scheme_whitelist: string[]; - /** @default false */ - enforce_mime_types: boolean; + /** @default false */ + enforce_mime_types: boolean; - /** @default ["image/jpeg","image/png","image/gif","image/heic","image/heif","image/webp","image/avif","video/webm","video/mp4","video/quicktime","video/ogg","audio/wave","audio/wav","audio/x-wav","audio/x-pn-wave","audio/vnd.wave","audio/ogg","audio/vorbis","audio/mpeg","audio/mp3","audio/webm","audio/flac","audio/aac","audio/m4a","audio/x-m4a","audio/mp4","audio/3gpp","video/x-ms-asf"] */ - allowed_mime_types: string[]; - }; + /** @default ["image/jpeg","image/png","image/gif","image/heic","image/heif","image/webp","image/avif","video/webm","video/mp4","video/quicktime","video/ogg","audio/wave","audio/wav","audio/x-wav","audio/x-pn-wave","audio/vnd.wave","audio/ogg","audio/vorbis","audio/mpeg","audio/mp3","audio/webm","audio/flac","audio/aac","audio/m4a","audio/x-m4a","audio/mp4","audio/3gpp","video/x-ms-asf"] */ + allowed_mime_types: string[]; + }; - defaults: { - /** @default "public" */ - visibility: string; + defaults: { + /** @default "public" */ + visibility: string; - /** @default "en" */ - language: string; + /** @default "en" */ + language: string; - /** @default "" */ - avatar: string; + /** @default "" */ + avatar: string; - /** @default "" */ - header: string; - }; + /** @default "" */ + header: string; + }; - federation: { - blocked: any[]; + federation: { + blocked: string[]; - followers_only: any[]; + followers_only: string[]; - discard: { - reports: any[]; + discard: { + reports: string[]; - deletes: any[]; + deletes: string[]; - updates: any[]; + updates: string[]; - media: any[]; + media: string[]; - follows: any[]; + follows: string[]; - likes: any[]; + likes: string[]; - reactions: any[]; + reactions: string[]; - banners: any[]; + banners: string[]; - avatars: any[]; - }; - }; + avatars: string[]; + }; + }; - instance: { - /** @default "Lysand" */ - name: string; + instance: { + /** @default "Lysand" */ + name: string; - /** @default "A test instance of Lysand" */ - description: string; + /** @default "A test instance of Lysand" */ + description: string; - /** @default "" */ - logo: string; + /** @default "" */ + logo: string; - /** @default "" */ - banner: string; - }; + /** @default "" */ + banner: string; + }; - filters: { - note_content: any[]; + filters: { + note_content: string[]; - emoji: any[]; + emoji: string[]; - username: any[]; + username: string[]; - displayname: any[]; + displayname: string[]; - bio: any[]; - }; + bio: string[]; + }; - logging: { - /** @default false */ - log_requests: boolean; + logging: { + /** @default false */ + log_requests: boolean; - /** @default false */ - log_requests_verbose: boolean; + /** @default false */ + log_requests_verbose: boolean; - /** @default false */ - log_ip: boolean; + /** @default false */ + log_ip: boolean; - /** @default true */ - log_filters: boolean; + /** @default true */ + log_filters: boolean; - storage: { - /** @default "logs/requests.log" */ - requests: string; - }; - }; + storage: { + /** @default "logs/requests.log" */ + requests: string; + }; + }; - ratelimits: { - /** @default 1 */ - duration_coeff: number; + ratelimits: { + /** @default 1 */ + duration_coeff: number; - /** @default 1 */ - max_coeff: number; - }; + /** @default 1 */ + max_coeff: number; + }; - /** @default {} */ - custom_ratelimits: Record< - string, - { - /** @default 30 */ - duration: number; + /** @default {} */ + custom_ratelimits: Record< + string, + { + /** @default 30 */ + duration: number; - /** @default 60 */ - max: number; - } - >; + /** @default 60 */ + max: number; + } + >; } export const defaultConfig: Config = { - database: { - host: "localhost", - port: 5432, - username: "lysand", - password: "lysand", - database: "lysand", - }, - redis: { - queue: { - host: "localhost", - port: 6379, - password: "", - database: 0, - }, - cache: { - host: "localhost", - port: 6379, - password: "", - database: 1, - enabled: false, - }, - }, - meilisearch: { - host: "localhost", - port: 7700, - api_key: "______________________________", - enabled: false, - }, - signups: { - tos_url: "https://my-site.com/tos", - registration: true, - rules: [ - "Do not harass others", - "Be nice to people", - "Don't spam", - "Don't post illegal content", - ], - }, - oidc: { - providers: [[]], - }, - http: { - base_url: "https://lysand.social", - bind: "0.0.0.0", - bind_port: "8080", - banned_ips: [], - banned_user_agents: [], - bait: { - enabled: false, - send_file: "", - bait_ips: ["127.0.0.1", "::1"], - bait_user_agents: ["curl", "wget"], - }, - }, - smtp: { - server: "smtp.example.com", - port: 465, - username: "test@example.com", - password: "____________", - tls: true, - enabled: false, - }, - media: { - backend: MediaBackendType.LOCAL, - deduplicate_media: true, - local_uploads_folder: "uploads", - conversion: { - convert_images: false, - convert_to: "webp", - }, - }, - s3: { - endpoint: "myhostname.banana.com", - access_key: "_____________", - secret_access_key: "_________________", - region: "", - bucket_name: "lysand", - public_url: "https://cdn.test.com", - }, - email: { - send_on_report: false, - send_on_suspend: false, - send_on_unsuspend: false, - verify_email: false, - }, - validation: { - max_displayname_size: 50, - max_bio_size: 160, - max_note_size: 5000, - max_avatar_size: 5000000, - max_header_size: 5000000, - max_media_size: 40000000, - max_media_attachments: 10, - max_media_description_size: 1000, - max_poll_options: 20, - max_poll_option_size: 500, - min_poll_duration: 60, - max_poll_duration: 1893456000, - max_username_size: 30, - username_blacklist: [ - ".well-known", - "~", - "about", - "activities", - "api", - "auth", - "dev", - "inbox", - "internal", - "main", - "media", - "nodeinfo", - "notice", - "oauth", - "objects", - "proxy", - "push", - "registration", - "relay", - "settings", - "status", - "tag", - "users", - "web", - "search", - "mfa", - ], - blacklist_tempmail: false, - email_blacklist: [], - url_scheme_whitelist: [ - "http", - "https", - "ftp", - "dat", - "dweb", - "gopher", - "hyper", - "ipfs", - "ipns", - "irc", - "xmpp", - "ircs", - "magnet", - "mailto", - "mumble", - "ssb", - "gemini", - ], - enforce_mime_types: false, - allowed_mime_types: [ - "image/jpeg", - "image/png", - "image/gif", - "image/heic", - "image/heif", - "image/webp", - "image/avif", - "video/webm", - "video/mp4", - "video/quicktime", - "video/ogg", - "audio/wave", - "audio/wav", - "audio/x-wav", - "audio/x-pn-wave", - "audio/vnd.wave", - "audio/ogg", - "audio/vorbis", - "audio/mpeg", - "audio/mp3", - "audio/webm", - "audio/flac", - "audio/aac", - "audio/m4a", - "audio/x-m4a", - "audio/mp4", - "audio/3gpp", - "video/x-ms-asf", - ], - }, - defaults: { - visibility: "public", - language: "en", - avatar: "", - header: "", - }, - federation: { - blocked: [], - followers_only: [], - discard: { - reports: [], - deletes: [], - updates: [], - media: [], - follows: [], - likes: [], - reactions: [], - banners: [], - avatars: [], - }, - }, - instance: { - name: "Lysand", - description: "A test instance of Lysand", - logo: "", - banner: "", - }, - filters: { - note_content: [], - emoji: [], - username: [], - displayname: [], - bio: [], - }, - logging: { - log_requests: false, - log_requests_verbose: false, - log_ip: false, - log_filters: true, - storage: { - requests: "logs/requests.log", - }, - }, - ratelimits: { - duration_coeff: 1, - max_coeff: 1, - }, - custom_ratelimits: {}, + database: { + host: "localhost", + port: 5432, + username: "lysand", + password: "lysand", + database: "lysand", + }, + redis: { + queue: { + host: "localhost", + port: 6379, + password: "", + database: 0, + }, + cache: { + host: "localhost", + port: 6379, + password: "", + database: 1, + enabled: false, + }, + }, + meilisearch: { + host: "localhost", + port: 7700, + api_key: "______________________________", + enabled: false, + }, + signups: { + tos_url: "https://my-site.com/tos", + registration: true, + rules: [ + "Do not harass others", + "Be nice to people", + "Don't spam", + "Don't post illegal content", + ], + }, + oidc: { + providers: [], + }, + http: { + base_url: "https://lysand.social", + bind: "0.0.0.0", + bind_port: "8080", + banned_ips: [], + banned_user_agents: [], + bait: { + enabled: false, + send_file: "", + bait_ips: ["127.0.0.1", "::1"], + bait_user_agents: ["curl", "wget"], + }, + }, + smtp: { + server: "smtp.example.com", + port: 465, + username: "test@example.com", + password: "____________", + tls: true, + enabled: false, + }, + media: { + backend: MediaBackendType.LOCAL, + deduplicate_media: true, + local_uploads_folder: "uploads", + conversion: { + convert_images: false, + convert_to: "webp", + }, + }, + s3: { + endpoint: "myhostname.banana.com", + access_key: "_____________", + secret_access_key: "_________________", + region: "", + bucket_name: "lysand", + public_url: "https://cdn.test.com", + }, + email: { + send_on_report: false, + send_on_suspend: false, + send_on_unsuspend: false, + verify_email: false, + }, + validation: { + max_displayname_size: 50, + max_bio_size: 160, + max_note_size: 5000, + max_avatar_size: 5000000, + max_header_size: 5000000, + max_media_size: 40000000, + max_media_attachments: 10, + max_media_description_size: 1000, + max_poll_options: 20, + max_poll_option_size: 500, + min_poll_duration: 60, + max_poll_duration: 1893456000, + max_username_size: 30, + username_blacklist: [ + ".well-known", + "~", + "about", + "activities", + "api", + "auth", + "dev", + "inbox", + "internal", + "main", + "media", + "nodeinfo", + "notice", + "oauth", + "objects", + "proxy", + "push", + "registration", + "relay", + "settings", + "status", + "tag", + "users", + "web", + "search", + "mfa", + ], + blacklist_tempmail: false, + email_blacklist: [], + url_scheme_whitelist: [ + "http", + "https", + "ftp", + "dat", + "dweb", + "gopher", + "hyper", + "ipfs", + "ipns", + "irc", + "xmpp", + "ircs", + "magnet", + "mailto", + "mumble", + "ssb", + "gemini", + ], + enforce_mime_types: false, + allowed_mime_types: [ + "image/jpeg", + "image/png", + "image/gif", + "image/heic", + "image/heif", + "image/webp", + "image/avif", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/vnd.wave", + "audio/ogg", + "audio/vorbis", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf", + ], + }, + defaults: { + visibility: "public", + language: "en", + avatar: "", + header: "", + }, + federation: { + blocked: [], + followers_only: [], + discard: { + reports: [], + deletes: [], + updates: [], + media: [], + follows: [], + likes: [], + reactions: [], + banners: [], + avatars: [], + }, + }, + instance: { + name: "Lysand", + description: "A test instance of Lysand", + logo: "", + banner: "", + }, + filters: { + note_content: [], + emoji: [], + username: [], + displayname: [], + bio: [], + }, + logging: { + log_requests: false, + log_requests_verbose: false, + log_ip: false, + log_filters: true, + storage: { + requests: "logs/requests.log", + }, + }, + ratelimits: { + duration_coeff: 1, + max_coeff: 1, + }, + custom_ratelimits: {}, }; diff --git a/packages/config-manager/index.ts b/packages/config-manager/index.ts index 27d2fb9b..5458a863 100644 --- a/packages/config-manager/index.ts +++ b/packages/config-manager/index.ts @@ -6,18 +6,18 @@ */ import { watchConfig } from "c12"; -import { defaultConfig, type Config } from "./config.type"; +import { type Config, defaultConfig } from "./config.type"; const { config } = await watchConfig({ - configFile: "./config/config.toml", - defaultConfig: defaultConfig, - overrides: - ( - await watchConfig({ - configFile: "./config/config.internal.toml", - defaultConfig: {} as Config, - }) - ).config ?? undefined, + configFile: "./config/config.toml", + defaultConfig: defaultConfig, + overrides: + ( + await watchConfig({ + configFile: "./config/config.internal.toml", + defaultConfig: {} as Config, + }) + ).config ?? undefined, }); const exportedConfig = config ?? defaultConfig; diff --git a/packages/config-manager/package.json b/packages/config-manager/package.json index 690b334e..b1e8fdfd 100644 --- a/packages/config-manager/package.json +++ b/packages/config-manager/package.json @@ -1,6 +1,6 @@ { - "name": "config-manager", - "version": "0.0.0", - "main": "index.ts", - "dependencies": { "@iarna/toml": "^2.2.5", "merge-deep-ts": "^1.2.6" } -} \ No newline at end of file + "name": "config-manager", + "version": "0.0.0", + "main": "index.ts", + "dependencies": { "@iarna/toml": "^2.2.5", "merge-deep-ts": "^1.2.6" } +} diff --git a/packages/log-manager/index.ts b/packages/log-manager/index.ts index 11d34196..89790d7f 100644 --- a/packages/log-manager/index.ts +++ b/packages/log-manager/index.ts @@ -1,12 +1,12 @@ +import { appendFile } from "node:fs/promises"; import type { BunFile } from "bun"; -import { appendFile } from "fs/promises"; export enum LogLevel { - DEBUG = "debug", - INFO = "info", - WARNING = "warning", - ERROR = "error", - CRITICAL = "critical", + DEBUG = "debug", + INFO = "info", + WARNING = "warning", + ERROR = "error", + CRITICAL = "critical", } /** @@ -14,161 +14,165 @@ export enum LogLevel { * @param output BunFile of output (can be a normal file or something like Bun.stdout) */ export class LogManager { - constructor(private output: BunFile) { - void this.write( - `--- INIT LogManager at ${new Date().toISOString()} ---` - ); - } + constructor(private output: BunFile) { + void this.write( + `--- INIT LogManager at ${new Date().toISOString()} ---`, + ); + } - /** - * Logs a message to the output - * @param level Importance of the log - * @param entity Emitter of the log - * @param message Message to log - * @param showTimestamp Whether to show the timestamp in the log - */ - async log( - level: LogLevel, - entity: string, - message: string, - showTimestamp = true - ) { - await this.write( - `${showTimestamp ? new Date().toISOString() + " " : ""}[${level.toUpperCase()}] ${entity}: ${message}` - ); - } + /** + * Logs a message to the output + * @param level Importance of the log + * @param entity Emitter of the log + * @param message Message to log + * @param showTimestamp Whether to show the timestamp in the log + */ + async log( + level: LogLevel, + entity: string, + message: string, + showTimestamp = true, + ) { + await this.write( + `${ + showTimestamp ? `${new Date().toISOString()} ` : "" + }[${level.toUpperCase()}] ${entity}: ${message}`, + ); + } - private async write(text: string) { - if (this.output == Bun.stdout) { - await Bun.write(Bun.stdout, text + "\n"); - } else { - if (!(await this.output.exists())) { - // Create file if it doesn't exist - await Bun.write(this.output, "", { - createPath: true, - }); - } - await appendFile(this.output.name ?? "", text + "\n"); - } - } + private async write(text: string) { + if (this.output === Bun.stdout) { + await Bun.write(Bun.stdout, `${text}\n`); + } else { + if (!(await this.output.exists())) { + // Create file if it doesn't exist + await Bun.write(this.output, "", { + createPath: true, + }); + } + await appendFile(this.output.name ?? "", `${text}\n`); + } + } - /** - * Logs an error to the output, wrapper for log - * @param level Importance of the log - * @param entity Emitter of the log - * @param error Error to log - */ - async logError(level: LogLevel, entity: string, error: Error) { - await this.log(level, entity, error.message); - } + /** + * Logs an error to the output, wrapper for log + * @param level Importance of the log + * @param entity Emitter of the log + * @param error Error to log + */ + async logError(level: LogLevel, entity: string, error: Error) { + await this.log(level, entity, error.message); + } - /** - * Logs a request to the output - * @param req Request to log - * @param ip IP of the request - * @param logAllDetails Whether to log all details of the request - */ - async logRequest(req: Request, ip?: string, logAllDetails = false) { - let string = ip ? `${ip}: ` : ""; + /** + * Logs a request to the output + * @param req Request to log + * @param ip IP of the request + * @param logAllDetails Whether to log all details of the request + */ + async logRequest(req: Request, ip?: string, logAllDetails = false) { + let string = ip ? `${ip}: ` : ""; - string += `${req.method} ${req.url}`; + string += `${req.method} ${req.url}`; - if (logAllDetails) { - string += `\n`; - string += ` [Headers]\n`; - // Pretty print headers - for (const [key, value] of req.headers.entries()) { - string += ` ${key}: ${value}\n`; - } + if (logAllDetails) { + string += "\n"; + string += " [Headers]\n"; + // Pretty print headers + for (const [key, value] of req.headers.entries()) { + string += ` ${key}: ${value}\n`; + } - // Pretty print body - string += ` [Body]\n`; - const content_type = req.headers.get("Content-Type"); + // Pretty print body + string += " [Body]\n"; + const content_type = req.headers.get("Content-Type"); - if (content_type && content_type.includes("application/json")) { - const json = await req.json(); - const stringified = JSON.stringify(json, null, 4) - .split("\n") - .map(line => ` ${line}`) - .join("\n"); + if (content_type?.includes("application/json")) { + const json = await req.json(); + const stringified = JSON.stringify(json, null, 4) + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); - string += `${stringified}\n`; - } else if ( - content_type && - (content_type.includes("application/x-www-form-urlencoded") || - content_type.includes("multipart/form-data")) - ) { - const formData = await req.formData(); - for (const [key, value] of formData.entries()) { - if (value.toString().length < 300) { - string += ` ${key}: ${value.toString()}\n`; - } else { - string += ` ${key}: <${value.toString().length} bytes>\n`; - } - } - } else { - const text = await req.text(); - string += ` ${text}\n`; - } - } - await this.log(LogLevel.INFO, "Request", string); - } + string += `${stringified}\n`; + } else if ( + content_type && + (content_type.includes("application/x-www-form-urlencoded") || + content_type.includes("multipart/form-data")) + ) { + const formData = await req.formData(); + for (const [key, value] of formData.entries()) { + if (value.toString().length < 300) { + string += ` ${key}: ${value.toString()}\n`; + } else { + string += ` ${key}: <${ + value.toString().length + } bytes>\n`; + } + } + } else { + const text = await req.text(); + string += ` ${text}\n`; + } + } + await this.log(LogLevel.INFO, "Request", string); + } } /** * Outputs to multiple LogManager instances at once */ export class MultiLogManager { - constructor(private logManagers: LogManager[]) {} + constructor(private logManagers: LogManager[]) {} - /** - * Logs a message to all logManagers - * @param level Importance of the log - * @param entity Emitter of the log - * @param message Message to log - * @param showTimestamp Whether to show the timestamp in the log - */ - async log( - level: LogLevel, - entity: string, - message: string, - showTimestamp = true - ) { - for (const logManager of this.logManagers) { - await logManager.log(level, entity, message, showTimestamp); - } - } + /** + * Logs a message to all logManagers + * @param level Importance of the log + * @param entity Emitter of the log + * @param message Message to log + * @param showTimestamp Whether to show the timestamp in the log + */ + async log( + level: LogLevel, + entity: string, + message: string, + showTimestamp = true, + ) { + for (const logManager of this.logManagers) { + await logManager.log(level, entity, message, showTimestamp); + } + } - /** - * Logs an error to all logManagers - * @param level Importance of the log - * @param entity Emitter of the log - * @param error Error to log - */ - async logError(level: LogLevel, entity: string, error: Error) { - for (const logManager of this.logManagers) { - await logManager.logError(level, entity, error); - } - } + /** + * Logs an error to all logManagers + * @param level Importance of the log + * @param entity Emitter of the log + * @param error Error to log + */ + async logError(level: LogLevel, entity: string, error: Error) { + for (const logManager of this.logManagers) { + await logManager.logError(level, entity, error); + } + } - /** - * Logs a request to all logManagers - * @param req Request to log - * @param ip IP of the request - * @param logAllDetails Whether to log all details of the request - */ - async logRequest(req: Request, ip?: string, logAllDetails = false) { - for (const logManager of this.logManagers) { - await logManager.logRequest(req, ip, logAllDetails); - } - } + /** + * Logs a request to all logManagers + * @param req Request to log + * @param ip IP of the request + * @param logAllDetails Whether to log all details of the request + */ + async logRequest(req: Request, ip?: string, logAllDetails = false) { + for (const logManager of this.logManagers) { + await logManager.logRequest(req, ip, logAllDetails); + } + } - /** - * Create a MultiLogManager from multiple LogManager instances - * @param logManagers LogManager instances to use - * @returns - */ - static fromLogManagers(...logManagers: LogManager[]) { - return new MultiLogManager(logManagers); - } + /** + * Create a MultiLogManager from multiple LogManager instances + * @param logManagers LogManager instances to use + * @returns + */ + static fromLogManagers(...logManagers: LogManager[]) { + return new MultiLogManager(logManagers); + } } diff --git a/packages/log-manager/package.json b/packages/log-manager/package.json index 679a8262..2cc02e72 100644 --- a/packages/log-manager/package.json +++ b/packages/log-manager/package.json @@ -2,5 +2,5 @@ "name": "log-manager", "version": "0.0.0", "main": "index.ts", - "dependencies": { } - } \ No newline at end of file + "dependencies": {} +} diff --git a/packages/log-manager/tests/log-manager.test.ts b/packages/log-manager/tests/log-manager.test.ts index 6b8b7bf5..56dd1356 100644 --- a/packages/log-manager/tests/log-manager.test.ts +++ b/packages/log-manager/tests/log-manager.test.ts @@ -1,117 +1,117 @@ -// FILEPATH: /home/jessew/Dev/lysand/packages/log-manager/log-manager.test.ts -import { LogManager, LogLevel, MultiLogManager } from "../index"; -import type fs from "fs/promises"; import { - describe, - it, - beforeEach, - expect, - jest, - mock, - type Mock, - test, + type Mock, + beforeEach, + describe, + expect, + it, + jest, + mock, + test, } from "bun:test"; +import type fs from "node:fs/promises"; import type { BunFile } from "bun"; +// FILEPATH: /home/jessew/Dev/lysand/packages/log-manager/log-manager.test.ts +import { LogLevel, LogManager, MultiLogManager } from "../index"; describe("LogManager", () => { - let logManager: LogManager; - let mockOutput: BunFile; - let mockAppend: Mock; + let logManager: LogManager; + let mockOutput: BunFile; + let mockAppend: Mock; - beforeEach(async () => { - mockOutput = Bun.file("test.log"); - mockAppend = jest.fn(); - await mock.module("fs/promises", () => ({ - appendFile: mockAppend, - })); - logManager = new LogManager(mockOutput); - }); + beforeEach(async () => { + mockOutput = Bun.file("test.log"); + mockAppend = jest.fn(); + await mock.module("fs/promises", () => ({ + appendFile: mockAppend, + })); + logManager = new LogManager(mockOutput); + }); - it("should initialize and write init log", () => { - expect(mockAppend).toHaveBeenCalledWith( - mockOutput.name, - expect.stringContaining("--- INIT LogManager at") - ); - }); + it("should initialize and write init log", () => { + expect(mockAppend).toHaveBeenCalledWith( + mockOutput.name, + expect.stringContaining("--- INIT LogManager at"), + ); + }); - it("should log message with timestamp", async () => { - await logManager.log(LogLevel.INFO, "TestEntity", "Test message"); - expect(mockAppend).toHaveBeenCalledWith( - mockOutput.name, - expect.stringContaining("[INFO] TestEntity: Test message") - ); - }); + it("should log message with timestamp", async () => { + await logManager.log(LogLevel.INFO, "TestEntity", "Test message"); + expect(mockAppend).toHaveBeenCalledWith( + mockOutput.name, + expect.stringContaining("[INFO] TestEntity: Test message"), + ); + }); - it("should log message without timestamp", async () => { - await logManager.log( - LogLevel.INFO, - "TestEntity", - "Test message", - false - ); - expect(mockAppend).toHaveBeenCalledWith( - mockOutput.name, - "[INFO] TestEntity: Test message\n" - ); - }); + it("should log message without timestamp", async () => { + await logManager.log( + LogLevel.INFO, + "TestEntity", + "Test message", + false, + ); + expect(mockAppend).toHaveBeenCalledWith( + mockOutput.name, + "[INFO] TestEntity: Test message\n", + ); + }); - test.skip("should write to stdout", async () => { - logManager = new LogManager(Bun.stdout); - await logManager.log(LogLevel.INFO, "TestEntity", "Test message"); + test.skip("should write to stdout", async () => { + logManager = new LogManager(Bun.stdout); + await logManager.log(LogLevel.INFO, "TestEntity", "Test message"); - const writeMock = jest.fn(); + const writeMock = jest.fn(); - await mock.module("Bun", () => ({ - stdout: Bun.stdout, - write: writeMock, - })); + await mock.module("Bun", () => ({ + stdout: Bun.stdout, + write: writeMock, + })); - expect(writeMock).toHaveBeenCalledWith( - Bun.stdout, - expect.stringContaining("[INFO] TestEntity: Test message") - ); - }); + expect(writeMock).toHaveBeenCalledWith( + Bun.stdout, + expect.stringContaining("[INFO] TestEntity: Test message"), + ); + }); - it("should throw error if output file does not exist", () => { - mockAppend.mockImplementationOnce(() => { - return Promise.reject( - new Error("Output file doesnt exist (and isnt stdout)") - ); - }); - expect( - logManager.log(LogLevel.INFO, "TestEntity", "Test message") - ).rejects.toThrow(Error); - }); + it("should throw error if output file does not exist", () => { + mockAppend.mockImplementationOnce(() => { + return Promise.reject( + new Error("Output file doesnt exist (and isnt stdout)"), + ); + }); + expect( + logManager.log(LogLevel.INFO, "TestEntity", "Test message"), + ).rejects.toThrow(Error); + }); - it("should log error message", async () => { - const error = new Error("Test error"); - await logManager.logError(LogLevel.ERROR, "TestEntity", error); - expect(mockAppend).toHaveBeenCalledWith( - mockOutput.name, - expect.stringContaining("[ERROR] TestEntity: Test error") - ); - }); + it("should log error message", async () => { + const error = new Error("Test error"); + await logManager.logError(LogLevel.ERROR, "TestEntity", error); + expect(mockAppend).toHaveBeenCalledWith( + mockOutput.name, + expect.stringContaining("[ERROR] TestEntity: Test error"), + ); + }); - it("should log basic request details", async () => { - const req = new Request("http://localhost/test", { method: "GET" }); - await logManager.logRequest(req, "127.0.0.1"); + it("should log basic request details", async () => { + const req = new Request("http://localhost/test", { method: "GET" }); + await logManager.logRequest(req, "127.0.0.1"); - expect(mockAppend).toHaveBeenCalledWith( - mockOutput.name, - expect.stringContaining("127.0.0.1: GET http://localhost/test") - ); - }); + expect(mockAppend).toHaveBeenCalledWith( + mockOutput.name, + expect.stringContaining("127.0.0.1: GET http://localhost/test"), + ); + }); - describe("Request logger", () => { - it("should log all request details for JSON content type", async () => { - const req = new Request("http://localhost/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ test: "value" }), - }); - await logManager.logRequest(req, "127.0.0.1", true); + describe("Request logger", () => { + it("should log all request details for JSON content type", async () => { + const req = new Request("http://localhost/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ test: "value" }), + }); + await logManager.logRequest(req, "127.0.0.1", true); - const expectedLog = `127.0.0.1: POST http://localhost/test + const expectedLog = `127.0.0.1: POST http://localhost/test [Headers] content-type: application/json [Body] @@ -120,112 +120,112 @@ describe("LogManager", () => { } `; - expect(mockAppend).toHaveBeenCalledWith( - mockOutput.name, - expect.stringContaining(expectedLog) - ); - }); + expect(mockAppend).toHaveBeenCalledWith( + mockOutput.name, + expect.stringContaining(expectedLog), + ); + }); - it("should log all request details for text content type", async () => { - const req = new Request("http://localhost/test", { - method: "POST", - headers: { "Content-Type": "text/plain" }, - body: "Test body", - }); - await logManager.logRequest(req, "127.0.0.1", true); + it("should log all request details for text content type", async () => { + const req = new Request("http://localhost/test", { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: "Test body", + }); + await logManager.logRequest(req, "127.0.0.1", true); - const expectedLog = `127.0.0.1: POST http://localhost/test + const expectedLog = `127.0.0.1: POST http://localhost/test [Headers] content-type: text/plain [Body] Test body `; - expect(mockAppend).toHaveBeenCalledWith( - mockOutput.name, - expect.stringContaining(expectedLog) - ); - }); + expect(mockAppend).toHaveBeenCalledWith( + mockOutput.name, + expect.stringContaining(expectedLog), + ); + }); - it("should log all request details for FormData content-type", async () => { - const formData = new FormData(); - formData.append("test", "value"); - const req = new Request("http://localhost/test", { - method: "POST", - body: formData, - }); - await logManager.logRequest(req, "127.0.0.1", true); + it("should log all request details for FormData content-type", async () => { + const formData = new FormData(); + formData.append("test", "value"); + const req = new Request("http://localhost/test", { + method: "POST", + body: formData, + }); + await logManager.logRequest(req, "127.0.0.1", true); - const expectedLog = `127.0.0.1: POST http://localhost/test + const expectedLog = `127.0.0.1: POST http://localhost/test [Headers] content-type: multipart/form-data; boundary=${ - req.headers.get("Content-Type")?.split("boundary=")[1] ?? "" - } + req.headers.get("Content-Type")?.split("boundary=")[1] ?? "" + } [Body] test: value `; - expect(mockAppend).toHaveBeenCalledWith( - mockOutput.name, - expect.stringContaining( - expectedLog.replace("----", expect.any(String)) - ) - ); - }); - }); + expect(mockAppend).toHaveBeenCalledWith( + mockOutput.name, + expect.stringContaining( + expectedLog.replace("----", expect.any(String)), + ), + ); + }); + }); }); describe("MultiLogManager", () => { - let multiLogManager: MultiLogManager; - let mockLogManagers: LogManager[]; - let mockLog: jest.Mock; - let mockLogError: jest.Mock; - let mockLogRequest: jest.Mock; + let multiLogManager: MultiLogManager; + let mockLogManagers: LogManager[]; + let mockLog: jest.Mock; + let mockLogError: jest.Mock; + let mockLogRequest: jest.Mock; - beforeEach(() => { - mockLog = jest.fn(); - mockLogError = jest.fn(); - mockLogRequest = jest.fn(); - mockLogManagers = [ - { - log: mockLog, - logError: mockLogError, - logRequest: mockLogRequest, - }, - { - log: mockLog, - logError: mockLogError, - logRequest: mockLogRequest, - }, - ] as unknown as LogManager[]; - multiLogManager = MultiLogManager.fromLogManagers(...mockLogManagers); - }); + beforeEach(() => { + mockLog = jest.fn(); + mockLogError = jest.fn(); + mockLogRequest = jest.fn(); + mockLogManagers = [ + { + log: mockLog, + logError: mockLogError, + logRequest: mockLogRequest, + }, + { + log: mockLog, + logError: mockLogError, + logRequest: mockLogRequest, + }, + ] as unknown as LogManager[]; + multiLogManager = MultiLogManager.fromLogManagers(...mockLogManagers); + }); - it("should log message to all logManagers", async () => { - await multiLogManager.log(LogLevel.INFO, "TestEntity", "Test message"); - expect(mockLog).toHaveBeenCalledTimes(2); - expect(mockLog).toHaveBeenCalledWith( - LogLevel.INFO, - "TestEntity", - "Test message", - true - ); - }); + it("should log message to all logManagers", async () => { + await multiLogManager.log(LogLevel.INFO, "TestEntity", "Test message"); + expect(mockLog).toHaveBeenCalledTimes(2); + expect(mockLog).toHaveBeenCalledWith( + LogLevel.INFO, + "TestEntity", + "Test message", + true, + ); + }); - it("should log error to all logManagers", async () => { - const error = new Error("Test error"); - await multiLogManager.logError(LogLevel.ERROR, "TestEntity", error); - expect(mockLogError).toHaveBeenCalledTimes(2); - expect(mockLogError).toHaveBeenCalledWith( - LogLevel.ERROR, - "TestEntity", - error - ); - }); + it("should log error to all logManagers", async () => { + const error = new Error("Test error"); + await multiLogManager.logError(LogLevel.ERROR, "TestEntity", error); + expect(mockLogError).toHaveBeenCalledTimes(2); + expect(mockLogError).toHaveBeenCalledWith( + LogLevel.ERROR, + "TestEntity", + error, + ); + }); - it("should log request to all logManagers", async () => { - const req = new Request("http://localhost/test", { method: "GET" }); - await multiLogManager.logRequest(req, "127.0.0.1", true); - expect(mockLogRequest).toHaveBeenCalledTimes(2); - expect(mockLogRequest).toHaveBeenCalledWith(req, "127.0.0.1", true); - }); + it("should log request to all logManagers", async () => { + const req = new Request("http://localhost/test", { method: "GET" }); + await multiLogManager.logRequest(req, "127.0.0.1", true); + expect(mockLogRequest).toHaveBeenCalledTimes(2); + expect(mockLogRequest).toHaveBeenCalledWith(req, "127.0.0.1", true); + }); }); diff --git a/packages/media-manager/backends/local.ts b/packages/media-manager/backends/local.ts index d5a8fb99..a150ba77 100644 --- a/packages/media-manager/backends/local.ts +++ b/packages/media-manager/backends/local.ts @@ -1,64 +1,65 @@ +import type { Config } from "config-manager"; +import { MediaBackend, MediaBackendType, MediaHasher } from ".."; import type { ConvertableMediaFormats } from "../media-converter"; import { MediaConverter } from "../media-converter"; -import { MediaBackend, MediaBackendType, MediaHasher } from ".."; -import type { ConfigType } from "config-manager"; export class LocalMediaBackend extends MediaBackend { - constructor(config: ConfigType) { - super(config, MediaBackendType.LOCAL); - } + constructor(config: Config) { + super(config, MediaBackendType.LOCAL); + } - public async addFile(file: File) { - if (this.shouldConvertImages(this.config)) { - const fileExtension = file.name.split(".").pop(); - const mediaConverter = new MediaConverter( - fileExtension as ConvertableMediaFormats, - this.config.media.conversion - .convert_to as ConvertableMediaFormats - ); - file = await mediaConverter.convert(file); - } + public async addFile(file: File) { + let convertedFile = file; + if (this.shouldConvertImages(this.config)) { + const fileExtension = file.name.split(".").pop(); + const mediaConverter = new MediaConverter( + fileExtension as ConvertableMediaFormats, + this.config.media.conversion + .convert_to as ConvertableMediaFormats, + ); + convertedFile = await mediaConverter.convert(file); + } - const hash = await new MediaHasher().getMediaHash(file); + const hash = await new MediaHasher().getMediaHash(convertedFile); - const newFile = Bun.file( - `${this.config.media.local_uploads_folder}/${hash}` - ); + const newFile = Bun.file( + `${this.config.media.local_uploads_folder}/${hash}`, + ); - if (await newFile.exists()) { - throw new Error("File already exists"); - } + if (await newFile.exists()) { + throw new Error("File already exists"); + } - await Bun.write(newFile, file); + await Bun.write(newFile, convertedFile); - return { - uploadedFile: file, - path: `./uploads/${file.name}`, - hash: hash, - }; - } + return { + uploadedFile: convertedFile, + path: `./uploads/${convertedFile.name}`, + hash: hash, + }; + } - public async getFileByHash( - hash: string, - databaseHashFetcher: (sha256: string) => Promise - ): Promise { - const filename = await databaseHashFetcher(hash); + public async getFileByHash( + hash: string, + databaseHashFetcher: (sha256: string) => Promise, + ): Promise { + const filename = await databaseHashFetcher(hash); - if (!filename) return null; + if (!filename) return null; - return this.getFile(filename); - } + return this.getFile(filename); + } - public async getFile(filename: string): Promise { - const file = Bun.file( - `${this.config.media.local_uploads_folder}/${filename}` - ); + public async getFile(filename: string): Promise { + const file = Bun.file( + `${this.config.media.local_uploads_folder}/${filename}`, + ); - if (!(await file.exists())) return null; + if (!(await file.exists())) return null; - return new File([await file.arrayBuffer()], filename, { - type: file.type, - lastModified: file.lastModified, - }); - } + return new File([await file.arrayBuffer()], filename, { + type: file.type, + lastModified: file.lastModified, + }); + } } diff --git a/packages/media-manager/backends/s3.ts b/packages/media-manager/backends/s3.ts index 76a19415..e96676b0 100644 --- a/packages/media-manager/backends/s3.ts +++ b/packages/media-manager/backends/s3.ts @@ -1,69 +1,74 @@ import { S3Client } from "@jsr/bradenmacdonald__s3-lite-client"; +import type { Config } from "config-manager"; +import { MediaBackend, MediaBackendType, MediaHasher } from ".."; import type { ConvertableMediaFormats } from "../media-converter"; import { MediaConverter } from "../media-converter"; -import { MediaBackend, MediaBackendType, MediaHasher } from ".."; -import type { ConfigType } from "config-manager"; export class S3MediaBackend extends MediaBackend { - constructor( - config: ConfigType, - private s3Client = new S3Client({ - endPoint: config.s3.endpoint, - useSSL: true, - region: config.s3.region || "auto", - bucket: config.s3.bucket_name, - accessKey: config.s3.access_key, - secretKey: config.s3.secret_access_key, - }) - ) { - super(config, MediaBackendType.S3); - } + constructor( + config: Config, + private s3Client = new S3Client({ + endPoint: config.s3.endpoint, + useSSL: true, + region: config.s3.region || "auto", + bucket: config.s3.bucket_name, + accessKey: config.s3.access_key, + secretKey: config.s3.secret_access_key, + }), + ) { + super(config, MediaBackendType.S3); + } - public async addFile(file: File) { - if (this.shouldConvertImages(this.config)) { - const fileExtension = file.name.split(".").pop(); - const mediaConverter = new MediaConverter( - fileExtension as ConvertableMediaFormats, - this.config.media.conversion - .convert_to as ConvertableMediaFormats - ); - file = await mediaConverter.convert(file); - } + public async addFile(file: File) { + let convertedFile = file; + if (this.shouldConvertImages(this.config)) { + const fileExtension = file.name.split(".").pop(); + const mediaConverter = new MediaConverter( + fileExtension as ConvertableMediaFormats, + this.config.media.conversion + .convert_to as ConvertableMediaFormats, + ); + convertedFile = await mediaConverter.convert(file); + } - const hash = await new MediaHasher().getMediaHash(file); + const hash = await new MediaHasher().getMediaHash(convertedFile); - await this.s3Client.putObject(file.name, file.stream(), { - size: file.size, - }); + await this.s3Client.putObject( + convertedFile.name, + convertedFile.stream(), + { + size: convertedFile.size, + }, + ); - return { - uploadedFile: file, - hash: hash, - }; - } + return { + uploadedFile: convertedFile, + hash: hash, + }; + } - public async getFileByHash( - hash: string, - databaseHashFetcher: (sha256: string) => Promise - ): Promise { - const filename = await databaseHashFetcher(hash); + public async getFileByHash( + hash: string, + databaseHashFetcher: (sha256: string) => Promise, + ): Promise { + const filename = await databaseHashFetcher(hash); - if (!filename) return null; + if (!filename) return null; - return this.getFile(filename); - } + return this.getFile(filename); + } - public async getFile(filename: string): Promise { - try { - await this.s3Client.statObject(filename); - } catch { - return null; - } + public async getFile(filename: string): Promise { + try { + await this.s3Client.statObject(filename); + } catch { + return null; + } - const file = await this.s3Client.getObject(filename); + const file = await this.s3Client.getObject(filename); - return new File([await file.arrayBuffer()], filename, { - type: file.headers.get("Content-Type") || "undefined", - }); - } + return new File([await file.arrayBuffer()], filename, { + type: file.headers.get("Content-Type") || "undefined", + }); + } } diff --git a/packages/media-manager/index.ts b/packages/media-manager/index.ts index 1dc24dba..64d99a94 100644 --- a/packages/media-manager/index.ts +++ b/packages/media-manager/index.ts @@ -1,101 +1,101 @@ -import type { ConfigType } from "config-manager"; +import type { Config } from "config-manager"; export enum MediaBackendType { - LOCAL = "local", - S3 = "s3", + LOCAL = "local", + S3 = "s3", } interface UploadedFileMetadata { - uploadedFile: File; - path?: string; - hash: string; + uploadedFile: File; + path?: string; + hash: string; } export class MediaHasher { - /** - * Returns the SHA-256 hash of a file in hex format - * @param media The file to hash - * @returns The SHA-256 hash of the file in hex format - */ - public async getMediaHash(media: File) { - const hash = new Bun.SHA256() - .update(await media.arrayBuffer()) - .digest("hex"); + /** + * Returns the SHA-256 hash of a file in hex format + * @param media The file to hash + * @returns The SHA-256 hash of the file in hex format + */ + public async getMediaHash(media: File) { + const hash = new Bun.SHA256() + .update(await media.arrayBuffer()) + .digest("hex"); - return hash; - } + return hash; + } } export class MediaBackend { - constructor( - public config: ConfigType, - public backend: MediaBackendType - ) {} + constructor( + public config: Config, + public backend: MediaBackendType, + ) {} - static async fromBackendType( - backend: MediaBackendType, - config: ConfigType - ): Promise { - switch (backend) { - case MediaBackendType.LOCAL: - return new (await import("./backends/local")).LocalMediaBackend( - config - ); - case MediaBackendType.S3: - return new (await import("./backends/s3")).S3MediaBackend( - config - ); - default: - throw new Error(`Unknown backend type: ${backend as any}`); - } - } + static async fromBackendType( + backend: MediaBackendType, + config: Config, + ): Promise { + switch (backend) { + case MediaBackendType.LOCAL: + return new (await import("./backends/local")).LocalMediaBackend( + config, + ); + case MediaBackendType.S3: + return new (await import("./backends/s3")).S3MediaBackend( + config, + ); + default: + throw new Error(`Unknown backend type: ${backend as string}`); + } + } - public getBackendType() { - return this.backend; - } + public getBackendType() { + return this.backend; + } - public shouldConvertImages(config: ConfigType) { - return config.media.conversion.convert_images; - } + public shouldConvertImages(config: Config) { + return config.media.conversion.convert_images; + } - /** - * Fetches file from backend from SHA-256 hash - * @param file SHA-256 hash of wanted file - * @param databaseHashFetcher Function that takes in a sha256 hash as input and outputs the filename of that file in the database - * @returns The file as a File object - */ - public getFileByHash( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - file: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - databaseHashFetcher: (sha256: string) => Promise - ): Promise { - return Promise.reject( - new Error("Do not call MediaBackend directly: use a subclass") - ); - } + /** + * Fetches file from backend from SHA-256 hash + * @param file SHA-256 hash of wanted file + * @param databaseHashFetcher Function that takes in a sha256 hash as input and outputs the filename of that file in the database + * @returns The file as a File object + */ + public getFileByHash( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + file: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + databaseHashFetcher: (sha256: string) => Promise, + ): Promise { + return Promise.reject( + new Error("Do not call MediaBackend directly: use a subclass"), + ); + } - /** - * Fetches file from backend from filename - * @param filename File name - * @returns The file as a File object - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public getFile(filename: string): Promise { - return Promise.reject( - new Error("Do not call MediaBackend directly: use a subclass") - ); - } + /** + * Fetches file from backend from filename + * @param filename File name + * @returns The file as a File object + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getFile(filename: string): Promise { + return Promise.reject( + new Error("Do not call MediaBackend directly: use a subclass"), + ); + } - /** - * Adds file to backend - * @param file File to add - * @returns Metadata about the uploaded file - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public addFile(file: File): Promise { - return Promise.reject( - new Error("Do not call MediaBackend directly: use a subclass") - ); - } + /** + * Adds file to backend + * @param file File to add + * @returns Metadata about the uploaded file + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public addFile(file: File): Promise { + return Promise.reject( + new Error("Do not call MediaBackend directly: use a subclass"), + ); + } } diff --git a/packages/media-manager/media-converter.ts b/packages/media-manager/media-converter.ts index 2602f4cf..9ff411c4 100644 --- a/packages/media-manager/media-converter.ts +++ b/packages/media-manager/media-converter.ts @@ -6,89 +6,89 @@ import sharp from "sharp"; export enum ConvertableMediaFormats { - PNG = "png", - WEBP = "webp", - JPEG = "jpeg", - JPG = "jpg", - AVIF = "avif", - JXL = "jxl", - HEIF = "heif", + PNG = "png", + WEBP = "webp", + JPEG = "jpeg", + JPG = "jpg", + AVIF = "avif", + JXL = "jxl", + HEIF = "heif", } /** * Handles media conversion between formats */ export class MediaConverter { - constructor( - public fromFormat: ConvertableMediaFormats, - public toFormat: ConvertableMediaFormats - ) {} + constructor( + public fromFormat: ConvertableMediaFormats, + public toFormat: ConvertableMediaFormats, + ) {} - /** - * Returns whether the media is convertable - * @returns Whether the media is convertable - */ - public isConvertable() { - return ( - this.fromFormat !== this.toFormat && - Object.values(ConvertableMediaFormats).includes(this.fromFormat) - ); - } + /** + * Returns whether the media is convertable + * @returns Whether the media is convertable + */ + public isConvertable() { + return ( + this.fromFormat !== this.toFormat && + Object.values(ConvertableMediaFormats).includes(this.fromFormat) + ); + } - /** - * Returns the file name with the extension replaced - * @param fileName File name to replace - * @returns File name with extension replaced - */ - private getReplacedFileName(fileName: string) { - return this.extractFilenameFromPath(fileName).replace( - new RegExp(`\\.${this.fromFormat}$`), - `.${this.toFormat}` - ); - } + /** + * Returns the file name with the extension replaced + * @param fileName File name to replace + * @returns File name with extension replaced + */ + private getReplacedFileName(fileName: string) { + return this.extractFilenameFromPath(fileName).replace( + new RegExp(`\\.${this.fromFormat}$`), + `.${this.toFormat}`, + ); + } - /** - * Extracts the filename from a path - * @param path Path to extract filename from - * @returns Extracted filename - */ - private extractFilenameFromPath(path: string) { - // Don't count escaped slashes as path separators - const pathParts = path.split(/(? = { - [P in keyof T]?: DeepPartial; + [P in keyof T]?: DeepPartial; }; describe("MediaBackend", () => { - let mediaBackend: MediaBackend; - let mockConfig: ConfigType; + let mediaBackend: MediaBackend; + let mockConfig: Config; - beforeEach(() => { - mockConfig = { - media: { - conversion: { - convert_images: true, - }, - }, - } as ConfigType; - mediaBackend = new MediaBackend(mockConfig, MediaBackendType.S3); - }); + beforeEach(() => { + mockConfig = { + media: { + conversion: { + convert_images: true, + }, + }, + } as Config; + mediaBackend = new MediaBackend(mockConfig, MediaBackendType.S3); + }); - it("should initialize with correct backend type", () => { - expect(mediaBackend.getBackendType()).toEqual(MediaBackendType.S3); - }); + it("should initialize with correct backend type", () => { + expect(mediaBackend.getBackendType()).toEqual(MediaBackendType.S3); + }); - describe("fromBackendType", () => { - it("should return a LocalMediaBackend instance for LOCAL backend type", async () => { - const backend = await MediaBackend.fromBackendType( - MediaBackendType.LOCAL, - mockConfig - ); - expect(backend).toBeInstanceOf(LocalMediaBackend); - }); + describe("fromBackendType", () => { + it("should return a LocalMediaBackend instance for LOCAL backend type", async () => { + const backend = await MediaBackend.fromBackendType( + MediaBackendType.LOCAL, + mockConfig, + ); + expect(backend).toBeInstanceOf(LocalMediaBackend); + }); - it("should return a S3MediaBackend instance for S3 backend type", async () => { - const backend = await MediaBackend.fromBackendType( - MediaBackendType.S3, - { - s3: { - endpoint: "localhost:4566", - region: "us-east-1", - bucket_name: "test-bucket", - access_key: "test-access", - public_url: "test", - secret_access_key: "test-secret", - }, - } as ConfigType - ); - expect(backend).toBeInstanceOf(S3MediaBackend); - }); + it("should return a S3MediaBackend instance for S3 backend type", async () => { + const backend = await MediaBackend.fromBackendType( + MediaBackendType.S3, + { + s3: { + endpoint: "localhost:4566", + region: "us-east-1", + bucket_name: "test-bucket", + access_key: "test-access", + public_url: "test", + secret_access_key: "test-secret", + }, + } as Config, + ); + expect(backend).toBeInstanceOf(S3MediaBackend); + }); - it("should throw an error for unknown backend type", () => { - expect( - MediaBackend.fromBackendType("unknown" as any, mockConfig) - ).rejects.toThrow("Unknown backend type: unknown"); - }); - }); + it("should throw an error for unknown backend type", () => { + expect( + // @ts-expect-error This is a test + MediaBackend.fromBackendType("unknown", mockConfig), + ).rejects.toThrow("Unknown backend type: unknown"); + }); + }); - it("should check if images should be converted", () => { - expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(true); - mockConfig.media.conversion.convert_images = false; - expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(false); - }); + it("should check if images should be converted", () => { + expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(true); + mockConfig.media.conversion.convert_images = false; + expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(false); + }); - it("should throw error when calling getFileByHash", () => { - const mockHash = "test-hash"; - const databaseHashFetcher = jest.fn().mockResolvedValue("test.jpg"); + it("should throw error when calling getFileByHash", () => { + const mockHash = "test-hash"; + const databaseHashFetcher = jest.fn().mockResolvedValue("test.jpg"); - expect( - mediaBackend.getFileByHash(mockHash, databaseHashFetcher) - ).rejects.toThrow(Error); - }); + expect( + mediaBackend.getFileByHash(mockHash, databaseHashFetcher), + ).rejects.toThrow(Error); + }); - it("should throw error when calling getFile", () => { - const mockFilename = "test.jpg"; + it("should throw error when calling getFile", () => { + const mockFilename = "test.jpg"; - expect(mediaBackend.getFile(mockFilename)).rejects.toThrow(Error); - }); + expect(mediaBackend.getFile(mockFilename)).rejects.toThrow(Error); + }); - it("should throw error when calling addFile", () => { - const mockFile = new File([""], "test.jpg"); + it("should throw error when calling addFile", () => { + const mockFile = new File([""], "test.jpg"); - expect(mediaBackend.addFile(mockFile)).rejects.toThrow(); - }); + expect(mediaBackend.addFile(mockFile)).rejects.toThrow(); + }); }); describe("S3MediaBackend", () => { - let s3MediaBackend: S3MediaBackend; - let mockS3Client: Partial; - let mockConfig: DeepPartial; - let mockFile: File; - let mockMediaHasher: MediaHasher; + let s3MediaBackend: S3MediaBackend; + let mockS3Client: Partial; + let mockConfig: DeepPartial; + let mockFile: File; + let mockMediaHasher: MediaHasher; - beforeEach(() => { - mockConfig = { - s3: { - endpoint: "http://localhost:4566", - region: "us-east-1", - bucket_name: "test-bucket", - access_key: "test-access-key", - secret_access_key: "test-secret-access-key", - public_url: "test", - }, - media: { - conversion: { - convert_to: ConvertableMediaFormats.PNG, - }, - }, - }; - mockFile = new File([new TextEncoder().encode("test")], "test.jpg"); - mockMediaHasher = new MediaHasher(); - mockS3Client = { - putObject: jest.fn().mockResolvedValue({}), - statObject: jest.fn().mockResolvedValue({}), - getObject: jest.fn().mockResolvedValue({ - blob: jest.fn().mockResolvedValue(new Blob()), - headers: new Headers({ "Content-Type": "image/jpeg" }), - }), - } as Partial; - s3MediaBackend = new S3MediaBackend( - mockConfig as ConfigType, - mockS3Client as S3Client - ); - }); + beforeEach(() => { + mockConfig = { + s3: { + endpoint: "http://localhost:4566", + region: "us-east-1", + bucket_name: "test-bucket", + access_key: "test-access-key", + secret_access_key: "test-secret-access-key", + public_url: "test", + }, + media: { + conversion: { + convert_to: ConvertableMediaFormats.PNG, + }, + }, + }; + mockFile = new File([new TextEncoder().encode("test")], "test.jpg"); + mockMediaHasher = new MediaHasher(); + mockS3Client = { + putObject: jest.fn().mockResolvedValue({}), + statObject: jest.fn().mockResolvedValue({}), + getObject: jest.fn().mockResolvedValue({ + blob: jest.fn().mockResolvedValue(new Blob()), + headers: new Headers({ "Content-Type": "image/jpeg" }), + }), + } as Partial; + s3MediaBackend = new S3MediaBackend( + mockConfig as Config, + mockS3Client as S3Client, + ); + }); - it("should initialize with correct type", () => { - expect(s3MediaBackend.getBackendType()).toEqual(MediaBackendType.S3); - }); + it("should initialize with correct type", () => { + expect(s3MediaBackend.getBackendType()).toEqual(MediaBackendType.S3); + }); - it("should add file", async () => { - const mockHash = "test-hash"; - spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); + it("should add file", async () => { + const mockHash = "test-hash"; + spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); - const result = await s3MediaBackend.addFile(mockFile); + const result = await s3MediaBackend.addFile(mockFile); - expect(result.uploadedFile).toEqual(mockFile); - expect(result.hash).toHaveLength(64); - expect(mockS3Client.putObject).toHaveBeenCalledWith( - mockFile.name, - expect.any(ReadableStream), - { size: mockFile.size } - ); - }); + expect(result.uploadedFile).toEqual(mockFile); + expect(result.hash).toHaveLength(64); + expect(mockS3Client.putObject).toHaveBeenCalledWith( + mockFile.name, + expect.any(ReadableStream), + { size: mockFile.size }, + ); + }); - it("should get file by hash", async () => { - const mockHash = "test-hash"; - const mockFilename = "test.jpg"; - const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename); - mockS3Client.statObject = jest.fn().mockResolvedValue({}); - mockS3Client.getObject = jest.fn().mockResolvedValue({ - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), - headers: new Headers({ "Content-Type": "image/jpeg" }), - }); + it("should get file by hash", async () => { + const mockHash = "test-hash"; + const mockFilename = "test.jpg"; + const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename); + mockS3Client.statObject = jest.fn().mockResolvedValue({}); + mockS3Client.getObject = jest.fn().mockResolvedValue({ + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), + headers: new Headers({ "Content-Type": "image/jpeg" }), + }); - const file = await s3MediaBackend.getFileByHash( - mockHash, - databaseHashFetcher - ); + const file = await s3MediaBackend.getFileByHash( + mockHash, + databaseHashFetcher, + ); - expect(file).not.toBeNull(); - expect(file?.name).toEqual(mockFilename); - expect(file?.type).toEqual("image/jpeg"); - }); + expect(file).not.toBeNull(); + expect(file?.name).toEqual(mockFilename); + expect(file?.type).toEqual("image/jpeg"); + }); - it("should get file", async () => { - const mockFilename = "test.jpg"; - mockS3Client.statObject = jest.fn().mockResolvedValue({}); - mockS3Client.getObject = jest.fn().mockResolvedValue({ - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), - headers: new Headers({ "Content-Type": "image/jpeg" }), - }); + it("should get file", async () => { + const mockFilename = "test.jpg"; + mockS3Client.statObject = jest.fn().mockResolvedValue({}); + mockS3Client.getObject = jest.fn().mockResolvedValue({ + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), + headers: new Headers({ "Content-Type": "image/jpeg" }), + }); - const file = await s3MediaBackend.getFile(mockFilename); + const file = await s3MediaBackend.getFile(mockFilename); - expect(file).not.toBeNull(); - expect(file?.name).toEqual(mockFilename); - expect(file?.type).toEqual("image/jpeg"); - }); + expect(file).not.toBeNull(); + expect(file?.name).toEqual(mockFilename); + expect(file?.type).toEqual("image/jpeg"); + }); }); describe("LocalMediaBackend", () => { - let localMediaBackend: LocalMediaBackend; - let mockConfig: ConfigType; - let mockFile: File; - let mockMediaHasher: MediaHasher; + let localMediaBackend: LocalMediaBackend; + let mockConfig: Config; + let mockFile: File; + let mockMediaHasher: MediaHasher; - beforeEach(() => { - mockConfig = { - media: { - conversion: { - convert_images: true, - convert_to: ConvertableMediaFormats.PNG, - }, - local_uploads_folder: "./uploads", - }, - } as ConfigType; - mockFile = Bun.file(__dirname + "/megamind.jpg") as unknown as File; - mockMediaHasher = new MediaHasher(); - localMediaBackend = new LocalMediaBackend(mockConfig); - }); + beforeEach(() => { + mockConfig = { + media: { + conversion: { + convert_images: true, + convert_to: ConvertableMediaFormats.PNG, + }, + local_uploads_folder: "./uploads", + }, + } as Config; + mockFile = Bun.file(`${__dirname}/megamind.jpg`) as unknown as File; + mockMediaHasher = new MediaHasher(); + localMediaBackend = new LocalMediaBackend(mockConfig); + }); - it("should initialize with correct type", () => { - expect(localMediaBackend.getBackendType()).toEqual( - MediaBackendType.LOCAL - ); - }); + it("should initialize with correct type", () => { + expect(localMediaBackend.getBackendType()).toEqual( + MediaBackendType.LOCAL, + ); + }); - it("should add file", async () => { - const mockHash = "test-hash"; - spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); - const mockMediaConverter = new MediaConverter( - ConvertableMediaFormats.JPG, - ConvertableMediaFormats.PNG - ); - spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile); - // @ts-expect-error This is a mock - spyOn(Bun, "file").mockImplementationOnce(() => ({ - exists: () => Promise.resolve(false), - })); - spyOn(Bun, "write").mockImplementationOnce(() => - Promise.resolve(mockFile.size) - ); + it("should add file", async () => { + const mockHash = "test-hash"; + spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); + const mockMediaConverter = new MediaConverter( + ConvertableMediaFormats.JPG, + ConvertableMediaFormats.PNG, + ); + spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile); + // @ts-expect-error This is a mock + spyOn(Bun, "file").mockImplementationOnce(() => ({ + exists: () => Promise.resolve(false), + })); + spyOn(Bun, "write").mockImplementationOnce(() => + Promise.resolve(mockFile.size), + ); - const result = await localMediaBackend.addFile(mockFile); + const result = await localMediaBackend.addFile(mockFile); - expect(result.uploadedFile).toEqual(mockFile); - expect(result.path).toEqual(`./uploads/megamind.png`); - expect(result.hash).toHaveLength(64); - }); + expect(result.uploadedFile).toEqual(mockFile); + expect(result.path).toEqual("./uploads/megamind.png"); + expect(result.hash).toHaveLength(64); + }); - it("should get file by hash", async () => { - const mockHash = "test-hash"; - const mockFilename = "test.jpg"; - const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename); - // @ts-expect-error This is a mock - spyOn(Bun, "file").mockImplementationOnce(() => ({ - exists: () => Promise.resolve(true), - arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), - type: "image/jpeg", - lastModified: 123456789, - })); + it("should get file by hash", async () => { + const mockHash = "test-hash"; + const mockFilename = "test.jpg"; + const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename); + // @ts-expect-error This is a mock + spyOn(Bun, "file").mockImplementationOnce(() => ({ + exists: () => Promise.resolve(true), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + type: "image/jpeg", + lastModified: 123456789, + })); - const file = await localMediaBackend.getFileByHash( - mockHash, - databaseHashFetcher - ); + const file = await localMediaBackend.getFileByHash( + mockHash, + databaseHashFetcher, + ); - expect(file).not.toBeNull(); - expect(file?.name).toEqual(mockFilename); - expect(file?.type).toEqual("image/jpeg"); - }); + expect(file).not.toBeNull(); + expect(file?.name).toEqual(mockFilename); + expect(file?.type).toEqual("image/jpeg"); + }); - it("should get file", async () => { - const mockFilename = "test.jpg"; - // @ts-expect-error This is a mock - spyOn(Bun, "file").mockImplementationOnce(() => ({ - exists: () => Promise.resolve(true), - arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), - type: "image/jpeg", - lastModified: 123456789, - })); + it("should get file", async () => { + const mockFilename = "test.jpg"; + // @ts-expect-error This is a mock + spyOn(Bun, "file").mockImplementationOnce(() => ({ + exists: () => Promise.resolve(true), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + type: "image/jpeg", + lastModified: 123456789, + })); - const file = await localMediaBackend.getFile(mockFilename); + const file = await localMediaBackend.getFile(mockFilename); - expect(file).not.toBeNull(); - expect(file?.name).toEqual(mockFilename); - expect(file?.type).toEqual("image/jpeg"); - }); + expect(file).not.toBeNull(); + expect(file?.name).toEqual(mockFilename); + expect(file?.type).toEqual("image/jpeg"); + }); }); diff --git a/packages/media-manager/tests/media-manager.test.ts b/packages/media-manager/tests/media-manager.test.ts index 017f3b6a..b6125b9d 100644 --- a/packages/media-manager/tests/media-manager.test.ts +++ b/packages/media-manager/tests/media-manager.test.ts @@ -1,65 +1,65 @@ // FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/media-converter.test.ts -import { describe, it, expect, beforeEach } from "bun:test"; -import { MediaConverter, ConvertableMediaFormats } from "../media-converter"; +import { beforeEach, describe, expect, it } from "bun:test"; +import { ConvertableMediaFormats, MediaConverter } from "../media-converter"; describe("MediaConverter", () => { - let mediaConverter: MediaConverter; + let mediaConverter: MediaConverter; - beforeEach(() => { - mediaConverter = new MediaConverter( - ConvertableMediaFormats.JPG, - ConvertableMediaFormats.PNG - ); - }); + beforeEach(() => { + mediaConverter = new MediaConverter( + ConvertableMediaFormats.JPG, + ConvertableMediaFormats.PNG, + ); + }); - it("should initialize with correct formats", () => { - expect(mediaConverter.fromFormat).toEqual(ConvertableMediaFormats.JPG); - expect(mediaConverter.toFormat).toEqual(ConvertableMediaFormats.PNG); - }); + it("should initialize with correct formats", () => { + expect(mediaConverter.fromFormat).toEqual(ConvertableMediaFormats.JPG); + expect(mediaConverter.toFormat).toEqual(ConvertableMediaFormats.PNG); + }); - it("should check if media is convertable", () => { - expect(mediaConverter.isConvertable()).toBe(true); - mediaConverter.toFormat = ConvertableMediaFormats.JPG; - expect(mediaConverter.isConvertable()).toBe(false); - }); + it("should check if media is convertable", () => { + expect(mediaConverter.isConvertable()).toBe(true); + mediaConverter.toFormat = ConvertableMediaFormats.JPG; + expect(mediaConverter.isConvertable()).toBe(false); + }); - it("should replace file name extension", () => { - const fileName = "test.jpg"; - const expectedFileName = "test.png"; - // Written like this because it's a private function - expect(mediaConverter["getReplacedFileName"](fileName)).toEqual( - expectedFileName - ); - }); + it("should replace file name extension", () => { + const fileName = "test.jpg"; + const expectedFileName = "test.png"; + // Written like this because it's a private function + expect(mediaConverter.getReplacedFileName(fileName)).toEqual( + expectedFileName, + ); + }); - describe("Filename extractor", () => { - it("should extract filename from path", () => { - const path = "path/to/test.jpg"; - const expectedFileName = "test.jpg"; - expect(mediaConverter["extractFilenameFromPath"](path)).toEqual( - expectedFileName - ); - }); + describe("Filename extractor", () => { + it("should extract filename from path", () => { + const path = "path/to/test.jpg"; + const expectedFileName = "test.jpg"; + expect(mediaConverter.extractFilenameFromPath(path)).toEqual( + expectedFileName, + ); + }); - it("should handle escaped slashes", () => { - const path = "path/to/test\\/test.jpg"; - const expectedFileName = "test\\/test.jpg"; - expect(mediaConverter["extractFilenameFromPath"](path)).toEqual( - expectedFileName - ); - }); - }); + it("should handle escaped slashes", () => { + const path = "path/to/test\\/test.jpg"; + const expectedFileName = "test\\/test.jpg"; + expect(mediaConverter.extractFilenameFromPath(path)).toEqual( + expectedFileName, + ); + }); + }); - it("should convert media", async () => { - const file = Bun.file(__dirname + "/megamind.jpg"); + it("should convert media", async () => { + const file = Bun.file(`${__dirname}/megamind.jpg`); - const convertedFile = await mediaConverter.convert( - file as unknown as File - ); + const convertedFile = await mediaConverter.convert( + file as unknown as File, + ); - expect(convertedFile.name).toEqual("megamind.png"); - expect(convertedFile.type).toEqual( - `image/${ConvertableMediaFormats.PNG}` - ); - }); + expect(convertedFile.name).toEqual("megamind.png"); + expect(convertedFile.type).toEqual( + `image/${ConvertableMediaFormats.PNG}`, + ); + }); }); diff --git a/packages/protocol-translator/index.ts b/packages/protocol-translator/index.ts index 06de64e0..ca5cda81 100644 --- a/packages/protocol-translator/index.ts +++ b/packages/protocol-translator/index.ts @@ -2,7 +2,7 @@ import type { APActor, APNote } from "activitypub-types"; import { ActivityPubTranslator } from "./protocols/activitypub"; export enum SupportedProtocols { - ACTIVITYPUB = "activitypub", + ACTIVITYPUB = "activitypub", } /** @@ -12,37 +12,40 @@ export enum SupportedProtocols { * This class is not meant to be instantiated directly, but rather for its children to be used. */ export class ProtocolTranslator { - static auto(object: any) { - const protocol = this.recognizeProtocol(object); - switch (protocol) { - case SupportedProtocols.ACTIVITYPUB: - return new ActivityPubTranslator(); - default: - throw new Error("Unknown protocol"); - } - } + // biome-ignore lint/suspicious/noExplicitAny: + static auto(object: any) { + const protocol = ProtocolTranslator.recognizeProtocol(object); + switch (protocol) { + case SupportedProtocols.ACTIVITYPUB: + return new ActivityPubTranslator(); + default: + throw new Error("Unknown protocol"); + } + } - /** - * Translates an ActivityPub actor to a Lysand user - * @param data Raw JSON-LD data from an ActivityPub actor - */ - user(data: APActor) { - // - } + /** + * Translates an ActivityPub actor to a Lysand user + * @param data Raw JSON-LD data from an ActivityPub actor + */ + user(data: APActor) { + // + } - /** - * Translates an ActivityPub note to a Lysand status - * @param data Raw JSON-LD data from an ActivityPub note - */ - status(data: APNote) { - // - } + /** + * Translates an ActivityPub note to a Lysand status + * @param data Raw JSON-LD data from an ActivityPub note + */ + status(data: APNote) { + // + } - /** - * Automatically recognizes the protocol of a given object - */ - private static recognizeProtocol(object: any) { - // Temporary stub - return SupportedProtocols.ACTIVITYPUB; - } + /** + * Automatically recognizes the protocol of a given object + */ + + // biome-ignore lint/suspicious/noExplicitAny: + private static recognizeProtocol(object: any) { + // Temporary stub + return SupportedProtocols.ACTIVITYPUB; + } } diff --git a/packages/protocol-translator/package.json b/packages/protocol-translator/package.json index 4262e9d5..22108e4c 100644 --- a/packages/protocol-translator/package.json +++ b/packages/protocol-translator/package.json @@ -1,9 +1,9 @@ { - "name": "protocol-translator", - "version": "0.0.0", - "main": "index.ts", - "dependencies": {}, - "devDependencies": { - "activitypub-types": "^1.1.0" - } -} \ No newline at end of file + "name": "protocol-translator", + "version": "0.0.0", + "main": "index.ts", + "dependencies": {}, + "devDependencies": { + "activitypub-types": "^1.1.0" + } +} diff --git a/packages/protocol-translator/protocols/activitypub.ts b/packages/protocol-translator/protocols/activitypub.ts index c3425d04..6e3c7697 100644 --- a/packages/protocol-translator/protocols/activitypub.ts +++ b/packages/protocol-translator/protocols/activitypub.ts @@ -1,11 +1,5 @@ import { ProtocolTranslator } from ".."; export class ActivityPubTranslator extends ProtocolTranslator { - constructor() { - super(); - } - - user() { - - } -} \ No newline at end of file + user() {} +} diff --git a/packages/request-parser/index.ts b/packages/request-parser/index.ts index 6351fecc..7e42699d 100644 --- a/packages/request-parser/index.ts +++ b/packages/request-parser/index.ts @@ -13,158 +13,158 @@ * @returns JavaScript object of type T */ export class RequestParser { - constructor(public request: Request) {} + constructor(public request: Request) {} - /** - * Parse request body into a JavaScript object - * @returns JavaScript object of type T - * @throws Error if body is invalid - */ - async toObject() { - try { - switch (await this.determineContentType()) { - case "application/json": - return this.parseJson(); - case "application/x-www-form-urlencoded": - return this.parseFormUrlencoded(); - case "multipart/form-data": - return this.parseFormData(); - default: - return this.parseQuery(); - } - } catch { - return {} as T; - } - } + /** + * Parse request body into a JavaScript object + * @returns JavaScript object of type T + * @throws Error if body is invalid + */ + async toObject() { + try { + switch (await this.determineContentType()) { + case "application/json": + return this.parseJson(); + case "application/x-www-form-urlencoded": + return this.parseFormUrlencoded(); + case "multipart/form-data": + return this.parseFormData(); + default: + return this.parseQuery(); + } + } catch { + return {} as T; + } + } - /** - * Determine body content type - * If there is no Content-Type header, automatically - * guess content type. Cuts off after ";" character - * @returns Content-Type header value, or empty string if there is no body - * @throws Error if body is invalid - * @private - */ - private async determineContentType() { - if (this.request.headers.get("Content-Type")) { - return ( - this.request.headers.get("Content-Type")?.split(";")[0] ?? "" - ); - } + /** + * Determine body content type + * If there is no Content-Type header, automatically + * guess content type. Cuts off after ";" character + * @returns Content-Type header value, or empty string if there is no body + * @throws Error if body is invalid + * @private + */ + private async determineContentType() { + if (this.request.headers.get("Content-Type")) { + return ( + this.request.headers.get("Content-Type")?.split(";")[0] ?? "" + ); + } - // Check if body is valid JSON - try { - await this.request.json(); - return "application/json"; - } catch { - // This is not JSON - } + // Check if body is valid JSON + try { + await this.request.json(); + return "application/json"; + } catch { + // This is not JSON + } - // Check if body is valid FormData - try { - await this.request.formData(); - return "multipart/form-data"; - } catch { - // This is not FormData - } + // Check if body is valid FormData + try { + await this.request.formData(); + return "multipart/form-data"; + } catch { + // This is not FormData + } - if (this.request.body) { - throw new Error("Invalid body"); - } + if (this.request.body) { + throw new Error("Invalid body"); + } - // If there is no body, return query parameters - return ""; - } + // If there is no body, return query parameters + return ""; + } - /** - * Parse FormData body into a JavaScript object - * @returns JavaScript object of type T - * @private - * @throws Error if body is invalid - */ - private async parseFormData(): Promise> { - const formData = await this.request.formData(); - const result: Partial = {}; + /** + * Parse FormData body into a JavaScript object + * @returns JavaScript object of type T + * @private + * @throws Error if body is invalid + */ + private async parseFormData(): Promise> { + const formData = await this.request.formData(); + const result: Partial = {}; - for (const [key, value] of formData.entries()) { - if (value instanceof File) { - result[key as keyof T] = value as any; - } else if (key.endsWith("[]")) { - const arrayKey = key.slice(0, -2) as keyof T; - if (!result[arrayKey]) { - result[arrayKey] = [] as T[keyof T]; - } + for (const [key, value] of formData.entries()) { + if (value instanceof File) { + 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 any[]).push(value); - } else { - result[key as keyof T] = value as any; - } - } + (result[arrayKey] as FormDataEntryValue[]).push(value); + } else { + result[key as keyof T] = value as T[keyof T]; + } + } - return result; - } + return result; + } - /** - * Parse application/x-www-form-urlencoded body into a JavaScript object - * @returns JavaScript object of type T - * @private - * @throws Error if body is invalid - */ - private async parseFormUrlencoded(): Promise> { - const formData = await this.request.formData(); - const result: Partial = {}; + /** + * Parse application/x-www-form-urlencoded body into a JavaScript object + * @returns JavaScript object of type T + * @private + * @throws Error if body is invalid + */ + private async parseFormUrlencoded(): Promise> { + const formData = await this.request.formData(); + const result: Partial = {}; - for (const [key, value] of formData.entries()) { - if (key.endsWith("[]")) { - const arrayKey = key.slice(0, -2) as keyof T; - if (!result[arrayKey]) { - result[arrayKey] = [] as T[keyof T]; - } + for (const [key, value] of formData.entries()) { + if (key.endsWith("[]")) { + const arrayKey = key.slice(0, -2) as keyof T; + if (!result[arrayKey]) { + result[arrayKey] = [] as T[keyof T]; + } - (result[arrayKey] as any[]).push(value); - } else { - result[key as keyof T] = value as any; - } - } + (result[arrayKey] as FormDataEntryValue[]).push(value); + } else { + result[key as keyof T] = value as T[keyof T]; + } + } - return result; - } + return result; + } - /** - * Parse JSON body into a JavaScript object - * @returns JavaScript object of type T - * @private - * @throws Error if body is invalid - */ - private async parseJson(): Promise> { - try { - return (await this.request.json()) as T; - } catch { - return {}; - } - } + /** + * Parse JSON body into a JavaScript object + * @returns JavaScript object of type T + * @private + * @throws Error if body is invalid + */ + private async parseJson(): Promise> { + try { + return (await this.request.json()) as T; + } catch { + return {}; + } + } - /** - * Parse query parameters into a JavaScript object - * @private - * @throws Error if body is invalid - * @returns JavaScript object of type T - */ - private parseQuery(): Partial { - const result: Partial = {}; - const url = new URL(this.request.url); + /** + * Parse query parameters into a JavaScript object + * @private + * @throws Error if body is invalid + * @returns JavaScript object of type T + */ + private parseQuery(): Partial { + const result: Partial = {}; + const url = new URL(this.request.url); - for (const [key, value] of url.searchParams.entries()) { - if (key.endsWith("[]")) { - const arrayKey = key.slice(0, -2) as keyof T; - if (!result[arrayKey]) { - result[arrayKey] = [] as T[keyof T]; - } - (result[arrayKey] as string[]).push(value); - } else { - result[key as keyof T] = value as any; - } - } - return result; - } + for (const [key, value] of url.searchParams.entries()) { + if (key.endsWith("[]")) { + const arrayKey = key.slice(0, -2) as keyof T; + if (!result[arrayKey]) { + result[arrayKey] = [] as T[keyof T]; + } + (result[arrayKey] as string[]).push(value); + } else { + result[key as keyof T] = value as T[keyof T]; + } + } + return result; + } } diff --git a/packages/request-parser/package.json b/packages/request-parser/package.json index 89d30d2c..2f2d3fdb 100644 --- a/packages/request-parser/package.json +++ b/packages/request-parser/package.json @@ -3,4 +3,4 @@ "version": "0.0.0", "main": "index.ts", "dependencies": {} -} \ No newline at end of file +} diff --git a/packages/request-parser/tests/request-parser.test.ts b/packages/request-parser/tests/request-parser.test.ts index d6f4bf20..9754b25a 100644 --- a/packages/request-parser/tests/request-parser.test.ts +++ b/packages/request-parser/tests/request-parser.test.ts @@ -1,158 +1,158 @@ -import { describe, it, expect, test } from "bun:test"; +import { describe, expect, it, test } from "bun:test"; import { RequestParser } from ".."; describe("RequestParser", () => { - describe("Should parse query parameters correctly", () => { - test("With text parameters", async () => { - const request = new Request( - "http://localhost?param1=value1¶m2=value2" - ); - const result = await new RequestParser(request).toObject<{ - param1: string; - param2: string; - }>(); - expect(result).toEqual({ param1: "value1", param2: "value2" }); - }); + describe("Should parse query parameters correctly", () => { + test("With text parameters", async () => { + const request = new Request( + "http://localhost?param1=value1¶m2=value2", + ); + const result = await new RequestParser(request).toObject<{ + param1: string; + param2: string; + }>(); + expect(result).toEqual({ param1: "value1", param2: "value2" }); + }); - test("With Array", async () => { - const request = new Request( - "http://localhost?test[]=value1&test[]=value2" - ); - const result = await new RequestParser(request).toObject<{ - test: string[]; - }>(); - expect(result.test).toEqual(["value1", "value2"]); - }); + test("With Array", async () => { + const request = new Request( + "http://localhost?test[]=value1&test[]=value2", + ); + const result = await new RequestParser(request).toObject<{ + test: string[]; + }>(); + expect(result.test).toEqual(["value1", "value2"]); + }); - test("With both at once", async () => { - const request = new Request( - "http://localhost?param1=value1¶m2=value2&test[]=value1&test[]=value2" - ); - const result = await new RequestParser(request).toObject<{ - param1: string; - param2: string; - test: string[]; - }>(); - expect(result).toEqual({ - param1: "value1", - param2: "value2", - test: ["value1", "value2"], - }); - }); - }); + test("With both at once", async () => { + const request = new Request( + "http://localhost?param1=value1¶m2=value2&test[]=value1&test[]=value2", + ); + const result = await new RequestParser(request).toObject<{ + param1: string; + param2: string; + test: string[]; + }>(); + expect(result).toEqual({ + param1: "value1", + param2: "value2", + test: ["value1", "value2"], + }); + }); + }); - it("should parse JSON body correctly", async () => { - const request = new Request("http://localhost", { - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ param1: "value1", param2: "value2" }), - }); - const result = await new RequestParser(request).toObject<{ - param1: string; - param2: string; - }>(); - expect(result).toEqual({ param1: "value1", param2: "value2" }); - }); + it("should parse JSON body correctly", async () => { + const request = new Request("http://localhost", { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ param1: "value1", param2: "value2" }), + }); + const result = await new RequestParser(request).toObject<{ + param1: string; + param2: string; + }>(); + expect(result).toEqual({ param1: "value1", param2: "value2" }); + }); - it("should handle invalid JSON body", async () => { - const request = new Request("http://localhost", { - headers: { "Content-Type": "application/json" }, - body: "invalid json", - }); - const result = await new RequestParser(request).toObject<{ - param1: string; - param2: string; - }>(); - expect(result).toEqual({}); - }); + it("should handle invalid JSON body", async () => { + const request = new Request("http://localhost", { + headers: { "Content-Type": "application/json" }, + body: "invalid json", + }); + const result = await new RequestParser(request).toObject<{ + param1: string; + param2: string; + }>(); + expect(result).toEqual({}); + }); - describe("should parse form data correctly", () => { - test("With basic text parameters", async () => { - const formData = new FormData(); - formData.append("param1", "value1"); - formData.append("param2", "value2"); - const request = new Request("http://localhost", { - method: "POST", - body: formData, - }); - const result = await new RequestParser(request).toObject<{ - param1: string; - param2: string; - }>(); - expect(result).toEqual({ param1: "value1", param2: "value2" }); - }); + describe("should parse form data correctly", () => { + test("With basic text parameters", async () => { + const formData = new FormData(); + formData.append("param1", "value1"); + formData.append("param2", "value2"); + const request = new Request("http://localhost", { + method: "POST", + body: formData, + }); + const result = await new RequestParser(request).toObject<{ + param1: string; + param2: string; + }>(); + expect(result).toEqual({ param1: "value1", param2: "value2" }); + }); - test("With File object", async () => { - const file = new File(["content"], "filename.txt", { - type: "text/plain", - }); - const formData = new FormData(); - formData.append("file", file); - const request = new Request("http://localhost", { - method: "POST", - body: formData, - }); - const result = await new RequestParser(request).toObject<{ - file: File; - }>(); - expect(result.file).toBeInstanceOf(File); - expect(await result.file?.text()).toEqual("content"); - }); + test("With File object", async () => { + const file = new File(["content"], "filename.txt", { + type: "text/plain", + }); + const formData = new FormData(); + formData.append("file", file); + const request = new Request("http://localhost", { + method: "POST", + body: formData, + }); + const result = await new RequestParser(request).toObject<{ + file: File; + }>(); + expect(result.file).toBeInstanceOf(File); + expect(await result.file?.text()).toEqual("content"); + }); - test("With Array", async () => { - const formData = new FormData(); - formData.append("test[]", "value1"); - formData.append("test[]", "value2"); - const request = new Request("http://localhost", { - method: "POST", - body: formData, - }); - const result = await new RequestParser(request).toObject<{ - test: string[]; - }>(); - expect(result.test).toEqual(["value1", "value2"]); - }); + test("With Array", async () => { + const formData = new FormData(); + formData.append("test[]", "value1"); + formData.append("test[]", "value2"); + const request = new Request("http://localhost", { + method: "POST", + body: formData, + }); + const result = await new RequestParser(request).toObject<{ + test: string[]; + }>(); + expect(result.test).toEqual(["value1", "value2"]); + }); - test("With all three at once", async () => { - const file = new File(["content"], "filename.txt", { - type: "text/plain", - }); - const formData = new FormData(); - formData.append("param1", "value1"); - formData.append("param2", "value2"); - formData.append("file", file); - formData.append("test[]", "value1"); - formData.append("test[]", "value2"); - const request = new Request("http://localhost", { - method: "POST", - body: formData, - }); - const result = await new RequestParser(request).toObject<{ - param1: string; - param2: string; - file: File; - test: string[]; - }>(); - expect(result).toEqual({ - param1: "value1", - param2: "value2", - file: file, - test: ["value1", "value2"], - }); - }); + test("With all three at once", async () => { + const file = new File(["content"], "filename.txt", { + type: "text/plain", + }); + const formData = new FormData(); + formData.append("param1", "value1"); + formData.append("param2", "value2"); + formData.append("file", file); + formData.append("test[]", "value1"); + formData.append("test[]", "value2"); + const request = new Request("http://localhost", { + method: "POST", + body: formData, + }); + const result = await new RequestParser(request).toObject<{ + param1: string; + param2: string; + file: File; + test: string[]; + }>(); + expect(result).toEqual({ + param1: "value1", + param2: "value2", + file: file, + test: ["value1", "value2"], + }); + }); - test("URL Encoded", async () => { - const request = new Request("http://localhost", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: "param1=value1¶m2=value2", - }); - const result = await new RequestParser(request).toObject<{ - param1: string; - param2: string; - }>(); - expect(result).toEqual({ param1: "value1", param2: "value2" }); - }); - }); + test("URL Encoded", async () => { + const request = new Request("http://localhost", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "param1=value1¶m2=value2", + }); + const result = await new RequestParser(request).toObject<{ + param1: string; + param2: string; + }>(); + expect(result).toEqual({ param1: "value1", param2: "value2" }); + }); + }); }); diff --git a/pages/App.vue b/pages/App.vue index 3b798503..f2bde911 100644 --- a/pages/App.vue +++ b/pages/App.vue @@ -1,6 +1,6 @@ \ No newline at end of file diff --git a/pages/main.ts b/pages/main.ts index 92f1b4c5..41aa6d39 100644 --- a/pages/main.ts +++ b/pages/main.ts @@ -1,13 +1,13 @@ -import { createApp } from "vue"; -import "./style.css"; import "virtual:uno.css"; +import { createApp } from "vue"; import { createRouter, createWebHistory } from "vue-router"; import App from "./App.vue"; import routes from "./routes"; +import "./style.css"; const router = createRouter({ - history: createWebHistory(), - routes: routes, + history: createWebHistory(), + routes: routes, }); const app = createApp(App); diff --git a/pages/pages/index.vue b/pages/pages/index.vue index 0c65d837..a9eb6045 100644 --- a/pages/pages/index.vue +++ b/pages/pages/index.vue @@ -37,7 +37,7 @@ \ No newline at end of file diff --git a/pages/pages/oauth/redirect.vue b/pages/pages/oauth/redirect.vue index 5a06fa51..6543636f 100644 --- a/pages/pages/oauth/redirect.vue +++ b/pages/pages/oauth/redirect.vue @@ -53,7 +53,7 @@ \ No newline at end of file diff --git a/pages/pages/register/index.vue b/pages/pages/register/index.vue index 731e579f..03cce8e2 100644 --- a/pages/pages/register/index.vue +++ b/pages/pages/register/index.vue @@ -98,12 +98,14 @@ \ No newline at end of file diff --git a/pages/routes.ts b/pages/routes.ts index b15ef1e7..355b9236 100644 --- a/pages/routes.ts +++ b/pages/routes.ts @@ -6,9 +6,9 @@ import registerIndexVue from "./pages/register/index.vue"; import successVue from "./pages/register/success.vue"; export default [ - { path: "/", component: indexVue }, - { path: "/oauth/authorize", component: authorizeVue }, - { path: "/oauth/redirect", component: redirectVue }, - { path: "/register", component: registerIndexVue }, - { path: "/register/success", component: successVue }, + { path: "/", component: indexVue }, + { path: "/oauth/authorize", component: authorizeVue }, + { path: "/oauth/redirect", component: redirectVue }, + { path: "/register", component: registerIndexVue }, + { path: "/register/success", component: successVue }, ] as RouteRecordRaw[]; diff --git a/pages/vite.config.ts b/pages/vite.config.ts index dd23d757..eaf65bed 100644 --- a/pages/vite.config.ts +++ b/pages/vite.config.ts @@ -1,35 +1,35 @@ -import { defineConfig } from "vite"; -import UnoCSS from "unocss/vite"; import vue from "@vitejs/plugin-vue"; +import UnoCSS from "unocss/vite"; +import { defineConfig } from "vite"; import pkg from "../package.json"; export default defineConfig({ - base: "/", - build: { - outDir: "./dist", - }, - // main.ts is in pages/ directory - resolve: { - alias: { - vue: "vue/dist/vue.esm-bundler", - }, - }, - server: { - hmr: { - clientPort: 5173, - }, - }, - define: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - __VERSION__: JSON.stringify(pkg.version), - }, - ssr: { - noExternal: ["@prisma/client"], - }, - plugins: [ - UnoCSS({ - mode: "global", - }), - vue(), - ], + base: "/", + build: { + outDir: "./dist", + }, + // main.ts is in pages/ directory + resolve: { + alias: { + vue: "vue/dist/vue.esm-bundler", + }, + }, + server: { + hmr: { + clientPort: 5173, + }, + }, + define: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + __VERSION__: JSON.stringify(pkg.version), + }, + ssr: { + noExternal: ["@prisma/client"], + }, + plugins: [ + UnoCSS({ + mode: "global", + }), + vue(), + ], }); diff --git a/plugins/test.plugin.ts b/plugins/test.plugin.ts index 2b279bb7..1fee5f10 100644 --- a/plugins/test.plugin.ts +++ b/plugins/test.plugin.ts @@ -2,11 +2,11 @@ import type { Server } from "./types"; import { HookTypes } from "./types"; const registerPlugin = (server: Server) => { - server.on(HookTypes.OnPostCreate, (req, newPost, author) => { - console.log("New post created!"); - console.log(`Post details: ${newPost.content} (${newPost.id})`); - console.log(`Made by ${author.username} (${author.id})`); - }); + server.on(HookTypes.OnPostCreate, (req, newPost, author) => { + console.log("New post created!"); + console.log(`Post details: ${newPost.content} (${newPost.id})`); + console.log(`Made by ${author.username} (${author.id})`); + }); }; export default registerPlugin; diff --git a/plugins/types.ts b/plugins/types.ts index b63f527f..9cac4956 100644 --- a/plugins/types.ts +++ b/plugins/types.ts @@ -4,152 +4,152 @@ import type { UserWithRelations } from "~database/entities/User"; import type { LysandObjectType } from "~types/lysand/Object"; export enum HookTypes { - /** - * Called before the server starts listening - */ - PreServe = "preServe", - /** - * Called after the server stops listening - */ - PostServe = "postServe", - /** - * Called on every HTTP request (before anything else is done) - */ - OnRequestReceive = "onRequestReceive", - /** - * Called on every HTTP request (after it is processed) - */ - OnRequestProcessed = "onRequestProcessed", - /** - * Called on every object received (before it is parsed and added to the database) - */ - OnObjectReceive = "onObjectReceive", - /** - * Called on every object processed (after it is parsed and added to the database) - */ - OnObjectProcessed = "onObjectProcessed", - /** - * Called when signature verification fails on an object - */ - OnCryptoFail = "onCryptoFail", - /** - * Called when signature verification succeeds on an object - */ - OnCryptoSuccess = "onCryptoSuccess", - /** - * Called when a user is banned by another user - */ - OnBan = "onBan", - /** - * Called when a user is suspended by another user - */ - OnSuspend = "onSuspend", - /** - * Called when a user is blocked by another user - */ - OnUserBlock = "onUserBlock", - /** - * Called when a user is muted by another user - */ - OnUserMute = "onUserMute", - /** - * Called when a user is followed by another user - */ - OnUserFollow = "onUserFollow", - /** - * Called when a user registers (before completing email verification) - */ - OnRegister = "onRegister", - /** - * Called when a user finishes registering (after completing email verification) - */ - OnRegisterFinish = "onRegisterFinish", - /** - * Called when a user deletes their account - */ - OnDeleteAccount = "onDeleteAccount", - /** - * Called when a post is created - */ - OnPostCreate = "onPostCreate", - /** - * Called when a post is deleted - */ - OnPostDelete = "onPostDelete", - /** - * Called when a post is updated - */ - OnPostUpdate = "onPostUpdate", + /** + * Called before the server starts listening + */ + PreServe = "preServe", + /** + * Called after the server stops listening + */ + PostServe = "postServe", + /** + * Called on every HTTP request (before anything else is done) + */ + OnRequestReceive = "onRequestReceive", + /** + * Called on every HTTP request (after it is processed) + */ + OnRequestProcessed = "onRequestProcessed", + /** + * Called on every object received (before it is parsed and added to the database) + */ + OnObjectReceive = "onObjectReceive", + /** + * Called on every object processed (after it is parsed and added to the database) + */ + OnObjectProcessed = "onObjectProcessed", + /** + * Called when signature verification fails on an object + */ + OnCryptoFail = "onCryptoFail", + /** + * Called when signature verification succeeds on an object + */ + OnCryptoSuccess = "onCryptoSuccess", + /** + * Called when a user is banned by another user + */ + OnBan = "onBan", + /** + * Called when a user is suspended by another user + */ + OnSuspend = "onSuspend", + /** + * Called when a user is blocked by another user + */ + OnUserBlock = "onUserBlock", + /** + * Called when a user is muted by another user + */ + OnUserMute = "onUserMute", + /** + * Called when a user is followed by another user + */ + OnUserFollow = "onUserFollow", + /** + * Called when a user registers (before completing email verification) + */ + OnRegister = "onRegister", + /** + * Called when a user finishes registering (after completing email verification) + */ + OnRegisterFinish = "onRegisterFinish", + /** + * Called when a user deletes their account + */ + OnDeleteAccount = "onDeleteAccount", + /** + * Called when a post is created + */ + OnPostCreate = "onPostCreate", + /** + * Called when a post is deleted + */ + OnPostDelete = "onPostDelete", + /** + * Called when a post is updated + */ + OnPostUpdate = "onPostUpdate", } export interface ServerStats { - postCount: number; + postCount: number; } interface ServerEvents { - [HookTypes.PreServe]: () => void; - [HookTypes.PostServe]: (stats: ServerStats) => void; - [HookTypes.OnRequestReceive]: (req: Request) => void; - [HookTypes.OnRequestProcessed]: (req: Request) => void; - [HookTypes.OnObjectReceive]: (obj: LysandObjectType) => void; - [HookTypes.OnObjectProcessed]: (obj: LysandObjectType) => void; - [HookTypes.OnCryptoFail]: ( - req: Request, - obj: LysandObjectType, - author: UserWithRelations, - publicKey: string - ) => void; - [HookTypes.OnCryptoSuccess]: ( - req: Request, - obj: LysandObjectType, - author: UserWithRelations, - publicKey: string - ) => void; - [HookTypes.OnBan]: ( - req: Request, - bannedUser: UserWithRelations, - banner: UserWithRelations - ) => void; - [HookTypes.OnSuspend]: ( - req: Request, - suspendedUser: UserWithRelations, - suspender: UserWithRelations - ) => void; - [HookTypes.OnUserBlock]: ( - req: Request, - blockedUser: UserWithRelations, - blocker: UserWithRelations - ) => void; - [HookTypes.OnUserMute]: ( - req: Request, - mutedUser: UserWithRelations, - muter: UserWithRelations - ) => void; - [HookTypes.OnUserFollow]: ( - req: Request, - followedUser: UserWithRelations, - follower: UserWithRelations - ) => void; - [HookTypes.OnRegister]: (req: Request, newUser: UserWithRelations) => void; - [HookTypes.OnDeleteAccount]: ( - req: Request, - deletedUser: UserWithRelations - ) => void; - [HookTypes.OnPostCreate]: ( - req: Request, - newPost: StatusWithRelations, - author: UserWithRelations - ) => void; - [HookTypes.OnPostDelete]: ( - req: Request, - deletedPost: StatusWithRelations, - deleter: UserWithRelations - ) => void; - [HookTypes.OnPostUpdate]: ( - req: Request, - updatedPost: StatusWithRelations, - updater: UserWithRelations - ) => void; + [HookTypes.PreServe]: () => void; + [HookTypes.PostServe]: (stats: ServerStats) => void; + [HookTypes.OnRequestReceive]: (req: Request) => void; + [HookTypes.OnRequestProcessed]: (req: Request) => void; + [HookTypes.OnObjectReceive]: (obj: LysandObjectType) => void; + [HookTypes.OnObjectProcessed]: (obj: LysandObjectType) => void; + [HookTypes.OnCryptoFail]: ( + req: Request, + obj: LysandObjectType, + author: UserWithRelations, + publicKey: string, + ) => void; + [HookTypes.OnCryptoSuccess]: ( + req: Request, + obj: LysandObjectType, + author: UserWithRelations, + publicKey: string, + ) => void; + [HookTypes.OnBan]: ( + req: Request, + bannedUser: UserWithRelations, + banner: UserWithRelations, + ) => void; + [HookTypes.OnSuspend]: ( + req: Request, + suspendedUser: UserWithRelations, + suspender: UserWithRelations, + ) => void; + [HookTypes.OnUserBlock]: ( + req: Request, + blockedUser: UserWithRelations, + blocker: UserWithRelations, + ) => void; + [HookTypes.OnUserMute]: ( + req: Request, + mutedUser: UserWithRelations, + muter: UserWithRelations, + ) => void; + [HookTypes.OnUserFollow]: ( + req: Request, + followedUser: UserWithRelations, + follower: UserWithRelations, + ) => void; + [HookTypes.OnRegister]: (req: Request, newUser: UserWithRelations) => void; + [HookTypes.OnDeleteAccount]: ( + req: Request, + deletedUser: UserWithRelations, + ) => void; + [HookTypes.OnPostCreate]: ( + req: Request, + newPost: StatusWithRelations, + author: UserWithRelations, + ) => void; + [HookTypes.OnPostDelete]: ( + req: Request, + deletedPost: StatusWithRelations, + deleter: UserWithRelations, + ) => void; + [HookTypes.OnPostUpdate]: ( + req: Request, + updatedPost: StatusWithRelations, + updater: UserWithRelations, + ) => void; } export class Server extends EventEmitter {} diff --git a/prisma.ts b/prisma.ts index 40312282..b2b1782f 100644 --- a/prisma.ts +++ b/prisma.ts @@ -3,7 +3,7 @@ import { config } from "config-manager"; // Proxies all `bunx prisma` commands with an environment variable process.stdout.write( - `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}\n` + `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}\n`, ); // Ends diff --git a/routes.ts b/routes.ts index eae9e028..f2837784 100644 --- a/routes.ts +++ b/routes.ts @@ -5,106 +5,106 @@ import type { APIRouteMeta } from "./types/api"; // This is to allow for compilation of the routes, so that we can minify them and // node_modules in production export const rawRoutes = { - "/api/v1/accounts": "./server/api/api/v1/accounts", - "/api/v1/accounts/familiar_followers": - "+api/v1/accounts/familiar_followers/index", - "/api/v1/accounts/relationships": - "./server/api/api/v1/accounts/relationships/index", - "/api/v1/accounts/search": "./server/api/api/v1/accounts/search/index", - "/api/v1/accounts/update_credentials": - "./server/api/api/v1/accounts/update_credentials/index", - "/api/v1/accounts/verify_credentials": - "./server/api/api/v1/accounts/verify_credentials/index", - "/api/v1/apps": "./server/api/api/v1/apps/index", - "/api/v1/apps/verify_credentials": - "./server/api/api/v1/apps/verify_credentials/index", - "/api/v1/blocks": "./server/api/api/v1/blocks/index", - "/api/v1/custom_emojis": "./server/api/api/v1/custom_emojis/index", - "/api/v1/favourites": "./server/api/api/v1/favourites/index", - "/api/v1/follow_requests": "./server/api/api/v1/follow_requests/index", - "/api/v1/instance": "./server/api/api/v1/instance/index", - "/api/v1/media": "./server/api/api/v1/media/index", - "/api/v1/mutes": "./server/api/api/v1/mutes/index", - "/api/v1/notifications": "./server/api/api/v1/notifications/index", - "/api/v1/profile/avatar": "./server/api/api/v1/profile/avatar", - "/api/v1/profile/header": "./server/api/api/v1/profile/header", - "/api/v1/statuses": "./server/api/api/v1/statuses/index", - "/api/v1/timelines/home": "./server/api/api/v1/timelines/home", - "/api/v1/timelines/public": "./server/api/api/v1/timelines/public", - "/api/v2/media": "./server/api/api/v2/media/index", - "/api/v2/search": "./server/api/api/v2/search/index", - "/auth/login": "./server/api/auth/login/index", - "/auth/redirect": "./server/api/auth/redirect/index", - "/nodeinfo/2.0": "./server/api/nodeinfo/2.0/index", - "/oauth/authorize-external": "./server/api/oauth/authorize-external/index", - "/oauth/providers": "./server/api/oauth/providers/index", - "/oauth/token": "./server/api/oauth/token/index", - "/api/v1/accounts/[id]": "./server/api/api/v1/accounts/[id]/index", - "/api/v1/accounts/[id]/block": "./server/api/api/v1/accounts/[id]/block", - "/api/v1/accounts/[id]/follow": "./server/api/api/v1/accounts/[id]/follow", - "/api/v1/accounts/[id]/followers": - "./server/api/api/v1/accounts/[id]/followers", - "/api/v1/accounts/[id]/following": - "./server/api/api/v1/accounts/[id]/following", - "/api/v1/accounts/[id]/mute": "./server/api/api/v1/accounts/[id]/mute", - "/api/v1/accounts/[id]/note": "./server/api/api/v1/accounts/[id]/note", - "/api/v1/accounts/[id]/pin": "./server/api/api/v1/accounts/[id]/pin", - "/api/v1/accounts/[id]/remove_from_followers": - "./server/api/api/v1/accounts/[id]/remove_from_followers", - "/api/v1/accounts/[id]/statuses": - "./server/api/api/v1/accounts/[id]/statuses", - "/api/v1/accounts/[id]/unblock": - "./server/api/api/v1/accounts/[id]/unblock", - "/api/v1/accounts/[id]/unfollow": - "./server/api/api/v1/accounts/[id]/unfollow", - "/api/v1/accounts/[id]/unmute": "./server/api/api/v1/accounts/[id]/unmute", - "/api/v1/accounts/[id]/unpin": "./server/api/api/v1/accounts/[id]/unpin", - "/api/v1/follow_requests/[account_id]/authorize": - "./server/api/api/v1/follow_requests/[account_id]/authorize", - "/api/v1/follow_requests/[account_id]/reject": - "./server/api/api/v1/follow_requests/[account_id]/reject", - "/api/v1/media/[id]": "./server/api/api/v1/media/[id]/index", - "/api/v1/statuses/[id]": "./server/api/api/v1/statuses/[id]/index", - "/api/v1/statuses/[id]/context": - "./server/api/api/v1/statuses/[id]/context", - "/api/v1/statuses/[id]/favourite": - "./server/api/api/v1/statuses/[id]/favourite", - "/api/v1/statuses/[id]/favourited_by": - "./server/api/api/v1/statuses/[id]/favourited_by", - "/api/v1/statuses/[id]/pin": "./server/api/api/v1/statuses/[id]/pin", - "/api/v1/statuses/[id]/reblog": "./server/api/api/v1/statuses/[id]/reblog", - "/api/v1/statuses/[id]/reblogged_by": - "./server/api/api/v1/statuses/[id]/reblogged_by", - "/api/v1/statuses/[id]/source": "./server/api/api/v1/statuses/[id]/source", - "/api/v1/statuses/[id]/unfavourite": - "./server/api/api/v1/statuses/[id]/unfavourite", - "/api/v1/statuses/[id]/unpin": "./server/api/api/v1/statuses/[id]/unpin", - "/api/v1/statuses/[id]/unreblog": - "./server/api/api/v1/statuses/[id]/unreblog", - "/media/[id]": "./server/api/media/[id]/index", - "/oauth/callback/[issuer]": "./server/api/oauth/callback/[issuer]/index", - "/object/[uuid]": "./server/api/object/[uuid]/index", - "/users/[uuid]": "./server/api/users/[uuid]/index", - "/users/[uuid]/inbox": "./server/api/users/[uuid]/inbox/index", - "/users/[uuid]/outbox": "./server/api/users/[uuid]/outbox/index", - "/[...404]": "./server/api/[...404]", + "/api/v1/accounts": "./server/api/api/v1/accounts", + "/api/v1/accounts/familiar_followers": + "+api/v1/accounts/familiar_followers/index", + "/api/v1/accounts/relationships": + "./server/api/api/v1/accounts/relationships/index", + "/api/v1/accounts/search": "./server/api/api/v1/accounts/search/index", + "/api/v1/accounts/update_credentials": + "./server/api/api/v1/accounts/update_credentials/index", + "/api/v1/accounts/verify_credentials": + "./server/api/api/v1/accounts/verify_credentials/index", + "/api/v1/apps": "./server/api/api/v1/apps/index", + "/api/v1/apps/verify_credentials": + "./server/api/api/v1/apps/verify_credentials/index", + "/api/v1/blocks": "./server/api/api/v1/blocks/index", + "/api/v1/custom_emojis": "./server/api/api/v1/custom_emojis/index", + "/api/v1/favourites": "./server/api/api/v1/favourites/index", + "/api/v1/follow_requests": "./server/api/api/v1/follow_requests/index", + "/api/v1/instance": "./server/api/api/v1/instance/index", + "/api/v1/media": "./server/api/api/v1/media/index", + "/api/v1/mutes": "./server/api/api/v1/mutes/index", + "/api/v1/notifications": "./server/api/api/v1/notifications/index", + "/api/v1/profile/avatar": "./server/api/api/v1/profile/avatar", + "/api/v1/profile/header": "./server/api/api/v1/profile/header", + "/api/v1/statuses": "./server/api/api/v1/statuses/index", + "/api/v1/timelines/home": "./server/api/api/v1/timelines/home", + "/api/v1/timelines/public": "./server/api/api/v1/timelines/public", + "/api/v2/media": "./server/api/api/v2/media/index", + "/api/v2/search": "./server/api/api/v2/search/index", + "/auth/login": "./server/api/auth/login/index", + "/auth/redirect": "./server/api/auth/redirect/index", + "/nodeinfo/2.0": "./server/api/nodeinfo/2.0/index", + "/oauth/authorize-external": "./server/api/oauth/authorize-external/index", + "/oauth/providers": "./server/api/oauth/providers/index", + "/oauth/token": "./server/api/oauth/token/index", + "/api/v1/accounts/[id]": "./server/api/api/v1/accounts/[id]/index", + "/api/v1/accounts/[id]/block": "./server/api/api/v1/accounts/[id]/block", + "/api/v1/accounts/[id]/follow": "./server/api/api/v1/accounts/[id]/follow", + "/api/v1/accounts/[id]/followers": + "./server/api/api/v1/accounts/[id]/followers", + "/api/v1/accounts/[id]/following": + "./server/api/api/v1/accounts/[id]/following", + "/api/v1/accounts/[id]/mute": "./server/api/api/v1/accounts/[id]/mute", + "/api/v1/accounts/[id]/note": "./server/api/api/v1/accounts/[id]/note", + "/api/v1/accounts/[id]/pin": "./server/api/api/v1/accounts/[id]/pin", + "/api/v1/accounts/[id]/remove_from_followers": + "./server/api/api/v1/accounts/[id]/remove_from_followers", + "/api/v1/accounts/[id]/statuses": + "./server/api/api/v1/accounts/[id]/statuses", + "/api/v1/accounts/[id]/unblock": + "./server/api/api/v1/accounts/[id]/unblock", + "/api/v1/accounts/[id]/unfollow": + "./server/api/api/v1/accounts/[id]/unfollow", + "/api/v1/accounts/[id]/unmute": "./server/api/api/v1/accounts/[id]/unmute", + "/api/v1/accounts/[id]/unpin": "./server/api/api/v1/accounts/[id]/unpin", + "/api/v1/follow_requests/[account_id]/authorize": + "./server/api/api/v1/follow_requests/[account_id]/authorize", + "/api/v1/follow_requests/[account_id]/reject": + "./server/api/api/v1/follow_requests/[account_id]/reject", + "/api/v1/media/[id]": "./server/api/api/v1/media/[id]/index", + "/api/v1/statuses/[id]": "./server/api/api/v1/statuses/[id]/index", + "/api/v1/statuses/[id]/context": + "./server/api/api/v1/statuses/[id]/context", + "/api/v1/statuses/[id]/favourite": + "./server/api/api/v1/statuses/[id]/favourite", + "/api/v1/statuses/[id]/favourited_by": + "./server/api/api/v1/statuses/[id]/favourited_by", + "/api/v1/statuses/[id]/pin": "./server/api/api/v1/statuses/[id]/pin", + "/api/v1/statuses/[id]/reblog": "./server/api/api/v1/statuses/[id]/reblog", + "/api/v1/statuses/[id]/reblogged_by": + "./server/api/api/v1/statuses/[id]/reblogged_by", + "/api/v1/statuses/[id]/source": "./server/api/api/v1/statuses/[id]/source", + "/api/v1/statuses/[id]/unfavourite": + "./server/api/api/v1/statuses/[id]/unfavourite", + "/api/v1/statuses/[id]/unpin": "./server/api/api/v1/statuses/[id]/unpin", + "/api/v1/statuses/[id]/unreblog": + "./server/api/api/v1/statuses/[id]/unreblog", + "/media/[id]": "./server/api/media/[id]/index", + "/oauth/callback/[issuer]": "./server/api/oauth/callback/[issuer]/index", + "/object/[uuid]": "./server/api/object/[uuid]/index", + "/users/[uuid]": "./server/api/users/[uuid]/index", + "/users/[uuid]/inbox": "./server/api/users/[uuid]/inbox/index", + "/users/[uuid]/outbox": "./server/api/users/[uuid]/outbox/index", + "/[...404]": "./server/api/[...404]", } as Record; // Returns the route filesystem path when given a URL export const routeMatcher = new Bun.FileSystemRouter({ - style: "nextjs", - dir: process.cwd() + "/server/api", + style: "nextjs", + dir: `${process.cwd()}/server/api`, }); export const matchRoute = async >(url: string) => { - const route = routeMatcher.match(url); - if (!route) return { file: null, matchedRoute: null }; + const route = routeMatcher.match(url); + if (!route) return { file: null, matchedRoute: null }; - return { - file: (await import(rawRoutes[route.name])) as { - default: RouteHandler; - meta: APIRouteMeta; - }, - matchedRoute: route, - }; + return { + file: (await import(rawRoutes[route.name])) as { + default: RouteHandler; + meta: APIRouteMeta; + }, + matchedRoute: route, + }; }; diff --git a/server.ts b/server.ts index 4ad77a76..09e9bfdd 100644 --- a/server.ts +++ b/server.ts @@ -1,251 +1,246 @@ import { errorResponse, jsonResponse } from "@response"; +import type { Config } from "config-manager"; import { matches } from "ip-matching"; -import { getFromRequest } from "~database/entities/User"; -import { type Config } from "config-manager"; import type { LogManager, MultiLogManager } from "log-manager"; import { LogLevel } from "log-manager"; import { RequestParser } from "request-parser"; +import { getFromRequest } from "~database/entities/User"; import { matchRoute } from "~routes"; export const createServer = ( - config: Config, - logger: LogManager | MultiLogManager, - isProd: boolean + config: Config, + logger: LogManager | MultiLogManager, + isProd: boolean, ) => - Bun.serve({ - port: config.http.bind_port, - hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0" - async fetch(req) { - // Check for banned IPs - const request_ip = this.requestIP(req)?.address ?? ""; + Bun.serve({ + port: config.http.bind_port, + hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0" + async fetch(req) { + // Check for banned IPs + const request_ip = this.requestIP(req)?.address ?? ""; - for (const ip of config.http.banned_ips) { - try { - if (matches(ip, request_ip)) { - return new Response(undefined, { - status: 403, - statusText: "Forbidden", - }); - } - } catch (e) { - console.error(`[-] Error while parsing banned IP "${ip}" `); - throw e; - } - } + for (const ip of config.http.banned_ips) { + try { + if (matches(ip, request_ip)) { + return new Response(undefined, { + status: 403, + statusText: "Forbidden", + }); + } + } catch (e) { + console.error(`[-] Error while parsing banned IP "${ip}" `); + throw e; + } + } - // Check for banned user agents (regex) - const ua = req.headers.get("User-Agent") ?? ""; + // Check for banned user agents (regex) + const ua = req.headers.get("User-Agent") ?? ""; - for (const agent of config.http.banned_user_agents) { - if (new RegExp(agent).test(ua)) { - return new Response(undefined, { - status: 403, - statusText: "Forbidden", - }); - } - } + for (const agent of config.http.banned_user_agents) { + if (new RegExp(agent).test(ua)) { + return new Response(undefined, { + status: 403, + statusText: "Forbidden", + }); + } + } - if (config.http.bait.enabled) { - // Check for bait IPs - for (const ip of config.http.bait.bait_ips) { - try { - if (matches(ip, request_ip)) { - const file = Bun.file( - config.http.bait.send_file || - "./pages/beemovie.txt" - ); + if (config.http.bait.enabled) { + // Check for bait IPs + for (const ip of config.http.bait.bait_ips) { + try { + if (matches(ip, request_ip)) { + const file = Bun.file( + config.http.bait.send_file || + "./pages/beemovie.txt", + ); - if (await file.exists()) { - return new Response(file); - } else { - await logger.log( - LogLevel.ERROR, - "Server.Bait", - `Bait file not found: ${config.http.bait.send_file}` - ); - } - } - } catch (e) { - console.error( - `[-] Error while parsing bait IP "${ip}" ` - ); - throw e; - } - } + if (await file.exists()) { + return new Response(file); + } + await logger.log( + LogLevel.ERROR, + "Server.Bait", + `Bait file not found: ${config.http.bait.send_file}`, + ); + } + } catch (e) { + console.error( + `[-] Error while parsing bait IP "${ip}" `, + ); + throw e; + } + } - // Check for bait user agents (regex) - for (const agent of config.http.bait.bait_user_agents) { - console.log(agent); - if (new RegExp(agent).test(ua)) { - const file = Bun.file( - config.http.bait.send_file || "./pages/beemovie.txt" - ); + // Check for bait user agents (regex) + for (const agent of config.http.bait.bait_user_agents) { + console.log(agent); + if (new RegExp(agent).test(ua)) { + const file = Bun.file( + config.http.bait.send_file || + "./pages/beemovie.txt", + ); - if (await file.exists()) { - return new Response(file); - } else { - await logger.log( - LogLevel.ERROR, - "Server.Bait", - `Bait file not found: ${config.http.bait.send_file}` - ); - } - } - } - } + if (await file.exists()) { + return new Response(file); + } + await logger.log( + LogLevel.ERROR, + "Server.Bait", + `Bait file not found: ${config.http.bait.send_file}`, + ); + } + } + } - if (config.logging.log_requests) { - await logger.logRequest( - req, - config.logging.log_ip ? request_ip : undefined, - config.logging.log_requests_verbose - ); - } + if (config.logging.log_requests) { + await logger.logRequest( + req, + config.logging.log_ip ? request_ip : undefined, + config.logging.log_requests_verbose, + ); + } - if (req.method === "OPTIONS") { - return jsonResponse({}); - } + if (req.method === "OPTIONS") { + return jsonResponse({}); + } - const { file: filePromise, matchedRoute } = await matchRoute( - req.url - ); + const { file: filePromise, matchedRoute } = await matchRoute( + req.url, + ); - const file = filePromise; + const file = filePromise; - if (matchedRoute && file == undefined) { - await logger.log( - LogLevel.ERROR, - "Server", - `Route file ${matchedRoute.filePath} not found or not registered in the routes file` - ); + if (matchedRoute && file === undefined) { + await logger.log( + LogLevel.ERROR, + "Server", + `Route file ${matchedRoute.filePath} not found or not registered in the routes file`, + ); - return errorResponse("Route not found", 500); - } + return errorResponse("Route not found", 500); + } - if ( - matchedRoute && - matchedRoute.name !== "/[...404]" && - file != undefined - ) { - const meta = file.meta; + if (matchedRoute && matchedRoute.name !== "/[...404]" && file) { + const meta = file.meta; - // Check for allowed requests - if (!meta.allowedMethods.includes(req.method as any)) { - return new Response(undefined, { - status: 405, - statusText: `Method not allowed: allowed methods are: ${meta.allowedMethods.join( - ", " - )}`, - }); - } + // Check for allowed requests + // @ts-expect-error Stupid error + if (!meta.allowedMethods.includes(req.method as string)) { + return new Response(undefined, { + status: 405, + statusText: `Method not allowed: allowed methods are: ${meta.allowedMethods.join( + ", ", + )}`, + }); + } - // TODO: Check for ratelimits - const auth = await getFromRequest(req); + // TODO: Check for ratelimits + const auth = await getFromRequest(req); - // Check for authentication if required - if (meta.auth.required) { - if (!auth.user) { - return new Response(undefined, { - status: 401, - statusText: "Unauthorized", - }); - } - } else if ( - (meta.auth.requiredOnMethods ?? []).includes( - req.method as any - ) - ) { - if (!auth.user) { - return new Response(undefined, { - status: 401, - statusText: "Unauthorized", - }); - } - } + // Check for authentication if required + if (meta.auth.required) { + if (!auth.user) { + return new Response(undefined, { + status: 401, + statusText: "Unauthorized", + }); + } + } else if ( + // @ts-expect-error Stupid error + (meta.auth.requiredOnMethods ?? []).includes(req.method) + ) { + if (!auth.user) { + return new Response(undefined, { + status: 401, + statusText: "Unauthorized", + }); + } + } - let parsedRequest = {}; + let parsedRequest = {}; - try { - parsedRequest = await new RequestParser(req).toObject(); - } catch (e) { - await logger.logError( - LogLevel.ERROR, - "Server.RouteRequestParser", - e as Error - ); - return new Response(undefined, { - status: 400, - statusText: "Bad request", - }); - } + try { + parsedRequest = await new RequestParser(req).toObject(); + } catch (e) { + await logger.logError( + LogLevel.ERROR, + "Server.RouteRequestParser", + e as Error, + ); + return new Response(undefined, { + status: 400, + statusText: "Bad request", + }); + } - return await file.default(req.clone(), matchedRoute, { - auth, - parsedRequest, - // To avoid having to rewrite each route - configManager: { - getConfig: () => Promise.resolve(config), - }, - }); - } else if (matchedRoute?.name === "/[...404]" || !matchedRoute) { - if (new URL(req.url).pathname.startsWith("/api")) { - return errorResponse("Route not found", 404); - } + return await file.default(req.clone(), matchedRoute, { + auth, + parsedRequest, + // To avoid having to rewrite each route + configManager: { + getConfig: () => Promise.resolve(config), + }, + }); + } + if (matchedRoute?.name === "/[...404]" || !matchedRoute) { + if (new URL(req.url).pathname.startsWith("/api")) { + return errorResponse("Route not found", 404); + } - // Proxy response from Vite at localhost:5173 if in development mode - if (isProd) { - if (new URL(req.url).pathname.startsWith("/assets")) { - const file = Bun.file( - `./pages/dist${new URL(req.url).pathname}` - ); + // Proxy response from Vite at localhost:5173 if in development mode + if (isProd) { + if (new URL(req.url).pathname.startsWith("/assets")) { + const file = Bun.file( + `./pages/dist${new URL(req.url).pathname}`, + ); - // Serve from pages/dist/assets - if (await file.exists()) { - return new Response(file); - } else return errorResponse("Asset not found", 404); - } - if (new URL(req.url).pathname.startsWith("/api")) { - return errorResponse("Route not found", 404); - } + // Serve from pages/dist/assets + if (await file.exists()) { + return new Response(file); + } + return errorResponse("Asset not found", 404); + } + if (new URL(req.url).pathname.startsWith("/api")) { + return errorResponse("Route not found", 404); + } - const file = Bun.file(`./pages/dist/index.html`); + const file = Bun.file("./pages/dist/index.html"); - // Serve from pages/dist - return new Response(file); - } else { - const proxy = await fetch( - req.url.replace( - config.http.base_url, - "http://localhost:5173" - ) - ).catch(async e => { - await logger.logError( - LogLevel.ERROR, - "Server.Proxy", - e as Error - ); - await logger.log( - LogLevel.ERROR, - "Server.Proxy", - `The development Vite server is not running or the route is not found: ${req.url.replace( - config.http.base_url, - "http://localhost:5173" - )}` - ); - return errorResponse("Route not found", 404); - }); + // Serve from pages/dist + return new Response(file); + } + const proxy = await fetch( + req.url.replace( + config.http.base_url, + "http://localhost:5173", + ), + ).catch(async (e) => { + await logger.logError( + LogLevel.ERROR, + "Server.Proxy", + e as Error, + ); + await logger.log( + LogLevel.ERROR, + "Server.Proxy", + `The development Vite server is not running or the route is not found: ${req.url.replace( + config.http.base_url, + "http://localhost:5173", + )}`, + ); + return errorResponse("Route not found", 404); + }); - if ( - proxy.status !== 404 && - !(await proxy.clone().text()).includes("404 Not Found") - ) { - return proxy; - } + if ( + proxy.status !== 404 && + !(await proxy.clone().text()).includes("404 Not Found") + ) { + return proxy; + } - return errorResponse("Route not found", 404); - } - } else { - return errorResponse("Route not found", 404); - } - }, - }); + return errorResponse("Route not found", 404); + } + return errorResponse("Route not found", 404); + }, + }); diff --git a/server/api/.well-known/host-meta/index.ts b/server/api/.well-known/host-meta/index.ts index c473fe6d..ecabdfe1 100644 --- a/server/api/.well-known/host-meta/index.ts +++ b/server/api/.well-known/host-meta/index.ts @@ -1,23 +1,22 @@ -import { xmlResponse } from "@response"; import { apiRoute, applyConfig } from "@api"; +import { xmlResponse } from "@response"; export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 60, - }, - route: "/.well-known/host-meta", + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 60, + }, + route: "/.well-known/host-meta", }); - export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); - - return xmlResponse(` + const config = await extraData.configManager.getConfig(); + + return xmlResponse(` diff --git a/server/api/.well-known/lysand.ts b/server/api/.well-known/lysand.ts index 5f7de0b6..5c2f8a56 100644 --- a/server/api/.well-known/lysand.ts +++ b/server/api/.well-known/lysand.ts @@ -1,43 +1,49 @@ -import { jsonResponse } from "@response"; import { apiRoute, applyConfig } from "@api"; +import { jsonResponse } from "@response"; export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 60, - }, - route: "/.well-known/lysand", + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 60, + }, + route: "/.well-known/lysand", }); export default apiRoute(async (req, matchedRoute, extraData) => { const config = await extraData.configManager.getConfig(); - // In the format acct:name@example.com + // In the format acct:name@example.com return jsonResponse({ type: "ServerMetadata", name: config.instance.name, version: "0.0.1", description: config.instance.description, - logo: config.instance.logo ? [ - { - content: config.instance.logo, - content_type: `image/${config.instance.logo.split(".")[1]}`, - } - ] : undefined, - banner: config.instance.banner ? [ - { - content: config.instance.banner, - content_type: `image/${config.instance.banner.split(".")[1]}`, - } - ] : undefined, - supported_extensions: [ - "org.lysand:custom_emojis" - ], + logo: config.instance.logo + ? [ + { + content: config.instance.logo, + content_type: `image/${ + config.instance.logo.split(".")[1] + }`, + }, + ] + : undefined, + banner: config.instance.banner + ? [ + { + content: config.instance.banner, + content_type: `image/${ + config.instance.banner.split(".")[1] + }`, + }, + ] + : undefined, + supported_extensions: ["org.lysand:custom_emojis"], website: "https://lysand.org", // TODO: Add admins, moderators field - }) -}) + }); +}); diff --git a/server/api/.well-known/nodeinfo/index.ts b/server/api/.well-known/nodeinfo/index.ts index a348c4da..68b910f8 100644 --- a/server/api/.well-known/nodeinfo/index.ts +++ b/server/api/.well-known/nodeinfo/index.ts @@ -1,25 +1,24 @@ import { apiRoute, applyConfig } from "@api"; export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 60, - }, - route: "/.well-known/nodeinfo", + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 60, + }, + route: "/.well-known/nodeinfo", }); - export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - return new Response("", { - status: 301, - headers: { - Location: `${config.http.base_url}/.well-known/nodeinfo/2.0`, - }, - }); + return new Response("", { + status: 301, + headers: { + Location: `${config.http.base_url}/.well-known/nodeinfo/2.0`, + }, + }); }); diff --git a/server/api/.well-known/webfinger/index.ts b/server/api/.well-known/webfinger/index.ts index f9b35a84..558fd415 100644 --- a/server/api/.well-known/webfinger/index.ts +++ b/server/api/.well-known/webfinger/index.ts @@ -1,59 +1,59 @@ -import { errorResponse, jsonResponse } from "@response"; import { apiRoute, applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 60, - }, - route: "/.well-known/webfinger", + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 60, + }, + route: "/.well-known/webfinger", }); export default apiRoute(async (req, matchedRoute, extraData) => { - // In the format acct:name@example.com - const resource = matchedRoute.query.resource; - const requestedUser = resource.split("acct:")[1]; - - const config = await extraData.configManager.getConfig(); - const host = new URL(config.http.base_url).hostname; - - // Check if user is a local user - if (requestedUser.split("@")[1] !== host) { - return errorResponse("User is a remote user", 404); - } - - const user = await client.user.findUnique({ - where: { username: requestedUser.split("@")[0] }, - }); - - if (!user) { - return errorResponse("User not found", 404); - } - - return jsonResponse({ - subject: `acct:${user.username}@${host}`, - - links: [ - { - rel: "self", - type: "application/activity+json", - href: `${config.http.base_url}/users/${user.username}/actor` - }, - { - rel: "https://webfinger.net/rel/profile-page", - type: "text/html", - href: `${config.http.base_url}/users/${user.username}` - }, - { - rel: "self", - type: "application/activity+json; profile=\"https://www.w3.org/ns/activitystreams\"", - href: `${config.http.base_url}/users/${user.username}/actor` - } - ] - }) -}); \ No newline at end of file + // In the format acct:name@example.com + const resource = matchedRoute.query.resource; + const requestedUser = resource.split("acct:")[1]; + + const config = await extraData.configManager.getConfig(); + const host = new URL(config.http.base_url).hostname; + + // Check if user is a local user + if (requestedUser.split("@")[1] !== host) { + return errorResponse("User is a remote user", 404); + } + + const user = await client.user.findUnique({ + where: { username: requestedUser.split("@")[0] }, + }); + + if (!user) { + return errorResponse("User not found", 404); + } + + return jsonResponse({ + subject: `acct:${user.username}@${host}`, + + links: [ + { + rel: "self", + type: "application/activity+json", + href: `${config.http.base_url}/users/${user.username}/actor`, + }, + { + rel: "https://webfinger.net/rel/profile-page", + type: "text/html", + href: `${config.http.base_url}/users/${user.username}`, + }, + { + rel: "self", + type: 'application/activity+json; profile="https://www.w3.org/ns/activitystreams"', + href: `${config.http.base_url}/users/${user.username}/actor`, + }, + ], + }); +}); diff --git a/server/api/[...404].ts b/server/api/[...404].ts index 871e3a3d..6036c6b9 100644 --- a/server/api/[...404].ts +++ b/server/api/[...404].ts @@ -2,20 +2,20 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse } from "@response"; export const meta = applyConfig({ - allowedMethods: ["POST", "GET", "PUT", "PATCH", "DELETE"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 100, - }, - route: "/[...404]", + allowedMethods: ["POST", "GET", "PUT", "PATCH", "DELETE"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 100, + }, + route: "/[...404]", }); /** * Default catch-all route, returns a 404 error. */ export default apiRoute(() => { - return errorResponse("This API route does not exist", 404); + return errorResponse("This API route does not exist", 404); }); diff --git a/server/api/api/v1/accounts/[id]/block.ts b/server/api/api/v1/accounts/[id]/block.ts index 5a3cbda9..4801a2d5 100644 --- a/server/api/api/v1/accounts/[id]/block.ts +++ b/server/api/api/v1/accounts/[id]/block.ts @@ -1,81 +1,81 @@ +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; import { - createNewRelationship, - relationshipToAPI, + createNewRelationship, + relationshipToAPI, } from "~database/entities/Relationship"; import { getRelationshipToOtherUser } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; -import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/accounts/:id/block", - auth: { - required: true, - oauthPermissions: ["write:blocks"], - }, + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/block", + auth: { + required: true, + oauthPermissions: ["write:blocks"], + }, }); /** * Blocks a user */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user: self } = extraData.auth; + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const user = await client.user.findUnique({ - where: { id }, - include: { - relationships: { - include: { - owner: true, - subject: true, - }, - }, - }, - }); + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, + }, + }); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - // Check if already following - let relationship = await getRelationshipToOtherUser(self, user); + // Check if already following + let relationship = await getRelationshipToOtherUser(self, user); - if (!relationship) { - // Create new relationship + if (!relationship) { + // Create new relationship - const newRelationship = await createNewRelationship(self, user); + const newRelationship = await createNewRelationship(self, user); - await client.user.update({ - where: { id: self.id }, - data: { - relationships: { - connect: { - id: newRelationship.id, - }, - }, - }, - }); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); - relationship = newRelationship; - } + relationship = newRelationship; + } - if (!relationship.blocking) { - relationship.blocking = true; - } + if (!relationship.blocking) { + relationship.blocking = true; + } - await client.relationship.update({ - where: { id: relationship.id }, - data: { - blocking: true, - }, - }); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + blocking: true, + }, + }); - return jsonResponse(relationshipToAPI(relationship)); + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/accounts/[id]/follow.ts b/server/api/api/v1/accounts/[id]/follow.ts index 21a0885d..0e6d52d9 100644 --- a/server/api/api/v1/accounts/[id]/follow.ts +++ b/server/api/api/v1/accounts/[id]/follow.ts @@ -1,99 +1,99 @@ +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; import { - createNewRelationship, - relationshipToAPI, + createNewRelationship, + relationshipToAPI, } from "~database/entities/Relationship"; import { getRelationshipToOtherUser } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; -import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/accounts/:id/follow", - auth: { - required: true, - oauthPermissions: ["write:follows"], - }, + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/follow", + auth: { + required: true, + oauthPermissions: ["write:follows"], + }, }); /** * Follow a user */ export default apiRoute<{ - reblogs?: boolean; - notify?: boolean; - languages?: string[]; + reblogs?: boolean; + notify?: boolean; + languages?: string[]; }>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user: self } = extraData.auth; + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const { languages, notify, reblogs } = extraData.parsedRequest; + const { languages, notify, reblogs } = extraData.parsedRequest; - const user = await client.user.findUnique({ - where: { id }, - include: { - relationships: { - include: { - owner: true, - subject: true, - }, - }, - }, - }); + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, + }, + }); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - // Check if already following - let relationship = await getRelationshipToOtherUser(self, user); + // Check if already following + let relationship = await getRelationshipToOtherUser(self, user); - if (!relationship) { - // Create new relationship + if (!relationship) { + // Create new relationship - const newRelationship = await createNewRelationship(self, user); + const newRelationship = await createNewRelationship(self, user); - await client.user.update({ - where: { id: self.id }, - data: { - relationships: { - connect: { - id: newRelationship.id, - }, - }, - }, - }); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); - relationship = newRelationship; - } + relationship = newRelationship; + } - if (!relationship.following) { - relationship.following = true; - } - if (reblogs) { - relationship.showingReblogs = true; - } - if (notify) { - relationship.notifying = true; - } - if (languages) { - relationship.languages = languages; - } + if (!relationship.following) { + relationship.following = true; + } + if (reblogs) { + relationship.showingReblogs = true; + } + if (notify) { + relationship.notifying = true; + } + if (languages) { + relationship.languages = languages; + } - await client.relationship.update({ - where: { id: relationship.id }, - data: { - following: true, - showingReblogs: reblogs ?? false, - notifying: notify ?? false, - languages: languages ?? [], - }, - }); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + following: true, + showingReblogs: reblogs ?? false, + notifying: notify ?? false, + languages: languages ?? [], + }, + }); - return jsonResponse(relationshipToAPI(relationship)); + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/accounts/[id]/followers.ts b/server/api/api/v1/accounts/[id]/followers.ts index b6dccb9b..0a10db50 100644 --- a/server/api/api/v1/accounts/[id]/followers.ts +++ b/server/api/api/v1/accounts/[id]/followers.ts @@ -1,82 +1,82 @@ +import { apiRoute, applyConfig } from "@api"; /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { errorResponse, jsonResponse } from "@response"; -import { userToAPI } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; +import { userToAPI } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 60, - duration: 60, - }, - route: "/accounts/:id/followers", - auth: { - required: false, - oauthPermissions: [], - }, + allowedMethods: ["GET"], + ratelimits: { + max: 60, + duration: 60, + }, + route: "/accounts/:id/followers", + auth: { + required: false, + oauthPermissions: [], + }, }); /** * Fetch all statuses for a user */ export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; }>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - // TODO: Add pinned - const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest; + // TODO: Add pinned + const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest; - const user = await client.user.findUnique({ - where: { id }, - include: userRelations, - }); + const user = await client.user.findUnique({ + where: { id }, + include: userRelations, + }); - if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400); + if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - const objects = await client.user.findMany({ - where: { - relationships: { - some: { - subjectId: user.id, - following: true, - }, - }, - id: { - lt: max_id, - gt: min_id, - gte: since_id, - }, - }, - include: userRelations, - take: Number(limit), - orderBy: { - id: "desc", - }, - }); + const objects = await client.user.findMany({ + where: { + relationships: { + some: { + subjectId: user.id, + following: true, + }, + }, + id: { + lt: max_id, + gt: min_id, + gte: since_id, + }, + }, + include: userRelations, + take: Number(limit), + orderBy: { + id: "desc", + }, + }); - // Constuct HTTP Link header (next and prev) - const linkHeader = []; - if (objects.length > 0) { - const urlWithoutQuery = req.url.split("?")[0]; - linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, - `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` - ); - } + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, + `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`, + ); + } - return jsonResponse( - await Promise.all(objects.map(object => userToAPI(object))), - 200, - { - Link: linkHeader.join(", "), - } - ); + return jsonResponse( + await Promise.all(objects.map((object) => userToAPI(object))), + 200, + { + Link: linkHeader.join(", "), + }, + ); }); diff --git a/server/api/api/v1/accounts/[id]/following.ts b/server/api/api/v1/accounts/[id]/following.ts index 48b4aa99..ab380b35 100644 --- a/server/api/api/v1/accounts/[id]/following.ts +++ b/server/api/api/v1/accounts/[id]/following.ts @@ -1,82 +1,82 @@ +import { apiRoute, applyConfig } from "@api"; /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { errorResponse, jsonResponse } from "@response"; -import { userToAPI } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; +import { userToAPI } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 60, - duration: 60, - }, - route: "/accounts/:id/following", - auth: { - required: false, - oauthPermissions: [], - }, + allowedMethods: ["GET"], + ratelimits: { + max: 60, + duration: 60, + }, + route: "/accounts/:id/following", + auth: { + required: false, + oauthPermissions: [], + }, }); /** * Fetch all statuses for a user */ export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; }>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - // TODO: Add pinned - const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest; + // TODO: Add pinned + const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest; - const user = await client.user.findUnique({ - where: { id }, - include: userRelations, - }); + const user = await client.user.findUnique({ + where: { id }, + include: userRelations, + }); - if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400); + if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - const objects = await client.user.findMany({ - where: { - relationshipSubjects: { - some: { - ownerId: user.id, - following: true, - }, - }, - id: { - lt: max_id, - gt: min_id, - gte: since_id, - }, - }, - include: userRelations, - take: Number(limit), - orderBy: { - id: "desc", - }, - }); + const objects = await client.user.findMany({ + where: { + relationshipSubjects: { + some: { + ownerId: user.id, + following: true, + }, + }, + id: { + lt: max_id, + gt: min_id, + gte: since_id, + }, + }, + include: userRelations, + take: Number(limit), + orderBy: { + id: "desc", + }, + }); - // Constuct HTTP Link header (next and prev) - const linkHeader = []; - if (objects.length > 0) { - const urlWithoutQuery = req.url.split("?")[0]; - linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, - `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` - ); - } + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, + `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`, + ); + } - return jsonResponse( - await Promise.all(objects.map(object => userToAPI(object))), - 200, - { - Link: linkHeader.join(", "), - } - ); + return jsonResponse( + await Promise.all(objects.map((object) => userToAPI(object))), + 200, + { + Link: linkHeader.join(", "), + }, + ); }); diff --git a/server/api/api/v1/accounts/[id]/index.ts b/server/api/api/v1/accounts/[id]/index.ts index 5fa17b51..48018421 100644 --- a/server/api/api/v1/accounts/[id]/index.ts +++ b/server/api/api/v1/accounts/[id]/index.ts @@ -1,46 +1,46 @@ +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; import type { UserWithRelations } from "~database/entities/User"; import { userToAPI } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; -import { client } from "~database/datasource"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/accounts/:id", - auth: { - required: true, - oauthPermissions: [], - }, + allowedMethods: ["GET"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id", + auth: { + required: true, + oauthPermissions: [], + }, }); /** * Fetch a user */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - // Check if ID is valid UUID - if (!id.match(/^[0-9a-fA-F]{24}$/)) { - return errorResponse("Invalid ID", 404); - } + const id = matchedRoute.params.id; + // Check if ID is valid UUID + if (!id.match(/^[0-9a-fA-F]{24}$/)) { + return errorResponse("Invalid ID", 404); + } - const { user } = extraData.auth; + const { user } = extraData.auth; - let foundUser: UserWithRelations | null; - try { - foundUser = await client.user.findUnique({ - where: { id }, - include: userRelations, - }); - } catch (e) { - return errorResponse("Invalid ID", 404); - } + let foundUser: UserWithRelations | null; + try { + foundUser = await client.user.findUnique({ + where: { id }, + include: userRelations, + }); + } catch (e) { + return errorResponse("Invalid ID", 404); + } - if (!foundUser) return errorResponse("User not found", 404); + if (!foundUser) return errorResponse("User not found", 404); - return jsonResponse(userToAPI(foundUser, user?.id === foundUser.id)); + return jsonResponse(userToAPI(foundUser, user?.id === foundUser.id)); }); diff --git a/server/api/api/v1/accounts/[id]/mute.ts b/server/api/api/v1/accounts/[id]/mute.ts index 19c75de0..dedf5973 100644 --- a/server/api/api/v1/accounts/[id]/mute.ts +++ b/server/api/api/v1/accounts/[id]/mute.ts @@ -1,93 +1,93 @@ +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; import { - createNewRelationship, - relationshipToAPI, + createNewRelationship, + relationshipToAPI, } from "~database/entities/Relationship"; import { getRelationshipToOtherUser } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; -import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/accounts/:id/mute", - auth: { - required: true, - oauthPermissions: ["write:mutes"], - }, + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/mute", + auth: { + required: true, + oauthPermissions: ["write:mutes"], + }, }); /** * Mute a user */ export default apiRoute<{ - notifications: boolean; - duration: number; + notifications: boolean; + duration: number; }>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user: self } = extraData.auth; + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { notifications, duration } = extraData.parsedRequest; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { notifications, duration } = extraData.parsedRequest; - const user = await client.user.findUnique({ - where: { id }, - include: { - relationships: { - include: { - owner: true, - subject: true, - }, - }, - }, - }); + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, + }, + }); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - // Check if already following - let relationship = await getRelationshipToOtherUser(self, user); + // Check if already following + let relationship = await getRelationshipToOtherUser(self, user); - if (!relationship) { - // Create new relationship + if (!relationship) { + // Create new relationship - const newRelationship = await createNewRelationship(self, user); + const newRelationship = await createNewRelationship(self, user); - await client.user.update({ - where: { id: self.id }, - data: { - relationships: { - connect: { - id: newRelationship.id, - }, - }, - }, - }); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); - relationship = newRelationship; - } + relationship = newRelationship; + } - if (!relationship.muting) { - relationship.muting = true; - } - if (notifications ?? true) { - relationship.mutingNotifications = true; - } + if (!relationship.muting) { + relationship.muting = true; + } + if (notifications ?? true) { + relationship.mutingNotifications = true; + } - await client.relationship.update({ - where: { id: relationship.id }, - data: { - muting: true, - mutingNotifications: notifications ?? true, - }, - }); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + muting: true, + mutingNotifications: notifications ?? true, + }, + }); - // TODO: Implement duration + // TODO: Implement duration - return jsonResponse(relationshipToAPI(relationship)); + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/accounts/[id]/note.ts b/server/api/api/v1/accounts/[id]/note.ts index de3e3812..4a16aa10 100644 --- a/server/api/api/v1/accounts/[id]/note.ts +++ b/server/api/api/v1/accounts/[id]/note.ts @@ -1,83 +1,83 @@ +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; import { - createNewRelationship, - relationshipToAPI, + createNewRelationship, + relationshipToAPI, } from "~database/entities/Relationship"; import { getRelationshipToOtherUser } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; -import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/accounts/:id/note", - auth: { - required: true, - oauthPermissions: ["write:accounts"], - }, + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/note", + auth: { + required: true, + oauthPermissions: ["write:accounts"], + }, }); /** * Sets a user note */ export default apiRoute<{ - comment: string; + comment: string; }>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user: self } = extraData.auth; + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const { comment } = extraData.parsedRequest; + const { comment } = extraData.parsedRequest; - const user = await client.user.findUnique({ - where: { id }, - include: { - relationships: { - include: { - owner: true, - subject: true, - }, - }, - }, - }); + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, + }, + }); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - // Check if already following - let relationship = await getRelationshipToOtherUser(self, user); + // Check if already following + let relationship = await getRelationshipToOtherUser(self, user); - if (!relationship) { - // Create new relationship + if (!relationship) { + // Create new relationship - const newRelationship = await createNewRelationship(self, user); + const newRelationship = await createNewRelationship(self, user); - await client.user.update({ - where: { id: self.id }, - data: { - relationships: { - connect: { - id: newRelationship.id, - }, - }, - }, - }); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); - relationship = newRelationship; - } + relationship = newRelationship; + } - relationship.note = comment ?? ""; + relationship.note = comment ?? ""; - await client.relationship.update({ - where: { id: relationship.id }, - data: { - note: relationship.note, - }, - }); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + note: relationship.note, + }, + }); - return jsonResponse(relationshipToAPI(relationship)); + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/accounts/[id]/pin.ts b/server/api/api/v1/accounts/[id]/pin.ts index c426d23c..11e06a9a 100644 --- a/server/api/api/v1/accounts/[id]/pin.ts +++ b/server/api/api/v1/accounts/[id]/pin.ts @@ -1,81 +1,81 @@ +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; import { - createNewRelationship, - relationshipToAPI, + createNewRelationship, + relationshipToAPI, } from "~database/entities/Relationship"; import { getRelationshipToOtherUser } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; -import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/accounts/:id/pin", - auth: { - required: true, - oauthPermissions: ["write:accounts"], - }, + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/pin", + auth: { + required: true, + oauthPermissions: ["write:accounts"], + }, }); /** * Pin a user */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user: self } = extraData.auth; + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const user = await client.user.findUnique({ - where: { id }, - include: { - relationships: { - include: { - owner: true, - subject: true, - }, - }, - }, - }); + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, + }, + }); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - // Check if already following - let relationship = await getRelationshipToOtherUser(self, user); + // Check if already following + let relationship = await getRelationshipToOtherUser(self, user); - if (!relationship) { - // Create new relationship + if (!relationship) { + // Create new relationship - const newRelationship = await createNewRelationship(self, user); + const newRelationship = await createNewRelationship(self, user); - await client.user.update({ - where: { id: self.id }, - data: { - relationships: { - connect: { - id: newRelationship.id, - }, - }, - }, - }); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); - relationship = newRelationship; - } + relationship = newRelationship; + } - if (!relationship.endorsed) { - relationship.endorsed = true; - } + if (!relationship.endorsed) { + relationship.endorsed = true; + } - await client.relationship.update({ - where: { id: relationship.id }, - data: { - endorsed: true, - }, - }); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + endorsed: true, + }, + }); - return jsonResponse(relationshipToAPI(relationship)); + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/accounts/[id]/remove_from_followers.ts b/server/api/api/v1/accounts/[id]/remove_from_followers.ts index 24673b11..fbdec146 100644 --- a/server/api/api/v1/accounts/[id]/remove_from_followers.ts +++ b/server/api/api/v1/accounts/[id]/remove_from_followers.ts @@ -1,95 +1,95 @@ +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; import { - createNewRelationship, - relationshipToAPI, + createNewRelationship, + relationshipToAPI, } from "~database/entities/Relationship"; import { getRelationshipToOtherUser } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; -import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/accounts/:id/remove_from_followers", - auth: { - required: true, - oauthPermissions: ["write:follows"], - }, + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/remove_from_followers", + auth: { + required: true, + oauthPermissions: ["write:follows"], + }, }); /** * Removes an account from your followers list */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user: self } = extraData.auth; + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const user = await client.user.findUnique({ - where: { id }, - include: { - relationships: { - include: { - owner: true, - subject: true, - }, - }, - }, - }); + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, + }, + }); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - // Check if already following - let relationship = await getRelationshipToOtherUser(self, user); + // Check if already following + let relationship = await getRelationshipToOtherUser(self, user); - if (!relationship) { - // Create new relationship + if (!relationship) { + // Create new relationship - const newRelationship = await createNewRelationship(self, user); + const newRelationship = await createNewRelationship(self, user); - await client.user.update({ - where: { id: self.id }, - data: { - relationships: { - connect: { - id: newRelationship.id, - }, - }, - }, - }); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); - relationship = newRelationship; - } + relationship = newRelationship; + } - if (relationship.followedBy) { - relationship.followedBy = false; - } + if (relationship.followedBy) { + relationship.followedBy = false; + } - await client.relationship.update({ - where: { id: relationship.id }, - data: { - followedBy: false, - }, - }); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + followedBy: false, + }, + }); - if (user.instanceId === null) { - // Also remove from followers list - await client.relationship.updateMany({ - where: { - ownerId: user.id, - subjectId: self.id, - following: true, - }, - data: { - following: false, - }, - }); - } + if (user.instanceId === null) { + // Also remove from followers list + await client.relationship.updateMany({ + where: { + ownerId: user.id, + subjectId: self.id, + following: true, + }, + data: { + following: false, + }, + }); + } - return jsonResponse(relationshipToAPI(relationship)); + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts index afd4c35c..ec33d38a 100644 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ b/server/api/api/v1/accounts/[id]/statuses.ts @@ -1,134 +1,136 @@ +import { apiRoute, applyConfig } from "@api"; /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { errorResponse, jsonResponse } from "@response"; -import { statusToAPI } from "~database/entities/Status"; -import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; +import { statusToAPI } from "~database/entities/Status"; import { - userRelations, - statusAndUserRelations, + statusAndUserRelations, + userRelations, } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/accounts/:id/statuses", - auth: { - required: false, - oauthPermissions: ["read:statuses"], - }, + allowedMethods: ["GET"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/statuses", + auth: { + required: false, + oauthPermissions: ["read:statuses"], + }, }); /** * Fetch all statuses for a user */ export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: string; - only_media?: boolean; - exclude_replies?: boolean; - exclude_reblogs?: boolean; - // TODO: Add with_muted - pinned?: boolean; - tagged?: string; + max_id?: string; + since_id?: string; + min_id?: string; + limit?: string; + only_media?: boolean; + exclude_replies?: boolean; + exclude_reblogs?: boolean; + // TODO: Add with_muted + pinned?: boolean; + tagged?: string; }>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - // TODO: Add pinned - const { - max_id, - min_id, - since_id, - limit = "20", - exclude_reblogs, - pinned, - } = extraData.parsedRequest; + // TODO: Add pinned + const { + max_id, + min_id, + since_id, + limit = "20", + exclude_reblogs, + pinned, + } = extraData.parsedRequest; - const user = await client.user.findUnique({ - where: { id }, - include: userRelations, - }); + const user = await client.user.findUnique({ + where: { id }, + include: userRelations, + }); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - if (pinned) { - const objects = await client.status.findMany({ - where: { - authorId: id, - isReblog: false, - pinnedBy: { - some: { - id: user.id, - }, - }, - id: { - lt: max_id, - gt: min_id, - gte: since_id, - }, - }, - include: statusAndUserRelations, - take: Number(limit), - orderBy: { - id: "desc", - }, - }); + if (pinned) { + const objects = await client.status.findMany({ + where: { + authorId: id, + isReblog: false, + pinnedBy: { + some: { + id: user.id, + }, + }, + id: { + lt: max_id, + gt: min_id, + gte: since_id, + }, + }, + include: statusAndUserRelations, + take: Number(limit), + orderBy: { + id: "desc", + }, + }); - // Constuct HTTP Link header (next and prev) - const linkHeader = []; - if (objects.length > 0) { - const urlWithoutQuery = req.url.split("?")[0]; - linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, - `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` - ); - } + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, + `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`, + ); + } - return jsonResponse( - await Promise.all(objects.map(status => statusToAPI(status, user))), - 200, - { - Link: linkHeader.join(", "), - } - ); - } + return jsonResponse( + await Promise.all( + objects.map((status) => statusToAPI(status, user)), + ), + 200, + { + Link: linkHeader.join(", "), + }, + ); + } - const objects = await client.status.findMany({ - where: { - authorId: id, - isReblog: exclude_reblogs ? true : undefined, - id: { - lt: max_id, - gt: min_id, - gte: since_id, - }, - }, - include: statusAndUserRelations, - take: Number(limit), - orderBy: { - id: "desc", - }, - }); + const objects = await client.status.findMany({ + where: { + authorId: id, + isReblog: exclude_reblogs ? true : undefined, + id: { + lt: max_id, + gt: min_id, + gte: since_id, + }, + }, + include: statusAndUserRelations, + take: Number(limit), + orderBy: { + id: "desc", + }, + }); - // Constuct HTTP Link header (next and prev) - const linkHeader = []; - if (objects.length > 0) { - const urlWithoutQuery = req.url.split("?")[0]; - linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, - `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` - ); - } + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, + `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`, + ); + } - return jsonResponse( - await Promise.all(objects.map(status => statusToAPI(status, user))), - 200, - { - Link: linkHeader.join(", "), - } - ); + return jsonResponse( + await Promise.all(objects.map((status) => statusToAPI(status, user))), + 200, + { + Link: linkHeader.join(", "), + }, + ); }); diff --git a/server/api/api/v1/accounts/[id]/unblock.ts b/server/api/api/v1/accounts/[id]/unblock.ts index 09701d70..eabbacf1 100644 --- a/server/api/api/v1/accounts/[id]/unblock.ts +++ b/server/api/api/v1/accounts/[id]/unblock.ts @@ -1,81 +1,81 @@ +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; import { - createNewRelationship, - relationshipToAPI, + createNewRelationship, + relationshipToAPI, } from "~database/entities/Relationship"; import { getRelationshipToOtherUser } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; -import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/accounts/:id/unblock", - auth: { - required: true, - oauthPermissions: ["write:blocks"], - }, + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/unblock", + auth: { + required: true, + oauthPermissions: ["write:blocks"], + }, }); /** * Blocks a user */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user: self } = extraData.auth; + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const user = await client.user.findUnique({ - where: { id }, - include: { - relationships: { - include: { - owner: true, - subject: true, - }, - }, - }, - }); + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, + }, + }); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - // Check if already following - let relationship = await getRelationshipToOtherUser(self, user); + // Check if already following + let relationship = await getRelationshipToOtherUser(self, user); - if (!relationship) { - // Create new relationship + if (!relationship) { + // Create new relationship - const newRelationship = await createNewRelationship(self, user); + const newRelationship = await createNewRelationship(self, user); - await client.user.update({ - where: { id: self.id }, - data: { - relationships: { - connect: { - id: newRelationship.id, - }, - }, - }, - }); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); - relationship = newRelationship; - } + relationship = newRelationship; + } - if (relationship.blocking) { - relationship.blocking = false; - } + if (relationship.blocking) { + relationship.blocking = false; + } - await client.relationship.update({ - where: { id: relationship.id }, - data: { - blocking: false, - }, - }); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + blocking: false, + }, + }); - return jsonResponse(relationshipToAPI(relationship)); + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/accounts/[id]/unfollow.ts b/server/api/api/v1/accounts/[id]/unfollow.ts index b5b79a5a..9a209908 100644 --- a/server/api/api/v1/accounts/[id]/unfollow.ts +++ b/server/api/api/v1/accounts/[id]/unfollow.ts @@ -1,81 +1,81 @@ +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; import { - createNewRelationship, - relationshipToAPI, + createNewRelationship, + relationshipToAPI, } from "~database/entities/Relationship"; import { getRelationshipToOtherUser } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; -import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/accounts/:id/unfollow", - auth: { - required: true, - oauthPermissions: ["write:follows"], - }, + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/unfollow", + auth: { + required: true, + oauthPermissions: ["write:follows"], + }, }); /** * Unfollows a user */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user: self } = extraData.auth; + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const user = await client.user.findUnique({ - where: { id }, - include: { - relationships: { - include: { - owner: true, - subject: true, - }, - }, - }, - }); + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, + }, + }); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - // Check if already following - let relationship = await getRelationshipToOtherUser(self, user); + // Check if already following + let relationship = await getRelationshipToOtherUser(self, user); - if (!relationship) { - // Create new relationship + if (!relationship) { + // Create new relationship - const newRelationship = await createNewRelationship(self, user); + const newRelationship = await createNewRelationship(self, user); - await client.user.update({ - where: { id: self.id }, - data: { - relationships: { - connect: { - id: newRelationship.id, - }, - }, - }, - }); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); - relationship = newRelationship; - } + relationship = newRelationship; + } - if (relationship.following) { - relationship.following = false; - } + if (relationship.following) { + relationship.following = false; + } - await client.relationship.update({ - where: { id: relationship.id }, - data: { - following: false, - }, - }); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + following: false, + }, + }); - return jsonResponse(relationshipToAPI(relationship)); + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/accounts/[id]/unmute.ts b/server/api/api/v1/accounts/[id]/unmute.ts index 0318ffdc..d459253d 100644 --- a/server/api/api/v1/accounts/[id]/unmute.ts +++ b/server/api/api/v1/accounts/[id]/unmute.ts @@ -1,83 +1,83 @@ +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; import { - createNewRelationship, - relationshipToAPI, + createNewRelationship, + relationshipToAPI, } from "~database/entities/Relationship"; import { getRelationshipToOtherUser } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; -import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/accounts/:id/unmute", - auth: { - required: true, - oauthPermissions: ["write:mutes"], - }, + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/unmute", + auth: { + required: true, + oauthPermissions: ["write:mutes"], + }, }); /** * Unmute a user */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user: self } = extraData.auth; + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const user = await client.user.findUnique({ - where: { id }, - include: { - relationships: { - include: { - owner: true, - subject: true, - }, - }, - }, - }); + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, + }, + }); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - // Check if already following - let relationship = await getRelationshipToOtherUser(self, user); + // Check if already following + let relationship = await getRelationshipToOtherUser(self, user); - if (!relationship) { - // Create new relationship + if (!relationship) { + // Create new relationship - const newRelationship = await createNewRelationship(self, user); + const newRelationship = await createNewRelationship(self, user); - await client.user.update({ - where: { id: self.id }, - data: { - relationships: { - connect: { - id: newRelationship.id, - }, - }, - }, - }); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); - relationship = newRelationship; - } + relationship = newRelationship; + } - if (relationship.muting) { - relationship.muting = false; - } + if (relationship.muting) { + relationship.muting = false; + } - // TODO: Implement duration + // TODO: Implement duration - await client.relationship.update({ - where: { id: relationship.id }, - data: { - muting: false, - }, - }); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + muting: false, + }, + }); - return jsonResponse(relationshipToAPI(relationship)); + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/accounts/[id]/unpin.ts b/server/api/api/v1/accounts/[id]/unpin.ts index c700c530..db028443 100644 --- a/server/api/api/v1/accounts/[id]/unpin.ts +++ b/server/api/api/v1/accounts/[id]/unpin.ts @@ -1,81 +1,81 @@ +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; import { - createNewRelationship, - relationshipToAPI, + createNewRelationship, + relationshipToAPI, } from "~database/entities/Relationship"; import { getRelationshipToOtherUser } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; -import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/accounts/:id/unpin", - auth: { - required: true, - oauthPermissions: ["write:accounts"], - }, + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/unpin", + auth: { + required: true, + oauthPermissions: ["write:accounts"], + }, }); /** * Unpin a user */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user: self } = extraData.auth; + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const user = await client.user.findUnique({ - where: { id }, - include: { - relationships: { - include: { - owner: true, - subject: true, - }, - }, - }, - }); + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, + }, + }); - if (!user) return errorResponse("User not found", 404); + if (!user) return errorResponse("User not found", 404); - // Check if already following - let relationship = await getRelationshipToOtherUser(self, user); + // Check if already following + let relationship = await getRelationshipToOtherUser(self, user); - if (!relationship) { - // Create new relationship + if (!relationship) { + // Create new relationship - const newRelationship = await createNewRelationship(self, user); + const newRelationship = await createNewRelationship(self, user); - await client.user.update({ - where: { id: self.id }, - data: { - relationships: { - connect: { - id: newRelationship.id, - }, - }, - }, - }); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); - relationship = newRelationship; - } + relationship = newRelationship; + } - if (relationship.endorsed) { - relationship.endorsed = false; - } + if (relationship.endorsed) { + relationship.endorsed = false; + } - await client.relationship.update({ - where: { id: relationship.id }, - data: { - endorsed: false, - }, - }); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + endorsed: false, + }, + }); - return jsonResponse(relationshipToAPI(relationship)); + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/accounts/familiar_followers/index.ts b/server/api/api/v1/accounts/familiar_followers/index.ts index 091b8078..cce1eac2 100644 --- a/server/api/api/v1/accounts/familiar_followers/index.ts +++ b/server/api/api/v1/accounts/familiar_followers/index.ts @@ -1,67 +1,67 @@ -import { errorResponse, jsonResponse } from "@response"; -import { userToAPI } from "~database/entities/User"; import { apiRoute, applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; +import { userToAPI } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/accounts/familiar_followers", - ratelimits: { - max: 5, - duration: 60, - }, - auth: { - required: true, - oauthPermissions: ["read:follows"], - }, + allowedMethods: ["GET"], + route: "/api/v1/accounts/familiar_followers", + ratelimits: { + max: 5, + duration: 60, + }, + auth: { + required: true, + oauthPermissions: ["read:follows"], + }, }); /** * Find familiar followers (followers of a user that you also follow) */ export default apiRoute<{ - id: string[]; + id: string[]; }>(async (req, matchedRoute, extraData) => { - const { user: self } = extraData.auth; + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const { id: ids } = extraData.parsedRequest; + const { id: ids } = extraData.parsedRequest; - // Minimum id count 1, maximum 10 - if (!ids || ids.length < 1 || ids.length > 10) { - return errorResponse("Number of ids must be between 1 and 10", 422); - } + // Minimum id count 1, maximum 10 + if (!ids || ids.length < 1 || ids.length > 10) { + return errorResponse("Number of ids must be between 1 and 10", 422); + } - const followersOfIds = await client.user.findMany({ - where: { - relationships: { - some: { - subjectId: { - in: ids, - }, - following: true, - }, - }, - }, - }); + const followersOfIds = await client.user.findMany({ + where: { + relationships: { + some: { + subjectId: { + in: ids, + }, + following: true, + }, + }, + }, + }); - // Find users that you follow in followersOfIds - const output = await client.user.findMany({ - where: { - relationships: { - some: { - ownerId: self.id, - subjectId: { - in: followersOfIds.map(f => f.id), - }, - following: true, - }, - }, - }, - include: userRelations, - }); + // Find users that you follow in followersOfIds + const output = await client.user.findMany({ + where: { + relationships: { + some: { + ownerId: self.id, + subjectId: { + in: followersOfIds.map((f) => f.id), + }, + following: true, + }, + }, + }, + include: userRelations, + }); - return jsonResponse(output.map(o => userToAPI(o))); + return jsonResponse(output.map((o) => userToAPI(o))); }); diff --git a/server/api/api/v1/accounts/index.ts b/server/api/api/v1/accounts/index.ts index 14c19b93..e721697e 100644 --- a/server/api/api/v1/accounts/index.ts +++ b/server/api/api/v1/accounts/index.ts @@ -1,202 +1,206 @@ +import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; import { tempmailDomains } from "@tempmail"; -import { apiRoute, applyConfig } from "@api"; +import ISO6391 from "iso-639-1"; import { client } from "~database/datasource"; import { createNewLocalUser } from "~database/entities/User"; -import ISO6391 from "iso-639-1"; export const meta = applyConfig({ - allowedMethods: ["POST"], - route: "/api/v1/accounts", - ratelimits: { - max: 2, - duration: 60, - }, - auth: { - required: false, - oauthPermissions: ["write:accounts"], - }, + allowedMethods: ["POST"], + route: "/api/v1/accounts", + ratelimits: { + max: 2, + duration: 60, + }, + auth: { + required: false, + oauthPermissions: ["write:accounts"], + }, }); export default apiRoute<{ - username: string; - email: string; - password: string; - agreement: boolean; - locale: string; - reason: string; + username: string; + email: string; + password: string; + agreement: boolean; + locale: string; + reason: string; }>(async (req, matchedRoute, extraData) => { - // TODO: Add Authorization check + // TODO: Add Authorization check - const body = extraData.parsedRequest; + const body = extraData.parsedRequest; - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - if (!config.signups.registration) { - return jsonResponse( - { - error: "Registration is disabled", - }, - 422 - ); - } + if (!config.signups.registration) { + return jsonResponse( + { + error: "Registration is disabled", + }, + 422, + ); + } - const errors: { - details: Record< - string, - { - error: - | "ERR_BLANK" - | "ERR_INVALID" - | "ERR_TOO_LONG" - | "ERR_TOO_SHORT" - | "ERR_BLOCKED" - | "ERR_TAKEN" - | "ERR_RESERVED" - | "ERR_ACCEPTED" - | "ERR_INCLUSION"; - description: string; - }[] - >; - } = { - details: { - password: [], - username: [], - email: [], - agreement: [], - locale: [], - reason: [], - }, - }; + const errors: { + details: Record< + string, + { + error: + | "ERR_BLANK" + | "ERR_INVALID" + | "ERR_TOO_LONG" + | "ERR_TOO_SHORT" + | "ERR_BLOCKED" + | "ERR_TAKEN" + | "ERR_RESERVED" + | "ERR_ACCEPTED" + | "ERR_INCLUSION"; + description: string; + }[] + >; + } = { + details: { + password: [], + username: [], + email: [], + agreement: [], + locale: [], + reason: [], + }, + }; - // Check if fields are blank - ["username", "email", "password", "agreement", "locale", "reason"].forEach( - value => { - // @ts-expect-error Value is always valid - if (!body[value]) - errors.details[value].push({ - error: "ERR_BLANK", - description: `can't be blank`, - }); - } - ); + // Check if fields are blank + for (const value of [ + "username", + "email", + "password", + "agreement", + "locale", + "reason", + ]) { + // @ts-expect-error We don't care about typing here + if (!body[value]) { + errors.details[value].push({ + error: "ERR_BLANK", + description: `can't be blank`, + }); + } + } - // Check if username is valid - if (!body.username?.match(/^[a-zA-Z0-9_]+$/)) - errors.details.username.push({ - error: "ERR_INVALID", - description: `must only contain letters, numbers, and underscores`, - }); + // Check if username is valid + if (!body.username?.match(/^[a-zA-Z0-9_]+$/)) + errors.details.username.push({ + error: "ERR_INVALID", + description: "must only contain letters, numbers, and underscores", + }); - // Check if username doesnt match filters - if ( - config.filters.username_filters.some(filter => - body.username?.match(filter) - ) - ) { - errors.details.username.push({ - error: "ERR_INVALID", - description: `contains blocked words`, - }); - } + // Check if username doesnt match filters + if ( + config.filters.username.some((filter) => body.username?.match(filter)) + ) { + errors.details.username.push({ + error: "ERR_INVALID", + description: "contains blocked words", + }); + } - // Check if username is too long - if ((body.username?.length ?? 0) > config.validation.max_username_size) - errors.details.username.push({ - error: "ERR_TOO_LONG", - description: `is too long (maximum is ${config.validation.max_username_size} characters)`, - }); + // Check if username is too long + if ((body.username?.length ?? 0) > config.validation.max_username_size) + errors.details.username.push({ + error: "ERR_TOO_LONG", + description: `is too long (maximum is ${config.validation.max_username_size} characters)`, + }); - // Check if username is too short - if ((body.username?.length ?? 0) < 3) - errors.details.username.push({ - error: "ERR_TOO_SHORT", - description: `is too short (minimum is 3 characters)`, - }); + // Check if username is too short + if ((body.username?.length ?? 0) < 3) + errors.details.username.push({ + error: "ERR_TOO_SHORT", + description: "is too short (minimum is 3 characters)", + }); - // Check if username is reserved - if (config.validation.username_blacklist.includes(body.username ?? "")) - errors.details.username.push({ - error: "ERR_RESERVED", - description: `is reserved`, - }); + // Check if username is reserved + if (config.validation.username_blacklist.includes(body.username ?? "")) + errors.details.username.push({ + error: "ERR_RESERVED", + description: "is reserved", + }); - // Check if username is taken - if (await client.user.findFirst({ where: { username: body.username } })) - errors.details.username.push({ - error: "ERR_TAKEN", - description: `is already taken`, - }); + // Check if username is taken + if (await client.user.findFirst({ where: { username: body.username } })) + errors.details.username.push({ + error: "ERR_TAKEN", + description: "is already taken", + }); - // Check if email is valid - if ( - !body.email?.match( - /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ - ) - ) - errors.details.email.push({ - error: "ERR_INVALID", - description: `must be a valid email address`, - }); + // Check if email is valid + if ( + !body.email?.match( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + ) + ) + errors.details.email.push({ + error: "ERR_INVALID", + description: "must be a valid email address", + }); - // Check if email is blocked - if ( - config.validation.email_blacklist.includes(body.email ?? "") || - (config.validation.blacklist_tempmail && - tempmailDomains.domains.includes((body.email ?? "").split("@")[1])) - ) - errors.details.email.push({ - error: "ERR_BLOCKED", - description: `is from a blocked email provider`, - }); + // Check if email is blocked + if ( + config.validation.email_blacklist.includes(body.email ?? "") || + (config.validation.blacklist_tempmail && + tempmailDomains.domains.includes((body.email ?? "").split("@")[1])) + ) + errors.details.email.push({ + error: "ERR_BLOCKED", + description: "is from a blocked email provider", + }); - // Check if agreement is accepted - if (!body.agreement) - errors.details.agreement.push({ - error: "ERR_ACCEPTED", - description: `must be accepted`, - }); + // Check if agreement is accepted + if (!body.agreement) + errors.details.agreement.push({ + error: "ERR_ACCEPTED", + description: "must be accepted", + }); - if (!body.locale) - errors.details.locale.push({ - error: "ERR_BLANK", - description: `can't be blank`, - }); + if (!body.locale) + errors.details.locale.push({ + error: "ERR_BLANK", + description: `can't be blank`, + }); - if (!ISO6391.validate(body.locale ?? "")) - errors.details.locale.push({ - error: "ERR_INVALID", - description: `must be a valid ISO 639-1 code`, - }); + if (!ISO6391.validate(body.locale ?? "")) + errors.details.locale.push({ + error: "ERR_INVALID", + description: "must be a valid ISO 639-1 code", + }); - // If any errors are present, return them - if (Object.values(errors.details).some(value => value.length > 0)) { - // Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted" + // If any errors are present, return them + if (Object.values(errors.details).some((value) => value.length > 0)) { + // Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted" - const errorsText = Object.entries(errors.details) - .map( - ([name, errors]) => - `${name} ${errors - .map(error => error.description) - .join(", ")}` - ) - .join(", "); - return jsonResponse( - { - error: `Validation failed: ${errorsText}`, - details: errors.details, - }, - 422 - ); - } + const errorsText = Object.entries(errors.details) + .map( + ([name, errors]) => + `${name} ${errors + .map((error) => error.description) + .join(", ")}`, + ) + .join(", "); + return jsonResponse( + { + error: `Validation failed: ${errorsText}`, + details: errors.details, + }, + 422, + ); + } - await createNewLocalUser({ - username: body.username ?? "", - password: body.password ?? "", - email: body.email ?? "", - }); + await createNewLocalUser({ + username: body.username ?? "", + password: body.password ?? "", + email: body.email ?? "", + }); - return new Response("", { - status: 200, - }); + return new Response("", { + status: 200, + }); }); diff --git a/server/api/api/v1/accounts/relationships/index.ts b/server/api/api/v1/accounts/relationships/index.ts index 890c9a7b..6c3b3ca0 100644 --- a/server/api/api/v1/accounts/relationships/index.ts +++ b/server/api/api/v1/accounts/relationships/index.ts @@ -1,66 +1,67 @@ -import { errorResponse, jsonResponse } from "@response"; -import { - createNewRelationship, - relationshipToAPI, -} from "~database/entities/Relationship"; import { apiRoute, applyConfig } from "@api"; +import type { User } from "@prisma/client"; +import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; +import { + createNewRelationship, + relationshipToAPI, +} from "~database/entities/Relationship"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/accounts/relationships", - ratelimits: { - max: 30, - duration: 60, - }, - auth: { - required: true, - oauthPermissions: ["read:follows"], - }, + allowedMethods: ["GET"], + route: "/api/v1/accounts/relationships", + ratelimits: { + max: 30, + duration: 60, + }, + auth: { + required: true, + oauthPermissions: ["read:follows"], + }, }); /** * Find relationships */ export default apiRoute<{ - id: string[]; + id: string[]; }>(async (req, matchedRoute, extraData) => { - const { user: self } = extraData.auth; + const { user: self } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const { id: ids } = extraData.parsedRequest; + const { id: ids } = extraData.parsedRequest; - // Minimum id count 1, maximum 10 - if (!ids || ids.length < 1 || ids.length > 10) { - return errorResponse("Number of ids must be between 1 and 10", 422); - } + // Minimum id count 1, maximum 10 + if (!ids || ids.length < 1 || ids.length > 10) { + return errorResponse("Number of ids must be between 1 and 10", 422); + } - const relationships = await client.relationship.findMany({ - where: { - ownerId: self.id, - subjectId: { - in: ids, - }, - }, - }); + const relationships = await client.relationship.findMany({ + where: { + ownerId: self.id, + subjectId: { + in: ids, + }, + }, + }); - // Find IDs that dont have a relationship - const missingIds = ids.filter( - id => !relationships.some(r => r.subjectId === id) - ); + // Find IDs that dont have a relationship + const missingIds = ids.filter( + (id) => !relationships.some((r) => r.subjectId === id), + ); - // Create the missing relationships - for (const id of missingIds) { - const relationship = await createNewRelationship(self, { id } as any); + // Create the missing relationships + for (const id of missingIds) { + const relationship = await createNewRelationship(self, { id } as User); - relationships.push(relationship); - } + relationships.push(relationship); + } - // Order in the same order as ids - relationships.sort( - (a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId) - ); + // Order in the same order as ids + relationships.sort( + (a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId), + ); - return jsonResponse(relationships.map(r => relationshipToAPI(r))); + return jsonResponse(relationships.map((r) => relationshipToAPI(r))); }); diff --git a/server/api/api/v1/accounts/search/index.ts b/server/api/api/v1/accounts/search/index.ts index 7011ee3f..1e3e810f 100644 --- a/server/api/api/v1/accounts/search/index.ts +++ b/server/api/api/v1/accounts/search/index.ts @@ -1,75 +1,75 @@ -import { errorResponse, jsonResponse } from "@response"; -import { userToAPI } from "~database/entities/User"; import { apiRoute, applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; +import { userToAPI } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/accounts/search", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - oauthPermissions: ["read:accounts"], - }, + allowedMethods: ["GET"], + route: "/api/v1/accounts/search", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + oauthPermissions: ["read:accounts"], + }, }); export default apiRoute<{ - q?: string; - limit?: number; - offset?: number; - resolve?: boolean; - following?: boolean; + q?: string; + limit?: number; + offset?: number; + resolve?: boolean; + following?: boolean; }>(async (req, matchedRoute, extraData) => { - // TODO: Add checks for disabled or not email verified accounts + // TODO: Add checks for disabled or not email verified accounts - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const { - following = false, - limit = 40, - offset, - q, - } = extraData.parsedRequest; + const { + following = false, + limit = 40, + offset, + q, + } = extraData.parsedRequest; - if (limit < 1 || limit > 80) { - return errorResponse("Limit must be between 1 and 80", 400); - } + if (limit < 1 || limit > 80) { + return errorResponse("Limit must be between 1 and 80", 400); + } - // TODO: Add WebFinger resolve + // TODO: Add WebFinger resolve - const accounts = await client.user.findMany({ - where: { - OR: [ - { - displayName: { - contains: q, - }, - }, - { - username: { - contains: q, - }, - }, - ], - relationshipSubjects: following - ? { - some: { - ownerId: user.id, - following, - }, - } - : undefined, - }, - take: Number(limit), - skip: Number(offset || 0), - include: userRelations, - }); + const accounts = await client.user.findMany({ + where: { + OR: [ + { + displayName: { + contains: q, + }, + }, + { + username: { + contains: q, + }, + }, + ], + relationshipSubjects: following + ? { + some: { + ownerId: user.id, + following, + }, + } + : undefined, + }, + take: Number(limit), + skip: Number(offset || 0), + include: userRelations, + }); - return jsonResponse(accounts.map(acct => userToAPI(acct))); + return jsonResponse(accounts.map((acct) => userToAPI(acct))); }); diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index 18be18f9..ce7aaa35 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -1,72 +1,72 @@ -import { errorResponse, jsonResponse } from "@response"; -import { userToAPI } from "~database/entities/User"; import { apiRoute, applyConfig } from "@api"; -import { sanitize } from "isomorphic-dompurify"; +import { convertTextToHtml } from "@formatting"; +import { errorResponse, jsonResponse } from "@response"; import { sanitizeHtml } from "@sanitization"; import ISO6391 from "iso-639-1"; -import { parseEmojis } from "~database/entities/Emoji"; -import { client } from "~database/datasource"; -import type { APISource } from "~types/entities/source"; -import { convertTextToHtml } from "@formatting"; +import { sanitize } from "isomorphic-dompurify"; import { MediaBackendType } from "media-manager"; import type { MediaBackend } from "media-manager"; +import { client } from "~database/datasource"; +import { getUrl } from "~database/entities/Attachment"; +import { parseEmojis } from "~database/entities/Emoji"; +import { userToAPI } from "~database/entities/User"; +import { userRelations } from "~database/entities/relations"; import { LocalMediaBackend } from "~packages/media-manager/backends/local"; import { S3MediaBackend } from "~packages/media-manager/backends/s3"; -import { getUrl } from "~database/entities/Attachment"; -import { userRelations } from "~database/entities/relations"; +import type { APISource } from "~types/entities/source"; export const meta = applyConfig({ - allowedMethods: ["PATCH"], - route: "/api/v1/accounts/update_credentials", - ratelimits: { - max: 2, - duration: 60, - }, - auth: { - required: true, - oauthPermissions: ["write:accounts"], - }, + allowedMethods: ["PATCH"], + route: "/api/v1/accounts/update_credentials", + ratelimits: { + max: 2, + duration: 60, + }, + auth: { + required: true, + oauthPermissions: ["write:accounts"], + }, }); export default apiRoute<{ - display_name: string; - note: string; - avatar: File; - header: File; - locked: string; - bot: string; - discoverable: string; - "source[privacy]": string; - "source[sensitive]": string; - "source[language]": string; + display_name: string; + note: string; + avatar: File; + header: File; + locked: string; + bot: string; + discoverable: string; + "source[privacy]": string; + "source[sensitive]": string; + "source[language]": string; }>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - const { - display_name, - note, - avatar, - header, - locked, - bot, - discoverable, - "source[privacy]": source_privacy, - "source[sensitive]": source_sensitive, - "source[language]": source_language, - } = extraData.parsedRequest; + const { + display_name, + note, + avatar, + header, + locked, + bot, + discoverable, + "source[privacy]": source_privacy, + "source[sensitive]": source_sensitive, + "source[language]": source_language, + } = extraData.parsedRequest; - const sanitizedNote = await sanitizeHtml(note ?? ""); + const sanitizedNote = await sanitizeHtml(note ?? ""); - const sanitizedDisplayName = sanitize(display_name ?? "", { - ALLOWED_TAGS: [], - ALLOWED_ATTR: [], - }); + const sanitizedDisplayName = sanitize(display_name ?? "", { + ALLOWED_TAGS: [], + ALLOWED_ATTR: [], + }); - /* if (!user.source) { + /* if (!user.source) { user.source = { privacy: "public", sensitive: false, @@ -75,191 +75,192 @@ export default apiRoute<{ }; } */ - let mediaManager: MediaBackend; + let mediaManager: MediaBackend; - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.LOCAL: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } + switch (config.media.backend as MediaBackendType) { + case MediaBackendType.LOCAL: + mediaManager = new LocalMediaBackend(config); + break; + case MediaBackendType.S3: + mediaManager = new S3MediaBackend(config); + break; + default: + // TODO: Replace with logger + throw new Error("Invalid media backend"); + } - if (display_name) { - // Check if within allowed display name lengths - if ( - sanitizedDisplayName.length < 3 || - sanitizedDisplayName.length > config.validation.max_displayname_size - ) { - return errorResponse( - `Display name must be between 3 and ${config.validation.max_displayname_size} characters`, - 422 - ); - } + if (display_name) { + // Check if within allowed display name lengths + if ( + sanitizedDisplayName.length < 3 || + sanitizedDisplayName.length > config.validation.max_displayname_size + ) { + return errorResponse( + `Display name must be between 3 and ${config.validation.max_displayname_size} characters`, + 422, + ); + } - // Check if display name doesnt match filters - if ( - config.filters.displayname.some(filter => - sanitizedDisplayName.match(filter) - ) - ) { - return errorResponse("Display name contains blocked words", 422); - } + // Check if display name doesnt match filters + if ( + config.filters.displayname.some((filter) => + sanitizedDisplayName.match(filter), + ) + ) { + return errorResponse("Display name contains blocked words", 422); + } - // Remove emojis - user.emojis = []; + // Remove emojis + user.emojis = []; - user.displayName = sanitizedDisplayName; - } + user.displayName = sanitizedDisplayName; + } - if (note && user.source) { - // Check if within allowed note length - if (sanitizedNote.length > config.validation.max_note_size) { - return errorResponse( - `Note must be less than ${config.validation.max_note_size} characters`, - 422 - ); - } + if (note && user.source) { + // Check if within allowed note length + if (sanitizedNote.length > config.validation.max_note_size) { + return errorResponse( + `Note must be less than ${config.validation.max_note_size} characters`, + 422, + ); + } - // Check if bio doesnt match filters - if (config.filters.bio.some(filter => sanitizedNote.match(filter))) { - return errorResponse("Bio contains blocked words", 422); - } + // Check if bio doesnt match filters + if (config.filters.bio.some((filter) => sanitizedNote.match(filter))) { + return errorResponse("Bio contains blocked words", 422); + } - (user.source as APISource).note = sanitizedNote; - // TODO: Convert note to HTML - user.note = await convertTextToHtml(sanitizedNote); - } + (user.source as APISource).note = sanitizedNote; + // TODO: Convert note to HTML + user.note = await convertTextToHtml(sanitizedNote); + } - if (source_privacy && user.source) { - // Check if within allowed privacy values - if ( - !["public", "unlisted", "private", "direct"].includes( - source_privacy - ) - ) { - return errorResponse( - "Privacy must be one of public, unlisted, private, or direct", - 422 - ); - } + if (source_privacy && user.source) { + // Check if within allowed privacy values + if ( + !["public", "unlisted", "private", "direct"].includes( + source_privacy, + ) + ) { + return errorResponse( + "Privacy must be one of public, unlisted, private, or direct", + 422, + ); + } - (user.source as APISource).privacy = source_privacy; - } + (user.source as APISource).privacy = source_privacy; + } - if (source_sensitive && user.source) { - // Check if within allowed sensitive values - if (source_sensitive !== "true" && source_sensitive !== "false") { - return errorResponse("Sensitive must be a boolean", 422); - } + if (source_sensitive && user.source) { + // Check if within allowed sensitive values + if (source_sensitive !== "true" && source_sensitive !== "false") { + return errorResponse("Sensitive must be a boolean", 422); + } - (user.source as APISource).sensitive = source_sensitive === "true"; - } + (user.source as APISource).sensitive = source_sensitive === "true"; + } - if (source_language && user.source) { - if (!ISO6391.validate(source_language)) { - return errorResponse( - "Language must be a valid ISO 639-1 code", - 422 - ); - } + if (source_language && user.source) { + if (!ISO6391.validate(source_language)) { + return errorResponse( + "Language must be a valid ISO 639-1 code", + 422, + ); + } - (user.source as APISource).language = source_language; - } + (user.source as APISource).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 (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, + ); + } - const { uploadedFile } = await mediaManager.addFile(avatar); + const { uploadedFile } = await mediaManager.addFile(avatar); - user.avatar = getUrl(uploadedFile.name, config); - } + user.avatar = getUrl(uploadedFile.name, 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 - ); - } + 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 { uploadedFile } = await mediaManager.addFile(header); + const { uploadedFile } = await mediaManager.addFile(header); - user.header = getUrl(uploadedFile.name, config); - } + user.header = getUrl(uploadedFile.name, config); + } - if (locked) { - // Check if locked is a boolean - if (locked !== "true" && locked !== "false") { - return errorResponse("Locked must be a boolean", 422); - } + if (locked) { + // Check if locked is a boolean + if (locked !== "true" && locked !== "false") { + return errorResponse("Locked must be a boolean", 422); + } - user.isLocked = locked === "true"; - } + user.isLocked = locked === "true"; + } - if (bot) { - // Check if bot is a boolean - if (bot !== "true" && bot !== "false") { - return errorResponse("Bot must be a boolean", 422); - } + if (bot) { + // Check if bot is a boolean + if (bot !== "true" && bot !== "false") { + return errorResponse("Bot must be a boolean", 422); + } - user.isBot = bot === "true"; - } + user.isBot = bot === "true"; + } - if (discoverable) { - // Check if discoverable is a boolean - if (discoverable !== "true" && discoverable !== "false") { - return errorResponse("Discoverable must be a boolean", 422); - } + if (discoverable) { + // Check if discoverable is a boolean + if (discoverable !== "true" && discoverable !== "false") { + return errorResponse("Discoverable must be a boolean", 422); + } - user.isDiscoverable = discoverable === "true"; - } + user.isDiscoverable = discoverable === "true"; + } - // Parse emojis + // Parse emojis - const displaynameEmojis = await parseEmojis(sanitizedDisplayName); - const noteEmojis = await parseEmojis(sanitizedNote); + const displaynameEmojis = await parseEmojis(sanitizedDisplayName); + const noteEmojis = await parseEmojis(sanitizedNote); - user.emojis = [...displaynameEmojis, ...noteEmojis]; + user.emojis = [...displaynameEmojis, ...noteEmojis]; - // Deduplicate emojis - user.emojis = user.emojis.filter( - (emoji, index, self) => self.findIndex(e => e.id === emoji.id) === index - ); + // Deduplicate emojis + user.emojis = user.emojis.filter( + (emoji, index, self) => + self.findIndex((e) => e.id === emoji.id) === index, + ); - const output = await client.user.update({ - where: { id: user.id }, - data: { - displayName: user.displayName, - note: user.note, - avatar: user.avatar, - header: user.header, - isLocked: user.isLocked, - isBot: user.isBot, - isDiscoverable: user.isDiscoverable, - emojis: { - disconnect: user.emojis.map(e => ({ - id: e.id, - })), - connect: user.emojis.map(e => ({ - id: e.id, - })), - }, - source: user.source || undefined, - }, - include: userRelations, - }); + const output = await client.user.update({ + where: { id: user.id }, + data: { + displayName: user.displayName, + note: user.note, + avatar: user.avatar, + header: user.header, + isLocked: user.isLocked, + isBot: user.isBot, + isDiscoverable: user.isDiscoverable, + emojis: { + disconnect: user.emojis.map((e) => ({ + id: e.id, + })), + connect: user.emojis.map((e) => ({ + id: e.id, + })), + }, + source: user.source || undefined, + }, + include: userRelations, + }); - return jsonResponse(userToAPI(output)); + return jsonResponse(userToAPI(output)); }); diff --git a/server/api/api/v1/accounts/verify_credentials/index.ts b/server/api/api/v1/accounts/verify_credentials/index.ts index 3d023b7e..3d07f9ea 100644 --- a/server/api/api/v1/accounts/verify_credentials/index.ts +++ b/server/api/api/v1/accounts/verify_credentials/index.ts @@ -1,28 +1,28 @@ +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { userToAPI } from "~database/entities/User"; -import { apiRoute, applyConfig } from "@api"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/accounts/verify_credentials", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - oauthPermissions: ["read:accounts"], - }, + allowedMethods: ["GET"], + route: "/api/v1/accounts/verify_credentials", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + oauthPermissions: ["read:accounts"], + }, }); export default apiRoute((req, matchedRoute, extraData) => { - // TODO: Add checks for disabled or not email verified accounts + // TODO: Add checks for disabled or not email verified accounts - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - return jsonResponse({ - ...userToAPI(user, true), - }); + return jsonResponse({ + ...userToAPI(user, true), + }); }); diff --git a/server/api/api/v1/apps/index.ts b/server/api/api/v1/apps/index.ts index c3e3bff8..5be98af7 100644 --- a/server/api/api/v1/apps/index.ts +++ b/server/api/api/v1/apps/index.ts @@ -1,65 +1,65 @@ +import { randomBytes } from "node:crypto"; import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { randomBytes } from "crypto"; import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["POST"], - route: "/api/v1/apps", - ratelimits: { - max: 2, - duration: 60, - }, - auth: { - required: false, - }, + allowedMethods: ["POST"], + route: "/api/v1/apps", + ratelimits: { + max: 2, + duration: 60, + }, + auth: { + required: false, + }, }); /** * Creates a new application to obtain OAuth 2 credentials */ export default apiRoute<{ - client_name: string; - redirect_uris: string; - scopes: string; - website: string; + client_name: string; + redirect_uris: string; + scopes: string; + website: string; }>(async (req, matchedRoute, extraData) => { - const { client_name, redirect_uris, scopes, website } = - extraData.parsedRequest; + const { client_name, redirect_uris, scopes, website } = + extraData.parsedRequest; - // Check if redirect URI is a valid URI, and also an absolute URI - if (redirect_uris) { - try { - const redirect_uri = new URL(redirect_uris); + // Check if redirect URI is a valid URI, and also an absolute URI + if (redirect_uris) { + try { + const redirect_uri = new URL(redirect_uris); - if (!redirect_uri.protocol.startsWith("http")) { - return errorResponse( - "Redirect URI must be an absolute URI", - 422 - ); - } - } catch { - return errorResponse("Redirect URI must be a valid URI", 422); - } - } - const application = await client.application.create({ - data: { - name: client_name || "", - redirect_uris: redirect_uris || "", - scopes: scopes || "read", - website: website || null, - client_id: randomBytes(32).toString("base64url"), - secret: randomBytes(64).toString("base64url"), - }, - }); + if (!redirect_uri.protocol.startsWith("http")) { + return errorResponse( + "Redirect URI must be an absolute URI", + 422, + ); + } + } catch { + return errorResponse("Redirect URI must be a valid URI", 422); + } + } + const application = await client.application.create({ + data: { + name: client_name || "", + redirect_uris: redirect_uris || "", + scopes: scopes || "read", + website: website || null, + client_id: randomBytes(32).toString("base64url"), + secret: randomBytes(64).toString("base64url"), + }, + }); - return jsonResponse({ - id: application.id, - name: application.name, - website: application.website, - client_id: application.client_id, - client_secret: application.secret, - redirect_uri: application.redirect_uris, - vapid_link: application.vapid_key, - }); + return jsonResponse({ + id: application.id, + name: application.name, + website: application.website, + client_id: application.client_id, + client_secret: application.secret, + redirect_uri: application.redirect_uris, + vapid_link: application.vapid_key, + }); }); diff --git a/server/api/api/v1/apps/verify_credentials/index.ts b/server/api/api/v1/apps/verify_credentials/index.ts index 4444213e..e26cf739 100644 --- a/server/api/api/v1/apps/verify_credentials/index.ts +++ b/server/api/api/v1/apps/verify_credentials/index.ts @@ -3,32 +3,32 @@ import { errorResponse, jsonResponse } from "@response"; import { getFromToken } from "~database/entities/Application"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/apps/verify_credentials", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - }, + allowedMethods: ["GET"], + route: "/api/v1/apps/verify_credentials", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, }); /** * Returns OAuth2 credentials */ export default apiRoute(async (req, matchedRoute, extraData) => { - const { user, token } = extraData.auth; - const application = await getFromToken(token); + const { user, token } = extraData.auth; + const application = await getFromToken(token); - if (!user) return errorResponse("Unauthorized", 401); - if (!application) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); + if (!application) return errorResponse("Unauthorized", 401); - return jsonResponse({ - name: application.name, - website: application.website, - vapid_key: application.vapid_key, - redirect_uris: application.redirect_uris, - scopes: application.scopes, - }); + return jsonResponse({ + name: application.name, + website: application.website, + vapid_key: application.vapid_key, + redirect_uris: application.redirect_uris, + scopes: application.scopes, + }); }); diff --git a/server/api/api/v1/blocks/index.ts b/server/api/api/v1/blocks/index.ts index a42f5264..e1b8240a 100644 --- a/server/api/api/v1/blocks/index.ts +++ b/server/api/api/v1/blocks/index.ts @@ -1,37 +1,37 @@ -import { errorResponse, jsonResponse } from "@response"; -import { userToAPI } from "~database/entities/User"; import { apiRoute, applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; +import { userToAPI } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/blocks", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - }, + allowedMethods: ["GET"], + route: "/api/v1/blocks", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, }); export default apiRoute(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const blocks = await client.user.findMany({ - where: { - relationshipSubjects: { - some: { - ownerId: user.id, - blocking: true, - }, - }, - }, - include: userRelations, - }); + const blocks = await client.user.findMany({ + where: { + relationshipSubjects: { + some: { + ownerId: user.id, + blocking: true, + }, + }, + }, + include: userRelations, + }); - return jsonResponse(blocks.map(u => userToAPI(u))); + return jsonResponse(blocks.map((u) => userToAPI(u))); }); diff --git a/server/api/api/v1/custom_emojis/index.ts b/server/api/api/v1/custom_emojis/index.ts index 224da926..80e91a25 100644 --- a/server/api/api/v1/custom_emojis/index.ts +++ b/server/api/api/v1/custom_emojis/index.ts @@ -4,25 +4,25 @@ import { client } from "~database/datasource"; import { emojiToAPI } from "~database/entities/Emoji"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/custom_emojis", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: false, - }, + allowedMethods: ["GET"], + route: "/api/v1/custom_emojis", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: false, + }, }); export default apiRoute(async () => { - const emojis = await client.emoji.findMany({ - where: { - instanceId: null, - }, - }); + const emojis = await client.emoji.findMany({ + where: { + instanceId: null, + }, + }); - return jsonResponse( - await Promise.all(emojis.map(emoji => emojiToAPI(emoji))) - ); + return jsonResponse( + await Promise.all(emojis.map((emoji) => emojiToAPI(emoji))), + ); }); diff --git a/server/api/api/v1/favourites/index.ts b/server/api/api/v1/favourites/index.ts index ec673ed4..821b846a 100644 --- a/server/api/api/v1/favourites/index.ts +++ b/server/api/api/v1/favourites/index.ts @@ -1,74 +1,74 @@ -import { errorResponse, jsonResponse } from "@response"; import { apiRoute, applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; -import { statusAndUserRelations } from "~database/entities/relations"; import { statusToAPI } from "~database/entities/Status"; +import { statusAndUserRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/favourites", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - }, + allowedMethods: ["GET"], + route: "/api/v1/favourites", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, }); export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; }>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest; + const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest; - if (limit < 1 || limit > 40) { - return errorResponse("Limit must be between 1 and 40", 400); - } + if (limit < 1 || limit > 40) { + return errorResponse("Limit must be between 1 and 40", 400); + } - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const objects = await client.status.findMany({ - where: { - id: { - lt: max_id ?? undefined, - gte: since_id ?? undefined, - gt: min_id ?? undefined, - }, - likes: { - some: { - likerId: user.id, - }, - }, - }, - include: statusAndUserRelations, - take: limit, - orderBy: { - id: "desc", - }, - }); + const objects = await client.status.findMany({ + where: { + id: { + lt: max_id ?? undefined, + gte: since_id ?? undefined, + gt: min_id ?? undefined, + }, + likes: { + some: { + likerId: user.id, + }, + }, + }, + include: statusAndUserRelations, + take: limit, + orderBy: { + id: "desc", + }, + }); - // Constuct HTTP Link header (next and prev) - const linkHeader = []; - if (objects.length > 0) { - const urlWithoutQuery = req.url.split("?")[0]; - linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, - `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` - ); - } + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, + `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`, + ); + } - return jsonResponse( - await Promise.all( - objects.map(async status => statusToAPI(status, user)) - ), - 200, - { - Link: linkHeader.join(", "), - } - ); + return jsonResponse( + await Promise.all( + objects.map(async (status) => statusToAPI(status, user)), + ), + 200, + { + Link: linkHeader.join(", "), + }, + ); }); diff --git a/server/api/api/v1/follow_requests/[account_id]/authorize.ts b/server/api/api/v1/follow_requests/[account_id]/authorize.ts index f99ef9fc..1ebcf4d4 100644 --- a/server/api/api/v1/follow_requests/[account_id]/authorize.ts +++ b/server/api/api/v1/follow_requests/[account_id]/authorize.ts @@ -1,75 +1,75 @@ -import { errorResponse, jsonResponse } from "@response"; import { apiRoute, applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; import { - checkForBidirectionalRelationships, - relationshipToAPI, + checkForBidirectionalRelationships, + relationshipToAPI, } from "~database/entities/Relationship"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["POST"], - route: "/api/v1/follow_requests/:account_id/authorize", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - }, + allowedMethods: ["POST"], + route: "/api/v1/follow_requests/:account_id/authorize", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, }); export default apiRoute(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const { account_id } = matchedRoute.params; + const { account_id } = matchedRoute.params; - const account = await client.user.findUnique({ - where: { - id: account_id, - }, - include: userRelations, - }); + const account = await client.user.findUnique({ + where: { + id: account_id, + }, + include: userRelations, + }); - if (!account) return errorResponse("Account not found", 404); + if (!account) return errorResponse("Account not found", 404); - // Check if there is a relationship on both sides - await checkForBidirectionalRelationships(user, account); + // Check if there is a relationship on both sides + await checkForBidirectionalRelationships(user, account); - // Authorize follow request - await client.relationship.updateMany({ - where: { - subjectId: user.id, - ownerId: account.id, - requested: true, - }, - data: { - requested: false, - following: true, - }, - }); + // Authorize follow request + await client.relationship.updateMany({ + where: { + subjectId: user.id, + ownerId: account.id, + requested: true, + }, + data: { + requested: false, + following: true, + }, + }); - // Update followedBy for other user - await client.relationship.updateMany({ - where: { - subjectId: account.id, - ownerId: user.id, - }, - data: { - followedBy: true, - }, - }); + // Update followedBy for other user + await client.relationship.updateMany({ + where: { + subjectId: account.id, + ownerId: user.id, + }, + data: { + followedBy: true, + }, + }); - const relationship = await client.relationship.findFirst({ - where: { - subjectId: account.id, - ownerId: user.id, - }, - }); + const relationship = await client.relationship.findFirst({ + where: { + subjectId: account.id, + ownerId: user.id, + }, + }); - if (!relationship) return errorResponse("Relationship not found", 404); + if (!relationship) return errorResponse("Relationship not found", 404); - return jsonResponse(relationshipToAPI(relationship)); + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/follow_requests/[account_id]/reject.ts b/server/api/api/v1/follow_requests/[account_id]/reject.ts index 6f9c6c57..f04ab469 100644 --- a/server/api/api/v1/follow_requests/[account_id]/reject.ts +++ b/server/api/api/v1/follow_requests/[account_id]/reject.ts @@ -1,63 +1,63 @@ -import { errorResponse, jsonResponse } from "@response"; import { apiRoute, applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; import { - checkForBidirectionalRelationships, - relationshipToAPI, + checkForBidirectionalRelationships, + relationshipToAPI, } from "~database/entities/Relationship"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["POST"], - route: "/api/v1/follow_requests/:account_id/reject", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - }, + allowedMethods: ["POST"], + route: "/api/v1/follow_requests/:account_id/reject", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, }); export default apiRoute(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const { account_id } = matchedRoute.params; + const { account_id } = matchedRoute.params; - const account = await client.user.findUnique({ - where: { - id: account_id, - }, - include: userRelations, - }); + const account = await client.user.findUnique({ + where: { + id: account_id, + }, + include: userRelations, + }); - if (!account) return errorResponse("Account not found", 404); + if (!account) return errorResponse("Account not found", 404); - // Check if there is a relationship on both sides - await checkForBidirectionalRelationships(user, account); + // Check if there is a relationship on both sides + await checkForBidirectionalRelationships(user, account); - // Reject follow request - await client.relationship.updateMany({ - where: { - subjectId: user.id, - ownerId: account.id, - requested: true, - }, - data: { - requested: false, - }, - }); + // Reject follow request + await client.relationship.updateMany({ + where: { + subjectId: user.id, + ownerId: account.id, + requested: true, + }, + data: { + requested: false, + }, + }); - const relationship = await client.relationship.findFirst({ - where: { - subjectId: account.id, - ownerId: user.id, - }, - }); + const relationship = await client.relationship.findFirst({ + where: { + subjectId: account.id, + ownerId: user.id, + }, + }); - if (!relationship) return errorResponse("Relationship not found", 404); + if (!relationship) return errorResponse("Relationship not found", 404); - return jsonResponse(relationshipToAPI(relationship)); + return jsonResponse(relationshipToAPI(relationship)); }); diff --git a/server/api/api/v1/follow_requests/index.ts b/server/api/api/v1/follow_requests/index.ts index cc3ab2b2..4916f63f 100644 --- a/server/api/api/v1/follow_requests/index.ts +++ b/server/api/api/v1/follow_requests/index.ts @@ -1,73 +1,73 @@ -import { errorResponse, jsonResponse } from "@response"; -import { userToAPI } from "~database/entities/User"; import { apiRoute, applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; +import { userToAPI } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/follow_requests", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - }, + allowedMethods: ["GET"], + route: "/api/v1/follow_requests", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, }); export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; }>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest; + const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest; - if (limit < 1 || limit > 40) { - return errorResponse("Limit must be between 1 and 40", 400); - } + if (limit < 1 || limit > 40) { + return errorResponse("Limit must be between 1 and 40", 400); + } - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const objects = await client.user.findMany({ - where: { - id: { - lt: max_id ?? undefined, - gte: since_id ?? undefined, - gt: min_id ?? undefined, - }, - relationships: { - some: { - subjectId: user.id, - requested: true, - }, - }, - }, - include: userRelations, - take: limit, - orderBy: { - id: "desc", - }, - }); + const objects = await client.user.findMany({ + where: { + id: { + lt: max_id ?? undefined, + gte: since_id ?? undefined, + gt: min_id ?? undefined, + }, + relationships: { + some: { + subjectId: user.id, + requested: true, + }, + }, + }, + include: userRelations, + take: limit, + orderBy: { + id: "desc", + }, + }); - // Constuct HTTP Link header (next and prev) - const linkHeader = []; - if (objects.length > 0) { - const urlWithoutQuery = req.url.split("?")[0]; - linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, - `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` - ); - } + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, + `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`, + ); + } - return jsonResponse( - objects.map(user => userToAPI(user)), - 200, - { - Link: linkHeader.join(", "), - } - ); + return jsonResponse( + objects.map((user) => userToAPI(user)), + 200, + { + Link: linkHeader.join(", "), + }, + ); }); diff --git a/server/api/api/v1/instance/index.ts b/server/api/api/v1/instance/index.ts index b05a19a9..7dfab0e3 100644 --- a/server/api/api/v1/instance/index.ts +++ b/server/api/api/v1/instance/index.ts @@ -2,157 +2,157 @@ import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; import { client } from "~database/datasource"; import { userToAPI } from "~database/entities/User"; -import type { APIInstance } from "~types/entities/instance"; -import manifest from "~package.json"; import { userRelations } from "~database/entities/relations"; +import manifest from "~package.json"; +import type { APIInstance } from "~types/entities/instance"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/instance", - ratelimits: { - max: 300, - duration: 60, - }, - auth: { - required: false, - }, + allowedMethods: ["GET"], + route: "/api/v1/instance", + ratelimits: { + max: 300, + duration: 60, + }, + auth: { + required: false, + }, }); export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - // Get software version from package.json - const version = manifest.version; + // Get software version from package.json + const version = manifest.version; - const statusCount = await client.status.count({ - where: { - instanceId: null, - }, - }); - const userCount = await client.user.count({ - where: { - instanceId: null, - }, - }); + const statusCount = await client.status.count({ + where: { + instanceId: null, + }, + }); + const userCount = await client.user.count({ + where: { + instanceId: null, + }, + }); - // Get the first created admin user - const contactAccount = await client.user.findFirst({ - where: { - instanceId: null, - isAdmin: true, - }, - orderBy: { - id: "asc", - }, - include: userRelations, - }); + // Get the first created admin user + const contactAccount = await client.user.findFirst({ + where: { + instanceId: null, + isAdmin: true, + }, + orderBy: { + id: "asc", + }, + include: userRelations, + }); - // Get user that have posted once in the last 30 days - const monthlyActiveUsers = await client.user.count({ - where: { - instanceId: null, - statuses: { - some: { - createdAt: { - gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), - }, - }, - }, - }, - }); + // Get user that have posted once in the last 30 days + const monthlyActiveUsers = await client.user.count({ + where: { + instanceId: null, + statuses: { + some: { + createdAt: { + gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + }, + }, + }, + }, + }); - const knownDomainsCount = await client.instance.count(); + const knownDomainsCount = await client.instance.count(); - // TODO: fill in more values - return jsonResponse({ - approval_required: false, - configuration: { - media_attachments: { - image_matrix_limit: config.validation.max_media_attachments, - image_size_limit: config.validation.max_media_size, - supported_mime_types: config.validation.allowed_mime_types, - video_frame_limit: 60, - video_matrix_limit: 10, - video_size_limit: config.validation.max_media_size, - }, - polls: { - max_characters_per_option: - config.validation.max_poll_option_size, - max_expiration: config.validation.max_poll_duration, - max_options: config.validation.max_poll_options, - min_expiration: 60, - }, - statuses: { - characters_reserved_per_url: 0, - max_characters: config.validation.max_note_size, - max_media_attachments: config.validation.max_media_attachments, - supported_mime_types: [ - "text/plain", - "text/markdown", - "text/html", - "text/x.misskeymarkdown", - ], - }, - }, - description: "A test instance", - email: "", - invites_enabled: false, - registrations: config.signups.registration, - languages: ["en"], - rules: config.signups.rules.map((r, index) => ({ - id: String(index), - text: r, - })), - stats: { - domain_count: knownDomainsCount, - status_count: statusCount, - user_count: userCount, - }, - thumbnail: "", - tos_url: config.signups.tos_url, - title: "Test Instance", - uri: new URL(config.http.base_url).hostname, - urls: { - streaming_api: "", - }, - version: `4.2.0+glitch (compatible; Lysand ${version}})`, - max_toot_chars: config.validation.max_note_size, - pleroma: { - metadata: { - // account_activation_required: false, - features: [ - "pleroma_api", - "akkoma_api", - "mastodon_api", - // "mastodon_api_streaming", - // "polls", - // "v2_suggestions", - // "pleroma_explicit_addressing", - // "shareable_emoji_packs", - // "multifetch", - // "pleroma:api/v1/notifications:include_types_filter", - "quote_posting", - "editing", - // "bubble_timeline", - // "relay", - // "pleroma_emoji_reactions", - // "exposable_reactions", - // "profile_directory", - // "custom_emoji_reactions", - // "pleroma:get:main/ostatus", - ], - post_formats: [ - "text/plain", - "text/html", - "text/markdown", - "text/x.misskeymarkdown", - ], - privileged_staff: false, - }, - stats: { - mau: monthlyActiveUsers, - }, - }, - contact_account: contactAccount ? userToAPI(contactAccount) : null, - } as APIInstance); + // TODO: fill in more values + return jsonResponse({ + approval_required: false, + configuration: { + media_attachments: { + image_matrix_limit: config.validation.max_media_attachments, + image_size_limit: config.validation.max_media_size, + supported_mime_types: config.validation.allowed_mime_types, + video_frame_limit: 60, + video_matrix_limit: 10, + video_size_limit: config.validation.max_media_size, + }, + polls: { + max_characters_per_option: + config.validation.max_poll_option_size, + max_expiration: config.validation.max_poll_duration, + max_options: config.validation.max_poll_options, + min_expiration: 60, + }, + statuses: { + characters_reserved_per_url: 0, + max_characters: config.validation.max_note_size, + max_media_attachments: config.validation.max_media_attachments, + supported_mime_types: [ + "text/plain", + "text/markdown", + "text/html", + "text/x.misskeymarkdown", + ], + }, + }, + description: "A test instance", + email: "", + invites_enabled: false, + registrations: config.signups.registration, + languages: ["en"], + rules: config.signups.rules.map((r, index) => ({ + id: String(index), + text: r, + })), + stats: { + domain_count: knownDomainsCount, + status_count: statusCount, + user_count: userCount, + }, + thumbnail: "", + tos_url: config.signups.tos_url, + title: "Test Instance", + uri: new URL(config.http.base_url).hostname, + urls: { + streaming_api: "", + }, + version: `4.2.0+glitch (compatible; Lysand ${version}})`, + max_toot_chars: config.validation.max_note_size, + pleroma: { + metadata: { + // account_activation_required: false, + features: [ + "pleroma_api", + "akkoma_api", + "mastodon_api", + // "mastodon_api_streaming", + // "polls", + // "v2_suggestions", + // "pleroma_explicit_addressing", + // "shareable_emoji_packs", + // "multifetch", + // "pleroma:api/v1/notifications:include_types_filter", + "quote_posting", + "editing", + // "bubble_timeline", + // "relay", + // "pleroma_emoji_reactions", + // "exposable_reactions", + // "profile_directory", + // "custom_emoji_reactions", + // "pleroma:get:main/ostatus", + ], + post_formats: [ + "text/plain", + "text/html", + "text/markdown", + "text/x.misskeymarkdown", + ], + privileged_staff: false, + }, + stats: { + mau: monthlyActiveUsers, + }, + }, + contact_account: contactAccount ? userToAPI(contactAccount) : null, + } as APIInstance); }); diff --git a/server/api/api/v1/media/[id]/index.ts b/server/api/api/v1/media/[id]/index.ts index 8abd1bf0..1f685540 100644 --- a/server/api/api/v1/media/[id]/index.ts +++ b/server/api/api/v1/media/[id]/index.ts @@ -1,109 +1,108 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { client } from "~database/datasource"; -import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import type { MediaBackend } from "media-manager"; import { MediaBackendType } from "media-manager"; +import { client } from "~database/datasource"; +import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import { LocalMediaBackend } from "~packages/media-manager/backends/local"; import { S3MediaBackend } from "~packages/media-manager/backends/s3"; export const meta = applyConfig({ - allowedMethods: ["GET", "PUT"], - ratelimits: { - max: 10, - duration: 60, - }, - route: "/api/v1/media/:id", - auth: { - required: true, - oauthPermissions: ["write:media"], - }, + allowedMethods: ["GET", "PUT"], + ratelimits: { + max: 10, + duration: 60, + }, + route: "/api/v1/media/:id", + auth: { + required: true, + oauthPermissions: ["write:media"], + }, }); /** * Get media information */ export default apiRoute<{ - thumbnail?: File; - description?: string; - focus?: string; + thumbnail?: File; + description?: string; + focus?: string; }>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) { - return errorResponse("Unauthorized", 401); - } + if (!user) { + return errorResponse("Unauthorized", 401); + } - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const attachment = await client.attachment.findUnique({ - where: { - id, - }, - }); + const attachment = await client.attachment.findUnique({ + where: { + id, + }, + }); - if (!attachment) { - return errorResponse("Media not found", 404); - } + if (!attachment) { + return errorResponse("Media not found", 404); + } - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - switch (req.method) { - case "GET": { - if (attachment.url) { - return jsonResponse(attachmentToAPI(attachment)); - } else { - return new Response(null, { - status: 206, - }); - } - } - case "PUT": { - const { description, thumbnail } = extraData.parsedRequest; + switch (req.method) { + case "GET": { + if (attachment.url) { + return jsonResponse(attachmentToAPI(attachment)); + } + return new Response(null, { + status: 206, + }); + } + case "PUT": { + const { description, thumbnail } = extraData.parsedRequest; - let thumbnailUrl = attachment.thumbnail_url; + let thumbnailUrl = attachment.thumbnail_url; - let mediaManager: MediaBackend; + let mediaManager: MediaBackend; - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.LOCAL: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } + switch (config.media.backend as MediaBackendType) { + case MediaBackendType.LOCAL: + mediaManager = new LocalMediaBackend(config); + break; + case MediaBackendType.S3: + mediaManager = new S3MediaBackend(config); + break; + default: + // TODO: Replace with logger + throw new Error("Invalid media backend"); + } - if (thumbnail) { - const { uploadedFile } = await mediaManager.addFile(thumbnail); - thumbnailUrl = getUrl(uploadedFile.name, config); - } + if (thumbnail) { + const { uploadedFile } = await mediaManager.addFile(thumbnail); + thumbnailUrl = getUrl(uploadedFile.name, config); + } - const descriptionText = description || attachment.description; + const descriptionText = description || attachment.description; - if ( - descriptionText !== attachment.description || - thumbnailUrl !== attachment.thumbnail_url - ) { - const newAttachment = await client.attachment.update({ - where: { - id, - }, - data: { - description: descriptionText, - thumbnail_url: thumbnailUrl, - }, - }); + if ( + descriptionText !== attachment.description || + thumbnailUrl !== attachment.thumbnail_url + ) { + const newAttachment = await client.attachment.update({ + where: { + id, + }, + data: { + description: descriptionText, + thumbnail_url: thumbnailUrl, + }, + }); - return jsonResponse(attachmentToAPI(newAttachment)); - } + return jsonResponse(attachmentToAPI(newAttachment)); + } - return jsonResponse(attachmentToAPI(attachment)); - } - } + return jsonResponse(attachmentToAPI(attachment)); + } + } - return errorResponse("Method not allowed", 405); + return errorResponse("Method not allowed", 405); }); diff --git a/server/api/api/v1/media/index.ts b/server/api/api/v1/media/index.ts index 202e5dbb..c8c9bec3 100644 --- a/server/api/api/v1/media/index.ts +++ b/server/api/api/v1/media/index.ts @@ -1,136 +1,136 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { client } from "~database/datasource"; import { encode } from "blurhash"; -import sharp from "sharp"; -import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import { MediaBackendType } from "media-manager"; import type { MediaBackend } from "media-manager"; +import sharp from "sharp"; +import { client } from "~database/datasource"; +import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import { LocalMediaBackend } from "~packages/media-manager/backends/local"; import { S3MediaBackend } from "~packages/media-manager/backends/s3"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 10, - duration: 60, - }, - route: "/api/v1/media", - auth: { - required: true, - oauthPermissions: ["write:media"], - }, + allowedMethods: ["POST"], + ratelimits: { + max: 10, + duration: 60, + }, + route: "/api/v1/media", + auth: { + required: true, + oauthPermissions: ["write:media"], + }, }); /** * Upload new media */ export default apiRoute<{ - file: File; - thumbnail?: File; - description?: string; - // TODO: Add focus - focus?: string; + file: File; + thumbnail?: File; + description?: string; + // TODO: Add focus + focus?: string; }>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) { - return errorResponse("Unauthorized", 401); - } + if (!user) { + return errorResponse("Unauthorized", 401); + } - const { file, thumbnail, description } = extraData.parsedRequest; + const { file, thumbnail, description } = extraData.parsedRequest; - if (!file) { - return errorResponse("No file provided", 400); - } + if (!file) { + return errorResponse("No file provided", 400); + } - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - if (file.size > config.validation.max_media_size) { - return errorResponse( - `File too large, max size is ${config.validation.max_media_size} bytes`, - 413 - ); - } + if (file.size > config.validation.max_media_size) { + return errorResponse( + `File too large, max size is ${config.validation.max_media_size} bytes`, + 413, + ); + } - if ( - config.validation.enforce_mime_types && - !config.validation.allowed_mime_types.includes(file.type) - ) { - return errorResponse("Invalid file type", 415); - } + if ( + config.validation.enforce_mime_types && + !config.validation.allowed_mime_types.includes(file.type) + ) { + return errorResponse("Invalid file type", 415); + } - if ( - description && - description.length > config.validation.max_media_description_size - ) { - return errorResponse( - `Description too long, max length is ${config.validation.max_media_description_size} characters`, - 413 - ); - } + if ( + description && + description.length > config.validation.max_media_description_size + ) { + return errorResponse( + `Description too long, max length is ${config.validation.max_media_description_size} characters`, + 413, + ); + } - const sha256 = new Bun.SHA256(); + const sha256 = new Bun.SHA256(); - const isImage = file.type.startsWith("image/"); + const isImage = file.type.startsWith("image/"); - const metadata = isImage - ? await sharp(await file.arrayBuffer()).metadata() - : null; + const metadata = isImage + ? await sharp(await file.arrayBuffer()).metadata() + : null; - const blurhash = isImage - ? encode( - new Uint8ClampedArray(await file.arrayBuffer()), - metadata?.width ?? 0, - metadata?.height ?? 0, - 4, - 4 - ) - : null; + const blurhash = isImage + ? encode( + new Uint8ClampedArray(await file.arrayBuffer()), + metadata?.width ?? 0, + metadata?.height ?? 0, + 4, + 4, + ) + : null; - let url = ""; + let url = ""; - let mediaManager: MediaBackend; + let mediaManager: MediaBackend; - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.LOCAL: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } + switch (config.media.backend as MediaBackendType) { + case MediaBackendType.LOCAL: + mediaManager = new LocalMediaBackend(config); + break; + case MediaBackendType.S3: + mediaManager = new S3MediaBackend(config); + break; + default: + // TODO: Replace with logger + throw new Error("Invalid media backend"); + } - const { uploadedFile } = await mediaManager.addFile(file); + const { uploadedFile } = await mediaManager.addFile(file); - url = getUrl(uploadedFile.name, config); + url = getUrl(uploadedFile.name, config); - let thumbnailUrl = ""; + let thumbnailUrl = ""; - if (thumbnail) { - const { uploadedFile } = await mediaManager.addFile(thumbnail); + if (thumbnail) { + const { uploadedFile } = await mediaManager.addFile(thumbnail); - thumbnailUrl = getUrl(uploadedFile.name, config); - } + thumbnailUrl = getUrl(uploadedFile.name, config); + } - const newAttachment = await client.attachment.create({ - data: { - url, - thumbnail_url: thumbnailUrl, - sha256: sha256.update(await file.arrayBuffer()).digest("hex"), - mime_type: file.type, - description: description ?? "", - size: file.size, - blurhash: blurhash ?? undefined, - width: metadata?.width ?? undefined, - height: metadata?.height ?? undefined, - }, - }); + const newAttachment = await client.attachment.create({ + data: { + url, + thumbnail_url: thumbnailUrl, + sha256: sha256.update(await file.arrayBuffer()).digest("hex"), + mime_type: file.type, + description: description ?? "", + size: file.size, + blurhash: blurhash ?? undefined, + width: metadata?.width ?? undefined, + height: metadata?.height ?? undefined, + }, + }); - // TODO: Add job to process videos and other media + // TODO: Add job to process videos and other media - return jsonResponse(attachmentToAPI(newAttachment)); + return jsonResponse(attachmentToAPI(newAttachment)); }); diff --git a/server/api/api/v1/mutes/index.ts b/server/api/api/v1/mutes/index.ts index b3ce6538..7d6bd590 100644 --- a/server/api/api/v1/mutes/index.ts +++ b/server/api/api/v1/mutes/index.ts @@ -1,37 +1,37 @@ -import { errorResponse, jsonResponse } from "@response"; -import { userToAPI } from "~database/entities/User"; import { apiRoute, applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; +import { userToAPI } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/mutes", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - }, + allowedMethods: ["GET"], + route: "/api/v1/mutes", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, }); export default apiRoute(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const blocks = await client.user.findMany({ - where: { - relationshipSubjects: { - some: { - ownerId: user.id, - muting: true, - }, - }, - }, - include: userRelations, - }); + const blocks = await client.user.findMany({ + where: { + relationshipSubjects: { + some: { + ownerId: user.id, + muting: true, + }, + }, + }, + include: userRelations, + }); - return jsonResponse(blocks.map(u => userToAPI(u))); + return jsonResponse(blocks.map((u) => userToAPI(u))); }); diff --git a/server/api/api/v1/notifications/index.ts b/server/api/api/v1/notifications/index.ts index de6aac44..aa535ca3 100644 --- a/server/api/api/v1/notifications/index.ts +++ b/server/api/api/v1/notifications/index.ts @@ -1,102 +1,102 @@ -import { errorResponse, jsonResponse } from "@response"; import { apiRoute, applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; import { notificationToAPI } from "~database/entities/Notification"; import { - userRelations, - statusAndUserRelations, + statusAndUserRelations, + userRelations, } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/notifications", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - }, + allowedMethods: ["GET"], + route: "/api/v1/notifications", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, }); export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; - exclude_types?: string[]; - types?: string[]; - account_id?: string; + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; + exclude_types?: string[]; + types?: string[]; + account_id?: string; }>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const { - account_id, - exclude_types, - limit = 15, - max_id, - min_id, - since_id, - types, - } = extraData.parsedRequest; + const { + account_id, + exclude_types, + limit = 15, + max_id, + min_id, + since_id, + types, + } = extraData.parsedRequest; - if (limit > 30) return errorResponse("Limit too high", 400); + if (limit > 30) return errorResponse("Limit too high", 400); - if (limit <= 0) return errorResponse("Limit too low", 400); + if (limit <= 0) return errorResponse("Limit too low", 400); - if (types && exclude_types) { - return errorResponse("Can't use both types and exclude_types", 400); - } + if (types && exclude_types) { + return errorResponse("Can't use both types and exclude_types", 400); + } - const objects = await client.notification.findMany({ - where: { - notifiedId: user.id, - id: { - lt: max_id, - gt: min_id, - gte: since_id, - }, - type: { - in: types, - notIn: exclude_types, - }, - accountId: account_id, - }, - include: { - account: { - include: userRelations, - }, - status: { - include: statusAndUserRelations, - }, - }, - orderBy: { - id: "desc", - }, - take: limit, - }); + const objects = await client.notification.findMany({ + where: { + notifiedId: user.id, + id: { + lt: max_id, + gt: min_id, + gte: since_id, + }, + type: { + in: types, + notIn: exclude_types, + }, + accountId: account_id, + }, + include: { + account: { + include: userRelations, + }, + status: { + include: statusAndUserRelations, + }, + }, + orderBy: { + id: "desc", + }, + take: limit, + }); - // Constuct HTTP Link header (next and prev) - const linkHeader = []; - if (objects.length > 0) { - const urlWithoutQuery = req.url.split("?")[0]; - linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` - ); - linkHeader.push( - `<${urlWithoutQuery}?since_id=${ - objects.at(-1)?.id - }&limit=${limit}>; rel="prev"` - ); - } + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"`, + ); + linkHeader.push( + `<${urlWithoutQuery}?since_id=${ + objects.at(-1)?.id + }&limit=${limit}>; rel="prev"`, + ); + } - return jsonResponse( - await Promise.all(objects.map(n => notificationToAPI(n))), - 200, - { - Link: linkHeader.join(", "), - } - ); + return jsonResponse( + await Promise.all(objects.map((n) => notificationToAPI(n))), + 200, + { + Link: linkHeader.join(", "), + }, + ); }); diff --git a/server/api/api/v1/profile/avatar.ts b/server/api/api/v1/profile/avatar.ts index 5810647d..d3e8827c 100644 --- a/server/api/api/v1/profile/avatar.ts +++ b/server/api/api/v1/profile/avatar.ts @@ -5,35 +5,35 @@ import { userToAPI } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["DELETE"], - ratelimits: { - max: 10, - duration: 60, - }, - route: "/api/v1/profile/avatar", - auth: { - required: true, - }, + allowedMethods: ["DELETE"], + ratelimits: { + max: 10, + duration: 60, + }, + route: "/api/v1/profile/avatar", + auth: { + required: true, + }, }); /** * Deletes a user avatar */ export default apiRoute(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - // Delete user avatar - const newUser = await client.user.update({ - where: { - id: user.id, - }, - data: { - avatar: "", - }, - include: userRelations, - }); + // Delete user avatar + const newUser = await client.user.update({ + where: { + id: user.id, + }, + data: { + avatar: "", + }, + include: userRelations, + }); - return jsonResponse(userToAPI(newUser)); + return jsonResponse(userToAPI(newUser)); }); diff --git a/server/api/api/v1/profile/header.ts b/server/api/api/v1/profile/header.ts index 65aea5e8..fbfdb3cd 100644 --- a/server/api/api/v1/profile/header.ts +++ b/server/api/api/v1/profile/header.ts @@ -5,35 +5,35 @@ import { userToAPI } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["DELETE"], - ratelimits: { - max: 10, - duration: 60, - }, - route: "/api/v1/profile/header", - auth: { - required: true, - }, + allowedMethods: ["DELETE"], + ratelimits: { + max: 10, + duration: 60, + }, + route: "/api/v1/profile/header", + auth: { + required: true, + }, }); /** * Deletes a user header */ export default apiRoute(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - // Delete user header - const newUser = await client.user.update({ - where: { - id: user.id, - }, - data: { - header: "", - }, - include: userRelations, - }); + // Delete user header + const newUser = await client.user.update({ + where: { + id: user.id, + }, + data: { + header: "", + }, + include: userRelations, + }); - return jsonResponse(userToAPI(newUser)); + return jsonResponse(userToAPI(newUser)); }); diff --git a/server/api/api/v1/statuses/[id]/context.ts b/server/api/api/v1/statuses/[id]/context.ts index 7a0e4f85..0c0fff7d 100644 --- a/server/api/api/v1/statuses/[id]/context.ts +++ b/server/api/api/v1/statuses/[id]/context.ts @@ -2,51 +2,51 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; import { - getAncestors, - getDescendants, - statusToAPI, + getAncestors, + getDescendants, + statusToAPI, } from "~database/entities/Status"; import { statusAndUserRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 8, - duration: 60, - }, - route: "/api/v1/statuses/:id/context", - auth: { - required: false, - }, + allowedMethods: ["GET"], + ratelimits: { + max: 8, + duration: 60, + }, + route: "/api/v1/statuses/:id/context", + auth: { + required: false, + }, }); /** * Fetch a user */ export default apiRoute(async (req, matchedRoute, extraData) => { - // Public for public statuses limited to 40 ancestors and 60 descendants with a maximum depth of 20. - // User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses. - const id = matchedRoute.params.id; + // Public for public statuses limited to 40 ancestors and 60 descendants with a maximum depth of 20. + // User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses. + const id = matchedRoute.params.id; - const { user } = extraData.auth; + const { user } = extraData.auth; - const foundStatus = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + const foundStatus = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - if (!foundStatus) return errorResponse("Record not found", 404); + if (!foundStatus) return errorResponse("Record not found", 404); - // Get all ancestors - const ancestors = await getAncestors(foundStatus, user); - const descendants = await getDescendants(foundStatus, user); + // Get all ancestors + const ancestors = await getAncestors(foundStatus, user); + const descendants = await getDescendants(foundStatus, user); - return jsonResponse({ - ancestors: await Promise.all( - ancestors.map(status => statusToAPI(status, user || undefined)) - ), - descendants: await Promise.all( - descendants.map(status => statusToAPI(status, user || undefined)) - ), - }); + return jsonResponse({ + ancestors: await Promise.all( + ancestors.map((status) => statusToAPI(status, user || undefined)), + ), + descendants: await Promise.all( + descendants.map((status) => statusToAPI(status, user || undefined)), + ), + }); }); diff --git a/server/api/api/v1/statuses/[id]/favourite.ts b/server/api/api/v1/statuses/[id]/favourite.ts index 9056c414..51838305 100644 --- a/server/api/api/v1/statuses/[id]/favourite.ts +++ b/server/api/api/v1/statuses/[id]/favourite.ts @@ -8,50 +8,50 @@ import { statusAndUserRelations } from "~database/entities/relations"; import type { APIStatus } from "~types/entities/status"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/favourite", - auth: { - required: true, - }, + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/favourite", + auth: { + required: true, + }, }); /** * Favourite a post */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const status = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) - return errorResponse("Record not found", 404); + // Check if user is authorized to view this status (if it's private) + if (!status || !isViewableByUser(status, user)) + return errorResponse("Record not found", 404); - const existingLike = await client.like.findFirst({ - where: { - likedId: status.id, - likerId: user.id, - }, - }); + const existingLike = await client.like.findFirst({ + where: { + likedId: status.id, + likerId: user.id, + }, + }); - if (!existingLike) { - await createLike(user, status); - } + if (!existingLike) { + await createLike(user, status); + } - return jsonResponse({ - ...(await statusToAPI(status, user)), - favourited: true, - favourites_count: status._count.likes + 1, - } as APIStatus); + return jsonResponse({ + ...(await statusToAPI(status, user)), + favourited: true, + favourites_count: status._count.likes + 1, + } as APIStatus); }); diff --git a/server/api/api/v1/statuses/[id]/favourited_by.ts b/server/api/api/v1/statuses/[id]/favourited_by.ts index 81ec2895..980f1eda 100644 --- a/server/api/api/v1/statuses/[id]/favourited_by.ts +++ b/server/api/api/v1/statuses/[id]/favourited_by.ts @@ -4,101 +4,101 @@ import { client } from "~database/datasource"; import { isViewableByUser } from "~database/entities/Status"; import { userToAPI } from "~database/entities/User"; import { - statusAndUserRelations, - userRelations, + statusAndUserRelations, + userRelations, } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/favourited_by", - auth: { - required: true, - }, + allowedMethods: ["GET"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/favourited_by", + auth: { + required: true, + }, }); /** * Fetch users who favourited the post */ export default apiRoute<{ - max_id?: string; - min_id?: string; - since_id?: string; - limit?: number; + max_id?: string; + min_id?: string; + since_id?: string; + limit?: number; }>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user } = extraData.auth; + const { user } = extraData.auth; - const status = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) - return errorResponse("Record not found", 404); + // Check if user is authorized to view this status (if it's private) + if (!status || !isViewableByUser(status, user)) + return errorResponse("Record not found", 404); - const { - max_id = null, - min_id = null, - since_id = null, - limit = 40, - } = extraData.parsedRequest; + const { + max_id = null, + min_id = null, + since_id = null, + limit = 40, + } = extraData.parsedRequest; - // Check for limit limits - if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); - if (limit < 1) return errorResponse("Invalid limit", 400); + // Check for limit limits + if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); + if (limit < 1) return errorResponse("Invalid limit", 400); - const objects = await client.user.findMany({ - where: { - likes: { - some: { - likedId: status.id, - }, - }, - id: { - lt: max_id ?? undefined, - gte: since_id ?? undefined, - gt: min_id ?? undefined, - }, - }, - include: { - ...userRelations, - likes: { - where: { - likedId: status.id, - }, - }, - }, - take: limit, - orderBy: { - id: "desc", - }, - }); + const objects = await client.user.findMany({ + where: { + likes: { + some: { + likedId: status.id, + }, + }, + id: { + lt: max_id ?? undefined, + gte: since_id ?? undefined, + gt: min_id ?? undefined, + }, + }, + include: { + ...userRelations, + likes: { + where: { + likedId: status.id, + }, + }, + }, + take: limit, + orderBy: { + id: "desc", + }, + }); - // Constuct HTTP Link header (next and prev) - const linkHeader = []; - if (objects.length > 0) { - const urlWithoutQuery = req.url.split("?")[0]; - linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` - ); - linkHeader.push( - `<${urlWithoutQuery}?since_id=${ - objects[objects.length - 1].id - }&limit=${limit}>; rel="prev"` - ); - } + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"`, + ); + linkHeader.push( + `<${urlWithoutQuery}?since_id=${ + objects[objects.length - 1].id + }&limit=${limit}>; rel="prev"`, + ); + } - return jsonResponse( - objects.map(user => userToAPI(user)), - 200, - { - Link: linkHeader.join(", "), - } - ); + return jsonResponse( + objects.map((user) => userToAPI(user)), + 200, + { + Link: linkHeader.join(", "), + }, + ); }); diff --git a/server/api/api/v1/statuses/[id]/index.ts b/server/api/api/v1/statuses/[id]/index.ts index 39810f2c..a0f6c476 100644 --- a/server/api/api/v1/statuses/[id]/index.ts +++ b/server/api/api/v1/statuses/[id]/index.ts @@ -4,215 +4,217 @@ import { sanitizeHtml } from "@sanitization"; import { parse } from "marked"; import { client } from "~database/datasource"; import { - editStatus, - isViewableByUser, - statusToAPI, + editStatus, + isViewableByUser, + statusToAPI, } from "~database/entities/Status"; import { statusAndUserRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET", "DELETE", "PUT"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id", - auth: { - required: false, - requiredOnMethods: ["DELETE", "PUT"], - }, + allowedMethods: ["GET", "DELETE", "PUT"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id", + auth: { + required: false, + requiredOnMethods: ["DELETE", "PUT"], + }, }); /** * Fetch a user */ export default apiRoute<{ - status?: string; - spoiler_text?: string; - sensitive?: boolean; - language?: string; - content_type?: string; - media_ids?: string[]; - "poll[options]"?: string[]; - "poll[expires_in]"?: number; - "poll[multiple]"?: boolean; - "poll[hide_totals]"?: boolean; + status?: string; + spoiler_text?: string; + sensitive?: boolean; + language?: string; + content_type?: string; + media_ids?: string[]; + "poll[options]"?: string[]; + "poll[expires_in]"?: number; + "poll[multiple]"?: boolean; + "poll[hide_totals]"?: boolean; }>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user } = extraData.auth; + const { user } = extraData.auth; - const status = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) - return errorResponse("Record not found", 404); + // Check if user is authorized to view this status (if it's private) + if (!status || !isViewableByUser(status, user)) + return errorResponse("Record not found", 404); - if (req.method === "GET") { - return jsonResponse(await statusToAPI(status)); - } else if (req.method === "DELETE") { - if (status.authorId !== user?.id) { - return errorResponse("Unauthorized", 401); - } + if (req.method === "GET") { + return jsonResponse(await statusToAPI(status)); + } + if (req.method === "DELETE") { + if (status.authorId !== user?.id) { + return errorResponse("Unauthorized", 401); + } - // TODO: Implement delete and redraft functionality + // TODO: Implement delete and redraft functionality - // Get associated Status object + // Get associated Status object - // Delete status and all associated objects - await client.status.delete({ - where: { id }, - }); + // Delete status and all associated objects + await client.status.delete({ + where: { id }, + }); - return jsonResponse( - { - ...(await statusToAPI(status, user)), - // TODO: Add - // text: Add source text - // poll: Add source poll - // media_attachments - }, - 200 - ); - } else if (req.method == "PUT") { - if (status.authorId !== user?.id) { - return errorResponse("Unauthorized", 401); - } + return jsonResponse( + { + ...(await statusToAPI(status, user)), + // TODO: Add + // text: Add source text + // poll: Add source poll + // media_attachments + }, + 200, + ); + } + if (req.method === "PUT") { + if (status.authorId !== user?.id) { + return errorResponse("Unauthorized", 401); + } - const { - status: statusText, - content_type, - "poll[expires_in]": expires_in, - "poll[options]": options, - media_ids, - spoiler_text, - sensitive, - } = extraData.parsedRequest; + const { + status: statusText, + content_type, + "poll[expires_in]": expires_in, + "poll[options]": options, + media_ids, + spoiler_text, + sensitive, + } = extraData.parsedRequest; - // TODO: Add Poll support - // Validate status - if (!statusText && !(media_ids && media_ids.length > 0)) { - return errorResponse( - "Status is required unless media is attached", - 422 - ); - } + // TODO: Add Poll support + // Validate status + if (!statusText && !(media_ids && media_ids.length > 0)) { + return errorResponse( + "Status is required unless media is attached", + 422, + ); + } - // Validate media_ids - if (media_ids && !Array.isArray(media_ids)) { - return errorResponse("Media IDs must be an array", 422); - } + // Validate media_ids + if (media_ids && !Array.isArray(media_ids)) { + return errorResponse("Media IDs must be an array", 422); + } - // Validate poll options - if (options && !Array.isArray(options)) { - return errorResponse("Poll options must be an array", 422); - } + // Validate poll options + if (options && !Array.isArray(options)) { + return errorResponse("Poll options must be an array", 422); + } - if (options && options.length > 4) { - return errorResponse("Poll options must be less than 5", 422); - } + if (options && options.length > 4) { + return errorResponse("Poll options must be less than 5", 422); + } - if (media_ids && media_ids.length > 0) { - // Disallow poll - if (options) { - return errorResponse("Cannot attach poll to media", 422); - } - if (media_ids.length > 4) { - return errorResponse("Media IDs must be less than 5", 422); - } - } + if (media_ids && media_ids.length > 0) { + // Disallow poll + if (options) { + return errorResponse("Cannot attach poll to media", 422); + } + if (media_ids.length > 4) { + return errorResponse("Media IDs must be less than 5", 422); + } + } - if (options && options.length > config.validation.max_poll_options) { - return errorResponse( - `Poll options must be less than ${config.validation.max_poll_options}`, - 422 - ); - } + if (options && options.length > config.validation.max_poll_options) { + return errorResponse( + `Poll options must be less than ${config.validation.max_poll_options}`, + 422, + ); + } - if ( - options && - options.some( - option => option.length > config.validation.max_poll_option_size - ) - ) { - return errorResponse( - `Poll options must be less than ${config.validation.max_poll_option_size} characters`, - 422 - ); - } + if ( + options?.some( + (option) => + option.length > config.validation.max_poll_option_size, + ) + ) { + return errorResponse( + `Poll options must be less than ${config.validation.max_poll_option_size} characters`, + 422, + ); + } - if (expires_in && expires_in < config.validation.min_poll_duration) { - return errorResponse( - `Poll duration must be greater than ${config.validation.min_poll_duration} seconds`, - 422 - ); - } + if (expires_in && expires_in < config.validation.min_poll_duration) { + return errorResponse( + `Poll duration must be greater than ${config.validation.min_poll_duration} seconds`, + 422, + ); + } - if (expires_in && expires_in > config.validation.max_poll_duration) { - return errorResponse( - `Poll duration must be less than ${config.validation.max_poll_duration} seconds`, - 422 - ); - } + if (expires_in && expires_in > config.validation.max_poll_duration) { + return errorResponse( + `Poll duration must be less than ${config.validation.max_poll_duration} seconds`, + 422, + ); + } - let sanitizedStatus: string; + let sanitizedStatus: string; - if (content_type === "text/markdown") { - sanitizedStatus = await sanitizeHtml(await parse(statusText ?? "")); - } else if (content_type === "text/x.misskeymarkdown") { - // Parse as MFM - // TODO: Parse as MFM - sanitizedStatus = await sanitizeHtml(await parse(statusText ?? "")); - } else { - sanitizedStatus = await sanitizeHtml(statusText ?? ""); - } + if (content_type === "text/markdown") { + sanitizedStatus = await sanitizeHtml(await parse(statusText ?? "")); + } else if (content_type === "text/x.misskeymarkdown") { + // Parse as MFM + // TODO: Parse as MFM + sanitizedStatus = await sanitizeHtml(await parse(statusText ?? "")); + } else { + sanitizedStatus = await sanitizeHtml(statusText ?? ""); + } - if (sanitizedStatus.length > config.validation.max_note_size) { - return errorResponse( - `Status must be less than ${config.validation.max_note_size} characters`, - 400 - ); - } + if (sanitizedStatus.length > config.validation.max_note_size) { + return errorResponse( + `Status must be less than ${config.validation.max_note_size} characters`, + 400, + ); + } - // Check if status body doesnt match filters - if ( - config.filters.note_content.some(filter => - statusText?.match(filter) - ) - ) { - return errorResponse("Status contains blocked words", 422); - } + // Check if status body doesnt match filters + if ( + config.filters.note_content.some((filter) => + statusText?.match(filter), + ) + ) { + return errorResponse("Status contains blocked words", 422); + } - // Check if media attachments are all valid + // Check if media attachments are all valid - const foundAttachments = await client.attachment.findMany({ - where: { - id: { - in: media_ids ?? [], - }, - }, - }); + const foundAttachments = await client.attachment.findMany({ + where: { + id: { + in: media_ids ?? [], + }, + }, + }); - if (foundAttachments.length !== (media_ids ?? []).length) { - return errorResponse("Invalid media IDs", 422); - } + if (foundAttachments.length !== (media_ids ?? []).length) { + return errorResponse("Invalid media IDs", 422); + } - // Update status - const newStatus = await editStatus(status, { - content: sanitizedStatus, - content_type, - media_attachments: media_ids, - spoiler_text: spoiler_text ?? "", - sensitive: sensitive ?? false, - }); + // Update status + const newStatus = await editStatus(status, { + content: sanitizedStatus, + content_type, + media_attachments: media_ids, + spoiler_text: spoiler_text ?? "", + sensitive: sensitive ?? false, + }); - return jsonResponse(await statusToAPI(newStatus, user)); - } + return jsonResponse(await statusToAPI(newStatus, user)); + } - return jsonResponse({}); + return jsonResponse({}); }); diff --git a/server/api/api/v1/statuses/[id]/pin.ts b/server/api/api/v1/statuses/[id]/pin.ts index 2ce107be..481ec6f8 100644 --- a/server/api/api/v1/statuses/[id]/pin.ts +++ b/server/api/api/v1/statuses/[id]/pin.ts @@ -6,55 +6,55 @@ import { statusToAPI } from "~database/entities/Status"; import { statusAndUserRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/pin", - auth: { - required: true, - }, + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/pin", + auth: { + required: true, + }, }); /** * Pin a post */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - let status = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + let status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - // Check if status exists - if (!status) return errorResponse("Record not found", 404); + // Check if status exists + if (!status) return errorResponse("Record not found", 404); - // Check if status is user's - if (status.authorId !== user.id) return errorResponse("Unauthorized", 401); + // Check if status is user's + if (status.authorId !== user.id) return errorResponse("Unauthorized", 401); - await client.user.update({ - where: { id: user.id }, - data: { - pinnedNotes: { - connect: { - id: status.id, - }, - }, - }, - }); + await client.user.update({ + where: { id: user.id }, + data: { + pinnedNotes: { + connect: { + id: status.id, + }, + }, + }, + }); - status = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - if (!status) return errorResponse("Record not found", 404); + if (!status) return errorResponse("Record not found", 404); - return jsonResponse(statusToAPI(status, user)); + return jsonResponse(statusToAPI(status, user)); }); diff --git a/server/api/api/v1/statuses/[id]/reblog.ts b/server/api/api/v1/statuses/[id]/reblog.ts index ae011812..e4565428 100644 --- a/server/api/api/v1/statuses/[id]/reblog.ts +++ b/server/api/api/v1/statuses/[id]/reblog.ts @@ -3,95 +3,95 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; import { isViewableByUser, statusToAPI } from "~database/entities/Status"; -import { type UserWithRelations } from "~database/entities/User"; +import type { UserWithRelations } from "~database/entities/User"; import { statusAndUserRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/reblog", - auth: { - required: true, - }, + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/reblog", + auth: { + required: true, + }, }); /** * Reblogs a post */ export default apiRoute<{ - visibility: "public" | "unlisted" | "private"; + visibility: "public" | "unlisted" | "private"; }>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - const config = await extraData.configManager.getConfig(); + const id = matchedRoute.params.id; + const config = await extraData.configManager.getConfig(); - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const { visibility = "public" } = extraData.parsedRequest; + const { visibility = "public" } = extraData.parsedRequest; - const status = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) - return errorResponse("Record not found", 404); + // Check if user is authorized to view this status (if it's private) + if (!status || !isViewableByUser(status, user)) + return errorResponse("Record not found", 404); - const existingReblog = await client.status.findFirst({ - where: { - authorId: user.id, - reblogId: status.id, - }, - }); + const existingReblog = await client.status.findFirst({ + where: { + authorId: user.id, + reblogId: status.id, + }, + }); - if (existingReblog) { - return errorResponse("Already reblogged", 422); - } + if (existingReblog) { + return errorResponse("Already reblogged", 422); + } - const newReblog = await client.status.create({ - data: { - authorId: user.id, - reblogId: status.id, - isReblog: true, - uri: `${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`, - visibility, - sensitive: false, - }, - include: statusAndUserRelations, - }); + const newReblog = await client.status.create({ + data: { + authorId: user.id, + reblogId: status.id, + isReblog: true, + uri: `${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`, + visibility, + sensitive: false, + }, + include: statusAndUserRelations, + }); - await client.status.update({ - where: { id: newReblog.id }, - data: { - uri: `${config.http.base_url}/statuses/${newReblog.id}`, - }, - include: statusAndUserRelations, - }); + await client.status.update({ + where: { id: newReblog.id }, + data: { + uri: `${config.http.base_url}/statuses/${newReblog.id}`, + }, + include: statusAndUserRelations, + }); - // Create notification for reblog if reblogged user is on the same instance - if ((status.author as UserWithRelations).instanceId === user.instanceId) { - await client.notification.create({ - data: { - accountId: user.id, - notifiedId: status.authorId, - type: "reblog", - statusId: status.reblogId, - }, - }); - } + // Create notification for reblog if reblogged user is on the same instance + if ((status.author as UserWithRelations).instanceId === user.instanceId) { + await client.notification.create({ + data: { + accountId: user.id, + notifiedId: status.authorId, + type: "reblog", + statusId: status.reblogId, + }, + }); + } - return jsonResponse( - await statusToAPI( - { - ...newReblog, - uri: `${config.http.base_url}/statuses/${newReblog.id}`, - }, - user - ) - ); + return jsonResponse( + await statusToAPI( + { + ...newReblog, + uri: `${config.http.base_url}/statuses/${newReblog.id}`, + }, + user, + ), + ); }); diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.ts b/server/api/api/v1/statuses/[id]/reblogged_by.ts index de3564c2..d132fe28 100644 --- a/server/api/api/v1/statuses/[id]/reblogged_by.ts +++ b/server/api/api/v1/statuses/[id]/reblogged_by.ts @@ -4,102 +4,102 @@ import { client } from "~database/datasource"; import { isViewableByUser } from "~database/entities/Status"; import { userToAPI } from "~database/entities/User"; import { - statusAndUserRelations, - userRelations, + statusAndUserRelations, + userRelations, } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/reblogged_by", - auth: { - required: true, - }, + allowedMethods: ["GET"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/reblogged_by", + auth: { + required: true, + }, }); /** * Fetch users who reblogged the post */ export default apiRoute<{ - max_id?: string; - min_id?: string; - since_id?: string; - limit?: number; + max_id?: string; + min_id?: string; + since_id?: string; + limit?: number; }>(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user } = extraData.auth; + const { user } = extraData.auth; - const status = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) - return errorResponse("Record not found", 404); + // Check if user is authorized to view this status (if it's private) + if (!status || !isViewableByUser(status, user)) + return errorResponse("Record not found", 404); - const { - max_id = null, - min_id = null, - since_id = null, - limit = 40, - } = extraData.parsedRequest; + const { + max_id = null, + min_id = null, + since_id = null, + limit = 40, + } = extraData.parsedRequest; - // Check for limit limits - if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); - if (limit < 1) return errorResponse("Invalid limit", 400); + // Check for limit limits + if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); + if (limit < 1) return errorResponse("Invalid limit", 400); - const objects = await client.user.findMany({ - where: { - statuses: { - some: { - reblogId: status.id, - }, - }, - id: { - lt: max_id ?? undefined, - gte: since_id ?? undefined, - gt: min_id ?? undefined, - }, - }, - include: { - ...userRelations, - statuses: { - where: { - reblogId: status.id, - }, - include: statusAndUserRelations, - }, - }, - take: limit, - orderBy: { - id: "desc", - }, - }); + const objects = await client.user.findMany({ + where: { + statuses: { + some: { + reblogId: status.id, + }, + }, + id: { + lt: max_id ?? undefined, + gte: since_id ?? undefined, + gt: min_id ?? undefined, + }, + }, + include: { + ...userRelations, + statuses: { + where: { + reblogId: status.id, + }, + include: statusAndUserRelations, + }, + }, + take: limit, + orderBy: { + id: "desc", + }, + }); - // Constuct HTTP Link header (next and prev) - const linkHeader = []; - if (objects.length > 0) { - const urlWithoutQuery = req.url.split("?")[0]; - linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` - ); - linkHeader.push( - `<${urlWithoutQuery}?since_id=${ - objects[objects.length - 1].id - }&limit=${limit}>; rel="prev"` - ); - } + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"`, + ); + linkHeader.push( + `<${urlWithoutQuery}?since_id=${ + objects[objects.length - 1].id + }&limit=${limit}>; rel="prev"`, + ); + } - return jsonResponse( - objects.map(user => userToAPI(user)), - 200, - { - Link: linkHeader.join(", "), - } - ); + return jsonResponse( + objects.map((user) => userToAPI(user)), + 200, + { + Link: linkHeader.join(", "), + }, + ); }); diff --git a/server/api/api/v1/statuses/[id]/source.ts b/server/api/api/v1/statuses/[id]/source.ts index a4f3ebdc..418be9ca 100644 --- a/server/api/api/v1/statuses/[id]/source.ts +++ b/server/api/api/v1/statuses/[id]/source.ts @@ -5,35 +5,35 @@ import { isViewableByUser } from "~database/entities/Status"; import { statusAndUserRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/source", - auth: { - required: true, - }, + allowedMethods: ["GET"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/source", + auth: { + required: true, + }, }); /** * Favourite a post */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const status = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) - return errorResponse("Record not found", 404); + // Check if user is authorized to view this status (if it's private) + if (!status || !isViewableByUser(status, user)) + return errorResponse("Record not found", 404); - return errorResponse("Not implemented yet"); + return errorResponse("Not implemented yet"); }); diff --git a/server/api/api/v1/statuses/[id]/unfavourite.ts b/server/api/api/v1/statuses/[id]/unfavourite.ts index a158a807..718cfc1e 100644 --- a/server/api/api/v1/statuses/[id]/unfavourite.ts +++ b/server/api/api/v1/statuses/[id]/unfavourite.ts @@ -8,41 +8,41 @@ import { statusAndUserRelations } from "~database/entities/relations"; import type { APIStatus } from "~types/entities/status"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/unfavourite", - auth: { - required: true, - }, + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/unfavourite", + auth: { + required: true, + }, }); /** * Unfavourite a post */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const status = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) - return errorResponse("Record not found", 404); + // Check if user is authorized to view this status (if it's private) + if (!status || !isViewableByUser(status, user)) + return errorResponse("Record not found", 404); - await deleteLike(user, status); + await deleteLike(user, status); - return jsonResponse({ - ...(await statusToAPI(status, user)), - favourited: false, - favourites_count: status._count.likes - 1, - } as APIStatus); + return jsonResponse({ + ...(await statusToAPI(status, user)), + favourited: false, + favourites_count: status._count.likes - 1, + } as APIStatus); }); diff --git a/server/api/api/v1/statuses/[id]/unpin.ts b/server/api/api/v1/statuses/[id]/unpin.ts index 42353077..074cfac1 100644 --- a/server/api/api/v1/statuses/[id]/unpin.ts +++ b/server/api/api/v1/statuses/[id]/unpin.ts @@ -5,55 +5,55 @@ import { statusToAPI } from "~database/entities/Status"; import { statusAndUserRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/unpin", - auth: { - required: true, - }, + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/unpin", + auth: { + required: true, + }, }); /** * Unpins a post */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - let status = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + let status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - // Check if status exists - if (!status) return errorResponse("Record not found", 404); + // Check if status exists + if (!status) return errorResponse("Record not found", 404); - // Check if status is user's - if (status.authorId !== user.id) return errorResponse("Unauthorized", 401); + // Check if status is user's + if (status.authorId !== user.id) return errorResponse("Unauthorized", 401); - await client.user.update({ - where: { id: user.id }, - data: { - pinnedNotes: { - disconnect: { - id: status.id, - }, - }, - }, - }); + await client.user.update({ + where: { id: user.id }, + data: { + pinnedNotes: { + disconnect: { + id: status.id, + }, + }, + }, + }); - status = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - if (!status) return errorResponse("Record not found", 404); + if (!status) return errorResponse("Record not found", 404); - return jsonResponse(statusToAPI(status, user)); + return jsonResponse(statusToAPI(status, user)); }); diff --git a/server/api/api/v1/statuses/[id]/unreblog.ts b/server/api/api/v1/statuses/[id]/unreblog.ts index 9a294484..86ae6d46 100644 --- a/server/api/api/v1/statuses/[id]/unreblog.ts +++ b/server/api/api/v1/statuses/[id]/unreblog.ts @@ -6,54 +6,54 @@ import { statusAndUserRelations } from "~database/entities/relations"; import type { APIStatus } from "~types/entities/status"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/unreblog", - auth: { - required: true, - }, + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/unreblog", + auth: { + required: true, + }, }); /** * Unreblogs a post */ export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const status = await client.status.findUnique({ - where: { id }, - include: statusAndUserRelations, - }); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); - // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) - return errorResponse("Record not found", 404); + // Check if user is authorized to view this status (if it's private) + if (!status || !isViewableByUser(status, user)) + return errorResponse("Record not found", 404); - const existingReblog = await client.status.findFirst({ - where: { - authorId: user.id, - reblogId: status.id, - }, - }); + const existingReblog = await client.status.findFirst({ + where: { + authorId: user.id, + reblogId: status.id, + }, + }); - if (!existingReblog) { - return errorResponse("Not already reblogged", 422); - } + if (!existingReblog) { + return errorResponse("Not already reblogged", 422); + } - await client.status.delete({ - where: { id: existingReblog.id }, - }); + await client.status.delete({ + where: { id: existingReblog.id }, + }); - return jsonResponse({ - ...(await statusToAPI(status, user)), - reblogged: false, - reblogs_count: status._count.reblogs - 1, - } as APIStatus); + return jsonResponse({ + ...(await statusToAPI(status, user)), + reblogged: false, + reblogs_count: status._count.reblogs - 1, + } as APIStatus); }); diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index 8e4e8674..c8db40e9 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -10,234 +10,233 @@ import type { UserWithRelations } from "~database/entities/User"; import { statusAndUserRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 300, - duration: 60, - }, - route: "/api/v1/statuses", - auth: { - required: true, - }, + allowedMethods: ["POST"], + ratelimits: { + max: 300, + duration: 60, + }, + route: "/api/v1/statuses", + auth: { + required: true, + }, }); /** * Post new status */ export default apiRoute<{ - status: string; - media_ids?: string[]; - "poll[options]"?: string[]; - "poll[expires_in]"?: number; - "poll[multiple]"?: boolean; - "poll[hide_totals]"?: boolean; - in_reply_to_id?: string; - quote_id?: string; - sensitive?: boolean; - spoiler_text?: string; - visibility?: "public" | "unlisted" | "private" | "direct"; - language?: string; - scheduled_at?: string; - local_only?: boolean; - content_type?: string; + status: string; + media_ids?: string[]; + "poll[options]"?: string[]; + "poll[expires_in]"?: number; + "poll[multiple]"?: boolean; + "poll[hide_totals]"?: boolean; + in_reply_to_id?: string; + quote_id?: string; + sensitive?: boolean; + spoiler_text?: string; + visibility?: "public" | "unlisted" | "private" | "direct"; + language?: string; + scheduled_at?: string; + local_only?: boolean; + content_type?: string; }>(async (req, matchedRoute, extraData) => { - const { user, token } = extraData.auth; - const application = await getFromToken(token); + const { user, token } = extraData.auth; + const application = await getFromToken(token); - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - const { - status, - media_ids, - "poll[expires_in]": expires_in, - // "poll[hide_totals]": hide_totals, - // "poll[multiple]": multiple, - "poll[options]": options, - in_reply_to_id, - quote_id, - // language, - scheduled_at, - sensitive, - spoiler_text, - visibility, - content_type, - } = extraData.parsedRequest; + const { + status, + media_ids, + "poll[expires_in]": expires_in, + // "poll[hide_totals]": hide_totals, + // "poll[multiple]": multiple, + "poll[options]": options, + in_reply_to_id, + quote_id, + // language, + scheduled_at, + sensitive, + spoiler_text, + visibility, + content_type, + } = extraData.parsedRequest; - // Validate status - if (!status && !(media_ids && media_ids.length > 0)) { - return errorResponse( - "Status is required unless media is attached", - 422 - ); - } + // Validate status + if (!status && !(media_ids && media_ids.length > 0)) { + return errorResponse( + "Status is required unless media is attached", + 422, + ); + } - // Validate media_ids - if (media_ids && !Array.isArray(media_ids)) { - return errorResponse("Media IDs must be an array", 422); - } + // Validate media_ids + if (media_ids && !Array.isArray(media_ids)) { + return errorResponse("Media IDs must be an array", 422); + } - // Validate poll options - if (options && !Array.isArray(options)) { - return errorResponse("Poll options must be an array", 422); - } + // Validate poll options + if (options && !Array.isArray(options)) { + return errorResponse("Poll options must be an array", 422); + } - if (options && options.length > 4) { - return errorResponse("Poll options must be less than 5", 422); - } + if (options && options.length > 4) { + return errorResponse("Poll options must be less than 5", 422); + } - if (media_ids && media_ids.length > 0) { - // Disallow poll - if (options) { - return errorResponse("Cannot attach poll to media", 422); - } - if (media_ids.length > 4) { - return errorResponse("Media IDs must be less than 5", 422); - } - } + if (media_ids && media_ids.length > 0) { + // Disallow poll + if (options) { + return errorResponse("Cannot attach poll to media", 422); + } + if (media_ids.length > 4) { + return errorResponse("Media IDs must be less than 5", 422); + } + } - if (options && options.length > config.validation.max_poll_options) { - return errorResponse( - `Poll options must be less than ${config.validation.max_poll_options}`, - 422 - ); - } + if (options && options.length > config.validation.max_poll_options) { + return errorResponse( + `Poll options must be less than ${config.validation.max_poll_options}`, + 422, + ); + } - if ( - options && - options.some( - option => option.length > config.validation.max_poll_option_size - ) - ) { - return errorResponse( - `Poll options must be less than ${config.validation.max_poll_option_size} characters`, - 422 - ); - } + if ( + options?.some( + (option) => option.length > config.validation.max_poll_option_size, + ) + ) { + return errorResponse( + `Poll options must be less than ${config.validation.max_poll_option_size} characters`, + 422, + ); + } - if (expires_in && expires_in < config.validation.min_poll_duration) { - return errorResponse( - `Poll duration must be greater than ${config.validation.min_poll_duration} seconds`, - 422 - ); - } + if (expires_in && expires_in < config.validation.min_poll_duration) { + return errorResponse( + `Poll duration must be greater than ${config.validation.min_poll_duration} seconds`, + 422, + ); + } - if (expires_in && expires_in > config.validation.max_poll_duration) { - return errorResponse( - `Poll duration must be less than ${config.validation.max_poll_duration} seconds`, - 422 - ); - } + if (expires_in && expires_in > config.validation.max_poll_duration) { + return errorResponse( + `Poll duration must be less than ${config.validation.max_poll_duration} seconds`, + 422, + ); + } - if (scheduled_at) { - if (new Date(scheduled_at).getTime() < Date.now()) { - return errorResponse("Scheduled time must be in the future", 422); - } - } + if (scheduled_at) { + if (new Date(scheduled_at).getTime() < Date.now()) { + return errorResponse("Scheduled time must be in the future", 422); + } + } - let sanitizedStatus: string; + let sanitizedStatus: string; - if (content_type === "text/markdown") { - sanitizedStatus = await sanitizeHtml(parse(status ?? "") as any); - } else if (content_type === "text/x.misskeymarkdown") { - // Parse as MFM - // TODO: Parse as MFM - sanitizedStatus = await sanitizeHtml(parse(status ?? "") as any); - } else { - sanitizedStatus = await sanitizeHtml(status ?? ""); - } + if (content_type === "text/markdown") { + sanitizedStatus = await sanitizeHtml(parse(status ?? "") as string); + } else if (content_type === "text/x.misskeymarkdown") { + // Parse as MFM + // TODO: Parse as MFM + sanitizedStatus = await sanitizeHtml(parse(status ?? "") as string); + } else { + sanitizedStatus = await sanitizeHtml(status ?? ""); + } - if (sanitizedStatus.length > config.validation.max_note_size) { - return errorResponse( - `Status must be less than ${config.validation.max_note_size} characters`, - 400 - ); - } + if (sanitizedStatus.length > config.validation.max_note_size) { + return errorResponse( + `Status must be less than ${config.validation.max_note_size} characters`, + 400, + ); + } - // Validate visibility - if ( - visibility && - !["public", "unlisted", "private", "direct"].includes(visibility) - ) { - return errorResponse("Invalid visibility", 422); - } + // Validate visibility + if ( + visibility && + !["public", "unlisted", "private", "direct"].includes(visibility) + ) { + return errorResponse("Invalid visibility", 422); + } - // Get reply account and status if exists - let replyStatus: StatusWithRelations | null = null; - let replyUser: UserWithRelations | null = null; - let quote: StatusWithRelations | null = null; + // Get reply account and status if exists + let replyStatus: StatusWithRelations | null = null; + let replyUser: UserWithRelations | null = null; + let quote: StatusWithRelations | null = null; - if (in_reply_to_id) { - replyStatus = await client.status.findUnique({ - where: { id: in_reply_to_id }, - include: statusAndUserRelations, - }); + if (in_reply_to_id) { + replyStatus = await client.status.findUnique({ + where: { id: in_reply_to_id }, + include: statusAndUserRelations, + }); - if (!replyStatus) { - return errorResponse("Reply status not found", 404); - } + if (!replyStatus) { + return errorResponse("Reply status not found", 404); + } - // @ts-expect-error Prisma Typescript doesn't include relations - replyUser = replyStatus.author; - } + // @ts-expect-error Prisma Typescript doesn't include relations + replyUser = replyStatus.author; + } - if (quote_id) { - quote = await client.status.findUnique({ - where: { id: quote_id }, - include: statusAndUserRelations, - }); + if (quote_id) { + quote = await client.status.findUnique({ + where: { id: quote_id }, + include: statusAndUserRelations, + }); - if (!quote) { - return errorResponse("Quote status not found", 404); - } - } + if (!quote) { + return errorResponse("Quote status not found", 404); + } + } - // Check if status body doesnt match filters - if (config.filters.note_content.some(filter => status?.match(filter))) { - return errorResponse("Status contains blocked words", 422); - } + // Check if status body doesnt match filters + if (config.filters.note_content.some((filter) => status?.match(filter))) { + return errorResponse("Status contains blocked words", 422); + } - // Check if media attachments are all valid + // Check if media attachments are all valid - const foundAttachments = await client.attachment.findMany({ - where: { - id: { - in: media_ids ?? [], - }, - }, - }); + const foundAttachments = await client.attachment.findMany({ + where: { + id: { + in: media_ids ?? [], + }, + }, + }); - if (foundAttachments.length !== (media_ids ?? []).length) { - return errorResponse("Invalid media IDs", 422); - } + if (foundAttachments.length !== (media_ids ?? []).length) { + return errorResponse("Invalid media IDs", 422); + } - const newStatus = await createNewStatus({ - account: user, - application, - content: sanitizedStatus, - visibility: - visibility || - (config.defaults.visibility as - | "public" - | "unlisted" - | "private" - | "direct"), - sensitive: sensitive || false, - spoiler_text: spoiler_text || "", - emojis: [], - media_attachments: media_ids, - reply: - replyStatus && replyUser - ? { - user: replyUser, - status: replyStatus, - } - : undefined, - quote: quote || undefined, - }); + const newStatus = await createNewStatus({ + account: user, + application, + content: sanitizedStatus, + visibility: + visibility || + (config.defaults.visibility as + | "public" + | "unlisted" + | "private" + | "direct"), + sensitive: sensitive || false, + spoiler_text: spoiler_text || "", + emojis: [], + media_attachments: media_ids, + reply: + replyStatus && replyUser + ? { + user: replyUser, + status: replyStatus, + } + : undefined, + quote: quote || undefined, + }); - // TODO: add database jobs to deliver the post + // TODO: add database jobs to deliver the post - return jsonResponse(await statusToAPI(newStatus, user)); + return jsonResponse(await statusToAPI(newStatus, user)); }); diff --git a/server/api/api/v1/timelines/home.ts b/server/api/api/v1/timelines/home.ts index 56c7f453..c48bf2c1 100644 --- a/server/api/api/v1/timelines/home.ts +++ b/server/api/api/v1/timelines/home.ts @@ -5,95 +5,95 @@ import { statusToAPI } from "~database/entities/Status"; import { statusAndUserRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 200, - duration: 60, - }, - route: "/api/v1/timelines/home", - auth: { - required: true, - }, + allowedMethods: ["GET"], + ratelimits: { + max: 200, + duration: 60, + }, + route: "/api/v1/timelines/home", + auth: { + required: true, + }, }); /** * Fetch home timeline statuses */ export default apiRoute<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; }>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest; + const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest; - if (limit < 1 || limit > 40) { - return errorResponse("Limit must be between 1 and 40", 400); - } + if (limit < 1 || limit > 40) { + return errorResponse("Limit must be between 1 and 40", 400); + } - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const objects = await client.status.findMany({ - where: { - id: { - lt: max_id ?? undefined, - gte: since_id ?? undefined, - gt: min_id ?? undefined, - }, - OR: [ - { - author: { - OR: [ - { - relationshipSubjects: { - some: { - ownerId: user.id, - following: true, - }, - }, - }, - { - id: user.id, - }, - ], - }, - }, - { - // Include posts where the user is mentioned in addition to posts by followed users - mentions: { - some: { - id: user.id, - }, - }, - }, - ], - }, - include: statusAndUserRelations, - take: limit, - orderBy: { - id: "desc", - }, - }); + const objects = await client.status.findMany({ + where: { + id: { + lt: max_id ?? undefined, + gte: since_id ?? undefined, + gt: min_id ?? undefined, + }, + OR: [ + { + author: { + OR: [ + { + relationshipSubjects: { + some: { + ownerId: user.id, + following: true, + }, + }, + }, + { + id: user.id, + }, + ], + }, + }, + { + // Include posts where the user is mentioned in addition to posts by followed users + mentions: { + some: { + id: user.id, + }, + }, + }, + ], + }, + include: statusAndUserRelations, + take: limit, + orderBy: { + id: "desc", + }, + }); - // Constuct HTTP Link header (next and prev) - const linkHeader = []; - if (objects.length > 0) { - const urlWithoutQuery = req.url.split("?")[0]; - linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, - `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` - ); - } + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, + `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`, + ); + } - return jsonResponse( - await Promise.all( - objects.map(async status => statusToAPI(status, user)) - ), - 200, - { - Link: linkHeader.join(", "), - } - ); + return jsonResponse( + await Promise.all( + objects.map(async (status) => statusToAPI(status, user)), + ), + 200, + { + Link: linkHeader.join(", "), + }, + ); }); diff --git a/server/api/api/v1/timelines/public.ts b/server/api/api/v1/timelines/public.ts index 7873398b..386238d0 100644 --- a/server/api/api/v1/timelines/public.ts +++ b/server/api/api/v1/timelines/public.ts @@ -5,84 +5,86 @@ import { statusToAPI } from "~database/entities/Status"; import { statusAndUserRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 200, - duration: 60, - }, - route: "/api/v1/timelines/public", - auth: { - required: false, - }, + allowedMethods: ["GET"], + ratelimits: { + max: 200, + duration: 60, + }, + route: "/api/v1/timelines/public", + auth: { + required: false, + }, }); export default apiRoute<{ - local?: boolean; - only_media?: boolean; - remote?: boolean; - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; + local?: boolean; + only_media?: boolean; + remote?: boolean; + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; }>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - const { - local, - limit = 20, - max_id, - min_id, - // only_media, - remote, - since_id, - } = extraData.parsedRequest; + const { user } = extraData.auth; + const { + local, + limit = 20, + max_id, + min_id, + // only_media, + remote, + since_id, + } = extraData.parsedRequest; - if (limit < 1 || limit > 40) { - return errorResponse("Limit must be between 1 and 40", 400); - } + if (limit < 1 || limit > 40) { + return errorResponse("Limit must be between 1 and 40", 400); + } - if (local && remote) { - return errorResponse("Cannot use both local and remote", 400); - } + if (local && remote) { + return errorResponse("Cannot use both local and remote", 400); + } - const objects = await client.status.findMany({ - where: { - id: { - lt: max_id ?? undefined, - gte: since_id ?? undefined, - gt: min_id ?? undefined, - }, - instanceId: remote - ? { - not: null, - } - : local - ? null - : undefined, - }, - include: statusAndUserRelations, - take: limit, - orderBy: { - id: "desc", - }, - }); + const objects = await client.status.findMany({ + where: { + id: { + lt: max_id ?? undefined, + gte: since_id ?? undefined, + gt: min_id ?? undefined, + }, + instanceId: remote + ? { + not: null, + } + : local + ? null + : undefined, + }, + include: statusAndUserRelations, + take: limit, + orderBy: { + id: "desc", + }, + }); - // Constuct HTTP Link header (next and prev) - const linkHeader = []; - if (objects.length > 0) { - const urlWithoutQuery = req.url.split("?")[0]; - linkHeader.push( - `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, - `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` - ); - } + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, + `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`, + ); + } - return jsonResponse( - await Promise.all( - objects.map(async status => statusToAPI(status, user || undefined)) - ), - 200, - { - Link: linkHeader.join(", "), - } - ); + return jsonResponse( + await Promise.all( + objects.map(async (status) => + statusToAPI(status, user || undefined), + ), + ), + 200, + { + Link: linkHeader.join(", "), + }, + ); }); diff --git a/server/api/api/v2/media/index.ts b/server/api/api/v2/media/index.ts index 2593ae05..150531ba 100644 --- a/server/api/api/v2/media/index.ts +++ b/server/api/api/v2/media/index.ts @@ -1,148 +1,148 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { client } from "~database/datasource"; import { encode } from "blurhash"; -import sharp from "sharp"; -import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import type { MediaBackend } from "media-manager"; import { MediaBackendType } from "media-manager"; +import sharp from "sharp"; +import { client } from "~database/datasource"; +import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import { LocalMediaBackend } from "~packages/media-manager/backends/local"; import { S3MediaBackend } from "~packages/media-manager/backends/s3"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 10, - duration: 60, - }, - route: "/api/v2/media", - auth: { - required: true, - oauthPermissions: ["write:media"], - }, + allowedMethods: ["POST"], + ratelimits: { + max: 10, + duration: 60, + }, + route: "/api/v2/media", + auth: { + required: true, + oauthPermissions: ["write:media"], + }, }); /** * Upload new media */ export default apiRoute<{ - file: File; - thumbnail: File; - description: string; - // TODO: Implement focus storage - focus: string; + file: File; + thumbnail: File; + description: string; + // TODO: Implement focus storage + focus: string; }>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - if (!user) { - return errorResponse("Unauthorized", 401); - } + if (!user) { + return errorResponse("Unauthorized", 401); + } - const { file, thumbnail, description } = extraData.parsedRequest; + const { file, thumbnail, description } = extraData.parsedRequest; - if (!file) { - return errorResponse("No file provided", 400); - } + if (!file) { + return errorResponse("No file provided", 400); + } - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - if (file.size > config.validation.max_media_size) { - return errorResponse( - `File too large, max size is ${config.validation.max_media_size} bytes`, - 413 - ); - } + if (file.size > config.validation.max_media_size) { + return errorResponse( + `File too large, max size is ${config.validation.max_media_size} bytes`, + 413, + ); + } - if ( - config.validation.enforce_mime_types && - !config.validation.allowed_mime_types.includes(file.type) - ) { - return errorResponse("Invalid file type", 415); - } + if ( + config.validation.enforce_mime_types && + !config.validation.allowed_mime_types.includes(file.type) + ) { + return errorResponse("Invalid file type", 415); + } - if ( - description && - description.length > config.validation.max_media_description_size - ) { - return errorResponse( - `Description too long, max length is ${config.validation.max_media_description_size} characters`, - 413 - ); - } + if ( + description && + description.length > config.validation.max_media_description_size + ) { + return errorResponse( + `Description too long, max length is ${config.validation.max_media_description_size} characters`, + 413, + ); + } - const sha256 = new Bun.SHA256(); + const sha256 = new Bun.SHA256(); - const isImage = file.type.startsWith("image/"); + const isImage = file.type.startsWith("image/"); - const metadata = isImage - ? await sharp(await file.arrayBuffer()).metadata() - : null; + const metadata = isImage + ? await sharp(await file.arrayBuffer()).metadata() + : null; - const blurhash = isImage - ? encode( - new Uint8ClampedArray(await file.arrayBuffer()), - metadata?.width ?? 0, - metadata?.height ?? 0, - 4, - 4 - ) - : null; + const blurhash = isImage + ? encode( + new Uint8ClampedArray(await file.arrayBuffer()), + metadata?.width ?? 0, + metadata?.height ?? 0, + 4, + 4, + ) + : null; - let url = ""; + let url = ""; - let mediaManager: MediaBackend; + let mediaManager: MediaBackend; - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.LOCAL: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } + switch (config.media.backend as MediaBackendType) { + case MediaBackendType.LOCAL: + mediaManager = new LocalMediaBackend(config); + break; + case MediaBackendType.S3: + mediaManager = new S3MediaBackend(config); + break; + default: + // TODO: Replace with logger + throw new Error("Invalid media backend"); + } - if (isImage) { - const { uploadedFile } = await mediaManager.addFile(file); + if (isImage) { + const { uploadedFile } = await mediaManager.addFile(file); - url = getUrl(uploadedFile.name, config); - } + url = getUrl(uploadedFile.name, config); + } - let thumbnailUrl = ""; + let thumbnailUrl = ""; - if (thumbnail) { - const { uploadedFile } = await mediaManager.addFile(thumbnail); + if (thumbnail) { + const { uploadedFile } = await mediaManager.addFile(thumbnail); - thumbnailUrl = getUrl(uploadedFile.name, config); - } + thumbnailUrl = getUrl(uploadedFile.name, config); + } - const newAttachment = await client.attachment.create({ - data: { - url, - thumbnail_url: thumbnailUrl, - sha256: sha256.update(await file.arrayBuffer()).digest("hex"), - mime_type: file.type, - description: description ?? "", - size: file.size, - blurhash: blurhash ?? undefined, - width: metadata?.width ?? undefined, - height: metadata?.height ?? undefined, - }, - }); + const newAttachment = await client.attachment.create({ + data: { + url, + thumbnail_url: thumbnailUrl, + sha256: sha256.update(await file.arrayBuffer()).digest("hex"), + mime_type: file.type, + description: description ?? "", + size: file.size, + blurhash: blurhash ?? undefined, + width: metadata?.width ?? undefined, + height: metadata?.height ?? undefined, + }, + }); - // TODO: Add job to process videos and other media + // TODO: Add job to process videos and other media - if (isImage) { - return jsonResponse(attachmentToAPI(newAttachment)); - } else { - return jsonResponse( - { - ...attachmentToAPI(newAttachment), - url: null, - }, - 202 - ); - } + if (isImage) { + return jsonResponse(attachmentToAPI(newAttachment)); + } + + return jsonResponse( + { + ...attachmentToAPI(newAttachment), + url: null, + }, + 202, + ); }); diff --git a/server/api/api/v2/search/index.ts b/server/api/api/v2/search/index.ts index d059b24f..58b04905 100644 --- a/server/api/api/v2/search/index.ts +++ b/server/api/api/v2/search/index.ts @@ -5,139 +5,139 @@ import { client } from "~database/datasource"; import { statusToAPI } from "~database/entities/Status"; import { userToAPI } from "~database/entities/User"; import { - statusAndUserRelations, - userRelations, + statusAndUserRelations, + userRelations, } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 10, - duration: 60, - }, - route: "/api/v2/search", - auth: { - required: false, - oauthPermissions: ["read:search"], - }, + allowedMethods: ["GET"], + ratelimits: { + max: 10, + duration: 60, + }, + route: "/api/v2/search", + auth: { + required: false, + oauthPermissions: ["read:search"], + }, }); /** * Upload new media */ export default apiRoute<{ - q?: string; - type?: string; - resolve?: boolean; - following?: boolean; - account_id?: string; - max_id?: string; - min_id?: string; - limit?: number; - offset?: number; + q?: string; + type?: string; + resolve?: boolean; + following?: boolean; + account_id?: string; + max_id?: string; + min_id?: string; + limit?: number; + offset?: number; }>(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; + const { user } = extraData.auth; - const { - q, - type, - resolve, - following, - account_id, - // max_id, - // min_id, - limit = 20, - offset, - } = extraData.parsedRequest; + const { + q, + type, + resolve, + following, + account_id, + // max_id, + // min_id, + limit = 20, + offset, + } = extraData.parsedRequest; - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - if (!config.meilisearch.enabled) { - return errorResponse("Meilisearch is not enabled", 501); - } + if (!config.meilisearch.enabled) { + return errorResponse("Meilisearch is not enabled", 501); + } - if (!user && (resolve || offset)) { - return errorResponse( - "Cannot use resolve or offset without being authenticated", - 401 - ); - } + if (!user && (resolve || offset)) { + return errorResponse( + "Cannot use resolve or offset without being authenticated", + 401, + ); + } - if (limit < 1 || limit > 40) { - return errorResponse("Limit must be between 1 and 40", 400); - } + if (limit < 1 || limit > 40) { + return errorResponse("Limit must be between 1 and 40", 400); + } - let accountResults: { id: string }[] = []; - let statusResults: { id: string }[] = []; + let accountResults: { id: string }[] = []; + let statusResults: { id: string }[] = []; - if (!type || type === "accounts") { - accountResults = ( - await meilisearch.index(MeiliIndexType.Accounts).search<{ - id: string; - }>(q, { - limit: Number(limit) || 10, - offset: Number(offset) || 0, - sort: ["createdAt:desc"], - }) - ).hits; - } + if (!type || type === "accounts") { + accountResults = ( + await meilisearch.index(MeiliIndexType.Accounts).search<{ + id: string; + }>(q, { + limit: Number(limit) || 10, + offset: Number(offset) || 0, + sort: ["createdAt:desc"], + }) + ).hits; + } - if (!type || type === "statuses") { - statusResults = ( - await meilisearch.index(MeiliIndexType.Statuses).search<{ - id: string; - }>(q, { - limit: Number(limit) || 10, - offset: Number(offset) || 0, - sort: ["createdAt:desc"], - }) - ).hits; - } + if (!type || type === "statuses") { + statusResults = ( + await meilisearch.index(MeiliIndexType.Statuses).search<{ + id: string; + }>(q, { + limit: Number(limit) || 10, + offset: Number(offset) || 0, + sort: ["createdAt:desc"], + }) + ).hits; + } - const accounts = await client.user.findMany({ - where: { - id: { - in: accountResults.map(hit => hit.id), - }, - relationshipSubjects: { - some: { - subjectId: user?.id, - following: following ? true : undefined, - }, - }, - }, - orderBy: { - createdAt: "desc", - }, - include: userRelations, - }); + const accounts = await client.user.findMany({ + where: { + id: { + in: accountResults.map((hit) => hit.id), + }, + relationshipSubjects: { + some: { + subjectId: user?.id, + following: following ? true : undefined, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + include: userRelations, + }); - const statuses = await client.status.findMany({ - where: { - id: { - in: statusResults.map(hit => hit.id), - }, - author: { - relationshipSubjects: { - some: { - subjectId: user?.id, - following: following ? true : undefined, - }, - }, - }, - authorId: account_id ? account_id : undefined, - }, - orderBy: { - createdAt: "desc", - }, - include: statusAndUserRelations, - }); + const statuses = await client.status.findMany({ + where: { + id: { + in: statusResults.map((hit) => hit.id), + }, + author: { + relationshipSubjects: { + some: { + subjectId: user?.id, + following: following ? true : undefined, + }, + }, + }, + authorId: account_id ? account_id : undefined, + }, + orderBy: { + createdAt: "desc", + }, + include: statusAndUserRelations, + }); - return jsonResponse({ - accounts: accounts.map(account => userToAPI(account)), - statuses: await Promise.all( - statuses.map(status => statusToAPI(status)) - ), - hashtags: [], - }); + return jsonResponse({ + accounts: accounts.map((account) => userToAPI(account)), + statuses: await Promise.all( + statuses.map((status) => statusToAPI(status)), + ), + hashtags: [], + }); }); diff --git a/server/api/auth/login/index.ts b/server/api/auth/login/index.ts index 1b6670cf..d2059066 100644 --- a/server/api/auth/login/index.ts +++ b/server/api/auth/login/index.ts @@ -1,105 +1,103 @@ +import { randomBytes } from "node:crypto"; import { apiRoute, applyConfig } from "@api"; -import { randomBytes } from "crypto"; import { client } from "~database/datasource"; import { TokenType } from "~database/entities/Token"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 4, - duration: 60, - }, - route: "/auth/login", - auth: { - required: false, - }, + allowedMethods: ["POST"], + ratelimits: { + max: 4, + duration: 60, + }, + route: "/auth/login", + auth: { + required: false, + }, }); /** * OAuth Code flow */ export default apiRoute<{ - email: string; - password: string; + email: string; + password: string; }>(async (req, matchedRoute, extraData) => { - const scopes = (matchedRoute.query.scope || "") - .replaceAll("+", " ") - .split(" "); - const redirect_uri = matchedRoute.query.redirect_uri; - const response_type = matchedRoute.query.response_type; - const client_id = matchedRoute.query.client_id; + const scopes = (matchedRoute.query.scope || "") + .replaceAll("+", " ") + .split(" "); + const redirect_uri = matchedRoute.query.redirect_uri; + const response_type = matchedRoute.query.response_type; + const client_id = matchedRoute.query.client_id; - const { email, password } = extraData.parsedRequest; + const { email, password } = extraData.parsedRequest; - const redirectToLogin = (error: string) => - Response.redirect( - `/oauth/authorize?` + - new URLSearchParams({ - ...matchedRoute.query, - error: encodeURIComponent(error), - }).toString(), - 302 - ); + const redirectToLogin = (error: string) => + Response.redirect( + `/oauth/authorize?${new URLSearchParams({ + ...matchedRoute.query, + error: encodeURIComponent(error), + }).toString()}`, + 302, + ); - if (response_type !== "code") - return redirectToLogin("Invalid response_type"); + if (response_type !== "code") + return redirectToLogin("Invalid response_type"); - if (!email || !password) - return redirectToLogin("Invalid username or password"); + if (!email || !password) + return redirectToLogin("Invalid username or password"); - // Get user - const user = await client.user.findFirst({ - where: { - email, - }, - include: userRelations, - }); + // Get user + const user = await client.user.findFirst({ + where: { + email, + }, + include: userRelations, + }); - if (!user || !(await Bun.password.verify(password, user.password || ""))) - return redirectToLogin("Invalid username or password"); + if (!user || !(await Bun.password.verify(password, user.password || ""))) + return redirectToLogin("Invalid username or password"); - // Get application - const application = await client.application.findFirst({ - where: { - client_id, - }, - }); + // Get application + const application = await client.application.findFirst({ + where: { + client_id, + }, + }); - if (!application) return redirectToLogin("Invalid client_id"); + if (!application) return redirectToLogin("Invalid client_id"); - const code = randomBytes(32).toString("hex"); + const code = randomBytes(32).toString("hex"); - await client.application.update({ - where: { id: application.id }, - data: { - tokens: { - create: { - access_token: randomBytes(64).toString("base64url"), - code: code, - scope: scopes.join(" "), - token_type: TokenType.BEARER, - user: { - connect: { - id: user.id, - }, - }, - }, - }, - }, - }); + await client.application.update({ + where: { id: application.id }, + data: { + tokens: { + create: { + access_token: randomBytes(64).toString("base64url"), + code: code, + scope: scopes.join(" "), + token_type: TokenType.BEARER, + user: { + connect: { + id: user.id, + }, + }, + }, + }, + }, + }); - // Redirect to OAuth confirmation screen - return Response.redirect( - `/oauth/redirect?` + - new URLSearchParams({ - redirect_uri, - code, - client_id, - application: application.name, - website: application.website ?? "", - scope: scopes.join(" "), - }).toString(), - 302 - ); + // Redirect to OAuth confirmation screen + return Response.redirect( + `/oauth/redirect?${new URLSearchParams({ + redirect_uri, + code, + client_id, + application: application.name, + website: application.website ?? "", + scope: scopes.join(" "), + }).toString()}`, + 302, + ); }); diff --git a/server/api/auth/redirect/index.ts b/server/api/auth/redirect/index.ts index be089d92..90e04a49 100644 --- a/server/api/auth/redirect/index.ts +++ b/server/api/auth/redirect/index.ts @@ -3,56 +3,55 @@ import { client } from "~database/datasource"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 4, - duration: 60, - }, - route: "/auth/redirect", - auth: { - required: false, - }, + allowedMethods: ["POST"], + ratelimits: { + max: 4, + duration: 60, + }, + route: "/auth/redirect", + auth: { + required: false, + }, }); /** * OAuth Code flow */ export default apiRoute<{ - email: string; - password: string; + email: string; + password: string; }>(async (req, matchedRoute) => { - const redirect_uri = decodeURIComponent(matchedRoute.query.redirect_uri); - const client_id = matchedRoute.query.client_id; - const code = matchedRoute.query.code; + const redirect_uri = decodeURIComponent(matchedRoute.query.redirect_uri); + const client_id = matchedRoute.query.client_id; + const code = matchedRoute.query.code; - const redirectToLogin = (error: string) => - Response.redirect( - `/oauth/authorize?` + - new URLSearchParams({ - ...matchedRoute.query, - error: encodeURIComponent(error), - }).toString(), - 302 - ); + const redirectToLogin = (error: string) => + Response.redirect( + `/oauth/authorize?${new URLSearchParams({ + ...matchedRoute.query, + error: encodeURIComponent(error), + }).toString()}`, + 302, + ); - // Get token - const token = await client.token.findFirst({ - where: { - code, - application: { - client_id, - }, - }, - include: { - user: { - include: userRelations, - }, - application: true, - }, - }); + // Get token + const token = await client.token.findFirst({ + where: { + code, + application: { + client_id, + }, + }, + include: { + user: { + include: userRelations, + }, + application: true, + }, + }); - if (!token) return redirectToLogin("Invalid code"); + if (!token) return redirectToLogin("Invalid code"); - // Redirect back to application - return Response.redirect(`${redirect_uri}?code=${code}`, 302); + // Redirect back to application + return Response.redirect(`${redirect_uri}?code=${code}`, 302); }); diff --git a/server/api/media/[id]/index.ts b/server/api/media/[id]/index.ts index 4ee0589a..3e9f6318 100644 --- a/server/api/media/[id]/index.ts +++ b/server/api/media/[id]/index.ts @@ -1,45 +1,45 @@ -import { errorResponse } from "@response"; import { apiRoute, applyConfig } from "@api"; +import { errorResponse } from "@response"; export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/media/:id", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: false, - }, + allowedMethods: ["GET"], + route: "/media/:id", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: false, + }, }); export default apiRoute(async (req, matchedRoute) => { - // TODO: Add checks for disabled or not email verified accounts + // TODO: Add checks for disabled or not email verified accounts - const id = matchedRoute.params.id; + const id = matchedRoute.params.id; - // parse `Range` header - const [start = 0, end = Infinity] = ( - (req.headers.get("Range") || "") - .split("=") // ["Range: bytes", "0-100"] - .at(-1) || "" - ) // "0-100" - .split("-") // ["0", "100"] - .map(Number); // [0, 100] + // parse `Range` header + const [start = 0, end = Number.POSITIVE_INFINITY] = ( + (req.headers.get("Range") || "") + .split("=") // ["Range: bytes", "0-100"] + .at(-1) || "" + ) // "0-100" + .split("-") // ["0", "100"] + .map(Number); // [0, 100] - // Serve file from filesystem - const file = Bun.file(`./uploads/${id}`); + // Serve file from filesystem + const file = Bun.file(`./uploads/${id}`); - const buffer = await file.arrayBuffer(); + const buffer = await file.arrayBuffer(); - if (!(await file.exists())) return errorResponse("File not found", 404); + if (!(await file.exists())) return errorResponse("File not found", 404); - // Can't directly copy file into Response because this crashes Bun for now - return new Response(buffer, { - headers: { - "Content-Type": file.type || "application/octet-stream", - "Content-Length": `${file.size - start}`, - "Content-Range": `bytes ${start}-${end}/${file.size}`, - }, - }); + // Can't directly copy file into Response because this crashes Bun for now + return new Response(buffer, { + headers: { + "Content-Type": file.type || "application/octet-stream", + "Content-Length": `${file.size - start}`, + "Content-Range": `bytes ${start}-${end}/${file.size}`, + }, + }); }); diff --git a/server/api/nodeinfo/2.0/index.ts b/server/api/nodeinfo/2.0/index.ts index 99288f60..581d2ef4 100644 --- a/server/api/nodeinfo/2.0/index.ts +++ b/server/api/nodeinfo/2.0/index.ts @@ -2,32 +2,32 @@ import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 500, - }, - route: "/nodeinfo/2.0", + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/nodeinfo/2.0", }); /** * ActivityPub nodeinfo 2.0 endpoint */ export default apiRoute(() => { - // TODO: Implement this - return jsonResponse({ - version: "2.0", - software: { name: "lysand", version: "0.0.1" }, - protocols: ["activitypub"], - services: { outbound: [], inbound: [] }, - usage: { - users: { total: 0, activeMonth: 0, activeHalfyear: 0 }, - localPosts: 0, - }, - openRegistrations: false, - metadata: {}, - }); + // TODO: Implement this + return jsonResponse({ + version: "2.0", + software: { name: "lysand", version: "0.0.1" }, + protocols: ["activitypub"], + services: { outbound: [], inbound: [] }, + usage: { + users: { total: 0, activeMonth: 0, activeHalfyear: 0 }, + localPosts: 0, + }, + openRegistrations: false, + metadata: {}, + }); }); diff --git a/server/api/oauth/authorize-external/index.ts b/server/api/oauth/authorize-external/index.ts index 688f7a6a..e392cd39 100644 --- a/server/api/oauth/authorize-external/index.ts +++ b/server/api/oauth/authorize-external/index.ts @@ -1,95 +1,91 @@ import { apiRoute, applyConfig } from "@api"; import { oauthRedirectUri } from "@constants"; import { - calculatePKCECodeChallenge, - discoveryRequest, - generateRandomCodeVerifier, - processDiscoveryResponse, + calculatePKCECodeChallenge, + discoveryRequest, + generateRandomCodeVerifier, + processDiscoveryResponse, } from "oauth4webapi"; import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 20, - }, - route: "/oauth/authorize-external", + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 20, + }, + route: "/oauth/authorize-external", }); /** * Redirects the user to the external OAuth provider */ export default apiRoute(async (req, matchedRoute, extraData) => { - const redirectToLogin = (error: string) => - Response.redirect( - `/oauth/authorize?` + - new URLSearchParams({ - ...matchedRoute.query, - error: encodeURIComponent(error), - }).toString(), - 302 - ); + const redirectToLogin = (error: string) => + Response.redirect( + `/oauth/authorize?${new URLSearchParams({ + ...matchedRoute.query, + error: encodeURIComponent(error), + }).toString()}`, + 302, + ); - const issuerId = matchedRoute.query.issuer; + const issuerId = matchedRoute.query.issuer; - // This is the Lysand client's client_id, not the external OAuth provider's client_id - const clientId = matchedRoute.query.clientId; + // This is the Lysand client's client_id, not the external OAuth provider's client_id + const clientId = matchedRoute.query.clientId; - if (!clientId || clientId === "undefined") { - return redirectToLogin("Missing client_id"); - } + if (!clientId || clientId === "undefined") { + return redirectToLogin("Missing client_id"); + } - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - const issuer = config.oidc.providers.find( - provider => provider.id === issuerId - ); + const issuer = config.oidc.providers.find( + (provider) => provider.id === issuerId, + ); - if (!issuer) { - return redirectToLogin("Invalid issuer"); - } + if (!issuer) { + return redirectToLogin("Invalid issuer"); + } - const issuerUrl = new URL(issuer.url); + const issuerUrl = new URL(issuer.url); - const authServer = await discoveryRequest(issuerUrl, { - algorithm: "oidc", - }).then(res => processDiscoveryResponse(issuerUrl, res)); + const authServer = await discoveryRequest(issuerUrl, { + algorithm: "oidc", + }).then((res) => processDiscoveryResponse(issuerUrl, res)); - const codeVerifier = generateRandomCodeVerifier(); + const codeVerifier = generateRandomCodeVerifier(); - // Store into database + // Store into database - const newFlow = await client.openIdLoginFlow.create({ - data: { - codeVerifier, - application: { - connect: { - client_id: clientId, - }, - }, - issuerId, - }, - }); + const newFlow = await client.openIdLoginFlow.create({ + data: { + codeVerifier, + application: { + connect: { + client_id: clientId, + }, + }, + issuerId, + }, + }); - const codeChallenge = await calculatePKCECodeChallenge(codeVerifier); + const codeChallenge = await calculatePKCECodeChallenge(codeVerifier); - return Response.redirect( - authServer.authorization_endpoint + - "?" + - new URLSearchParams({ - client_id: issuer.client_id, - redirect_uri: - oauthRedirectUri(issuerId) + `?flow=${newFlow.id}`, - response_type: "code", - scope: "openid profile email", - // PKCE - code_challenge_method: "S256", - code_challenge: codeChallenge, - }).toString(), - 302 - ); + return Response.redirect( + `${authServer.authorization_endpoint}?${new URLSearchParams({ + client_id: issuer.client_id, + redirect_uri: `${oauthRedirectUri(issuerId)}?flow=${newFlow.id}`, + response_type: "code", + scope: "openid profile email", + // PKCE + code_challenge_method: "S256", + code_challenge: codeChallenge, + }).toString()}`, + 302, + ); }); diff --git a/server/api/oauth/callback/[issuer]/index.ts b/server/api/oauth/callback/[issuer]/index.ts index c898ed28..6b40f265 100644 --- a/server/api/oauth/callback/[issuer]/index.ts +++ b/server/api/oauth/callback/[issuer]/index.ts @@ -1,198 +1,196 @@ +import { randomBytes } from "node:crypto"; import { apiRoute, applyConfig } from "@api"; import { oauthRedirectUri } from "@constants"; -import { randomBytes } from "crypto"; import { - authorizationCodeGrantRequest, - discoveryRequest, - expectNoState, - isOAuth2Error, - processDiscoveryResponse, - validateAuthResponse, - userInfoRequest, - processAuthorizationCodeOpenIDResponse, - processUserInfoResponse, - getValidatedIdTokenClaims, + authorizationCodeGrantRequest, + discoveryRequest, + expectNoState, + getValidatedIdTokenClaims, + isOAuth2Error, + processAuthorizationCodeOpenIDResponse, + processDiscoveryResponse, + processUserInfoResponse, + userInfoRequest, + validateAuthResponse, } from "oauth4webapi"; import { client } from "~database/datasource"; import { TokenType } from "~database/entities/Token"; export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 20, - }, - route: "/oauth/callback/:issuer", + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 20, + }, + route: "/oauth/callback/:issuer", }); /** * Redirects the user to the external OAuth provider */ export default apiRoute(async (req, matchedRoute, extraData) => { - const redirectToLogin = (error: string) => - Response.redirect( - `/oauth/authorize?` + - new URLSearchParams({ - client_id: matchedRoute.query.clientId, - error: encodeURIComponent(error), - }).toString(), - 302 - ); + const redirectToLogin = (error: string) => + Response.redirect( + `/oauth/authorize?${new URLSearchParams({ + client_id: matchedRoute.query.clientId, + error: encodeURIComponent(error), + }).toString()}`, + 302, + ); - const currentUrl = new URL(req.url); + const currentUrl = new URL(req.url); - // Remove state query parameter from URL - currentUrl.searchParams.delete("state"); - const issuerParam = matchedRoute.params.issuer; - const flow = await client.openIdLoginFlow.findFirst({ - where: { - id: matchedRoute.query.flow, - }, - include: { - application: true, - }, - }); + // Remove state query parameter from URL + currentUrl.searchParams.delete("state"); + const issuerParam = matchedRoute.params.issuer; + const flow = await client.openIdLoginFlow.findFirst({ + where: { + id: matchedRoute.query.flow, + }, + include: { + application: true, + }, + }); - if (!flow) { - return redirectToLogin("Invalid flow"); - } + if (!flow) { + return redirectToLogin("Invalid flow"); + } - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - const issuer = config.oidc.providers.find( - provider => provider.id === issuerParam - ); + const issuer = config.oidc.providers.find( + (provider) => provider.id === issuerParam, + ); - if (!issuer) { - return redirectToLogin("Invalid issuer"); - } + if (!issuer) { + return redirectToLogin("Invalid issuer"); + } - const issuerUrl = new URL(issuer.url); + const issuerUrl = new URL(issuer.url); - const authServer = await discoveryRequest(issuerUrl, { - algorithm: "oidc", - }).then(res => processDiscoveryResponse(issuerUrl, res)); + const authServer = await discoveryRequest(issuerUrl, { + algorithm: "oidc", + }).then((res) => processDiscoveryResponse(issuerUrl, res)); - const parameters = validateAuthResponse( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - currentUrl, - // Whether to expect state or not - expectNoState - ); + const parameters = validateAuthResponse( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + currentUrl, + // Whether to expect state or not + expectNoState, + ); - if (isOAuth2Error(parameters)) { - return redirectToLogin( - parameters.error_description || parameters.error - ); - } + if (isOAuth2Error(parameters)) { + return redirectToLogin( + parameters.error_description || parameters.error, + ); + } - const response = await authorizationCodeGrantRequest( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - parameters, - oauthRedirectUri(issuerParam) + `?flow=${flow.id}`, - flow.codeVerifier - ); + const response = await authorizationCodeGrantRequest( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + parameters, + `${oauthRedirectUri(issuerParam)}?flow=${flow.id}`, + flow.codeVerifier, + ); - const result = await processAuthorizationCodeOpenIDResponse( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - response - ); + const result = await processAuthorizationCodeOpenIDResponse( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + response, + ); - if (isOAuth2Error(result)) { - return redirectToLogin(result.error_description || result.error); - } + if (isOAuth2Error(result)) { + return redirectToLogin(result.error_description || result.error); + } - const { access_token } = result; + const { access_token } = result; - const claims = getValidatedIdTokenClaims(result); - const { sub } = claims; + const claims = getValidatedIdTokenClaims(result); + const { sub } = claims; - // Validate `sub` - // Later, we'll use this to automatically set the user's data - await userInfoRequest( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - access_token - ).then(res => - processUserInfoResponse( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - sub, - res - ) - ); + // Validate `sub` + // Later, we'll use this to automatically set the user's data + await userInfoRequest( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + access_token, + ).then((res) => + processUserInfoResponse( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + sub, + res, + ), + ); - const user = await client.user.findFirst({ - where: { - linkedOpenIdAccounts: { - some: { - serverId: sub, - issuerId: issuer.id, - }, - }, - }, - }); + const user = await client.user.findFirst({ + where: { + linkedOpenIdAccounts: { + some: { + serverId: sub, + issuerId: issuer.id, + }, + }, + }, + }); - if (!user) { - return redirectToLogin("No user found with that account"); - } + if (!user) { + return redirectToLogin("No user found with that account"); + } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!flow.application) return redirectToLogin("Invalid client_id"); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!flow.application) return redirectToLogin("Invalid client_id"); - const code = randomBytes(32).toString("hex"); + const code = randomBytes(32).toString("hex"); - await client.application.update({ - where: { id: flow.application.id }, - data: { - tokens: { - create: { - access_token: randomBytes(64).toString("base64url"), - code: code, - scope: flow.application.scopes, - token_type: TokenType.BEARER, - user: { - connect: { - id: user.id, - }, - }, - }, - }, - }, - }); + await client.application.update({ + where: { id: flow.application.id }, + data: { + tokens: { + create: { + access_token: randomBytes(64).toString("base64url"), + code: code, + scope: flow.application.scopes, + token_type: TokenType.BEARER, + user: { + connect: { + id: user.id, + }, + }, + }, + }, + }, + }); - // Redirect back to application - return Response.redirect( - `/oauth/redirect?` + - new URLSearchParams({ - redirect_uri: flow.application.redirect_uris, - code, - client_id: flow.application.client_id, - application: flow.application.name, - website: flow.application.website ?? "", - scope: flow.application.scopes, - }).toString(), - 302 - ); + // Redirect back to application + return Response.redirect( + `/oauth/redirect?${new URLSearchParams({ + redirect_uri: flow.application.redirect_uris, + code, + client_id: flow.application.client_id, + application: flow.application.name, + website: flow.application.website ?? "", + scope: flow.application.scopes, + }).toString()}`, + 302, + ); }); diff --git a/server/api/oauth/providers/index.ts b/server/api/oauth/providers/index.ts index ad0d9769..25a95eb2 100644 --- a/server/api/oauth/providers/index.ts +++ b/server/api/oauth/providers/index.ts @@ -2,28 +2,28 @@ import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 10, - }, - route: "/oauth/providers", + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 10, + }, + route: "/oauth/providers", }); /** * Lists available OAuth providers */ export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - return jsonResponse( - config.oidc.providers.map(p => ({ - name: p.name, - icon: p.icon, - id: p.id, - })) - ); + return jsonResponse( + config.oidc.providers.map((p) => ({ + name: p.name, + icon: p.icon, + id: p.id, + })), + ); }); diff --git a/server/api/oauth/token/index.ts b/server/api/oauth/token/index.ts index f75f2209..43302c06 100644 --- a/server/api/oauth/token/index.ts +++ b/server/api/oauth/token/index.ts @@ -3,61 +3,61 @@ import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; export const meta = applyConfig({ - allowedMethods: ["POST"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 10, - }, - route: "/oauth/token", + allowedMethods: ["POST"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 10, + }, + route: "/oauth/token", }); /** * Allows getting token from OAuth code */ export default apiRoute<{ - grant_type: string; - code: string; - redirect_uri: string; - client_id: string; - client_secret: string; - scope: string; + grant_type: string; + code: string; + redirect_uri: string; + client_id: string; + client_secret: string; + scope: string; }>(async (req, matchedRoute, extraData) => { - const { grant_type, code, redirect_uri, client_id, client_secret, scope } = - extraData.parsedRequest; + const { grant_type, code, redirect_uri, client_id, client_secret, scope } = + extraData.parsedRequest; - if (grant_type !== "authorization_code") - return errorResponse( - "Invalid grant type (try 'authorization_code')", - 400 - ); + if (grant_type !== "authorization_code") + return errorResponse( + "Invalid grant type (try 'authorization_code')", + 400, + ); - // Get associated token - const token = await client.token.findFirst({ - where: { - code, - application: { - client_id, - secret: client_secret, - redirect_uris: redirect_uri, - scopes: scope?.replaceAll("+", " "), - }, - scope: scope?.replaceAll("+", " "), - }, - include: { - application: true, - }, - }); + // Get associated token + const token = await client.token.findFirst({ + where: { + code, + application: { + client_id, + secret: client_secret, + redirect_uris: redirect_uri, + scopes: scope?.replaceAll("+", " "), + }, + scope: scope?.replaceAll("+", " "), + }, + include: { + application: true, + }, + }); - if (!token) - return errorResponse("Invalid access token or client credentials", 401); + if (!token) + return errorResponse("Invalid access token or client credentials", 401); - return jsonResponse({ - access_token: token.access_token, - token_type: token.token_type, - scope: token.scope, - created_at: token.created_at, - }); + return jsonResponse({ + access_token: token.access_token, + token_type: token.token_type, + scope: token.scope, + created_at: token.created_at, + }); }); diff --git a/server/api/object/[uuid]/index.ts b/server/api/object/[uuid]/index.ts index 3803234b..e11a5c6c 100644 --- a/server/api/object/[uuid]/index.ts +++ b/server/api/object/[uuid]/index.ts @@ -2,17 +2,17 @@ import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 500, - }, - route: "/object/:id", + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/object/:id", }); export default apiRoute(() => { - return jsonResponse({}); + return jsonResponse({}); }); diff --git a/server/api/routes.type.ts b/server/api/routes.type.ts index b3ff1ef7..df2f6de8 100644 --- a/server/api/routes.type.ts +++ b/server/api/routes.type.ts @@ -3,13 +3,13 @@ import type { Config } from "config-manager"; import type { AuthData } from "~database/entities/User"; export type RouteHandler = ( - req: Request, - matchedRoute: MatchedRoute, - extraData: { - auth: AuthData; - parsedRequest: Partial; - configManager: { - getConfig: () => Promise; - }; - } + req: Request, + matchedRoute: MatchedRoute, + extraData: { + auth: AuthData; + parsedRequest: Partial; + configManager: { + getConfig: () => Promise; + }; + }, ) => Response | Promise; diff --git a/server/api/users/[uuid]/inbox/index.ts b/server/api/users/[uuid]/inbox/index.ts index cd1e22d3..a1a98e25 100644 --- a/server/api/users/[uuid]/inbox/index.ts +++ b/server/api/users/[uuid]/inbox/index.ts @@ -9,394 +9,393 @@ import { createFromObject } from "~database/entities/Object"; import { createNewStatus, fetchFromRemote } from "~database/entities/Status"; import { parseMentionsUris } from "~database/entities/User"; import { - userRelations, - statusAndUserRelations, + statusAndUserRelations, + userRelations, } from "~database/entities/relations"; import type { - Announce, - Like, - LysandAction, - LysandPublication, - Patch, - Undo, + Announce, + Like, + LysandAction, + LysandPublication, + Patch, + Undo, } from "~types/lysand/Object"; export const meta = applyConfig({ - allowedMethods: ["POST"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 500, - }, - route: "/users/:username/inbox", + allowedMethods: ["POST"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/users/:username/inbox", }); /** * ActivityPub user inbox endpoint */ export default apiRoute(async (req, matchedRoute, extraData) => { - const username = matchedRoute.params.username; + const username = matchedRoute.params.username; - const config = await extraData.configManager.getConfig(); + const config = await extraData.configManager.getConfig(); - try { - if ( - config.activitypub.reject_activities.includes( - new URL(req.headers.get("Origin") ?? "").hostname - ) - ) { - // Discard request - return jsonResponse({}); - } - } catch (e) { - console.error( - `[-] Error parsing Origin header of incoming Activity from ${req.headers.get( - "Origin" - )}` - ); - console.error(e); - } + /* try { + if ( + config.activitypub.reject_activities.includes( + new URL(req.headers.get("Origin") ?? "").hostname, + ) + ) { + // Discard request + return jsonResponse({}); + } + } catch (e) { + console.error( + `[-] Error parsing Origin header of incoming Activity from ${req.headers.get( + "Origin", + )}`, + ); + console.error(e); + } */ - // Process request body - const body = (await req.json()) as LysandPublication | LysandAction; + // Process request body + const body = (await req.json()) as LysandPublication | LysandAction; - const author = await client.user.findUnique({ - where: { - username, - }, - include: userRelations, - }); + const author = await client.user.findUnique({ + where: { + username, + }, + include: userRelations, + }); - if (!author) { - // TODO: Add new author to database - return errorResponse("Author not found", 404); - } + if (!author) { + // TODO: Add new author to database + return errorResponse("Author not found", 404); + } - // Verify HTTP signature - if (config.activitypub.authorized_fetch) { - // Check if date is older than 30 seconds - const origin = req.headers.get("Origin"); + // Verify HTTP signature + /* if (config.activitypub.authorized_fetch) { + // Check if date is older than 30 seconds + const origin = req.headers.get("Origin"); - if (!origin) { - return errorResponse("Origin header is required", 401); - } + if (!origin) { + return errorResponse("Origin header is required", 401); + } - const date = req.headers.get("Date"); + const date = req.headers.get("Date"); - if (!date) { - return errorResponse("Date header is required", 401); - } + if (!date) { + return errorResponse("Date header is required", 401); + } - if (new Date(date).getTime() < Date.now() - 30000) { - return errorResponse("Date is too old (max 30 seconds)", 401); - } + if (new Date(date).getTime() < Date.now() - 30000) { + return errorResponse("Date is too old (max 30 seconds)", 401); + } - const signatureHeader = req.headers.get("Signature"); + const signatureHeader = req.headers.get("Signature"); - if (!signatureHeader) { - return errorResponse("Signature header is required", 401); - } + if (!signatureHeader) { + return errorResponse("Signature header is required", 401); + } - const signature = signatureHeader - .split("signature=")[1] - .replace(/"/g, ""); + const signature = signatureHeader + .split("signature=")[1] + .replace(/"/g, ""); - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(await req.text()) - ); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(await req.text()), + ); - const expectedSignedString = - `(request-target): ${req.method.toLowerCase()} ${req.url}\n` + - `host: ${req.url}\n` + - `date: ${date}\n` + - `digest: SHA-256=${Buffer.from(digest).toString("base64")}`; + const expectedSignedString = + `(request-target): ${req.method.toLowerCase()} ${req.url}\n` + + `host: ${req.url}\n` + + `date: ${date}\n` + + `digest: SHA-256=${Buffer.from(digest).toString("base64")}`; - // author.public_key is base64 encoded raw public key - const publicKey = await crypto.subtle.importKey( - "spki", - Buffer.from(author.publicKey, "base64"), - "Ed25519", - false, - ["verify"] - ); + // author.public_key is base64 encoded raw public key + const publicKey = await crypto.subtle.importKey( + "spki", + Buffer.from(author.publicKey, "base64"), + "Ed25519", + false, + ["verify"], + ); - // Check if signed string is valid - const isValid = await crypto.subtle.verify( - "Ed25519", - publicKey, - Buffer.from(signature, "base64"), - new TextEncoder().encode(expectedSignedString) - ); + // Check if signed string is valid + const isValid = await crypto.subtle.verify( + "Ed25519", + publicKey, + Buffer.from(signature, "base64"), + new TextEncoder().encode(expectedSignedString), + ); - if (!isValid) { - return errorResponse("Invalid signature", 401); - } - } + if (!isValid) { + return errorResponse("Invalid signature", 401); + } + } */ - // Get the object's ActivityPub type - const type = body.type; + // Get the object's ActivityPub type + const type = body.type; - switch (type) { - case "Note": { - // Store the object in the LysandObject table - await createFromObject(body); + switch (type) { + case "Note": { + // Store the object in the LysandObject table + await createFromObject(body); - const content = getBestContentType(body.contents); + const content = getBestContentType(body.contents); - const emojis = await parseEmojis(content?.content || ""); + const emojis = await parseEmojis(content?.content || ""); - const newStatus = await createNewStatus({ - account: author, - content: content?.content || "", - content_type: content?.content_type, - application: null, - // TODO: Add visibility - visibility: "public", - spoiler_text: body.subject || "", - sensitive: body.is_sensitive, - uri: body.uri, - emojis: emojis, - mentions: await parseMentionsUris(body.mentions), - }); + const newStatus = await createNewStatus({ + account: author, + content: content?.content || "", + content_type: content?.content_type, + application: null, + // TODO: Add visibility + visibility: "public", + spoiler_text: body.subject || "", + sensitive: body.is_sensitive, + uri: body.uri, + emojis: emojis, + mentions: await parseMentionsUris(body.mentions), + }); - // If there is a reply, fetch all the reply parents and add them to the database - if (body.replies_to.length > 0) { - newStatus.inReplyToPostId = - (await fetchFromRemote(body.replies_to[0]))?.id || null; - } + // If there is a reply, fetch all the reply parents and add them to the database + if (body.replies_to.length > 0) { + newStatus.inReplyToPostId = + (await fetchFromRemote(body.replies_to[0]))?.id || null; + } - // Same for quotes - if (body.quotes.length > 0) { - newStatus.quotingPostId = - (await fetchFromRemote(body.quotes[0]))?.id || null; - } + // Same for quotes + if (body.quotes.length > 0) { + newStatus.quotingPostId = + (await fetchFromRemote(body.quotes[0]))?.id || null; + } - await client.status.update({ - where: { - id: newStatus.id, - }, - data: { - inReplyToPostId: newStatus.inReplyToPostId, - quotingPostId: newStatus.quotingPostId, - }, - }); + await client.status.update({ + where: { + id: newStatus.id, + }, + data: { + inReplyToPostId: newStatus.inReplyToPostId, + quotingPostId: newStatus.quotingPostId, + }, + }); - break; - } - case "Patch": { - const patch = body as Patch; - // Store the object in the LysandObject table - await createFromObject(patch); + break; + } + case "Patch": { + const patch = body as Patch; + // Store the object in the LysandObject table + await createFromObject(patch); - // Edit the status + // Edit the status - const content = getBestContentType(patch.contents); + const content = getBestContentType(patch.contents); - const emojis = await parseEmojis(content?.content || ""); + const emojis = await parseEmojis(content?.content || ""); - const status = await client.status.findUnique({ - where: { - uri: patch.patched_id, - }, - include: statusAndUserRelations, - }); + const status = await client.status.findUnique({ + where: { + uri: patch.patched_id, + }, + include: statusAndUserRelations, + }); - if (!status) { - return errorResponse("Status not found", 404); - } + if (!status) { + return errorResponse("Status not found", 404); + } - status.content = content?.content || ""; - status.contentType = content?.content_type || "text/plain"; - status.spoilerText = patch.subject || ""; - status.sensitive = patch.is_sensitive; - status.emojis = emojis; + status.content = content?.content || ""; + status.contentType = content?.content_type || "text/plain"; + status.spoilerText = patch.subject || ""; + status.sensitive = patch.is_sensitive; + status.emojis = emojis; - // If there is a reply, fetch all the reply parents and add them to the database - if (body.replies_to.length > 0) { - status.inReplyToPostId = - (await fetchFromRemote(body.replies_to[0]))?.id || null; - } + // If there is a reply, fetch all the reply parents and add them to the database + if (body.replies_to.length > 0) { + status.inReplyToPostId = + (await fetchFromRemote(body.replies_to[0]))?.id || null; + } - // Same for quotes - if (body.quotes.length > 0) { - status.quotingPostId = - (await fetchFromRemote(body.quotes[0]))?.id || null; - } + // Same for quotes + if (body.quotes.length > 0) { + status.quotingPostId = + (await fetchFromRemote(body.quotes[0]))?.id || null; + } - await client.status.update({ - where: { - id: status.id, - }, - data: { - content: status.content, - contentType: status.contentType, - spoilerText: status.spoilerText, - sensitive: status.sensitive, - emojis: { - connect: status.emojis.map(emoji => ({ - id: emoji.id, - })), - }, - inReplyToPostId: status.inReplyToPostId, - quotingPostId: status.quotingPostId, - }, - }); - break; - } - case "Like": { - const like = body as Like; - // Store the object in the LysandObject table - await createFromObject(body); + await client.status.update({ + where: { + id: status.id, + }, + data: { + content: status.content, + contentType: status.contentType, + spoilerText: status.spoilerText, + sensitive: status.sensitive, + emojis: { + connect: status.emojis.map((emoji) => ({ + id: emoji.id, + })), + }, + inReplyToPostId: status.inReplyToPostId, + quotingPostId: status.quotingPostId, + }, + }); + break; + } + case "Like": { + const like = body as Like; + // Store the object in the LysandObject table + await createFromObject(body); - const likedStatus = await client.status.findUnique({ - where: { - uri: like.object, - }, - include: statusAndUserRelations, - }); + const likedStatus = await client.status.findUnique({ + where: { + uri: like.object, + }, + include: statusAndUserRelations, + }); - if (!likedStatus) { - return errorResponse("Status not found", 404); - } + if (!likedStatus) { + return errorResponse("Status not found", 404); + } - await createLike(author, likedStatus); + await createLike(author, likedStatus); - break; - } - case "Dislike": { - // Store the object in the LysandObject table - await createFromObject(body); + break; + } + case "Dislike": { + // Store the object in the LysandObject table + await createFromObject(body); - return jsonResponse({ - info: "Dislikes are not supported by this software", - }); - break; - } - case "Follow": { - // Store the object in the LysandObject table - await createFromObject(body); - break; - } - case "FollowAccept": { - // Store the object in the LysandObject table - await createFromObject(body); - break; - } - case "FollowReject": { - // Store the object in the LysandObject table - await createFromObject(body); - break; - } - case "Announce": { - const announce = body as Announce; - // Store the object in the LysandObject table - await createFromObject(body); + return jsonResponse({ + info: "Dislikes are not supported by this software", + }); + } + case "Follow": { + // Store the object in the LysandObject table + await createFromObject(body); + break; + } + case "FollowAccept": { + // Store the object in the LysandObject table + await createFromObject(body); + break; + } + case "FollowReject": { + // Store the object in the LysandObject table + await createFromObject(body); + break; + } + case "Announce": { + const announce = body as Announce; + // Store the object in the LysandObject table + await createFromObject(body); - const rebloggedStatus = await client.status.findUnique({ - where: { - uri: announce.object, - }, - include: statusAndUserRelations, - }); + const rebloggedStatus = await client.status.findUnique({ + where: { + uri: announce.object, + }, + include: statusAndUserRelations, + }); - if (!rebloggedStatus) { - return errorResponse("Status not found", 404); - } + if (!rebloggedStatus) { + return errorResponse("Status not found", 404); + } - // Create new reblog - await client.status.create({ - data: { - authorId: author.id, - reblogId: rebloggedStatus.id, - isReblog: true, - uri: body.uri, - visibility: rebloggedStatus.visibility, - sensitive: false, - }, - include: statusAndUserRelations, - }); + // Create new reblog + await client.status.create({ + data: { + authorId: author.id, + reblogId: rebloggedStatus.id, + isReblog: true, + uri: body.uri, + visibility: rebloggedStatus.visibility, + sensitive: false, + }, + include: statusAndUserRelations, + }); - // Create notification - await client.notification.create({ - data: { - accountId: author.id, - notifiedId: rebloggedStatus.authorId, - type: "reblog", - statusId: rebloggedStatus.id, - }, - }); - break; - } - case "Undo": { - const undo = body as Undo; - // Store the object in the LysandObject table - await createFromObject(body); + // Create notification + await client.notification.create({ + data: { + accountId: author.id, + notifiedId: rebloggedStatus.authorId, + type: "reblog", + statusId: rebloggedStatus.id, + }, + }); + break; + } + case "Undo": { + const undo = body as Undo; + // Store the object in the LysandObject table + await createFromObject(body); - const object = await client.lysandObject.findUnique({ - where: { - uri: undo.object, - }, - }); + const object = await client.lysandObject.findUnique({ + where: { + uri: undo.object, + }, + }); - if (!object) { - return errorResponse("Object not found", 404); - } + if (!object) { + return errorResponse("Object not found", 404); + } - switch (object.type) { - case "Like": { - const status = await client.status.findUnique({ - where: { - uri: undo.object, - authorId: author.id, - }, - include: statusAndUserRelations, - }); + switch (object.type) { + case "Like": { + const status = await client.status.findUnique({ + where: { + uri: undo.object, + authorId: author.id, + }, + include: statusAndUserRelations, + }); - if (!status) { - return errorResponse("Status not found", 404); - } + if (!status) { + return errorResponse("Status not found", 404); + } - await deleteLike(author, status); - break; - } - case "Announce": { - await client.status.delete({ - where: { - uri: undo.object, - authorId: author.id, - }, - include: statusAndUserRelations, - }); - break; - } - case "Note": { - await client.status.delete({ - where: { - uri: undo.object, - authorId: author.id, - }, - include: statusAndUserRelations, - }); - break; - } - default: { - return errorResponse("Invalid object type", 400); - } - } - break; - } - case "Extension": { - // Store the object in the LysandObject table - await createFromObject(body); - break; - } - default: { - return errorResponse("Invalid type", 400); - } - } + await deleteLike(author, status); + break; + } + case "Announce": { + await client.status.delete({ + where: { + uri: undo.object, + authorId: author.id, + }, + include: statusAndUserRelations, + }); + break; + } + case "Note": { + await client.status.delete({ + where: { + uri: undo.object, + authorId: author.id, + }, + include: statusAndUserRelations, + }); + break; + } + default: { + return errorResponse("Invalid object type", 400); + } + } + break; + } + case "Extension": { + // Store the object in the LysandObject table + await createFromObject(body); + break; + } + default: { + return errorResponse("Invalid type", 400); + } + } - return jsonResponse({}); + return jsonResponse({}); }); diff --git a/server/api/users/[uuid]/index.ts b/server/api/users/[uuid]/index.ts index af62b0af..6466b00f 100644 --- a/server/api/users/[uuid]/index.ts +++ b/server/api/users/[uuid]/index.ts @@ -5,33 +5,33 @@ import { userToLysand } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["POST"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 500, - }, - route: "/users/:uuid", + allowedMethods: ["POST"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/users/:uuid", }); /** * ActivityPub user inbox endpoint */ export default apiRoute(async (req, matchedRoute) => { - const uuid = matchedRoute.params.uuid; + const uuid = matchedRoute.params.uuid; - const user = await client.user.findUnique({ - where: { - id: uuid, - }, - include: userRelations, - }); + const user = await client.user.findUnique({ + where: { + id: uuid, + }, + include: userRelations, + }); - if (!user) { - return errorResponse("User not found", 404); - } + if (!user) { + return errorResponse("User not found", 404); + } - return jsonResponse(userToLysand(user)); + return jsonResponse(userToLysand(user)); }); diff --git a/server/api/users/[uuid]/outbox/index.ts b/server/api/users/[uuid]/outbox/index.ts index c2e10094..ec0e97f7 100644 --- a/server/api/users/[uuid]/outbox/index.ts +++ b/server/api/users/[uuid]/outbox/index.ts @@ -1,65 +1,65 @@ -import { jsonResponse } from "@response"; import { apiRoute, applyConfig } from "@api"; -import { statusToLysand } from "~database/entities/Status"; +import { jsonResponse } from "@response"; import { client } from "~database/datasource"; +import { statusToLysand } from "~database/entities/Status"; import { statusAndUserRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 500, - }, - route: "/users/:uuid/outbox", + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/users/:uuid/outbox", }); /** * ActivityPub user outbox endpoint */ export default apiRoute(async (req, matchedRoute, extraData) => { - const uuid = matchedRoute.params.uuid; - const pageNumber = Number(matchedRoute.query.page) || 1; - const config = await extraData.configManager.getConfig(); - const host = new URL(config.http.base_url).hostname; + const uuid = matchedRoute.params.uuid; + const pageNumber = Number(matchedRoute.query.page) || 1; + const config = await extraData.configManager.getConfig(); + const host = new URL(config.http.base_url).hostname; - const statuses = await client.status.findMany({ - where: { - authorId: uuid, - visibility: { - in: ["public", "unlisted"], - }, - }, - take: 20, - skip: 20 * (pageNumber - 1), - include: statusAndUserRelations, - }); + const statuses = await client.status.findMany({ + where: { + authorId: uuid, + visibility: { + in: ["public", "unlisted"], + }, + }, + take: 20, + skip: 20 * (pageNumber - 1), + include: statusAndUserRelations, + }); - const totalStatuses = await client.status.count({ - where: { - authorId: uuid, - visibility: { - in: ["public", "unlisted"], - }, - }, - }); + const totalStatuses = await client.status.count({ + where: { + authorId: uuid, + visibility: { + in: ["public", "unlisted"], + }, + }, + }); - return jsonResponse({ - first: `${host}/users/${uuid}/outbox?page=1`, - last: `${host}/users/${uuid}/outbox?page=1`, - total_items: totalStatuses, - // Server actor - author: `${config.http.base_url}/users/actor`, - next: - statuses.length === 20 - ? `${host}/users/${uuid}/outbox?page=${pageNumber + 1}` - : undefined, - prev: - pageNumber > 1 - ? `${host}/users/${uuid}/outbox?page=${pageNumber - 1}` - : undefined, - items: statuses.map(s => statusToLysand(s)), - }); + return jsonResponse({ + first: `${host}/users/${uuid}/outbox?page=1`, + last: `${host}/users/${uuid}/outbox?page=1`, + total_items: totalStatuses, + // Server actor + author: `${config.http.base_url}/users/actor`, + next: + statuses.length === 20 + ? `${host}/users/${uuid}/outbox?page=${pageNumber + 1}` + : undefined, + prev: + pageNumber > 1 + ? `${host}/users/${uuid}/outbox?page=${pageNumber - 1}` + : undefined, + items: statuses.map((s) => statusToLysand(s)), + }); }); diff --git a/tests/api.test.ts b/tests/api.test.ts index 54c1958a..5399e6e0 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,11 +1,11 @@ -import type { Token } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Token } from "@prisma/client"; import { config } from "config-manager"; import { client } from "~database/datasource"; import { TokenType } from "~database/entities/Token"; import { - type UserWithRelations, - createNewLocalUser, + type UserWithRelations, + createNewLocalUser, } from "~database/entities/User"; import type { APIEmoji } from "~types/entities/emoji"; import type { APIInstance } from "~types/entities/instance"; @@ -17,142 +17,142 @@ let token: Token; let user: UserWithRelations; describe("API Tests", () => { - beforeAll(async () => { - // Initialize test user - user = await createNewLocalUser({ - email: "test@test.com", - username: "test", - password: "test", - display_name: "", - }); + beforeAll(async () => { + // Initialize test user + user = await createNewLocalUser({ + email: "test@test.com", + username: "test", + password: "test", + display_name: "", + }); - token = await client.token.create({ - data: { - access_token: "test", - application: { - create: { - client_id: "test", - name: "Test Application", - redirect_uris: "https://example.com", - scopes: "read write", - secret: "test", - website: "https://example.com", - vapid_key: null, - }, - }, - code: "test", - scope: "read write", - token_type: TokenType.BEARER, - user: { - connect: { - id: user.id, - }, - }, - }, - }); - }); + token = await client.token.create({ + data: { + access_token: "test", + application: { + create: { + client_id: "test", + name: "Test Application", + redirect_uris: "https://example.com", + scopes: "read write", + secret: "test", + website: "https://example.com", + vapid_key: null, + }, + }, + code: "test", + scope: "read write", + token_type: TokenType.BEARER, + user: { + connect: { + id: user.id, + }, + }, + }, + }); + }); - afterAll(async () => { - await client.user.deleteMany({ - where: { - username: { - in: ["test", "test2"], - }, - }, - }); + afterAll(async () => { + await client.user.deleteMany({ + where: { + username: { + in: ["test", "test2"], + }, + }, + }); - await client.application.deleteMany({ - where: { - client_id: "test", - }, - }); - }); + await client.application.deleteMany({ + where: { + client_id: "test", + }, + }); + }); - describe("GET /api/v1/instance", () => { - test("should return an APIInstance object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl(`${base_url}/api/v1/instance`, base_url), - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - } - ) - ); + describe("GET /api/v1/instance", () => { + test("should return an APIInstance object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl(`${base_url}/api/v1/instance`, base_url), + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - const instance = (await response.json()) as APIInstance; + const instance = (await response.json()) as APIInstance; - expect(instance.uri).toBe(new URL(config.http.base_url).hostname); - expect(instance.title).toBeDefined(); - expect(instance.description).toBeDefined(); - expect(instance.email).toBeDefined(); - expect(instance.version).toBeDefined(); - expect(instance.urls).toBeDefined(); - expect(instance.stats).toBeDefined(); - expect(instance.thumbnail).toBeDefined(); - expect(instance.languages).toBeDefined(); - // Not implemented yet - // expect(instance.contact_account).toBeDefined(); - expect(instance.rules).toBeDefined(); - expect(instance.approval_required).toBeDefined(); - expect(instance.max_toot_chars).toBeDefined(); - }); - }); + expect(instance.uri).toBe(new URL(config.http.base_url).hostname); + expect(instance.title).toBeDefined(); + expect(instance.description).toBeDefined(); + expect(instance.email).toBeDefined(); + expect(instance.version).toBeDefined(); + expect(instance.urls).toBeDefined(); + expect(instance.stats).toBeDefined(); + expect(instance.thumbnail).toBeDefined(); + expect(instance.languages).toBeDefined(); + // Not implemented yet + // expect(instance.contact_account).toBeDefined(); + expect(instance.rules).toBeDefined(); + expect(instance.approval_required).toBeDefined(); + expect(instance.max_toot_chars).toBeDefined(); + }); + }); - describe("GET /api/v1/custom_emojis", () => { - beforeAll(async () => { - await client.emoji.create({ - data: { - instanceId: null, - url: "https://example.com/test.png", - content_type: "image/png", - shortcode: "test", - visible_in_picker: true, - }, - }); - }); + describe("GET /api/v1/custom_emojis", () => { + beforeAll(async () => { + await client.emoji.create({ + data: { + instanceId: null, + url: "https://example.com/test.png", + content_type: "image/png", + shortcode: "test", + visible_in_picker: true, + }, + }); + }); - test("should return an array of at least one custom emoji", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `${base_url}/api/v1/custom_emojis`, - base_url - ), - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } - ) - ); + test("should return an array of at least one custom emoji", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `${base_url}/api/v1/custom_emojis`, + base_url, + ), + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - const emojis = (await response.json()) as APIEmoji[]; + const emojis = (await response.json()) as APIEmoji[]; - expect(emojis.length).toBeGreaterThan(0); - expect(emojis[0].shortcode).toBeString(); - expect(emojis[0].url).toBeString(); - }); + expect(emojis.length).toBeGreaterThan(0); + expect(emojis[0].shortcode).toBeString(); + expect(emojis[0].url).toBeString(); + }); - afterAll(async () => { - await client.emoji.deleteMany({ - where: { - shortcode: "test", - }, - }); - }); - }); + afterAll(async () => { + await client.emoji.deleteMany({ + where: { + shortcode: "test", + }, + }); + }); + }); }); diff --git a/tests/api/accounts.test.ts b/tests/api/accounts.test.ts index a62ebc43..32aaea3f 100644 --- a/tests/api/accounts.test.ts +++ b/tests/api/accounts.test.ts @@ -1,16 +1,16 @@ -import type { Token } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Token } from "@prisma/client"; +import { config } from "config-manager"; import { client } from "~database/datasource"; import { TokenType } from "~database/entities/Token"; import { - type UserWithRelations, - createNewLocalUser, + type UserWithRelations, + createNewLocalUser, } from "~database/entities/User"; +import { sendTestRequest, wrapRelativeUrl } from "~tests/utils"; import type { APIAccount } from "~types/entities/account"; import type { APIRelationship } from "~types/entities/relationship"; import type { APIStatus } from "~types/entities/status"; -import { config } from "config-manager"; -import { sendTestRequest, wrapRelativeUrl } from "~tests/utils"; const base_url = config.http.base_url; @@ -19,738 +19,738 @@ let user: UserWithRelations; let user2: UserWithRelations; beforeAll(async () => { - await client.user.deleteMany({ - where: { - username: { - in: ["test", "test2"], - }, - }, - }); + await client.user.deleteMany({ + where: { + username: { + in: ["test", "test2"], + }, + }, + }); - user = await createNewLocalUser({ - email: "test@test.com", - username: "test", - password: "test", - display_name: "", - }); + user = await createNewLocalUser({ + email: "test@test.com", + username: "test", + password: "test", + display_name: "", + }); - user2 = await createNewLocalUser({ - email: "test2@test.com", - username: "test2", - password: "test2", - display_name: "", - }); + user2 = await createNewLocalUser({ + email: "test2@test.com", + username: "test2", + password: "test2", + display_name: "", + }); - token = await client.token.create({ - data: { - access_token: "test", - application: { - create: { - client_id: "test", - name: "Test Application", - redirect_uris: "https://example.com", - scopes: "read write", - secret: "test", - website: "https://example.com", - vapid_key: null, - }, - }, - code: "test", - scope: "read write", - token_type: TokenType.BEARER, - user: { - connect: { - id: user.id, - }, - }, - }, - }); + token = await client.token.create({ + data: { + access_token: "test", + application: { + create: { + client_id: "test", + name: "Test Application", + redirect_uris: "https://example.com", + scopes: "read write", + secret: "test", + website: "https://example.com", + vapid_key: null, + }, + }, + code: "test", + scope: "read write", + token_type: TokenType.BEARER, + user: { + connect: { + id: user.id, + }, + }, + }, + }); }); afterAll(async () => { - await client.user.deleteMany({ - where: { - username: { - in: ["test", "test2"], - }, - }, - }); + await client.user.deleteMany({ + where: { + username: { + in: ["test", "test2"], + }, + }, + }); - await client.application.deleteMany({ - where: { - client_id: "test", - }, - }); + await client.application.deleteMany({ + where: { + client_id: "test", + }, + }); }); describe("API Tests", () => { - describe("POST /api/v1/accounts/:id", () => { - test("should return a 404 error when trying to fetch a non-existent user", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl("/api/v1/accounts/999999", base_url), - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); - - expect(response.status).toBe(404); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - }); - }); - - describe("PATCH /api/v1/accounts/update_credentials", () => { - test("should update the authenticated user's display name", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - "/api/v1/accounts/update_credentials", - base_url - ), - { - method: "PATCH", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - display_name: "New Display Name", - }), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const user = (await response.json()) as APIAccount; - - expect(user.display_name).toBe("New Display Name"); - }); - }); - - describe("GET /api/v1/accounts/verify_credentials", () => { - test("should return the authenticated user's account information", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - "/api/v1/accounts/verify_credentials", - base_url - ), - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIAccount; - - expect(account.username).toBe(user.username); - expect(account.bot).toBe(false); - expect(account.locked).toBe(false); - expect(account.created_at).toBeDefined(); - expect(account.followers_count).toBe(0); - expect(account.following_count).toBe(0); - expect(account.statuses_count).toBe(0); - expect(account.note).toBe(""); - expect(account.url).toBe( - `${config.http.base_url}/users/${user.id}` - ); - expect(account.avatar).toBeDefined(); - expect(account.avatar_static).toBeDefined(); - expect(account.header).toBeDefined(); - expect(account.header_static).toBeDefined(); - expect(account.emojis).toEqual([]); - expect(account.fields).toEqual([]); - expect(account.source?.fields).toEqual([]); - expect(account.source?.privacy).toBe("public"); - expect(account.source?.language).toBeNull(); - expect(account.source?.note).toBe(""); - expect(account.source?.sensitive).toBe(false); - }); - }); - - describe("GET /api/v1/accounts/:id/statuses", () => { - test("should return the statuses of the specified user", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user.id}/statuses`, - base_url - ), - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const statuses = (await response.json()) as APIStatus[]; - - expect(statuses.length).toBe(0); - }); - }); - - describe("POST /api/v1/accounts/:id/follow", () => { - test("should follow the specified user and return an APIRelationship object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user2.id}/follow`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const relationship = (await response.json()) as APIRelationship; - - expect(relationship.id).toBe(user2.id); - expect(relationship.following).toBe(true); - }); - }); - - describe("POST /api/v1/accounts/:id/unfollow", () => { - test("should unfollow the specified user and return an APIRelationship object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user2.id}/unfollow`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.following).toBe(false); - }); - }); - - describe("POST /api/v1/accounts/:id/remove_from_followers", () => { - test("should remove the specified user from the authenticated user's followers and return an APIRelationship object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user2.id}/remove_from_followers`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.followed_by).toBe(false); - }); - }); - - describe("POST /api/v1/accounts/:id/block", () => { - test("should block the specified user and return an APIRelationship object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user2.id}/block`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.blocking).toBe(true); - }); - }); - - describe("GET /api/v1/blocks", () => { - test("should return an array of APIAccount objects for the user's blocked accounts", async () => { - const response = await sendTestRequest( - new Request(wrapRelativeUrl("/api/v1/blocks", base_url), { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - }) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - const body = (await response.json()) as APIAccount[]; - - expect(Array.isArray(body)).toBe(true); - expect(body.length).toBe(1); - expect(body[0].id).toBe(user2.id); - }); - }); - - describe("POST /api/v1/accounts/:id/unblock", () => { - test("should unblock the specified user and return an APIRelationship object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user2.id}/unblock`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.blocking).toBe(false); - }); - }); - - describe("POST /api/v1/accounts/:id/mute with notifications parameter", () => { - test("should mute the specified user and return an APIRelationship object with notifications set to false", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user2.id}/mute`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ notifications: true }), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.muting).toBe(true); - expect(account.muting_notifications).toBe(true); - }); - - test("should mute the specified user and return an APIRelationship object with notifications set to true", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user2.id}/mute`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ notifications: false }), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.muting).toBe(true); - expect(account.muting_notifications).toBe(true); - }); - }); - - describe("GET /api/v1/mutes", () => { - test("should return an array of APIAccount objects for the user's muted accounts", async () => { - const response = await sendTestRequest( - new Request(wrapRelativeUrl("/api/v1/mutes", base_url), { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - }) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const body = (await response.json()) as APIAccount[]; - - expect(Array.isArray(body)).toBe(true); - expect(body.length).toBe(1); - expect(body[0].id).toBe(user2.id); - }); - }); - - describe("POST /api/v1/accounts/:id/unmute", () => { - test("should unmute the specified user and return an APIRelationship object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user2.id}/unmute`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.muting).toBe(false); - }); - }); - - describe("POST /api/v1/accounts/:id/pin", () => { - test("should pin the specified user and return an APIRelationship object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user2.id}/pin`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.endorsed).toBe(true); - }); - }); - - describe("POST /api/v1/accounts/:id/unpin", () => { - test("should unpin the specified user and return an APIRelationship object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user2.id}/unpin`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.endorsed).toBe(false); - }); - }); - - describe("POST /api/v1/accounts/:id/note", () => { - test("should update the specified account's note and return the updated account object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user2.id}/note`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ comment: "This is a new note" }), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIAccount; - - expect(account.id).toBe(user2.id); - expect(account.note).toBe("This is a new note"); - }); - }); - - describe("GET /api/v1/accounts/relationships", () => { - test("should return an array of APIRelationship objects for the authenticated user's relationships", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/relationships?id[]=${user2.id}`, - base_url - ), - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const relationships = (await response.json()) as APIRelationship[]; - - expect(Array.isArray(relationships)).toBe(true); - expect(relationships.length).toBeGreaterThan(0); - expect(relationships[0].id).toBeDefined(); - expect(relationships[0].following).toBeDefined(); - expect(relationships[0].followed_by).toBeDefined(); - expect(relationships[0].blocking).toBeDefined(); - expect(relationships[0].muting).toBeDefined(); - expect(relationships[0].muting_notifications).toBeDefined(); - expect(relationships[0].requested).toBeDefined(); - expect(relationships[0].domain_blocking).toBeDefined(); - expect(relationships[0].notifying).toBeDefined(); - }); - }); - - describe("DELETE /api/v1/profile/avatar", () => { - test("should delete the avatar of the authenticated user and return the updated account object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl("/api/v1/profile/avatar", base_url), - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIAccount; - - expect(account.id).toBeDefined(); - expect(account.avatar).toBe(""); - }); - }); - - describe("DELETE /api/v1/profile/header", () => { - test("should delete the header of the authenticated user and return the updated account object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl("/api/v1/profile/header", base_url), - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIAccount; - - expect(account.id).toBeDefined(); - expect(account.header).toBe(""); - }); - }); - - describe("GET /api/v1/accounts/familiar_followers", () => { - test("should follow the user", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user2.id}/follow`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - }); - - test("should return an array of objects with id and accounts properties, where id is a string and accounts is an array of APIAccount objects", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/familiar_followers?id[]=${user2.id}`, - base_url - ), - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } - ) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const familiarFollowers = (await response.json()) as { - id: string; - accounts: APIAccount[]; - }[]; - - expect(Array.isArray(familiarFollowers)).toBe(true); - expect(familiarFollowers.length).toBe(0); - /* expect(typeof familiarFollowers[0].id).toBe("string"); + describe("POST /api/v1/accounts/:id", () => { + test("should return a 404 error when trying to fetch a non-existent user", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl("/api/v1/accounts/999999", base_url), + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); + + expect(response.status).toBe(404); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + }); + }); + + describe("PATCH /api/v1/accounts/update_credentials", () => { + test("should update the authenticated user's display name", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + "/api/v1/accounts/update_credentials", + base_url, + ), + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + display_name: "New Display Name", + }), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const user = (await response.json()) as APIAccount; + + expect(user.display_name).toBe("New Display Name"); + }); + }); + + describe("GET /api/v1/accounts/verify_credentials", () => { + test("should return the authenticated user's account information", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + "/api/v1/accounts/verify_credentials", + base_url, + ), + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIAccount; + + expect(account.username).toBe(user.username); + expect(account.bot).toBe(false); + expect(account.locked).toBe(false); + expect(account.created_at).toBeDefined(); + expect(account.followers_count).toBe(0); + expect(account.following_count).toBe(0); + expect(account.statuses_count).toBe(0); + expect(account.note).toBe(""); + expect(account.url).toBe( + `${config.http.base_url}/users/${user.id}`, + ); + expect(account.avatar).toBeDefined(); + expect(account.avatar_static).toBeDefined(); + expect(account.header).toBeDefined(); + expect(account.header_static).toBeDefined(); + expect(account.emojis).toEqual([]); + expect(account.fields).toEqual([]); + expect(account.source?.fields).toEqual([]); + expect(account.source?.privacy).toBe("public"); + expect(account.source?.language).toBeNull(); + expect(account.source?.note).toBe(""); + expect(account.source?.sensitive).toBe(false); + }); + }); + + describe("GET /api/v1/accounts/:id/statuses", () => { + test("should return the statuses of the specified user", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user.id}/statuses`, + base_url, + ), + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const statuses = (await response.json()) as APIStatus[]; + + expect(statuses.length).toBe(0); + }); + }); + + describe("POST /api/v1/accounts/:id/follow", () => { + test("should follow the specified user and return an APIRelationship object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user2.id}/follow`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const relationship = (await response.json()) as APIRelationship; + + expect(relationship.id).toBe(user2.id); + expect(relationship.following).toBe(true); + }); + }); + + describe("POST /api/v1/accounts/:id/unfollow", () => { + test("should unfollow the specified user and return an APIRelationship object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user2.id}/unfollow`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.following).toBe(false); + }); + }); + + describe("POST /api/v1/accounts/:id/remove_from_followers", () => { + test("should remove the specified user from the authenticated user's followers and return an APIRelationship object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user2.id}/remove_from_followers`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.followed_by).toBe(false); + }); + }); + + describe("POST /api/v1/accounts/:id/block", () => { + test("should block the specified user and return an APIRelationship object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user2.id}/block`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.blocking).toBe(true); + }); + }); + + describe("GET /api/v1/blocks", () => { + test("should return an array of APIAccount objects for the user's blocked accounts", async () => { + const response = await sendTestRequest( + new Request(wrapRelativeUrl("/api/v1/blocks", base_url), { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + const body = (await response.json()) as APIAccount[]; + + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(1); + expect(body[0].id).toBe(user2.id); + }); + }); + + describe("POST /api/v1/accounts/:id/unblock", () => { + test("should unblock the specified user and return an APIRelationship object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user2.id}/unblock`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.blocking).toBe(false); + }); + }); + + describe("POST /api/v1/accounts/:id/mute with notifications parameter", () => { + test("should mute the specified user and return an APIRelationship object with notifications set to false", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user2.id}/mute`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ notifications: true }), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.muting).toBe(true); + expect(account.muting_notifications).toBe(true); + }); + + test("should mute the specified user and return an APIRelationship object with notifications set to true", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user2.id}/mute`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ notifications: false }), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.muting).toBe(true); + expect(account.muting_notifications).toBe(true); + }); + }); + + describe("GET /api/v1/mutes", () => { + test("should return an array of APIAccount objects for the user's muted accounts", async () => { + const response = await sendTestRequest( + new Request(wrapRelativeUrl("/api/v1/mutes", base_url), { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const body = (await response.json()) as APIAccount[]; + + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(1); + expect(body[0].id).toBe(user2.id); + }); + }); + + describe("POST /api/v1/accounts/:id/unmute", () => { + test("should unmute the specified user and return an APIRelationship object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user2.id}/unmute`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.muting).toBe(false); + }); + }); + + describe("POST /api/v1/accounts/:id/pin", () => { + test("should pin the specified user and return an APIRelationship object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user2.id}/pin`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.endorsed).toBe(true); + }); + }); + + describe("POST /api/v1/accounts/:id/unpin", () => { + test("should unpin the specified user and return an APIRelationship object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user2.id}/unpin`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.endorsed).toBe(false); + }); + }); + + describe("POST /api/v1/accounts/:id/note", () => { + test("should update the specified account's note and return the updated account object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user2.id}/note`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ comment: "This is a new note" }), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIAccount; + + expect(account.id).toBe(user2.id); + expect(account.note).toBe("This is a new note"); + }); + }); + + describe("GET /api/v1/accounts/relationships", () => { + test("should return an array of APIRelationship objects for the authenticated user's relationships", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/relationships?id[]=${user2.id}`, + base_url, + ), + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const relationships = (await response.json()) as APIRelationship[]; + + expect(Array.isArray(relationships)).toBe(true); + expect(relationships.length).toBeGreaterThan(0); + expect(relationships[0].id).toBeDefined(); + expect(relationships[0].following).toBeDefined(); + expect(relationships[0].followed_by).toBeDefined(); + expect(relationships[0].blocking).toBeDefined(); + expect(relationships[0].muting).toBeDefined(); + expect(relationships[0].muting_notifications).toBeDefined(); + expect(relationships[0].requested).toBeDefined(); + expect(relationships[0].domain_blocking).toBeDefined(); + expect(relationships[0].notifying).toBeDefined(); + }); + }); + + describe("DELETE /api/v1/profile/avatar", () => { + test("should delete the avatar of the authenticated user and return the updated account object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl("/api/v1/profile/avatar", base_url), + { + method: "DELETE", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIAccount; + + expect(account.id).toBeDefined(); + expect(account.avatar).toBe(""); + }); + }); + + describe("DELETE /api/v1/profile/header", () => { + test("should delete the header of the authenticated user and return the updated account object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl("/api/v1/profile/header", base_url), + { + method: "DELETE", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const account = (await response.json()) as APIAccount; + + expect(account.id).toBeDefined(); + expect(account.header).toBe(""); + }); + }); + + describe("GET /api/v1/accounts/familiar_followers", () => { + test("should follow the user", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/${user2.id}/follow`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + }); + + test("should return an array of objects with id and accounts properties, where id is a string and accounts is an array of APIAccount objects", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/api/v1/accounts/familiar_followers?id[]=${user2.id}`, + base_url, + ), + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const familiarFollowers = (await response.json()) as { + id: string; + accounts: APIAccount[]; + }[]; + + expect(Array.isArray(familiarFollowers)).toBe(true); + expect(familiarFollowers.length).toBe(0); + /* expect(typeof familiarFollowers[0].id).toBe("string"); expect(Array.isArray(familiarFollowers[0].accounts)).toBe(true); expect(familiarFollowers[0].accounts.length).toBeGreaterThanOrEqual( 0 @@ -787,6 +787,6 @@ describe("API Tests", () => { ).toBeDefined(); expect(familiarFollowers[0].accounts[0].emojis).toBeDefined(); expect(familiarFollowers[0].accounts[0].fields).toBeDefined(); */ - }); - }); + }); + }); }); diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index 4f65dcd3..b6a34569 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -1,17 +1,17 @@ -import type { Token } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Token } from "@prisma/client"; +import { config } from "config-manager"; import { client } from "~database/datasource"; import { TokenType } from "~database/entities/Token"; import { - type UserWithRelations, - createNewLocalUser, + type UserWithRelations, + createNewLocalUser, } from "~database/entities/User"; +import { sendTestRequest, wrapRelativeUrl } from "~tests/utils"; import type { APIAccount } from "~types/entities/account"; import type { APIAsyncAttachment } from "~types/entities/async_attachment"; import type { APIContext } from "~types/entities/context"; import type { APIStatus } from "~types/entities/status"; -import { config } from "config-manager"; -import { sendTestRequest, wrapRelativeUrl } from "~tests/utils"; const base_url = config.http.base_url; @@ -22,504 +22,504 @@ let status2: APIStatus | null = null; let media1: APIAsyncAttachment | null = null; describe("API Tests", () => { - beforeAll(async () => { - await client.user.deleteMany({ - where: { - username: { - in: ["test", "test2"], - }, - }, - }); + beforeAll(async () => { + await client.user.deleteMany({ + where: { + username: { + in: ["test", "test2"], + }, + }, + }); - user = await createNewLocalUser({ - email: "test@test.com", - username: "test", - password: "test", - display_name: "", - }); + user = await createNewLocalUser({ + email: "test@test.com", + username: "test", + password: "test", + display_name: "", + }); - token = await client.token.create({ - data: { - access_token: "test", - application: { - create: { - client_id: "test", - name: "Test Application", - redirect_uris: "https://example.com", - scopes: "read write", - secret: "test", - website: "https://example.com", - vapid_key: null, - }, - }, - code: "test", - scope: "read write", - token_type: TokenType.BEARER, - user: { - connect: { - id: user.id, - }, - }, - }, - }); - }); + token = await client.token.create({ + data: { + access_token: "test", + application: { + create: { + client_id: "test", + name: "Test Application", + redirect_uris: "https://example.com", + scopes: "read write", + secret: "test", + website: "https://example.com", + vapid_key: null, + }, + }, + code: "test", + scope: "read write", + token_type: TokenType.BEARER, + user: { + connect: { + id: user.id, + }, + }, + }, + }); + }); - afterAll(async () => { - await client.user.deleteMany({ - where: { - username: { - in: ["test", "test2"], - }, - }, - }); + afterAll(async () => { + await client.user.deleteMany({ + where: { + username: { + in: ["test", "test2"], + }, + }, + }); - await client.application.deleteMany({ - where: { - client_id: "test", - }, - }); - }); + await client.application.deleteMany({ + where: { + client_id: "test", + }, + }); + }); - describe("POST /api/v2/media", () => { - test("should upload a file and return a MediaAttachment object", async () => { - const formData = new FormData(); - formData.append("file", new Blob(["test"], { type: "text/plain" })); + describe("POST /api/v2/media", () => { + test("should upload a file and return a MediaAttachment object", async () => { + const formData = new FormData(); + formData.append("file", new Blob(["test"], { type: "text/plain" })); - const response = await sendTestRequest( - new Request( - wrapRelativeUrl(`${base_url}/api/v2/media`, base_url), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - body: formData, - } - ) - ); + const response = await sendTestRequest( + new Request( + wrapRelativeUrl(`${base_url}/api/v2/media`, base_url), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + body: formData, + }, + ), + ); - expect(response.status).toBe(202); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(202); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - media1 = (await response.json()) as APIAsyncAttachment; + media1 = (await response.json()) as APIAsyncAttachment; - expect(media1.id).toBeDefined(); - expect(media1.type).toBe("unknown"); - expect(media1.url).toBeDefined(); - }); - }); + expect(media1.id).toBeDefined(); + expect(media1.type).toBe("unknown"); + expect(media1.url).toBeDefined(); + }); + }); - describe("POST /api/v1/statuses", () => { - test("should create a new status and return an APIStatus object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl(`${base_url}/api/v1/statuses`, base_url), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - status: "Hello, world!", - visibility: "public", - media_ids: [media1?.id], - }), - } - ) - ); + describe("POST /api/v1/statuses", () => { + test("should create a new status and return an APIStatus object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl(`${base_url}/api/v1/statuses`, base_url), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + status: "Hello, world!", + visibility: "public", + media_ids: [media1?.id], + }), + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - status = (await response.json()) as APIStatus; - expect(status.content).toContain("Hello, world!"); - expect(status.visibility).toBe("public"); - expect(status.account.id).toBe(user.id); - expect(status.replies_count).toBe(0); - expect(status.favourites_count).toBe(0); - expect(status.reblogged).toBe(false); - expect(status.favourited).toBe(false); - expect(status.media_attachments).toBeArrayOfSize(1); - expect(status.mentions).toEqual([]); - expect(status.tags).toEqual([]); - expect(status.sensitive).toBe(false); - expect(status.spoiler_text).toBe(""); - expect(status.language).toBeNull(); - expect(status.pinned).toBe(false); - expect(status.visibility).toBe("public"); - expect(status.card).toBeNull(); - expect(status.poll).toBeNull(); - expect(status.emojis).toEqual([]); - expect(status.in_reply_to_id).toBeNull(); - expect(status.in_reply_to_account_id).toBeNull(); - }); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + status = (await response.json()) as APIStatus; + expect(status.content).toContain("Hello, world!"); + expect(status.visibility).toBe("public"); + expect(status.account.id).toBe(user.id); + expect(status.replies_count).toBe(0); + expect(status.favourites_count).toBe(0); + expect(status.reblogged).toBe(false); + expect(status.favourited).toBe(false); + expect(status.media_attachments).toBeArrayOfSize(1); + expect(status.mentions).toEqual([]); + expect(status.tags).toEqual([]); + expect(status.sensitive).toBe(false); + expect(status.spoiler_text).toBe(""); + expect(status.language).toBeNull(); + expect(status.pinned).toBe(false); + expect(status.visibility).toBe("public"); + expect(status.card).toBeNull(); + expect(status.poll).toBeNull(); + expect(status.emojis).toEqual([]); + expect(status.in_reply_to_id).toBeNull(); + expect(status.in_reply_to_account_id).toBeNull(); + }); - test("should create a new status in reply to the previous one", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl(`${base_url}/api/v1/statuses`, base_url), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - status: "This is a reply!", - visibility: "public", - in_reply_to_id: status?.id, - }), - } - ) - ); + test("should create a new status in reply to the previous one", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl(`${base_url}/api/v1/statuses`, base_url), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + status: "This is a reply!", + visibility: "public", + in_reply_to_id: status?.id, + }), + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - status2 = (await response.json()) as APIStatus; - expect(status2.content).toContain("This is a reply!"); - expect(status2.visibility).toBe("public"); - expect(status2.account.id).toBe(user.id); - expect(status2.replies_count).toBe(0); - expect(status2.favourites_count).toBe(0); - expect(status2.reblogged).toBe(false); - expect(status2.favourited).toBe(false); - expect(status2.media_attachments).toEqual([]); - expect(status2.mentions).toEqual([]); - expect(status2.tags).toEqual([]); - expect(status2.sensitive).toBe(false); - expect(status2.spoiler_text).toBe(""); - expect(status2.language).toBeNull(); - expect(status2.pinned).toBe(false); - expect(status2.visibility).toBe("public"); - expect(status2.card).toBeNull(); - expect(status2.poll).toBeNull(); - expect(status2.emojis).toEqual([]); - expect(status2.in_reply_to_id).toEqual(status?.id || null); - expect(status2.in_reply_to_account_id).toEqual(user.id); - }); - }); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + status2 = (await response.json()) as APIStatus; + expect(status2.content).toContain("This is a reply!"); + expect(status2.visibility).toBe("public"); + expect(status2.account.id).toBe(user.id); + expect(status2.replies_count).toBe(0); + expect(status2.favourites_count).toBe(0); + expect(status2.reblogged).toBe(false); + expect(status2.favourited).toBe(false); + expect(status2.media_attachments).toEqual([]); + expect(status2.mentions).toEqual([]); + expect(status2.tags).toEqual([]); + expect(status2.sensitive).toBe(false); + expect(status2.spoiler_text).toBe(""); + expect(status2.language).toBeNull(); + expect(status2.pinned).toBe(false); + expect(status2.visibility).toBe("public"); + expect(status2.card).toBeNull(); + expect(status2.poll).toBeNull(); + expect(status2.emojis).toEqual([]); + expect(status2.in_reply_to_id).toEqual(status?.id || null); + expect(status2.in_reply_to_account_id).toEqual(user.id); + }); + }); - describe("GET /api/v1/statuses/:id", () => { - test("should return the specified status object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `${base_url}/api/v1/statuses/${status?.id}`, - base_url - ), - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); + describe("GET /api/v1/statuses/:id", () => { + test("should return the specified status object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `${base_url}/api/v1/statuses/${status?.id}`, + base_url, + ), + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - const statusJson = (await response.json()) as APIStatus; + const statusJson = (await response.json()) as APIStatus; - expect(statusJson.id).toBe(status?.id || ""); - expect(statusJson.content).toBeDefined(); - expect(statusJson.created_at).toBeDefined(); - expect(statusJson.account).toBeDefined(); - expect(statusJson.reblog).toBeDefined(); - expect(statusJson.application).toBeDefined(); - expect(statusJson.emojis).toBeDefined(); - expect(statusJson.media_attachments).toBeDefined(); - expect(statusJson.poll).toBeDefined(); - expect(statusJson.card).toBeDefined(); - expect(statusJson.visibility).toBeDefined(); - expect(statusJson.sensitive).toBeDefined(); - expect(statusJson.spoiler_text).toBeDefined(); - expect(statusJson.uri).toBeDefined(); - expect(statusJson.url).toBeDefined(); - expect(statusJson.replies_count).toBeDefined(); - expect(statusJson.reblogs_count).toBeDefined(); - expect(statusJson.favourites_count).toBeDefined(); - expect(statusJson.favourited).toBeDefined(); - expect(statusJson.reblogged).toBeDefined(); - expect(statusJson.muted).toBeDefined(); - expect(statusJson.bookmarked).toBeDefined(); - expect(statusJson.pinned).toBeDefined(); - }); - }); + expect(statusJson.id).toBe(status?.id || ""); + expect(statusJson.content).toBeDefined(); + expect(statusJson.created_at).toBeDefined(); + expect(statusJson.account).toBeDefined(); + expect(statusJson.reblog).toBeDefined(); + expect(statusJson.application).toBeDefined(); + expect(statusJson.emojis).toBeDefined(); + expect(statusJson.media_attachments).toBeDefined(); + expect(statusJson.poll).toBeDefined(); + expect(statusJson.card).toBeDefined(); + expect(statusJson.visibility).toBeDefined(); + expect(statusJson.sensitive).toBeDefined(); + expect(statusJson.spoiler_text).toBeDefined(); + expect(statusJson.uri).toBeDefined(); + expect(statusJson.url).toBeDefined(); + expect(statusJson.replies_count).toBeDefined(); + expect(statusJson.reblogs_count).toBeDefined(); + expect(statusJson.favourites_count).toBeDefined(); + expect(statusJson.favourited).toBeDefined(); + expect(statusJson.reblogged).toBeDefined(); + expect(statusJson.muted).toBeDefined(); + expect(statusJson.bookmarked).toBeDefined(); + expect(statusJson.pinned).toBeDefined(); + }); + }); - describe("POST /api/v1/statuses/:id/reblog", () => { - test("should reblog the specified status and return the reblogged status object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `${base_url}/api/v1/statuses/${status?.id}/reblog`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); + describe("POST /api/v1/statuses/:id/reblog", () => { + test("should reblog the specified status and return the reblogged status object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `${base_url}/api/v1/statuses/${status?.id}/reblog`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - const rebloggedStatus = (await response.json()) as APIStatus; + const rebloggedStatus = (await response.json()) as APIStatus; - expect(rebloggedStatus.id).toBeDefined(); - expect(rebloggedStatus.reblog?.id).toEqual(status?.id ?? ""); - expect(rebloggedStatus.reblog?.reblogged).toBe(true); - }); - }); + expect(rebloggedStatus.id).toBeDefined(); + expect(rebloggedStatus.reblog?.id).toEqual(status?.id ?? ""); + expect(rebloggedStatus.reblog?.reblogged).toBe(true); + }); + }); - describe("POST /api/v1/statuses/:id/unreblog", () => { - test("should unreblog the specified status and return the original status object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `${base_url}/api/v1/statuses/${status?.id}/unreblog`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); + describe("POST /api/v1/statuses/:id/unreblog", () => { + test("should unreblog the specified status and return the original status object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `${base_url}/api/v1/statuses/${status?.id}/unreblog`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - const unrebloggedStatus = (await response.json()) as APIStatus; + const unrebloggedStatus = (await response.json()) as APIStatus; - expect(unrebloggedStatus.id).toBeDefined(); - expect(unrebloggedStatus.reblogged).toBe(false); - }); - }); + expect(unrebloggedStatus.id).toBeDefined(); + expect(unrebloggedStatus.reblogged).toBe(false); + }); + }); - describe("GET /api/v1/statuses/:id/context", () => { - test("should return the context of the specified status", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `${base_url}/api/v1/statuses/${status?.id}/context`, - base_url - ), - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); + describe("GET /api/v1/statuses/:id/context", () => { + test("should return the context of the specified status", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `${base_url}/api/v1/statuses/${status?.id}/context`, + base_url, + ), + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - const context = (await response.json()) as APIContext; + const context = (await response.json()) as APIContext; - expect(context.ancestors.length).toBe(0); - expect(context.descendants.length).toBe(1); + expect(context.ancestors.length).toBe(0); + expect(context.descendants.length).toBe(1); - // First descendant should be status2 - expect(context.descendants[0].id).toBe(status2?.id || ""); - }); - }); + // First descendant should be status2 + expect(context.descendants[0].id).toBe(status2?.id || ""); + }); + }); - describe("GET /api/v1/timelines/public", () => { - test("should return an array of APIStatus objects that includes the created status", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `${base_url}/api/v1/timelines/public`, - base_url - ), - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); + describe("GET /api/v1/timelines/public", () => { + test("should return an array of APIStatus objects that includes the created status", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `${base_url}/api/v1/timelines/public`, + base_url, + ), + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - const statuses = (await response.json()) as APIStatus[]; + const statuses = (await response.json()) as APIStatus[]; - expect(statuses.some(s => s.id === status?.id)).toBe(true); - }); - }); + expect(statuses.some((s) => s.id === status?.id)).toBe(true); + }); + }); - describe("GET /api/v1/accounts/:id/statuses", () => { - test("should return the statuses of the specified user", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `${base_url}/api/v1/accounts/${user.id}/statuses`, - base_url - ), - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); + describe("GET /api/v1/accounts/:id/statuses", () => { + test("should return the statuses of the specified user", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `${base_url}/api/v1/accounts/${user.id}/statuses`, + base_url, + ), + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - const statuses = (await response.json()) as APIStatus[]; + const statuses = (await response.json()) as APIStatus[]; - expect(statuses.length).toBe(2); + expect(statuses.length).toBe(2); - const status1 = statuses[0]; + const status1 = statuses[0]; - // Basic validation - expect(status1.content).toContain("This is a reply!"); - expect(status1.visibility).toBe("public"); - expect(status1.account.id).toBe(user.id); - }); - }); + // Basic validation + expect(status1.content).toContain("This is a reply!"); + expect(status1.visibility).toBe("public"); + expect(status1.account.id).toBe(user.id); + }); + }); - describe("POST /api/v1/statuses/:id/favourite", () => { - test("should favourite the specified status object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `${base_url}/api/v1/statuses/${status?.id}/favourite`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); + describe("POST /api/v1/statuses/:id/favourite", () => { + test("should favourite the specified status object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `${base_url}/api/v1/statuses/${status?.id}/favourite`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); - expect(response.status).toBe(200); - }); - }); + expect(response.status).toBe(200); + }); + }); - describe("GET /api/v1/statuses/:id/favourited_by", () => { - test("should return an array of User objects who favourited the specified status", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `${base_url}/api/v1/statuses/${status?.id}/favourited_by`, - base_url - ), - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); + describe("GET /api/v1/statuses/:id/favourited_by", () => { + test("should return an array of User objects who favourited the specified status", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `${base_url}/api/v1/statuses/${status?.id}/favourited_by`, + base_url, + ), + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - const users = (await response.json()) as APIAccount[]; + const users = (await response.json()) as APIAccount[]; - expect(users.length).toBe(1); - expect(users[0].id).toBe(user.id); - }); - }); + expect(users.length).toBe(1); + expect(users[0].id).toBe(user.id); + }); + }); - describe("POST /api/v1/statuses/:id/unfavourite", () => { - test("should unfavourite the specified status object", async () => { - // Unfavourite the status - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `${base_url}/api/v1/statuses/${status?.id}/unfavourite`, - base_url - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ) - ); + describe("POST /api/v1/statuses/:id/unfavourite", () => { + test("should unfavourite the specified status object", async () => { + // Unfavourite the status + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `${base_url}/api/v1/statuses/${status?.id}/unfavourite`, + base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); - const updatedStatus = (await response.json()) as APIStatus; + const updatedStatus = (await response.json()) as APIStatus; - expect(updatedStatus.favourited).toBe(false); - expect(updatedStatus.favourites_count).toBe(0); - }); - }); + expect(updatedStatus.favourited).toBe(false); + expect(updatedStatus.favourites_count).toBe(0); + }); + }); - describe("DELETE /api/v1/statuses/:id", () => { - test("should delete the specified status object", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `${base_url}/api/v1/statuses/${status?.id}`, - base_url - ), - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } - ) - ); + describe("DELETE /api/v1/statuses/:id", () => { + test("should delete the specified status object", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `${base_url}/api/v1/statuses/${status?.id}`, + base_url, + ), + { + method: "DELETE", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + }, + ), + ); - expect(response.status).toBe(200); - }); - }); + expect(response.status).toBe(200); + }); + }); }); diff --git a/tests/cli.skip-test.ts b/tests/cli.skip-test.ts index ca67faf5..7d8e3968 100644 --- a/tests/cli.skip-test.ts +++ b/tests/cli.skip-test.ts @@ -3,92 +3,92 @@ import { client } from "~database/datasource"; import { createNewLocalUser } from "~database/entities/User"; describe("cli.ts", () => { - describe("User creation", () => { - it("should execute user create command without admin flag", async () => { - afterAll(async () => { - await client.user.deleteMany({ - where: { - username: "testuser297", - email: "testuser297@gmail.com", - }, - }); - }); + describe("User creation", () => { + it("should execute user create command without admin flag", async () => { + afterAll(async () => { + await client.user.deleteMany({ + where: { + username: "testuser297", + email: "testuser297@gmail.com", + }, + }); + }); - // Run command and wait for it to finish - Bun.spawnSync([ - "bun", - "run", - "cli.ts", - "user", - "create", - "testuser297", - "password123", - "testuser297@gmail.com", - ]); + // Run command and wait for it to finish + Bun.spawnSync([ + "bun", + "run", + "cli.ts", + "user", + "create", + "testuser297", + "password123", + "testuser297@gmail.com", + ]); - const createdUser = await client.user.findFirst({ - where: { - username: "testuser297", - email: "testuser297@gmail.com", - }, - }); + const createdUser = await client.user.findFirst({ + where: { + username: "testuser297", + email: "testuser297@gmail.com", + }, + }); - expect(createdUser).toBeDefined(); - }); + expect(createdUser).toBeDefined(); + }); - it("should execute user create command with admin flag", async () => { - afterAll(async () => { - await client.user.deleteMany({ - where: { - username: "testuser297", - email: "testuser297@gmail.com", - }, - }); - }); + it("should execute user create command with admin flag", async () => { + afterAll(async () => { + await client.user.deleteMany({ + where: { + username: "testuser297", + email: "testuser297@gmail.com", + }, + }); + }); - // Run command and wait for it to finish - Bun.spawnSync([ - "bun", - "run", - "cli.ts", - "user", - "create", - "testuser297", - "password123", - "testuser297@gmail.com", - "--admin", - ]); + // Run command and wait for it to finish + Bun.spawnSync([ + "bun", + "run", + "cli.ts", + "user", + "create", + "testuser297", + "password123", + "testuser297@gmail.com", + "--admin", + ]); - const createdUser = await client.user.findFirst({ - where: { - username: "testuser297", - email: "testuser297@gmail.com", - isAdmin: true, - }, - }); + const createdUser = await client.user.findFirst({ + where: { + username: "testuser297", + email: "testuser297@gmail.com", + isAdmin: true, + }, + }); - expect(createdUser).toBeDefined(); - }); - }); + expect(createdUser).toBeDefined(); + }); + }); - it("should execute user delete command", async () => { - beforeAll(async () => { - await createNewLocalUser({ - username: "bob124", - password: "jesus", - email: "bob124@bob124.com", - }); - }); + it("should execute user delete command", async () => { + beforeAll(async () => { + await createNewLocalUser({ + username: "bob124", + password: "jesus", + email: "bob124@bob124.com", + }); + }); - Bun.spawnSync(["bun", "run", "cli", "user", "delete", "bob124"]); + Bun.spawnSync(["bun", "run", "cli", "user", "delete", "bob124"]); - const userExists = await client.user.findFirst({ - where: { - username: "bob124", - email: "bob124@bob124.com", - }, - }); + const userExists = await client.user.findFirst({ + where: { + username: "bob124", + email: "bob124@bob124.com", + }, + }); - expect(!!userExists).toBe(false); - }); + expect(!!userExists).toBe(false); + }); }); diff --git a/tests/oauth-scopes.test.ts b/tests/oauth-scopes.test.ts index 0ccf2d12..51a60358 100644 --- a/tests/oauth-scopes.test.ts +++ b/tests/oauth-scopes.test.ts @@ -1,95 +1,135 @@ -import { checkIfOauthIsValid } from "@oauth"; import { describe, expect, it } from "bun:test"; +import { checkIfOauthIsValid } from "@oauth"; +import type { Application } from "@prisma/client"; describe("checkIfOauthIsValid", () => { - it("should return true when routeScopes and application.scopes are empty", () => { - const application = { scopes: "" }; - const routeScopes: string[] = []; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(true); - }); + it("should return true when routeScopes and application.scopes are empty", () => { + const application = { scopes: "" }; + const routeScopes: string[] = []; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(true); + }); - it("should return true when routeScopes is empty and application.scopes contains write:* or write", () => { - const application = { scopes: "write:*" }; - const routeScopes: string[] = []; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(true); - }); + it("should return true when routeScopes is empty and application.scopes contains write:* or write", () => { + const application = { scopes: "write:*" }; + const routeScopes: string[] = []; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(true); + }); - it("should return true when routeScopes is empty and application.scopes contains read:* or read", () => { - const application = { scopes: "read:*" }; - const routeScopes: string[] = []; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(true); - }); + it("should return true when routeScopes is empty and application.scopes contains read:* or read", () => { + const application = { scopes: "read:*" }; + const routeScopes: string[] = []; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(true); + }); - it("should return true when routeScopes contains only write: permissions and application.scopes contains write:* or write", () => { - const application = { scopes: "write:*" }; - const routeScopes = ["write:users", "write:posts"]; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(true); - }); + it("should return true when routeScopes contains only write: permissions and application.scopes contains write:* or write", () => { + const application = { scopes: "write:*" }; + const routeScopes = ["write:users", "write:posts"]; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(true); + }); - it("should return true when routeScopes contains only read: permissions and application.scopes contains read:* or read", () => { - const application = { scopes: "read:*" }; - const routeScopes = ["read:users", "read:posts"]; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(true); - }); + it("should return true when routeScopes contains only read: permissions and application.scopes contains read:* or read", () => { + const application = { scopes: "read:*" }; + const routeScopes = ["read:users", "read:posts"]; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(true); + }); - it("should return true when routeScopes contains both write: and read: permissions and application.scopes contains write:* or write and read:* or read", () => { - const application = { scopes: "write:* read:*" }; - const routeScopes = ["write:users", "read:posts"]; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(true); - }); + it("should return true when routeScopes contains both write: and read: permissions and application.scopes contains write:* or write and read:* or read", () => { + const application = { scopes: "write:* read:*" }; + const routeScopes = ["write:users", "read:posts"]; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(true); + }); - it("should return false when routeScopes contains write: permissions but application.scopes does not contain write:* or write", () => { - const application = { scopes: "read:*" }; - const routeScopes = ["write:users", "write:posts"]; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(false); - }); + it("should return false when routeScopes contains write: permissions but application.scopes does not contain write:* or write", () => { + const application = { scopes: "read:*" }; + const routeScopes = ["write:users", "write:posts"]; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(false); + }); - it("should return false when routeScopes contains read: permissions but application.scopes does not contain read:* or read", () => { - const application = { scopes: "write:*" }; - const routeScopes = ["read:users", "read:posts"]; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(false); - }); + it("should return false when routeScopes contains read: permissions but application.scopes does not contain read:* or read", () => { + const application = { scopes: "write:*" }; + const routeScopes = ["read:users", "read:posts"]; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(false); + }); - it("should return false when routeScopes contains both write: and read: permissions but application.scopes does not contain write:* or write and read:* or read", () => { - const application = { scopes: "" }; - const routeScopes = ["write:users", "read:posts"]; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(false); - }); + it("should return false when routeScopes contains both write: and read: permissions but application.scopes does not contain write:* or write and read:* or read", () => { + const application = { scopes: "" }; + const routeScopes = ["write:users", "read:posts"]; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(false); + }); - it("should return true when routeScopes contains a mix of valid and invalid permissions and application.scopes contains all the required permissions", () => { - const application = { scopes: "write:* read:*" }; - const routeScopes = ["write:users", "invalid:permission", "read:posts"]; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(true); - }); + it("should return true when routeScopes contains a mix of valid and invalid permissions and application.scopes contains all the required permissions", () => { + const application = { scopes: "write:* read:*" }; + const routeScopes = ["write:users", "invalid:permission", "read:posts"]; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(true); + }); - it("should return false when routeScopes contains a mix of valid and invalid permissions but application.scopes does not contain all the required permissions", () => { - const application = { scopes: "write:*" }; - const routeScopes = ["write:users", "invalid:permission", "read:posts"]; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(false); - }); + it("should return false when routeScopes contains a mix of valid and invalid permissions but application.scopes does not contain all the required permissions", () => { + const application = { scopes: "write:*" }; + const routeScopes = ["write:users", "invalid:permission", "read:posts"]; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(false); + }); - it("should return true when routeScopes contains a mix of valid write and read permissions and application.scopes contains all the required permissions", () => { - const application = { scopes: "write:* read:posts" }; - const routeScopes = ["write:users", "read:posts"]; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(true); - }); + it("should return true when routeScopes contains a mix of valid write and read permissions and application.scopes contains all the required permissions", () => { + const application = { scopes: "write:* read:posts" }; + const routeScopes = ["write:users", "read:posts"]; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(true); + }); - it("should return false when routeScopes contains a mix of valid write and read permissions but application.scopes does not contain all the required permissions", () => { - const application = { scopes: "write:*" }; - const routeScopes = ["write:users", "read:posts"]; - const result = checkIfOauthIsValid(application as any, routeScopes); - expect(result).toBe(false); - }); + it("should return false when routeScopes contains a mix of valid write and read permissions but application.scopes does not contain all the required permissions", () => { + const application = { scopes: "write:*" }; + const routeScopes = ["write:users", "read:posts"]; + const result = checkIfOauthIsValid( + application as Application, + routeScopes, + ); + expect(result).toBe(false); + }); }); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index d8b1b41d..2a905643 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -1,5 +1,5 @@ -import type { Application, Token } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Application, Token } from "@prisma/client"; import { client } from "~database/datasource"; import { createNewLocalUser } from "~database/entities/User"; import { sendTestRequest, wrapRelativeUrl } from "./utils"; @@ -12,152 +12,152 @@ let code: string; let token: Token; beforeAll(async () => { - // Init test user - await createNewLocalUser({ - email: "test@test.com", - username: "test", - password: "test", - display_name: "", - }); + // Init test user + await createNewLocalUser({ + email: "test@test.com", + username: "test", + password: "test", + display_name: "", + }); }); describe("POST /api/v1/apps/", () => { - test("should create an application", async () => { - const formData = new FormData(); + test("should create an application", async () => { + const formData = new FormData(); - formData.append("client_name", "Test Application"); - formData.append("website", "https://example.com"); - formData.append("redirect_uris", "https://example.com"); - formData.append("scopes", "read write"); + formData.append("client_name", "Test Application"); + formData.append("website", "https://example.com"); + formData.append("redirect_uris", "https://example.com"); + formData.append("scopes", "read write"); - const response = await sendTestRequest( - new Request(wrapRelativeUrl("/api/v1/apps/", base_url), { - method: "POST", - body: formData, - }) - ); + const response = await sendTestRequest( + new Request(wrapRelativeUrl("/api/v1/apps/", base_url), { + method: "POST", + body: formData, + }), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe("application/json"); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const json = await response.json(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const json = await response.json(); - expect(json).toEqual({ - id: expect.any(String), - name: "Test Application", - website: "https://example.com", - client_id: expect.any(String), - client_secret: expect.any(String), - redirect_uri: "https://example.com", - vapid_link: null, - }); + expect(json).toEqual({ + id: expect.any(String), + name: "Test Application", + website: "https://example.com", + client_id: expect.any(String), + client_secret: expect.any(String), + redirect_uri: "https://example.com", + vapid_link: null, + }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - client_id = json.client_id; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - client_secret = json.client_secret; - }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + client_id = json.client_id; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + client_secret = json.client_secret; + }); }); describe("POST /auth/login/", () => { - test("should get a code", async () => { - const formData = new FormData(); + test("should get a code", async () => { + const formData = new FormData(); - formData.append("email", "test@test.com"); - formData.append("password", "test"); + formData.append("email", "test@test.com"); + formData.append("password", "test"); - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, - base_url - ), - { - method: "POST", - body: formData, - } - ) - ); + const response = await sendTestRequest( + new Request( + wrapRelativeUrl( + `/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, + base_url, + ), + { + method: "POST", + body: formData, + }, + ), + ); - expect(response.status).toBe(302); - expect(response.headers.get("Location")).toMatch( - /^\/oauth\/redirect\?redirect_uri=https%3A%2F%2Fexample.com&code=[a-f0-9]+&client_id=[a-zA-Z0-9_-]+&application=Test\+Application&website=https%3A%2F%2Fexample.com&scope=read\+write$/ - ); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toMatch( + /^\/oauth\/redirect\?redirect_uri=https%3A%2F%2Fexample.com&code=[a-f0-9]+&client_id=[a-zA-Z0-9_-]+&application=Test\+Application&website=https%3A%2F%2Fexample.com&scope=read\+write$/, + ); - code = - new URL( - response.headers.get("Location") ?? "", - "http://lysand.localhost:8080" - ).searchParams.get("code") ?? ""; - }); + code = + new URL( + response.headers.get("Location") ?? "", + "http://lysand.localhost:8080", + ).searchParams.get("code") ?? ""; + }); }); describe("POST /oauth/token/", () => { - test("should get an access token", async () => { - const formData = new FormData(); + test("should get an access token", async () => { + const formData = new FormData(); - formData.append("grant_type", "authorization_code"); - formData.append("code", code); - formData.append("redirect_uri", "https://example.com"); - formData.append("client_id", client_id); - formData.append("client_secret", client_secret); - formData.append("scope", "read+write"); + formData.append("grant_type", "authorization_code"); + formData.append("code", code); + formData.append("redirect_uri", "https://example.com"); + formData.append("client_id", client_id); + formData.append("client_secret", client_secret); + formData.append("scope", "read+write"); - const response = await sendTestRequest( - new Request(wrapRelativeUrl("/oauth/token/", base_url), { - method: "POST", - body: formData, - }) - ); + const response = await sendTestRequest( + new Request(wrapRelativeUrl("/oauth/token/", base_url), { + method: "POST", + body: formData, + }), + ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const json = await response.json(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const json = await response.json(); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe("application/json"); - expect(json).toEqual({ - access_token: expect.any(String), - token_type: "Bearer", - scope: "read write", - created_at: expect.any(String), - }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(json).toEqual({ + access_token: expect.any(String), + token_type: "Bearer", + scope: "read write", + created_at: expect.any(String), + }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - token = json; - }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + token = json; + }); }); describe("GET /api/v1/apps/verify_credentials", () => { - test("should return the authenticated application's credentials", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl("/api/v1/apps/verify_credentials", base_url), - { - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } - ) - ); + test("should return the authenticated application's credentials", async () => { + const response = await sendTestRequest( + new Request( + wrapRelativeUrl("/api/v1/apps/verify_credentials", base_url), + { + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + }, + ), + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe("application/json"); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); - const credentials = (await response.json()) as Partial; + const credentials = (await response.json()) as Partial; - expect(credentials.name).toBe("Test Application"); - expect(credentials.website).toBe("https://example.com"); - expect(credentials.redirect_uris).toBe("https://example.com"); - expect(credentials.scopes).toBe("read write"); - }); + expect(credentials.name).toBe("Test Application"); + expect(credentials.website).toBe("https://example.com"); + expect(credentials.redirect_uris).toBe("https://example.com"); + expect(credentials.scopes).toBe("read write"); + }); }); afterAll(async () => { - // Clean up user - await client.user.delete({ - where: { - username: "test", - }, - }); + // Clean up user + await client.user.delete({ + where: { + username: "test", + }, + }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 60de2330..53d23be2 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -7,9 +7,9 @@ import { server } from "~index"; * @returns Response from the server */ export async function sendTestRequest(req: Request) { - return server.fetch(req); + return server.fetch(req); } export function wrapRelativeUrl(url: string, base_url: string) { - return new URL(url, base_url); + return new URL(url, base_url); } diff --git a/tsconfig.json b/tsconfig.json index 66108e6b..5cbff0d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,42 +1,42 @@ { - "compilerOptions": { - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "module": "esnext", - "target": "esnext", - "moduleResolution": "bundler", - "moduleDetection": "force", - "allowImportingTsExtensions": true, - "noEmit": true, - "composite": true, - "strict": true, - "downlevelIteration": true, - "skipLibCheck": true, - "noImplicitAny": true, - "jsx": "preserve", - "allowSyntheticDefaultImports": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "forceConsistentCasingInFileNames": true, - "allowJs": true, - "emitDecoratorMetadata": false, - "experimentalDecorators": true, - "verbatimModuleSyntax": true, - "types": [ - "bun-types", // add Bun global - ], - "paths": { - "@*": ["./utils/*"], - "~*": ["./*"], - "+*": ["./server/api/*"], - }, - }, - "include": [ - "*.ts", - "*.d.ts", - "*.vue", - "**/*.ts", - "**/*.d.ts", - "**/*.vue", - "server/api/.well-known/**/*.ts", - ] + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "noImplicitAny": true, + "jsx": "preserve", + "allowSyntheticDefaultImports": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "emitDecoratorMetadata": false, + "experimentalDecorators": true, + "verbatimModuleSyntax": true, + "types": [ + "bun-types" // add Bun global + ], + "paths": { + "@*": ["./utils/*"], + "~*": ["./*"], + "+*": ["./server/api/*"] + } + }, + "include": [ + "*.ts", + "*.d.ts", + "*.vue", + "**/*.ts", + "**/*.d.ts", + "**/*.vue", + "server/api/.well-known/**/*.ts" + ] } diff --git a/types.d.ts b/types.d.ts index 790a8ec9..eff56313 100644 --- a/types.d.ts +++ b/types.d.ts @@ -4,25 +4,25 @@ import type { APIField } from "~types/entities/field"; import type { ContentFormat } from "~types/lysand/Object"; declare namespace global { - namespace PrismaJson { - type InstanceLogo = ContentFormat[]; - type ObjectData = LysandObject; - type ObjectExtensions = LysandObject["extensions"]; - interface UserEndpoints { - inbox: string; - liked: string; - outbox: string; - disliked: string; - featured: string; - followers: string; - following: string; - } - interface UserSource { - note: string; - fields: APIField[]; - privacy: APIAccount["privacy"]; - language: string; - sensitive: boolean; - } - } + namespace PrismaJson { + type InstanceLogo = ContentFormat[]; + type ObjectData = LysandObject; + type ObjectExtensions = LysandObject["extensions"]; + interface UserEndpoints { + inbox: string; + liked: string; + outbox: string; + disliked: string; + featured: string; + followers: string; + following: string; + } + interface UserSource { + note: string; + fields: APIField[]; + privacy: APIAccount["privacy"]; + language: string; + sensitive: boolean; + } + } } diff --git a/types/activitypub.ts b/types/activitypub.ts index 619b8563..ae4d8a83 100644 --- a/types/activitypub.ts +++ b/types/activitypub.ts @@ -1,141 +1,141 @@ export type APActivityPubContext = - | "https://www.w3.org/ns/activitystreams" - | { - ostatus: string; - atomUri: string; - inReplyToAtomUri: string; - conversation: string; - sensitive: string; - toot: string; - votersCount: string; - litepub: string; - directMessage: string; - }; + | "https://www.w3.org/ns/activitystreams" + | { + ostatus: string; + atomUri: string; + inReplyToAtomUri: string; + conversation: string; + sensitive: string; + toot: string; + votersCount: string; + litepub: string; + directMessage: string; + }; export interface APActivityPubObject { - id: string; - type: string; - summary?: string; - inReplyTo?: string; - published: string; - url: string; - attributedTo: string; - to: string[]; - cc: string[]; - sensitive?: boolean; - atomUri: string; - inReplyToAtomUri?: string; - conversation: string; - content: string; - contentMap: Record; - attachment: APActivityPubAttachment[]; - tag: APTag[]; - context?: string; - quoteUri?: string; - quoteUrl?: string; - source?: { - content: string; - mediaType: string; - }; + id: string; + type: string; + summary?: string; + inReplyTo?: string; + published: string; + url: string; + attributedTo: string; + to: string[]; + cc: string[]; + sensitive?: boolean; + atomUri: string; + inReplyToAtomUri?: string; + conversation: string; + content: string; + contentMap: Record; + attachment: APActivityPubAttachment[]; + tag: APTag[]; + context?: string; + quoteUri?: string; + quoteUrl?: string; + source?: { + content: string; + mediaType: string; + }; } export interface APActivityPubAttachment { - type?: string; - mediaType?: string; - url?: string; - name?: string; + type?: string; + mediaType?: string; + url?: string; + name?: string; } -export interface APActivityPubCollection { - id: string; - type: string; - first?: { - type: string; - next: string; - partOf: string; - items: any[]; // replace any with your item type - }; +export interface APActivityPubCollection { + id: string; + type: string; + first?: { + type: string; + next: string; + partOf: string; + items: T[]; + }; } export interface APActivityPubNote extends APActivityPubObject { - type: "Note"; + type: "Note"; } export interface APActivityPubActivity { - "@context": APActivityPubContext[]; - id: string; - type: string; - actor: string; - published: string; - to: string[]; - cc: string[]; - object: APActivityPubNote; + "@context": APActivityPubContext[]; + id: string; + type: string; + actor: string; + published: string; + to: string[]; + cc: string[]; + object: APActivityPubNote; } export type APActorContext = - | "https://www.w3.org/ns/activitystreams" - | "https://w3id.org/security/v1" - | Record< - string, - | string - | { "@id": string; "@type": string } - | { "@container": string; "@id": string } - >; + | "https://www.w3.org/ns/activitystreams" + | "https://w3id.org/security/v1" + | Record< + string, + | string + | { "@id": string; "@type": string } + | { "@container": string; "@id": string } + >; export interface APActorPublicKey { - id: string; - owner: string; - publicKeyPem: string; + id: string; + owner: string; + publicKeyPem: string; } export interface APActorEndpoints { - sharedInbox: string; + sharedInbox: string; } export interface APActorIcon { - type: string; - mediaType: string; - url: string; + type: string; + mediaType: string; + url: string; } export interface APActor { - "@context": APActorContext[]; - id: string; - type: string; - following: string; - followers: string; - inbox: string; - outbox: string; - featured: string; - featuredTags: string; - preferredUsername: string; - name: string; - summary: string; - url: string; - manuallyApprovesFollowers: boolean; - discoverable: boolean; - indexable: boolean; - published: string; - memorial: boolean; - devices: string; - publicKey: APActorPublicKey; - tag: APTag[]; - attachment: APAttachment[]; - endpoints: APActorEndpoints; - icon: APActorIcon; + "@context": APActorContext[]; + id: string; + type: string; + following: string; + followers: string; + inbox: string; + outbox: string; + featured: string; + featuredTags: string; + preferredUsername: string; + name: string; + summary: string; + url: string; + manuallyApprovesFollowers: boolean; + discoverable: boolean; + indexable: boolean; + published: string; + memorial: boolean; + devices: string; + publicKey: APActorPublicKey; + tag: APTag[]; + attachment: APAttachment[]; + endpoints: APActorEndpoints; + icon: APActorIcon; } export interface APTag { - type: string; - href: string; - name: string; + type: string; + href: string; + name: string; } export interface APAttachment { - type: string; - mediaType: string; - url: string; - name?: string; - blurhash?: string; - description?: string; + type: string; + mediaType: string; + url: string; + name?: string; + blurhash?: string; + description?: string; } diff --git a/types/api.ts b/types/api.ts index 215762b7..7b695f52 100644 --- a/types/api.ts +++ b/types/api.ts @@ -1,13 +1,13 @@ export interface APIRouteMeta { - allowedMethods: ("GET" | "POST" | "PUT" | "DELETE" | "PATCH")[]; - ratelimits: { - max: number; - duration: number; - }; - route: string; - auth: { - required: boolean; - requiredOnMethods?: ("GET" | "POST" | "PUT" | "DELETE" | "PATCH")[]; - oauthPermissions?: string[]; - }; + allowedMethods: ("GET" | "POST" | "PUT" | "DELETE" | "PATCH")[]; + ratelimits: { + max: number; + duration: number; + }; + route: string; + auth: { + required: boolean; + requiredOnMethods?: ("GET" | "POST" | "PUT" | "DELETE" | "PATCH")[]; + oauthPermissions?: string[]; + }; } diff --git a/types/entities/account.ts b/types/entities/account.ts index 0d418927..bd77eedb 100644 --- a/types/entities/account.ts +++ b/types/entities/account.ts @@ -4,32 +4,32 @@ import type { APIRole } from "./role"; import type { APISource } from "./source"; export interface APIAccount { - id: string; - username: string; - acct: string; - display_name: string; - locked: boolean; - discoverable?: boolean; - group: boolean | null; - noindex: boolean | null; - suspended: boolean | null; - limited: boolean | null; - created_at: string; - followers_count: number; - following_count: number; - statuses_count: number; - note: string; - url: string; - avatar: string; - avatar_static: string; - header: string; - header_static: string; - emojis: APIEmoji[]; - moved: APIAccount | null; - fields: APIField[]; - bot: boolean; - source?: APISource; - role?: APIRole; - mute_expires_at?: string; - pleroma?: any; + id: string; + username: string; + acct: string; + display_name: string; + locked: boolean; + discoverable?: boolean; + group: boolean | null; + noindex: boolean | null; + suspended: boolean | null; + limited: boolean | null; + created_at: string; + followers_count: number; + following_count: number; + statuses_count: number; + note: string; + url: string; + avatar: string; + avatar_static: string; + header: string; + header_static: string; + emojis: APIEmoji[]; + moved: APIAccount | null; + fields: APIField[]; + bot: boolean; + source?: APISource; + role?: APIRole; + mute_expires_at?: string; + pleroma?: object; } diff --git a/types/entities/activity.ts b/types/entities/activity.ts index 5b21fdc2..0b0b286b 100644 --- a/types/entities/activity.ts +++ b/types/entities/activity.ts @@ -1,6 +1,6 @@ export interface APIActivity { - week: string; - statuses: string; - logins: string; - registrations: string; + week: string; + statuses: string; + logins: string; + registrations: string; } diff --git a/types/entities/announcement.ts b/types/entities/announcement.ts index 33fa3a41..01147efc 100644 --- a/types/entities/announcement.ts +++ b/types/entities/announcement.ts @@ -2,38 +2,38 @@ import type { APIEmoji } from "./emoji"; import type { APIStatusTag } from "./status"; export interface APIAnnouncement { - id: string; - content: string; - starts_at: string | null; - ends_at: string | null; - published: boolean; - all_day: boolean; - published_at: string; - updated_at: string; - read: boolean | null; - mentions: AnnouncementAccount[]; - statuses: AnnouncementStatus[]; - tags: APIStatusTag[]; - emojis: APIEmoji[]; - reactions: AnnouncementReaction[]; + id: string; + content: string; + starts_at: string | null; + ends_at: string | null; + published: boolean; + all_day: boolean; + published_at: string; + updated_at: string; + read: boolean | null; + mentions: AnnouncementAccount[]; + statuses: AnnouncementStatus[]; + tags: APIStatusTag[]; + emojis: APIEmoji[]; + reactions: AnnouncementReaction[]; } export interface AnnouncementAccount { - id: string; - username: string; - url: string; - acct: string; + id: string; + username: string; + url: string; + acct: string; } export interface AnnouncementStatus { - id: string; - url: string; + id: string; + url: string; } export interface AnnouncementReaction { - name: string; - count: number; - me: boolean | null; - url: string | null; - static_url: string | null; + name: string; + count: number; + me: boolean | null; + url: string | null; + static_url: string | null; } diff --git a/types/entities/application.ts b/types/entities/application.ts index 3439df77..ad047ed6 100644 --- a/types/entities/application.ts +++ b/types/entities/application.ts @@ -1,5 +1,5 @@ export interface APIApplication { - name: string; - website?: string | null; - vapid_key?: string | null; + name: string; + website?: string | null; + vapid_key?: string | null; } diff --git a/types/entities/async_attachment.ts b/types/entities/async_attachment.ts index 9cb0f371..ede90e89 100644 --- a/types/entities/async_attachment.ts +++ b/types/entities/async_attachment.ts @@ -1,13 +1,13 @@ import type { APIMeta } from "./attachment"; export interface APIAsyncAttachment { - id: string; - type: "unknown" | "image" | "gifv" | "video" | "audio"; - url: string | null; - remote_url: string | null; - preview_url: string; - text_url: string | null; - meta: APIMeta | null; - description: string | null; - blurhash: string | null; + id: string; + type: "unknown" | "image" | "gifv" | "video" | "audio"; + url: string | null; + remote_url: string | null; + preview_url: string; + text_url: string | null; + meta: APIMeta | null; + description: string | null; + blurhash: string | null; } diff --git a/types/entities/attachment.ts b/types/entities/attachment.ts index b494fdd0..c8e64a6f 100644 --- a/types/entities/attachment.ts +++ b/types/entities/attachment.ts @@ -1,47 +1,47 @@ export interface APISub { - // For Image, Gifv, and Video - width?: number; - height?: number; - size?: string; - aspect?: number; + // For Image, Gifv, and Video + width?: number; + height?: number; + size?: string; + aspect?: number; - // For Gifv and Video - frame_rate?: string; + // For Gifv and Video + frame_rate?: string; - // For Audio, Gifv, and Video - duration?: number; - bitrate?: number; + // For Audio, Gifv, and Video + duration?: number; + bitrate?: number; } export interface APIFocus { - x: number; - y: number; + x: number; + y: number; } export interface APIMeta { - original?: APISub; - small?: APISub; - focus?: APIFocus; - length?: string; - duration?: number; - fps?: number; - size?: string; - width?: number; - height?: number; - aspect?: number; - audio_encode?: string; - audio_bitrate?: string; - audio_channel?: string; + original?: APISub; + small?: APISub; + focus?: APIFocus; + length?: string; + duration?: number; + fps?: number; + size?: string; + width?: number; + height?: number; + aspect?: number; + audio_encode?: string; + audio_bitrate?: string; + audio_channel?: string; } export interface APIAttachment { - id: string; - type: "unknown" | "image" | "gifv" | "video" | "audio"; - url: string; - remote_url: string | null; - preview_url: string | null; - text_url: string | null; - meta: APIMeta | null; - description: string | null; - blurhash: string | null; + id: string; + type: "unknown" | "image" | "gifv" | "video" | "audio"; + url: string; + remote_url: string | null; + preview_url: string | null; + text_url: string | null; + meta: APIMeta | null; + description: string | null; + blurhash: string | null; } diff --git a/types/entities/card.ts b/types/entities/card.ts index 1e139ad0..01ed6b64 100644 --- a/types/entities/card.ts +++ b/types/entities/card.ts @@ -1,16 +1,16 @@ export interface APICard { - url: string; - title: string; - description: string; - type: "link" | "photo" | "video" | "rich"; - image: string | null; - author_name: string; - author_url: string; - provider_name: string; - provider_url: string; - html: string; - width: number; - height: number; - embed_url: string; - blurhash: string | null; + url: string; + title: string; + description: string; + type: "link" | "photo" | "video" | "rich"; + image: string | null; + author_name: string; + author_url: string; + provider_name: string; + provider_url: string; + html: string; + width: number; + height: number; + embed_url: string; + blurhash: string | null; } diff --git a/types/entities/context.ts b/types/entities/context.ts index 160a2b5a..29978aa8 100644 --- a/types/entities/context.ts +++ b/types/entities/context.ts @@ -1,6 +1,6 @@ import type { APIStatus } from "./status"; export interface APIContext { - ancestors: APIStatus[]; - descendants: APIStatus[]; + ancestors: APIStatus[]; + descendants: APIStatus[]; } diff --git a/types/entities/conversation.ts b/types/entities/conversation.ts index ac7a10ab..bc2cf5d5 100644 --- a/types/entities/conversation.ts +++ b/types/entities/conversation.ts @@ -2,8 +2,8 @@ import type { APIAccount } from "./account"; import type { APIStatus } from "./status"; export interface APIConversation { - id: string; - accounts: APIAccount[]; - last_status: APIStatus | null; - unread: boolean; + id: string; + accounts: APIAccount[]; + last_status: APIStatus | null; + unread: boolean; } diff --git a/types/entities/emoji.ts b/types/entities/emoji.ts index ea725c8e..d4732931 100644 --- a/types/entities/emoji.ts +++ b/types/entities/emoji.ts @@ -1,7 +1,7 @@ export interface APIEmoji { - shortcode: string; - static_url: string; - url: string; - visible_in_picker: boolean; - category?: string; + shortcode: string; + static_url: string; + url: string; + visible_in_picker: boolean; + category?: string; } diff --git a/types/entities/featured_tag.ts b/types/entities/featured_tag.ts index e8a04d2d..75d03cc1 100644 --- a/types/entities/featured_tag.ts +++ b/types/entities/featured_tag.ts @@ -1,6 +1,6 @@ export interface APIFeaturedTag { - id: string; - name: string; - statuses_count: number; - last_status_at: string; + id: string; + name: string; + statuses_count: number; + last_status_at: string; } diff --git a/types/entities/field.ts b/types/entities/field.ts index 2030e80d..9c9bb666 100644 --- a/types/entities/field.ts +++ b/types/entities/field.ts @@ -1,5 +1,5 @@ export interface APIField { - name: string; - value: string; - verified_at: string | null; + name: string; + value: string; + verified_at: string | null; } diff --git a/types/entities/filter.ts b/types/entities/filter.ts index e3fd6d43..46a5438f 100644 --- a/types/entities/filter.ts +++ b/types/entities/filter.ts @@ -1,10 +1,10 @@ export interface APIFilter { - id: string; - phrase: string; - context: FilterContext[]; - expires_at: string | null; - irreversible: boolean; - whole_word: boolean; + id: string; + phrase: string; + context: FilterContext[]; + expires_at: string | null; + irreversible: boolean; + whole_word: boolean; } export type FilterContext = string; diff --git a/types/entities/history.ts b/types/entities/history.ts index 985b52f0..312ada9b 100644 --- a/types/entities/history.ts +++ b/types/entities/history.ts @@ -1,5 +1,5 @@ export interface APIHistory { - day: string; - uses: number; - accounts: number; + day: string; + uses: number; + accounts: number; } diff --git a/types/entities/identity_proof.ts b/types/entities/identity_proof.ts index 5964723e..85334ef3 100644 --- a/types/entities/identity_proof.ts +++ b/types/entities/identity_proof.ts @@ -1,7 +1,7 @@ export interface APIIdentityProof { - provider: string; - provider_username: string; - updated_at: string; - proof_url: string; - profile_url: string; + provider: string; + provider_username: string; + updated_at: string; + proof_url: string; + profile_url: string; } diff --git a/types/entities/instance.ts b/types/entities/instance.ts index f1efd08a..5ffbdfe3 100644 --- a/types/entities/instance.ts +++ b/types/entities/instance.ts @@ -3,46 +3,46 @@ import type { APIStats } from "./stats"; import type { APIURLs } from "./urls"; export interface APIInstance { - tos_url: string | undefined; - uri: string; - title: string; - description: string; - email: string; - version: string; - thumbnail: string | null; - urls: APIURLs; - stats: APIStats; - languages: string[]; - registrations: boolean; - approval_required: boolean; - invites_enabled: boolean; - max_toot_chars?: number; - configuration: { - statuses: { - max_characters: number; - max_media_attachments: number; - characters_reserved_per_url: number; - }; - media_attachments: { - supported_mime_types: string[]; - image_size_limit: number; - image_matrix_limit: number; - video_size_limit: number; - video_frame_limit: number; - video_matrix_limit: number; - }; - polls: { - max_options: number; - max_characters_per_option: number; - min_expiration: number; - max_expiration: number; - }; - }; - contact_account: APIAccount; - rules: APIInstanceRule[]; + tos_url: string | undefined; + uri: string; + title: string; + description: string; + email: string; + version: string; + thumbnail: string | null; + urls: APIURLs; + stats: APIStats; + languages: string[]; + registrations: boolean; + approval_required: boolean; + invites_enabled: boolean; + max_toot_chars?: number; + configuration: { + statuses: { + max_characters: number; + max_media_attachments: number; + characters_reserved_per_url: number; + }; + media_attachments: { + supported_mime_types: string[]; + image_size_limit: number; + image_matrix_limit: number; + video_size_limit: number; + video_frame_limit: number; + video_matrix_limit: number; + }; + polls: { + max_options: number; + max_characters_per_option: number; + min_expiration: number; + max_expiration: number; + }; + }; + contact_account: APIAccount; + rules: APIInstanceRule[]; } export interface APIInstanceRule { - id: string; - text: string; + id: string; + text: string; } diff --git a/types/entities/list.ts b/types/entities/list.ts index 85ccbf52..08eedfc4 100644 --- a/types/entities/list.ts +++ b/types/entities/list.ts @@ -1,7 +1,7 @@ export interface APIList { - id: string; - title: string; - replies_policy: APIRepliesPolicy; + id: string; + title: string; + replies_policy: APIRepliesPolicy; } export type APIRepliesPolicy = "followed" | "list" | "none"; diff --git a/types/entities/marker.ts b/types/entities/marker.ts index d81b47aa..6468f6a4 100644 --- a/types/entities/marker.ts +++ b/types/entities/marker.ts @@ -1,12 +1,12 @@ export interface APIMarker { - home: { - last_read_id: string; - version: number; - updated_at: string; - }; - notifications: { - last_read_id: string; - version: number; - updated_at: string; - }; + home: { + last_read_id: string; + version: number; + updated_at: string; + }; + notifications: { + last_read_id: string; + version: number; + updated_at: string; + }; } diff --git a/types/entities/mention.ts b/types/entities/mention.ts index f229af90..d2d2cfd7 100644 --- a/types/entities/mention.ts +++ b/types/entities/mention.ts @@ -1,6 +1,6 @@ export interface APIMention { - id: string; - username: string; - url: string; - acct: string; + id: string; + username: string; + url: string; + acct: string; } diff --git a/types/entities/notification.ts b/types/entities/notification.ts index a9766473..7eb3815a 100644 --- a/types/entities/notification.ts +++ b/types/entities/notification.ts @@ -2,11 +2,11 @@ import type { APIAccount } from "./account"; import type { APIStatus } from "./status"; export interface APINotification { - account: APIAccount; - created_at: string; - id: string; - status?: APIStatus; - type: APINotificationType; + account: APIAccount; + created_at: string; + id: string; + status?: APIStatus; + type: APINotificationType; } export type APINotificationType = string; diff --git a/types/entities/poll.ts b/types/entities/poll.ts index 9242eab3..562a004d 100644 --- a/types/entities/poll.ts +++ b/types/entities/poll.ts @@ -1,11 +1,11 @@ import type { APIPollOption } from "./poll_option"; export interface APIPoll { - id: string; - expires_at: string | null; - expired: boolean; - multiple: boolean; - votes_count: number; - options: APIPollOption[]; - voted: boolean; + id: string; + expires_at: string | null; + expired: boolean; + multiple: boolean; + votes_count: number; + options: APIPollOption[]; + voted: boolean; } diff --git a/types/entities/poll_option.ts b/types/entities/poll_option.ts index 86017868..183c0c3c 100644 --- a/types/entities/poll_option.ts +++ b/types/entities/poll_option.ts @@ -1,4 +1,4 @@ export interface APIPollOption { - title: string; - votes_count: number | null; + title: string; + votes_count: number | null; } diff --git a/types/entities/preferences.ts b/types/entities/preferences.ts index 1ad5b14f..7eacab1d 100644 --- a/types/entities/preferences.ts +++ b/types/entities/preferences.ts @@ -1,7 +1,7 @@ export interface APIPreferences { - "posting:default:visibility": "public" | "unlisted" | "private" | "direct"; - "posting:default:sensitive": boolean; - "posting:default:language": string | null; - "reading:expand:media": "default" | "show_all" | "hide_all"; - "reading:expand:spoilers": boolean; + "posting:default:visibility": "public" | "unlisted" | "private" | "direct"; + "posting:default:sensitive": boolean; + "posting:default:language": string | null; + "reading:expand:media": "default" | "show_all" | "hide_all"; + "reading:expand:spoilers": boolean; } diff --git a/types/entities/push_subscription.ts b/types/entities/push_subscription.ts index ab48c6dc..a20095ff 100644 --- a/types/entities/push_subscription.ts +++ b/types/entities/push_subscription.ts @@ -1,14 +1,14 @@ export interface APIAlerts { - follow: boolean; - favourite: boolean; - mention: boolean; - reblog: boolean; - poll: boolean; + follow: boolean; + favourite: boolean; + mention: boolean; + reblog: boolean; + poll: boolean; } export interface APIPushSubscription { - id: string; - endpoint: string; - server_key: string; - alerts: APIAlerts; + id: string; + endpoint: string; + server_key: string; + alerts: APIAlerts; } diff --git a/types/entities/relationship.ts b/types/entities/relationship.ts index 5dffd082..8715757b 100644 --- a/types/entities/relationship.ts +++ b/types/entities/relationship.ts @@ -1,16 +1,16 @@ export interface APIRelationship { - id: string; - following: boolean; - followed_by: boolean; - blocking: boolean; - blocked_by: boolean; - muting: boolean; - muting_notifications: boolean; - requested: boolean; - domain_blocking: boolean; - showing_reblogs: boolean; - endorsed: boolean; - notifying: boolean; - note: string; - languages: string[]; + id: string; + following: boolean; + followed_by: boolean; + blocking: boolean; + blocked_by: boolean; + muting: boolean; + muting_notifications: boolean; + requested: boolean; + domain_blocking: boolean; + showing_reblogs: boolean; + endorsed: boolean; + notifying: boolean; + note: string; + languages: string[]; } diff --git a/types/entities/report.ts b/types/entities/report.ts index 28d7d222..13157837 100644 --- a/types/entities/report.ts +++ b/types/entities/report.ts @@ -1,15 +1,15 @@ import type { APIAccount } from "./account"; export interface APIReport { - id: string; - action_taken: boolean; - action_taken_at: string | null; - category: APICategory; - comment: string; - forwarded: boolean; - status_ids: string[] | null; - rule_ids: string[] | null; - target_account: APIAccount; + id: string; + action_taken: boolean; + action_taken_at: string | null; + category: APICategory; + comment: string; + forwarded: boolean; + status_ids: string[] | null; + rule_ids: string[] | null; + target_account: APIAccount; } export type APICategory = "spam" | "violation" | "other"; diff --git a/types/entities/results.ts b/types/entities/results.ts index 494cce96..f8d7180e 100644 --- a/types/entities/results.ts +++ b/types/entities/results.ts @@ -3,7 +3,7 @@ import type { APIStatus } from "./status"; import type { APITag } from "./tag"; export interface APIResults { - accounts: APIAccount[]; - statuses: APIStatus[]; - hashtags: APITag[]; + accounts: APIAccount[]; + statuses: APIStatus[]; + hashtags: APITag[]; } diff --git a/types/entities/role.ts b/types/entities/role.ts index 6bb0f4b6..6fa60f44 100644 --- a/types/entities/role.ts +++ b/types/entities/role.ts @@ -1,3 +1,3 @@ export interface APIRole { - name: string; + name: string; } diff --git a/types/entities/scheduled_status.ts b/types/entities/scheduled_status.ts index a5975b9d..d2a34fb1 100644 --- a/types/entities/scheduled_status.ts +++ b/types/entities/scheduled_status.ts @@ -2,8 +2,8 @@ import type { APIAttachment } from "./attachment"; import type { APIStatusParams } from "./status_params"; export interface APIScheduledStatus { - id: string; - scheduled_at: string; - params: APIStatusParams; - media_attachments: APIAttachment[]; + id: string; + scheduled_at: string; + params: APIStatusParams; + media_attachments: APIAttachment[]; } diff --git a/types/entities/source.ts b/types/entities/source.ts index 1433b90b..54b9e0a2 100644 --- a/types/entities/source.ts +++ b/types/entities/source.ts @@ -1,9 +1,9 @@ import type { APIField } from "./field"; export interface APISource { - privacy: string | null; - sensitive: boolean | null; - language: string | null; - note: string; - fields: APIField[]; + privacy: string | null; + sensitive: boolean | null; + language: string | null; + note: string; + fields: APIField[]; } diff --git a/types/entities/stats.ts b/types/entities/stats.ts index 3effffe9..8790d502 100644 --- a/types/entities/stats.ts +++ b/types/entities/stats.ts @@ -1,5 +1,5 @@ export interface APIStats { - user_count: number; - status_count: number; - domain_count: number; + user_count: number; + status_count: number; + domain_count: number; } diff --git a/types/entities/status.ts b/types/entities/status.ts index 377ddf37..7c9840ab 100644 --- a/types/entities/status.ts +++ b/types/entities/status.ts @@ -7,40 +7,40 @@ import type { APIMention } from "./mention"; import type { APIPoll } from "./poll"; export interface APIStatus { - id: string; - uri: string; - url: string; - account: APIAccount; - in_reply_to_id: string | null; - in_reply_to_account_id: string | null; - reblog: APIStatus | null; - content: string; - created_at: string; - emojis: APIEmoji[]; - replies_count: number; - reblogs_count: number; - favourites_count: number; - reblogged: boolean | null; - favourited: boolean | null; - muted: boolean | null; - sensitive: boolean; - spoiler_text: string; - visibility: "public" | "unlisted" | "private" | "direct"; - media_attachments: APIAttachment[]; - mentions: APIMention[]; - tags: APIStatusTag[]; - card: APICard | null; - poll: APIPoll | null; - application: APIApplication | null; - language: string | null; - pinned: boolean | null; - bookmarked?: boolean; - // These parameters are unique parameters in fedibird.com for quote. - quote_id?: string; - quote?: APIStatus | null; + id: string; + uri: string; + url: string; + account: APIAccount; + in_reply_to_id: string | null; + in_reply_to_account_id: string | null; + reblog: APIStatus | null; + content: string; + created_at: string; + emojis: APIEmoji[]; + replies_count: number; + reblogs_count: number; + favourites_count: number; + reblogged: boolean | null; + favourited: boolean | null; + muted: boolean | null; + sensitive: boolean; + spoiler_text: string; + visibility: "public" | "unlisted" | "private" | "direct"; + media_attachments: APIAttachment[]; + mentions: APIMention[]; + tags: APIStatusTag[]; + card: APICard | null; + poll: APIPoll | null; + application: APIApplication | null; + language: string | null; + pinned: boolean | null; + bookmarked?: boolean; + // These parameters are unique parameters in fedibird.com for quote. + quote_id?: string; + quote?: APIStatus | null; } export interface APIStatusTag { - name: string; - url: string; + name: string; + url: string; } diff --git a/types/entities/status_params.ts b/types/entities/status_params.ts index 7c74bb56..9aa76256 100644 --- a/types/entities/status_params.ts +++ b/types/entities/status_params.ts @@ -1,10 +1,10 @@ export interface APIStatusParams { - text: string; - in_reply_to_id: string | null; - media_ids: string[] | null; - sensitive: boolean | null; - spoiler_text: string | null; - visibility: "public" | "unlisted" | "private" | "direct" | null; - scheduled_at: string | null; - application_id: number; + text: string; + in_reply_to_id: string | null; + media_ids: string[] | null; + sensitive: boolean | null; + spoiler_text: string | null; + visibility: "public" | "unlisted" | "private" | "direct" | null; + scheduled_at: string | null; + application_id: number; } diff --git a/types/entities/status_source.ts b/types/entities/status_source.ts index 3f82d1b8..81c896fb 100644 --- a/types/entities/status_source.ts +++ b/types/entities/status_source.ts @@ -1,5 +1,5 @@ export interface APIStatusSource { - id: string; - text: string; - spoiler_text: string; + id: string; + text: string; + spoiler_text: string; } diff --git a/types/entities/tag.ts b/types/entities/tag.ts index 7fe4395b..2368f24f 100644 --- a/types/entities/tag.ts +++ b/types/entities/tag.ts @@ -1,8 +1,8 @@ import type { APIHistory } from "./history"; export interface APITag { - name: string; - url: string; - history: APIHistory[]; - following?: boolean; + name: string; + url: string; + history: APIHistory[]; + following?: boolean; } diff --git a/types/entities/token.ts b/types/entities/token.ts index 54450272..1beee10e 100644 --- a/types/entities/token.ts +++ b/types/entities/token.ts @@ -1,6 +1,6 @@ export interface APIToken { - access_token: string; - token_type: string; - scope: string; - created_at: number; + access_token: string; + token_type: string; + scope: string; + created_at: number; } diff --git a/types/entities/urls.ts b/types/entities/urls.ts index 8a7134f1..efff795f 100644 --- a/types/entities/urls.ts +++ b/types/entities/urls.ts @@ -1,3 +1,3 @@ export interface APIURLs { - streaming_api: string; + streaming_api: string; } diff --git a/types/lysand/Extension.ts b/types/lysand/Extension.ts index 5a733664..ebdefa56 100644 --- a/types/lysand/Extension.ts +++ b/types/lysand/Extension.ts @@ -1,6 +1,6 @@ import type { LysandObjectType } from "./Object"; export interface ExtensionType extends LysandObjectType { - type: "Extension"; - extension_type: string; + type: "Extension"; + extension_type: string; } diff --git a/types/lysand/Object.ts b/types/lysand/Object.ts index fded0d26..86103f1d 100644 --- a/types/lysand/Object.ts +++ b/types/lysand/Object.ts @@ -1,177 +1,177 @@ import type { Emoji } from "./extensions/org.lysand/custom_emojis"; export interface LysandObjectType { - type: string; - id: string; // Either a UUID or some kind of time-based UUID-compatible system - uri: string; // URI to the note - created_at: string; - extensions?: { - // Should be in the format - // "organization:extension_name": value - // Example: "org.joinmastodon:spoiler_text": "This is a spoiler!" - "org.lysand:custom_emojis"?: { - emojis: Emoji[]; - }; - "org.lysand:reactions"?: { - reactions: string; - }; - "org.lysand:polls"?: { - poll: { - options: ContentFormat[][]; - votes: number[]; - expires_at: string; - multiple_choice: boolean; - }; - }; + type: string; + id: string; // Either a UUID or some kind of time-based UUID-compatible system + uri: string; // URI to the note + created_at: string; + extensions?: { + // Should be in the format + // "organization:extension_name": value + // Example: "org.joinmastodon:spoiler_text": "This is a spoiler!" + "org.lysand:custom_emojis"?: { + emojis: Emoji[]; + }; + "org.lysand:reactions"?: { + reactions: string; + }; + "org.lysand:polls"?: { + poll: { + options: ContentFormat[][]; + votes: number[]; + expires_at: string; + multiple_choice: boolean; + }; + }; - [key: string]: any; - }; + [key: string]: Record | undefined; + }; } export interface ActorPublicKeyData { - public_key: string; - actor: string; + public_key: string; + actor: string; } export interface Collection { - first: string; - last: string; - next?: string; - prev?: string; - items: T[]; + first: string; + last: string; + next?: string; + prev?: string; + items: T[]; } export interface LysandUser extends LysandObjectType { - type: "User"; - bio: ContentFormat[]; + type: "User"; + bio: ContentFormat[]; - inbox: string; - outbox: string; - followers: string; - following: string; - liked: string; - disliked: string; - featured: string; + inbox: string; + outbox: string; + followers: string; + following: string; + liked: string; + disliked: string; + featured: string; - indexable: boolean; - fields?: { - key: ContentFormat[]; - value: ContentFormat[]; - }[]; - display_name?: string; - public_key?: ActorPublicKeyData; - username: string; - avatar?: ContentFormat[]; - header?: ContentFormat[]; + indexable: boolean; + fields?: { + key: ContentFormat[]; + value: ContentFormat[]; + }[]; + display_name?: string; + public_key?: ActorPublicKeyData; + username: string; + avatar?: ContentFormat[]; + header?: ContentFormat[]; } export interface LysandPublication extends LysandObjectType { - type: "Note" | "Patch"; - author: string; - contents: ContentFormat[]; - mentions: string[]; - replies_to: string[]; - quotes: string[]; - is_sensitive: boolean; - subject: string; - attachments: ContentFormat[][]; + type: "Note" | "Patch"; + author: string; + contents: ContentFormat[]; + mentions: string[]; + replies_to: string[]; + quotes: string[]; + is_sensitive: boolean; + subject: string; + attachments: ContentFormat[][]; } export interface LysandAction extends LysandObjectType { - type: - | "Like" - | "Dislike" - | "Follow" - | "FollowAccept" - | "FollowReject" - | "Announce" - | "Undo" - | "Extension"; - author: string; + type: + | "Like" + | "Dislike" + | "Follow" + | "FollowAccept" + | "FollowReject" + | "Announce" + | "Undo" + | "Extension"; + author: string; } /** * A Note is a publication on the network, such as a post or comment */ export interface Note extends LysandPublication { - type: "Note"; + type: "Note"; } /** * A Patch is an edit to a Note */ export interface Patch extends LysandPublication { - type: "Patch"; - patched_id: string; - patched_at: string; + type: "Patch"; + patched_id: string; + patched_at: string; } export interface Like extends LysandAction { - type: "Like"; - object: string; + type: "Like"; + object: string; } export interface Dislike extends LysandAction { - type: "Dislike"; - object: string; + type: "Dislike"; + object: string; } export interface Announce extends LysandAction { - type: "Announce"; - object: string; + type: "Announce"; + object: string; } export interface Undo extends LysandAction { - type: "Undo"; - object: string; + type: "Undo"; + object: string; } export interface Follow extends LysandAction { - type: "Follow"; - followee: string; + type: "Follow"; + followee: string; } export interface FollowAccept extends LysandAction { - type: "FollowAccept"; - follower: string; + type: "FollowAccept"; + follower: string; } export interface FollowReject extends LysandAction { - type: "FollowReject"; - follower: string; + type: "FollowReject"; + follower: string; } export interface ServerMetadata extends LysandObjectType { - type: "ServerMetadata"; - name: string; - version?: string; - description?: string; - website?: string; - moderators?: string[]; - admins?: string[]; - logo?: ContentFormat[]; - banner?: ContentFormat[]; - supported_extensions?: string[]; + type: "ServerMetadata"; + name: string; + version?: string; + description?: string; + website?: string; + moderators?: string[]; + admins?: string[]; + logo?: ContentFormat[]; + banner?: ContentFormat[]; + supported_extensions?: string[]; } /** * Content format is an array of objects that contain the content and the content type. */ export interface ContentFormat { - content: string; - content_type: string; - description?: string; - size?: number; - hash?: { - md5?: string; - sha1?: string; - sha256?: string; - sha512?: string; - [key: string]: string | undefined; - }; - blurhash?: string; - fps?: number; - width?: number; - height?: number; - duration?: number; + content: string; + content_type: string; + description?: string; + size?: number; + hash?: { + md5?: string; + sha1?: string; + sha256?: string; + sha512?: string; + [key: string]: string | undefined; + }; + blurhash?: string; + fps?: number; + width?: number; + height?: number; + duration?: number; } diff --git a/types/lysand/extensions/org.lysand/custom_emojis.ts b/types/lysand/extensions/org.lysand/custom_emojis.ts index 50aa0338..f2070357 100644 --- a/types/lysand/extensions/org.lysand/custom_emojis.ts +++ b/types/lysand/extensions/org.lysand/custom_emojis.ts @@ -1,7 +1,7 @@ import type { ContentFormat } from "../../Object"; export interface Emoji { - name: string; - url: ContentFormat[]; - alt?: string; + name: string; + url: ContentFormat[]; + alt?: string; } diff --git a/types/lysand/extensions/org.lysand/polls.ts b/types/lysand/extensions/org.lysand/polls.ts index 15d3f5fb..ec89ba11 100644 --- a/types/lysand/extensions/org.lysand/polls.ts +++ b/types/lysand/extensions/org.lysand/polls.ts @@ -1,14 +1,14 @@ import type { ExtensionType } from "../../Extension"; export interface OrgLysandPollsVoteType extends ExtensionType { - extension_type: "org.lysand:polls/Vote"; - author: string; - poll: string; - option: number; + extension_type: "org.lysand:polls/Vote"; + author: string; + poll: string; + option: number; } export interface OrgLysandPollsVoteResultType extends ExtensionType { - extension_type: "org.lysand:polls/VoteResult"; - poll: string; - votes: number[]; + extension_type: "org.lysand:polls/VoteResult"; + poll: string; + votes: number[]; } diff --git a/types/lysand/extensions/org.lysand/reactions.ts b/types/lysand/extensions/org.lysand/reactions.ts index 4113dfcd..677f52ff 100644 --- a/types/lysand/extensions/org.lysand/reactions.ts +++ b/types/lysand/extensions/org.lysand/reactions.ts @@ -1,8 +1,8 @@ import type { ExtensionType } from "../../Extension"; export interface OrgLysandReactionsType extends ExtensionType { - extension_type: "org.lysand:reactions/Reaction"; - author: string; - object: string; - content: string; + extension_type: "org.lysand:reactions/Reaction"; + author: string; + object: string; + content: string; } diff --git a/uno.config.ts b/uno.config.ts index ff472c3a..e120f3d3 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -1,22 +1,22 @@ -import { - defineConfig, - presetUno, - presetTypography, - presetWebFonts, -} from "unocss"; import { presetForms } from "@julr/unocss-preset-forms"; +import { + defineConfig, + presetTypography, + presetUno, + presetWebFonts, +} from "unocss"; export default defineConfig({ - presets: [ - presetUno(), - presetTypography({ - cssExtend: { - "h1,h2,h3,h4,h5.h6": { - "font-family": "'Poppins'", - }, - }, - }), - presetWebFonts(), - presetForms(), - ], + presets: [ + presetUno(), + presetTypography({ + cssExtend: { + "h1,h2,h3,h4,h5.h6": { + "font-family": "'Poppins'", + }, + }, + }), + presetWebFonts(), + presetForms(), + ], }); diff --git a/utils/api.ts b/utils/api.ts index d197aa74..55aa682b 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -3,20 +3,20 @@ import type { RouteHandler } from "~server/api/routes.type"; import type { APIRouteMeta } from "~types/api"; export const applyConfig = (routeMeta: APIRouteMeta) => { - const newMeta = routeMeta; + const newMeta = routeMeta; - // Apply ratelimits from config - newMeta.ratelimits.duration *= config.ratelimits.duration_coeff; - newMeta.ratelimits.max *= config.ratelimits.max_coeff; + // Apply ratelimits from config + newMeta.ratelimits.duration *= config.ratelimits.duration_coeff; + newMeta.ratelimits.max *= config.ratelimits.max_coeff; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (config.custom_ratelimits[routeMeta.route]) { - newMeta.ratelimits = config.custom_ratelimits[routeMeta.route]; - } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (config.custom_ratelimits[routeMeta.route]) { + newMeta.ratelimits = config.custom_ratelimits[routeMeta.route]; + } - return newMeta; + return newMeta; }; export const apiRoute = (routeFunction: RouteHandler) => { - return routeFunction; + return routeFunction; }; diff --git a/utils/constants.ts b/utils/constants.ts index 29b479bf..96bd661f 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -1,4 +1,4 @@ import { config } from "config-manager"; export const oauthRedirectUri = (issuer: string) => - `${config.http.base_url}/oauth/callback/${issuer}`; + `${config.http.base_url}/oauth/callback/${issuer}`; diff --git a/utils/content_types.ts b/utils/content_types.ts index d1d64f7e..71c929d9 100644 --- a/utils/content_types.ts +++ b/utils/content_types.ts @@ -1,19 +1,21 @@ import type { ContentFormat } from "~types/lysand/Object"; export const getBestContentType = (contents: ContentFormat[]) => { - // Find the best content and content type - if (contents.find(c => c.content_type === "text/x.misskeymarkdown")) { - return ( - contents.find(c => c.content_type === "text/x.misskeymarkdown") || - null - ); - } else if (contents.find(c => c.content_type === "text/html")) { - return contents.find(c => c.content_type === "text/html") || null; - } else if (contents.find(c => c.content_type === "text/markdown")) { - return contents.find(c => c.content_type === "text/markdown") || null; - } else if (contents.find(c => c.content_type === "text/plain")) { - return contents.find(c => c.content_type === "text/plain") || null; - } else { - return contents[0] || null; - } + // Find the best content and content type + if (contents.find((c) => c.content_type === "text/x.misskeymarkdown")) { + return ( + contents.find((c) => c.content_type === "text/x.misskeymarkdown") || + null + ); + } + if (contents.find((c) => c.content_type === "text/html")) { + return contents.find((c) => c.content_type === "text/html") || null; + } + if (contents.find((c) => c.content_type === "text/markdown")) { + return contents.find((c) => c.content_type === "text/markdown") || null; + } + if (contents.find((c) => c.content_type === "text/plain")) { + return contents.find((c) => c.content_type === "text/plain") || null; + } + return contents[0] || null; }; diff --git a/utils/formatting.ts b/utils/formatting.ts index 1cb56e15..2d802dfe 100644 --- a/utils/formatting.ts +++ b/utils/formatting.ts @@ -10,20 +10,20 @@ import { parse } from "marked"; * @returns HTML */ export const convertTextToHtml = async ( - text: string, - content_type?: string + text: string, + content_type?: string, ) => { - if (content_type === "text/markdown") { - return linkifyHtml(await sanitizeHtml(await parse(text))); - } else if (content_type === "text/x.misskeymarkdown") { - // Parse as MFM - // TODO: Implement MFM - return text; - } else { - // Parse as plaintext - return linkifyStr(text) - .split("\n") - .map(line => `

${line}

`) - .join("\n"); - } + if (content_type === "text/markdown") { + return linkifyHtml(await sanitizeHtml(await parse(text))); + } + if (content_type === "text/x.misskeymarkdown") { + // Parse as MFM + // TODO: Implement MFM + return text; + } + // Parse as plaintext + return linkifyStr(text) + .split("\n") + .map((line) => `

${line}

`) + .join("\n"); }; diff --git a/utils/meilisearch.ts b/utils/meilisearch.ts index bfde2890..0e1234ff 100644 --- a/utils/meilisearch.ts +++ b/utils/meilisearch.ts @@ -1,174 +1,174 @@ -import chalk from "chalk"; -import { client } from "~database/datasource"; -import { Meilisearch } from "meilisearch"; import type { Status, User } from "@prisma/client"; +import chalk from "chalk"; import { config } from "config-manager"; import { LogLevel, type LogManager, type MultiLogManager } from "log-manager"; +import { Meilisearch } from "meilisearch"; +import { client } from "~database/datasource"; export const meilisearch = new Meilisearch({ - host: `${config.meilisearch.host}:${config.meilisearch.port}`, - apiKey: config.meilisearch.api_key, + host: `${config.meilisearch.host}:${config.meilisearch.port}`, + apiKey: config.meilisearch.api_key, }); export const connectMeili = async (logger: MultiLogManager | LogManager) => { - if (!config.meilisearch.enabled) return; + if (!config.meilisearch.enabled) return; - if (await meilisearch.isHealthy()) { - await meilisearch - .index(MeiliIndexType.Accounts) - .updateSortableAttributes(["createdAt"]); + if (await meilisearch.isHealthy()) { + await meilisearch + .index(MeiliIndexType.Accounts) + .updateSortableAttributes(["createdAt"]); - await meilisearch - .index(MeiliIndexType.Accounts) - .updateSearchableAttributes(["username", "displayName", "note"]); + await meilisearch + .index(MeiliIndexType.Accounts) + .updateSearchableAttributes(["username", "displayName", "note"]); - await meilisearch - .index(MeiliIndexType.Statuses) - .updateSortableAttributes(["createdAt"]); + await meilisearch + .index(MeiliIndexType.Statuses) + .updateSortableAttributes(["createdAt"]); - await meilisearch - .index(MeiliIndexType.Statuses) - .updateSearchableAttributes(["content"]); + await meilisearch + .index(MeiliIndexType.Statuses) + .updateSearchableAttributes(["content"]); - await logger.log( - LogLevel.INFO, - "Meilisearch", - "Connected to Meilisearch" - ); - } else { - await logger.log( - LogLevel.CRITICAL, - "Meilisearch", - "Error while connecting to Meilisearch" - ); - process.exit(1); - } + await logger.log( + LogLevel.INFO, + "Meilisearch", + "Connected to Meilisearch", + ); + } else { + await logger.log( + LogLevel.CRITICAL, + "Meilisearch", + "Error while connecting to Meilisearch", + ); + process.exit(1); + } }; export enum MeiliIndexType { - Accounts = "accounts", - Statuses = "statuses", + Accounts = "accounts", + Statuses = "statuses", } export const addStausToMeilisearch = async (status: Status) => { - if (!config.meilisearch.enabled) return; + if (!config.meilisearch.enabled) return; - await meilisearch.index(MeiliIndexType.Statuses).addDocuments([ - { - id: status.id, - content: status.content, - createdAt: status.createdAt, - }, - ]); + await meilisearch.index(MeiliIndexType.Statuses).addDocuments([ + { + id: status.id, + content: status.content, + createdAt: status.createdAt, + }, + ]); }; export const addUserToMeilisearch = async (user: User) => { - if (!config.meilisearch.enabled) return; + if (!config.meilisearch.enabled) return; - await meilisearch.index(MeiliIndexType.Accounts).addDocuments([ - { - id: user.id, - username: user.username, - displayName: user.displayName, - note: user.note, - createdAt: user.createdAt, - }, - ]); + await meilisearch.index(MeiliIndexType.Accounts).addDocuments([ + { + id: user.id, + username: user.username, + displayName: user.displayName, + note: user.note, + createdAt: user.createdAt, + }, + ]); }; export const getNthDatabaseAccountBatch = ( - n: number, - batchSize = 1000 + n: number, + batchSize = 1000, ): Promise[]> => { - return client.user.findMany({ - skip: n * batchSize, - take: batchSize, - select: { - id: true, - username: true, - displayName: true, - note: true, - createdAt: true, - }, - orderBy: { - createdAt: "asc", - }, - }); + return client.user.findMany({ + skip: n * batchSize, + take: batchSize, + select: { + id: true, + username: true, + displayName: true, + note: true, + createdAt: true, + }, + orderBy: { + createdAt: "asc", + }, + }); }; export const getNthDatabaseStatusBatch = ( - n: number, - batchSize = 1000 + n: number, + batchSize = 1000, ): Promise[]> => { - return client.status.findMany({ - skip: n * batchSize, - take: batchSize, - select: { - id: true, - content: true, - createdAt: true, - }, - orderBy: { - createdAt: "asc", - }, - }); + return client.status.findMany({ + skip: n * batchSize, + take: batchSize, + select: { + id: true, + content: true, + createdAt: true, + }, + orderBy: { + createdAt: "asc", + }, + }); }; export const rebuildSearchIndexes = async ( - indexes: MeiliIndexType[], - batchSize = 100 + indexes: MeiliIndexType[], + batchSize = 100, ) => { - if (indexes.includes(MeiliIndexType.Accounts)) { - const accountCount = await client.user.count(); + if (indexes.includes(MeiliIndexType.Accounts)) { + const accountCount = await client.user.count(); - for (let i = 0; i < accountCount / batchSize; i++) { - const accounts = await getNthDatabaseAccountBatch(i, batchSize); + for (let i = 0; i < accountCount / batchSize; i++) { + const accounts = await getNthDatabaseAccountBatch(i, batchSize); - const progress = Math.round((i / (accountCount / batchSize)) * 100); + const progress = Math.round((i / (accountCount / batchSize)) * 100); - console.log(`${chalk.green(`✓`)} ${progress}%`); + console.log(`${chalk.green("✓")} ${progress}%`); - // Sync with Meilisearch - await meilisearch - .index(MeiliIndexType.Accounts) - .addDocuments(accounts); - } + // Sync with Meilisearch + await meilisearch + .index(MeiliIndexType.Accounts) + .addDocuments(accounts); + } - const meiliAccountCount = ( - await meilisearch.index(MeiliIndexType.Accounts).getStats() - ).numberOfDocuments; + const meiliAccountCount = ( + await meilisearch.index(MeiliIndexType.Accounts).getStats() + ).numberOfDocuments; - console.log( - `${chalk.green(`✓`)} ${chalk.bold( - `Done! ${meiliAccountCount} accounts indexed` - )}` - ); - } + console.log( + `${chalk.green("✓")} ${chalk.bold( + `Done! ${meiliAccountCount} accounts indexed`, + )}`, + ); + } - if (indexes.includes(MeiliIndexType.Statuses)) { - const statusCount = await client.status.count(); + if (indexes.includes(MeiliIndexType.Statuses)) { + const statusCount = await client.status.count(); - for (let i = 0; i < statusCount / batchSize; i++) { - const statuses = await getNthDatabaseStatusBatch(i, batchSize); + for (let i = 0; i < statusCount / batchSize; i++) { + const statuses = await getNthDatabaseStatusBatch(i, batchSize); - const progress = Math.round((i / (statusCount / batchSize)) * 100); + const progress = Math.round((i / (statusCount / batchSize)) * 100); - console.log(`${chalk.green(`✓`)} ${progress}%`); + console.log(`${chalk.green("✓")} ${progress}%`); - // Sync with Meilisearch - await meilisearch - .index(MeiliIndexType.Statuses) - .addDocuments(statuses); - } + // Sync with Meilisearch + await meilisearch + .index(MeiliIndexType.Statuses) + .addDocuments(statuses); + } - const meiliStatusCount = ( - await meilisearch.index(MeiliIndexType.Statuses).getStats() - ).numberOfDocuments; + const meiliStatusCount = ( + await meilisearch.index(MeiliIndexType.Statuses).getStats() + ).numberOfDocuments; - console.log( - `${chalk.green(`✓`)} ${chalk.bold( - `Done! ${meiliStatusCount} statuses indexed` - )}` - ); - } + console.log( + `${chalk.green("✓")} ${chalk.bold( + `Done! ${meiliStatusCount} statuses indexed`, + )}`, + ); + } }; diff --git a/utils/merge.ts b/utils/merge.ts index fe480b6d..87d77345 100644 --- a/utils/merge.ts +++ b/utils/merge.ts @@ -1,16 +1,17 @@ export const deepMerge = ( - target: Record, - source: Record + target: Record, + source: Record, ) => { - const result = { ...target, ...source }; - for (const key of Object.keys(result)) { - result[key] = - typeof target[key] == "object" && typeof source[key] == "object" - ? deepMerge(target[key], source[key]) - : structuredClone(result[key]); - } - return result; + const result = { ...target, ...source }; + for (const key of Object.keys(result)) { + result[key] = + typeof target[key] === "object" && typeof source[key] === "object" + ? // @ts-expect-error deepMerge is recursive + deepMerge(target[key], source[key]) + : structuredClone(result[key]); + } + return result; }; -export const deepMergeArray = (array: Record[]) => - array.reduce((ci, ni) => deepMerge(ci, ni), {}); +export const deepMergeArray = (array: Record[]) => + array.reduce((ci, ni) => deepMerge(ci, ni), {}); diff --git a/utils/module.ts b/utils/module.ts index 212d863c..a9aefd6a 100644 --- a/utils/module.ts +++ b/utils/module.ts @@ -1,4 +1,4 @@ -import { fileURLToPath } from "url"; +import { fileURLToPath } from "node:url"; /** * Determines whether a module is the entry point for the running node process. @@ -19,13 +19,13 @@ import { fileURLToPath } from "url"; * ``` */ export const moduleIsEntry = (moduleOrImportMetaUrl: NodeModule | string) => { - if (typeof moduleOrImportMetaUrl === "string") { - return process.argv[1] === fileURLToPath(moduleOrImportMetaUrl); - } + if (typeof moduleOrImportMetaUrl === "string") { + return process.argv[1] === fileURLToPath(moduleOrImportMetaUrl); + } - if (typeof require !== "undefined" && "exports" in moduleOrImportMetaUrl) { - return require.main === moduleOrImportMetaUrl; - } + if (typeof require !== "undefined" && "exports" in moduleOrImportMetaUrl) { + return require.main === moduleOrImportMetaUrl; + } - return false; + return false; }; diff --git a/utils/oauth.ts b/utils/oauth.ts index f095242d..e5dca756 100644 --- a/utils/oauth.ts +++ b/utils/oauth.ts @@ -7,57 +7,57 @@ import type { Application } from "@prisma/client"; * @returns Whether the OAuth application is valid for the route */ export const checkIfOauthIsValid = ( - application: Application, - routeScopes: string[] + application: Application, + routeScopes: string[], ) => { - if (routeScopes.length === 0) { - return true; - } + if (routeScopes.length === 0) { + return true; + } - const hasAllWriteScopes = - application.scopes.split(" ").includes("write:*") || - application.scopes.split(" ").includes("write"); + const hasAllWriteScopes = + application.scopes.split(" ").includes("write:*") || + application.scopes.split(" ").includes("write"); - const hasAllReadScopes = - application.scopes.split(" ").includes("read:*") || - application.scopes.split(" ").includes("read"); + const hasAllReadScopes = + application.scopes.split(" ").includes("read:*") || + application.scopes.split(" ").includes("read"); - if (hasAllWriteScopes && hasAllReadScopes) { - return true; - } + if (hasAllWriteScopes && hasAllReadScopes) { + return true; + } - let nonMatchedScopes = routeScopes; + let nonMatchedScopes = routeScopes; - if (hasAllWriteScopes) { - // Filter out all write scopes as valid - nonMatchedScopes = routeScopes.filter( - scope => !scope.startsWith("write:") - ); - } + if (hasAllWriteScopes) { + // Filter out all write scopes as valid + nonMatchedScopes = routeScopes.filter( + (scope) => !scope.startsWith("write:"), + ); + } - if (hasAllReadScopes) { - // Filter out all read scopes as valid - nonMatchedScopes = routeScopes.filter( - scope => !scope.startsWith("read:") - ); - } + if (hasAllReadScopes) { + // Filter out all read scopes as valid + nonMatchedScopes = routeScopes.filter( + (scope) => !scope.startsWith("read:"), + ); + } - // If there are still scopes left, check if they match - // If there are no scopes left, return true - if (nonMatchedScopes.length === 0) { - return true; - } + // If there are still scopes left, check if they match + // If there are no scopes left, return true + if (nonMatchedScopes.length === 0) { + return true; + } - // If there are scopes left, check if they match - if ( - nonMatchedScopes.every(scope => - application.scopes.split(" ").includes(scope) - ) - ) { - return true; - } + // If there are scopes left, check if they match + if ( + nonMatchedScopes.every((scope) => + application.scopes.split(" ").includes(scope), + ) + ) { + return true; + } - return false; + return false; }; export const oauthCodeVerifiers: Record = {}; diff --git a/utils/redis.ts b/utils/redis.ts index 0f3714bb..118102c2 100644 --- a/utils/redis.ts +++ b/utils/redis.ts @@ -5,54 +5,54 @@ import Redis from "ioredis"; import { createPrismaRedisCache } from "prisma-redis-middleware"; const cacheRedis = config.redis.cache.enabled - ? new Redis({ - host: config.redis.cache.host, - port: Number(config.redis.cache.port), - password: config.redis.cache.password, - db: Number(config.redis.cache.database), - }) - : null; + ? new Redis({ + host: config.redis.cache.host, + port: Number(config.redis.cache.port), + password: config.redis.cache.password, + db: Number(config.redis.cache.database), + }) + : null; -cacheRedis?.on("error", e => { - console.log(e); +cacheRedis?.on("error", (e) => { + console.log(e); }); export { cacheRedis }; export const initializeRedisCache = async () => { - if (cacheRedis) { - // Test connection - try { - await cacheRedis.ping(); - } catch (e) { - console.error( - `${chalk.red(`✗`)} ${chalk.bold( - `Error while connecting to Redis` - )}` - ); - throw e; - } + if (cacheRedis) { + // Test connection + try { + await cacheRedis.ping(); + } catch (e) { + console.error( + `${chalk.red("✗")} ${chalk.bold( + "Error while connecting to Redis", + )}`, + ); + throw e; + } - console.log(`${chalk.green(`✓`)} ${chalk.bold(`Connected to Redis`)}`); + console.log(`${chalk.green("✓")} ${chalk.bold("Connected to Redis")}`); - const cacheMiddleware: Prisma.Middleware = createPrismaRedisCache({ - storage: { - type: "redis", - options: { - client: cacheRedis, - invalidation: { - referencesTTL: 300, - }, - }, - }, - cacheTime: 300, - onError: e => { - console.error(e); - }, - }); + const cacheMiddleware: Prisma.Middleware = createPrismaRedisCache({ + storage: { + type: "redis", + options: { + client: cacheRedis, + invalidation: { + referencesTTL: 300, + }, + }, + }, + cacheTime: 300, + onError: (e) => { + console.error(e); + }, + }); - return cacheMiddleware; - } + return cacheMiddleware; + } - return null; + return null; }; diff --git a/utils/response.ts b/utils/response.ts index a34685cf..31877a44 100644 --- a/utils/response.ts +++ b/utils/response.ts @@ -2,54 +2,54 @@ import type { APActivity, APObject } from "activitypub-types"; import type { NodeObject } from "jsonld"; export const jsonResponse = ( - data: object, - status = 200, - headers: Record = {} + data: object, + status = 200, + headers: Record = {}, ) => { - return new Response(JSON.stringify(data), { - headers: { - "Content-Type": "application/json", - "X-Frame-Options": "DENY", - "X-Permitted-Cross-Domain-Policies": "none", - "Access-Control-Allow-Credentials": "true", - "Access-Control-Allow-Headers": - "Authorization,Content-Type,Idempotency-Key", - "Access-Control-Allow-Methods": "POST,PUT,DELETE,GET,PATCH,OPTIONS", - "Access-Control-Allow-Origin": "*", - "Access-Control-Expose-Headers": - "Link,X-RateLimit-Reset,X-RateLimit-Limit,X-RateLimit-Remaining,X-Request-Id,Idempotency-Key", - ...headers, - }, - status, - }); + return new Response(JSON.stringify(data), { + headers: { + "Content-Type": "application/json", + "X-Frame-Options": "DENY", + "X-Permitted-Cross-Domain-Policies": "none", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": + "Authorization,Content-Type,Idempotency-Key", + "Access-Control-Allow-Methods": "POST,PUT,DELETE,GET,PATCH,OPTIONS", + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": + "Link,X-RateLimit-Reset,X-RateLimit-Limit,X-RateLimit-Remaining,X-Request-Id,Idempotency-Key", + ...headers, + }, + status, + }); }; export const xmlResponse = (data: string, status = 200) => { - return new Response(data, { - headers: { - "Content-Type": "application/xml", - }, - status, - }); + return new Response(data, { + headers: { + "Content-Type": "application/xml", + }, + status, + }); }; export const jsonLdResponse = ( - data: NodeObject | APActivity | APObject, - status = 200 + data: NodeObject | APActivity | APObject, + status = 200, ) => { - return new Response(JSON.stringify(data), { - headers: { - "Content-Type": "application/activity+json", - }, - status, - }); + return new Response(JSON.stringify(data), { + headers: { + "Content-Type": "application/activity+json", + }, + status, + }); }; export const errorResponse = (error: string, status = 500) => { - return jsonResponse( - { - error: error, - }, - status - ); + return jsonResponse( + { + error: error, + }, + status, + ); }; diff --git a/utils/sanitization.ts b/utils/sanitization.ts index 89f85b08..5aa6495a 100644 --- a/utils/sanitization.ts +++ b/utils/sanitization.ts @@ -2,73 +2,73 @@ import { config } from "config-manager"; import { sanitize } from "isomorphic-dompurify"; export const sanitizeHtml = async (html: string) => { - const sanitizedHtml = sanitize(html, { - ALLOWED_TAGS: [ - "a", - "p", - "br", - "b", - "i", - "em", - "strong", - "del", - "code", - "u", - "pre", - "ul", - "ol", - "li", - "blockquote", - ], - ALLOWED_ATTR: [ - "href", - "target", - "title", - "rel", - "class", - "start", - "reversed", - "value", - ], - ALLOWED_URI_REGEXP: new RegExp( - `/^(?:(?:${config.validation.url_scheme_whitelist.join( - "|" - )}):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i` - ), - USE_PROFILES: { - mathMl: true, - }, - }); + const sanitizedHtml = sanitize(html, { + ALLOWED_TAGS: [ + "a", + "p", + "br", + "b", + "i", + "em", + "strong", + "del", + "code", + "u", + "pre", + "ul", + "ol", + "li", + "blockquote", + ], + ALLOWED_ATTR: [ + "href", + "target", + "title", + "rel", + "class", + "start", + "reversed", + "value", + ], + ALLOWED_URI_REGEXP: new RegExp( + `/^(?:(?:${config.validation.url_scheme_whitelist.join( + "|", + )}):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i`, + ), + USE_PROFILES: { + mathMl: true, + }, + }); - // Check text to only allow h-*, p-*, u-*, dt-*, e-*, mention, hashtag, ellipsis, invisible classes - const allowedClasses = [ - "h-", - "p-", - "u-", - "dt-", - "e-", - "mention", - "hashtag", - "ellipsis", - "invisible", - ]; + // Check text to only allow h-*, p-*, u-*, dt-*, e-*, mention, hashtag, ellipsis, invisible classes + const allowedClasses = [ + "h-", + "p-", + "u-", + "dt-", + "e-", + "mention", + "hashtag", + "ellipsis", + "invisible", + ]; - return await new HTMLRewriter() - .on("*[class]", { - element(element) { - const classes = element.getAttribute("class")?.split(" ") ?? []; + return await new HTMLRewriter() + .on("*[class]", { + element(element) { + const classes = element.getAttribute("class")?.split(" ") ?? []; - classes.forEach(className => { - if ( - !allowedClasses.some(allowedClass => - className.startsWith(allowedClass) - ) - ) { - element.removeAttribute("class"); - } - }); - }, - }) - .transform(new Response(sanitizedHtml)) - .text(); + for (const className of classes) { + if ( + !allowedClasses.some((allowedClass) => + className.startsWith(allowedClass), + ) + ) { + element.removeAttribute("class"); + } + } + }, + }) + .transform(new Response(sanitizedHtml)) + .text(); }; diff --git a/utils/temp.ts b/utils/temp.ts index efea7158..865a1b59 100644 --- a/utils/temp.ts +++ b/utils/temp.ts @@ -1,20 +1,20 @@ -import { join } from "path"; -import { exists, mkdir, writeFile, readFile } from "fs/promises"; +import { exists, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; export const writeToTempDirectory = async (filename: string, data: string) => { - const tempDir = join("/tmp/", "lysand"); - if (!(await exists(tempDir))) await mkdir(tempDir); + const tempDir = join("/tmp/", "lysand"); + if (!(await exists(tempDir))) await mkdir(tempDir); - const tempFile = join(tempDir, filename); - await writeFile(tempFile, data); + const tempFile = join(tempDir, filename); + await writeFile(tempFile, data); - return tempFile; + return tempFile; }; export const readFromTempDirectory = async (filename: string) => { - const tempDir = join("/tmp/", "lysand"); - if (!(await exists(tempDir))) await mkdir(tempDir); + const tempDir = join("/tmp/", "lysand"); + if (!(await exists(tempDir))) await mkdir(tempDir); - const tempFile = join(tempDir, filename); - return readFile(tempFile, "utf-8"); + const tempFile = join(tempDir, filename); + return readFile(tempFile, "utf-8"); }; diff --git a/utils/tempmail.ts b/utils/tempmail.ts index 02f65f1a..3465713a 100644 --- a/utils/tempmail.ts +++ b/utils/tempmail.ts @@ -1,3002 +1,3002 @@ export const tempmailDomains = { - lastCheckTime: 1681220245859, - domains: [ - "getapet.net", - "0-mail.com", - "027168.com", - "0815.ru", - "0815.su", - "0clickemail.com", - "0wnd.net", - "0wnd.org", - "1.emailfake.ml", - "10mail.org", - "10minutemail.cf", - "10minutemail.co.uk", - "10minutemail.co.za", - "10minutemail.com", - "10minutemail.de", - "10minutemail.ga", - "10minutemail.gq", - "10minutemail.ml", - "10minutemail.net", - "10minutemail.us", - "10minutenemail.de", - "123-m.com", - "12hosting.net", - "12minutemail.com", - "12storage.com", - "1ce.us", - "1chuan.com", - "1clck2.com", - "1mail.ml", - "1pad.de", - "1up.orangotango.gq", - "1zhuan.com", - "2-ch.space", - "2000rebates.stream", - "20email.eu", - "20mail.in", - "20mail.it", - "20minute.email", - "20minutemail.com", - "21cn.com", - "24hourmail.com", - "2ch.coms.hk", - "2prong.com", - "3.emailfake.ml", - "3.fackme.gq", - "30minutemail.com", - "30wave.com", - "33mail.com", - "3d-painting.com", - "3mail.ga", - "4mail.cf", - "4mail.ga", - "4warding.com", - "4warding.net", - "4warding.org", - "5.fackme.gq", - "5mail.cf", - "5mail.ga", - "6.emailfake.ml", - "6.fackme.gq", - "60minutemail.com", - "675hosting.com", - "675hosting.net", - "675hosting.org", - "69-ew.tk", - "6ip.us", - "6mail.cf", - "6mail.ga", - "6mail.ml", - "6paq.com", - "6url.com", - "7.fackme.gq", - "75hosting.com", - "75hosting.net", - "75hosting.org", - "7days-printing.com", - "7ddf32e.info", - "7mail.ga", - "7mail.ml", - "7tags.com", - "7uy35p.tk", - "8.fackme.gq", - "8mail.cf", - "8mail.ga", - "8mail.ml", - "99experts.com", - "9mail.cf", - "9me.site", - "9ox.net", - "a-bc.net", - "a.betr.co", - "a.wxnw.net", - "a45.in", - "abusemail.de", - "abyssmail.com", - "ac20mail.in", - "acentri.com", - "adbet.co", - "add3000.pp.ua", - "adrianou.gq", - "advantimo.com", - "afrobacon.com", - "ag.us.to", - "agedmail.com", - "ahk.jp", - "ajaxapp.net", - "alivance.com", - "amail.com", - "amilegit.com", - "amiri.net", - "amiriindustries.com", - "anappthat.com", - "ano-mail.net", - "anonbox.net", - "anonymail.dk", - "anonymbox.com", - "anotherdomaincyka.tk", - "antichef.com", - "antichef.net", - "antispam.de", - "antonelli.usa.cc", - "apkmd.com", - "appixie.com", - "armyspy.com", - "art-en-ligne.pro", - "arur01.tk", - "arurgitu.gq", - "arurimport.ml", - "asdasd.nl", - "ass.pp.ua", - "aver.com", - "avia-tonic.fr", - "azazazatashkent.tk", - "azmeil.tk", - "babau.cf", - "babau.flu.cc", - "babau.ga", - "babau.gq", - "babau.igg.biz", - "babau.ml", - "babau.nut.cc", - "babau.usa.cc", - "bareed.ws", - "barryogorman.com", - "baxomale.ht.cx", - "bccto.me", - "bdmuzic.pw", - "beddly.com", - "beefmilk.com", - "belastingdienst.pw", - "big1.us", - "bigprofessor.so", - "bigstring.com", - "binka.me", - "binkmail.com", - "bio-muesli.net", - "bione.co", - "bladesmail.net", - "blogmyway.org", - "blutig.me", - "boatmail.us", - "bobmail.info", - "bodhi.lawlita.com", - "bofthew.com", - "bongobongo.cf", - "bongobongo.flu.cc", - "bongobongo.ga", - "bongobongo.igg.biz", - "bongobongo.ml", - "bongobongo.nut.cc", - "bongobongo.tk", - "bongobongo.usa.cc", - "bootybay.de", - "boun.cr", - "bouncr.com", - "boxformail.in", - "boximail.com", - "boxtemp.com.br", - "breadtimes.press", - "brefmail.com", - "brennendesreich.de", - "broadbandninja.com", - "bsnow.net", - "bst-72.com", - "btcmail.pw", - "bu.mintemail.com", - "buffemail.com", - "bugmenot.com", - "bugmenot.ml", - "bumpymail.com", - "bund.us", - "bundes-li.ga", - "burnthespam.info", - "burstmail.info", - "buxap.com", - "buyusedlibrarybooks.org", - "byom.de", - "c.andreihusanu.ro", - "c.hcac.net", - "c.wlist.ro", - "c2.hu", - "c51vsgq.com", - "cachedot.net", - "car101.pro", - "cartelera.org", - "casualdx.com", - "cbair.com", - "ce.mintemail.com", - "cellurl.com", - "centermail.com", - "centermail.net", - "cetpass.com", - "chacuo.net", - "chammy.info", - "cheaphub.net", - "cheatmail.de", - "chechnya.conf.work", - "chogmail.com", - "choicemail1.com", - "chong-mail.com", - "chong-mail.net", - "chong-mail.org", - "ckaazaza.tk", - "clixser.com", - "clrmail.com", - "clubfier.com", - "cmail.com", - "cmail.net", - "cmail.org", - "cnn.coms.hk", - "cobarekyo1.ml", - "cocodani.cf", - "coldemail.info", - "consumerriot.com", - "contrasto.cu.cc", - "cool.fr.nf", - "correo.blogos.net", - "cosmorph.com", - "courriel.fr.nf", - "courrieltemporaire.com", - "crankmails.com", - "crapmail.org", - "crazespaces.pw", - "crazymailing.com", - "cream.pink", - "crotslep.ml", - "crotslep.tk", - "cubiclink.com", - "curryworld.de", - "cust.in", - "cuvox.de", - "cx.de-a.org", - "cyber-innovation.club", - "cyber-phone.eu", - "dacoolest.com", - "daintly.com", - "dandikmail.com", - "dasdasdascyka.tk", - "dayrep.com", - "dbunker.com", - "dcemail.com", - "deadaddress.com", - "deadchildren.org", - "deadfake.cf", - "deadfake.ga", - "deadfake.ml", - "deadfake.tk", - "deadspam.com", - "deagot.com", - "dealja.com", - "despam.it", - "despammed.com", - "devnullmail.com", - "dfgh.net", - "dfghj.ml", - "dharmatel.net", - "digitalsanctuary.com", - "dingbone.com", - "discard-email.cf", - "discard.cf", - "discard.email", - "discard.ga", - "discard.gq", - "discard.ml", - "discard.tk", - "discardmail.com", - "discardmail.de", - "disign-concept.eu", - "disign-revelation.com", - "dispomail.eu", - "disposable-email.ml", - "disposable.cf", - "disposable.ga", - "disposable.ml", - "disposableaddress.com", - "disposableemailaddresses.com", - "disposableemailaddresses.emailmiser.com", - "disposableinbox.com", - "dispose.it", - "disposeamail.com", - "disposemail.com", - "dispostable.com", - "divermail.com", - "divismail.ru", - "dlemail.ru", - "dm.w3internet.co.uk", - "dodgeit.com", - "dodgit.com", - "dodgit.org", - "dodsi.com", - "doiea.com", - "domforfb1.tk", - "domforfb2.tk", - "domforfb3.tk", - "domforfb4.tk", - "domforfb5.tk", - "domforfb6.tk", - "domforfb7.tk", - "domforfb8.tk", - "domforfb9.tk", - "domozmail.com", - "donemail.ru", - "dontreg.com", - "dontsendmespam.de", - "dot-ml.ml", - "dot-ml.tk", - "dotmsg.com", - "dr69.site", - "drdrb.com", - "drdrb.net", - "drivetagdev.com", - "droplar.com", - "dropmail.me", - "duam.net", - "dudmail.com", - "dump-email.info", - "dumpandjunk.com", - "dumpmail.de", - "dumpyemail.com", - "duskmail.com", - "dw.now.im", - "dx.abuser.eu", - "dx.allowed.org", - "dx.awiki.org", - "dx.ez.lv", - "dx.sly.io", - "e-mail.com", - "e-mail.org", - "e.arno.fi", - "e.blogspam.ro", - "e.discard-email.cf", - "e.milavitsaromania.ro", - "e.wupics.com", - "e0yk-mail.ml", - "e4ward.com", - "easytrashmail.com", - "ecolo-online.fr", - "ee2.pl", - "eelmail.com", - "einrot.com", - "einrot.de", - "email-fake.cf", - "email-fake.ga", - "email-fake.gq", - "email-fake.ml", - "email-fake.tk", - "email.cbes.net", - "email60.com", - "emailage.cf", - "emailage.ga", - "emailage.gq", - "emailage.ml", - "emailage.tk", - "emaildienst.de", - "emailfake.ml", - "emailgo.de", - "emailias.com", - "emailigo.de", - "emailinfive.com", - "emailisvalid.com", - "emaillime.com", - "emailmiser.com", - "emailproxsy.com", - "emails.ga", - "emailsensei.com", - "emailspam.cf", - "emailspam.ga", - "emailspam.gq", - "emailspam.ml", - "emailspam.tk", - "emailtea.com", - "emailtemporar.ro", - "emailtemporario.com.br", - "emailthe.net", - "emailtmp.com", - "emailto.de", - "emailwarden.com", - "emailx.at.hm", - "emailxfer.com", - "emailz.cf", - "emailz.ga", - "emailz.gq", - "emailz.ml", - "emeil.in", - "emeil.ir", - "emil.com", - "emkei.cf", - "emkei.ga", - "emkei.gq", - "emkei.ml", - "emkei.tk", - "eml.pp.ua", - "emltmp.com", - "emz.net", - "enterto.com", - "ephemail.net", - "eqiluxspam.ga", - "erasf.com", - "ese.kr", - "est.une.victime.ninja", - "estate-invest.fr", - "etranquil.com", - "etranquil.net", - "etranquil.org", - "eu.igg.biz", - "everytg.ml", - "evopo.com", - "evyush.com", - "explodemail.com", - "eyepaste.com", - "ezlo.co", - "f5.si", - "facebook-email.cf", - "facebook-email.ga", - "facebook-email.ml", - "facebookmail.gq", - "facebookmail.ml", - "fake-email.pp.ua", - "fake-mail.cf", - "fake-mail.ga", - "fake-mail.ml", - "fake.i-3gk.cf", - "fake.i-3gk.ga", - "fake.i-3gk.gq", - "fake.i-3gk.ml", - "fakeinbox.cf", - "fakeinbox.com", - "fakeinbox.ga", - "fakeinbox.ml", - "fakeinbox.tk", - "fakeinformation.com", - "fakemail.fr", - "fakemailgenerator.com", - "fakemailz.com", - "fammix.com", - "fansworldwide.de", - "fantasymail.de", - "fast-mail.fr", - "fastacura.com", - "fastchevy.com", - "fastchrysler.com", - "fastemails.us", - "fastkawasaki.com", - "fastmazda.com", - "fastmitsubishi.com", - "fastnissan.com", - "fastsubaru.com", - "fastsuzuki.com", - "fasttoyota.com", - "fastyamaha.com", - "fatflap.com", - "fbi.coms.hk", - "fbmail1.ml", - "fdfdsfds.com", - "ficken.de", - "fightallspam.com", - "fiifke.de", - "filzmail.com", - "fixmail.tk", - "fizmail.com", - "flashbox.5july.org", - "fleckens.hu", - "flemail.ru", - "flurred.com", - "flyspam.com", - "foodbooto.com", - "footard.com", - "forgetmail.com", - "fornow.eu", - "forward.cat", - "fr33mail.info", - "fragolina2.tk", - "frapmail.com", - "frappina.tk", - "frappina99.tk", - "free-email.cf", - "free-email.ga", - "freelance-france.eu", - "freemail.ms", - "freemail.tweakly.net", - "freemails.cf", - "freemails.ga", - "freemails.ml", - "freemeil.ga", - "freemeil.gq", - "freemeil.ml", - "freundin.ru", - "friendlymail.co.uk", - "front14.org", - "fuckingduh.com", - "fudgerub.com", - "fulvie.com", - "fun64.com", - "fun64.net", - "fuwamofu.com", - "fux0ringduh.com", - "fw.moza.pl", - "g.hmail.us", - "gafy.net", - "gamgling.com", - "gamno.config.work", - "garliclife.com", - "gawab.com", - "gelitik.in", - "get-mail.cf", - "get-mail.ga", - "get-mail.ml", - "get-mail.tk", - "get.pp.ua", - "get1mail.com", - "get2mail.fr", - "getairmail.cf", - "getairmail.com", - "getairmail.ga", - "getairmail.gq", - "getairmail.ml", - "getairmail.tk", - "getmails.eu", - "getnada.com", - "getonemail.com", - "getonemail.net", - "ghosttexter.de", - "girlsundertheinfluence.com", - "gishpuppy.com", - "glubex.com", - "go.irc.so", - "go2usa.info", - "godut.com", - "goemailgo.com", - "goooogle.flu.cc", - "goooogle.igg.biz", - "goooogle.nut.cc", - "goooogle.usa.cc", - "gorillaswithdirtyarmpits.com", - "gotmail.com", - "gotmail.net", - "gotmail.org", - "gotti.otherinbox.com", - "gowikibooks.com", - "gowikicampus.com", - "gowikicars.com", - "gowikifilms.com", - "gowikigames.com", - "gowikimusic.com", - "gowikinetwork.com", - "gowikitravel.com", - "gowikitv.com", - "grandmamail.com", - "grandmasmail.com", - "great-host.in", - "greensloth.com", - "grr.la", - "gsrv.co.uk", - "guerillamail.biz", - "guerillamail.com", - "guerillamail.net", - "guerillamail.org", - "guerrillamail.biz", - "guerrillamail.com", - "guerrillamail.de", - "guerrillamail.info", - "guerrillamail.net", - "guerrillamail.org", - "guerrillamailblock.com", - "gustr.com", - "h.mintemail.com", - "h8s.org", - "hacccc.com", - "haltospam.com", - "harakirimail.com", - "haribu.net", - "hartbot.de", - "hasanmail.ml", - "hash.pp.ua", - "hatespam.org", - "hellodream.mobi", - "herp.in", - "hezll.com", - "hidemail.de", - "hidemail.pro", - "hidemail.us", - "hidzz.com", - "hmamail.com", - "hochsitze.com", - "hoer.pw", - "holl.ga", - "hopemail.biz", - "hostcalls.com", - "hot-mail.cf", - "hot-mail.ga", - "hot-mail.gq", - "hot-mail.ml", - "hot-mail.tk", - "hotpop.com", - "housat.com", - "hstermail.com", - "hukkmu.tk", - "hulapla.de", - "humn.ws.gy", - "i.istii.ro", - "i.klipp.su", - "i.wawi.es", - "i.xcode.ro", - "i201zzf8x.com", - "i2pmail.org", - "ichigo.me", - "ieatspam.eu", - "ieatspam.info", - "ieh-mail.de", - "ihateyoualot.info", - "ihazspam.ca", - "iheartspam.org", - "ikbenspamvrij.nl", - "imails.info", - "imgof.com", - "imgv.de", - "immo-gerance.info", - "imstations.com", - "inbax.tk", - "inbound.plus", - "inbox.si", - "inboxalias.com", - "inboxbear.com", - "inboxclean.com", - "inboxclean.org", - "inboxproxy.com", - "inclusiveprogress.com", - "incognitomail.com", - "incognitomail.net", - "incognitomail.org", - "infest.org", - "info-radio.ml", - "inmynetwork.tk", - "insorg-mail.info", - "instant-mail.de", - "instantemailaddress.com", - "instantmail.fr", - "ip4.pp.ua", - "ip6.pp.ua", - "ipoo.org", - "irish2me.com", - "iroid.com", - "isdaq.com", - "italia.flu.cc", - "italia.igg.biz", - "itmtx.com", - "itsme.edu.pl", - "iwi.net", - "jcpclothing.ga", - "je-recycle.info", - "jet-renovation.fr", - "jetable.com", - "jetable.fr.nf", - "jetable.net", - "jetable.org", - "jetable.pp.ua", - "jnxjn.com", - "jobbikszimpatizans.hu", - "jourrapide.com", - "jp.ftp.sh", - "jsrsolutions.com", - "junk1e.com", - "junkmail.ga", - "junkmail.gq", - "jwk4227ufn.com", - "k.fido.be", - "kanker.website", - "kasmail.com", - "kaspop.com", - "katztube.com", - "kazelink.ml", - "keepmymail.com", - "keinpardon.de", - "kemska.pw", - "killmail.com", - "killmail.net", - "kimsdisk.com", - "kingsq.ga", - "kir.ch.tc", - "klassmaster.com", - "klassmaster.net", - "klzlk.com", - "knol-power.nl", - "kook.ml", - "koszmail.pl", - "kuatcak.cf", - "kuatcak.tk", - "kuatmail.gq", - "kuatmail.tk", - "kulturbetrieb.info", - "kurzepost.de", - "kusrc.com", - "l33r.eu", - "labetteraverouge.at", - "lackmail.net", - "lackmail.ru", - "lags.us", - "landmail.co", - "laoeq.com", - "laoho.com", - "last-chance.pro", - "lastmail.co", - "lastmail.com", - "lazyinbox.com", - "leeching.net", - "legalrc.loan", - "letthemeatspam.com", - "lhsdv.com", - "lifebyfood.com", - "lillemap.net", - "link2mail.net", - "linkedintuts2016.pw", - "litedrop.com", - "liveradio.tk", - "loadby.us", - "loan101.pro", - "login-email.cf", - "login-email.ga", - "login-email.ml", - "login-email.tk", - "loh.pp.ua", - "lol.ovpn.to", - "lolfreak.net", - "lolito.tk", - "lookugly.com", - "lopl.co.cc", - "lortemail.dk", - "lovefall.ml", - "lovemeleaveme.com", - "lovesea.gq", - "lr7.us", - "lr78.com", - "lroid.com", - "luv2.us", - "m.ddcrew.com", - "m4ilweb.info", - "maboard.com", - "macr2.com", - "mail-easy.fr", - "mail-filter.com", - "mail-temporaire.fr", - "mail-tester.com", - "mail.backflip.cf", - "mail.by", - "mail.mezimages.net", - "mail.wtf", - "mail114.net", - "mail2rss.org", - "mail333.com", - "mail4trash.com", - "mailbidon.com", - "mailblocks.com", - "mailbox72.biz", - "mailbox80.biz", - "mailbucket.org", - "mailcat.biz", - "mailcatch.com", - "maildrop.cc", - "maildrop.cf", - "maildrop.ga", - "maildrop.gq", - "maildrop.ml", - "maildx.com", - "maileater.com", - "mailed.ro", - "maileme101.com", - "mailexpire.com", - "mailfa.tk", - "mailfall.com", - "mailforspam.com", - "mailfree.ga", - "mailfree.gq", - "mailfree.ml", - "mailfreeonline.com", - "mailfs.com", - "mailguard.me", - "mailhero.io", - "mailimate.com", - "mailin8r.com", - "mailinatar.com", - "mailinater.com", - "mailinator.com", - "mailinator.gq", - "mailinator.net", - "mailinator.org", - "mailinator.us", - "mailinator2.com", - "mailincubator.com", - "mailismagic.com", - "mailjunk.cf", - "mailjunk.ga", - "mailjunk.gq", - "mailjunk.ml", - "mailjunk.tk", - "mailmate.com", - "mailme.gq", - "mailme.ir", - "mailme.lv", - "mailme24.com", - "mailmetrash.com", - "mailmoat.com", - "mailna.me", - "mailnator.com", - "mailnesia.com", - "mailnull.com", - "mailpick.biz", - "mailproxsy.com", - "mailquack.com", - "mailrock.biz", - "mailsac.com", - "mailscheap.us", - "mailscrap.com", - "mailseal.de", - "mailshell.com", - "mailsiphon.com", - "mailslapping.com", - "mailslite.com", - "mailspam.usa.cc", - "mailspam.xyz", - "mailtemp.info", - "mailtome.de", - "mailtothis.com", - "mailzi.ru", - "mailzilla.com", - "mailzilla.org", - "mailzilla.orgmbx.cc", - "makemetheking.com", - "manifestgenerator.com", - "manybrain.com", - "martin.securehost.com.es", - "materiali.ml", - "mbx.cc", - "mciek.com", - "mega.zik.dj", - "meinspamschutz.de", - "meltmail.com", - "merda.flu.cc", - "merda.igg.biz", - "merda.nut.cc", - "merda.usa.cc", - "merry.pink", - "messagebeamer.de", - "mezimages.net", - "mfsa.ru", - "mierdamail.com", - "migmail.net", - "migmail.pl", - "migumail.com", - "mintemail.com", - "mjukglass.nu", - "moakt.com", - "moakt.ws", - "mobi.web.id", - "mobileninja.co.uk", - "moburl.com", - "mohmal.com", - "mohmal.im", - "mohmal.in", - "mohmal.tech", - "moncourrier.fr.nf", - "monemail.fr.nf", - "monmail.fr.nf", - "monumentmail.com", - "mor19.uu.gl", - "morahdsl.cf", - "mox.pp.ua", - "mrresourcepacks.tk", - "ms9.mailslite.com", - "msa.minsmail.com", - "mt2009.com", - "mt2014.com", - "mt2015.com", - "mt2016.com", - "mt2017.com", - "muehlacker.tk", - "muq.orangotango.tk", - "mvrht.com", - "mx0.wwwnew.eu", - "my.efxs.ca", - "my.spam.orangotango.ml", - "my10minutemail.com", - "mycleaninbox.net", - "myemailboxy.com", - "mymail-in.net", - "mymailoasis.com", - "mymailto.cf", - "mymailto.ga", - "myneocards.cz", - "mynetstore.de", - "mypacks.net", - "mypartyclip.de", - "myphantomemail.com", - "myspaceinc.com", - "myspaceinc.net", - "myspaceinc.org", - "myspacepimpedup.com", - "myspamless.com", - "mytemp.email", - "mytempemail.com", - "mytrashmail.com", - "n.ra3.us", - "n.spamtrap.co", - "n.zavio.nl", - "napalm51.cf", - "napalm51.flu.cc", - "napalm51.ga", - "napalm51.gq", - "napalm51.igg.biz", - "napalm51.ml", - "napalm51.nut.cc", - "napalm51.tk", - "napalm51.usa.cc", - "neko2.net", - "neomailbox.com", - "nepwk.com", - "nervmich.net", - "nervtmich.net", - "netmails.com", - "netmails.net", - "netzidiot.de", - "neverbox.com", - "nezzart.com", - "nice-4u.com", - "nike.coms.hk", - "nincsmail.com", - "nmail.cf", - "no-spam.ws", - "nobulk.com", - "noclickemail.com", - "nogmailspam.info", - "nomail.xl.cx", - "nomail2me.com", - "nomorespamemails.com", - "nonspam.eu", - "nonspammer.de", - "noref.in", - "nospam.wins.com.br", - "nospam.ze.tc", - "nospam4.us", - "nospamfor.us", - "nospamthanks.info", - "notmailinator.com", - "notsharingmy.info", - "nowhere.org", - "nowmymail.com", - "ntlhelp.net", - "nurfuerspam.de", - "nus.edu.sg", - "nutpa.net", - "nwldx.com", - "o.cfo2go.ro", - "o.oai.asia", - "o.opendns.ro", - "o.spamtrap.ro", - "objectmail.com", - "obobbo.com", - "odaymail.com", - "olypmall.ru", - "one-time.email", - "oneoffemail.com", - "oneoffmail.com", - "onewaymail.com", - "online.ms", - "oopi.org", - "opayq.com", - "orango.cu.cc", - "ordinaryamerican.net", - "oshietechan.link", - "otherinbox.com", - "ourklips.com", - "outlawspam.com", - "ovpn.to", - "owlpic.com", - "p71ce1m.com", - "pagamenti.tk", - "pancakemail.com", - "paplease.com", - "parlimentpetitioner.tk", - "pcusers.otherinbox.com", - "pepbot.com", - "pepsi.coms.hk", - "pfui.ru", - "photo-impact.eu", - "phpbb.uu.gl", - "phus8kajuspa.cu.cc", - "pig.pp.ua", - "pimpedupmyspace.com", - "pjjkp.com", - "plexolan.de", - "ploae.com", - "po.bot.nu", - "poh.pp.ua", - "pokemail.net", - "politikerclub.de", - "polyfaust.com", - "poofy.org", - "pookmail.com", - "porco.cf", - "porco.ga", - "porco.gq", - "porco.ml", - "postacin.com", - "ppetw.com", - "premium-mail.fr", - "privacy.net", - "privy-mail.com", - "privymail.de", - "project-xhabbo.com", - "proxymail.eu", - "prtnx.com", - "prtz.eu", - "psles.com", - "psoxs.com", - "punkass.com", - "purple.flu.cc", - "purple.igg.biz", - "purple.nut.cc", - "purple.usa.cc", - "puttanamaiala.tk", - "putthisinyourspamdatabase.com", - "pw.flu.cc", - "pw.igg.biz", - "pw.nut.cc", - "pwrby.com", - "q5vm7pi9.com", - "qasti.com", - "qbfree.us", - "qisdo.com", - "qisoa.com", - "qs.dp76.com", - "quickinbox.com", - "quickmail.nl", - "r8.porco.cf", - "radiku.ye.vc", - "rajeshcon.cf", - "rcpt.at", - "reality-concept.club", - "reallymymail.com", - "receiveee.chickenkiller.com", - "receiveee.com", - "recode.me", - "reconmail.com", - "recursor.net", - "recyclemail.dk", - "reddit.usa.cc", - "regbypass.com", - "regbypass.comsafe-mail.net", - "regspaces.tk", - "rejectmail.com", - "remail.cf", - "remail.ga", - "resgedvgfed.tk", - "rhyta.com", - "rk9.chickenkiller.com", - "rklips.com", - "rkomo.com", - "rmqkr.net", - "rootfest.net", - "royal.net", - "rppkn.com", - "rtrtr.com", - "rudymail.ml", - "ruffrey.com", - "ruru.be", - "rx.dred.ru", - "rx.qc.to", - "s.bloq.ro", - "s.dextm.ro", - "s.proprietativalcea.ro", - "s.sa.igg.biz", - "s.spamserver.flu.cc", - "s.vdig.com", - "s00.orangotango.ga", - "s0ny.net", - "sa.igg.biz", - "safe-mail.net", - "safersignup.de", - "safetymail.info", - "safetypost.de", - "sandelf.de", - "savelife.ml", - "saynotospams.com", - "scatmail.com", - "schafmail.de", - "secure-mail.biz", - "secure-mail.cc", - "securehost.com.es", - "selfdestructingmail.com", - "selfdestructingmail.org", - "sendspamhere.com", - "servermaps.net", - "sfmail.top", - "sharedmailbox.org", - "sharklasers.com", - "shieldedmail.com", - "shiftmail.com", - "shitaway.cf", - "shitaway.cu.cc", - "shitaway.flu.cc", - "shitaway.ga", - "shitaway.gq", - "shitaway.igg.biz", - "shitaway.ml", - "shitaway.nut.cc", - "shitaway.tk", - "shitaway.usa.cc", - "shitmail.de", - "shitmail.me", - "shitmail.org", - "shitware.nl", - "shockinmytown.cu.cc", - "shortmail.net", - "shotmail.ru", - "showslow.de", - "shuffle.email", - "sibmail.com", - "siliwangi.ga", - "sinnlos-mail.de", - "siteposter.net", - "skeefmail.com", - "skrx.tk", - "sky-mail.ga", - "slaskpost.se", - "slave-auctions.net", - "slippery.email", - "slipry.net", - "slopsbox.com", - "slushmail.com", - "smap.4nmv.ru", - "smashmail.de", - "smellfear.com", - "smellrear.com", - "snakemail.com", - "sneakemail.com", - "snkmail.com", - "social-mailer.tk", - "sofimail.com", - "sofort-mail.de", - "softpls.asia", - "sogetthis.com", - "sohu.com", - "soisz.com", - "solar-impact.pro", - "solvemail.info", - "soodomail.com", - "soodonims.com", - "spam-a.porco.cf", - "spam-b.porco.cf", - "spam-be-gone.com", - "spam.2012-2016.ru", - "spam.la", - "spam.orangotango.ml", - "spam.su", - "spam4.me", - "spamavert.com", - "spambob.com", - "spambob.net", - "spambob.org", - "spambog.com", - "spambog.de", - "spambog.net", - "spambog.ru", - "spambooger.com", - "spambox.info", - "spambox.irishspringrealty.com", - "spambox.us", - "spamcannon.com", - "spamcannon.net", - "spamcero.com", - "spamcon.org", - "spamcorptastic.com", - "spamcowboy.com", - "spamcowboy.net", - "spamcowboy.org", - "spamday.com", - "spamdecoy.net", - "spamex.com", - "spamfighter.cf", - "spamfighter.ga", - "spamfighter.gq", - "spamfighter.ml", - "spamfighter.tk", - "spamfree.eu", - "spamfree24.com", - "spamfree24.de", - "spamfree24.eu", - "spamfree24.info", - "spamfree24.net", - "spamfree24.org", - "spamgoes.in", - "spamgourmet.com", - "spamgourmet.net", - "spamgourmet.org", - "spamherelots.com", - "spamhereplease.com", - "spamhole.com", - "spamify.com", - "spaminator.de", - "spamkill.info", - "spaml.com", - "spaml.de", - "spammotel.com", - "spamobox.com", - "spamoff.de", - "spamsalad.in", - "spamserver.cf", - "spamserver.flu.cc", - "spamserver.ml", - "spamserver.tk", - "spamslicer.com", - "spamspot.com", - "spamstack.net", - "spamthis.co.uk", - "spamthisplease.com", - "spamtrail.com", - "spamtroll.net", - "speed.1s.fr", - "sperma.cf", - "spikio.com", - "spoofmail.de", - "spybox.de", - "squizzy.de", - "squizzy.net", - "sr.ro.lt", - "sraka.xyz", - "sroff.com", - "ss.undo.it", - "ssoia.com", - "startkeys.com", - "stexsy.com", - "stinkefinger.net", - "stop-my-spam.cf", - "stop-my-spam.com", - "stop-my-spam.ga", - "stop-my-spam.ml", - "stop-my-spam.pp.ua", - "stop-my-spam.tk", - "streetwisemail.com", - "stromox.com", - "stuffmail.de", - "sudolife.me", - "sudolife.net", - "sudomail.biz", - "sudomail.com", - "sudomail.net", - "sudoverse.com", - "sudoverse.net", - "sudoweb.net", - "sudoworld.com", - "sudoworld.net", - "supergreatmail.com", - "supermailer.jp", - "superrito.com", - "superstachel.de", - "suremail.info", - "susi.ml", - "svk.jp", - "sweetxxx.de", - "szerz.com", - "t.psh.me", - "tafmail.com", - "taglead.com", - "tagyourself.com", - "talkinator.com", - "tapchicuoihoi.com", - "tarzan.usa.cc", - "tarzanmail.cf", - "tarzanmail.ml", - "teamspeak3.ga", - "teewars.org", - "teleworm.com", - "teleworm.us", - "temp-mail.com", - "temp-mail.de", - "temp-mail.org", - "temp.bartdevos.be", - "temp.emeraldwebmail.com", - "temp.headstrong.de", - "temp.mail.y59.jp", - "tempail.com", - "tempalias.com", - "tempe-mail.com", - "tempemail.biz", - "tempemail.co.za", - "tempemail.com", - "tempemail.net", - "tempinbox.co.uk", - "tempinbox.com", - "tempmail.co", - "tempmail.it", - "tempmail.pro", - "tempmail.us", - "tempmail2.com", - "tempmaildemo.com", - "tempmailer.com", - "tempomail.fr", - "temporarily.de", - "temporarioemail.com.br", - "temporaryemail.net", - "temporaryemail.us", - "temporaryforwarding.com", - "temporaryinbox.com", - "tempsky.com", - "tempthe.net", - "tempymail.com", - "thanksnospam.info", - "thankyou2010.com", - "thecloudindex.com", - "thereddoors.online", - "thisisnotmyrealemail.com", - "thraml.com", - "thrma.com", - "throam.com", - "thrott.com", - "throwam.com", - "throwawayemailaddress.com", - "throwawaymail.com", - "throya.com", - "tilien.com", - "tittbit.in", - "tm.tosunkaya.com", - "tmail.ws", - "tmailinator.com", - "toiea.com", - "toomail.biz", - "tradermail.info", - "tralalajos.ga", - "tralalajos.gq", - "tralalajos.ml", - "tralalajos.tk", - "trash-amil.com", - "trash-mail.at", - "trash-mail.cf", - "trash-mail.com", - "trash-mail.de", - "trash-mail.ga", - "trash-mail.gq", - "trash-mail.ml", - "trash-mail.tk", - "trash2009.com", - "trash2010.com", - "trash2011.com", - "trashcanmail.com", - "trashdevil.com", - "trashdevil.de", - "trashemail.de", - "trashmail.at", - "trashmail.com", - "trashmail.de", - "trashmail.me", - "trashmail.net", - "trashmail.org", - "trashmail.ws", - "trashmailer.com", - "trashymail.com", - "trashymail.net", - "trayna.com", - "trbvm.com", - "trbvn.com", - "trbvo.com", - "trickmail.net", - "trillianpro.com", - "trump.flu.cc", - "trump.igg.biz", - "tryalert.com", - "turoid.com", - "turual.com", - "tvchd.com", - "tverya.com", - "twinmail.de", - "twoweirdtricks.com", - "ty.ceed.se", - "tyldd.com", - "u.0u.ro", - "u.10x.es", - "u.2sea.org", - "u.900k.es", - "u.civvic.ro", - "u.dmarc.ro", - "u.labo.ch", - "u14269.ml", - "uacro.com", - "ubismail.net", - "ucupdong.ml", - "uggsrock.com", - "uk.flu.cc", - "uk.igg.biz", - "uk.nut.cc", - "umail.net", - "unmail.ru", - "upliftnow.com", - "uplipht.com", - "urfey.com", - "uroid.com", - "used-product.fr", - "username.e4ward.com", - "ux.dob.jp", - "ux.uk.to", - "v.0v.ro", - "v.jsonp.ro", - "vaasfc4.tk", - "valemail.net", - "venompen.com", - "veryrealemail.com", - "vfemail.net", - "vickaentb.tk", - "vidchart.com", - "viditag.com", - "viewcastmedia.com", - "viewcastmedia.net", - "viewcastmedia.org", - "visa.coms.hk", - "vkcode.ru", - "vomoto.com", - "vp.ycare.de", - "vps30.com", - "vps911.net", - "vssms.com", - "vubby.com", - "vzlom4ik.tk", - "w.0w.ro", - "walala.org", - "walkmail.net", - "walkmail.ru", - "wasd.dropmail.me", - "wazabi.club", - "we.qq.my", - "web-contact.info", - "web-emailbox.eu", - "web-ideal.fr", - "web-mail.pp.ua", - "web.discard-email.cf", - "webcontact-france.eu", - "webemail.me", - "webm4il.info", - "webuser.in", - "wee.my", - "wefjo.grn.cc", - "weg-werf-email.de", - "wegwerf-email-addressen.de", - "wegwerf-emails.de", - "wegwerfadresse.de", - "wegwerfemail.de", - "wegwerfmail.de", - "wegwerfmail.info", - "wegwerfmail.net", - "wegwerfmail.org", - "wegwerpmailadres.nl", - "wetrainbayarea.com", - "wetrainbayarea.org", - "wfgdfhj.tk", - "wh4f.org", - "whatiaas.com", - "whatpaas.com", - "whatsaas.com", - "whopy.com", - "whtjddn.33mail.com", - "whyspam.me", - "wickmail.net", - "wilemail.com", - "willselfdestruct.com", - "winemaven.info", - "wiz2.site", - "wmail.cf", - "wollan.info", - "worldspace.link", - "wovz.cu.cc", - "wr.moeri.org", - "wronghead.com", - "wt2.orangotango.cf", - "wuzup.net", - "wuzupmail.net", - "www.bccto.me", - "www.e4ward.com", - "www.gishpuppy.com", - "www.mailinator.com", - "wwwnew.eu", - "xagloo.com", - "xemaps.com", - "xents.com", - "xing886.uu.gl", - "xmaily.com", - "xoxox.cc", - "xoxy.net", - "xww.ro", - "xy9ce.tk", - "xyzfree.net", - "xzsok.com", - "yapped.net", - "yeah.net", - "yellow.flu.cc", - "yellow.hotakama.tk", - "yellow.igg.biz", - "yep.it", - "yert.ye.vc", - "yogamaven.com", - "yomail.info", - "yopmail.com", - "yopmail.fr", - "yopmail.gq", - "yopmail.net", - "yopmail.pp.ua", - "yordanmail.cf", - "youmail.ga", - "yourlifesucks.cu.cc", - "ypmail.webarnak.fr.eu.org", - "yroid.com", - "yuurok.com", - "yyt.resolution4print.info", - "z1p.biz", - "za.com", - "zain.site", - "zainmax.net", - "zaktouni.fr", - "ze.gally.jp", - "zehnminutenmail.de", - "zeta-telecom.com", - "zetmail.com", - "zhcne.com", - "zhouemail.510520.org", - "zippymail.info", - "zoaxe.com", - "zoemail.com", - "zoemail.net", - "zoemail.org", - "zombo.flu.cc", - "zombo.igg.biz", - "zombo.nut.cc", - "zomg.info", - "zxcv.com", - "zxcvbnm.com", - "zzz.com", - "225522.ml", - "44556677.igg.biz", - "466453.usa.cc", - "a0.igg.biz", - "a1.usa.cc", - "a2.flu.cc", - "anon.leemail.me", - "anonymize.com", - "asiarap.usa.cc", - "ay33rs.flu.cc", - "b0.nut.cc", - "bloxter.cu.cc", - "browniesgoreng.com", - "brownieskukuskreasi.com", - "brownieslumer.com", - "c4utar.ml", - "citroen-c1.ml", - "colafanta.cf", - "de-fake.instafly.cf", - "de-fake.webfly.cf", - "fake-box.com", - "fiat-500.ga", - "horvathurtablahoz.ml", - "hunrap.usa.cc", - "kachadresp.tk", - "lajoska.pe.hu", - "mrblacklist.gq", - "opel-corsa.tk", - "opentrash.com", - "paller.cf", - "password.colafanta.cf", - "re-gister.com", - "renault-clio.cf", - "retkesbusz.nut.cc", - "spam.flu.cc", - "spam.igg.biz", - "spam.nut.cc", - "spam.usa.cc", - "teleosaurs.xyz", - "top9appz.info", - "trash-me.com", - "viroleni.cu.cc", - "vw-golf.gq", - "yandere.cu.cc", - "you-spam.com", - "inbox2.info", - "nullbox.info", - "spambox.org", - "mailed.in", - "onlatedotcom.info", - "smapfree24.org", - "smapfree24.de", - "smapfree24.info", - "smapfree24.com", - "smapfree24.eu", - "email.net", - "ma1l.bij.pl", - "sify.com", - "tmail.com", - "xmail.com", - "atvclub.msk.ru", - "xagloo.co", - "mailhazard.com", - "mailhazard.us", - "mailhz.me", - "zebins.com", - "zebins.eu", - "amail4.me", - "1fsdfdsfsdf.tk", - "2fdgdfgdfgdf.tk", - "3trtretgfrfe.tk", - "4gfdsgfdgfd.tk", - "5ghgfhfghfgh.tk", - "6hjgjhgkilkj.tk", - "abcmail.email", - "ama-trade.de", - "anonmails.de", - "antireg.ru", - "antispammail.de", - "artman-conception.com", - "breakthru.com", - "bspamfree.org", - "buymoreplays.com", - "card.zp.ua", - "cek.pm", - "childsavetrust.org", - "d3p.dk", - "delikkt.de", - "dm.w3internet.co.ukexample.com", - "einmalmail.de", - "eintagsmail.de", - "emailtemporanea.com", - "emailtemporanea.net", - "ero-tube.org", - "express.net.ua", - "fivemail.de", - "fyii.de", - "gehensiemirnichtaufdensack.de", - "giantmail.de", - "gmial.com", - "hat-geld.de", - "infocom.zp.ua", - "inoutmail.de", - "inoutmail.eu", - "inoutmail.info", - "inoutmail.net", - "ip6.li", - "lawlita.com", - "lukop.dk", - "m21.cc", - "mail.zp.ua", - "mail1a.de", - "mail21.cc", - "mailbiz.biz", - "mailde.de", - "mailde.info", - "maileimer.de", - "mailms.com", - "mailorg.org", - "mailtrash.net", - "mailtv.net", - "mailtv.tv", - "ministry-of-silly-walks.de", - "misterpinball.de", - "mycard.net.ua", - "mysamp.de", - "mytempmail.com", - "nabuma.com", - "nincsmail.hu", - "nnh.com", - "noblepioneer.com", - "nomail.pw", - "nospammail.net", - "odnorazovoe.ru", - "poczta.onet.pl", - "privatdemail.net", - "realtyalerts.ca", - "reliable-mail.com", - "schrott-email.de", - "secretemail.de", - "senseless-entertainment.com", - "services391.com", - "shieldemail.com", - "shmeriously.com", - "slapsfromlastnight.com", - "sneakmail.de", - "spamail.de", - "spamarrest.com", - "squizzy.eu", - "super-auswahl.de", - "temp-mail.ru", - "tempmail.eu", - "tempmailer.de", - "temporarymailaddress.com", - "thc.st", - "thelimestones.com", - "thismail.net", - "tizi.com", - "topranklist.de", - "trialmail.de", - "us.af", - "viralplays.com", - "vpn.st", - "vsimcard.com", - "wasteland.rfc822.org", - "wegwerfemail.com", - "willhackforfood.biz", - "x.ip6.li", - "yourdomain.com", - "zehnminuten.de", - "0815.ry", - "0845.ru", - "10mail.com", - "10minut.com.pl", - "10minutesmail.com", - "10x9.com", - "12houremail.com", - "12minutemail.net", - "420blaze.it", - "8127ep.com", - "8chan.co", - "a.mailcker.com", - "a.vztc.com", - "akapost.com", - "akerd.com", - "ama-trans.de", - "anon-mail.de", - "antireg.com", - "antispam24.de", - "asdasd.ru", - "b2cmail.de", - "bio-muesli.info", - "blackmarket.to", - "br.mintemail.com", - "bugmenever.com", - "cam4you.cc", - "cc.liamria", - "cock.li", - "coieo.com", - "cumallover.me", - "dicksinhisan.us", - "dicksinmyan.us", - "dotman.de", - "dropcake.de", - "edv.to", - "ee1.pl", - "example.com", - "fakedemail.com", - "film-blog.biz", - "fly-ts.de", - "freeletter.me", - "garbagemail.org", - "garrifulio.mailexpire.com", - "geschent.biz", - "gmal.com", - "goat.si", - "gomail.in", - "guerillamailblock.com", - "horsefucker.org", - "hotmai.com", - "hotmial.com", - "humaility.com", - "hush.ai", - "ignoremail.com", - "inboxdesign.me", - "inboxed.im", - "inboxed.pw", - "inboxstore.me", - "iozak.com", - "is.af", - "junk.to", - "kmhow.com", - "kostenlosemailadresse.de", - "lavabit.com", - "linuxmail.so", - "llogin.ru", - "losemymail.com", - "loves.dicksinhisan.us", - "loves.dicksinmyan.us", - "luckymail.org", - "mac.hush.com", - "mail2world.com", - "maildu.de", - "mailita.tk", - "malahov.de", - "msb.minsmail.com", - "muchomail.com", - "national.shitposting.agency", - "nevermail.de", - "nigge.rs", - "nobugmail.com", - "nobuma.com", - "ohaaa.de", - "omail.pro", - "postonline.me", - "powered.name", - "privy-mail.de", - "put2.net", - "qoika.com", - "rcs.gaggle.net", - "redchan.it", - "schmeissweg.tk", - "secmail.pw", - "server.ms", - "shut.name", - "shut.ws", - "sky-ts.de", - "sofortmail.de", - "sry.li", - "suioe.com", - "superplatyna.com", - "techemail.com", - "techgroup.me", - "tfwno.gf", - "tokem.co", - "tormail.org", - "trashinbox.com", - "uyhip.com", - "vipmail.name", - "vipmail.pw", - "vztc.com", - "wants.dicksinhisan.us", - "wants.dicksinmyan.us", - "watch-harry-potter.com", - "watchfull.net", - "wegwerf-email-adressen.de", - "wegwerf-email.de", - "wegwerf-email.net", - "wegwerfemail.net", - "wegwerfemail.org", - "wegwerfemailadresse.com", - "wegwrfmail.de", - "wegwrfmail.net", - "wegwrfmail.org", - "wolfsmail.tk", - "writeme.us", - "yanet.me", - "youmailr.com", - "yxzx.net", - "randomail.net", - "0x207.info", - "1-8.biz", - "100likers.com", - "140unichars.com", - "147.cl", - "14n.co.uk", - "1st-forms.com", - "1to1mail.org", - "2120001.net", - "36ru.com", - "3l6.com", - "4-n.us", - "418.dk", - "5gramos.com", - "5oz.ru", - "5x25.com", - "672643.net", - "80665.com", - "abakiss.com", - "academiccommunity.com", - "adobeccepdm.com", - "adpugh.org", - "adsd.org", - "adwaterandstir.com", - "aegia.net", - "aegiscorp.net", - "aeonpsi.com", - "agtx.net", - "al-qaeda.us", - "aligamel.com", - "alisongamel.com", - "alldirectbuy.com", - "allen.nom.za", - "allthegoodnamesaretaken.org", - "alph.wtf", - "amazon-aws.org", - "amelabs.com", - "ampsylike.com", - "an.id.au", - "anappfor.com", - "andthen.us", - "animesos.com", - "anonymized.org", - "anonymousness.com", - "ansibleemail.com", - "anthony-junkmail.com", - "apfelkorps.de", - "aphlog.com", - "appc.se", - "appinventor.nl", - "aron.us", - "arroisijewellery.com", - "arvato-community.de", - "aschenbrandt.net", - "ashleyandrew.com", - "astroempires.info", - "at0mik.org", - "augmentationtechnology.com", - "autorobotica.com", - "autotwollow.com", - "axiz.org", - "azcomputerworks.com", - "badgerland.eu", - "badoop.com", - "basscode.org", - "bauwerke-online.com", - "bazaaboom.com", - "bcast.ws", - "bearsarefuzzy.com", - "belljonestax.com", - "benipaula.org", - "bestchoiceusedcar.com", - "bidourlnks.com", - "bigwhoop.co.za", - "blip.ch", - "bluedumpling.info", - "bluewerks.com", - "bobmurchison.com", - "bonobo.email", - "bookthemmore.com", - "borged.com", - "borged.net", - "borged.org", - "brandallday.net", - "briggsmarcus.com", - "bspooky.com", - "btb-notes.com", - "btc.email", - "bulrushpress.com", - "bum.net", - "bunchofidiots.com", - "bunsenhoneydew.com", - "businessbackend.com", - "businesssuccessislifesuccess.com", - "buspad.org", - "buyordie.info", - "byebyemail.com", - "byespm.com", - "californiafitnessdeals.com", - "chielo.com", - "chilkat.com", - "chithinh.com", - "chumpstakingdumps.com", - "cigar-auctions.com", - "ckiso.com", - "cl-cl.org", - "cl0ne.net", - "clandest.in", - "cnamed.com", - "cnmsg.net", - "cnsds.de", - "codeandscotch.com", - "codivide.com", - "compareshippingrates.org", - "completegolfswing.com", - "comwest.de", - "coolandwacky.us", - "coolimpool.org", - "crankhole.com", - "crastination.de", - "crossroadsmail.com", - "cszbl.com", - "daemsteam.com", - "dammexe.net", - "darkharvestfilms.com", - "daryxfox.net", - "dash-pads.com", - "dataarca.com", - "datarca.com", - "datazo.ca", - "davidkoh.net", - "davidlcreative.com", - "dealrek.com", - "deekayen.us", - "defomail.com", - "degradedfun.net", - "delayload.com", - "delayload.net", - "der-kombi.de", - "derkombi.de", - "derluxuswagen.de", - "diapaulpainting.com", - "digitalmariachis.com", - "dildosfromspace.com", - "dispo.in", - "dodgemail.de", - "dolphinnet.net", - "doquier.tk", - "dotslashrage.com", - "douchelounge.com", - "dozvon-spb.ru", - "droolingfanboy.de", - "dspwebservices.com", - "dukedish.com", - "durandinterstellar.com", - "dyceroprojects.com", - "dz17.net", - "e3z.de", - "ebeschlussbuch.de", - "ebs.com.ar", - "ecallheandi.com", - "edinburgh-airporthotels.com", - "elearningjournal.org", - "electro.mn", - "elitevipatlantamodels.com", - "emailresort.com", - "emailsingularity.net", - "ephemeral.email", - "ericjohnson.ml", - "esc.la", - "escapehatchapp.com", - "esemay.com", - "esgeneri.com", - "esprity.com", - "evanfox.info", - "exitstageleft.net", - "ezstest.com", - "f4k.es", - "fag.wf", - "failbone.com", - "faithkills.com", - "fangoh.com", - "farrse.co.uk", - "fasternet.biz", - "fer-gabon.org", - "fettometern.com", - "fictionsite.com", - "figshot.com", - "filbert4u.com", - "filberts4u.com", - "flowu.com", - "flyinggeek.net", - "foobarbot.net", - "forecastertests.com", - "forspam.net", - "foxtrotter.info", - "freebabysittercam.com", - "freeblackbootytube.com", - "freecat.net", - "freedompop.us", - "freefattymovies.com", - "freemail.hu", - "freeplumpervideos.com", - "freeschoolgirlvids.com", - "freesistercam.com", - "freeteenbums.com", - "fuckedupload.com", - "funnycodesnippets.com", - "furzauflunge.de", - "g4hdrop.us", - "galaxy.tv", - "gamegregious.com", - "garbagecollector.org", - "gardenscape.ca", - "garrymccooey.com", - "gav0.com", - "geldwaschmaschine.de", - "genderfuck.net", - "giaiphapmuasam.com", - "ginzi.be", - "ginzi.co.uk", - "ginzi.es", - "ginzi.net", - "ginzy.co.uk", - "ginzy.eu", - "girlsindetention.com", - "glitch.sx", - "globaltouron.com", - "glucosegrin.com", - "gnctr-calgary.com", - "gothere.biz", - "greggamel.com", - "greggamel.net", - "gregorsky.zone", - "gregorygamel.com", - "gregorygamel.net", - "gs-arc.org", - "gsredcross.org", - "gudanglowongan.com", - "gynzi.co.uk", - "gynzi.es", - "gynzy.at", - "gynzy.es", - "gynzy.eu", - "gynzy.gr", - "gynzy.info", - "gynzy.lt", - "gynzy.mobi", - "gynzy.pl", - "gynzy.ro", - "gynzy.sk", - "habitue.net", - "hackthatbit.ch", - "hahawrong.com", - "hawrong.com", - "hazelnut4u.com", - "hazelnuts4u.com", - "hazmatshipping.org", - "heathenhammer.com", - "heathenhero.com", - "helloricky.com", - "helpinghandtaxcenter.org", - "herpderp.nl", - "hiddentragedy.com", - "highbros.org", - "hoanggiaanh.com", - "hungpackage.com", - "huskion.net", - "hvastudiesucces.nl", - "hwsye.net", - "ibnuh.bz", - "icantbelieveineedtoexplainthisshit.com", - "icx.in", - "illistnoise.com", - "ilovespam.com", - "indieclad.com", - "indirect.ws", - "ineec.net", - "insanumingeniumhomebrew.com", - "interstats.org", - "intersteller.com", - "ironiebehindert.de", - "irssi.tv", - "isukrainestillacountry.com", - "it7.ovh", - "itunesgiftcodegenerator.com", - "j-p.us", - "jafps.com", - "jdmadventures.com", - "jellyrolls.com", - "jobposts.net", - "jobs-to-be-done.net", - "joelpet.com", - "joetestalot.com", - "jopho.com", - "jungkamushukum.com", - "kakadua.net", - "kalapi.org", - "kamsg.com", - "kariplan.com", - "kartvelo.com", - "kcrw.de", - "keinhirn.de", - "keipino.de", - "kemptvillebaseball.com", - "kennedy808.com", - "kiois.com", - "kisstwink.com", - "kitnastar.com", - "kludgemush.com", - "kommunity.biz", - "kopagas.com", - "kopaka.net", - "kosmetik-obatkuat.com", - "krypton.tk", - "kuhrap.com", - "kwift.net", - "kwilco.net", - "l-c-a.us", - "lakelivingstonrealestate.com", - "letmeinonthis.com", - "lez.se", - "liamcyrus.com", - "lifetotech.com", - "ligsb.com", - "lilo.me", - "lindenbaumjapan.com", - "lkgn.se", - "locomodev.net", - "loin.in", - "lolmail.biz", - "lpfmgmtltd.com", - "lru.me", - "lukecarriere.com", - "lukemail.info", - "lyfestylecreditsolutions.com", - "macromaid.com", - "magamail.com", - "magicbox.ro", - "maidlow.info", - "mail-owl.com", - "mail707.com", - "mailback.com", - "mailchop.com", - "mailinator.co.uk", - "mailinator.info", - "mailonaut.com", - "mailorc.com", - "malayalamdtp.com", - "mansiondev.com", - "markmurfin.com", - "mcache.net", - "messwiththebestdielikethe.rest", - "miaferrari.com", - "midcoastcustoms.com", - "midcoastcustoms.net", - "midcoastsolutions.com", - "midcoastsolutions.net", - "midlertidig.com", - "midlertidig.net", - "midlertidig.org", - "mijnhva.nl", - "mildin.org.ua", - "mkpfilm.com", - "ml8.ca", - "mockmyid.com", - "momentics.ru", - "moneypipe.net", - "moonwake.com", - "moreawesomethanyou.com", - "moreorcs.com", - "motique.de", - "mountainregionallibrary.net", - "msgos.com", - "mspeciosa.com", - "msxd.com", - "mtmdev.com", - "muathegame.com", - "mucincanon.com", - "mutant.me", - "mwarner.org", - "mxfuel.com", - "mybitti.de", - "mycorneroftheinter.net", - "mydemo.equipment", - "myecho.es", - "mykickassideas.com", - "myopang.com", - "mywarnernet.net", - "myzx.com", - "n1nja.org", - "nakedtruth.biz", - "nanonym.ch", - "nationalgardeningclub.com", - "negated.com", - "netricity.nl", - "netris.net", - "netviewer-france.com", - "nextstopvalhalla.com", - "nfast.net", - "nguyenusedcars.com", - "nicknassar.com", - "niwl.net", - "nnot.net", - "no-ux.com", - "nodezine.com", - "norseforce.com", - "nothingtoseehere.ca", - "notrnailinator.com", - "nubescontrol.com", - "nuts2trade.com", - "ny7.me", - "o2stk.org", - "o7i.net", - "obfusko.com", - "obxpestcontrol.com", - "oerpub.org", - "offshore-proxies.net", - "okclprojects.com", - "okrent.us", - "omnievents.org", - "onlineidea.info", - "onqin.com", - "ontyne.biz", - "oolus.com", - "ourpreviewdomain.com", - "ownsyou.de", - "oxopoha.com", - "pa9e.com", - "pastebitch.com", - "penisgoes.in", - "peterdethier.com", - "petrzilka.net", - "photomark.net", - "pi.vu", - "pinehill-seattle.org", - "pingir.com", - "pisls.com", - "plhk.ru", - "plw.me", - "pojok.ml", - "pokiemobile.com", - "poopiebutt.club", - "popesodomy.com", - "popgx.com", - "poutineyourface.com", - "powlearn.com", - "primabananen.net", - "pro-tag.org", - "procrackers.com", - "projectcl.com", - "propscore.com", - "proxyparking.com", - "purcell.email", - "purelogistics.org", - "qipmail.net", - "quadrafit.com", - "qvy.me", - "qwickmail.com", - "r4nd0m.de", - "raetp9.com", - "raketenmann.de", - "rancidhome.net", - "raqid.com", - "rax.la", - "raxtest.com", - "recipeforfailure.com", - "redfeathercrow.com", - "remarkable.rocks", - "remote.li", - "reptilegenetics.com", - "revolvingdoorhoax.org", - "riddermark.de", - "risingsuntouch.com", - "rnailinator.com", - "robertspcrepair.com", - "ronnierage.net", - "rotaniliam.com", - "rowe-solutions.com", - "royaldoodles.org", - "rumgel.com", - "rustydoor.com", - "s33db0x.com", - "sabrestlouis.com", - "sackboii.com", - "saharanightstempe.com", - "samsclass.info", - "sandwhichvideo.com", - "sanfinder.com", - "sanim.net", - "sanstr.com", - "satukosong.com", - "sausen.com", - "schachrol.com", - "sd3.in", - "secured-link.net", - "seekapps.com", - "sejaa.lv", - "sendfree.org", - "sendingspecialflyers.com", - "sexforswingers.com", - "sexical.com", - "shhmail.com", - "shhuut.org", - "shipfromto.com", - "shiphazmat.org", - "shipping-regulations.com", - "shippingterms.org", - "shrib.com", - "simpleitsecurity.info", - "sinfiltro.cl", - "singlespride.com", - "sizzlemctwizzle.com", - "skkk.edu.my", - "sky-inbox.com", - "slothmail.net", - "smtp99.com", - "smwg.info", - "socialfurry.org", - "solventtrap.wiki", - "spam.org.es", - "spamlot.net", - "speedgaus.net", - "spritzzone.de", - "stanfordujjain.com", - "starlight-breaker.net", - "startfu.com", - "statdvr.com", - "stathost.net", - "statiix.com", - "steambot.net", - "stumpfwerk.com", - "suburbanthug.com", - "suckmyd.com", - "sylvannet.com", - "tafoi.gr", - "tagmymedia.com", - "tanukis.org", - "tb-on-line.net", - "telecomix.pl", - "testudine.com", - "theaviors.com", - "thebearshark.com", - "thediamants.org", - "thembones.com.au", - "themostemail.com", - "thescrappermovie.com", - "theteastory.info", - "thietbivanphong.asia", - "thisurl.website", - "thnikka.com", - "thunkinator.org", - "thxmate.com", - "timgiarevn.com", - "timkassouf.com", - "tinyurl24.com", - "tlpn.org", - "tmpjr.me", - "toddsbighug.com", - "tokenmail.de", - "tonymanso.com", - "top101.de", - "topofertasdehoy.com", - "toprumours.com", - "toss.pw", - "totalvista.com", - "totesmail.com", - "tp-qa-mail.com", - "tranceversal.com", - "trasz.com", - "trollproject.com", - "tropicalbass.info", - "trungtamtoeic.com", - "ttszuo.xyz", - "tualias.com", - "txtadvertise.com", - "ufacturing.com", - "uguuchantele.com", - "uhhu.ru", - "unimark.org", - "unit7lahaina.com", - "uploadnolimit.com", - "urfunktion.se", - "utiket.us", - "uwork4.us", - "vaati.org", - "valhalladev.com", - "verdejo.com", - "veryday.ch", - "veryday.eu", - "veryday.info", - "victoriantwins.com", - "vikingsonly.com", - "vinernet.com", - "vipxm.net", - "vixletdev.com", - "vmailing.info", - "vmani.com", - "vmpanda.com", - "vorga.org", - "votiputox.org", - "voxelcore.com", - "wakingupesther.com", - "watchever.biz", - "watchironman3onlinefreefullmovie.com", - "wbml.net", - "webtrip.ch", - "welikecookies.com", - "wg0.com", - "whatifanalytics.com", - "whiffles.org", - "wibblesmith.com", - "widget.gg", - "wimsg.com", - "wralawfirm.com", - "x1x.spb.ru", - "x24.com", - "xcompress.com", - "xcpy.com", - "xjoi.com", - "xn--9kq967o.com", - "xrho.com", - "xwaretech.com", - "xwaretech.info", - "xwaretech.net", - "yaqp.com", - "ynmrealty.com", - "yougotgoated.com", - "youneedmore.info", - "yourewronghereswhy.com", - "yourlms.biz", - "yspend.com", - "yugasandrika.com", - "yui.it", - "zepp.dk", - "zipsendtest.com", - "zoetropes.org", - "zombie-hive.com", - "zumpul.com", - "pooae.com", - "foxja.com", - "kloap.com", - "yhg.biz", - "rooftest.net", - "pp.ua", - "20email.it", - "extremail.ru", - "kismail.ru", - "mail.bccto.me", - "mail72.com", - "figjs.com", - "139.com", - "188.com", - "189.cn", - "20mail.eu", - "alfamailr.org", - "asdfasdfmail.net", - "asdfmail.net", - "asooemail.net", - "dfgggg.org", - "e-mail.net", - "emailll.org", - "fghmail.net", - "fsfsdf.org", - "iaoss.com", - "jamit.com.au", - "mailadadad.org", - "popmailserv.org", - "rtotlmail.net", - "vip.188.com", - "vip.sohu.com", - "vip.sohu.net", - "vip.tom.com", - "vipsohu.net", - "ymail.net", - "ymail.org", - "game.com", - "zasod.com", - "ispyco.ru", - "mailspeed.ru", - "throwawayemail.com", - "dsiay.com", - "emailo.pro", - "wierie.tk", - "morriesworld.ml", - "freealtgen.com", - "oing.cf", - "mailxcdn.com", - "deyom.com", - "reftoken.net", - "amail.club", - "mail.fast10s.design", - "zhorachu.com", - "ethersports.org", - "tinoza.org", - "payperex2.com", - "nezdiro.org", - "ether123.net", - "averdov.com", - "axsup.net", - "datum2.com", - "geronra.com", - "asorent.com", - "33m.co", - "aji.kr", - "anyalias.com", - "bei.kr", - "bel.kr", - "beo.kr", - "bfo.kr", - "bho.kr", - "bko.kr", - "chickenkiller.com", - "cid.kr", - "cko.kr", - "coms.hk", - "cu.cc", - "dbo.kr", - "dko.kr", - "dnses.ro", - "doy.kr", - "efo.kr", - "eho.kr", - "ely.kr", - "emailfreedom.ml", - "emlhub.com", - "emlpro.com", - "emy.kr", - "enu.kr", - "eny.kr", - "ewa.kr", - "exi.kr", - "fackme.gq", - "fassagforpresident.ga", - "flu.cc", - "foy.kr", - "fr.nf", - "gmail.gr.com", - "gmeil.me", - "gok.kr", - "haddo.eu", - "hix.kr", - "hiz.kr", - "igg.biz", - "iki.kr", - "irr.kr", - "jil.kr", - "jto.kr", - "justemail.ml", - "kadokawa.top", - "lal.kr", - "lbe.kr", - "lei.kr", - "lko.co.kr", - "lko.kr", - "lom.kr", - "loy.kr", - "luo.kr", - "mail0.ga", - "mbe.kr", - "mko.kr", - "mlo.kr", - "mp-j.ga", - "mp-j.gq", - "mp-j.ml", - "mp-j.tk", - "nafko.cf", - "nko.kr", - "npv.kr", - "nuo.kr", - "nut.cc", - "obo.kr", - "orangotango.ml", - "owa.kr", - "oyu.kr", - "poy.kr", - "qbi.kr", - "rao.kr", - "rko.kr", - "row.kr", - "spamtrap.ro", - "tko.co.kr", - "tko.kr", - "tmo.kr", - "toi.kr", - "uha.kr", - "uko.kr", - "umy.kr", - "uny.kr", - "upy.kr", - "usa.cc", - "uu.gl", - "uvy.kr", - "uyu.kr", - "vay.kr", - "vba.kr", - "veo.kr", - "wil.kr", - "xxi2.com", - "ye.vc", - "qq.com", - "001.igg.biz", - "0x00.name", - "1000rebates.stream", - "10host.top", - "117.yyolf.net", - "11top.xyz", - "1rentcar.top", - "1ss.noip.me", - "1usemail.com", - "2014mail.ru", - "291.usa.cc", - "2sea.xyz", - "3ew.usa.cc", - "487.nut.cc", - "4tb.host", - "4w.io", - "54np.club", - "5july.org", - "5music.info", - "5music.top", - "7rent.top", - "806.flu.cc", - "88clean.pro", - "a.sach.ir", - "a0f7ukc.com", - "a41odgz7jh.com", - "a54pd15op.com", - "aaaw45e.com", - "abyssemail.com", - "adesktop.com", - "adx-telecom.com", - "agustusmp3.xyz", - "aistis.xyz", - "akademiyauspexa.xyz", - "akorde.al", - "aldeyaa.ae", - "alimunjaya.xyz", - "alsheim.no-ip.org", - "alumnimp3.xyz", - "amoksystems.com", - "anthropologycommunity.com", - "asdfghmail.com", - "asspoo.com", - "assurancespourmoi.eu", - "azjuggalos.com", - "b.reed.to", - "b9x45v1m.com", - "backalleybowling.info", - "badhus.org", - "ballsofsteel.net", - "bandai.nom.co", - "barrypov.com", - "barryspov.com", - "bartoparcadecabinet.com", - "beck-it.net", - "bho.hu", - "bigwiki.xyz", - "bin.8191.at", - "biometicsliquidvitamins.com", - "bitwerke.com", - "bogotadc.info", - "bungabunga.cf", - "businesscredit.xyz", - "buygapfashion.com", - "bwa33.net", - "by8006l.com", - "c.kadag.ir", - "c.theplug.org", - "cafecar.xyz", - "carrnelpartners.com", - "caseedu.tk", - "cd.mintemail.com", - "central-servers.xyz", - "centrallosana.ga", - "cheaphorde.com", - "chef.asana.biz", - "chilelinks.cl", - "chinatov.com", - "choco.la", - "chris.burgercentral.us", - "christopherfretz.com", - "civilizationdesign.xyz", - "cl.gl", - "clay.xyz", - "clinicatbf.com", - "clipmail.eu", - "cloud99.pro", - "cloud99.top", - "cls-audio.club", - "cognitiveways.xyz", - "colorweb.cf", - "communitybuildingworks.xyz", - "contentwanted.com", - "cortex.kicks-ass.net", - "cr97mt49.com", - "cultmovie.com", - "cutout.club", - "cybersex.com", - "czqjii8.com", - "d58pb91.com", - "d8u.us", - "dancemanual.com", - "darknode.org", - "derder.net", - "dev-null.cf", - "dev-null.ga", - "dev-null.gq", - "dev-null.ml", - "dff55.dynu.net", - "dfg6.kozow.com", - "digdown.xyz", - "dinkmail.com", - "disaq.com", - "disario.info", - "disposablemails.com", - "dmarc.ro", - "doanart.com", - "doxcity.net", - "dqkerui.com", - "dragons-spirit.org", - "drynic.com", - "dt.com", - "dwse.edu.pl", - "e.4pet.ro", - "e.amav.ro", - "e.l5.ca", - "e.nodie.cc", - "e.shapoo.ch", - "e7n06wz.com", - "eastwan.net", - "eatrnet.com", - "eb609s25w.com", - "eco.ilmale.it", - "edrishn.xyz", - "emailmenow.info", - "eonmech.com", - "etgdev.de", - "ezfill.club", - "faithkills.org", - "fakeinbox.info", - "fartwallet.com", - "faze.biz", - "fc66998.com", - "fetchnet.co.uk", - "fingermouse.org", - "fishfortomorrow.xyz", - "fls4.gleeze.com", - "foquita.com", - "francanet.com.br", - "freebullets.net", - "freechristianbookstore.com", - "freemommyvids.com", - "freeshemaledvds.com", - "freesistervids.com", - "freetubearchive.com", - "fsagc.xyz", - "fun2.biz", - "furusato.tokyo", - "gero.us", - "getnowtoday.cf", - "gibit.us", - "gimesson.pe.hu", - "giuras.club", - "giuypaiw8.com", - "godataflow.xyz", - "goodjab.club", - "gowikimusic.great-host.in", - "greenst.info", - "greyjack.com", - "gwspt71.com", - "h.thc.lv", - "h1z8ckvz.com", - "h2-yy.nut.cc", - "h9js8y6.com", - "hackersquad.tk", - "hackrz.xyz", - "happykorea.club", - "happykoreas.xyz", - "harmonyst.xyz", - "hdmoviestore.us", - "healyourself.xyz", - "hiddencorner.xyz", - "hostmonitor.net", - "hvtechnical.com", - "i.ryanb.com", - "i4j0j3iz0.com", - "icemovie.link", - "ihaxyour.info", - "iku.us", - "ilnostrogrossograssomatrimoniomolisano.com", - "imankul.com", - "imovie.link", - "inaby.com", - "inapplicable.org", - "indonesianherbalmedicine.com", - "inpowiki.xyz", - "ipswell.com", - "irabops.com", - "ircbox.xyz", - "ispuntheweb.com", - "istakalisa.club", - "j.rvb.ro", - "jeramywebb.com", - "jetableemail.com", - "josefadventures.org", - "jredm.com", - "jswfdb48z.com", - "jv6hgh1.com", - "jyliananderik.com", - "k3663a40w.com", - "kah.pw", - "kaijenwan.com", - "kampoeng3d.club", - "kekecog.com", - "ketiksms.club", - "kickmark.com", - "kiham.club", - "kitten-mittons.com", - "kormail.xyz", - "kuai909.com", - "kuaijenwan.com", - "l.safdv.com", - "ladymacbeth.tk", - "ledoktre.com", - "lesbugs.com", - "lexisense.com", - "likesyouback.com", - "lmcudh4h.com", - "localserv.no-ip.org", - "locanto1.club", - "locantospot.top", - "locateme10.com", - "lordsofts.com", - "lostpositive.xyz", - "lpo.ddnsfree.com", - "m2r60ff.com", - "mail.aws910.com", - "mail.illistnoise.com", - "mail.mailinator.com", - "mail.partskyline.com", - "mail.ticket-please.ga", - "mail4you.usa.cc", - "maildump.tk", - "mailinator.pl", - "mailkor.xyz", - "mailmetrash.comilzilla.org", - "mailna.in", - "mailpooch.com", - "mailthunder.ml", - "mao.igg.biz", - "mastahype.net", - "mattmason.xyz", - "medsheet.com", - "mejjang.xyz", - "metroset.net", - "mhwolf.net", - "mihep.com", - "miodonski.ch", - "miraigames.net", - "mmail.igg.biz", - "mmailinater.com", - "mockmyid.co", - "msrc.ml", - "mswork.ru", - "mufux.com", - "mugglenet.org", - "mustbedestroyed.org", - "mymailjos.cf", - "mymailjos.ga", - "mymailjos.tk", - "myn4s.ddns.net", - "mythnick.club", - "naturalious.com", - "nctuiem.xyz", - "neibu306.com", - "neibu963.com", - "newdawnnm.xyz", - "nie-podam.pl", - "niepodam.pl", - "nl.szucsati.net", - "nodnor.club", - "nomail.cf", - "nomail.ch", - "nomail.ga", - "nomailthankyou.com", - "northemquest.com", - "nostrajewellery.xyz", - "o.idigo.org", - "o.muti.ro", - "o060bgr3qg.com", - "oceancares.xyz", - "ohdomain.xyz", - "ohioticketpayments.xyz", - "onebiginbox.com", - "onelegalplan.com", - "otherinbox.codupmyspace.com", - "p.9q.ro", - "p.k4ds.org", - "parkcrestlakewood.xyz", - "paulfucksallthebitches.com", - "pencalc.xyz", - "penis.computer", - "peppe.usa.cc", - "personal-email.ml", - "podam.pl", - "polarkingxx.ml", - "poliusraas.tk", - "premiumperson.website", - "prs7.xyz", - "psychedelicwarrior.xyz", - "pumps-fashion.com", - "pwp.lv", - "qafatwallet.com", - "qj97r73md7v5.com", - "qs2k.com", - "qt1.ddns.net", - "querydirect.com", - "quickreport.it", - "r.yasser.ru", - "r8r4p0cb.com", - "radecoratingltd.com", - "@cevipsa.com", - "@cpav3.com", - "@nuclene.com", - "@steveix.com", - "@mocvn.com", - "@tenvil.com", - "@tgvis.com", - "@amozix.com", - "@anypsd.com", - "@maxric.com", - "greencafe24.com", - "coffeetimer24.com", - "waterisgone.com", - "paperpapyrus.com", - "klovenode.com", - "drowblock.com", - "dishcatfish.com", - "superblohey.com", - "happy2023year.com", - "gixenmixen.com", - "bloheyz.com", - "zipcatfish.com", - "myinfoinc.com", - ], + lastCheckTime: 1681220245859, + domains: [ + "getapet.net", + "0-mail.com", + "027168.com", + "0815.ru", + "0815.su", + "0clickemail.com", + "0wnd.net", + "0wnd.org", + "1.emailfake.ml", + "10mail.org", + "10minutemail.cf", + "10minutemail.co.uk", + "10minutemail.co.za", + "10minutemail.com", + "10minutemail.de", + "10minutemail.ga", + "10minutemail.gq", + "10minutemail.ml", + "10minutemail.net", + "10minutemail.us", + "10minutenemail.de", + "123-m.com", + "12hosting.net", + "12minutemail.com", + "12storage.com", + "1ce.us", + "1chuan.com", + "1clck2.com", + "1mail.ml", + "1pad.de", + "1up.orangotango.gq", + "1zhuan.com", + "2-ch.space", + "2000rebates.stream", + "20email.eu", + "20mail.in", + "20mail.it", + "20minute.email", + "20minutemail.com", + "21cn.com", + "24hourmail.com", + "2ch.coms.hk", + "2prong.com", + "3.emailfake.ml", + "3.fackme.gq", + "30minutemail.com", + "30wave.com", + "33mail.com", + "3d-painting.com", + "3mail.ga", + "4mail.cf", + "4mail.ga", + "4warding.com", + "4warding.net", + "4warding.org", + "5.fackme.gq", + "5mail.cf", + "5mail.ga", + "6.emailfake.ml", + "6.fackme.gq", + "60minutemail.com", + "675hosting.com", + "675hosting.net", + "675hosting.org", + "69-ew.tk", + "6ip.us", + "6mail.cf", + "6mail.ga", + "6mail.ml", + "6paq.com", + "6url.com", + "7.fackme.gq", + "75hosting.com", + "75hosting.net", + "75hosting.org", + "7days-printing.com", + "7ddf32e.info", + "7mail.ga", + "7mail.ml", + "7tags.com", + "7uy35p.tk", + "8.fackme.gq", + "8mail.cf", + "8mail.ga", + "8mail.ml", + "99experts.com", + "9mail.cf", + "9me.site", + "9ox.net", + "a-bc.net", + "a.betr.co", + "a.wxnw.net", + "a45.in", + "abusemail.de", + "abyssmail.com", + "ac20mail.in", + "acentri.com", + "adbet.co", + "add3000.pp.ua", + "adrianou.gq", + "advantimo.com", + "afrobacon.com", + "ag.us.to", + "agedmail.com", + "ahk.jp", + "ajaxapp.net", + "alivance.com", + "amail.com", + "amilegit.com", + "amiri.net", + "amiriindustries.com", + "anappthat.com", + "ano-mail.net", + "anonbox.net", + "anonymail.dk", + "anonymbox.com", + "anotherdomaincyka.tk", + "antichef.com", + "antichef.net", + "antispam.de", + "antonelli.usa.cc", + "apkmd.com", + "appixie.com", + "armyspy.com", + "art-en-ligne.pro", + "arur01.tk", + "arurgitu.gq", + "arurimport.ml", + "asdasd.nl", + "ass.pp.ua", + "aver.com", + "avia-tonic.fr", + "azazazatashkent.tk", + "azmeil.tk", + "babau.cf", + "babau.flu.cc", + "babau.ga", + "babau.gq", + "babau.igg.biz", + "babau.ml", + "babau.nut.cc", + "babau.usa.cc", + "bareed.ws", + "barryogorman.com", + "baxomale.ht.cx", + "bccto.me", + "bdmuzic.pw", + "beddly.com", + "beefmilk.com", + "belastingdienst.pw", + "big1.us", + "bigprofessor.so", + "bigstring.com", + "binka.me", + "binkmail.com", + "bio-muesli.net", + "bione.co", + "bladesmail.net", + "blogmyway.org", + "blutig.me", + "boatmail.us", + "bobmail.info", + "bodhi.lawlita.com", + "bofthew.com", + "bongobongo.cf", + "bongobongo.flu.cc", + "bongobongo.ga", + "bongobongo.igg.biz", + "bongobongo.ml", + "bongobongo.nut.cc", + "bongobongo.tk", + "bongobongo.usa.cc", + "bootybay.de", + "boun.cr", + "bouncr.com", + "boxformail.in", + "boximail.com", + "boxtemp.com.br", + "breadtimes.press", + "brefmail.com", + "brennendesreich.de", + "broadbandninja.com", + "bsnow.net", + "bst-72.com", + "btcmail.pw", + "bu.mintemail.com", + "buffemail.com", + "bugmenot.com", + "bugmenot.ml", + "bumpymail.com", + "bund.us", + "bundes-li.ga", + "burnthespam.info", + "burstmail.info", + "buxap.com", + "buyusedlibrarybooks.org", + "byom.de", + "c.andreihusanu.ro", + "c.hcac.net", + "c.wlist.ro", + "c2.hu", + "c51vsgq.com", + "cachedot.net", + "car101.pro", + "cartelera.org", + "casualdx.com", + "cbair.com", + "ce.mintemail.com", + "cellurl.com", + "centermail.com", + "centermail.net", + "cetpass.com", + "chacuo.net", + "chammy.info", + "cheaphub.net", + "cheatmail.de", + "chechnya.conf.work", + "chogmail.com", + "choicemail1.com", + "chong-mail.com", + "chong-mail.net", + "chong-mail.org", + "ckaazaza.tk", + "clixser.com", + "clrmail.com", + "clubfier.com", + "cmail.com", + "cmail.net", + "cmail.org", + "cnn.coms.hk", + "cobarekyo1.ml", + "cocodani.cf", + "coldemail.info", + "consumerriot.com", + "contrasto.cu.cc", + "cool.fr.nf", + "correo.blogos.net", + "cosmorph.com", + "courriel.fr.nf", + "courrieltemporaire.com", + "crankmails.com", + "crapmail.org", + "crazespaces.pw", + "crazymailing.com", + "cream.pink", + "crotslep.ml", + "crotslep.tk", + "cubiclink.com", + "curryworld.de", + "cust.in", + "cuvox.de", + "cx.de-a.org", + "cyber-innovation.club", + "cyber-phone.eu", + "dacoolest.com", + "daintly.com", + "dandikmail.com", + "dasdasdascyka.tk", + "dayrep.com", + "dbunker.com", + "dcemail.com", + "deadaddress.com", + "deadchildren.org", + "deadfake.cf", + "deadfake.ga", + "deadfake.ml", + "deadfake.tk", + "deadspam.com", + "deagot.com", + "dealja.com", + "despam.it", + "despammed.com", + "devnullmail.com", + "dfgh.net", + "dfghj.ml", + "dharmatel.net", + "digitalsanctuary.com", + "dingbone.com", + "discard-email.cf", + "discard.cf", + "discard.email", + "discard.ga", + "discard.gq", + "discard.ml", + "discard.tk", + "discardmail.com", + "discardmail.de", + "disign-concept.eu", + "disign-revelation.com", + "dispomail.eu", + "disposable-email.ml", + "disposable.cf", + "disposable.ga", + "disposable.ml", + "disposableaddress.com", + "disposableemailaddresses.com", + "disposableemailaddresses.emailmiser.com", + "disposableinbox.com", + "dispose.it", + "disposeamail.com", + "disposemail.com", + "dispostable.com", + "divermail.com", + "divismail.ru", + "dlemail.ru", + "dm.w3internet.co.uk", + "dodgeit.com", + "dodgit.com", + "dodgit.org", + "dodsi.com", + "doiea.com", + "domforfb1.tk", + "domforfb2.tk", + "domforfb3.tk", + "domforfb4.tk", + "domforfb5.tk", + "domforfb6.tk", + "domforfb7.tk", + "domforfb8.tk", + "domforfb9.tk", + "domozmail.com", + "donemail.ru", + "dontreg.com", + "dontsendmespam.de", + "dot-ml.ml", + "dot-ml.tk", + "dotmsg.com", + "dr69.site", + "drdrb.com", + "drdrb.net", + "drivetagdev.com", + "droplar.com", + "dropmail.me", + "duam.net", + "dudmail.com", + "dump-email.info", + "dumpandjunk.com", + "dumpmail.de", + "dumpyemail.com", + "duskmail.com", + "dw.now.im", + "dx.abuser.eu", + "dx.allowed.org", + "dx.awiki.org", + "dx.ez.lv", + "dx.sly.io", + "e-mail.com", + "e-mail.org", + "e.arno.fi", + "e.blogspam.ro", + "e.discard-email.cf", + "e.milavitsaromania.ro", + "e.wupics.com", + "e0yk-mail.ml", + "e4ward.com", + "easytrashmail.com", + "ecolo-online.fr", + "ee2.pl", + "eelmail.com", + "einrot.com", + "einrot.de", + "email-fake.cf", + "email-fake.ga", + "email-fake.gq", + "email-fake.ml", + "email-fake.tk", + "email.cbes.net", + "email60.com", + "emailage.cf", + "emailage.ga", + "emailage.gq", + "emailage.ml", + "emailage.tk", + "emaildienst.de", + "emailfake.ml", + "emailgo.de", + "emailias.com", + "emailigo.de", + "emailinfive.com", + "emailisvalid.com", + "emaillime.com", + "emailmiser.com", + "emailproxsy.com", + "emails.ga", + "emailsensei.com", + "emailspam.cf", + "emailspam.ga", + "emailspam.gq", + "emailspam.ml", + "emailspam.tk", + "emailtea.com", + "emailtemporar.ro", + "emailtemporario.com.br", + "emailthe.net", + "emailtmp.com", + "emailto.de", + "emailwarden.com", + "emailx.at.hm", + "emailxfer.com", + "emailz.cf", + "emailz.ga", + "emailz.gq", + "emailz.ml", + "emeil.in", + "emeil.ir", + "emil.com", + "emkei.cf", + "emkei.ga", + "emkei.gq", + "emkei.ml", + "emkei.tk", + "eml.pp.ua", + "emltmp.com", + "emz.net", + "enterto.com", + "ephemail.net", + "eqiluxspam.ga", + "erasf.com", + "ese.kr", + "est.une.victime.ninja", + "estate-invest.fr", + "etranquil.com", + "etranquil.net", + "etranquil.org", + "eu.igg.biz", + "everytg.ml", + "evopo.com", + "evyush.com", + "explodemail.com", + "eyepaste.com", + "ezlo.co", + "f5.si", + "facebook-email.cf", + "facebook-email.ga", + "facebook-email.ml", + "facebookmail.gq", + "facebookmail.ml", + "fake-email.pp.ua", + "fake-mail.cf", + "fake-mail.ga", + "fake-mail.ml", + "fake.i-3gk.cf", + "fake.i-3gk.ga", + "fake.i-3gk.gq", + "fake.i-3gk.ml", + "fakeinbox.cf", + "fakeinbox.com", + "fakeinbox.ga", + "fakeinbox.ml", + "fakeinbox.tk", + "fakeinformation.com", + "fakemail.fr", + "fakemailgenerator.com", + "fakemailz.com", + "fammix.com", + "fansworldwide.de", + "fantasymail.de", + "fast-mail.fr", + "fastacura.com", + "fastchevy.com", + "fastchrysler.com", + "fastemails.us", + "fastkawasaki.com", + "fastmazda.com", + "fastmitsubishi.com", + "fastnissan.com", + "fastsubaru.com", + "fastsuzuki.com", + "fasttoyota.com", + "fastyamaha.com", + "fatflap.com", + "fbi.coms.hk", + "fbmail1.ml", + "fdfdsfds.com", + "ficken.de", + "fightallspam.com", + "fiifke.de", + "filzmail.com", + "fixmail.tk", + "fizmail.com", + "flashbox.5july.org", + "fleckens.hu", + "flemail.ru", + "flurred.com", + "flyspam.com", + "foodbooto.com", + "footard.com", + "forgetmail.com", + "fornow.eu", + "forward.cat", + "fr33mail.info", + "fragolina2.tk", + "frapmail.com", + "frappina.tk", + "frappina99.tk", + "free-email.cf", + "free-email.ga", + "freelance-france.eu", + "freemail.ms", + "freemail.tweakly.net", + "freemails.cf", + "freemails.ga", + "freemails.ml", + "freemeil.ga", + "freemeil.gq", + "freemeil.ml", + "freundin.ru", + "friendlymail.co.uk", + "front14.org", + "fuckingduh.com", + "fudgerub.com", + "fulvie.com", + "fun64.com", + "fun64.net", + "fuwamofu.com", + "fux0ringduh.com", + "fw.moza.pl", + "g.hmail.us", + "gafy.net", + "gamgling.com", + "gamno.config.work", + "garliclife.com", + "gawab.com", + "gelitik.in", + "get-mail.cf", + "get-mail.ga", + "get-mail.ml", + "get-mail.tk", + "get.pp.ua", + "get1mail.com", + "get2mail.fr", + "getairmail.cf", + "getairmail.com", + "getairmail.ga", + "getairmail.gq", + "getairmail.ml", + "getairmail.tk", + "getmails.eu", + "getnada.com", + "getonemail.com", + "getonemail.net", + "ghosttexter.de", + "girlsundertheinfluence.com", + "gishpuppy.com", + "glubex.com", + "go.irc.so", + "go2usa.info", + "godut.com", + "goemailgo.com", + "goooogle.flu.cc", + "goooogle.igg.biz", + "goooogle.nut.cc", + "goooogle.usa.cc", + "gorillaswithdirtyarmpits.com", + "gotmail.com", + "gotmail.net", + "gotmail.org", + "gotti.otherinbox.com", + "gowikibooks.com", + "gowikicampus.com", + "gowikicars.com", + "gowikifilms.com", + "gowikigames.com", + "gowikimusic.com", + "gowikinetwork.com", + "gowikitravel.com", + "gowikitv.com", + "grandmamail.com", + "grandmasmail.com", + "great-host.in", + "greensloth.com", + "grr.la", + "gsrv.co.uk", + "guerillamail.biz", + "guerillamail.com", + "guerillamail.net", + "guerillamail.org", + "guerrillamail.biz", + "guerrillamail.com", + "guerrillamail.de", + "guerrillamail.info", + "guerrillamail.net", + "guerrillamail.org", + "guerrillamailblock.com", + "gustr.com", + "h.mintemail.com", + "h8s.org", + "hacccc.com", + "haltospam.com", + "harakirimail.com", + "haribu.net", + "hartbot.de", + "hasanmail.ml", + "hash.pp.ua", + "hatespam.org", + "hellodream.mobi", + "herp.in", + "hezll.com", + "hidemail.de", + "hidemail.pro", + "hidemail.us", + "hidzz.com", + "hmamail.com", + "hochsitze.com", + "hoer.pw", + "holl.ga", + "hopemail.biz", + "hostcalls.com", + "hot-mail.cf", + "hot-mail.ga", + "hot-mail.gq", + "hot-mail.ml", + "hot-mail.tk", + "hotpop.com", + "housat.com", + "hstermail.com", + "hukkmu.tk", + "hulapla.de", + "humn.ws.gy", + "i.istii.ro", + "i.klipp.su", + "i.wawi.es", + "i.xcode.ro", + "i201zzf8x.com", + "i2pmail.org", + "ichigo.me", + "ieatspam.eu", + "ieatspam.info", + "ieh-mail.de", + "ihateyoualot.info", + "ihazspam.ca", + "iheartspam.org", + "ikbenspamvrij.nl", + "imails.info", + "imgof.com", + "imgv.de", + "immo-gerance.info", + "imstations.com", + "inbax.tk", + "inbound.plus", + "inbox.si", + "inboxalias.com", + "inboxbear.com", + "inboxclean.com", + "inboxclean.org", + "inboxproxy.com", + "inclusiveprogress.com", + "incognitomail.com", + "incognitomail.net", + "incognitomail.org", + "infest.org", + "info-radio.ml", + "inmynetwork.tk", + "insorg-mail.info", + "instant-mail.de", + "instantemailaddress.com", + "instantmail.fr", + "ip4.pp.ua", + "ip6.pp.ua", + "ipoo.org", + "irish2me.com", + "iroid.com", + "isdaq.com", + "italia.flu.cc", + "italia.igg.biz", + "itmtx.com", + "itsme.edu.pl", + "iwi.net", + "jcpclothing.ga", + "je-recycle.info", + "jet-renovation.fr", + "jetable.com", + "jetable.fr.nf", + "jetable.net", + "jetable.org", + "jetable.pp.ua", + "jnxjn.com", + "jobbikszimpatizans.hu", + "jourrapide.com", + "jp.ftp.sh", + "jsrsolutions.com", + "junk1e.com", + "junkmail.ga", + "junkmail.gq", + "jwk4227ufn.com", + "k.fido.be", + "kanker.website", + "kasmail.com", + "kaspop.com", + "katztube.com", + "kazelink.ml", + "keepmymail.com", + "keinpardon.de", + "kemska.pw", + "killmail.com", + "killmail.net", + "kimsdisk.com", + "kingsq.ga", + "kir.ch.tc", + "klassmaster.com", + "klassmaster.net", + "klzlk.com", + "knol-power.nl", + "kook.ml", + "koszmail.pl", + "kuatcak.cf", + "kuatcak.tk", + "kuatmail.gq", + "kuatmail.tk", + "kulturbetrieb.info", + "kurzepost.de", + "kusrc.com", + "l33r.eu", + "labetteraverouge.at", + "lackmail.net", + "lackmail.ru", + "lags.us", + "landmail.co", + "laoeq.com", + "laoho.com", + "last-chance.pro", + "lastmail.co", + "lastmail.com", + "lazyinbox.com", + "leeching.net", + "legalrc.loan", + "letthemeatspam.com", + "lhsdv.com", + "lifebyfood.com", + "lillemap.net", + "link2mail.net", + "linkedintuts2016.pw", + "litedrop.com", + "liveradio.tk", + "loadby.us", + "loan101.pro", + "login-email.cf", + "login-email.ga", + "login-email.ml", + "login-email.tk", + "loh.pp.ua", + "lol.ovpn.to", + "lolfreak.net", + "lolito.tk", + "lookugly.com", + "lopl.co.cc", + "lortemail.dk", + "lovefall.ml", + "lovemeleaveme.com", + "lovesea.gq", + "lr7.us", + "lr78.com", + "lroid.com", + "luv2.us", + "m.ddcrew.com", + "m4ilweb.info", + "maboard.com", + "macr2.com", + "mail-easy.fr", + "mail-filter.com", + "mail-temporaire.fr", + "mail-tester.com", + "mail.backflip.cf", + "mail.by", + "mail.mezimages.net", + "mail.wtf", + "mail114.net", + "mail2rss.org", + "mail333.com", + "mail4trash.com", + "mailbidon.com", + "mailblocks.com", + "mailbox72.biz", + "mailbox80.biz", + "mailbucket.org", + "mailcat.biz", + "mailcatch.com", + "maildrop.cc", + "maildrop.cf", + "maildrop.ga", + "maildrop.gq", + "maildrop.ml", + "maildx.com", + "maileater.com", + "mailed.ro", + "maileme101.com", + "mailexpire.com", + "mailfa.tk", + "mailfall.com", + "mailforspam.com", + "mailfree.ga", + "mailfree.gq", + "mailfree.ml", + "mailfreeonline.com", + "mailfs.com", + "mailguard.me", + "mailhero.io", + "mailimate.com", + "mailin8r.com", + "mailinatar.com", + "mailinater.com", + "mailinator.com", + "mailinator.gq", + "mailinator.net", + "mailinator.org", + "mailinator.us", + "mailinator2.com", + "mailincubator.com", + "mailismagic.com", + "mailjunk.cf", + "mailjunk.ga", + "mailjunk.gq", + "mailjunk.ml", + "mailjunk.tk", + "mailmate.com", + "mailme.gq", + "mailme.ir", + "mailme.lv", + "mailme24.com", + "mailmetrash.com", + "mailmoat.com", + "mailna.me", + "mailnator.com", + "mailnesia.com", + "mailnull.com", + "mailpick.biz", + "mailproxsy.com", + "mailquack.com", + "mailrock.biz", + "mailsac.com", + "mailscheap.us", + "mailscrap.com", + "mailseal.de", + "mailshell.com", + "mailsiphon.com", + "mailslapping.com", + "mailslite.com", + "mailspam.usa.cc", + "mailspam.xyz", + "mailtemp.info", + "mailtome.de", + "mailtothis.com", + "mailzi.ru", + "mailzilla.com", + "mailzilla.org", + "mailzilla.orgmbx.cc", + "makemetheking.com", + "manifestgenerator.com", + "manybrain.com", + "martin.securehost.com.es", + "materiali.ml", + "mbx.cc", + "mciek.com", + "mega.zik.dj", + "meinspamschutz.de", + "meltmail.com", + "merda.flu.cc", + "merda.igg.biz", + "merda.nut.cc", + "merda.usa.cc", + "merry.pink", + "messagebeamer.de", + "mezimages.net", + "mfsa.ru", + "mierdamail.com", + "migmail.net", + "migmail.pl", + "migumail.com", + "mintemail.com", + "mjukglass.nu", + "moakt.com", + "moakt.ws", + "mobi.web.id", + "mobileninja.co.uk", + "moburl.com", + "mohmal.com", + "mohmal.im", + "mohmal.in", + "mohmal.tech", + "moncourrier.fr.nf", + "monemail.fr.nf", + "monmail.fr.nf", + "monumentmail.com", + "mor19.uu.gl", + "morahdsl.cf", + "mox.pp.ua", + "mrresourcepacks.tk", + "ms9.mailslite.com", + "msa.minsmail.com", + "mt2009.com", + "mt2014.com", + "mt2015.com", + "mt2016.com", + "mt2017.com", + "muehlacker.tk", + "muq.orangotango.tk", + "mvrht.com", + "mx0.wwwnew.eu", + "my.efxs.ca", + "my.spam.orangotango.ml", + "my10minutemail.com", + "mycleaninbox.net", + "myemailboxy.com", + "mymail-in.net", + "mymailoasis.com", + "mymailto.cf", + "mymailto.ga", + "myneocards.cz", + "mynetstore.de", + "mypacks.net", + "mypartyclip.de", + "myphantomemail.com", + "myspaceinc.com", + "myspaceinc.net", + "myspaceinc.org", + "myspacepimpedup.com", + "myspamless.com", + "mytemp.email", + "mytempemail.com", + "mytrashmail.com", + "n.ra3.us", + "n.spamtrap.co", + "n.zavio.nl", + "napalm51.cf", + "napalm51.flu.cc", + "napalm51.ga", + "napalm51.gq", + "napalm51.igg.biz", + "napalm51.ml", + "napalm51.nut.cc", + "napalm51.tk", + "napalm51.usa.cc", + "neko2.net", + "neomailbox.com", + "nepwk.com", + "nervmich.net", + "nervtmich.net", + "netmails.com", + "netmails.net", + "netzidiot.de", + "neverbox.com", + "nezzart.com", + "nice-4u.com", + "nike.coms.hk", + "nincsmail.com", + "nmail.cf", + "no-spam.ws", + "nobulk.com", + "noclickemail.com", + "nogmailspam.info", + "nomail.xl.cx", + "nomail2me.com", + "nomorespamemails.com", + "nonspam.eu", + "nonspammer.de", + "noref.in", + "nospam.wins.com.br", + "nospam.ze.tc", + "nospam4.us", + "nospamfor.us", + "nospamthanks.info", + "notmailinator.com", + "notsharingmy.info", + "nowhere.org", + "nowmymail.com", + "ntlhelp.net", + "nurfuerspam.de", + "nus.edu.sg", + "nutpa.net", + "nwldx.com", + "o.cfo2go.ro", + "o.oai.asia", + "o.opendns.ro", + "o.spamtrap.ro", + "objectmail.com", + "obobbo.com", + "odaymail.com", + "olypmall.ru", + "one-time.email", + "oneoffemail.com", + "oneoffmail.com", + "onewaymail.com", + "online.ms", + "oopi.org", + "opayq.com", + "orango.cu.cc", + "ordinaryamerican.net", + "oshietechan.link", + "otherinbox.com", + "ourklips.com", + "outlawspam.com", + "ovpn.to", + "owlpic.com", + "p71ce1m.com", + "pagamenti.tk", + "pancakemail.com", + "paplease.com", + "parlimentpetitioner.tk", + "pcusers.otherinbox.com", + "pepbot.com", + "pepsi.coms.hk", + "pfui.ru", + "photo-impact.eu", + "phpbb.uu.gl", + "phus8kajuspa.cu.cc", + "pig.pp.ua", + "pimpedupmyspace.com", + "pjjkp.com", + "plexolan.de", + "ploae.com", + "po.bot.nu", + "poh.pp.ua", + "pokemail.net", + "politikerclub.de", + "polyfaust.com", + "poofy.org", + "pookmail.com", + "porco.cf", + "porco.ga", + "porco.gq", + "porco.ml", + "postacin.com", + "ppetw.com", + "premium-mail.fr", + "privacy.net", + "privy-mail.com", + "privymail.de", + "project-xhabbo.com", + "proxymail.eu", + "prtnx.com", + "prtz.eu", + "psles.com", + "psoxs.com", + "punkass.com", + "purple.flu.cc", + "purple.igg.biz", + "purple.nut.cc", + "purple.usa.cc", + "puttanamaiala.tk", + "putthisinyourspamdatabase.com", + "pw.flu.cc", + "pw.igg.biz", + "pw.nut.cc", + "pwrby.com", + "q5vm7pi9.com", + "qasti.com", + "qbfree.us", + "qisdo.com", + "qisoa.com", + "qs.dp76.com", + "quickinbox.com", + "quickmail.nl", + "r8.porco.cf", + "radiku.ye.vc", + "rajeshcon.cf", + "rcpt.at", + "reality-concept.club", + "reallymymail.com", + "receiveee.chickenkiller.com", + "receiveee.com", + "recode.me", + "reconmail.com", + "recursor.net", + "recyclemail.dk", + "reddit.usa.cc", + "regbypass.com", + "regbypass.comsafe-mail.net", + "regspaces.tk", + "rejectmail.com", + "remail.cf", + "remail.ga", + "resgedvgfed.tk", + "rhyta.com", + "rk9.chickenkiller.com", + "rklips.com", + "rkomo.com", + "rmqkr.net", + "rootfest.net", + "royal.net", + "rppkn.com", + "rtrtr.com", + "rudymail.ml", + "ruffrey.com", + "ruru.be", + "rx.dred.ru", + "rx.qc.to", + "s.bloq.ro", + "s.dextm.ro", + "s.proprietativalcea.ro", + "s.sa.igg.biz", + "s.spamserver.flu.cc", + "s.vdig.com", + "s00.orangotango.ga", + "s0ny.net", + "sa.igg.biz", + "safe-mail.net", + "safersignup.de", + "safetymail.info", + "safetypost.de", + "sandelf.de", + "savelife.ml", + "saynotospams.com", + "scatmail.com", + "schafmail.de", + "secure-mail.biz", + "secure-mail.cc", + "securehost.com.es", + "selfdestructingmail.com", + "selfdestructingmail.org", + "sendspamhere.com", + "servermaps.net", + "sfmail.top", + "sharedmailbox.org", + "sharklasers.com", + "shieldedmail.com", + "shiftmail.com", + "shitaway.cf", + "shitaway.cu.cc", + "shitaway.flu.cc", + "shitaway.ga", + "shitaway.gq", + "shitaway.igg.biz", + "shitaway.ml", + "shitaway.nut.cc", + "shitaway.tk", + "shitaway.usa.cc", + "shitmail.de", + "shitmail.me", + "shitmail.org", + "shitware.nl", + "shockinmytown.cu.cc", + "shortmail.net", + "shotmail.ru", + "showslow.de", + "shuffle.email", + "sibmail.com", + "siliwangi.ga", + "sinnlos-mail.de", + "siteposter.net", + "skeefmail.com", + "skrx.tk", + "sky-mail.ga", + "slaskpost.se", + "slave-auctions.net", + "slippery.email", + "slipry.net", + "slopsbox.com", + "slushmail.com", + "smap.4nmv.ru", + "smashmail.de", + "smellfear.com", + "smellrear.com", + "snakemail.com", + "sneakemail.com", + "snkmail.com", + "social-mailer.tk", + "sofimail.com", + "sofort-mail.de", + "softpls.asia", + "sogetthis.com", + "sohu.com", + "soisz.com", + "solar-impact.pro", + "solvemail.info", + "soodomail.com", + "soodonims.com", + "spam-a.porco.cf", + "spam-b.porco.cf", + "spam-be-gone.com", + "spam.2012-2016.ru", + "spam.la", + "spam.orangotango.ml", + "spam.su", + "spam4.me", + "spamavert.com", + "spambob.com", + "spambob.net", + "spambob.org", + "spambog.com", + "spambog.de", + "spambog.net", + "spambog.ru", + "spambooger.com", + "spambox.info", + "spambox.irishspringrealty.com", + "spambox.us", + "spamcannon.com", + "spamcannon.net", + "spamcero.com", + "spamcon.org", + "spamcorptastic.com", + "spamcowboy.com", + "spamcowboy.net", + "spamcowboy.org", + "spamday.com", + "spamdecoy.net", + "spamex.com", + "spamfighter.cf", + "spamfighter.ga", + "spamfighter.gq", + "spamfighter.ml", + "spamfighter.tk", + "spamfree.eu", + "spamfree24.com", + "spamfree24.de", + "spamfree24.eu", + "spamfree24.info", + "spamfree24.net", + "spamfree24.org", + "spamgoes.in", + "spamgourmet.com", + "spamgourmet.net", + "spamgourmet.org", + "spamherelots.com", + "spamhereplease.com", + "spamhole.com", + "spamify.com", + "spaminator.de", + "spamkill.info", + "spaml.com", + "spaml.de", + "spammotel.com", + "spamobox.com", + "spamoff.de", + "spamsalad.in", + "spamserver.cf", + "spamserver.flu.cc", + "spamserver.ml", + "spamserver.tk", + "spamslicer.com", + "spamspot.com", + "spamstack.net", + "spamthis.co.uk", + "spamthisplease.com", + "spamtrail.com", + "spamtroll.net", + "speed.1s.fr", + "sperma.cf", + "spikio.com", + "spoofmail.de", + "spybox.de", + "squizzy.de", + "squizzy.net", + "sr.ro.lt", + "sraka.xyz", + "sroff.com", + "ss.undo.it", + "ssoia.com", + "startkeys.com", + "stexsy.com", + "stinkefinger.net", + "stop-my-spam.cf", + "stop-my-spam.com", + "stop-my-spam.ga", + "stop-my-spam.ml", + "stop-my-spam.pp.ua", + "stop-my-spam.tk", + "streetwisemail.com", + "stromox.com", + "stuffmail.de", + "sudolife.me", + "sudolife.net", + "sudomail.biz", + "sudomail.com", + "sudomail.net", + "sudoverse.com", + "sudoverse.net", + "sudoweb.net", + "sudoworld.com", + "sudoworld.net", + "supergreatmail.com", + "supermailer.jp", + "superrito.com", + "superstachel.de", + "suremail.info", + "susi.ml", + "svk.jp", + "sweetxxx.de", + "szerz.com", + "t.psh.me", + "tafmail.com", + "taglead.com", + "tagyourself.com", + "talkinator.com", + "tapchicuoihoi.com", + "tarzan.usa.cc", + "tarzanmail.cf", + "tarzanmail.ml", + "teamspeak3.ga", + "teewars.org", + "teleworm.com", + "teleworm.us", + "temp-mail.com", + "temp-mail.de", + "temp-mail.org", + "temp.bartdevos.be", + "temp.emeraldwebmail.com", + "temp.headstrong.de", + "temp.mail.y59.jp", + "tempail.com", + "tempalias.com", + "tempe-mail.com", + "tempemail.biz", + "tempemail.co.za", + "tempemail.com", + "tempemail.net", + "tempinbox.co.uk", + "tempinbox.com", + "tempmail.co", + "tempmail.it", + "tempmail.pro", + "tempmail.us", + "tempmail2.com", + "tempmaildemo.com", + "tempmailer.com", + "tempomail.fr", + "temporarily.de", + "temporarioemail.com.br", + "temporaryemail.net", + "temporaryemail.us", + "temporaryforwarding.com", + "temporaryinbox.com", + "tempsky.com", + "tempthe.net", + "tempymail.com", + "thanksnospam.info", + "thankyou2010.com", + "thecloudindex.com", + "thereddoors.online", + "thisisnotmyrealemail.com", + "thraml.com", + "thrma.com", + "throam.com", + "thrott.com", + "throwam.com", + "throwawayemailaddress.com", + "throwawaymail.com", + "throya.com", + "tilien.com", + "tittbit.in", + "tm.tosunkaya.com", + "tmail.ws", + "tmailinator.com", + "toiea.com", + "toomail.biz", + "tradermail.info", + "tralalajos.ga", + "tralalajos.gq", + "tralalajos.ml", + "tralalajos.tk", + "trash-amil.com", + "trash-mail.at", + "trash-mail.cf", + "trash-mail.com", + "trash-mail.de", + "trash-mail.ga", + "trash-mail.gq", + "trash-mail.ml", + "trash-mail.tk", + "trash2009.com", + "trash2010.com", + "trash2011.com", + "trashcanmail.com", + "trashdevil.com", + "trashdevil.de", + "trashemail.de", + "trashmail.at", + "trashmail.com", + "trashmail.de", + "trashmail.me", + "trashmail.net", + "trashmail.org", + "trashmail.ws", + "trashmailer.com", + "trashymail.com", + "trashymail.net", + "trayna.com", + "trbvm.com", + "trbvn.com", + "trbvo.com", + "trickmail.net", + "trillianpro.com", + "trump.flu.cc", + "trump.igg.biz", + "tryalert.com", + "turoid.com", + "turual.com", + "tvchd.com", + "tverya.com", + "twinmail.de", + "twoweirdtricks.com", + "ty.ceed.se", + "tyldd.com", + "u.0u.ro", + "u.10x.es", + "u.2sea.org", + "u.900k.es", + "u.civvic.ro", + "u.dmarc.ro", + "u.labo.ch", + "u14269.ml", + "uacro.com", + "ubismail.net", + "ucupdong.ml", + "uggsrock.com", + "uk.flu.cc", + "uk.igg.biz", + "uk.nut.cc", + "umail.net", + "unmail.ru", + "upliftnow.com", + "uplipht.com", + "urfey.com", + "uroid.com", + "used-product.fr", + "username.e4ward.com", + "ux.dob.jp", + "ux.uk.to", + "v.0v.ro", + "v.jsonp.ro", + "vaasfc4.tk", + "valemail.net", + "venompen.com", + "veryrealemail.com", + "vfemail.net", + "vickaentb.tk", + "vidchart.com", + "viditag.com", + "viewcastmedia.com", + "viewcastmedia.net", + "viewcastmedia.org", + "visa.coms.hk", + "vkcode.ru", + "vomoto.com", + "vp.ycare.de", + "vps30.com", + "vps911.net", + "vssms.com", + "vubby.com", + "vzlom4ik.tk", + "w.0w.ro", + "walala.org", + "walkmail.net", + "walkmail.ru", + "wasd.dropmail.me", + "wazabi.club", + "we.qq.my", + "web-contact.info", + "web-emailbox.eu", + "web-ideal.fr", + "web-mail.pp.ua", + "web.discard-email.cf", + "webcontact-france.eu", + "webemail.me", + "webm4il.info", + "webuser.in", + "wee.my", + "wefjo.grn.cc", + "weg-werf-email.de", + "wegwerf-email-addressen.de", + "wegwerf-emails.de", + "wegwerfadresse.de", + "wegwerfemail.de", + "wegwerfmail.de", + "wegwerfmail.info", + "wegwerfmail.net", + "wegwerfmail.org", + "wegwerpmailadres.nl", + "wetrainbayarea.com", + "wetrainbayarea.org", + "wfgdfhj.tk", + "wh4f.org", + "whatiaas.com", + "whatpaas.com", + "whatsaas.com", + "whopy.com", + "whtjddn.33mail.com", + "whyspam.me", + "wickmail.net", + "wilemail.com", + "willselfdestruct.com", + "winemaven.info", + "wiz2.site", + "wmail.cf", + "wollan.info", + "worldspace.link", + "wovz.cu.cc", + "wr.moeri.org", + "wronghead.com", + "wt2.orangotango.cf", + "wuzup.net", + "wuzupmail.net", + "www.bccto.me", + "www.e4ward.com", + "www.gishpuppy.com", + "www.mailinator.com", + "wwwnew.eu", + "xagloo.com", + "xemaps.com", + "xents.com", + "xing886.uu.gl", + "xmaily.com", + "xoxox.cc", + "xoxy.net", + "xww.ro", + "xy9ce.tk", + "xyzfree.net", + "xzsok.com", + "yapped.net", + "yeah.net", + "yellow.flu.cc", + "yellow.hotakama.tk", + "yellow.igg.biz", + "yep.it", + "yert.ye.vc", + "yogamaven.com", + "yomail.info", + "yopmail.com", + "yopmail.fr", + "yopmail.gq", + "yopmail.net", + "yopmail.pp.ua", + "yordanmail.cf", + "youmail.ga", + "yourlifesucks.cu.cc", + "ypmail.webarnak.fr.eu.org", + "yroid.com", + "yuurok.com", + "yyt.resolution4print.info", + "z1p.biz", + "za.com", + "zain.site", + "zainmax.net", + "zaktouni.fr", + "ze.gally.jp", + "zehnminutenmail.de", + "zeta-telecom.com", + "zetmail.com", + "zhcne.com", + "zhouemail.510520.org", + "zippymail.info", + "zoaxe.com", + "zoemail.com", + "zoemail.net", + "zoemail.org", + "zombo.flu.cc", + "zombo.igg.biz", + "zombo.nut.cc", + "zomg.info", + "zxcv.com", + "zxcvbnm.com", + "zzz.com", + "225522.ml", + "44556677.igg.biz", + "466453.usa.cc", + "a0.igg.biz", + "a1.usa.cc", + "a2.flu.cc", + "anon.leemail.me", + "anonymize.com", + "asiarap.usa.cc", + "ay33rs.flu.cc", + "b0.nut.cc", + "bloxter.cu.cc", + "browniesgoreng.com", + "brownieskukuskreasi.com", + "brownieslumer.com", + "c4utar.ml", + "citroen-c1.ml", + "colafanta.cf", + "de-fake.instafly.cf", + "de-fake.webfly.cf", + "fake-box.com", + "fiat-500.ga", + "horvathurtablahoz.ml", + "hunrap.usa.cc", + "kachadresp.tk", + "lajoska.pe.hu", + "mrblacklist.gq", + "opel-corsa.tk", + "opentrash.com", + "paller.cf", + "password.colafanta.cf", + "re-gister.com", + "renault-clio.cf", + "retkesbusz.nut.cc", + "spam.flu.cc", + "spam.igg.biz", + "spam.nut.cc", + "spam.usa.cc", + "teleosaurs.xyz", + "top9appz.info", + "trash-me.com", + "viroleni.cu.cc", + "vw-golf.gq", + "yandere.cu.cc", + "you-spam.com", + "inbox2.info", + "nullbox.info", + "spambox.org", + "mailed.in", + "onlatedotcom.info", + "smapfree24.org", + "smapfree24.de", + "smapfree24.info", + "smapfree24.com", + "smapfree24.eu", + "email.net", + "ma1l.bij.pl", + "sify.com", + "tmail.com", + "xmail.com", + "atvclub.msk.ru", + "xagloo.co", + "mailhazard.com", + "mailhazard.us", + "mailhz.me", + "zebins.com", + "zebins.eu", + "amail4.me", + "1fsdfdsfsdf.tk", + "2fdgdfgdfgdf.tk", + "3trtretgfrfe.tk", + "4gfdsgfdgfd.tk", + "5ghgfhfghfgh.tk", + "6hjgjhgkilkj.tk", + "abcmail.email", + "ama-trade.de", + "anonmails.de", + "antireg.ru", + "antispammail.de", + "artman-conception.com", + "breakthru.com", + "bspamfree.org", + "buymoreplays.com", + "card.zp.ua", + "cek.pm", + "childsavetrust.org", + "d3p.dk", + "delikkt.de", + "dm.w3internet.co.ukexample.com", + "einmalmail.de", + "eintagsmail.de", + "emailtemporanea.com", + "emailtemporanea.net", + "ero-tube.org", + "express.net.ua", + "fivemail.de", + "fyii.de", + "gehensiemirnichtaufdensack.de", + "giantmail.de", + "gmial.com", + "hat-geld.de", + "infocom.zp.ua", + "inoutmail.de", + "inoutmail.eu", + "inoutmail.info", + "inoutmail.net", + "ip6.li", + "lawlita.com", + "lukop.dk", + "m21.cc", + "mail.zp.ua", + "mail1a.de", + "mail21.cc", + "mailbiz.biz", + "mailde.de", + "mailde.info", + "maileimer.de", + "mailms.com", + "mailorg.org", + "mailtrash.net", + "mailtv.net", + "mailtv.tv", + "ministry-of-silly-walks.de", + "misterpinball.de", + "mycard.net.ua", + "mysamp.de", + "mytempmail.com", + "nabuma.com", + "nincsmail.hu", + "nnh.com", + "noblepioneer.com", + "nomail.pw", + "nospammail.net", + "odnorazovoe.ru", + "poczta.onet.pl", + "privatdemail.net", + "realtyalerts.ca", + "reliable-mail.com", + "schrott-email.de", + "secretemail.de", + "senseless-entertainment.com", + "services391.com", + "shieldemail.com", + "shmeriously.com", + "slapsfromlastnight.com", + "sneakmail.de", + "spamail.de", + "spamarrest.com", + "squizzy.eu", + "super-auswahl.de", + "temp-mail.ru", + "tempmail.eu", + "tempmailer.de", + "temporarymailaddress.com", + "thc.st", + "thelimestones.com", + "thismail.net", + "tizi.com", + "topranklist.de", + "trialmail.de", + "us.af", + "viralplays.com", + "vpn.st", + "vsimcard.com", + "wasteland.rfc822.org", + "wegwerfemail.com", + "willhackforfood.biz", + "x.ip6.li", + "yourdomain.com", + "zehnminuten.de", + "0815.ry", + "0845.ru", + "10mail.com", + "10minut.com.pl", + "10minutesmail.com", + "10x9.com", + "12houremail.com", + "12minutemail.net", + "420blaze.it", + "8127ep.com", + "8chan.co", + "a.mailcker.com", + "a.vztc.com", + "akapost.com", + "akerd.com", + "ama-trans.de", + "anon-mail.de", + "antireg.com", + "antispam24.de", + "asdasd.ru", + "b2cmail.de", + "bio-muesli.info", + "blackmarket.to", + "br.mintemail.com", + "bugmenever.com", + "cam4you.cc", + "cc.liamria", + "cock.li", + "coieo.com", + "cumallover.me", + "dicksinhisan.us", + "dicksinmyan.us", + "dotman.de", + "dropcake.de", + "edv.to", + "ee1.pl", + "example.com", + "fakedemail.com", + "film-blog.biz", + "fly-ts.de", + "freeletter.me", + "garbagemail.org", + "garrifulio.mailexpire.com", + "geschent.biz", + "gmal.com", + "goat.si", + "gomail.in", + "guerillamailblock.com", + "horsefucker.org", + "hotmai.com", + "hotmial.com", + "humaility.com", + "hush.ai", + "ignoremail.com", + "inboxdesign.me", + "inboxed.im", + "inboxed.pw", + "inboxstore.me", + "iozak.com", + "is.af", + "junk.to", + "kmhow.com", + "kostenlosemailadresse.de", + "lavabit.com", + "linuxmail.so", + "llogin.ru", + "losemymail.com", + "loves.dicksinhisan.us", + "loves.dicksinmyan.us", + "luckymail.org", + "mac.hush.com", + "mail2world.com", + "maildu.de", + "mailita.tk", + "malahov.de", + "msb.minsmail.com", + "muchomail.com", + "national.shitposting.agency", + "nevermail.de", + "nigge.rs", + "nobugmail.com", + "nobuma.com", + "ohaaa.de", + "omail.pro", + "postonline.me", + "powered.name", + "privy-mail.de", + "put2.net", + "qoika.com", + "rcs.gaggle.net", + "redchan.it", + "schmeissweg.tk", + "secmail.pw", + "server.ms", + "shut.name", + "shut.ws", + "sky-ts.de", + "sofortmail.de", + "sry.li", + "suioe.com", + "superplatyna.com", + "techemail.com", + "techgroup.me", + "tfwno.gf", + "tokem.co", + "tormail.org", + "trashinbox.com", + "uyhip.com", + "vipmail.name", + "vipmail.pw", + "vztc.com", + "wants.dicksinhisan.us", + "wants.dicksinmyan.us", + "watch-harry-potter.com", + "watchfull.net", + "wegwerf-email-adressen.de", + "wegwerf-email.de", + "wegwerf-email.net", + "wegwerfemail.net", + "wegwerfemail.org", + "wegwerfemailadresse.com", + "wegwrfmail.de", + "wegwrfmail.net", + "wegwrfmail.org", + "wolfsmail.tk", + "writeme.us", + "yanet.me", + "youmailr.com", + "yxzx.net", + "randomail.net", + "0x207.info", + "1-8.biz", + "100likers.com", + "140unichars.com", + "147.cl", + "14n.co.uk", + "1st-forms.com", + "1to1mail.org", + "2120001.net", + "36ru.com", + "3l6.com", + "4-n.us", + "418.dk", + "5gramos.com", + "5oz.ru", + "5x25.com", + "672643.net", + "80665.com", + "abakiss.com", + "academiccommunity.com", + "adobeccepdm.com", + "adpugh.org", + "adsd.org", + "adwaterandstir.com", + "aegia.net", + "aegiscorp.net", + "aeonpsi.com", + "agtx.net", + "al-qaeda.us", + "aligamel.com", + "alisongamel.com", + "alldirectbuy.com", + "allen.nom.za", + "allthegoodnamesaretaken.org", + "alph.wtf", + "amazon-aws.org", + "amelabs.com", + "ampsylike.com", + "an.id.au", + "anappfor.com", + "andthen.us", + "animesos.com", + "anonymized.org", + "anonymousness.com", + "ansibleemail.com", + "anthony-junkmail.com", + "apfelkorps.de", + "aphlog.com", + "appc.se", + "appinventor.nl", + "aron.us", + "arroisijewellery.com", + "arvato-community.de", + "aschenbrandt.net", + "ashleyandrew.com", + "astroempires.info", + "at0mik.org", + "augmentationtechnology.com", + "autorobotica.com", + "autotwollow.com", + "axiz.org", + "azcomputerworks.com", + "badgerland.eu", + "badoop.com", + "basscode.org", + "bauwerke-online.com", + "bazaaboom.com", + "bcast.ws", + "bearsarefuzzy.com", + "belljonestax.com", + "benipaula.org", + "bestchoiceusedcar.com", + "bidourlnks.com", + "bigwhoop.co.za", + "blip.ch", + "bluedumpling.info", + "bluewerks.com", + "bobmurchison.com", + "bonobo.email", + "bookthemmore.com", + "borged.com", + "borged.net", + "borged.org", + "brandallday.net", + "briggsmarcus.com", + "bspooky.com", + "btb-notes.com", + "btc.email", + "bulrushpress.com", + "bum.net", + "bunchofidiots.com", + "bunsenhoneydew.com", + "businessbackend.com", + "businesssuccessislifesuccess.com", + "buspad.org", + "buyordie.info", + "byebyemail.com", + "byespm.com", + "californiafitnessdeals.com", + "chielo.com", + "chilkat.com", + "chithinh.com", + "chumpstakingdumps.com", + "cigar-auctions.com", + "ckiso.com", + "cl-cl.org", + "cl0ne.net", + "clandest.in", + "cnamed.com", + "cnmsg.net", + "cnsds.de", + "codeandscotch.com", + "codivide.com", + "compareshippingrates.org", + "completegolfswing.com", + "comwest.de", + "coolandwacky.us", + "coolimpool.org", + "crankhole.com", + "crastination.de", + "crossroadsmail.com", + "cszbl.com", + "daemsteam.com", + "dammexe.net", + "darkharvestfilms.com", + "daryxfox.net", + "dash-pads.com", + "dataarca.com", + "datarca.com", + "datazo.ca", + "davidkoh.net", + "davidlcreative.com", + "dealrek.com", + "deekayen.us", + "defomail.com", + "degradedfun.net", + "delayload.com", + "delayload.net", + "der-kombi.de", + "derkombi.de", + "derluxuswagen.de", + "diapaulpainting.com", + "digitalmariachis.com", + "dildosfromspace.com", + "dispo.in", + "dodgemail.de", + "dolphinnet.net", + "doquier.tk", + "dotslashrage.com", + "douchelounge.com", + "dozvon-spb.ru", + "droolingfanboy.de", + "dspwebservices.com", + "dukedish.com", + "durandinterstellar.com", + "dyceroprojects.com", + "dz17.net", + "e3z.de", + "ebeschlussbuch.de", + "ebs.com.ar", + "ecallheandi.com", + "edinburgh-airporthotels.com", + "elearningjournal.org", + "electro.mn", + "elitevipatlantamodels.com", + "emailresort.com", + "emailsingularity.net", + "ephemeral.email", + "ericjohnson.ml", + "esc.la", + "escapehatchapp.com", + "esemay.com", + "esgeneri.com", + "esprity.com", + "evanfox.info", + "exitstageleft.net", + "ezstest.com", + "f4k.es", + "fag.wf", + "failbone.com", + "faithkills.com", + "fangoh.com", + "farrse.co.uk", + "fasternet.biz", + "fer-gabon.org", + "fettometern.com", + "fictionsite.com", + "figshot.com", + "filbert4u.com", + "filberts4u.com", + "flowu.com", + "flyinggeek.net", + "foobarbot.net", + "forecastertests.com", + "forspam.net", + "foxtrotter.info", + "freebabysittercam.com", + "freeblackbootytube.com", + "freecat.net", + "freedompop.us", + "freefattymovies.com", + "freemail.hu", + "freeplumpervideos.com", + "freeschoolgirlvids.com", + "freesistercam.com", + "freeteenbums.com", + "fuckedupload.com", + "funnycodesnippets.com", + "furzauflunge.de", + "g4hdrop.us", + "galaxy.tv", + "gamegregious.com", + "garbagecollector.org", + "gardenscape.ca", + "garrymccooey.com", + "gav0.com", + "geldwaschmaschine.de", + "genderfuck.net", + "giaiphapmuasam.com", + "ginzi.be", + "ginzi.co.uk", + "ginzi.es", + "ginzi.net", + "ginzy.co.uk", + "ginzy.eu", + "girlsindetention.com", + "glitch.sx", + "globaltouron.com", + "glucosegrin.com", + "gnctr-calgary.com", + "gothere.biz", + "greggamel.com", + "greggamel.net", + "gregorsky.zone", + "gregorygamel.com", + "gregorygamel.net", + "gs-arc.org", + "gsredcross.org", + "gudanglowongan.com", + "gynzi.co.uk", + "gynzi.es", + "gynzy.at", + "gynzy.es", + "gynzy.eu", + "gynzy.gr", + "gynzy.info", + "gynzy.lt", + "gynzy.mobi", + "gynzy.pl", + "gynzy.ro", + "gynzy.sk", + "habitue.net", + "hackthatbit.ch", + "hahawrong.com", + "hawrong.com", + "hazelnut4u.com", + "hazelnuts4u.com", + "hazmatshipping.org", + "heathenhammer.com", + "heathenhero.com", + "helloricky.com", + "helpinghandtaxcenter.org", + "herpderp.nl", + "hiddentragedy.com", + "highbros.org", + "hoanggiaanh.com", + "hungpackage.com", + "huskion.net", + "hvastudiesucces.nl", + "hwsye.net", + "ibnuh.bz", + "icantbelieveineedtoexplainthisshit.com", + "icx.in", + "illistnoise.com", + "ilovespam.com", + "indieclad.com", + "indirect.ws", + "ineec.net", + "insanumingeniumhomebrew.com", + "interstats.org", + "intersteller.com", + "ironiebehindert.de", + "irssi.tv", + "isukrainestillacountry.com", + "it7.ovh", + "itunesgiftcodegenerator.com", + "j-p.us", + "jafps.com", + "jdmadventures.com", + "jellyrolls.com", + "jobposts.net", + "jobs-to-be-done.net", + "joelpet.com", + "joetestalot.com", + "jopho.com", + "jungkamushukum.com", + "kakadua.net", + "kalapi.org", + "kamsg.com", + "kariplan.com", + "kartvelo.com", + "kcrw.de", + "keinhirn.de", + "keipino.de", + "kemptvillebaseball.com", + "kennedy808.com", + "kiois.com", + "kisstwink.com", + "kitnastar.com", + "kludgemush.com", + "kommunity.biz", + "kopagas.com", + "kopaka.net", + "kosmetik-obatkuat.com", + "krypton.tk", + "kuhrap.com", + "kwift.net", + "kwilco.net", + "l-c-a.us", + "lakelivingstonrealestate.com", + "letmeinonthis.com", + "lez.se", + "liamcyrus.com", + "lifetotech.com", + "ligsb.com", + "lilo.me", + "lindenbaumjapan.com", + "lkgn.se", + "locomodev.net", + "loin.in", + "lolmail.biz", + "lpfmgmtltd.com", + "lru.me", + "lukecarriere.com", + "lukemail.info", + "lyfestylecreditsolutions.com", + "macromaid.com", + "magamail.com", + "magicbox.ro", + "maidlow.info", + "mail-owl.com", + "mail707.com", + "mailback.com", + "mailchop.com", + "mailinator.co.uk", + "mailinator.info", + "mailonaut.com", + "mailorc.com", + "malayalamdtp.com", + "mansiondev.com", + "markmurfin.com", + "mcache.net", + "messwiththebestdielikethe.rest", + "miaferrari.com", + "midcoastcustoms.com", + "midcoastcustoms.net", + "midcoastsolutions.com", + "midcoastsolutions.net", + "midlertidig.com", + "midlertidig.net", + "midlertidig.org", + "mijnhva.nl", + "mildin.org.ua", + "mkpfilm.com", + "ml8.ca", + "mockmyid.com", + "momentics.ru", + "moneypipe.net", + "moonwake.com", + "moreawesomethanyou.com", + "moreorcs.com", + "motique.de", + "mountainregionallibrary.net", + "msgos.com", + "mspeciosa.com", + "msxd.com", + "mtmdev.com", + "muathegame.com", + "mucincanon.com", + "mutant.me", + "mwarner.org", + "mxfuel.com", + "mybitti.de", + "mycorneroftheinter.net", + "mydemo.equipment", + "myecho.es", + "mykickassideas.com", + "myopang.com", + "mywarnernet.net", + "myzx.com", + "n1nja.org", + "nakedtruth.biz", + "nanonym.ch", + "nationalgardeningclub.com", + "negated.com", + "netricity.nl", + "netris.net", + "netviewer-france.com", + "nextstopvalhalla.com", + "nfast.net", + "nguyenusedcars.com", + "nicknassar.com", + "niwl.net", + "nnot.net", + "no-ux.com", + "nodezine.com", + "norseforce.com", + "nothingtoseehere.ca", + "notrnailinator.com", + "nubescontrol.com", + "nuts2trade.com", + "ny7.me", + "o2stk.org", + "o7i.net", + "obfusko.com", + "obxpestcontrol.com", + "oerpub.org", + "offshore-proxies.net", + "okclprojects.com", + "okrent.us", + "omnievents.org", + "onlineidea.info", + "onqin.com", + "ontyne.biz", + "oolus.com", + "ourpreviewdomain.com", + "ownsyou.de", + "oxopoha.com", + "pa9e.com", + "pastebitch.com", + "penisgoes.in", + "peterdethier.com", + "petrzilka.net", + "photomark.net", + "pi.vu", + "pinehill-seattle.org", + "pingir.com", + "pisls.com", + "plhk.ru", + "plw.me", + "pojok.ml", + "pokiemobile.com", + "poopiebutt.club", + "popesodomy.com", + "popgx.com", + "poutineyourface.com", + "powlearn.com", + "primabananen.net", + "pro-tag.org", + "procrackers.com", + "projectcl.com", + "propscore.com", + "proxyparking.com", + "purcell.email", + "purelogistics.org", + "qipmail.net", + "quadrafit.com", + "qvy.me", + "qwickmail.com", + "r4nd0m.de", + "raetp9.com", + "raketenmann.de", + "rancidhome.net", + "raqid.com", + "rax.la", + "raxtest.com", + "recipeforfailure.com", + "redfeathercrow.com", + "remarkable.rocks", + "remote.li", + "reptilegenetics.com", + "revolvingdoorhoax.org", + "riddermark.de", + "risingsuntouch.com", + "rnailinator.com", + "robertspcrepair.com", + "ronnierage.net", + "rotaniliam.com", + "rowe-solutions.com", + "royaldoodles.org", + "rumgel.com", + "rustydoor.com", + "s33db0x.com", + "sabrestlouis.com", + "sackboii.com", + "saharanightstempe.com", + "samsclass.info", + "sandwhichvideo.com", + "sanfinder.com", + "sanim.net", + "sanstr.com", + "satukosong.com", + "sausen.com", + "schachrol.com", + "sd3.in", + "secured-link.net", + "seekapps.com", + "sejaa.lv", + "sendfree.org", + "sendingspecialflyers.com", + "sexforswingers.com", + "sexical.com", + "shhmail.com", + "shhuut.org", + "shipfromto.com", + "shiphazmat.org", + "shipping-regulations.com", + "shippingterms.org", + "shrib.com", + "simpleitsecurity.info", + "sinfiltro.cl", + "singlespride.com", + "sizzlemctwizzle.com", + "skkk.edu.my", + "sky-inbox.com", + "slothmail.net", + "smtp99.com", + "smwg.info", + "socialfurry.org", + "solventtrap.wiki", + "spam.org.es", + "spamlot.net", + "speedgaus.net", + "spritzzone.de", + "stanfordujjain.com", + "starlight-breaker.net", + "startfu.com", + "statdvr.com", + "stathost.net", + "statiix.com", + "steambot.net", + "stumpfwerk.com", + "suburbanthug.com", + "suckmyd.com", + "sylvannet.com", + "tafoi.gr", + "tagmymedia.com", + "tanukis.org", + "tb-on-line.net", + "telecomix.pl", + "testudine.com", + "theaviors.com", + "thebearshark.com", + "thediamants.org", + "thembones.com.au", + "themostemail.com", + "thescrappermovie.com", + "theteastory.info", + "thietbivanphong.asia", + "thisurl.website", + "thnikka.com", + "thunkinator.org", + "thxmate.com", + "timgiarevn.com", + "timkassouf.com", + "tinyurl24.com", + "tlpn.org", + "tmpjr.me", + "toddsbighug.com", + "tokenmail.de", + "tonymanso.com", + "top101.de", + "topofertasdehoy.com", + "toprumours.com", + "toss.pw", + "totalvista.com", + "totesmail.com", + "tp-qa-mail.com", + "tranceversal.com", + "trasz.com", + "trollproject.com", + "tropicalbass.info", + "trungtamtoeic.com", + "ttszuo.xyz", + "tualias.com", + "txtadvertise.com", + "ufacturing.com", + "uguuchantele.com", + "uhhu.ru", + "unimark.org", + "unit7lahaina.com", + "uploadnolimit.com", + "urfunktion.se", + "utiket.us", + "uwork4.us", + "vaati.org", + "valhalladev.com", + "verdejo.com", + "veryday.ch", + "veryday.eu", + "veryday.info", + "victoriantwins.com", + "vikingsonly.com", + "vinernet.com", + "vipxm.net", + "vixletdev.com", + "vmailing.info", + "vmani.com", + "vmpanda.com", + "vorga.org", + "votiputox.org", + "voxelcore.com", + "wakingupesther.com", + "watchever.biz", + "watchironman3onlinefreefullmovie.com", + "wbml.net", + "webtrip.ch", + "welikecookies.com", + "wg0.com", + "whatifanalytics.com", + "whiffles.org", + "wibblesmith.com", + "widget.gg", + "wimsg.com", + "wralawfirm.com", + "x1x.spb.ru", + "x24.com", + "xcompress.com", + "xcpy.com", + "xjoi.com", + "xn--9kq967o.com", + "xrho.com", + "xwaretech.com", + "xwaretech.info", + "xwaretech.net", + "yaqp.com", + "ynmrealty.com", + "yougotgoated.com", + "youneedmore.info", + "yourewronghereswhy.com", + "yourlms.biz", + "yspend.com", + "yugasandrika.com", + "yui.it", + "zepp.dk", + "zipsendtest.com", + "zoetropes.org", + "zombie-hive.com", + "zumpul.com", + "pooae.com", + "foxja.com", + "kloap.com", + "yhg.biz", + "rooftest.net", + "pp.ua", + "20email.it", + "extremail.ru", + "kismail.ru", + "mail.bccto.me", + "mail72.com", + "figjs.com", + "139.com", + "188.com", + "189.cn", + "20mail.eu", + "alfamailr.org", + "asdfasdfmail.net", + "asdfmail.net", + "asooemail.net", + "dfgggg.org", + "e-mail.net", + "emailll.org", + "fghmail.net", + "fsfsdf.org", + "iaoss.com", + "jamit.com.au", + "mailadadad.org", + "popmailserv.org", + "rtotlmail.net", + "vip.188.com", + "vip.sohu.com", + "vip.sohu.net", + "vip.tom.com", + "vipsohu.net", + "ymail.net", + "ymail.org", + "game.com", + "zasod.com", + "ispyco.ru", + "mailspeed.ru", + "throwawayemail.com", + "dsiay.com", + "emailo.pro", + "wierie.tk", + "morriesworld.ml", + "freealtgen.com", + "oing.cf", + "mailxcdn.com", + "deyom.com", + "reftoken.net", + "amail.club", + "mail.fast10s.design", + "zhorachu.com", + "ethersports.org", + "tinoza.org", + "payperex2.com", + "nezdiro.org", + "ether123.net", + "averdov.com", + "axsup.net", + "datum2.com", + "geronra.com", + "asorent.com", + "33m.co", + "aji.kr", + "anyalias.com", + "bei.kr", + "bel.kr", + "beo.kr", + "bfo.kr", + "bho.kr", + "bko.kr", + "chickenkiller.com", + "cid.kr", + "cko.kr", + "coms.hk", + "cu.cc", + "dbo.kr", + "dko.kr", + "dnses.ro", + "doy.kr", + "efo.kr", + "eho.kr", + "ely.kr", + "emailfreedom.ml", + "emlhub.com", + "emlpro.com", + "emy.kr", + "enu.kr", + "eny.kr", + "ewa.kr", + "exi.kr", + "fackme.gq", + "fassagforpresident.ga", + "flu.cc", + "foy.kr", + "fr.nf", + "gmail.gr.com", + "gmeil.me", + "gok.kr", + "haddo.eu", + "hix.kr", + "hiz.kr", + "igg.biz", + "iki.kr", + "irr.kr", + "jil.kr", + "jto.kr", + "justemail.ml", + "kadokawa.top", + "lal.kr", + "lbe.kr", + "lei.kr", + "lko.co.kr", + "lko.kr", + "lom.kr", + "loy.kr", + "luo.kr", + "mail0.ga", + "mbe.kr", + "mko.kr", + "mlo.kr", + "mp-j.ga", + "mp-j.gq", + "mp-j.ml", + "mp-j.tk", + "nafko.cf", + "nko.kr", + "npv.kr", + "nuo.kr", + "nut.cc", + "obo.kr", + "orangotango.ml", + "owa.kr", + "oyu.kr", + "poy.kr", + "qbi.kr", + "rao.kr", + "rko.kr", + "row.kr", + "spamtrap.ro", + "tko.co.kr", + "tko.kr", + "tmo.kr", + "toi.kr", + "uha.kr", + "uko.kr", + "umy.kr", + "uny.kr", + "upy.kr", + "usa.cc", + "uu.gl", + "uvy.kr", + "uyu.kr", + "vay.kr", + "vba.kr", + "veo.kr", + "wil.kr", + "xxi2.com", + "ye.vc", + "qq.com", + "001.igg.biz", + "0x00.name", + "1000rebates.stream", + "10host.top", + "117.yyolf.net", + "11top.xyz", + "1rentcar.top", + "1ss.noip.me", + "1usemail.com", + "2014mail.ru", + "291.usa.cc", + "2sea.xyz", + "3ew.usa.cc", + "487.nut.cc", + "4tb.host", + "4w.io", + "54np.club", + "5july.org", + "5music.info", + "5music.top", + "7rent.top", + "806.flu.cc", + "88clean.pro", + "a.sach.ir", + "a0f7ukc.com", + "a41odgz7jh.com", + "a54pd15op.com", + "aaaw45e.com", + "abyssemail.com", + "adesktop.com", + "adx-telecom.com", + "agustusmp3.xyz", + "aistis.xyz", + "akademiyauspexa.xyz", + "akorde.al", + "aldeyaa.ae", + "alimunjaya.xyz", + "alsheim.no-ip.org", + "alumnimp3.xyz", + "amoksystems.com", + "anthropologycommunity.com", + "asdfghmail.com", + "asspoo.com", + "assurancespourmoi.eu", + "azjuggalos.com", + "b.reed.to", + "b9x45v1m.com", + "backalleybowling.info", + "badhus.org", + "ballsofsteel.net", + "bandai.nom.co", + "barrypov.com", + "barryspov.com", + "bartoparcadecabinet.com", + "beck-it.net", + "bho.hu", + "bigwiki.xyz", + "bin.8191.at", + "biometicsliquidvitamins.com", + "bitwerke.com", + "bogotadc.info", + "bungabunga.cf", + "businesscredit.xyz", + "buygapfashion.com", + "bwa33.net", + "by8006l.com", + "c.kadag.ir", + "c.theplug.org", + "cafecar.xyz", + "carrnelpartners.com", + "caseedu.tk", + "cd.mintemail.com", + "central-servers.xyz", + "centrallosana.ga", + "cheaphorde.com", + "chef.asana.biz", + "chilelinks.cl", + "chinatov.com", + "choco.la", + "chris.burgercentral.us", + "christopherfretz.com", + "civilizationdesign.xyz", + "cl.gl", + "clay.xyz", + "clinicatbf.com", + "clipmail.eu", + "cloud99.pro", + "cloud99.top", + "cls-audio.club", + "cognitiveways.xyz", + "colorweb.cf", + "communitybuildingworks.xyz", + "contentwanted.com", + "cortex.kicks-ass.net", + "cr97mt49.com", + "cultmovie.com", + "cutout.club", + "cybersex.com", + "czqjii8.com", + "d58pb91.com", + "d8u.us", + "dancemanual.com", + "darknode.org", + "derder.net", + "dev-null.cf", + "dev-null.ga", + "dev-null.gq", + "dev-null.ml", + "dff55.dynu.net", + "dfg6.kozow.com", + "digdown.xyz", + "dinkmail.com", + "disaq.com", + "disario.info", + "disposablemails.com", + "dmarc.ro", + "doanart.com", + "doxcity.net", + "dqkerui.com", + "dragons-spirit.org", + "drynic.com", + "dt.com", + "dwse.edu.pl", + "e.4pet.ro", + "e.amav.ro", + "e.l5.ca", + "e.nodie.cc", + "e.shapoo.ch", + "e7n06wz.com", + "eastwan.net", + "eatrnet.com", + "eb609s25w.com", + "eco.ilmale.it", + "edrishn.xyz", + "emailmenow.info", + "eonmech.com", + "etgdev.de", + "ezfill.club", + "faithkills.org", + "fakeinbox.info", + "fartwallet.com", + "faze.biz", + "fc66998.com", + "fetchnet.co.uk", + "fingermouse.org", + "fishfortomorrow.xyz", + "fls4.gleeze.com", + "foquita.com", + "francanet.com.br", + "freebullets.net", + "freechristianbookstore.com", + "freemommyvids.com", + "freeshemaledvds.com", + "freesistervids.com", + "freetubearchive.com", + "fsagc.xyz", + "fun2.biz", + "furusato.tokyo", + "gero.us", + "getnowtoday.cf", + "gibit.us", + "gimesson.pe.hu", + "giuras.club", + "giuypaiw8.com", + "godataflow.xyz", + "goodjab.club", + "gowikimusic.great-host.in", + "greenst.info", + "greyjack.com", + "gwspt71.com", + "h.thc.lv", + "h1z8ckvz.com", + "h2-yy.nut.cc", + "h9js8y6.com", + "hackersquad.tk", + "hackrz.xyz", + "happykorea.club", + "happykoreas.xyz", + "harmonyst.xyz", + "hdmoviestore.us", + "healyourself.xyz", + "hiddencorner.xyz", + "hostmonitor.net", + "hvtechnical.com", + "i.ryanb.com", + "i4j0j3iz0.com", + "icemovie.link", + "ihaxyour.info", + "iku.us", + "ilnostrogrossograssomatrimoniomolisano.com", + "imankul.com", + "imovie.link", + "inaby.com", + "inapplicable.org", + "indonesianherbalmedicine.com", + "inpowiki.xyz", + "ipswell.com", + "irabops.com", + "ircbox.xyz", + "ispuntheweb.com", + "istakalisa.club", + "j.rvb.ro", + "jeramywebb.com", + "jetableemail.com", + "josefadventures.org", + "jredm.com", + "jswfdb48z.com", + "jv6hgh1.com", + "jyliananderik.com", + "k3663a40w.com", + "kah.pw", + "kaijenwan.com", + "kampoeng3d.club", + "kekecog.com", + "ketiksms.club", + "kickmark.com", + "kiham.club", + "kitten-mittons.com", + "kormail.xyz", + "kuai909.com", + "kuaijenwan.com", + "l.safdv.com", + "ladymacbeth.tk", + "ledoktre.com", + "lesbugs.com", + "lexisense.com", + "likesyouback.com", + "lmcudh4h.com", + "localserv.no-ip.org", + "locanto1.club", + "locantospot.top", + "locateme10.com", + "lordsofts.com", + "lostpositive.xyz", + "lpo.ddnsfree.com", + "m2r60ff.com", + "mail.aws910.com", + "mail.illistnoise.com", + "mail.mailinator.com", + "mail.partskyline.com", + "mail.ticket-please.ga", + "mail4you.usa.cc", + "maildump.tk", + "mailinator.pl", + "mailkor.xyz", + "mailmetrash.comilzilla.org", + "mailna.in", + "mailpooch.com", + "mailthunder.ml", + "mao.igg.biz", + "mastahype.net", + "mattmason.xyz", + "medsheet.com", + "mejjang.xyz", + "metroset.net", + "mhwolf.net", + "mihep.com", + "miodonski.ch", + "miraigames.net", + "mmail.igg.biz", + "mmailinater.com", + "mockmyid.co", + "msrc.ml", + "mswork.ru", + "mufux.com", + "mugglenet.org", + "mustbedestroyed.org", + "mymailjos.cf", + "mymailjos.ga", + "mymailjos.tk", + "myn4s.ddns.net", + "mythnick.club", + "naturalious.com", + "nctuiem.xyz", + "neibu306.com", + "neibu963.com", + "newdawnnm.xyz", + "nie-podam.pl", + "niepodam.pl", + "nl.szucsati.net", + "nodnor.club", + "nomail.cf", + "nomail.ch", + "nomail.ga", + "nomailthankyou.com", + "northemquest.com", + "nostrajewellery.xyz", + "o.idigo.org", + "o.muti.ro", + "o060bgr3qg.com", + "oceancares.xyz", + "ohdomain.xyz", + "ohioticketpayments.xyz", + "onebiginbox.com", + "onelegalplan.com", + "otherinbox.codupmyspace.com", + "p.9q.ro", + "p.k4ds.org", + "parkcrestlakewood.xyz", + "paulfucksallthebitches.com", + "pencalc.xyz", + "penis.computer", + "peppe.usa.cc", + "personal-email.ml", + "podam.pl", + "polarkingxx.ml", + "poliusraas.tk", + "premiumperson.website", + "prs7.xyz", + "psychedelicwarrior.xyz", + "pumps-fashion.com", + "pwp.lv", + "qafatwallet.com", + "qj97r73md7v5.com", + "qs2k.com", + "qt1.ddns.net", + "querydirect.com", + "quickreport.it", + "r.yasser.ru", + "r8r4p0cb.com", + "radecoratingltd.com", + "@cevipsa.com", + "@cpav3.com", + "@nuclene.com", + "@steveix.com", + "@mocvn.com", + "@tenvil.com", + "@tgvis.com", + "@amozix.com", + "@anypsd.com", + "@maxric.com", + "greencafe24.com", + "coffeetimer24.com", + "waterisgone.com", + "paperpapyrus.com", + "klovenode.com", + "drowblock.com", + "dishcatfish.com", + "superblohey.com", + "happy2023year.com", + "gixenmixen.com", + "bloheyz.com", + "zipcatfish.com", + "myinfoinc.com", + ], };

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 diff --git a/classes/activitypub.ts b/classes/activitypub.ts deleted file mode 100644 index 3ce4c8df..00000000 --- a/classes/activitypub.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { APActivity, APActor } from "activitypub-types"; - -export class RemoteActor { - private actorData: APActor | null; - private actorUri: string; - - constructor(actor: APActor | string) { - if (typeof actor === "string") { - this.actorUri = actor; - this.actorData = null; - } else { - this.actorUri = actor.id || ""; - this.actorData = actor; - } - } - - public async fetch() { - const response = await fetch(this.actorUri); - const actorJson = (await response.json()) as APActor; - this.actorData = actorJson; - } - - public getData() { - return this.actorData; - } -} - -export class RemoteActivity { - private data: APActivity | null; - private uri: string; - - constructor(uri: string, data: APActivity | null) { - this.uri = uri; - this.data = data; - } - - public async fetch() { - const response = await fetch(this.uri); - const json = (await response.json()) as APActivity; - this.data = json; - } - - public getData() { - return this.data; - } - - public async getActor() { - if (!this.data) { - throw new Error("No data"); - } - - if (Array.isArray(this.data.actor)) { - throw new Error("Multiple actors"); - } - - if (typeof this.data.actor === "string") { - const actor = new RemoteActor(this.data.actor); - await actor.fetch(); - return actor.getData(); - } - - return new RemoteActor(this.data.actor as any); - } -} diff --git a/cli.ts b/cli.ts index 69e8afb9..a974061e 100644 --- a/cli.ts +++ b/cli.ts @@ -1,1754 +1,1818 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Parser } from "@json2csv/plainjs"; +import { MeiliIndexType, rebuildSearchIndexes } from "@meilisearch"; +import type { Prisma } from "@prisma/client"; import chalk from "chalk"; -import { createNewLocalUser } from "~database/entities/User"; -import Table from "cli-table"; -import { rebuildSearchIndexes, MeiliIndexType } from "@meilisearch"; -import { getUrl } from "~database/entities/Attachment"; -import extract from "extract-zip"; -import { client } from "~database/datasource"; import { CliBuilder, CliCommand } from "cli-parser"; +import Table from "cli-table"; +import extract from "extract-zip"; +import { MediaBackend } from "media-manager"; +import { client } from "~database/datasource"; +import { getUrl } from "~database/entities/Attachment"; +import { createNewLocalUser } from "~database/entities/User"; import { CliParameterType } from "~packages/cli-parser/cli-builder.type"; import { config } from "~packages/config-manager"; -import { Parser } from "@json2csv/plainjs"; -import type { Prisma } from "@prisma/client"; -import { MediaBackend } from "media-manager"; -import { mkdtemp } from "fs/promises"; -import { join } from "path"; -import { tmpdir } from "os"; const args = process.argv; const filterObjects = (output: T[], fields: string[]) => { - if (fields.length === 0) return output; + if (fields.length === 0) return output; - return output.map(element => { - // If fields is specified, only include provided fields - // This is a bit of a mess - if (fields.length > 0) { - const keys = Object.keys(element); - const filteredKeys = keys.filter(key => fields.includes(key)); - return Object.entries(element) - .filter(([key]) => filteredKeys.includes(key)) - .reduce((acc, [key, value]) => { - // @ts-expect-error This is fine - acc[key] = value; - return acc; - }, {}) as Partial; - } else { - return element; - } - }); + return output.map((element) => { + // If fields is specified, only include provided fields + // This is a bit of a mess + if (fields.length > 0) { + const keys = Object.keys(element); + const filteredKeys = keys.filter((key) => fields.includes(key)); + return Object.entries(element) + .filter(([key]) => filteredKeys.includes(key)) + .reduce((acc, [key, value]) => { + // @ts-expect-error This is fine + acc[key] = value; + return acc; + }, {}) as Partial; + } + return element; + }); }; const cliBuilder = new CliBuilder([ - new CliCommand<{ - username: string; - password: string; - email: string; - admin: boolean; - help: boolean; - }>( - ["user", "create"], - [ - { - name: "username", - type: CliParameterType.STRING, - description: "Username of the user", - needsValue: true, - positioned: false, - }, - { - name: "password", - type: CliParameterType.STRING, - description: "Password of the user", - needsValue: true, - positioned: false, - }, - { - name: "email", - type: CliParameterType.STRING, - description: "Email of the user", - needsValue: true, - positioned: false, - }, - { - name: "admin", - type: CliParameterType.BOOLEAN, - description: "Make the user an admin", - needsValue: false, - positioned: false, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - ], - async (instance: CliCommand, args) => { - const { username, password, email, admin, help } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - // Check if username, password and email are provided - if (!username || !password || !email) { - console.log( - `${chalk.red(`✗`)} Missing username, password or email` - ); - return 1; - } - - // Check if user already exists - const user = await client.user.findFirst({ - where: { - OR: [{ username }, { email }], - }, - }); - - if (user) { - if (user.username === username) { - console.log( - `${chalk.red(`✗`)} User with username ${chalk.blue(username)} already exists` - ); - } else { - console.log( - `${chalk.red(`✗`)} User with email ${chalk.blue(email)} already exists` - ); - } - return 1; - } - - // Create user - const newUser = await createNewLocalUser({ - email: email, - password: password, - username: username, - admin: admin, - }); - - console.log( - `${chalk.green(`✓`)} Created user ${chalk.blue( - newUser.username - )}${admin ? chalk.green(" (admin)") : ""}` - ); - - return 0; - }, - "Creates a new user", - "bun cli user create --username admin --password password123 --email email@email.com" - ), - new CliCommand<{ - username: string; - help: boolean; - noconfirm: boolean; - }>( - ["user", "delete"], - [ - { - name: "username", - type: CliParameterType.STRING, - description: "Username of the user", - needsValue: true, - positioned: true, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - { - name: "noconfirm", - shortName: "y", - type: CliParameterType.EMPTY, - description: "Skip confirmation", - needsValue: false, - positioned: false, - }, - ], - async (instance: CliCommand, args) => { - const { username, help } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!username) { - console.log(`${chalk.red(`✗`)} Missing username`); - return 1; - } - - const user = await client.user.findFirst({ - where: { - username: username, - }, - }); - - if (!user) { - console.log(`${chalk.red(`✗`)} User not found`); - return 1; - } - - if (!args.noconfirm) { - process.stdout.write( - `Are you sure you want to delete user ${chalk.blue( - user.username - )}?\n${chalk.red(chalk.bold("This is a destructive action and cannot be undone!"))} [y/N] ` - ); - - for await (const line of console) { - if (line.trim().toLowerCase() === "y") { - break; - } else { - console.log(`${chalk.red(`✗`)} Deletion cancelled`); - return 0; - } - } - } - - await client.user.delete({ - where: { - id: user.id, - }, - }); - - console.log( - `${chalk.green(`✓`)} Deleted user ${chalk.blue(user.username)}` - ); - - return 0; - }, - "Deletes a user", - "bun cli user delete --username admin" - ), - new CliCommand<{ - admins: boolean; - help: boolean; - format: string; - limit: number; - redact: boolean; - fields: string[]; - }>( - ["user", "list"], - [ - { - name: "admins", - type: CliParameterType.BOOLEAN, - description: "List only admins", - needsValue: false, - positioned: false, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - { - name: "format", - type: CliParameterType.STRING, - description: "Output format (can be json or csv)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "limit", - type: CliParameterType.NUMBER, - description: - "Limit the number of users to list (defaults to 200)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "redact", - type: CliParameterType.BOOLEAN, - description: - "Redact sensitive information (such as password hashes, emails or keys)", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "fields", - type: CliParameterType.ARRAY, - description: - "If provided, restricts output to these fields (comma-separated)", - needsValue: true, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { admins, help, fields = [] } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (args.format && !["json", "csv"].includes(args.format)) { - console.log(`${chalk.red(`✗`)} Invalid format`); - return 1; - } - const users = filterObjects( - await client.user.findMany({ - where: { - isAdmin: admins || undefined, - }, - take: args.limit ?? 200, - include: { - instance: - fields.length == 0 - ? true - : fields.includes("instance"), - }, - }), - fields - ); - - if (args.redact) { - for (const user of users) { - user.email = "[REDACTED]"; - user.password = "[REDACTED]"; - user.publicKey = "[REDACTED]"; - user.privateKey = "[REDACTED]"; - } - } - - if (args.format === "json") { - console.log(JSON.stringify(users, null, 4)); - return 0; - } else if (args.format == "csv") { - const parser = new Parser({}); - console.log(parser.parse(users)); - return 0; - } - - console.log( - `${chalk.green(`✓`)} Found ${chalk.blue(users.length)} users (limit ${args.limit ?? 200})` - ); - - const tableHead = filterObjects( - [ - { - username: chalk.white(chalk.bold("Username")), - email: chalk.white(chalk.bold("Email")), - displayName: chalk.white(chalk.bold("Display Name")), - isAdmin: chalk.white(chalk.bold("Admin?")), - instance: chalk.white(chalk.bold("Instance URL")), - createdAt: chalk.white(chalk.bold("Created At")), - id: chalk.white(chalk.bold("Internal UUID")), - }, - ], - fields - )[0]; - - const table = new Table({ - head: Object.values(tableHead), - }); - - for (const user of users) { - // Print table of users - const data = { - username: () => chalk.yellow(`@${user.username}`), - email: () => chalk.green(user.email), - displayName: () => chalk.blue(user.displayName), - isAdmin: () => chalk.red(user.isAdmin ? "Yes" : "No"), - instance: () => - chalk.blue( - user.instance ? user.instance.base_url : "Local" - ), - createdAt: () => chalk.blue(user.createdAt?.toISOString()), - id: () => chalk.blue(user.id), - }; - - // Only keep the fields specified if --fields is provided - if (args.fields) { - const keys = Object.keys(data); - for (const key of keys) { - if (!args.fields.includes(key)) { - // @ts-expect-error This is fine - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete data[key]; - } - } - } - - table.push(Object.values(data).map(fn => fn())); - } - - console.log(table.toString()); - - return 0; - }, - "Lists all users", - "bun cli user list" - ), - new CliCommand<{ - query: string; - fields: string[]; - format: string; - help: boolean; - "case-sensitive": boolean; - limit: number; - redact: boolean; - }>( - ["user", "search"], - [ - { - name: "query", - type: CliParameterType.STRING, - description: "Query to search for", - needsValue: true, - positioned: true, - }, - { - name: "fields", - type: CliParameterType.ARRAY, - description: "Fields to search in", - needsValue: true, - positioned: false, - }, - { - name: "format", - type: CliParameterType.STRING, - description: "Output format (can be json or csv)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - { - name: "case-sensitive", - shortName: "c", - type: CliParameterType.EMPTY, - description: "Case-sensitive search", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "limit", - type: CliParameterType.NUMBER, - description: "Limit the number of users to list (default 20)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "redact", - type: CliParameterType.BOOLEAN, - description: - "Redact sensitive information (such as password hashes, emails or keys)", - needsValue: false, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { - query, - fields = [], - help, - limit = 20, - "case-sensitive": caseSensitive = false, - redact, - } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!query) { - console.log(`${chalk.red(`✗`)} Missing query parameter`); - return 1; - } - - if (fields.length === 0) { - console.log(`${chalk.red(`✗`)} Missing fields parameter`); - return 1; - } - - const queries: Prisma.UserWhereInput[] = []; - - for (const field of fields) { - queries.push({ - [field]: { - contains: query, - mode: caseSensitive ? "default" : "insensitive", - }, - }); - } - - const users = await client.user.findMany({ - where: { - OR: queries, - }, - include: { - instance: true, - }, - take: limit, - }); - - if (redact) { - for (const user of users) { - user.email = "[REDACTED]"; - user.password = "[REDACTED]"; - user.publicKey = "[REDACTED]"; - user.privateKey = "[REDACTED]"; - } - } - - if (args.format === "json") { - console.log(JSON.stringify(users, null, 4)); - return 0; - } else if (args.format === "csv") { - const parser = new Parser({}); - console.log(parser.parse(users)); - return 0; - } - - console.log( - `${chalk.green(`✓`)} Found ${chalk.blue(users.length)} users (limit ${limit})` - ); - - const table = new Table({ - head: [ - chalk.white(chalk.bold("Username")), - chalk.white(chalk.bold("Email")), - chalk.white(chalk.bold("Display Name")), - chalk.white(chalk.bold("Admin?")), - chalk.white(chalk.bold("Instance URL")), - ], - }); - - for (const user of users) { - table.push([ - chalk.yellow(`@${user.username}`), - chalk.green(user.email), - chalk.blue(user.displayName), - chalk.red(user.isAdmin ? "Yes" : "No"), - chalk.blue( - user.instanceId ? user.instance?.base_url : "Local" - ), - ]); - } - - console.log(table.toString()); - - return 0; - }, - "Searches for a user", - "bun cli user search bob --fields email,username" - ), - - new CliCommand<{ - username: string; - "issuer-id": string; - "server-id": string; - help: boolean; - }>( - ["user", "oidc", "connect"], - [ - { - name: "username", - type: CliParameterType.STRING, - description: "Username of the local account", - needsValue: true, - positioned: true, - }, - { - name: "issuer-id", - type: CliParameterType.STRING, - description: "ID of the OpenID Connect issuer in config", - needsValue: true, - positioned: false, - }, - { - name: "server-id", - type: CliParameterType.STRING, - description: "ID of the user on the OpenID Connect server", - needsValue: true, - positioned: false, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - ], - async (instance: CliCommand, args) => { - const { - username, - "issuer-id": issuerId, - "server-id": serverId, - help, - } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!username || !issuerId || !serverId) { - console.log(`${chalk.red(`✗`)} Missing username, issuer or ID`); - return 1; - } - - // Check if issuerId is valid - if (!config.oidc.providers.find(p => p.id === issuerId)) { - console.log(`${chalk.red(`✗`)} Invalid issuer ID`); - return 1; - } - - const user = await client.user.findFirst({ - where: { - username: username, - }, - include: { - linkedOpenIdAccounts: true, - }, - }); - - if (!user) { - console.log(`${chalk.red(`✗`)} User not found`); - return 1; - } - - if (user.linkedOpenIdAccounts.find(a => a.issuerId === issuerId)) { - console.log( - `${chalk.red(`✗`)} User ${chalk.blue( - user.username - )} is already connected to this OpenID Connect issuer with another account` - ); - return 1; - } - - // Connect the OpenID account - await client.user.update({ - where: { - id: user.id, - }, - data: { - linkedOpenIdAccounts: { - create: { - issuerId: issuerId, - serverId: serverId, - }, - }, - }, - }); - - console.log( - `${chalk.green(`✓`)} Connected OpenID Connect account to user ${chalk.blue( - user.username - )}` - ); - - return 0; - }, - "Connects an OpenID Connect account to a local account", - "bun cli user oidc connect admin google 123456789" - ), - new CliCommand<{ - "server-id": string; - help: boolean; - }>( - ["user", "oidc", "disconnect"], - [ - { - name: "server-id", - type: CliParameterType.STRING, - description: "Server ID of the OpenID Connect account", - needsValue: true, - positioned: true, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - ], - async (instance: CliCommand, args) => { - const { "server-id": id, help } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!id) { - console.log(`${chalk.red(`✗`)} Missing ID`); - return 1; - } - - const account = await client.openIdAccount.findFirst({ - where: { - serverId: id, - }, - include: { - User: true, - }, - }); - - if (!account) { - console.log(`${chalk.red(`✗`)} Account not found`); - return 1; - } - - await client.openIdAccount.delete({ - where: { - id: account.id, - }, - }); - - console.log( - `${chalk.green(`✓`)} Disconnected OpenID account from user ${chalk.blue(account.User?.username)}` - ); - - return 0; - }, - "Disconnects an OpenID Connect account from a local account", - "bun cli user oidc disconnect 123456789" - ), - new CliCommand<{ - id: string; - help: boolean; - noconfirm: boolean; - }>( - ["note", "delete"], - [ - { - name: "id", - type: CliParameterType.STRING, - description: "ID of the note", - needsValue: true, - positioned: true, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - { - name: "noconfirm", - shortName: "y", - type: CliParameterType.EMPTY, - description: "Skip confirmation", - needsValue: false, - positioned: false, - }, - ], - async (instance: CliCommand, args) => { - const { id, help } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!id) { - console.log(`${chalk.red(`✗`)} Missing ID`); - return 1; - } - - const note = await client.status.findFirst({ - where: { - id: id, - }, - }); - - if (!note) { - console.log(`${chalk.red(`✗`)} Note not found`); - return 1; - } - - if (!args.noconfirm) { - process.stdout.write( - `Are you sure you want to delete note ${chalk.blue( - note.id - )}?\n${chalk.red(chalk.bold("This is a destructive action and cannot be undone!"))} [y/N] ` - ); - - for await (const line of console) { - if (line.trim().toLowerCase() === "y") { - break; - } else { - console.log(`${chalk.red(`✗`)} Deletion cancelled`); - return 0; - } - } - } - - await client.status.delete({ - where: { - id: note.id, - }, - }); - - console.log( - `${chalk.green(`✓`)} Deleted note ${chalk.blue(note.id)}` - ); - - return 0; - }, - "Deletes a note", - "bun cli note delete 018c1838-6e0b-73c4-a157-a91ea4e25d1d" - ), - new CliCommand<{ - query: string; - fields: string[]; - local: boolean; - remote: boolean; - format: string; - help: boolean; - "case-sensitive": boolean; - limit: number; - redact: boolean; - }>( - ["note", "search"], - [ - { - name: "query", - type: CliParameterType.STRING, - description: "Query to search for", - needsValue: true, - positioned: true, - }, - { - name: "fields", - type: CliParameterType.ARRAY, - description: "Fields to search in", - needsValue: true, - positioned: false, - }, - { - name: "local", - type: CliParameterType.BOOLEAN, - description: "Only search in local statuses", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "remote", - type: CliParameterType.BOOLEAN, - description: "Only search in remote statuses", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "format", - type: CliParameterType.STRING, - description: "Output format (can be json or csv)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - { - name: "case-sensitive", - shortName: "c", - type: CliParameterType.EMPTY, - description: "Case-sensitive search", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "limit", - type: CliParameterType.NUMBER, - description: "Limit the number of notes to list (default 20)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "redact", - type: CliParameterType.BOOLEAN, - description: - "Redact sensitive information (such as password hashes, emails or keys)", - needsValue: false, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { - query, - local, - remote, - format, - help, - limit = 20, - fields = [], - "case-sensitive": caseSensitive = false, - redact, - } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!query) { - console.log(`${chalk.red(`✗`)} Missing query parameter`); - return 1; - } - - if (fields.length === 0) { - console.log(`${chalk.red(`✗`)} Missing fields parameter`); - return 1; - } - - const queries: Prisma.StatusWhereInput[] = []; - - for (const field of fields) { - queries.push({ - [field]: { - contains: query, - mode: caseSensitive ? "default" : "insensitive", - }, - }); - } - - let instanceIdQuery; - - if (local && remote) { - instanceIdQuery = undefined; - } else if (local) { - instanceIdQuery = null; - } else if (remote) { - instanceIdQuery = { - not: null, - }; - } else { - instanceIdQuery = undefined; - } - - const notes = await client.status.findMany({ - where: { - OR: queries, - instanceId: instanceIdQuery, - }, - include: { - author: true, - instance: true, - }, - take: limit, - }); - - if (redact) { - for (const note of notes) { - note.author.email = "[REDACTED]"; - note.author.password = "[REDACTED]"; - note.author.publicKey = "[REDACTED]"; - note.author.privateKey = "[REDACTED]"; - } - } - - if (format === "json") { - console.log(JSON.stringify(notes, null, 4)); - return 0; - } else if (format === "csv") { - const parser = new Parser({}); - console.log(parser.parse(notes)); - return 0; - } - - console.log( - `${chalk.green(`✓`)} Found ${chalk.blue(notes.length)} notes (limit ${limit})` - ); - - const table = new Table({ - head: [ - chalk.white(chalk.bold("ID")), - chalk.white(chalk.bold("Content")), - chalk.white(chalk.bold("Author")), - chalk.white(chalk.bold("Instance")), - chalk.white(chalk.bold("Created At")), - ], - }); - - for (const note of notes) { - table.push([ - chalk.yellow(note.id), - chalk.green(note.content), - chalk.blue(note.author.username), - chalk.red( - note.instanceId ? note.instance?.base_url : "Yes" - ), - chalk.blue(note.createdAt.toISOString()), - ]); - } - - console.log(table.toString()); - - return 0; - }, - "Searches for a status", - "bun cli note search hello --fields content --local" - ), - new CliCommand<{ - help: boolean; - type: string[]; - }>( - ["index", "rebuild"], - [ - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - { - name: "type", - type: CliParameterType.ARRAY, - description: - "Type(s) of index(es) to rebuild (can be accounts or statuses)", - needsValue: true, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { help, type = [] } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - // Check if Meilisearch is enabled - if (!config.meilisearch.enabled) { - console.log(`${chalk.red(`✗`)} Meilisearch is not enabled`); - return 1; - } - - // Check type validity - for (const _type of type) { - if ( - !Object.values(MeiliIndexType).includes( - _type as MeiliIndexType - ) - ) { - console.log( - `${chalk.red(`✗`)} Invalid index type ${chalk.blue(_type)}` - ); - return 1; - } - } - - if (type.length === 0) { - // Rebuild all indexes - await rebuildSearchIndexes(Object.values(MeiliIndexType)); - } else { - await rebuildSearchIndexes(type as MeiliIndexType[]); - } - - console.log(`${chalk.green(`✓`)} Rebuilt search indexes`); - - return 0; - }, - "Rebuilds the Meilisearch indexes", - "bun cli index rebuild" - ), - new CliCommand<{ - help: boolean; - shortcode: string; - url: string; - "keep-url": boolean; - }>( - ["emoji", "add"], - [ - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "shortcode", - type: CliParameterType.STRING, - description: "Shortcode of the new emoji", - needsValue: true, - positioned: true, - }, - { - name: "url", - type: CliParameterType.STRING, - description: "URL of the new emoji", - needsValue: true, - positioned: true, - }, - { - name: "keep-url", - type: CliParameterType.BOOLEAN, - description: - "Keep the URL of the emoji instead of uploading the file to object storage", - needsValue: false, - positioned: false, - }, - ], - async (instance: CliCommand, args) => { - const { help, shortcode, url } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!shortcode) { - console.log(`${chalk.red(`✗`)} Missing shortcode`); - return 1; - } - if (!url) { - console.log(`${chalk.red(`✗`)} Missing URL`); - return 1; - } - - // Check if shortcode is valid - if (!shortcode.match(/^[a-zA-Z0-9-_]+$/)) { - console.log( - `${chalk.red(`✗`)} Invalid shortcode (must be alphanumeric with dashes and underscores allowed)` - ); - return 1; - } - - // Check if URL is valid - if (!URL.canParse(url)) { - console.log( - `${chalk.red(`✗`)} Invalid URL (must be a valid full URL, including protocol)` - ); - return 1; - } - - // Check if emoji already exists - const existingEmoji = await client.emoji.findFirst({ - where: { - shortcode: shortcode, - instanceId: null, - }, - }); - - if (existingEmoji) { - console.log( - `${chalk.red(`✗`)} Emoji with shortcode ${chalk.blue( - shortcode - )} already exists` - ); - return 1; - } - - let newUrl = url; - - if (!args["keep-url"]) { - // Upload the emoji to object storage - const mediaBackend = await MediaBackend.fromBackendType( - config.media.backend, - config - ); - - console.log( - `${chalk.blue(`⏳`)} Downloading emoji from ${chalk.underline(chalk.blue(url))}` - ); - - const downloadedFile = await fetch(url).then( - async r => - new File( - [await r.blob()], - url.split("/").pop() ?? - `${crypto.randomUUID()}-emoji.png` - ) - ); - - const metadata = await mediaBackend - .addFile(downloadedFile) - .catch(() => null); - - if (!metadata) { - console.log( - `${chalk.red(`✗`)} Failed to upload emoji to object storage (is your URL accessible?)` - ); - return 1; - } - - newUrl = getUrl(metadata.uploadedFile.name, config); - - console.log( - `${chalk.green(`✓`)} Uploaded emoji to object storage` - ); - } - - // Add the emoji - const content_type = `image/${url - .split(".") - .pop() - ?.replace("jpg", "jpeg")}}`; - - const emoji = await client.emoji.create({ - data: { - shortcode: shortcode, - url: newUrl, - visible_in_picker: true, - content_type: content_type, - instanceId: null, - }, - }); - - console.log( - `${chalk.green(`✓`)} Created emoji ${chalk.blue( - emoji.shortcode - )}` - ); - - return 0; - }, - "Adds a custom emoji", - "bun cli emoji add bun https://bun.com/bun.png" - ), - new CliCommand<{ - help: boolean; - shortcode: string; - noconfirm: boolean; - }>( - ["emoji", "delete"], - [ - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "shortcode", - type: CliParameterType.STRING, - description: - "Shortcode of the emoji to delete (can add up to two wildcards *)", - needsValue: true, - positioned: true, - }, - { - name: "noconfirm", - type: CliParameterType.BOOLEAN, - description: "Skip confirmation", - needsValue: false, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { help, shortcode, noconfirm } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!shortcode) { - console.log(`${chalk.red(`✗`)} Missing shortcode`); - return 1; - } - - // Check if shortcode is valid - if (!shortcode.match(/^[a-zA-Z0-9-_*]+$/)) { - console.log( - `${chalk.red(`✗`)} Invalid shortcode (must be alphanumeric with dashes and underscores allowed + optional wildcards)` - ); - return 1; - } - - // Validate up to one wildcard - if (shortcode.split("*").length > 3) { - console.log( - `${chalk.red(`✗`)} Invalid shortcode (can only have up to two wildcards)` - ); - return 1; - } - - const hasWildcard = shortcode.includes("*"); - const hasTwoWildcards = shortcode.split("*").length === 3; - - const emojis = await client.emoji.findMany({ - where: { - shortcode: { - startsWith: hasWildcard - ? shortcode.split("*")[0] - : undefined, - endsWith: hasWildcard - ? shortcode.split("*").at(-1) - : undefined, - contains: hasTwoWildcards - ? shortcode.split("*")[1] - : undefined, - equals: hasWildcard ? undefined : shortcode, - }, - instanceId: null, - }, - }); - - if (emojis.length === 0) { - console.log( - `${chalk.red(`✗`)} No emoji with shortcode ${chalk.blue( - shortcode - )} found` - ); - return 1; - } - - // List emojis and ask for confirmation - for (const emoji of emojis) { - console.log( - `${chalk.blue(emoji.shortcode)}: ${chalk.underline( - emoji.url - )}` - ); - } - - if (!noconfirm) { - process.stdout.write( - `Are you sure you want to delete these emojis?\n${chalk.red(chalk.bold("This is a destructive action and cannot be undone!"))} [y/N] ` - ); - - for await (const line of console) { - if (line.trim().toLowerCase() === "y") { - break; - } else { - console.log(`${chalk.red(`✗`)} Deletion cancelled`); - return 0; - } - } - } - - await client.emoji.deleteMany({ - where: { - id: { - in: emojis.map(e => e.id), - }, - }, - }); - - console.log( - `${chalk.green(`✓`)} Deleted emojis matching shortcode ${chalk.blue( - shortcode - )}` - ); - - return 0; - }, - "Deletes custom emojis", - "bun cli emoji delete bun" - ), - new CliCommand<{ - help: boolean; - format: string; - limit: number; - }>( - ["emoji", "list"], - [ - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "format", - type: CliParameterType.STRING, - description: "Output format (can be json or csv)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "limit", - type: CliParameterType.NUMBER, - description: "Limit the number of emojis to list (default 20)", - needsValue: true, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { help, format, limit = 20 } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - const emojis = await client.emoji.findMany({ - where: { - instanceId: null, - }, - take: limit, - }); - - if (format === "json") { - console.log(JSON.stringify(emojis, null, 4)); - return 0; - } else if (format === "csv") { - const parser = new Parser({}); - console.log(parser.parse(emojis)); - return 0; - } - - console.log( - `${chalk.green(`✓`)} Found ${chalk.blue(emojis.length)} emojis (limit ${limit})` - ); - - const table = new Table({ - head: [ - chalk.white(chalk.bold("Shortcode")), - chalk.white(chalk.bold("URL")), - ], - }); - - for (const emoji of emojis) { - table.push([ - chalk.blue(emoji.shortcode), - chalk.underline(emoji.url), - ]); - } - - console.log(table.toString()); - - return 0; - }, - "Lists all custom emojis", - "bun cli emoji list" - ), - new CliCommand<{ - help: boolean; - url: string; - noconfirm: boolean; - }>( - ["emoji", "import"], - [ - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "url", - type: CliParameterType.STRING, - description: "URL of the emoji pack manifest", - needsValue: true, - positioned: true, - }, - { - name: "noconfirm", - type: CliParameterType.BOOLEAN, - description: "Skip confirmation", - needsValue: false, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { help, url, noconfirm } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!url) { - console.log(`${chalk.red(`✗`)} Missing URL`); - return 1; - } - - // Check if URL is valid - if (!URL.canParse(url)) { - console.log( - `${chalk.red(`✗`)} Invalid URL (must be a valid full URL, including protocol)` - ); - return 1; - } - - // Fetch the emoji pack manifest - const manifest = await fetch(url) - .then( - r => - r.json() as Promise< - Record< - string, - { - files: string; - homepage: string; - src: string; - src_sha256?: string; - } - > - > - ) - .catch(() => null); - - if (!manifest) { - console.log( - `${chalk.red(`✗`)} Failed to fetch emoji pack manifest from ${chalk.underline( - url - )}` - ); - return 1; - } - - const homepage = Object.values(manifest)[0].homepage; - // If URL is not a valid URL, assume it's a relative path to homepage - const srcUrl = URL.canParse(Object.values(manifest)[0].src) - ? Object.values(manifest)[0].src - : new URL(Object.values(manifest)[0].src, homepage).toString(); - const filesUrl = URL.canParse(Object.values(manifest)[0].files) - ? Object.values(manifest)[0].files - : new URL( - Object.values(manifest)[0].files, - homepage - ).toString(); - - console.log( - `${chalk.blue(`⏳`)} Fetching emoji pack from ${chalk.underline( - srcUrl - )}` - ); - - // Fetch actual pack (should be a zip file) - const pack = await fetch(srcUrl) - .then( - async r => - new File( - [await r.blob()], - srcUrl.split("/").pop() ?? "pack.zip" - ) - ) - .catch(() => null); - - // Check if pack is valid - if (!pack) { - console.log( - `${chalk.red(`✗`)} Failed to fetch emoji pack from ${chalk.underline( - srcUrl - )}` - ); - return 1; - } - - // Validate sha256 if available - if (Object.values(manifest)[0].src_sha256) { - const sha256 = new Bun.SHA256() - .update(await pack.arrayBuffer()) - .digest("hex"); - if (sha256 !== Object.values(manifest)[0].src_sha256) { - console.log( - `${chalk.red(`✗`)} SHA256 of pack (${chalk.blue( - sha256 - )}) does not match manifest ${chalk.blue( - Object.values(manifest)[0].src_sha256 - )}` - ); - return 1; - } else { - console.log( - `${chalk.green(`✓`)} SHA256 of pack matches manifest` - ); - } - } else { - console.log( - `${chalk.yellow(`⚠`)} No SHA256 in manifest, skipping validation` - ); - } - - console.log( - `${chalk.green(`✓`)} Fetched emoji pack from ${chalk.underline(srcUrl)}, unzipping to tempdir` - ); - - // Unzip the pack to temp dir - const tempDir = await mkdtemp(join(tmpdir(), "bun-emoji-import-")); - - console.log(join(tempDir, pack.name)); - - // Put the pack as a file - await Bun.write(join(tempDir, pack.name), pack); - - await extract(join(tempDir, pack.name), { - dir: tempDir, - }); - - console.log( - `${chalk.green(`✓`)} Unzipped emoji pack to ${chalk.blue(tempDir)}` - ); - - console.log( - `${chalk.blue(`⏳`)} Fetching emoji pack file metadata from ${chalk.underline( - filesUrl - )}` - ); - - // Fetch files URL - const packFiles = await fetch(filesUrl) - .then(r => r.json() as Promise>) - .catch(() => null); - - if (!packFiles) { - console.log( - `${chalk.red(`✗`)} Failed to fetch emoji pack file metadata from ${chalk.underline( - filesUrl - )}` - ); - return 1; - } - - console.log( - `${chalk.green(`✓`)} Fetched emoji pack file metadata from ${chalk.underline( - filesUrl - )}` - ); - - if (Object.keys(packFiles).length === 0) { - console.log(`${chalk.red(`✗`)} Empty emoji pack`); - return 1; - } - - if (!noconfirm) { - process.stdout.write( - `Are you sure you want to import ${chalk.blue( - Object.keys(packFiles).length - )} emojis from ${chalk.underline(chalk.blue(url))}? [y/N] ` - ); - - for await (const line of console) { - if (line.trim().toLowerCase() === "y") { - break; - } else { - console.log(`${chalk.red(`✗`)} Import cancelled`); - return 0; - } - } - } - - const successfullyImported: string[] = []; - - // Add emojis - for (const [shortcode, url] of Object.entries(packFiles)) { - // If emoji URL is not a valid URL, assume it's a relative path to homepage - const fileUrl = Bun.pathToFileURL( - join(tempDir, url) - ).toString(); - - // Check if emoji already exists - const existingEmoji = await client.emoji.findFirst({ - where: { - shortcode: shortcode, - instanceId: null, - }, - }); - - if (existingEmoji) { - console.log( - `${chalk.red(`✗`)} Emoji with shortcode ${chalk.blue( - shortcode - )} already exists` - ); - continue; - } - - // Add the emoji by calling the add command - const returnCode = await cliBuilder.processArgs([ - "emoji", - "add", - shortcode, - fileUrl, - "--noconfirm", - ]); - - if (returnCode === 0) successfullyImported.push(shortcode); - } - - console.log( - `${chalk.green(`✓`)} Imported ${successfullyImported.length} emojis from ${chalk.underline( - url - )}` - ); - - // List imported - if (successfullyImported.length > 0) { - console.log( - `${chalk.green(`✓`)} Successfully imported ${successfullyImported.length} emojis: ${successfullyImported.join( - ", " - )}` - ); - } - - // List unimported - if (successfullyImported.length < Object.keys(packFiles).length) { - const unimported = Object.keys(packFiles).filter( - key => !successfullyImported.includes(key) - ); - console.log( - `${chalk.red(`✗`)} Failed to import ${unimported.length} emojis: ${unimported.join( - ", " - )}` - ); - } - - return 0; - }, - "Imports a Pleroma emoji pack", - "bun cli emoji import https://site.com/neofox/manifest.json" - ), + new CliCommand<{ + username: string; + password: string; + email: string; + admin: boolean; + help: boolean; + }>( + ["user", "create"], + [ + { + name: "username", + type: CliParameterType.STRING, + description: "Username of the user", + needsValue: true, + positioned: false, + }, + { + name: "password", + type: CliParameterType.STRING, + description: "Password of the user", + needsValue: true, + positioned: false, + }, + { + name: "email", + type: CliParameterType.STRING, + description: "Email of the user", + needsValue: true, + positioned: false, + }, + { + name: "admin", + type: CliParameterType.BOOLEAN, + description: "Make the user an admin", + needsValue: false, + positioned: false, + }, + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + }, + ], + async (instance: CliCommand, args) => { + const { username, password, email, admin, help } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + // Check if username, password and email are provided + if (!username || !password || !email) { + console.log( + `${chalk.red("✗")} Missing username, password or email`, + ); + return 1; + } + + // Check if user already exists + const user = await client.user.findFirst({ + where: { + OR: [{ username }, { email }], + }, + }); + + if (user) { + if (user.username === username) { + console.log( + `${chalk.red("✗")} User with username ${chalk.blue( + username, + )} already exists`, + ); + } else { + console.log( + `${chalk.red("✗")} User with email ${chalk.blue( + email, + )} already exists`, + ); + } + return 1; + } + + // Create user + const newUser = await createNewLocalUser({ + email: email, + password: password, + username: username, + admin: admin, + }); + + console.log( + `${chalk.green("✓")} Created user ${chalk.blue( + newUser.username, + )}${admin ? chalk.green(" (admin)") : ""}`, + ); + + return 0; + }, + "Creates a new user", + "bun cli user create --username admin --password password123 --email email@email.com", + ), + new CliCommand<{ + username: string; + help: boolean; + noconfirm: boolean; + }>( + ["user", "delete"], + [ + { + name: "username", + type: CliParameterType.STRING, + description: "Username of the user", + needsValue: true, + positioned: true, + }, + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + }, + { + name: "noconfirm", + shortName: "y", + type: CliParameterType.EMPTY, + description: "Skip confirmation", + needsValue: false, + positioned: false, + }, + ], + async (instance: CliCommand, args) => { + const { username, help } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + if (!username) { + console.log(`${chalk.red("✗")} Missing username`); + return 1; + } + + const user = await client.user.findFirst({ + where: { + username: username, + }, + }); + + if (!user) { + console.log(`${chalk.red("✗")} User not found`); + return 1; + } + + if (!args.noconfirm) { + process.stdout.write( + `Are you sure you want to delete user ${chalk.blue( + user.username, + )}?\n${chalk.red( + chalk.bold( + "This is a destructive action and cannot be undone!", + ), + )} [y/N] `, + ); + + for await (const line of console) { + if (line.trim().toLowerCase() === "y") { + break; + } + console.log(`${chalk.red("✗")} Deletion cancelled`); + return 0; + } + } + + await client.user.delete({ + where: { + id: user.id, + }, + }); + + console.log( + `${chalk.green("✓")} Deleted user ${chalk.blue(user.username)}`, + ); + + return 0; + }, + "Deletes a user", + "bun cli user delete --username admin", + ), + new CliCommand<{ + admins: boolean; + help: boolean; + format: string; + limit: number; + redact: boolean; + fields: string[]; + }>( + ["user", "list"], + [ + { + name: "admins", + type: CliParameterType.BOOLEAN, + description: "List only admins", + needsValue: false, + positioned: false, + }, + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + }, + { + name: "format", + type: CliParameterType.STRING, + description: "Output format (can be json or csv)", + needsValue: true, + positioned: false, + optional: true, + }, + { + name: "limit", + type: CliParameterType.NUMBER, + description: + "Limit the number of users to list (defaults to 200)", + needsValue: true, + positioned: false, + optional: true, + }, + { + name: "redact", + type: CliParameterType.BOOLEAN, + description: + "Redact sensitive information (such as password hashes, emails or keys)", + needsValue: false, + positioned: false, + optional: true, + }, + { + name: "fields", + type: CliParameterType.ARRAY, + description: + "If provided, restricts output to these fields (comma-separated)", + needsValue: true, + positioned: false, + optional: true, + }, + ], + async (instance: CliCommand, args) => { + const { admins, help, fields = [] } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + if (args.format && !["json", "csv"].includes(args.format)) { + console.log(`${chalk.red("✗")} Invalid format`); + return 1; + } + const users = filterObjects( + await client.user.findMany({ + where: { + isAdmin: admins || undefined, + }, + take: args.limit ?? 200, + include: { + instance: + fields.length === 0 + ? true + : fields.includes("instance"), + }, + }), + fields, + ); + + if (args.redact) { + for (const user of users) { + user.email = "[REDACTED]"; + user.password = "[REDACTED]"; + user.publicKey = "[REDACTED]"; + user.privateKey = "[REDACTED]"; + } + } + + if (args.format === "json") { + console.log(JSON.stringify(users, null, 4)); + return 0; + } + if (args.format === "csv") { + const parser = new Parser({}); + console.log(parser.parse(users)); + return 0; + } + + console.log( + `${chalk.green("✓")} Found ${chalk.blue( + users.length, + )} users (limit ${args.limit ?? 200})`, + ); + + const tableHead = filterObjects( + [ + { + username: chalk.white(chalk.bold("Username")), + email: chalk.white(chalk.bold("Email")), + displayName: chalk.white(chalk.bold("Display Name")), + isAdmin: chalk.white(chalk.bold("Admin?")), + instance: chalk.white(chalk.bold("Instance URL")), + createdAt: chalk.white(chalk.bold("Created At")), + id: chalk.white(chalk.bold("Internal UUID")), + }, + ], + fields, + )[0]; + + const table = new Table({ + head: Object.values(tableHead), + }); + + for (const user of users) { + // Print table of users + const data = { + username: () => chalk.yellow(`@${user.username}`), + email: () => chalk.green(user.email), + displayName: () => chalk.blue(user.displayName), + isAdmin: () => chalk.red(user.isAdmin ? "Yes" : "No"), + instance: () => + chalk.blue( + user.instance ? user.instance.base_url : "Local", + ), + createdAt: () => chalk.blue(user.createdAt?.toISOString()), + id: () => chalk.blue(user.id), + }; + + // Only keep the fields specified if --fields is provided + if (args.fields) { + const keys = Object.keys(data); + for (const key of keys) { + if (!args.fields.includes(key)) { + // @ts-expect-error This is fine + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete data[key]; + } + } + } + + table.push(Object.values(data).map((fn) => fn())); + } + + console.log(table.toString()); + + return 0; + }, + "Lists all users", + "bun cli user list", + ), + new CliCommand<{ + query: string; + fields: string[]; + format: string; + help: boolean; + "case-sensitive": boolean; + limit: number; + redact: boolean; + }>( + ["user", "search"], + [ + { + name: "query", + type: CliParameterType.STRING, + description: "Query to search for", + needsValue: true, + positioned: true, + }, + { + name: "fields", + type: CliParameterType.ARRAY, + description: "Fields to search in", + needsValue: true, + positioned: false, + }, + { + name: "format", + type: CliParameterType.STRING, + description: "Output format (can be json or csv)", + needsValue: true, + positioned: false, + optional: true, + }, + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + }, + { + name: "case-sensitive", + shortName: "c", + type: CliParameterType.EMPTY, + description: "Case-sensitive search", + needsValue: false, + positioned: false, + optional: true, + }, + { + name: "limit", + type: CliParameterType.NUMBER, + description: "Limit the number of users to list (default 20)", + needsValue: true, + positioned: false, + optional: true, + }, + { + name: "redact", + type: CliParameterType.BOOLEAN, + description: + "Redact sensitive information (such as password hashes, emails or keys)", + needsValue: false, + positioned: false, + optional: true, + }, + ], + async (instance: CliCommand, args) => { + const { + query, + fields = [], + help, + limit = 20, + "case-sensitive": caseSensitive = false, + redact, + } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + if (!query) { + console.log(`${chalk.red("✗")} Missing query parameter`); + return 1; + } + + if (fields.length === 0) { + console.log(`${chalk.red("✗")} Missing fields parameter`); + return 1; + } + + const queries: Prisma.UserWhereInput[] = []; + + for (const field of fields) { + queries.push({ + [field]: { + contains: query, + mode: caseSensitive ? "default" : "insensitive", + }, + }); + } + + const users = await client.user.findMany({ + where: { + OR: queries, + }, + include: { + instance: true, + }, + take: limit, + }); + + if (redact) { + for (const user of users) { + user.email = "[REDACTED]"; + user.password = "[REDACTED]"; + user.publicKey = "[REDACTED]"; + user.privateKey = "[REDACTED]"; + } + } + + if (args.format === "json") { + console.log(JSON.stringify(users, null, 4)); + return 0; + } + if (args.format === "csv") { + const parser = new Parser({}); + console.log(parser.parse(users)); + return 0; + } + + console.log( + `${chalk.green("✓")} Found ${chalk.blue( + users.length, + )} users (limit ${limit})`, + ); + + const table = new Table({ + head: [ + chalk.white(chalk.bold("Username")), + chalk.white(chalk.bold("Email")), + chalk.white(chalk.bold("Display Name")), + chalk.white(chalk.bold("Admin?")), + chalk.white(chalk.bold("Instance URL")), + ], + }); + + for (const user of users) { + table.push([ + chalk.yellow(`@${user.username}`), + chalk.green(user.email), + chalk.blue(user.displayName), + chalk.red(user.isAdmin ? "Yes" : "No"), + chalk.blue( + user.instanceId ? user.instance?.base_url : "Local", + ), + ]); + } + + console.log(table.toString()); + + return 0; + }, + "Searches for a user", + "bun cli user search bob --fields email,username", + ), + + new CliCommand<{ + username: string; + "issuer-id": string; + "server-id": string; + help: boolean; + }>( + ["user", "oidc", "connect"], + [ + { + name: "username", + type: CliParameterType.STRING, + description: "Username of the local account", + needsValue: true, + positioned: true, + }, + { + name: "issuer-id", + type: CliParameterType.STRING, + description: "ID of the OpenID Connect issuer in config", + needsValue: true, + positioned: false, + }, + { + name: "server-id", + type: CliParameterType.STRING, + description: "ID of the user on the OpenID Connect server", + needsValue: true, + positioned: false, + }, + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + }, + ], + async (instance: CliCommand, args) => { + const { + username, + "issuer-id": issuerId, + "server-id": serverId, + help, + } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + if (!username || !issuerId || !serverId) { + console.log(`${chalk.red("✗")} Missing username, issuer or ID`); + return 1; + } + + // Check if issuerId is valid + if (!config.oidc.providers.find((p) => p.id === issuerId)) { + console.log(`${chalk.red("✗")} Invalid issuer ID`); + return 1; + } + + const user = await client.user.findFirst({ + where: { + username: username, + }, + include: { + linkedOpenIdAccounts: true, + }, + }); + + if (!user) { + console.log(`${chalk.red("✗")} User not found`); + return 1; + } + + if ( + user.linkedOpenIdAccounts.find((a) => a.issuerId === issuerId) + ) { + console.log( + `${chalk.red("✗")} User ${chalk.blue( + user.username, + )} is already connected to this OpenID Connect issuer with another account`, + ); + return 1; + } + + // Connect the OpenID account + await client.user.update({ + where: { + id: user.id, + }, + data: { + linkedOpenIdAccounts: { + create: { + issuerId: issuerId, + serverId: serverId, + }, + }, + }, + }); + + console.log( + `${chalk.green( + "✓", + )} Connected OpenID Connect account to user ${chalk.blue( + user.username, + )}`, + ); + + return 0; + }, + "Connects an OpenID Connect account to a local account", + "bun cli user oidc connect admin google 123456789", + ), + new CliCommand<{ + "server-id": string; + help: boolean; + }>( + ["user", "oidc", "disconnect"], + [ + { + name: "server-id", + type: CliParameterType.STRING, + description: "Server ID of the OpenID Connect account", + needsValue: true, + positioned: true, + }, + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + }, + ], + async (instance: CliCommand, args) => { + const { "server-id": id, help } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + if (!id) { + console.log(`${chalk.red("✗")} Missing ID`); + return 1; + } + + const account = await client.openIdAccount.findFirst({ + where: { + serverId: id, + }, + include: { + User: true, + }, + }); + + if (!account) { + console.log(`${chalk.red("✗")} Account not found`); + return 1; + } + + await client.openIdAccount.delete({ + where: { + id: account.id, + }, + }); + + console.log( + `${chalk.green( + "✓", + )} Disconnected OpenID account from user ${chalk.blue( + account.User?.username, + )}`, + ); + + return 0; + }, + "Disconnects an OpenID Connect account from a local account", + "bun cli user oidc disconnect 123456789", + ), + new CliCommand<{ + id: string; + help: boolean; + noconfirm: boolean; + }>( + ["note", "delete"], + [ + { + name: "id", + type: CliParameterType.STRING, + description: "ID of the note", + needsValue: true, + positioned: true, + }, + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + }, + { + name: "noconfirm", + shortName: "y", + type: CliParameterType.EMPTY, + description: "Skip confirmation", + needsValue: false, + positioned: false, + }, + ], + async (instance: CliCommand, args) => { + const { id, help } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + if (!id) { + console.log(`${chalk.red("✗")} Missing ID`); + return 1; + } + + const note = await client.status.findFirst({ + where: { + id: id, + }, + }); + + if (!note) { + console.log(`${chalk.red("✗")} Note not found`); + return 1; + } + + if (!args.noconfirm) { + process.stdout.write( + `Are you sure you want to delete note ${chalk.blue( + note.id, + )}?\n${chalk.red( + chalk.bold( + "This is a destructive action and cannot be undone!", + ), + )} [y/N] `, + ); + + for await (const line of console) { + if (line.trim().toLowerCase() === "y") { + break; + } + console.log(`${chalk.red("✗")} Deletion cancelled`); + return 0; + } + } + + await client.status.delete({ + where: { + id: note.id, + }, + }); + + console.log( + `${chalk.green("✓")} Deleted note ${chalk.blue(note.id)}`, + ); + + return 0; + }, + "Deletes a note", + "bun cli note delete 018c1838-6e0b-73c4-a157-a91ea4e25d1d", + ), + new CliCommand<{ + query: string; + fields: string[]; + local: boolean; + remote: boolean; + format: string; + help: boolean; + "case-sensitive": boolean; + limit: number; + redact: boolean; + }>( + ["note", "search"], + [ + { + name: "query", + type: CliParameterType.STRING, + description: "Query to search for", + needsValue: true, + positioned: true, + }, + { + name: "fields", + type: CliParameterType.ARRAY, + description: "Fields to search in", + needsValue: true, + positioned: false, + }, + { + name: "local", + type: CliParameterType.BOOLEAN, + description: "Only search in local statuses", + needsValue: false, + positioned: false, + optional: true, + }, + { + name: "remote", + type: CliParameterType.BOOLEAN, + description: "Only search in remote statuses", + needsValue: false, + positioned: false, + optional: true, + }, + { + name: "format", + type: CliParameterType.STRING, + description: "Output format (can be json or csv)", + needsValue: true, + positioned: false, + optional: true, + }, + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + }, + { + name: "case-sensitive", + shortName: "c", + type: CliParameterType.EMPTY, + description: "Case-sensitive search", + needsValue: false, + positioned: false, + optional: true, + }, + { + name: "limit", + type: CliParameterType.NUMBER, + description: "Limit the number of notes to list (default 20)", + needsValue: true, + positioned: false, + optional: true, + }, + { + name: "redact", + type: CliParameterType.BOOLEAN, + description: + "Redact sensitive information (such as password hashes, emails or keys)", + needsValue: false, + positioned: false, + optional: true, + }, + ], + async (instance: CliCommand, args) => { + const { + query, + local, + remote, + format, + help, + limit = 20, + fields = [], + "case-sensitive": caseSensitive = false, + redact, + } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + if (!query) { + console.log(`${chalk.red("✗")} Missing query parameter`); + return 1; + } + + if (fields.length === 0) { + console.log(`${chalk.red("✗")} Missing fields parameter`); + return 1; + } + + const queries: Prisma.StatusWhereInput[] = []; + + for (const field of fields) { + queries.push({ + [field]: { + contains: query, + mode: caseSensitive ? "default" : "insensitive", + }, + }); + } + + let instanceIdQuery: Prisma.StatusWhereInput["instanceId"]; + + if (local && remote) { + instanceIdQuery = undefined; + } else if (local) { + instanceIdQuery = null; + } else if (remote) { + instanceIdQuery = { + not: null, + }; + } else { + instanceIdQuery = undefined; + } + + const notes = await client.status.findMany({ + where: { + OR: queries, + instanceId: instanceIdQuery, + }, + include: { + author: true, + instance: true, + }, + take: limit, + }); + + if (redact) { + for (const note of notes) { + note.author.email = "[REDACTED]"; + note.author.password = "[REDACTED]"; + note.author.publicKey = "[REDACTED]"; + note.author.privateKey = "[REDACTED]"; + } + } + + if (format === "json") { + console.log(JSON.stringify(notes, null, 4)); + return 0; + } + if (format === "csv") { + const parser = new Parser({}); + console.log(parser.parse(notes)); + return 0; + } + + console.log( + `${chalk.green("✓")} Found ${chalk.blue( + notes.length, + )} notes (limit ${limit})`, + ); + + const table = new Table({ + head: [ + chalk.white(chalk.bold("ID")), + chalk.white(chalk.bold("Content")), + chalk.white(chalk.bold("Author")), + chalk.white(chalk.bold("Instance")), + chalk.white(chalk.bold("Created At")), + ], + }); + + for (const note of notes) { + table.push([ + chalk.yellow(note.id), + chalk.green(note.content), + chalk.blue(note.author.username), + chalk.red( + note.instanceId ? note.instance?.base_url : "Yes", + ), + chalk.blue(note.createdAt.toISOString()), + ]); + } + + console.log(table.toString()); + + return 0; + }, + "Searches for a status", + "bun cli note search hello --fields content --local", + ), + new CliCommand<{ + help: boolean; + type: string[]; + }>( + ["index", "rebuild"], + [ + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + }, + { + name: "type", + type: CliParameterType.ARRAY, + description: + "Type(s) of index(es) to rebuild (can be accounts or statuses)", + needsValue: true, + positioned: false, + optional: true, + }, + ], + async (instance: CliCommand, args) => { + const { help, type = [] } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + // Check if Meilisearch is enabled + if (!config.meilisearch.enabled) { + console.log(`${chalk.red("✗")} Meilisearch is not enabled`); + return 1; + } + + // Check type validity + for (const _type of type) { + if ( + !Object.values(MeiliIndexType).includes( + _type as MeiliIndexType, + ) + ) { + console.log( + `${chalk.red("✗")} Invalid index type ${chalk.blue( + _type, + )}`, + ); + return 1; + } + } + + if (type.length === 0) { + // Rebuild all indexes + await rebuildSearchIndexes(Object.values(MeiliIndexType)); + } else { + await rebuildSearchIndexes(type as MeiliIndexType[]); + } + + console.log(`${chalk.green("✓")} Rebuilt search indexes`); + + return 0; + }, + "Rebuilds the Meilisearch indexes", + "bun cli index rebuild", + ), + new CliCommand<{ + help: boolean; + shortcode: string; + url: string; + "keep-url": boolean; + }>( + ["emoji", "add"], + [ + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + optional: true, + }, + { + name: "shortcode", + type: CliParameterType.STRING, + description: "Shortcode of the new emoji", + needsValue: true, + positioned: true, + }, + { + name: "url", + type: CliParameterType.STRING, + description: "URL of the new emoji", + needsValue: true, + positioned: true, + }, + { + name: "keep-url", + type: CliParameterType.BOOLEAN, + description: + "Keep the URL of the emoji instead of uploading the file to object storage", + needsValue: false, + positioned: false, + }, + ], + async (instance: CliCommand, args) => { + const { help, shortcode, url } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + if (!shortcode) { + console.log(`${chalk.red("✗")} Missing shortcode`); + return 1; + } + if (!url) { + console.log(`${chalk.red("✗")} Missing URL`); + return 1; + } + + // Check if shortcode is valid + if (!shortcode.match(/^[a-zA-Z0-9-_]+$/)) { + console.log( + `${chalk.red( + "✗", + )} Invalid shortcode (must be alphanumeric with dashes and underscores allowed)`, + ); + return 1; + } + + // Check if URL is valid + if (!URL.canParse(url)) { + console.log( + `${chalk.red( + "✗", + )} Invalid URL (must be a valid full URL, including protocol)`, + ); + return 1; + } + + // Check if emoji already exists + const existingEmoji = await client.emoji.findFirst({ + where: { + shortcode: shortcode, + instanceId: null, + }, + }); + + if (existingEmoji) { + console.log( + `${chalk.red("✗")} Emoji with shortcode ${chalk.blue( + shortcode, + )} already exists`, + ); + return 1; + } + + let newUrl = url; + + if (!args["keep-url"]) { + // Upload the emoji to object storage + const mediaBackend = await MediaBackend.fromBackendType( + config.media.backend, + config, + ); + + console.log( + `${chalk.blue( + "⏳", + )} Downloading emoji from ${chalk.underline( + chalk.blue(url), + )}`, + ); + + const downloadedFile = await fetch(url).then( + async (r) => + new File( + [await r.blob()], + url.split("/").pop() ?? + `${crypto.randomUUID()}-emoji.png`, + ), + ); + + const metadata = await mediaBackend + .addFile(downloadedFile) + .catch(() => null); + + if (!metadata) { + console.log( + `${chalk.red( + "✗", + )} Failed to upload emoji to object storage (is your URL accessible?)`, + ); + return 1; + } + + newUrl = getUrl(metadata.uploadedFile.name, config); + + console.log( + `${chalk.green("✓")} Uploaded emoji to object storage`, + ); + } + + // Add the emoji + const content_type = `image/${url + .split(".") + .pop() + ?.replace("jpg", "jpeg")}}`; + + const emoji = await client.emoji.create({ + data: { + shortcode: shortcode, + url: newUrl, + visible_in_picker: true, + content_type: content_type, + instanceId: null, + }, + }); + + console.log( + `${chalk.green("✓")} Created emoji ${chalk.blue( + emoji.shortcode, + )}`, + ); + + return 0; + }, + "Adds a custom emoji", + "bun cli emoji add bun https://bun.com/bun.png", + ), + new CliCommand<{ + help: boolean; + shortcode: string; + noconfirm: boolean; + }>( + ["emoji", "delete"], + [ + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + optional: true, + }, + { + name: "shortcode", + type: CliParameterType.STRING, + description: + "Shortcode of the emoji to delete (can add up to two wildcards *)", + needsValue: true, + positioned: true, + }, + { + name: "noconfirm", + type: CliParameterType.BOOLEAN, + description: "Skip confirmation", + needsValue: false, + positioned: false, + optional: true, + }, + ], + async (instance: CliCommand, args) => { + const { help, shortcode, noconfirm } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + if (!shortcode) { + console.log(`${chalk.red("✗")} Missing shortcode`); + return 1; + } + + // Check if shortcode is valid + if (!shortcode.match(/^[a-zA-Z0-9-_*]+$/)) { + console.log( + `${chalk.red( + "✗", + )} Invalid shortcode (must be alphanumeric with dashes and underscores allowed + optional wildcards)`, + ); + return 1; + } + + // Validate up to one wildcard + if (shortcode.split("*").length > 3) { + console.log( + `${chalk.red( + "✗", + )} Invalid shortcode (can only have up to two wildcards)`, + ); + return 1; + } + + const hasWildcard = shortcode.includes("*"); + const hasTwoWildcards = shortcode.split("*").length === 3; + + const emojis = await client.emoji.findMany({ + where: { + shortcode: { + startsWith: hasWildcard + ? shortcode.split("*")[0] + : undefined, + endsWith: hasWildcard + ? shortcode.split("*").at(-1) + : undefined, + contains: hasTwoWildcards + ? shortcode.split("*")[1] + : undefined, + equals: hasWildcard ? undefined : shortcode, + }, + instanceId: null, + }, + }); + + if (emojis.length === 0) { + console.log( + `${chalk.red("✗")} No emoji with shortcode ${chalk.blue( + shortcode, + )} found`, + ); + return 1; + } + + // List emojis and ask for confirmation + for (const emoji of emojis) { + console.log( + `${chalk.blue(emoji.shortcode)}: ${chalk.underline( + emoji.url, + )}`, + ); + } + + if (!noconfirm) { + process.stdout.write( + `Are you sure you want to delete these emojis?\n${chalk.red( + chalk.bold( + "This is a destructive action and cannot be undone!", + ), + )} [y/N] `, + ); + + for await (const line of console) { + if (line.trim().toLowerCase() === "y") { + break; + } + console.log(`${chalk.red("✗")} Deletion cancelled`); + return 0; + } + } + + await client.emoji.deleteMany({ + where: { + id: { + in: emojis.map((e) => e.id), + }, + }, + }); + + console.log( + `${chalk.green( + "✓", + )} Deleted emojis matching shortcode ${chalk.blue(shortcode)}`, + ); + + return 0; + }, + "Deletes custom emojis", + "bun cli emoji delete bun", + ), + new CliCommand<{ + help: boolean; + format: string; + limit: number; + }>( + ["emoji", "list"], + [ + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + optional: true, + }, + { + name: "format", + type: CliParameterType.STRING, + description: "Output format (can be json or csv)", + needsValue: true, + positioned: false, + optional: true, + }, + { + name: "limit", + type: CliParameterType.NUMBER, + description: "Limit the number of emojis to list (default 20)", + needsValue: true, + positioned: false, + optional: true, + }, + ], + async (instance: CliCommand, args) => { + const { help, format, limit = 20 } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + const emojis = await client.emoji.findMany({ + where: { + instanceId: null, + }, + take: limit, + }); + + if (format === "json") { + console.log(JSON.stringify(emojis, null, 4)); + return 0; + } + if (format === "csv") { + const parser = new Parser({}); + console.log(parser.parse(emojis)); + return 0; + } + + console.log( + `${chalk.green("✓")} Found ${chalk.blue( + emojis.length, + )} emojis (limit ${limit})`, + ); + + const table = new Table({ + head: [ + chalk.white(chalk.bold("Shortcode")), + chalk.white(chalk.bold("URL")), + ], + }); + + for (const emoji of emojis) { + table.push([ + chalk.blue(emoji.shortcode), + chalk.underline(emoji.url), + ]); + } + + console.log(table.toString()); + + return 0; + }, + "Lists all custom emojis", + "bun cli emoji list", + ), + new CliCommand<{ + help: boolean; + url: string; + noconfirm: boolean; + }>( + ["emoji", "import"], + [ + { + name: "help", + shortName: "h", + type: CliParameterType.EMPTY, + description: "Show help message", + needsValue: false, + positioned: false, + optional: true, + }, + { + name: "url", + type: CliParameterType.STRING, + description: "URL of the emoji pack manifest", + needsValue: true, + positioned: true, + }, + { + name: "noconfirm", + type: CliParameterType.BOOLEAN, + description: "Skip confirmation", + needsValue: false, + positioned: false, + optional: true, + }, + ], + async (instance: CliCommand, args) => { + const { help, url, noconfirm } = args; + + if (help) { + instance.displayHelp(); + return 0; + } + + if (!url) { + console.log(`${chalk.red("✗")} Missing URL`); + return 1; + } + + // Check if URL is valid + if (!URL.canParse(url)) { + console.log( + `${chalk.red( + "✗", + )} Invalid URL (must be a valid full URL, including protocol)`, + ); + return 1; + } + + // Fetch the emoji pack manifest + const manifest = await fetch(url) + .then( + (r) => + r.json() as Promise< + Record< + string, + { + files: string; + homepage: string; + src: string; + src_sha256?: string; + } + > + >, + ) + .catch(() => null); + + if (!manifest) { + console.log( + `${chalk.red( + "✗", + )} Failed to fetch emoji pack manifest from ${chalk.underline( + url, + )}`, + ); + return 1; + } + + const homepage = Object.values(manifest)[0].homepage; + // If URL is not a valid URL, assume it's a relative path to homepage + const srcUrl = URL.canParse(Object.values(manifest)[0].src) + ? Object.values(manifest)[0].src + : new URL(Object.values(manifest)[0].src, homepage).toString(); + const filesUrl = URL.canParse(Object.values(manifest)[0].files) + ? Object.values(manifest)[0].files + : new URL( + Object.values(manifest)[0].files, + homepage, + ).toString(); + + console.log( + `${chalk.blue("⏳")} Fetching emoji pack from ${chalk.underline( + srcUrl, + )}`, + ); + + // Fetch actual pack (should be a zip file) + const pack = await fetch(srcUrl) + .then( + async (r) => + new File( + [await r.blob()], + srcUrl.split("/").pop() ?? "pack.zip", + ), + ) + .catch(() => null); + + // Check if pack is valid + if (!pack) { + console.log( + `${chalk.red( + "✗", + )} Failed to fetch emoji pack from ${chalk.underline( + srcUrl, + )}`, + ); + return 1; + } + + // Validate sha256 if available + if (Object.values(manifest)[0].src_sha256) { + const sha256 = new Bun.SHA256() + .update(await pack.arrayBuffer()) + .digest("hex"); + if (sha256 !== Object.values(manifest)[0].src_sha256) { + console.log( + `${chalk.red("✗")} SHA256 of pack (${chalk.blue( + sha256, + )}) does not match manifest ${chalk.blue( + Object.values(manifest)[0].src_sha256, + )}`, + ); + return 1; + } + console.log( + `${chalk.green("✓")} SHA256 of pack matches manifest`, + ); + } else { + console.log( + `${chalk.yellow( + "⚠", + )} No SHA256 in manifest, skipping validation`, + ); + } + + console.log( + `${chalk.green("✓")} Fetched emoji pack from ${chalk.underline( + srcUrl, + )}, unzipping to tempdir`, + ); + + // Unzip the pack to temp dir + const tempDir = await mkdtemp(join(tmpdir(), "bun-emoji-import-")); + + console.log(join(tempDir, pack.name)); + + // Put the pack as a file + await Bun.write(join(tempDir, pack.name), pack); + + await extract(join(tempDir, pack.name), { + dir: tempDir, + }); + + console.log( + `${chalk.green("✓")} Unzipped emoji pack to ${chalk.blue( + tempDir, + )}`, + ); + + console.log( + `${chalk.blue( + "⏳", + )} Fetching emoji pack file metadata from ${chalk.underline( + filesUrl, + )}`, + ); + + // Fetch files URL + const packFiles = await fetch(filesUrl) + .then((r) => r.json() as Promise>) + .catch(() => null); + + if (!packFiles) { + console.log( + `${chalk.red( + "✗", + )} Failed to fetch emoji pack file metadata from ${chalk.underline( + filesUrl, + )}`, + ); + return 1; + } + + console.log( + `${chalk.green( + "✓", + )} Fetched emoji pack file metadata from ${chalk.underline( + filesUrl, + )}`, + ); + + if (Object.keys(packFiles).length === 0) { + console.log(`${chalk.red("✗")} Empty emoji pack`); + return 1; + } + + if (!noconfirm) { + process.stdout.write( + `Are you sure you want to import ${chalk.blue( + Object.keys(packFiles).length, + )} emojis from ${chalk.underline(chalk.blue(url))}? [y/N] `, + ); + + for await (const line of console) { + if (line.trim().toLowerCase() === "y") { + break; + } + console.log(`${chalk.red("✗")} Import cancelled`); + return 0; + } + } + + const successfullyImported: string[] = []; + + // Add emojis + for (const [shortcode, url] of Object.entries(packFiles)) { + // If emoji URL is not a valid URL, assume it's a relative path to homepage + const fileUrl = Bun.pathToFileURL( + join(tempDir, url), + ).toString(); + + // Check if emoji already exists + const existingEmoji = await client.emoji.findFirst({ + where: { + shortcode: shortcode, + instanceId: null, + }, + }); + + if (existingEmoji) { + console.log( + `${chalk.red("✗")} Emoji with shortcode ${chalk.blue( + shortcode, + )} already exists`, + ); + continue; + } + + // Add the emoji by calling the add command + const returnCode = await cliBuilder.processArgs([ + "emoji", + "add", + shortcode, + fileUrl, + "--noconfirm", + ]); + + if (returnCode === 0) successfullyImported.push(shortcode); + } + + console.log( + `${chalk.green("✓")} Imported ${ + successfullyImported.length + } emojis from ${chalk.underline(url)}`, + ); + + // List imported + if (successfullyImported.length > 0) { + console.log( + `${chalk.green("✓")} Successfully imported ${ + successfullyImported.length + } emojis: ${successfullyImported.join(", ")}`, + ); + } + + // List unimported + if (successfullyImported.length < Object.keys(packFiles).length) { + const unimported = Object.keys(packFiles).filter( + (key) => !successfullyImported.includes(key), + ); + console.log( + `${chalk.red("✗")} Failed to import ${ + unimported.length + } emojis: ${unimported.join(", ")}`, + ); + } + + return 0; + }, + "Imports a Pleroma emoji pack", + "bun cli emoji import https://site.com/neofox/manifest.json", + ), ]); const exitCode = await cliBuilder.processArgs(args); -process.exit(Number(exitCode == undefined ? 0 : exitCode)); +process.exit(Number(exitCode === undefined ? 0 : exitCode)); diff --git a/database/datasource.ts b/database/datasource.ts index 05a32cad..d91ca8a0 100644 --- a/database/datasource.ts +++ b/database/datasource.ts @@ -3,7 +3,7 @@ import { PrismaClient } from "@prisma/client"; import { config } from "config-manager"; const client = new PrismaClient({ - datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`, + datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`, }); /* const federationQueue = new Queue("federation", { diff --git a/database/entities/Application.ts b/database/entities/Application.ts index 5bd4f219..eefb32f7 100644 --- a/database/entities/Application.ts +++ b/database/entities/Application.ts @@ -1,6 +1,6 @@ -import type { APIApplication } from "~types/entities/application"; import type { Application } from "@prisma/client"; import { client } from "~database/datasource"; +import type { APIApplication } from "~types/entities/application"; /** * Represents an application that can authenticate with the API. @@ -12,18 +12,18 @@ import { client } from "~database/datasource"; * @returns The application associated with the given access token, or null if no such application exists. */ export const getFromToken = async ( - token: string + token: string, ): Promise => { - const dbToken = await client.token.findFirst({ - where: { - access_token: token, - }, - include: { - application: true, - }, - }); + const dbToken = await client.token.findFirst({ + where: { + access_token: token, + }, + include: { + application: true, + }, + }); - return dbToken?.application || null; + return dbToken?.application || null; }; /** @@ -31,9 +31,9 @@ export const getFromToken = async ( * @returns The API application representation of this application. */ export const applicationToAPI = (app: Application): APIApplication => { - return { - name: app.name, - website: app.website, - vapid_key: app.vapid_key, - }; + return { + name: app.name, + website: app.website, + vapid_key: app.vapid_key, + }; }; diff --git a/database/entities/Attachment.ts b/database/entities/Attachment.ts index 9519eaeb..c2452bd3 100644 --- a/database/entities/Attachment.ts +++ b/database/entities/Attachment.ts @@ -1,69 +1,70 @@ import type { Attachment } from "@prisma/client"; -import type { ConfigType } from "config-manager"; +import type { Config } from "config-manager"; import { MediaBackendType } from "media-manager"; import type { APIAsyncAttachment } from "~types/entities/async_attachment"; import type { APIAttachment } from "~types/entities/attachment"; export const attachmentToAPI = ( - attachment: Attachment + attachment: Attachment, ): APIAsyncAttachment | APIAttachment => { - let type = "unknown"; + let type = "unknown"; - if (attachment.mime_type.startsWith("image/")) { - type = "image"; - } else if (attachment.mime_type.startsWith("video/")) { - type = "video"; - } else if (attachment.mime_type.startsWith("audio/")) { - type = "audio"; - } + if (attachment.mime_type.startsWith("image/")) { + type = "image"; + } else if (attachment.mime_type.startsWith("video/")) { + type = "video"; + } else if (attachment.mime_type.startsWith("audio/")) { + type = "audio"; + } - return { - id: attachment.id, - type: type as any, - url: attachment.url, - remote_url: attachment.remote_url, - preview_url: attachment.thumbnail_url, - text_url: null, - meta: { - width: attachment.width || undefined, - height: attachment.height || undefined, - fps: attachment.fps || undefined, - size: - attachment.width && attachment.height - ? `${attachment.width}x${attachment.height}` - : undefined, - duration: attachment.duration || undefined, - length: attachment.size?.toString() || undefined, - aspect: - attachment.width && attachment.height - ? attachment.width / attachment.height - : undefined, - original: { - width: attachment.width || undefined, - height: attachment.height || undefined, - size: - attachment.width && attachment.height - ? `${attachment.width}x${attachment.height}` - : undefined, - aspect: - attachment.width && attachment.height - ? attachment.width / attachment.height - : undefined, - }, - // Idk whether size or length is the right value - }, - description: attachment.description, - blurhash: attachment.blurhash, - }; + return { + id: attachment.id, + type: type as "image" | "video" | "audio" | "unknown", + url: attachment.url, + remote_url: attachment.remote_url, + preview_url: attachment.thumbnail_url, + text_url: null, + meta: { + width: attachment.width || undefined, + height: attachment.height || undefined, + fps: attachment.fps || undefined, + size: + attachment.width && attachment.height + ? `${attachment.width}x${attachment.height}` + : undefined, + duration: attachment.duration || undefined, + length: attachment.size?.toString() || undefined, + aspect: + attachment.width && attachment.height + ? attachment.width / attachment.height + : undefined, + original: { + width: attachment.width || undefined, + height: attachment.height || undefined, + size: + attachment.width && attachment.height + ? `${attachment.width}x${attachment.height}` + : undefined, + aspect: + attachment.width && attachment.height + ? attachment.width / attachment.height + : undefined, + }, + // Idk whether size or length is the right value + }, + description: attachment.description, + blurhash: attachment.blurhash, + }; }; -export const getUrl = (name: string, config: ConfigType) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - if (config.media.backend === MediaBackendType.LOCAL) { - return `${config.http.base_url}/media/${name}`; - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison, @typescript-eslint/no-unnecessary-condition - } else if (config.media.backend === MediaBackendType.S3) { - return `${config.s3.public_url}/${name}`; - } - return ""; +export const getUrl = (name: string, config: Config) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (config.media.backend === MediaBackendType.LOCAL) { + return `${config.http.base_url}/media/${name}`; + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison, @typescript-eslint/no-unnecessary-condition + } + if (config.media.backend === MediaBackendType.S3) { + return `${config.s3.public_url}/${name}`; + } + return ""; }; diff --git a/database/entities/Emoji.ts b/database/entities/Emoji.ts index 5a610fc4..6f9f57f1 100644 --- a/database/entities/Emoji.ts +++ b/database/entities/Emoji.ts @@ -1,7 +1,7 @@ +import type { Emoji } from "@prisma/client"; +import { client } from "~database/datasource"; import type { APIEmoji } from "~types/entities/emoji"; import type { Emoji as LysandEmoji } from "~types/lysand/extensions/org.lysand/custom_emojis"; -import { client } from "~database/datasource"; -import type { Emoji } from "@prisma/client"; /** * Represents an emoji entity in the database. @@ -13,41 +13,41 @@ import type { Emoji } from "@prisma/client"; * @returns An array of emojis */ export const parseEmojis = async (text: string): Promise => { - const regex = /:[a-zA-Z0-9_]+:/g; - const matches = text.match(regex); - if (!matches) return []; - return await client.emoji.findMany({ - where: { - shortcode: { - in: matches.map(match => match.replace(/:/g, "")), - }, - instanceId: null, - }, - include: { - instance: true, - }, - }); + const regex = /:[a-zA-Z0-9_]+:/g; + const matches = text.match(regex); + if (!matches) return []; + return await client.emoji.findMany({ + where: { + shortcode: { + in: matches.map((match) => match.replace(/:/g, "")), + }, + instanceId: null, + }, + include: { + instance: true, + }, + }); }; export const addEmojiIfNotExists = async (emoji: LysandEmoji) => { - const existingEmoji = await client.emoji.findFirst({ - where: { - shortcode: emoji.name, - instance: null, - }, - }); + const existingEmoji = await client.emoji.findFirst({ + where: { + shortcode: emoji.name, + instance: null, + }, + }); - if (existingEmoji) return existingEmoji; + if (existingEmoji) return existingEmoji; - return await client.emoji.create({ - data: { - shortcode: emoji.name, - url: emoji.url[0].content, - alt: emoji.alt || null, - content_type: emoji.url[0].content_type, - visible_in_picker: true, - }, - }); + return await client.emoji.create({ + data: { + shortcode: emoji.name, + url: emoji.url[0].content, + alt: emoji.alt || null, + content_type: emoji.url[0].content_type, + visible_in_picker: true, + }, + }); }; /** @@ -55,43 +55,43 @@ export const addEmojiIfNotExists = async (emoji: LysandEmoji) => { * @returns The APIEmoji object. */ export const emojiToAPI = (emoji: Emoji): APIEmoji => { - return { - shortcode: emoji.shortcode, - static_url: emoji.url, // TODO: Add static version - url: emoji.url, - visible_in_picker: emoji.visible_in_picker, - category: undefined, - }; + return { + shortcode: emoji.shortcode, + static_url: emoji.url, // TODO: Add static version + url: emoji.url, + visible_in_picker: emoji.visible_in_picker, + category: undefined, + }; }; export const emojiToLysand = (emoji: Emoji): LysandEmoji => { - return { - name: emoji.shortcode, - url: [ - { - content: emoji.url, - content_type: emoji.content_type, - }, - ], - alt: emoji.alt || undefined, - }; + return { + name: emoji.shortcode, + url: [ + { + content: emoji.url, + content_type: emoji.content_type, + }, + ], + alt: emoji.alt || undefined, + }; }; /** * Converts the emoji to an ActivityPub object. * @returns The ActivityPub object. */ -export const emojiToActivityPub = (emoji: Emoji): any => { - // replace any with your ActivityPub Emoji type - return { - type: "Emoji", - name: `:${emoji.shortcode}:`, - updated: new Date().toISOString(), - icon: { - type: "Image", - url: emoji.url, - mediaType: emoji.content_type, - alt: emoji.alt || undefined, - }, - }; +export const emojiToActivityPub = (emoji: Emoji): object => { + // replace any with your ActivityPub Emoji type + return { + type: "Emoji", + name: `:${emoji.shortcode}:`, + updated: new Date().toISOString(), + icon: { + type: "Image", + url: emoji.url, + mediaType: emoji.content_type, + alt: emoji.alt || undefined, + }, + }; }; diff --git a/database/entities/Instance.ts b/database/entities/Instance.ts index 46d43a84..7d52b4e3 100644 --- a/database/entities/Instance.ts +++ b/database/entities/Instance.ts @@ -12,38 +12,38 @@ import type { ServerMetadata } from "~types/lysand/Object"; * @returns Either the database instance if it already exists, or a newly created instance. */ export const addInstanceIfNotExists = async ( - url: string + url: string, ): Promise => { - const origin = new URL(url).origin; - const hostname = new URL(url).hostname; + const origin = new URL(url).origin; + const hostname = new URL(url).hostname; - const found = await client.instance.findFirst({ - where: { - base_url: hostname, - }, - }); + const found = await client.instance.findFirst({ + where: { + base_url: hostname, + }, + }); - if (found) return found; + if (found) return found; - // Fetch the instance configuration - const metadata = (await fetch(`${origin}/.well-known/lysand`).then(res => - res.json() - )) as Partial; + // Fetch the instance configuration + const metadata = (await fetch(`${origin}/.well-known/lysand`).then((res) => + res.json(), + )) as Partial; - if (metadata.type !== "ServerMetadata") { - throw new Error("Invalid instance metadata"); - } + if (metadata.type !== "ServerMetadata") { + throw new Error("Invalid instance metadata"); + } - if (!(metadata.name && metadata.version)) { - throw new Error("Invalid instance metadata"); - } + if (!(metadata.name && metadata.version)) { + throw new Error("Invalid instance metadata"); + } - return await client.instance.create({ - data: { - base_url: hostname, - name: metadata.name, - version: metadata.version, - logo: metadata.logo as any, - }, - }); + return await client.instance.create({ + data: { + base_url: hostname, + name: metadata.name, + version: metadata.version, + logo: metadata.logo, + }, + }); }; diff --git a/database/entities/Like.ts b/database/entities/Like.ts index ea6f726e..00935004 100644 --- a/database/entities/Like.ts +++ b/database/entities/Like.ts @@ -1,23 +1,25 @@ +import type { Like, Prisma } from "@prisma/client"; +import { config } from "config-manager"; +import { client } from "~database/datasource"; /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import type { Like as LysandLike } from "~types/lysand/Object"; -import type { Like } from "@prisma/client"; -import { client } from "~database/datasource"; -import type { UserWithRelations } from "./User"; import type { StatusWithRelations } from "./Status"; -import { config } from "config-manager"; +import type { UserWithRelations } from "./User"; /** * Represents a Like entity in the database. */ export const toLysand = (like: Like): LysandLike => { - return { - id: like.id, - author: (like as any).liker?.uri, - type: "Like", - created_at: new Date(like.createdAt).toISOString(), - object: (like as any).liked?.uri, - uri: `${config.http.base_url}/actions/${like.id}`, - }; + return { + id: like.id, + // biome-ignore lint/suspicious/noExplicitAny: to be rewritten + author: (like as any).liker?.uri, + type: "Like", + created_at: new Date(like.createdAt).toISOString(), + // biome-ignore lint/suspicious/noExplicitAny: to be rewritten + object: (like as any).liked?.uri, + uri: `${config.http.base_url}/actions/${like.id}`, + }; }; /** @@ -26,29 +28,29 @@ export const toLysand = (like: Like): LysandLike => { * @param status Status being liked */ export const createLike = async ( - user: UserWithRelations, - status: StatusWithRelations + user: UserWithRelations, + status: StatusWithRelations, ) => { - await client.like.create({ - data: { - likedId: status.id, - likerId: user.id, - }, - }); + await client.like.create({ + data: { + likedId: status.id, + likerId: user.id, + }, + }); - if (status.author.instanceId === user.instanceId) { - // Notify the user that their post has been favourited - await client.notification.create({ - data: { - accountId: user.id, - type: "favourite", - notifiedId: status.authorId, - statusId: status.id, - }, - }); - } else { - // TODO: Add database jobs for federating this - } + if (status.author.instanceId === user.instanceId) { + // Notify the user that their post has been favourited + await client.notification.create({ + data: { + accountId: user.id, + type: "favourite", + notifiedId: status.authorId, + statusId: status.id, + }, + }); + } else { + // TODO: Add database jobs for federating this + } }; /** @@ -57,28 +59,28 @@ export const createLike = async ( * @param status Status being unliked */ export const deleteLike = async ( - user: UserWithRelations, - status: StatusWithRelations + user: UserWithRelations, + status: StatusWithRelations, ) => { - await client.like.deleteMany({ - where: { - likedId: status.id, - likerId: user.id, - }, - }); + await client.like.deleteMany({ + where: { + likedId: status.id, + likerId: user.id, + }, + }); - // Notify the user that their post has been favourited - await client.notification.deleteMany({ - where: { - accountId: user.id, - type: "favourite", - notifiedId: status.authorId, - statusId: status.id, - }, - }); + // Notify the user that their post has been favourited + await client.notification.deleteMany({ + where: { + accountId: user.id, + type: "favourite", + notifiedId: status.authorId, + statusId: status.id, + }, + }); - if (user.instanceId === null && status.author.instanceId !== null) { - // User is local, federate the delete - // TODO: Federate this - } + if (user.instanceId === null && status.author.instanceId !== null) { + // User is local, federate the delete + // TODO: Federate this + } }; diff --git a/database/entities/Notification.ts b/database/entities/Notification.ts index 80399f30..cab2c4f3 100644 --- a/database/entities/Notification.ts +++ b/database/entities/Notification.ts @@ -4,20 +4,20 @@ import { type StatusWithRelations, statusToAPI } from "./Status"; import { type UserWithRelations, userToAPI } from "./User"; export type NotificationWithRelations = Notification & { - status: StatusWithRelations | null; - account: UserWithRelations; + status: StatusWithRelations | null; + account: UserWithRelations; }; export const notificationToAPI = async ( - notification: NotificationWithRelations + notification: NotificationWithRelations, ): Promise => { - return { - account: userToAPI(notification.account), - created_at: new Date(notification.createdAt).toISOString(), - id: notification.id, - type: notification.type, - status: notification.status - ? await statusToAPI(notification.status, notification.account) - : undefined, - }; + return { + account: userToAPI(notification.account), + created_at: new Date(notification.createdAt).toISOString(), + id: notification.id, + type: notification.type, + status: notification.status + ? await statusToAPI(notification.status, notification.account) + : undefined, + }; }; diff --git a/database/entities/Object.ts b/database/entities/Object.ts index c01dc549..5fed8be3 100644 --- a/database/entities/Object.ts +++ b/database/entities/Object.ts @@ -9,79 +9,79 @@ import type { LysandObjectType } from "~types/lysand/Object"; */ export const createFromObject = async (object: LysandObjectType) => { - const foundObject = await client.lysandObject.findFirst({ - where: { remote_id: object.id }, - include: { - author: true, - }, - }); + const foundObject = await client.lysandObject.findFirst({ + where: { remote_id: object.id }, + include: { + author: true, + }, + }); - if (foundObject) { - return foundObject; - } + if (foundObject) { + return foundObject; + } - const author = await client.lysandObject.findFirst({ - where: { uri: (object as any).author }, - }); + const author = await client.lysandObject.findFirst({ + // biome-ignore lint/suspicious/noExplicitAny: + where: { uri: (object as any).author }, + }); - return await client.lysandObject.create({ - data: { - authorId: author?.id, - created_at: new Date(object.created_at), - extensions: object.extensions || {}, - remote_id: object.id, - type: object.type, - uri: object.uri, - // Rest of data (remove id, author, created_at, extensions, type, uri) - extra_data: Object.fromEntries( - Object.entries(object).filter( - ([key]) => - ![ - "id", - "author", - "created_at", - "extensions", - "type", - "uri", - ].includes(key) - ) - ), - }, - }); + return await client.lysandObject.create({ + data: { + authorId: author?.id, + created_at: new Date(object.created_at), + extensions: object.extensions || {}, + remote_id: object.id, + type: object.type, + uri: object.uri, + // Rest of data (remove id, author, created_at, extensions, type, uri) + extra_data: Object.fromEntries( + Object.entries(object).filter( + ([key]) => + ![ + "id", + "author", + "created_at", + "extensions", + "type", + "uri", + ].includes(key), + ), + ), + }, + }); }; export const toLysand = (lyObject: LysandObject): LysandObjectType => { - return { - id: lyObject.remote_id || lyObject.id, - created_at: new Date(lyObject.created_at).toISOString(), - type: lyObject.type, - uri: lyObject.uri, - // @ts-expect-error This works, I promise - ...lyObject.extra_data, - extensions: lyObject.extensions, - }; + return { + id: lyObject.remote_id || lyObject.id, + created_at: new Date(lyObject.created_at).toISOString(), + type: lyObject.type, + uri: lyObject.uri, + ...lyObject.extra_data, + extensions: lyObject.extensions, + }; }; export const isPublication = (lyObject: LysandObject): boolean => { - return lyObject.type === "Note" || lyObject.type === "Patch"; + return lyObject.type === "Note" || lyObject.type === "Patch"; }; export const isAction = (lyObject: LysandObject): boolean => { - return [ - "Like", - "Follow", - "Dislike", - "FollowAccept", - "FollowReject", - "Undo", - "Announce", - ].includes(lyObject.type); + return [ + "Like", + "Follow", + "Dislike", + "FollowAccept", + "FollowReject", + "Undo", + "Announce", + ].includes(lyObject.type); }; export const isActor = (lyObject: LysandObject): boolean => { - return lyObject.type === "User"; + return lyObject.type === "User"; }; export const isExtension = (lyObject: LysandObject): boolean => { - return lyObject.type === "Extension"; + return lyObject.type === "Extension"; }; diff --git a/database/entities/Queue.ts b/database/entities/Queue.ts index 570d79de..a5e399a5 100644 --- a/database/entities/Queue.ts +++ b/database/entities/Queue.ts @@ -1,7 +1,7 @@ -// import { Worker } from "bullmq"; -import { statusToLysand, type StatusWithRelations } from "./Status"; import type { User } from "@prisma/client"; import { config } from "config-manager"; +// import { Worker } from "bullmq"; +import { type StatusWithRelations, statusToLysand } from "./Status"; /* export const federationWorker = new Worker( "federation", @@ -123,68 +123,68 @@ import { config } from "config-manager"; * from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String */ export const str2ab = (str: string) => { - const buf = new ArrayBuffer(str.length); - const bufView = new Uint8Array(buf); - for (let i = 0, strLen = str.length; i < strLen; i++) { - bufView[i] = str.charCodeAt(i); - } - return buf; + const buf = new ArrayBuffer(str.length); + const bufView = new Uint8Array(buf); + for (let i = 0, strLen = str.length; i < strLen; i++) { + bufView[i] = str.charCodeAt(i); + } + return buf; }; export const federateStatusTo = async ( - status: StatusWithRelations, - sender: User, - user: User + status: StatusWithRelations, + sender: User, + user: User, ) => { - const privateKey = await crypto.subtle.importKey( - "pkcs8", - str2ab(atob(user.privateKey ?? "")), - "Ed25519", - false, - ["sign"] - ); + const privateKey = await crypto.subtle.importKey( + "pkcs8", + str2ab(atob(user.privateKey ?? "")), + "Ed25519", + false, + ["sign"], + ); - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode("request_body") - ); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode("request_body"), + ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const userInbox = new URL(user.endpoints.inbox); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const userInbox = new URL(user.endpoints.inbox); - const date = new Date(); + const date = new Date(); - const signature = await crypto.subtle.sign( - "Ed25519", - privateKey, - new TextEncoder().encode( - `(request-target): post ${userInbox.pathname}\n` + - `host: ${userInbox.host}\n` + - `date: ${date.toUTCString()}\n` + - `digest: SHA-256=${btoa( - String.fromCharCode(...new Uint8Array(digest)) - )}\n` - ) - ); + const signature = await crypto.subtle.sign( + "Ed25519", + privateKey, + new TextEncoder().encode( + `(request-target): post ${userInbox.pathname}\n` + + `host: ${userInbox.host}\n` + + `date: ${date.toUTCString()}\n` + + `digest: SHA-256=${btoa( + String.fromCharCode(...new Uint8Array(digest)), + )}\n`, + ), + ); - const signatureBase64 = btoa( - String.fromCharCode(...new Uint8Array(signature)) - ); + const signatureBase64 = btoa( + String.fromCharCode(...new Uint8Array(signature)), + ); - return fetch(userInbox, { - method: "POST", - headers: { - "Content-Type": "application/json", - Date: date.toUTCString(), - Origin: config.http.base_url, - Signature: `keyId="${sender.uri}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`, - }, - body: JSON.stringify(statusToLysand(status)), - }); + return fetch(userInbox, { + method: "POST", + headers: { + "Content-Type": "application/json", + Date: date.toUTCString(), + Origin: config.http.base_url, + Signature: `keyId="${sender.uri}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`, + }, + body: JSON.stringify(statusToLysand(status)), + }); }; export const addStatusFederationJob = async (statusId: string) => { - /* await federationQueue.add("federation", { + /* await federationQueue.add("federation", { id: statusId, }); */ }; diff --git a/database/entities/Relationship.ts b/database/entities/Relationship.ts index d9d8b384..07566ff7 100644 --- a/database/entities/Relationship.ts +++ b/database/entities/Relationship.ts @@ -1,6 +1,6 @@ import type { Relationship, User } from "@prisma/client"; -import type { APIRelationship } from "~types/entities/relationship"; import { client } from "~database/datasource"; +import type { APIRelationship } from "~types/entities/relationship"; /** * Stores Mastodon API relationships @@ -13,55 +13,55 @@ import { client } from "~database/datasource"; * @returns The newly created relationship. */ export const createNewRelationship = async ( - owner: User, - other: User + owner: User, + other: User, ): Promise => { - return await client.relationship.create({ - data: { - ownerId: owner.id, - subjectId: other.id, - languages: [], - following: false, - showingReblogs: false, - notifying: false, - followedBy: false, - blocking: false, - blockedBy: false, - muting: false, - mutingNotifications: false, - requested: false, - domainBlocking: false, - endorsed: false, - note: "", - }, - }); + return await client.relationship.create({ + data: { + ownerId: owner.id, + subjectId: other.id, + languages: [], + following: false, + showingReblogs: false, + notifying: false, + followedBy: false, + blocking: false, + blockedBy: false, + muting: false, + mutingNotifications: false, + requested: false, + domainBlocking: false, + endorsed: false, + note: "", + }, + }); }; export const checkForBidirectionalRelationships = async ( - user1: User, - user2: User, - createIfNotExists = true + user1: User, + user2: User, + createIfNotExists = true, ): Promise => { - const relationship1 = await client.relationship.findFirst({ - where: { - ownerId: user1.id, - subjectId: user2.id, - }, - }); + const relationship1 = await client.relationship.findFirst({ + where: { + ownerId: user1.id, + subjectId: user2.id, + }, + }); - const relationship2 = await client.relationship.findFirst({ - where: { - ownerId: user2.id, - subjectId: user1.id, - }, - }); + const relationship2 = await client.relationship.findFirst({ + where: { + ownerId: user2.id, + subjectId: user1.id, + }, + }); - if (!relationship1 && !relationship2 && createIfNotExists) { - await createNewRelationship(user1, user2); - await createNewRelationship(user2, user1); - } + if (!relationship1 && !relationship2 && createIfNotExists) { + await createNewRelationship(user1, user2); + await createNewRelationship(user2, user1); + } - return !!relationship1 && !!relationship2; + return !!relationship1 && !!relationship2; }; /** @@ -69,20 +69,20 @@ export const checkForBidirectionalRelationships = async ( * @returns The API-friendly relationship. */ export const relationshipToAPI = (rel: Relationship): APIRelationship => { - return { - blocked_by: rel.blockedBy, - blocking: rel.blocking, - domain_blocking: rel.domainBlocking, - endorsed: rel.endorsed, - followed_by: rel.followedBy, - following: rel.following, - id: rel.subjectId, - muting: rel.muting, - muting_notifications: rel.mutingNotifications, - notifying: rel.notifying, - requested: rel.requested, - showing_reblogs: rel.showingReblogs, - languages: rel.languages, - note: rel.note, - }; + return { + blocked_by: rel.blockedBy, + blocking: rel.blocking, + domain_blocking: rel.domainBlocking, + endorsed: rel.endorsed, + followed_by: rel.followedBy, + following: rel.following, + id: rel.subjectId, + muting: rel.muting, + muting_notifications: rel.mutingNotifications, + notifying: rel.notifying, + requested: rel.requested, + showing_reblogs: rel.showingReblogs, + languages: rel.languages, + note: rel.note, + }; }; diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 6eb4c6f8..1d8e4d95 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -1,37 +1,37 @@ +import { getBestContentType } from "@content_types"; +import { addStausToMeilisearch } from "@meilisearch"; +import { + type Application, + type Emoji, + Prisma, + type Relationship, + type Status, + type User, +} from "@prisma/client"; +import { sanitizeHtml } from "@sanitization"; +import { config } from "config-manager"; +import { htmlToText } from "html-to-text"; +import linkifyHtml from "linkify-html"; +import linkifyStr from "linkify-string"; +import { parse } from "marked"; +import { client } from "~database/datasource"; +import type { APIAttachment } from "~types/entities/attachment"; +import type { APIStatus } from "~types/entities/status"; +import type { LysandPublication, Note } from "~types/lysand/Object"; +import { applicationToAPI } from "./Application"; +import { attachmentToAPI } from "./Attachment"; +import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji"; /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import type { UserWithRelations } from "./User"; import { fetchRemoteUser, parseMentionsUris, userToAPI } from "./User"; -import { client } from "~database/datasource"; -import type { LysandPublication, Note } from "~types/lysand/Object"; -import { htmlToText } from "html-to-text"; -import { getBestContentType } from "@content_types"; -import { - Prisma, - type Application, - type Emoji, - type Relationship, - type Status, - type User, -} from "@prisma/client"; -import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji"; -import type { APIStatus } from "~types/entities/status"; -import { applicationToAPI } from "./Application"; -import { attachmentToAPI } from "./Attachment"; -import type { APIAttachment } from "~types/entities/attachment"; -import { sanitizeHtml } from "@sanitization"; -import { parse } from "marked"; -import linkifyStr from "linkify-string"; -import linkifyHtml from "linkify-html"; -import { addStausToMeilisearch } from "@meilisearch"; -import { config } from "config-manager"; import { statusAndUserRelations, userRelations } from "./relations"; const statusRelations = Prisma.validator()({ - include: statusAndUserRelations, + include: statusAndUserRelations, }); export type StatusWithRelations = Prisma.StatusGetPayload< - typeof statusRelations + typeof statusRelations >; /** @@ -44,76 +44,75 @@ export type StatusWithRelations = Prisma.StatusGetPayload< * @returns Whether this status is viewable by the user. */ export const isViewableByUser = (status: Status, user: User | null) => { - if (status.authorId === user?.id) return true; - if (status.visibility === "public") return true; - else if (status.visibility === "unlisted") return true; - else if (status.visibility === "private") { - // @ts-expect-error Prisma TypeScript types dont include relations - return !!(user?.relationships as Relationship[]).find( - rel => rel.id === status.authorId - ); - } else { - // @ts-expect-error Prisma TypeScript types dont include relations - return user && (status.mentions as User[]).includes(user); - } + if (status.authorId === user?.id) return true; + if (status.visibility === "public") return true; + if (status.visibility === "unlisted") return true; + if (status.visibility === "private") { + // @ts-expect-error Prisma TypeScript types dont include relations + return !!(user?.relationships as Relationship[]).find( + (rel) => rel.id === status.authorId, + ); + } + // @ts-expect-error Prisma TypeScript types dont include relations + return user && (status.mentions as User[]).includes(user); }; export const fetchFromRemote = async (uri: string): Promise => { - // Check if already in database + // Check if already in database - const existingStatus: StatusWithRelations | null = - await client.status.findFirst({ - where: { - uri: uri, - }, - include: statusAndUserRelations, - }); + const existingStatus: StatusWithRelations | null = + await client.status.findFirst({ + where: { + uri: uri, + }, + include: statusAndUserRelations, + }); - if (existingStatus) return existingStatus; + if (existingStatus) return existingStatus; - const status = await fetch(uri); + const status = await fetch(uri); - if (status.status === 404) return null; + if (status.status === 404) return null; - const body = (await status.json()) as LysandPublication; + const body = (await status.json()) as LysandPublication; - const content = getBestContentType(body.contents); + const content = getBestContentType(body.contents); - const emojis = await parseEmojis(content?.content || ""); + const emojis = await parseEmojis(content?.content || ""); - const author = await fetchRemoteUser(body.author); + const author = await fetchRemoteUser(body.author); - let replyStatus: Status | null = null; - let quotingStatus: Status | null = null; + let replyStatus: Status | null = null; + let quotingStatus: Status | null = null; - if (body.replies_to.length > 0) { - replyStatus = await fetchFromRemote(body.replies_to[0]); - } + if (body.replies_to.length > 0) { + replyStatus = await fetchFromRemote(body.replies_to[0]); + } - if (body.quotes.length > 0) { - quotingStatus = await fetchFromRemote(body.quotes[0]); - } + if (body.quotes.length > 0) { + quotingStatus = await fetchFromRemote(body.quotes[0]); + } - return await createNewStatus({ - account: author, - content: content?.content || "", - content_type: content?.content_type, - application: null, - // TODO: Add visibility - visibility: "public", - spoiler_text: body.subject || "", - uri: body.uri, - sensitive: body.is_sensitive, - emojis: emojis, - mentions: await parseMentionsUris(body.mentions), - reply: replyStatus - ? { - status: replyStatus, - user: (replyStatus as any).author, - } - : undefined, - quote: quotingStatus || undefined, - }); + return await createNewStatus({ + account: author, + content: content?.content || "", + content_type: content?.content_type, + application: null, + // TODO: Add visibility + visibility: "public", + spoiler_text: body.subject || "", + uri: body.uri, + sensitive: body.is_sensitive, + emojis: emojis, + mentions: await parseMentionsUris(body.mentions), + reply: replyStatus + ? { + status: replyStatus, + user: (replyStatus as StatusWithRelations).author, + } + : undefined, + quote: quotingStatus || undefined, + }); }; /** @@ -121,34 +120,34 @@ export const fetchFromRemote = async (uri: string): Promise => { */ // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars export const getAncestors = async ( - status: StatusWithRelations, - fetcher: UserWithRelations | null + status: StatusWithRelations, + fetcher: UserWithRelations | null, ) => { - const ancestors: StatusWithRelations[] = []; + const ancestors: StatusWithRelations[] = []; - let currentStatus = status; + let currentStatus = status; - while (currentStatus.inReplyToPostId) { - const parent = await client.status.findFirst({ - where: { - id: currentStatus.inReplyToPostId, - }, - include: statusAndUserRelations, - }); + while (currentStatus.inReplyToPostId) { + const parent = await client.status.findFirst({ + where: { + id: currentStatus.inReplyToPostId, + }, + include: statusAndUserRelations, + }); - if (!parent) break; + if (!parent) break; - ancestors.push(parent); + ancestors.push(parent); - currentStatus = parent; - } + currentStatus = parent; + } - // Filter for posts that are viewable by the user + // Filter for posts that are viewable by the user - const viewableAncestors = ancestors.filter(ancestor => - isViewableByUser(ancestor, fetcher) - ); - return viewableAncestors; + const viewableAncestors = ancestors.filter((ancestor) => + isViewableByUser(ancestor, fetcher), + ); + return viewableAncestors; }; /** @@ -157,42 +156,42 @@ export const getAncestors = async ( */ // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars export const getDescendants = async ( - status: StatusWithRelations, - fetcher: UserWithRelations | null, - depth = 0 + status: StatusWithRelations, + fetcher: UserWithRelations | null, + depth = 0, ) => { - const descendants: StatusWithRelations[] = []; + const descendants: StatusWithRelations[] = []; - const currentStatus = status; + const currentStatus = status; - // Fetch all children of children of children recursively calling getDescendants + // Fetch all children of children of children recursively calling getDescendants - const children = await client.status.findMany({ - where: { - inReplyToPostId: currentStatus.id, - }, - include: statusAndUserRelations, - }); + const children = await client.status.findMany({ + where: { + inReplyToPostId: currentStatus.id, + }, + include: statusAndUserRelations, + }); - for (const child of children) { - descendants.push(child); + for (const child of children) { + descendants.push(child); - if (depth < 20) { - const childDescendants = await getDescendants( - child, - fetcher, - depth + 1 - ); - descendants.push(...childDescendants); - } - } + if (depth < 20) { + const childDescendants = await getDescendants( + child, + fetcher, + depth + 1, + ); + descendants.push(...childDescendants); + } + } - // Filter for posts that are viewable by the user + // Filter for posts that are viewable by the user - const viewableDescendants = descendants.filter(descendant => - isViewableByUser(descendant, fetcher) - ); - return viewableDescendants; + const viewableDescendants = descendants.filter((descendant) => + isViewableByUser(descendant, fetcher), + ); + return viewableDescendants; }; /** @@ -201,250 +200,250 @@ export const getDescendants = async ( * @returns A promise that resolves with the new status. */ export const createNewStatus = async (data: { - account: User; - application: Application | null; - content: string; - visibility: APIStatus["visibility"]; - sensitive: boolean; - spoiler_text: string; - emojis?: Emoji[]; - content_type?: string; - uri?: string; - mentions?: User[]; - media_attachments?: string[]; - reply?: { - status: Status; - user: User; - }; - quote?: Status; + account: User; + application: Application | null; + content: string; + visibility: APIStatus["visibility"]; + sensitive: boolean; + spoiler_text: string; + emojis?: Emoji[]; + content_type?: string; + uri?: string; + mentions?: User[]; + media_attachments?: string[]; + reply?: { + status: Status; + user: User; + }; + quote?: Status; }) => { - // Get people mentioned in the content (match @username or @username@domain.com mentions) - const mentionedPeople = - data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? []; + // Get people mentioned in the content (match @username or @username@domain.com mentions) + const mentionedPeople = + data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? []; - let mentions = data.mentions || []; + let mentions = data.mentions || []; - // Parse emojis - const emojis = await parseEmojis(data.content); + // Parse emojis + const emojis = await parseEmojis(data.content); - data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis; + data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis; - // Get list of mentioned users - if (mentions.length === 0) { - mentions = await client.user.findMany({ - where: { - OR: mentionedPeople.map(person => ({ - username: person.split("@")[1], - instance: { - base_url: person.split("@")[2], - }, - })), - }, - include: userRelations, - }); - } + // Get list of mentioned users + if (mentions.length === 0) { + mentions = await client.user.findMany({ + where: { + OR: mentionedPeople.map((person) => ({ + username: person.split("@")[1], + instance: { + base_url: person.split("@")[2], + }, + })), + }, + include: userRelations, + }); + } - let formattedContent; + let formattedContent = ""; - // Get HTML version of content - if (data.content_type === "text/markdown") { - formattedContent = linkifyHtml( - await sanitizeHtml(await parse(data.content)) - ); - } else if (data.content_type === "text/x.misskeymarkdown") { - // Parse as MFM - } else { - // Parse as plaintext - formattedContent = linkifyStr(data.content); + // Get HTML version of content + if (data.content_type === "text/markdown") { + formattedContent = linkifyHtml( + await sanitizeHtml(await parse(data.content)), + ); + } else if (data.content_type === "text/x.misskeymarkdown") { + // Parse as MFM + } else { + // Parse as plaintext + formattedContent = linkifyStr(data.content); - // Split by newline and add