Files
sencho/frontend/src/lib/stackFilesApi.ts
T
Anso 37e6e48b40 feat(files): copy & duplicate, bulk actions, disk-backed uploads, and an accessible file tree (#1409)
* perf(files): spool uploads to disk instead of buffering in memory

Switch the stack file-explorer upload from multer memoryStorage to
diskStorage and stream the spooled temp file through the file-root
gateway, so an upload is never held fully in RAM. Authorization and
root resolution now run before multer spools, so an unauthorized or
read-only-root request is rejected without writing a temp file, and the
spool is removed on every exit path. The named-volume helper write
verifies the written byte count, since cat cannot report a short write.

* feat(files): copy and duplicate files in the explorer

Add a copy capability to the stack file explorer: a same-folder
Duplicate (auto-suffixed name) and a "Copy to..." destination picker,
on both filesystem and named-volume roots. Copying is within-root,
symlink-leaf-safe, blocks a directory copy into its own subtree, and
refuses to create a protected name (compose/.env) at the stack root
while still allowing a protected file to be duplicated under a new name.

* feat(files): make the file tree keyboard accessible

Bring the stack file explorer tree to the WCAG tree pattern: rows are
treeitems carrying aria-level, aria-selected, and aria-expanded, with a
single roving tabindex and full keyboard navigation (arrow keys,
Home/End, Enter/Space) over a flattened visible-node list that stays in
lockstep with the rendered rows. A polite live region announces the
selected file. No visual change to the tree.

* feat(files): bulk select, delete, move, and download files

Add multi-select to the stack file explorer (checkboxes plus Shift and
Ctrl/Cmd click over the visible order) driving three bulk actions:
delete, move, and download as a streamed .tar.gz. All run within the
active root on both filesystem and named-volume backends, report
per-item results so partial failures surface (with the failed items
kept selected for retry), normalize ancestor/descendant selections
server-side, and cap the archive entry and byte counts before any
bytes are streamed. Protected compose/.env files are excluded from
bulk delete and move but may still be downloaded.

* docs(files): document copy, bulk actions, and keyboard navigation

Add the copy/duplicate and multi-select bulk delete/move/download
sections to the Files & Volumes page, a keyboard-navigation note for the
tree, an updated context-menu reference, and bulk troubleshooting entries.

* fix(files): inline path-injection barriers at the new file-op sinks

CodeQL js/path-injection does not credit the wrapped isPathWithinBase
containment check, so the new copy/bulk/disk-upload flows tripped the
gate. Inline the canonical path.resolve + startsWith barrier at the
realpath sink in resolveSafePathWithin (covers every user-relPath flow)
and confirm the multer spool path resolves within UPLOAD_TMP_DIR before
unlinking it or streaming it onward. Behavior is unchanged; the paths
were already validated.

* fix(files): guard the ancestor-walk realpath sink too

The first barrier covered realpath(target), but the ENOENT ancestor
walk re-derives the path via path.dirname, which static analysis treats
as a fresh tainted value. Add the same inline containment barrier before
that realpath and resolve the root case via the untainted base, so the
only tainted realpath input is one the startsWith check has cleared.
Behavior is unchanged.

* fix(files): resolve the root case off the taint path in the ancestor walk

The compound guard on existing (the same variable as the startsWith
subject) was not credited as a sanitizer. Handle the root case before
the barrier by resolving the untainted base directly, leaving a plain
canonical startsWith guard on the strictly-within ancestor. Behavior is
unchanged.

* fix(files): harden helper-backend bulk download and uploads

Address three issues found in the named-volume (helper) backend:

- Bulk download could send 200 headers before discovering a file the
  helper download path refuses, tearing the archive mid-stream. The
  prewalk now rejects symlinks, non-regular ("other") entries, and
  files over the per-file download cap before any header (400/413).
  FileEntry gains an 'other' type so non-regular entries stay distinct
  from regular files as they pass through the gateway.
- The helper directory listing was fully buffered before the archive
  entry cap could fire. listDir now accepts a limit; the list script
  stops after limit+1 rows and the gateway reports truncation.
- A stdin pipeline error during a helper upload masked the container's
  real nonzero exit code (and its 4xx mapping) as a generic 500. The
  nonzero exit now wins; the masked stream error is logged.

* feat(files): add a New file toolbar button with server-enforced create-only

The stack file explorer could create a folder from a toolbar button but a
new file only from a folder's right-click menu, so a file could not be
created at the stack root at all. Add a New file toolbar button beside New
folder, targeting the current directory.

Creating a file now routes through a new createEmptyStackFile helper that
posts a zero-byte file through the existing upload endpoint with overwrite
off, so the server's exclusive-create path rejects an existing name instead
of clobbering it. A file collision surfaces inline in the dialog; a folder
collision and other failures surface as a toast.

* fix(files): widen the tree row hit area and add horizontal scroll for long names

Right-clicking a file tree row only opened the Sencho context menu when the
click landed on the filename; the rest of the row fell through to the native
browser menu, and long names were truncated with no way to read them.

Make each row span the full pane width (and grow with its content) so the
whole row is the context-menu trigger, and let the tree scroll horizontally
so a long name is reachable instead of clipped. A new opt-in horizontal prop
on ScrollArea adds the styled horizontal scrollbar without clamping content
width.

* docs(files): document the New file button, full-row right-click, and long-name scrolling

* test(files): cover createEmptyStackFile targeting the stack root

Add an API-layer case for the empty-directory (stack root) create path, the
primary reason the New file toolbar button exists, so a regression in the
root-level URL would be caught at unit speed rather than only in e2e.
2026-06-22 19:33:06 -04:00

526 lines
18 KiB
TypeScript

import { apiFetch } from './api';
/** The id of the stack source directory root (mirrors the backend constant). */
export const STACK_SOURCE_ROOT_ID = 'stack-source';
/**
* Mirrors backend/src/utils/validation.ts::isValidRelativeStackPath. Client
* defense-in-depth: the backend rejects path-traversal attempts, but catching
* them client-side avoids a wasted round trip and surfaces a clearer error
* to the user. Also guards against a future regression on the server side.
*
* Allow: the empty string (means the stack root) and POSIX-style relative
* paths with no traversal segments. Reject: absolute paths, drive letters,
* backslashes, double slashes, NUL bytes, and any segment that is `.` or `..`.
*/
export function isClientSafeRelPath(rel: string): boolean {
if (typeof rel !== 'string') return false;
if (rel === '') return true;
if (rel.includes('\0')) return false;
if (rel.includes('\\')) return false;
if (/^[a-zA-Z]:/.test(rel) || rel.startsWith('/')) return false;
if (rel.includes('//')) return false;
const segments = rel.split('/');
return !segments.some(seg => seg === '..' || seg === '.');
}
function assertSafeRelPath(rel: string, label = 'path'): void {
if (!isClientSafeRelPath(rel)) {
throw new Error(`Invalid ${label}: must be a relative path inside the stack directory`);
}
}
/**
* Mirrors backend/src/services/FileSystemService.ts::isProtectedRelPath. Only the
* compose and .env files at the stack ROOT are protected; an entry with the same
* basename nested in a subdirectory is an ordinary file. FileEntry.isProtected is
* basename-only, so callers that care about position (move source gating, root
* destination gating) use this instead.
*/
const PROTECTED_ROOT_NAMES = new Set([
'compose.yaml',
'compose.yml',
'docker-compose.yaml',
'docker-compose.yml',
'.env',
]);
export function isProtectedRootRelPath(rel: string): boolean {
if (!rel || rel.includes('/')) return false;
return PROTECTED_ROOT_NAMES.has(rel);
}
/** True when `candidateRel` is `ancestorRel` itself or sits inside it. */
export function isSameOrDescendantPath(ancestorRel: string, candidateRel: string): boolean {
return candidateRel === ancestorRel || candidateRel.startsWith(`${ancestorRel}/`);
}
/** The directory portion of a relative path; '' for a root-level entry. */
export function relPathParentDir(rel: string): string {
return rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '';
}
/**
* Pick a non-colliding "<name> copy" sibling name for Duplicate. A leading-dot
* file (e.g. .env) is treated as having no extension, so the suffix lands before
* any real extension (`app copy.conf`) but as a plain suffix for dotfiles
* (`.env copy`).
*/
export function nextDuplicateName(original: string, existing: Set<string>): string {
const dot = original.lastIndexOf('.');
const hasExt = dot > 0;
const base = hasExt ? original.slice(0, dot) : original;
const ext = hasExt ? original.slice(dot) : '';
let candidate = `${base} copy${ext}`;
let n = 2;
while (existing.has(candidate)) {
candidate = `${base} copy ${n}${ext}`;
n += 1;
}
return candidate;
}
/** Custom drag MIME so move drops are told apart from OS file drags (`Files`). */
export const FILE_ENTRY_DND_MIME = 'application/x-sencho-file-entry';
export interface FileEntryDragPayload {
relPath: string;
name: string;
type: FileEntry['type'];
}
const ENTRY_TYPES: ReadonlySet<FileEntry['type']> = new Set(['file', 'directory', 'symlink']);
/**
* Reads a tree-move drag payload from a DataTransfer, or null when this is not
* one of our entry drags (e.g. an OS file drag carries `Files`, handled by the
* upload dropzone). The parsed JSON is shape-validated rather than blindly cast,
* so a malformed or foreign payload becomes an ignored no-op instead of flowing
* downstream with undefined fields.
*/
export function readFileEntryDragPayload(dt: DataTransfer): FileEntryDragPayload | null {
if (!dt.types.includes(FILE_ENTRY_DND_MIME)) return null;
let parsed: unknown;
try {
parsed = JSON.parse(dt.getData(FILE_ENTRY_DND_MIME));
} catch (err) {
console.warn('Ignored malformed file-entry drag payload', err);
return null;
}
if (
typeof parsed === 'object' && parsed !== null &&
typeof (parsed as FileEntryDragPayload).relPath === 'string' &&
typeof (parsed as FileEntryDragPayload).name === 'string' &&
ENTRY_TYPES.has((parsed as FileEntryDragPayload).type)
) {
return parsed as FileEntryDragPayload;
}
console.warn('Ignored file-entry drag payload with an unexpected shape');
return null;
}
export interface FileEntry {
name: string;
type: 'file' | 'directory' | 'symlink';
size: number;
mtime: number;
isProtected: boolean;
}
export interface FileContentResult {
content?: string;
binary: boolean;
oversized: boolean;
size: number;
mime: string;
mtimeMs: number;
/**
* Opaque optimistic-concurrency token, round-tripped verbatim as If-Match on
* save. For stack-source/bind roots it is the weak ETag over the mtime; for
* named-volume roots it is a composite token. Optional for back-compat with a
* server that has not yet been upgraded (falls back to the mtime ETag).
*/
version?: string;
}
/**
* A browsable/editable file root for a stack: the stack source, a bind mount, or
* a named volume. Wire mirror of the backend `StackFileRoot`
* (backend/src/services/StackFileRootsService.ts); keep the two shapes in sync.
*/
export interface FileRootMount {
service: string;
containerPath: string;
readOnly: boolean;
}
export interface FileRoot {
id: string;
kind: 'stack-source' | 'bind' | 'volume';
label: string;
hostPathOrName: string;
mounts: FileRootMount[];
readonly: boolean;
accessible: boolean;
browsable: boolean;
writable: boolean;
chmodable: boolean;
dangerous: boolean;
managedSourceOverlap: boolean;
warning: string | null;
backend: 'fs' | 'helper';
}
/**
* Thrown by writeStackFile when the server reports the target file has been
* modified since the caller's last read (HTTP 412). The current server-side
* content, mtime, and version token are attached so callers can prompt the user
* to reconcile and retry with the fresh token.
*/
export class FileConflictError extends Error {
readonly code = 'PRECONDITION_FAILED' as const;
readonly currentContent: string;
readonly currentMtimeMs: number;
readonly currentVersion: string | null;
constructor(message: string, currentContent: string, currentMtimeMs: number, currentVersion: string | null) {
super(message);
this.name = 'FileConflictError';
this.currentContent = currentContent;
this.currentMtimeMs = currentMtimeMs;
this.currentVersion = currentVersion;
}
}
export async function parseApiError(res: Response): Promise<string> {
try {
const data = await res.json();
return (data as { error?: string }).error ?? `HTTP ${res.status}`;
} catch {
return `HTTP ${res.status}`;
}
}
function stackFilesUrl(stackName: string, suffix: string): string {
return `/stacks/${encodeURIComponent(stackName)}/files${suffix}`;
}
/** `&rootId=...` to append onto an existing query string, or '' for the default stack-source root. */
function rootParam(rootId?: string): string {
return rootId ? `&rootId=${encodeURIComponent(rootId)}` : '';
}
/** Discover the browsable/editable file roots for a stack (Volumes + Stack source). */
export async function listFileRoots(stackName: string): Promise<FileRoot[]> {
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/file-roots`);
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<FileRoot[]>;
}
export async function listStackDirectory(
stackName: string,
relPath: string,
rootId?: string,
): Promise<FileEntry[]> {
assertSafeRelPath(relPath);
const res = await apiFetch(stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`));
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<FileEntry[]>;
}
export async function readStackFile(
stackName: string,
relPath: string,
options?: { forceText?: boolean; rootId?: string }
): Promise<FileContentResult> {
assertSafeRelPath(relPath);
const forceSuffix = options?.forceText ? '&force=text' : '';
const res = await apiFetch(
stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}${forceSuffix}${rootParam(options?.rootId)}`)
);
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<FileContentResult>;
}
export async function downloadStackFile(
stackName: string,
relPath: string,
rootId?: string,
): Promise<Response> {
assertSafeRelPath(relPath);
return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`));
}
/**
* Thrown by uploadStackFile when the target filename already exists in the
* directory and the caller did not opt into overwrite. The FileUploadDropzone
* surfaces a confirm dialog on this signal and retries with overwrite=true.
*/
export class UploadConflictError extends Error {
readonly code = 'FILE_EXISTS' as const;
constructor(message: string) {
super(message);
this.name = 'UploadConflictError';
}
}
export async function uploadStackFile(
stackName: string,
targetDir: string,
file: File,
options?: { localOnly?: boolean; overwrite?: boolean; rootId?: string }
): Promise<void> {
assertSafeRelPath(targetDir, 'target directory');
const fd = new FormData();
fd.append('file', file, file.name);
const activeNodeId = options?.localOnly ? null : localStorage.getItem('sencho-active-node');
const headers: Record<string, string> = {};
if (activeNodeId) {
headers['x-node-id'] = activeNodeId;
}
const overwriteSuffix = options?.overwrite ? '&overwrite=1' : '';
// Use fetch directly: apiFetch always sets Content-Type: application/json,
// which breaks multipart boundary negotiation. The 401 side-effects are
// replicated manually below.
const res = await fetch(
`/api${stackFilesUrl(stackName, `/upload?path=${encodeURIComponent(targetDir)}${overwriteSuffix}${rootParam(options?.rootId)}`)}`,
{ method: 'POST', credentials: 'include', headers, body: fd }
);
if (res.status === 401) {
if (!res.headers.get('x-sencho-proxy')) {
window.dispatchEvent(new Event('sencho-unauthorized'));
}
throw new Error('Unauthorized');
}
if (res.status === 409) {
let body: { code?: string; error?: string } = {};
try { body = await res.clone().json(); } catch { /* ignore */ }
if (body.code === 'FILE_EXISTS') {
throw new UploadConflictError(body.error ?? `${file.name} already exists.`);
}
// DIR_EXISTS and any other 409 fall through to the generic Error path so the
// dropzone surfaces the server message as a toast and does NOT offer a Replace
// confirmation (a directory cannot be replaced by a file upload).
}
if (!res.ok) {
if (res.status === 404) {
try {
const clone = res.clone();
const errData = await clone.json();
if (errData.error?.includes('not found') && errData.error?.includes('Node')) {
window.dispatchEvent(new Event('node-not-found'));
}
} catch { /* ignore */ }
}
throw new Error(await parseApiError(res));
}
}
/**
* Create a new empty file at `dirRelPath/fileName`. Routes through the upload
* endpoint with a zero-byte file and no overwrite, so the server's exclusive
* create path is authoritative and an existing entry is never clobbered: an
* existing file of that name is rejected as FILE_EXISTS (thrown as
* UploadConflictError), and an existing folder is rejected as DIR_EXISTS (a
* generic Error). Works on both the filesystem and named-volume backends.
*/
export async function createEmptyStackFile(
stackName: string,
dirRelPath: string,
fileName: string,
options?: { rootId?: string }
): Promise<void> {
const blank = new File([new Uint8Array(0)], fileName, { type: 'text/plain' });
await uploadStackFile(stackName, dirRelPath, blank, { rootId: options?.rootId, overwrite: false });
}
export async function writeStackFile(
stackName: string,
relPath: string,
content: string,
options?: { ifMatchVersion?: string; rootId?: string }
): Promise<{ version: string | null; mtimeMs: number | null }> {
assertSafeRelPath(relPath);
const headers: Record<string, string> = {};
if (options?.ifMatchVersion) {
// Send the opaque version token verbatim (it is already a valid quoted
// If-Match value for both fs and helper roots).
headers['If-Match'] = options.ifMatchVersion;
}
const res = await apiFetch(
stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}${rootParam(options?.rootId)}`),
{ method: 'PUT', headers, body: JSON.stringify({ content }) }
);
if (res.status === 412) {
let body: { currentContent?: string; currentMtimeMs?: number; currentVersion?: string; error?: string } = {};
try { body = await res.clone().json(); } catch { /* ignore */ }
throw new FileConflictError(
body.error ?? 'File has been modified since you last read it.',
typeof body.currentContent === 'string' ? body.currentContent : '',
typeof body.currentMtimeMs === 'number' ? body.currentMtimeMs : 0,
typeof body.currentVersion === 'string'
? body.currentVersion
: res.headers.get('ETag'),
);
}
if (!res.ok) throw new Error(await parseApiError(res));
// The ETag is the opaque version token for the new content; round-trip it
// verbatim on the next save. mtimeMs is parsed for display only.
const version = res.headers.get('ETag');
let mtimeMs: number | null = null;
if (version) {
const stripped = version.replace(/^W\//i, '').trim().replace(/^"(.*)"$/, '$1');
const parsed = Number(stripped);
if (Number.isFinite(parsed)) mtimeMs = parsed;
}
return { version, mtimeMs };
}
export async function deleteStackPath(
stackName: string,
relPath: string,
recursive?: boolean,
rootId?: string,
): Promise<void> {
assertSafeRelPath(relPath);
const recursiveSuffix = recursive ? '&recursive=1' : '';
const res = await apiFetch(
stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}${recursiveSuffix}${rootParam(rootId)}`),
{ method: 'DELETE' },
);
if (!res.ok) throw new Error(await parseApiError(res));
}
export async function mkdirStackPath(
stackName: string,
relPath: string,
rootId?: string,
): Promise<void> {
assertSafeRelPath(relPath);
const res = await apiFetch(
stackFilesUrl(stackName, `/folder?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`),
{ method: 'POST', body: JSON.stringify({}) }
);
if (!res.ok) throw new Error(await parseApiError(res));
}
export async function renameStackPath(
stackName: string,
fromRel: string,
toRel: string,
rootId?: string,
): Promise<void> {
assertSafeRelPath(fromRel, 'source path');
assertSafeRelPath(toRel, 'destination path');
const res = await apiFetch(
stackFilesUrl(stackName, `/rename${rootId ? `?rootId=${encodeURIComponent(rootId)}` : ''}`),
{ method: 'PATCH', body: JSON.stringify({ from: fromRel, to: toRel }) }
);
if (!res.ok) throw new Error(await parseApiError(res));
}
export async function copyStackFile(
stackName: string,
fromRel: string,
toRel: string,
rootId?: string,
): Promise<void> {
assertSafeRelPath(fromRel, 'source path');
assertSafeRelPath(toRel, 'destination path');
const res = await apiFetch(
stackFilesUrl(stackName, `/copy${rootId ? `?rootId=${encodeURIComponent(rootId)}` : ''}`),
{ method: 'POST', body: JSON.stringify({ from: fromRel, to: toRel }) }
);
if (!res.ok) throw new Error(await parseApiError(res));
}
/** A failed item in a partial-success bulk operation. */
export interface BulkFailure {
path: string;
error: string;
}
export interface BulkDeleteResult {
deleted: string[];
failed: BulkFailure[];
}
export interface BulkMoveResult {
moved: string[];
failed: BulkFailure[];
}
export async function bulkDeleteStackPaths(stackName: string, paths: string[], rootId?: string): Promise<BulkDeleteResult> {
const res = await apiFetch(
stackFilesUrl(stackName, `/bulk-delete${rootId ? `?rootId=${encodeURIComponent(rootId)}` : ''}`),
{ method: 'POST', body: JSON.stringify({ paths }) }
);
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<BulkDeleteResult>;
}
export async function bulkMoveStackPaths(stackName: string, from: string[], toDir: string, rootId?: string): Promise<BulkMoveResult> {
const res = await apiFetch(
stackFilesUrl(stackName, `/bulk-move${rootId ? `?rootId=${encodeURIComponent(rootId)}` : ''}`),
{ method: 'POST', body: JSON.stringify({ from, toDir }) }
);
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<BulkMoveResult>;
}
/** GET the bulk-download archive (repeated ?path= so read-only API tokens work). */
export async function bulkDownloadStackFiles(stackName: string, paths: string[], rootId?: string): Promise<Response> {
const query = paths.map((p) => `path=${encodeURIComponent(p)}`).join('&');
const suffix = rootId ? `&rootId=${encodeURIComponent(rootId)}` : '';
return apiFetch(stackFilesUrl(stackName, `/bulk-download?${query}${suffix}`));
}
/**
* Drop any selected path whose ancestor is also selected (so a folder and a file
* inside it are not both acted on). A UX convenience; the backend re-normalizes
* authoritatively (with per-root case-awareness), so this is not the boundary.
*/
export function normalizeSelection(paths: string[]): string[] {
const set = new Set(paths);
return [...set].filter((p) => {
const segments = p.split('/');
for (let i = 1; i < segments.length; i++) {
if (set.has(segments.slice(0, i).join('/'))) return false;
}
return true;
});
}
export interface EntryPermissions {
mode: number;
octal: string;
}
export async function getStackEntryPermissions(
stackName: string,
relPath: string,
rootId?: string,
): Promise<EntryPermissions> {
assertSafeRelPath(relPath);
const res = await apiFetch(stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`));
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<EntryPermissions>;
}
export async function setStackEntryPermissions(
stackName: string,
relPath: string,
mode: number,
rootId?: string,
): Promise<void> {
assertSafeRelPath(relPath);
const res = await apiFetch(
stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`),
{ method: 'PUT', body: JSON.stringify({ mode }) }
);
if (!res.ok) throw new Error(await parseApiError(res));
}