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:
@@ -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 || [];
|
||||
}
|
||||
Reference in New Issue
Block a user