chore: move root lib to server
This commit is contained in:
parent
17f2178e36
commit
c20aa62c3e
15 changed files with 387 additions and 16 deletions
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<GetBackupScheduleDto>(schedule, 200);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 { 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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ export const snapshotSchema = type({
|
|||
size: "number",
|
||||
duration: "number",
|
||||
tags: "string[]",
|
||||
retentionCategories: "string[]",
|
||||
});
|
||||
|
||||
const listSnapshotsResponse = snapshotSchema.array();
|
||||
|
|
|
|||
55
app/server/utils/retention-categories.ts
Normal file
55
app/server/utils/retention-categories.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue