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 94e2d483..0ccf0537 100755 Binary files a/bun.lockb and b/bun.lockb differ 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;