fix(restore): discover backups from disk, not just the DB

The Restore wizard's "Choose Backup to Restore" list was driven only
by the backup_runs table. After `docker compose down -v` (the disaster
this whole hardening effort is designed to recover from), the DB is
empty and the wizard shows "No backups found in selected source" —
exactly when it's needed most. The manifest JSONs are still on disk;
the wizard just can't see them.

Adds disk-first discovery:
  - Walks backup_destination_path AND backup_manifest_path (manifests
    can live in a sibling directory under the canonical
    <root>/manifests/backup-manifest-<id>.json layout). Depth-limited
    recursion (3 levels) so the scan doesn't enumerate the photo tree.
  - Matches backup-manifest-*.json|yaml AND legacy bare manifest.json.
  - Parses each manifest for real metadata (timestamp, size, file
    count, database.backup_file presence) instead of showing the
    admin opaque filenames.
  - Layers in surviving backup_runs rows, deduping by manifest_id.

Applied to both GET /available-backups (legacy) and POST /list-backups
(the one the frontend actually calls). Same helper, two call sites.

Side benefit: each returned row now carries `databaseIncluded` — so a
future Restore UI iteration can show a "this backup has no DB dump"
warning before the admin picks a files-only backup. Exactly the
surface that would have caught Ralf's original four files-only
manifests if it had existed.
This commit is contained in:
Luca
2026-05-30 04:07:40 +02:00
parent ed7ab61b90
commit f664fea60c
+257 -95
View File
@@ -348,51 +348,7 @@ router.get('/run/:id/report', requirePermission('backup.view'), async (req, res)
*/
router.get('/available-backups', requirePermission('backup.view'), async (req, res) => {
try {
const backups = [];
// Get local file backups
const backupConfig = await getBackupConfig();
if (backupConfig.backup_destination_type === 'local' && backupConfig.backup_destination_path) {
try {
const files = await fs.readdir(backupConfig.backup_destination_path);
for (const file of files) {
if (file.endsWith('.json') || file.endsWith('.yaml')) {
const filePath = path.join(backupConfig.backup_destination_path, file);
const stats = await fs.stat(filePath);
backups.push({
type: 'local',
name: file,
path: filePath,
size: stats.size,
modified: stats.mtime
});
}
}
} catch (error) {
logger.warn('Failed to list local backups:', error);
}
}
// Get database backups from backup_runs table
const backupRuns = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.limit(20);
for (const run of backupRuns) {
backups.push({
type: run.manifest_path.startsWith('s3://') ? 's3' : 'local',
name: `Backup ${run.completed_at}`,
path: run.manifest_path,
manifestId: run.manifest_id,
size: run.total_size_bytes,
filesCount: run.files_backed_up,
duration: run.duration_seconds,
completed: run.completed_at
});
}
const backups = await discoverAvailableBackups();
res.json({
success: true,
data: backups
@@ -406,6 +362,213 @@ router.get('/available-backups', requirePermission('backup.view'), async (req, r
}
});
/**
* Discover restorable backups by walking the configured destination
* directory + harvesting the backup_runs table.
*
* **Why we recurse the disk first, DB second**
*
* The disk is the source of truth for restore. After a disaster
* (`docker compose down -v`, drive corruption, fresh install) the
* `backup_runs` table is empty — but the manifest JSONs are exactly
* what's left on disk for an admin to recover from. A wizard that
* only reads the DB shows "No backups found" precisely when the
* admin needs it most. So we walk first, dedupe-by-manifestId
* against any surviving DB rows, and present a unified list.
*
* Discovery rules:
* - Walks `backup_destination_path` AND `backup_manifest_path` if
* they're distinct (manifests can live in a sibling directory).
* - Recurses up to 3 levels deep — enough to find
* `<root>/manifests/backup-manifest-<id>.json` (the default
* layout) without scanning the entire photo tree.
* - Matches manifest files by glob: `backup-manifest-*.json`,
* `backup-manifest-*.yaml`, and the legacy bare `manifest.json`.
* - Parses each manifest to extract real metadata (timestamp,
* size, file count, source type) instead of showing the admin
* a list of opaque filenames.
*
* Returns: array of `{ type, name, path, manifestId, size,
* filesCount, completed, source: 'disk' | 'db' }`.
*/
async function discoverAvailableBackups() {
const backupConfig = await getBackupConfig();
const backups = [];
const seenManifestIds = new Set();
if (backupConfig.backup_destination_type === 'local') {
const roots = new Set();
if (backupConfig.backup_destination_path) roots.add(backupConfig.backup_destination_path);
if (backupConfig.backup_manifest_path) roots.add(backupConfig.backup_manifest_path);
for (const root of roots) {
try {
const manifestPaths = await walkForManifests(root, 3);
for (const filePath of manifestPaths) {
try {
const parsed = await parseManifestMetadata(filePath);
if (parsed.manifestId) seenManifestIds.add(parsed.manifestId);
backups.push(parsed);
} catch (err) {
// Don't fail discovery because ONE manifest is corrupt —
// surface the file with a note so the admin sees something
// is wrong and can investigate.
logger.warn(`Manifest unreadable at ${filePath}: ${err.message}`);
const stats = await fs.stat(filePath).catch(() => null);
backups.push({
type: 'local',
name: path.basename(filePath),
path: filePath,
manifestId: null,
size: stats?.size || 0,
filesCount: null,
completed: stats?.mtime || null,
source: 'disk',
corrupt: true,
error: err.message,
});
}
}
} catch (err) {
logger.warn(`Could not scan backup root ${root}: ${err.message}`);
}
}
}
// Layer in surviving DB rows, deduping by manifest_id so we don't
// show the same backup twice with different shapes.
const backupRuns = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.limit(50);
for (const run of backupRuns) {
if (run.manifest_id && seenManifestIds.has(run.manifest_id)) continue;
backups.push({
type: run.manifest_path.startsWith('s3://') ? 's3' : 'local',
name: `Backup ${run.completed_at}`,
path: run.manifest_path,
manifestId: run.manifest_id,
size: run.total_size_bytes,
filesCount: run.files_backed_up,
duration: run.duration_seconds,
completed: run.completed_at,
source: 'db',
});
}
// Most recent first.
backups.sort((a, b) => {
const aTime = a.completed ? new Date(a.completed).getTime() : 0;
const bTime = b.completed ? new Date(b.completed).getTime() : 0;
return bTime - aTime;
});
return backups;
}
/**
* Recursive manifest finder. Depth-limited so we don't enumerate
* thousands of photo files. Yields absolute paths.
*/
async function walkForManifests(dir, maxDepth, depth = 0) {
if (depth > maxDepth) return [];
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
const out = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip obvious noise: photo trees, node_modules, hidden dirs.
if (entry.name === 'events' || entry.name === 'business-docs'
|| entry.name === 'thumbnails' || entry.name === 'previews'
|| entry.name === 'heroes' || entry.name === 'uploads'
|| entry.name.startsWith('.')
|| entry.name === 'node_modules') continue;
out.push(...await walkForManifests(full, maxDepth, depth + 1));
} else if (entry.isFile() && isManifestFilename(entry.name)) {
out.push(full);
}
}
return out;
}
function isManifestFilename(name) {
// Canonical: backup-manifest-<id>.json / .yaml
// Legacy: manifest.json (inside backup-<id>/manifest.json layout)
// Be liberal in what we accept — admin may have renamed.
if (/^backup-manifest-.+\.(json|ya?ml)$/i.test(name)) return true;
if (/^manifest\.(json|ya?ml)$/i.test(name)) return true;
return false;
}
/**
* Parse a manifest file and pull out the fields the wizard wants.
* Tolerates schema drift across manifest versions (v1, v2) by
* checking multiple shapes.
*/
async function parseManifestMetadata(filePath) {
const raw = await fs.readFile(filePath, 'utf8');
let parsed;
if (filePath.toLowerCase().endsWith('.json')) {
parsed = JSON.parse(raw);
} else {
// Minimal YAML support — most admins use JSON; only do require()
// if a .yaml manifest is actually present.
const yaml = require('js-yaml');
parsed = yaml.load(raw);
}
const stats = await fs.stat(filePath);
// v2 shape: { manifest: { id, timestamp }, backup: { ... }, files: [...], database: { ... } }
// v1 shape: { backup_id, started_at, files: [...] } (older)
const manifestId =
parsed?.manifest?.id
|| parsed?.backup?.id
|| parsed?.backup_id
|| null;
const completed =
parsed?.backup?.completed_at
|| parsed?.manifest?.timestamp
|| parsed?.completed_at
|| stats.mtime;
const filesCount =
(Array.isArray(parsed?.files) ? parsed.files.length : null)
?? parsed?.backup?.total_files
?? null;
const totalSizeBytes =
parsed?.backup?.total_size_bytes
?? parsed?.total_size_bytes
?? null;
return {
type: 'local',
name: path.basename(filePath),
path: filePath,
manifestId,
size: totalSizeBytes ?? stats.size,
filesCount,
completed,
source: 'disk',
databaseIncluded: Boolean(parsed?.database?.backup_file),
// Helpful for the UI: lets it show "This backup has no DB" warning
// — exactly the surface that would have caught Ralf's original
// four files-only manifests if it had existed.
schemaVersion: parsed?.manifest?.version || parsed?.version || '1.0',
};
}
/**
* List backups for restore (POST version for frontend compatibility)
* Accepts source type in request body
@@ -413,59 +576,58 @@ router.get('/available-backups', requirePermission('backup.view'), async (req, r
router.post('/list-backups', requirePermission('backup.view'), async (req, res) => {
try {
const { source } = req.body; // 'local', 's3', or undefined for all
const backups = [];
// Get backup configuration
const backupConfig = await getBackupConfig();
// Use the same disk-first discovery the GET endpoint uses so that
// a fresh post-`docker compose down -v` install (empty backup_runs
// table) can still see what's on disk. The whole point of restore
// is "the DB is broken, rebuild it from disk" — a wizard that
// only queries the DB shows "No backups found" exactly when it's
// needed most. See discoverAvailableBackups for the full rationale.
const discovered = await discoverAvailableBackups();
// Get database backups from backup_runs table
const backupRuns = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.limit(20);
const filtered = source
? discovered.filter((b) => b.type === source)
: discovered;
for (const run of backupRuns) {
const isS3 = run.manifest_path.startsWith('s3://');
const backupType = isS3 ? 's3' : 'local';
// Filter by source if specified
if (source && source !== backupType) {
continue;
}
backups.push({
id: run.id,
type: backupType,
name: `Backup from ${new Date(run.completed_at).toLocaleString()}`,
path: run.manifest_path,
manifest_path: run.manifest_path,
manifestId: run.manifest_id,
manifestPath: run.manifest_path,
size: parseInt(run.total_size_bytes) || 0,
total_size: parseInt(run.total_size_bytes) || 0,
total_size_bytes: parseInt(run.total_size_bytes) || 0,
filesCount: run.files_backed_up || 0,
files_backed_up: run.files_backed_up || 0,
duration: run.duration_seconds,
duration_seconds: run.duration_seconds,
// Frontend expects snake_case date fields
created_at: run.completed_at,
completed_at: run.completed_at,
started_at: run.started_at,
// camelCase aliases
completedAt: run.completed_at,
startedAt: run.started_at,
// Backup metadata
status: run.status,
backup_type: run.backup_type,
backupType: run.backup_type,
backup_mode: run.backup_mode,
backupMode: run.backup_mode,
app_version: run.app_version,
appVersion: run.app_version
});
}
// Shape for frontend compatibility — preserves every alias the
// frontend was already reading (snake_case + camelCase), so the
// UI rendering doesn't have to change.
const backups = filtered.map((b) => ({
id: b.manifestId || null,
type: b.type,
name: b.completed
? `Backup from ${new Date(b.completed).toLocaleString()}`
: b.name,
path: b.path,
manifest_path: b.path,
manifestId: b.manifestId,
manifestPath: b.path,
size: parseInt(b.size) || 0,
total_size: parseInt(b.size) || 0,
total_size_bytes: parseInt(b.size) || 0,
filesCount: b.filesCount || 0,
files_backed_up: b.filesCount || 0,
duration: b.duration || null,
duration_seconds: b.duration || null,
created_at: b.completed,
completed_at: b.completed,
started_at: b.completed,
completedAt: b.completed,
startedAt: b.completed,
status: 'completed',
// Stage A-aware: when the source is a disk-scanned manifest we
// can tell the wizard whether the DB dump is present, so the
// UI can warn before the admin picks a files-only backup.
database_included: b.databaseIncluded,
databaseIncluded: b.databaseIncluded,
corrupt: b.corrupt || false,
// Provenance: 'disk' (manifest read from filesystem) vs 'db'
// (backup_runs row that the disk didn't surface) — useful for
// debugging which side is missing.
source: b.source,
schema_version: b.schemaVersion,
schemaVersion: b.schemaVersion,
}));
res.json({
success: true,