From c20aa62c3e72f71c524deb95668611cf4eb0a4e5 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 1 Feb 2026 18:34:58 +0100 Subject: [PATCH] chore: move root lib to server --- app/client/lib/auth-client.ts | 2 +- app/server/app.ts | 2 +- .../__tests__/convert-legacy-user.test.ts | 278 ++++++++++++++++++ .../auth-middlewares/convert-legacy-user.ts | 0 .../lib/auth-middlewares/only-one-user.ts | 0 app/{ => server}/lib/auth.ts | 10 +- app/server/modules/auth/auth.middleware.ts | 2 +- .../backups/__tests__/backups.service.test.ts | 6 +- .../modules/backups/backups.controller.ts | 2 +- app/server/modules/backups/backups.service.ts | 19 +- app/server/modules/lifecycle/startup.ts | 2 +- .../repositories/repositories.controller.ts | 22 ++ .../modules/repositories/repositories.dto.ts | 1 + app/server/utils/retention-categories.ts | 55 ++++ app/test/setup.ts | 2 +- 15 files changed, 387 insertions(+), 16 deletions(-) create mode 100644 app/server/lib/auth-middlewares/__tests__/convert-legacy-user.test.ts rename app/{ => server}/lib/auth-middlewares/convert-legacy-user.ts (100%) rename app/{ => server}/lib/auth-middlewares/only-one-user.ts (100%) rename app/{ => server}/lib/auth.ts (94%) create mode 100644 app/server/utils/retention-categories.ts diff --git a/app/client/lib/auth-client.ts b/app/client/lib/auth-client.ts index 66974411..7dae9135 100644 --- a/app/client/lib/auth-client.ts +++ b/app/client/lib/auth-client.ts @@ -1,7 +1,7 @@ import { createAuthClient } from "better-auth/react"; import { twoFactorClient, usernameClient, adminClient, organizationClient } from "better-auth/client/plugins"; import { inferAdditionalFields } from "better-auth/client/plugins"; -import type { auth } from "~/lib/auth"; +import type { auth } from "~/server/lib/auth"; export const authClient = createAuthClient({ plugins: [ diff --git a/app/server/app.ts b/app/server/app.ts index fc46038a..e97ec007 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -17,7 +17,7 @@ import { notificationsController } from "./modules/notifications/notifications.c import { handleServiceError } from "./utils/errors"; import { logger } from "./utils/logger"; import { config } from "./core/config"; -import { auth } from "~/lib/auth"; +import { auth } from "~/server/lib/auth"; export const generalDescriptor = (app: Hono) => openAPIRouteHandler(app, { diff --git a/app/server/lib/auth-middlewares/__tests__/convert-legacy-user.test.ts b/app/server/lib/auth-middlewares/__tests__/convert-legacy-user.test.ts new file mode 100644 index 00000000..0eb75083 --- /dev/null +++ b/app/server/lib/auth-middlewares/__tests__/convert-legacy-user.test.ts @@ -0,0 +1,278 @@ +import { test, describe, mock, beforeEach, afterEach, expect } from "bun:test"; +import { convertLegacyUserOnFirstLogin } from "../convert-legacy-user"; +import { db } from "~/server/db/db"; +import { usersTable, account, organization, member } from "~/server/db/schema"; +import type { AuthMiddlewareContext } from "../../auth"; + +describe("convertLegacyUserOnFirstLogin", () => { + beforeEach(async () => { + await db.delete(member); + await db.delete(account); + await db.delete(organization); + await db.delete(usersTable); + }); + + afterEach(() => { + mock.restore(); + }); + + const createContext = (path: string, body: Record) => ({ path, body }) as AuthMiddlewareContext; + + test("should return early for non-sign-in paths", async () => { + const ctx = createContext("/sign-up", { username: "test", password: "test" }); + const result = await convertLegacyUserOnFirstLogin(ctx); + expect(result).toBeUndefined(); + }); + + test("should do nothing when no legacy user exists", async () => { + await db.insert(usersTable).values({ + id: crypto.randomUUID(), + username: "existing-user", + email: "existing@test.com", + name: "Existing User", + passwordHash: null, + }); + + const ctx = createContext("/sign-in/username", { + username: "existing-user", + password: "password123", + }); + + const result = await convertLegacyUserOnFirstLogin(ctx); + expect(result).toBeUndefined(); + + // Verify user still exists with no account + const user = await db.query.usersTable.findFirst({ + where: { username: "existing-user" }, + }); + expect(user).toBeDefined(); + expect(user?.passwordHash).toBeNull(); + }); + + test("should throw UnauthorizedError for invalid password", async () => { + const hashedPassword = await Bun.password.hash("correct-password"); + + // Create a legacy user with a hashed password + const userId = crypto.randomUUID(); + await db.insert(usersTable).values({ + id: userId, + username: "legacy-user", + email: "legacy@test.com", + name: "Legacy User", + passwordHash: hashedPassword, + }); + + const ctx = createContext("/sign-in/username", { + username: "legacy-user", + password: "wrong-password", + }); + + expect(convertLegacyUserOnFirstLogin(ctx)).rejects.toThrow("Invalid credentials"); + + // Verify user still exists (not migrated) + const user = await db.query.usersTable.findFirst({ + where: { username: "legacy-user" }, + }); + expect(user).toBeDefined(); + expect(user?.passwordHash).toBe(hashedPassword); + }); + + test("should migrate legacy user with existing organization membership", async () => { + const password = "correct-password"; + const hashedPassword = await Bun.password.hash(password); + + // Create legacy user + const userId = crypto.randomUUID(); + await db.insert(usersTable).values({ + id: userId, + username: "legacy-with-org", + email: "legacy-org@test.com", + name: "Legacy With Org", + passwordHash: hashedPassword, + role: "admin", + }); + + // Create organization and membership + const orgId = crypto.randomUUID(); + await db.insert(organization).values({ + id: orgId, + name: "Legacy Org", + slug: "legacy-org", + createdAt: new Date(), + }); + + const membershipId = crypto.randomUUID(); + await db.insert(member).values({ + id: membershipId, + userId: userId, + organizationId: orgId, + role: "owner", + createdAt: new Date(), + }); + + const ctx = createContext("/sign-in/username", { + username: "legacy-with-org", + password, + }); + + await convertLegacyUserOnFirstLogin(ctx); + + // Verify old user is deleted + const oldUser = await db.query.usersTable.findFirst({ + where: { id: userId }, + }); + expect(oldUser).toBeUndefined(); + + // Verify new user exists + const newUser = await db.query.usersTable.findFirst({ + where: { username: "legacy-with-org" }, + }); + expect(newUser).toBeDefined(); + expect(newUser?.email).toBe("legacy-org@test.com"); + expect(newUser?.name).toBe("Legacy With Org"); + expect(newUser?.role).toBe("admin"); + expect(newUser?.passwordHash).toBeNull(); + expect(newUser?.id).not.toBe(userId); + + // Verify account was created + const userAccount = await db.query.account.findFirst({ + where: { userId: newUser?.id }, + }); + expect(userAccount).toBeDefined(); + expect(userAccount?.providerId).toBe("credential"); + expect(userAccount?.accountId).toBe("legacy-with-org"); + expect(userAccount?.password).toBeDefined(); + + // Verify membership was migrated + const memberships = await db.query.member.findMany({ + where: { userId: newUser?.id }, + }); + expect(memberships.length).toBe(1); + expect(memberships[0].organizationId).toBe(orgId); + expect(memberships[0].role).toBe("owner"); + }); + + test("should migrate legacy user and create new organization when no membership exists", async () => { + const password = "correct-password"; + const hashedPassword = await Bun.password.hash(password); + + // Create legacy user without organization membership + const userId = crypto.randomUUID(); + await db.insert(usersTable).values({ + id: userId, + username: "legacy-no-org", + email: "legacy-noorg@test.com", + name: "Legacy No Org", + passwordHash: hashedPassword, + hasDownloadedResticPassword: true, + }); + + const ctx = createContext("/sign-in/username", { + username: "legacy-no-org", + password, + }); + + await convertLegacyUserOnFirstLogin(ctx); + + // Verify old user is deleted + const oldUser = await db.query.usersTable.findFirst({ + where: { id: userId }, + }); + expect(oldUser).toBeUndefined(); + + // Verify new user exists + const newUser = await db.query.usersTable.findFirst({ + where: { username: "legacy-no-org" }, + }); + expect(newUser).toBeDefined(); + expect(newUser?.email).toBe("legacy-noorg@test.com"); + expect(newUser?.hasDownloadedResticPassword).toBe(true); + expect(newUser?.role).toBe("admin"); + + // Verify account was created + const userAccount = await db.query.account.findFirst({ + where: { userId: newUser?.id }, + }); + expect(userAccount).toBeDefined(); + + // Verify new organization was created + const memberships = await db.query.member.findMany({ + where: { userId: newUser?.id }, + }); + expect(memberships.length).toBe(1); + expect(memberships[0].role).toBe("owner"); + + const org = await db.query.organization.findFirst({ + where: { id: memberships[0].organizationId }, + }); + expect(org).toBeDefined(); + expect(org?.name).toBe("Legacy No Org's Workspace"); + expect(org?.metadata).toBeDefined(); + }); + + test("should be case-insensitive for username", async () => { + const password = "correct-password"; + const hashedPassword = await Bun.password.hash(password); + + const userId = crypto.randomUUID(); + await db.insert(usersTable).values({ + id: userId, + username: "legacy-user", + email: "legacy@test.com", + name: "Legacy User", + passwordHash: hashedPassword, + }); + + // Try login with uppercase username + const ctx = createContext("/sign-in/username", { + username: "LEGACY-USER", + password, + }); + + await convertLegacyUserOnFirstLogin(ctx); + + // Verify migration happened + const oldUser = await db.query.usersTable.findFirst({ + where: { id: userId }, + }); + expect(oldUser).toBeUndefined(); + + const newUser = await db.query.usersTable.findFirst({ + where: { username: "legacy-user" }, + }); + expect(newUser).toBeDefined(); + }); + + test("should trim whitespace from username", async () => { + const password = "correct-password"; + const hashedPassword = await Bun.password.hash(password); + + const userId = crypto.randomUUID(); + await db.insert(usersTable).values({ + id: userId, + username: "legacy-user", + email: "legacy@test.com", + name: "Legacy User", + passwordHash: hashedPassword, + }); + + // Try login with whitespace + const ctx = createContext("/sign-in/username", { + username: " legacy-user ", + password, + }); + + await convertLegacyUserOnFirstLogin(ctx); + + // Verify migration happened + const oldUser = await db.query.usersTable.findFirst({ + where: { id: userId }, + }); + expect(oldUser).toBeUndefined(); + + const newUser = await db.query.usersTable.findFirst({ + where: { username: "legacy-user" }, + }); + expect(newUser).toBeDefined(); + }); +}); diff --git a/app/lib/auth-middlewares/convert-legacy-user.ts b/app/server/lib/auth-middlewares/convert-legacy-user.ts similarity index 100% rename from app/lib/auth-middlewares/convert-legacy-user.ts rename to app/server/lib/auth-middlewares/convert-legacy-user.ts diff --git a/app/lib/auth-middlewares/only-one-user.ts b/app/server/lib/auth-middlewares/only-one-user.ts similarity index 100% rename from app/lib/auth-middlewares/only-one-user.ts rename to app/server/lib/auth-middlewares/only-one-user.ts diff --git a/app/lib/auth.ts b/app/server/lib/auth.ts similarity index 94% rename from app/lib/auth.ts rename to app/server/lib/auth.ts index 8b418291..f2090731 100644 --- a/app/lib/auth.ts +++ b/app/server/lib/auth.ts @@ -10,12 +10,12 @@ import { admin, createAuthMiddleware, twoFactor, username, organization } from " import { UnauthorizedError } from "http-errors-enhanced"; import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user"; import { eq } from "drizzle-orm"; -import { config } from "../server/core/config"; -import { db } from "../server/db/db"; -import { cryptoUtils } from "../server/utils/crypto"; -import { organization as organizationTable, member, usersTable } from "../server/db/schema"; +import { config } from "../core/config"; +import { db } from "../db/db"; +import { cryptoUtils } from "../utils/crypto"; +import { organization as organizationTable, member, usersTable } from "../db/schema"; import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user"; -import { authService } from "../server/modules/auth/auth.service"; +import { authService } from "../modules/auth/auth.service"; export type AuthMiddlewareContext = MiddlewareContext>; diff --git a/app/server/modules/auth/auth.middleware.ts b/app/server/modules/auth/auth.middleware.ts index 90134cd5..feb606cd 100644 --- a/app/server/modules/auth/auth.middleware.ts +++ b/app/server/modules/auth/auth.middleware.ts @@ -1,5 +1,5 @@ import { createMiddleware } from "hono/factory"; -import { auth } from "~/lib/auth"; +import { auth } from "~/server/lib/auth"; import { db } from "~/server/db/db"; import { withContext } from "~/server/core/request-context"; diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts index 5f200b2a..ecfbc939 100644 --- a/app/server/modules/backups/__tests__/backups.service.test.ts +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -41,7 +41,7 @@ describe("execute backup", () => { await backupsService.executeBackup(schedule.id); // assert - const updatedSchedule = await backupsService.getSchedule(schedule.id); + const updatedSchedule = await backupsService.getScheduleById(schedule.id); expect(updatedSchedule.nextBackupAt).not.toBeNull(); const nextBackupAt = new Date(updatedSchedule.nextBackupAt ?? 0); @@ -129,7 +129,7 @@ describe("execute backup", () => { await backupsService.executeBackup(schedule.id); // assert - const updatedSchedule = await backupsService.getSchedule(schedule.id); + const updatedSchedule = await backupsService.getScheduleById(schedule.id); expect(updatedSchedule.lastBackupStatus).toBe("warning"); }); @@ -150,7 +150,7 @@ describe("execute backup", () => { await backupsService.executeBackup(schedule.id); // assert - const updatedSchedule = await backupsService.getSchedule(schedule.id); + const updatedSchedule = await backupsService.getScheduleById(schedule.id); expect(updatedSchedule.lastBackupStatus).toBe("error"); }); }); diff --git a/app/server/modules/backups/backups.controller.ts b/app/server/modules/backups/backups.controller.ts index e7a3a97a..d99481de 100644 --- a/app/server/modules/backups/backups.controller.ts +++ b/app/server/modules/backups/backups.controller.ts @@ -52,7 +52,7 @@ export const backupScheduleController = new Hono() }) .get("/:scheduleId", getBackupScheduleDto, async (c) => { const scheduleId = c.req.param("scheduleId"); - const schedule = await backupsService.getSchedule(Number(scheduleId)); + const schedule = await backupsService.getScheduleById(Number(scheduleId)); return c.json(schedule, 200); }) diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index d7402e47..ab1825a9 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -67,7 +67,7 @@ const listSchedules = async () => { return schedules; }; -const getSchedule = async (scheduleId: number) => { +const getScheduleById = async (scheduleId: number) => { const organizationId = getOrganizationId(); const schedule = await db.query.backupSchedulesTable.findFirst({ where: { AND: [{ id: scheduleId }, { organizationId }] }, @@ -81,6 +81,20 @@ const getSchedule = async (scheduleId: number) => { return schedule; }; +const getScheduleByShortId = async (shortId: string) => { + const organizationId = getOrganizationId(); + const schedule = await db.query.backupSchedulesTable.findFirst({ + where: { AND: [{ shortId }, { organizationId }] }, + with: { volume: true, repository: true }, + }); + + if (!schedule) { + throw new NotFoundError("Backup schedule not found"); + } + + return schedule; +}; + const createSchedule = async (data: CreateBackupScheduleBody) => { const organizationId = getOrganizationId(); if (!cron.validate(data.cronExpression)) { @@ -775,7 +789,7 @@ const reorderSchedules = async (scheduleIds: number[]) => { export const backupsService = { listSchedules, - getSchedule, + getScheduleById, createSchedule, updateSchedule, deleteSchedule, @@ -788,4 +802,5 @@ export const backupsService = { updateMirrors, getMirrorCompatibility, reorderSchedules, + getScheduleByShortId, }; diff --git a/app/server/modules/lifecycle/startup.ts b/app/server/modules/lifecycle/startup.ts index c272cac1..b830733a 100644 --- a/app/server/modules/lifecycle/startup.ts +++ b/app/server/modules/lifecycle/startup.ts @@ -12,7 +12,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"; +import { initAuth } from "~/server/lib/auth"; import { toMessage } from "~/server/utils/errors"; import { withContext } from "~/server/core/request-context"; diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index ba562d02..08c73088 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -40,8 +40,12 @@ import { type UpdateRepositoryDto, } from "./repositories.dto"; import { repositoriesService } from "./repositories.service"; +import { backupsService } from "../backups/backups.service"; import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone"; import { requireAuth } from "../auth/auth.middleware"; +import { computeRetentionCategories } from "../../utils/retention-categories"; +import { logger } from "~/server/utils/logger"; +import { toMessage } from "~/server/utils/errors"; export const repositoriesController = new Hono() .use(requireAuth) @@ -88,6 +92,22 @@ export const repositoriesController = new Hono() const { backupId } = c.req.valid("query"); const res = await repositoriesService.listSnapshots(id, backupId); + let retentionCategories: Map = new Map(); + if (backupId) { + try { + const schedule = await backupsService.getScheduleByShortId(backupId); + if (schedule?.retentionPolicy) { + const snapshotsForCategories = res.map((snapshot) => ({ + short_id: snapshot.short_id, + time: new Date(snapshot.time).getTime(), + })); + retentionCategories = computeRetentionCategories(snapshotsForCategories, schedule.retentionPolicy); + } + } catch (error) { + logger.warn(`Failed to fetch retention policy for backup ID ${backupId}`, toMessage(error)); + } + } + const snapshots = res.map((snapshot) => { const { summary } = snapshot; @@ -104,6 +124,7 @@ export const repositoriesController = new Hono() tags: snapshot.tags ?? [], size: summary?.total_bytes_processed || 0, time: new Date(snapshot.time).getTime(), + retentionCategories: retentionCategories.get(snapshot.short_id) ?? [], }; }); @@ -132,6 +153,7 @@ export const repositoriesController = new Hono() paths: snapshot.paths, size: snapshot.summary?.total_bytes_processed || 0, tags: snapshot.tags ?? [], + retentionCategories: [], summary: snapshot.summary, }; diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index 485db506..a8ca11ba 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -178,6 +178,7 @@ export const snapshotSchema = type({ size: "number", duration: "number", tags: "string[]", + retentionCategories: "string[]", }); const listSnapshotsResponse = snapshotSchema.array(); diff --git a/app/server/utils/retention-categories.ts b/app/server/utils/retention-categories.ts new file mode 100644 index 00000000..980be032 --- /dev/null +++ b/app/server/utils/retention-categories.ts @@ -0,0 +1,55 @@ +import { format } from "date-fns"; +import type { RetentionPolicy } from "../modules/backups/backups.dto"; + +export type RetentionCategory = "latest" | "hourly" | "daily" | "weekly" | "monthly" | "yearly"; + +interface SnapshotInfo { + short_id: string; + time: number; +} + +const RETENTION_RULES = [ + { prop: "keepHourly", tag: "hourly", fmt: "yyyy-MM-dd-HH" }, + { prop: "keepDaily", tag: "daily", fmt: "yyyy-MM-dd" }, + { prop: "keepWeekly", tag: "weekly", fmt: "RRRR-'W'II" }, + { prop: "keepMonthly", tag: "monthly", fmt: "yyyy-MM" }, + { prop: "keepYearly", tag: "yearly", fmt: "yyyy" }, +] as const; + +export const computeRetentionCategories = (snapshots: SnapshotInfo[], policy: RetentionPolicy | null) => { + const categories = new Map(); + + if (!policy || snapshots.length === 0) return categories; + + const sorted = [...snapshots].sort((a, b) => b.time - a.time); + + const addTag = (id: string, tag: RetentionCategory) => { + const tags = categories.get(id) ?? []; + if (!tags.includes(tag)) categories.set(id, [...tags, tag]); + }; + + if (policy.keepLast && policy.keepLast > 0) { + sorted.slice(0, 1).forEach((s) => addTag(s.short_id, "latest")); + } + + for (const { prop, tag, fmt } of RETENTION_RULES) { + const limit = policy[prop]; + if (!limit || limit <= 0) continue; + + const seenBuckets = new Set(); + let count = 0; + + for (const snapshot of sorted) { + if (count >= limit) break; + + const bucketKey = format(snapshot.time, fmt); + if (!seenBuckets.has(bucketKey)) { + seenBuckets.add(bucketKey); + addTag(snapshot.short_id, tag); + count++; + } + } + } + + return categories; +}; diff --git a/app/test/setup.ts b/app/test/setup.ts index e986c495..5ca37ec8 100644 --- a/app/test/setup.ts +++ b/app/test/setup.ts @@ -4,7 +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"; +import { initAuth } from "~/server/lib/auth"; setSchema(schema);