Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Assets also provides **Asset Bridge** for arbitrary files, complete local direct

Asset Bridge uses the same owner-authorized BFF, immutable revisions and direct resumable Storage transfers as native packages. Creation and first revision reservation are atomic; an uncertain creation or commit response reuses the saved operation identity, including after selecting the same files again. The selection is checked in full before upload: at most 200 files, 512 MiB per file and 1 GiB total after extraction. ZIP extraction occurs locally in the browser with bounded streaming, verified CRC/size, portable relative paths and matching local/central records. Encrypted, split, ZIP64, symbolic-link and colliding-path ZIP inputs are refused before publication; unpack unsupported archives locally and upload the directory. Only the selected ZIP is expanded; nested archives remain original files. Ordinary file/folder selection can also preserve archive files without extraction.

[`upload-policy.ts`](../src/studio/upload-policy.ts) owns the shared executable/installer extension denylist. Browser intake and the BFF apply it to every incoming revision package, including paths in expanded application bundles and ZIP selections. Scripts, including MATLAB `.m`, and data remain allowed. A regression check covers every current Craft native format through the package validator. Craft-specific native imports and editor saves retain their format validation alongside this policy. Uploaded collections use attachment downloads and a neutral binary media type. Robyn reads them through the existing project-scoped `studio_list_assets` / `studio_open_asset` tools; workstation client 0.10.1 adds zero-byte collection materialization without weakening native/source validation.
[`upload-policy.ts`](../src/studio/upload-policy.ts) owns the shared executable/installer extension denylist. Browser intake and the BFF apply it to every incoming revision package, including paths in expanded application bundles and ZIP selections. Scripts, including MATLAB `.m`, and data remain allowed. A regression check covers every current Craft native format through the package validator. Craft-specific native imports and known source previews retain their format validation. Normal Import files sends unrecognized selections to Asset Bridge, which stores the originals with neutral binary types and attachment downloads. Native task exports use known media types when available and preserve unfamiliar formats as binary attachments under the same BFF upload policy. Robyn reads exact collections through the project-scoped `studio_list_assets` / `studio_open_asset` tools.

## Workstation jobs and browser control

Expand Down
5 changes: 3 additions & 2 deletions src/app/api/studio/revisions/[id]/file/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ApiError, handleError } from "@/lib/http";
import { requireStudio, ownedRevision, dbFailure, STUDIO_BUCKET } from "@/studio/server";
import { validatePackagePath } from "@/studio/assets";
import { savedFileDownloadOptions } from "@/studio/saved-file";

