refactor(config): ♻️ Redo config structure from scratch, simplify validation code, improve checks, add support for loading sensitive data from paths

This commit is contained in:
Jesse Wierzbinski 2025-02-15 02:47:29 +01:00
parent d4afd84019
commit 54fd81f076
No known key found for this signature in database
118 changed files with 3892 additions and 5291 deletions

View file

@ -28,7 +28,7 @@ import { type ParsedQs, parse } from "qs";
import { fromZodError } from "zod-validation-error";
import { ApiError } from "~/classes/errors/api-error";
import type { AuthData } from "~/classes/functions/user";
import { config } from "~/packages/config-manager/index.ts";
import { config } from "~/config.ts";
import { ErrorSchema, type HonoEnv } from "~/types/api";
export const reusedResponses = {
@ -229,7 +229,7 @@ export const checkRouteNeedsChallenge = async (
required: boolean,
context: Context,
): Promise<void> => {
if (!required) {
if (!(required && config.validation.challenges)) {
return;
}
@ -325,7 +325,7 @@ export const auth = <AuthRequired extends boolean>(options: {
}
// Challenge check
if (options.challenge && config.validation.challenges.enabled) {
if (options.challenge && config.validation.challenges) {
await checkRouteNeedsChallenge(options.challenge, context);
}
@ -573,7 +573,7 @@ export const debugRequest = async (req: Request): Promise<void> => {
const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`;
if (config.logging.log_requests_verbose) {
if (config.logging.types.requests_content) {
logger.debug`${urlAndMethod}\n${hash}\n${headers}\n${bodyLog}`;
} else {
logger.debug`${urlAndMethod}`;
@ -594,7 +594,7 @@ export const debugResponse = async (res: Response): Promise<void> => {
const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`;
if (config.logging.log_requests_verbose) {
if (config.logging.types.requests_content) {
logger.debug`${status}\n${headers}\n${bodyLog}`;
} else {
logger.debug`${status}`;

View file

@ -8,7 +8,8 @@ import { fetchQueue } from "~/classes/queues/fetch";
import { inboxQueue } from "~/classes/queues/inbox";
import { mediaQueue } from "~/classes/queues/media";
import { pushQueue } from "~/classes/queues/push";
import { config } from "~/packages/config-manager";
import { config } from "~/config.ts";
import pkg from "~/package.json";
import type { HonoEnv } from "~/types/api";
export const applyToHono = (app: OpenAPIHono<HonoEnv>): void => {
@ -31,9 +32,7 @@ export const applyToHono = (app: OpenAPIHono<HonoEnv>): void => {
alternative: "/favicon.ico",
},
boardLogo: {
path:
config.instance.logo?.toString() ??
"https://cdn.versia.pub/branding/icon.svg",
path: config.instance.branding.logo?.origin ?? pkg.icon,
height: 40,
},
},

View file

@ -3,16 +3,20 @@ import { Challenges } from "@versia/kit/tables";
import { createChallenge } from "altcha-lib";
import type { Challenge } from "altcha-lib/types";
import { sql } from "drizzle-orm";
import { config } from "~/packages/config-manager";
import { config } from "~/config.ts";
export const generateChallenge = async (
maxNumber = config.validation.challenges.difficulty,
maxNumber?: number,
): Promise<{
id: string;
challenge: Challenge;
expiresAt: string;
createdAt: string;
}> => {
if (!config.validation.challenges) {
throw new Error("Challenges are not enabled");
}
const expirationDate = new Date(
Date.now() + config.validation.challenges.expiration * 1000,
);
@ -23,7 +27,7 @@ export const generateChallenge = async (
const challenge = await createChallenge({
hmacKey: config.validation.challenges.key,
expires: expirationDate,
maxNumber,
maxNumber: maxNumber ?? config.validation.challenges.difficulty,
algorithm: "SHA-256",
params: {
challenge_id: uuid,

View file

@ -1,4 +1,4 @@
import { config } from "~/packages/config-manager/index.ts";
import { config } from "~/config.ts";
export const localObjectUri = (id: string): URL =>
new URL(`/objects/${id}`, config.http.base_url);

View file

@ -1,7 +1,7 @@
import type { ContentFormat } from "@versia/federation/types";
import { htmlToText as htmlToTextLib } from "html-to-text";
import { lookup } from "mime-types";
import { config } from "~/packages/config-manager";
import { config } from "~/config.ts";
export const getBestContentType = (
content?: ContentFormat | null,
@ -67,7 +67,7 @@ export const mimeLookup = (url: URL): Promise<string> => {
const fetchLookup = fetch(url, {
method: "HEAD",
// @ts-expect-error Proxy is a Bun-specific feature
proxy: config.http.proxy.address,
proxy: config.http.proxy_address,
})
.then(
(response) =>

View file

@ -1,152 +0,0 @@
import { getLogger } from "@logtape/logtape";
import { User } from "@versia/kit/db";
import chalk from "chalk";
import { generateVAPIDKeys } from "web-push";
import type { Config } from "~/packages/config-manager";
export const checkConfig = async (config: Config): Promise<void> => {
await checkFederationConfig(config);
await checkHttpProxyConfig(config);
await checkChallengeConfig(config);
await checkVapidConfig(config);
};
const checkHttpProxyConfig = async (config: Config): Promise<void> => {
const logger = getLogger("server");
if (config.http.proxy.enabled) {
logger.info`HTTP proxy enabled at ${chalk.gray(config.http.proxy.address)}, testing...`;
// Test the proxy
const response = await fetch("https://api.ipify.org?format=json", {
// @ts-expect-error Proxy is a Bun-specific feature
proxy: config.http.proxy.address,
});
const ip = (await response.json()).ip;
logger.info`Your IPv4 address is ${chalk.gray(ip)}`;
if (!response.ok) {
throw new Error(
"The HTTP proxy is enabled, but the proxy address is not reachable",
);
}
}
};
const checkChallengeConfig = async (config: Config): Promise<void> => {
const logger = getLogger("server");
if (
config.validation.challenges.enabled &&
!config.validation.challenges.key
) {
logger.fatal`Challenges are enabled, but the challenge key is not set in the config`;
logger.fatal`Below is a generated key for you to copy in the config at validation.challenges.key`;
const key = await crypto.subtle.generateKey(
{
name: "HMAC",
hash: "SHA-256",
},
true,
["sign"],
);
const exported = await crypto.subtle.exportKey("raw", key);
const base64 = Buffer.from(exported).toString("base64");
logger.fatal`Generated key: ${chalk.gray(base64)}`;
// Hang until Ctrl+C is pressed
await Bun.sleep(Number.POSITIVE_INFINITY);
}
};
const checkFederationConfig = async (config: Config): Promise<void> => {
const logger = getLogger("server");
if (!(config.instance.keys.public && config.instance.keys.private)) {
logger.fatal`The federation keys are not set in the config`;
logger.fatal`Below are generated keys for you to copy in the config at instance.keys.public and instance.keys.private`;
// Generate a key for them
const { public_key, private_key } = await User.generateKeys();
logger.fatal`Generated public key: ${chalk.gray(public_key)}`;
logger.fatal`Generated private key: ${chalk.gray(private_key)}`;
// Hang until Ctrl+C is pressed
await Bun.sleep(Number.POSITIVE_INFINITY);
}
// Try and import the key
const privateKey = await crypto.subtle
.importKey(
"pkcs8",
Buffer.from(config.instance.keys.private, "base64"),
"Ed25519",
false,
["sign"],
)
.catch((e) => e as Error);
// Try and import the key
const publicKey = await crypto.subtle
.importKey(
"spki",
Buffer.from(config.instance.keys.public, "base64"),
"Ed25519",
false,
["verify"],
)
.catch((e) => e as Error);
if (privateKey instanceof Error || publicKey instanceof Error) {
throw new Error(
"The federation keys could not be imported! You may generate new ones by removing the old ones from the config and restarting the server.",
);
}
};
const checkVapidConfig = async (config: Config): Promise<void> => {
const logger = getLogger("server");
if (
config.notifications.push.enabled &&
!(
config.notifications.push.vapid.public ||
config.notifications.push.vapid.private
)
) {
logger.fatal`The VAPID keys are not set in the config, but push notifications are enabled.`;
logger.fatal`Below are generated keys for you to copy in the config at notifications.push.vapid`;
const { privateKey, publicKey } = await generateVAPIDKeys();
logger.fatal`Generated public key: ${chalk.gray(publicKey)}`;
logger.fatal`Generated private key: ${chalk.gray(privateKey)}`;
// Hang until Ctrl+C is pressed
await Bun.sleep(Number.POSITIVE_INFINITY);
}
// These use a format I don't understand, so I'm just going to check the length
const validateKey = (key: string): boolean => key.length > 10;
if (
!(
validateKey(config.notifications.push.vapid.public) &&
validateKey(config.notifications.push.vapid.private)
)
) {
throw new Error(
"The VAPID keys could not be imported! You may generate new ones by removing the old ones from the config and restarting the server.",
);
}
};

View file

@ -19,7 +19,7 @@ import {
getLevelFilter,
} from "@logtape/logtape";
import chalk from "chalk";
import { config } from "~/packages/config-manager";
import { config } from "~/config.ts";
// HACK: This is a workaround for the lack of type exports in the Logtape package.
// biome-ignore format: Biome formatter bug
@ -156,7 +156,7 @@ export const configureLoggers = (silent = false): Promise<void> =>
console: getConsoleSink({
formatter: defaultConsoleFormatter,
}),
file: getBaseRotatingFileSink(config.logging.storage.requests, {
file: getBaseRotatingFileSink(config.logging.log_file_path, {
maxFiles: 10,
maxSize: 10 * 1024 * 1024,
formatter: defaultTextFormatter,

View file

@ -1,35 +0,0 @@
import { getLogger } from "@logtape/logtape";
import { markdownParse } from "~/classes/functions/status";
import { sentry } from "./sentry.ts";
export const renderMarkdownInPath = async (
path: string,
defaultText?: string,
): Promise<{
content: string;
lastModified: Date;
}> => {
let content = await markdownParse(defaultText ?? "");
let lastModified = new Date(1970, 0, 0);
const extendedDescriptionFile = Bun.file(path || "");
if (path && (await extendedDescriptionFile.exists())) {
content =
(await markdownParse(
(await extendedDescriptionFile.text().catch(async (e) => {
await getLogger("server").error`${e}`;
sentry?.captureException(e);
return "";
})) ||
defaultText ||
"",
)) || "";
lastModified = new Date(extendedDescriptionFile.lastModified);
}
return {
content,
lastModified,
};
};

View file

@ -1,5 +1,5 @@
import IORedis from "ioredis";
import { config } from "~/packages/config-manager/index.ts";
import { config } from "~/config.ts";
export const connection = new IORedis({
host: config.redis.queue.host,

View file

@ -1,4 +1,4 @@
import { config } from "~/packages/config-manager";
import { config } from "~/config.ts";
export type Json =
| string

View file

@ -1,11 +1,11 @@
import * as Sentry from "@sentry/bun";
import { config } from "~/config.ts";
import pkg from "~/package.json";
import { config } from "~/packages/config-manager/index.ts";
const sentryInstance =
config.logging.sentry.enabled &&
config.logging.sentry &&
Sentry.init({
dsn: config.logging.sentry.dsn,
dsn: config.logging.sentry.dsn.origin,
debug: config.logging.sentry.debug,
sampleRate: config.logging.sentry.sample_rate,
maxBreadcrumbs: config.logging.sentry.max_breadcrumbs,

View file

@ -1,31 +1,29 @@
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { OpenAPIHono, z } from "@hono/zod-openapi";
import type { Server } from "bun";
import type { Config } from "~/packages/config-manager/config.type";
import type { ConfigSchema } from "~/classes/config/schema.ts";
import type { HonoEnv } from "~/types/api";
import { debugResponse } from "./api.ts";
export const createServer = (
config: Config,
config: z.infer<typeof ConfigSchema>,
app: OpenAPIHono<HonoEnv>,
): Server =>
Bun.serve({
port: config.http.bind_port,
reusePort: true,
tls: config.http.tls.enabled
tls: config.http.tls
? {
key: Bun.file(config.http.tls.key),
cert: Bun.file(config.http.tls.cert),
key: config.http.tls.key.file,
cert: config.http.tls.cert.file,
passphrase: config.http.tls.passphrase,
ca: config.http.tls.ca
? Bun.file(config.http.tls.ca)
: undefined,
ca: config.http.tls.ca?.file,
}
: undefined,
hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0"
hostname: config.http.bind,
async fetch(req, server): Promise<Response> {
const output = await app.fetch(req, { ip: server.requestIP(req) });
if (config.logging.log_responses) {
if (config.logging.types.responses) {
await debugResponse(output.clone());
}