mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor: Rewrite functions into packages
This commit is contained in:
parent
847e679a10
commit
78f216092b
2
index.ts
2
index.ts
|
|
@ -162,8 +162,6 @@ const logRequest = async (req: Request) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add headers
|
// 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();
|
const headers = req.headers.entries();
|
||||||
|
|
||||||
for (const [key, value] of headers) {
|
for (const [key, value] of headers) {
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,8 @@
|
||||||
"prisma": "^5.6.0",
|
"prisma": "^5.6.0",
|
||||||
"prisma-redis-middleware": "^4.8.0",
|
"prisma-redis-middleware": "^4.8.0",
|
||||||
"semver": "^7.5.4",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
8
packages/cli-parser/cli-builder.type.ts
Normal file
8
packages/cli-parser/cli-builder.type.ts
Normal file
|
|
@ -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";
|
||||||
|
}
|
||||||
203
packages/cli-parser/index.ts
Normal file
203
packages/cli-parser/index.ts
Normal file
|
|
@ -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<string, any>) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses string array arguments into a full JavaScript object
|
||||||
|
* @param argsWithoutCategories
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private parseArgs(argsWithoutCategories: string[]): Record<string, any> {
|
||||||
|
const parsedArgs: Record<string, any> = {};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/cli-parser/package.json
Normal file
6
packages/cli-parser/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "arg-parser",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"main": "index.ts",
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
217
packages/cli-parser/tests/cli-builder.test.ts
Normal file
217
packages/cli-parser/tests/cli-builder.test.ts
Normal file
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
359
packages/config-manager/config-type.type.ts
Normal file
359
packages/config-manager/config-type.type.ts
Normal file
|
|
@ -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: {},
|
||||||
|
};
|
||||||
118
packages/config-manager/index.ts
Normal file
118
packages/config-manager/index.ts
Normal file
|
|
@ -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<T = ConfigType>} The merged config file as a JSON object
|
||||||
|
*/
|
||||||
|
async getConfig<T = ConfigType>() {
|
||||||
|
const config = await this.readConfig<T>();
|
||||||
|
const internalConfig = await this.readInternalConfig<T>();
|
||||||
|
|
||||||
|
return this.mergeConfigs<T>(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<T = ConfigType>} The internal config file as a JSON object
|
||||||
|
*/
|
||||||
|
private async readInternalConfig<T = ConfigType>() {
|
||||||
|
const config = Bun.file(this.getInternalConfigPath());
|
||||||
|
|
||||||
|
if (!(await config.exists())) {
|
||||||
|
await Bun.write(config, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.parseConfig<T>(await config.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Reads the config file and returns it as a JSON object
|
||||||
|
* @returns {Promise<T = ConfigType>} The config file as a JSON object
|
||||||
|
*/
|
||||||
|
private async readConfig<T = ConfigType>() {
|
||||||
|
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<T>(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<T = ConfigType>(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<T = ConfigType>(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<T = ConfigType>(...configs: T[]) {
|
||||||
|
return merge(configs) as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/config-manager/package.json
Normal file
6
packages/config-manager/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "config-manager",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"main": "index.ts",
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
96
packages/config-manager/tests/config-manager.test.ts
Normal file
96
packages/config-manager/tests/config-manager.test.ts
Normal file
|
|
@ -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<typeof mockConfig>();
|
||||||
|
|
||||||
|
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<typeof mockConfig>();
|
||||||
|
|
||||||
|
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<Record<string, string>>(
|
||||||
|
config1,
|
||||||
|
config2
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mergedConfig).toEqual({
|
||||||
|
key1: "value1",
|
||||||
|
key2: "newValue2",
|
||||||
|
key3: "value3",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
170
packages/request-parser/index.ts
Normal file
170
packages/request-parser/index.ts
Normal file
|
|
@ -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<T>() {
|
||||||
|
try {
|
||||||
|
switch (await this.determineContentType()) {
|
||||||
|
case "application/json":
|
||||||
|
return this.parseJson<T>();
|
||||||
|
case "application/x-www-form-urlencoded":
|
||||||
|
return this.parseFormUrlencoded<T>();
|
||||||
|
case "multipart/form-data":
|
||||||
|
return this.parseFormData<T>();
|
||||||
|
default:
|
||||||
|
return this.parseQuery<T>();
|
||||||
|
}
|
||||||
|
} 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<T>(): Promise<Partial<T>> {
|
||||||
|
const formData = await this.request.formData();
|
||||||
|
const result: Partial<T> = {};
|
||||||
|
|
||||||
|
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<T>(): Promise<Partial<T>> {
|
||||||
|
const formData = await this.request.formData();
|
||||||
|
const result: Partial<T> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (key.endsWith("[]")) {
|
||||||
|
const arrayKey = key.slice(0, -2) as keyof T;
|
||||||
|
if (!result[arrayKey]) {
|
||||||
|
result[arrayKey] = [] as T[keyof T];
|
||||||
|
}
|
||||||
|
|
||||||
|
(result[arrayKey] as any[]).push(value);
|
||||||
|
} else {
|
||||||
|
result[key as keyof T] = value as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>(): Promise<Partial<T>> {
|
||||||
|
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<T>(): Partial<T> {
|
||||||
|
const result: Partial<T> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/request-parser/package.json
Normal file
6
packages/request-parser/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "request-parser",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"main": "index.ts",
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
158
packages/request-parser/tests/request-parser.test.ts
Normal file
158
packages/request-parser/tests/request-parser.test.ts
Normal file
|
|
@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { getConfig } from "~classes/configmanager";
|
import { getConfig } from "~classes/configmanager";
|
||||||
import { parseRequest } from "@request";
|
|
||||||
import { jsonResponse } from "@response";
|
import { jsonResponse } from "@response";
|
||||||
import { tempmailDomains } from "@tempmail";
|
import { tempmailDomains } from "@tempmail";
|
||||||
import { applyConfig } from "@api";
|
import { applyConfig } from "@api";
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
import { createNewLocalUser } from "~database/entities/User";
|
import { createNewLocalUser } from "~database/entities/User";
|
||||||
import ISO6391 from "iso-639-1";
|
import ISO6391 from "iso-639-1";
|
||||||
|
import type { RouteHandler } from "~server/api/routes.type";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["POST"],
|
||||||
|
|
@ -19,20 +19,17 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
const handler: RouteHandler<{
|
||||||
* Creates a new user
|
username: string;
|
||||||
*/
|
email: string;
|
||||||
export default async (req: Request): Promise<Response> => {
|
password: string;
|
||||||
|
agreement: boolean;
|
||||||
|
locale: string;
|
||||||
|
reason: string;
|
||||||
|
}> = async (req, matchedRoute, extraData) => {
|
||||||
// TODO: Add Authorization check
|
// TODO: Add Authorization check
|
||||||
|
|
||||||
const body = await parseRequest<{
|
const body = extraData.parsedRequest;
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
agreement: boolean;
|
|
||||||
locale: string;
|
|
||||||
reason: string;
|
|
||||||
}>(req);
|
|
||||||
|
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
|
|
@ -94,8 +91,8 @@ export default async (req: Request): Promise<Response> => {
|
||||||
|
|
||||||
// Check if username doesnt match filters
|
// Check if username doesnt match filters
|
||||||
if (
|
if (
|
||||||
config.filters.username_filters.some(
|
config.filters.username_filters.some(filter =>
|
||||||
filter => body.username?.match(filter)
|
body.username?.match(filter)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
errors.details.username.push({
|
errors.details.username.push({
|
||||||
|
|
@ -204,3 +201,8 @@ export default async (req: Request): Promise<Response> => {
|
||||||
status: 200,
|
status: 200,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user
|
||||||
|
*/
|
||||||
|
export default handler;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import {
|
import { userRelations, userToAPI } from "~database/entities/User";
|
||||||
getFromRequest,
|
|
||||||
userRelations,
|
|
||||||
userToAPI,
|
|
||||||
} from "~database/entities/User";
|
|
||||||
import { applyConfig } from "@api";
|
import { applyConfig } from "@api";
|
||||||
import { parseRequest } from "@request";
|
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
|
import type { RouteHandler } from "~server/api/routes.type";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -20,10 +16,16 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async (req: Request): Promise<Response> => {
|
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
|
// 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);
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
|
@ -32,13 +34,7 @@ export default async (req: Request): Promise<Response> => {
|
||||||
limit = 40,
|
limit = 40,
|
||||||
offset,
|
offset,
|
||||||
q,
|
q,
|
||||||
} = await parseRequest<{
|
} = extraData.parsedRequest;
|
||||||
q?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
resolve?: boolean;
|
|
||||||
following?: boolean;
|
|
||||||
}>(req);
|
|
||||||
|
|
||||||
if (limit < 1 || limit > 80) {
|
if (limit < 1 || limit > 80) {
|
||||||
return errorResponse("Limit must be between 1 and 80", 400);
|
return errorResponse("Limit must be between 1 and 80", 400);
|
||||||
|
|
@ -66,7 +62,7 @@ export default async (req: Request): Promise<Response> => {
|
||||||
ownerId: user.id,
|
ownerId: user.id,
|
||||||
following,
|
following,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
take: Number(limit),
|
take: Number(limit),
|
||||||
|
|
@ -76,3 +72,5 @@ export default async (req: Request): Promise<Response> => {
|
||||||
|
|
||||||
return jsonResponse(accounts.map(acct => userToAPI(acct)));
|
return jsonResponse(accounts.map(acct => userToAPI(acct)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default handler;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { getConfig } from "~classes/configmanager";
|
import { getConfig } from "~classes/configmanager";
|
||||||
import { parseRequest } from "@request";
|
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import {
|
import { userRelations, userToAPI } from "~database/entities/User";
|
||||||
userRelations,
|
|
||||||
userToAPI,
|
|
||||||
type AuthData,
|
|
||||||
} from "~database/entities/User";
|
|
||||||
import { applyConfig } from "@api";
|
import { applyConfig } from "@api";
|
||||||
import { sanitize } from "isomorphic-dompurify";
|
import { sanitize } from "isomorphic-dompurify";
|
||||||
import { sanitizeHtml } from "@sanitization";
|
import { sanitizeHtml } from "@sanitization";
|
||||||
|
|
@ -15,7 +10,7 @@ import { parseEmojis } from "~database/entities/Emoji";
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
import type { APISource } from "~types/entities/source";
|
import type { APISource } from "~types/entities/source";
|
||||||
import { convertTextToHtml } from "@formatting";
|
import { convertTextToHtml } from "@formatting";
|
||||||
import type { MatchedRoute } from "bun";
|
import type { RouteHandler } from "~server/api/routes.type";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["PATCH"],
|
allowedMethods: ["PATCH"],
|
||||||
|
|
@ -29,15 +24,19 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
const handler: RouteHandler<{
|
||||||
* Patches a user
|
display_name: string;
|
||||||
*/
|
note: string;
|
||||||
export default async (
|
avatar: File;
|
||||||
req: Request,
|
header: File;
|
||||||
matchedRoute: MatchedRoute,
|
locked: string;
|
||||||
auth: AuthData
|
bot: string;
|
||||||
): Promise<Response> => {
|
discoverable: string;
|
||||||
const { user } = auth;
|
"source[privacy]": string;
|
||||||
|
"source[sensitive]": string;
|
||||||
|
"source[language]": string;
|
||||||
|
}> = async (req, matchedRoute, extraData) => {
|
||||||
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
|
@ -54,18 +53,7 @@ export default async (
|
||||||
"source[privacy]": source_privacy,
|
"source[privacy]": source_privacy,
|
||||||
"source[sensitive]": source_sensitive,
|
"source[sensitive]": source_sensitive,
|
||||||
"source[language]": source_language,
|
"source[language]": source_language,
|
||||||
} = await parseRequest<{
|
} = extraData.parsedRequest;
|
||||||
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);
|
|
||||||
|
|
||||||
const sanitizedNote = await sanitizeHtml(note ?? "");
|
const sanitizedNote = await sanitizeHtml(note ?? "");
|
||||||
|
|
||||||
|
|
@ -147,7 +135,7 @@ export default async (
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
// 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) {
|
if (source_sensitive && user.source) {
|
||||||
|
|
@ -157,7 +145,7 @@ export default async (
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
// 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) {
|
if (source_language && user.source) {
|
||||||
|
|
@ -169,7 +157,7 @@ export default async (
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
(user.source as any).language = source_language;
|
user.source.language = source_language;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (avatar) {
|
if (avatar) {
|
||||||
|
|
@ -264,3 +252,5 @@ export default async (
|
||||||
|
|
||||||
return jsonResponse(userToAPI(output));
|
return jsonResponse(userToAPI(output));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default handler;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { getFromRequest, userToAPI } from "~database/entities/User";
|
import { userToAPI } from "~database/entities/User";
|
||||||
import { applyConfig } from "@api";
|
import { applyConfig } from "@api";
|
||||||
|
import type { RouteHandler } from "~server/api/routes.type";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -14,10 +15,12 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async (req: Request): Promise<Response> => {
|
const handler: RouteHandler<> = (req, matchedRoute, extraData) => {};
|
||||||
|
|
||||||
|
const handler: RouteHandler<""> = (req, matchedRoute, extraData) => {
|
||||||
// TODO: Add checks for disabled or not email verified accounts
|
// TODO: Add checks for disabled or not email verified accounts
|
||||||
|
|
||||||
const { user } = await getFromRequest(req);
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
if (!user) return errorResponse("Unauthorized", 401);
|
if (!user) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
|
@ -25,3 +28,5 @@ export default async (req: Request): Promise<Response> => {
|
||||||
...userToAPI(user, true),
|
...userToAPI(user, true),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default handler;
|
||||||
|
|
|
||||||
13
server/api/routes.type.ts
Normal file
13
server/api/routes.type.ts
Normal file
|
|
@ -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<T> = (
|
||||||
|
req: Request,
|
||||||
|
matchedRoute: MatchedRoute,
|
||||||
|
extraData: {
|
||||||
|
auth: AuthData;
|
||||||
|
parsedRequest: Partial<T>;
|
||||||
|
configManager: ConfigManager;
|
||||||
|
}
|
||||||
|
) => Response | Promise<Response>;
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["ESNext", "DOM"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* either FormData, query parameters, or JSON in the request
|
* either FormData, query parameters, or JSON in the request
|
||||||
* @param request The request to parse
|
* @param request The request to parse
|
||||||
*/
|
*/
|
||||||
export async function parseRequest<T>(request: Request): Promise<Partial<T>> {
|
/* export async function parseRequest<T>(request: Request): Promise<Partial<T>> {
|
||||||
const query = new URL(request.url).searchParams;
|
const query = new URL(request.url).searchParams;
|
||||||
let output: Partial<T> = {};
|
let output: Partial<T> = {};
|
||||||
|
|
||||||
|
|
@ -93,3 +93,4 @@ export async function parseRequest<T>(request: Request): Promise<Partial<T>> {
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue