From aa2bdd34acc412e0efd1a3af20ea774e2ca20f2a Mon Sep 17 00:00:00 2001 From: alexcibotari Date: Mon, 26 Oct 2026 10:43:08 +0100 Subject: [PATCH 1/2] Revert "fix: redirect assets over 31 MiB to a signed Storage URL" This reverts commit 6f9c2b6b. Signing needs the functions runtime service account to hold roles/iam.serviceAccountTokenCreator on itself, and the Cloud Build deploy account (build-deploy@) cannot grant it (iam.serviceAccounts.getIamPolicy denied), so in production every asset over 31 MiB answered 500. It would be a manual IAM step on every install. --- cloudbuild.yaml | 15 ---- docs/cdn-caching.md | 2 - docs/deployment/check.md | 15 ---- docs/deployment/firebase-setup.md | 9 +- docs/v1-functions-api.md | 2 +- functions/src/utils/stream-object.ts | 116 ++++---------------------- functions/src/v1/cdn-assets.test.ts | 64 +------------- functions/src/v1/cdn.ts | 25 +++--- scripts/localess/apis.mjs | 3 - scripts/localess/apis.test.mjs | 3 +- scripts/localess/checks.mjs | 38 +-------- scripts/localess/checks.test.mjs | 30 +------ scripts/localess/commands/check.mjs | 45 +++------- scripts/localess/commands/deploy.mjs | 5 -- scripts/localess/firebase-gaps.mjs | 63 -------------- scripts/localess/url-signing.mjs | 78 ----------------- scripts/localess/url-signing.test.mjs | 28 ------- 17 files changed, 47 insertions(+), 494 deletions(-) delete mode 100644 scripts/localess/url-signing.mjs delete mode 100644 scripts/localess/url-signing.test.mjs diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 10e34daee..1a3d4ccd7 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -23,7 +23,6 @@ steps: pubsub.googleapis.com \ storage.googleapis.com \ translate.googleapis.com \ - iamcredentials.googleapis.com \ --project=$PROJECT_ID - name: 'node:24' id: 'install-deps' @@ -78,20 +77,6 @@ steps: - name: ghcr.io/lessify/firebase:edge id: 'firebase-deploy' args: [ 'deploy', '--config=firebase.$PROJECT_ID.json', '--project=$PROJECT_ID', '--only=hosting,functions,storage,firestore,auth', '--force' ] - # let the functions sign Storage URLs as themselves: assets over 31 MiB are redirected to one, - # because Hosting truncates function responses at 32 MiB (mirrors scripts/localess/url-signing.mjs) - - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' - id: 'grant-url-signing' - entrypoint: 'bash' - allowFailure: true - args: - - '-c' - - | - SA="$PROJECT_NUMBER-compute@developer.gserviceaccount.com" - gcloud iam service-accounts add-iam-policy-binding "$$SA" \ - --member="serviceAccount:$$SA" \ - --role=roles/iam.serviceAccountTokenCreator \ - --project=$PROJECT_ID options: logging: CLOUD_LOGGING_ONLY substitutions: diff --git a/docs/cdn-caching.md b/docs/cdn-caching.md index 7d7d6851d..e4afc3810 100644 --- a/docs/cdn-caching.md +++ b/docs/cdn-caching.md @@ -37,8 +37,6 @@ Whenever the marker is rewritten, its generation increments. The content marker | Asset response (incl. `/original`, `/download`) | 365 days | 365 days | browser / CDN | | Asset `304 Not Modified` | 365 days | 365 days | browser / CDN | | Asset canonical-size redirect (`w`/`h` above source) | 365 days | 365 days | browser / CDN | -| Asset over 31 MiB — `302` to a signed Storage URL (valid 24 h) | 1 hour | 1 hour | browser / CDN | -| Asset over 31 MiB that could not be signed — `500` | no-cache | no-cache | — | | 400 — invalid asset transform param | 1 hour | 1 hour | browser / CDN | | 404 — space not found | 7 days | 7 days | browser / CDN | | 404 — content cache marker missing / content not found on disk (content routes only) | 10 min | 10 min | browser / CDN | diff --git a/docs/deployment/check.md b/docs/deployment/check.md index 53c933e0c..0b354c69e 100644 --- a/docs/deployment/check.md +++ b/docs/deployment/check.md @@ -136,20 +136,6 @@ invoked by Google's own service agents, so making them public would widen access --- -## URL signing - -Firebase Hosting buffers a rewritten function response and cuts it off at 32 MiB, still answering -`200` with a matching `Content-Length` — so a large video "downloads" as its first 32 MiB. The -asset API therefore redirects anything over 31 MiB to a short-lived signed Storage URL. The -functions have no key file, so they sign through the IAM Credentials `signBlob` API as their own -runtime service account (the default compute account, `-compute@developer.gserviceaccount.com`), -which needs `roles/iam.serviceAccountTokenCreator` **on itself**. Nothing grants that by default. - -`localess:deploy` grants it after each functions deploy; `--fix` grants it here. The account only -exists once functions have been deployed, so before that the row reads `?` and points at a deploy. - ---- - ## `--fix` ```bash @@ -165,7 +151,6 @@ Applies only the repairs that are **free and reversible**: | Project labels | Writes the three markers | | Local files / region | A full `syncLocalFiles`, propagating the live Firestore location everywhere | | Invoker bindings | Adds `allUsers` to `roles/run.invoker`, preserving every other binding | -| URL signing | Grants the runtime service account `roles/iam.serviceAccountTokenCreator` on itself | | Admin user | Prompts for credentials, then creates the account, its admin claim and its documents | Everything else is reported with the command that does it, because it is not safe to do as a side diff --git a/docs/deployment/firebase-setup.md b/docs/deployment/firebase-setup.md index c25aa8cc4..d68a6827f 100644 --- a/docs/deployment/firebase-setup.md +++ b/docs/deployment/firebase-setup.md @@ -92,7 +92,7 @@ In order: 1. Preflight firebase-tools present and >= 15.29.0, CLI authenticated 2. Project adopt --project, or pick from a list / create one 3. Billing link a billing account, then wait for it to become active -4. APIs check the 16 required APIs, enable whichever are off +4. APIs check the 15 required APIs, enable whichever are off 5. Firestore adopt the existing location, or ask and create in --region 6. Storage create and link the default bucket in --region 7. Storage CORS allow GET and HEAD from any origin, when the bucket has no rules @@ -116,15 +116,8 @@ firestore identitytoolkit firebasestorage firebaseextensions cloudfunctions cloudbuild artifactregistry run eventarc pubsub storage translate -iamcredentials ``` -`iamcredentials.googleapis.com` is what the functions sign Storage URLs through — assets over -31 MiB are redirected to a signed URL, because Firebase Hosting truncates function responses at -32 MiB. Signing also needs the runtime service account to hold -`roles/iam.serviceAccountTokenCreator` on itself; deploy grants that after the functions are up -(the account does not exist before), and `localess:check` reports it. - ### Authentication `firebase.json` carries an `auth` block, which the `auth` deploy target sends to Google's diff --git a/docs/v1-functions-api.md b/docs/v1-functions-api.md index 575dec2c0..30d0b7edb 100644 --- a/docs/v1-functions-api.md +++ b/docs/v1-functions-api.md @@ -37,7 +37,7 @@ Content delivery with cache-busting and asset transformation. All content/transl - **`thumbnail` param** — Collapses an animated WebP/GIF to its first frame, and extracts a video frame via FFmpeg (requires `w`). Has no effect on other image types. Since animations now resize with every frame intact, `thumbnail` is how you ask for a *still* rather than how you make resizing work. - **EXIF orientation is applied** — Sharp strips the orientation tag on re-encode, so a rotated source is baked into the pixels. Without it a portrait phone photo returned landscape with its aspect ratio transposed. - **Embedded colour profiles are carried through** — via `keepIccProfile()`. Sources without one gain nothing; tagging every response as sRGB would add ~506 bytes each. -- **`/original` and `/download`** — serve the stored bytes exactly as uploaded, inline and as an `attachment` respectively. Since a bare transform request re-encodes, these are the only way to retrieve the original file. Neither enters Sharp, and both reject `w`/`h`/`q`/`f`/`fit`/`thumbnail`/`download` with `400` rather than ignoring them. Objects over 31 MiB are answered with a `302` to a signed Storage URL (24 h validity, redirect cached 1 h) carrying the same `Content-Type` and `Content-Disposition`, because Firebase Hosting truncates function responses at 32 MiB; smaller ones are streamed from Storage with `Range` support. The same applies to a bare `/assets/:assetId` request for a non-image type (video, audio, PDF). Signing needs the runtime service account to hold `roles/iam.serviceAccountTokenCreator` on itself — see [check.md](deployment/check.md#url-signing). +- **`/original` and `/download`** — serve the stored bytes exactly as uploaded, inline and as an `attachment` respectively. Since a bare transform request re-encodes, these are the only way to retrieve the original file. Neither enters Sharp, and both reject `w`/`h`/`q`/`f`/`fit`/`thumbnail`/`download` with `400` rather than ignoring them. - **Removed in v4** — the `?download` flag and `f=original`. Both return `400` with a message naming the replacement route. Responses already cached under the old spellings keep serving for the remainder of their 365-day TTL. #### Asset resize combinations (`w` / `h`) diff --git a/functions/src/utils/stream-object.ts b/functions/src/utils/stream-object.ts index a42d4d442..01b5aecf2 100644 --- a/functions/src/utils/stream-object.ts +++ b/functions/src/utils/stream-object.ts @@ -3,26 +3,13 @@ import { logger } from 'firebase-functions'; import type { Readable } from 'stream'; /** - * Largest object served through the function; anything bigger is redirected to Storage. - * - * Firebase Hosting buffers a rewritten function response and cuts it off at 32 MiB — a 104 MiB - * video arrived as its first 32 MiB with a `200` and a matching `Content-Length`, so the browser - * reported a finished download. Chunked encoding does not help: it gets past Cloud Run's own - * 32 MiB limit, but not past Hosting's. Kept a little under the limit to leave room for headers. - */ -export const MAX_PROXIED_BYTES = 31 * 1024 * 1024; - -/** - * How long a signed Storage URL stays valid. Long enough that a video player can still seek - * (each seek is a fresh `Range` request against the signed URL) well after the page loaded. + * Cloud Run rejects a response larger than 32 MiB with a bare `500` unless it is streamed with + * chunked transfer encoding — and a response carrying `Content-Length` is not streamed in that + * sense. Bodies at or under this size keep their `Content-Length` (so clients show progress); + * anything larger is sent without one, which is what makes Node fall back to chunked encoding. + * Kept a little under the hard limit to leave room for the headers. */ -export const SIGNED_URL_TTL_SECONDS = 24 * 60 * 60; - -/** - * How long the redirect itself may be cached, by the browser and by the Hosting CDN. Well under - * the signed URL's lifetime, so a cached redirect never points at an expired signature. - */ -export const SIGNED_REDIRECT_MAX_AGE = 60 * 60; +export const MAX_CONTENT_LENGTH_BYTES = 31 * 1024 * 1024; export type ByteRange = { start: number; end: number }; @@ -58,99 +45,24 @@ export function parseByteRange(header: string | undefined, size: number): ByteRa } /** The slice of a Storage `File` this needs, so tests can hand in a fake. */ -export interface StoredFile { +export interface StreamableFile { createReadStream(options?: { start?: number; end?: number; validation?: false }): Readable; - getSignedUrl(config: { - version: 'v4'; - action: 'read'; - expires: number; - responseDisposition?: string; - responseType?: string; - }): Promise<[string]>; -} - -/** The headers the stored object must be served with, wherever it is served from. */ -export interface StoredObjectHeaders { - contentType: string; - contentDisposition: string; -} - -/** - * Serves a stored object: streamed through the function when Hosting can carry it, otherwise a - * `302` to a short-lived signed Storage URL. The caller sets the type, disposition, cache and - * security headers first; the redirect overrides the cache header and repeats type and - * disposition as signed response overrides, so Storage serves the same filename. - * - * Signing calls the IAM Credentials `signBlob` API, so the runtime service account needs - * `iam.serviceAccounts.signBlob` on itself (`npm run localess:check -- --fix` grants it). When - * that is missing this answers an uncached `500` — never the truncated body Hosting would make - * of a streamed one. - * @param {Request} req Incoming request, read for `Range` and the method - * @param {Response} res Response to write - * @param {StoredFile} file Storage object to serve - * @param {number} size Object size in bytes, from its metadata - * @param {StoredObjectHeaders} headers Type and disposition to serve the object with - * @param {string} tag Log prefix of the calling route - * @return {Promise} resolves once the response is handed off - */ -export async function serveStoredObject( - req: Request, - res: Response, - file: StoredFile, - size: number, - headers: StoredObjectHeaders, - tag: string -): Promise { - if (size <= MAX_PROXIED_BYTES) { - streamStoredObject(req, res, file, size, tag); - return; - } - let url: string; - try { - [url] = await file.getSignedUrl({ - version: 'v4', - action: 'read', - expires: Date.now() + SIGNED_URL_TTL_SECONDS * 1000, - responseDisposition: headers.contentDisposition, - responseType: headers.contentType, - }); - } catch (error) { - logger.error(`${tag} could not sign a Storage URL for a ${size}-byte object`, error); - res.removeHeader('Content-Disposition'); - res - .status(500) - .header('Cache-Control', 'no-cache') - .contentType('text/plain') - .send( - 'This asset is too large to serve through Hosting, and the function could not sign a Storage URL for it. ' + - 'Grant the functions service account roles/iam.serviceAccountTokenCreator on itself: npm run localess:check -- --fix' - ); - return; - } - logger.info(`${tag} ${size} bytes, redirecting to Storage`); - res.removeHeader('Content-Disposition'); - res.header('Cache-Control', `public, max-age=${SIGNED_REDIRECT_MAX_AGE}, s-maxage=${SIGNED_REDIRECT_MAX_AGE}`).redirect(302, url); } /** * Streams a stored object to the response, honouring a single `Range`. Headers other than * length and range (type, disposition, cache) must already be set by the caller. * - * Replaces `download()` to `/tmp` + `sendFile`, which held the whole object in memory: `/tmp` - * is RAM on Cloud Functions. + * Replaces `download()` to `/tmp` + `sendFile`: on Cloud Functions `/tmp` is RAM, so that held + * the whole object in memory, and `sendFile` always sets `Content-Length` — which is what made + * every object over 32 MiB fail. * @param {Request} req Incoming request, read for `Range` and the method * @param {Response} res Response to stream into - * @param {StoredFile} file Storage object to read + * @param {StreamableFile} file Storage object to read * @param {number} size Object size in bytes, from its metadata * @param {string} tag Log prefix of the calling route */ -export function streamStoredObject( - req: Request, - res: Response, - file: Pick, - size: number, - tag: string -): void { +export function streamStoredObject(req: Request, res: Response, file: StreamableFile, size: number, tag: string): void { res.header('Accept-Ranges', 'bytes'); const range = parseByteRange(req.headers.range, size); if (range === 'unsatisfiable') { @@ -162,7 +74,9 @@ export function streamStoredObject( if (range) { res.status(206).header('Content-Range', `bytes ${start}-${end}/${size}`); } - res.header('Content-Length', String(length)); + if (length <= MAX_CONTENT_LENGTH_BYTES) { + res.header('Content-Length', String(length)); + } if (req.method === 'HEAD' || length === 0) { res.end(); return; diff --git a/functions/src/v1/cdn-assets.test.ts b/functions/src/v1/cdn-assets.test.ts index 330e265c6..cc56e872e 100644 --- a/functions/src/v1/cdn-assets.test.ts +++ b/functions/src/v1/cdn-assets.test.ts @@ -19,9 +19,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; * The env has to be set before the module graph loads, which is why the imports are dynamic. */ const MD5 = 'abc123=='; -const SIGNED_URL = 'https://storage.googleapis.com/test-project.appspot.com/signed'; -/** Past what Firebase Hosting can carry, so the object is redirected rather than streamed. */ -const LARGE = 40 * 1024 * 1024; let CDN: express.Router; let bucket: { file: (path: string) => unknown }; @@ -85,24 +82,13 @@ interface StoredAsset { storageMissing?: boolean; /** Simulates a deleted asset still referenced by published content. */ documentMissing?: boolean; - /** Overrides the stored object's size, to exercise the large-object path without the bytes. */ - size?: number; - /** Simulates a runtime service account that is not allowed to sign URLs. */ - signingFails?: boolean; } -function given(asset: StoredAsset): { - download: ReturnType; - createReadStream: ReturnType; - getSignedUrl: ReturnType; -} { +function given(asset: StoredAsset): { download: ReturnType; createReadStream: ReturnType } { const file = { getMetadata: asset.storageMissing ? vi.fn().mockRejectedValue(new Error('not found')) - : vi.fn().mockResolvedValue([{ md5Hash: MD5, size: String(asset.size ?? asset.bytes.length) }]), - getSignedUrl: asset.signingFails - ? vi.fn().mockRejectedValue(new Error('Permission iam.serviceAccounts.signBlob denied')) - : vi.fn().mockResolvedValue([SIGNED_URL]), + : vi.fn().mockResolvedValue([{ md5Hash: MD5, size: String(asset.bytes.length) }]), createReadStream: vi .fn() .mockImplementation((options?: { start?: number; end?: number }) => @@ -202,52 +188,6 @@ describe('asset routes — the passthrough pair', () => { expect(res.headers['content-range']).toBe('bytes */10'); }); - it('/download redirects an object too large for Hosting to a signed Storage URL', async () => { - const file = given({ bytes: Buffer.from('x'), size: LARGE, type: 'video/quicktime', extension: '.mov' }); - - const res = await request(app()).get(`${URL}/download`); - - expect(res.status).toBe(302); - expect(res.headers['location']).toBe(SIGNED_URL); - expect(res.headers['cache-control']).toContain('max-age=3600'); - expect(res.headers['content-disposition']).toBeUndefined(); - expect(file.createReadStream).not.toHaveBeenCalled(); - // Storage must hand back the same filename and type the function would have sent. - const config = file.getSignedUrl.mock.calls[0][0]; - expect(config).toMatchObject({ version: 'v4', action: 'read', responseType: 'video/quicktime' }); - expect(config.responseDisposition).toContain('attachment'); - expect(config.responseDisposition).toContain('photo.mov'); - }); - - it('/original signs an inline disposition for a large object', async () => { - const file = given({ bytes: Buffer.from('x'), size: LARGE, type: 'video/mp4', extension: '.mp4' }); - - const res = await request(app()).get(`${URL}/original`); - - expect(res.status).toBe(302); - expect(file.getSignedUrl.mock.calls[0][0].responseDisposition).toContain('inline'); - }); - - it('a large object that cannot be signed fails loudly and uncached, never truncated', async () => { - const file = given({ bytes: Buffer.from('x'), size: LARGE, type: 'video/quicktime', extension: '.mov', signingFails: true }); - - const res = await request(app()).get(`${URL}/download`); - - expect(res.status).toBe(500); - expect(res.headers['cache-control']).toBe('no-cache'); - expect(res.text).toContain('localess:check'); - expect(file.createReadStream).not.toHaveBeenCalled(); - }); - - it('a bare transform-route URL on a large video redirects too', async () => { - given({ bytes: Buffer.from('x'), size: LARGE, type: 'video/mp4', extension: '.mp4' }); - - const res = await request(app()).get(URL); - - expect(res.status).toBe(302); - expect(res.headers['location']).toBe(SIGNED_URL); - }); - it('a bare transform-route URL on a video streams the stored bytes', async () => { const bytes = Buffer.from('video bytes'); const file = given({ bytes, type: 'video/mp4', extension: '.mp4' }); diff --git a/functions/src/v1/cdn.ts b/functions/src/v1/cdn.ts index fc4b10dad..4815928f4 100644 --- a/functions/src/v1/cdn.ts +++ b/functions/src/v1/cdn.ts @@ -55,7 +55,7 @@ import { assetResponsePolicy } from '../utils/asset-headers'; import { buildContentDisposition } from '../utils/content-disposition'; import { redactQuery } from '../utils/log-redact'; import { resolveLocaleFilePath } from '../utils/locale-utils'; -import { serveStoredObject } from '../utils/stream-object'; +import { streamStoredObject } from '../utils/stream-object'; import { RequestWithToken, requireContentPermissions, @@ -725,22 +725,20 @@ CDN.get('/api/v1/spaces/:spaceId/assets/:assetId', async (req, res) => { overwriteType = format ? formatMimeMap[format] : 'image/webp'; filename = `${asset.name}-${suffix || `w${width}`}-thumbnail${format ? formatExtMap[format] : '.webp'}`; } else { - // Video, audio, PDF and the rest pass through untouched, so they are streamed (or, past - // what Hosting can carry, redirected to Storage) rather than staged in `/tmp`. + // Video, audio, PDF and the rest pass through untouched, so they are streamed rather than + // staged in `/tmp` — see the `/download` route for why that matters past 32 MiB. streamed = true; } const policy = assetResponsePolicy(overwriteType || asset.type, false); - const contentDisposition = buildContentDisposition(filename, policy.attachment); res .header('Cache-Control', `public, max-age=${CACHE_ASSET_MAX_AGE}, s-maxage=${CACHE_ASSET_MAX_AGE}`) - .header('Content-Disposition', contentDisposition) + .header('Content-Disposition', buildContentDisposition(filename, policy.attachment)) .set(policy.headers) .contentType(policy.contentType); if (output) { res.send(output); } else if (streamed) { - const size = Number(objectMetadata?.['size'] ?? 0); - await serveStoredObject(req, res, assetFile, size, { contentType: policy.contentType, contentDisposition }, '[V1:AssetById]'); + streamStoredObject(req, res, assetFile, Number(objectMetadata?.['size'] ?? 0), '[V1:AssetById]'); } else { res.sendFile(tempFilePath); } @@ -838,16 +836,15 @@ CDN.get(['/api/v1/spaces/:spaceId/assets/:assetId/original', '/api/v1/spaces/:sp } } - // Streamed straight from Storage rather than staged in `/tmp` (RAM on Cloud Functions), and - // redirected to a signed Storage URL past what Firebase Hosting can carry — see - // `serveStoredObject`. Either way `Range` works, so a client can resume a partial transfer. + // Streamed straight from Storage rather than downloaded to `/tmp` and sent with `sendFile`: + // `/tmp` is RAM on Cloud Functions, and `sendFile` always sets `Content-Length`, which makes + // Cloud Run reject anything over 32 MiB — a 100 MB video failed with a bare `500`. The stream + // honours `Range` itself, so a client can still resume a partial transfer. const policy = assetResponsePolicy(asset.type, attachment); - const contentDisposition = buildContentDisposition(`${asset.name}${asset.extension}`, policy.attachment); res .header('Cache-Control', `public, max-age=${CACHE_ASSET_MAX_AGE}, s-maxage=${CACHE_ASSET_MAX_AGE}`) - .header('Content-Disposition', contentDisposition) + .header('Content-Disposition', buildContentDisposition(`${asset.name}${asset.extension}`, policy.attachment)) .set(policy.headers) .contentType(policy.contentType); - const size = Number(objectMetadata?.['size'] ?? 0); - await serveStoredObject(req, res, assetFile, size, { contentType: policy.contentType, contentDisposition }, tag); + streamStoredObject(req, res, assetFile, Number(objectMetadata?.['size'] ?? 0), tag); }); diff --git a/scripts/localess/apis.mjs b/scripts/localess/apis.mjs index 093cf21a4..f66d18270 100644 --- a/scripts/localess/apis.mjs +++ b/scripts/localess/apis.mjs @@ -32,9 +32,6 @@ export const REQUIRED_APIS = Object.freeze([ 'pubsub.googleapis.com', 'storage.googleapis.com', 'translate.googleapis.com', - // The functions sign Storage URLs for assets too large to proxy through Hosting, and - // signing without a key file goes through this API's `signBlob`. - 'iamcredentials.googleapis.com', ]); /** diff --git a/scripts/localess/apis.test.mjs b/scripts/localess/apis.test.mjs index a90dd836d..5c4bf56d4 100644 --- a/scripts/localess/apis.test.mjs +++ b/scripts/localess/apis.test.mjs @@ -37,10 +37,9 @@ test('REQUIRED_APIS is frozen so a caller cannot mutate the shared list', () => }); test('missingApis returns a fresh array rather than the shared constant', () => { - const before = REQUIRED_APIS.length; const missing = missingApis(null); missing.pop(); - assert.equal(REQUIRED_APIS.length, before); + assert.equal(REQUIRED_APIS.length, 15); }); test('cloudbuild.yaml enables exactly the required APIs', () => { diff --git a/scripts/localess/checks.mjs b/scripts/localess/checks.mjs index 501ac8399..04b5c5d90 100644 --- a/scripts/localess/checks.mjs +++ b/scripts/localess/checks.mjs @@ -14,7 +14,6 @@ */ import { missingApis, REQUIRED_APIS } from './apis.mjs'; import { needsBucketCors } from './bucket-cors.mjs'; -import { SIGNER_ROLE } from './url-signing.mjs'; import { MANAGED_LABEL, REGION_LABEL, VERSION_LABEL, hasMarker, markerRegion, markerVersion } from './markers.mjs'; export const OK = 'ok'; @@ -39,7 +38,6 @@ export const FIX_CORS = 'cors'; export const FIX_LABELS = 'labels'; export const FIX_LOCAL_FILES = 'localFiles'; export const FIX_INVOKERS = 'invokers'; -export const FIX_SIGNING = 'signing'; export const FIX_ADMIN_USER = 'adminUser'; /** @@ -54,15 +52,7 @@ export const FIX_ADMIN_USER = 'adminUser'; * repair sorts to index -1 and would silently run first - which the FIX_ORDER coverage test * exists to prevent. */ -export const FIX_ORDER = Object.freeze([ - FIX_APIS, - FIX_CORS, - FIX_LABELS, - FIX_LOCAL_FILES, - FIX_INVOKERS, - FIX_SIGNING, - FIX_ADMIN_USER, -]); +export const FIX_ORDER = Object.freeze([FIX_APIS, FIX_CORS, FIX_LABELS, FIX_LOCAL_FILES, FIX_INVOKERS, FIX_ADMIN_USER]); const result = (id, group, title, status, detail, { fix = null, hint = null } = {}) => ({ id, @@ -331,31 +321,6 @@ export function checkInvokers(invokers, projectId) { ); } -/** - * Whether the functions can sign Storage URLs as their runtime service account. - * - * Firebase Hosting truncates function responses at 32 MiB, so larger assets are redirected - * to a signed Storage URL - and signing without a key file needs the account to hold - * `iam.serviceAccounts.signBlob` on itself. Without it every large asset download fails. - */ -export function checkUrlSigning(signing, projectId) { - const title = 'URL signing'; - if (!signing || signing.granted === null) { - return result('signing', DEPLOYMENT, title, UNKNOWN, 'could not read the runtime service account policy'); - } - if (signing.granted === undefined) { - return result('signing', DEPLOYMENT, title, UNKNOWN, `${signing.serviceAccount} does not exist yet`, { - hint: `npm run localess:deploy -- --project ${projectId} --only functions`, - }); - } - if (signing.granted) { - return result('signing', DEPLOYMENT, title, OK, `${signing.serviceAccount} can sign`); - } - return result('signing', DEPLOYMENT, title, MISSING, `${signing.serviceAccount} lacks ${SIGNER_ROLE}; assets over 31 MiB cannot be downloaded`, { - fix: FIX_SIGNING, - }); -} - /** Email/password is the provider Localess provisions; the rest are a console decision. */ export function checkEmailProvider(identityConfig, projectId) { if (identityConfig === null || identityConfig === undefined) { @@ -443,7 +408,6 @@ export function buildReport(facts) { checkLocalFiles(facts.absentLocalFiles, facts.localFileCount), checkFunctions(facts.deployedFunctions, facts.expectedGroups, projectId), checkInvokers(facts.invokers, projectId), - checkUrlSigning(facts.signing, projectId), checkEmailProvider(facts.identityConfig, projectId), checkAdminUser(facts.accounts, facts.accountLimit, projectId), ]; diff --git a/scripts/localess/checks.test.mjs b/scripts/localess/checks.test.mjs index 64ef8e2ff..a84d529bb 100644 --- a/scripts/localess/checks.test.mjs +++ b/scripts/localess/checks.test.mjs @@ -14,7 +14,6 @@ import { FIX_LABELS, FIX_LOCAL_FILES, FIX_ORDER, - FIX_SIGNING, INFRASTRUCTURE, MISSING, OK, @@ -30,7 +29,6 @@ import { checkFunctions, checkHostingSite, checkInvokers, - checkUrlSigning, checkLabels, checkLocalFiles, checkRegionAgreement, @@ -403,7 +401,6 @@ const healthyFacts = { deployedFunctions: [{ id: 'setup', entryPoint: 'setup', callableTrigger: {} }], expectedGroups: ['setup'], invokers: [{ id: 'setup', hasInvoker: true }], - signing: { serviceAccount: '1-compute@developer.gserviceaccount.com', granted: true }, identityConfig: { signIn: { email: { enabled: true } } }, accounts: { recordsCount: 1, users: [{ email: 'a@example.com', customAttributes: '{"role":"admin"}' }] }, accountLimit: 500, @@ -414,7 +411,7 @@ const healthyFacts = { test('buildReport returns every check, grouped in install order', () => { const results = buildReport(healthyFacts); - assert.equal(results.length, 15); + assert.equal(results.length, 14); const groups = [...new Set(results.map(entry => entry.group))]; assert.deepEqual(groups, [INFRASTRUCTURE, DEPLOYMENT, APPLICATION]); }); @@ -433,7 +430,7 @@ test('a healthy project reports no problems and exits 0', () => { test('buildReport survives a project where nothing could be read', () => { const blind = { projectId: PROJECT, absentLocalFiles: [], localFileCount: 5, expectedGroups: [], regions: {} }; const results = buildReport(blind); - assert.equal(results.length, 15); + assert.equal(results.length, 14); // Nothing was established, so nothing is reported as broken. assert.equal(exitCodeFor(results), 0); }); @@ -480,32 +477,9 @@ test('FIX_ORDER covers every repair a check can ask for', () => { checkLabels({}), checkLocalFiles(['x'], 5), checkInvokers([{ id: 'setup', hasInvoker: false }], PROJECT), - checkUrlSigning({ serviceAccount: 'sa', granted: false }, PROJECT), checkAdminUser({ recordsCount: 0, users: [] }, 500, PROJECT), ].map(entry => entry.fix), ); for (const fix of everyFix) assert.ok(FIX_ORDER.includes(fix), `${fix} is absent from FIX_ORDER`); assert.equal(everyFix.size, FIX_ORDER.length); }); - -test('checkUrlSigning is ok when the runtime account can sign', () => { - assert.equal(checkUrlSigning({ serviceAccount: 'sa', granted: true }, PROJECT).status, OK); -}); - -test('checkUrlSigning offers the repair when the role is missing', () => { - const entry = checkUrlSigning({ serviceAccount: 'sa', granted: false }, PROJECT); - assert.equal(entry.status, MISSING); - assert.equal(entry.fix, FIX_SIGNING); - assert.match(entry.detail, /31 MiB/); -}); - -test('checkUrlSigning points at a functions deploy when the account does not exist yet', () => { - const entry = checkUrlSigning({ serviceAccount: 'sa', granted: undefined }, PROJECT); - assert.equal(entry.status, UNKNOWN); - assert.match(entry.hint, /--only functions/); -}); - -test('checkUrlSigning reports unknown when the policy could not be read', () => { - assert.equal(checkUrlSigning({ serviceAccount: 'sa', granted: null }, PROJECT).status, UNKNOWN); - assert.equal(checkUrlSigning(null, PROJECT).status, UNKNOWN); -}); diff --git a/scripts/localess/commands/check.mjs b/scripts/localess/commands/check.mjs index dea185a13..6db041f85 100644 --- a/scripts/localess/commands/check.mjs +++ b/scripts/localess/commands/check.mjs @@ -39,7 +39,6 @@ import { } from '../firebase-gaps.mjs'; import { ensureRequiredApis } from '../apis.mjs'; import { ensureBucketCors } from '../bucket-cors.mjs'; -import { ensureUrlSigning, readUrlSigning } from '../url-signing.mjs'; import { PASSWORD_ENV, ensureFirstAdmin } from '../admin-user.mjs'; import { buildMarkerLabels, markerRegion } from '../markers.mjs'; import { @@ -51,7 +50,6 @@ import { FIX_INVOKERS, FIX_LABELS, FIX_LOCAL_FILES, - FIX_SIGNING, INFRASTRUCTURE, MISSING, OK, @@ -140,31 +138,19 @@ async function readInvokers(projectId, deployed) { async function gather(projectId) { log.step(`Inspecting ${projectId}`); - const [ - billingEnabled, - enabledApis, - databases, - bucketName, - webApps, - hostingSites, - labels, - deployedFunctions, - identityConfig, - accounts, - signing, - ] = await Promise.all([ - attempt(isBillingEnabled(projectId)), - listEnabledApis(projectId), - attempt(cli.listFirestoreDatabases(projectId)), - attempt(getDefaultBucket(projectId), undefined), - attempt(cli.listWebApps(projectId)), - attempt(cli.listHostingSites(projectId)), - readProjectLabels(projectId), - attempt(cli.listFunctions(projectId)), - getIdentityConfig(projectId), - queryIdentityUsers(projectId, ACCOUNT_SCAN_LIMIT), - attempt(readUrlSigning(projectId)), - ]); + const [billingEnabled, enabledApis, databases, bucketName, webApps, hostingSites, labels, deployedFunctions, identityConfig, accounts] = + await Promise.all([ + attempt(isBillingEnabled(projectId)), + listEnabledApis(projectId), + attempt(cli.listFirestoreDatabases(projectId)), + attempt(getDefaultBucket(projectId), undefined), + attempt(cli.listWebApps(projectId)), + attempt(cli.listHostingSites(projectId)), + readProjectLabels(projectId), + attempt(cli.listFunctions(projectId)), + getIdentityConfig(projectId), + queryIdentityUsers(projectId, ACCOUNT_SCAN_LIMIT), + ]); const [bucketCors, invokers, live] = await Promise.all([ bucketName ? getBucketCors(bucketName) : null, @@ -190,7 +176,6 @@ async function gather(projectId) { deployedFunctions, expectedGroups, invokers, - signing, identityConfig, accounts, accountLimit: ACCOUNT_SCAN_LIMIT, @@ -311,10 +296,6 @@ async function applyFix(fix, facts, options) { return; } - case FIX_SIGNING: - await ensureUrlSigning(projectId, log); - return; - case FIX_ADMIN_USER: // Talks to the Identity Platform API directly, so this works even on a project whose // functions failed to deploy - which is exactly the project this command diagnoses. diff --git a/scripts/localess/commands/deploy.mjs b/scripts/localess/commands/deploy.mjs index 26f69438c..1e91d418e 100644 --- a/scripts/localess/commands/deploy.mjs +++ b/scripts/localess/commands/deploy.mjs @@ -21,7 +21,6 @@ import { buildMarkerLabels } from '../markers.mjs'; import { mergeProjectLabels } from '../firebase-gaps.mjs'; import { ensureRequiredApis } from '../apis.mjs'; import { ensureBucketCors } from '../bucket-cors.mjs'; -import { ensureUrlSigning } from '../url-signing.mjs'; import { assertManaged, identifyProject } from '../projects.mjs'; import { confirmDeployPlan } from '../prompts.mjs'; import { ROOT, createLogger, rel } from '../log.mjs'; @@ -329,10 +328,6 @@ export async function run(argv) { log.step('Deploying'); await deployWithRetry(deployArgs, env); - // After the deploy, because the runtime service account only exists once functions have - // been deployed. Best-effort: it gates large asset downloads, not the deploy. - if (deployingFunctions) await ensureUrlSigning(projectId, log); - log.step('Refreshing the version marker'); try { await mergeProjectLabels(projectId, buildMarkerLabels(VERSION, region)); diff --git a/scripts/localess/firebase-gaps.mjs b/scripts/localess/firebase-gaps.mjs index 55392fb60..0a3f47a40 100644 --- a/scripts/localess/firebase-gaps.mjs +++ b/scripts/localess/firebase-gaps.mjs @@ -97,12 +97,6 @@ async function init() { urlPrefix: 'https://identitytoolkit.googleapis.com', apiVersion: 'admin/v2', }), - // Service-account IAM policies. The function runtime signs Storage URLs as itself, which - // needs a binding on the service account resource - not on the project. - iam: new Client({ - urlPrefix: 'https://iam.googleapis.com', - apiVersion: 'v1', - }), // Document writes. The Admin SDK is not available here, so the bootstrap writes its // documents through the same authenticated client everything else uses. firestore: new Client({ @@ -321,63 +315,6 @@ export async function readProjectLabels(projectId) { } } -/** - * The project number, or `null` when the project cannot be read. Needed to name the default - * compute service account, which is keyed by number rather than id. - */ -export async function getProjectNumber(projectId) { - try { - const { resourceManager } = await api(); - const response = await resourceManager.get(`/projects/${projectId}`); - return response.body?.name?.split('/')[1] ?? null; - } catch { - return null; - } -} - -/** - * The IAM policy *of* a service account (who may act as it), `undefined` when the account - * does not exist, or `null` when the policy cannot be read. - * - * Absent is its own answer because the default compute service account only appears once - * the Compute API is on, which the first functions deploy does - so before that there is - * nothing to grant on, rather than something wrong. - */ -export async function getServiceAccountIamPolicy(projectId, email) { - try { - const { iam } = await api(); - const res = await iam.post(`/projects/${projectId}/serviceAccounts/${email}:getIamPolicy`, {}, { resolveOnHTTPError: true }); - if (res.status === 404) return undefined; - if (res.status >= 400) return null; - return res.body ?? {}; - } catch { - return null; - } -} - -/** Adds `member` to `role` on a service account, preserving every other binding. */ -export async function addServiceAccountBinding(projectId, email, role, member) { - const { iam } = await api(); - const policy = await getServiceAccountIamPolicy(projectId, email); - if (!policy) throw new Error(`Could not read the IAM policy of ${email}`); - - const bindings = policy.bindings ?? []; - const existing = bindings.find(binding => binding.role === role); - const merged = existing - ? bindings.map(binding => - binding === existing ? { ...binding, members: [...new Set([...(binding.members ?? []), member])] } : binding, - ) - : [...bindings, { role, members: [member] }]; - - const res = await iam.post( - `/projects/${projectId}/serviceAccounts/${email}:setIamPolicy`, - { policy: { ...policy, bindings: merged } }, - { resolveOnHTTPError: true }, - ); - if (res.status >= 400) throw fail(`Could not grant ${role} on ${email}`, res); - return res.body; -} - /** * Merges labels into a project, leaving every other label untouched. * diff --git a/scripts/localess/url-signing.mjs b/scripts/localess/url-signing.mjs deleted file mode 100644 index 02a7d836b..000000000 --- a/scripts/localess/url-signing.mjs +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Lets the functions sign Storage URLs. - * - * Firebase Hosting cuts a rewritten function response off at 32 MiB, so the asset API - * redirects anything larger to a short-lived signed Storage URL (functions/src/utils/ - * stream-object.ts). The functions have no key file, so they sign through the IAM - * Credentials `signBlob` API as their own runtime service account - which needs - * `iam.serviceAccounts.signBlob` *on itself*. Nothing grants that by default, and without it - * every large asset download fails. - * - * Applied after each functions deploy and checked by `localess:check`, like the invoker - * bindings: the account only exists once functions have been deployed. - */ -import { addServiceAccountBinding, getProjectNumber, getServiceAccountIamPolicy } from './firebase-gaps.mjs'; - -/** The narrowest predefined role carrying `iam.serviceAccounts.signBlob`. */ -export const SIGNER_ROLE = 'roles/iam.serviceAccountTokenCreator'; - -/** Gen-2 functions run as the default compute service account unless told otherwise. */ -export function runtimeServiceAccount(projectNumber) { - return `${projectNumber}-compute@developer.gserviceaccount.com`; -} - -/** Whether `policy` lets `email` sign as itself. */ -export function canSign(policy, email) { - return (policy?.bindings ?? []).some( - binding => binding.role === SIGNER_ROLE && (binding.members ?? []).includes(`serviceAccount:${email}`), - ); -} - -/** - * The signing state: `{ serviceAccount, granted }`, where `granted` is `true`, `false`, or - * `null` when it could not be read. `serviceAccount` is `null` when the project number is - * unreadable, and `granted` is `undefined` when the account does not exist yet. - */ -export async function readUrlSigning(projectId) { - const projectNumber = await getProjectNumber(projectId); - if (!projectNumber) return { serviceAccount: null, granted: null }; - - const serviceAccount = runtimeServiceAccount(projectNumber); - const policy = await getServiceAccountIamPolicy(projectId, serviceAccount); - if (policy === undefined) return { serviceAccount, granted: undefined }; - if (policy === null) return { serviceAccount, granted: null }; - return { serviceAccount, granted: canSign(policy, serviceAccount) }; -} - -/** - * Grants the runtime service account the right to sign as itself, when it lacks it. - * - * Best-effort, like the bucket CORS: it needs `iam.serviceAccounts.setIamPolicy`, and an - * operator without it should still get a deploy. Only large asset downloads depend on it, - * and `localess:check` keeps reporting the gap until it is closed. - */ -export async function ensureUrlSigning(projectId, log) { - log.step('Checking that the functions can sign Storage URLs'); - - const { serviceAccount, granted } = await readUrlSigning(projectId); - if (granted === true) { - log.skip(`${serviceAccount} already holds ${SIGNER_ROLE} on itself`); - return; - } - if (granted === undefined) { - log.skip(`${serviceAccount} does not exist yet; it is created by the first functions deploy`); - return; - } - if (granted === null) { - log.warn(`could not read the IAM policy of ${serviceAccount ?? 'the runtime service account'}`); - return; - } - - try { - await addServiceAccountBinding(projectId, serviceAccount, SIGNER_ROLE, `serviceAccount:${serviceAccount}`); - log.done(`${serviceAccount} can now sign Storage URLs`); - } catch (error) { - log.warn(`could not grant ${SIGNER_ROLE} on ${serviceAccount} (${error.message})`); - log.warn('assets larger than 31 MiB will fail to download until it is granted'); - } -} diff --git a/scripts/localess/url-signing.test.mjs b/scripts/localess/url-signing.test.mjs deleted file mode 100644 index 2b4952891..000000000 --- a/scripts/localess/url-signing.test.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; - -import { SIGNER_ROLE, canSign, runtimeServiceAccount } from './url-signing.mjs'; - -const SA = runtimeServiceAccount('123456'); - -test('runtimeServiceAccount names the default compute service account', () => { - assert.equal(SA, '123456-compute@developer.gserviceaccount.com'); -}); - -test('canSign is true when the account holds the signer role on itself', () => { - assert.equal(canSign({ bindings: [{ role: SIGNER_ROLE, members: [`serviceAccount:${SA}`] }] }, SA), true); -}); - -test('canSign is false when the role is held by someone else', () => { - const policy = { bindings: [{ role: SIGNER_ROLE, members: ['serviceAccount:other@example.iam.gserviceaccount.com'] }] }; - assert.equal(canSign(policy, SA), false); -}); - -test('canSign is false when the account holds a different role', () => { - assert.equal(canSign({ bindings: [{ role: 'roles/iam.serviceAccountUser', members: [`serviceAccount:${SA}`] }] }, SA), false); -}); - -test('canSign is false for an empty policy', () => { - assert.equal(canSign({}, SA), false); - assert.equal(canSign(undefined, SA), false); -}); From 8448f04ca34879282516c8ca1b9c2e7b5fedf5a1 Mon Sep 17 00:00:00 2001 From: alexcibotari Date: Mon, 26 Oct 2026 10:43:08 +0100 Subject: [PATCH 2/2] fix: refuse assets over 31 MiB with 413 instead of a truncated 200 Problem: Firebase Hosting cuts rewritten function responses off at 32 MiB but still answers 200 with a matching Content-Length. With the streaming passthrough, a 104 MiB .mov arrived as its first 33,553,834 bytes and looked complete, so the self-hosted import (which only rejects non-200 assets) would store a corrupt copy. Fix: streamStoredObject refuses objects above MAX_SERVED_BYTES (31 MiB) with an uncached 413 and a plain-text explanation, before reading from Storage. Applies to /original, /download and bare /assets/:id on passthrough types. Smaller objects keep streaming with Range support and now always carry Content-Length. Documented as a known issue in docs/v1-functions-api.md (Known limitations), docs/deployment/overview.md (Known issues) and the cdn-caching TTL table, including why the signed-URL fix was reverted. --- docs/cdn-caching.md | 1 + docs/deployment/overview.md | 12 +++++++++ docs/v1-functions-api.md | 10 +++++++- functions/src/utils/stream-object.ts | 37 +++++++++++++++++++--------- functions/src/v1/cdn-assets.test.ts | 27 +++++++++++++++++++- functions/src/v1/cdn.ts | 9 +++---- 6 files changed, 77 insertions(+), 19 deletions(-) diff --git a/docs/cdn-caching.md b/docs/cdn-caching.md index e4afc3810..253ee7985 100644 --- a/docs/cdn-caching.md +++ b/docs/cdn-caching.md @@ -37,6 +37,7 @@ Whenever the marker is rewritten, its generation increments. The content marker | Asset response (incl. `/original`, `/download`) | 365 days | 365 days | browser / CDN | | Asset `304 Not Modified` | 365 days | 365 days | browser / CDN | | Asset canonical-size redirect (`w`/`h` above source) | 365 days | 365 days | browser / CDN | +| `413` — asset over 31 MiB (known limitation, see [v1-functions-api.md](v1-functions-api.md#known-limitations)) | no-cache | no-cache | — | | 400 — invalid asset transform param | 1 hour | 1 hour | browser / CDN | | 404 — space not found | 7 days | 7 days | browser / CDN | | 404 — content cache marker missing / content not found on disk (content routes only) | 10 min | 10 min | browser / CDN | diff --git a/docs/deployment/overview.md b/docs/deployment/overview.md index 8980f28f7..a80738f75 100644 --- a/docs/deployment/overview.md +++ b/docs/deployment/overview.md @@ -89,6 +89,18 @@ you do not. --- +## Known issues + +- **Assets over 31 MiB cannot be downloaded through the API.** Firebase Hosting truncates function + responses at 32 MiB while still reporting success, so the asset API refuses larger objects with + `413` rather than serve a corrupt file. Uploads are unaffected and the file stays intact in + Storage. A self-hosted import from such an install skips those assets with an error — copy them + from the bucket by hand (`gsutil cp gs:///spaces//assets//original .`). + Lifting the limit needs a signed-URL redirect, which needs a manual IAM grant per install; see + [Known limitations](../v1-functions-api.md#known-limitations). + +--- + ## Two deployment models This documentation covers **local deploy**: an operator runs the commands from a checkout on their diff --git a/docs/v1-functions-api.md b/docs/v1-functions-api.md index 30d0b7edb..722c21b05 100644 --- a/docs/v1-functions-api.md +++ b/docs/v1-functions-api.md @@ -37,9 +37,17 @@ Content delivery with cache-busting and asset transformation. All content/transl - **`thumbnail` param** — Collapses an animated WebP/GIF to its first frame, and extracts a video frame via FFmpeg (requires `w`). Has no effect on other image types. Since animations now resize with every frame intact, `thumbnail` is how you ask for a *still* rather than how you make resizing work. - **EXIF orientation is applied** — Sharp strips the orientation tag on re-encode, so a rotated source is baked into the pixels. Without it a portrait phone photo returned landscape with its aspect ratio transposed. - **Embedded colour profiles are carried through** — via `keepIccProfile()`. Sources without one gain nothing; tagging every response as sRGB would add ~506 bytes each. -- **`/original` and `/download`** — serve the stored bytes exactly as uploaded, inline and as an `attachment` respectively. Since a bare transform request re-encodes, these are the only way to retrieve the original file. Neither enters Sharp, and both reject `w`/`h`/`q`/`f`/`fit`/`thumbnail`/`download` with `400` rather than ignoring them. +- **`/original` and `/download`** — serve the stored bytes exactly as uploaded, inline and as an `attachment` respectively. Since a bare transform request re-encodes, these are the only way to retrieve the original file. Neither enters Sharp, and both reject `w`/`h`/`q`/`f`/`fit`/`thumbnail`/`download` with `400` rather than ignoring them. Both stream from Storage with `Range` support; objects over 31 MiB are refused with `413` — see [Known limitations](#known-limitations). - **Removed in v4** — the `?download` flag and `f=original`. Both return `400` with a message naming the replacement route. Responses already cached under the old spellings keep serving for the remainder of their 365-day TTL. +#### Known limitations + +- **Assets over 31 MiB cannot be served (`413`).** Firebase Hosting buffers every rewritten function response and cuts it off at 32 MiB — while still answering `200` with a matching `Content-Length`, so the client believes the transfer completed. Observed on a 104 MiB `.mov`: through `*.web.app` it arrived as its first 33,553,834 bytes; called directly on Cloud Run the function sent all 108,726,933. Streaming does not help — chunked encoding gets past Cloud Run's own 32 MiB limit, not Hosting's. + + So the API **refuses** such objects rather than send a truncated body: `/original`, `/download`, and a bare `/assets/:assetId` on a passthrough type (video, audio, PDF) answer `413` with `Cache-Control: no-cache` and a plain-text explanation. Clients must treat any non-`200` as a failure — the self-hosted Firebase import does, and skips the asset instead of storing a broken copy. Uploading still works; the file is intact in Storage and can be fetched from the Firebase console or `gsutil`. + + The fix is a `302` to a short-lived signed Storage URL. It was implemented and reverted, because signing without a key file needs the functions' runtime service account to hold `roles/iam.serviceAccountTokenCreator` **on itself**, and that grant needs an account with IAM admin rights — Cloud Build's deploy account cannot apply it, so it would be a manual step on every install. Limit: `MAX_SERVED_BYTES` in `functions/src/utils/stream-object.ts`. + #### Asset resize combinations (`w` / `h`) Sharp is called as `resize(width ?? null, height ?? null, { fit })`, defaulting to `cover` when `fit` is not given. diff --git a/functions/src/utils/stream-object.ts b/functions/src/utils/stream-object.ts index 01b5aecf2..d8dce462a 100644 --- a/functions/src/utils/stream-object.ts +++ b/functions/src/utils/stream-object.ts @@ -3,13 +3,14 @@ import { logger } from 'firebase-functions'; import type { Readable } from 'stream'; /** - * Cloud Run rejects a response larger than 32 MiB with a bare `500` unless it is streamed with - * chunked transfer encoding — and a response carrying `Content-Length` is not streamed in that - * sense. Bodies at or under this size keep their `Content-Length` (so clients show progress); - * anything larger is sent without one, which is what makes Node fall back to chunked encoding. - * Kept a little under the hard limit to leave room for the headers. + * Largest object this API will serve. Known issue: Firebase Hosting buffers a rewritten function + * response and cuts it off at 32 MiB while still answering `200` with a matching + * `Content-Length`, so a larger asset would arrive silently truncated — and a client such as the + * self-hosted import would store the broken copy as if it were complete. Anything bigger is + * refused outright instead (see docs/v1-functions-api.md, "Known limitations"). Kept a little + * under the limit to leave room for the headers. */ -export const MAX_CONTENT_LENGTH_BYTES = 31 * 1024 * 1024; +export const MAX_SERVED_BYTES = 31 * 1024 * 1024; export type ByteRange = { start: number; end: number }; @@ -53,9 +54,8 @@ export interface StreamableFile { * Streams a stored object to the response, honouring a single `Range`. Headers other than * length and range (type, disposition, cache) must already be set by the caller. * - * Replaces `download()` to `/tmp` + `sendFile`: on Cloud Functions `/tmp` is RAM, so that held - * the whole object in memory, and `sendFile` always sets `Content-Length` — which is what made - * every object over 32 MiB fail. + * Replaces `download()` to `/tmp` + `sendFile`, which held the whole object in memory: `/tmp` is + * RAM on Cloud Functions. Objects above {@link MAX_SERVED_BYTES} are refused with `413`. * @param {Request} req Incoming request, read for `Range` and the method * @param {Response} res Response to stream into * @param {StreamableFile} file Storage object to read @@ -63,6 +63,21 @@ export interface StreamableFile { * @param {string} tag Log prefix of the calling route */ export function streamStoredObject(req: Request, res: Response, file: StreamableFile, size: number, tag: string): void { + if (size > MAX_SERVED_BYTES) { + // Refused with an explicit status rather than streamed: Hosting would truncate the body and + // still report success. Uncached, so lifting the limit later takes effect immediately. + logger.warn(`${tag} refused a ${size}-byte object: above the ${MAX_SERVED_BYTES}-byte limit Hosting can serve`); + res.removeHeader('Content-Disposition'); + res + .status(413) + .header('Cache-Control', 'no-cache') + .contentType('text/plain') + .send( + `This asset is ${size} bytes, above the ${MAX_SERVED_BYTES}-byte limit the API can serve through Firebase Hosting. ` + + 'Known issue: see "Known limitations" in docs/v1-functions-api.md.' + ); + return; + } res.header('Accept-Ranges', 'bytes'); const range = parseByteRange(req.headers.range, size); if (range === 'unsatisfiable') { @@ -74,9 +89,7 @@ export function streamStoredObject(req: Request, res: Response, file: Streamable if (range) { res.status(206).header('Content-Range', `bytes ${start}-${end}/${size}`); } - if (length <= MAX_CONTENT_LENGTH_BYTES) { - res.header('Content-Length', String(length)); - } + res.header('Content-Length', String(length)); if (req.method === 'HEAD' || length === 0) { res.end(); return; diff --git a/functions/src/v1/cdn-assets.test.ts b/functions/src/v1/cdn-assets.test.ts index cc56e872e..8516eb175 100644 --- a/functions/src/v1/cdn-assets.test.ts +++ b/functions/src/v1/cdn-assets.test.ts @@ -19,6 +19,8 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; * The env has to be set before the module graph loads, which is why the imports are dynamic. */ const MD5 = 'abc123=='; +/** Past what Firebase Hosting can carry without truncating it. */ +const LARGE = 40 * 1024 * 1024; let CDN: express.Router; let bucket: { file: (path: string) => unknown }; @@ -82,13 +84,15 @@ interface StoredAsset { storageMissing?: boolean; /** Simulates a deleted asset still referenced by published content. */ documentMissing?: boolean; + /** Overrides the stored object's size, to exercise the large-object path without the bytes. */ + size?: number; } function given(asset: StoredAsset): { download: ReturnType; createReadStream: ReturnType } { const file = { getMetadata: asset.storageMissing ? vi.fn().mockRejectedValue(new Error('not found')) - : vi.fn().mockResolvedValue([{ md5Hash: MD5, size: String(asset.bytes.length) }]), + : vi.fn().mockResolvedValue([{ md5Hash: MD5, size: String(asset.size ?? asset.bytes.length) }]), createReadStream: vi .fn() .mockImplementation((options?: { start?: number; end?: number }) => @@ -188,6 +192,27 @@ describe('asset routes — the passthrough pair', () => { expect(res.headers['content-range']).toBe('bytes */10'); }); + it.each([['/download'], ['/original']])('%s refuses an object too large for Hosting with an explicit 413', async route => { + const file = given({ bytes: Buffer.from('x'), size: LARGE, type: 'video/quicktime', extension: '.mov' }); + + const res = await request(app()).get(`${URL}${route}`); + + // Never a truncated 200: the import only rejects an asset whose status is not 200. + expect(res.status).toBe(413); + expect(res.headers['cache-control']).toBe('no-cache'); + expect(res.headers['content-disposition']).toBeUndefined(); + expect(res.text).toContain('Known limitations'); + expect(file.createReadStream).not.toHaveBeenCalled(); + }); + + it('a bare transform-route URL on a large video is refused too', async () => { + given({ bytes: Buffer.from('x'), size: LARGE, type: 'video/mp4', extension: '.mp4' }); + + const res = await request(app()).get(URL); + + expect(res.status).toBe(413); + }); + it('a bare transform-route URL on a video streams the stored bytes', async () => { const bytes = Buffer.from('video bytes'); const file = given({ bytes, type: 'video/mp4', extension: '.mp4' }); diff --git a/functions/src/v1/cdn.ts b/functions/src/v1/cdn.ts index 4815928f4..b55d22083 100644 --- a/functions/src/v1/cdn.ts +++ b/functions/src/v1/cdn.ts @@ -726,7 +726,7 @@ CDN.get('/api/v1/spaces/:spaceId/assets/:assetId', async (req, res) => { filename = `${asset.name}-${suffix || `w${width}`}-thumbnail${format ? formatExtMap[format] : '.webp'}`; } else { // Video, audio, PDF and the rest pass through untouched, so they are streamed rather than - // staged in `/tmp` — see the `/download` route for why that matters past 32 MiB. + // staged in `/tmp`; past what Hosting can carry they are refused — see `streamStoredObject`. streamed = true; } const policy = assetResponsePolicy(overwriteType || asset.type, false); @@ -836,10 +836,9 @@ CDN.get(['/api/v1/spaces/:spaceId/assets/:assetId/original', '/api/v1/spaces/:sp } } - // Streamed straight from Storage rather than downloaded to `/tmp` and sent with `sendFile`: - // `/tmp` is RAM on Cloud Functions, and `sendFile` always sets `Content-Length`, which makes - // Cloud Run reject anything over 32 MiB — a 100 MB video failed with a bare `500`. The stream - // honours `Range` itself, so a client can still resume a partial transfer. + // Streamed straight from Storage rather than staged in `/tmp`, which is RAM on Cloud Functions. + // The stream honours `Range` itself, so a client can still resume a partial transfer. Objects + // past what Firebase Hosting can carry are refused with `413` — see `streamStoredObject`. const policy = assetResponsePolicy(asset.type, attachment); res .header('Cache-Control', `public, max-age=${CACHE_ASSET_MAX_AGE}, s-maxage=${CACHE_ASSET_MAX_AGE}`)