chore: pr feedbacks

This commit is contained in:
Nicolas Meienberger 2026-01-17 22:19:28 +01:00
parent 203df04214
commit 24f195defe
8 changed files with 1703 additions and 43 deletions

View file

@ -0,0 +1,29 @@
DROP INDEX `backup_schedules_table_name_unique`;--> statement-breakpoint
DROP INDEX `notification_destinations_table_name_unique`;--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_users_table` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text,
`has_downloaded_restic_password` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`email_verified` integer DEFAULT false NOT NULL,
`image` text,
`display_username` text,
`two_factor_enabled` integer DEFAULT false NOT NULL,
`role` text DEFAULT 'user' NOT NULL,
`banned` integer DEFAULT false NOT NULL,
`ban_reason` text,
`ban_expires` integer
);
--> statement-breakpoint
INSERT INTO `__new_users_table`("id", "username", "password_hash", "has_downloaded_restic_password", "created_at", "updated_at", "name", "email", "email_verified", "image", "display_username", "two_factor_enabled", "role", "banned", "ban_reason", "ban_expires") SELECT "id", "username", "password_hash", "has_downloaded_restic_password", "created_at", "updated_at", "name", "email", "email_verified", "image", "display_username", "two_factor_enabled", "role", "banned", "ban_reason", "ban_expires" FROM `users_table`;--> statement-breakpoint
DROP TABLE `users_table`;--> statement-breakpoint
ALTER TABLE `__new_users_table` RENAME TO `users_table`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_table_email_unique` ON `users_table` (`email`);--> statement-breakpoint
CREATE UNIQUE INDEX `member_org_user_uidx` ON `member` (`organization_id`,`user_id`);

File diff suppressed because it is too large Load diff

View file

@ -288,6 +288,13 @@
"when": 1768659604809,
"tag": "0040_tidy_maelstrom",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1768684588784,
"tag": "0041_motionless_storm",
"breakpoints": true
}
]
}

View file

@ -31,8 +31,8 @@ export const usersTable = sqliteTable("users_table", {
image: text("image"),
displayUsername: text("display_username"),
twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).notNull().default(false),
role: text("role"),
banned: integer("banned", { mode: "boolean" }).default(false),
role: text("role").notNull().default("user"),
banned: integer("banned", { mode: "boolean" }).notNull().default(false),
banReason: text("ban_reason"),
banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
});
@ -142,7 +142,11 @@ export const member = sqliteTable(
role: text("role").default("member").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
},
(table) => [index("member_organizationId_idx").on(table.organizationId), index("member_userId_idx").on(table.userId)],
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
uniqueIndex("member_org_user_uidx").on(table.organizationId, table.userId),
],
);
export const invitation = sqliteTable(
@ -234,7 +238,7 @@ export type RepositoryInsert = typeof repositoriesTable.$inferInsert;
export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
id: int().primaryKey({ autoIncrement: true }),
shortId: text("short_id").notNull().unique(),
name: text().notNull().unique(),
name: text().notNull(),
volumeId: int("volume_id")
.notNull()
.references(() => volumesTable.id, { onDelete: "cascade" }),
@ -280,7 +284,7 @@ export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
*/
export const notificationDestinationsTable = sqliteTable("notification_destinations_table", {
id: int().primaryKey({ autoIncrement: true }),
name: text().notNull().unique(),
name: text().notNull(),
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
type: text().$type<NotificationType>().notNull(),
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(),

View file

@ -13,6 +13,10 @@ import { db } from "../db/db";
export class CleanupDanglingMountsJob extends Job {
async run() {
const organizations = await db.query.organization.findMany({});
if (organizations.length === 0) {
logger.warn("No organizations found; skipping dangling mount cleanup to avoid false positives.");
return { done: true, timestamp: new Date() };
}
const allVolumes = [];
for (const org of organizations) {

View file

@ -13,7 +13,7 @@ const migrateTag = async (
scheduleName: string,
): Promise<string | null> => {
const repoUrl = buildRepoUrl(repository.config);
const env = await buildEnv(repository.config, "org-id");
const env = await buildEnv(repository.config, repository.organizationId);
const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag];

View file

@ -1,3 +1,4 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "../../../db/db";
import { organization, repositoriesTable, volumesTable, notificationDestinationsTable } from "../../../db/schema";
@ -20,6 +21,35 @@ import { RESTIC_PASS_FILE } from "~/server/core/constants";
* database encryption is decoupled from restic repository passwords.
*/
const legacyDecrypt = async (encryptedData: string): Promise<string> => {
const keyLength = 32;
const algorithm = "aes-256-gcm" as const;
if (!cryptoUtils.isEncrypted(encryptedData)) {
return encryptedData;
}
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
const parts = encryptedData.split(":").slice(1); // Remove prefix
const saltHex = parts.shift() as string;
const salt = Buffer.from(saltHex, "hex");
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256");
const iv = Buffer.from(parts.shift() as string, "hex");
const encrypted = Buffer.from(parts.shift() as string, "hex");
const tag = Buffer.from(parts.shift() as string, "hex");
const decipher = crypto.createDecipheriv(algorithm, key, iv);
decipher.setAuthTag(tag);
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
};
type MigrationError = { name: string; error: string };
const rekeySecrets = async (config: Record<string, unknown>): Promise<Record<string, unknown>> => {
@ -27,7 +57,7 @@ const rekeySecrets = async (config: Record<string, unknown>): Promise<Record<str
for (const [key, value] of Object.entries(rekeyedConfig)) {
if (typeof value === "string" && cryptoUtils.isEncrypted(value)) {
const decrypted = await cryptoUtils.legacyDecrypt(value);
const decrypted = await legacyDecrypt(value);
rekeyedConfig[key] = await cryptoUtils.sealSecret(decrypted);
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
rekeyedConfig[key] = await rekeySecrets(value as Record<string, unknown>);

View file

@ -1,8 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { RESTIC_PASS_FILE } from "../core/constants";
import { getAppSecret } from "../core/config";
import { config, getAppSecret } from "../core/config";
import { isNodeJSErrnoException } from "./fs";
import { promisify } from "node:util";
@ -140,36 +139,6 @@ const decrypt = async (encryptedData: string) => {
return decrypted.toString();
};
/**
* Decrypts data using the legacy restic passfile.
* Used during migration to re-key existing secrets.
*/
const legacyDecrypt = async (encryptedData: string): Promise<string> => {
if (!isEncrypted(encryptedData)) {
return encryptedData;
}
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
const parts = encryptedData.split(":").slice(1); // Remove prefix
const saltHex = parts.shift() as string;
const salt = Buffer.from(saltHex, "hex");
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256");
const iv = Buffer.from(parts.shift() as string, "hex");
const encrypted = Buffer.from(parts.shift() as string, "hex");
const tag = Buffer.from(parts.shift() as string, "hex");
const decipher = crypto.createDecipheriv(algorithm, key, iv);
decipher.setAuthTag(tag);
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
};
/**
* Resolves secret references and encrypted database values.
*
@ -218,9 +187,7 @@ const sealSecret = async (value: string): Promise<string> => {
};
async function deriveSecret(label: string) {
const masterSecret = getAppSecret();
const derivedKey = await hkdf("sha256", masterSecret, "", label, 32);
const derivedKey = await hkdf("sha256", config.appSecret, "", label, 32);
return Buffer.from(derivedKey).toString("hex");
}
@ -233,7 +200,6 @@ export const cryptoUtils = {
resolveSecret,
sealSecret,
deriveSecret,
legacyDecrypt,
generateResticPassword,
isEncrypted,
};