fix(picker): make New folder names Link Existing can use, and tighten the route

The name rule now matches safePathSchema (letters, digits, space and _ - . ~,
no .., no leading dot, at most 255 bytes) so every folder it makes can be
linked. A file as the parent is a 400, errno text is no longer returned,
showHidden is forwarded like browse, and Create is disabled while the request
runs. Adds multi-user scoping tests.
This commit is contained in:
Devvyn committed 2026-10-31 19:44:02 +08:00
1 parent d5e4fe0f87
commit 869633ae6d
8 files changed
+205 -53

No files matched your search

+11 -1
View File
@@ -222,12 +222,20 @@ const PathPicker = {
const name = input.value.trim();
if (!name || !this._currentPath) return;
const status = overlay.querySelector('.path-picker-status');
const create = overlay.querySelector('.path-picker-newfolder-create');
const parent = this._currentPath;
// One request at a time: a double tap would send a second one whose 409 shows after the first succeeded.
create.disabled = true;
try {
const res = await fetch('/api/filesystem/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: parent, name, sessionId: this._options?.sessionId || undefined }),
body: JSON.stringify({
path: parent,
name,
sessionId: this._options?.sessionId || undefined,
showHidden: this._showHidden ? 'true' : undefined,
}),
});
const result = await res.json();
if (!result?.success) throw new Error(result?.error || 'Could not create the folder');
@@ -240,6 +248,8 @@ const PathPicker = {
if (!this.overlay) return;
status.textContent = error.message || 'Could not create the folder';
status.classList.add('error');
} finally {
create.disabled = false;
}
});
},
+39 -39
View File
@@ -12140,45 +12140,6 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
gap: 0.25rem;
}
.path-picker-newfolder {
flex-basis: auto;
padding: 0 10px;
white-space: nowrap;
}
.path-picker-newfolder[hidden],
.path-picker-newfolder-form[hidden] {
display: none;
}
.path-picker-newfolder-form {
display: flex;
gap: 8px;
padding: 0 12px 10px;
}
.path-picker-newfolder-name {
flex: 1 1 auto;
min-width: 0;
height: 38px;
padding: 0 10px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
}
.path-picker-newfolder-create,
.path-picker-newfolder-cancel {
height: 38px;
padding: 0 12px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
/* Show-hidden toggle: a literal `.*` glyph rather than an icon, so its meaning
* (dot-prefixed files and folders) survives every skin and font stack. */
.btn-file-browser-hidden {
@@ -15828,6 +15789,45 @@ body.touch-device.cjk-input-visible .main {
border-color: var(--accent);
}
.path-picker-newfolder {
flex: 0 0 auto;
padding: 0 10px;
white-space: nowrap;
}
.path-picker-newfolder[hidden],
.path-picker-newfolder-form[hidden] {
display: none;
}
.path-picker-newfolder-form {
display: flex;
gap: 8px;
padding: 0 12px 10px;
}
.path-picker-newfolder-name {
flex: 1 1 auto;
min-width: 0;
height: 38px;
padding: 0 10px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
}
.path-picker-newfolder-create,
.path-picker-newfolder-cancel {
height: 38px;
padding: 0 12px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.path-picker-up:disabled {
opacity: 0.35;
cursor: default;
+20 -4
View File
@@ -1453,21 +1453,28 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
};
});
// Inline preview for files selected through the root-confined filesystem picker.
// "New folder" in the path picker: creates ONE folder directly inside a folder the picker may browse.
app.post('/api/filesystem/mkdir', async (req, reply): Promise<ApiResponse<{ path: string }>> => {
const { path: parentPath, name, sessionId } = parseBody(FilesystemMkdirSchema, req.body);
const { path: parentPath, name, sessionId, showHidden } = parseBody(FilesystemMkdirSchema, req.body);
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
parentPath,
sessionId
sessionId,
wantsHiddenPickerEntries(showHidden)
);
const target = join(resolvedPath, name);
if (isBlockedPickerPath(resolvedPath, blockedTrees, true) || isBlockedPickerPath(target, blockedTrees, true)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folders cannot be created here');
}
// Like browse, a file is not a place to create in.
try {
if (!(await fs.stat(resolvedPath)).isDirectory()) throw new Error('not a directory');
} catch {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The folder to create in must be a directory');
}
try {
// Not recursive: the parent was just resolved inside an allowed root, and an existing folder must be a 409.
await fs.mkdir(target);
@@ -1481,12 +1488,21 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder is not writable');
}
if (code === 'ENAMETOOLONG') {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folder name is too long');
}
// Never the errno text: it names the resolved, symlink-followed path, not the one the picker shows.
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Could not create the folder: ${getErrorMessage(err)}`);
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Could not create the folder${code ? ` (${code})` : ''}`
);
}
return { success: true, data: { path: join(candidatePath, name) } };
});
// Inline preview for files selected through the root-confined filesystem picker.
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query);
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
+13 -6
View File
@@ -83,8 +83,11 @@ const filesystemPickerPathSchema = z
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 a
* single path segment (no separator, no `.`/`..`, not dot-prefixed, which the picker hides by default).
* 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
* most 255 BYTES, the usual filesystem limit.
*/
export const FilesystemMkdirSchema = z.object({
path: filesystemPickerPathSchema,
@@ -92,16 +95,20 @@ export const FilesystemMkdirSchema = z.object({
.string()
.trim()
.min(1, 'Folder name is required')
.max(255)
.refine((n) => !/[/\\\0\n\r]/.test(n), { message: 'Folder name cannot contain slashes or control characters' })
.refine((n) => n !== '.' && n !== '..' && !n.startsWith('.'), {
message: 'Folder name cannot start with a dot',
.refine((n) => Buffer.byteLength(n) <= 255, { message: 'Folder name is too long' })
.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',
}),
sessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
.optional(),
/** Same opt-in flag as browse: lets the picker create inside a hidden folder it is showing. */
showHidden: showHiddenQuerySchema,
});
/** Query validation for the lazy, allowlisted filesystem path picker. */