From 89315bb8a11eb8857cd2072c69519336bfc70f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Thu, 8 Jan 2026 14:57:06 +0100 Subject: [PATCH 1/7] fix: defer auth initialization until after passfile exists --- app/lib/auth.ts | 77 ++++++++++++++++--------- app/server/modules/lifecycle/startup.ts | 3 + 2 files changed, 52 insertions(+), 28 deletions(-) diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 049295a2..93f2333c 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -14,36 +14,57 @@ import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user"; export type AuthMiddlewareContext = MiddlewareContext>; -export const auth = betterAuth({ - secret: await cryptoUtils.deriveSecret("better-auth"), - hooks: { - before: createAuthMiddleware(async (ctx) => { - await ensureOnlyOneUser(ctx); - await convertLegacyUserOnFirstLogin(ctx); +let _auth: ReturnType | null = null; + +const createAuth = async () => { + if (_auth) return _auth; + + _auth = betterAuth({ + secret: await cryptoUtils.deriveSecret("better-auth"), + hooks: { + before: createAuthMiddleware(async (ctx) => { + await ensureOnlyOneUser(ctx); + await convertLegacyUserOnFirstLogin(ctx); + }), + }, + database: drizzleAdapter(db, { + provider: "sqlite", }), - }, - database: drizzleAdapter(db, { - provider: "sqlite", - }), - emailAndPassword: { - enabled: true, - }, - user: { - modelName: "usersTable", - additionalFields: { - username: { - type: "string", - returned: true, - required: true, - }, - hasDownloadedResticPassword: { - type: "boolean", - returned: true, + emailAndPassword: { + enabled: true, + }, + user: { + modelName: "usersTable", + additionalFields: { + username: { + type: "string", + returned: true, + required: true, + }, + hasDownloadedResticPassword: { + type: "boolean", + returned: true, + }, }, }, + session: { + modelName: "sessionsTable", + }, + plugins: [username({})], + }); + + return _auth; +}; + +export const auth = { + get api() { + if (!_auth) throw new Error("Auth not initialized. Call initAuth() first."); + return _auth.api; }, - session: { - modelName: "sessionsTable", + get handler() { + if (!_auth) throw new Error("Auth not initialized. Call initAuth() first."); + return _auth.handler; }, - plugins: [username({})], -}); +}; + +export const initAuth = createAuth; diff --git a/app/server/modules/lifecycle/startup.ts b/app/server/modules/lifecycle/startup.ts index 9d18aaa4..9b8ccfb6 100644 --- a/app/server/modules/lifecycle/startup.ts +++ b/app/server/modules/lifecycle/startup.ts @@ -50,6 +50,9 @@ export const startup = async () => { logger.error(`Error ensuring restic passfile exists: ${err.message}`); }); + const { initAuth } = await import("~/lib/auth"); + await initAuth(); + await ensureLatestConfigurationSchema(); const volumes = await db.query.volumesTable.findMany({ From e6793c9655c2af02e9c79f7d34267922b7b1b81f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Thu, 8 Jan 2026 15:13:30 +0100 Subject: [PATCH 2/7] refactor: restructure auth initialization to fix type errors --- app/lib/auth.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 93f2333c..8b0264a3 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -14,13 +14,9 @@ import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user"; export type AuthMiddlewareContext = MiddlewareContext>; -let _auth: ReturnType | null = null; - -const createAuth = async () => { - if (_auth) return _auth; - - _auth = betterAuth({ - secret: await cryptoUtils.deriveSecret("better-auth"), +const createBetterAuth = (secret: string) => + betterAuth({ + secret, hooks: { before: createAuthMiddleware(async (ctx) => { await ensureOnlyOneUser(ctx); @@ -53,10 +49,19 @@ const createAuth = async () => { plugins: [username({})], }); +type Auth = ReturnType; + +let _auth: Auth | null = null; + +const createAuth = async (): Promise => { + if (_auth) return _auth; + + _auth = createBetterAuth(await cryptoUtils.deriveSecret("better-auth")); + return _auth; }; -export const auth = { +export const auth: Auth = { get api() { if (!_auth) throw new Error("Auth not initialized. Call initAuth() first."); return _auth.api; @@ -65,6 +70,6 @@ export const auth = { if (!_auth) throw new Error("Auth not initialized. Call initAuth() first."); return _auth.handler; }, -}; +} as Auth; export const initAuth = createAuth; From 87d267a1c3fad32f3773df02bb04c7d98dc9cc04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Thu, 8 Jan 2026 15:22:20 +0100 Subject: [PATCH 3/7] static import and error catching --- app/server/modules/lifecycle/startup.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/server/modules/lifecycle/startup.ts b/app/server/modules/lifecycle/startup.ts index 9b8ccfb6..30619cdb 100644 --- a/app/server/modules/lifecycle/startup.ts +++ b/app/server/modules/lifecycle/startup.ts @@ -13,6 +13,7 @@ import { repositoriesService } from "../repositories/repositories.service"; import { notificationsService } from "../notifications/notifications.service"; import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount"; import { cache } from "~/server/utils/cache"; +import { initAuth } from "~/lib/auth"; const ensureLatestConfigurationSchema = async () => { const volumes = await db.query.volumesTable.findMany({}); @@ -50,8 +51,10 @@ export const startup = async () => { logger.error(`Error ensuring restic passfile exists: ${err.message}`); }); - const { initAuth } = await import("~/lib/auth"); - await initAuth(); + await initAuth().catch((err) => { + logger.error(`Error initializing auth: ${err.message}`); + throw err; + }); await ensureLatestConfigurationSchema(); From 72a0c2e923433698a145b8397598e357424bd214 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:46:32 +0100 Subject: [PATCH 4/7] refactor: implement a proper code migration system (#324) * refactor: implement a proper code migration system * chore: rename .tsx -> .ts --- app/server/core/constants.ts | 2 - app/server/index.ts | 8 +- app/server/modules/lifecycle/checkpoint.ts | 88 -------------- app/server/modules/lifecycle/migrations.ts | 114 ++++++++++++++++++ .../00001-retag-snapshots.ts} | 63 ++-------- app/server/utils/logger.ts | 2 +- 6 files changed, 127 insertions(+), 150 deletions(-) delete mode 100644 app/server/modules/lifecycle/checkpoint.ts create mode 100644 app/server/modules/lifecycle/migrations.ts rename app/server/modules/lifecycle/{migration.ts => migrations/00001-retag-snapshots.ts} (60%) diff --git a/app/server/core/constants.ts b/app/server/core/constants.ts index 003b8a39..972407a9 100644 --- a/app/server/core/constants.ts +++ b/app/server/core/constants.ts @@ -5,5 +5,3 @@ export const DATABASE_URL = process.env.DATABASE_URL || "/var/lib/zerobyte/data/ export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass"; export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE]; - -export const REQUIRED_MIGRATIONS = []; // ["v0.21.1"] add this once re-tagging migration is removed diff --git a/app/server/index.ts b/app/server/index.ts index f2c51b6c..28bd6df4 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -2,14 +2,12 @@ import { createHonoServer } from "react-router-hono-server/bun"; import * as schema from "./db/schema"; import { setSchema, runDbMigrations } from "./db/db"; import { startup } from "./modules/lifecycle/startup"; -import { retagSnapshots } from "./modules/lifecycle/migration"; import { logger } from "./utils/logger"; import { shutdown } from "./modules/lifecycle/shutdown"; -import { REQUIRED_MIGRATIONS } from "./core/constants"; -import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint"; import { createApp } from "./app"; import { config } from "./core/config"; import { runCLI } from "./cli"; +import { runMigrations } from "./modules/lifecycle/migrations"; setSchema(schema); @@ -22,9 +20,7 @@ const app = createApp(); runDbMigrations(); -await retagSnapshots(); -await validateRequiredMigrations(REQUIRED_MIGRATIONS); - +await runMigrations(); await startup(); export type AppType = typeof app; diff --git a/app/server/modules/lifecycle/checkpoint.ts b/app/server/modules/lifecycle/checkpoint.ts deleted file mode 100644 index d9f80a23..00000000 --- a/app/server/modules/lifecycle/checkpoint.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { eq, sql } from "drizzle-orm"; -import { db } from "../../db/db"; -import { appMetadataTable, usersTable } from "../../db/schema"; -import { logger } from "../../utils/logger"; - -const MIGRATION_KEY_PREFIX = "migration:"; - -export const recordMigrationCheckpoint = async (version: string): Promise => { - const key = `${MIGRATION_KEY_PREFIX}${version}`; - const now = Date.now(); - - await db - .insert(appMetadataTable) - .values({ - key, - value: JSON.stringify({ completedAt: new Date().toISOString() }), - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: appMetadataTable.key, - set: { - value: JSON.stringify({ completedAt: new Date().toISOString() }), - updatedAt: now, - }, - }); - - logger.info(`Recorded migration checkpoint for ${version}`); -}; - -export const hasMigrationCheckpoint = async (version: string): Promise => { - const key = `${MIGRATION_KEY_PREFIX}${version}`; - const result = await db.query.appMetadataTable.findFirst({ - where: eq(appMetadataTable.key, key), - }); - return result !== undefined; -}; - -export const validateRequiredMigrations = async (requiredVersions: string[]): Promise => { - const userCount = await db.select({ count: sql`count(*)` }).from(usersTable); - const isFreshInstall = userCount[0]?.count === 0; - - if (isFreshInstall) { - logger.info("Fresh installation detected, skipping migration checkpoint validation."); - - for (const version of requiredVersions) { - const hasCheckpoint = await hasMigrationCheckpoint(version); - if (!hasCheckpoint) { - await recordMigrationCheckpoint(version); - } - } - - return; - } - - for (const version of requiredVersions) { - const hasCheckpoint = await hasMigrationCheckpoint(version); - if (!hasCheckpoint) { - logger.error(` -================================================================================ -MIGRATION ERROR: Required migration ${version} has not been run. - -You are attempting to start a version of Zerobyte that requires migration -checkpoints from previous versions. This typically happens when you skip -versions during an upgrade. - -To fix this: -1. First upgrade to version ${version} and run the application once -2. Validate that everything is still working correctly -3. Then upgrade to the current version - -================================================================================ -`); - process.exit(1); - } - } -}; - -export const getMigrationCheckpoints = async (): Promise<{ version: string; completedAt: string }[]> => { - const results = await db.query.appMetadataTable.findMany({ - where: (table, { like }) => like(table.key, `${MIGRATION_KEY_PREFIX}%`), - }); - - return results.map((r) => ({ - version: r.key.replace(MIGRATION_KEY_PREFIX, ""), - completedAt: JSON.parse(r.value).completedAt, - })); -}; diff --git a/app/server/modules/lifecycle/migrations.ts b/app/server/modules/lifecycle/migrations.ts new file mode 100644 index 00000000..83c0edb8 --- /dev/null +++ b/app/server/modules/lifecycle/migrations.ts @@ -0,0 +1,114 @@ +import { db } from "~/server/db/db"; +import { logger } from "../../utils/logger"; +import { v00001 } from "./migrations/00001-retag-snapshots"; +import { usersTable } from "~/server/db/schema"; +import { sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; +import { appMetadataTable } from "../../db/schema"; + +const MIGRATION_KEY_PREFIX = "migration:"; + +const recordMigrationCheckpoint = async (version: string): Promise => { + const key = `${MIGRATION_KEY_PREFIX}${version}`; + const now = Date.now(); + + await db + .insert(appMetadataTable) + .values({ key, value: JSON.stringify({ completedAt: new Date().toISOString() }), createdAt: now, updatedAt: now }) + .onConflictDoUpdate({ + target: appMetadataTable.key, + set: { value: JSON.stringify({ completedAt: new Date().toISOString() }), updatedAt: now }, + }); + + logger.info(`Recorded migration checkpoint for ${version}`); +}; + +const hasMigrationCheckpoint = async (id: string): Promise => { + const key = `${MIGRATION_KEY_PREFIX}${id}`; + const result = await db.query.appMetadataTable.findFirst({ + where: eq(appMetadataTable.key, key), + }); + return result !== undefined; +}; + +type MigrationEntity = { + execute: () => Promise<{ success: boolean; errors: Array<{ name: string; error: string }> }>; + id: string; + type: "maintenance" | "critical"; + dependsOn?: string[]; +}; + +const registry: MigrationEntity[] = [v00001]; + +export const runMigrations = async () => { + const userCount = await db.select({ count: sql`count(*)` }).from(usersTable); + const isFreshInstall = userCount[0]?.count === 0; + + if (isFreshInstall) { + logger.debug("Fresh installation detected, skipping migration checkpoint validation."); + + for (const migration of registry) { + const hasCheckpoint = await hasMigrationCheckpoint(migration.id); + if (!hasCheckpoint) { + await recordMigrationCheckpoint(migration.id); + } + } + + return; + } + + for (const migration of registry) { + const alreadyMigrated = await hasMigrationCheckpoint(migration.id); + + if (alreadyMigrated) { + logger.debug(`Migration ${migration.id} already completed, skipping.`); + continue; + } + + if (migration.dependsOn) { + for (const dep of migration.dependsOn) { + const depCompleted = await hasMigrationCheckpoint(dep); + if (!depCompleted) { + const err = [ + "================================================================================", + `🚨 MIGRATION ERROR: Migration ${migration.id} depends on migration ${dep}.`, + "The application cannot start until the required migration has successfully completed.", + "Please fix the issues and restart the application.", + "", + "Seek support by opening an issue on the Zerobyte GitHub repository if you need assistance.", + "================================================================================", + ]; + err.forEach((line) => logger.error(line)); + process.exit(1); + } + } + } + + logger.info(`Running migration: ${migration.id} (${migration.type})`); + const result = await migration.execute(); + if (result.success) { + logger.info(`Migration ${migration.id} completed successfully.`); + await recordMigrationCheckpoint(migration.id); + } else { + logger.error(`Migration ${migration.id} completed with errors: ${result.errors.length} items failed.`); + for (const err of result.errors) { + logger.error(`Migration failure - ${err.name}: ${err.error}`); + } + + if (migration.type === "critical") { + const err = [ + "================================================================================", + `🚨 MIGRATION ERROR: Critical migration ${migration.id} failed.`, + "", + "The application cannot start until this migration has successfully completed.", + "", + "Please fix the issues and restart the application. Seek support by opening an issue", + "on the Zerobyte GitHub repository if you need assistance.", + "================================================================================", + ]; + err.forEach((line) => logger.error(line)); + process.exit(1); + } + } + } +}; diff --git a/app/server/modules/lifecycle/migration.ts b/app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts similarity index 60% rename from app/server/modules/lifecycle/migration.ts rename to app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts index 50fa9e5a..234922f8 100644 --- a/app/server/modules/lifecycle/migration.ts +++ b/app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts @@ -1,60 +1,11 @@ import { eq } from "drizzle-orm"; -import { db } from "../../db/db"; -import { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../db/schema"; -import { logger } from "../../utils/logger"; -import { hasMigrationCheckpoint, recordMigrationCheckpoint } from "./checkpoint"; +import { db } from "../../../db/db"; +import { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../../db/schema"; +import { logger } from "../../../utils/logger"; import { toMessage } from "~/server/utils/errors"; import { safeSpawn } from "~/server/utils/spawn"; import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "~/server/utils/restic"; -const MIGRATION_VERSION = "v0.21.1"; - -interface MigrationResult { - success: boolean; - errors: Array<{ name: string; error: string }>; -} - -export class MigrationError extends Error { - version: string; - failedItems: Array<{ name: string; error: string }>; - - constructor(version: string, failedItems: Array<{ name: string; error: string }>) { - const itemNames = failedItems.map((e) => e.name).join(", "); - super(`Migration ${version} failed for: ${itemNames}`); - this.version = version; - this.failedItems = failedItems; - this.name = "MigrationError"; - } -} - -export const retagSnapshots = async () => { - const alreadyMigrated = await hasMigrationCheckpoint(MIGRATION_VERSION); - if (alreadyMigrated) { - logger.debug(`Migration ${MIGRATION_VERSION} already completed, skipping.`); - return; - } - - logger.info(`Starting snapshots retagging migration (${MIGRATION_VERSION})...`); - - const result = await migrateSnapshotsToShortIdTag(); - const allErrors = [...result.errors]; - - if (allErrors.length > 0) { - logger.error(`Migration ${MIGRATION_VERSION} completed with errors: ${allErrors.length} items failed.`); - logger.error( - `Some snapshots could not be retagged. Please check the logs for details. Fix any repository in error state and re-start zerobyte to retry the migration for failed items.`, - ); - for (const err of allErrors) { - logger.error(`Migration failure - ${err.name}: ${err.error}`); - } - - return; - } - - await recordMigrationCheckpoint(MIGRATION_VERSION); - logger.info(`Snapshots retagging migration (${MIGRATION_VERSION}) complete.`); -}; - const migrateTag = async ( oldTag: string, newTag: string, @@ -81,7 +32,7 @@ const migrateTag = async ( return null; }; -const migrateSnapshotsToShortIdTag = async (): Promise => { +const execute = async () => { const errors: Array<{ name: string; error: string }> = []; const backupSchedules = await db.query.backupSchedulesTable.findMany({}); @@ -134,3 +85,9 @@ const migrateSnapshotsToShortIdTag = async (): Promise => { return { success: errors.length === 0, errors }; }; + +export const v00001 = { + execute, + id: "00001-retag-snapshots", + type: "maintenance" as const, +}; diff --git a/app/server/utils/logger.ts b/app/server/utils/logger.ts index 6d106f60..2bc39507 100644 --- a/app/server/utils/logger.ts +++ b/app/server/utils/logger.ts @@ -27,7 +27,7 @@ const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) => return sanitizeSensitiveData(JSON.stringify(m, null, 2)); } - return sanitizeSensitiveData(String(JSON.stringify(m))); + return sanitizeSensitiveData(String(m as string)); }); winstonLogger.log(level, stringMessages.join(" ")); From a25d303d54aeda63968fd4292a1d3e61f2d16ce2 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:53:02 +0100 Subject: [PATCH 5/7] chore: add more linting rules and fix warnings (#325) --- .oxlintrc.json | 5 +++-- app/client/components/file-tree.tsx | 17 +++++++++++++---- app/client/components/snapshots-table.tsx | 2 +- app/client/components/ui/alert.tsx | 4 ++-- app/client/components/ui/breadcrumb.tsx | 9 +++++---- .../auth/routes/download-recovery-key.tsx | 1 - app/client/modules/auth/routes/login.tsx | 2 +- app/client/modules/auth/routes/onboarding.tsx | 2 +- .../backups/components/create-schedule-form.tsx | 2 +- .../backups/components/schedule-summary.tsx | 2 +- app/client/modules/settings/routes/settings.tsx | 1 - 11 files changed, 28 insertions(+), 19 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 634c4b7c..0caeb87a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["unicorn", "typescript", "oxc"], + "plugins": ["eslint", "unicorn", "typescript", "oxc", "import", "react", "react-perf", "node", "jsx-a11y"], "categories": {}, "rules": { "constructor-super": "warn", @@ -112,7 +112,8 @@ "unicorn/no-useless-length-check": "warn", "unicorn/no-useless-spread": "warn", "unicorn/prefer-set-size": "warn", - "unicorn/prefer-string-starts-ends-with": "warn" + "unicorn/prefer-string-starts-ends-with": "warn", + "import/no-cycle": "error" }, "settings": { "jsx-a11y": { diff --git a/app/client/components/file-tree.tsx b/app/client/components/file-tree.tsx index fa9559c2..22d0a5c4 100644 --- a/app/client/components/file-tree.tsx +++ b/app/client/components/file-tree.tsx @@ -489,18 +489,27 @@ interface ButtonProps { const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => { const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick?.(); + } + }, + [onClick], + ); + return ( - // biome-ignore lint/a11y/noStaticElementInteractions: we handle click and hover manually - // biome-ignore lint/a11y/useKeyWithClickEvents: we handle click and hover manually -
{icon}
{children}
-
+ ); }); diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index 2e3b7f79..9ff31b8f 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -158,7 +158,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { } setSelectedIds(newSelected); }} - aria-label={`Select snapshot ${snapshot.short_id}`} + aria-label={`Select snapshot ${snapshot.short_id}` as string} /> diff --git a/app/client/components/ui/alert.tsx b/app/client/components/ui/alert.tsx index 1014ae72..229491a7 100644 --- a/app/client/components/ui/alert.tsx +++ b/app/client/components/ui/alert.tsx @@ -27,9 +27,9 @@ const Alert = React.forwardRef< )); Alert.displayName = "Alert"; -const AlertTitle = React.forwardRef>( +const AlertTitle = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
{props.children}
), ); AlertTitle.displayName = "AlertTitle"; diff --git a/app/client/components/ui/breadcrumb.tsx b/app/client/components/ui/breadcrumb.tsx index e1fb0843..f838da10 100644 --- a/app/client/components/ui/breadcrumb.tsx +++ b/app/client/components/ui/breadcrumb.tsx @@ -43,16 +43,17 @@ function BreadcrumbLink({ ); } -function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { +function BreadcrumbPage({ className, children, ...props }: React.ComponentProps<"a">) { return ( - + > + {children} + ); } diff --git a/app/client/modules/auth/routes/download-recovery-key.tsx b/app/client/modules/auth/routes/download-recovery-key.tsx index af29dd05..da20ce32 100644 --- a/app/client/modules/auth/routes/download-recovery-key.tsx +++ b/app/client/modules/auth/routes/download-recovery-key.tsx @@ -88,7 +88,6 @@ export default function DownloadRecoveryKeyPage() { onChange={(e) => setPassword(e.target.value)} placeholder="Enter your password" required - autoFocus disabled={downloadResticPassword.isPending} />

Enter your account password to download the recovery key

diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index f342ccd5..fb089eb6 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -84,7 +84,7 @@ export default function LoginPage() { Username - + diff --git a/app/client/modules/auth/routes/onboarding.tsx b/app/client/modules/auth/routes/onboarding.tsx index f9e3f490..6290b598 100644 --- a/app/client/modules/auth/routes/onboarding.tsx +++ b/app/client/modules/auth/routes/onboarding.tsx @@ -115,7 +115,7 @@ export default function OnboardingPage() { Username - + Choose a username for the admin account diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index 8e2a088b..577fb064 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -393,7 +393,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: type="button" onClick={() => handleRemovePath(path)} className="ml-1 hover:bg-destructive/20 rounded p-0.5 transition-colors" - aria-label={`Remove ${path}`} + aria-label={`Remove ${path}` as string} > diff --git a/app/client/modules/backups/components/schedule-summary.tsx b/app/client/modules/backups/components/schedule-summary.tsx index 2036ccfa..16f1df03 100644 --- a/app/client/modules/backups/components/schedule-summary.tsx +++ b/app/client/modules/backups/components/schedule-summary.tsx @@ -65,7 +65,7 @@ export const ScheduleSummary = (props: Props) => { repositoryLabel: schedule.repositoryId || "No repository selected", retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy", }; - }, [schedule, schedule.volume.name]); + }, [schedule]); const handleConfirmDelete = () => { setShowDeleteConfirm(false); diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index a1a75225..70a2fe8a 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -244,7 +244,6 @@ export default function Settings({ loaderData }: Route.ComponentProps) { onChange={(e) => setDownloadPassword(e.target.value)} placeholder="Enter your password" required - autoFocus /> From 10b85fdd422d538571e15d7f0a05af05a0a47e8e Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Thu, 8 Jan 2026 19:43:40 +0100 Subject: [PATCH 6/7] chore: verbose logging for mount commands --- Dockerfile | 5 ++- .../modules/backends/nfs/nfs-backend.ts | 38 +++++++++++++++---- .../modules/backends/utils/backend-utils.ts | 27 ++++++++----- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index c9816cc3..b100b1f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,9 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \ VITE_RCLONE_VERSION=${RCLONE_VERSION} \ VITE_SHOUTRRR_VERSION=${SHOUTRRR_VERSION} -RUN apk upgrade --no-cache && \ - apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini nfs-utils cifs-utils +RUN apk update --no-cache && \ + apk upgrade --no-cache && \ + apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini nfs-utils cifs-utils util-linux ENTRYPOINT ["/sbin/tini", "-s", "--"] diff --git a/app/server/modules/backends/nfs/nfs-backend.ts b/app/server/modules/backends/nfs/nfs-backend.ts index 314b2554..73ea30f3 100644 --- a/app/server/modules/backends/nfs/nfs-backend.ts +++ b/app/server/modules/backends/nfs/nfs-backend.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; +import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes"; import { OPERATION_TIMEOUT } from "../../../core/constants"; import { toMessage } from "../../../utils/errors"; import { logger } from "../../../utils/logger"; @@ -7,19 +8,24 @@ import { getMountForPath } from "../../../utils/mountinfo"; import { withTimeout } from "../../../utils/timeout"; import type { VolumeBackend } from "../backend"; import { executeMount, executeUnmount } from "../utils/backend-utils"; -import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes"; const mount = async (config: BackendConfig, path: string) => { logger.debug(`Mounting volume ${path}...`); if (config.backend !== "nfs") { logger.error("Provided config is not for NFS backend"); - return { status: BACKEND_STATUS.error, error: "Provided config is not for NFS backend" }; + return { + status: BACKEND_STATUS.error, + error: "Provided config is not for NFS backend", + }; } if (os.platform() !== "linux") { logger.error("NFS mounting is only supported on Linux hosts."); - return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." }; + return { + status: BACKEND_STATUS.error, + error: "NFS mounting is only supported on Linux hosts.", + }; } const { status } = await checkHealth(path); @@ -28,7 +34,9 @@ const mount = async (config: BackendConfig, path: string) => { } if (status === "error") { - logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`); + logger.debug( + `Trying to unmount any existing mounts at ${path} before mounting...`, + ); await unmount(path); } @@ -37,6 +45,9 @@ const mount = async (config: BackendConfig, path: string) => { const source = `${config.server}:${config.exportPath}`; const options = [`vers=${config.version}`, `port=${config.port}`]; + if (config.version === "3") { + options.push("nolock"); + } if (config.readOnly) { options.push("ro"); } @@ -62,7 +73,10 @@ const mount = async (config: BackendConfig, path: string) => { const unmount = async (path: string) => { if (os.platform() !== "linux") { logger.error("NFS unmounting is only supported on Linux hosts."); - return { status: BACKEND_STATUS.error, error: "NFS unmounting is only supported on Linux hosts." }; + return { + status: BACKEND_STATUS.error, + error: "NFS unmounting is only supported on Linux hosts.", + }; } const run = async () => { @@ -83,7 +97,10 @@ const unmount = async (path: string) => { try { return await withTimeout(run(), OPERATION_TIMEOUT, "NFS unmount"); } catch (err) { - logger.error("Error unmounting NFS volume", { path, error: toMessage(err) }); + logger.error("Error unmounting NFS volume", { + path, + error: toMessage(err), + }); return { status: BACKEND_STATUS.error, error: toMessage(err) }; } }; @@ -103,7 +120,9 @@ const checkHealth = async (path: string) => { } if (!mount.fstype.startsWith("nfs")) { - throw new Error(`Path ${path} is not mounted as NFS (found ${mount.fstype}).`); + throw new Error( + `Path ${path} is not mounted as NFS (found ${mount.fstype}).`, + ); } logger.debug(`NFS volume at ${path} is healthy and mounted.`); @@ -121,7 +140,10 @@ const checkHealth = async (path: string) => { } }; -export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({ +export const makeNfsBackend = ( + config: BackendConfig, + path: string, +): VolumeBackend => ({ mount: () => mount(config, path), unmount: () => unmount(path), checkHealth: () => checkHealth(path), diff --git a/app/server/modules/backends/utils/backend-utils.ts b/app/server/modules/backends/utils/backend-utils.ts index e610217d..ea11570c 100644 --- a/app/server/modules/backends/utils/backend-utils.ts +++ b/app/server/modules/backends/utils/backend-utils.ts @@ -1,23 +1,30 @@ import * as fs from "node:fs/promises"; import * as npath from "node:path"; +import { $ } from "bun"; import { toMessage } from "../../../utils/errors"; import { logger } from "../../../utils/logger"; -import { $ } from "bun"; export const executeMount = async (args: string[]): Promise => { - let stderr: string | undefined; + const shouldBeVerbose = process.env.LOG_LEVEL === "debug" || process.env.NODE_ENV !== "production"; + const hasVerboseFlag = args.some((arg) => arg === "-v" || arg.startsWith("-vv")); + const effectiveArgs = shouldBeVerbose && !hasVerboseFlag ? ["-vvv", ...args] : args; - logger.debug(`Executing mount ${args.join(" ")}`); - const result = await $`mount ${args}`.nothrow(); - stderr = result.stderr.toString(); + logger.debug(`Executing mount ${effectiveArgs.join(" ")}`); + const result = await $`mount ${effectiveArgs}`.nothrow(); - if (stderr?.trim()) { - logger.warn(stderr.trim()); + const stdout = result.stdout.toString().trim(); + const stderr = result.stderr.toString().trim(); + + if (result.exitCode === 0) { + logger.debug(stdout); + logger.debug(stderr); + return; } - if (result.exitCode !== 0) { - throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`); - } + logger.warn(stdout); + logger.warn(stderr); + + throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr || stdout || "unknown error"}`); }; export const executeUnmount = async (path: string): Promise => { From a4464d243afd4349b0ff19fd5c7eacaf12303cee Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Thu, 8 Jan 2026 20:44:42 +0100 Subject: [PATCH 7/7] refactor: auth to proxy pattern --- app/lib/auth.ts | 19 ++++++++++--------- app/server/index.ts | 4 ++-- app/server/modules/lifecycle/startup.ts | 3 ++- app/test/setup-client.ts | 2 +- app/test/setup.ts | 2 ++ 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 8b0264a3..f35ac450 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -61,15 +61,16 @@ const createAuth = async (): Promise => { return _auth; }; -export const auth: Auth = { - get api() { - if (!_auth) throw new Error("Auth not initialized. Call initAuth() first."); - return _auth.api; +export const auth = new Proxy( + {}, + { + get(_, prop, receiver) { + if (!_auth) { + throw new Error("Auth not initialized. Call initAuth() first."); + } + return Reflect.get(_auth, prop, receiver); + }, }, - get handler() { - if (!_auth) throw new Error("Auth not initialized. Call initAuth() first."); - return _auth.handler; - }, -} as Auth; +) as Auth; export const initAuth = createAuth; diff --git a/app/server/index.ts b/app/server/index.ts index 28bd6df4..8faf6a90 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -16,10 +16,10 @@ if (cliRun) { process.exit(0); } -const app = createApp(); - runDbMigrations(); +const app = createApp(); + await runMigrations(); await startup(); diff --git a/app/server/modules/lifecycle/startup.ts b/app/server/modules/lifecycle/startup.ts index 30619cdb..f7e5fadb 100644 --- a/app/server/modules/lifecycle/startup.ts +++ b/app/server/modules/lifecycle/startup.ts @@ -14,6 +14,7 @@ import { notificationsService } from "../notifications/notifications.service"; import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount"; import { cache } from "~/server/utils/cache"; import { initAuth } from "~/lib/auth"; +import { toMessage } from "~/server/utils/errors"; const ensureLatestConfigurationSchema = async () => { const volumes = await db.query.volumesTable.findMany({}); @@ -52,7 +53,7 @@ export const startup = async () => { }); await initAuth().catch((err) => { - logger.error(`Error initializing auth: ${err.message}`); + logger.error(`Error initializing auth: ${toMessage(err)}`); throw err; }); diff --git a/app/test/setup-client.ts b/app/test/setup-client.ts index f737ee3e..a0faa585 100644 --- a/app/test/setup-client.ts +++ b/app/test/setup-client.ts @@ -1,4 +1,4 @@ import "./setup.ts"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; -GlobalRegistrator.register(); +GlobalRegistrator.register({ url: "http://localhost:3000" }); diff --git a/app/test/setup.ts b/app/test/setup.ts index 81927747..ea73e5b0 100644 --- a/app/test/setup.ts +++ b/app/test/setup.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { cwd } from "node:process"; import * as schema from "~/server/db/schema"; import { db, setSchema } from "~/server/db/db"; +import { initAuth } from "~/lib/auth"; setSchema(schema); @@ -27,4 +28,5 @@ void mock.module("~/server/utils/crypto", () => ({ beforeAll(async () => { const migrationsFolder = path.join(cwd(), "app", "drizzle"); migrate(db, { migrationsFolder }); + await initAuth(); });