Download Hooks
Authorize and sign GET download URLs or proxy streams.
Enable download operations with download: true or a DownloadConfig object on a named route.
The key must sit under this route's keyPrefix. That is not ownership — a
session-only guard still lets any signed-in user download any namespaced
key they know. See
Security.
import { S3Client } from "@aws-sdk/client-s3";
import { dimahS3, errors, route } from "@dimah-s3/server";
export const awsS3 = new S3Client({/* env */});
export const s3 = dimahS3({
client: awsS3,
bucket: process.env.S3_BUCKET!,
routes: {
avatar: route({
download: {
disposition: "inline",
guard: async ({ key, request }) => {
const session = await getSession(request);
if (!session) throw errors.unauthorized();
},
},
}),
},
});Download lifecycle flow
- download.resolvehook
- download.guardhook
- onPresignedhook
- Fetch from S3S3
resolve: Optionally rewrites downloadfileName,disposition(inlinevsattachment), orexpiresIn. A returnedexpiresInreplaces the route cap and any client request, then is clamped tomaxExpiresIn.guard: Validates request permissions for the specifiedkey.expiresInhere is the TTL that will be signed (0whenmodeis"proxy").onPresigned: Receives the signed URL and expiration details.
A client may send expiresIn on the download query. The signed TTL is the shorter of that request and download.expiresIn (default 10 minutes) unless resolve replaces it. GET /routes includes the cap as download.expiresIn. Proxy downloads ignore it and return expiresIn: 0.
Open in the browser only for known types
A client can ask for disposition: "inline". Without a limit, any stored
object — HTML or SVG included — would then render in a browser tab. Set
inlineTypes to the types this route may open inline:
files: route({
download: {
inlineTypes: ["image/*", "application/pdf", "video/mp4", "text/plain"],
},
}),After the guards, the server reads the object's type from HeadObject:
- On the list (
image/*matches the same way asupload.fileTypes): the requestedinlineis kept. - Anything else is signed as
attachment, whatever the client asked for. - A wildcard never matches types that run as a page: HTML, any XML
(including
image/svg+xml), and JavaScript. List one exactly to allow it. - An object stored as
application/octet-streamis typed from its file name. When that type is allowed, it is signed asResponseContentTypeso the browser renders it.
GET /routes returns the list as download.inlineTypes. A client can pass it
to resolveInlineDisposition from @dimah-s3/core to choose Preview or
Download with the server's rule. The same helper works inside resolve.
Proxy responses (GET /file) also send X-Content-Type-Options: nosniff.
Type reference
import type {
DownloadGuardContext,
DownloadOnPresignedContext,
DownloadResolveInfo,
} from "@dimah-s3/server";DownloadGuardContext
Prop
Type
DownloadOnPresignedContext
Prop
Type
DownloadResolveInfo
Prop
Type
Frequently asked questions
Yes, up to the route cap. download.expiresIn is the maximum. A shorter
expiresIn on the query is signed as requested. Return expiresIn from
resolve when the server should choose a different TTL, including one
longer than the route cap. That value is still clamped to maxExpiresIn.
files: route({
download: {
expiresIn: 60 * 60 * 24,
},
}),Use download.resolve to set the downloaded file name per request:
avatar: route({
download: {
resolve: async ({ key, request }) => {
const user = await getUserByKey(key);
return {
fileName: `${user.username}-avatar.png`,
disposition: "attachment",
};
},
},
});Set mode: "proxy". The browser receives a same-origin URL
(/api/s3/file?key=...), and your server streams the response instead of
returning a direct bucket URL:
avatar: route({
download: {
mode: "proxy",
},
});