diff --git a/app/airflow/pyproject.toml b/app/airflow/pyproject.toml index 596104119..6ad6cf136 100644 --- a/app/airflow/pyproject.toml +++ b/app/airflow/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "carrot-airflow" -version = "4.2.2" +version = "4.2.3" description = "Add your description here" readme = "README.md" requires-python = ">=3.11" diff --git a/app/api/pyproject.toml b/app/api/pyproject.toml index 300edef66..8a6a226ea 100644 --- a/app/api/pyproject.toml +++ b/app/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "api" -version = "4.2.2" +version = "4.2.3" description = "Web app for Carrot-Mapper" authors = [ diff --git a/app/next-client-app/api/files.ts b/app/next-client-app/api/files.ts index 33b6a9c78..912ceab60 100644 --- a/app/next-client-app/api/files.ts +++ b/app/next-client-app/api/files.ts @@ -16,11 +16,11 @@ const fetchKeys = { export async function list( scan_report_id: number, - filter: string | undefined + filter: string | undefined, ): Promise | null> { try { return await request>( - fetchKeys.list(scan_report_id, filter) + fetchKeys.list(scan_report_id, filter), ); } catch (error) { return null; @@ -29,7 +29,7 @@ export async function list( export async function requestFile( scan_report_id: number, - file_type: FileTypeFormat + file_type: FileTypeFormat, ): Promise<{ success: boolean; errorMessage?: string }> { try { await request(fetchKeys.requestFile(scan_report_id), { @@ -51,16 +51,17 @@ export async function requestFile( export async function downloadFile( scan_report_id: number, - file_id?: number + file_id?: number, ): Promise<{ success: boolean; errorMessage?: string; data?: any; blob?: Blob; + downloadUrl?: string; }> { try { if (!file_id) { - // The case of SR exporting + // The case of SR exporting - keep existing logic for now const response = await request(fetchKeys.downloadFile(scan_report_id), { download: true, }); @@ -70,13 +71,9 @@ export async function downloadFile( return { success: true, data: base64String }; } else { - // The case of mapping rules file downloading - use streaming for all file types - const response = await request( - fetchKeys.downloadFile(scan_report_id, file_id), - { download: true } - ); - // Return the blob directly - return { success: true, blob: response as Blob }; + // The case of mapping rules file downloading - use streaming API route + const downloadUrl = `/api/download/${scan_report_id}/${file_id}`; + return { success: true, downloadUrl }; } } catch (error: any) { return { success: false, errorMessage: error.message }; diff --git a/app/next-client-app/app/(protected)/scanreports/[id]/downloads/columns.tsx b/app/next-client-app/app/(protected)/scanreports/[id]/downloads/columns.tsx index 5e1e6f920..8a4c1b4d8 100644 --- a/app/next-client-app/app/(protected)/scanreports/[id]/downloads/columns.tsx +++ b/app/next-client-app/app/(protected)/scanreports/[id]/downloads/columns.tsx @@ -26,7 +26,7 @@ export const columns: ColumnDef[] = [ return format(created_at, "d MMM HH:mm"); }, enableHiding: true, - enableSorting: true + enableSorting: true, }, { id: "User", @@ -43,7 +43,7 @@ export const columns: ColumnDef[] = [ return <>{user.username}; }, enableHiding: true, - enableSorting: false + enableSorting: false, }, { id: "Type", @@ -60,7 +60,7 @@ export const columns: ColumnDef[] = [ return {file_type.display_name}; }, enableHiding: true, - enableSorting: false + enableSorting: false, }, { id: "Download", @@ -69,22 +69,28 @@ export const columns: ColumnDef[] = [ const { id, scan_report, name } = row.original; const handleDownload = async () => { const response = await downloadFile(scan_report, id); - if (response.success && response.blob) { - // Use the blob directly + if (response.success && response.downloadUrl) { + // Use the streaming download URL + window.open(response.downloadUrl, "_blank"); + } else if (response.success && response.blob) { + // Fallback for SR exporting (blob approach) saveAs(response.blob, name); } else { toast.error( - `Error downloading file: ${(response.errorMessage as any).message}` + `Error downloading file: ${response.errorMessage || "Unknown error"}`, ); } }; return ( ); }, enableHiding: true, - enableSorting: false - } + enableSorting: false, + }, ]; diff --git a/app/next-client-app/app/api/download/[scan_report_id]/[file_id]/route.ts b/app/next-client-app/app/api/download/[scan_report_id]/[file_id]/route.ts new file mode 100644 index 000000000..e946f8c51 --- /dev/null +++ b/app/next-client-app/app/api/download/[scan_report_id]/[file_id]/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { options as authOptions } from "@/auth/options"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ scan_report_id: string; file_id: string }> }, +) { + try { + const { scan_report_id, file_id } = await params; + + // Get the session for authentication + const session = await getServerSession(authOptions); + const token = session?.access_token; + + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Construct the Django backend URL + const backendUrl = `${process.env.BACKEND_URL}/api/v2/scanreports/${scan_report_id}/rules/downloads/${file_id}/`; + + // Forward the request to Django backend + const backendResponse = await fetch(backendUrl, { + method: "GET", + headers: { + Authorization: `JWT ${token}`, + // Forward any relevant headers from the original request + ...(request.headers.get("user-agent") && { + "User-Agent": request.headers.get("user-agent")!, + }), + }, + }); + + // If the backend request failed, return the same status + if (!backendResponse.ok) { + return new NextResponse(backendResponse.body, { + status: backendResponse.status, + statusText: backendResponse.statusText, + }); + } + + // Get the content type and disposition from the backend response + const contentType = backendResponse.headers.get("content-type"); + const contentDisposition = backendResponse.headers.get( + "content-disposition", + ); + const contentLength = backendResponse.headers.get("content-length"); + + // Create headers for the Next.js response + const responseHeaders = new Headers(); + + if (contentType) { + responseHeaders.set("content-type", contentType); + } + + if (contentDisposition) { + responseHeaders.set("content-disposition", contentDisposition); + } + + if (contentLength) { + responseHeaders.set("content-length", contentLength); + } + + // Set cache headers to prevent caching of file downloads + responseHeaders.set("cache-control", "no-cache, no-store, must-revalidate"); + responseHeaders.set("pragma", "no-cache"); + responseHeaders.set("expires", "0"); + + // Stream the response body directly to the client + return new NextResponse(backendResponse.body, { + status: 200, + headers: responseHeaders, + }); + } catch (error) { + console.error("Error in download proxy:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/next-client-app/package.json b/app/next-client-app/package.json index 0a91da29f..1ce72ac10 100644 --- a/app/next-client-app/package.json +++ b/app/next-client-app/package.json @@ -1,6 +1,6 @@ { "name": "next-client-app", - "version": "4.2.2", + "version": "4.2.3", "private": true, "scripts": { "dev": "next dev --turbo", diff --git a/pyproject.toml b/pyproject.toml index e06f5bde1..2e51af5f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "carrot-mapper" -version = "4.2.2" +version = "4.2.3" description = "" authors = [ { name = "Sam Cox", email = "sam.cox@nottingham.ac.uk" },