fix(backups): показать отсутствие файлов и защитить удаление записей

This commit is contained in:
Denozordec committed 2026-10-14 14:30:33 +07:00
1 parent e452e528e1
commit 6617b776bb
26 files changed
+420 -36

No files matched your search

+11 -4
View File
@@ -215,10 +215,17 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
}
})
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const hit = await (deps.delete ?? deleteBackupRecord)(req.params.id)
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
return reply.status(204).send()
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema, querystring: z.object({ recordOnly: z.enum(["true", "false"]).optional() }) } }, async (req, reply) => {
try {
const recordOnly = req.query.recordOnly === "true"
const hit = await (deps.delete ?? deleteBackupRecord)(req.params.id, { recordOnly })
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
reply.header("X-Backup-Delete-Mode", recordOnly ? "record-only" : "file-and-record")
return reply.status(204).send()
} catch (error) {
if (error instanceof BackupStorageConflictError) return reply.status(409).send({ error: error.message })
throw error
}
})
}
}
@@ -0,0 +1,28 @@
import assert from "node:assert/strict"
import { mkdtemp, writeFile, mkdir, rm, lstat } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { withBackupLocalFileStates } from "./backup-local-file-state.js"
import type { BackupItemDto } from "@mmapp/contracts/backups"
const directory = await mkdtemp(path.join(os.tmpdir(), "mm-backup-state-"))
const row = (filename: string, storage: BackupItemDto["storage"] = "local"): BackupItemDto => ({ id: filename, filename, serverId: 1, serverName: "fixture", sizeBytes: 0, createdAt: "2026-10-14T00:00:00Z", kind: "manual", storage })
try {
await writeFile(path.join(directory, "available.rsc"), "# own fixture\n")
await mkdir(path.join(directory, "directory.rsc"))
const rows = [row("available.rsc"), row("missing.rsc"), row("missing-both.rsc", "both"), row("remote.rsc", "s3"), row("directory.rsc"), row("../outside.rsc")]
const result = await withBackupLocalFileStates(rows, directory)
assert.deepEqual(result.map(r => r.localFileStatus), ["available", "missing", "missing", "not-stored", "unknown", "unknown"])
assert(rows.every(r => r.localFileStatus === undefined))
assert.equal((await lstat(path.join(directory, "available.rsc"))).isFile(), true)
const denied = await withBackupLocalFileStates([row("denied.rsc")], directory, (async () => { throw Object.assign(new Error("denied"), { code: "EACCES" }) }) as typeof lstat)
assert.equal(denied[0].localFileStatus, "unknown")
let active = 0, max = 0
const inspect = (async () => { active++; max = Math.max(max, active); await new Promise(r => setTimeout(r, 1)); active--; return lstat(path.join(directory, "available.rsc")) }) as typeof lstat
const many = await withBackupLocalFileStates(Array.from({ length: 100 }, (_, i) => row(`${i}.rsc`)), directory, inspect)
assert.equal(many.length, 100); assert(max <= 8); assert.equal(active, 0)
let touched = false
await withBackupLocalFileStates([row("remote-only.rsc", "s3"), row("../outside.rsc")], directory, (async () => { touched = true; throw Error("unexpected") }) as typeof lstat)
assert.equal(touched, false)
console.log("backup local states: real file/missing/both/remote/directory/path, permission error and bounded100 passed")
} finally { await rm(directory, { recursive: true, force: true }) }
@@ -0,0 +1,28 @@
import { lstat } from "node:fs/promises"
import type { BackupItemDto } from "@mmapp/contracts/backups"
import { backupDeletionPath } from "./backup-deletion.js"
/** Inspect local copies without probing S3; preserve unknown states and bound filesystem work. */
export async function withBackupLocalFileStates<T extends BackupItemDto>(
rows: T[], directory: string, inspect = lstat,
): Promise<T[]> {
const result = [...rows]
let cursor = 0
await Promise.all(Array.from({ length: Math.min(8, rows.length) }, async () => {
while (cursor < rows.length) {
const index = cursor++, row = rows[index]
let status: BackupItemDto["localFileStatus"] = "not-stored"
if (row.storage !== "s3") {
try {
const info = await inspect(backupDeletionPath(directory, row.filename))
status = info.isFile() ? "available" : "unknown"
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
status = code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unknown"
}
}
result[index] = { ...row, localFileStatus: status }
}
}))
return result
}
+31 -8
View File
@@ -6,13 +6,15 @@ import type {
BackupScheduleSettingsDto,
BackupStorageSettingsDto,
PutBackupStorageSettings,
BackupLocalFileStatus,
} from "@mmapp/contracts/backups"
import { mapBackupEntry } from "./backup-dto.js"
import { pruneBackupRecords } from "./backup-prune.js"
import { assertRestorableBackupScript, importBackupOnce } from "./backup-restore.js"
import { deleteStoredBackup } from "./backup-deletion.js"
import { backupDeletionPath, deleteStoredBackup } from "./backup-deletion.js"
import { withBackupLocalFileStates } from "./backup-local-file-state.js"
import { assertBackupStorageConnectionChange, planBackupStorageUpdate } from "./backup-storage-update.js"
import { withBackupStorageOperation, withBackupStorageSettingsUpdate } from "./backup-storage-operation.js"
import { BackupStorageConflictError, withBackupStorageOperation, withBackupStorageSettingsUpdate } from "./backup-storage-operation.js"
import { db } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js"
@@ -47,6 +49,7 @@ export type BackupMeta = {
storage: "local" | "s3" | "both"
s3Key?: string | null
uploadError?: string | null
localFileStatus?: BackupLocalFileStatus
}
type BackupRow = typeof backupEntries.$inferSelect
@@ -59,12 +62,12 @@ export async function ensureBackupStorage(): Promise<void> {
export async function listBackups(): Promise<BackupMeta[]> {
const rows = await db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt))
return rows.map(rowToMeta)
return withBackupLocalFileStates(rows.map(rowToMeta), BACKUPS_DIR)
}
export async function getBackupById(id: string): Promise<BackupMeta | null> {
const row = (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
return row ? rowToMeta(row) : null
return row ? (await withBackupLocalFileStates([rowToMeta(row)], BACKUPS_DIR))[0] : null
}
export async function insertBackup(meta: BackupMeta): Promise<void> {
@@ -92,14 +95,23 @@ async function getBackupRow(id: string): Promise<BackupRow | undefined> {
return (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
}
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
return withBackupStorageOperation(() => deleteStoredBackupRecord(id))
export async function deleteBackupRecord(id: string, options: { recordOnly?: boolean } = {}): Promise<BackupMeta | null> {
return withBackupStorageOperation(() => deleteStoredBackupRecord(id, Boolean(options.recordOnly)))
}
async function deleteStoredBackupRecord(id: string): Promise<BackupMeta | null> {
async function deleteStoredBackupRecord(id: string, recordOnly: boolean): Promise<BackupMeta | null> {
const row = await getBackupRow(id)
if (!row) return null
const meta = rowToMeta(row)
if (recordOnly) {
const [current] = await withBackupLocalFileStates([meta], BACKUPS_DIR)
if (row.storage !== "local" || row.s3Key || current.localFileStatus !== "missing") {
throw new BackupStorageConflictError("Файл больше не подтверждён отсутствующим. Обновите историю перед удалением записи.")
}
// Never remove a file in this mode, even if an external writer recreates it after the check.
await db.delete(backupEntries).where(eq(backupEntries.id, id))
return meta
}
await deleteStoredBackup(row, BACKUPS_DIR, {
removeS3: async key => {
const cfg = await getS3ConfigIfEnabled()
@@ -481,6 +493,7 @@ export async function readBackupContent(meta: BackupMeta): Promise<Buffer> {
}
async function readStoredBackupContent(meta: BackupMeta): Promise<Buffer> {
let remoteUnavailable = false
if ((meta.storage === "s3" || meta.storage === "both") && meta.s3Key) {
try {
const cfg = await getS3ConfigIfEnabled()
@@ -488,11 +501,21 @@ async function readStoredBackupContent(meta: BackupMeta): Promise<Buffer> {
const client = createS3ClientFromConfig(cfg)
return await s3GetObject(client, cfg.bucket, meta.s3Key)
}
remoteUnavailable = true
} catch {
remoteUnavailable = true
/* fallback: локальная копия, если есть */
}
}
return await readFile(path.join(BACKUPS_DIR, meta.filename))
try {
return await readFile(backupDeletionPath(BACKUPS_DIR, meta.filename))
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === "ENOENT" || code === "ENOTDIR") throw new Error(remoteUnavailable
? "Файл недоступен в S3, локальная копия отсутствует. Проверьте подключение к хранилищу."
: "Локальный файл резервной копии отсутствует. Создайте новую копию или восстановите файл в хранилище.")
throw error
}
}
export async function restoreBackupToDevice(id: string): Promise<{ backupId: string; serverId: number; filename: string; serverName: string }> {