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
@@ -1,7 +1,8 @@
import { useRef } from 'react';
import { useRef, useState } from 'react';
import { UploadCloud } from 'lucide-react';
import { ConfirmModal } from '@/components/ui/modal';
import { toast } from '@/components/ui/toast-store';
import { uploadStackFile } from '@/lib/stackFilesApi';
import { uploadStackFile, UploadConflictError } from '@/lib/stackFilesApi';
const MAX_BYTES = 25 * 1024 * 1024; // 25 MB
@@ -19,29 +20,38 @@ export function FileUploadDropzone({
onUploaded,
}: FileUploadDropzoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [conflict, setConflict] = useState<File | null>(null);
if (!canEdit) return null;
const handleFile = async (file: File) => {
if (file.size > MAX_BYTES) {
toast.error('File exceeds 25 MB.');
return;
}
const runUpload = async (file: File, overwrite: boolean): Promise<void> => {
const loadingId = toast.loading(`Uploading ${file.name}...`);
try {
await uploadStackFile(stackName, currentDir, file);
toast.success('Uploaded.');
await uploadStackFile(stackName, currentDir, file, { overwrite });
toast.success(overwrite ? 'Replaced.' : 'Uploaded.');
onUploaded();
} catch (e: unknown) {
if (e instanceof UploadConflictError) {
setConflict(file);
return;
}
toast.error(e instanceof Error ? e.message : 'Upload failed.');
} finally {
toast.dismiss(loadingId);
}
};
const handleFile = (file: File) => {
if (file.size > MAX_BYTES) {
toast.error('File exceeds 25 MB.');
return;
}
void runUpload(file, false);
};
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const file = ev.target.files?.[0];
if (file) void handleFile(file);
if (file) handleFile(file);
ev.target.value = '';
};
@@ -69,6 +79,25 @@ export function FileUploadDropzone({
<UploadCloud className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
Upload file
</div>
<ConfirmModal
open={conflict !== null}
onOpenChange={(next) => { if (!next) setConflict(null); }}
onCancel={() => setConflict(null)}
kicker="FILES · REPLACE EXISTING"
title="Replace existing file?"
description={conflict ? `${conflict.name} already exists in this folder.` : ''}
confirmLabel="Replace"
onConfirm={() => {
const file = conflict;
setConflict(null);
if (file) void runUpload(file, true);
}}
>
<p className="text-sm text-muted-foreground">
{conflict?.name ?? 'The file'} already exists. Replacing it overwrites the current contents and cannot be undone.
</p>
</ConfirmModal>
</>
);
}