fix(intake): support range requests for uploaded assets
This commit is contained in:
parent
8baa41dd0b
commit
2cdb29c650
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user