From 2cdb29c650926f7750db40bcf350e181e777a627 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 7 May 2026 14:47:18 +0800 Subject: [PATCH] fix(intake): support range requests for uploaded assets --- .../assets/[...assetPath]/route.ts | 82 ++++++++++++++++++- .../non-integrated/upload/route.test.ts | 53 ++++++++++-- 2 files changed, 127 insertions(+), 8 deletions(-) diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/assets/[...assetPath]/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/assets/[...assetPath]/route.ts index 3c5dec49..865c924c 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/assets/[...assetPath]/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/assets/[...assetPath]/route.ts @@ -48,6 +48,46 @@ function getContentType(filePath: string) { ); } +function parseByteRange(rangeHeader: string | null, fileSize: number) { + if (!rangeHeader) return null; + + const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); + if (!match || fileSize <= 0) return "invalid" as const; + + const [, rawStart, rawEnd] = match; + if (!rawStart && !rawEnd) return "invalid" as const; + + if (!rawStart) { + const suffixLength = Number(rawEnd); + if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) { + return "invalid" as const; + } + + return { + start: Math.max(fileSize - suffixLength, 0), + end: fileSize - 1, + }; + } + + const start = Number(rawStart); + const end = rawEnd ? Number(rawEnd) : fileSize - 1; + + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end < start || + start >= fileSize + ) { + return "invalid" as const; + } + + return { + start, + end: Math.min(end, fileSize - 1), + }; +} + export async function GET(request: NextRequest, context: RouteContext) { return handleRequest( request, @@ -79,18 +119,54 @@ export async function GET(request: NextRequest, context: RouteContext) { } const fileStat = await stat(resolvedPath); + const contentType = getContentType(resolvedPath); + const baseHeaders = { + "Accept-Ranges": "bytes", + "Cache-Control": "private, max-age=31536000, immutable", + "Content-Type": contentType, + }; + const range = parseByteRange( + request.headers.get("range"), + fileStat.size, + ); + + if (range === "invalid") { + return new NextResponse(null, { + status: 416, + headers: { + ...baseHeaders, + "Content-Range": `bytes */${fileStat.size}`, + }, + }); + } + + if (range) { + const stream = createReadStream(resolvedPath, { + start: range.start, + end: range.end, + }); + const contentLength = range.end - range.start + 1; + + return new NextResponse(Readable.toWeb(stream) as ReadableStream, { + status: 206, + headers: { + ...baseHeaders, + "Content-Length": String(contentLength), + "Content-Range": `bytes ${range.start}-${range.end}/${fileStat.size}`, + }, + }); + } + const stream = createReadStream(resolvedPath); return new NextResponse(Readable.toWeb(stream) as ReadableStream, { status: 200, headers: { - "Content-Type": getContentType(resolvedPath), + ...baseHeaders, "Content-Length": String(fileStat.size), - "Cache-Control": "private, max-age=31536000, immutable", }, }); }, { requireAuth: true }, ); } - diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.test.ts b/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.test.ts index 32a9a445..974eafa6 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.test.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.test.ts @@ -183,11 +183,18 @@ describe('POST /api/intake/non-integrated/upload', () => { ) expect(Buffer.from(storedBytes).toString('utf8')).toBe('patent-certificate') - const downloadResponse = await GET({} as any, { - params: Promise.resolve({ - assetPath: [uploadPayload.data.storageKey], - }), - }) + const downloadResponse = await GET( + { + headers: { + get: () => null, + }, + } as any, + { + params: Promise.resolve({ + assetPath: [uploadPayload.data.storageKey], + }), + }, + ) const downloadedBytes = await readResponseBody(downloadResponse) expect(downloadResponse.status).toBe(200) @@ -197,6 +204,42 @@ describe('POST /api/intake/non-integrated/upload', () => { expect(downloadedBytes.toString('utf8')).toBe('patent-certificate') }) + it('serves uploaded videos with byte-range support for browser playback', async () => { + const videoBytes = new TextEncoder().encode('video-content') + + const uploadResponse = await POST({ + formData: async () => + createFormData( + createUploadFile(videoBytes, 'product-video.mp4', 'video/mp4'), + ), + } as any) + const uploadPayload = await uploadResponse.json() + + const rangeResponse = await GET( + { + headers: { + get: (name: string) => + name.toLowerCase() === 'range' ? 'bytes=0-4' : null, + }, + } as any, + { + params: Promise.resolve({ + assetPath: [uploadPayload.data.storageKey], + }), + }, + ) + const rangeBytes = await readResponseBody(rangeResponse) + + expect(rangeResponse.status).toBe(206) + expect(getResponseHeader(rangeResponse, 'Accept-Ranges')).toBe('bytes') + expect(getResponseHeader(rangeResponse, 'Content-Type')).toBe('video/mp4') + expect(getResponseHeader(rangeResponse, 'Content-Length')).toBe('5') + expect(getResponseHeader(rangeResponse, 'Content-Range')).toBe( + `bytes 0-4/${videoBytes.length}`, + ) + expect(rangeBytes.toString('utf8')).toBe('video') + }) + it('allows files below the configured intake upload limit', async () => { const modelBytes = new TextEncoder().encode('model-placeholder') const uploadResponse = await POST({