Compare commits

...

2 Commits

Author SHA1 Message Date
Lluis Agusti
47f22e4923 fix(frontend): use signed URL redirect for workspace downloads to prevent truncation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:58:53 +08:00
Lluis Agusti
21c3a98c5a feat(backend): add download-url endpoint returning signed URLs for workspace files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:58:53 +08:00
2 changed files with 92 additions and 20 deletions

View File

@@ -131,6 +131,54 @@ class StorageUsageResponse(BaseModel):
file_count: int
class DownloadUrlResponse(BaseModel):
url: str
direct: bool # True = browser can fetch URL directly (signed GCS URL)
@router.get(
"/files/{file_id}/download-url",
summary="Get download URL for a file",
)
async def get_file_download_url(
user_id: Annotated[str, fastapi.Security(get_user_id)],
file_id: str,
) -> DownloadUrlResponse:
"""
Return a download URL for a workspace file.
For GCS storage: returns a time-limited signed URL the browser can fetch directly.
For local storage: returns the API download path (must still be proxied).
"""
workspace = await get_workspace(user_id)
if workspace is None:
raise fastapi.HTTPException(status_code=404, detail="Workspace not found")
file = await get_workspace_file(file_id, workspace.id)
if file is None:
raise fastapi.HTTPException(status_code=404, detail="File not found")
storage = await get_workspace_storage()
if file.storage_path.startswith("local://"):
return DownloadUrlResponse(
url=f"/api/workspace/files/{file_id}/download",
direct=False,
)
# GCS — try to generate signed URL
try:
url = await storage.get_download_url(file.storage_path, expires_in=300)
if url.startswith("/api/"):
return DownloadUrlResponse(url=url, direct=False)
return DownloadUrlResponse(url=url, direct=True)
except Exception:
return DownloadUrlResponse(
url=f"/api/workspace/files/{file_id}/download",
direct=False,
)
@router.get(
"/files/{file_id}/download",
summary="Download file by ID",

View File

@@ -31,11 +31,11 @@ function isWorkspaceDownloadRequest(path: string[]): boolean {
}
/**
* Handle workspace file download requests with proper binary response streaming.
* Handle workspace file download requests using signed URL redirect or full buffering.
*/
async function handleWorkspaceDownload(
req: NextRequest,
backendUrl: string,
path: string[],
): Promise<NextResponse> {
const token = await getServerAuthToken();
@@ -44,40 +44,64 @@ async function handleWorkspaceDownload(
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(backendUrl, {
// Build the download-url endpoint path (replace last segment)
const urlPath = [...path];
urlPath[urlPath.length - 1] = "download-url";
const downloadUrlEndpoint = buildBackendUrl(urlPath, "");
// Ask backend for signed URL
const urlResponse = await fetch(downloadUrlEndpoint, {
method: "GET",
headers,
redirect: "follow", // Follow redirects to signed URLs
});
if (!response.ok) {
if (!urlResponse.ok) {
return NextResponse.json(
{ error: `Failed to download file: ${response.statusText}` },
{ status: response.status },
{ error: `Failed to get download URL: ${urlResponse.statusText}` },
{ status: urlResponse.status },
);
}
// Get the content type from the backend response
const contentType =
response.headers.get("Content-Type") || "application/octet-stream";
const contentDisposition = response.headers.get("Content-Disposition");
const { url, direct } = (await urlResponse.json()) as {
url: string;
direct: boolean;
};
// Direct URL (GCS signed) — redirect browser to fetch directly from GCS
if (direct) {
return NextResponse.redirect(url, 302);
}
// Non-direct (local storage) — proxy with full buffering to avoid truncation
const backendUrl = buildBackendUrl(path, new URL(req.url).search);
const fileResponse = await fetch(backendUrl, {
method: "GET",
headers,
redirect: "follow",
});
if (!fileResponse.ok) {
return NextResponse.json(
{ error: `Failed to download file: ${fileResponse.statusText}` },
{ status: fileResponse.status },
);
}
const buffer = await fileResponse.arrayBuffer();
const contentType =
fileResponse.headers.get("Content-Type") || "application/octet-stream";
const contentDisposition = fileResponse.headers.get("Content-Disposition");
// Stream the response body
const responseHeaders: Record<string, string> = {
"Content-Type": contentType,
"Content-Length": String(buffer.byteLength),
};
if (contentDisposition) {
responseHeaders["Content-Disposition"] = contentDisposition;
}
const contentLength = response.headers.get("Content-Length");
if (contentLength) {
responseHeaders["Content-Length"] = contentLength;
}
// Stream the response body directly instead of buffering in memory
return new NextResponse(response.body, {
return new NextResponse(buffer, {
status: 200,
headers: responseHeaders,
});
@@ -250,7 +274,7 @@ async function handler(
try {
// Handle workspace file downloads separately (binary response)
if (method === "GET" && isWorkspaceDownloadRequest(path)) {
return await handleWorkspaceDownload(req, backendUrl);
return await handleWorkspaceDownload(req, path);
}
if (method === "GET" || method === "DELETE") {