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.
129 lines
3.8 KiB
TypeScript
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}`);
|
|
}
|