# Download Hooks (https://v1.s3.dimah.dev/docs/server/hooks/download)

Enable download operations with `download: true` or a `DownloadConfig` object on a [named route](https://v1.s3.dimah.dev/docs/server/routes).

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](https://v1.s3.dimah.dev/docs/server/security#route-namespace-vs-ownership).

```ts title="lib/s3.ts"
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 }) => {
          // [!code ++]
          const session = await getSession(request); // [!code ++]
          if (!session) throw errors.unauthorized(); // [!code ++]
        }, // [!code ++]
      },
    }),
  },
});
```

---

## Download lifecycle flow [#download-lifecycle-flow]

<Flow
  label="Download flow"
  steps={[
    { name: "download.resolve", kind: "hook" },
    { name: "download.guard", kind: "hook" },
    { name: "onPresigned", kind: "hook" },
    { name: "Fetch from S3", kind: "s3" },
  ]}
/>

1. **`resolve`**: Optionally rewrites download `fileName`, `disposition` (`inline` vs `attachment`), or `expiresIn`. A returned `expiresIn` replaces the route cap and any client request, then is clamped to `maxExpiresIn`.
2. **`guard`**: Validates request permissions for the specified `key`. `expiresIn` here is the TTL that will be signed (`0` when `mode` is `"proxy"`).
3. **`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 [#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:

```ts title="lib/s3.ts"
files: route({
  download: {
    inlineTypes: ["image/*", "application/pdf", "video/mp4", "text/plain"], // [!code ++]
  },
}),
```

After the guards, the server reads the object's type from HeadObject:

- On the list (`image/*` matches the same way as `upload.fileTypes`): the
  requested `inline` is 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-stream` is typed from its file name.
  When that type is allowed, it is signed as `ResponseContentType` so 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 [#type-reference]

```ts
import type {
  DownloadGuardContext,
  DownloadOnPresignedContext,
  DownloadResolveInfo,
} from "@dimah-s3/server";
```

### DownloadGuardContext [#downloadguardcontext]

<AutoTypeTable
  path="packages/server/src/types/hook-contexts.ts"
  name="DownloadGuardContext"
/>

---

### DownloadOnPresignedContext [#downloadonpresignedcontext]

<AutoTypeTable
  path="packages/server/src/types/hook-contexts.ts"
  name="DownloadOnPresignedContext"
/>

---

### DownloadResolveInfo [#downloadresolveinfo]

<AutoTypeTable
  path="packages/server/src/types/hook-contexts.ts"
  name="DownloadResolveInfo"
/>

## Frequently asked questions [#frequently-asked-questions]

<Accordions>

<Accordion title="Can the client choose how long a download link lasts?">

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`.

```ts
files: route({
  download: {
    expiresIn: 60 * 60 * 24, // [!code ++]
  },
}),
```

</Accordion>

<Accordion title="How do I change the download filename dynamically?">

Use `download.resolve` to set the downloaded file name per request:

```ts
avatar: route({
  download: {
    resolve: async ({ key, request }) => {
      const user = await getUserByKey(key);
      return {
        fileName: `${user.username}-avatar.png`, // [!code ++]
        disposition: "attachment", // [!code ++]
      };
    },
  },
});
```

</Accordion>

<Accordion title="How do I stream downloads through my server?">

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:

```ts
avatar: route({
  download: {
    mode: "proxy", // [!code ++]
  },
});
```

</Accordion>

</Accordions>
