Skip to content

fix(thumbnails): generate WebP thumbnails as frontend now expects#6733

Open
jaskfla wants to merge 25 commits intorelease-2026-16from
rn-1869-issue-3
Open

fix(thumbnails): generate WebP thumbnails as frontend now expects#6733
jaskfla wants to merge 25 commits intorelease-2026-16from
rn-1869-issue-3

Conversation

@jaskfla
Copy link
Copy Markdown
Contributor

@jaskfla jaskfla commented Apr 23, 2026

RN-1869 issue 3

Manual release steps

  1. npm install --include=optional --omit=dev --os=cpu --cpu=arm64
  2. ZIP relevant code
  3. Update thumbnail creation Lambda runtime to Node 24
  4. Change Lambda architecture to arm64
  5. Upload code to Lambda
  6. Verity handler is configured to CreateThumbnail.handler
  7. Update S3 trigger to require .webp suffix

Changes

  • Requires Node 24, previously Node 16 (deprecated)
  • Retires callbacks in favour of async–await syntax (removing dependency on async)
  • Replaces jimp and png-to-jpeg with sharp
  • Removes dependency on file-type, directly attempting to decode with sharp
  • Adds missing dependencies
  • Tested new Lambda with arm64 architecture instead of x86 (cheaper!)
  • Bit more logging
INFO	📥 Fetched tupaia/dev_uploads/images/1776926771701_700898_.webp in 1,105 ms
INFO	🧩 Decoded as 3,000 × 1,636 webp (181,452 B) in 12 ms
INFO	🗜️ Compressed to 500 × 273 webp (11,536 B) in 2,000 ms
INFO	📤 Uploaded to tupaia/thumbnails/dev_uploads/images/1776926771701_700898_.webp in 204 ms
INFO	🏁 Done in 3,901 ms

Note

Technically this is a bit non-ideal now that S3Client compresses the images before uploading to S3. So this Lambda is actually compressing an already-compressed image.

Not enough of an issue to block this PR, though.

🦸 Review Hero

  • Run Review Hero
  • Auto-fix review suggestions
  • Auto-fix CI failures

@review-hero
Copy link
Copy Markdown

review-hero Bot commented Apr 23, 2026

🦸 Review Hero (could not post inline comments — showing here instead)

packages/tupaia-web/src/features/Dashboard/Photo.tsx:36

[Bugs & Correctness] critical

The src props for the small button and the enlarged dialog are swapped. <Image src={photoUrl}> (the full-size original) is used for the small thumbnail button, while <img src={thumbUrl}> (the thumbnail) is used for the enlarged dialog. This means clicking 'Expand image' shows the lower-resolution thumbnail, and the small button always loads the full-size original (performance regression). Fix: use thumbUrl for <Image> and photoUrl for the dialog <img>.


packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:46

[Bugs & Correctness] suggestion

fileType at version ^7.2.0 uses only magic-byte detection and cannot identify SVG files (they are plain XML with no magic bytes). If an SVG is uploaded, fileType() returns undefined and the handler throws 'Could not determine the image type' rather than reaching the SUPPORTED_IMAGE_MIMES check. Additionally, sharp SVG rasterisation in a Lambda environment requires libvips to be compiled with librsvg support, which may not be present. Consider either removing image/svg+xml from SUPPORTED_IMAGE_MIMES or upgrading file-type to a version that handles SVGs, and verifying the Lambda layer includes librsvg.


packages/tupaia-web/src/features/Dashboard/Photo.tsx:12

[Bugs & Correctness] critical

getOrgUnitPhotoUrl still applies .replace('.png', '.jpg') (line 12) but the Lambda now generates .webp thumbnails for all input types. Every thumbnail URL will be broken: PNG uploads get a .jpg URL that no longer exists, JPG/TIFF/AVIF uploads keep their original extension, none matching the .webp file the Lambda actually wrote. The fix is to replace any extension with .webp, e.g. photoUrl.replace(/\.[^.]+$/, '.webp').replace(/${dir}/, 'thumbnails/${dir}') (or restructure similarly).


packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:46

[Performance] suggestion

new Uint8Array(response.Body) creates a full in-memory copy of the downloaded image buffer solely to detect the MIME type. sharp already validates the format internally and throws a descriptive error on unsupported or corrupt input, so this copy is redundant. For a 5 MB source image this doubles peak Lambda memory at the exact moment both buffers are live. Pass response.Body directly to sharp() and catch its error if you want to report a friendly message; remove the fileType call and its dependency entirely.

@review-hero
Copy link
Copy Markdown

review-hero Bot commented Apr 23, 2026

🦸 Review Hero Summary
9 agents reviewed this PR | 2 critical | 2 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 4 below threshold

Below consensus threshold (4 unique issues not confirmed by majority)
Location Agent Severity Comment
packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:11 Security suggestion SVG is added to SUPPORTED_IMAGE_MIMES and passed directly to sharp(), which uses librsvg internally. librsvg can follow external resource references embedded in SVG files (e.g. <image href="http://...
packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:36 Security suggestion srcKey is derived directly from the S3 event payload with no code-level validation that it starts with 'uploads/' or 'dev_uploads/'. The comment acknowledges this prefix is required, but safety is ...
packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:46 Bugs & Correctness suggestion response.Body from AWS SDK v2 getObject is typed as S3.Body | undefined. Both new Uint8Array(response.Body) (line 46) and sharp(response.Body) (line 52) will throw a confusing TypeError i...
packages/tupaia-web/src/features/Dashboard/Photo.tsx:39 Security suggestion The button now renders the raw photoUrl without passing through getOrgUnitPhotoUrl(). While getOrgUnitPhotoUrl performs no security sanitization (it is pure string manipulation), photoUrl c...
Local fix prompt (copy to your coding agent)

Fix these issues identified on the pull request. One commit per issue fixed.


packages/tupaia-web/src/features/Dashboard/Photo.tsx:36: The src props for the small button and the enlarged dialog are swapped. &lt;Image src={photoUrl}> (the full-size original) is used for the small thumbnail button, while &lt;img src={thumbUrl}> (the thumbnail) is used for the enlarged dialog. This means clicking 'Expand image' shows the lower-resolution thumbnail, and the small button always loads the full-size original (performance regression). Fix: use thumbUrl for &lt;Image> and photoUrl for the dialog &lt;img>.


packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:46: fileType at version ^7.2.0 uses only magic-byte detection and cannot identify SVG files (they are plain XML with no magic bytes). If an SVG is uploaded, fileType() returns undefined and the handler throws 'Could not determine the image type' rather than reaching the SUPPORTED_IMAGE_MIMES check. Additionally, sharp SVG rasterisation in a Lambda environment requires libvips to be compiled with librsvg support, which may not be present. Consider either removing image/svg+xml from SUPPORTED_IMAGE_MIMES or upgrading file-type to a version that handles SVGs, and verifying the Lambda layer includes librsvg.


packages/tupaia-web/src/features/Dashboard/Photo.tsx:12: getOrgUnitPhotoUrl still applies .replace('.png', '.jpg') (line 12) but the Lambda now generates .webp thumbnails for all input types. Every thumbnail URL will be broken: PNG uploads get a .jpg URL that no longer exists, JPG/TIFF/AVIF uploads keep their original extension, none matching the .webp file the Lambda actually wrote. The fix is to replace any extension with .webp, e.g. photoUrl.replace(/\.[^.]+$/, '.webp').replace(/${dir}/, 'thumbnails/${dir}') (or restructure similarly).


packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:46: new Uint8Array(response.Body) creates a full in-memory copy of the downloaded image buffer solely to detect the MIME type. sharp already validates the format internally and throws a descriptive error on unsupported or corrupt input, so this copy is redundant. For a 5 MB source image this doubles peak Lambda memory at the exact moment both buffers are live. Pass response.Body directly to sharp() and catch its error if you want to report a friendly message; remove the fileType call and its dependency entirely.

Comment thread packages/tupaia-web/src/features/Dashboard/Photo.tsx Outdated
Comment thread packages/tupaia-web/src/features/Dashboard/Photo.tsx
Comment thread packages/server-utils/src/s3/constants.ts Outdated
Comment thread packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs Outdated
Comment thread packages/central-server/src/devops/CreateThumbnail/package-lock.json Outdated
Comment thread packages/tupaia-web/src/features/Dashboard/Photo.tsx
Comment thread packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs Outdated
Comment thread packages/tupaia-web/src/features/Dashboard/Photo.tsx
Comment thread packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs Outdated
@review-hero
Copy link
Copy Markdown

review-hero Bot commented Apr 23, 2026

🦸 Review Hero Summary
9 agents reviewed this PR | 2 critical | 2 suggestions | 1 nitpick | Filtering: consensus 3 voters, 10 below threshold

Below consensus threshold (10 unique issues not confirmed by majority)
Location Agent Severity Comment
packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:13 Performance nitpick performance.getEntriesByName(performanceEntryName) allocates a new array on every call, and logWithTiming is called 4–5 times per invocation. On warm-start Lambda instances processing many imag...
packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:27 Security suggestion srcBucket is taken verbatim from the untrusted event payload. If the Lambda's IAM role (image-resize) has S3 permissions broader than the application bucket, a crafted direct invocation could poi...
packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:31 Security suggestion srcKey is accepted from the event without validating it starts with the expected uploads/ or dev_uploads/ prefix. The comment acknowledges this is enforced only by the Lambda trigger configurat...
packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:32 Bugs & Correctness suggestion The SVG early-return path (line 35) exits before the try/finally block, so performance.clearMarks() / clearMeasures() never run for SVG invocations. On a warm Lambda container that receives a...
packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:33 Security suggestion The SVG-skip guard (srcKey.endsWith('.svg')) relies solely on the file extension of the S3 key. An S3 object with a .svg key is skipped entirely and will never have a thumbnail generated, but t...
packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:48 Performance suggestion The entire source image is downloaded into a single in-memory Buffer before any size check occurs. A malicious or accidental multi-hundred-MB upload will fully load into Lambda memory before sharp ...
packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:49 Security suggestion The dstKey is derived by stripping the extension from srcKey and appending .webp. If S3 allows keys containing path-separator-like sequences (e.g. a filename like ../../config/secret), the ...
packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:62 Performance suggestion At peak, both response.Body (the full original image buffer from S3) and outputBuffer (the encoded WebP) are live in memory simultaneously until putObject resolves. For large source images (e...
packages/tupaia-web/src/features/Dashboard/Photo.tsx:19 Security suggestion The SVG bypass check photoUrl.endsWith('.svg') is case-sensitive. A URL ending in .SVG (uppercase) would fall through to the thumbnail path transform and produce a broken thumbnail URL, silentl...
packages/tupaia-web/src/features/Dashboard/Photo.tsx:38 Security suggestion The enlarged dialog now uses the raw photoUrl from the API rather than the processed thumbnail URL. The getOrgUnitPhotoUrl transformation previously guaranteed the URL pointed into the controll...

Nitpicks

File Line Agent Comment
packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs 54 Security util.inspect(event, { depth: 5 }) logs the full S3 event record including bucket name and object key to CloudWatch. Bucket names and key prefixes are operational data that should not be in uncontrolled logs; prefer logging only the fields actually needed (bucket name + key) rather than the enti...
Local fix prompt (copy to your coding agent)

Fix these issues identified on the pull request. One commit per issue fixed.


packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:57: width and height from sharp().metadata() are typed number | undefined — they can be undefined for vector formats or corrupted files. The template literal calls width.toLocaleString() and height.toLocaleString() without optional chaining, which throws TypeError: Cannot read properties of undefined. Notice size on the same line correctly uses size?.toLocaleString(). Since this line is inside the try block, the throw propagates to the catch, causing the Lambda to fail and re-throw without ever attempting the resize. Fix: ${width?.toLocaleString()} × ${height?.toLocaleString()}.


packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:11: Intl.DurationFormat was added to V8 in 13.4 (Node.js 24). AWS Lambda currently supports Node.js 18, 20, and 22 — all of which lack this API. Because the call is at module initialisation (not inside the handler), every invocation on Node ≤ 22 will fail with 'TypeError: Intl.DurationFormat is not a constructor' before the handler is ever invoked. Fix: replace with a simple helper that formats the duration manually (e.g. ${ms}ms), or guard with typeof Intl.DurationFormat !== 'undefined'.


packages/tupaia-web/src/features/Dashboard/Photo.tsx:18: getOrgUnitPhotoUrl handles SVG, legacy PNG (→.jpg), and new WebP correctly, but .jpeg uploads from the legacy era are passed through with their original extension intact — the old Lambda kept them as .jpeg thumbnails. If any legacy thumbnail was stored as .jpeg (not .jpg), the URL constructed here won't match the actual S3 key and the thumbnail will 404. Consider adding a .replace(/.jpeg$/, '.jpeg') no-op comment, or explicitly document that .jpeg was never a possible upload extension, so readers know this case was considered.


packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:54: Calling decoded.metadata() before the transform pipeline causes sharp to parse/open the image twice — once for metadata, once for the actual resize+encode. The metadata (format, width, height, size) is used only for logging, not for any processing decision. For large source images this adds measurable latency. Either drop the metadata log entirely, or extract stats from the output buffer after .toBuffer() (sharp's toBuffer({ resolveWithObject: true }) returns { data, info } where info contains width/height/format/size of the output, which is still useful for the log).


packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs:54: util.inspect(event, { depth: 5 }) logs the full S3 event record including bucket name and object key to CloudWatch. Bucket names and key prefixes are operational data that should not be in uncontrolled logs; prefer logging only the fields actually needed (bucket name + key) rather than the entire event, which could include additional metadata added in the future.

Comment thread packages/central-server/src/devops/CreateThumbnail/CreateThumbnail.mjs Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 772a834. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant