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.
166 lines
4.6 KiB
TypeScript
166 lines
4.6 KiB
TypeScript
/**
|
|
* The folders on the app's data volume (`DATA_DIR`) the files browser at
|
|
* `/admin/files` can open, one volume per kind of owner: a bot's own
|
|
* folder, a workspace's shared one, a computer's home, the media library,
|
|
* an MCP server's install folder, and the database.
|
|
*
|
|
* Client-safe: no `node:path` here, so a chip in a message can build the
|
|
* same link the page reads.
|
|
*/
|
|
export const StorageVolumeDefs = [
|
|
{
|
|
value: "bots",
|
|
title: "Bots",
|
|
/**
|
|
* How a path ref names it (`[path:bot:3:reports]`).
|
|
*/
|
|
ref: "bot",
|
|
owner_kind: "bot",
|
|
},
|
|
{
|
|
value: "workspaces",
|
|
title: "Workspaces",
|
|
ref: "workspace",
|
|
owner_kind: "workspace",
|
|
},
|
|
{
|
|
value: "computers",
|
|
title: "Computers",
|
|
ref: "computer",
|
|
owner_kind: "computer",
|
|
},
|
|
{
|
|
value: "media",
|
|
title: "Media",
|
|
ref: "media",
|
|
owner_kind: "user",
|
|
},
|
|
{
|
|
value: "mcp",
|
|
title: "MCP servers",
|
|
ref: "mcp",
|
|
owner_kind: "mcp",
|
|
},
|
|
{
|
|
value: "db",
|
|
title: "Database",
|
|
ref: "db",
|
|
owner_kind: "none",
|
|
},
|
|
] as const;
|
|
|
|
export const StorageVolumes = StorageVolumeDefs.map((def) => def.value);
|
|
|
|
export type StorageVolume = (typeof StorageVolumeDefs)[number]["value"];
|
|
|
|
export type StorageOwnerKind = (typeof StorageVolumeDefs)[number]["owner_kind"];
|
|
|
|
/**
|
|
* One browsable folder: its volume and who owns it (a bot id, workspace
|
|
* id, computer id, user id or MCP server id; 0 for the database).
|
|
*/
|
|
export type StorageSpec = {
|
|
volume: StorageVolume;
|
|
owner_id: number;
|
|
};
|
|
|
|
/**
|
|
* Where the files browser lands when it has no folder asked for.
|
|
*/
|
|
export const StorageBrowserPath = "/admin/files";
|
|
|
|
/**
|
|
* A volume's definition, or undefined for an unknown one.
|
|
*/
|
|
export function storageVolumeDef({ volume }: { volume?: string | null }) {
|
|
return StorageVolumeDefs.find((def): def is (typeof StorageVolumeDefs)[number] => def.value == volume);
|
|
}
|
|
|
|
/**
|
|
* Whether `volume` is one of `StorageVolumes`.
|
|
*/
|
|
export function isStorageVolume(volume?: string | null): volume is StorageVolume {
|
|
return Boolean(storageVolumeDef({ volume }));
|
|
}
|
|
|
|
/**
|
|
* The volume a path ref names (`bot`, `computer`, ...), or null.
|
|
*/
|
|
export function storageVolumeFromRef({ name }: { name?: string | null }): StorageVolume | null {
|
|
const def = StorageVolumeDefs.find((it) => it.ref == name);
|
|
|
|
return def ? def.value : null;
|
|
}
|
|
|
|
/**
|
|
* Whether that volume's folder needs an owner id (everything but the
|
|
* database).
|
|
*/
|
|
export function storageVolumeNeedsOwner({ volume }: { volume: StorageVolume }) {
|
|
return storageVolumeDef({ volume })?.owner_kind != "none";
|
|
}
|
|
|
|
/**
|
|
* A volume's title, for the picker and a chip's tooltip.
|
|
*/
|
|
export function storageVolumeLabel({ volume }: { volume: StorageVolume }) {
|
|
return storageVolumeDef({ volume })?.title || volume;
|
|
}
|
|
|
|
/**
|
|
* Reads a volume and owner from an untrusted source (a query string, a
|
|
* ref, a request body): null when either is wrong, so a bad id never
|
|
* becomes a path.
|
|
*/
|
|
export function parseStorageSpec({
|
|
volume,
|
|
owner_id,
|
|
}: {
|
|
volume?: string | null;
|
|
owner_id?: number | string | null;
|
|
}): StorageSpec | null {
|
|
if (!isStorageVolume(volume)) return null;
|
|
|
|
const wanted = storageVolumeNeedsOwner({ volume });
|
|
const owner = Number(owner_id);
|
|
|
|
if (!wanted) return { volume, owner_id: 0 };
|
|
|
|
if (!Number.isFinite(owner) || owner <= 0) return null;
|
|
|
|
return { volume, owner_id: Math.trunc(owner) };
|
|
}
|
|
|
|
/**
|
|
* Whether two folders are the same one.
|
|
*/
|
|
export function isSameStorageSpec({ a, b }: { a?: StorageSpec | null; b?: StorageSpec | null }) {
|
|
return Boolean(a && b && a.volume == b.volume && a.owner_id == b.owner_id);
|
|
}
|
|
|
|
/**
|
|
* The files browser's address for a folder and, optionally, a path inside
|
|
* it. What a chip links to, and what the page's breadcrumb builds.
|
|
*/
|
|
export function storageSpecHref({ spec, path }: { spec: StorageSpec; path?: string | null }) {
|
|
const query = new URLSearchParams({
|
|
volume: spec.volume,
|
|
owner: String(spec.owner_id),
|
|
});
|
|
const clean = (path || "").trim().replace(/^\/+|\/+$/g, "");
|
|
|
|
if (clean) query.set("path", clean);
|
|
|
|
return `${StorageBrowserPath}?${query.toString()}`;
|
|
}
|
|
|
|
/**
|
|
* A folder's label for a chip: the last path segment, or the volume's
|
|
* title at the top.
|
|
*/
|
|
export function storagePathName({ volume, path }: { volume: StorageVolume; path?: string | null }) {
|
|
const segments = (path || "").split("/").filter(Boolean);
|
|
|
|
return segments.at(-1) || storageVolumeLabel({ volume });
|
|
}
|