diff --git a/.eslintrc.cjs b/.eslintrc.cjs index fe8ec85f..4e88644a 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -9,7 +9,7 @@ module.exports = { parserOptions: { project: "./tsconfig.json", }, - ignorePatterns: ["node_modules/", "dist/", ".eslintrc.cjs"], + ignorePatterns: ["node_modules/", "dist/", ".eslintrc.cjs", "cli.ts"], plugins: ["@typescript-eslint"], root: true, rules: { diff --git a/.gitignore b/.gitignore index 52091f63..38109eac 100644 --- a/.gitignore +++ b/.gitignore @@ -170,4 +170,6 @@ dist config/config.toml config/config.internal.toml uploads/ -pages/dist \ No newline at end of file +pages/dist +log.txt +*.log \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..980184cd --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "configurations": [ + { + "type": "node", + "name": "vscode-jest-tests.v2.lysand", + "request": "launch", + "args": [ + "test", + "${jest.testFile}" + ], + "cwd": "/home/jessew/Dev/lysand", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "program": "/home/jessew/.bun/bin/bun" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 4d360cbc..6286bcf3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,5 @@ { - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "jest.jestCommandLine": "/home/jessew/.bun/bin/bun test", + "jest.rootPath": "." } diff --git a/Dockerfile b/Dockerfile index 36ad1b37..51b9eb58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,10 @@ # use the official Bun image # see all versions at https://hub.docker.com/r/oven/bun/tags -FROM oven/bun:1.0.15-alpine as base +FROM oven/bun:1.0.30-alpine as base WORKDIR /usr/src/app -RUN apk add vips # Required for Prisma to work -COPY --from=node:18-alpine /usr/local/bin/node /usr/local/bin/node +# COPY --from=node:18-alpine /usr/local/bin/node /usr/local/bin/node # install dependencies into temp directory # this will cache them and speed up future builds @@ -15,15 +14,13 @@ FROM base AS install RUN mkdir -p /temp COPY . /temp WORKDIR /temp -RUN bun install --frozen-lockfile --production. +RUN bun install --frozen-lockfile --production # Build Vite in pages RUN bunx --bun vite build pages # Build the project -RUN bun build --entrypoints ./index.ts ./prisma.ts ./cli.ts --outdir dist --target bun --splitting --minify --external bullmq --external @prisma/client -RUN mkdir ./dist/pages -RUN cp -r ./pages/dist ./dist/pages +RUN bun run build.ts WORKDIR /temp/dist # copy production dependencies and source code into final image diff --git a/README.md b/README.md index 145fe01c..53a9f152 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,11 @@ ![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white) ![Bun](https://img.shields.io/badge/Bun-%23000000.svg?style=for-the-badge&logo=bun&logoColor=white) ![VS Code Insiders](https://img.shields.io/badge/VS%20Code%20Insiders-35b393.svg?style=for-the-badge&logo=visual-studio-code&logoColor=white) ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black) ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) ![ESLint](https://img.shields.io/badge/ESLint-4B3263?style=for-the-badge&logo=eslint&logoColor=white) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa?style=for-the-badge)](code_of_conduct.md) -> [!IMPORTANT] -> This project is **not abandoned**, my laptop merely broke and I am waiting for a new one to arrive - ## What is this? -This is a project to create a federated social network based on the [Lysand](https://lysand.org) protocol. It is currently in alpha phase, with basic federation and API support. +This is a project to create a federated social network based on the [Lysand](https://lysand.org) protocol. It is currently in alpha phase, with basic federation and almost complete Mastodon API support. -This project aims to be a fully featured social network, with a focus on privacy, security, and performance. It will implement the Mastodon API for support with clients that already support Mastodon or Pleroma. +This project aims to be a fully featured social network, with a focus on privacy, security, and performance. It implements the Mastodon API for support with clients that already support Mastodon or Pleroma. > [!NOTE] > This project is not affiliated with Mastodon or Pleroma, and is not a fork of either project. It is a new project built from the ground up. @@ -35,7 +32,7 @@ This project aims to be a fully featured social network, with a focus on privacy ## Benchmarks > [!NOTE] -> These benchmarks are not representative of real-world performance, and are only meant to be used as a rough guide. +> These benchmarks are not representative of real-world performance, and are only meant to be used as a rough guide. Load, and therefore performance, will vary depending on the server's hardware and software configuration, as well as user activity. ### Timeline Benchmarks @@ -67,18 +64,21 @@ $ bun run benchmarks/timelines.ts 10000 ✓ 10000 requests fulfilled in 12.44852s ``` -Lysand is extremely fast and can handle tens of thousands of HTTP requests per second on a good server. +Lysand is extremely fast and can handle thousands of HTTP requests per second on a good server. ## How do I run it? ### Requirements -- The [Bun Runtime](https://bun.sh), version 1.0.5 or later (usage of the latest version is recommended) +- The [Bun Runtime](https://bun.sh), version 1.0.30 or later (usage of the latest version is recommended) - A PostgreSQL database - (Optional but recommended) A Linux-based operating system - (Optional if you want search) A working Meiliseach instance -> **Note**: We will not be offerring support to Windows or MacOS users. If you are using one of these operating systems, please use a virtual machine or container to run Lysand. +> [!WARNING] +> Lysand has not been tested on Windows or MacOS. It is recommended to use a Linux-based operating system to run Lysand. +> +> We will not be offerring support to Windows or MacOS users. If you are using one of these operating systems, please use a virtual machine or container to run Lysand. ### Installation @@ -152,6 +152,9 @@ bun start ### Using the CLI +> [!WARNING] +> The CLI is currently broken due to unknown bugs that are actively being investigated. The following instructions are for when this is fixed. + Lysand includes a built-in CLI for managing the server. To use it, simply run the following command: ```bash @@ -279,10 +282,12 @@ Working endpoints are: - `/api/v1/blocks` - `/api/v1/mutes` - `/api/v2/media` +- `/api/v1/notifications` Tests needed but completed: - `/api/v1/media/:id` +- `/api/v2/media` - `/api/v1/favourites` - `/api/v1/accounts/:id/followers` - `/api/v1/accounts/:id/following` @@ -335,7 +340,6 @@ Endpoints left: - `/api/v1/lists/:id` (`GET`, `PUT`, `DELETE`) - `/api/v1/markers` (`GET`, `POST`) - `/api/v1/lists/:id/accounts` (`GET`, `POST`, `DELETE`) -- `/api/v1/notifications` - `/api/v1/notifications/:id` - `/api/v1/notifications/clear` - `/api/v1/notifications/:id/dismiss` diff --git a/benchmarks/timelines.ts b/benchmarks/timelines.ts index 410d5958..b5348b69 100644 --- a/benchmarks/timelines.ts +++ b/benchmarks/timelines.ts @@ -2,10 +2,10 @@ * Usage: TOKEN=your_token_here bun benchmark:timeline */ -import { getConfig } from "~classes/configmanager"; import chalk from "chalk"; +import { ConfigManager } from "config-manager"; -const config = getConfig(); +const config = await new ConfigManager({}).getConfig(); const token = process.env.TOKEN; const requestCount = Number(process.argv[2]) || 100; diff --git a/build.ts b/build.ts index 57b3bbc5..090d12a9 100644 --- a/build.ts +++ b/build.ts @@ -17,13 +17,17 @@ await Bun.build({ entrypoints: [ process.cwd() + "/index.ts", process.cwd() + "/prisma.ts", - process.cwd() + "./cli.ts", + // process.cwd() + "/cli.ts", ], outdir: process.cwd() + "/dist", target: "bun", splitting: true, minify: true, - external: ["bullmq", "@prisma/client"], + external: ["bullmq"], +}).then(output => { + if (!output.success) { + console.log(output.logs); + } }); // Create pages directory diff --git a/bun.lockb b/bun.lockb index f33d46fb..633b6622 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..bea1efe1 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install.scopes] +"@jsr" = "https://npm.jsr.io" diff --git a/classes/configmanager.ts b/classes/configmanager.ts deleted file mode 100644 index 77bd7d74..00000000 --- a/classes/configmanager.ts +++ /dev/null @@ -1,444 +0,0 @@ -/** - * @file configmanager.ts - * @summary ConfigManager system to retrieve and modify system configuration - * @description Can read from a hand-written file, config.toml, or from a machine-saved file, config.internal.toml - * Fuses both and provides a way to retrieve individual values - */ - -import { parse, stringify } from "@iarna/toml"; -import chalk from "chalk"; -import merge from "merge-deep-ts"; - -const scanConfig = async () => { - const config = Bun.file(process.cwd() + "/config/config.toml"); - - if (!(await config.exists())) { - console.error( - `${chalk.red(`✗`)} ${chalk.bold( - "Error while reading config: " - )} Config file not found` - ); - process.exit(1); - } - - return parse(await config.text()) as ConfigType; -}; - -// Creates the internal config with nothing in it if it doesnt exist -const scanInternalConfig = async () => { - const config = Bun.file(process.cwd() + "/config/config.internal.toml"); - - if (!(await config.exists())) { - await Bun.write(config, ""); - } - - return parse(await config.text()) as ConfigType; -}; - -let config = await scanConfig(); -const internalConfig = await scanInternalConfig(); - -export interface ConfigType { - database: { - host: string; - port: number; - username: string; - password: string; - database: string; - }; - - redis: { - queue: { - host: string; - port: number; - password: string; - database: number | null; - }; - cache: { - host: string; - port: number; - password: string; - database: number | null; - enabled: boolean; - }; - }; - - meilisearch: { - host: string; - port: number; - api_key: string; - enabled: boolean; - }; - - signups: { - tos_url: string; - rules: string[]; - registration: boolean; - }; - - oidc: { - providers: { - name: string; - id: string; - url: string; - client_id: string; - client_secret: string; - icon: string; - }[]; - }; - - http: { - base_url: string; - bind: string; - bind_port: string; - banned_ips: string[]; - banned_user_agents: string[]; - }; - - instance: { - name: string; - description: string; - banner: string; - logo: string; - }; - - smtp: { - server: string; - port: number; - username: string; - password: string; - tls: boolean; - }; - - validation: { - max_displayname_size: number; - max_bio_size: number; - max_username_size: number; - max_note_size: number; - max_avatar_size: number; - max_header_size: number; - max_media_size: number; - max_media_attachments: number; - max_media_description_size: number; - max_poll_options: number; - max_poll_option_size: number; - min_poll_duration: number; - max_poll_duration: number; - - username_blacklist: string[]; - blacklist_tempmail: boolean; - email_blacklist: string[]; - url_scheme_whitelist: string[]; - - enforce_mime_types: boolean; - allowed_mime_types: string[]; - }; - - media: { - backend: string; - deduplicate_media: boolean; - conversion: { - convert_images: boolean; - convert_to: string; - }; - }; - - s3: { - endpoint: string; - access_key: string; - secret_access_key: string; - region: string; - bucket_name: string; - public_url: string; - }; - - defaults: { - visibility: string; - language: string; - avatar: string; - header: string; - }; - - email: { - send_on_report: boolean; - send_on_suspend: boolean; - send_on_unsuspend: boolean; - }; - - activitypub: { - use_tombstones: boolean; - reject_activities: string[]; - force_followers_only: string[]; - discard_reports: string[]; - discard_deletes: string[]; - discard_banners: string[]; - discard_avatars: string[]; - discard_updates: string[]; - discard_follows: string[]; - force_sensitive: string[]; - remove_media: string[]; - fetch_all_collection_members: boolean; - authorized_fetch: boolean; - }; - - filters: { - note_filters: string[]; - username_filters: string[]; - displayname_filters: string[]; - bio_filters: string[]; - emoji_filters: string[]; - }; - - logging: { - log_requests: boolean; - 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; -} - -export const configDefaults: ConfigType = { - http: { - bind: "http://0.0.0.0", - bind_port: "8000", - base_url: "http://lysand.localhost:8000", - banned_ips: [], - banned_user_agents: [], - }, - database: { - host: "localhost", - port: 5432, - username: "postgres", - password: "postgres", - 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: 1491, - api_key: "", - enabled: false, - }, - signups: { - tos_url: "", - rules: [], - registration: false, - }, - oidc: { - providers: [], - }, - instance: { - banner: "", - description: "", - logo: "", - name: "", - }, - smtp: { - password: "", - port: 465, - server: "", - tls: true, - username: "", - }, - media: { - backend: "local", - deduplicate_media: true, - conversion: { - convert_images: false, - convert_to: "webp", - }, - }, - email: { - send_on_report: false, - send_on_suspend: false, - send_on_unsuspend: false, - }, - s3: { - access_key: "", - bucket_name: "", - endpoint: "", - public_url: "", - region: "", - secret_access_key: "", - }, - validation: { - max_displayname_size: 50, - max_bio_size: 6000, - max_note_size: 5000, - max_avatar_size: 5_000_000, - max_header_size: 5_000_000, - max_media_size: 40_000_000, - 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", - ], - - enforce_mime_types: false, - allowed_mime_types: [], - }, - defaults: { - visibility: "public", - language: "en", - avatar: "", - header: "", - }, - activitypub: { - use_tombstones: true, - reject_activities: [], - force_followers_only: [], - discard_reports: [], - discard_deletes: [], - discard_banners: [], - discard_avatars: [], - force_sensitive: [], - discard_updates: [], - discard_follows: [], - remove_media: [], - fetch_all_collection_members: false, - authorized_fetch: false, - }, - filters: { - note_filters: [], - username_filters: [], - displayname_filters: [], - bio_filters: [], - emoji_filters: [], - }, - logging: { - log_requests: false, - log_requests_verbose: false, - log_filters: true, - }, - ratelimits: { - duration_coeff: 1, - max_coeff: 1, - }, - custom_ratelimits: {}, -}; - -export const getConfig = () => { - // Deeply merge configDefaults, config and internalConfig - return merge([configDefaults, config, internalConfig]) as any as ConfigType; -}; - -/** - * Sets the internal config - * @param newConfig Any part of ConfigType - */ -export const setConfig = async (newConfig: Partial) => { - const newInternalConfig = merge([ - internalConfig, - newConfig, - ]) as any as ConfigType; - - // Prepend a warning comment and write the new TOML to the file - await Bun.write( - Bun.file(process.cwd() + "/config/config.internal.toml"), - `# This file is automatically generated. Do not modify it manually.\n${stringify( - newInternalConfig as any - )}` - ); -}; - -export const getHost = () => { - const url = new URL(getConfig().http.base_url); - - return url.host; -}; - -// Refresh config every 5 seconds -setInterval(() => { - scanConfig() - .then(newConfig => { - if (newConfig !== config) { - config = newConfig; - } - }) - .catch(e => { - console.error(e); - }); -}, 5000); - -export { config }; diff --git a/classes/media.ts b/classes/media.ts deleted file mode 100644 index 483a173d..00000000 --- a/classes/media.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { GetObjectCommandOutput } from "@aws-sdk/client-s3"; -import { - GetObjectCommand, - PutObjectCommand, - S3Client, -} from "@aws-sdk/client-s3"; -import type { ConfigType } from "~classes/configmanager"; -import sharp from "sharp"; -import { exists, mkdir } from "fs/promises"; -class MediaBackend { - backend: string; - - constructor(backend: string) { - this.backend = backend; - } - - /** - * Adds media to the media backend - * @param media - * @returns The hash of the file in SHA-256 (hex format) with the file extension added to it - */ - async addMedia(media: File) { - const hash = new Bun.SHA256() - .update(await media.arrayBuffer()) - .digest("hex"); - - return `${hash}.${media.name.split(".").pop()}`; - } - - async convertMedia(media: File, config: ConfigType) { - const sharpCommand = sharp(await media.arrayBuffer()); - - // Rename ".jpg" files to ".jpeg" to avoid sharp errors - let name = media.name; - if (media.name.endsWith(".jpg")) { - name = media.name.replace(".jpg", ".jpeg"); - } - - const fileFormatToConvertTo = config.media.conversion.convert_to; - - switch (fileFormatToConvertTo) { - case "png": - return new File( - [(await sharpCommand.png().toBuffer()).buffer] as any, - // Replace the file extension with PNG - name.replace(/\.[^/.]+$/, ".png"), - { - type: "image/png", - } - ); - case "webp": - return new File( - [(await sharpCommand.webp().toBuffer()).buffer] as any, - // Replace the file extension with WebP - name.replace(/\.[^/.]+$/, ".webp"), - { - type: "image/webp", - } - ); - case "jpeg": - return new File( - [(await sharpCommand.jpeg().toBuffer()).buffer] as any, - // Replace the file extension with JPEG - name.replace(/\.[^/.]+$/, ".jpeg"), - { - type: "image/jpeg", - } - ); - case "avif": - return new File( - [(await sharpCommand.avif().toBuffer()).buffer] as any, - // Replace the file extension with AVIF - name.replace(/\.[^/.]+$/, ".avif"), - { - type: "image/avif", - } - ); - // Needs special build of libvips - case "jxl": - return new File( - [(await sharpCommand.jxl().toBuffer()).buffer] as any, - // Replace the file extension with JXL - name.replace(/\.[^/.]+$/, ".jxl"), - { - type: "image/jxl", - } - ); - case "heif": - return new File( - [(await sharpCommand.heif().toBuffer()).buffer] as any, - // Replace the file extension with HEIF - name.replace(/\.[^/.]+$/, ".heif"), - { - type: "image/heif", - } - ); - default: - return media; - } - } - - /** - * Retrieves element from media backend by hash - * @param hash The hash of the element in SHA-256 hex format - * @param extension The extension of the file - * @returns The file as a File object - */ - // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars - async getMediaByHash( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - hash: string - ): Promise { - return new File([], "test"); - } -} - -/** - * S3 Backend, stores files in S3 - */ -export class S3Backend extends MediaBackend { - client: S3Client; - config: ConfigType; - - constructor(config: ConfigType) { - super("s3"); - - this.config = config; - - this.client = new S3Client({ - endpoint: this.config.s3.endpoint, - region: this.config.s3.region || "auto", - credentials: { - accessKeyId: this.config.s3.access_key, - secretAccessKey: this.config.s3.secret_access_key, - }, - }); - } - - async addMedia(media: File): Promise { - if (this.config.media.conversion.convert_images) { - media = await this.convertMedia(media, this.config); - } - - const hash = await super.addMedia(media); - - if (!hash) { - throw new Error("Failed to hash file"); - } - - // Check if file is already present - const existingFile = await this.getMediaByHash(hash); - - if (existingFile) { - // File already exists, so return the hash without uploading it - return hash; - } - - const command = new PutObjectCommand({ - Bucket: this.config.s3.bucket_name, - Key: hash, - Body: Buffer.from(await media.arrayBuffer()), - ContentType: media.type, - ContentLength: media.size, - Metadata: { - "x-amz-meta-original-name": media.name, - }, - }); - - const response = await this.client.send(command); - - if (response.$metadata.httpStatusCode !== 200) { - throw new Error("Failed to upload file"); - } - - return hash; - } - - async getMediaByHash(hash: string): Promise { - const command = new GetObjectCommand({ - Bucket: this.config.s3.bucket_name, - Key: hash, - }); - - let response: GetObjectCommandOutput; - - try { - response = await this.client.send(command); - } catch { - return null; - } - - if (response.$metadata.httpStatusCode !== 200) { - throw new Error("Failed to get file"); - } - - const body = await response.Body?.transformToByteArray(); - - if (!body) { - throw new Error("Failed to get file"); - } - - return new File([body], hash, { - type: response.ContentType, - }); - } -} - -/** - * Local backend, stores files on filesystem - */ -export class LocalBackend extends MediaBackend { - config: ConfigType; - - constructor(config: ConfigType) { - super("local"); - - this.config = config; - } - - async addMedia(media: File): Promise { - if (this.config.media.conversion.convert_images) { - media = await this.convertMedia(media, this.config); - } - - const hash = await super.addMedia(media); - - if (!(await exists(`${process.cwd()}/uploads`))) { - await mkdir(`${process.cwd()}/uploads`); - } - - await Bun.write(Bun.file(`${process.cwd()}/uploads/${hash}`), media); - - return hash; - } - - async getMediaByHash(hash: string): Promise { - const file = Bun.file(`${process.cwd()}/uploads/${hash}`); - - if (!(await file.exists())) { - return null; - } - - return new File([await file.arrayBuffer()], `${hash}`, { - type: file.type, - }); - } -} - -export const uploadFile = (file: File, config: ConfigType) => { - const backend = config.media.backend; - - if (backend === "local") { - return new LocalBackend(config).addMedia(file); - } else if (backend === "s3") { - return new S3Backend(config).addMedia(file); - } -}; - -export const getFile = ( - hash: string, - extension: string, - config: ConfigType -) => { - const backend = config.media.backend; - - if (backend === "local") { - return new LocalBackend(config).getMediaByHash(hash); - } else if (backend === "s3") { - return new S3Backend(config).getMediaByHash(hash); - } - - return null; -}; diff --git a/cli.ts b/cli.ts index c94c14d7..1392520e 100644 --- a/cli.ts +++ b/cli.ts @@ -1,23 +1,139 @@ -import type { Prisma } from "@prisma/client"; import chalk from "chalk"; -import { client } from "~database/datasource"; import { createNewLocalUser } from "~database/entities/User"; import Table from "cli-table"; import { rebuildSearchIndexes, MeiliIndexType } from "@meilisearch"; -import { getConfig } from "~classes/configmanager"; -import { uploadFile } from "~classes/media"; import { getUrl } from "~database/entities/Attachment"; import { mkdir, exists } from "fs/promises"; import extract from "extract-zip"; +import { client } from "~database/datasource"; +import { CliBuilder, CliCommand } from "cli-parser"; +import { CliParameterType } from "~packages/cli-parser/cli-builder.type"; +import { PrismaClient } from "@prisma/client"; +import { ConfigManager } from "~packages/config-manager"; const args = process.argv; +const config = await new ConfigManager({}).getConfig(); + +console.error("CLI is temporarily broken, please use the Prisma CLI instead"); +process.exit(1); + +const cliBuilder = new CliBuilder([ + new CliCommand( + ["help"], + [], + () => { + cliBuilder.displayHelp(); + }, + "Shows help for the CLI" + ), + 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, + }, + ], + (instance: CliCommand, args) => { + const { username, password, email, admin, help } = args; + + if (help) { + instance.displayHelp(); + return; + } + + // Check if username, password and email are provided + if (!username || !password || !email) { + console.log( + `${chalk.red(`✗`)} Missing username, password or email` + ); + return; + } + + // Check if user already exists + void client.user + .findFirst({ + where: { + OR: [{ username }, { email }], + }, + }) + .then(user => { + if (user) { + console.log(`${chalk.red(`✗`)} User already exists`); + return; + } + + console.log("Sus"); + + // 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)") : ""}` + ); */ + }); + }, + "Creates a new user", + "bun cli user create --username admin --password password123 --email email@email.com" + ), +]); + +cliBuilder.processArgs(args); + +process.exit(0); + /** * Make the text have a width of 20 characters, padding with gray dots * Text can be a Chalk string, in which case formatting codes should not be counted in text length * @param text The text to align */ -const alignDots = (text: string, length = 20) => { +/* const alignDots = (text: string, length = 20) => { // Remove formatting codes // eslint-disable-next-line no-control-regex const textLength = text.replace(/\u001b\[\d+m/g, "").length; @@ -1065,3 +1181,4 @@ switch (command) { } process.exit(0); + */ diff --git a/config/config.example.toml b/config/config.example.toml index e9b95555..de256901 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -71,6 +71,8 @@ tls = true backend = "s3" # Whether to check the hash of media when uploading to avoid duplication deduplicate_media = true +# If media backend is "local", this is the folder where the files will be stored +local_uploads_folder = "uploads" [media.conversion] convert_images = false @@ -270,6 +272,8 @@ emoji_filters = [] # NOT IMPLEMENTED log_requests = true # Log request and their contents (warning: this is a lot of data) log_requests_verbose = false +# For GDPR compliance, you can disable logging of IPs +log_ip = false # Log all filtered objects log_filters = true diff --git a/database/datasource.ts b/database/datasource.ts index b2a1e23a..334d2d58 100644 --- a/database/datasource.ts +++ b/database/datasource.ts @@ -1,8 +1,8 @@ import { Queue } from "bullmq"; -import { getConfig } from "../utils/config"; import { PrismaClient } from "@prisma/client"; +import { ConfigManager } from "config-manager"; -const config = getConfig(); +const config = await new ConfigManager({}).getConfig(); const client = new PrismaClient({ datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`, diff --git a/database/entities/Attachment.ts b/database/entities/Attachment.ts index fefa8cd7..abb07c9e 100644 --- a/database/entities/Attachment.ts +++ b/database/entities/Attachment.ts @@ -1,5 +1,6 @@ -import type { ConfigType } from "~classes/configmanager"; import type { Attachment } from "@prisma/client"; +import type { ConfigType } from "config-manager"; +import { MediaBackendType } from "media-manager"; import type { APIAsyncAttachment } from "~types/entities/async_attachment"; import type { APIAttachment } from "~types/entities/attachment"; @@ -56,11 +57,13 @@ export const attachmentToAPI = ( }; }; -export const getUrl = (hash: string, config: ConfigType) => { - if (config.media.backend === "local") { - return `${config.http.base_url}/media/${hash}`; - } else if (config.media.backend === "s3") { - return `${config.s3.public_url}/${hash}`; +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 + } else 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 e72af19c..5a610fc4 100644 --- a/database/entities/Emoji.ts +++ b/database/entities/Emoji.ts @@ -95,25 +95,3 @@ export const emojiToActivityPub = (emoji: Emoji): any => { }, }; }; - -export const addAPEmojiIfNotExists = async (apEmoji: any) => { - // replace any with your ActivityPub Emoji type - const existingEmoji = await client.emoji.findFirst({ - where: { - shortcode: apEmoji.name.replace(/:/g, ""), - instance: null, - }, - }); - - if (existingEmoji) return existingEmoji; - - return await client.emoji.create({ - data: { - shortcode: apEmoji.name.replace(/:/g, ""), - url: apEmoji.icon.url, - alt: apEmoji.icon.alt || null, - content_type: apEmoji.icon.mediaType, - visible_in_picker: true, - }, - }); -}; diff --git a/database/entities/Like.ts b/database/entities/Like.ts index c1839b90..037fd547 100644 --- a/database/entities/Like.ts +++ b/database/entities/Like.ts @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import type { Like as LysandLike } from "~types/lysand/Object"; -import { getConfig } from "~classes/configmanager"; import type { Like } from "@prisma/client"; import { client } from "~database/datasource"; import type { UserWithRelations } from "./User"; import type { StatusWithRelations } from "./Status"; +import { ConfigManager } from "config-manager"; + +const config = await new ConfigManager({}).getConfig(); /** * Represents a Like entity in the database. @@ -16,7 +18,7 @@ export const toLysand = (like: Like): LysandLike => { type: "Like", created_at: new Date(like.createdAt).toISOString(), object: (like as any).liked?.uri, - uri: `${getConfig().http.base_url}/actions/${like.id}`, + uri: `${config.http.base_url}/actions/${like.id}`, }; }; diff --git a/database/entities/Queue.ts b/database/entities/Queue.ts index 90ebd37b..9364497e 100644 --- a/database/entities/Queue.ts +++ b/database/entities/Queue.ts @@ -1,4 +1,3 @@ -import { getConfig } from "~classes/configmanager"; import { Worker } from "bullmq"; import { client, federationQueue } from "~database/datasource"; import { @@ -7,8 +6,9 @@ import { type StatusWithRelations, } from "./Status"; import type { User } from "@prisma/client"; +import { ConfigManager } from "config-manager"; -const config = getConfig(); +const config = await new ConfigManager({}).getConfig(); export const federationWorker = new Worker( "federation", @@ -44,7 +44,7 @@ export const federationWorker = new Worker( instanceId: { not: null, }, - } + } : {}, // Mentioned users { diff --git a/database/entities/Status.ts b/database/entities/Status.ts index ee1d5496..b3ff0d6c 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { getConfig } from "~classes/configmanager"; import type { UserWithRelations } from "./User"; import { fetchRemoteUser, @@ -29,8 +28,9 @@ import { parse } from "marked"; import linkifyStr from "linkify-string"; import linkifyHtml from "linkify-html"; import { addStausToMeilisearch } from "@meilisearch"; +import { ConfigManager } from "config-manager"; -const config = getConfig(); +const config = await new ConfigManager({}).getConfig(); export const statusAndUserRelations: Prisma.StatusInclude = { author: { @@ -211,7 +211,7 @@ export const fetchFromRemote = async (uri: string): Promise => { ? { status: replyStatus, user: (replyStatus as any).author, - } + } : undefined, quote: quotingStatus || undefined, }); @@ -349,7 +349,9 @@ export const createNewStatus = async (data: { // Get HTML version of content if (data.content_type === "text/markdown") { - formattedContent = linkifyHtml(await sanitizeHtml(parse(data.content))); + formattedContent = linkifyHtml( + await sanitizeHtml(await parse(data.content)) + ); } else if (data.content_type === "text/x.misskeymarkdown") { // Parse as MFM } else { @@ -387,7 +389,7 @@ export const createNewStatus = async (data: { id: attachment, }; }), - } + } : undefined, inReplyToPostId: data.reply?.status.id, quotingPostId: data.quote?.id, @@ -480,7 +482,9 @@ export const editStatus = async ( // Get HTML version of content if (data.content_type === "text/markdown") { - formattedContent = linkifyHtml(await sanitizeHtml(parse(data.content))); + formattedContent = linkifyHtml( + await sanitizeHtml(await parse(data.content)) + ); } else if (data.content_type === "text/x.misskeymarkdown") { // Parse as MFM } else { @@ -519,7 +523,7 @@ export const editStatus = async ( id: attachment, }; }), - } + } : undefined, mentions: { connect: mentions.map(mention => { @@ -606,15 +610,15 @@ export const statusToAPI = async ( quote: status.quotingPost ? await statusToAPI( status.quotingPost as unknown as StatusWithRelations - ) + ) : null, quote_id: status.quotingPost?.id || undefined, }; }; -export const statusToActivityPub = async ( - status: StatusWithRelations, - user?: UserWithRelations +/* export const statusToActivityPub = async ( + status: StatusWithRelations + // user?: UserWithRelations ): Promise => { // replace any with your ActivityPub type return { @@ -657,7 +661,7 @@ export const statusToActivityPub = async ( visibility: "public", // adjust as needed // add more fields as needed }; -}; +}; */ export const statusToLysand = (status: StatusWithRelations): Note => { return { diff --git a/database/entities/User.ts b/database/entities/User.ts index e6ed2275..744c1767 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -1,5 +1,3 @@ -import type { ConfigType } from "~classes/configmanager"; -import { getConfig } from "~classes/configmanager"; import type { APIAccount } from "~types/entities/account"; import type { User as LysandUser } from "~types/lysand/Object"; import { htmlToText } from "html-to-text"; @@ -10,6 +8,10 @@ import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji"; import { addInstanceIfNotExists } from "./Instance"; import type { APISource } from "~types/entities/source"; import { addUserToMeilisearch } from "@meilisearch"; +import { ConfigManager, type ConfigType } from "config-manager"; + +const configManager = new ConfigManager({}); +const config = await configManager.getConfig(); export interface AuthData { user: UserWithRelations | null; @@ -201,7 +203,7 @@ export const createNewLocalUser = async (data: { header?: string; admin?: boolean; }) => { - const config = getConfig(); + const config = await configManager.getConfig(); const keys = await generateUserKeys(); @@ -344,8 +346,6 @@ export const userToAPI = ( user: UserWithRelations, isOwnAccount = false ): APIAccount => { - const config = getConfig(); - return { id: user.id, username: user.username, @@ -373,7 +373,7 @@ export const userToAPI = ( header_static: "", acct: user.instance === null - ? `${user.username}` + ? user.username : `${user.username}@${user.instance.base_url}`, // TODO: Add these fields limited: false, @@ -424,13 +424,13 @@ export const userToLysand = (user: UserWithRelations): LysandUser => { username: user.username, avatar: [ { - content: getAvatarUrl(user, getConfig()) || "", + content: getAvatarUrl(user, config) || "", content_type: `image/${user.avatar.split(".")[1]}`, }, ], header: [ { - content: getHeaderUrl(user, getConfig()) || "", + content: getHeaderUrl(user, config) || "", content_type: `image/${user.header.split(".")[1]}`, }, ], @@ -458,7 +458,7 @@ export const userToLysand = (user: UserWithRelations): LysandUser => { ], })), public_key: { - actor: `${getConfig().http.base_url}/users/${user.id}`, + actor: `${config.http.base_url}/users/${user.id}`, public_key: user.publicKey, }, extensions: { diff --git a/index.ts b/index.ts index c096a408..b3c79055 100644 --- a/index.ts +++ b/index.ts @@ -1,39 +1,36 @@ -import { getConfig } from "~classes/configmanager"; -import { jsonResponse } from "@response"; -import chalk from "chalk"; -import { appendFile } from "fs/promises"; -import { matches } from "ip-matching"; -import { getFromRequest } from "~database/entities/User"; -import { mkdir } from "fs/promises"; import type { PrismaClientInitializationError } from "@prisma/client/runtime/library"; import { initializeRedisCache } from "@redis"; import { connectMeili } from "@meilisearch"; -import { matchRoute } from "~routes"; +import { ConfigManager } from "config-manager"; +import { client } from "~database/datasource"; +import { LogLevel, LogManager, MultiLogManager } from "log-manager"; +import { moduleIsEntry } from "@module"; +import { createServer } from "~server"; const timeAtStart = performance.now(); -console.log(`${chalk.green(`>`)} ${chalk.bold("Starting Lysand...")}`); +const configManager = new ConfigManager({}); +const config = await configManager.getConfig(); -const config = getConfig(); 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 consoleLogger = new LogManager( + isEntry ? Bun.stdout : Bun.file(`/dev/null`) +); +const dualLogger = new MultiLogManager([logger, consoleLogger]); -// Needs to be imported after config is loaded -import { client } from "~database/datasource"; +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"); -if (!(await requests_log.exists())) { - console.log(`${chalk.green(`✓`)} ${chalk.bold("Creating logs folder...")}`); - await mkdir(process.cwd() + "/logs"); - await Bun.write(process.cwd() + "/logs/requests.log", ""); -} - const redisCache = await initializeRedisCache(); if (config.meilisearch.enabled) { - await connectMeili(); + await connectMeili(dualLogger); } if (redisCache) { @@ -46,165 +43,23 @@ try { postCount = await client.status.count(); } catch (e) { const error = e as PrismaClientInitializationError; - console.error( - `${chalk.red(`✗`)} ${chalk.bold( - "Error while connecting to database: " - )} ${error.message}` - ); + await logger.logError(LogLevel.CRITICAL, "Database", error); + await consoleLogger.logError(LogLevel.CRITICAL, "Database", error); process.exit(1); } -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 ?? ""; +const server = createServer(config, configManager, dualLogger, isProd); - 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; - } - } - - await logRequest(req); - - if (req.method === "OPTIONS") { - return jsonResponse({}); - } - - const { file, matchedRoute } = matchRoute(req.url); - - if (matchedRoute) { - const meta = (await 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 - 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", - }); - } - } - - return await (await file).default(req.clone(), matchedRoute, auth); - } else { - // Proxy response from Vite at localhost:5173 if in development mode - if (isProd) { - if (new URL(req.url).pathname.startsWith("/assets")) { - // Serve from pages/dist/assets - return new Response( - Bun.file(`./pages/dist${new URL(req.url).pathname}`) - ); - } - - // Serve from pages/dist - return new Response(Bun.file(`./pages/dist/index.html`)); - } else { - const proxy = await fetch( - req.url.replace( - config.http.base_url, - "http://localhost:5173" - ) - ); - - if (proxy.status !== 404) { - return proxy; - } - } - - return new Response(undefined, { - status: 404, - statusText: "Route not found", - }); - } - }, -}); - -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 - // @ts-expect-error TypeScript is missing entries for some reason - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - 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` - ); - } -}; - -// Remove previous console.log -// console.clear(); - -console.log( - `${chalk.green(`✓`)} ${chalk.bold( - `Lysand started at ${chalk.blue( - `${config.http.bind}:${config.http.bind_port}` - )} in ${chalk.gray((performance.now() - timeAtStart).toFixed(0))}ms` - )}` +await dualLogger.log( + LogLevel.INFO, + "Server", + `Lysand started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms` ); -console.log( - `${chalk.green(`✓`)} ${chalk.bold(`Database is ${chalk.blue("online")}`)}` +await dualLogger.log( + LogLevel.INFO, + "Database", + `Database is online, now serving ${postCount} posts` ); -// Print "serving x posts" -console.log( - `${chalk.green(`✓`)} ${chalk.bold( - `Serving ${chalk.blue(postCount)} posts` - )}` -); +export { config, server }; diff --git a/package.json b/package.json index ecacd291..8bae2a0e 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "start": "NODE_ENV=production bun run dist/index.js --prod", "migrate-dev": "bun prisma migrate dev", "migrate": "bun prisma migrate deploy", - "lint": "eslint --config .eslintrc.cjs --ext .ts .", + "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", @@ -109,6 +109,11 @@ "prisma": "^5.6.0", "prisma-redis-middleware": "^4.8.0", "semver": "^7.5.4", - "sharp": "^0.33.0-rc.2" + "sharp": "^0.33.0-rc.2", + "request-parser": "file:packages/request-parser", + "config-manager": "file:packages/config-manager", + "cli-parser": "file:packages/cli-parser", + "log-manager": "file:packages/log-manager", + "media-manager": "file:packages/media-manager" } } \ No newline at end of file diff --git a/packages/cli-parser/bun.lockb b/packages/cli-parser/bun.lockb new file mode 100755 index 00000000..249be439 Binary files /dev/null and b/packages/cli-parser/bun.lockb differ diff --git a/packages/cli-parser/cli-builder.type.ts b/packages/cli-parser/cli-builder.type.ts new file mode 100644 index 00000000..89c6dece --- /dev/null +++ b/packages/cli-parser/cli-builder.type.ts @@ -0,0 +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; +} + +export enum CliParameterType { + STRING = "string", + NUMBER = "number", + BOOLEAN = "boolean", + ARRAY = "array", + EMPTY = "empty", +} diff --git a/packages/cli-parser/index.ts b/packages/cli-parser/index.ts new file mode 100644 index 00000000..25dccc55 --- /dev/null +++ b/packages/cli-parser/index.ts @@ -0,0 +1,398 @@ +import { CliParameterType, type CliParameter } from "./cli-builder.type"; +import chalk from "chalk"; + +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]); +} + +interface TreeType { + [key: string]: CliCommand | TreeType; +} + +/** + * Builder for a CLI + * @param commands Array of commands to register + */ +export class CliBuilder { + 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 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 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] + ) + ); + } + + /** + * 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; + } + } + + /** + * Turn raw system args into a CLI command and run it + * @param args Args directly from process.argv + */ + processArgs(args: string[]) { + const revelantArgs = this.getRelevantArgs(args); + // 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) + ); + + // Get command with largest category size + const command = matchingCommands.reduce((prev, current) => + prev.categories.length > current.categories.length ? prev : current + ); + + const argsWithoutCategories = args.slice(command.categories.length - 1); + + 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 = {}; + + 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] = {}; + } + + // Move down to the next level of the tree + currentLevel = currentLevel[part] as TreeType; + } + } + + return tree; + } + + /** + * Display help for every command in a tree manner + */ + displayHelp() { + /* + user + set + admin: List of admin commands + --prod: Whether to run in production + --dev: Whether to run in development + username: Username of the admin + Example: user set admin --prod --dev --username John + delete + ... + verify + ... + */ + 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) + ); + + 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); + } + } + }; + + 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("|"); + return Math.max(Number(prev), left.length); + }) + ); + + for (const line of writeBuffer.split("\n")) { + const [left, right] = line.split("|"); + if (!right) { + console.log(left); + continue; + } + const dots = ".".repeat(optimal_length + 5 - left.length); + console.log(`${left}${dots}${right}`); + } + } +} + +type ExecuteFunction = ( + instance: CliCommand, + args: Partial +) => Promise | 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 = ` +${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")} +${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)}` : ""} +`; + + 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; + + 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 + ); + if (positionedArgType) { + parsedArgs[positionedArgType.name] = this.castArgValue( + arg, + positionedArgType.type + ); + } + } + } + + 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; + } + } + + /** + * Runs the execute function with the parsed parameters as an argument + */ + run(argsWithoutCategories: string[]) { + const args = this.parseArgs(argsWithoutCategories); + void this.execute(this, args as any); + } +} diff --git a/packages/cli-parser/package.json b/packages/cli-parser/package.json new file mode 100644 index 00000000..acc2e3ee --- /dev/null +++ b/packages/cli-parser/package.json @@ -0,0 +1,6 @@ +{ + "name": "arg-parser", + "version": "0.0.0", + "main": "index.ts", + "dependencies": { "strip-ansi": "^7.1.0" } +} \ No newline at end of file diff --git a/packages/cli-parser/tests/cli-builder.test.ts b/packages/cli-parser/tests/cli-builder.test.ts new file mode 100644 index 00000000..b45d713c --- /dev/null +++ b/packages/cli-parser/tests/cli-builder.test.ts @@ -0,0 +1,485 @@ +// 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 stripAnsi from "strip-ansi"; +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 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 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; + + 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 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 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 run the execute function with the parsed parameters", () => { + 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 + ); + + 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", () => { + 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 + ); + + 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", + }); + }); + + 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.displayHelp(); + + const loggedString = consoleLogSpy.mock.calls.map(call => + stripAnsi(call[0]) + )[0]; + + 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" + ); + }); +}); + +describe("CliBuilder", () => { + 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]); + }); + + 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 error when adding duplicates", () => { + expect(() => { + cliBuilder.registerCommand(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 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", () => { + const mockExecute = jest.fn(); + const mockCommand = new CliCommand( + ["category1", "sub1"], + [ + { + name: "arg1", + type: CliParameterType.STRING, + needsValue: true, + positioned: false, + }, + ], + mockExecute + ); + cliBuilder.registerCommand(mockCommand); + 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; + + 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 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 show help menu", () => { + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => { + // Do nothing + }); + + 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" + ); + + cliBuilder.registerCommand(cliCommand); + cliBuilder.displayHelp(); + + const loggedString = consoleLogSpy.mock.calls + .map(call => stripAnsi(call[0])) + .join("\n"); + + 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" + ); + }); +}); diff --git a/utils/config.ts b/packages/config-manager/config-type.type.ts similarity index 87% rename from utils/config.ts rename to packages/config-manager/config-type.type.ts index a672a613..9e654609 100644 --- a/utils/config.ts +++ b/packages/config-manager/config-type.type.ts @@ -1,22 +1,4 @@ -import { parse } from "@iarna/toml"; -import chalk from "chalk"; - -const scanConfig = async () => { - const config = Bun.file(process.cwd() + "/config/config.toml"); - - if (!(await config.exists())) { - console.error( - `${chalk.red(`✗`)} ${chalk.bold( - "Error while reading config: " - )} Config file not found` - ); - process.exit(1); - } - - return parse(await config.text()) as ConfigType; -}; - -let config = await scanConfig(); +import type { MediaBackendType } from "media-manager"; export interface ConfigType { database: { @@ -72,6 +54,7 @@ export interface ConfigType { bind: string; bind_port: string; banned_ips: string[]; + banned_user_agents: string[]; }; instance: { @@ -114,12 +97,13 @@ export interface ConfigType { }; media: { - backend: string; + backend: MediaBackendType; deduplicate_media: boolean; conversion: { convert_images: boolean; convert_to: string; }; + local_uploads_folder: string; }; s3: { @@ -171,6 +155,7 @@ export interface ConfigType { logging: { log_requests: boolean; log_requests_verbose: boolean; + log_ip: boolean; log_filters: boolean; }; @@ -195,6 +180,7 @@ export const configDefaults: ConfigType = { bind_port: "8000", base_url: "http://lysand.localhost:8000", banned_ips: [], + banned_user_agents: [], }, database: { host: "localhost", @@ -252,6 +238,7 @@ export const configDefaults: ConfigType = { convert_images: false, convert_to: "webp", }, + local_uploads_folder: "uploads", }, email: { send_on_report: false, @@ -367,6 +354,7 @@ export const configDefaults: ConfigType = { logging: { log_requests: false, log_requests_verbose: false, + log_ip: false, log_filters: true, }, ratelimits: { @@ -375,31 +363,3 @@ export const configDefaults: ConfigType = { }, custom_ratelimits: {}, }; - -export const getConfig = () => { - return { - ...configDefaults, - ...config, - }; -}; - -export const getHost = () => { - const url = new URL(getConfig().http.base_url); - - return url.host; -}; - -// Refresh config every 5 seconds -setInterval(() => { - scanConfig() - .then(newConfig => { - if (newConfig !== config) { - config = newConfig; - } - }) - .catch(e => { - console.error(e); - }); -}, 5000); - -export { config }; diff --git a/packages/config-manager/index.ts b/packages/config-manager/index.ts new file mode 100644 index 00000000..d9cbb52f --- /dev/null +++ b/packages/config-manager/index.ts @@ -0,0 +1,122 @@ +/** + * @file index.ts + * @summary ConfigManager system to retrieve and modify system configuration + * @description Can read from a hand-written file, config.toml, or from a machine-saved file, config.internal.toml + * Fuses both and provides a way to retrieve individual values + */ + +import { parse, stringify, type JsonMap } from "@iarna/toml"; +import type { ConfigType } from "./config-type.type"; +import { configDefaults } from "./config-type.type"; +import merge from "merge-deep-ts"; + +export class ConfigManager { + constructor( + public config: { + configPathOverride?: string; + internalConfigPathOverride?: string; + } + ) {} + + /** + * @summary Reads the config files and returns the merge as a JSON object + * @returns {Promise} The merged config file as a JSON object + */ + async getConfig() { + const config = await this.readConfig(); + const internalConfig = await this.readInternalConfig(); + + return this.mergeConfigs(config, internalConfig); + } + + getConfigPath() { + return ( + this.config.configPathOverride || + process.cwd() + "/config/config.toml" + ); + } + + getInternalConfigPath() { + return ( + this.config.internalConfigPathOverride || + process.cwd() + "/config/config.internal.toml" + ); + } + + /** + * @summary Reads the internal config file and returns it as a JSON object + * @returns {Promise} The internal config file as a JSON object + */ + private async readInternalConfig() { + const config = Bun.file(this.getInternalConfigPath()); + + if (!(await config.exists())) { + await Bun.write(config, ""); + } + + return this.parseConfig(await config.text()); + } + + /** + * @summary Reads the config file and returns it as a JSON object + * @returns {Promise} The config file as a JSON object + */ + private async readConfig() { + const config = Bun.file(this.getConfigPath()); + + if (!(await config.exists())) { + throw new Error( + `Error while reading config at path ${this.getConfigPath()}: Config file not found` + ); + } + + return this.parseConfig(await config.text()); + } + + /** + * @summary Parses a TOML string and returns it as a JSON object + * @param text The TOML string to parse + * @returns {T = ConfigType} The parsed TOML string as a JSON object + * @throws {Error} If the TOML string is invalid + * @private + */ + private parseConfig(text: string) { + try { + // To all [Symbol] keys from the object + return JSON.parse(JSON.stringify(parse(text))) as T; + } catch (e: any) { + throw new Error( + `Error while parsing config at path ${this.getConfigPath()}: ${e}` + ); + } + } + + /** + * Writes changed values to the internal config + * @param config The new config object + */ + async writeConfig(config: T) { + const path = this.getInternalConfigPath(); + const file = Bun.file(path); + + await Bun.write( + file, + `# THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT IT MANUALLY, EDIT THE STANDARD CONFIG.TOML INSTEAD.\n${stringify( + config as JsonMap + )}` + ); + } + + /** + * @summary Merges two config objects together, with + * the latter configs' values taking precedence + * @param configs + * @returns + */ + private mergeConfigs(...configs: T[]) { + return merge(configs) as T; + } +} + +export type { ConfigType }; +export const defaultConfig = configDefaults; diff --git a/packages/config-manager/package.json b/packages/config-manager/package.json new file mode 100644 index 00000000..e3c7ad60 --- /dev/null +++ b/packages/config-manager/package.json @@ -0,0 +1,6 @@ +{ + "name": "config-manager", + "version": "0.0.0", + "main": "index.ts", + "dependencies": {} +} \ No newline at end of file diff --git a/packages/config-manager/tests/config-manager.test.ts b/packages/config-manager/tests/config-manager.test.ts new file mode 100644 index 00000000..2635aba2 --- /dev/null +++ b/packages/config-manager/tests/config-manager.test.ts @@ -0,0 +1,96 @@ +// FILEPATH: /home/jessew/Dev/lysand/packages/config-manager/config-manager.test.ts +import { stringify } from "@iarna/toml"; +import { ConfigManager } from ".."; +import { describe, beforeEach, spyOn, it, expect } from "bun:test"; + +describe("ConfigManager", () => { + let configManager: ConfigManager; + + beforeEach(() => { + configManager = new ConfigManager({ + configPathOverride: "./config/config.toml", + internalConfigPathOverride: "./config/config.internal.toml", + }); + }); + + it("should get the correct config path", () => { + expect(configManager.getConfigPath()).toEqual("./config/config.toml"); + }); + + it("should get the correct internal config path", () => { + expect(configManager.getInternalConfigPath()).toEqual( + "./config/config.internal.toml" + ); + }); + + it("should read the config file correctly", async () => { + const mockConfig = { key: "value" }; + + // @ts-expect-error This is a mock + spyOn(Bun, "file").mockImplementationOnce(() => ({ + exists: () => + new Promise(resolve => { + resolve(true); + }), + text: () => + new Promise(resolve => { + resolve(stringify(mockConfig)); + }), + })); + + const config = await configManager.getConfig(); + + expect(config).toEqual(mockConfig); + }); + + it("should read the internal config file correctly", async () => { + const mockConfig = { key: "value" }; + + // @ts-expect-error This is a mock + spyOn(Bun, "file").mockImplementationOnce(() => ({ + exists: () => + new Promise(resolve => { + resolve(true); + }), + text: () => + new Promise(resolve => { + resolve(stringify(mockConfig)); + }), + })); + + const config = + // @ts-expect-error Force call private function for testing + await configManager.readInternalConfig(); + + expect(config).toEqual(mockConfig); + }); + + it("should write to the internal config file correctly", async () => { + const mockConfig = { key: "value" }; + + spyOn(Bun, "write").mockImplementationOnce( + () => + new Promise(resolve => { + resolve(10); + }) + ); + + await configManager.writeConfig(mockConfig); + }); + + it("should merge configs correctly", () => { + const config1 = { key1: "value1", key2: "value2" }; + const config2 = { key2: "newValue2", key3: "value3" }; + // @ts-expect-error Force call private function for testing + const mergedConfig = configManager.mergeConfigs>( + config1, + config2 + ); + + expect(mergedConfig).toEqual({ + key1: "value1", + key2: "newValue2", + key3: "value3", + }); + }); +}); diff --git a/packages/log-manager/index.ts b/packages/log-manager/index.ts new file mode 100644 index 00000000..37d03e1d --- /dev/null +++ b/packages/log-manager/index.ts @@ -0,0 +1,171 @@ +import type { BunFile } from "bun"; +import { appendFile } from "fs/promises"; + +export enum LogLevel { + DEBUG = "debug", + INFO = "info", + WARNING = "warning", + ERROR = "error", + CRITICAL = "critical", +} + +/** + * Class for handling logging to disk or to stdout + * @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()} ---` + ); + } + + /** + * 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 (!this.output.name) { + throw new Error(`Output file doesnt exist (and isnt stdout)`); + } + 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 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}`; + + 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"); + + 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"); + + 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[]) {} + + /** + * 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 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); + } +} diff --git a/packages/log-manager/package.json b/packages/log-manager/package.json new file mode 100644 index 00000000..679a8262 --- /dev/null +++ b/packages/log-manager/package.json @@ -0,0 +1,6 @@ +{ + "name": "log-manager", + "version": "0.0.0", + "main": "index.ts", + "dependencies": { } + } \ No newline at end of file diff --git a/packages/log-manager/tests/log-manager.test.ts b/packages/log-manager/tests/log-manager.test.ts new file mode 100644 index 00000000..6b8b7bf5 --- /dev/null +++ b/packages/log-manager/tests/log-manager.test.ts @@ -0,0 +1,231 @@ +// 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, +} from "bun:test"; +import type { BunFile } from "bun"; + +describe("LogManager", () => { + 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); + }); + + 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 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"); + + const writeMock = jest.fn(); + + await mock.module("Bun", () => ({ + stdout: Bun.stdout, + write: writeMock, + })); + + 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 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"); + + 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); + + const expectedLog = `127.0.0.1: POST http://localhost/test + [Headers] + content-type: application/json + [Body] + { + "test": "value" + } +`; + + 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); + + 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) + ); + }); + + 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 + [Headers] + content-type: multipart/form-data; boundary=${ + req.headers.get("Content-Type")?.split("boundary=")[1] ?? "" + } + [Body] + test: value +`; + + 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; + + 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 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); + }); +}); diff --git a/packages/media-manager/backends/local.ts b/packages/media-manager/backends/local.ts new file mode 100644 index 00000000..d5a8fb99 --- /dev/null +++ b/packages/media-manager/backends/local.ts @@ -0,0 +1,64 @@ +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); + } + + 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); + } + + const hash = await new MediaHasher().getMediaHash(file); + + const newFile = Bun.file( + `${this.config.media.local_uploads_folder}/${hash}` + ); + + if (await newFile.exists()) { + throw new Error("File already exists"); + } + + await Bun.write(newFile, file); + + return { + uploadedFile: file, + path: `./uploads/${file.name}`, + hash: hash, + }; + } + + public async getFileByHash( + hash: string, + databaseHashFetcher: (sha256: string) => Promise + ): Promise { + const filename = await databaseHashFetcher(hash); + + if (!filename) return null; + + return this.getFile(filename); + } + + public async getFile(filename: string): Promise { + const file = Bun.file( + `${this.config.media.local_uploads_folder}/${filename}` + ); + + if (!(await file.exists())) return null; + + 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 new file mode 100644 index 00000000..46c2cb41 --- /dev/null +++ b/packages/media-manager/backends/s3.ts @@ -0,0 +1,69 @@ +import { S3Client } from "@bradenmacdonald/s3-lite-client"; +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); + } + + 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); + } + + const hash = await new MediaHasher().getMediaHash(file); + + await this.s3Client.putObject(file.name, file.stream(), { + size: file.size, + }); + + return { + uploadedFile: file, + hash: hash, + }; + } + + public async getFileByHash( + hash: string, + databaseHashFetcher: (sha256: string) => Promise + ): Promise { + const filename = await databaseHashFetcher(hash); + + if (!filename) return null; + + return this.getFile(filename); + } + + public async getFile(filename: string): Promise { + try { + await this.s3Client.statObject(filename); + } catch { + return null; + } + + const file = await this.s3Client.getObject(filename); + + return new File([await file.arrayBuffer()], filename, { + type: file.headers.get("Content-Type") || "undefined", + }); + } +} diff --git a/packages/media-manager/bun.lockb b/packages/media-manager/bun.lockb new file mode 100755 index 00000000..202c862d Binary files /dev/null and b/packages/media-manager/bun.lockb differ diff --git a/packages/media-manager/bunfig.toml b/packages/media-manager/bunfig.toml new file mode 100644 index 00000000..bea1efe1 --- /dev/null +++ b/packages/media-manager/bunfig.toml @@ -0,0 +1,2 @@ +[install.scopes] +"@jsr" = "https://npm.jsr.io" diff --git a/packages/media-manager/index.ts b/packages/media-manager/index.ts new file mode 100644 index 00000000..501495b1 --- /dev/null +++ b/packages/media-manager/index.ts @@ -0,0 +1,83 @@ +import type { ConfigType } from "config-manager"; + +export enum MediaBackendType { + LOCAL = "local", + S3 = "s3", +} + +interface UploadedFileMetadata { + 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"); + + return hash; + } +} + +export class MediaBackend { + constructor( + public config: ConfigType, + public backend: MediaBackendType + ) {} + + public getBackendType() { + return this.backend; + } + + public shouldConvertImages(config: ConfigType) { + 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 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") + ); + } +} diff --git a/packages/media-manager/media-converter.ts b/packages/media-manager/media-converter.ts new file mode 100644 index 00000000..2602f4cf --- /dev/null +++ b/packages/media-manager/media-converter.ts @@ -0,0 +1,94 @@ +/** + * @packageDocumentation + * @module MediaManager + * @description Handles media conversion between formats + */ +import sharp from "sharp"; + +export enum ConvertableMediaFormats { + 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 + ) {} + + /** + * 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}` + ); + } + + /** + * 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; +}; + +describe("MediaBackend", () => { + let mediaBackend: MediaBackend; + let mockConfig: ConfigType; + + beforeEach(() => { + mockConfig = { + media: { + conversion: { + convert_images: true, + }, + }, + } as ConfigType; + mediaBackend = new MediaBackend(mockConfig, MediaBackendType.S3); + }); + + it("should initialize with correct backend type", () => { + expect(mediaBackend.getBackendType()).toEqual(MediaBackendType.S3); + }); + + 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"); + + expect( + mediaBackend.getFileByHash(mockHash, databaseHashFetcher) + ).rejects.toThrow(Error); + }); + + it("should throw error when calling getFile", () => { + const mockFilename = "test.jpg"; + + expect(mediaBackend.getFile(mockFilename)).rejects.toThrow(Error); + }); + + it("should throw error when calling addFile", () => { + const mockFile = new File([""], "test.jpg"); + + expect(mediaBackend.addFile(mockFile)).rejects.toThrow(); + }); +}); + +describe("S3MediaBackend", () => { + 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 + ); + }); + + 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); + + 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 } + ); + }); + + 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 + ); + + 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" }), + }); + + const file = await s3MediaBackend.getFile(mockFilename); + + 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; + + 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); + }); + + 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) + ); + + const result = await localMediaBackend.addFile(mockFile); + + 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, + })); + + const file = await localMediaBackend.getFileByHash( + mockHash, + databaseHashFetcher + ); + + 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, + })); + + const file = await localMediaBackend.getFile(mockFilename); + + 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 new file mode 100644 index 00000000..017f3b6a --- /dev/null +++ b/packages/media-manager/tests/media-manager.test.ts @@ -0,0 +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"; + +describe("MediaConverter", () => { + let mediaConverter: MediaConverter; + + 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 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 + ); + }); + + 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 convert media", async () => { + const file = Bun.file(__dirname + "/megamind.jpg"); + + const convertedFile = await mediaConverter.convert( + file as unknown as File + ); + + expect(convertedFile.name).toEqual("megamind.png"); + expect(convertedFile.type).toEqual( + `image/${ConvertableMediaFormats.PNG}` + ); + }); +}); diff --git a/packages/media-manager/tests/megamind.jpg b/packages/media-manager/tests/megamind.jpg new file mode 100644 index 00000000..0f8f035a Binary files /dev/null and b/packages/media-manager/tests/megamind.jpg differ diff --git a/packages/request-parser/index.ts b/packages/request-parser/index.ts new file mode 100644 index 00000000..6351fecc --- /dev/null +++ b/packages/request-parser/index.ts @@ -0,0 +1,170 @@ +/** + * RequestParser + * @file index.ts + * @module request-parser + * @description Parses Request object into a JavaScript object based on the content type + */ + +/** + * RequestParser + * Parses Request object into a JavaScript object + * based on the Content-Type header + * @param request Request object + * @returns JavaScript object of type T + */ +export class RequestParser { + 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; + } + } + + /** + * 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 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 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 = {}; + + 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]; + } + + (result[arrayKey] as any[]).push(value); + } else { + result[key as keyof T] = value as any; + } + } + + 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 = {}; + + 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; + } + } + + 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 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; + } +} diff --git a/packages/request-parser/package.json b/packages/request-parser/package.json new file mode 100644 index 00000000..89d30d2c --- /dev/null +++ b/packages/request-parser/package.json @@ -0,0 +1,6 @@ +{ + "name": "request-parser", + "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 new file mode 100644 index 00000000..d6f4bf20 --- /dev/null +++ b/packages/request-parser/tests/request-parser.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, 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" }); + }); + + 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"], + }); + }); + }); + + 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({}); + }); + + 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 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("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/prisma.ts b/prisma.ts index a4fd0076..63566b3d 100644 --- a/prisma.ts +++ b/prisma.ts @@ -1,8 +1,7 @@ +import { ConfigManager } from "config-manager"; + // Proxies all `bunx prisma` commands with an environment variable - -import { getConfig } from "~classes/configmanager"; - -const config = getConfig(); +const config = await new ConfigManager({}).getConfig(); process.stdout.write( `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}\n` diff --git a/routes.ts b/routes.ts index 834cf00b..b894201e 100644 --- a/routes.ts +++ b/routes.ts @@ -1,5 +1,4 @@ -import type { MatchedRoute } from "bun"; -import type { AuthData } from "~database/entities/User"; +import type { RouteHandler } from "~server/api/routes.type"; import type { APIRouteMeta } from "~types/api"; const serverPath = process.cwd() + "/server/api"; @@ -8,148 +7,161 @@ const serverPath = process.cwd() + "/server/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": import(serverPath + "/api/v1/accounts/index.ts"), - "/api/v1/accounts/familiar_followers": import( + "/api/v1/accounts": await import(serverPath + "/api/v1/accounts/index.ts"), + "/api/v1/accounts/familiar_followers": await import( serverPath + "/api/v1/accounts/familiar_followers/index.ts" ), - "/api/v1/accounts/relationships": import( + "/api/v1/accounts/relationships": await import( serverPath + "/api/v1/accounts/relationships/index.ts" ), - "/api/v1/accounts/search": import( + "/api/v1/accounts/search": await import( serverPath + "/api/v1/accounts/search/index.ts" ), - "/api/v1/accounts/update_credentials": import( + "/api/v1/accounts/update_credentials": await import( serverPath + "/api/v1/accounts/update_credentials/index.ts" ), - "/api/v1/accounts/verify_credentials": import( + "/api/v1/accounts/verify_credentials": await import( serverPath + "/api/v1/accounts/verify_credentials/index.ts" ), - "/api/v1/apps": import(serverPath + "/api/v1/apps/index.ts"), - "/api/v1/apps/verify_credentials": import( + "/api/v1/apps": await import(serverPath + "/api/v1/apps/index.ts"), + "/api/v1/apps/verify_credentials": await import( serverPath + "/api/v1/apps/verify_credentials/index.ts" ), - "/api/v1/blocks": import(serverPath + "/api/v1/blocks/index.ts"), - "/api/v1/custom_emojis": import( + "/api/v1/blocks": await import(serverPath + "/api/v1/blocks/index.ts"), + "/api/v1/custom_emojis": await import( serverPath + "/api/v1/custom_emojis/index.ts" ), - "/api/v1/favourites": import(serverPath + "/api/v1/favourites/index.ts"), - "/api/v1/follow_requests": import( + "/api/v1/favourites": await import( + serverPath + "/api/v1/favourites/index.ts" + ), + "/api/v1/follow_requests": await import( serverPath + "/api/v1/follow_requests/index.ts" ), - "/api/v1/instance": import(serverPath + "/api/v1/instance/index.ts"), - "/api/v1/media": import(serverPath + "/api/v1/media/index.ts"), - "/api/v1/mutes": import(serverPath + "/api/v1/mutes/index.ts"), - "/api/v1/notifications": import( + "/api/v1/instance": await import(serverPath + "/api/v1/instance/index.ts"), + "/api/v1/media": await import(serverPath + "/api/v1/media/index.ts"), + "/api/v1/mutes": await import(serverPath + "/api/v1/mutes/index.ts"), + "/api/v1/notifications": await import( serverPath + "/api/v1/notifications/index.ts" ), - "/api/v1/profile/avatar": import(serverPath + "/api/v1/profile/avatar.ts"), - "/api/v1/profile/header": import(serverPath + "/api/v1/profile/header.ts"), - "/api/v1/statuses": import(serverPath + "/api/v1/statuses/index.ts"), - "/api/v1/timelines/home": import(serverPath + "/api/v1/timelines/home.ts"), - "/api/v1/timelines/public": import( + "/api/v1/profile/avatar": await import( + serverPath + "/api/v1/profile/avatar.ts" + ), + "/api/v1/profile/header": await import( + serverPath + "/api/v1/profile/header.ts" + ), + "/api/v1/statuses": await import(serverPath + "/api/v1/statuses/index.ts"), + "/api/v1/timelines/home": await import( + serverPath + "/api/v1/timelines/home.ts" + ), + "/api/v1/timelines/public": await import( serverPath + "/api/v1/timelines/public.ts" ), - "/api/v2/media": import(serverPath + "/api/v2/media/index.ts"), - "/api/v2/search": import(serverPath + "/api/v2/search/index.ts"), - "/auth/login": import(serverPath + "/auth/login/index.ts"), - "/nodeinfo/2.0": import(serverPath + "/nodeinfo/2.0/index.ts"), - "/oauth/authorize-external": import( + "/api/v2/media": await import(serverPath + "/api/v2/media/index.ts"), + "/api/v2/search": await import(serverPath + "/api/v2/search/index.ts"), + "/auth/login": await import(serverPath + "/auth/login/index.ts"), + "/nodeinfo/2.0": await import(serverPath + "/nodeinfo/2.0/index.ts"), + "/oauth/authorize-external": await import( serverPath + "/oauth/authorize-external/index.ts" ), - "/oauth/providers": import(serverPath + "/oauth/providers/index.ts"), - "/oauth/token": import(serverPath + "/oauth/token/index.ts"), - "/api/v1/accounts/[id]": import( + "/oauth/providers": await import(serverPath + "/oauth/providers/index.ts"), + "/oauth/token": await import(serverPath + "/oauth/token/index.ts"), + "/api/v1/accounts/[id]": await import( serverPath + "/api/v1/accounts/[id]/index.ts" ), - "/api/v1/accounts/[id]/block": import( + "/api/v1/accounts/[id]/block": await import( serverPath + "/api/v1/accounts/[id]/block.ts" ), - "/api/v1/accounts/[id]/follow": import( + "/api/v1/accounts/[id]/follow": await import( serverPath + "/api/v1/accounts/[id]/follow.ts" ), - "/api/v1/accounts/[id]/followers": import( + "/api/v1/accounts/[id]/followers": await import( serverPath + "/api/v1/accounts/[id]/followers.ts" ), - "/api/v1/accounts/[id]/following": import( + "/api/v1/accounts/[id]/following": await import( serverPath + "/api/v1/accounts/[id]/following.ts" ), - "/api/v1/accounts/[id]/mute": import( + "/api/v1/accounts/[id]/mute": await import( serverPath + "/api/v1/accounts/[id]/mute.ts" ), - "/api/v1/accounts/[id]/note": import( + "/api/v1/accounts/[id]/note": await import( serverPath + "/api/v1/accounts/[id]/note.ts" ), - "/api/v1/accounts/[id]/pin": import( + "/api/v1/accounts/[id]/pin": await import( serverPath + "/api/v1/accounts/[id]/pin.ts" ), - "/api/v1/accounts/[id]/remove_from_followers": import( + "/api/v1/accounts/[id]/remove_from_followers": await import( serverPath + "/api/v1/accounts/[id]/remove_from_followers.ts" ), - "/api/v1/accounts/[id]/statuses": import( + "/api/v1/accounts/[id]/statuses": await import( serverPath + "/api/v1/accounts/[id]/statuses.ts" ), - "/api/v1/accounts/[id]/unblock": import( + "/api/v1/accounts/[id]/unblock": await import( serverPath + "/api/v1/accounts/[id]/unblock.ts" ), - "/api/v1/accounts/[id]/unfollow": import( + "/api/v1/accounts/[id]/unfollow": await import( serverPath + "/api/v1/accounts/[id]/unfollow.ts" ), - "/api/v1/accounts/[id]/unmute": import( + "/api/v1/accounts/[id]/unmute": await import( serverPath + "/api/v1/accounts/[id]/unmute.ts" ), - "/api/v1/accounts/[id]/unpin": import( + "/api/v1/accounts/[id]/unpin": await import( serverPath + "/api/v1/accounts/[id]/unpin.ts" ), - "/api/v1/follow_requests/[account_id]/authorize": import( + "/api/v1/follow_requests/[account_id]/authorize": await import( serverPath + "/api/v1/follow_requests/[account_id]/authorize.ts" ), - "/api/v1/follow_requests/[account_id]/reject": import( + "/api/v1/follow_requests/[account_id]/reject": await import( serverPath + "/api/v1/follow_requests/[account_id]/reject.ts" ), - "/api/v1/media/[id]": import(serverPath + "/api/v1/media/[id]/index.ts"), - "/api/v1/statuses/[id]": import( + "/api/v1/media/[id]": await import( + serverPath + "/api/v1/media/[id]/index.ts" + ), + "/api/v1/statuses/[id]": await import( serverPath + "/api/v1/statuses/[id]/index.ts" ), - "/api/v1/statuses/[id]/context": import( + "/api/v1/statuses/[id]/context": await import( serverPath + "/api/v1/statuses/[id]/context.ts" ), - "/api/v1/statuses/[id]/favourite": import( + "/api/v1/statuses/[id]/favourite": await import( serverPath + "/api/v1/statuses/[id]/favourite.ts" ), - "/api/v1/statuses/[id]/favourited_by": import( + "/api/v1/statuses/[id]/favourited_by": await import( serverPath + "/api/v1/statuses/[id]/favourited_by.ts" ), - "/api/v1/statuses/[id]/pin": import( + "/api/v1/statuses/[id]/pin": await import( serverPath + "/api/v1/statuses/[id]/pin.ts" ), - "/api/v1/statuses/[id]/reblog": import( + "/api/v1/statuses/[id]/reblog": await import( serverPath + "/api/v1/statuses/[id]/reblog.ts" ), - "/api/v1/statuses/[id]/reblogged_by": import( + "/api/v1/statuses/[id]/reblogged_by": await import( serverPath + "/api/v1/statuses/[id]/reblogged_by.ts" ), - "/api/v1/statuses/[id]/source": import( + "/api/v1/statuses/[id]/source": await import( serverPath + "/api/v1/statuses/[id]/source.ts" ), - "/api/v1/statuses/[id]/unfavourite": import( + "/api/v1/statuses/[id]/unfavourite": await import( serverPath + "/api/v1/statuses/[id]/unfavourite.ts" ), - "/api/v1/statuses/[id]/unpin": import( + "/api/v1/statuses/[id]/unpin": await import( serverPath + "/api/v1/statuses/[id]/unpin.ts" ), - "/api/v1/statuses/[id]/unreblog": import( + "/api/v1/statuses/[id]/unreblog": await import( serverPath + "/api/v1/statuses/[id]/unreblog.ts" ), - "/media/[id]": import(serverPath + "/media/[id]/index.ts"), - "/oauth/callback/[issuer]": import( + "/media/[id]": await import(serverPath + "/media/[id]/index.ts"), + "/oauth/callback/[issuer]": await import( serverPath + "/oauth/callback/[issuer]/index.ts" ), - "/object/[uuid]": import(serverPath + "/object/[uuid]/index.ts"), - "/users/[uuid]": import(serverPath + "/users/[uuid]/index.ts"), - "/users/[uuid]/inbox": import(serverPath + "/users/[uuid]/inbox/index.ts"), - "/users/[uuid]/outbox": import( + "/object/[uuid]": await import(serverPath + "/object/[uuid]/index.ts"), + "/users/[uuid]": await import(serverPath + "/users/[uuid]/index.ts"), + "/users/[uuid]/inbox": await import( + serverPath + "/users/[uuid]/inbox/index.ts" + ), + "/users/[uuid]/outbox": await import( serverPath + "/users/[uuid]/outbox/index.ts" ), + "/[...404]": await import(serverPath + "/[...404].ts"), }; // Returns the route filesystem path when given a URL @@ -158,20 +170,19 @@ export const routeMatcher = new Bun.FileSystemRouter({ dir: process.cwd() + "/server/api", }); -export const matchRoute = (url: string) => { +export const matchRoute = >(url: string) => { const route = routeMatcher.match(url); if (!route) return { file: null, matchedRoute: null }; return { // @ts-expect-error TypeScript parses this as a defined object instead of an arbitrarily editable route file - file: rawRoutes[route.name] as Promise<{ - meta: APIRouteMeta; - default: ( - req: Request, - matchedRoute: MatchedRoute, - auth: AuthData - ) => Response | Promise; - }>, + file: rawRoutes[route.name] as Promise< + | { + meta: APIRouteMeta; + default: RouteHandler; + } + | undefined + >, matchedRoute: route, }; }; diff --git a/server.ts b/server.ts new file mode 100644 index 00000000..4189f216 --- /dev/null +++ b/server.ts @@ -0,0 +1,186 @@ +import { errorResponse, jsonResponse } from "@response"; +import { matches } from "ip-matching"; +import { getFromRequest } from "~database/entities/User"; +import type { ConfigManager, ConfigType } from "config-manager"; +import type { LogManager, MultiLogManager } from "log-manager"; +import { LogLevel } from "log-manager"; +import { RequestParser } from "request-parser"; + +export const createServer = ( + config: ConfigType, + configManager: ConfigManager, + 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 ?? ""; + + 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") ?? ""; + + 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.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 it isn't dynamically imported, it causes trouble with imports + // There shouldn't be a performance hit after bundling right? + const { matchRoute } = await import("~routes"); + + const { file: filePromise, matchedRoute } = matchRoute(req.url); + + const file = await filePromise; + + 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); + } + + if ( + matchedRoute && + matchedRoute.name !== "/[...404]" && + file != undefined + ) { + 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 + 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", + }); + } + } + + 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", + }); + } + + return await file.default(req.clone(), matchedRoute, { + auth, + configManager, + parsedRequest, + }); + } else if (matchedRoute?.name === "/[...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}` + ); + + // 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); + } + + 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 + ); + return errorResponse("Route not found", 404); + }); + + if (proxy.status !== 404) { + return proxy; + } + } + + return errorResponse("Route not found", 404); + } else { + 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 ca416ba3..c473fe6d 100644 --- a/server/api/.well-known/host-meta/index.ts +++ b/server/api/.well-known/host-meta/index.ts @@ -1,7 +1,5 @@ -import { MatchedRoute } from "bun"; -import { getConfig, getHost } from "~classes/configmanager"; import { xmlResponse } from "@response"; -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -16,18 +14,13 @@ export const meta = applyConfig({ }); -/** - * Host meta endpoint - */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { - const config = getConfig(); +export default apiRoute(async (req, matchedRoute, extraData) => { + 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 6a80d16e..5f7de0b6 100644 --- a/server/api/.well-known/lysand.ts +++ b/server/api/.well-known/lysand.ts @@ -1,7 +1,5 @@ import { jsonResponse } from "@response"; -import { MatchedRoute } from "bun"; -import { getConfig } from "~classes/configmanager"; -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -15,14 +13,9 @@ export const meta = applyConfig({ route: "/.well-known/lysand", }); -/** - * Lysand instance metadata endpoint - */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { - const config = getConfig(); +export default apiRoute(async (req, matchedRoute, extraData) => { + const config = await extraData.configManager.getConfig(); + // In the format acct:name@example.com return jsonResponse({ type: "ServerMetadata", @@ -47,4 +40,4 @@ export default async ( 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 81bd149b..a348c4da 100644 --- a/server/api/.well-known/nodeinfo/index.ts +++ b/server/api/.well-known/nodeinfo/index.ts @@ -1,6 +1,4 @@ -import { MatchedRoute } from "bun"; -import { getConfig, getHost } from "~classes/configmanager"; -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -15,14 +13,8 @@ export const meta = applyConfig({ }); -/** - * Redirect to /nodeinfo/2.0 - */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { - const config = getConfig(); +export default apiRoute(async (req, matchedRoute, extraData) => { + const config = await extraData.configManager.getConfig(); return new Response("", { status: 301, @@ -30,4 +22,4 @@ export default async ( 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 9e9e7e1e..f9b35a84 100644 --- a/server/api/.well-known/webfinger/index.ts +++ b/server/api/.well-known/webfinger/index.ts @@ -1,7 +1,5 @@ import { errorResponse, jsonResponse } from "@response"; -import { MatchedRoute } from "bun"; -import { getConfig, getHost } from "~classes/configmanager"; -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -16,35 +14,30 @@ export const meta = applyConfig({ route: "/.well-known/webfinger", }); -/** - * ActivityPub WebFinger endpoint - */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +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 = getConfig(); - + + 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] !== getHost()) { + 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}@${getHost()}`, - + subject: `acct:${user.username}@${host}`, + links: [ { rel: "self", @@ -63,4 +56,4 @@ export default async ( } ] }) -}; +}); \ No newline at end of file diff --git a/server/api/[...404].ts b/server/api/[...404].ts new file mode 100644 index 00000000..871e3a3d --- /dev/null +++ b/server/api/[...404].ts @@ -0,0 +1,21 @@ +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]", +}); + +/** + * Default catch-all route, returns a 404 error. + */ +export default apiRoute(() => { + 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 e0e55252..d1fee876 100644 --- a/server/api/api/v1/accounts/[id]/block.ts +++ b/server/api/api/v1/accounts/[id]/block.ts @@ -1,14 +1,10 @@ import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import { - getFromRequest, - getRelationshipToOtherUser, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -26,13 +22,10 @@ export const meta = applyConfig({ /** * Blocks a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user: self } = await getFromRequest(req); + const { user: self } = extraData.auth; if (!self) return errorResponse("Unauthorized", 401); @@ -84,4 +77,4 @@ export default async ( }); 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 3d0b37d8..abd75e74 100644 --- a/server/api/api/v1/accounts/[id]/follow.ts +++ b/server/api/api/v1/accounts/[id]/follow.ts @@ -1,15 +1,10 @@ -import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import { - getFromRequest, - getRelationshipToOtherUser, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -27,21 +22,18 @@ export const meta = applyConfig({ /** * Follow a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute<{ + reblogs?: boolean; + notify?: boolean; + languages?: string[]; +}>(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user: self } = await getFromRequest(req); + const { user: self } = extraData.auth; if (!self) return errorResponse("Unauthorized", 401); - const { languages, notify, reblogs } = await parseRequest<{ - reblogs?: boolean; - notify?: boolean; - languages?: string[]; - }>(req); + const { languages, notify, reblogs } = extraData.parsedRequest; const user = await client.user.findUnique({ where: { id }, @@ -103,4 +95,4 @@ export default async ( }); 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 af85ffdf..1de86d63 100644 --- a/server/api/api/v1/accounts/[id]/followers.ts +++ b/server/api/api/v1/accounts/[id]/followers.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { userRelations, userToAPI } from "~database/entities/User"; -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -20,24 +19,16 @@ export const meta = applyConfig({ /** * Fetch all statuses for a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute<{ + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; +}>(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; // TODO: Add pinned - const { - max_id, - min_id, - since_id, - limit = 20, - }: { - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; - } = matchedRoute.query; + const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest; const user = await client.user.findUnique({ where: { id }, @@ -86,4 +77,4 @@ export default async ( Link: linkHeader.join(", "), } ); -}; +}); diff --git a/server/api/api/v1/accounts/[id]/following.ts b/server/api/api/v1/accounts/[id]/following.ts index 2f9d8142..a6c25988 100644 --- a/server/api/api/v1/accounts/[id]/following.ts +++ b/server/api/api/v1/accounts/[id]/following.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { userRelations, userToAPI } from "~database/entities/User"; -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -20,24 +19,16 @@ export const meta = applyConfig({ /** * Fetch all statuses for a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute<{ + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; +}>(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; // TODO: Add pinned - const { - max_id, - min_id, - since_id, - limit = 20, - }: { - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; - } = matchedRoute.query; + const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest; const user = await client.user.findUnique({ where: { id }, @@ -86,4 +77,4 @@ export default async ( Link: linkHeader.join(", "), } ); -}; +}); diff --git a/server/api/api/v1/accounts/[id]/index.ts b/server/api/api/v1/accounts/[id]/index.ts index ee3763cb..b1527eb6 100644 --- a/server/api/api/v1/accounts/[id]/index.ts +++ b/server/api/api/v1/accounts/[id]/index.ts @@ -1,12 +1,7 @@ import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import type { UserWithRelations } from "~database/entities/User"; -import { - getFromRequest, - userRelations, - userToAPI, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { userRelations, userToAPI } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -24,17 +19,14 @@ export const meta = applyConfig({ /** * Fetch a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +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 { user } = await getFromRequest(req); + const { user } = extraData.auth; let foundUser: UserWithRelations | null; try { @@ -49,4 +41,4 @@ export default async ( if (!foundUser) return errorResponse("User not found", 404); 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 54dba828..e0790e8c 100644 --- a/server/api/api/v1/accounts/[id]/mute.ts +++ b/server/api/api/v1/accounts/[id]/mute.ts @@ -1,15 +1,10 @@ -import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import { - getFromRequest, - getRelationshipToOtherUser, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -27,21 +22,18 @@ export const meta = applyConfig({ /** * Mute a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute<{ + notifications: boolean; + duration: number; +}>(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user: self } = await getFromRequest(req); + const { user: self } = extraData.auth; if (!self) return errorResponse("Unauthorized", 401); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { notifications, duration } = await parseRequest<{ - notifications: boolean; - duration: number; - }>(req); + const { notifications, duration } = extraData.parsedRequest; const user = await client.user.findUnique({ where: { id }, @@ -97,4 +89,4 @@ export default async ( // TODO: Implement duration 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 d67d6bda..b1dc07aa 100644 --- a/server/api/api/v1/accounts/[id]/note.ts +++ b/server/api/api/v1/accounts/[id]/note.ts @@ -1,15 +1,10 @@ -import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import { - getFromRequest, - getRelationshipToOtherUser, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -27,19 +22,16 @@ export const meta = applyConfig({ /** * Sets a user note */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute<{ + comment: string; +}>(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user: self } = await getFromRequest(req); + const { user: self } = extraData.auth; if (!self) return errorResponse("Unauthorized", 401); - const { comment } = await parseRequest<{ - comment: string; - }>(req); + const { comment } = extraData.parsedRequest; const user = await client.user.findUnique({ where: { id }, @@ -87,4 +79,4 @@ export default async ( }); 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 04cdb68e..4e3f398c 100644 --- a/server/api/api/v1/accounts/[id]/pin.ts +++ b/server/api/api/v1/accounts/[id]/pin.ts @@ -1,14 +1,10 @@ import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import { - getFromRequest, - getRelationshipToOtherUser, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -26,13 +22,10 @@ export const meta = applyConfig({ /** * Pin a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user: self } = await getFromRequest(req); + const { user: self } = extraData.auth; if (!self) return errorResponse("Unauthorized", 401); @@ -84,4 +77,4 @@ export default async ( }); 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 94e2ddb0..3a3495f3 100644 --- a/server/api/api/v1/accounts/[id]/remove_from_followers.ts +++ b/server/api/api/v1/accounts/[id]/remove_from_followers.ts @@ -1,14 +1,10 @@ import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import { - getFromRequest, - getRelationshipToOtherUser, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -26,13 +22,10 @@ export const meta = applyConfig({ /** * Removes an account from your followers list */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user: self } = await getFromRequest(req); + const { user: self } = extraData.auth; if (!self) return errorResponse("Unauthorized", 401); @@ -98,4 +91,4 @@ export default async ( } 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 5ba37ec3..b4b5963b 100644 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ b/server/api/api/v1/accounts/[id]/statuses.ts @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; import { userRelations } from "~database/entities/User"; -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -21,10 +20,18 @@ export const meta = applyConfig({ /** * Fetch all statuses for a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +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; +}>(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; // TODO: Add pinned @@ -35,18 +42,7 @@ export default async ( limit = "20", exclude_reblogs, pinned, - }: { - 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; - } = matchedRoute.query; + } = extraData.parsedRequest; const user = await client.user.findUnique({ where: { id }, @@ -131,4 +127,4 @@ export default async ( Link: linkHeader.join(", "), } ); -}; +}); diff --git a/server/api/api/v1/accounts/[id]/unblock.ts b/server/api/api/v1/accounts/[id]/unblock.ts index ea0139c2..ebfdeeb5 100644 --- a/server/api/api/v1/accounts/[id]/unblock.ts +++ b/server/api/api/v1/accounts/[id]/unblock.ts @@ -1,14 +1,10 @@ import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import { - getFromRequest, - getRelationshipToOtherUser, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -26,13 +22,10 @@ export const meta = applyConfig({ /** * Blocks a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user: self } = await getFromRequest(req); + const { user: self } = extraData.auth; if (!self) return errorResponse("Unauthorized", 401); @@ -84,4 +77,4 @@ export default async ( }); 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 de6ba90a..56fc0f8d 100644 --- a/server/api/api/v1/accounts/[id]/unfollow.ts +++ b/server/api/api/v1/accounts/[id]/unfollow.ts @@ -1,14 +1,10 @@ import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import { - getFromRequest, - getRelationshipToOtherUser, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -26,13 +22,10 @@ export const meta = applyConfig({ /** * Unfollows a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user: self } = await getFromRequest(req); + const { user: self } = extraData.auth; if (!self) return errorResponse("Unauthorized", 401); @@ -84,4 +77,4 @@ export default async ( }); 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 5a498a24..b5c64fde 100644 --- a/server/api/api/v1/accounts/[id]/unmute.ts +++ b/server/api/api/v1/accounts/[id]/unmute.ts @@ -1,14 +1,10 @@ import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import { - getFromRequest, - getRelationshipToOtherUser, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -26,13 +22,10 @@ export const meta = applyConfig({ /** * Unmute a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user: self } = await getFromRequest(req); + const { user: self } = extraData.auth; if (!self) return errorResponse("Unauthorized", 401); @@ -86,4 +79,4 @@ export default async ( }); 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 ee52384e..1e1ea84d 100644 --- a/server/api/api/v1/accounts/[id]/unpin.ts +++ b/server/api/api/v1/accounts/[id]/unpin.ts @@ -1,14 +1,10 @@ import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import { - getFromRequest, - getRelationshipToOtherUser, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -26,13 +22,10 @@ export const meta = applyConfig({ /** * Unpin a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user: self } = await getFromRequest(req); + const { user: self } = extraData.auth; if (!self) return errorResponse("Unauthorized", 401); @@ -84,4 +77,4 @@ export default async ( }); 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 f3667b00..bf697de0 100644 --- a/server/api/api/v1/accounts/familiar_followers/index.ts +++ b/server/api/api/v1/accounts/familiar_followers/index.ts @@ -1,11 +1,6 @@ -import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { - getFromRequest, - userRelations, - userToAPI, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { userRelations, userToAPI } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -23,14 +18,14 @@ export const meta = applyConfig({ /** * Find familiar followers (followers of a user that you also follow) */ -export default async (req: Request): Promise => { - const { user: self } = await getFromRequest(req); +export default apiRoute<{ + id: string[]; +}>(async (req, matchedRoute, extraData) => { + const { user: self } = extraData.auth; if (!self) return errorResponse("Unauthorized", 401); - const { "id[]": ids } = await parseRequest<{ - "id[]": string[]; - }>(req); + const { id: ids } = extraData.parsedRequest; // Minimum id count 1, maximum 10 if (!ids || ids.length < 1 || ids.length > 10) { @@ -67,4 +62,4 @@ export default async (req: Request): Promise => { }); 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 ecd3211c..9a6e8925 100644 --- a/server/api/api/v1/accounts/index.ts +++ b/server/api/api/v1/accounts/index.ts @@ -1,8 +1,6 @@ -import { getConfig } from "~classes/configmanager"; -import { parseRequest } from "@request"; import { jsonResponse } from "@response"; import { tempmailDomains } from "@tempmail"; -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; import { createNewLocalUser } from "~database/entities/User"; import ISO6391 from "iso-639-1"; @@ -19,22 +17,19 @@ export const meta = applyConfig({ }, }); -/** - * Creates a new user - */ -export default async (req: Request): Promise => { +export default apiRoute<{ + username: string; + email: string; + password: string; + agreement: boolean; + locale: string; + reason: string; +}>(async (req, matchedRoute, extraData) => { // TODO: Add Authorization check - const body = await parseRequest<{ - username: string; - email: string; - password: string; - agreement: boolean; - locale: string; - reason: string; - }>(req); + const body = extraData.parsedRequest; - const config = getConfig(); + const config = await extraData.configManager.getConfig(); if (!config.signups.registration) { return jsonResponse( @@ -94,8 +89,8 @@ export default async (req: Request): Promise => { // Check if username doesnt match filters if ( - config.filters.username_filters.some( - filter => body.username?.match(filter) + config.filters.username_filters.some(filter => + body.username?.match(filter) ) ) { errors.details.username.push({ @@ -203,4 +198,4 @@ export default async (req: Request): Promise => { 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 89b32f0e..bed66e35 100644 --- a/server/api/api/v1/accounts/relationships/index.ts +++ b/server/api/api/v1/accounts/relationships/index.ts @@ -1,11 +1,9 @@ -import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import { getFromRequest } from "~database/entities/User"; -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -23,14 +21,14 @@ export const meta = applyConfig({ /** * Find relationships */ -export default async (req: Request): Promise => { - const { user: self } = await getFromRequest(req); +export default apiRoute<{ + id: string[]; +}>(async (req, matchedRoute, extraData) => { + const { user: self } = extraData.auth; if (!self) return errorResponse("Unauthorized", 401); - const { "id[]": ids } = await parseRequest<{ - "id[]": string[]; - }>(req); + const { id: ids } = extraData.parsedRequest; // Minimum id count 1, maximum 10 if (!ids || ids.length < 1 || ids.length > 10) { @@ -64,4 +62,4 @@ export default async (req: Request): Promise => { ); 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 f523e9fe..15c6b529 100644 --- a/server/api/api/v1/accounts/search/index.ts +++ b/server/api/api/v1/accounts/search/index.ts @@ -1,11 +1,6 @@ import { errorResponse, jsonResponse } from "@response"; -import { - getFromRequest, - userRelations, - userToAPI, -} from "~database/entities/User"; -import { applyConfig } from "@api"; -import { parseRequest } from "@request"; +import { userRelations, userToAPI } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -20,10 +15,16 @@ export const meta = applyConfig({ }, }); -export default async (req: Request): Promise => { +export default apiRoute<{ + q?: string; + limit?: number; + offset?: number; + resolve?: boolean; + following?: boolean; +}>(async (req, matchedRoute, extraData) => { // TODO: Add checks for disabled or not email verified accounts - const { user } = await getFromRequest(req); + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -32,13 +33,7 @@ export default async (req: Request): Promise => { limit = 40, offset, q, - } = await parseRequest<{ - q?: string; - limit?: number; - offset?: number; - resolve?: boolean; - following?: boolean; - }>(req); + } = extraData.parsedRequest; if (limit < 1 || limit > 80) { return errorResponse("Limit must be between 1 and 80", 400); @@ -66,7 +61,7 @@ export default async (req: Request): Promise => { ownerId: user.id, following, }, - } + } : undefined, }, take: Number(limit), @@ -75,4 +70,4 @@ export default async (req: Request): Promise => { }); 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 2c6ebe96..4a48807f 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -1,21 +1,18 @@ -import { getConfig } from "~classes/configmanager"; -import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { - userRelations, - userToAPI, - type AuthData, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { userRelations, userToAPI } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { sanitize } from "isomorphic-dompurify"; import { sanitizeHtml } from "@sanitization"; -import { uploadFile } from "~classes/media"; 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 type { MatchedRoute } from "bun"; +import { MediaBackendType } from "media-manager"; +import type { MediaBackend } from "media-manager"; +import { LocalMediaBackend } from "~packages/media-manager/backends/local"; +import { S3MediaBackend } from "~packages/media-manager/backends/s3"; +import { getUrl } from "~database/entities/Attachment"; export const meta = applyConfig({ allowedMethods: ["PATCH"], @@ -29,19 +26,23 @@ export const meta = applyConfig({ }, }); -/** - * Patches a user - */ -export default async ( - req: Request, - matchedRoute: MatchedRoute, - auth: AuthData -): Promise => { - const { user } = auth; +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; +}>(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); - const config = getConfig(); + const config = await extraData.configManager.getConfig(); const { display_name, @@ -54,18 +55,7 @@ export default async ( "source[privacy]": source_privacy, "source[sensitive]": source_sensitive, "source[language]": source_language, - } = await parseRequest<{ - display_name: string; - note: string; - avatar: File; - header: File; - locked: string; - bot: string; - discoverable: string; - "source[privacy]": string; - "source[sensitive]": string; - "source[language]": string; - }>(req); + } = extraData.parsedRequest; const sanitizedNote = await sanitizeHtml(note ?? ""); @@ -83,6 +73,20 @@ export default async ( }; } + 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"); + } + if (display_name) { // Check if within allowed display name lengths if ( @@ -146,8 +150,8 @@ export default async ( ); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (user.source as any).privacy = source_privacy; + // @ts-expect-error Prisma Typescript doesn't include relations + user.source.privacy = source_privacy; } if (source_sensitive && user.source) { @@ -156,8 +160,8 @@ export default async ( return errorResponse("Sensitive must be a boolean", 422); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (user.source as any).sensitive = source_sensitive === "true"; + // @ts-expect-error Prisma Typescript doesn't include relations + user.source.sensitive = source_sensitive === "true"; } if (source_language && user.source) { @@ -168,8 +172,8 @@ export default async ( ); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (user.source as any).language = source_language; + // @ts-expect-error Prisma Typescript doesn't include relations + user.source.language = source_language; } if (avatar) { @@ -181,9 +185,9 @@ export default async ( ); } - const hash = await uploadFile(avatar, config); + const { uploadedFile } = await mediaManager.addFile(avatar); - user.avatar = hash || ""; + user.avatar = getUrl(uploadedFile.name, config); } if (header) { @@ -195,9 +199,9 @@ export default async ( ); } - const hash = await uploadFile(header, config); + const { uploadedFile } = await mediaManager.addFile(header); - user.header = hash || ""; + user.header = getUrl(uploadedFile.name, config); } if (locked) { @@ -263,4 +267,4 @@ export default async ( }); 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 94543895..e317389f 100644 --- a/server/api/api/v1/accounts/verify_credentials/index.ts +++ b/server/api/api/v1/accounts/verify_credentials/index.ts @@ -1,6 +1,6 @@ import { errorResponse, jsonResponse } from "@response"; -import { getFromRequest, userToAPI } from "~database/entities/User"; -import { applyConfig } from "@api"; +import { userToAPI } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -14,14 +14,14 @@ export const meta = applyConfig({ }, }); -export default async (req: Request): Promise => { +export default apiRoute((req, matchedRoute, extraData) => { // TODO: Add checks for disabled or not email verified accounts - const { user } = await getFromRequest(req); + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); return jsonResponse({ ...userToAPI(user, true), }); -}; +}); diff --git a/server/api/api/v1/apps/index.ts b/server/api/api/v1/apps/index.ts index 175599f8..c3e3bff8 100644 --- a/server/api/api/v1/apps/index.ts +++ b/server/api/api/v1/apps/index.ts @@ -1,5 +1,4 @@ -import { applyConfig } from "@api"; -import { parseRequest } from "@request"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { randomBytes } from "crypto"; import { client } from "~database/datasource"; @@ -19,13 +18,14 @@ export const meta = applyConfig({ /** * Creates a new application to obtain OAuth 2 credentials */ -export default async (req: Request): Promise => { - const { client_name, redirect_uris, scopes, website } = await parseRequest<{ - client_name: string; - redirect_uris: string; - scopes: string; - website: string; - }>(req); +export default apiRoute<{ + client_name: string; + redirect_uris: string; + scopes: string; + website: string; +}>(async (req, matchedRoute, extraData) => { + 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) { @@ -62,4 +62,4 @@ export default async (req: Request): Promise => { 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 a281be66..4444213e 100644 --- a/server/api/api/v1/apps/verify_credentials/index.ts +++ b/server/api/api/v1/apps/verify_credentials/index.ts @@ -1,7 +1,6 @@ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { getFromToken } from "~database/entities/Application"; -import { getFromRequest } from "~database/entities/User"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -18,8 +17,8 @@ export const meta = applyConfig({ /** * Returns OAuth2 credentials */ -export default async (req: Request): Promise => { - const { user, token } = await getFromRequest(req); +export default apiRoute(async (req, matchedRoute, extraData) => { + const { user, token } = extraData.auth; const application = await getFromToken(token); if (!user) return errorResponse("Unauthorized", 401); @@ -32,4 +31,4 @@ export default async (req: Request): Promise => { 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 99ee967f..0bbfa26b 100644 --- a/server/api/api/v1/blocks/index.ts +++ b/server/api/api/v1/blocks/index.ts @@ -1,10 +1,6 @@ import { errorResponse, jsonResponse } from "@response"; -import { - getFromRequest, - userRelations, - userToAPI, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { userRelations, userToAPI } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -19,8 +15,8 @@ export const meta = applyConfig({ }, }); -export default async (req: Request): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -37,4 +33,4 @@ export default async (req: Request): Promise => { }); 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 1590bbc0..224da926 100644 --- a/server/api/api/v1/custom_emojis/index.ts +++ b/server/api/api/v1/custom_emojis/index.ts @@ -1,4 +1,4 @@ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; import { client } from "~database/datasource"; import { emojiToAPI } from "~database/entities/Emoji"; @@ -15,11 +15,7 @@ export const meta = applyConfig({ }, }); -/** - * S - */ -// eslint-disable-next-line @typescript-eslint/require-await -export default async (): Promise => { +export default apiRoute(async () => { const emojis = await client.emoji.findMany({ where: { instanceId: null, @@ -29,4 +25,4 @@ export default async (): Promise => { 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 55e0a672..7e0b1dbb 100644 --- a/server/api/api/v1/favourites/index.ts +++ b/server/api/api/v1/favourites/index.ts @@ -1,8 +1,6 @@ import { errorResponse, jsonResponse } from "@response"; -import { getFromRequest } from "~database/entities/User"; -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; -import { parseRequest } from "@request"; import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; export const meta = applyConfig({ @@ -17,20 +15,15 @@ export const meta = applyConfig({ }, }); -export default async (req: Request): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute<{ + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; +}>(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; - const { - limit = 20, - max_id, - min_id, - since_id, - } = await parseRequest<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; - }>(req); + 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); @@ -77,4 +70,4 @@ export default async (req: Request): Promise => { 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 074aae92..874633da 100644 --- a/server/api/api/v1/follow_requests/[account_id]/authorize.ts +++ b/server/api/api/v1/follow_requests/[account_id]/authorize.ts @@ -1,8 +1,7 @@ import { errorResponse, jsonResponse } from "@response"; -import { getFromRequest, userRelations } from "~database/entities/User"; -import { applyConfig } from "@api"; +import { userRelations } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; -import type { MatchedRoute } from "bun"; import { checkForBidirectionalRelationships, relationshipToAPI, @@ -20,11 +19,8 @@ export const meta = applyConfig({ }, }); -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -76,4 +72,4 @@ export default async ( if (!relationship) return errorResponse("Relationship not found", 404); 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 6a667e79..41d4105c 100644 --- a/server/api/api/v1/follow_requests/[account_id]/reject.ts +++ b/server/api/api/v1/follow_requests/[account_id]/reject.ts @@ -1,8 +1,7 @@ import { errorResponse, jsonResponse } from "@response"; -import { getFromRequest, userRelations } from "~database/entities/User"; -import { applyConfig } from "@api"; +import { userRelations } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; -import type { MatchedRoute } from "bun"; import { checkForBidirectionalRelationships, relationshipToAPI, @@ -20,11 +19,8 @@ export const meta = applyConfig({ }, }); -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -64,4 +60,4 @@ export default async ( if (!relationship) return errorResponse("Relationship not found", 404); 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 d465c61d..ac937d46 100644 --- a/server/api/api/v1/follow_requests/index.ts +++ b/server/api/api/v1/follow_requests/index.ts @@ -1,12 +1,7 @@ import { errorResponse, jsonResponse } from "@response"; -import { - getFromRequest, - userRelations, - userToAPI, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { userRelations, userToAPI } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; -import { parseRequest } from "@request"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -20,20 +15,15 @@ export const meta = applyConfig({ }, }); -export default async (req: Request): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute<{ + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; +}>(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; - const { - limit = 20, - max_id, - min_id, - since_id, - } = await parseRequest<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; - }>(req); + 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); @@ -79,4 +69,4 @@ export default async (req: Request): Promise => { Link: linkHeader.join(", "), } ); -}; +}); diff --git a/server/api/api/v1/instance/index.ts b/server/api/api/v1/instance/index.ts index 259dbe0b..4fabefd0 100644 --- a/server/api/api/v1/instance/index.ts +++ b/server/api/api/v1/instance/index.ts @@ -1,5 +1,4 @@ -import { applyConfig } from "@api"; -import { getConfig } from "~classes/configmanager"; +import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; import { client } from "~database/datasource"; import { userRelations, userToAPI } from "~database/entities/User"; @@ -18,12 +17,8 @@ export const meta = applyConfig({ }, }); -/** - * Creates a new user - */ -// eslint-disable-next-line @typescript-eslint/require-await -export default async (): Promise => { - const config = getConfig(); +export default apiRoute(async (req, matchedRoute, extraData) => { + const config = await extraData.configManager.getConfig(); // Get software version from package.json const version = manifest.version; @@ -159,4 +154,4 @@ export default async (): Promise => { }, 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 f1e16511..8abd1bf0 100644 --- a/server/api/api/v1/media/[id]/index.ts +++ b/server/api/api/v1/media/[id]/index.ts @@ -1,15 +1,13 @@ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; -import { uploadFile } from "~classes/media"; -import { getConfig } from "~classes/configmanager"; import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; -import type { MatchedRoute } from "bun"; -import { parseRequest } from "@request"; +import type { MediaBackend } from "media-manager"; +import { MediaBackendType } from "media-manager"; +import { LocalMediaBackend } from "~packages/media-manager/backends/local"; +import { S3MediaBackend } from "~packages/media-manager/backends/s3"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["GET", "PUT"], ratelimits: { max: 10, @@ -25,11 +23,12 @@ export const meta: APIRouteMeta = applyConfig({ /** * Get media information */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute<{ + thumbnail?: File; + description?: string; + focus?: string; +}>(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; if (!user) { return errorResponse("Unauthorized", 401); @@ -47,7 +46,7 @@ export default async ( return errorResponse("Media not found", 404); } - const config = getConfig(); + const config = await extraData.configManager.getConfig(); switch (req.method) { case "GET": { @@ -60,21 +59,27 @@ export default async ( } } case "PUT": { - const { description, thumbnail } = await parseRequest<{ - thumbnail?: File; - description?: string; - focus?: string; - }>(req); + const { description, thumbnail } = extraData.parsedRequest; let thumbnailUrl = attachment.thumbnail_url; - if (thumbnail) { - const hash = await uploadFile( - thumbnail as unknown as File, - config - ); + let mediaManager: MediaBackend; - thumbnailUrl = hash ? getUrl(hash, config) : ""; + 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); } const descriptionText = description || attachment.description; @@ -101,4 +106,4 @@ export default async ( } 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 568d3bf1..202e5dbb 100644 --- a/server/api/api/v1/media/index.ts +++ b/server/api/api/v1/media/index.ts @@ -1,15 +1,15 @@ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; import { encode } from "blurhash"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; import sharp from "sharp"; -import { uploadFile } from "~classes/media"; -import { getConfig } from "~classes/configmanager"; import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; +import { MediaBackendType } from "media-manager"; +import type { MediaBackend } from "media-manager"; +import { LocalMediaBackend } from "~packages/media-manager/backends/local"; +import { S3MediaBackend } from "~packages/media-manager/backends/s3"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["POST"], ratelimits: { max: 10, @@ -25,27 +25,26 @@ export const meta: APIRouteMeta = applyConfig({ /** * Upload new media */ -export default async (req: Request): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute<{ + file: File; + thumbnail?: File; + description?: string; + // TODO: Add focus + focus?: string; +}>(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; if (!user) { return errorResponse("Unauthorized", 401); } - const form = await req.formData(); - - const file = form.get("file") as unknown as File | undefined; - const thumbnail = form.get("thumbnail"); - const description = form.get("description") as string | undefined; - - // Floating point numbers from -1.0 to 1.0, comma delimited - // const focus = form.get("focus"); + const { file, thumbnail, description } = extraData.parsedRequest; if (!file) { return errorResponse("No file provided", 400); } - const config = getConfig(); + const config = await extraData.configManager.getConfig(); if (file.size > config.validation.max_media_size) { return errorResponse( @@ -86,21 +85,35 @@ export default async (req: Request): Promise => { metadata?.height ?? 0, 4, 4 - ) + ) : null; let url = ""; - const hash = await uploadFile(file, config); + let mediaManager: MediaBackend; - url = hash ? getUrl(hash, config) : ""; + 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); + + url = getUrl(uploadedFile.name, config); let thumbnailUrl = ""; if (thumbnail) { - const hash = await uploadFile(thumbnail as unknown as File, config); + const { uploadedFile } = await mediaManager.addFile(thumbnail); - thumbnailUrl = hash ? getUrl(hash, config) : ""; + thumbnailUrl = getUrl(uploadedFile.name, config); } const newAttachment = await client.attachment.create({ @@ -120,4 +133,4 @@ export default async (req: Request): Promise => { // TODO: Add job to process videos and other media return jsonResponse(attachmentToAPI(newAttachment)); -}; +}); diff --git a/server/api/api/v1/mutes/index.ts b/server/api/api/v1/mutes/index.ts index dfa48057..702cc434 100644 --- a/server/api/api/v1/mutes/index.ts +++ b/server/api/api/v1/mutes/index.ts @@ -1,10 +1,6 @@ import { errorResponse, jsonResponse } from "@response"; -import { - getFromRequest, - userRelations, - userToAPI, -} from "~database/entities/User"; -import { applyConfig } from "@api"; +import { userRelations, userToAPI } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; export const meta = applyConfig({ @@ -19,8 +15,8 @@ export const meta = applyConfig({ }, }); -export default async (req: Request): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -37,4 +33,4 @@ export default async (req: Request): Promise => { }); 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 b5eb1c3b..b8971a27 100644 --- a/server/api/api/v1/notifications/index.ts +++ b/server/api/api/v1/notifications/index.ts @@ -1,9 +1,8 @@ import { errorResponse, jsonResponse } from "@response"; -import { getFromRequest, userRelations } from "~database/entities/User"; -import { applyConfig } from "@api"; +import { userRelations } from "~database/entities/User"; +import { apiRoute, applyConfig } from "@api"; import { client } from "~database/datasource"; import { statusAndUserRelations } from "~database/entities/Status"; -import { parseRequest } from "@request"; import { notificationToAPI } from "~database/entities/Notification"; export const meta = applyConfig({ @@ -18,8 +17,16 @@ export const meta = applyConfig({ }, }); -export default async (req: Request): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute<{ + 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; if (!user) return errorResponse("Unauthorized", 401); @@ -31,15 +38,7 @@ export default async (req: Request): Promise => { min_id, since_id, types, - } = await parseRequest<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; - exclude_types?: string[]; - types?: string[]; - account_id?: string; - }>(req); + } = extraData.parsedRequest; if (limit > 30) return errorResponse("Limit too high", 400); @@ -85,8 +84,9 @@ export default async (req: Request): Promise => { `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` ); linkHeader.push( - `<${urlWithoutQuery}?since_id=${objects.at(-1) - ?.id}&limit=${limit}>; rel="prev"` + `<${urlWithoutQuery}?since_id=${ + objects.at(-1)?.id + }&limit=${limit}>; rel="prev"` ); } @@ -97,4 +97,4 @@ export default async (req: Request): Promise => { Link: linkHeader.join(", "), } ); -}; +}); diff --git a/server/api/api/v1/profile/avatar.ts b/server/api/api/v1/profile/avatar.ts index c263718d..f6a1ea47 100644 --- a/server/api/api/v1/profile/avatar.ts +++ b/server/api/api/v1/profile/avatar.ts @@ -1,14 +1,9 @@ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; -import { - getFromRequest, - userRelations, - userToAPI, -} from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; +import { userRelations, userToAPI } from "~database/entities/User"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["DELETE"], ratelimits: { max: 10, @@ -23,8 +18,8 @@ export const meta: APIRouteMeta = applyConfig({ /** * Deletes a user avatar */ -export default async (req: Request): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -40,4 +35,4 @@ export default async (req: Request): Promise => { }); return jsonResponse(userToAPI(newUser)); -}; +}); diff --git a/server/api/api/v1/profile/header.ts b/server/api/api/v1/profile/header.ts index 9c9f9ec3..b3b52a60 100644 --- a/server/api/api/v1/profile/header.ts +++ b/server/api/api/v1/profile/header.ts @@ -1,14 +1,9 @@ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; -import { - getFromRequest, - userRelations, - userToAPI, -} from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; +import { userRelations, userToAPI } from "~database/entities/User"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["DELETE"], ratelimits: { max: 10, @@ -23,8 +18,8 @@ export const meta: APIRouteMeta = applyConfig({ /** * Deletes a user header */ -export default async (req: Request): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -40,4 +35,4 @@ export default async (req: Request): Promise => { }); 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 ab9aadab..93eed932 100644 --- a/server/api/api/v1/statuses/[id]/context.ts +++ b/server/api/api/v1/statuses/[id]/context.ts @@ -1,6 +1,5 @@ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { client } from "~database/datasource"; import { getAncestors, @@ -8,10 +7,8 @@ import { statusAndUserRelations, statusToAPI, } from "~database/entities/Status"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["GET"], ratelimits: { max: 8, @@ -26,15 +23,12 @@ export const meta: APIRouteMeta = applyConfig({ /** * Fetch a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +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; - const { user } = await getFromRequest(req); + const { user } = extraData.auth; const foundStatus = await client.status.findUnique({ where: { id }, @@ -55,4 +49,4 @@ export default async ( 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 d1f8c39a..c58e959e 100644 --- a/server/api/api/v1/statuses/[id]/favourite.ts +++ b/server/api/api/v1/statuses/[id]/favourite.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { client } from "~database/datasource"; import { createLike } from "~database/entities/Like"; import { @@ -9,11 +8,9 @@ import { statusAndUserRelations, statusToAPI, } from "~database/entities/Status"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; import type { APIStatus } from "~types/entities/status"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["POST"], ratelimits: { max: 100, @@ -28,13 +25,10 @@ export const meta: APIRouteMeta = applyConfig({ /** * Favourite a post */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user } = await getFromRequest(req); + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -63,4 +57,4 @@ export default async ( 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 edbb0fdc..066eb681 100644 --- a/server/api/api/v1/statuses/[id]/favourited_by.ts +++ b/server/api/api/v1/statuses/[id]/favourited_by.ts @@ -1,21 +1,13 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { applyConfig } from "@api"; -import { parseRequest } from "@request"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { client } from "~database/datasource"; import { isViewableByUser, statusAndUserRelations, } from "~database/entities/Status"; -import { - getFromRequest, - userRelations, - userToAPI, -} from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; +import { userRelations, userToAPI } from "~database/entities/User"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["GET"], ratelimits: { max: 100, @@ -30,13 +22,15 @@ export const meta: APIRouteMeta = applyConfig({ /** * Fetch users who favourited the post */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute<{ + max_id?: string; + min_id?: string; + since_id?: string; + limit?: number; +}>(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user } = await getFromRequest(req); + const { user } = extraData.auth; const status = await client.status.findUnique({ where: { id }, @@ -52,12 +46,7 @@ export default async ( min_id = null, since_id = null, limit = 40, - } = await parseRequest<{ - max_id?: string; - min_id?: string; - since_id?: string; - limit?: number; - }>(req); + } = extraData.parsedRequest; // Check for limit limits if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); @@ -111,4 +100,4 @@ export default async ( Link: linkHeader.join(", "), } ); -}; +}); diff --git a/server/api/api/v1/statuses/[id]/index.ts b/server/api/api/v1/statuses/[id]/index.ts index 22e335ed..8dfdc8b7 100644 --- a/server/api/api/v1/statuses/[id]/index.ts +++ b/server/api/api/v1/statuses/[id]/index.ts @@ -1,9 +1,6 @@ -import { applyConfig } from "@api"; -import { getConfig } from "~classes/configmanager"; -import { parseRequest } from "@request"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { sanitizeHtml } from "@sanitization"; -import type { MatchedRoute } from "bun"; import { parse } from "marked"; import { client } from "~database/datasource"; import { @@ -12,10 +9,8 @@ import { statusAndUserRelations, statusToAPI, } from "~database/entities/Status"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["GET", "DELETE", "PUT"], ratelimits: { max: 100, @@ -31,20 +26,28 @@ export const meta: APIRouteMeta = applyConfig({ /** * Fetch a user */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +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; +}>(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user } = await getFromRequest(req); + const { user } = extraData.auth; const status = await client.status.findUnique({ where: { id }, include: statusAndUserRelations, }); - const config = 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)) @@ -85,22 +88,11 @@ export default async ( status: statusText, content_type, "poll[expires_in]": expires_in, - "poll[options][]": options, - "media_ids[]": media_ids, + "poll[options]": options, + media_ids: media_ids, spoiler_text, sensitive, - } = await parseRequest<{ - 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; - }>(req); + } = extraData.parsedRequest; // TODO: Add Poll support // Validate status @@ -171,11 +163,11 @@ export default async ( let sanitizedStatus: string; if (content_type === "text/markdown") { - sanitizedStatus = await sanitizeHtml(parse(statusText ?? "")); + sanitizedStatus = await sanitizeHtml(await parse(statusText ?? "")); } else if (content_type === "text/x.misskeymarkdown") { // Parse as MFM // TODO: Parse as MFM - sanitizedStatus = await sanitizeHtml(parse(statusText ?? "")); + sanitizedStatus = await sanitizeHtml(await parse(statusText ?? "")); } else { sanitizedStatus = await sanitizeHtml(statusText ?? ""); } @@ -189,8 +181,8 @@ export default async ( // Check if status body doesnt match filters if ( - config.filters.note_filters.some( - filter => statusText?.match(filter) + config.filters.note_filters.some(filter => + statusText?.match(filter) ) ) { return errorResponse("Status contains blocked words", 422); @@ -223,4 +215,4 @@ export default async ( } return jsonResponse({}); -}; +}); diff --git a/server/api/api/v1/statuses/[id]/pin.ts b/server/api/api/v1/statuses/[id]/pin.ts index 72eceee6..fd215f46 100644 --- a/server/api/api/v1/statuses/[id]/pin.ts +++ b/server/api/api/v1/statuses/[id]/pin.ts @@ -1,13 +1,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { client } from "~database/datasource"; import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["POST"], ratelimits: { max: 100, @@ -22,13 +19,10 @@ export const meta: APIRouteMeta = applyConfig({ /** * Pin a post */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user } = await getFromRequest(req); + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -62,4 +56,4 @@ export default async ( if (!status) return errorResponse("Record not found", 404); 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 9cf3716e..73b0ef73 100644 --- a/server/api/api/v1/statuses/[id]/reblog.ts +++ b/server/api/api/v1/statuses/[id]/reblog.ts @@ -1,22 +1,15 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { applyConfig } from "@api"; -import { getConfig } from "~classes/configmanager"; -import { parseRequest } from "@request"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { client } from "~database/datasource"; import { isViewableByUser, statusAndUserRelations, statusToAPI, } from "~database/entities/Status"; -import { - getFromRequest, - type UserWithRelations, -} from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; +import { type UserWithRelations } from "~database/entities/User"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["POST"], ratelimits: { max: 100, @@ -31,20 +24,17 @@ export const meta: APIRouteMeta = applyConfig({ /** * Reblogs a post */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute<{ + visibility: "public" | "unlisted" | "private"; +}>(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const config = getConfig(); + const config = await extraData.configManager.getConfig(); - const { user } = await getFromRequest(req); + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); - const { visibility = "public" } = await parseRequest<{ - visibility: "public" | "unlisted" | "private"; - }>(req); + const { visibility = "public" } = extraData.parsedRequest; const status = await client.status.findUnique({ where: { id }, @@ -107,4 +97,4 @@ export default async ( user ) ); -}; +}); diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.ts b/server/api/api/v1/statuses/[id]/reblogged_by.ts index d51fa975..b02a02a2 100644 --- a/server/api/api/v1/statuses/[id]/reblogged_by.ts +++ b/server/api/api/v1/statuses/[id]/reblogged_by.ts @@ -1,21 +1,13 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { applyConfig } from "@api"; -import { parseRequest } from "@request"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { client } from "~database/datasource"; import { isViewableByUser, statusAndUserRelations, } from "~database/entities/Status"; -import { - getFromRequest, - userRelations, - userToAPI, -} from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; +import { userRelations, userToAPI } from "~database/entities/User"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["GET"], ratelimits: { max: 100, @@ -30,13 +22,15 @@ export const meta: APIRouteMeta = applyConfig({ /** * Fetch users who reblogged the post */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute<{ + max_id?: string; + min_id?: string; + since_id?: string; + limit?: number; +}>(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user } = await getFromRequest(req); + const { user } = extraData.auth; const status = await client.status.findUnique({ where: { id }, @@ -52,12 +46,7 @@ export default async ( min_id = null, since_id = null, limit = 40, - } = await parseRequest<{ - max_id?: string; - min_id?: string; - since_id?: string; - limit?: number; - }>(req); + } = extraData.parsedRequest; // Check for limit limits if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); @@ -112,4 +101,4 @@ export default async ( Link: linkHeader.join(", "), } ); -}; +}); diff --git a/server/api/api/v1/statuses/[id]/source.ts b/server/api/api/v1/statuses/[id]/source.ts index ae9defbc..5bb90600 100644 --- a/server/api/api/v1/statuses/[id]/source.ts +++ b/server/api/api/v1/statuses/[id]/source.ts @@ -1,19 +1,12 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { applyConfig } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; +import { apiRoute, applyConfig } from "@api"; +import { errorResponse } from "@response"; import { client } from "~database/datasource"; -import { createLike } from "~database/entities/Like"; import { isViewableByUser, statusAndUserRelations, - statusToAPI, } from "~database/entities/Status"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; -import type { APIStatus } from "~types/entities/status"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["GET"], ratelimits: { max: 100, @@ -28,13 +21,10 @@ export const meta: APIRouteMeta = applyConfig({ /** * Favourite a post */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user } = await getFromRequest(req); + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -46,4 +36,6 @@ export default async ( // 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"); +}); diff --git a/server/api/api/v1/statuses/[id]/unfavourite.ts b/server/api/api/v1/statuses/[id]/unfavourite.ts index 36de4fe2..66ebe82b 100644 --- a/server/api/api/v1/statuses/[id]/unfavourite.ts +++ b/server/api/api/v1/statuses/[id]/unfavourite.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { client } from "~database/datasource"; import { deleteLike } from "~database/entities/Like"; import { @@ -9,11 +8,9 @@ import { statusAndUserRelations, statusToAPI, } from "~database/entities/Status"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; import type { APIStatus } from "~types/entities/status"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["POST"], ratelimits: { max: 100, @@ -28,13 +25,10 @@ export const meta: APIRouteMeta = applyConfig({ /** * Unfavourite a post */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user } = await getFromRequest(req); + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -54,4 +48,4 @@ export default async ( 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 af8f4a7a..89ac2da4 100644 --- a/server/api/api/v1/statuses/[id]/unpin.ts +++ b/server/api/api/v1/statuses/[id]/unpin.ts @@ -1,13 +1,9 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { client } from "~database/datasource"; import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["POST"], ratelimits: { max: 100, @@ -22,13 +18,10 @@ export const meta: APIRouteMeta = applyConfig({ /** * Unpins a post */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user } = await getFromRequest(req); + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -62,4 +55,4 @@ export default async ( if (!status) return errorResponse("Record not found", 404); 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 56a1f82a..07d92b33 100644 --- a/server/api/api/v1/statuses/[id]/unreblog.ts +++ b/server/api/api/v1/statuses/[id]/unreblog.ts @@ -1,18 +1,14 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { client } from "~database/datasource"; import { isViewableByUser, statusAndUserRelations, statusToAPI, } from "~database/entities/Status"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; import type { APIStatus } from "~types/entities/status"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["POST"], ratelimits: { max: 100, @@ -27,13 +23,10 @@ export const meta: APIRouteMeta = applyConfig({ /** * Unreblogs a post */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const id = matchedRoute.params.id; - const { user } = await getFromRequest(req); + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -66,4 +59,4 @@ export default async ( 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 0aa39329..97eb34e5 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -1,12 +1,6 @@ -/* 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 "~classes/configmanager"; -import { parseRequest } from "@request"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { sanitizeHtml } from "@sanitization"; -import type { MatchedRoute } from "bun"; import { parse } from "marked"; import { client } from "~database/datasource"; import { getFromToken } from "~database/entities/Application"; @@ -16,10 +10,9 @@ import { statusAndUserRelations, statusToAPI, } from "~database/entities/Status"; -import type { AuthData, UserWithRelations } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; +import type { UserWithRelations } from "~database/entities/User"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["POST"], ratelimits: { max: 300, @@ -34,50 +27,46 @@ export const meta: APIRouteMeta = applyConfig({ /** * Post new status */ -export default async ( - req: Request, - matchedRoute: MatchedRoute, - authData: AuthData -): Promise => { - const { user, token } = authData; +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; +}>(async (req, matchedRoute, extraData) => { + const { user, token } = extraData.auth; const application = await getFromToken(token); if (!user) return errorResponse("Unauthorized", 401); - const config = 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[hide_totals]": hide_totals, + // "poll[multiple]": multiple, "poll[options]": options, in_reply_to_id, quote_id, - language, + // language, scheduled_at, sensitive, spoiler_text, visibility, content_type, - } = await parseRequest<{ - 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; - }>(req); + } = extraData.parsedRequest; // Validate status if (!status && !(media_ids && media_ids.length > 0)) { @@ -246,7 +235,7 @@ export default async ( ? { user: replyUser, status: replyStatus, - } + } : undefined, quote: quote || undefined, }); @@ -254,4 +243,4 @@ export default async ( // TODO: add database jobs to deliver the post 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 cb1374c4..ce4e96aa 100644 --- a/server/api/api/v1/timelines/home.ts +++ b/server/api/api/v1/timelines/home.ts @@ -1,13 +1,9 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { applyConfig } from "@api"; -import { parseRequest } from "@request"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["GET"], ratelimits: { max: 200, @@ -22,20 +18,15 @@ export const meta: APIRouteMeta = applyConfig({ /** * Fetch home timeline statuses */ -export default async (req: Request): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute<{ + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; +}>(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; - const { - limit = 20, - max_id, - min_id, - since_id, - } = await parseRequest<{ - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; - }>(req); + 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); @@ -104,4 +95,4 @@ export default async (req: Request): Promise => { Link: linkHeader.join(", "), } ); -}; +}); diff --git a/server/api/api/v1/timelines/public.ts b/server/api/api/v1/timelines/public.ts index 6c1e418e..c58cfb15 100644 --- a/server/api/api/v1/timelines/public.ts +++ b/server/api/api/v1/timelines/public.ts @@ -1,12 +1,9 @@ -import { applyConfig } from "@api"; -import { parseRequest } from "@request"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["GET"], ratelimits: { max: 200, @@ -18,8 +15,16 @@ export const meta: APIRouteMeta = applyConfig({ }, }); -export default async (req: Request): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute<{ + 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, @@ -28,15 +33,7 @@ export default async (req: Request): Promise => { // only_media, remote, since_id, - } = await parseRequest<{ - local?: boolean; - only_media?: boolean; - remote?: boolean; - max_id?: string; - since_id?: string; - min_id?: string; - limit?: number; - }>(req); + } = extraData.parsedRequest; if (limit < 1 || limit > 40) { return errorResponse("Limit must be between 1 and 40", 400); @@ -56,10 +53,10 @@ export default async (req: Request): Promise => { instanceId: remote ? { not: null, - } + } : local - ? null - : undefined, + ? null + : undefined, }, include: statusAndUserRelations, take: limit, @@ -87,4 +84,4 @@ export default async (req: Request): Promise => { Link: linkHeader.join(", "), } ); -}; +}); diff --git a/server/api/api/v2/media/index.ts b/server/api/api/v2/media/index.ts index 32585cdf..2593ae05 100644 --- a/server/api/api/v2/media/index.ts +++ b/server/api/api/v2/media/index.ts @@ -1,15 +1,15 @@ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; import { encode } from "blurhash"; -import { getFromRequest } from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; import sharp from "sharp"; -import { uploadFile } from "~classes/media"; -import { getConfig } from "~classes/configmanager"; import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; +import type { MediaBackend } from "media-manager"; +import { MediaBackendType } from "media-manager"; +import { LocalMediaBackend } from "~packages/media-manager/backends/local"; +import { S3MediaBackend } from "~packages/media-manager/backends/s3"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["POST"], ratelimits: { max: 10, @@ -25,27 +25,26 @@ export const meta: APIRouteMeta = applyConfig({ /** * Upload new media */ -export default async (req: Request): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute<{ + file: File; + thumbnail: File; + description: string; + // TODO: Implement focus storage + focus: string; +}>(async (req, matchedRoute, extraData) => { + const { user } = extraData.auth; if (!user) { return errorResponse("Unauthorized", 401); } - const form = await req.formData(); - - const file = form.get("file") as unknown as File | undefined; - const thumbnail = form.get("thumbnail"); - const description = form.get("description") as string | undefined; - - // Floating point numbers from -1.0 to 1.0, comma delimited - // const focus = form.get("focus"); + const { file, thumbnail, description } = extraData.parsedRequest; if (!file) { return errorResponse("No file provided", 400); } - const config = getConfig(); + const config = await extraData.configManager.getConfig(); if (file.size > config.validation.max_media_size) { return errorResponse( @@ -86,23 +85,37 @@ export default async (req: Request): Promise => { metadata?.height ?? 0, 4, 4 - ) + ) : null; let url = ""; - if (isImage) { - const hash = await uploadFile(file, config); + let mediaManager: MediaBackend; - url = hash ? getUrl(hash, config) : ""; + 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); + + url = getUrl(uploadedFile.name, config); } let thumbnailUrl = ""; if (thumbnail) { - const hash = await uploadFile(thumbnail as unknown as File, config); + const { uploadedFile } = await mediaManager.addFile(thumbnail); - thumbnailUrl = hash ? getUrl(hash, config) : ""; + thumbnailUrl = getUrl(uploadedFile.name, config); } const newAttachment = await client.attachment.create({ @@ -132,4 +145,4 @@ export default async (req: Request): Promise => { 202 ); } -}; +}); diff --git a/server/api/api/v2/search/index.ts b/server/api/api/v2/search/index.ts index c40f7a92..c327dedb 100644 --- a/server/api/api/v2/search/index.ts +++ b/server/api/api/v2/search/index.ts @@ -1,18 +1,11 @@ -import { applyConfig } from "@api"; -import { getConfig } from "~classes/configmanager"; +import { apiRoute, applyConfig } from "@api"; import { MeiliIndexType, meilisearch } from "@meilisearch"; -import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; -import { - getFromRequest, - userRelations, - userToAPI, -} from "~database/entities/User"; -import type { APIRouteMeta } from "~types/api"; +import { userRelations, userToAPI } from "~database/entities/User"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["GET"], ratelimits: { max: 10, @@ -28,8 +21,18 @@ export const meta: APIRouteMeta = applyConfig({ /** * Upload new media */ -export default async (req: Request): Promise => { - const { user } = await getFromRequest(req); +export default apiRoute<{ + 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 { q, @@ -41,19 +44,9 @@ export default async (req: Request): Promise => { // min_id, limit = 20, offset, - } = await parseRequest<{ - q?: string; - type?: string; - resolve?: boolean; - following?: boolean; - account_id?: string; - max_id?: string; - min_id?: string; - limit?: number; - offset?: number; - }>(req); + } = extraData.parsedRequest; - const config = getConfig(); + const config = await extraData.configManager.getConfig(); if (!config.meilisearch.enabled) { return errorResponse("Meilisearch is not enabled", 501); @@ -143,4 +136,4 @@ export default async (req: Request): Promise => { ), hashtags: [], }); -}; +}); diff --git a/server/api/auth/login/index.ts b/server/api/auth/login/index.ts index c8d6bc9a..7b9094b6 100644 --- a/server/api/auth/login/index.ts +++ b/server/api/auth/login/index.ts @@ -1,12 +1,10 @@ -import { applyConfig } from "@api"; -import type { MatchedRoute } from "bun"; +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/User"; -import type { APIRouteMeta } from "~types/api"; -export const meta: APIRouteMeta = applyConfig({ +export const meta = applyConfig({ allowedMethods: ["POST"], ratelimits: { max: 4, @@ -21,10 +19,10 @@ export const meta: APIRouteMeta = applyConfig({ /** * OAuth Code flow */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute<{ + email: string; + password: string; +}>(async (req, matchedRoute, extraData) => { const scopes = (matchedRoute.query.scope || "") .replaceAll("+", " ") .split(" "); @@ -32,10 +30,7 @@ export default async ( const response_type = matchedRoute.query.response_type; const client_id = matchedRoute.query.client_id; - const formData = await req.formData(); - - const email = formData.get("email")?.toString() || null; - const password = formData.get("password")?.toString() || null; + const { email, password } = extraData.parsedRequest; const redirectToLogin = (error: string) => Response.redirect( @@ -96,4 +91,4 @@ export default async ( // 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 cd8ebfae..4ee0589a 100644 --- a/server/api/media/[id]/index.ts +++ b/server/api/media/[id]/index.ts @@ -1,6 +1,5 @@ import { errorResponse } from "@response"; -import { applyConfig } from "@api"; -import type { MatchedRoute } from "bun"; +import { apiRoute, applyConfig } from "@api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -14,10 +13,7 @@ export const meta = applyConfig({ }, }); -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute) => { // TODO: Add checks for disabled or not email verified accounts const id = matchedRoute.params.id; @@ -46,4 +42,4 @@ export default async ( "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 8d3c0cef..99288f60 100644 --- a/server/api/nodeinfo/2.0/index.ts +++ b/server/api/nodeinfo/2.0/index.ts @@ -1,4 +1,4 @@ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; export const meta = applyConfig({ @@ -16,8 +16,7 @@ export const meta = applyConfig({ /** * ActivityPub nodeinfo 2.0 endpoint */ -// eslint-disable-next-line @typescript-eslint/require-await -export default async (): Promise => { +export default apiRoute(() => { // TODO: Implement this return jsonResponse({ version: "2.0", @@ -31,4 +30,4 @@ export default async (): Promise => { openRegistrations: false, metadata: {}, }); -}; +}); diff --git a/server/api/oauth/authorize-external/index.ts b/server/api/oauth/authorize-external/index.ts index db24bbcb..688f7a6a 100644 --- a/server/api/oauth/authorize-external/index.ts +++ b/server/api/oauth/authorize-external/index.ts @@ -1,7 +1,5 @@ -import { applyConfig } from "@api"; -import { getConfig } from "~classes/configmanager"; +import { apiRoute, applyConfig } from "@api"; import { oauthRedirectUri } from "@constants"; -import type { MatchedRoute } from "bun"; import { calculatePKCECodeChallenge, discoveryRequest, @@ -25,11 +23,7 @@ export const meta = applyConfig({ /** * Redirects the user to the external OAuth provider */ -export default async ( - req: Request, - matchedRoute: MatchedRoute - // eslint-disable-next-line @typescript-eslint/require-await -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const redirectToLogin = (error: string) => Response.redirect( `/oauth/authorize?` + @@ -49,7 +43,7 @@ export default async ( return redirectToLogin("Missing client_id"); } - const config = getConfig(); + const config = await extraData.configManager.getConfig(); const issuer = config.oidc.providers.find( provider => provider.id === issuerId @@ -98,4 +92,4 @@ export default async ( }).toString(), 302 ); -}; +}); diff --git a/server/api/oauth/callback/[issuer]/index.ts b/server/api/oauth/callback/[issuer]/index.ts index dd5290a2..15d905eb 100644 --- a/server/api/oauth/callback/[issuer]/index.ts +++ b/server/api/oauth/callback/[issuer]/index.ts @@ -1,7 +1,5 @@ -import { applyConfig } from "@api"; -import { getConfig } from "~classes/configmanager"; +import { apiRoute, applyConfig } from "@api"; import { oauthRedirectUri } from "@constants"; -import type { MatchedRoute } from "bun"; import { randomBytes } from "crypto"; import { authorizationCodeGrantRequest, @@ -33,10 +31,7 @@ export const meta = applyConfig({ /** * Redirects the user to the external OAuth provider */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const redirectToLogin = (error: string) => Response.redirect( `/oauth/authorize?` + @@ -65,7 +60,7 @@ export default async ( return redirectToLogin("Invalid flow"); } - const config = getConfig(); + const config = await extraData.configManager.getConfig(); const issuer = config.oidc.providers.find( provider => provider.id === issuerParam @@ -192,4 +187,4 @@ export default async ( `${flow.application.redirect_uris}?code=${code}`, 302 ); -}; +}); diff --git a/server/api/oauth/providers/index.ts b/server/api/oauth/providers/index.ts index c3cc1ac8..ad0d9769 100644 --- a/server/api/oauth/providers/index.ts +++ b/server/api/oauth/providers/index.ts @@ -1,5 +1,4 @@ -import { applyConfig } from "@api"; -import { getConfig } from "~classes/configmanager"; +import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; export const meta = applyConfig({ @@ -17,9 +16,8 @@ export const meta = applyConfig({ /** * Lists available OAuth providers */ -// eslint-disable-next-line @typescript-eslint/require-await -export default async (): Promise => { - const config = getConfig(); +export default apiRoute(async (req, matchedRoute, extraData) => { + const config = await extraData.configManager.getConfig(); return jsonResponse( config.oidc.providers.map(p => ({ @@ -28,4 +26,4 @@ export default async (): Promise => { id: p.id, })) ); -}; +}); diff --git a/server/api/oauth/token/index.ts b/server/api/oauth/token/index.ts index 70a68db8..f75f2209 100644 --- a/server/api/oauth/token/index.ts +++ b/server/api/oauth/token/index.ts @@ -1,5 +1,4 @@ -import { applyConfig } from "@api"; -import { parseRequest } from "@request"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; @@ -18,16 +17,16 @@ export const meta = applyConfig({ /** * Allows getting token from OAuth code */ -export default async (req: Request): Promise => { +export default apiRoute<{ + 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 } = - await parseRequest<{ - grant_type: string; - code: string; - redirect_uri: string; - client_id: string; - client_secret: string; - scope: string; - }>(req); + extraData.parsedRequest; if (grant_type !== "authorization_code") return errorResponse( @@ -61,4 +60,4 @@ export default async (req: Request): Promise => { 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 4d91f5b9..3803234b 100644 --- a/server/api/object/[uuid]/index.ts +++ b/server/api/object/[uuid]/index.ts @@ -1,8 +1,5 @@ -/* eslint-disable @typescript-eslint/require-await */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -16,12 +13,6 @@ export const meta = applyConfig({ route: "/object/:id", }); -/** - * Fetch a user - */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(() => { return jsonResponse({}); -}; +}); diff --git a/server/api/routes.type.ts b/server/api/routes.type.ts new file mode 100644 index 00000000..d3cea716 --- /dev/null +++ b/server/api/routes.type.ts @@ -0,0 +1,13 @@ +import type { MatchedRoute } from "bun"; +import type { ConfigManager } from "config-manager"; +import type { AuthData } from "~database/entities/User"; + +export type RouteHandler = ( + req: Request, + matchedRoute: MatchedRoute, + extraData: { + auth: AuthData; + parsedRequest: Partial; + configManager: ConfigManager; + } +) => Response | Promise; diff --git a/server/api/users/[uuid]/inbox/index.ts b/server/api/users/[uuid]/inbox/index.ts index bc325870..f1547cc0 100644 --- a/server/api/users/[uuid]/inbox/index.ts +++ b/server/api/users/[uuid]/inbox/index.ts @@ -1,10 +1,7 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { applyConfig } from "@api"; -import { getConfig } from "~classes/configmanager"; +// TODO: Refactor into smaller packages +import { apiRoute, applyConfig } from "@api"; import { getBestContentType } from "@content_types"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { client } from "~database/datasource"; import { parseEmojis } from "~database/entities/Emoji"; import { createLike, deleteLike } from "~database/entities/Like"; @@ -39,13 +36,10 @@ export const meta = applyConfig({ /** * ActivityPub user inbox endpoint */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const username = matchedRoute.params.username; - const config = getConfig(); + const config = await extraData.configManager.getConfig(); try { if ( @@ -313,7 +307,7 @@ export default async ( } // Create new reblog - const newReblog = await client.status.create({ + await client.status.create({ data: { authorId: author.id, reblogId: rebloggedStatus.id, @@ -405,4 +399,4 @@ export default async ( } return jsonResponse({}); -}; +}); diff --git a/server/api/users/[uuid]/index.ts b/server/api/users/[uuid]/index.ts index 3c392ee6..763caf08 100644 --- a/server/api/users/[uuid]/index.ts +++ b/server/api/users/[uuid]/index.ts @@ -1,9 +1,5 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { applyConfig } from "@api"; -import { getConfig } from "~classes/configmanager"; +import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; import { client } from "~database/datasource"; import { userRelations, userToLysand } from "~database/entities/User"; @@ -22,14 +18,9 @@ export const meta = applyConfig({ /** * ActivityPub user inbox endpoint */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute) => { const uuid = matchedRoute.params.uuid; - const config = getConfig(); - const user = await client.user.findUnique({ where: { id: uuid, @@ -42,4 +33,4 @@ export default async ( } return jsonResponse(userToLysand(user)); -}; +}); diff --git a/server/api/users/[uuid]/outbox/index.ts b/server/api/users/[uuid]/outbox/index.ts index d130668a..90add2c7 100644 --- a/server/api/users/[uuid]/outbox/index.ts +++ b/server/api/users/[uuid]/outbox/index.ts @@ -1,7 +1,5 @@ import { jsonResponse } from "@response"; -import type { MatchedRoute } from "bun"; -import { getConfig, getHost } from "~classes/configmanager"; -import { applyConfig } from "@api"; +import { apiRoute, applyConfig } from "@api"; import { statusAndUserRelations, statusToLysand, @@ -23,13 +21,11 @@ export const meta = applyConfig({ /** * ActivityPub user outbox endpoint */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { +export default apiRoute(async (req, matchedRoute, extraData) => { const uuid = matchedRoute.params.uuid; const pageNumber = Number(matchedRoute.query.page) || 1; - const config = getConfig(); + const config = await extraData.configManager.getConfig(); + const host = new URL(config.http.base_url).hostname; const statuses = await client.status.findMany({ where: { @@ -53,19 +49,19 @@ export default async ( }); return jsonResponse({ - first: `${getHost()}/users/${uuid}/outbox?page=1`, - last: `${getHost()}/users/${uuid}/outbox?page=1`, + 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 - ? `${getHost()}/users/${uuid}/outbox?page=${pageNumber + 1}` + ? `${host}/users/${uuid}/outbox?page=${pageNumber + 1}` : undefined, prev: pageNumber > 1 - ? `${getHost()}/users/${uuid}/outbox?page=${pageNumber - 1}` + ? `${host}/users/${uuid}/outbox?page=${pageNumber - 1}` : undefined, items: statuses.map(s => statusToLysand(s)), }); -}; +}); diff --git a/tests/actor.test.ts b/tests/actor.test.ts deleted file mode 100644 index fab17ac7..00000000 --- a/tests/actor.test.ts +++ /dev/null @@ -1 +0,0 @@ -// Empty file diff --git a/tests/api.test.ts b/tests/api.test.ts index 63b3a070..3416c9b3 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,8 +1,6 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { getConfig } from "~classes/configmanager"; import type { Token } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { ConfigManager } from "config-manager"; import { client } from "~database/datasource"; import { TokenType } from "~database/entities/Token"; import { @@ -11,8 +9,10 @@ import { } from "~database/entities/User"; import type { APIEmoji } from "~types/entities/emoji"; import type { APIInstance } from "~types/entities/instance"; +import { sendTestRequest, wrapRelativeUrl } from "./utils"; -const config = getConfig(); +const config = await new ConfigManager({}).getConfig(); +const base_url = config.http.base_url; let token: Token; let user: UserWithRelations; @@ -71,14 +71,16 @@ describe("API Tests", () => { describe("GET /api/v1/instance", () => { test("should return an APIInstance object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/instance`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - } + 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); @@ -117,15 +119,21 @@ describe("API Tests", () => { }, }); }); + test("should return an array of at least one custom emoji", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/custom_emojis`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } + 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); @@ -139,6 +147,7 @@ describe("API Tests", () => { expect(emojis[0].shortcode).toBeString(); expect(emojis[0].url).toBeString(); }); + afterAll(async () => { await client.emoji.deleteMany({ where: { diff --git a/tests/api/accounts.test.ts b/tests/api/accounts.test.ts index feee9680..201d1066 100644 --- a/tests/api/accounts.test.ts +++ b/tests/api/accounts.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { getConfig } from "~classes/configmanager"; import type { Token } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { client } from "~database/datasource"; @@ -12,21 +9,24 @@ import { import type { APIAccount } from "~types/entities/account"; import type { APIRelationship } from "~types/entities/relationship"; import type { APIStatus } from "~types/entities/status"; +import { ConfigManager } from "config-manager"; +import { sendTestRequest, wrapRelativeUrl } from "~tests/utils"; -const config = getConfig(); +const config = await new ConfigManager({}).getConfig(); +const base_url = config.http.base_url; let token: Token; let user: UserWithRelations; let user2: UserWithRelations; beforeAll(async () => { - /* await client.user.deleteMany({ + await client.user.deleteMany({ where: { username: { in: ["test", "test2"], }, }, - }); */ + }); user = await createNewLocalUser({ email: "test@test.com", @@ -87,15 +87,17 @@ afterAll(async () => { 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 fetch( - `${config.http.base_url}/api/v1/accounts/999999`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } + 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); @@ -107,18 +109,23 @@ describe("API Tests", () => { describe("PATCH /api/v1/accounts/update_credentials", () => { test("should update the authenticated user's display name", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/update_credentials`, - { - method: "PATCH", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - display_name: "New Display Name", - }), - } + 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); @@ -134,15 +141,20 @@ describe("API Tests", () => { describe("GET /api/v1/accounts/verify_credentials", () => { test("should return the authenticated user's account information", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/verify_credentials`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } + 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); @@ -179,15 +191,20 @@ describe("API Tests", () => { describe("GET /api/v1/accounts/:id/statuses", () => { test("should return the statuses of the specified user", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user.id}/statuses`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } + 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); @@ -203,16 +220,21 @@ describe("API Tests", () => { describe("POST /api/v1/accounts/:id/follow", () => { test("should follow the specified user and return an APIRelationship object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/follow`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } + 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); @@ -229,16 +251,21 @@ describe("API Tests", () => { describe("POST /api/v1/accounts/:id/unfollow", () => { test("should unfollow the specified user and return an APIRelationship object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/unfollow`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } + 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); @@ -255,16 +282,21 @@ describe("API Tests", () => { 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 fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/remove_from_followers`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } + 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); @@ -281,16 +313,21 @@ describe("API Tests", () => { describe("POST /api/v1/accounts/:id/block", () => { test("should block the specified user and return an APIRelationship object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/block`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } + 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); @@ -307,14 +344,13 @@ describe("API Tests", () => { describe("GET /api/v1/blocks", () => { test("should return an array of APIAccount objects for the user's blocked accounts", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/blocks`, - { + 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); @@ -331,16 +367,21 @@ describe("API Tests", () => { describe("POST /api/v1/accounts/:id/unblock", () => { test("should unblock the specified user and return an APIRelationship object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/unblock`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } + 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); @@ -357,16 +398,21 @@ describe("API Tests", () => { 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 fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/mute`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ notifications: true }), - } + 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); @@ -382,16 +428,21 @@ describe("API Tests", () => { }); test("should mute the specified user and return an APIRelationship object with notifications set to true", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/mute`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ notifications: false }), - } + 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); @@ -409,14 +460,13 @@ describe("API Tests", () => { describe("GET /api/v1/mutes", () => { test("should return an array of APIAccount objects for the user's muted accounts", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/mutes`, - { + 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); @@ -434,16 +484,21 @@ describe("API Tests", () => { describe("POST /api/v1/accounts/:id/unmute", () => { test("should unmute the specified user and return an APIRelationship object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/unmute`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } + 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); @@ -460,16 +515,21 @@ describe("API Tests", () => { describe("POST /api/v1/accounts/:id/pin", () => { test("should pin the specified user and return an APIRelationship object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/pin`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } + 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); @@ -486,16 +546,21 @@ describe("API Tests", () => { describe("POST /api/v1/accounts/:id/unpin", () => { test("should unpin the specified user and return an APIRelationship object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/unpin`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } + 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); @@ -512,16 +577,21 @@ describe("API Tests", () => { 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 fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/note`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ comment: "This is a new note" }), - } + 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); @@ -538,14 +608,19 @@ describe("API Tests", () => { describe("GET /api/v1/accounts/relationships", () => { test("should return an array of APIRelationship objects for the authenticated user's relationships", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/relationships?id[]=${user2.id}`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } + 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); @@ -571,15 +646,17 @@ describe("API Tests", () => { 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 fetch( - `${config.http.base_url}/api/v1/profile/avatar`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } + 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); @@ -596,15 +673,17 @@ describe("API Tests", () => { 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 fetch( - `${config.http.base_url}/api/v1/profile/header`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } + 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); @@ -621,16 +700,21 @@ describe("API Tests", () => { describe("GET /api/v1/accounts/familiar_followers", () => { test("should follow the user", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/follow`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } + 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); @@ -640,14 +724,19 @@ describe("API Tests", () => { }); 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 fetch( - `${config.http.base_url}/api/v1/accounts/familiar_followers?id[]=${user2.id}`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } + 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); diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index fd08b5bb..94bd9287 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { getConfig } from "~classes/configmanager"; import type { Token } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { client } from "~database/datasource"; @@ -13,8 +10,11 @@ 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 { ConfigManager } from "config-manager"; +import { sendTestRequest, wrapRelativeUrl } from "~tests/utils"; -const config = getConfig(); +const config = await new ConfigManager({}).getConfig(); +const base_url = config.http.base_url; let token: Token; let user: UserWithRelations; @@ -86,15 +86,17 @@ describe("API Tests", () => { const formData = new FormData(); formData.append("file", new Blob(["test"], { type: "text/plain" })); - const response = await fetch( - `${config.http.base_url}/api/v2/media`, - { - 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); @@ -112,20 +114,22 @@ describe("API Tests", () => { describe("POST /api/v1/statuses", () => { test("should create a new status and return an APIStatus object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/statuses`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - status: "Hello, world!", - visibility: "public", - media_ids: [media1?.id], - }), - } + 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); @@ -158,20 +162,22 @@ describe("API Tests", () => { }); test("should create a new status in reply to the previous one", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/statuses`, - { - 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, - }), - } + 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); @@ -206,14 +212,20 @@ describe("API Tests", () => { describe("GET /api/v1/statuses/:id", () => { test("should return the specified status object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/statuses/${status?.id}`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } + 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); @@ -251,15 +263,20 @@ describe("API Tests", () => { describe("POST /api/v1/statuses/:id/reblog", () => { test("should reblog the specified status and return the reblogged status object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/statuses/${status?.id}/reblog`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } + 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); @@ -277,15 +294,20 @@ describe("API Tests", () => { describe("POST /api/v1/statuses/:id/unreblog", () => { test("should unreblog the specified status and return the original status object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/statuses/${status?.id}/unreblog`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } + 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); @@ -302,15 +324,20 @@ describe("API Tests", () => { describe("GET /api/v1/statuses/:id/context", () => { test("should return the context of the specified status", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/statuses/${status?.id}/context`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } + 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); @@ -330,14 +357,20 @@ describe("API Tests", () => { describe("GET /api/v1/timelines/public", () => { test("should return an array of APIStatus objects that includes the created status", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/timelines/public`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } + 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); @@ -353,15 +386,20 @@ describe("API Tests", () => { describe("GET /api/v1/accounts/:id/statuses", () => { test("should return the statuses of the specified user", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user.id}/statuses`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } + 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); @@ -384,14 +422,20 @@ describe("API Tests", () => { describe("POST /api/v1/statuses/:id/favourite", () => { test("should favourite the specified status object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/statuses/${status?.id}/favourite`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } + 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); @@ -400,14 +444,20 @@ describe("API Tests", () => { 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 fetch( - `${config.http.base_url}/api/v1/statuses/${status?.id}/favourited_by`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } + 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); @@ -425,14 +475,20 @@ describe("API Tests", () => { describe("POST /api/v1/statuses/:id/unfavourite", () => { test("should unfavourite the specified status object", async () => { // Unfavourite the status - const response = await fetch( - `${config.http.base_url}/api/v1/statuses/${status?.id}/unfavourite`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } + 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); @@ -449,14 +505,19 @@ describe("API Tests", () => { describe("DELETE /api/v1/statuses/:id", () => { test("should delete the specified status object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/statuses/${status?.id}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } + 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); diff --git a/tests/cli.test.ts b/tests/cli.skip-test.ts similarity index 100% rename from tests/cli.test.ts rename to tests/cli.skip-test.ts diff --git a/tests/entities/Instance.test.ts b/tests/entities/Instance.test.ts deleted file mode 100644 index e168dbce..00000000 --- a/tests/entities/Instance.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { AppDataSource } from "~database/datasource"; -import { Instance } from "~database/entities/Instance"; - -let instance: Instance; - -beforeAll(async () => { - if (!AppDataSource.isInitialized) await AppDataSource.initialize(); -}); - -describe("Instance", () => { - it("should add an instance to the database if it doesn't already exist", async () => { - const url = "https://mastodon.social"; - instance = await Instance.addIfNotExists(url); - expect(instance.base_url).toBe("mastodon.social"); - }); -}); - -afterAll(async () => { - await instance.remove(); - - await AppDataSource.destroy(); -}); - */ diff --git a/tests/entities/Media.test.ts b/tests/entities/Media.test.ts deleted file mode 100644 index df24228d..00000000 --- a/tests/entities/Media.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { type ConfigType, getConfig } from "~classes/configmanager"; -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { LocalBackend, S3Backend } from "~classes/media"; -import { unlink } from "fs/promises"; -import { DeleteObjectCommand } from "@aws-sdk/client-s3"; - -const originalConfig = getConfig(); -const modifiedConfig: ConfigType = { - ...originalConfig, - media: { - ...originalConfig.media, - conversion: { - ...originalConfig.media.conversion, - convert_images: false, - }, - }, -}; - -describe("LocalBackend", () => { - let localBackend: LocalBackend; - let fileName: string; - - beforeAll(() => { - localBackend = new LocalBackend(modifiedConfig); - }); - - afterAll(async () => { - await unlink(`${process.cwd()}/uploads/${fileName}`); - }); - - describe("addMedia", () => { - it("should write the file to the local filesystem and return the hash", async () => { - const media = new File(["test"], "test.txt", { - type: "text/plain", - }); - - const hash = await localBackend.addMedia(media); - fileName = hash; - - expect(hash).toBeDefined(); - }); - }); - - describe("getMediaByHash", () => { - it("should retrieve the file from the local filesystem and return it as a File object", async () => { - const media = await localBackend.getMediaByHash(fileName); - - expect(media).toBeInstanceOf(File); - }); - - it("should return null if the file does not exist", async () => { - const media = - await localBackend.getMediaByHash("does-not-exist.txt"); - - expect(media).toBeNull(); - }); - }); -}); - -describe("S3Backend", () => { - const s3Backend = new S3Backend(modifiedConfig); - let fileName: string; - - afterAll(async () => { - const command = new DeleteObjectCommand({ - Bucket: modifiedConfig.s3.bucket_name, - Key: fileName, - }); - - await s3Backend.client.send(command); - }); - - describe("addMedia", () => { - it("should write the file to the S3 bucket and return the hash", async () => { - const media = new File(["test"], "test.txt", { - type: "text/plain", - }); - - const hash = await s3Backend.addMedia(media); - fileName = hash; - - expect(hash).toBeDefined(); - }); - }); - - describe("getMediaByHash", () => { - it("should retrieve the file from the S3 bucket and return it as a File object", async () => { - const media = await s3Backend.getMediaByHash(fileName); - - expect(media).toBeInstanceOf(File); - }); - - it("should return null if the file does not exist", async () => { - const media = await s3Backend.getMediaByHash("does-not-exist.txt"); - - expect(media).toBeNull(); - }); - }); -}); diff --git a/tests/inbox.test.ts b/tests/inbox.test.ts deleted file mode 100644 index fab17ac7..00000000 --- a/tests/inbox.test.ts +++ /dev/null @@ -1 +0,0 @@ -// Empty file diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 3657ece2..3de351d4 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -1,10 +1,11 @@ -import { getConfig } from "~classes/configmanager"; import type { Application, Token } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { client } from "~database/datasource"; import { createNewLocalUser } from "~database/entities/User"; +import { sendTestRequest, wrapRelativeUrl } from "./utils"; -const config = getConfig(); +// const config = await new ConfigManager({}).getConfig(); +const base_url = "http://lysand.localhost:8080"; //config.http.base_url; let client_id: string; let client_secret: string; @@ -30,10 +31,12 @@ describe("POST /api/v1/apps/", () => { formData.append("redirect_uris", "https://example.com"); formData.append("scopes", "read write"); - const response = await fetch(`${config.http.base_url}/api/v1/apps/`, { - 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"); @@ -65,14 +68,19 @@ describe("POST /auth/login/", () => { formData.append("email", "test@test.com"); formData.append("password", "test"); - const response = await fetch( - `${config.http.base_url}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, - { - method: "POST", - body: formData, - redirect: "manual", - } + 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( /https:\/\/example.com\?code=/ @@ -94,11 +102,12 @@ describe("POST /oauth/token/", () => { formData.append("client_secret", client_secret); formData.append("scope", "read+write"); - const response = await fetch(`${config.http.base_url}/oauth/token/`, { - method: "POST", - // Do not set the Content-Type header for some reason - 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(); @@ -119,15 +128,15 @@ describe("POST /oauth/token/", () => { describe("GET /api/v1/apps/verify_credentials", () => { test("should return the authenticated application's credentials", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/apps/verify_credentials`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } + 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); diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 00000000..60de2330 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,15 @@ +import { server } from "~index"; + +/** + * This allows us to send a test request to the server even when it isnt running + * CURRENTLY NOT WORKING, NEEDS TO BE FIXED + * @param req Request to send + * @returns Response from the server + */ +export async function sendTestRequest(req: Request) { + return server.fetch(req); +} + +export function wrapRelativeUrl(url: string, base_url: string) { + return new URL(url, base_url); +} diff --git a/tsconfig.json b/tsconfig.json index f9efccf5..ef00e6ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "lib": ["ESNext", "DOM"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "esnext", "target": "esnext", "moduleResolution": "bundler", diff --git a/utils/api.ts b/utils/api.ts index 01a479f7..283cf136 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -1,8 +1,10 @@ -import { getConfig } from "~classes/configmanager"; +import { ConfigManager } from "config-manager"; +import type { RouteHandler } from "~server/api/routes.type"; import type { APIRouteMeta } from "~types/api"; +const config = await new ConfigManager({}).getConfig(); + export const applyConfig = (routeMeta: APIRouteMeta) => { - const config = getConfig(); const newMeta = routeMeta; // Apply ratelimits from config @@ -16,3 +18,7 @@ export const applyConfig = (routeMeta: APIRouteMeta) => { return newMeta; }; + +export const apiRoute = (routeFunction: RouteHandler) => { + return routeFunction; +}; diff --git a/utils/constants.ts b/utils/constants.ts index 3e9c7686..4de00425 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -1,6 +1,6 @@ -import { getConfig } from "~classes/configmanager"; +import { ConfigManager } from "config-manager"; -const config = getConfig(); +const config = await new ConfigManager({}).getConfig(); export const oauthRedirectUri = (issuer: string) => `${config.http.base_url}/oauth/callback/${issuer}`; diff --git a/utils/formatting.ts b/utils/formatting.ts index 871c6670..1cb56e15 100644 --- a/utils/formatting.ts +++ b/utils/formatting.ts @@ -14,7 +14,7 @@ export const convertTextToHtml = async ( content_type?: string ) => { if (content_type === "text/markdown") { - return linkifyHtml(await sanitizeHtml(parse(text))); + return linkifyHtml(await sanitizeHtml(await parse(text))); } else if (content_type === "text/x.misskeymarkdown") { // Parse as MFM // TODO: Implement MFM diff --git a/utils/meilisearch.ts b/utils/meilisearch.ts index aa80b73a..e885ddbc 100644 --- a/utils/meilisearch.ts +++ b/utils/meilisearch.ts @@ -1,17 +1,18 @@ -import { getConfig } from "~classes/configmanager"; import chalk from "chalk"; import { client } from "~database/datasource"; import { Meilisearch } from "meilisearch"; import type { Status, User } from "@prisma/client"; +import { ConfigManager } from "config-manager"; +import { LogLevel, type LogManager, type MultiLogManager } from "log-manager"; -const config = getConfig(); +const config = await new ConfigManager({}).getConfig(); export const meilisearch = new Meilisearch({ host: `${config.meilisearch.host}:${config.meilisearch.port}`, apiKey: config.meilisearch.api_key, }); -export const connectMeili = async () => { +export const connectMeili = async (logger: MultiLogManager | LogManager) => { if (!config.meilisearch.enabled) return; if (await meilisearch.isHealthy()) { @@ -31,14 +32,16 @@ export const connectMeili = async () => { .index(MeiliIndexType.Statuses) .updateSearchableAttributes(["content"]); - console.log( - `${chalk.green(`✓`)} ${chalk.bold(`Connected to Meilisearch`)}` + await logger.log( + LogLevel.INFO, + "Meilisearch", + "Connected to Meilisearch" ); } else { - console.error( - `${chalk.red(`✗`)} ${chalk.bold( - `Error while connecting to Meilisearch` - )}` + await logger.log( + LogLevel.CRITICAL, + "Meilisearch", + "Error while connecting to Meilisearch" ); process.exit(1); } diff --git a/utils/module.ts b/utils/module.ts new file mode 100644 index 00000000..212d863c --- /dev/null +++ b/utils/module.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from "url"; + +/** + * Determines whether a module is the entry point for the running node process. + * This works for both CommonJS and ES6 environments. + * + * ### CommonJS + * ```js + * if (moduleIsEntry(module)) { + * console.log('WOO HOO!!!'); + * } + * ``` + * + * ### ES6 + * ```js + * if (moduleIsEntry(import.meta.url)) { + * console.log('WOO HOO!!!'); + * } + * ``` + */ +export const moduleIsEntry = (moduleOrImportMetaUrl: NodeModule | string) => { + if (typeof moduleOrImportMetaUrl === "string") { + return process.argv[1] === fileURLToPath(moduleOrImportMetaUrl); + } + + if (typeof require !== "undefined" && "exports" in moduleOrImportMetaUrl) { + return require.main === moduleOrImportMetaUrl; + } + + return false; +}; diff --git a/utils/redis.ts b/utils/redis.ts index a38faa8b..e6d61910 100644 --- a/utils/redis.ts +++ b/utils/redis.ts @@ -1,10 +1,10 @@ -import { getConfig } from "~classes/configmanager"; import type { Prisma } from "@prisma/client"; import chalk from "chalk"; +import { ConfigManager } from "config-manager"; import Redis from "ioredis"; import { createPrismaRedisCache } from "prisma-redis-middleware"; -const config = getConfig(); +const config = await new ConfigManager({}).getConfig(); const cacheRedis = config.redis.cache.enabled ? new Redis({ @@ -12,7 +12,7 @@ const cacheRedis = config.redis.cache.enabled port: Number(config.redis.cache.port), password: config.redis.cache.password, db: Number(config.redis.cache.database ?? 0), - }) + }) : null; cacheRedis?.on("error", e => { diff --git a/utils/request.ts b/utils/request.ts deleted file mode 100644 index bd9911fc..00000000 --- a/utils/request.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Takes a request, and turns FormData or query parameters - * into a JSON object as would be returned by req.json() - * This is a translation layer that allows clients to use - * either FormData, query parameters, or JSON in the request - * @param request The request to parse - */ -export async function parseRequest(request: Request): Promise> { - const query = new URL(request.url).searchParams; - let output: Partial = {}; - - // Parse SearchParams arrays into JSON arrays - const arrayKeys = [...query.keys()].filter(key => key.endsWith("[]")); - const nonArrayKeys = [...query.keys()].filter(key => !key.endsWith("[]")); - - for (const key of arrayKeys) { - const value = query.getAll(key); - query.delete(key); - query.append(key, JSON.stringify(value)); - } - - // Append non array keys to output - for (const key of nonArrayKeys) { - // @ts-expect-error Complains about type - output[key] = query.get(key); - } - - const queryEntries = [...query.entries()]; - - if (queryEntries.length > 0) { - const data: Record = {}; - - const arrayKeys = [...query.keys()].filter(key => key.endsWith("[]")); - - for (const key of arrayKeys) { - const value = query.getAll(key); - query.delete(key); - // @ts-expect-error JSON arrays are valid - data[key] = JSON.parse(value); - } - - output = { - ...output, - ...(data as T), - }; - } - - // if request contains a JSON body - if (request.headers.get("Content-Type")?.includes("application/json")) { - try { - output = { - ...output, - ...((await request.json()) as T), - }; - } catch { - // Invalid JSON - } - } - - // If request contains FormData - if (request.headers.get("Content-Type")?.includes("multipart/form-data")) { - // @ts-expect-error It hates entries() for some reason - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const formData = [...(await request.formData()).entries()]; - - if (formData.length > 0) { - const data: Record = {}; - - for (const [key, value] of formData) { - // If object, parse as JSON - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-base-to-string, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - data[key] = JSON.parse(value.toString()); - } catch { - // If a file, set as a file - if (value instanceof File) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - data[key] = value; - } else { - // Otherwise, set as a string - // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - data[key] = value.toString(); - } - } - } - - output = { - ...output, - ...(data as T), - }; - } - } - - return output; -} diff --git a/utils/sanitization.ts b/utils/sanitization.ts index e26e1074..e2426b95 100644 --- a/utils/sanitization.ts +++ b/utils/sanitization.ts @@ -1,8 +1,8 @@ -import { getConfig } from "~classes/configmanager"; +import { ConfigManager } from "config-manager"; import { sanitize } from "isomorphic-dompurify"; export const sanitizeHtml = async (html: string) => { - const config = getConfig(); + const config = await new ConfigManager({}).getConfig(); const sanitizedHtml = sanitize(html, { ALLOWED_TAGS: [