Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions packages/img/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ non-normalized Unicode, or more than 1024 UTF-8 bytes are rejected before signin

### Direct delivery

Choose the delivery policy based on the page's lifetime: use authorized redirects for cached
markup and pages that may outlive a CDN signature. Use direct delivery for request-authorized
galleries that do not need a new application authorization check when each image loads.

Direct delivery is the default and fits image-heavy views that already authorize their data while
rendering. The component calls Next.js `connection()` before creating short-lived signed URLs. A
built-in Suspense boundary lets a Cache Components page prerender a shell, but the signed image
Expand All @@ -95,8 +99,8 @@ image that has no source and makes no request. `suspenseFallback` explicitly rep

The browser requests the selected candidate directly from Smart CDN. Lazy loading remains the
platform default. A candidate first requested after its signature expires can fail on an unusually
long-lived page; choose an appropriate bounded `expiresInMs`, eagerly load a measured critical
image, or use authorized redirect delivery.
long-lived page. Prefer authorized redirects for that case; a longer direct signature only delays
the boundary and also extends the lifetime of a URL that has already been issued.

### Authorized redirects

Expand All @@ -120,6 +124,8 @@ export const { Image, storageRoute } = createTransloaditImage({
basePath: '/app',
route: '/api/private-images',
},
expiresInMs: 5 * 60 * 1000,
rotationIntervalMs: 30 * 1000,
},
workspace,
})
Expand All @@ -137,18 +143,31 @@ The handler rejects changed, duplicate, unknown, oversized, or malformed capabil
calling application authorization. `authorize` must return the boolean `true` for the current
request.

Use browser-attached credentials, normally your same-origin session cookie, in `authenticate`.
Native image requests cannot attach an application-defined Bearer header.

After authorization, the handler returns a private, non-cacheable `307` to a fresh signed Smart CDN
URL. Image bytes still bypass Next.js. Rotating the Transloadit secret invalidates existing
capabilities, so redeploy cached static markup at the same time.

The example issues CDN grants valid for at least five minutes and at most five minutes thirty
seconds, including the rotation window. These are explicit example settings, not new defaults;
allow enough time for a cold transformation. Cached capabilities can still request a new grant
after an earlier CDN URL has expired, provided application authorization continues to allow access.

Revoking application access denies **new redirect grants**. It does not invalidate signed CDN URLs
already handed to a browser: those remain valid until their expiry. Downloaded bytes cannot be
recalled. Shorter grants bound this remaining access window; they do not provide instant revocation.

| Property | Direct, the default | Authorized redirect |
| --- | --- | --- |
| Next.js work per loaded image | None | One authorization + redirect |
| Image bytes through Next.js | Never | Never |
| Shared/static image markup | No | Yes |
| Request-time revocation | No | Yes |
| Application checks for new image grants | During page rendering | On each redirect request |
| Already-issued CDN URLs | Valid until expiry | Valid until expiry |
| Long-lived lazy pages | Signature can expire | Fresh CDN signature per load |
| Typical fit | Large authorized galleries | Strict ACLs and revocation |
| Typical fit | Request-authorized galleries | Cached markup and long-lived private pages |

## Responsive policy

Expand Down
44 changes: 44 additions & 0 deletions packages/img/test/next-server.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,50 @@ describe('createTransloaditImage', () => {
expect(connection).not.toHaveBeenCalled()
})

test('refreshes an expired target from cached markup, then denies new grants after revocation', async () => {
const authorize = vi.fn(() => true)
const { Image, storageRoute } = createTransloaditImage({
...baseConfiguration,
storage: {
allowedPathPrefixes: ['documents/'],
delivery: { authorize, route: '/api/private-images' },
expiresInMs: 5 * 60 * 1000,
rotationIntervalMs: 30 * 1000,
},
})
const document = parseMarkup(
renderToStaticMarkup(
<Image alt="Long-lived preview" height={300} src="documents/report.pdf" width={400} />,
),
)
const originalCapability = new URL(getFirstCandidate(document), 'https://app.example')
const firstResponse = await storageRoute(new Request(originalCapability))
const firstLocation = firstResponse.headers.get('location')
if (firstLocation === null) throw new Error('Expected the first authorized target')
const originalExpiry = Number(new URL(firstLocation).searchParams.get('exp'))
expect(originalExpiry).toBe(Date.parse('2029-01-01T12:07:30Z'))

vi.setSystemTime(originalExpiry + 1)
const renewed = await storageRoute(new Request(originalCapability))
const renewedLocation = renewed.headers.get('location')
if (renewedLocation === null) throw new Error('Expected a renewed authorized target')
expect(renewed.status).toBe(307)
expect(renewedLocation).not.toBe(firstLocation)
expect(Number(new URL(renewedLocation).searchParams.get('exp'))).toBe(
Date.parse('2029-01-01T12:13:00Z'),
)
expect(renewed.headers.get('cache-control')).toBe('private, no-store')
expect(await renewed.text()).toBe('')

authorize.mockReturnValue(false)
const denied = await storageRoute(new Request(originalCapability))
expect(denied.status).toBe(404)
expect(denied.headers.get('location')).toBeNull()
expect(denied.headers.get('cache-control')).toBe('private, no-store')
expect(await denied.text()).toBe('')
expect(authorize).toHaveBeenCalledTimes(3)
})

test('binds capabilities to the secret, workspace, Template, route, and basePath', async () => {
const { url } = getStorageRouteCandidate()
const authorize = vi.fn(() => true)
Expand Down
2 changes: 2 additions & 0 deletions scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const { Image, storageRoute } = createTransloaditImage({
basePath: '/fixture',
route: '/api/private-images',
},
expiresInMs: 5 * 60 * 1000,
rotationIntervalMs: 30 * 1000,
},
})

Expand Down
8 changes: 8 additions & 0 deletions scripts/test-img-next-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,11 +335,19 @@ async function main(): Promise<void> {
const routeCandidate = getFirstPictureCandidates(redirectHtml)[0]
assert(routeCandidate !== undefined, 'Expected a redirect route candidate')
const routeUrl = new URL(routeCandidate, baseUrl)
const beforeAuthorization = Date.now()
const allowed = await fetch(routeUrl, {
headers: { Authorization: 'Bearer fixture' },
redirect: 'manual',
})
assert(allowed.status === 307, 'Authorized Storage route did not redirect')
const location = allowed.headers.get('location')
assert(location !== null, 'Authorized Storage route has no target')
const expiresAt = Number(new URL(location).searchParams.get('exp'))
assert(
expiresAt >= beforeAuthorization + 5 * 60 * 1000 && expiresAt <= Date.now() + 330_000,
'Redirect fixture did not use the documented five-minute plus 30-second grant',
)
assert(
allowed.headers.get('location')?.startsWith('https://cdn.example/') === true,
'Authorized Storage route did not target Smart CDN',
Expand Down