Skip to content
Merged
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
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,15 @@ For more details see the [Usage](docs/Usage.md) documentation.

The following environment variables are used by the image-handler:

| Name | Description |
|---------------------------|-------------------------------------------------|
| `AUTO_WEBP` | Flag if the AUTO WEBP feature should be enabled |
| `CORS_ENABLED` | Flag if CORS should be enabled |
| `CORS_ORIGIN` | CORS origin. |
| `SOURCE_BUCKETS` | S3 Bucket with source images |
| Name | Description | Default |
|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------|
| `AUTO_WEBP` | Flag if the AUTO WEBP feature should be enabled | – |
| `CORS_ENABLED` | Flag if CORS should be enabled | – |
| `CORS_ORIGIN` | CORS origin. | – |
| `SOURCE_BUCKETS` | S3 Bucket with source images | – |
| `UNBOUNDED_FIT_IN_MAX_DIMENSION` | Max longest side (px) for unbounded `fit-in/0x0` requests. Without a bound the image is returned at full native resolution and can exceed the 6 MB Lambda payload limit (`TooLargeImageException`). Only downscales; smaller images pass through untouched. | `4000` |
| `MAX_ANIMATED_PIXELS` | Pixel budget for animated images (GIF/animated WebP). The number of decoded frames is capped to `MAX_ANIMATED_PIXELS / (frameWidth × frameHeight)` so high-frame-count animations stay under the payload limit. Excess frames are dropped (animation truncated). | `5000000` |
| `MAX_ANIMATED_FRAMES` | Absolute upper bound on decoded frames for animated images, applied alongside `MAX_ANIMATED_PIXELS`. | `100` |

### Building

Expand Down
22 changes: 21 additions & 1 deletion source/image-handler/src/image-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@

import sharp, { OverlayOptions, SharpOptions } from 'sharp';

import { ContentTypes, ImageEdits, ImageFitTypes, ImageFormatTypes, ImageHandlerError, ImageRequestInfo, StatusCodes, } from './lib';
import {
ContentTypes,
ImageEdits,
ImageFitTypes,
ImageFormatTypes,
ImageHandlerError,
ImageRequestInfo,
StatusCodes,
} from './lib';
import { S3 } from '@aws-sdk/client-s3';
import { rgbaToThumbHash } from './lib/thumbhash';

export class ImageHandler {
private readonly LAMBDA_PAYLOAD_LIMIT = 6 * 1024 * 1024;
private readonly MAX_ANIMATED_PIXELS = parseInt(process.env.MAX_ANIMATED_PIXELS) || 5_000_000;
private readonly MAX_ANIMATED_FRAMES = parseInt(process.env.MAX_ANIMATED_FRAMES) || 100;

constructor(private readonly s3Client: S3) {}

Expand Down Expand Up @@ -97,6 +107,16 @@ export class ImageHandler {
if (!metadata.pages || metadata.pages <= 1) {
options.animated = false;
image = await this.instantiateSharpImage(originalImage, edits, options);
} else {
const perFramePixels = (metadata.width ?? 1) * (metadata.pageHeight ?? metadata.height ?? 1);
const maxFrames = Math.min(
this.MAX_ANIMATED_FRAMES,
Math.max(1, Math.floor(this.MAX_ANIMATED_PIXELS / perFramePixels)),
);
if (metadata.pages > maxFrames) {
options.pages = maxFrames;
image = await this.instantiateSharpImage(originalImage, edits, options);
}
}
}

Expand Down
12 changes: 0 additions & 12 deletions source/image-handler/src/thumbor-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,7 @@ import { ImageEdits, ImageFitTypes, ImageFormatTypes } from './lib';

export class ThumborMapper {
private static readonly EMPTY_IMAGE_EDITS: ImageEdits = {};

// Upper bound (px, longest side) applied to otherwise-unbounded `0x0` resize requests
// (e.g. `fit-in/0x0`). Without a bound, sharp returns the image at its full native (cropped)
// resolution; for multi-megapixel originals the base64-encoded result exceeds the 6 MB Lambda
// payload limit and fails with `TooLargeImageException`. Override via the
// `UNBOUNDED_FIT_IN_MAX_DIMENSION` environment variable.
private static readonly UNBOUNDED_FIT_IN_MAX_DIMENSION = parseInt(process.env.UNBOUNDED_FIT_IN_MAX_DIMENSION) || 4000;

/**
* Initializer function for creating a new Thumbor mapping, used by the image
* handler to perform image modifications based on legacy URL path requests.
Expand Down Expand Up @@ -458,12 +451,7 @@ export class ThumborMapper {
if (width === 0 || height === 0) {
resizeEdit.resize.fit = ImageFitTypes.INSIDE;
}

if (width === 0 && height === 0) {
// `0x0` (e.g. `fit-in/0x0`) requests no resize bound, which would return the image at
// its full native (cropped) resolution and can blow past the 6 MB Lambda payload limit.
// Clamp the longest side so large images are downscaled to fit; `withoutEnlargement`
// leaves images already smaller than the bound untouched (no upscaling).
resizeEdit.resize.width = ThumborMapper.UNBOUNDED_FIT_IN_MAX_DIMENSION;
resizeEdit.resize.height = ThumborMapper.UNBOUNDED_FIT_IN_MAX_DIMENSION;
resizeEdit.resize.withoutEnlargement = true;
Expand Down
11 changes: 7 additions & 4 deletions source/image-handler/terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ module "lambda" {

environment = {
variables = {
AUTO_WEBP = "Yes"
CORS_ENABLED = "Yes"
CORS_ORIGIN = "*"
SOURCE_BUCKETS = "master-images-${var.account_id}-${var.region}"
AUTO_WEBP = "Yes"
CORS_ENABLED = "Yes"
CORS_ORIGIN = "*"
SOURCE_BUCKETS = "master-images-${var.account_id}-${var.region}"
UNBOUNDED_FIT_IN_MAX_DIMENSION = "4000"
MAX_ANIMATED_PIXELS = "5000000"
MAX_ANIMATED_FRAMES = "100"
}
}

Expand Down
28 changes: 28 additions & 0 deletions source/image-handler/test/image-handler/animated.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,34 @@ describe('animated', () => {
});
});

it('Should cap decoded frames when the animation exceeds the frame guard', async () => {
// Arrange — force the cap to 1 frame so the 2-page fixture trips the guard
process.env.MAX_ANIMATED_FRAMES = '1';
const request: ImageRequestInfo = {
requestType: RequestTypes.DEFAULT,
contentType: ContentTypes.GIF,
bucket: 'sample-bucket',
key: 'sample-image-001.gif',
edits: { grayscale: true },
originalImage: gifImage,
};

// Act
const imageHandler = new ImageHandler(s3Client);
const instantiateSpy = jest.spyOn<any, 'instantiateSharpImage'>(imageHandler, 'instantiateSharpImage');
await imageHandler.process(request);

// Assert — re-instantiated with an explicit page cap, animation semantics preserved
expect(instantiateSpy).toHaveBeenCalledTimes(2);
expect(instantiateSpy).toHaveBeenLastCalledWith(request.originalImage, request.edits, {
failOn: 'none',
animated: true,
pages: 1,
});

delete process.env.MAX_ANIMATED_FRAMES;
});

it('Should attempt to create animated image if animated edit is set to true, regardless of original image and content type', async () => {
// Arrange
const request: ImageRequestInfo = {
Expand Down