feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -1,15 +0,0 @@
import { mysqlTable, primaryKey, text, varchar } from "drizzle-orm/mysql-core";
export const trustedCertificateHostnames = mysqlTable(
"trusted_certificate_hostname",
{
hostname: varchar({ length: 256 }).notNull(),
thumbprint: varchar({ length: 128 }).notNull(),
certificate: text().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.hostname, table.thumbprint],
}),
}),
);
@@ -1,10 +1,8 @@
import { createSchema } from "../../../db";
import * as mysql from "./mysql";
import * as postgresql from "./postgresql";
import * as sqlite from "./sqlite";
export const schema = createSchema({
"better-sqlite3": () => sqlite,
mysql2: () => mysql,
"node-postgres": () => postgresql,
});
@@ -1,6 +1,5 @@
import { DB_CASING } from "../constants";
import { createDbMapping } from "../mapping";
import { createMysqlDb } from "./mysql";
import { createPostgresDb } from "./postgresql";
import type { SharedDrizzleConfig } from "./shared";
import { WinstonDrizzleLogger } from "./shared";
@@ -20,7 +19,6 @@ export const createDb = <TSchema extends Record<string, unknown>>(schema: TSchem
const config = createSharedConfig(schema);
return createDbMapping({
mysql2: () => createMysqlDb(config),
"node-postgres": () => createPostgresDb(config),
"better-sqlite3": () => createSqliteDb(config),
});
@@ -1,35 +0,0 @@
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2";
import type { PoolOptions } from "mysql2";
import { dbEnv } from "../env";
import type { SharedDrizzleConfig } from "./shared";
export const createMysqlDb = <TSchema extends Record<string, unknown>>(config: SharedDrizzleConfig<TSchema>) => {
const connection = createMysqlDbConnection();
return drizzle<TSchema>(connection, {
...config,
mode: "default",
});
};
const createMysqlDbConnection = () => {
const defaultOptions = {
maxIdle: 0,
idleTimeout: 60000,
enableKeepAlive: true,
} satisfies Partial<PoolOptions>;
if (!dbEnv.HOST) {
return mysql.createPool({ ...defaultOptions, uri: dbEnv.URL });
}
return mysql.createPool({
...defaultOptions,
host: dbEnv.HOST,
port: dbEnv.PORT,
database: dbEnv.NAME,
user: dbEnv.USER,
password: dbEnv.PASSWORD,
});
};
@@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("database driver configuration", () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv("SKIP_ENV_VALIDATION", "true");
vi.stubEnv("DB_DRIVER", "better-sqlite3");
vi.stubEnv("DB_DIALECT", "sqlite");
vi.stubEnv("DB_URL", ":memory:");
});
afterEach(() => vi.unstubAllEnvs());
it.each(["mysql", "mariadb"])("rejects retired dialect %s before SQLite fallback", async (dialect) => {
vi.stubEnv("DB_DIALECT", dialect);
await expect(import("./env")).rejects.toThrow("MySQL and MariaDB are no longer supported");
});
it.each(["mysql2", "mysql", "mariadb"])(
"rejects retired driver %s even when validation is skipped",
async (driver) => {
vi.stubEnv("DB_DRIVER", driver);
await expect(import("./env")).rejects.toThrow("MySQL and MariaDB are no longer supported");
},
);
it.each(["mysql://localhost/homarr", "mariadb://localhost/homarr"])(
"rejects retired database URL %s",
async (url) => {
vi.stubEnv("DB_URL", url);
await expect(import("./env")).rejects.toThrow("Migrate your database to SQLite or PostgreSQL");
},
);
it.each(["better-sqlite3", "node-postgres"])("preserves supported driver %s", async (driver) => {
vi.stubEnv("DB_DRIVER", driver);
const { dbEnv } = await import("./env");
expect(dbEnv.DRIVER).toBe(driver);
});
});
+13 -4
View File
@@ -2,9 +2,18 @@ import { z } from "zod/v4";
import { createEnv, runtimeEnvWithPrefix } from "@homarr/core/infrastructure/env";
if (
["mysql", "mariadb"].includes(process.env.DB_DIALECT ?? "") ||
["mysql2", "mysql", "mariadb"].includes(process.env.DB_DRIVER ?? "") ||
/^(mysql|mariadb):/i.test(process.env.DB_URL ?? "")
) {
throw new Error(
"MySQL and MariaDB are no longer supported. Migrate your database to SQLite or PostgreSQL before starting Homarr.",
);
}
const drivers = {
betterSqlite3: "better-sqlite3",
mysql2: "mysql2",
nodePostgres: "node-postgres",
} as const;
@@ -21,8 +30,8 @@ export const dbEnv = createEnv({
*/
server: {
DRIVER: z
.union([z.literal(drivers.betterSqlite3), z.literal(drivers.mysql2), z.literal(drivers.nodePostgres)], {
message: `Invalid database driver, supported are ${Object.keys(drivers).join(", ")}`,
.union([z.literal(drivers.betterSqlite3), z.literal(drivers.nodePostgres)], {
message: `Invalid database driver, supported are ${Object.values(drivers).join(", ")}`,
})
.default(drivers.betterSqlite3),
...(urlRequired
@@ -42,7 +51,7 @@ export const dbEnv = createEnv({
.regex(/\d+/)
.transform(Number)
.refine((number) => number >= 1)
.default(isDriver(drivers.mysql2) ? 3306 : 5432),
.default(5432),
USER: z.string(),
PASSWORD: z.string(),
NAME: z.string(),
@@ -3,7 +3,6 @@ import { createDbMapping } from "./mapping";
export { createDb } from "./drivers";
export const createSchema = createDbMapping;
export { createMysqlDb } from "./drivers/mysql";
export { createSqliteDb } from "./drivers/sqlite";
export { createPostgresDb } from "./drivers/postgresql";
export { createSharedConfig as createSharedDbConfig } from "./drivers";
@@ -6,3 +6,5 @@ export {
createHttpsAgentAsync,
fetchWithTrustedCertificatesAsync,
} from "./request";
export { withHttpRequestSignalAsync, getHttpRequestSignal } from "./request-signal";
@@ -0,0 +1,16 @@
import { AsyncLocalStorage } from "node:async_hooks";
// Scope a deadline to one operation without changing unrelated users of shared clients.
const requestSignal = new AsyncLocalStorage<AbortSignal>();
export const withHttpRequestSignalAsync = async <T>(signal: AbortSignal, operation: () => Promise<T>) => {
signal.throwIfAborted();
return await requestSignal.run(signal, operation);
};
export const getHttpRequestSignal = (signal?: AbortSignal | null) => {
const scoped = requestSignal.getStore();
if (!scoped) return signal ?? undefined;
if (!signal || signal === scoped) return scoped;
return AbortSignal.any([scoped, signal]);
};
@@ -1,8 +1,11 @@
import { createHash } from "node:crypto";
import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https";
import { checkServerIdentity } from "node:tls";
import type { LookupFunction } from "node:net";
import { checkServerIdentity, createSecureContext } from "node:tls";
import type { SecureContext } from "node:tls";
import axios from "axios";
import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
import type { EnvHttpProxyAgent as UndiciEnvHttpProxyAgent, RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici";
import {
@@ -12,7 +15,32 @@ import {
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
import type { TrustedCertificateHostname } from "../certificates/hostnames";
import { getHttpRequestSignal } from "./request-signal";
import { withTimeoutAsync } from "./timeout";
// Parsing the entire CA store for every parallel connection adds synchronous
// work to the rendering thread. Reuse only the TLS context, never auth or data.
const tlsContexts = new Map<string, SecureContext>();
const getTlsContext = (ca: NonNullable<AgentOptions["ca"]>) => {
const hash = createHash("sha256");
for (const certificate of Array.isArray(ca) ? ca : [ca]) {
hash
.update(String(Buffer.byteLength(certificate)))
.update(":")
.update(certificate);
}
const identity = hash.digest("hex");
const existing = tlsContexts.get(identity);
if (existing) return existing;
const context = createSecureContext({ ca });
if (tlsContexts.size >= 4) {
const oldest = tlsContexts.keys().next().value;
if (oldest !== undefined) tlsContexts.delete(oldest);
}
tlsContexts.set(identity, context);
return context;
};
export const createCustomCheckServerIdentity = (
trustedHostnames: TrustedCertificateHostname[],
): typeof checkServerIdentity => {
@@ -29,24 +57,34 @@ export const createCustomCheckServerIdentity = (
};
export const createCertificateAgentAsync = async (
override?: {
override?: Partial<{
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
},
agentOptions?: Pick<UndiciAgent.Options, "bodyTimeout">,
lookup: LookupFunction;
}>,
agentOptions?: Pick<
UndiciEnvHttpProxyAgent.Options,
"autoSelectFamily" | "bodyTimeout" | "httpProxy" | "httpsProxy" | "noProxy"
>,
) => {
const ca = override?.ca ?? (await getAllTrustedCertificatesAsync());
const identityCheck =
override?.checkServerIdentity ?? createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync());
return new UndiciHttpAgent({
...agentOptions,
connect: override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
connect: {
secureContext: getTlsContext(ca),
checkServerIdentity: identityCheck,
...(override?.lookup ? { lookup: override.lookup } : {}),
},
});
};
export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" | "checkServerIdentity">) => {
const ca = override?.ca ?? (await getAllTrustedCertificatesAsync());
return new HttpsAgent({
ca: await getAllTrustedCertificatesAsync(),
ca,
secureContext: getTlsContext(ca),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
// Override the ca and checkServerIdentity if provided
...override,
@@ -57,15 +95,24 @@ export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" |
export const createAxiosCertificateInstanceAsync = async (
override?: Pick<AgentOptions, "ca" | "checkServerIdentity">,
) => {
return axios.create({
const client = axios.create({
httpsAgent: await createHttpsAgentAsync(override),
});
client.interceptors.request.use((config) => {
const signal = getHttpRequestSignal(config.signal as AbortSignal | undefined);
signal?.throwIfAborted();
config.signal = signal;
return config;
});
return client;
};
export const fetchWithTrustedCertificatesAsync = async (
url: RequestInfo,
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
): Promise<Response> => {
const requestSignal = getHttpRequestSignal(options?.signal);
requestSignal?.throwIfAborted();
const agent =
options?.dispatcher ??
(await createCertificateAgentAsync(
@@ -78,7 +125,7 @@ export const fetchWithTrustedCertificatesAsync = async (
async (signal) =>
fetch(url, {
...fetchOptions,
signal,
signal: AbortSignal.any([signal, requestSignal ?? signal]),
dispatcher: agent,
}),
options.timeout,
@@ -88,6 +135,7 @@ export const fetchWithTrustedCertificatesAsync = async (
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
return fetch(url, {
...fetchOptions,
signal: requestSignal,
dispatcher: agent,
});
};
@@ -1,6 +1,20 @@
import z from "zod/v4";
export const logLevels = ["error", "warn", "info", "debug"] as const;
export type LogLevel = (typeof logLevels)[number];
export const loggerMessageSchema = z.object({
id: z.string(),
timestamp: z.date(),
message: z.string(),
level: z.enum(logLevels),
});
export type LoggerMessage = z.infer<typeof loggerMessageSchema>;
export const LOG_HISTORY_MAX_ENTRIES = 5_000;
export const LOG_HISTORY_KEY = "list:logging:history";
export const LOG_PUBLISH_CHANNEL = "pubSub:logging";
export const logLevelConfiguration = {
error: {
prefix: "🔴",
@@ -1,8 +1,11 @@
import superjson from "superjson";
import { randomUUID } from "node:crypto";
import { stringify } from "superjson";
import Transport from "winston-transport";
import type { RedisClient } from "../../redis/client";
import { createRedisClient } from "../../redis/client";
import type { LoggerMessage } from "../constants";
import { LOG_HISTORY_KEY, LOG_HISTORY_MAX_ENTRIES, LOG_PUBLISH_CHANNEL } from "../constants";
const messageSymbol = Symbol.for("message");
const levelSymbol = Symbol.for("level");
@@ -13,32 +16,41 @@ const levelSymbol = Symbol.for("level");
//
export class RedisTransport extends Transport {
private redis: RedisClient | null = null;
public static readonly publishChannel = "pubSub:logging";
/**
* Log the info to the Redis channel
* Retain the log in a bounded Redis list and publish it to live subscribers.
*/
log(info: { [messageSymbol]: string; [levelSymbol]: string }, callback: () => void) {
log(info: { [messageSymbol]: string; [levelSymbol]: LoggerMessage["level"] }, callback: () => void) {
setImmediate(() => {
this.emit("logged", info);
});
// Is only initialized here because it did not work when initialized in the constructor or outside the class
this.redis ??= createRedisClient();
const message: LoggerMessage = {
id: randomUUID(),
timestamp: new Date(),
message: info[messageSymbol],
level: info[levelSymbol],
};
const serializedMessage = stringify(message);
this.redis
.publish(
RedisTransport.publishChannel,
superjson.stringify({
message: info[messageSymbol],
level: info[levelSymbol],
}),
)
callback();
void Promise.resolve()
.then(() => {
callback();
// Is only initialized here because it did not work when initialized in the constructor or outside the class
if (!this.redis) {
this.redis = createRedisClient();
this.redis.options.maxRetriesPerRequest = 1;
this.redis.on("error", () => undefined);
}
return this.redis
.multi()
.lpush(LOG_HISTORY_KEY, serializedMessage)
.ltrim(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1)
.publish(LOG_PUBLISH_CHANNEL, serializedMessage)
.exec();
})
.catch(() => {
// Ignore errors
});
.catch(() => undefined);
}
}
@@ -9,7 +9,7 @@ const defaultRedisOptions = {
export type { Redis as RedisClient } from "ioredis";
export const createRedisClient = () =>
export const createRedisClient = (options: RedisOptions = {}) =>
redisEnv.IS_EXTERNAL
? new Redis({
...defaultRedisOptions,
@@ -23,5 +23,6 @@ export const createRedisClient = () =>
: undefined,
username: redisEnv.USERNAME,
password: redisEnv.PASSWORD,
...options,
})
: new Redis(defaultRedisOptions);
: new Redis({ ...defaultRedisOptions, ...options });