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:
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import chatBotsSql from "./chat-bots-sql";
|
||||
import computersSql from "./computers-sql";
|
||||
import reachableBotsSql from "./reachable-bots-sql";
|
||||
import workspaceBotsSql from "./workspace-bots-sql";
|
||||
import workspacesSql from "./workspaces-sql";
|
||||
import type { SqlCondition } from "./types";
|
||||
@@ -50,6 +51,18 @@ describe("chatBotsSql", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reachableBotsSql", () => {
|
||||
test("a member also reaches the workers of a workspace they were added to", () => {
|
||||
expect(ids({ table: "bots", alias: "b", condition: reachableBotsSql({ role: "member", user_id: 7 }) })).toEqual([2, 3]);
|
||||
expect(ids({ table: "bots", alias: "b", condition: reachableBotsSql({ role: "member", user_id: 8 }) })).toEqual([]);
|
||||
});
|
||||
|
||||
test("managers reach what they chat with", () => {
|
||||
expect(ids({ table: "bots", alias: "b", condition: reachableBotsSql({ role: "super_admin", user_id: 1 }) })).toEqual([1, 2, 3]);
|
||||
expect(ids({ table: "bots", alias: "b", condition: reachableBotsSql({ role: "admin", user_id: 5 }) })).toEqual([2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspacesSql", () => {
|
||||
test("managers reach every workspace, members only theirs", () => {
|
||||
expect(ids({ table: "workspaces", alias: "w", condition: workspacesSql({ role: "admin", user_id: 5 }) })).toEqual([10, 11]);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { CrewAccessParams, SqlCondition } from "./types";
|
||||
import chatBotsSql from "./chat-bots-sql";
|
||||
|
||||
/**
|
||||
* The bots a user reaches at all: the ones in their own chat
|
||||
* (`chatBotsSql`) plus, outside it, every worker attached to a workspace
|
||||
* they were added to. The same condition `grabUserBot` applies, so a
|
||||
* picker built from it lists exactly the folders the resolver opens. The
|
||||
* coordinator is the super admin's alone.
|
||||
*/
|
||||
export default function reachableBotsSql({
|
||||
role,
|
||||
user_id,
|
||||
alias = "b",
|
||||
}: CrewAccessParams): SqlCondition {
|
||||
const chat = chatBotsSql({ role, user_id, alias });
|
||||
|
||||
if (role != "member") return chat;
|
||||
|
||||
return {
|
||||
sql: `(${chat.sql} OR (${alias}.kind != 'coordinator' AND ${alias}.id IN (
|
||||
SELECT wb.bot_id FROM workspace_bots wb
|
||||
JOIN workspace_users wu ON wu.workspace_id = wb.workspace_id AND wu.user_id = ?
|
||||
JOIN workspaces w ON w.id = wb.workspace_id AND w.archived = 0
|
||||
)))`,
|
||||
values: [...chat.values, Number(user_id)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import pathRef, { parsePathRefHref, pathRefHref, pathRefUrl } from "./path-ref";
|
||||
import linkComputerRefs from "./link-computer-refs";
|
||||
|
||||
describe("pathRef", () => {
|
||||
test("writes a folder and a file", () => {
|
||||
expect(pathRef({ volume: "computer", owner_id: 3, path: "seo/coderank/raw" })).toBe(
|
||||
"[path:computer:3:seo/coderank/raw]",
|
||||
);
|
||||
expect(pathRef({ volume: "computer", owner_id: 3, path: "/seo/raw.json " })).toBe(
|
||||
"[path:computer:3:seo/raw.json]",
|
||||
);
|
||||
expect(pathRef({ volume: "bot", owner_id: 1 })).toBe("[path:bot:1]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathRefHref", () => {
|
||||
test("encodes the path into the link", () => {
|
||||
expect(pathRefHref({ volume: "computer", owner_id: 3, path: "seo/raw mes" })).toBe(
|
||||
`#path-${encodeURIComponent("computer:3:seo/raw mes")}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("reads back what it wrote", () => {
|
||||
const href = pathRefHref({ volume: "computer", owner_id: 3, path: "seo/raw mes/#1" });
|
||||
|
||||
expect(parsePathRefHref({ href })).toEqual({
|
||||
spec: { volume: "computers", owner_id: 3 },
|
||||
path: "seo/raw mes/#1",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps the ref's volume name to the browser's", () => {
|
||||
expect(parsePathRefHref({ href: pathRefHref({ volume: "bot", owner_id: 1, path: "a" }) })).toEqual({
|
||||
spec: { volume: "bots", owner_id: 1 },
|
||||
path: "a",
|
||||
});
|
||||
expect(
|
||||
parsePathRefHref({ href: pathRefHref({ volume: "workspace", owner_id: 2 }) }),
|
||||
).toEqual({
|
||||
spec: { volume: "workspaces", owner_id: 2 },
|
||||
path: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("refuses an unknown volume or owner", () => {
|
||||
expect(parsePathRefHref({ href: "#path-secrets:1:a" })).toBeNull();
|
||||
expect(parsePathRefHref({ href: "#path-computer:abc:a" })).toBeNull();
|
||||
expect(parsePathRefHref({ href: "#file-12" })).toBeNull();
|
||||
expect(parsePathRefHref({ href: undefined })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathRefUrl", () => {
|
||||
test("opens the files browser on the folder", () => {
|
||||
expect(pathRefUrl({ volume: "computer", owner_id: 3, path: "seo/raw" })).toBe(
|
||||
`/admin/files?volume=computers&owner=3&path=seo%2Fraw`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linkComputerRefs", () => {
|
||||
test("turns a path ref into a link", () => {
|
||||
const linked = linkComputerRefs({ text: "In [path:computer:3:seo/raw]." });
|
||||
|
||||
expect(linked).toContain("(#path-");
|
||||
expect(parsePathRefHref({ href: linked.match(/\((#path-[^)]+)\)/)?.[1] })).toEqual({
|
||||
spec: { volume: "computers", owner_id: 3 },
|
||||
path: "seo/raw",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Where a bot names a folder or file on the app's data volume in a
|
||||
* message: `[path:computer:3:seo/coderank/raw]`. The chat turns it into a
|
||||
* chip that opens it in the files browser (`/admin/files`), which is how a
|
||||
* bot points the user at something it left on a computer (raw evidence,
|
||||
* a crawl, a report it never copied) without importing it first.
|
||||
*/
|
||||
import { StorageBrowserPath, storageSpecHref, storageVolumeFromRef, type StorageSpec } from "./storage-volumes";
|
||||
|
||||
/**
|
||||
* The link target a path ref becomes, e.g. `#path-computer%3A3%3Aseo%2Fraw`.
|
||||
*/
|
||||
export const PathRefHrefPrefix = "#path-";
|
||||
|
||||
type RefParams = {
|
||||
/**
|
||||
* The volume by the name a ref uses: `bot`, `workspace`, `computer`,
|
||||
* `media`, `mcp` or `db`.
|
||||
*/
|
||||
volume: string;
|
||||
owner_id: number | string;
|
||||
path?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* How a bot names a folder or file in a message. The path is written as it
|
||||
* is; the chat encodes it into the link.
|
||||
*/
|
||||
export default function pathRef({ volume, owner_id, path }: RefParams) {
|
||||
const clean = (path || "").trim().replace(/^\/+|\/+$/g, "");
|
||||
|
||||
return clean
|
||||
? `[path:${volume}:${owner_id}:${clean}]`
|
||||
: `[path:${volume}:${owner_id}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The href a path ref becomes: everything after `#path-`, URI encoded, so
|
||||
* spaces, `#` and `:` in a path survive markdown.
|
||||
*/
|
||||
export function pathRefHref({ volume, owner_id, path }: RefParams) {
|
||||
const clean = (path || "").trim().replace(/^\/+|\/+$/g, "");
|
||||
|
||||
return `${PathRefHrefPrefix}${encodeURIComponent(
|
||||
clean ? `${volume}:${owner_id}:${clean}` : `${volume}:${owner_id}`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a path ref's link back into a folder and a path inside it, or null
|
||||
* when it names a volume that isn't one (or has no owner).
|
||||
*/
|
||||
export function parsePathRefHref({
|
||||
href,
|
||||
}: {
|
||||
href?: string | null;
|
||||
}): { spec: StorageSpec; path: string } | null {
|
||||
if (!href?.startsWith(PathRefHrefPrefix)) return null;
|
||||
|
||||
const decoded = decodeURIComponent(href.slice(PathRefHrefPrefix.length));
|
||||
const [name, raw_owner, ...rest] = decoded.split(":");
|
||||
const volume = storageVolumeFromRef({ name });
|
||||
const owner_id = Number(raw_owner);
|
||||
|
||||
if (!volume) return null;
|
||||
|
||||
if (!Number.isFinite(owner_id) || owner_id < 0) return null;
|
||||
|
||||
return {
|
||||
spec: { volume, owner_id: Math.trunc(owner_id) },
|
||||
path: rest.join("/").replace(/^\/+|\/+$/g, ""),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a path ref opens: the files browser, on that folder. A volume the
|
||||
* browser doesn't know lands on its first folder.
|
||||
*/
|
||||
export function pathRefUrl({ volume, owner_id, path }: RefParams) {
|
||||
const known = storageVolumeFromRef({ name: volume });
|
||||
|
||||
if (!known) return StorageBrowserPath;
|
||||
|
||||
return storageSpecHref({ spec: { volume: known, owner_id: Number(owner_id) }, path });
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
StorageBrowserPath,
|
||||
isSameStorageSpec,
|
||||
isStorageVolume,
|
||||
parseStorageSpec,
|
||||
storagePathName,
|
||||
storageSpecHref,
|
||||
storageVolumeFromRef,
|
||||
storageVolumeLabel,
|
||||
storageVolumeNeedsOwner,
|
||||
} from "./storage-volumes";
|
||||
|
||||
describe("parseStorageSpec", () => {
|
||||
test("reads a volume and its owner", () => {
|
||||
expect(parseStorageSpec({ volume: "computers", owner_id: "3" })).toEqual({
|
||||
volume: "computers",
|
||||
owner_id: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("gives the database no owner", () => {
|
||||
expect(parseStorageSpec({ volume: "db", owner_id: "999" })).toEqual({
|
||||
volume: "db",
|
||||
owner_id: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("refuses an unknown volume", () => {
|
||||
expect(parseStorageSpec({ volume: "secrets", owner_id: 1 })).toBeNull();
|
||||
expect(parseStorageSpec({ volume: "", owner_id: 1 })).toBeNull();
|
||||
expect(parseStorageSpec({ volume: null, owner_id: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
test("refuses a missing or bad owner", () => {
|
||||
expect(parseStorageSpec({ volume: "bots" })).toBeNull();
|
||||
expect(parseStorageSpec({ volume: "bots", owner_id: 0 })).toBeNull();
|
||||
expect(parseStorageSpec({ volume: "bots", owner_id: -2 })).toBeNull();
|
||||
expect(parseStorageSpec({ volume: "bots", owner_id: "abc" })).toBeNull();
|
||||
expect(parseStorageSpec({ volume: "bots", owner_id: "../../etc" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("storageSpecHref", () => {
|
||||
test("builds the browser's address", () => {
|
||||
expect(storageSpecHref({ spec: { volume: "bots", owner_id: 3 } })).toBe(
|
||||
`${StorageBrowserPath}?volume=bots&owner=3`,
|
||||
);
|
||||
});
|
||||
|
||||
test("adds and encodes a path", () => {
|
||||
expect(
|
||||
storageSpecHref({ spec: { volume: "computers", owner_id: 1 }, path: "/seo/raw mes/" }),
|
||||
).toBe(`${StorageBrowserPath}?volume=computers&owner=1&path=seo%2Fraw+mes`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("volume names", () => {
|
||||
test("maps a path ref's name to its volume", () => {
|
||||
expect(storageVolumeFromRef({ name: "computer" })).toBe("computers");
|
||||
expect(storageVolumeFromRef({ name: "bot" })).toBe("bots");
|
||||
expect(storageVolumeFromRef({ name: "nope" })).toBeNull();
|
||||
expect(storageVolumeFromRef({ name: null })).toBeNull();
|
||||
});
|
||||
|
||||
test("knows which volumes need an owner", () => {
|
||||
expect(storageVolumeNeedsOwner({ volume: "db" })).toBe(false);
|
||||
expect(storageVolumeNeedsOwner({ volume: "media" })).toBe(true);
|
||||
expect(isStorageVolume("mcp")).toBe(true);
|
||||
expect(isStorageVolume("Media")).toBe(false);
|
||||
});
|
||||
|
||||
test("labels a volume and a path", () => {
|
||||
expect(storageVolumeLabel({ volume: "mcp" })).toBe("MCP servers");
|
||||
expect(storagePathName({ volume: "computers", path: "seo/coderank/raw" })).toBe("raw");
|
||||
expect(storagePathName({ volume: "computers", path: "" })).toBe("Computers");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameStorageSpec", () => {
|
||||
test("compares volume and owner", () => {
|
||||
const a = { volume: "bots", owner_id: 1 } as const;
|
||||
|
||||
expect(isSameStorageSpec({ a, b: { volume: "bots", owner_id: 1 } })).toBe(true);
|
||||
expect(isSameStorageSpec({ a, b: { volume: "bots", owner_id: 2 } })).toBe(false);
|
||||
expect(isSameStorageSpec({ a, b: { volume: "workspaces", owner_id: 1 } })).toBe(false);
|
||||
expect(isSameStorageSpec({ a, b: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user