mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
d8b6f8cf3b
* feat(stack-files): force-text override for misidentified binary files
The binary-detection heuristic (30% non-printable / NUL in the first
8 KB) sometimes flags UTF-8 files that happen to carry an embedded NUL
or a high non-printable ratio, locking the user out of inline editing
with only a Download fallback.
readStackFile now accepts an optional { forceText: true } that bypasses
isBinaryBuffer on the small-file path and returns the bytes as UTF-8
content. The route exposes this as ?force=text on GET /files/content.
The oversized branch deliberately stays untouched: returning a multi-MB
file as JSON-encoded text is wasteful regardless of the heuristic.
SpecialFilePanel grows an optional extraAction slot. The viewer's
binary branch wires Open as text anyway, which refetches with the new
flag, clears isBinary, and routes the content through the existing
Monaco editor path. A failed override surfaces both an inline error
panel and a toast so the user knows why the click did nothing.
Backend tests pin the heuristic-vs-override behaviour pair on a file
with a literal NUL byte. The frontend test asserts that the second
readStackFile call carries forceText: true and that Monaco mounts.
Troubleshooting accordion entry updated to mention the new affordance.
* fix(stack-files): guard the binary-override path against oversized files
The override on the binary panel could open Monaco against an empty
content buffer if the backend's oversized branch ran (files past the
2 MB inline-preview cap intentionally carry no content even when
force=text is set). Saving that empty buffer would wipe the file on
disk.
Two reinforcing changes:
- Initial load now checks result.oversized before result.binary, so a
file that is both oversized and has binary bytes in the 8 KB probe
shows the Download panel rather than the binary panel. The size
signal stays in front of the operator and the override button never
surfaces for a file that cannot be safely opened inline.
- The handleForceText handler now respects result.oversized on the
refetch and transitions to the Download panel instead of clearing
isBinary and copying result.content ?? '' into Monaco.
Same handler also gains a stale-request guard via a selectedPathRef:
a slow override for file A no longer stomps on file B's state if the
user navigated away while the request was in flight.
Two regression tests pin the new behaviour: oversized+binary surfaces
the Download panel on initial load, and an oversized refetch from the
binary panel routes to the Download panel rather than Monaco.
281 lines
9.2 KiB
TypeScript
281 lines
9.2 KiB
TypeScript
import { apiFetch } from './api';
|
|
|
|
/**
|
|
* 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`);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 and mtime are attached so callers can prompt the user to reconcile.
|
|
*/
|
|
export class FileConflictError extends Error {
|
|
readonly code = 'PRECONDITION_FAILED' as const;
|
|
readonly currentContent: string;
|
|
readonly currentMtimeMs: number;
|
|
constructor(message: string, currentContent: string, currentMtimeMs: number) {
|
|
super(message);
|
|
this.name = 'FileConflictError';
|
|
this.currentContent = currentContent;
|
|
this.currentMtimeMs = currentMtimeMs;
|
|
}
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
export async function listStackDirectory(
|
|
stackName: string,
|
|
relPath: string
|
|
): Promise<FileEntry[]> {
|
|
assertSafeRelPath(relPath);
|
|
const res = await apiFetch(stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}`));
|
|
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 }
|
|
): Promise<FileContentResult> {
|
|
assertSafeRelPath(relPath);
|
|
const forceSuffix = options?.forceText ? '&force=text' : '';
|
|
const res = await apiFetch(
|
|
stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}${forceSuffix}`)
|
|
);
|
|
if (!res.ok) throw new Error(await parseApiError(res));
|
|
return res.json() as Promise<FileContentResult>;
|
|
}
|
|
|
|
export async function downloadStackFile(
|
|
stackName: string,
|
|
relPath: string
|
|
): Promise<Response> {
|
|
assertSafeRelPath(relPath);
|
|
return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(relPath)}`));
|
|
}
|
|
|
|
/**
|
|
* 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 }
|
|
): 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}`)}`,
|
|
{ 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));
|
|
}
|
|
}
|
|
|
|
export async function writeStackFile(
|
|
stackName: string,
|
|
relPath: string,
|
|
content: string,
|
|
options?: { ifMatchMtimeMs?: number }
|
|
): Promise<{ mtimeMs: number | null }> {
|
|
assertSafeRelPath(relPath);
|
|
const headers: Record<string, string> = {};
|
|
if (options?.ifMatchMtimeMs !== undefined) {
|
|
headers['If-Match'] = `"${Math.floor(options.ifMatchMtimeMs)}"`;
|
|
}
|
|
const res = await apiFetch(
|
|
stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`),
|
|
{ method: 'PUT', headers, body: JSON.stringify({ content }) }
|
|
);
|
|
if (res.status === 412) {
|
|
let body: { currentContent?: string; currentMtimeMs?: number; 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,
|
|
);
|
|
}
|
|
if (!res.ok) throw new Error(await parseApiError(res));
|
|
// Parse the ETag the server set so callers can update their local mtime.
|
|
const etag = res.headers.get('ETag');
|
|
if (etag) {
|
|
const stripped = etag.replace(/^W\//i, '').trim().replace(/^"(.*)"$/, '$1');
|
|
const parsed = Number(stripped);
|
|
if (Number.isFinite(parsed)) return { mtimeMs: parsed };
|
|
}
|
|
return { mtimeMs: null };
|
|
}
|
|
|
|
export async function deleteStackPath(
|
|
stackName: string,
|
|
relPath: string,
|
|
recursive?: boolean
|
|
): Promise<void> {
|
|
assertSafeRelPath(relPath);
|
|
const qs = recursive
|
|
? `path=${encodeURIComponent(relPath)}&recursive=1`
|
|
: `path=${encodeURIComponent(relPath)}`;
|
|
const res = await apiFetch(stackFilesUrl(stackName, `?${qs}`), { method: 'DELETE' });
|
|
if (!res.ok) throw new Error(await parseApiError(res));
|
|
}
|
|
|
|
export async function mkdirStackPath(
|
|
stackName: string,
|
|
relPath: string
|
|
): Promise<void> {
|
|
assertSafeRelPath(relPath);
|
|
const res = await apiFetch(
|
|
stackFilesUrl(stackName, `/folder?path=${encodeURIComponent(relPath)}`),
|
|
{ method: 'POST', body: JSON.stringify({}) }
|
|
);
|
|
if (!res.ok) throw new Error(await parseApiError(res));
|
|
}
|
|
|
|
export async function renameStackPath(
|
|
stackName: string,
|
|
fromRel: string,
|
|
toRel: string
|
|
): Promise<void> {
|
|
assertSafeRelPath(fromRel, 'source path');
|
|
assertSafeRelPath(toRel, 'destination path');
|
|
const res = await apiFetch(
|
|
stackFilesUrl(stackName, '/rename'),
|
|
{ method: 'PATCH', body: JSON.stringify({ from: fromRel, to: toRel }) }
|
|
);
|
|
if (!res.ok) throw new Error(await parseApiError(res));
|
|
}
|
|
|
|
export interface EntryPermissions {
|
|
mode: number;
|
|
octal: string;
|
|
}
|
|
|
|
export async function getStackEntryPermissions(
|
|
stackName: string,
|
|
relPath: string
|
|
): Promise<EntryPermissions> {
|
|
assertSafeRelPath(relPath);
|
|
const res = await apiFetch(stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`));
|
|
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
|
|
): Promise<void> {
|
|
assertSafeRelPath(relPath);
|
|
const res = await apiFetch(
|
|
stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`),
|
|
{ method: 'PUT', body: JSON.stringify({ mode }) }
|
|
);
|
|
if (!res.ok) throw new Error(await parseApiError(res));
|
|
}
|
|
|