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,
|
"when": 1768659604809,
|
||||||
"tag": "0040_tidy_maelstrom",
|
"tag": "0040_tidy_maelstrom",
|
||||||
"breakpoints": true
|
"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"),
|
image: text("image"),
|
||||||
displayUsername: text("display_username"),
|
displayUsername: text("display_username"),
|
||||||
twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).notNull().default(false),
|
twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
role: text("role"),
|
role: text("role").notNull().default("user"),
|
||||||
banned: integer("banned", { mode: "boolean" }).default(false),
|
banned: integer("banned", { mode: "boolean" }).notNull().default(false),
|
||||||
banReason: text("ban_reason"),
|
banReason: text("ban_reason"),
|
||||||
banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
|
banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
|
||||||
});
|
});
|
||||||
|
|
@ -142,7 +142,11 @@ export const member = sqliteTable(
|
||||||
role: text("role").default("member").notNull(),
|
role: text("role").default("member").notNull(),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).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(
|
export const invitation = sqliteTable(
|
||||||
|
|
@ -234,7 +238,7 @@ export type RepositoryInsert = typeof repositoriesTable.$inferInsert;
|
||||||
export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
||||||
id: int().primaryKey({ autoIncrement: true }),
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
shortId: text("short_id").notNull().unique(),
|
shortId: text("short_id").notNull().unique(),
|
||||||
name: text().notNull().unique(),
|
name: text().notNull(),
|
||||||
volumeId: int("volume_id")
|
volumeId: int("volume_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => volumesTable.id, { onDelete: "cascade" }),
|
.references(() => volumesTable.id, { onDelete: "cascade" }),
|
||||||
|
|
@ -280,7 +284,7 @@ export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
||||||
*/
|
*/
|
||||||
export const notificationDestinationsTable = sqliteTable("notification_destinations_table", {
|
export const notificationDestinationsTable = sqliteTable("notification_destinations_table", {
|
||||||
id: int().primaryKey({ autoIncrement: true }),
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
name: text().notNull().unique(),
|
name: text().notNull(),
|
||||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
type: text().$type<NotificationType>().notNull(),
|
type: text().$type<NotificationType>().notNull(),
|
||||||
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().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 {
|
export class CleanupDanglingMountsJob extends Job {
|
||||||
async run() {
|
async run() {
|
||||||
const organizations = await db.query.organization.findMany({});
|
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 = [];
|
const allVolumes = [];
|
||||||
for (const org of organizations) {
|
for (const org of organizations) {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const migrateTag = async (
|
||||||
scheduleName: string,
|
scheduleName: string,
|
||||||
): Promise<string | null> => {
|
): Promise<string | null> => {
|
||||||
const repoUrl = buildRepoUrl(repository.config);
|
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];
|
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 { eq } from "drizzle-orm";
|
||||||
import { db } from "../../../db/db";
|
import { db } from "../../../db/db";
|
||||||
import { organization, repositoriesTable, volumesTable, notificationDestinationsTable } from "../../../db/schema";
|
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.
|
* 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 };
|
type MigrationError = { name: string; error: string };
|
||||||
|
|
||||||
const rekeySecrets = async (config: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
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)) {
|
for (const [key, value] of Object.entries(rekeyedConfig)) {
|
||||||
if (typeof value === "string" && cryptoUtils.isEncrypted(value)) {
|
if (typeof value === "string" && cryptoUtils.isEncrypted(value)) {
|
||||||
const decrypted = await cryptoUtils.legacyDecrypt(value);
|
const decrypted = await legacyDecrypt(value);
|
||||||
rekeyedConfig[key] = await cryptoUtils.sealSecret(decrypted);
|
rekeyedConfig[key] = await cryptoUtils.sealSecret(decrypted);
|
||||||
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||||
rekeyedConfig[key] = await rekeySecrets(value as Record<string, unknown>);
|
rekeyedConfig[key] = await rekeySecrets(value as Record<string, unknown>);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { RESTIC_PASS_FILE } from "../core/constants";
|
import { config, getAppSecret } from "../core/config";
|
||||||
import { getAppSecret } from "../core/config";
|
|
||||||
import { isNodeJSErrnoException } from "./fs";
|
import { isNodeJSErrnoException } from "./fs";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
|
@ -140,36 +139,6 @@ const decrypt = async (encryptedData: string) => {
|
||||||
return decrypted.toString();
|
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.
|
* Resolves secret references and encrypted database values.
|
||||||
*
|
*
|
||||||
|
|
@ -218,9 +187,7 @@ const sealSecret = async (value: string): Promise<string> => {
|
||||||
};
|
};
|
||||||
|
|
||||||
async function deriveSecret(label: string) {
|
async function deriveSecret(label: string) {
|
||||||
const masterSecret = getAppSecret();
|
const derivedKey = await hkdf("sha256", config.appSecret, "", label, 32);
|
||||||
|
|
||||||
const derivedKey = await hkdf("sha256", masterSecret, "", label, 32);
|
|
||||||
|
|
||||||
return Buffer.from(derivedKey).toString("hex");
|
return Buffer.from(derivedKey).toString("hex");
|
||||||
}
|
}
|
||||||
|
|
@ -233,7 +200,6 @@ export const cryptoUtils = {
|
||||||
resolveSecret,
|
resolveSecret,
|
||||||
sealSecret,
|
sealSecret,
|
||||||
deriveSecret,
|
deriveSecret,
|
||||||
legacyDecrypt,
|
|
||||||
generateResticPassword,
|
generateResticPassword,
|
||||||
isEncrypted,
|
isEncrypted,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue