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
+20 -2
View File
@@ -1310,6 +1310,8 @@ type FsErrorCode =
| 'NOT_FOUND'
| 'TOO_LARGE'
| 'ALREADY_EXISTS'
| 'FILE_EXISTS'
| 'DIR_EXISTS'
| 'PROTECTED_FILE';
function sendFsError(
@@ -1477,11 +1479,27 @@ stacksRouter.post(
return res.status(400).json({ error: 'Invalid filename' });
}
const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName;
const overwrite = String(req.query.overwrite) === '1';
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 {
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);
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 });
return res.status(204).send();
} catch (err: unknown) {