mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
eed7e04e71
* 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.
94 lines
3.9 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
});
|