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,94 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useEffect, useState } from "react";
import type { StorageFolderPayload } from "@/src/types";
type Params = {
spec: { volume: string; owner_id: number } | null;
/**
* The folder's path inside the volume; empty at the top.
*/
path: string;
/**
* The search the user submitted (not what is typed).
*/
search?: string;
/**
* The folder the page already carries, when this is that one.
*/
page_folder?: StorageFolderPayload | null;
};
/**
* One folder of the files browser. The page's own copy is used when it is
* the folder asked for and nothing is searched; anything else loads from
* the API, so a page never carries the whole volume.
*/
export default function useStorageFolder({ spec, path, search, page_folder }: Params) {
const from_page = Boolean(
page_folder &&
spec &&
page_folder.spec.volume == spec.volume &&
page_folder.spec.owner_id == spec.owner_id &&
page_folder.path == path &&
!search,
);
const [folder, set_folder] = useState<StorageFolderPayload | null>(
from_page ? page_folder || null : null,
);
const [loading, set_loading] = useState(!from_page && Boolean(spec));
const [error, set_error] = useState<string>();
useEffect(() => {
if (!spec) {
set_folder(null);
set_loading(false);
return;
}
if (from_page) {
set_folder(page_folder || null);
set_loading(false);
set_error(undefined);
return;
}
const controller = new AbortController();
const load = async () => {
set_loading(true);
set_error(undefined);
const query = new URLSearchParams({
volume: spec.volume,
owner: String(spec.owner_id),
});
if (path) query.set("path", path);
if (search) query.set("search", search);
const res = await fetchApi<{}, APIResponseObject<StorageFolderPayload>>(
`/api/admin/storage/list?${query.toString()}`,
{ method: "GET" },
).catch(() => null);
if (controller.signal.aborted) return;
set_loading(false);
if (!res?.success || !res.singleRes) {
set_folder(null);
set_error(res?.msg || `Couldn't open that folder.`);
return;
}
set_folder(res.singleRes);
};
load().catch(() => {});
return () => controller.abort();
}, [spec?.volume, spec?.owner_id, path, search, from_page, page_folder]);
return { folder, loading, error };
}
@@ -0,0 +1,59 @@
import Stack from "@moduletrace/twui/layout/Stack";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import { ChevronRight } from "lucide-react";
type Props = {
crumbs: { name: string; href: string }[];
onOpenFolder: (path: string) => void;
};
/**
* The trail back up the open folder: the volume's top, then one step per
* folder. Every crumb but the one being shown opens where it points.
*/
export default function FilesBreadcrumbRow({ crumbs, onOpenFolder }: Props) {
if (!crumbs.length) return null;
return (
<Stack className="w-full mt-4 gap-0! items-center flex-row flex-wrap">
{crumbs.map((crumb, index) => {
const last = index == crumbs.length - 1;
return (
<Stack
key={crumb.href}
className="gap-0! items-center flex-row flex-nowrap min-w-0"
>
{index ? (
<Span className="mx-1 text-ink-secondary" aria-hidden>
<ChevronRight size={14} />
</Span>
) : null}
<Button
type="button"
variant="ghost"
color="gray"
size="smaller"
disabled={last}
onClick={() => onOpenFolder(pathOfHref({ href: crumb.href }))}
title={crumb.name || "Top"}
className="px-1! py-0.5! text-[12px] font-bold text-ink-secondary"
>
{crumb.name || "Top"}
</Button>
</Stack>
);
})}
</Stack>
);
}
/**
* The path a crumb's address opens, read back from its query.
*/
function pathOfHref({ href }: { href: string }) {
const query = href.split("?")[1];
return new URLSearchParams(query).get("path") || "";
}
@@ -0,0 +1,28 @@
import Input from "@moduletrace/twui/form/Input";
type Props = {
value: string;
loading?: boolean;
onChange: (search: string) => void;
onSubmit: (search: string) => void;
};
/**
* Narrows the open folder by name or path. Enter runs the search; clearing
* the field runs it again empty.
*/
export default function FilesSearchInput({ value, loading, onChange, onSubmit }: Props) {
return (
<Input
placeholder="Search this folder"
value={value}
disabled={loading}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key == "Enter") onSubmit(event.currentTarget.value);
}}
clearInputProps={{ onClick: () => onSubmit("") }}
wrapperWrapperProps={{ className: "w-full mt-3" }}
/>
);
}
@@ -0,0 +1,40 @@
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import type { StorageSpecOption } from "@/src/types";
import { storageSpecHref, storageVolumeLabel } from "@/src/utils/storage-volumes";
type Props = {
option: StorageSpecOption;
selected: boolean;
onOpen: () => void;
};
/**
* One folder in the files browser's picker: the bot, workspace, computer,
* media library, MCP server or database it is, named the way the crew
* names it.
*/
export default function FilesVolumeRow({ option, selected, onOpen }: Props) {
return (
<Link
href={storageSpecHref({ spec: option.spec })}
title={`${storageVolumeLabel({ volume: option.spec.volume })}: ${option.label}`}
aria-current={selected ? "page" : undefined}
onClick={(event) => {
event.preventDefault();
onOpen();
}}
className={twMerge(
"flex w-full min-w-0 items-center gap-2 rounded-default px-2 py-1.5 border-0 text-ink",
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
selected && "bg-ink/5 hover:bg-ink/5",
)}
>
<Span className="truncate text-[13px] font-bold leading-5">{option.label}</Span>
<Span className="ml-auto shrink-0 text-[12px] leading-4 text-ink-secondary">
{storageVolumeLabel({ volume: option.spec.volume })}
</Span>
</Link>
);
}
@@ -0,0 +1,76 @@
import Link from "@moduletrace/twui/layout/Link";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { twMerge } from "tailwind-merge";
import { FolderOpen } from "lucide-react";
import type { MediaType, StorageEntry } from "@/src/types";
import formatBytes from "@/src/utils/format-bytes";
import relativeTime from "@/src/utils/relative-time";
import AttachmentIcon from "@/src/pages/admin/bots/[bot_id]/(partials)/attachment-icon";
type Props = {
entry: StorageEntry;
selected?: boolean;
onOpenFolder: (path: string) => void;
};
/**
* One entry in the open folder: a folder opens in place, a file opens in a
* new tab (its own media record when it is one of the bot's files, else
* the browser's streaming route).
*/
export default function StorageEntryRow({ entry, selected, onOpenFolder }: Props) {
const is_folder = entry.kind == "folder";
return (
<Row
className={twMerge(
"w-full gap-3! flex-nowrap items-start py-2 border-0 border-b border-solid border-hairline last:border-b-0",
selected && "bg-ink/5",
)}
>
<Row className="mt-0.5 text-ink-secondary">
{is_folder ? (
<FolderOpen size={16} aria-hidden className="shrink-0" />
) : (
<AttachmentIcon media_type={entry.media_type as MediaType} size={16} />
)}
</Row>
<Stack className="min-w-0 flex-1 gap-0.5!">
{is_folder ? (
<Link
href={entry.url || "/admin/files"}
title={`Open ${entry.name}`}
onClick={(event) => {
event.preventDefault();
onOpenFolder(entry.path);
}}
className="text-[13px] font-bold leading-5 text-ink break-words hover:opacity-80"
>
{entry.name}
</Link>
) : (
<Link
href={entry.url}
target="_blank"
rel="noopener noreferrer"
title={`Open ${entry.name}`}
className="text-[13px] font-bold leading-5 text-ink break-words hover:opacity-80"
>
{entry.name}
</Link>
)}
<Span className="text-[12px] leading-4 text-ink-secondary">
{[
is_folder ? "Folder" : formatBytes({ bytes: entry.size }),
entry.modified_at ? relativeTime({ at: entry.modified_at }) : "",
entry.file_id ? "Saved by the bot" : "",
]
.filter(Boolean)
.join(" · ")}
</Span>
</Stack>
</Row>
);
}
@@ -0,0 +1,127 @@
import H2 from "@moduletrace/twui/layout/H2";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { StorageFolderPayload, StorageSpecOption } from "@/src/types";
import type { StorageSpec } from "@/src/utils/storage-volumes";
import FilesBreadcrumbRow from "../(partials)/files-breadcrumb-row";
import FilesSearchInput from "../(partials)/files-search-input";
import FilesVolumeRow from "../(partials)/files-volume-row";
import StorageEntryRow from "../(partials)/storage-entry-row";
import Divider from "@moduletrace/twui/layout/Divider";
import Paper from "@moduletrace/twui/elements/Paper";
type Props = {
folders: StorageSpecOption[];
folder: StorageFolderPayload | null;
spec: StorageSpec | null;
path: string;
search: string;
loading?: boolean;
error?: string;
onSearch: (search: string) => void;
onSubmitSearch: () => void;
onOpenFolder: (path: string) => void;
onOpenVolume: (option: StorageSpecOption) => void;
};
/**
* The files browser: the folders the user reaches on the left, the open
* folder's entries on the right. Read-only: a bot changes its own files
* with its tools, and the chat's files modal can delete them.
*/
export default function FilesBrowser({
folders,
folder,
spec,
path,
search,
loading,
error,
onSearch,
onSubmitSearch,
onOpenFolder,
onOpenVolume,
}: Props) {
return (
<Row className="w-full mt-6 gap-6! flex-wrap items-start lg:flex-nowrap">
<Stack className="w-full lg:w-[280px] shrink-0 items-stretch">
<H2 className="mb-2 text-[18px]! leading-6 font-bold tracking-normal text-ink">
Folders
</H2>
<Divider />
<Stack className="gap-0! items-stretch">
{folders.length ? (
folders.map((option) => (
<FilesVolumeRow
key={`${option.spec.volume}-${option.spec.owner_id}`}
option={option}
selected={
spec?.volume == option.spec.volume &&
spec?.owner_id == option.spec.owner_id
}
onOpen={() => onOpenVolume(option)}
/>
))
) : (
<Span className="py-4 text-[13px] leading-[18px] text-ink-secondary">
Nothing to show yet: give a bot a file, or let one
work on a computer.
</Span>
)}
</Stack>
</Stack>
<Paper className="w-full xl:p-8 grow h-full">
<Stack className="grow min-w-0 w-full items-stretch">
<Stack className="gap-1! items-stretch">
<H2 className="text-[18px]! leading-6 font-bold tracking-normal text-ink">
{folder?.breadcrumbs.at(-1)?.name ||
folder?.label ||
"Files"}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{folder
? `${folder.label}: ${folder.total} item(s). Everything a bot, a workspace or a computer keeps for you.`
: "Pick a folder to look inside."}
</Span>
</Stack>
<FilesBreadcrumbRow
crumbs={folder?.breadcrumbs || []}
onOpenFolder={onOpenFolder}
/>
<FilesSearchInput
value={search}
loading={loading}
onChange={onSearch}
onSubmit={onSubmitSearch}
/>
<Stack className="w-full mt-2 gap-0! items-stretch">
{folder?.entries.length ? (
folder.entries.map((entry) => (
<StorageEntryRow
key={entry.path}
entry={entry}
selected={
entry.kind == "folder" &&
entry.path == path
}
onOpenFolder={onOpenFolder}
/>
))
) : (
<Span className="py-8 text-[13px] leading-[18px] text-ink-secondary">
{loading
? "Loading..."
: error ||
(search
? `Nothing matches "${search}".`
: "This folder is empty.")}
</Span>
)}
</Stack>
</Stack>
</Paper>
</Row>
);
}
@@ -0,0 +1,73 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import type { StorageSpecOption } from "@/src/types";
import { parseStorageSpec, type StorageSpec } from "@/src/utils/storage-volumes";
import useStorageFolder from "../(hooks)/use-storage-folder";
import FilesBrowser from "./files-browser";
/**
* The files page's state: which folder is open and where inside it, kept
* in the address so a link to a folder can be shared and the browser's own
* back button walks back up. The folder asked for comes with the page; a
* new folder loads through the API.
*/
export default function FilesPane() {
const { pageProps } = useContext(AppContext);
const folders = pageProps?.storage_folders || [];
const page_folder = pageProps?.storage_folder;
const [spec, set_spec] = useState<StorageSpec | null>(
parseStorageSpec({ volume: page_folder?.spec.volume, owner_id: page_folder?.spec.owner_id }),
);
const [path, set_path] = useState(page_folder?.path || "");
const [search, set_search] = useState("");
const [query, set_query] = useState("");
const { folder, loading, error } = useStorageFolder({
spec,
path,
search: query,
page_folder,
});
const navigate = ({
next_spec,
next_path,
}: {
next_spec: StorageSpec | null;
next_path: string;
}) => {
set_spec(next_spec);
set_path(next_path);
set_search("");
set_query("");
const url = new URL(window.location.href);
if (next_spec) {
url.searchParams.set("volume", next_spec.volume);
url.searchParams.set("owner", String(next_spec.owner_id));
}
if (next_path) url.searchParams.set("path", next_path);
else url.searchParams.delete("path");
history.pushState(null, "", url);
};
return (
<FilesBrowser
folders={folders}
folder={folder}
spec={spec}
path={path}
search={search}
loading={loading}
error={error}
onSearch={set_search}
onSubmitSearch={() => set_query(search)}
onOpenFolder={(next_path: string) => navigate({ next_spec: spec, next_path })}
onOpenVolume={(option: StorageSpecOption) =>
navigate({ next_spec: option.spec, next_path: "" })
}
/>
);
}
+84
View File
@@ -0,0 +1,84 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import listStorageEntries, { storageBreadcrumbs } from "@/src/functions/backend/storage/list-storage-entries";
import resolveStoragePath from "@/src/functions/backend/storage/resolve-storage-path";
import resolveStorageSpec, { grabStorageSpecs } from "@/src/functions/backend/storage/resolve-storage-spec";
import type { PagePropsType, StorageFolderPayload } from "@/src/types";
import { parseStorageSpec, type StorageSpec } from "@/src/utils/storage-volumes";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* Files: one folder on the app's data volume. The folders the user may
* open come with the page, and so does the folder asked for (or the first
* one); moving to another folder loads through the API, so a page stays
* small.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query = params.query || {};
const { user } = await userAuth({ req: params.req });
if (!user?.id) {
return { redirect: { destination: "/auth/login" } };
}
const folders = await grabStorageSpecs({ user_id: user.id });
const asked = parseStorageSpec({
volume: query.volume,
owner_id: query.owner,
});
const known =
asked &&
folders.some(
(folder) =>
folder.spec.volume == asked.volume && folder.spec.owner_id == asked.owner_id,
);
const spec = known ? asked : folders[0]?.spec;
if (!spec) {
return { props: { storage_folders: [], storage_folder: null } };
}
const storage_folder = await folderPayload({
user_id: Number(user.id),
spec,
path: query.path || "",
});
return { props: { storage_folders: folders, storage_folder } };
};
/**
* One folder's payload, or null when the user doesn't reach it (or its
* path escapes the volume).
*/
async function folderPayload({
user_id,
spec,
path,
}: {
user_id: number;
spec: StorageSpec;
path?: string;
}): Promise<StorageFolderPayload | null> {
const resolved = await resolveStorageSpec({ user_id, spec }).catch(() => null);
if (!resolved) return null;
const target = resolveStoragePath({ root: resolved.root, path });
const listing = await listStorageEntries({
resolved,
absolute: target.absolute,
relative: target.relative,
}).catch(() => null);
if (!listing) return null;
return {
spec: resolved.spec,
label: resolved.label,
path: target.relative,
breadcrumbs: storageBreadcrumbs({ spec: resolved.spec, path: target.relative }),
entries: listing.entries,
total: listing.total,
offset: listing.offset,
};
}
+23
View File
@@ -0,0 +1,23 @@
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
import FilesPane from "./(sections)/files-pane";
export default function AdminFilesPage() {
return (
<SettingsShell
title="Files"
description="Every folder a bot, a workspace or a computer keeps, on the app's data volume."
wide
>
<FilesPane />
</SettingsShell>
);
}
export const meta: BunextPageModuleMetaFn = async () => {
return {
title: `Files | ${SiteData["SiteName"]}`,
description: "Browse the files your bots, workspaces and computers keep.",
};
};
+63
View File
@@ -0,0 +1,63 @@
import { statSync } from "node:fs";
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabStorageFile from "@/src/functions/backend/storage/grab-storage-file";
import resolveStorageSpec from "@/src/functions/backend/storage/resolve-storage-spec";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import { StorageFileRequestSchema } from "@/src/schemas/storage-schema";
import { parseStorageSpec } from "@/src/utils/storage-volumes";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* `GET ?volume=..&owner=..&path=..`: one file on the data volume, streamed
* as it is. Access is checked twice, once for the folder
* (`resolveStorageSpec`) and once for the file's own path
* (`grabStorageFile`), so a folder the user reaches never becomes a way to
* read the rest of the disk.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, query }) => {
try {
if (req.method != "GET") {
return { success: false, msg: `Method not allowed` };
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { volume, owner, path } = parseWithSchema({
schema: StorageFileRequestSchema,
data: query,
});
const spec = parseStorageSpec({ volume, owner_id: owner });
if (!spec) return { success: false, msg: `Not found.` };
const resolved = await resolveStorageSpec({ user_id: user.id, spec });
if (!resolved) return { success: false, msg: `Not found.` };
const file = grabStorageFile({ resolved, path });
if (!file) return { success: false, msg: `Not found.` };
const etag = `"${file.size}-${Math.round(statSync(file.absolute).mtimeMs)}"`;
const headers = new Headers({
"Content-Type": file.mime_type,
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`,
"X-Content-Type-Options": "nosniff",
ETag: etag,
"Cache-Control": "private, no-cache",
});
if (req.headers.get("if-none-match") === etag) {
return new Response(null, { status: 304, headers });
}
return new Response(Bun.file(file.absolute), { headers });
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+69
View File
@@ -0,0 +1,69 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import listStorageEntries, { storageBreadcrumbs } from "@/src/functions/backend/storage/list-storage-entries";
import resolveStoragePath from "@/src/functions/backend/storage/resolve-storage-path";
import resolveStorageSpec from "@/src/functions/backend/storage/resolve-storage-spec";
import type { StorageFolderPayload } from "@/src/types";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import { StorageListRequestSchema } from "@/src/schemas/storage-schema";
import { parseStorageSpec } from "@/src/utils/storage-volumes";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* One folder on the data volume, for the files browser: its entries, the
* breadcrumb trail and the folder's name. Only a folder the user reaches
* (`resolveStorageSpec`) and only a path inside it
* (`resolveStoragePath`), so a request never turns into a read.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<StorageFolderPayload>> = async ({
req,
query,
}) => {
try {
if (req.method != "GET") {
return { success: false, msg: `Method not allowed` };
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { volume, owner, path, search, offset } = parseWithSchema({
schema: StorageListRequestSchema,
data: query,
});
const spec = parseStorageSpec({ volume, owner_id: owner });
if (!spec) return { success: false, msg: `Not found.` };
const resolved = await resolveStorageSpec({ user_id: user.id, spec });
if (!resolved) return { success: false, msg: `Not found.` };
const target = resolveStoragePath({ root: resolved.root, path });
const listing = await listStorageEntries({
resolved,
absolute: target.absolute,
relative: target.relative,
search,
offset,
});
return {
success: true,
singleRes: {
spec: resolved.spec,
label: resolved.label,
path: target.relative,
breadcrumbs: storageBreadcrumbs({ spec: resolved.spec, path: target.relative }),
entries: listing.entries,
total: listing.total,
offset: listing.offset,
},
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};