Skip to content

Commit 0c00f35

Browse files
authored
Merge pull request #416 from shapehq/bugfix/cancel-downloading-large-files
/api/proxy cancels downloading large files and long operations
2 parents e226bef + f1f9296 commit 0c00f35

File tree

2 files changed

+67
-3
lines changed

2 files changed

+67
-3
lines changed

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ POSTGRESQL_DB=framna-docs
1313
REPOSITORY_NAME_SUFFIX=-openapi
1414
HIDDEN_REPOSITORIES=
1515
NEW_PROJECT_TEMPLATE_REPOSITORY=shapehq/starter-openapi
16+
PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES = 10
17+
PROXY_API_TIMEOUT_IN_SECONDS = 30
1618
GITHUB_WEBHOOK_SECRET=preshared secret also put in app configuration in GitHub
1719
GITHUB_WEBHOK_REPOSITORY_ALLOWLIST=
1820
GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST=

src/app/api/proxy/route.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { NextRequest, NextResponse } from "next/server"
2-
import { makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common"
2+
import { env, makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common"
33
import { session } from "@/composition"
44

5+
const ErrorName = {
6+
MAX_FILE_SIZE_EXCEEDED: "MaxFileSizeExceededError",
7+
TIMEOUT: "TimeoutError"
8+
}
9+
510
export async function GET(req: NextRequest) {
611
const isAuthenticated = await session.getIsAuthenticated()
712
if (!isAuthenticated) {
@@ -17,6 +22,63 @@ export async function GET(req: NextRequest) {
1722
} catch {
1823
return makeAPIErrorResponse(400, "Invalid \"url\" query parameter.")
1924
}
20-
const file = await fetch(url).then(r => r.blob())
21-
return new NextResponse(file, { status: 200 })
25+
try {
26+
const maxMegabytes = Number(env.getOrThrow("PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES"))
27+
const timeoutInSeconds = Number(env.getOrThrow("PROXY_API_TIMEOUT_IN_SECONDS"))
28+
const maxBytes = maxMegabytes * 1024 * 1024
29+
const file = await downloadFile({ url, maxBytes, timeoutInSeconds })
30+
return new NextResponse(file, { status: 200 })
31+
} catch (error) {
32+
if (error instanceof Error == false) {
33+
return makeAPIErrorResponse(500, "An unknown error occurred.")
34+
}
35+
if (error.name === ErrorName.MAX_FILE_SIZE_EXCEEDED) {
36+
return makeAPIErrorResponse(413, "The operation was aborted.")
37+
} else if (error.name === ErrorName.TIMEOUT) {
38+
return makeAPIErrorResponse(408, "The operation timed out.")
39+
} else {
40+
return makeAPIErrorResponse(500, error.message)
41+
}
42+
}
43+
}
44+
45+
async function downloadFile(params: {
46+
url: URL,
47+
maxBytes: number,
48+
timeoutInSeconds: number
49+
}): Promise<Blob> {
50+
const { url, maxBytes, timeoutInSeconds } = params
51+
const abortController = new AbortController()
52+
const timeoutSignal = AbortSignal.timeout(timeoutInSeconds * 1000)
53+
const response = await fetch(url, {
54+
signal: AbortSignal.any([abortController.signal, timeoutSignal])
55+
})
56+
if (!response.body) {
57+
throw new Error("Response body unavailable")
58+
}
59+
let totalBytes = 0
60+
let didExceedMaxBytes = false
61+
const reader = response.body.getReader()
62+
const chunks: Uint8Array[] = []
63+
// eslint-disable-next-line no-constant-condition
64+
while (true) {
65+
// eslint-disable-next-line no-await-in-loop
66+
const { done, value } = await reader.read()
67+
if (done) {
68+
break
69+
}
70+
totalBytes += value.length
71+
chunks.push(value)
72+
if (totalBytes >= maxBytes) {
73+
didExceedMaxBytes = true
74+
abortController.abort()
75+
break
76+
}
77+
}
78+
if (didExceedMaxBytes) {
79+
const error = new Error("Maximum file size exceeded")
80+
error.name = ErrorName.MAX_FILE_SIZE_EXCEEDED
81+
throw error
82+
}
83+
return new Blob(chunks)
2284
}

0 commit comments

Comments
 (0)