diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index ea2b5fe5..2a066da5 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -14,7 +14,7 @@ import { notificationsService } from "../notifications/notifications.service"; import { repoMutex } from "../../core/repository-mutex"; import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility"; import path from "node:path"; -import { generateShortId } from "~/server/utils/id"; +import { generateShortId, isValidShortId } from "~/server/utils/id"; const runningBackups = new Map(); @@ -82,7 +82,7 @@ const getSchedule = async (scheduleId: number) => { return schedule; }; -const createSchedule = async (data: CreateBackupScheduleBody) => { +const createSchedule = async (data: CreateBackupScheduleBody, providedShortId?: string) => { if (!cron.validate(data.cronExpression)) { throw new BadRequestError("Invalid cron expression"); } @@ -95,6 +95,25 @@ const createSchedule = async (data: CreateBackupScheduleBody) => { throw new ConflictError("A backup schedule with this name already exists"); } + // Use provided shortId if valid, otherwise generate a new one + let shortId: string; + if (providedShortId) { + if (!isValidShortId(providedShortId)) { + throw new BadRequestError(`Invalid shortId format: '${providedShortId}'. Must be 8 base64url characters.`); + } + const shortIdInUse = await db.query.backupSchedulesTable.findFirst({ + where: eq(backupSchedulesTable.shortId, providedShortId), + }); + if (shortIdInUse) { + throw new ConflictError( + `Schedule shortId '${providedShortId}' is already in use by schedule '${shortIdInUse.name}'`, + ); + } + shortId = providedShortId; + } else { + shortId = generateShortId(); + } + const volume = await db.query.volumesTable.findFirst({ where: eq(volumesTable.id, data.volumeId), }); @@ -127,7 +146,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => { includePatterns: data.includePatterns ?? [], oneFileSystem: data.oneFileSystem, nextBackupAt: nextBackupAt, - shortId: generateShortId(), + shortId, }) .returning(); diff --git a/app/server/modules/lifecycle/config-import.ts b/app/server/modules/lifecycle/config-import.ts index 537dfcff..587956c2 100644 --- a/app/server/modules/lifecycle/config-import.ts +++ b/app/server/modules/lifecycle/config-import.ts @@ -167,7 +167,9 @@ async function importVolumes(volumes: unknown[]): Promise { continue; } - await volumeService.createVolume(v.name, v.config as BackendConfig); + // Pass shortId from config if provided (for IaC reproducibility) + const shortId = typeof v.shortId === "string" ? v.shortId : undefined; + await volumeService.createVolume(v.name, v.config as BackendConfig, shortId); logger.info(`Initialized volume from config: ${v.name}`); result.succeeded++; @@ -257,10 +259,13 @@ async function importRepositories(repositories: unknown[]): Promise, + repoByName: Map, backupServiceModule: typeof import("../backups/backups.service"), ): Promise { const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; @@ -576,7 +586,11 @@ async function attachScheduleMirrors( const existingMirrors = await backupServiceModule.backupsService.getMirrors(scheduleId); const existingRepoIds = new Set(existingMirrors.map((m) => m.repositoryId)); - const mirrorConfigs: Array<{ repositoryId: string; repositoryName: string; enabled: boolean }> = []; + const mirrorConfigs: Array<{ + repositoryId: string; + repositoryName: string; + enabled: boolean; + }> = []; for (const m of mirrors) { if (!isRecord(m)) continue; diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 2656dbd4..1208b7ba 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -4,7 +4,7 @@ import { ConflictError, InternalServerError, NotFoundError } from "http-errors-e import { db } from "../../db/db"; import { repositoriesTable } from "../../db/schema"; import { toMessage } from "../../utils/errors"; -import { generateShortId } from "../../utils/id"; +import { generateShortId, isValidShortId } from "../../utils/id"; import { restic } from "../../utils/restic"; import { cryptoUtils } from "../../utils/crypto"; import { repoMutex } from "../../core/repository-mutex"; @@ -62,9 +62,32 @@ const encryptConfig = async (config: RepositoryConfig): Promise { +const createRepository = async ( + name: string, + config: RepositoryConfig, + compressionMode?: CompressionMode, + providedShortId?: string, +) => { const id = crypto.randomUUID(); - const shortId = generateShortId(); + + // Use provided shortId if valid, otherwise generate a new one + let shortId: string; + if (providedShortId) { + if (!isValidShortId(providedShortId)) { + throw new Error(`Invalid shortId format: '${providedShortId}'. Must be 8 base64url characters.`); + } + const shortIdInUse = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.shortId, providedShortId), + }); + if (shortIdInUse) { + throw new ConflictError( + `Repository shortId '${providedShortId}' is already in use by repository '${shortIdInUse.name}'`, + ); + } + shortId = providedShortId; + } else { + shortId = generateShortId(); + } let processedConfig = config; if (config.backend === "local" && !config.isExistingRepository) { diff --git a/app/server/modules/volumes/volume.service.ts b/app/server/modules/volumes/volume.service.ts index bde3be3b..32533fc1 100644 --- a/app/server/modules/volumes/volume.service.ts +++ b/app/server/modules/volumes/volume.service.ts @@ -8,7 +8,7 @@ import { db } from "../../db/db"; import { volumesTable } from "../../db/schema"; import { cryptoUtils } from "../../utils/crypto"; import { toMessage } from "../../utils/errors"; -import { generateShortId } from "../../utils/id"; +import { generateShortId, isValidShortId } from "../../utils/id"; import { getStatFs, type StatFs } from "../../utils/mountinfo"; import { withTimeout } from "../../utils/timeout"; import { createVolumeBackend } from "../backends/backend"; @@ -48,7 +48,7 @@ const listVolumes = async () => { return volumes; }; -const createVolume = async (name: string, backendConfig: BackendConfig) => { +const createVolume = async (name: string, backendConfig: BackendConfig, providedShortId?: string) => { const slug = slugify(name, { lower: true, strict: true }); const existing = await db.query.volumesTable.findFirst({ @@ -59,7 +59,22 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => { throw new ConflictError("Volume already exists"); } - const shortId = generateShortId(); + // Use provided shortId if valid, otherwise generate a new one + let shortId: string; + if (providedShortId) { + if (!isValidShortId(providedShortId)) { + throw new Error(`Invalid shortId format: '${providedShortId}'. Must be 8 base64url characters.`); + } + const shortIdInUse = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.shortId, providedShortId), + }); + if (shortIdInUse) { + throw new ConflictError(`Volume shortId '${providedShortId}' is already in use by volume '${shortIdInUse.name}'`); + } + shortId = providedShortId; + } else { + shortId = generateShortId(); + } const encryptedConfig = await encryptSensitiveFields(backendConfig); const [created] = await db diff --git a/app/server/utils/id.ts b/app/server/utils/id.ts index 18bc2030..4a3143e0 100644 --- a/app/server/utils/id.ts +++ b/app/server/utils/id.ts @@ -1,6 +1,13 @@ import crypto from "node:crypto"; -export const generateShortId = (length = 8): string => { +const SHORT_ID_LENGTH = 8; + +export const generateShortId = (length = SHORT_ID_LENGTH): string => { const bytesNeeded = Math.ceil((length * 3) / 4); return crypto.randomBytes(bytesNeeded).toString("base64url").slice(0, length); }; + +export const isValidShortId = (value: string, length = SHORT_ID_LENGTH): boolean => { + const regex = new RegExp(`^[A-Za-z0-9_-]{${length}}$`); + return regex.test(value); +};