chore: move root lib to server

This commit is contained in:
Nicolas Meienberger 2026-02-01 18:34:58 +01:00
parent 17f2178e36
commit c20aa62c3e
15 changed files with 387 additions and 16 deletions

View file

@ -1,7 +1,7 @@
import { createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
import { twoFactorClient, usernameClient, adminClient, organizationClient } from "better-auth/client/plugins"; import { twoFactorClient, usernameClient, adminClient, organizationClient } from "better-auth/client/plugins";
import { inferAdditionalFields } 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({ export const authClient = createAuthClient({
plugins: [ plugins: [

View file

@ -17,7 +17,7 @@ import { notificationsController } from "./modules/notifications/notifications.c
import { handleServiceError } from "./utils/errors"; import { handleServiceError } from "./utils/errors";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
import { config } from "./core/config"; import { config } from "./core/config";
import { auth } from "~/lib/auth"; import { auth } from "~/server/lib/auth";
export const generalDescriptor = (app: Hono) => export const generalDescriptor = (app: Hono) =>
openAPIRouteHandler(app, { openAPIRouteHandler(app, {

View file

@ -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<string, string>) => ({ 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();
});
});

View file

@ -10,12 +10,12 @@ import { admin, createAuthMiddleware, twoFactor, username, organization } from "
import { UnauthorizedError } from "http-errors-enhanced"; import { UnauthorizedError } from "http-errors-enhanced";
import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user"; import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { config } from "../server/core/config"; import { config } from "../core/config";
import { db } from "../server/db/db"; import { db } from "../db/db";
import { cryptoUtils } from "../server/utils/crypto"; import { cryptoUtils } from "../utils/crypto";
import { organization as organizationTable, member, usersTable } from "../server/db/schema"; import { organization as organizationTable, member, usersTable } from "../db/schema";
import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user"; 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<MiddlewareOptions, AuthContext<BetterAuthOptions>>; export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;

View file

@ -1,5 +1,5 @@
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import { auth } from "~/lib/auth"; import { auth } from "~/server/lib/auth";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { withContext } from "~/server/core/request-context"; import { withContext } from "~/server/core/request-context";

View file

@ -41,7 +41,7 @@ describe("execute backup", () => {
await backupsService.executeBackup(schedule.id); await backupsService.executeBackup(schedule.id);
// assert // assert
const updatedSchedule = await backupsService.getSchedule(schedule.id); const updatedSchedule = await backupsService.getScheduleById(schedule.id);
expect(updatedSchedule.nextBackupAt).not.toBeNull(); expect(updatedSchedule.nextBackupAt).not.toBeNull();
const nextBackupAt = new Date(updatedSchedule.nextBackupAt ?? 0); const nextBackupAt = new Date(updatedSchedule.nextBackupAt ?? 0);
@ -129,7 +129,7 @@ describe("execute backup", () => {
await backupsService.executeBackup(schedule.id); await backupsService.executeBackup(schedule.id);
// assert // assert
const updatedSchedule = await backupsService.getSchedule(schedule.id); const updatedSchedule = await backupsService.getScheduleById(schedule.id);
expect(updatedSchedule.lastBackupStatus).toBe("warning"); expect(updatedSchedule.lastBackupStatus).toBe("warning");
}); });
@ -150,7 +150,7 @@ describe("execute backup", () => {
await backupsService.executeBackup(schedule.id); await backupsService.executeBackup(schedule.id);
// assert // assert
const updatedSchedule = await backupsService.getSchedule(schedule.id); const updatedSchedule = await backupsService.getScheduleById(schedule.id);
expect(updatedSchedule.lastBackupStatus).toBe("error"); expect(updatedSchedule.lastBackupStatus).toBe("error");
}); });
}); });

View file

@ -52,7 +52,7 @@ export const backupScheduleController = new Hono()
}) })
.get("/:scheduleId", getBackupScheduleDto, async (c) => { .get("/:scheduleId", getBackupScheduleDto, async (c) => {
const scheduleId = c.req.param("scheduleId"); const scheduleId = c.req.param("scheduleId");
const schedule = await backupsService.getSchedule(Number(scheduleId)); const schedule = await backupsService.getScheduleById(Number(scheduleId));
return c.json<GetBackupScheduleDto>(schedule, 200); return c.json<GetBackupScheduleDto>(schedule, 200);
}) })

View file

