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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FileUploadDropzone } from '../FileUploadDropzone';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/stackFilesApi', () => ({
|
||||
uploadStackFile: vi.fn(),
|
||||
}));
|
||||
// Mock the API module FIRST so the real class export is replaced before
|
||||
// FileUploadDropzone imports it.
|
||||
vi.mock('@/lib/stackFilesApi', () => {
|
||||
class MockUploadConflictError extends Error {
|
||||
readonly code = 'FILE_EXISTS' as const;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'UploadConflictError';
|
||||
}
|
||||
}
|
||||
return {
|
||||
uploadStackFile: vi.fn(),
|
||||
UploadConflictError: MockUploadConflictError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
@@ -15,6 +27,15 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { FileUploadDropzone } from '../FileUploadDropzone';
|
||||
import { uploadStackFile, UploadConflictError } from '@/lib/stackFilesApi';
|
||||
|
||||
const mockUpload = uploadStackFile as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpload.mockReset();
|
||||
});
|
||||
|
||||
describe('FileUploadDropzone', () => {
|
||||
it('renders upload control for users with stack edit permission', () => {
|
||||
render(
|
||||
@@ -41,4 +62,50 @@ describe('FileUploadDropzone', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the replace dialog on FILE_EXISTS and retries with overwrite on confirm', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUploaded = vi.fn();
|
||||
mockUpload
|
||||
.mockRejectedValueOnce(new UploadConflictError('foo.txt already exists.'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
render(
|
||||
<FileUploadDropzone stackName="app" currentDir="" canEdit onUploaded={onUploaded} />,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText(/upload file/i) as HTMLInputElement;
|
||||
const file = new File(['payload'], 'foo.txt', { type: 'text/plain' });
|
||||
await user.upload(input, file);
|
||||
|
||||
expect(await screen.findByText(/replace existing file/i)).toBeInTheDocument();
|
||||
expect(mockUpload).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpload).toHaveBeenNthCalledWith(1, 'app', '', file, { overwrite: false });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^replace$/i }));
|
||||
|
||||
expect(mockUpload).toHaveBeenCalledTimes(2);
|
||||
expect(mockUpload).toHaveBeenNthCalledWith(2, 'app', '', file, { overwrite: true });
|
||||
expect(onUploaded).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves the original file untouched when the user cancels the replace dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUploaded = vi.fn();
|
||||
mockUpload.mockRejectedValueOnce(new UploadConflictError('foo.txt already exists.'));
|
||||
|
||||
render(
|
||||
<FileUploadDropzone stackName="app" currentDir="" canEdit onUploaded={onUploaded} />,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText(/upload file/i) as HTMLInputElement;
|
||||
const file = new File(['payload'], 'foo.txt', { type: 'text/plain' });
|
||||
await user.upload(input, file);
|
||||
|
||||
expect(await screen.findByText(/replace existing file/i)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /^cancel$/i }));
|
||||
|
||||
expect(mockUpload).toHaveBeenCalledTimes(1);
|
||||
expect(onUploaded).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user