chore: pr feedbacks
This commit is contained in:
parent
203df04214
commit
24f195defe
8 changed files with 1703 additions and 43 deletions
29
app/drizzle/0041_motionless_storm.sql
Normal file
29
app/drizzle/0041_motionless_storm.sql
Normal 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`);
|
||||
1620
app/drizzle/meta/0041_snapshot.json
Normal file
1620
app/drizzle/meta/0041_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -288,6 +288,13 @@
|
|||
"when": 1768659604809,
|
||||
"tag": "0040_tidy_maelstrom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1768684588784,
|
||||
"tag": "0041_motionless_storm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue