diff --git a/packages/img/README.md b/packages/img/README.md index 0b78f87f..954a696b 100644 --- a/packages/img/README.md +++ b/packages/img/README.md @@ -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 @@ -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 @@ -120,6 +124,8 @@ export const { Image, storageRoute } = createTransloaditImage({ basePath: '/app', route: '/api/private-images', }, + expiresInMs: 5 * 60 * 1000, + rotationIntervalMs: 30 * 1000, }, workspace, }) @@ -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 diff --git a/packages/img/test/next-server.test.tsx b/packages/img/test/next-server.test.tsx index 421840bf..be4feb58 100644 --- a/packages/img/test/next-server.test.tsx +++ b/packages/img/test/next-server.test.tsx @@ -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( + Long-lived preview, + ), + ) + 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) diff --git a/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx index bfbd58e1..64dddbb8 100644 --- a/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx +++ b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx @@ -11,6 +11,8 @@ const { Image, storageRoute } = createTransloaditImage({ basePath: '/fixture', route: '/api/private-images', }, + expiresInMs: 5 * 60 * 1000, + rotationIntervalMs: 30 * 1000, }, }) diff --git a/scripts/test-img-next-fixture.ts b/scripts/test-img-next-fixture.ts index 79cb1caf..f3927c6e 100644 --- a/scripts/test-img-next-fixture.ts +++ b/scripts/test-img-next-fixture.ts @@ -335,11 +335,19 @@ async function main(): Promise { 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',