diff --git a/.env.example b/.env.example index d69da90c..1e98a941 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,8 @@ POSTGRESQL_DB=framna-docs REPOSITORY_NAME_SUFFIX=-openapi HIDDEN_REPOSITORIES= NEW_PROJECT_TEMPLATE_REPOSITORY=shapehq/starter-openapi +PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES = 10 +PROXY_API_TIMEOUT_IN_SECONDS = 30 GITHUB_WEBHOOK_SECRET=preshared secret also put in app configuration in GitHub GITHUB_WEBHOK_REPOSITORY_ALLOWLIST= GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST= diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index 22226634..7b96c170 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -1,7 +1,12 @@ import { NextRequest, NextResponse } from "next/server" -import { makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common" +import { env, makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common" import { session } from "@/composition" +const ErrorName = { + MAX_FILE_SIZE_EXCEEDED: "MaxFileSizeExceededError", + TIMEOUT: "TimeoutError" +} + export async function GET(req: NextRequest) { const isAuthenticated = await session.getIsAuthenticated() if (!isAuthenticated) { @@ -17,6 +22,63 @@ export async function GET(req: NextRequest) { } catch { return makeAPIErrorResponse(400, "Invalid \"url\" query parameter.") } - const file = await fetch(url).then(r => r.blob()) - return new NextResponse(file, { status: 200 }) + try { + const maxMegabytes = Number(env.getOrThrow("PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES")) + const timeoutInSeconds = Number(env.getOrThrow("PROXY_API_TIMEOUT_IN_SECONDS")) + const maxBytes = maxMegabytes * 1024 * 1024 + const file = await downloadFile({ url, maxBytes, timeoutInSeconds }) + return new NextResponse(file, { status: 200 }) + } catch (error) { + if (error instanceof Error == false) { + return makeAPIErrorResponse(500, "An unknown error occurred.") + } + if (error.name === ErrorName.MAX_FILE_SIZE_EXCEEDED) { + return makeAPIErrorResponse(413, "The operation was aborted.") + } else if (error.name === ErrorName.TIMEOUT) { + return makeAPIErrorResponse(408, "The operation timed out.") + } else { + return makeAPIErrorResponse(500, error.message) + } + } +} + +async function downloadFile(params: { + url: URL, + maxBytes: number, + timeoutInSeconds: number +}): Promise { + const { url, maxBytes, timeoutInSeconds } = params + const abortController = new AbortController() + const timeoutSignal = AbortSignal.timeout(timeoutInSeconds * 1000) + const response = await fetch(url, { + signal: AbortSignal.any([abortController.signal, timeoutSignal]) + }) + if (!response.body) { + throw new Error("Response body unavailable") + } + let totalBytes = 0 + let didExceedMaxBytes = false + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + // eslint-disable-next-line no-constant-condition + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read() + if (done) { + break + } + totalBytes += value.length + chunks.push(value) + if (totalBytes >= maxBytes) { + didExceedMaxBytes = true + abortController.abort() + break + } + } + if (didExceedMaxBytes) { + const error = new Error("Maximum file size exceeded") + error.name = ErrorName.MAX_FILE_SIZE_EXCEEDED + throw error + } + return new Blob(chunks) }