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 e2ea5ecd..901f0977 100755 Binary files a/bun.lockb and b/bun.lockb differ 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 = () => {