Files
sencho/backend/src/routes/volumes.ts
T
Anso eed7e04e71 fix(resources): harden Resources Hub data race, prune errors, and scan lifecycle (#1251)
* fix(resources): harden Resources Hub data race, prune errors, and scan lifecycle

Guard fetchAllData with a generation counter so switching nodes mid-load
cannot let a stale fetch overwrite the newly selected node's resources.

handlePrune now checks res.ok and surfaces the server error instead of
showing a false success toast on a failed prune, matching the delete and
purge handlers.

Make the vulnerability-scan poll loop abort-aware via an AbortController
that cancels on unmount and on node switch, dismissing the loading toast
and avoiding state updates after the view is gone.

Add developer-mode diagnostic logging to the prune, network-create, and
volume list/read paths, gated by the existing developer_mode setting and
never logging file bodies or secrets.

Add regression tests covering the node-switch race and the prune error
path.

* fix(resources): make scan-poll abort cancel in-flight requests and guard parse window

Thread the scan AbortController signal into the scan POST, status poll, and
image-summaries fetches so an abort cancels the in-flight request and the
loading toast clears promptly instead of after the request settles.

Add abort checks after each response-body parse so an abort landing during
JSON parsing cannot fire a stale completion toast, open the scan sheet, or
write summaries for a node the user already left.

Bump the fetch generation on unmount so a fetchAllData that resolves after
the view is gone drops its state writes and load-error toast.

Add a regression test for the post-unmount load-failure path.
2026-05-29 11:47:33 -04:00

94 lines
3.9 KiB
TypeScript

import { Router, type Request, type Response } from 'express';
import { VolumeBrowserService, isValidVolumeName, PathTraversalError, VolumeNotFoundError, HelperImageError, ExecError } from '../services/VolumeBrowserService';
import { DatabaseService } from '../services/DatabaseService';
import { requireAdmin } from '../middleware/tierGates';
import { sanitizeForLog } from '../utils/safeLog';
import { isDebugEnabled } from '../utils/debug';
export const volumesRouter = Router();
function readPathParam(req: Request): string {
const raw = req.query.path;
if (typeof raw !== 'string') return '';
return raw;
}
function mapServiceError(error: unknown, res: Response, fallback: string): Response {
if (error instanceof PathTraversalError) return res.status(400).json({ error: error.message });
if (error instanceof VolumeNotFoundError) return res.status(404).json({ error: error.message });
if (error instanceof HelperImageError) return res.status(503).json({ error: error.message });
if (error instanceof ExecError) return res.status(error.status).json({ error: error.message });
console.error(`[Volumes] ${fallback}:`, error);
return res.status(500).json({ error: fallback });
}
volumesRouter.get('/:name/list', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const name = req.params.name as string;
if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' });
const path = readPathParam(req);
const startedAt = Date.now();
const entries = await VolumeBrowserService.getInstance(req.nodeId).listDir(name, path);
if (isDebugEnabled()) {
console.debug('[Volumes:debug] list', {
volume: sanitizeForLog(name), path: sanitizeForLog(path || '/'), entries: entries.length, ms: Date.now() - startedAt,
});
}
res.json(entries);
} catch (error: unknown) {
mapServiceError(error, res, 'Failed to list volume directory');
}
});
volumesRouter.get('/:name/stat', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const name = req.params.name as string;
if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' });
const path = readPathParam(req);
const meta = await VolumeBrowserService.getInstance(req.nodeId).stat(name, path);
res.json(meta);
} catch (error: unknown) {
mapServiceError(error, res, 'Failed to stat volume path');
}
});
volumesRouter.get('/:name/read', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const name = req.params.name as string;
const requestPath = readPathParam(req);
let outcome: 'success' | 'error' = 'error';
try {
if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' });
const startedAt = Date.now();
const result = await VolumeBrowserService.getInstance(req.nodeId).readFile(name, requestPath);
outcome = 'success';
if (isDebugEnabled()) {
// Never log the file body; only its shape.
console.debug('[Volumes:debug] read', {
volume: sanitizeForLog(name), path: sanitizeForLog(requestPath || '/'),
size: result.size, binary: result.binary, truncated: result.truncated, ms: Date.now() - startedAt,
});
}
res.json(result);
} catch (error: unknown) {
mapServiceError(error, res, 'Failed to read volume file');
} finally {
try {
DatabaseService.getInstance().insertAuditLog({
timestamp: Date.now(),
username: req.user?.username ?? 'unknown',
method: 'GET',
path: req.path,
status_code: res.statusCode,
node_id: req.nodeId ?? null,
ip_address: req.ip ?? 'unknown',
summary: `${outcome === 'success' ? 'Read' : 'Failed read of'} volume file: ${sanitizeForLog(name)}:${sanitizeForLog(requestPath || '/')}`,
});
} catch (auditErr) {
console.error('[Volumes] Audit log insert failed:', auditErr);
}
}
});