Files
cobble/src/functions/backend/storage/resolve-storage-path.ts
T
tben 956942bba9 Add a files browser for every folder on the data volume
A bot's deliverables and the raw material behind them only existed as
paths in a message, so they couldn't be opened from the app at all. This
browses them read-only at /admin/files, one volume at a time: a bot's own
folder, a workspace's shared one, a computer's home (where a bot's ~/...
actually is), the media library, an MCP server's install folder and the
database.

Access is the whole gate: resolveStorageSpec checks the viewer against
the volume first (a bot through grabUserBot, a workspace through
grabUserWorkspace, a computer through grabUserComputer, media only their
own unless they manage the crew) and no path is built before it passes.
resolveStoragePath then applies the resolveBotPath rules to any root.
The picker lists folders through reachableBotsSql, so it never offers one
the resolver would refuse.

Reading is listStorageEntries (folders first, paginated, symlinks
skipped, catalog rows joined so a bot file still opens through its media
row) and grabStorageFile for bytes under a cap, both behind an ETag.
2026-10-22 07:37:16 +01:00

129 lines
3.8 KiB
TypeScript

import { existsSync, realpathSync } from "node:fs";
import path from "node:path";
import { AppData } from "@/src/data/app-data";
import AppError from "@/src/utils/app-error";
type Params = {
/**
* The volume's absolute root (`resolveStorageSpec`'s `root`), already
* checked against the user.
*/
root: string;
/**
* The path a request asked for, relative to that root, e.g.
* `seo/coderank/raw`. Never absolute, and may be empty for the root
* itself.
*/
path?: string | null;
};
export type ResolvedStoragePath = {
/**
* The normalized path inside the root, forward slashes, no leading or
* trailing slash. Empty at the root.
*/
relative: string;
/**
* The absolute path on disk, inside `root`.
*/
absolute: string;
/**
* The folder part of `relative`, empty at the top.
*/
folder: string;
/**
* The last segment, empty at the root.
*/
name: string;
};
/**
* Turns a requested path into one safely inside a volume's root: rejects
* absolute paths, `..`, NUL bytes, anything too long or too deep, and
* anything a symlink points outside the root. The same rules a bot's own
* file path gets (`resolveBotPath`), for a root the browser picks rather
* than a bot's folders.
*
* A path that doesn't exist yet is allowed (a folder is made on demand by
* the bot's tools); a caller that needs an existing file checks it
* afterwards.
*/
export default function resolveStoragePath({ root, path: requested }: Params): ResolvedStoragePath {
const resolved_root = path.resolve(root);
const raw = (requested || "").trim().replace(/\\/g, "/");
if (raw.includes("\0")) {
throw new AppError(`That path isn't valid.`);
}
if (raw.startsWith("/") || /^[a-zA-Z]:/.test(raw)) {
throw new AppError(`Use a path inside the folder, not an absolute one.`);
}
const segments = raw.split("/").map((segment) => segment.trim()).filter(Boolean);
for (const segment of segments) {
if (segment == "." || segment == "..") {
throw new AppError(`That path isn't valid: it must stay inside the folder.`);
}
}
if (segments.length > AppData.BotFilePathMaxDepth) {
throw new AppError(`That path is too deep: at most ${AppData.BotFilePathMaxDepth} folders.`);
}
const relative = segments.join("/");
if (relative.length > AppData.BotFilePathMaxChars) {
throw new AppError(`That path is too long.`);
}
const absolute = path.join(resolved_root, ...segments);
if (!isInsideRoot({ root: resolved_root, absolute })) {
throw new AppError(`That path isn't valid: it must stay inside the folder.`);
}
guardRealPath({ root: resolved_root, absolute });
return {
relative,
absolute,
folder: segments.slice(0, -1).join("/"),
name: segments.at(-1) || "",
};
}
/**
* Refuses a path whose existing part resolves outside the root through a
* symlink (or any other reason).
*/
function guardRealPath({ root, absolute }: { root: string; absolute: string }) {
const real_root = existsSync(root) ? realpathSync(root) : path.resolve(root);
let probe = absolute;
while (!existsSync(probe)) {
const parent = path.dirname(probe);
if (parent == probe) break;
probe = parent;
}
const real_probe = realpathSync(probe);
if (!isInsideRoot({ root: real_root, absolute: real_probe })) {
throw new AppError(`That path isn't valid: it must stay inside the folder.`);
}
}
/**
* Whether a path is inside a root, before it exists.
*/
export function isInsideRoot({ root, absolute }: { root: string; absolute: string }) {
const resolved_root = path.resolve(root);
const resolved = path.resolve(absolute);
return resolved == resolved_root || resolved.startsWith(`${resolved_root}${path.sep}`);
}