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,62 @@
|
||||
import { statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { mimeTypeForFile } from "../db/bot-files/media-type-for-file";
|
||||
import resolveStoragePath from "./resolve-storage-path";
|
||||
import type { ResolvedStorageSpec } from "./resolve-storage-spec";
|
||||
|
||||
type Params = {
|
||||
resolved: ResolvedStorageSpec;
|
||||
/**
|
||||
* The file to open, relative to the volume's root.
|
||||
*/
|
||||
path?: string | null;
|
||||
};
|
||||
|
||||
export type StorageFile = {
|
||||
/**
|
||||
* The absolute path on disk, inside the volume.
|
||||
*/
|
||||
absolute: string;
|
||||
/**
|
||||
* Its path inside the volume, e.g. `seo/coderank/raw/crawl.json`.
|
||||
*/
|
||||
relative: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mime_type: string;
|
||||
modified_at: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One file for the browser to open: checked to be inside the volume (the
|
||||
* same rules a bot's own path gets), to exist, and to be a regular file
|
||||
* rather than a folder. Null when it isn't one, so a caller can answer
|
||||
* "not found" without leaking what is there.
|
||||
*/
|
||||
export default function grabStorageFile({ resolved, path: requested }: Params): StorageFile | null {
|
||||
const resolved_path = resolveStoragePath({ root: resolved.root, path: requested });
|
||||
|
||||
if (!resolved_path.name) return null;
|
||||
|
||||
let stats;
|
||||
|
||||
try {
|
||||
stats = statSync(resolved_path.absolute);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stats.isFile()) return null;
|
||||
|
||||
if (stats.size > AppData.StorageBrowserReadMaxBytes) return null;
|
||||
|
||||
return {
|
||||
absolute: resolved_path.absolute,
|
||||
relative: resolved_path.relative,
|
||||
name: path.basename(resolved_path.absolute),
|
||||
size: stats.size,
|
||||
mime_type: mimeTypeForFile({ name: resolved_path.name }),
|
||||
modified_at: Math.round(stats.mtimeMs),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { existsSync, lstatSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import grabMediaURL from "../media/grab-media-url";
|
||||
import { isInsideRoot } from "./resolve-storage-path";
|
||||
import type { ResolvedStorageSpec } from "./resolve-storage-spec";
|
||||
import {
|
||||
storageSpecHref,
|
||||
type StorageSpec,
|
||||
} from "@/src/utils/storage-volumes";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The folder, already access-checked (`resolveStorageSpec`).
|
||||
*/
|
||||
resolved: ResolvedStorageSpec;
|
||||
/**
|
||||
* The directory to list, already checked (`resolveStoragePath`).
|
||||
*/
|
||||
absolute: string;
|
||||
/**
|
||||
* The same directory, relative to the volume's root. Empty at the top.
|
||||
*/
|
||||
relative: string;
|
||||
/**
|
||||
* Match a name or path, case-insensitive. Folders are kept so a match
|
||||
* deeper down is still reachable.
|
||||
*/
|
||||
search?: string | null;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type StorageEntry = {
|
||||
name: string;
|
||||
/**
|
||||
* Where it is, inside the volume's root, e.g. `seo/coderank/raw`.
|
||||
*/
|
||||
path: string;
|
||||
kind: "file" | "folder";
|
||||
size: number;
|
||||
modified_at: number;
|
||||
media_type?: string;
|
||||
/**
|
||||
* The `bot_files` row, when one of the bot's own files is that path, so
|
||||
* it keeps opening through its own media record.
|
||||
*/
|
||||
file_id?: number;
|
||||
/**
|
||||
* Where to open it: a `bot_files` row's media URL, else the volume
|
||||
* browser's own streaming route.
|
||||
*/
|
||||
url?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One directory on the data volume, for the files browser: its entries
|
||||
* (folders first), a page of them, and a `bot_files` row attached to any
|
||||
* that has one. Entries a symlink leads out of the volume with are left
|
||||
* out, so nothing outside the folder is ever listed.
|
||||
*/
|
||||
export default async function listStorageEntries({
|
||||
resolved,
|
||||
absolute,
|
||||
relative,
|
||||
search,
|
||||
offset = 0,
|
||||
}: Params) {
|
||||
/**
|
||||
* A folder nothing has written to yet (a computer's home before it
|
||||
* starts, a bot's before its first file) shows as empty rather than as
|
||||
* an error.
|
||||
*/
|
||||
if (!existsSync(absolute)) return { entries: [], total: 0, offset };
|
||||
|
||||
const rows = readdirSync(absolute, { withFileTypes: true });
|
||||
const indexed = await botFileRows({ resolved, paths: rows.map((row) => joinPath({ relative, name: row.name })) });
|
||||
const needle = search?.trim().toLowerCase() || "";
|
||||
const entries: StorageEntry[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.name == ".DS_Store") continue;
|
||||
|
||||
const entry_path = joinPath({ relative, name: row.name });
|
||||
|
||||
if (needle && !entry_path.toLowerCase().includes(needle)) continue;
|
||||
|
||||
const entry_absolute = path.join(absolute, row.name);
|
||||
|
||||
if (!isInsideRoot({ root: resolved.root, absolute: entry_absolute })) continue;
|
||||
|
||||
let stats;
|
||||
|
||||
try {
|
||||
stats = lstatSync(entry_absolute);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* A symlink is not followed: it may lead anywhere, and the
|
||||
* volume's own folders are what this lists.
|
||||
*/
|
||||
if (stats.isSymbolicLink()) continue;
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
entries.push({
|
||||
name: row.name,
|
||||
path: entry_path,
|
||||
kind: "folder",
|
||||
size: 0,
|
||||
modified_at: Math.round(stats.mtimeMs),
|
||||
url: storageSpecHref({ spec: resolved.spec, path: entry_path }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!stats.isFile()) continue;
|
||||
|
||||
const file = indexed.get(entry_path);
|
||||
const { media_url } = file?.media_id
|
||||
? grabMediaURL({ media: { id: file.media_id } as any })
|
||||
: {};
|
||||
|
||||
entries.push({
|
||||
name: row.name,
|
||||
path: entry_path,
|
||||
kind: "file",
|
||||
size: stats.size,
|
||||
modified_at: Math.round(stats.mtimeMs),
|
||||
media_type: file?.media_type || undefined,
|
||||
file_id: file?.id ? Number(file.id) : undefined,
|
||||
url: media_url || fileUrl({ spec: resolved.spec, path: entry_path }),
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((a, b) => {
|
||||
if (a.kind != b.kind) return a.kind == "folder" ? -1 : 1;
|
||||
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true });
|
||||
});
|
||||
|
||||
return {
|
||||
entries: entries.slice(offset, offset + AppData.StorageBrowserPageSize),
|
||||
total: entries.length,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a folder's file opens when it has no `bot_files` row (a computer's
|
||||
* home, the media library, an MCP server's install): the browser's own
|
||||
* streaming route, which checks the same access again.
|
||||
*/
|
||||
export function fileUrl({ spec, path }: { spec: StorageSpec; path: string }) {
|
||||
const query = new URLSearchParams({
|
||||
volume: spec.volume,
|
||||
owner: String(spec.owner_id),
|
||||
path,
|
||||
});
|
||||
|
||||
return `/api/admin/storage/file?${query.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `bot_files` rows behind the paths of one start and workspace folder,
|
||||
* keyed by path. Nothing for the other volumes, which have no catalog.
|
||||
*/
|
||||
async function botFileRows({
|
||||
resolved,
|
||||
paths,
|
||||
}: {
|
||||
resolved: ResolvedStorageSpec;
|
||||
paths: string[];
|
||||
}) {
|
||||
const rows = new Map<string, BUN_SQLITE_COBBLE_BOT_FILES>();
|
||||
|
||||
if (resolved.spec.volume != "bots" && resolved.spec.volume != "workspaces") return rows;
|
||||
if (!paths.length) return rows;
|
||||
|
||||
const scope = resolved.spec.volume == "bots" ? "bot" : "workspace";
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
|
||||
sql: `SELECT * FROM bot_files WHERE scope = ? AND owner_id = ? AND path IN (${paths
|
||||
.map(() => "?")
|
||||
.join(",")})`,
|
||||
values: [scope, resolved.spec.owner_id, ...paths],
|
||||
});
|
||||
|
||||
for (const row of res.payload || []) {
|
||||
if (row.path) rows.set(String(row.path), row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function joinPath({ relative, name }: { relative: string; name: string }) {
|
||||
return relative ? `${relative}/${name}` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* The breadcrumb trail of a folder, from the volume's top: one crumb per
|
||||
* segment, each with the address that opens it.
|
||||
*/
|
||||
export function storageBreadcrumbs({
|
||||
spec,
|
||||
path: relative,
|
||||
}: {
|
||||
spec: StorageSpec;
|
||||
path?: string | null;
|
||||
}) {
|
||||
const segments = (relative || "").split("/").filter(Boolean);
|
||||
const crumbs = [{ name: "", href: storageSpecHref({ spec }) }];
|
||||
|
||||
for (let i = 1; i <= segments.length; i++) {
|
||||
crumbs.push({
|
||||
name: segments[i - 1] || "",
|
||||
href: storageSpecHref({ spec, path: segments.slice(0, i).join("/") }),
|
||||
});
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import resolveStoragePath, { isInsideRoot } from "./resolve-storage-path";
|
||||
|
||||
const base = mkdtempSync(path.join(tmpdir(), "cobble-storage-path-"));
|
||||
const root = path.join(base, "root");
|
||||
const outside = path.join(base, "outside");
|
||||
|
||||
mkdirSync(path.join(root, "reports"), { recursive: true });
|
||||
mkdirSync(outside, { recursive: true });
|
||||
writeFileSync(path.join(root, "reports", "q3.md"), "# Q3");
|
||||
writeFileSync(path.join(outside, "secret.md"), "secret");
|
||||
symlinkSync(outside, path.join(root, "escape"));
|
||||
|
||||
afterAll(() => rmSync(base, { recursive: true, force: true }));
|
||||
|
||||
describe("resolveStoragePath", () => {
|
||||
test("keeps a path inside the root", () => {
|
||||
expect(resolveStoragePath({ root, path: "reports/q3.md" })).toEqual({
|
||||
relative: "reports/q3.md",
|
||||
absolute: path.join(root, "reports", "q3.md"),
|
||||
folder: "reports",
|
||||
name: "q3.md",
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes slashes and trims", () => {
|
||||
expect(resolveStoragePath({ root, path: " reports// /q3.md " }).relative).toBe(
|
||||
"reports/q3.md",
|
||||
);
|
||||
});
|
||||
|
||||
test("reads the root itself", () => {
|
||||
expect(resolveStoragePath({ root, path: "" })).toEqual({
|
||||
relative: "",
|
||||
absolute: path.resolve(root),
|
||||
folder: "",
|
||||
name: "",
|
||||
});
|
||||
expect(resolveStoragePath({ root, path: null }).name).toBe("");
|
||||
});
|
||||
|
||||
test("refuses an absolute path", () => {
|
||||
expect(() => resolveStoragePath({ root, path: "/etc/passwd" })).toThrow();
|
||||
expect(() => resolveStoragePath({ root, path: "C:/windows" })).toThrow();
|
||||
});
|
||||
|
||||
test("refuses climbing out", () => {
|
||||
expect(() => resolveStoragePath({ root, path: "../outside/secret.md" })).toThrow();
|
||||
expect(() => resolveStoragePath({ root, path: "reports/../../outside" })).toThrow();
|
||||
expect(() => resolveStoragePath({ root, path: "./q3.md" })).toThrow();
|
||||
});
|
||||
|
||||
test("refuses a NUL byte", () => {
|
||||
expect(() => resolveStoragePath({ root, path: "reports/q3.md\0" })).toThrow();
|
||||
});
|
||||
|
||||
test("refuses a symlink that leads out", () => {
|
||||
expect(() => resolveStoragePath({ root, path: "escape/secret.md" })).toThrow();
|
||||
});
|
||||
|
||||
test("refuses a path that is too deep", () => {
|
||||
expect(() =>
|
||||
resolveStoragePath({ root, path: Array.from({ length: 12 }, (_, i) => `f${i}`).join("/") }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInsideRoot", () => {
|
||||
test("tells a child from a sibling", () => {
|
||||
expect(isInsideRoot({ root, absolute: path.join(root, "reports") })).toBe(true);
|
||||
expect(isInsideRoot({ root, absolute: root })).toBe(true);
|
||||
expect(isInsideRoot({ root, absolute: path.join(base, "rooted") })).toBe(false);
|
||||
expect(isInsideRoot({ root, absolute: outside })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
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}`);
|
||||
}
|
||||
@@ -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