mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user