mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
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:
@@ -1,6 +1,17 @@
|
||||
import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { promises as fsPromises, createReadStream } from 'fs';
|
||||
import type { Readable } from 'stream';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { isBinaryBuffer } from '../utils/binaryDetect';
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
size: number;
|
||||
mtime: number;
|
||||
isProtected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the writable Sencho data directory (same one DatabaseService /
|
||||
@@ -14,6 +25,22 @@ function getBackupBaseDir(): string {
|
||||
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
const PROTECTED_STACK_FILES = new Set([
|
||||
'compose.yaml',
|
||||
'compose.yml',
|
||||
'docker-compose.yaml',
|
||||
'docker-compose.yml',
|
||||
'.env',
|
||||
]);
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
'.yaml': 'text/yaml',
|
||||
'.yml': 'text/yaml',
|
||||
'.json': 'application/json',
|
||||
'.sh': 'text/x-sh',
|
||||
'.env': 'text/plain',
|
||||
};
|
||||
|
||||
/**
|
||||
* FileSystemService - local-only file I/O for compose stack management.
|
||||
*
|
||||
@@ -320,4 +347,218 @@ export class FileSystemService {
|
||||
return { exists: false, timestamp: null };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stack-scoped file explorer methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private guessMime(filePath: string): string {
|
||||
if (path.basename(filePath) === '.env') return 'text/plain';
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return MIME_MAP[ext] ?? 'text/plain';
|
||||
}
|
||||
|
||||
private async resolveSafeStackPath(stackName: string, relPath: string): Promise<string> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
if (!isPathWithinBase(stackDir, this.baseDir)) {
|
||||
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
const target = relPath === '' ? stackDir : path.resolve(stackDir, relPath);
|
||||
|
||||
if (!isPathWithinBase(target, stackDir)) {
|
||||
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
|
||||
let realTarget: string;
|
||||
try {
|
||||
realTarget = await fsPromises.realpath(target);
|
||||
} catch (err: unknown) {
|
||||
const fsErr = err as NodeJS.ErrnoException;
|
||||
if (fsErr.code !== 'ENOENT') throw err;
|
||||
|
||||
// Walk up to the deepest existing ancestor, then reattach the suffix.
|
||||
let existing = target;
|
||||
const suffix: string[] = [];
|
||||
while (true) {
|
||||
const parent = path.dirname(existing);
|
||||
if (parent === existing) {
|
||||
// Reached filesystem root without finding an existing path.
|
||||
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
suffix.unshift(path.basename(existing));
|
||||
existing = parent;
|
||||
try {
|
||||
const realExisting = await fsPromises.realpath(existing);
|
||||
if (!isPathWithinBase(realExisting, stackDir)) {
|
||||
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
|
||||
}
|
||||
realTarget = path.join(realExisting, ...suffix);
|
||||
break;
|
||||
} catch (innerErr: unknown) {
|
||||
const innerFsErr = innerErr as NodeJS.ErrnoException;
|
||||
if (innerFsErr.code !== 'ENOENT') throw innerErr;
|
||||
// Continue walking up.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPathWithinBase(realTarget, stackDir)) {
|
||||
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
|
||||
}
|
||||
|
||||
return realTarget;
|
||||
}
|
||||
|
||||
async listStackDirectory(stackName: string, relPath: string): Promise<FileEntry[]> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
const dirents = await fsPromises.readdir(safePath, { withFileTypes: true });
|
||||
|
||||
const entries = await Promise.all(
|
||||
dirents.map(async (dirent): Promise<FileEntry> => {
|
||||
const entryPath = path.join(safePath, dirent.name);
|
||||
let size = 0;
|
||||
let mtime = 0;
|
||||
try {
|
||||
const st = await fsPromises.stat(entryPath);
|
||||
size = dirent.isDirectory() ? 0 : st.size;
|
||||
mtime = st.mtimeMs;
|
||||
} catch {
|
||||
// stat can fail for broken symlinks; use defaults.
|
||||
}
|
||||
const type: FileEntry['type'] = dirent.isDirectory()
|
||||
? 'directory'
|
||||
: dirent.isSymbolicLink()
|
||||
? 'symlink'
|
||||
: 'file';
|
||||
return {
|
||||
name: dirent.name,
|
||||
type,
|
||||
size,
|
||||
mtime,
|
||||
isProtected: PROTECTED_STACK_FILES.has(dirent.name),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return entries.sort((a, b) => {
|
||||
if (a.type === 'directory' && b.type !== 'directory') return -1;
|
||||
if (a.type !== 'directory' && b.type === 'directory') return 1;
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
async readStackFile(
|
||||
stackName: string,
|
||||
relPath: string,
|
||||
maxBytes: number = 2 * 1024 * 1024
|
||||
): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string }> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
const stat = await fsPromises.stat(safePath);
|
||||
const mime = this.guessMime(safePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' });
|
||||
}
|
||||
|
||||
if (stat.size > maxBytes) {
|
||||
const fd = await fsPromises.open(safePath, 'r');
|
||||
const probe = Buffer.allocUnsafe(8192);
|
||||
const { bytesRead } = await fd.read(probe, 0, 8192, 0);
|
||||
await fd.close();
|
||||
const binary = isBinaryBuffer(probe.subarray(0, bytesRead));
|
||||
return { binary, oversized: true, size: stat.size, mime };
|
||||
}
|
||||
|
||||
const buf = await fsPromises.readFile(safePath);
|
||||
|
||||
if (isBinaryBuffer(buf)) {
|
||||
return { binary: true, oversized: false, size: stat.size, mime };
|
||||
}
|
||||
|
||||
return { binary: false, oversized: false, size: stat.size, mime, content: buf.toString('utf-8') };
|
||||
}
|
||||
|
||||
async streamStackFile(
|
||||
stackName: string,
|
||||
relPath: string
|
||||
): Promise<{ stream: Readable; size: number; filename: string; mime: string }> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
const stat = await fsPromises.stat(safePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' });
|
||||
}
|
||||
|
||||
return {
|
||||
stream: createReadStream(safePath),
|
||||
size: stat.size,
|
||||
filename: path.basename(safePath),
|
||||
mime: this.guessMime(safePath),
|
||||
};
|
||||
}
|
||||
|
||||
async writeStackFile(stackName: string, relPath: string, content: string): Promise<void> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
|
||||
await fsPromises.writeFile(safePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
async writeStackFileBuffer(stackName: string, relPath: string, buffer: Buffer): Promise<void> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
|
||||
await fsPromises.writeFile(safePath, buffer);
|
||||
}
|
||||
|
||||
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise<void> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
|
||||
if (recursive) {
|
||||
await fsPromises.rm(safePath, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fsPromises.unlink(safePath);
|
||||
} catch (err: unknown) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code === 'EISDIR') {
|
||||
try {
|
||||
await fsPromises.rmdir(safePath);
|
||||
} catch (inner: unknown) {
|
||||
const ie = inner as NodeJS.ErrnoException;
|
||||
if (ie.code === 'ENOTEMPTY' || ie.code === 'EEXIST') {
|
||||
throw Object.assign(new Error('Directory is not empty'), { code: 'NOT_EMPTY' });
|
||||
}
|
||||
throw inner;
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async mkdirStackPath(stackName: string, relPath: string): Promise<void> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
await fsPromises.mkdir(safePath, { recursive: true });
|
||||
}
|
||||
|
||||
async statStackEntry(stackName: string, relPath: string): Promise<FileEntry> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
// Use lstat so symlinks are reported as 'symlink' rather than resolved to target type.
|
||||
const stat = await fsPromises.lstat(safePath);
|
||||
const name = path.basename(safePath);
|
||||
|
||||
const type: FileEntry['type'] = stat.isDirectory()
|
||||
? 'directory'
|
||||
: stat.isSymbolicLink()
|
||||
? 'symlink'
|
||||
: 'file';
|
||||
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
size: stat.isDirectory() ? 0 : stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
isProtected: PROTECTED_STACK_FILES.has(name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user