From fb31375b7441fffc4050607698e65941493542b4 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Wed, 15 May 2024 16:37:25 -1000 Subject: [PATCH] refactor(config): :fire: Replace config validation with Zod --- .vscode/settings.json | 3 +- bun.lockb | Bin 281324 -> 269316 bytes cli.ts | 1759 ----------------- config/config.example.toml | 146 +- index.ts | 3 +- package.json | 1 - packages/cli-parser/cli-builder.type.ts | 23 - packages/cli-parser/index.ts | 450 ----- packages/cli-parser/package.json | 6 - packages/cli-parser/tests/cli-builder.test.ts | 488 ----- packages/config-manager/config.type.ts | 1106 +++++------ packages/config-manager/index.ts | 35 +- packages/config-manager/package.json | 4 +- packages/database-interface/user.ts | 6 +- utils/api.ts | 4 +- 15 files changed, 543 insertions(+), 3491 deletions(-) delete mode 100644 cli.ts delete mode 100644 packages/cli-parser/cli-builder.type.ts delete mode 100644 packages/cli-parser/index.ts delete mode 100644 packages/cli-parser/package.json delete mode 100644 packages/cli-parser/tests/cli-builder.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index ba32fe28..c16ea735 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,7 @@ "build", "api", "cli", - "federation" + "federation", + "config" ] } diff --git a/bun.lockb b/bun.lockb index 94e2d4834bd37837e4cb83ce36af010e1a1d059d..0ccf05372d0ca6ed1346ab4081d675727f07cef6 100755 GIT binary patch delta 65335 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 delta 73498 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 F{{t$u=#~Hg diff --git a/cli.ts b/cli.ts deleted file mode 100644 index 172f6b15..00000000 --- a/cli.ts +++ /dev/null @@ -1,1759 +0,0 @@ -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Parser } from "@json2csv/plainjs"; -import { MeiliIndexType, rebuildSearchIndexes } from "@meilisearch"; -import chalk from "chalk"; -import { CliBuilder, CliCommand } from "cli-parser"; -import { CliParameterType } from "cli-parser/cli-builder.type"; -import Table from "cli-table"; -import { config } from "config-manager"; -import { type SQL, and, eq, inArray, like, or, sql } from "drizzle-orm"; -import extract from "extract-zip"; -import { MediaBackend } from "media-manager"; -import { lookup } from "mime-types"; -import { getUrl } from "~database/entities/Attachment"; -import { client, db } from "~drizzle/db"; -import { Emojis, Notes, OpenIdAccounts, Users } from "~drizzle/schema"; -import { Note } from "~packages/database-interface/note"; -import { User } from "~packages/database-interface/user"; - -await client.connect(); -const args = process.argv; - -const filterObjects = (output: T[], fields: string[]) => { - if (fields.length === 0) return output; - - return output.map((element) => { - // If fields is specified, only include provided fields - // This is a bit of a mess - if (fields.length > 0) { - const keys = Object.keys(element); - const filteredKeys = keys.filter((key) => fields.includes(key)); - return Object.entries(element) - .filter(([key]) => filteredKeys.includes(key)) - .reduce((acc, [key, value]) => { - // @ts-expect-error This is fine - acc[key] = value; - return acc; - }, {}) as Partial; - } - return element; - }); -}; - -const cliBuilder = new CliBuilder([ - new CliCommand<{ - username: string; - password: string; - email: string; - admin: boolean; - help: boolean; - }>( - ["user", "create"], - [ - { - name: "username", - type: CliParameterType.STRING, - description: "Username of the user", - needsValue: true, - positioned: false, - }, - { - name: "password", - type: CliParameterType.STRING, - description: "Password of the user", - needsValue: true, - positioned: false, - }, - { - name: "email", - type: CliParameterType.STRING, - description: "Email of the user", - needsValue: true, - positioned: false, - }, - { - name: "admin", - type: CliParameterType.BOOLEAN, - description: "Make the user an admin", - needsValue: false, - positioned: false, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - ], - async (instance: CliCommand, args) => { - const { username, password, email, admin, help } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - // Check if username, password and email are provided - if (!username || !password || !email) { - console.log( - `${chalk.red("✗")} Missing username, password or email`, - ); - return 1; - } - - // Check if user already exists - const user = await User.fromSql( - or(eq(Users.username, username), eq(Users.email, email)), - ); - - if (user) { - if (user.getUser().username === username) { - console.log( - `${chalk.red("✗")} User with username ${chalk.blue( - username, - )} already exists`, - ); - } else { - console.log( - `${chalk.red("✗")} User with email ${chalk.blue( - email, - )} already exists`, - ); - } - return 1; - } - - // Create user - const newUser = await User.fromDataLocal({ - email: email, - password: password, - username: username, - admin: admin, - }); - - console.log( - `${chalk.green("✓")} Created user ${chalk.blue( - newUser?.getUser().username, - )}${admin ? chalk.green(" (admin)") : ""}`, - ); - - return 0; - }, - "Creates a new user", - "bun cli user create --username admin --password password123 --email email@email.com", - ), - new CliCommand<{ - username: string; - help: boolean; - noconfirm: boolean; - }>( - ["user", "delete"], - [ - { - name: "username", - type: CliParameterType.STRING, - description: "Username of the user", - needsValue: true, - positioned: true, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - { - name: "noconfirm", - shortName: "y", - type: CliParameterType.EMPTY, - description: "Skip confirmation", - needsValue: false, - positioned: false, - }, - ], - async (instance: CliCommand, args) => { - const { username, help } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!username) { - console.log(`${chalk.red("✗")} Missing username`); - return 1; - } - - const foundUser = await User.fromSql(eq(Users.username, username)); - - if (!foundUser) { - console.log(`${chalk.red("✗")} User not found`); - return 1; - } - - if (!args.noconfirm) { - process.stdout.write( - `Are you sure you want to delete user ${chalk.blue( - foundUser.getUser().username, - )}?\n${chalk.red( - chalk.bold( - "This is a destructive action and cannot be undone!", - ), - )} [y/N] `, - ); - - for await (const line of console) { - if (line.trim().toLowerCase() === "y") { - break; - } - console.log(`${chalk.red("✗")} Deletion cancelled`); - return 0; - } - } - - await db.delete(Users).where(eq(Users.id, foundUser.id)); - - console.log( - `${chalk.green("✓")} Deleted user ${chalk.blue( - foundUser.getUser().username, - )}`, - ); - - return 0; - }, - "Deletes a user", - "bun cli user delete --username admin", - ), - new CliCommand<{ - admins: boolean; - help: boolean; - format: string; - limit: number; - redact: boolean; - fields: string[]; - }>( - ["user", "list"], - [ - { - name: "admins", - type: CliParameterType.BOOLEAN, - description: "List only admins", - needsValue: false, - positioned: false, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - { - name: "format", - type: CliParameterType.STRING, - description: "Output format (can be json or csv)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "limit", - type: CliParameterType.NUMBER, - description: - "Limit the number of users to list (defaults to 200)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "redact", - type: CliParameterType.BOOLEAN, - description: - "Redact sensitive information (such as password hashes, emails or keys)", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "fields", - type: CliParameterType.ARRAY, - description: - "If provided, restricts output to these fields (comma-separated)", - needsValue: true, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { admins, help, fields = [] } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (args.format && !["json", "csv"].includes(args.format)) { - console.log(`${chalk.red("✗")} Invalid format`); - return 1; - } - - let users = ( - await User.manyFromSql( - admins ? eq(Users.isAdmin, true) : undefined, - undefined, - args.limit ?? 200, - ) - ).map((u) => u.getUser()); - - // If instance is not in fields, remove them - if (fields.length > 0 && !fields.includes("instance")) { - users = users.map((user) => ({ - ...user, - instance: null, - })); - } - - if (args.redact) { - for (const user of users) { - user.email = "[REDACTED]"; - user.password = "[REDACTED]"; - user.publicKey = "[REDACTED]"; - user.privateKey = "[REDACTED]"; - } - } - - if (args.format === "json") { - console.log(JSON.stringify(users, null, 4)); - return 0; - } - if (args.format === "csv") { - const parser = new Parser({}); - console.log(parser.parse(users)); - return 0; - } - - console.log( - `${chalk.green("✓")} Found ${chalk.blue( - users.length, - )} users (limit ${args.limit ?? 200})`, - ); - - const tableHead = filterObjects( - [ - { - username: chalk.white(chalk.bold("Username")), - email: chalk.white(chalk.bold("Email")), - displayName: chalk.white(chalk.bold("Display Name")), - isAdmin: chalk.white(chalk.bold("Admin?")), - instance: chalk.white(chalk.bold("Instance URL")), - createdAt: chalk.white(chalk.bold("Created At")), - id: chalk.white(chalk.bold("Internal UUID")), - }, - ], - fields, - )[0]; - - const table = new Table({ - head: Object.values(tableHead), - }); - - for (const user of users) { - // Print table of users - const data = { - username: () => chalk.yellow(`@${user.username}`), - email: () => chalk.green(user.email), - displayName: () => chalk.blue(user.displayName), - isAdmin: () => chalk.red(user.isAdmin ? "Yes" : "No"), - instance: () => - chalk.blue( - user.instance ? user.instance.baseUrl : "Local", - ), - createdAt: () => - chalk.blue(new Date(user.createdAt).toISOString()), - id: () => chalk.blue(user.id), - }; - - // Only keep the fields specified if --fields is provided - if (args.fields) { - const keys = Object.keys(data); - for (const key of keys) { - if (!args.fields.includes(key)) { - // @ts-expect-error This is fine - delete data[key]; - } - } - } - - table.push(Object.values(data).map((fn) => fn())); - } - - console.log(table.toString()); - - return 0; - }, - "Lists all users", - "bun cli user list", - ), - new CliCommand<{ - query: string; - fields: string[]; - format: string; - help: boolean; - "case-sensitive": boolean; - limit: number; - redact: boolean; - }>( - ["user", "search"], - [ - { - name: "query", - type: CliParameterType.STRING, - description: "Query to search for", - needsValue: true, - positioned: true, - }, - { - name: "fields", - type: CliParameterType.ARRAY, - description: "Fields to search in", - needsValue: true, - positioned: false, - }, - { - name: "format", - type: CliParameterType.STRING, - description: "Output format (can be json or csv)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - { - name: "case-sensitive", - shortName: "c", - type: CliParameterType.EMPTY, - description: "Case-sensitive search", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "limit", - type: CliParameterType.NUMBER, - description: "Limit the number of users to list (default 20)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "redact", - type: CliParameterType.BOOLEAN, - description: - "Redact sensitive information (such as password hashes, emails or keys)", - needsValue: false, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { - query, - fields = [], - help, - limit = 20, - "case-sensitive": caseSensitive = false, - redact, - } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!query) { - console.log(`${chalk.red("✗")} Missing query parameter`); - return 1; - } - - if (fields.length === 0) { - console.log(`${chalk.red("✗")} Missing fields parameter`); - return 1; - } - - const users: User["user"][] = ( - await User.manyFromSql( - // @ts-ignore - or(...fields.map((field) => eq(users[field], query))), - undefined, - Number(limit), - ) - ).map((u) => u.getUser()); - - if (redact) { - for (const user of users) { - user.email = "[REDACTED]"; - user.password = "[REDACTED]"; - user.publicKey = "[REDACTED]"; - user.privateKey = "[REDACTED]"; - } - } - - if (args.format === "json") { - console.log(JSON.stringify(users, null, 4)); - return 0; - } - if (args.format === "csv") { - const parser = new Parser({}); - console.log(parser.parse(users)); - return 0; - } - - console.log( - `${chalk.green("✓")} Found ${chalk.blue( - users.length, - )} users (limit ${limit})`, - ); - - const table = new Table({ - head: [ - chalk.white(chalk.bold("Username")), - chalk.white(chalk.bold("Email")), - chalk.white(chalk.bold("Display Name")), - chalk.white(chalk.bold("Admin?")), - chalk.white(chalk.bold("Instance URL")), - ], - }); - - for (const user of users) { - table.push([ - chalk.yellow(`@${user.username}`), - chalk.green(user.email), - chalk.blue(user.displayName), - chalk.red(user.isAdmin ? "Yes" : "No"), - chalk.blue( - user.instanceId ? user.instance?.baseUrl : "Local", - ), - ]); - } - - console.log(table.toString()); - - return 0; - }, - "Searches for a user", - "bun cli user search bob --fields email,username", - ), - - new CliCommand<{ - username: string; - "issuer-id": string; - "server-id": string; - help: boolean; - }>( - ["user", "oidc", "connect"], - [ - { - name: "username", - type: CliParameterType.STRING, - description: "Username of the local account", - needsValue: true, - positioned: true, - }, - { - name: "issuer-id", - type: CliParameterType.STRING, - description: "ID of the OpenID Connect issuer in config", - needsValue: true, - positioned: false, - }, - { - name: "server-id", - type: CliParameterType.STRING, - description: "ID of the user on the OpenID Connect server", - needsValue: true, - positioned: false, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - ], - async (instance: CliCommand, args) => { - const { - username, - "issuer-id": issuerId, - "server-id": serverId, - help, - } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!username || !issuerId || !serverId) { - console.log(`${chalk.red("✗")} Missing username, issuer or ID`); - return 1; - } - - // Check if issuerId is valid - if (!config.oidc.providers.find((p) => p.id === issuerId)) { - console.log(`${chalk.red("✗")} Invalid issuer ID`); - return 1; - } - - const user = await User.fromSql(eq(Users.username, username)); - - if (!user) { - console.log(`${chalk.red("✗")} User not found`); - return 1; - } - - const linkedOpenIdAccounts = await db.query.OpenIdAccounts.findMany( - { - where: (account, { eq, and }) => - and( - eq(account.userId, user.id), - eq(account.issuerId, issuerId), - ), - }, - ); - - if (linkedOpenIdAccounts.find((a) => a.issuerId === issuerId)) { - console.log( - `${chalk.red("✗")} User ${chalk.blue( - user.getUser().username, - )} is already connected to this OpenID Connect issuer with another account`, - ); - return 1; - } - - // Connect the OpenID account - await db.insert(OpenIdAccounts).values({ - issuerId: issuerId, - serverId: serverId, - userId: user.id, - }); - - console.log( - `${chalk.green( - "✓", - )} Connected OpenID Connect account to user ${chalk.blue( - user.getUser().username, - )}`, - ); - - return 0; - }, - "Connects an OpenID Connect account to a local account", - "bun cli user oidc connect admin google 123456789", - ), - new CliCommand<{ - "server-id": string; - help: boolean; - }>( - ["user", "oidc", "disconnect"], - [ - { - name: "server-id", - type: CliParameterType.STRING, - description: "Server ID of the OpenID Connect account", - needsValue: true, - positioned: true, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - ], - async (instance: CliCommand, args) => { - const { "server-id": id, help } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!id) { - console.log(`${chalk.red("✗")} Missing ID`); - return 1; - } - - const account = await db.query.OpenIdAccounts.findFirst({ - where: (account, { eq }) => eq(account.serverId, id), - }); - - if (!account) { - console.log(`${chalk.red("✗")} Account not found`); - return 1; - } - - if (!account.userId) { - console.log( - `${chalk.red("✗")} Account ${chalk.blue( - account.serverId, - )} is not connected to any user`, - ); - return 1; - } - - const user = await User.fromId(account.userId); - - await db - .delete(OpenIdAccounts) - .where(eq(OpenIdAccounts.id, account.id)); - - console.log( - `${chalk.green( - "✓", - )} Disconnected OpenID account from user ${chalk.blue( - user?.getUser().username, - )}`, - ); - - return 0; - }, - "Disconnects an OpenID Connect account from a local account", - "bun cli user oidc disconnect 123456789", - ), - new CliCommand<{ - id: string; - help: boolean; - noconfirm: boolean; - }>( - ["note", "delete"], - [ - { - name: "id", - type: CliParameterType.STRING, - description: "ID of the note", - needsValue: true, - positioned: true, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - { - name: "noconfirm", - shortName: "y", - type: CliParameterType.EMPTY, - description: "Skip confirmation", - needsValue: false, - positioned: false, - }, - ], - async (instance: CliCommand, args) => { - const { id, help } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!id) { - console.log(`${chalk.red("✗")} Missing ID`); - return 1; - } - - const note = await Note.fromId(id); - - if (!note) { - console.log(`${chalk.red("✗")} Note not found`); - return 1; - } - - if (!args.noconfirm) { - process.stdout.write( - `Are you sure you want to delete note ${chalk.blue( - note.getStatus().id, - )}?\n${chalk.red( - chalk.bold( - "This is a destructive action and cannot be undone!", - ), - )} [y/N] `, - ); - - for await (const line of console) { - if (line.trim().toLowerCase() === "y") { - break; - } - console.log(`${chalk.red("✗")} Deletion cancelled`); - return 0; - } - } - - await note.delete(); - - console.log( - `${chalk.green("✓")} Deleted note ${chalk.blue( - note.getStatus().id, - )}`, - ); - - return 0; - }, - "Deletes a note", - "bun cli note delete 018c1838-6e0b-73c4-a157-a91ea4e25d1d", - ), - new CliCommand<{ - query: string; - fields: string[]; - local: boolean; - remote: boolean; - format: string; - help: boolean; - "case-sensitive": boolean; - limit: number; - redact: boolean; - }>( - ["note", "search"], - [ - { - name: "query", - type: CliParameterType.STRING, - description: "Query to search for", - needsValue: true, - positioned: true, - }, - { - name: "fields", - type: CliParameterType.ARRAY, - description: "Fields to search in", - needsValue: true, - positioned: false, - }, - { - name: "local", - type: CliParameterType.BOOLEAN, - description: "Only search in local statuses", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "remote", - type: CliParameterType.BOOLEAN, - description: "Only search in remote statuses", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "format", - type: CliParameterType.STRING, - description: "Output format (can be json or csv)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - { - name: "case-sensitive", - shortName: "c", - type: CliParameterType.EMPTY, - description: "Case-sensitive search", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "limit", - type: CliParameterType.NUMBER, - description: "Limit the number of notes to list (default 20)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "redact", - type: CliParameterType.BOOLEAN, - description: - "Redact sensitive information (such as password hashes, emails or keys)", - needsValue: false, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { - query, - local, - remote, - format, - help, - limit = 20, - fields = [], - "case-sensitive": caseSensitive = false, - redact, - } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!query) { - console.log(`${chalk.red("✗")} Missing query parameter`); - return 1; - } - - if (fields.length === 0) { - console.log(`${chalk.red("✗")} Missing fields parameter`); - return 1; - } - - let instanceQuery: SQL | undefined = - sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${Notes.authorId} AND "User"."instanceId" IS NULL)`; - - if (local && remote) { - instanceQuery = undefined; - } else if (local) { - instanceQuery = sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${Notes.authorId} AND "User"."instanceId" IS NULL)`; - } else if (remote) { - instanceQuery = sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${Notes.authorId} AND "User"."instanceId" IS NOT NULL)`; - } - - const notes = ( - await Note.manyFromSql( - and( - or( - ...fields.map((field) => - // @ts-expect-error - like(Notes[field], `%${query}%`), - ), - ), - instanceQuery, - ), - undefined, - Number(limit), - ) - ).map((n) => n.getStatus()); - - if (redact) { - for (const note of notes) { - note.author.email = "[REDACTED]"; - note.author.password = "[REDACTED]"; - note.author.publicKey = "[REDACTED]"; - note.author.privateKey = "[REDACTED]"; - } - } - - if (format === "json") { - console.log(JSON.stringify(notes, null, 4)); - return 0; - } - if (format === "csv") { - const parser = new Parser({}); - console.log(parser.parse(notes)); - return 0; - } - - console.log( - `${chalk.green("✓")} Found ${chalk.blue( - notes.length, - )} notes (limit ${limit})`, - ); - - const table = new Table({ - head: [ - chalk.white(chalk.bold("ID")), - chalk.white(chalk.bold("Content")), - chalk.white(chalk.bold("Author")), - chalk.white(chalk.bold("Instance")), - chalk.white(chalk.bold("Created At")), - ], - }); - - for (const note of notes) { - table.push([ - chalk.yellow(note.id), - chalk.green(note.content), - chalk.blue(note.author.username), - chalk.red( - note.author.instanceId - ? note.author.instance?.baseUrl - : "Yes", - ), - chalk.blue(new Date(note.createdAt).toISOString()), - ]); - } - - console.log(table.toString()); - - return 0; - }, - "Searches for a status", - "bun cli note search hello --fields content --local", - ), - new CliCommand<{ - help: boolean; - type: string[]; - }>( - ["index", "rebuild"], - [ - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - }, - { - name: "type", - type: CliParameterType.ARRAY, - description: - "Type(s) of index(es) to rebuild (can be accounts or statuses)", - needsValue: true, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { help, type = [] } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - // Check if Meilisearch is enabled - if (!config.meilisearch.enabled) { - console.log(`${chalk.red("✗")} Meilisearch is not enabled`); - return 1; - } - - // Check type validity - for (const _type of type) { - if ( - !Object.values(MeiliIndexType).includes( - _type as MeiliIndexType, - ) - ) { - console.log( - `${chalk.red("✗")} Invalid index type ${chalk.blue( - _type, - )}`, - ); - return 1; - } - } - - if (type.length === 0) { - // Rebuild all indexes - await rebuildSearchIndexes(Object.values(MeiliIndexType)); - } else { - await rebuildSearchIndexes(type as MeiliIndexType[]); - } - - console.log(`${chalk.green("✓")} Rebuilt search indexes`); - - return 0; - }, - "Rebuilds the Meilisearch indexes", - "bun cli index rebuild", - ), - new CliCommand<{ - help: boolean; - shortcode: string; - url: string; - "keep-url": boolean; - }>( - ["emoji", "add"], - [ - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "shortcode", - type: CliParameterType.STRING, - description: "Shortcode of the new emoji", - needsValue: true, - positioned: true, - }, - { - name: "url", - type: CliParameterType.STRING, - description: "URL of the new emoji", - needsValue: true, - positioned: true, - }, - { - name: "keep-url", - type: CliParameterType.BOOLEAN, - description: - "Keep the URL of the emoji instead of uploading the file to object storage", - needsValue: false, - positioned: false, - }, - ], - async (instance: CliCommand, args) => { - const { help, shortcode, url } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!shortcode) { - console.log(`${chalk.red("✗")} Missing shortcode`); - return 1; - } - if (!url) { - console.log(`${chalk.red("✗")} Missing URL`); - return 1; - } - - // Check if shortcode is valid - if (!shortcode.match(/^[a-zA-Z0-9-_]+$/)) { - console.log( - `${chalk.red( - "✗", - )} Invalid shortcode (must be alphanumeric with dashes and underscores allowed)`, - ); - return 1; - } - - // Check if URL is valid - if (!URL.canParse(url)) { - console.log( - `${chalk.red( - "✗", - )} Invalid URL (must be a valid full URL, including protocol)`, - ); - return 1; - } - - // Check if emoji already exists - const existingEmoji = await db.query.Emojis.findFirst({ - where: (emoji, { and, eq, isNull }) => - and( - eq(emoji.shortcode, shortcode), - isNull(emoji.instanceId), - ), - }); - - if (existingEmoji) { - console.log( - `${chalk.red("✗")} Emoji with shortcode ${chalk.blue( - shortcode, - )} already exists`, - ); - return 1; - } - - let newUrl = url; - - if (!args["keep-url"]) { - // Upload the emoji to object storage - const mediaBackend = await MediaBackend.fromBackendType( - config.media.backend, - config, - ); - - console.log( - `${chalk.blue( - "⏳", - )} Downloading emoji from ${chalk.underline( - chalk.blue(url), - )}`, - ); - - const downloadedFile = await fetch(url).then( - async (r) => - new File( - [await r.blob()], - url.split("/").pop() ?? - `${crypto.randomUUID()}-emoji.png`, - ), - ); - - const metadata = await mediaBackend - .addFile(downloadedFile) - .catch(() => null); - - if (!metadata) { - console.log( - `${chalk.red( - "✗", - )} Failed to upload emoji to object storage (is your URL accessible?)`, - ); - return 1; - } - - newUrl = getUrl(metadata.path, config); - - console.log( - `${chalk.green("✓")} Uploaded emoji to object storage`, - ); - } - - // Add the emoji - const content_type = lookup(newUrl) || "application/octet-stream"; - - const newEmoji = ( - await db - .insert(Emojis) - .values({ - shortcode: shortcode, - url: newUrl, - visibleInPicker: true, - contentType: content_type, - }) - .returning() - )[0]; - - console.log( - `${chalk.green("✓")} Created emoji ${chalk.blue( - newEmoji.shortcode, - )}`, - ); - - return 0; - }, - "Adds a custom emoji", - "bun cli emoji add bun https://bun.com/bun.png", - ), - new CliCommand<{ - help: boolean; - shortcode: string; - noconfirm: boolean; - }>( - ["emoji", "delete"], - [ - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "shortcode", - type: CliParameterType.STRING, - description: - "Shortcode of the emoji to delete (wildcards supported)", - needsValue: true, - positioned: true, - }, - { - name: "noconfirm", - type: CliParameterType.BOOLEAN, - description: "Skip confirmation", - needsValue: false, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { help, shortcode, noconfirm } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!shortcode) { - console.log(`${chalk.red("✗")} Missing shortcode`); - return 1; - } - - // Check if shortcode is valid - if (!shortcode.match(/^[a-zA-Z0-9-_*]+$/)) { - console.log( - `${chalk.red( - "✗", - )} Invalid shortcode (must be alphanumeric with dashes and underscores allowed + optional wildcards)`, - ); - return 1; - } - - const emojis = await db.query.Emojis.findMany({ - where: (emoji, { and, isNull, like }) => - and( - like(emoji.shortcode, shortcode.replace(/\*/g, "%")), - isNull(emoji.instanceId), - ), - }); - - if (emojis.length === 0) { - console.log( - `${chalk.red("✗")} No emoji with shortcode ${chalk.blue( - shortcode, - )} found`, - ); - return 1; - } - - // List emojis and ask for confirmation - for (const emoji of emojis) { - console.log( - `${chalk.blue(emoji.shortcode)}: ${chalk.underline( - emoji.url, - )}`, - ); - } - - if (!noconfirm) { - process.stdout.write( - `Are you sure you want to delete these emojis?\n${chalk.red( - chalk.bold( - "This is a destructive action and cannot be undone!", - ), - )} [y/N] `, - ); - - for await (const line of console) { - if (line.trim().toLowerCase() === "y") { - break; - } - console.log(`${chalk.red("✗")} Deletion cancelled`); - return 0; - } - } - - await db.delete(Emojis).where( - inArray( - Emojis.id, - emojis.map((e) => e.id), - ), - ); - - console.log( - `${chalk.green( - "✓", - )} Deleted emojis matching shortcode ${chalk.blue(shortcode)}`, - ); - - return 0; - }, - "Deletes custom emojis", - "bun cli emoji delete bun", - ), - new CliCommand<{ - help: boolean; - format: string; - limit: number; - }>( - ["emoji", "list"], - [ - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "format", - type: CliParameterType.STRING, - description: "Output format (can be json or csv)", - needsValue: true, - positioned: false, - optional: true, - }, - { - name: "limit", - type: CliParameterType.NUMBER, - description: "Limit the number of emojis to list (default 20)", - needsValue: true, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { help, format, limit = 20 } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - const emojis = await db.query.Emojis.findMany({ - where: (emoji, { isNull }) => isNull(emoji.instanceId), - limit: Number(limit), - }); - - if (format === "json") { - console.log(JSON.stringify(emojis, null, 4)); - return 0; - } - if (format === "csv") { - const parser = new Parser({}); - console.log(parser.parse(emojis)); - return 0; - } - - console.log( - `${chalk.green("✓")} Found ${chalk.blue( - emojis.length, - )} emojis (limit ${limit})`, - ); - - const table = new Table({ - head: [ - chalk.white(chalk.bold("Shortcode")), - chalk.white(chalk.bold("URL")), - ], - }); - - for (const emoji of emojis) { - table.push([ - chalk.blue(emoji.shortcode), - chalk.underline(emoji.url), - ]); - } - - console.log(table.toString()); - - return 0; - }, - "Lists all custom emojis", - "bun cli emoji list", - ), - new CliCommand<{ - help: boolean; - url: string; - noconfirm: boolean; - }>( - ["emoji", "import"], - [ - { - name: "help", - shortName: "h", - type: CliParameterType.EMPTY, - description: "Show help message", - needsValue: false, - positioned: false, - optional: true, - }, - { - name: "url", - type: CliParameterType.STRING, - description: "URL of the emoji pack manifest", - needsValue: true, - positioned: true, - }, - { - name: "noconfirm", - type: CliParameterType.BOOLEAN, - description: "Skip confirmation", - needsValue: false, - positioned: false, - optional: true, - }, - ], - async (instance: CliCommand, args) => { - const { help, url, noconfirm } = args; - - if (help) { - instance.displayHelp(); - return 0; - } - - if (!url) { - console.log(`${chalk.red("✗")} Missing URL`); - return 1; - } - - // Check if URL is valid - if (!URL.canParse(url)) { - console.log( - `${chalk.red( - "✗", - )} Invalid URL (must be a valid full URL, including protocol)`, - ); - return 1; - } - - // Fetch the emoji pack manifest - const manifest = await fetch(url) - .then( - (r) => - r.json() as Promise< - Record< - string, - { - files: string; - homepage: string; - src: string; - src_sha256?: string; - } - > - >, - ) - .catch(() => null); - - if (!manifest) { - console.log( - `${chalk.red( - "✗", - )} Failed to fetch emoji pack manifest from ${chalk.underline( - url, - )}`, - ); - return 1; - } - - const homepage = Object.values(manifest)[0].homepage; - // If URL is not a valid URL, assume it's a relative path to homepage - const srcUrl = URL.canParse(Object.values(manifest)[0].src) - ? Object.values(manifest)[0].src - : new URL(Object.values(manifest)[0].src, homepage).toString(); - const filesUrl = URL.canParse(Object.values(manifest)[0].files) - ? Object.values(manifest)[0].files - : new URL( - Object.values(manifest)[0].files, - homepage, - ).toString(); - - console.log( - `${chalk.blue("⏳")} Fetching emoji pack from ${chalk.underline( - srcUrl, - )}`, - ); - - // Fetch actual pack (should be a zip file) - const pack = await fetch(srcUrl) - .then( - async (r) => - new File( - [await r.blob()], - srcUrl.split("/").pop() ?? "pack.zip", - ), - ) - .catch(() => null); - - // Check if pack is valid - if (!pack) { - console.log( - `${chalk.red( - "✗", - )} Failed to fetch emoji pack from ${chalk.underline( - srcUrl, - )}`, - ); - return 1; - } - - // Validate sha256 if available - if (Object.values(manifest)[0].src_sha256) { - const sha256 = new Bun.SHA256() - .update(await pack.arrayBuffer()) - .digest("hex"); - if (sha256 !== Object.values(manifest)[0].src_sha256) { - console.log( - `${chalk.red("✗")} SHA256 of pack (${chalk.blue( - sha256, - )}) does not match manifest ${chalk.blue( - Object.values(manifest)[0].src_sha256, - )}`, - ); - return 1; - } - console.log( - `${chalk.green("✓")} SHA256 of pack matches manifest`, - ); - } else { - console.log( - `${chalk.yellow( - "⚠", - )} No SHA256 in manifest, skipping validation`, - ); - } - - console.log( - `${chalk.green("✓")} Fetched emoji pack from ${chalk.underline( - srcUrl, - )}, unzipping to tempdir`, - ); - - // Unzip the pack to temp dir - const tempDir = await mkdtemp(join(tmpdir(), "bun-emoji-import-")); - - // Put the pack as a file - await Bun.write(join(tempDir, pack.name), pack); - - await extract(join(tempDir, pack.name), { - dir: tempDir, - }); - - console.log( - `${chalk.green("✓")} Unzipped emoji pack to ${chalk.blue( - tempDir, - )}`, - ); - - console.log( - `${chalk.blue( - "⏳", - )} Fetching emoji pack file metadata from ${chalk.underline( - filesUrl, - )}`, - ); - - // Fetch files URL - const packFiles = await fetch(filesUrl) - .then((r) => r.json() as Promise>) - .catch(() => null); - - if (!packFiles) { - console.log( - `${chalk.red( - "✗", - )} Failed to fetch emoji pack file metadata from ${chalk.underline( - filesUrl, - )}`, - ); - return 1; - } - - console.log( - `${chalk.green( - "✓", - )} Fetched emoji pack file metadata from ${chalk.underline( - filesUrl, - )}`, - ); - - if (Object.keys(packFiles).length === 0) { - console.log(`${chalk.red("✗")} Empty emoji pack`); - return 1; - } - - if (!noconfirm) { - process.stdout.write( - `Are you sure you want to import ${chalk.blue( - Object.keys(packFiles).length, - )} emojis from ${chalk.underline(chalk.blue(url))}? [y/N] `, - ); - - for await (const line of console) { - if (line.trim().toLowerCase() === "y") { - break; - } - console.log(`${chalk.red("✗")} Import cancelled`); - return 0; - } - } - - const successfullyImported: string[] = []; - - // Add emojis - for (const [shortcode, url] of Object.entries(packFiles)) { - // If emoji URL is not a valid URL, assume it's a relative path to homepage - const fileUrl = Bun.pathToFileURL( - join(tempDir, url), - ).toString(); - - // Check if emoji already exists - const existingEmoji = await db.query.Emojis.findFirst({ - where: (emoji, { and, eq, isNull }) => - and( - eq(emoji.shortcode, shortcode), - isNull(emoji.instanceId), - ), - }); - - if (existingEmoji) { - console.log( - `${chalk.red("✗")} Emoji with shortcode ${chalk.blue( - shortcode, - )} already exists`, - ); - continue; - } - - // Add the emoji by calling the add command - const returnCode = await cliBuilder.processArgs([ - "emoji", - "add", - shortcode, - fileUrl, - "--noconfirm", - ]); - - if (returnCode === 0) successfullyImported.push(shortcode); - } - - console.log( - `${chalk.green("✓")} Imported ${ - successfullyImported.length - } emojis from ${chalk.underline(url)}`, - ); - - // List imported - if (successfullyImported.length > 0) { - console.log( - `${chalk.green("✓")} Successfully imported ${ - successfullyImported.length - } emojis: ${successfullyImported.join(", ")}`, - ); - } - - // List unimported - if (successfullyImported.length < Object.keys(packFiles).length) { - const unimported = Object.keys(packFiles).filter( - (key) => !successfullyImported.includes(key), - ); - console.log( - `${chalk.red("✗")} Failed to import ${ - unimported.length - } emojis: ${unimported.join(", ")}`, - ); - } - - return 0; - }, - "Imports a Pleroma emoji pack", - "bun cli emoji import https://site.com/neofox/manifest.json", - ), -]); - -const exitCode = await cliBuilder.processArgs(args); - -process.exit(Number(exitCode === undefined ? 0 : exitCode)); diff --git a/config/config.example.toml b/config/config.example.toml index 09ed7d80..2a30d39f 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -1,15 +1,9 @@ -# Lysand Config -# All of these values can be changed via the CLI (they will be saved in a file named config.internal.toml -# in the same directory as this one) -# Changing this file does not require a restart, but might take a few seconds to apply -# This file will be merged with the CLI configuration, taking precedence over it - [database] # Main PostgreSQL database connection host = "localhost" port = 5432 username = "lysand" -password = "lysand" +password = "mycoolpassword" database = "lysand" [redis.queue] @@ -19,12 +13,13 @@ host = "localhost" port = 6379 password = "" database = 0 +enabled = true [redis.cache] # Redis instance to be used as a timeline cache # Optional, can be the same as the queue instance host = "localhost" -port = 6379 +port = 40004 password = "" database = 1 enabled = false @@ -32,13 +27,13 @@ enabled = false [meilisearch] # If Meilisearch is not configured, search will not be enabled host = "localhost" -port = 7700 -api_key = "______________________________" -enabled = false +port = 40007 +api_key = "" +enabled = true [signups] # URL of your Terms of Service -tos_url = "https://my-site.com/tos" +tos_url = "https://social.lysand.org/tos" # Whether to enable registrations or not registration = true rules = [ @@ -56,23 +51,20 @@ jwt_key = "" # This is an example configuration # The provider MUST support OpenID Connect with .well-known discovery # Most notably, GitHub does not support this -# Set the allowed redirect URIs to (regex) /oauth/callback/?.* to allow Lysand to use it -# The last ?.* is important, as it allows for query parameters to be passed [[oidc.providers]] -# Test with custom Authentik instance -name = "CPlusPatch ID" -id = "cpluspatch-id" -url = "https://id.cpluspatch.com/application/o/lysand-testing/" -client_id = "______________________________" -client_secret = "__________________________________" -icon = "https://cpluspatch.com/images/icons/logo.svg" +# name = "CPlusPatch ID" +# id = "cpluspatch-id" +# url = "https://id.cpluspatch.com/application/o/lysand-testing/" +# client_id = "XXXX" +# client_secret = "XXXXX" +# icon = "https://cpluspatch.com/images/icons/logo.svg" [http] # The full URL Lysand will be reachable by (paths are not supported) -base_url = "https://lysand.social" -# Address to bind to -bind = "0.0.0.0" -bind_port = "8080" +base_url = "https://lysand.localhost:9900" +# Address to bind to (0.0.0.0 is suggested for proxies) +bind = "lysand.localhost" +bind_port = 9900 # Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported) banned_ips = [] @@ -85,8 +77,8 @@ banned_user_agents = [ [http.tls] # If these values are set, Lysand will use these files for TLS enabled = false -key = "config/privatekey.pem" -cert = "config/certificate.pem" +key = "" +cert = "" passphrase = "" ca = "" @@ -107,20 +99,25 @@ enabled = true # The URL to reach the frontend at (should be on a local network) url = "http://localhost:3000" +[frontend.settings] +# Arbitrary key/value pairs to be passed to the frontend +# This can be used to set up custom themes, etc on supported frontends. +# theme = "dark" + [frontend.glitch] # Enable the Glitch frontend integration enabled = false # Glitch assets folder assets = "glitch" # Server the assets were ripped from (and any eventual CDNs) -server = ["https://glitch.social", "https://static.glitch.social"] +server = ["https://tech.lgbt"] [smtp] # SMTP server to use for sending emails server = "smtp.example.com" port = 465 username = "test@example.com" -password = "____________" +password = "password123" tls = true # Disable all email functions (this will allow people to sign up without verifying # their email) @@ -131,7 +128,7 @@ enabled = false # If you need to change this value after setting up your instance, you must move all the files # from one backend to the other manually (the CLI will have an option to do this later) # TODO: Add CLI command to move files -backend = "local" +backend = "s3" # Whether to check the hash of media when uploading to avoid duplication deduplicate_media = true # If media backend is "local", this is the folder where the files will be stored @@ -140,29 +137,19 @@ local_uploads_folder = "uploads" [media.conversion] # Whether to automatically convert images to another format on upload -convert_images = false -# Can be: "jxl", "webp", "avif", "png", "jpg", "heif" +convert_images = true +# Can be: "image/jxl", "image/webp", "image/avif", "image/png", "image/jpeg", "image/heif", "image/gif" # JXL support will likely not work -convert_to = "webp" +convert_to = "image/webp" [s3] # Can be left blank if you don't use the S3 media backend -endpoint = "myhostname.banana.com" -access_key = "_____________" -secret_access_key = "_________________" -region = "" -bucket_name = "lysand" -public_url = "https://cdn.test.com" - -[email] -# Sends an email to moderators when a report is received -send_on_report = false -# Sends an email to moderators when a user is suspended -send_on_suspend = false -# Sends an email to moderators when a user is unsuspended -send_on_unsuspend = false -# Verify user emails when signing up (except via OIDC) -verify_email = false +# endpoint = "" +# access_key = "XXXXX" +# secret_access_key = "XXX" +# region = "" +# bucket_name = "lysand" +# public_url = "https://cdn.example.com" [validation] # Checks user data @@ -240,36 +227,8 @@ url_scheme_whitelist = [ # This can easily be spoofed, but if it is spoofed it will appear broken # to normal clients until despoofed enforce_mime_types = false -allowed_mime_types = [ - "image/jpeg", - "image/png", - "image/gif", - "image/heic", - "image/heif", - "image/webp", - "image/avif", - "video/webm", - "video/mp4", - "video/quicktime", - "video/ogg", - "audio/wave", - "audio/wav", - "audio/x-wav", - "audio/x-pn-wave", - "audio/vnd.wave", - "audio/ogg", - "audio/vorbis", - "audio/mpeg", - "audio/mp3", - "audio/webm", - "audio/flac", - "audio/aac", - "audio/m4a", - "audio/x-m4a", - "audio/mp4", - "audio/3gpp", - "video/x-ms-asf", -] +# Defaults to all valid MIME types +# allowed_mime_types = [] [defaults] # Default visibility for new notes @@ -278,10 +237,10 @@ allowed_mime_types = [ visibility = "public" # Default language for new notes (ISO code) language = "en" -# Default avatar, must be a valid URL or "" for a placeholder avatar -avatar = "" -# Default header, must be a valid URL or "" for none -header = "" +# Default avatar, must be a valid URL or left out for a placeholder avatar +# avatar = "" +# Default header, must be a valid URL or left out for none +# header = "" # A style name from https://www.dicebear.com/styles placeholder_style = "thumbs" @@ -310,19 +269,20 @@ avatars = [] [instance] name = "Lysand" -description = "A test instance of Lysand" +description = "A Lysand instance" # Path to a file containing a longer description of your instance # This will be parsed as Markdown -extended_description_path = "" -# URL to your instance logo (jpg files should be renamed to jpeg) -logo = "" -# URL to your instance banner (jpg files should be renamed to jpeg) -banner = "" +# extended_description_path = "config/description.md" +# URL to your instance logo +# logo = "" +# URL to your instance banner +# banner = "" [filters] # Regex filters for federated and local data -# Does not apply retroactively (try the CLI for that) +# Drops data matching the filters +# Does not apply retroactively to existing data # Note contents note_content = [ @@ -341,7 +301,7 @@ log_requests = false # Log request and their contents (warning: this is a lot of data) log_requests_verbose = false # Available levels: debug, info, warning, error, critical -log_level = "info" +log_level = "debug" # For GDPR compliance, you can disable logging of IPs log_ip = false @@ -362,5 +322,5 @@ max_coeff = 1.0 [custom_ratelimits] # Add in any API route in this style here # Applies before the global ratelimit changes -"/api/v1/accounts/:id/block" = { duration = 30, max = 60 } -"/api/v1/timelines/public" = { duration = 60, max = 200 } +# "/api/v1/accounts/:id/block" = { duration = 30, max = 60 } +# "/api/v1/timelines/public" = { duration = 60, max = 200 } diff --git a/index.ts b/index.ts index a405109e..ad8651e0 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,7 @@ import { dualLogger } from "@loggers"; import { connectMeili } from "@meilisearch"; import { errorResponse, response } from "@response"; +import chalk from "chalk"; import { config } from "config-manager"; import { Hono } from "hono"; import { LogLevel, LogManager, type MultiLogManager } from "log-manager"; @@ -70,7 +71,7 @@ if (isEntry) { await dualServerLogger.log( LogLevel.CRITICAL, "Server", - `${privateKey};${publicKey}`, + chalk.gray(`${privateKey};${publicKey}`), ); process.exit(1); } diff --git a/package.json b/package.json index 38b7fe23..0e54ef68 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,6 @@ "blurhash": "^2.0.5", "bullmq": "^5.7.1", "chalk": "^5.3.0", - "cli-parser": "workspace:*", "cli-progress": "^3.12.0", "cli-table": "^0.3.11", "config-manager": "workspace:*", diff --git a/packages/cli-parser/cli-builder.type.ts b/packages/cli-parser/cli-builder.type.ts deleted file mode 100644 index 94553dbb..00000000 --- a/packages/cli-parser/cli-builder.type.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface CliParameter { - name: string; - /* Like -v for --version */ - shortName?: string; - /** - * If not positioned, the argument will need to be called with --name value instead of just value - * @default true - */ - positioned?: boolean; - /* Whether the argument needs a value (requires positioned to be false) */ - needsValue?: boolean; - optional?: true; - type: CliParameterType; - description?: string; -} - -export enum CliParameterType { - STRING = "string", - NUMBER = "number", - BOOLEAN = "boolean", - ARRAY = "array", - EMPTY = "empty", -} diff --git a/packages/cli-parser/index.ts b/packages/cli-parser/index.ts deleted file mode 100644 index bb78107f..00000000 --- a/packages/cli-parser/index.ts +++ /dev/null @@ -1,450 +0,0 @@ -import chalk from "chalk"; -import { type CliParameter, CliParameterType } from "./cli-builder.type"; - -export function startsWithArray(fullArray: string[], startArray: string[]) { - if (startArray.length > fullArray.length) { - return false; - } - return fullArray - .slice(0, startArray.length) - .every((value, index) => value === startArray[index]); -} - -interface TreeType { - [key: string]: CliCommand | TreeType; -} - -/** - * Builder for a CLI - * @param commands Array of commands to register - */ -export class CliBuilder { - constructor(public commands: CliCommand[] = []) {} - - /** - * Add command to the CLI - * @throws Error if command already exists - * @param command Command to add - */ - registerCommand(command: CliCommand) { - if (this.checkIfCommandAlreadyExists(command)) { - throw new Error( - `Command category '${command.categories.join( - " ", - )}' already exists`, - ); - } - this.commands.push(command); - } - - /** - * Add multiple commands to the CLI - * @throws Error if command already exists - * @param commands Commands to add - */ - registerCommands(commands: CliCommand[]) { - const existingCommand = commands.find((command) => - this.checkIfCommandAlreadyExists(command), - ); - if (existingCommand) { - throw new Error( - `Command category '${existingCommand.categories.join( - " ", - )}' already exists`, - ); - } - this.commands.push(...commands); - } - - /** - * Remove command from the CLI - * @param command Command to remove - */ - deregisterCommand(command: CliCommand) { - this.commands = this.commands.filter( - (registeredCommand) => registeredCommand !== command, - ); - } - - /** - * Remove multiple commands from the CLI - * @param commands Commands to remove - */ - deregisterCommands(commands: CliCommand[]) { - this.commands = this.commands.filter( - (registeredCommand) => !commands.includes(registeredCommand), - ); - } - - checkIfCommandAlreadyExists(command: CliCommand) { - return this.commands.some( - (registeredCommand) => - registeredCommand.categories.length === - command.categories.length && - registeredCommand.categories.every( - (category, index) => category === command.categories[index], - ), - ); - } - - /** - * Get relevant args for the command (without executable or runtime) - * @param args Arguments passed to the CLI - */ - private getRelevantArgs(args: string[]) { - if (args[0].startsWith("./")) { - // Formatted like ./cli.ts [command] - return args.slice(1); - } - if (args[0].includes("bun")) { - // Formatted like bun cli.ts [command] - return args.slice(2); - } - return args; - } - - /** - * Turn raw system args into a CLI command and run it - * @param args Args directly from process.argv - */ - async processArgs(args: string[]) { - const revelantArgs = this.getRelevantArgs(args); - - // Handle "-h", "--help" and "help" commands as special cases - if (revelantArgs.length === 1) { - if (["-h", "--help", "help"].includes(revelantArgs[0])) { - this.displayHelp(); - return; - } - } - - // 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), - ); - - if (matchingCommands.length === 0) { - console.log( - `Invalid command "${revelantArgs.join( - " ", - )}". Please use the ${chalk.bold( - "help", - )} command to see a list of commands`, - ); - return 0; - } - - // Get command with largest category size - const command = matchingCommands.reduce((prev, current) => - prev.categories.length > current.categories.length ? prev : current, - ); - - const argsWithoutCategories = revelantArgs.slice( - command.categories.length, - ); - - return await command.run(argsWithoutCategories); - } - - /** - * Recursively urns the commands into a tree where subcategories mark each sub-branch - * @example - * ```txt - * user verify - * user delete - * user new admin - * user new - * -> - * user - * verify - * delete - * new - * admin - * "" - * ``` - */ - getCommandTree(commands: CliCommand[]): TreeType { - const tree: TreeType = {}; - - for (const command of commands) { - let currentLevel = tree; // Start at the root - - // Split the command into parts and iterate over them - for (const part of command.categories) { - // If this part doesn't exist in the current level of the tree, add it (__proto__ check to prevent prototype pollution) - if (!currentLevel[part] && part !== "__proto__") { - // If this is the last part of the command, add the command itself - if ( - part === - command.categories[command.categories.length - 1] - ) { - currentLevel[part] = command; - break; - } - currentLevel[part] = {}; - } - - // Move down to the next level of the tree - currentLevel = currentLevel[part] as TreeType; - } - } - - return tree; - } - - /** - * Display help for every command in a tree manner - */ - displayHelp() { - /* - user - set - admin: List of admin commands - --prod: Whether to run in production - --dev: Whether to run in development - username: Username of the admin - Example: user set admin --prod --dev --username John - delete - ... - verify - ... - */ - const tree = this.getCommandTree(this.commands); - let writeBuffer = ""; - - const displayTree = (tree: TreeType, depth = 0) => { - for (const [key, value] of Object.entries(tree)) { - if (value instanceof CliCommand) { - writeBuffer += `${" ".repeat(depth)}${chalk.blue( - key, - )}|${chalk.underline(value.description)}\n`; - const positionedArgs = value.argTypes.filter( - (arg) => arg.positioned ?? true, - ); - const unpositionedArgs = value.argTypes.filter( - (arg) => !(arg.positioned ?? true), - ); - - for (const arg of positionedArgs) { - writeBuffer += `${" ".repeat( - depth + 1, - )}${chalk.green(arg.name)}|${ - arg.description ?? "(no description)" - } ${arg.optional ? chalk.gray("(optional)") : ""}\n`; - } - for (const arg of unpositionedArgs) { - writeBuffer += `${" ".repeat( - depth + 1, - )}${chalk.yellow(`--${arg.name}`)}${ - arg.shortName - ? `, ${chalk.yellow(`-${arg.shortName}`)}` - : "" - }|${arg.description ?? "(no description)"} ${ - arg.optional ? chalk.gray("(optional)") : "" - }\n`; - } - - if (value.example) { - writeBuffer += `${" ".repeat(depth + 1)}${chalk.bold( - "Example:", - )} ${chalk.bgGray(value.example)}\n`; - } - } else { - writeBuffer += `${" ".repeat(depth)}${chalk.blue( - key, - )}\n`; - displayTree(value, depth + 1); - } - } - }; - - displayTree(tree); - - // Replace all "|" with enough dots so that the text on the left + the dots = the same length - const optimal_length = Number( - writeBuffer - .split("\n") - // @ts-expect-error I don't know how this works and I don't want to know - .reduce((prev, current) => { - // If previousValue is empty - if (!prev) - return current.includes("|") - ? current.split("|")[0].length - : 0; - if (!current.includes("|")) return prev; - const [left] = current.split("|"); - // Strip ANSI color codes or they mess up the length - return Math.max(Number(prev), Bun.stringWidth(left)); - }), - ); - - for (const line of writeBuffer.split("\n")) { - const [left, right] = line.split("|"); - if (!right) { - console.log(left); - continue; - } - // Strip ANSI color codes or they mess up the length - const dots = ".".repeat(optimal_length + 5 - Bun.stringWidth(left)); - console.log(`${left}${dots}${right}`); - } - } -} - -type ExecuteFunction = ( - instance: CliCommand, - args: Partial, -) => Promise | Promise | number | void; - -/** - * A command that can be executed from the command line - * @param categories Example: `["user", "create"]` for the command `./cli user create --name John` - */ - -// biome-ignore lint/suspicious/noExplicitAny: -export class CliCommand { - constructor( - public categories: string[], - public argTypes: CliParameter[], - private execute: ExecuteFunction, - public description?: string, - public example?: string, - ) {} - - /** - * Display help message for the command - * formatted with Chalk and with emojis - */ - displayHelp() { - const positionedArgs = this.argTypes.filter( - (arg) => arg.positioned ?? true, - ); - const unpositionedArgs = this.argTypes.filter( - (arg) => !(arg.positioned ?? true), - ); - const helpMessage = ` -${chalk.green("📚 Command:")} ${chalk.yellow(this.categories.join(" "))} -${this.description ? `${chalk.cyan(this.description)}\n` : ""} -${chalk.magenta("🔧 Arguments:")} -${positionedArgs - .map( - (arg) => - `${chalk.bold(arg.name)}: ${chalk.blue( - arg.description ?? "(no description)", - )} ${arg.optional ? chalk.gray("(optional)") : ""}`, - ) - .join("\n")} -${unpositionedArgs - .map( - (arg) => - `--${chalk.bold(arg.name)}${ - arg.shortName ? `, -${arg.shortName}` : "" - }: ${chalk.blue(arg.description ?? "(no description)")} ${ - arg.optional ? chalk.gray("(optional)") : "" - }`, - ) - .join("\n")}${ - this.example - ? `\n${chalk.magenta("🚀 Example:")}\n${chalk.bgGray(this.example)}` - : "" -} -`; - - console.log(helpMessage); - } - - /** - * Parses string array arguments into a full JavaScript object - * @param argsWithoutCategories - * @returns - */ - private parseArgs( - argsWithoutCategories: string[], - ): Record { - const parsedArgs: Record = - {}; - let currentParameter: CliParameter | null = null; - - for (let i = 0; i < argsWithoutCategories.length; i++) { - const arg = argsWithoutCategories[i]; - - if (arg.startsWith("--")) { - const argName = arg.substring(2); - currentParameter = - this.argTypes.find((argType) => argType.name === argName) || - null; - if (currentParameter && !currentParameter.needsValue) { - parsedArgs[argName] = true; - currentParameter = null; - } else if (currentParameter?.needsValue) { - parsedArgs[argName] = this.castArgValue( - argsWithoutCategories[i + 1], - currentParameter.type, - ); - i++; - currentParameter = null; - } - } else if (arg.startsWith("-")) { - const shortName = arg.substring(1); - const argType = this.argTypes.find( - (argType) => argType.shortName === shortName, - ); - if (argType && !argType.needsValue) { - parsedArgs[argType.name] = true; - } else if (argType?.needsValue) { - parsedArgs[argType.name] = this.castArgValue( - argsWithoutCategories[i + 1], - argType.type, - ); - i++; - } - } else if (currentParameter) { - parsedArgs[currentParameter.name] = this.castArgValue( - arg, - currentParameter.type, - ); - currentParameter = null; - } else { - const positionedArgType = this.argTypes.find( - (argType) => - argType.positioned && !parsedArgs[argType.name], - ); - if (positionedArgType) { - parsedArgs[positionedArgType.name] = this.castArgValue( - arg, - positionedArgType.type, - ); - } - } - } - - return parsedArgs; - } - - private castArgValue( - value: string, - type: CliParameter["type"], - ): string | number | boolean | string[] { - switch (type) { - case CliParameterType.STRING: - return value; - case CliParameterType.NUMBER: - return Number(value); - case CliParameterType.BOOLEAN: - return value === "true"; - case CliParameterType.ARRAY: - return value.split(","); - default: - return value; - } - } - - /** - * Runs the execute function with the parsed parameters as an argument - */ - async run(argsWithoutCategories: string[]) { - const args = this.parseArgs(argsWithoutCategories); - return await this.execute(this, args as T); - } -} diff --git a/packages/cli-parser/package.json b/packages/cli-parser/package.json deleted file mode 100644 index 83f27af5..00000000 --- a/packages/cli-parser/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "cli-parser", - "version": "0.0.0", - "main": "index.ts", - "dependencies": { "chalk": "^5.3.0", "strip-ansi": "^7.1.0" } -} diff --git a/packages/cli-parser/tests/cli-builder.test.ts b/packages/cli-parser/tests/cli-builder.test.ts deleted file mode 100644 index e8fe2c92..00000000 --- a/packages/cli-parser/tests/cli-builder.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { beforeEach, describe, expect, it, jest, spyOn } from "bun:test"; -import stripAnsi from "strip-ansi"; -// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts -import { CliBuilder, CliCommand, startsWithArray } from ".."; -import { CliParameterType } from "../cli-builder.type"; - -describe("startsWithArray", () => { - it("should return true when fullArray starts with startArray", () => { - const fullArray = ["a", "b", "c", "d", "e"]; - const startArray = ["a", "b", "c"]; - expect(startsWithArray(fullArray, startArray)).toBe(true); - }); - - it("should return false when fullArray does not start with startArray", () => { - const fullArray = ["a", "b", "c", "d", "e"]; - const startArray = ["b", "c", "d"]; - expect(startsWithArray(fullArray, startArray)).toBe(false); - }); - - it("should return true when startArray is empty", () => { - const fullArray = ["a", "b", "c", "d", "e"]; - const startArray: string[] = []; - expect(startsWithArray(fullArray, startArray)).toBe(true); - }); - - it("should return false when fullArray is shorter than startArray", () => { - const fullArray = ["a", "b", "c"]; - const startArray = ["a", "b", "c", "d", "e"]; - expect(startsWithArray(fullArray, startArray)).toBe(false); - }); -}); - -describe("CliCommand", () => { - let cliCommand: CliCommand; - - beforeEach(() => { - cliCommand = new CliCommand( - ["category1", "category2"], - [ - { - name: "arg1", - type: CliParameterType.STRING, - needsValue: true, - }, - { - name: "arg2", - shortName: "a", - type: CliParameterType.NUMBER, - needsValue: true, - }, - { - name: "arg3", - type: CliParameterType.BOOLEAN, - needsValue: false, - }, - { - name: "arg4", - type: CliParameterType.ARRAY, - needsValue: true, - }, - ], - () => { - // Do nothing - }, - ); - }); - - it("should parse string arguments correctly", () => { - // @ts-expect-error Testing private method - const args = cliCommand.parseArgs([ - "--arg1", - "value1", - "--arg2", - "42", - "--arg3", - "--arg4", - "value1,value2", - ]); - expect(args).toEqual({ - arg1: "value1", - arg2: 42, - arg3: true, - arg4: ["value1", "value2"], - }); - }); - - it("should parse short names for arguments too", () => { - // @ts-expect-error Testing private method - const args = cliCommand.parseArgs([ - "--arg1", - "value1", - "-a", - "42", - "--arg3", - "--arg4", - "value1,value2", - ]); - expect(args).toEqual({ - arg1: "value1", - arg2: 42, - arg3: true, - arg4: ["value1", "value2"], - }); - }); - - it("should cast argument values correctly", () => { - // @ts-expect-error Testing private method - expect(cliCommand.castArgValue("42", CliParameterType.NUMBER)).toBe(42); - // @ts-expect-error Testing private method - expect(cliCommand.castArgValue("true", CliParameterType.BOOLEAN)).toBe( - true, - ); - expect( - // @ts-expect-error Testing private method - cliCommand.castArgValue("value1,value2", CliParameterType.ARRAY), - ).toEqual(["value1", "value2"]); - }); - - it("should run the execute function with the parsed parameters", async () => { - const mockExecute = jest.fn(); - cliCommand = new CliCommand( - ["category1", "category2"], - [ - { - name: "arg1", - type: CliParameterType.STRING, - needsValue: true, - }, - { - name: "arg2", - type: CliParameterType.NUMBER, - needsValue: true, - }, - { - name: "arg3", - type: CliParameterType.BOOLEAN, - needsValue: false, - }, - { - name: "arg4", - type: CliParameterType.ARRAY, - needsValue: true, - }, - ], - mockExecute, - ); - - await cliCommand.run([ - "--arg1", - "value1", - "--arg2", - "42", - "--arg3", - "--arg4", - "value1,value2", - ]); - expect(mockExecute).toHaveBeenCalledWith(cliCommand, { - arg1: "value1", - arg2: 42, - arg3: true, - arg4: ["value1", "value2"], - }); - }); - - it("should work with a mix of positioned and non-positioned arguments", async () => { - const mockExecute = jest.fn(); - cliCommand = new CliCommand( - ["category1", "category2"], - [ - { - name: "arg1", - type: CliParameterType.STRING, - needsValue: true, - }, - { - name: "arg2", - type: CliParameterType.NUMBER, - needsValue: true, - }, - { - name: "arg3", - type: CliParameterType.BOOLEAN, - needsValue: false, - }, - { - name: "arg4", - type: CliParameterType.ARRAY, - needsValue: true, - }, - { - name: "arg5", - type: CliParameterType.STRING, - needsValue: true, - positioned: true, - }, - ], - mockExecute, - ); - - await cliCommand.run([ - "--arg1", - "value1", - "--arg2", - "42", - "--arg3", - "--arg4", - "value1,value2", - "value5", - ]); - - expect(mockExecute).toHaveBeenCalledWith(cliCommand, { - arg1: "value1", - arg2: 42, - arg3: true, - arg4: ["value1", "value2"], - arg5: "value5", - }); - }); - - it("should display help message correctly", () => { - const consoleLogSpy = spyOn(console, "log").mockImplementation(() => { - // Do nothing - }); - - cliCommand = new CliCommand( - ["category1", "category2"], - [ - { - name: "arg1", - type: CliParameterType.STRING, - needsValue: true, - description: "Argument 1", - optional: true, - }, - { - name: "arg2", - type: CliParameterType.NUMBER, - needsValue: true, - description: "Argument 2", - }, - { - name: "arg3", - type: CliParameterType.BOOLEAN, - needsValue: false, - description: "Argument 3", - optional: true, - positioned: false, - }, - { - name: "arg4", - type: CliParameterType.ARRAY, - needsValue: true, - description: "Argument 4", - positioned: false, - }, - ], - () => { - // Do nothing - }, - "This is a test command", - "category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2", - ); - - cliCommand.displayHelp(); - - const loggedString = consoleLogSpy.mock.calls.map((call) => - stripAnsi(call[0]), - )[0]; - - consoleLogSpy.mockRestore(); - - expect(loggedString).toContain("📚 Command: category1 category2"); - expect(loggedString).toContain("🔧 Arguments:"); - expect(loggedString).toContain("arg1: Argument 1 (optional)"); - expect(loggedString).toContain("arg2: Argument 2"); - expect(loggedString).toContain("--arg3: Argument 3 (optional)"); - expect(loggedString).toContain("--arg4: Argument 4"); - expect(loggedString).toContain("🚀 Example:"); - expect(loggedString).toContain( - "category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2", - ); - }); -}); - -describe("CliBuilder", () => { - let cliBuilder: CliBuilder; - let mockCommand1: CliCommand; - let mockCommand2: CliCommand; - - beforeEach(() => { - mockCommand1 = new CliCommand(["category1"], [], jest.fn()); - mockCommand2 = new CliCommand(["category2"], [], jest.fn()); - cliBuilder = new CliBuilder([mockCommand1]); - }); - - it("should register a command correctly", () => { - cliBuilder.registerCommand(mockCommand2); - expect(cliBuilder.commands).toContain(mockCommand2); - }); - - it("should register multiple commands correctly", () => { - const mockCommand3 = new CliCommand(["category3"], [], jest.fn()); - cliBuilder.registerCommands([mockCommand2, mockCommand3]); - expect(cliBuilder.commands).toContain(mockCommand2); - expect(cliBuilder.commands).toContain(mockCommand3); - }); - - it("should error when adding duplicates", () => { - expect(() => { - cliBuilder.registerCommand(mockCommand1); - }).toThrow(); - - expect(() => { - cliBuilder.registerCommands([mockCommand1]); - }).toThrow(); - }); - - it("should deregister a command correctly", () => { - cliBuilder.deregisterCommand(mockCommand1); - expect(cliBuilder.commands).not.toContain(mockCommand1); - }); - - it("should deregister multiple commands correctly", () => { - cliBuilder.registerCommand(mockCommand2); - cliBuilder.deregisterCommands([mockCommand1, mockCommand2]); - expect(cliBuilder.commands).not.toContain(mockCommand1); - expect(cliBuilder.commands).not.toContain(mockCommand2); - }); - - it("should process args correctly", async () => { - const mockExecute = jest.fn(); - const mockCommand = new CliCommand( - ["category1", "sub1"], - [ - { - name: "arg1", - type: CliParameterType.STRING, - needsValue: true, - positioned: false, - }, - ], - mockExecute, - ); - cliBuilder.registerCommand(mockCommand); - await cliBuilder.processArgs([ - "./cli.ts", - "category1", - "sub1", - "--arg1", - "value1", - ]); - expect(mockExecute).toHaveBeenCalledWith(expect.anything(), { - arg1: "value1", - }); - }); - - describe("should build command tree", () => { - let cliBuilder: CliBuilder; - let mockCommand1: CliCommand; - let mockCommand2: CliCommand; - let mockCommand3: CliCommand; - let mockCommand4: CliCommand; - let mockCommand5: CliCommand; - - beforeEach(() => { - mockCommand1 = new CliCommand(["user", "verify"], [], jest.fn()); - mockCommand2 = new CliCommand(["user", "delete"], [], jest.fn()); - mockCommand3 = new CliCommand( - ["user", "new", "admin"], - [], - jest.fn(), - ); - mockCommand4 = new CliCommand(["user", "new"], [], jest.fn()); - mockCommand5 = new CliCommand(["admin", "delete"], [], jest.fn()); - cliBuilder = new CliBuilder([ - mockCommand1, - mockCommand2, - mockCommand3, - mockCommand4, - mockCommand5, - ]); - }); - - it("should build the command tree correctly", () => { - const tree = cliBuilder.getCommandTree(cliBuilder.commands); - expect(tree).toEqual({ - user: { - verify: mockCommand1, - delete: mockCommand2, - new: { - admin: mockCommand3, - }, - }, - admin: { - delete: mockCommand5, - }, - }); - }); - - it("should build the command tree correctly when there are no commands", () => { - cliBuilder = new CliBuilder([]); - const tree = cliBuilder.getCommandTree(cliBuilder.commands); - expect(tree).toEqual({}); - }); - - it("should build the command tree correctly when there is only one command", () => { - cliBuilder = new CliBuilder([mockCommand1]); - const tree = cliBuilder.getCommandTree(cliBuilder.commands); - expect(tree).toEqual({ - user: { - verify: mockCommand1, - }, - }); - }); - }); - - it("should show help menu", () => { - const consoleLogSpy = spyOn(console, "log").mockImplementation(() => { - // Do nothing - }); - - const cliBuilder = new CliBuilder(); - - const cliCommand = new CliCommand( - ["category1", "category2"], - [ - { - name: "name", - type: CliParameterType.STRING, - needsValue: true, - description: "Name of new item", - }, - { - name: "delete-previous", - type: CliParameterType.NUMBER, - needsValue: false, - positioned: false, - optional: true, - description: "Also delete the previous item", - }, - { - name: "arg3", - type: CliParameterType.BOOLEAN, - needsValue: false, - }, - { - name: "arg4", - type: CliParameterType.ARRAY, - needsValue: true, - }, - ], - () => { - // Do nothing - }, - "I love sussy sauces", - "emoji add --url https://site.com/image.png", - ); - - cliBuilder.registerCommand(cliCommand); - cliBuilder.displayHelp(); - - const loggedString = consoleLogSpy.mock.calls - .map((call) => stripAnsi(call[0])) - .join("\n"); - - consoleLogSpy.mockRestore(); - - expect(loggedString).toContain("category1"); - expect(loggedString).toContain( - " category2.................I love sussy sauces", - ); - expect(loggedString).toContain( - " name..................Name of new item", - ); - expect(loggedString).toContain( - " arg3..................(no description)", - ); - expect(loggedString).toContain( - " arg4..................(no description)", - ); - expect(loggedString).toContain( - " --delete-previous.....Also delete the previous item (optional)", - ); - expect(loggedString).toContain( - " Example: emoji add --url https://site.com/image.png", - ); - }); -}); diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index 26a5685b..255b3a93 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -1,663 +1,457 @@ +import { z } from "zod"; +import { types as mimeTypes } from "mime-types"; + export enum MediaBackendType { LOCAL = "local", S3 = "s3", } -export interface Config { - database: { - /** @default "localhost" */ - host: string; - - /** @default 5432 */ - port: number; - - /** @default "lysand" */ - username: string; - - /** @default "lysand" */ - password: string; - - /** @default "lysand" */ - database: string; - }; - - redis: { - queue: { - /** @default "localhost" */ - host: string; - - /** @default 6379 */ - port: number; - - /** @default "" */ - password: string; - - /** @default 0 */ - database: number; - }; - - cache: { - /** @default "localhost" */ - host: string; - - /** @default 6379 */ - port: number; - - /** @default "" */ - password: string; - - /** @default 1 */ - database: number; - - /** @default false */ - enabled: boolean; - }; - }; - - meilisearch: { - /** @default "localhost" */ - host: string; - - /** @default 7700 */ - port: number; - - /** @default "______________________________" */ - api_key: string; - - /** @default false */ - enabled: boolean; - }; - - signups: { - /** @default "https://my-site.com/tos" */ - tos_url: string; - - /** @default true */ - registration: boolean; - - /** @default [] */ - rules: string[]; - }; - - oidc: { - /** @default [] */ - providers: { - name: string; - id: string; - url: string; - client_id: string; - client_secret: string; - icon: string; - }[]; - - jwt_key: string; - }; - - http: { - /** @default "https://lysand.social" */ - base_url: string; - - /** @default "0.0.0.0" */ - bind: string; - - /** @default "8080" */ - bind_port: string; - - banned_ips: string[]; - - banned_user_agents: string[]; - - tls: { - /** @default false */ - enabled: boolean; - - /** @default "" */ - key: string; - - /** @default "" */ - cert: string; - - /** @default "" */ - passphrase: string; - - /** @default "" */ - ca: string; - }; - - bait: { - /** @default false */ - enabled: boolean; - - /** @default "" */ - send_file: string; - - /** @default ["127.0.0.1","::1"] */ - bait_ips: string[]; - - /** @default ["curl","wget"] */ - bait_user_agents: string[]; - }; - }; - - frontend: { - /** @default true */ - enabled: boolean; - - /** @default "http://localhost:3000" */ - url: string; - - glitch: { - /** @default false */ - enabled: boolean; - - /** @default "glitch" */ - assets: string; - - /** @default [] */ - server: string[]; - }; - }; - - smtp: { - /** @default "smtp.example.com" */ - server: string; - - /** @default 465 */ - port: number; - - /** @default "test@example.com" */ - username: string; - - /** @default "____________" */ - password: string; - - /** @default true */ - tls: boolean; - - /** @default false */ - enabled: boolean; - }; - - media: { - /** @default "local" */ - backend: MediaBackendType; - - /** @default true */ - deduplicate_media: boolean; - - /** @default "uploads" */ - local_uploads_folder: string; - - conversion: { - /** @default false */ - convert_images: boolean; - - /** @default "image/webp" */ - convert_to: string; - }; - }; - - s3: { - /** @default "myhostname.banana.com" */ - endpoint: string; - - /** @default "_____________" */ - access_key: string; - - /** @default "_________________" */ - secret_access_key: string; - - /** @default "" */ - region: string; - - /** @default "lysand" */ - bucket_name: string; - - /** @default "https://cdn.test.com" */ - public_url: string; - }; - - email: { - /** @default false */ - send_on_report: boolean; - - /** @default false */ - send_on_suspend: boolean; - - /** @default false */ - send_on_unsuspend: boolean; - - /** @default false */ - verify_email: boolean; - }; - - validation: { - /** @default 50 */ - max_displayname_size: number; - - /** @default 160 */ - max_bio_size: number; - - /** @default 5000 */ - max_note_size: number; - - /** @default 5000000 */ - max_avatar_size: number; - - /** @default 5000000 */ - max_header_size: number; - - /** @default 40000000 */ - max_media_size: number; - - /** @default 10 */ - max_media_attachments: number; - - /** @default 1000 */ - max_media_description_size: number; - - /** @default 20 */ - max_poll_options: number; - - /** @default 500 */ - max_poll_option_size: number; - - /** @default 60 */ - min_poll_duration: number; - - /** @default 1893456000 */ - max_poll_duration: number; - - /** @default 30 */ - max_username_size: number; - - /** @default 10 */ - max_field_count: number; - - /** @default 1000 */ - max_field_name_size: number; - - /** @default 1000 */ - max_field_value_size: number; - - /** @default [".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"] */ - username_blacklist: string[]; - - /** @default false */ - blacklist_tempmail: boolean; - - email_blacklist: string[]; - - /** @default ["http","https","ftp","dat","dweb","gopher","hyper","ipfs","ipns","irc","xmpp","ircs","magnet","mailto","mumble","ssb","gemini"] */ - url_scheme_whitelist: string[]; - - /** @default false */ - enforce_mime_types: boolean; - - /** @default ["image/jpeg","image/png","image/gif","image/heic","image/heif","image/webp","image/avif","video/webm","video/mp4","video/quicktime","video/ogg","audio/wave","audio/wav","audio/x-wav","audio/x-pn-wave","audio/vnd.wave","audio/ogg","audio/vorbis","audio/mpeg","audio/mp3","audio/webm","audio/flac","audio/aac","audio/m4a","audio/x-m4a","audio/mp4","audio/3gpp","video/x-ms-asf"] */ - allowed_mime_types: string[]; - }; - - defaults: { - /** @default "public" */ - visibility: string; - - /** @default "en" */ - language: string; - - /** @default "" */ - avatar: string; - - /** @default "" */ - header: string; - - /** @default "thumbs" */ - placeholder_style: string; - }; - - federation: { - blocked: string[]; - - followers_only: string[]; - - discard: { - reports: string[]; - - deletes: string[]; - - updates: string[]; - - media: string[]; - - follows: string[]; - - likes: string[]; - - reactions: string[]; - - banners: string[]; - - avatars: string[]; - }; - }; - - instance: { - /** @default "Lysand" */ - name: string; - - /** @default "A test instance of Lysand" */ - description: string; - - /** @default "" */ - extended_description_path: string; - - /** @default "" */ - logo: string; - - /** @default "" */ - banner: string; - }; - - filters: { - note_content: string[]; - - emoji: string[]; - - username: string[]; - - displayname: string[]; - - bio: string[]; - }; - - logging: { - /** @default false */ - log_requests: boolean; - - /** @default false */ - log_requests_verbose: boolean; - - /** @default "info" */ - log_level: "info" | "debug" | "warning" | "error" | "critical"; - - /** @default false */ - log_ip: boolean; - - /** @default true */ - log_filters: boolean; - - storage: { - /** @default "logs/requests.log" */ - requests: string; - }; - }; - - ratelimits: { - /** @default 1 */ - duration_coeff: number; - - /** @default 1 */ - max_coeff: number; - }; - - /** @default {} */ - custom_ratelimits: Record< - string, - { - /** @default 30 */ - duration: number; - - /** @default 60 */ - max: number; - } - >; -} - -export const defaultConfig: Config = { - database: { - host: "localhost", - port: 5432, - username: "lysand", - password: "lysand", - database: "lysand", - }, - redis: { - queue: { - host: "localhost", - port: 6379, +export const configValidator = z.object({ + database: z.object({ + host: z.string().min(1).default("localhost"), + port: z + .number() + .int() + .min(1) + .max(2 ** 16 - 1) + .default(5432), + username: z.string().min(1), + password: z.string().default(""), + database: z.string().min(1).default("lysand"), + }), + redis: z.object({ + queue: z + .object({ + host: z.string().min(1).default("localhost"), + port: z + .number() + .int() + .min(1) + .max(2 ** 16 - 1) + .default(6379), + password: z.string().default(""), + database: z.number().int().default(0), + enabled: z.boolean().default(false), + }) + .default({ + host: "localhost", + port: 6379, + password: "", + database: 0, + enabled: false, + }), + cache: z + .object({ + host: z.string().min(1).default("localhost"), + port: z + .number() + .int() + .min(1) + .max(2 ** 16 - 1) + .default(6379), + password: z.string().default(""), + database: z.number().int().default(1), + enabled: z.boolean().default(false), + }) + .default({ + host: "localhost", + port: 6379, + password: "", + database: 1, + enabled: false, + }), + }), + meilisearch: z.object({ + host: z.string().min(1).default("localhost"), + port: z + .number() + .int() + .min(1) + .max(2 ** 16 - 1) + .default(7700), + api_key: z.string().min(1), + enabled: z.boolean().default(false), + }), + signups: z.object({ + tos_url: z.string().min(1).optional(), + registration: z.boolean().default(true), + rules: z.array(z.string()).default([]), + }), + oidc: z.object({ + providers: z + .array( + z.object({ + name: z.string().min(1), + id: z.string().min(1), + url: z.string().min(1), + client_id: z.string().min(1), + client_secret: z.string().min(1), + icon: z.string().min(1).optional(), + }), + ) + .default([]), + jwt_key: z.string().min(3).includes(";").default("").optional(), + }), + http: z.object({ + base_url: z.string().min(1).default("http://lysand.social"), + bind: z.string().min(1).default("0.0.0.0"), + bind_port: z + .number() + .int() + .min(1) + .max(2 ** 16 - 1) + .default(8080), + // Not using .ip() because we allow CIDR ranges and wildcards and such + banned_ips: z.array(z.string()).default([]), + banned_user_agents: z.array(z.string()).default([]), + tls: z.object({ + enabled: z.boolean().default(false), + key: z.string(), + cert: z.string(), + passphrase: z.string().optional(), + ca: z.string().optional(), + }), + bait: z.object({ + enabled: z.boolean().default(false), + send_file: z.string().optional(), + bait_ips: z.array(z.string()).default([]), + bait_user_agents: z.array(z.string()).default([]), + }), + }), + frontend: z + .object({ + enabled: z.boolean().default(true), + url: z.string().min(1).url().default("http://localhost:3000"), + glitch: z + .object({ + enabled: z.boolean().default(false), + assets: z.string().min(1).default("glitch"), + server: z.array(z.string().url().min(1)).default([]), + }) + .default({ + enabled: false, + assets: "glitch", + server: [], + }), + settings: z.record(z.string(), z.any()).default({}), + }) + .default({ + enabled: true, + url: "http://localhost:3000", + glitch: { + enabled: false, + assets: "glitch", + server: [], + }, + settings: {}, + }), + smtp: z + .object({ + server: z.string().min(1), + port: z + .number() + .int() + .min(1) + .max(2 ** 16 - 1) + .default(465), + username: z.string().min(1), + password: z.string().min(1).optional(), + tls: z.boolean().default(true), + enabled: z.boolean().default(false), + }) + .default({ + server: "", + port: 465, + username: "", password: "", - database: 0, - }, - cache: { - host: "localhost", - port: 6379, - password: "", - database: 1, + tls: true, enabled: false, - }, - }, - meilisearch: { - host: "localhost", - port: 7700, - api_key: "______________________________", - enabled: false, - }, - signups: { - tos_url: "https://my-site.com/tos", - registration: true, - rules: [], - }, - oidc: { - providers: [], - jwt_key: "", - }, - http: { - base_url: "https://lysand.social", - bind: "0.0.0.0", - bind_port: "8080", - banned_ips: [], - banned_user_agents: [], - tls: { - enabled: false, - key: "", - cert: "", - passphrase: "", - ca: "", - }, - bait: { - enabled: false, - send_file: "", - bait_ips: ["127.0.0.1", "::1"], - bait_user_agents: ["curl", "wget"], - }, - }, - frontend: { - enabled: true, - url: "http://localhost:3000", - glitch: { - enabled: false, - assets: "glitch", - server: [], - }, - }, - smtp: { - server: "smtp.example.com", - port: 465, - username: "test@example.com", - password: "____________", - tls: true, - enabled: false, - }, - media: { - backend: MediaBackendType.LOCAL, - deduplicate_media: true, - local_uploads_folder: "uploads", - conversion: { - convert_images: false, - convert_to: "image/webp", - }, - }, - s3: { - endpoint: "myhostname.banana.com", - access_key: "_____________", - secret_access_key: "_________________", - region: "", - bucket_name: "lysand", - public_url: "https://cdn.test.com", - }, - email: { - send_on_report: false, - send_on_suspend: false, - send_on_unsuspend: false, - verify_email: false, - }, - validation: { - max_displayname_size: 50, - max_bio_size: 160, - max_note_size: 5000, - max_avatar_size: 5000000, - max_header_size: 5000000, - max_media_size: 40000000, - 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, - max_field_count: 10, - max_field_name_size: 1000, - max_field_value_size: 1000, - 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", - "gemini", - ], - enforce_mime_types: false, - allowed_mime_types: [ - "image/jpeg", - "image/png", - "image/gif", - "image/heic", - "image/heif", - "image/webp", - "image/avif", - "video/webm", - "video/mp4", - "video/quicktime", - "video/ogg", - "audio/wave", - "audio/wav", - "audio/x-wav", - "audio/x-pn-wave", - "audio/vnd.wave", - "audio/ogg", - "audio/vorbis", - "audio/mpeg", - "audio/mp3", - "audio/webm", - "audio/flac", - "audio/aac", - "audio/m4a", - "audio/x-m4a", - "audio/mp4", - "audio/3gpp", - "video/x-ms-asf", - ], - }, - defaults: { - visibility: "public", - language: "en", - avatar: "", - header: "", - placeholder_style: "thumbs", - }, - federation: { - blocked: [], - followers_only: [], - discard: { - reports: [], - deletes: [], - updates: [], - media: [], - follows: [], - likes: [], - reactions: [], - banners: [], - avatars: [], - }, - }, - instance: { - name: "Lysand", - description: "A test instance of Lysand", - extended_description_path: "", - logo: "", - banner: "", - }, - filters: { - note_content: [], - emoji: [], - username: [], - displayname: [], - bio: [], - }, - logging: { - log_requests: false, - log_requests_verbose: false, - log_level: "info", - log_ip: false, - log_filters: true, - storage: { - requests: "logs/requests.log", - }, - }, - ratelimits: { - duration_coeff: 1, - max_coeff: 1, - }, - custom_ratelimits: {}, -}; + }), + media: z + .object({ + backend: z + .nativeEnum(MediaBackendType) + .default(MediaBackendType.LOCAL), + deduplicate_media: z.boolean().default(true), + local_uploads_folder: z.string().min(1).default("uploads"), + conversion: z + .object({ + convert_images: z.boolean().default(false), + convert_to: z.string().default("image/webp"), + }) + .default({ + convert_images: false, + convert_to: "image/webp", + }), + }) + .default({ + backend: MediaBackendType.LOCAL, + deduplicate_media: true, + local_uploads_folder: "uploads", + conversion: { + convert_images: false, + convert_to: "image/webp", + }, + }), + s3: z + .object({ + endpoint: z.string().min(1), + access_key: z.string().min(1), + secret_access_key: z.string().min(1), + region: z.string().optional(), + bucket_name: z.string().min(1).default("lysand"), + public_url: z.string().min(1).url(), + }) + .optional(), + validation: z + .object({ + max_displayname_size: z.number().int().default(50), + max_bio_size: z.number().int().default(160), + max_note_size: z.number().int().default(5000), + max_avatar_size: z.number().int().default(5000000), + max_header_size: z.number().int().default(5000000), + max_media_size: z.number().int().default(40000000), + max_media_attachments: z.number().int().default(10), + max_media_description_size: z.number().int().default(1000), + max_poll_options: z.number().int().default(20), + max_poll_option_size: z.number().int().default(500), + min_poll_duration: z.number().int().default(60), + max_poll_duration: z.number().int().default(1893456000), + max_username_size: z.number().int().default(30), + max_field_count: z.number().int().default(10), + max_field_name_size: z.number().int().default(1000), + max_field_value_size: z.number().int().default(1000), + username_blacklist: z + .array(z.string()) + .default([ + ".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: z.boolean().default(false), + email_blacklist: z.array(z.string()).default([]), + url_scheme_whitelist: z + .array(z.string()) + .default([ + "http", + "https", + "ftp", + "dat", + "dweb", + "gopher", + "hyper", + "ipfs", + "ipns", + "irc", + "xmpp", + "ircs", + "magnet", + "mailto", + "mumble", + "ssb", + "gemini", + ]), + enforce_mime_types: z.boolean().default(false), + allowed_mime_types: z + .array(z.string()) + .default(Object.values(mimeTypes)), + }) + .default({ + max_displayname_size: 50, + max_bio_size: 160, + max_note_size: 5000, + max_avatar_size: 5000000, + max_header_size: 5000000, + max_media_size: 40000000, + 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, + max_field_count: 10, + max_field_name_size: 1000, + max_field_value_size: 1000, + 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", + "gemini", + ], + enforce_mime_types: false, + allowed_mime_types: Object.values(mimeTypes), + }), + defaults: z + .object({ + visibility: z.string().default("public"), + language: z.string().default("en"), + avatar: z.string().url().optional(), + header: z.string().url().optional(), + placeholder_style: z.string().default("thumbs"), + }) + .default({ + visibility: "public", + language: "en", + avatar: undefined, + header: undefined, + placeholder_style: "thumbs", + }), + federation: z + .object({ + blocked: z.array(z.string().url()).default([]), + followers_only: z.array(z.string().url()).default([]), + discard: z.object({ + reports: z.array(z.string().url()).default([]), + deletes: z.array(z.string().url()).default([]), + updates: z.array(z.string().url()).default([]), + media: z.array(z.string().url()).default([]), + follows: z.array(z.string().url()).default([]), + likes: z.array(z.string().url()).default([]), + reactions: z.array(z.string().url()).default([]), + banners: z.array(z.string().url()).default([]), + avatars: z.array(z.string().url()).default([]), + }), + }) + .default({ + blocked: [], + followers_only: [], + discard: { + reports: [], + deletes: [], + updates: [], + media: [], + follows: [], + likes: [], + reactions: [], + banners: [], + avatars: [], + }, + }), + instance: z + .object({ + name: z.string().min(1).default("Lysand"), + description: z.string().min(1).default("A Lysand instance"), + extended_description_path: z.string().optional(), + logo: z.string().url().optional(), + banner: z.string().url().optional(), + }) + .default({ + name: "Lysand", + description: "A Lysand instance", + extended_description_path: undefined, + logo: undefined, + banner: undefined, + }), + filters: z.object({ + note_content: z.array(z.string()).default([]), + emoji: z.array(z.string()).default([]), + username: z.array(z.string()).default([]), + displayname: z.array(z.string()).default([]), + bio: z.array(z.string()).default([]), + }), + logging: z + .object({ + log_requests: z.boolean().default(false), + log_requests_verbose: z.boolean().default(false), + log_level: z + .enum(["debug", "info", "warning", "error", "critical"]) + .default("info"), + log_ip: z.boolean().default(false), + log_filters: z.boolean().default(true), + storage: z.object({ + requests: z.string().default("logs/requests.log"), + }), + }) + .default({ + log_requests: false, + log_requests_verbose: false, + log_level: "info", + log_ip: false, + log_filters: true, + storage: { + requests: "logs/requests.log", + }, + }), + ratelimits: z.object({ + duration_coeff: z.number().default(1), + max_coeff: z.number().default(1), + custom: z + .record( + z.string(), + z.object({ + duration: z.number().default(30), + max: z.number().default(60), + }), + ) + .default({}), + }), +}); + +export type Config = z.infer; diff --git a/packages/config-manager/index.ts b/packages/config-manager/index.ts index 5458a863..99611f7d 100644 --- a/packages/config-manager/index.ts +++ b/packages/config-manager/index.ts @@ -5,22 +5,43 @@ * Fuses both and provides a way to retrieve individual values */ -import { watchConfig } from "c12"; -import { type Config, defaultConfig } from "./config.type"; +import { watchConfig, loadConfig } from "c12"; +import { configValidator, type Config } from "./config.type"; +import { fromError } from "zod-validation-error"; +import chalk from "chalk"; -const { config } = await watchConfig({ +const { config } = await watchConfig({ configFile: "./config/config.toml", - defaultConfig: defaultConfig, overrides: ( - await watchConfig({ + await loadConfig({ configFile: "./config/config.internal.toml", - defaultConfig: {} as Config, }) ).config ?? undefined, }); -const exportedConfig = config ?? defaultConfig; +const parsed = await configValidator.safeParseAsync(config); + +if (!parsed.success) { + console.log( + `${chalk.bgRed.white( + " CRITICAL ", + )} There was an error parsing the config file at ${chalk.bold( + "./config/config.toml", + )}. Please fix the file and try again.`, + ); + console.log( + `${chalk.bgRed.white( + " CRITICAL ", + )} Follow the installation intructions and get a sample config file from the repository if needed.`, + ); + console.log( + `${chalk.bgRed.white(" CRITICAL ")} ${fromError(parsed.error).message}`, + ); + process.exit(1); +} + +const exportedConfig = parsed.data; export { exportedConfig as config }; export type { Config }; diff --git a/packages/config-manager/package.json b/packages/config-manager/package.json index 28fbe8c5..a7d9a2db 100644 --- a/packages/config-manager/package.json +++ b/packages/config-manager/package.json @@ -4,6 +4,8 @@ "main": "index.ts", "type": "module", "dependencies": { - "c12": "^1.10.0" + "c12": "^1.10.0", + "zod": "^3.23.8", + "zod-validation-error": "^3.3.0" } } diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts index 122a4358..e514f6d1 100644 --- a/packages/database-interface/user.ts +++ b/packages/database-interface/user.ts @@ -365,8 +365,8 @@ export class User { : await Bun.password.hash(data.password), email: data.email, note: data.bio ?? "", - avatar: data.avatar ?? config.defaults.avatar, - header: data.header ?? config.defaults.avatar, + avatar: data.avatar ?? config.defaults.avatar ?? "", + header: data.header ?? config.defaults.avatar ?? "", isAdmin: data.admin ?? false, publicKey: keys.public_key, fields: [], @@ -399,7 +399,7 @@ export class User { * @returns The raw URL for the user's header */ getHeaderUrl(config: Config) { - if (!this.user.header) return config.defaults.header; + if (!this.user.header) return config.defaults.header || ""; return this.user.header; } diff --git a/utils/api.ts b/utils/api.ts index 2ac5b8ad..ef5d0bc2 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -31,8 +31,8 @@ export const applyConfig = (routeMeta: APIRouteMetadata) => { newMeta.ratelimits.duration *= config.ratelimits.duration_coeff; newMeta.ratelimits.max *= config.ratelimits.max_coeff; - if (config.custom_ratelimits[routeMeta.route]) { - newMeta.ratelimits = config.custom_ratelimits[routeMeta.route]; + if (config.ratelimits.custom[routeMeta.route]) { + newMeta.ratelimits = config.ratelimits.custom[routeMeta.route]; } return newMeta;