@ -67,7 +67,7 @@ const listSchedules = async () => {
return schedules; return schedules;
}; };
const getSchedule = async (scheduleId: number) => { const getScheduleById = async (scheduleId: number) => {
const organizationId = getOrganizationId(); const organizationId = getOrganizationId();
const schedule = await db.query.backupSchedulesTable.findFirst({ const schedule = await db.query.backupSchedulesTable.findFirst({
where: { AND: [{ id: scheduleId }, { organizationId }] }, where: { AND: [{ id: scheduleId }, { organizationId }] },
@ -81,6 +81,20 @@ const getSchedule = async (scheduleId: number) => {
return schedule; 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 createSchedule = async (data: CreateBackupScheduleBody) => {
const organizationId = getOrganizationId(); const organizationId = getOrganizationId();
if (!cron.validate(data.cronExpression)) { if (!cron.validate(data.cronExpression)) {
@ -775,7 +789,7 @@ const reorderSchedules = async (scheduleIds: number[]) => {
export const backupsService = { export const backupsService = {
listSchedules, listSchedules,
getSchedule, getScheduleById,
createSchedule, createSchedule,
updateSchedule, updateSchedule,
deleteSchedule, deleteSchedule,
@ -788,4 +802,5 @@ export const backupsService = {
updateMirrors, updateMirrors,
getMirrorCompatibility, getMirrorCompatibility,
reorderSchedules, reorderSchedules,
getScheduleByShortId,
}; };

View file

@ -12,7 +12,7 @@ import { repositoriesService } from "../repositories/repositories.service";
import { notificationsService } from "../notifications/notifications.service"; import { notificationsService } from "../notifications/notifications.service";
import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount"; import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
import { cache } from "~/server/utils/cache"; import { cache } from "~/server/utils/cache";
import { initAuth } from "~/lib/auth"; import { initAuth } from "~/server/lib/auth";
import { toMessage } from "~/server/utils/errors"; import { toMessage } from "~/server/utils/errors";
import { withContext } from "~/server/core/request-context"; import { withContext } from "~/server/core/request-context";

View file

@ -40,8 +40,12 @@ import {
type UpdateRepositoryDto, type UpdateRepositoryDto,
} from "./repositories.dto"; } from "./repositories.dto";
import { repositoriesService } from "./repositories.service"; import { repositoriesService } from "./repositories.service";
import { backupsService } from "../backups/backups.service";
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone"; import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
import { requireAuth } from "../auth/auth.middleware"; 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() export const repositoriesController = new Hono()
.use(requireAuth) .use(requireAuth)
@ -88,6 +92,22 @@ export const repositoriesController = new Hono()
const { backupId } = c.req.valid("query"); const { backupId } = c.req.valid("query");
const res = await repositoriesService.listSnapshots(id, backupId); const res = await repositoriesService.listSnapshots(id, backupId);
let retentionCategories: Map<string, string[]> = 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 snapshots = res.map((snapshot) => {
const { summary } = snapshot; const { summary } = snapshot;
@ -104,6 +124,7 @@ export const repositoriesController = new Hono()
tags: snapshot.tags ?? [], tags: snapshot.tags ?? [],
size: summary?.total_bytes_processed || 0, size: summary?.total_bytes_processed || 0,
time: new Date(snapshot.time).getTime(), time: new Date(snapshot.time).getTime(),
retentionCategories: retentionCategories.get(snapshot.short_id) ?? [],
}; };
}); });
@ -132,6 +153,7 @@ export const repositoriesController = new Hono()
paths: snapshot.paths, paths: snapshot.paths,
size: snapshot.summary?.total_bytes_processed || 0, size: snapshot.summary?.total_bytes_processed || 0,
tags: snapshot.tags ?? [], tags: snapshot.tags ?? [],
retentionCategories: [],
summary: snapshot.summary, summary: snapshot.summary,
}; };

View file

@ -178,6 +178,7 @@ export const snapshotSchema = type({
size: "number", size: "number",
duration: "number", duration: "number",
tags: "string[]", tags: "string[]",
retentionCategories: "string[]",
}); });
const listSnapshotsResponse = snapshotSchema.array(); const listSnapshotsResponse = snapshotSchema.array();

View file

@ -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<string, RetentionCategory[]>();
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<string>();
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;
};

View file

@ -4,7 +4,7 @@ import path from "node:path";
import { cwd } from "node:process"; import { cwd } from "node:process";
import * as schema from "~/server/db/schema"; import * as schema from "~/server/db/schema";
import { db, setSchema } from "~/server/db/db"; import { db, setSchema } from "~/server/db/db";
import { initAuth } from "~/lib/auth"; import { initAuth } from "~/server/lib/auth";
setSchema(schema); setSchema(schema);