From ca7d325cb1469bbd2526f4f21d14fa69c986b96b Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sun, 15 Oct 2023 17:51:29 -1000 Subject: [PATCH] New API route format to make code cleaner --- .dockerignore | 15 ++ .eslintrc.cjs | 2 + bun.lockb | Bin 228319 -> 233994 bytes database/entities/RawActivity.ts | 2 +- database/entities/User.ts | 7 + index.ts | 132 +++++++++++++----- package.json | 125 +++++++++-------- server/api/api/v1/accounts/[id]/block.ts | 21 ++- server/api/api/v1/accounts/[id]/follow.ts | 21 ++- server/api/api/v1/accounts/[id]/index.ts | 21 ++- server/api/api/v1/accounts/[id]/mute.ts | 21 ++- server/api/api/v1/accounts/[id]/note.ts | 21 ++- server/api/api/v1/accounts/[id]/pin.ts | 21 ++- .../v1/accounts/[id]/remove_from_followers.ts | 21 ++- server/api/api/v1/accounts/[id]/statuses.ts | 13 ++ server/api/api/v1/accounts/[id]/unblock.ts | 21 ++- server/api/api/v1/accounts/[id]/unfollow.ts | 21 ++- server/api/api/v1/accounts/[id]/unmute.ts | 21 ++- server/api/api/v1/accounts/[id]/unpin.ts | 21 ++- .../v1/accounts/familiar_followers/index.ts | 21 ++- server/api/api/v1/accounts/index.ts | 13 ++ .../api/v1/accounts/relationships/index.ts | 21 ++- .../v1/accounts/update_credentials/index.ts | 25 ++-- .../v1/accounts/verify_credentials/index.ts | 27 ++-- server/api/api/v1/apps/index.ts | 13 ++ .../api/v1/apps/verify_credentials/index.ts | 21 ++- server/api/api/v1/custom_emojis/index.ts | 15 +- server/api/api/v1/instance/index.ts | 13 ++ server/api/api/v1/statuses/[id]/index.ts | 23 ++- server/api/api/v1/statuses/index.ts | 26 ++-- server/api/api/v1/timelines/home.ts | 14 ++ server/api/api/v1/timelines/public.ts | 14 ++ server/api/auth/login/index.ts | 14 ++ types/api.ts | 12 ++ utils/api.ts | 18 +++ utils/config.ts | 20 +++ 36 files changed, 600 insertions(+), 237 deletions(-) create mode 100644 .dockerignore create mode 100644 types/api.ts create mode 100644 utils/api.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f965aed1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +Dockerfile* +docker-compose* +.dockerignore +.git +.gitignore +README.md +LICENSE +.vscode +Makefile +helm-charts +.env +.editorconfig +.idea +coverage* diff --git a/.eslintrc.cjs b/.eslintrc.cjs index eade8cf0..df531c9f 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -14,5 +14,7 @@ module.exports = { root: true, rules: { "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-explicit-any": "off" }, }; diff --git a/bun.lockb b/bun.lockb index e2ea5ecd92e4898d14aa6e3633dc551a25f8d238..901f0977214069c13bd4d39a07a67964424a7c0b 100755 GIT binary patch delta 47755 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 ZRnbKLWGLy~iosb$L8=7lL=~-6{Rh2#AmIQ2 diff --git a/database/entities/RawActivity.ts b/database/entities/RawActivity.ts index eb75f050..e5337a6d 100644 --- a/database/entities/RawActivity.ts +++ b/database/entities/RawActivity.ts @@ -193,7 +193,7 @@ export class RawActivity extends BaseEntity { * Returns the ActivityPub representation of the activity. * @returns The ActivityPub representation of the activity. */ - makeActivityPubRepresentation() { + getActivityPubRepresentation() { return { ...this.data, object: this.objects[0].data, diff --git a/database/entities/User.ts b/database/entities/User.ts index 2081962a..1b4862ba 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -156,6 +156,13 @@ export class User extends BaseEntity { @JoinTable() pinned_notes!: RawObject[]; + static async getFromRequest(req: Request) { + // Check auth token + const token = req.headers.get("Authorization")?.split(" ")[1] || ""; + + return { user: await User.retrieveFromToken(token), token }; + } + /** * Update this user data from its actor * @returns The updated user. diff --git a/index.ts b/index.ts index ca9e7314..ae263e7b 100644 --- a/index.ts +++ b/index.ts @@ -1,8 +1,12 @@ import { getConfig } from "@config"; import { jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; import { appendFile } from "fs/promises"; +import { matches } from "ip-matching"; import "reflect-metadata"; import { AppDataSource } from "~database/datasource"; +import { User } from "~database/entities/User"; +import { APIRouteMeta } from "~types/api"; const router = new Bun.FileSystemRouter({ style: "nextjs", @@ -25,38 +29,25 @@ Bun.serve({ port: config.http.bind_port, hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0" async fetch(req) { - if (config.logging.log_requests_verbose) { - await appendFile( - `${process.cwd()}/logs/requests.log`, - `[${new Date().toISOString()}] ${req.method} ${ - req.url - }\n\tHeaders:\n` - ); + /* Check for banned IPs */ + const request_ip = this.requestIP(req)?.address ?? ""; - // Add headers - - const headers = req.headers.entries(); - - for (const [key, value] of headers) { - await appendFile( - `${process.cwd()}/logs/requests.log`, - `\t\t${key}: ${value}\n` - ); + 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; } - - const body = await req.clone().text(); - - await appendFile( - `${process.cwd()}/logs/requests.log`, - `\tBody:\n\t${body}\n` - ); - } else if (config.logging.log_requests) { - await appendFile( - process.cwd() + "/logs/requests.log", - `[${new Date().toISOString()}] ${req.method} ${req.url}\n` - ); } + await logRequest(req); + if (req.method === "OPTIONS") { return jsonResponse({}); } @@ -64,11 +55,52 @@ Bun.serve({ const matchedRoute = router.match(req); if (matchedRoute) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - return (await import(matchedRoute.filePath)).default( - req, - matchedRoute - ) as Response | Promise; + const file: { + meta: APIRouteMeta; + default: ( + req: Request, + matchedRoute: MatchedRoute + ) => Response | Promise; + } = await import(matchedRoute.filePath); + + 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( + ", " + )}`, + }); + } + + // TODO: Check for ratelimits + + // Check for authentication if required + if (meta.auth.required) { + const { user } = await User.getFromRequest(req); + + if (!user) { + return new Response(undefined, { + status: 401, + statusText: "Unauthorized", + }); + } + } else if ( + (meta.auth.requiredOnMethods ?? []).includes(req.method as any) + ) { + const { user } = await User.getFromRequest(req); + + if (!user) { + return new Response(undefined, { + status: 401, + statusText: "Unauthorized", + }); + } + } + + return file.default(req, matchedRoute); } else { return new Response(undefined, { status: 404, @@ -78,4 +110,38 @@ Bun.serve({ }, }); +const logRequest = async (req: Request) => { + if (config.logging.log_requests_verbose) { + await appendFile( + `${process.cwd()}/logs/requests.log`, + `[${new Date().toISOString()}] ${req.method} ${ + req.url + }\n\tHeaders:\n` + ); + + // Add headers + + const headers = req.headers.entries(); + + for (const [key, value] of headers) { + await appendFile( + `${process.cwd()}/logs/requests.log`, + `\t\t${key}: ${value}\n` + ); + } + + const body = await req.clone().text(); + + await appendFile( + `${process.cwd()}/logs/requests.log`, + `\tBody:\n\t${body}\n` + ); + } else if (config.logging.log_requests) { + await appendFile( + process.cwd() + "/logs/requests.log", + `[${new Date().toISOString()}] ${req.method} ${req.url}\n` + ); + } +}; + console.log("[+] Lysand started!"); diff --git a/package.json b/package.json index e14be57a..7ef5c4cd 100644 --- a/package.json +++ b/package.json @@ -1,64 +1,65 @@ { - "name": "lysand", - "module": "index.ts", - "type": "module", - "version": "0.0.1", - "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/CPlusPatch/lysand/issues" - }, - "icon": "https://github.com/CPlusPatch/lysand", - "license": "AGPL-3.0", - "keywords": [ - "federated", - "activitypub", - "bun" - ], - "maintainers": [ - { - "email": "contact@cpluspatch.com", - "name": "CPlusPatch", - "url": "https://cpluspatch.com" - } - ], - "repository": { - "type": "git", - "url": "git+https://github.com/CPlusPatch/lysand.git" - }, - "private": true, - "scripts": { - "dev": "bun run index.ts", - "start": "bun run index.ts" - }, - "devDependencies": { - "@julr/unocss-preset-forms": "^0.0.5", - "@types/jsonld": "^1.5.9", - "@typescript-eslint/eslint-plugin": "^6.6.0", - "@typescript-eslint/parser": "^6.6.0", - "@unocss/cli": "^0.55.7", - "activitypub-types": "^1.0.3", - "bun-types": "latest", - "eslint": "^8.49.0", - "eslint-config-prettier": "^9.0.0", - "eslint-formatter-pretty": "^5.0.0", - "eslint-formatter-summary": "^1.1.0", - "eslint-plugin-prettier": "^5.0.0", - "prettier": "^3.0.3", - "typescript": "^5.2.2", - "unocss": "^0.55.7" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "dependencies": { - "jsonld": "^8.3.1", - "pg": "^8.11.3", - "reflect-metadata": "^0.1.13", - "typeorm": "^0.3.17" - } + "name": "lysand", + "module": "index.ts", + "type": "module", + "version": "0.0.1", + "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/CPlusPatch/lysand/issues" + }, + "icon": "https://github.com/CPlusPatch/lysand", + "license": "AGPL-3.0", + "keywords": [ + "federated", + "activitypub", + "bun" + ], + "maintainers": [ + { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + } + ], + "repository": { + "type": "git", + "url": "git+https://github.com/CPlusPatch/lysand.git" + }, + "private": true, + "scripts": { + "dev": "bun run index.ts", + "start": "bun run index.ts" + }, + "devDependencies": { + "@julr/unocss-preset-forms": "^0.0.5", + "@types/jsonld": "^1.5.9", + "@typescript-eslint/eslint-plugin": "^6.6.0", + "@typescript-eslint/parser": "^6.6.0", + "@unocss/cli": "^0.55.7", + "activitypub-types": "^1.0.3", + "bun-types": "latest", + "eslint": "^8.49.0", + "eslint-config-prettier": "^9.0.0", + "eslint-formatter-pretty": "^5.0.0", + "eslint-formatter-summary": "^1.1.0", + "eslint-plugin-prettier": "^5.0.0", + "prettier": "^3.0.3", + "typescript": "^5.2.2", + "unocss": "^0.55.7" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "ip-matching": "^2.1.2", + "jsonld": "^8.3.1", + "pg": "^8.11.3", + "reflect-metadata": "^0.1.13", + "typeorm": "^0.3.17" + } } \ No newline at end of file diff --git a/server/api/api/v1/accounts/[id]/block.ts b/server/api/api/v1/accounts/[id]/block.ts index 4af533aa..25c39232 100644 --- a/server/api/api/v1/accounts/[id]/block.ts +++ b/server/api/api/v1/accounts/[id]/block.ts @@ -2,6 +2,19 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/block", + auth: { + required: true, + }, +}); /** * Blocks a user @@ -12,13 +25,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const self = await User.retrieveFromToken(token); + const { user: self } = await User.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/accounts/[id]/follow.ts b/server/api/api/v1/accounts/[id]/follow.ts index eed3a27f..0abf6b5e 100644 --- a/server/api/api/v1/accounts/[id]/follow.ts +++ b/server/api/api/v1/accounts/[id]/follow.ts @@ -3,6 +3,19 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/follow", + auth: { + required: true, + }, +}); /** * Follow a user @@ -13,13 +26,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const self = await User.retrieveFromToken(token); + const { user: self } = await User.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/accounts/[id]/index.ts b/server/api/api/v1/accounts/[id]/index.ts index 79ca69c1..ced8b5c9 100644 --- a/server/api/api/v1/accounts/[id]/index.ts +++ b/server/api/api/v1/accounts/[id]/index.ts @@ -1,6 +1,19 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id", + auth: { + required: true, + }, +}); /** * Fetch a user @@ -11,13 +24,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1]; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const user = await User.retrieveFromToken(token); + const { user } = await User.getFromRequest(req); let foundUser: User | null; try { diff --git a/server/api/api/v1/accounts/[id]/mute.ts b/server/api/api/v1/accounts/[id]/mute.ts index 4669ef34..99759de0 100644 --- a/server/api/api/v1/accounts/[id]/mute.ts +++ b/server/api/api/v1/accounts/[id]/mute.ts @@ -3,6 +3,19 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/mute", + auth: { + required: true, + }, +}); /** * Mute a user @@ -13,13 +26,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const self = await User.retrieveFromToken(token); + const { user: self } = await User.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/accounts/[id]/note.ts b/server/api/api/v1/accounts/[id]/note.ts index 66ba3b4c..2aeec023 100644 --- a/server/api/api/v1/accounts/[id]/note.ts +++ b/server/api/api/v1/accounts/[id]/note.ts @@ -3,6 +3,19 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/note", + auth: { + required: true, + }, +}); /** * Sets a user note @@ -13,13 +26,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const self = await User.retrieveFromToken(token); + const { user: self } = await User.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/accounts/[id]/pin.ts b/server/api/api/v1/accounts/[id]/pin.ts index db85fe3d..76f21186 100644 --- a/server/api/api/v1/accounts/[id]/pin.ts +++ b/server/api/api/v1/accounts/[id]/pin.ts @@ -2,6 +2,19 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/pin", + auth: { + required: true, + }, +}); /** * Pin a user @@ -12,13 +25,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const self = await User.retrieveFromToken(token); + const { user: self } = await User.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); 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 382c0930..f4beba31 100644 --- a/server/api/api/v1/accounts/[id]/remove_from_followers.ts +++ b/server/api/api/v1/accounts/[id]/remove_from_followers.ts @@ -2,6 +2,19 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/remove_from_followers", + auth: { + required: true, + }, +}); /** * Removes an account from your followers list @@ -12,13 +25,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const self = await User.retrieveFromToken(token); + const { user: self } = await User.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts index 5e5bae7b..e9221f4a 100644 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ b/server/api/api/v1/accounts/[id]/statuses.ts @@ -2,6 +2,19 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Status } from "~database/entities/Status"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/statuses", + auth: { + required: false, + }, +}); /** * Fetch all statuses for a user diff --git a/server/api/api/v1/accounts/[id]/unblock.ts b/server/api/api/v1/accounts/[id]/unblock.ts index 64734d08..b28f9f28 100644 --- a/server/api/api/v1/accounts/[id]/unblock.ts +++ b/server/api/api/v1/accounts/[id]/unblock.ts @@ -2,6 +2,19 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/unblock", + auth: { + required: true, + }, +}); /** * Blocks a user @@ -12,13 +25,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const self = await User.retrieveFromToken(token); + const { user: self } = await User.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/accounts/[id]/unfollow.ts b/server/api/api/v1/accounts/[id]/unfollow.ts index 4912e4fd..faace4fd 100644 --- a/server/api/api/v1/accounts/[id]/unfollow.ts +++ b/server/api/api/v1/accounts/[id]/unfollow.ts @@ -2,6 +2,19 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/unfollow", + auth: { + required: true, + }, +}); /** * Unfollows a user @@ -12,13 +25,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const self = await User.retrieveFromToken(token); + const { user: self } = await User.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/accounts/[id]/unmute.ts b/server/api/api/v1/accounts/[id]/unmute.ts index 07b6d5b6..1ad64bb2 100644 --- a/server/api/api/v1/accounts/[id]/unmute.ts +++ b/server/api/api/v1/accounts/[id]/unmute.ts @@ -2,6 +2,19 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/unmute", + auth: { + required: true, + }, +}); /** * Unmute a user @@ -12,13 +25,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const self = await User.retrieveFromToken(token); + const { user: self } = await User.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/accounts/[id]/unpin.ts b/server/api/api/v1/accounts/[id]/unpin.ts index 44686e92..c20cecc5 100644 --- a/server/api/api/v1/accounts/[id]/unpin.ts +++ b/server/api/api/v1/accounts/[id]/unpin.ts @@ -2,6 +2,19 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/accounts/:id/unpin", + auth: { + required: true, + }, +}); /** * Unpin a user @@ -12,13 +25,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const self = await User.retrieveFromToken(token); + const { user: self } = await User.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/accounts/familiar_followers/index.ts b/server/api/api/v1/accounts/familiar_followers/index.ts index ac4f42ab..2961fc47 100644 --- a/server/api/api/v1/accounts/familiar_followers/index.ts +++ b/server/api/api/v1/accounts/familiar_followers/index.ts @@ -2,18 +2,25 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { User } from "~database/entities/User"; import { APIAccount } from "~types/entities/account"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + route: "/api/v1/accounts/familiar_followers", + ratelimits: { + max: 30, + duration: 60, + }, + auth: { + required: true, + }, +}); /** * Find familiar followers (followers of a user that you also follow) */ export default async (req: Request): Promise => { - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const self = await User.retrieveFromToken(token); + const { user: self } = await User.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/accounts/index.ts b/server/api/api/v1/accounts/index.ts index 9e58a187..32f52c35 100644 --- a/server/api/api/v1/accounts/index.ts +++ b/server/api/api/v1/accounts/index.ts @@ -3,6 +3,19 @@ import { parseRequest } from "@request"; import { jsonResponse } from "@response"; import { tempmailDomains } from "@tempmail"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + route: "/api/v1/accounts", + ratelimits: { + max: 2, + duration: 60, + }, + auth: { + required: true, + }, +}); /** * Creates a new user diff --git a/server/api/api/v1/accounts/relationships/index.ts b/server/api/api/v1/accounts/relationships/index.ts index 8c847510..6e55e815 100644 --- a/server/api/api/v1/accounts/relationships/index.ts +++ b/server/api/api/v1/accounts/relationships/index.ts @@ -2,18 +2,25 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { Relationship } from "~database/entities/Relationship"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + route: "/api/v1/accounts/relationships", + ratelimits: { + max: 30, + duration: 60, + }, + auth: { + required: true, + }, +}); /** * Find relationships */ export default async (req: Request): Promise => { - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const self = await User.retrieveFromToken(token); + const { user: self } = await User.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index 6097e5ee..d515d7dc 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -2,22 +2,25 @@ import { getConfig } from "@config"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["PATCH"], + route: "/api/v1/accounts/update_credentials", + ratelimits: { + max: 2, + duration: 60, + }, + auth: { + required: true, + }, +}); /** * Patches a user */ export default async (req: Request): Promise => { - // Check if request is a PATCH request - if (req.method !== "PATCH") - return errorResponse("This method requires a PATCH request", 405); - - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const user = await User.retrieveFromToken(token); + const { user } = await User.getFromRequest(req); if (!user) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/accounts/verify_credentials/index.ts b/server/api/api/v1/accounts/verify_credentials/index.ts index 4c509626..1c82b064 100644 --- a/server/api/api/v1/accounts/verify_credentials/index.ts +++ b/server/api/api/v1/accounts/verify_credentials/index.ts @@ -1,22 +1,23 @@ import { errorResponse, jsonResponse } from "@response"; import { User } from "~database/entities/User"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + route: "/api/v1/accounts/verify_credentials", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, +}); -/** - * Patches a user - */ export default async (req: Request): Promise => { // TODO: Add checks for disabled or not email verified accounts - // Check if request is a PATCH request - if (req.method !== "GET") - return errorResponse("This method requires a GET request", 405); - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const user = await User.retrieveFromToken(token); + const { user } = await User.getFromRequest(req); if (!user) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/apps/index.ts b/server/api/api/v1/apps/index.ts index 8191b7f4..62ee7206 100644 --- a/server/api/api/v1/apps/index.ts +++ b/server/api/api/v1/apps/index.ts @@ -1,8 +1,21 @@ +import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { randomBytes } from "crypto"; import { Application } from "~database/entities/Application"; +export const meta = applyConfig({ + allowedMethods: ["POST"], + route: "/api/v1/apps", + ratelimits: { + max: 2, + duration: 60, + }, + auth: { + required: false, + }, +}); + /** * Creates a new application to obtain OAuth 2 credentials */ diff --git a/server/api/api/v1/apps/verify_credentials/index.ts b/server/api/api/v1/apps/verify_credentials/index.ts index be239ef9..14cf5437 100644 --- a/server/api/api/v1/apps/verify_credentials/index.ts +++ b/server/api/api/v1/apps/verify_credentials/index.ts @@ -1,18 +1,25 @@ +import { applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { Application } from "~database/entities/Application"; import { User } from "~database/entities/User"; +export const meta = applyConfig({ + allowedMethods: ["GET"], + route: "/api/v1/apps/verify_credentials", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, +}); + /** * Returns OAuth2 credentials */ export default async (req: Request): Promise => { - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const user = await User.retrieveFromToken(token); + const { user, token } = await User.getFromRequest(req); const application = await Application.getFromToken(token); if (!user) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/custom_emojis/index.ts b/server/api/api/v1/custom_emojis/index.ts index 248a6baf..2de2a801 100644 --- a/server/api/api/v1/custom_emojis/index.ts +++ b/server/api/api/v1/custom_emojis/index.ts @@ -1,9 +1,22 @@ +import { applyConfig } from "@api"; import { jsonResponse } from "@response"; import { IsNull } from "typeorm"; import { Emoji } from "~database/entities/Emoji"; +export const meta = applyConfig({ + allowedMethods: ["GET"], + route: "/api/v1/custom_emojis", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: false, + }, +}); + /** - * Creates a new user + * S */ // eslint-disable-next-line @typescript-eslint/require-await export default async (): Promise => { diff --git a/server/api/api/v1/instance/index.ts b/server/api/api/v1/instance/index.ts index c53223f3..c981d881 100644 --- a/server/api/api/v1/instance/index.ts +++ b/server/api/api/v1/instance/index.ts @@ -1,8 +1,21 @@ +import { applyConfig } from "@api"; import { getConfig } from "@config"; import { jsonResponse } from "@response"; import { Status } from "~database/entities/Status"; import { User } from "~database/entities/User"; +export const meta = applyConfig({ + allowedMethods: ["GET"], + route: "/api/v1/instance", + ratelimits: { + max: 300, + duration: 60, + }, + auth: { + required: false, + }, +}); + /** * Creates a new user */ diff --git a/server/api/api/v1/statuses/[id]/index.ts b/server/api/api/v1/statuses/[id]/index.ts index 2f623e35..e70369e3 100644 --- a/server/api/api/v1/statuses/[id]/index.ts +++ b/server/api/api/v1/statuses/[id]/index.ts @@ -1,8 +1,23 @@ +import { applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { RawObject } from "~database/entities/RawObject"; import { Status } from "~database/entities/Status"; import { User } from "~database/entities/User"; +import { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["GET", "DELETE"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id", + auth: { + required: false, + requiredOnMethods: ["DELETE"], + }, +}); /** * Fetch a user @@ -13,13 +28,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const user = await User.retrieveFromToken(token); + const { user } = await User.getFromRequest(req); // TODO: Add checks for user's permissions to view this status diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index 5e4a9f50..7fc3a3bb 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unused-vars */ +import { applyConfig } from "@api"; import { getConfig } from "@config"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; @@ -9,22 +10,25 @@ import { Application } from "~database/entities/Application"; import { RawObject } from "~database/entities/RawObject"; import { Status } from "~database/entities/Status"; import { User } from "~database/entities/User"; +import { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 300, + duration: 60, + }, + route: "/api/v1/statuses", + auth: { + required: true, + }, +}); /** * Post new status */ export default async (req: Request): Promise => { - // Check if request is a PATCH request - if (req.method !== "POST") - return errorResponse("This method requires a POST request", 405); - - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || null; - - if (!token) - return errorResponse("This method requires an authenticated user", 422); - - const user = await User.retrieveFromToken(token); + const { user, token } = await User.getFromRequest(req); const application = await Application.getFromToken(token); if (!user) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/timelines/home.ts b/server/api/api/v1/timelines/home.ts index 30788309..2a7a9e01 100644 --- a/server/api/api/v1/timelines/home.ts +++ b/server/api/api/v1/timelines/home.ts @@ -1,6 +1,20 @@ +import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { RawObject } from "~database/entities/RawObject"; +import { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 200, + duration: 60, + }, + route: "/api/v1/timelines/home", + auth: { + required: true, + }, +}); /** * Fetch home timeline statuses diff --git a/server/api/api/v1/timelines/public.ts b/server/api/api/v1/timelines/public.ts index 595a68c3..4474bf47 100644 --- a/server/api/api/v1/timelines/public.ts +++ b/server/api/api/v1/timelines/public.ts @@ -1,6 +1,20 @@ +import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { RawObject } from "~database/entities/RawObject"; +import { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 200, + duration: 60, + }, + route: "/api/v1/timelines/public", + auth: { + required: false, + }, +}); /** * Fetch public timeline statuses diff --git a/server/api/auth/login/index.ts b/server/api/auth/login/index.ts index 69629bf3..4f400578 100644 --- a/server/api/auth/login/index.ts +++ b/server/api/auth/login/index.ts @@ -1,9 +1,23 @@ +import { applyConfig } from "@api"; import { errorResponse } from "@response"; import { MatchedRoute } from "bun"; import { randomBytes } from "crypto"; import { Application } from "~database/entities/Application"; import { Token } from "~database/entities/Token"; import { User } from "~database/entities/User"; +import { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 4, + duration: 60, + }, + route: "/auth/login", + auth: { + required: false, + }, +}); /** * OAuth Code flow diff --git a/types/api.ts b/types/api.ts new file mode 100644 index 00000000..16d812cc --- /dev/null +++ b/types/api.ts @@ -0,0 +1,12 @@ +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")[]; + }; +} diff --git a/utils/api.ts b/utils/api.ts new file mode 100644 index 00000000..73125bb0 --- /dev/null +++ b/utils/api.ts @@ -0,0 +1,18 @@ +import { getConfig } from "@config"; +import { APIRouteMeta } from "~types/api"; + +export const applyConfig = (routeMeta: APIRouteMeta) => { + const config = getConfig(); + const newMeta = routeMeta; + + // 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]; + } + + return newMeta; +}; diff --git a/utils/config.ts b/utils/config.ts index 0a1efa5f..51398852 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -13,6 +13,7 @@ export interface ConfigType { base_url: string; bind: string; bind_port: string; + banned_ips: string[]; }; validation: { @@ -67,6 +68,19 @@ export interface ConfigType { log_requests_verbose: boolean; log_filters: boolean; }; + + ratelimits: { + duration_coeff: number; + max_coeff: number; + }; + + custom_ratelimits: Record< + string, + { + duration: number; + max: number; + } + >; [key: string]: unknown; } @@ -75,6 +89,7 @@ export const configDefaults: ConfigType = { bind: "http://0.0.0.0", bind_port: "8000", base_url: "http://fediproject.localhost:8000", + banned_ips: [], }, database: { host: "localhost", @@ -178,6 +193,11 @@ export const configDefaults: ConfigType = { log_requests_verbose: false, log_filters: true, }, + ratelimits: { + duration_coeff: 1, + max_coeff: 1, + }, + custom_ratelimits: {}, }; export const getConfig = () => {