mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 06:35:29 +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:
@@ -320,6 +320,63 @@ describe('POST /api/stacks/:stackName/files/upload', () => {
|
|||||||
const content = await fs.readFile(path.join(stacksDir, STACK, 'subdir', 'sub.txt'), 'utf-8');
|
const content = await fs.readFile(path.join(stacksDir, STACK, 'subdir', 'sub.txt'), 'utf-8');
|
||||||
expect(content).toBe('subdir content');
|
expect(content).toBe('subdir content');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 409 FILE_EXISTS when the target name already exists and overwrite is not set', async () => {
|
||||||
|
const target = path.join(stacksDir, STACK, 'existing.txt');
|
||||||
|
await fs.writeFile(target, 'original');
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/stacks/${STACK}/files/upload`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', Buffer.from('replacement'), 'existing.txt');
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.code).toBe('FILE_EXISTS');
|
||||||
|
// Original content must be preserved when the upload is rejected.
|
||||||
|
const after = await fs.readFile(target, 'utf-8');
|
||||||
|
expect(after).toBe('original');
|
||||||
|
await fs.unlink(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites when ?overwrite=1 is set', async () => {
|
||||||
|
const target = path.join(stacksDir, STACK, 'replaceme.txt');
|
||||||
|
await fs.writeFile(target, 'before');
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/stacks/${STACK}/files/upload`)
|
||||||
|
.query({ overwrite: '1' })
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', Buffer.from('after'), 'replaceme.txt');
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
const after = await fs.readFile(target, 'utf-8');
|
||||||
|
expect(after).toBe('after');
|
||||||
|
await fs.unlink(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 DIR_EXISTS when a directory occupies the upload target name', async () => {
|
||||||
|
const dir = path.join(stacksDir, STACK, 'collide-dir');
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/stacks/${STACK}/files/upload`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', Buffer.from('whatever'), 'collide-dir');
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.code).toBe('DIR_EXISTS');
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still returns 409 DIR_EXISTS even when ?overwrite=1 is set (directories are never replaced)', async () => {
|
||||||
|
const dir = path.join(stacksDir, STACK, 'collide-dir-2');
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/stacks/${STACK}/files/upload`)
|
||||||
|
.query({ overwrite: '1' })
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', Buffer.from('whatever'), 'collide-dir-2');
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.code).toBe('DIR_EXISTS');
|
||||||
|
// The directory must still exist after the rejected upload.
|
||||||
|
const stat = await fs.stat(dir);
|
||||||
|
expect(stat.isDirectory()).toBe(true);
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT /:stackName/files/content ─────────────────────────────────────────────
|
// ── PUT /:stackName/files/content ─────────────────────────────────────────────
|
||||||
@@ -702,10 +759,15 @@ describe('protected stack files', () => {
|
|||||||
expect(res.status).toBe(204);
|
expect(res.status).toBe(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /files/upload still succeeds when overwriting compose.yaml (legitimate replace)', async () => {
|
it('POST /files/upload still succeeds when overwriting compose.yaml with overwrite=1 (legitimate replace)', async () => {
|
||||||
|
// Combined semantics: same-name uploads need ?overwrite=1 to pass the
|
||||||
|
// upload-confirm gate. The protected-file enforcement deliberately does
|
||||||
|
// NOT block this path because replacing compose.yaml via upload is a
|
||||||
|
// legitimate user-driven action.
|
||||||
const replacement = 'services:\n uploaded:\n image: busybox\n';
|
const replacement = 'services:\n uploaded:\n image: busybox\n';
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post(`/api/stacks/${STACK}/files/upload`)
|
.post(`/api/stacks/${STACK}/files/upload`)
|
||||||
|
.query({ overwrite: '1' })
|
||||||
.set('Cookie', adminCookie)
|
.set('Cookie', adminCookie)
|
||||||
.attach('file', Buffer.from(replacement), 'compose.yaml');
|
.attach('file', Buffer.from(replacement), 'compose.yaml');
|
||||||
expect(res.status).toBe(204);
|
expect(res.status).toBe(204);
|
||||||
|
|||||||
@@ -1310,6 +1310,8 @@ type FsErrorCode =
|
|||||||
| 'NOT_FOUND'
|
| 'NOT_FOUND'
|
||||||
| 'TOO_LARGE'
|
| 'TOO_LARGE'
|
||||||
| 'ALREADY_EXISTS'
|
| 'ALREADY_EXISTS'
|
||||||
|
| 'FILE_EXISTS'
|
||||||
|
| 'DIR_EXISTS'
|
||||||
| 'PROTECTED_FILE';
|
| 'PROTECTED_FILE';
|
||||||
|
|
||||||
function sendFsError(
|
function sendFsError(
|
||||||
@@ -1477,11 +1479,27 @@ stacksRouter.post(
|
|||||||
return res.status(400).json({ error: 'Invalid filename' });
|
return res.status(400).json({ error: 'Invalid filename' });
|
||||||
}
|
}
|
||||||
const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName;
|
const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName;
|
||||||
|
const overwrite = String(req.query.overwrite) === '1';
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
logFileDiag('upload start', { stackName, relPath: targetRelPath, nodeId: req.nodeId, size: req.file.size });
|
logFileDiag('upload start', { stackName, relPath: targetRelPath, nodeId: req.nodeId, size: req.file.size, overwrite });
|
||||||
try {
|
try {
|
||||||
|
const existing = await FileSystemService.getInstance(req.nodeId).pathKind(stackName, targetRelPath);
|
||||||
|
if (existing === 'directory') {
|
||||||
|
// A directory can never be replaced by an upload; surface a distinct code
|
||||||
|
// so the UI does not offer a useless "Replace" button.
|
||||||
|
return res.status(409).json({
|
||||||
|
error: `A folder named ${originalName} already exists in this folder. Rename the upload or remove the folder first.`,
|
||||||
|
code: 'DIR_EXISTS',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (existing === 'file' && !overwrite) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: `${originalName} already exists in this folder. Confirm to replace.`,
|
||||||
|
code: 'FILE_EXISTS',
|
||||||
|
});
|
||||||
|
}
|
||||||
await FileSystemService.getInstance(req.nodeId).writeStackFileBuffer(stackName, targetRelPath, req.file.buffer);
|
await FileSystemService.getInstance(req.nodeId).writeStackFileBuffer(stackName, targetRelPath, req.file.buffer);
|
||||||
logFileOperation('info', 'upload complete', { nodeId: req.nodeId, size: req.file.size });
|
logFileOperation('info', 'upload complete', { nodeId: req.nodeId, size: req.file.size, overwrite });
|
||||||
logFileDiag('upload timing', { stackName, relPath: targetRelPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
logFileDiag('upload timing', { stackName, relPath: targetRelPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -711,6 +711,25 @@ export class FileSystemService {
|
|||||||
await fsPromises.writeFile(safePath, buffer);
|
await fsPromises.writeFile(safePath, buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns 'file' or 'directory' if the resolved path exists, null if it
|
||||||
|
* does not. Path-resolution errors (INVALID_PATH, SYMLINK_ESCAPE) propagate
|
||||||
|
* so callers do not silently treat a malformed path as 'available for write'.
|
||||||
|
* Callers should validate inputs upstream before invoking this helper.
|
||||||
|
*/
|
||||||
|
async pathKind(stackName: string, relPath: string): Promise<'file' | 'directory' | null> {
|
||||||
|
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||||
|
try {
|
||||||
|
const stat = await fsPromises.lstat(safePath);
|
||||||
|
if (stat.isDirectory()) return 'directory';
|
||||||
|
return 'file';
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as NodeJS.ErrnoException;
|
||||||
|
if (e.code === 'ENOENT') return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise<void> {
|
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise<void> {
|
||||||
if (isProtectedRelPath(relPath)) throw protectedFileError(relPath);
|
if (isProtectedRelPath(relPath)) throw protectedFileError(relPath);
|
||||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { UploadCloud } from 'lucide-react';
|
import { UploadCloud } from 'lucide-react';
|
||||||
|
import { ConfirmModal } from '@/components/ui/modal';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
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
|
const MAX_BYTES = 25 * 1024 * 1024; // 25 MB
|
||||||
|
|
||||||
@@ -19,29 +20,38 @@ export function FileUploadDropzone({
|
|||||||
onUploaded,
|
onUploaded,
|
||||||
}: FileUploadDropzoneProps) {
|
}: FileUploadDropzoneProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [conflict, setConflict] = useState<File | null>(null);
|
||||||
|
|
||||||
if (!canEdit) return null;
|
if (!canEdit) return null;
|
||||||
|
|
||||||
const handleFile = async (file: File) => {
|
const runUpload = async (file: File, overwrite: boolean): Promise<void> => {
|
||||||
if (file.size > MAX_BYTES) {
|
|
||||||
toast.error('File exceeds 25 MB.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const loadingId = toast.loading(`Uploading ${file.name}...`);
|
const loadingId = toast.loading(`Uploading ${file.name}...`);
|
||||||
try {
|
try {
|
||||||
await uploadStackFile(stackName, currentDir, file);
|
await uploadStackFile(stackName, currentDir, file, { overwrite });
|
||||||
toast.success('Uploaded.');
|
toast.success(overwrite ? 'Replaced.' : 'Uploaded.');
|
||||||
onUploaded();
|
onUploaded();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof UploadConflictError) {
|
||||||
|
setConflict(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error(e instanceof Error ? e.message : 'Upload failed.');
|
toast.error(e instanceof Error ? e.message : 'Upload failed.');
|
||||||
} finally {
|
} finally {
|
||||||
toast.dismiss(loadingId);
|
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 handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = ev.target.files?.[0];
|
const file = ev.target.files?.[0];
|
||||||
if (file) void handleFile(file);
|
if (file) handleFile(file);
|
||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,6 +79,25 @@ export function FileUploadDropzone({
|
|||||||
<UploadCloud className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
<UploadCloud className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
||||||
Upload file
|
Upload file
|
||||||
</div>
|
</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 { render, screen } from '@testing-library/react';
|
||||||
import { FileUploadDropzone } from '../FileUploadDropzone';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
vi.mock('@/lib/stackFilesApi', () => ({
|
// Mock the API module FIRST so the real class export is replaced before
|
||||||
uploadStackFile: vi.fn(),
|
// 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', () => ({
|
vi.mock('@/components/ui/toast-store', () => ({
|
||||||
toast: {
|
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', () => {
|
describe('FileUploadDropzone', () => {
|
||||||
it('renders upload control for users with stack edit permission', () => {
|
it('renders upload control for users with stack edit permission', () => {
|
||||||
render(
|
render(
|
||||||
@@ -41,4 +62,50 @@ describe('FileUploadDropzone', () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,11 +84,24 @@ export async function downloadStackFile(
|
|||||||
return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(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(
|
export async function uploadStackFile(
|
||||||
stackName: string,
|
stackName: string,
|
||||||
targetDir: string,
|
targetDir: string,
|
||||||
file: File,
|
file: File,
|
||||||
options?: { localOnly?: boolean }
|
options?: { localOnly?: boolean; overwrite?: boolean }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
assertSafeRelPath(targetDir, 'target directory');
|
assertSafeRelPath(targetDir, 'target directory');
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
@@ -100,11 +113,12 @@ export async function uploadStackFile(
|
|||||||
headers['x-node-id'] = activeNodeId;
|
headers['x-node-id'] = activeNodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overwriteSuffix = options?.overwrite ? '&overwrite=1' : '';
|
||||||
// Use fetch directly: apiFetch always sets Content-Type: application/json,
|
// Use fetch directly: apiFetch always sets Content-Type: application/json,
|
||||||
// which breaks multipart boundary negotiation. The 401 side-effects are
|
// which breaks multipart boundary negotiation. The 401 side-effects are
|
||||||
// replicated manually below.
|
// replicated manually below.
|
||||||
const res = await fetch(
|
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 }
|
{ method: 'POST', credentials: 'include', headers, body: fd }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -115,6 +129,17 @@ export async function uploadStackFile(
|
|||||||
throw new Error('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.ok) {
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user