/** Authorize the exact file on every open/seek; bytes stay in private Storage. */
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
Expand All @@ -13,11 +14,11 @@ export async function GET(request: Request, context: { params: Promise<{ id: str
let path: string;
try { path = validatePackagePath(url.searchParams.get("path")); }
catch { throw new ApiError(400, "invalid_path", "Choose a file from this saved revision."); }
const { data: file, error } = await db.from("studio_package_files").select("storage_key,path").eq("workspace_id", workspaceId).eq("revision_id", id).eq("path", path).maybeSingle();
const { data: file, error } = await db.from("studio_package_files").select("storage_key,path,mime_type").eq("workspace_id", workspaceId).eq("revision_id", id).eq("path", path).maybeSingle();
dbFailure(error);
if (!file) throw new ApiError(404, "not_found", "Saved file not found.");
const { data, error: storageError } = await db.storage.from(STUDIO_BUCKET).createSignedUrl(file.storage_key, 300,
url.searchParams.get("inline") === "1" ? undefined : { download: file.path.split("/").pop() });
savedFileDownloadOptions(file.path, file.mime_type, url.searchParams.get("inline") === "1"));
if (storageError || !data) throw new ApiError(503, "storage_unavailable", "The file could not be opened. Try again shortly.");
return new Response(null, { status: 307, headers: { Location: data.signedUrl, "Cache-Control": "private, no-store" } });
} catch (error) { return handleError(error); }
Expand Down
12 changes: 10 additions & 2 deletions src/components/studio/AssetBridgeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@ import {BLOCKED_UPLOAD_EXTENSIONS} from "@/studio/upload-policy";
import {describeLocalPackage,digestFile,savePackage,type LocalPackageFile,type SaveResult} from "@/studio/transfer-client";
import {StudioDialog} from "./StudioDialog";

export function AssetBridgeDialog({projectId,close,saved,returnFocusRef}:{projectId:string;close:()=>void;saved:(result:SaveResult)=>Promise<void>;returnFocusRef:React.RefObject<HTMLElement|null>}) {
export interface BridgeSelection { files: File[]; mode: "files" | "folder" }

export function AssetBridgeDialog({projectId,close,saved,returnFocusRef,initialSelection}:{projectId:string;close:()=>void;saved:(result:SaveResult)=>Promise<void>;returnFocusRef:React.RefObject<HTMLElement|null>;initialSelection?:BridgeSelection}) {
const [files,setFiles]=useState<LocalPackageFile[]>([]),[name,setName]=useState("");
const [busy,setBusy]=useState(false),[locked,setLocked]=useState(false),[error,setError]=useState("");
const [status,setStatus]=useState(""),[progress,setProgress]=useState(0);
const filePicker=useRef<HTMLInputElement>(null),folderPicker=useRef<HTMLInputElement>(null),archivePicker=useRef<HTMLInputElement>(null);
const controller=useRef<AbortController|null>(null),running=useRef(false),pending=useRef<{id:string;name:string;key:string}|null>(null);
useEffect(()=>()=>controller.current?.abort(),[]);
const initial=useRef(initialSelection);
useEffect(()=>{
const selection=initial.current;
initial.current=undefined;
if(selection)void choose(selection.files,selection.mode);
return()=>controller.current?.abort();
},[]);

async function choose(selected:File[],mode:"files"|"folder"|"archive") {
if(!selected.length||running.current||pending.current)return;
Expand Down
8 changes: 7 additions & 1 deletion src/components/studio/ImportDocumentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {validatePackagePath,type AssetSummary} from "@/studio/assets";
import {checkNativeImport,importPath,importedDocumentName,nativeImportApp,nativeImportApps} from "@/studio/native-import";
import {sourceFormat,sourceImportFiles} from "@/studio/source-import";
import {savePackage,type LocalPackageFile,type SaveResult} from "@/studio/transfer-client";
import {AssetBridgeDialog,type BridgeSelection} from "./AssetBridgeDialog";
import {StudioDialog} from "./StudioDialog";

interface ImportTask {id:string;name:string;appId:string|null;files:LocalPackageFile[];created?:boolean;result?:SaveResult}
Expand All @@ -20,6 +21,7 @@ export function ImportDocumentDialog({projectId,initialFiles,folder=false,close,
const [application,setApplication]=useState("");
const [busy,setBusy]=useState(false),[error,setError]=useState(""),[progress,setProgress]=useState(0),[status,setStatus]=useState("");
const [locked,setLocked]=useState(false),[completed,setCompleted]=useState(0);
const [bridgeSelection,setBridgeSelection]=useState<BridgeSelection|null>(null);
const started=useRef(false),running=useRef(false),tasks=useRef<ImportTask[]|null>(null),controller=useRef<AbortController|null>(null);
const extraFiles=useRef<HTMLInputElement>(null),folderPicker=useRef<HTMLInputElement>(null);
const candidates=files.filter(file=>nativeImportApps(file.name).length),choices=nativeImportApps(master);
Expand All @@ -29,7 +31,9 @@ export function ImportDocumentDialog({projectId,initialFiles,folder=false,close,
async function run(selected:File[]=files,main:string=master,chosen:string=application) {
if(running.current||!selected.length)return;
if(!main&&!selected.every(file=>sourceFormat(file.name))){
if(!selected.some(file=>nativeImportApp(file.name)))setError("No supported project was found. Choose a native Craft project, or supported images, audio and video.");
if(!selected.some(file=>nativeImportApps(file.name).length)){
setBridgeSelection({files:selected,mode:selected.every(file=>file.webkitRelativePath)?"folder":"files"});
}
return;
}
const available=nativeImportApps(main);
Expand Down Expand Up @@ -91,6 +95,8 @@ export function ImportDocumentDialog({projectId,initialFiles,folder=false,close,
}
useEffect(()=>{if(started.current)return;started.current=true;void run(initialFiles,master);},[]);

if(bridgeSelection)return <AssetBridgeDialog projectId={projectId} initialSelection={bridgeSelection} close={close} saved={saved} returnFocusRef={returnFocusRef}/>;

return <StudioDialog title={busy?(app?`Opening in ${app.name}…`:"Adding to your library…"):error?"Let’s finish this import.":folder&&!files.length?"Bring the whole project.":"Choose what to open."} eyebrow="BRING EXISTING FILES" close={close} busy={busy} returnFocusRef={returnFocusRef}>
{files.length>0&&<p className="studio-import-filename">{master||files[0].name}{files.length>1&&<small> · {files.length} files selected</small>}</p>}
<p className="studio-muted">{sources?"Images, audio and video stay available across your project. We’ll save the originals in your project and library, then open a preview.":choices.length>1?"Choose the application for this file. Robyn keeps the original bytes and any linked files together.":"Robyn selects the Craft, keeps the original files and opens your saved project. Linked files stay together."}</p>
Expand Down
Loading
Loading