mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
1 parent
d5e4fe0f87
commit
869633ae6d
8 files changed
+205
-53
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user