fix(intake): support range requests for uploaded assets

This commit is contained in:
WuYongJie 2026-05-07 14:47:18 +08:00
parent 8baa41dd0b
commit 2cdb29c650
2 changed files with 127 additions and 8 deletions

View File

@ -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 },
);
}

View File

@ -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, {
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({