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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
Reference in new issue
Block a user