fix(stack-files): confirm before overwriting an existing upload target (#1204)

* fix(stack-files): confirm before overwriting an existing upload target

Same-name uploads previously truncated the existing file silently. A
user dragging a file with a name that matched an in-place file
destroyed the original with no warning and no undo.

The upload route now reads ?overwrite=0|1. When the flag is not set
and the target already exists, the server returns 409 FILE_EXISTS
and the original file is untouched. The frontend opens a confirm
dialog and retries with overwrite=1 on the user's approval; cancel
keeps the original.

A new pathExists helper on FileSystemService performs the existence
check through the same path-resolution barrier as the write so a
malicious relPath cannot bypass the conflict check. UploadConflictError
is exported so callers can distinguish the conflict case from generic
upload failures without parsing error strings.

* fix(stack-files): distinct DIR_EXISTS code, drop INVALID_PATH swallow in existence check
This commit is contained in:
Anso
2026-05-24 23:17:22 -04:00
committed by GitHub
parent 37b12379c1
commit c8b095b887
6 changed files with 240 additions and 20 deletions
+27 -2
View File
@@ -84,11 +84,24 @@ export async function downloadStackFile(
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 }
options?: { localOnly?: boolean; overwrite?: boolean }
): Promise<void> {
assertSafeRelPath(targetDir, 'target directory');
const fd = new FormData();
@@ -100,11 +113,12 @@ export async function uploadStackFile(
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)}`)}`,
`/api${stackFilesUrl(stackName, `/upload?path=${encodeURIComponent(targetDir)}${overwriteSuffix}`)}`,
{ method: 'POST', credentials: 'include', headers, body: fd }
);
@@ -115,6 +129,17 @@ export async function uploadStackFile(
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 {