38 lines
1.5 KiB
TypeScript
38 lines
1.5 KiB
TypeScript
import 'server-only'
|
|
import { fileId, fileRepository } from '@/entities/file'
|
|
import { blob, db } from '@/shared/api'
|
|
import { currentVisitor } from '@/shared/lib/visitor-session'
|
|
|
|
function notFound(): Response {
|
|
return new Response('not found', { status: 404 })
|
|
}
|
|
|
|
/**
|
|
* The bytes of one attachment, for the visitor who attached it. The platform's
|
|
* blob URL needs the deployment token, which the browser does not have, so the
|
|
* app downloads server-side and streams the bytes back. Anything that is not
|
|
* the visitor's own attachment answers 404, never 403.
|
|
*/
|
|
export async function downloadAttachment(id: string): Promise<Response> {
|
|
const parsed = fileId.safeParse(id)
|
|
if (!parsed.success) return notFound()
|
|
const visitor = await currentVisitor()
|
|
if (!visitor) return notFound()
|
|
const file = await fileRepository(db()).get(parsed.data, visitor)
|
|
if (!file) return notFound()
|
|
const upstream = await blob().download(file.url)
|
|
if (!upstream.body) return notFound()
|
|
return new Response(upstream.body, {
|
|
headers: {
|
|
'content-type': file.contentType,
|
|
// RFC 5987: the name is percent-encoded, so quotes and CR/LF cannot inject.
|
|
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
|
},
|
|
})
|
|
}
|
|
|
|
/** `GET /files/<id>`: the route handler the app router re-exports. */
|
|
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }): Promise<Response> {
|
|
return downloadAttachment((await context.params).id)
|
|
}
|