diff --git a/bun.lockb b/bun.lockb index f33d46fb..04d28da7 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.ts b/index.ts index c096a408..6a002674 100644 --- a/index.ts +++ b/index.ts @@ -162,8 +162,6 @@ const logRequest = async (req: Request) => { ); // 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) { diff --git a/package.json b/package.json index ecacd291..f1ef982e 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,8 @@ "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" } } \ No newline at end of file diff --git a/packages/cli-parser/cli-builder.type.ts b/packages/cli-parser/cli-builder.type.ts new file mode 100644 index 00000000..8cd2bd30 --- /dev/null +++ b/packages/cli-parser/cli-builder.type.ts @@ -0,0 +1,8 @@ +export interface CliParameter { + name: string; + // If not positioned, the argument will need to be called with --name value instead of just value + positioned?: boolean; + // Whether the argument needs a value (requires positioned to be false) + needsValue?: boolean; + type: "string" | "number" | "boolean" | "array"; +} diff --git a/packages/cli-parser/index.ts b/packages/cli-parser/index.ts new file mode 100644 index 00000000..34c31177 --- /dev/null +++ b/packages/cli-parser/index.ts @@ -0,0 +1,203 @@ +import type { CliParameter } from "./cli-builder.type"; + +export function startsWithArray(fullArray: any[], startArray: any[]) { + if (startArray.length > fullArray.length) { + return false; + } + return fullArray + .slice(0, startArray.length) + .every((value, index) => value === startArray[index]); +} + +/** + * 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); + } +} + +/** + * 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: (args: Record) => void + ) {} + + /** + * 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 (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 "string": + return value; + case "number": + return Number(value); + case "boolean": + return value === "true"; + case "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); + this.execute(args); + } +} diff --git a/packages/cli-parser/package.json b/packages/cli-parser/package.json new file mode 100644 index 00000000..bf582902 --- /dev/null +++ b/packages/cli-parser/package.json @@ -0,0 +1,6 @@ +{ + "name": "arg-parser", + "version": "0.0.0", + "main": "index.ts", + "dependencies": {} +} \ 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..8c5b3f5c --- /dev/null +++ b/packages/cli-parser/tests/cli-builder.test.ts @@ -0,0 +1,217 @@ +// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts +import { CliCommand, CliBuilder, startsWithArray } from ".."; +import { describe, beforeEach, it, expect, jest } from "bun:test"; + +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: "string", needsValue: true }, + { name: "arg2", type: "number", needsValue: true }, + { name: "arg3", type: "boolean", needsValue: false }, + { name: "arg4", type: "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 cast argument values correctly", () => { + expect(cliCommand["castArgValue"]("42", "number")).toBe(42); + expect(cliCommand["castArgValue"]("true", "boolean")).toBe(true); + expect(cliCommand["castArgValue"]("value1,value2", "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: "string", needsValue: true }, + { name: "arg2", type: "number", needsValue: true }, + { name: "arg3", type: "boolean", needsValue: false }, + { name: "arg4", type: "array", needsValue: true }, + ], + mockExecute + ); + + cliCommand.run([ + "--arg1", + "value1", + "--arg2", + "42", + "--arg3", + "--arg4", + "value1,value2", + ]); + expect(mockExecute).toHaveBeenCalledWith({ + 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: "string", needsValue: true }, + { name: "arg2", type: "number", needsValue: true }, + { name: "arg3", type: "boolean", needsValue: false }, + { name: "arg4", type: "array", needsValue: true }, + { + name: "arg5", + type: "string", + needsValue: true, + positioned: true, + }, + ], + mockExecute + ); + + cliCommand.run([ + "--arg1", + "value1", + "--arg2", + "42", + "--arg3", + "--arg4", + "value1,value2", + "value5", + ]); + + expect(mockExecute).toHaveBeenCalledWith({ + arg1: "value1", + arg2: 42, + arg3: true, + arg4: ["value1", "value2"], + arg5: "value5", + }); + }); +}); + +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: "string", + needsValue: true, + positioned: false, + }, + ], + mockExecute + ); + cliBuilder.registerCommand(mockCommand); + cliBuilder.processArgs([ + "./cli.ts", + "category1", + "sub1", + "--arg1", + "value1", + ]); + expect(mockExecute).toHaveBeenCalledWith({ + arg1: "value1", + }); + }); +}); diff --git a/packages/config-manager/config-type.type.ts b/packages/config-manager/config-type.type.ts new file mode 100644 index 00000000..940716d5 --- /dev/null +++ b/packages/config-manager/config-type.type.ts @@ -0,0 +1,359 @@ +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: {}, +}; diff --git a/packages/config-manager/index.ts b/packages/config-manager/index.ts new file mode 100644 index 00000000..03d1e4fc --- /dev/null +++ b/packages/config-manager/index.ts @@ -0,0 +1,118 @@ +/** + * @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 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; + } +} 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/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/server/api/api/v1/accounts/index.ts b/server/api/api/v1/accounts/index.ts index ecd3211c..bd5ec124 100644 --- a/server/api/api/v1/accounts/index.ts +++ b/server/api/api/v1/accounts/index.ts @@ -1,11 +1,11 @@ import { getConfig } from "~classes/configmanager"; -import { parseRequest } from "@request"; import { jsonResponse } from "@response"; import { tempmailDomains } from "@tempmail"; import { applyConfig } from "@api"; import { client } from "~database/datasource"; import { createNewLocalUser } from "~database/entities/User"; import ISO6391 from "iso-639-1"; +import type { RouteHandler } from "~server/api/routes.type"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -19,20 +19,17 @@ export const meta = applyConfig({ }, }); -/** - * Creates a new user - */ -export default async (req: Request): Promise => { +const handler: RouteHandler<{ + 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(); @@ -94,8 +91,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({ @@ -204,3 +201,8 @@ export default async (req: Request): Promise => { status: 200, }); }; + +/** + * Creates a new user + */ +export default handler; diff --git a/server/api/api/v1/accounts/search/index.ts b/server/api/api/v1/accounts/search/index.ts index f523e9fe..3aa51910 100644 --- a/server/api/api/v1/accounts/search/index.ts +++ b/server/api/api/v1/accounts/search/index.ts @@ -1,12 +1,8 @@ import { errorResponse, jsonResponse } from "@response"; -import { - getFromRequest, - userRelations, - userToAPI, -} from "~database/entities/User"; +import { userRelations, userToAPI } from "~database/entities/User"; import { applyConfig } from "@api"; -import { parseRequest } from "@request"; import { client } from "~database/datasource"; +import type { RouteHandler } from "~server/api/routes.type"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -20,10 +16,16 @@ export const meta = applyConfig({ }, }); -export default async (req: Request): Promise => { +const handler: RouteHandler<{ + 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 +34,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 +62,7 @@ export default async (req: Request): Promise => { ownerId: user.id, following, }, - } + } : undefined, }, take: Number(limit), @@ -76,3 +72,5 @@ export default async (req: Request): Promise => { return jsonResponse(accounts.map(acct => userToAPI(acct))); }; + +export default handler; diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index 2c6ebe96..4ce1142b 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -1,11 +1,6 @@ import { getConfig } from "~classes/configmanager"; -import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { - userRelations, - userToAPI, - type AuthData, -} from "~database/entities/User"; +import { userRelations, userToAPI } from "~database/entities/User"; import { applyConfig } from "@api"; import { sanitize } from "isomorphic-dompurify"; import { sanitizeHtml } from "@sanitization"; @@ -15,7 +10,7 @@ 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 type { RouteHandler } from "~server/api/routes.type"; export const meta = applyConfig({ allowedMethods: ["PATCH"], @@ -29,15 +24,19 @@ export const meta = applyConfig({ }, }); -/** - * Patches a user - */ -export default async ( - req: Request, - matchedRoute: MatchedRoute, - auth: AuthData -): Promise => { - const { user } = auth; +const handler: RouteHandler<{ + 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); @@ -54,18 +53,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 ?? ""); @@ -147,7 +135,7 @@ export default async ( } // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (user.source as any).privacy = source_privacy; + user.source.privacy = source_privacy; } if (source_sensitive && user.source) { @@ -157,7 +145,7 @@ export default async ( } // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (user.source as any).sensitive = source_sensitive === "true"; + user.source.sensitive = source_sensitive === "true"; } if (source_language && user.source) { @@ -169,7 +157,7 @@ export default async ( } // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (user.source as any).language = source_language; + user.source.language = source_language; } if (avatar) { @@ -264,3 +252,5 @@ export default async ( return jsonResponse(userToAPI(output)); }; + +export default handler; diff --git a/server/api/api/v1/accounts/verify_credentials/index.ts b/server/api/api/v1/accounts/verify_credentials/index.ts index 94543895..1a56825b 100644 --- a/server/api/api/v1/accounts/verify_credentials/index.ts +++ b/server/api/api/v1/accounts/verify_credentials/index.ts @@ -1,6 +1,7 @@ import { errorResponse, jsonResponse } from "@response"; -import { getFromRequest, userToAPI } from "~database/entities/User"; +import { userToAPI } from "~database/entities/User"; import { applyConfig } from "@api"; +import type { RouteHandler } from "~server/api/routes.type"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -14,10 +15,12 @@ export const meta = applyConfig({ }, }); -export default async (req: Request): Promise => { +const handler: RouteHandler<> = (req, matchedRoute, extraData) => {}; + +const handler: RouteHandler<""> = (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); @@ -25,3 +28,5 @@ export default async (req: Request): Promise => { ...userToAPI(user, true), }); }; + +export default handler; 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/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/request.ts b/utils/request.ts index bd9911fc..8bd59566 100644 --- a/utils/request.ts +++ b/utils/request.ts @@ -5,7 +5,7 @@ * either FormData, query parameters, or JSON in the request * @param request The request to parse */ -export async function parseRequest(request: Request): Promise> { +/* export async function parseRequest(request: Request): Promise> { const query = new URL(request.url).searchParams; let output: Partial = {}; @@ -93,3 +93,4 @@ export async function parseRequest(request: Request): Promise> { return output; } + */