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.
This commit is contained in:
2026-10-22 07:37:16 +01:00
parent 07042a2288
commit 956942bba9
26 changed files with 2064 additions and 1 deletions
@@ -0,0 +1,281 @@
import path from "node:path";
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import computersSql from "@/src/utils/crew-access/computers-sql";
import reachableBotsSql from "@/src/utils/crew-access/reachable-bots-sql";
import workspacesSql from "@/src/utils/crew-access/workspaces-sql";
import grabDirNames from "@/src/utils/grab-dir-names";
import type { CrewRole } from "@/src/utils/rbac/crew-role";
import { storageVolumeLabel, type StorageSpec } from "@/src/utils/storage-volumes";
import grabCrewRole from "../db/access/grab-crew-role";
import grabUserBot from "../db/bots/grab-user-bot";
import grabUserComputer from "../db/computers/grab-user-computer";
import grabUserWorkspace from "../db/workspaces/grab-user-workspace";
type Params = {
user_id: number | string;
spec: StorageSpec;
};
/**
* One browsable folder, only if the user reaches it: its absolute root on
* the data volume and a label for the page. Null when the folder is not
* theirs to open, so a request never turns into a path before this has
* passed.
*
* Who reaches what: a bot, workspace or computer through the same SQL the
* crew uses (`grabUserBot`, `grabUserWorkspace`, `grabUserComputer`, so a
* member sees only their own grants); their own media folder (super
* admins and admins anyone's); an MCP server's install folder for crew
* managers; the database for the super admin alone.
*/
export default async function resolveStorageSpec({ user_id, spec }: Params) {
const role = await grabCrewRole({ user_id });
const root = await storageRoot({ spec, role, user_id });
if (!root) return null;
return {
spec,
root,
label: await storageLabel({ spec, role, user_id }),
};
}
export type ResolvedStorageSpec = NonNullable<Awaited<ReturnType<typeof resolveStorageSpec>>>;
/**
* The folder's absolute root, when the user reaches it.
*/
async function storageRoot({
spec,
role,
user_id,
}: {
spec: StorageSpec;
role: CrewRole;
user_id: number | string;
}) {
const { DATA_DIR } = grabDirNames();
switch (spec.volume) {
case "bots":
return (await grabUserBot({ bot_id: spec.owner_id, user_id }))
? path.join(DATA_DIR, AppData.BotDataDir, String(spec.owner_id))
: null;
case "workspaces":
return (await grabUserWorkspace({ workspace_id: spec.owner_id, user_id }))
? path.join(DATA_DIR, AppData.WorkspaceDataDir, String(spec.owner_id))
: null;
case "computers":
return (await grabUserComputer({ computer_id: spec.owner_id, user_id }))
? path.join(DATA_DIR, AppData.ComputerDataDir, String(spec.owner_id))
: null;
case "media":
if (role == "member" && Number(spec.owner_id) != Number(user_id)) return null;
return (await hasUser({ user_id: spec.owner_id }))
? path.join(DATA_DIR, "media", String(spec.owner_id))
: null;
case "mcp":
if (role == "member") return null;
return (await hasMcpServer({ server_id: spec.owner_id }))
? path.join(DATA_DIR, AppData.McpDataDir, String(spec.owner_id))
: null;
case "db":
if (role != "super_admin") return null;
return dbRoot();
}
}
/**
* What the page calls the folder: the bot's, workspace's or computer's
* own name where one is known.
*/
async function storageLabel({
spec,
role,
user_id,
}: {
spec: StorageSpec;
role: CrewRole;
user_id: number | string;
}) {
switch (spec.volume) {
case "bots":
return (await grabUserBot({ bot_id: spec.owner_id, user_id }))?.name || `Bot ${spec.owner_id}`;
case "workspaces":
return (
(await grabUserWorkspace({ workspace_id: spec.owner_id, user_id }))?.name ||
`Workspace ${spec.owner_id}`
);
case "computers":
return (
(await grabUserComputer({ computer_id: spec.owner_id, user_id }))?.name ||
`Computer ${spec.owner_id}`
);
case "media": {
if (role == "member" || Number(spec.owner_id) == Number(user_id)) return "My media";
const person = await grabPersonName({ user_id: spec.owner_id });
return person ? `${person}'s media` : `Media of user ${spec.owner_id}`;
}
case "mcp":
return (await grabMcpServerName({ server_id: spec.owner_id })) || `MCP server ${spec.owner_id}`;
case "db":
return storageVolumeLabel({ volume: spec.volume });
}
}
/**
* Where the database is kept: `DB_DIR` on the data volume in the app's
* container, else the folder bun-sqlite uses from the working directory.
*/
function dbRoot() {
const dir = process.env.DB_DIR || "./db";
return path.isAbsolute(dir) ? dir : path.join(process.cwd(), dir);
}
async function hasUser({ user_id }: { user_id: number | string }) {
const res = await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM users WHERE id = ? LIMIT 1`,
values: [Number(user_id)],
});
return Boolean(res.payload?.[0]?.id);
}
async function grabPersonName({ user_id }: { user_id: number | string }) {
const res = await BunSQLite.sql<{
first_name?: string | null;
last_name?: string | null;
username?: string | null;
}>({
sql: `SELECT first_name, last_name, username FROM users WHERE id = ? LIMIT 1`,
values: [Number(user_id)],
});
const person = res.payload?.[0];
if (!person) return "";
return [person.first_name, person.last_name].filter(Boolean).join(" ") || person.username || "";
}
async function hasMcpServer({ server_id }: { server_id: number | string }) {
const res = await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM mcp_servers WHERE id = ? LIMIT 1`,
values: [Number(server_id)],
});
return Boolean(res.payload?.[0]?.id);
}
async function grabMcpServerName({ server_id }: { server_id: number | string }) {
const res = await BunSQLite.sql<{ name?: string | null }>({
sql: `SELECT name FROM mcp_servers WHERE id = ? LIMIT 1`,
values: [Number(server_id)],
});
return res.payload?.[0]?.name || "";
}
export type StorageSpecOption = {
spec: StorageSpec;
label: string;
};
/**
* Every folder a user can open, for the page's picker: their bots,
* workspaces and computers, their own media folder, and (for crew
* managers) every MCP server's folder. The database comes last, for the
* super admin alone. The crew SQL is the same as the rest of the app, so
* the picker never lists a folder the resolver would refuse.
*/
export async function grabStorageSpecs({
user_id,
}: {
user_id: number | string;
}): Promise<StorageSpecOption[]> {
const role = await grabCrewRole({ user_id });
const options: StorageSpecOption[] = [];
const bot_access = reachableBotsSql({ role, user_id });
const bots = await rows<{ id: number; name?: string | null }>({
sql: `SELECT b.id, b.name FROM bots b WHERE ${bot_access.sql} ORDER BY b.name`,
values: bot_access.values,
});
for (const bot of bots) {
options.push({
spec: { volume: "bots", owner_id: Number(bot.id) },
label: bot.name || `Bot ${bot.id}`,
});
}
const workspace_access = workspacesSql({ role, user_id });
const workspaces = await rows<{ id: number; name?: string | null }>({
sql: `SELECT w.id, w.name FROM workspaces w WHERE ${workspace_access.sql} AND w.archived = 0 ORDER BY w.name`,
values: workspace_access.values,
});
for (const workspace of workspaces) {
options.push({
spec: { volume: "workspaces", owner_id: Number(workspace.id) },
label: workspace.name || `Workspace ${workspace.id}`,
});
}
const computer_access = computersSql({ role, user_id });
const computers = await rows<{ id: number; name?: string | null }>({
sql: `SELECT c.id, c.name FROM computers c WHERE ${computer_access.sql} ORDER BY c.name`,
values: computer_access.values,
});
for (const computer of computers) {
options.push({
spec: { volume: "computers", owner_id: Number(computer.id) },
label: computer.name || `Computer ${computer.id}`,
});
}
options.push({ spec: { volume: "media", owner_id: Number(user_id) }, label: "My media" });
if (role != "member") {
const servers = await rows<{ id: number; name?: string | null }>({
sql: `SELECT id, name FROM mcp_servers ORDER BY name`,
values: [],
});
for (const server of servers) {
options.push({
spec: { volume: "mcp", owner_id: Number(server.id) },
label: server.name || `MCP server ${server.id}`,
});
}
}
if (role == "super_admin") {
options.push({ spec: { volume: "db", owner_id: 0 }, label: storageVolumeLabel({ volume: "db" }) });
}
return options;
}
async function rows<T extends { [k: string]: any }>({
sql,
values,
}: {
sql: string;
values: (string | number)[];
}) {
return (await BunSQLite.sql<T>({ sql, values })).payload || [];
}