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,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: "" })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user