feat(files): per-stack file explorer (#780)

* feat(files): backend foundation for stack file explorer

Install multer for multipart file upload handling. Add
isValidRelativeStackPath to validation.ts to guard client-supplied
relative paths against traversal, absolute paths, NUL bytes, backslash
injection, and double-slash segments. Add isBinaryBuffer to a new
binaryDetect.ts utility for heuristic text/binary detection via
NUL-byte fast exit and non-printable byte ratio sampling.

* fix(files): reject bare dot segments in isValidRelativeStackPath

* feat(files): add safe stack-scoped file I/O methods to FileSystemService

Adds FileEntry interface and seven new public methods to FileSystemService
for stack-scoped file operations: listStackDirectory, readStackFile,
streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and
statStackEntry.

Each method routes through a private resolveSafeStackPath helper that
enforces two-phase path containment: a pre-realpath lexical check plus a
post-realpath symlink-escape check. ENOENT targets are handled by walking
up to the deepest existing ancestor, realpaths that ancestor, and
reattaching the remaining suffix.

Binary detection delegates to isBinaryBuffer; path safety delegates to
isPathWithinBase. Protected file names and the MIME map are module-level
constants to avoid repeated allocation.

* feat(files): frontend API wrappers and Monaco language helper

* fix(files): tighten stackFilesApi error handling and localOnly support

* fix(files): FileSystemService safety and correctness fixes

* feat(files): add file explorer API endpoints to stacks router

* feat(files): FileTree and FileTreeNode components

* fix(files): route security hardening and stream cleanup

* fix(files): FileTree accessibility, icon stroke, stale fetch guard

Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the
design system. Add aria-expanded to directory rows for accessibility.
Guard handleDirClick .then() callbacks against stale stack name
references when the component re-renders with a new stack. Add
toast.info fallbacks when compose.yaml or .env is clicked without a
navigation callback registered.

* feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm

* fix(files): resolve code quality findings in file explorer components

- Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix)
- Fix blob download: append anchor to DOM before click, defer URL revoke 100ms
- Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm
- Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone
- Add success toast on folder creation in NewFolderDialog
- Switch all (e as Error).message casts to instanceof Error narrowing

* test(files): unit tests for binary detection, stack path safety, and file explorer routes

- binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL,
  PNG header, threshold boundary, sampleBytes parameter)
- filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/
  rejects matrix) and FileSystemService stack methods against a real temp dir
  (listStackDirectory sort and protection flags, readStackFile text/binary/
  oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath,
  traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows
- stack-files-routes.test.ts: route-level integration tests for all seven
  file explorer endpoints; covers auth gating, Community-tier 403 gates,
  input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths

* feat(files): StackFileExplorer container with lazy tree, viewer, and action bar

* fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change

* test(files): add missing test coverage for file explorer routes and service

* feat(files): add Files tab to EditorLayout with StackFileExplorer integration

* fix(files): add defensive activeTab guard to saveFile and discardChanges

* test(files): unit tests for FileTree expand/collapse and FileViewer render modes

Covers the three FileViewer content modes (text/Monaco, binary panel,
oversized panel) and the FileTree expand/collapse/cache cycle: first
expand fetches the subdirectory, second click collapses without a fetch,
third click re-expands from the in-memory cache without a second fetch.

* test(e2e): file explorer community and skipper+ flows

Covers the full file-explorer feature surface in two describe blocks:

Community (read-only): intercepts /api/license to simulate community
tier, confirms the upgrade pill is visible in the left pane, and
asserts that the Save button is absent after opening a text file.

Skipper+ (full CRUD): uploads a text file and confirms it appears in
the tree; edits config/app.conf and saves via Monaco; deletes an
uploaded file and asserts the tree entry is gone; issues a raw HTTP
request to the download endpoint and checks for status 200 and the
content-disposition: attachment header.

Also adds data-testid="file-action-delete" to the action bar Delete
button in StackFileExplorer for stable targeting, and exports
waitForStacksLoaded from e2e/helpers.ts to eliminate the three
identical local copies in stacks, deploy-log-panel, and stack-files
spec files.

* fix(e2e): improve test isolation and selector stability in stack-files spec

Move beforeEach seed to beforeAll/afterAll so fixtures are created once per
suite, not before every test. Extract shared seedSuite/teardownSuite helpers
to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in
try/catch so failures log a warning rather than masking test results.

Replace waitForTimeout(500) with a deterministic expect on the file tree
sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn"
to replace the fragile button text/positional selectors. Assert Save button
starts disabled before editing.

* docs(files): add stack file explorer documentation

Add user-facing guide for the stack file explorer feature covering
tier access (Community read-only, Skipper+ read-write), viewing
limits, upload/download caps, protected file routing, and
troubleshooting. Update the editor page to reference the new guide
and register the page in the navigation.

* fix(docs): use canonical Skipper tier name in file explorer overview card

* fix(files): resolve lint errors blocking CI

Remove unnecessary backslash escape before double-quote in the
Content-Disposition regex (no-useless-escape). Replace five synchronous
setState resets at the top of the FileTree mount effect with a React key
prop on the FileTree element in StackFileExplorer so remounting resets
state automatically, eliminating the react-hooks/set-state-in-effect
violation.

* test(files): fix e2e seeding to work on community-tier CI

Replace the browser-side paid upload/mkdir API calls in seedTestStack with
direct Node fs writes. The upload and folder endpoints require Skipper+ so
they returned 403 on CI, which runs with no license set. Stack creation
via POST /api/stacks stays as an API call since it is community-allowed and
keeps the backend registry in sync.

Add a per-test tier check in the Skipper+ beforeEach that skips gracefully
when the instance is community, matching the pattern in auto-heal-policies.
This commit is contained in:
Anso
2026-04-26 13:05:19 -04:00
committed by GitHub
parent dd9d33813b
commit 801a098a5b
29 changed files with 3645 additions and 71 deletions
+194 -2
View File
@@ -1,6 +1,7 @@
import { Router, type Request, type Response } from 'express';
import { Router, type Request, type Response, type NextFunction } from 'express';
import path from 'path';
import YAML from 'yaml';
import multer from 'multer';
import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
import DockerController from '../services/DockerController';
@@ -13,7 +14,7 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { requirePermission } from '../middleware/permissions';
import { requirePaid, requireAdmin } from '../middleware/tierGates';
import { NotificationService } from '../services/NotificationService';
import { isValidStackName, isValidServiceName, isPathWithinBase } from '../utils/validation';
import { isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { sendGitSourceError } from '../utils/gitSourceHttp';
@@ -100,6 +101,15 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis
}
}
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 25 * 1024 * 1024, files: 1 },
});
function getRelPath(req: Request): string {
return typeof req.query.path === 'string' ? req.query.path : '';
}
export const stacksRouter = Router();
stacksRouter.get('/', async (req: Request, res: Response) => {
@@ -847,3 +857,185 @@ stacksRouter.get('/:stackName/backup', async (req: Request, res: Response) => {
res.status(500).json({ error: message });
}
});
// ── File explorer endpoints ──
type FsErrorCode = 'INVALID_PATH' | 'SYMLINK_ESCAPE' | 'IS_DIRECTORY' | 'NOT_EMPTY' | 'NOT_FOUND' | 'TOO_LARGE';
function sendFsError(
res: Response,
err: unknown,
fallback: string,
opts: { notFoundMessage?: string } = {},
): Response {
const e = err as NodeJS.ErrnoException & { code?: string };
if (e.code === 'INVALID_PATH' || e.code === 'SYMLINK_ESCAPE') {
return res.status(400).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'IS_DIRECTORY') {
return res.status(400).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'NOT_EMPTY') {
return res.status(409).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'ENOENT') {
return res.status(404).json({ error: opts.notFoundMessage ?? 'File not found', code: 'NOT_FOUND' });
}
console.error(`[files] ${fallback}:`, e.message);
return res.status(500).json({ error: fallback });
}
stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
const entries = await FileSystemService.getInstance(req.nodeId).listStackDirectory(stackName, relPath);
return res.json(entries);
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to list directory');
}
});
stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
const relPath = getRelPath(req);
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath);
return res.json(result);
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to read file');
}
});
stacksRouter.get('/:stackName/files/download', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
const result = await FileSystemService.getInstance(req.nodeId).streamStackFile(stackName, relPath);
res.setHeader('Content-Type', result.mime);
res.setHeader('Content-Length', result.size);
const encodedFilename = encodeURIComponent(result.filename);
const safeFilename = result.filename.replace(/[\\"]/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`);
result.stream.on('error', (streamErr) => {
console.error('[files] stream error:', streamErr);
if (!res.headersSent) res.status(500).end();
else res.destroy();
});
req.on('close', () => result.stream.destroy());
result.stream.pipe(res);
return;
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to download file');
}
});
stacksRouter.post(
'/:stackName/files/upload',
(req: Request, res: Response, next: NextFunction) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
if (!requirePaid(req, res)) return;
upload.single('file')(req, res, (err) => {
if (err && (err as multer.MulterError).code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File exceeds 25 MB limit', code: 'TOO_LARGE' });
}
if (err) return res.status(500).json({ error: 'Upload failed' });
next();
});
},
async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!req.file) {
return res.status(400).json({ error: 'No file provided' });
}
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const safeName = path.basename(req.file.originalname);
if (!safeName || safeName === '.' || safeName === '..') {
return res.status(400).json({ error: 'Invalid filename' });
}
const targetRelPath = relPath ? `${relPath}/${safeName}` : safeName;
try {
await FileSystemService.getInstance(req.nodeId).writeStackFileBuffer(stackName, targetRelPath, req.file.buffer);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to upload file', { notFoundMessage: 'Target directory not found' });
}
},
);
stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const { content } = req.body as { content?: unknown };
if (typeof content !== 'string') {
return res.status(400).json({ error: '"content" must be a string' });
}
try {
await FileSystemService.getInstance(req.nodeId).writeStackFile(stackName, relPath, content);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to write file');
}
});
stacksRouter.delete('/:stackName/files', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (relPath === '') return res.status(400).json({ error: 'Path is required for delete' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const recursive = req.query.recursive === '1';
try {
await FileSystemService.getInstance(req.nodeId).deleteStackPath(stackName, relPath, recursive);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to delete path');
}
});
stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (relPath === '') return res.status(400).json({ error: 'Path is required to create a folder' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
await FileSystemService.getInstance(req.nodeId).mkdirStackPath(stackName, relPath);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to create folder');
}
});