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,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