fix(picker): do not offer New folder inside a cases directory

A folder made in a cases directory is already a case (no scaffold), which Link
Existing then refuses by name and which deleting would take the linked folder
with. The route refuses a cases directory as the parent (the shared one and the
caller's own), a leading tilde in the name, and maps a vanished parent to 404.
Docs now say every name it accepts is one Link Existing accepts, and the wiki
describes the button.
This commit is contained in:
Devvyn committed 2026-10-31 21:12:46 +08:00
1 parent 869633ae6d
commit 7a3629e890
7 files changed
+68 -8

No files matched your search

+20
View File
@@ -57,6 +57,7 @@ import {
findSessionOrFail,
getAuthUser,
parseBody,
resolveCasesDir,
validateSessionFilePath,
validateSessionFilePathLexical,
} from '../route-helpers.js';
@@ -1468,6 +1469,21 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folders cannot be created here');
}
// The cases directories (shared, and the caller's own in multi-user mode) are what GET /api/cases lists as
// local cases: a folder made there is a case with no scaffold, which Link then refuses by name and which
// deleting would take the linked folder with. A case there is made through Add Case > New Case.
const casesDirs = await Promise.all(
[...new Set([CASES_DIR, resolveCasesDir(getAuthUser(req))])].map((dir) =>
fs.realpath(dir).catch(() => resolve(dir))
)
);
if (casesDirs.includes(resolvedPath)) {
reply.code(400);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'This is a cases folder: every folder in it is a case. Use Add Case > New Case to make one there.'
);
}
// Like browse, a file is not a place to create in.
try {
if (!(await fs.stat(resolvedPath)).isDirectory()) throw new Error('not a directory');
@@ -1488,6 +1504,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder is not writable');
}
if (code === 'ENOENT') {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
}
if (code === 'ENAMETOOLONG') {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folder name is too long');
+4 -4
View File
@@ -85,8 +85,8 @@ const showHiddenQuerySchema = z.enum(['true', 'false']).optional();
/**
* Body for the path picker's "New folder": one folder name created directly inside `path`. The name is held
* to the character set `safePathSchema` accepts (letters, digits, `_ - . ~` and space, no `..`), so every
* folder this makes is one Link Existing and a session working directory will accept, and never a control
* character. It is also one path segment and not dot-prefixed (the picker hides those by default), and at
* name it accepts is one Link Existing and a session working directory accept (the parent's own path is
* judged there separately), and never a control character. It is also one path segment, not dot-prefixed (the picker hides those by default) and not tilde-prefixed (a folder named `~` is an `rm -rf ~` accident), and at
* most 255 BYTES, the usual filesystem limit.
*/
export const FilesystemMkdirSchema = z.object({
@@ -99,8 +99,8 @@ export const FilesystemMkdirSchema = z.object({
.refine((n) => /^[\p{L}\p{N}_\-. ~]+$/u.test(n), {
message: 'Folder name can only use letters, numbers, spaces and _ - . ~',
})
.refine((n) => !n.includes('..') && !n.startsWith('.'), {
message: 'Folder name cannot contain ".." or start with a dot',
.refine((n) => !n.includes('..') && !n.startsWith('.') && !n.startsWith('~'), {
message: 'Folder name cannot contain "..", or start with a dot or a tilde',
}),
sessionId: z
.string()