Skip to content

Commit 0fec7bc

Browse files
authored
feat(asset-server-plugin): add background color query param for transparent images (#4999)
1 parent b8004ae commit 0fec7bc

7 files changed

Lines changed: 278 additions & 4 deletions

File tree

packages/asset-server-plugin/src/asset-server.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import fs from 'fs-extra';
66
import mime from 'mime-types';
77
import path from 'path';
88

9-
import { getValidFormat } from './common';
9+
import { getValidBackgroundColor, getValidFormat } from './common';
1010
import { ImageTransformParameters, ImageTransformStrategy } from './config/image-transform-strategy';
1111
import { S3AssetStorageStrategy } from './config/s3-asset-storage-strategy';
1212
import { ASSET_SERVER_PLUGIN_INIT_OPTIONS, DEFAULT_CACHE_HEADER, loggerCtx } from './constants';
@@ -199,6 +199,7 @@ export class AssetServer {
199199
const fpx = +queryParams.fpx || undefined;
200200
const fpy = +queryParams.fpy || undefined;
201201
const format = getValidFormat(queryParams.format);
202+
const backgroundColor = getValidBackgroundColor(queryParams.bg);
202203

203204
return {
204205
width,
@@ -209,14 +210,16 @@ export class AssetServer {
209210
fpx,
210211
fpy,
211212
preset: queryParams.preset,
213+
backgroundColor,
212214
};
213215
}
214216

215217
private getFileNameFromParameters(filePath: string, params: ImageTransformParameters): string {
216-
const { width: w, height: h, mode, preset, fpx, fpy, format, quality: q } = params;
218+
const { width: w, height: h, mode, preset, fpx, fpy, format, quality: q, backgroundColor } = params;
217219
/* eslint-disable @typescript-eslint/restrict-template-expressions */
218220
const focalPoint = fpx && fpy ? `_fpx${fpx}_fpy${fpy}` : '';
219221
const quality = q ? `_q${q}` : '';
222+
const bg = backgroundColor ? `_bg${backgroundColor.replace('#', '')}` : '';
220223
const imageFormat = getValidFormat(format);
221224
let imageParamsString = '';
222225
if (w || h) {
@@ -238,6 +241,9 @@ export class AssetServer {
238241
if (quality) {
239242
imageParamsString += quality;
240243
}
244+
if (bg) {
245+
imageParamsString += bg;
246+
}
241247

242248
const decodedReqPath = this.sanitizeFilePath(filePath);
243249
if (imageParamsString !== '') {

packages/asset-server-plugin/src/common.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,19 @@ export function getValidFormat(format?: unknown): ImageTransformFormat | undefin
4040
return undefined;
4141
}
4242
}
43+
44+
/**
45+
* Validates and normalizes a background color hex string.
46+
* Accepts 3, 4, 6, or 8 character hex strings (with or without `#` prefix).
47+
* Returns the normalized hex string with `#` prefix, or `undefined` if invalid.
48+
*/
49+
export function getValidBackgroundColor(input?: unknown): string | undefined {
50+
if (typeof input !== 'string' || input.length === 0) {
51+
return undefined;
52+
}
53+
const hex = input.startsWith('#') ? input.slice(1) : input;
54+
if (/^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6}$|^[0-9a-fA-F]{8}$/.test(hex)) {
55+
return `#${hex.toLowerCase()}`;
56+
}
57+
return undefined;
58+
}

packages/asset-server-plugin/src/config/image-transform-strategy.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ export interface ImageTransformParameters {
2020
fpx: number | undefined;
2121
fpy: number | undefined;
2222
preset: string | undefined;
23+
/**
24+
* @description
25+
* A hex color string (e.g. `'#ffffff'`) to be used as the background color
26+
* for images with alpha transparency. The alpha channel will be merged with
27+
* this color using Sharp's `flatten()` method.
28+
*
29+
* @since 3.8.0
30+
*/
31+
backgroundColor: string | undefined;
2332
}
2433

2534
/**

packages/asset-server-plugin/src/config/preset-only-strategy.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ImageTransformFormat } from '../types';
1+
import { HexColorString, ImageTransformFormat } from '../types';
22

33
import {
44
GetImageTransformParametersArgs,
@@ -42,6 +42,22 @@ export interface PresetOnlyStrategyOptions {
4242
* @default false
4343
*/
4444
allowFocalPoint?: boolean;
45+
/**
46+
* @description
47+
* Whether to allow the background color to be specified in the URL.
48+
* If omitted (default), the `bg` parameter is dropped.
49+
* If set to an array of hex color strings (e.g. `['#ffffff', '#000000']`),
50+
* only those colors are permitted — keeping the cache key space bounded.
51+
* If set to `'any'`, any valid hex color is accepted (explicit opt-in to an
52+
* unbounded cache surface).
53+
*
54+
* Comparison is case-insensitive, so `'#FFFFFF'` and `'#ffffff'` are
55+
* treated as the same entry.
56+
*
57+
* @default undefined
58+
* @since 3.8.0
59+
*/
60+
permittedBackgroundColors?: HexColorString[] | 'any';
4561
}
4662

4763
/**
@@ -73,6 +89,7 @@ export interface PresetOnlyStrategyOptions {
7389
* permittedQuality: [0, 50, 75, 85, 95],
7490
* permittedFormats: ['jpg', 'webp', 'avif'],
7591
* allowFocalPoint: true,
92+
* permittedBackgroundColors: ['#ffffff', '#000000'],
7693
* }),
7794
* });
7895
* ```
@@ -107,6 +124,23 @@ export class PresetOnlyStrategy implements ImageTransformStrategy {
107124
fpx: this.options.allowFocalPoint ? input.fpx : undefined,
108125
fpy: this.options.allowFocalPoint ? input.fpy : undefined,
109126
preset: input.preset,
127+
backgroundColor: this.getPermittedBackgroundColor(input.backgroundColor),
110128
};
111129
}
130+
131+
private getPermittedBackgroundColor(input: string | undefined): string | undefined {
132+
if (!input) {
133+
return undefined;
134+
}
135+
const permitted = this.options.permittedBackgroundColors;
136+
if (!permitted) {
137+
return undefined;
138+
}
139+
if (permitted === 'any') {
140+
return input;
141+
}
142+
const normalise = (c: string) => c.toLowerCase();
143+
const normalised = normalise(input);
144+
return permitted.some(c => normalise(c) === normalised) ? input : undefined;
145+
}
112146
}

packages/asset-server-plugin/src/transform-image.spec.ts

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import sharp from 'sharp';
12
import { describe, expect, it } from 'vitest';
23

3-
import { Dimensions, Point, resizeToFocalPoint } from './transform-image';
4+
import { getValidBackgroundColor } from './common';
5+
import { PresetOnlyStrategy } from './config/preset-only-strategy';
6+
import { Dimensions, Point, resizeToFocalPoint, transformImage } from './transform-image';
47

58
describe('resizeToFocalPoint', () => {
69
it('no resize, crop left', () => {
@@ -67,3 +70,189 @@ describe('resizeToFocalPoint', () => {
6770
});
6871
});
6972
});
73+
74+
describe('getValidBackgroundColor', () => {
75+
it('accepts 6-char hex without #', () => {
76+
expect(getValidBackgroundColor('ffffff')).toBe('#ffffff');
77+
});
78+
79+
it('accepts 6-char hex with #', () => {
80+
expect(getValidBackgroundColor('#F0A703')).toBe('#f0a703');
81+
});
82+
83+
it('accepts 3-char shorthand hex', () => {
84+
expect(getValidBackgroundColor('fff')).toBe('#fff');
85+
});
86+
87+
it('accepts 8-char hex with alpha', () => {
88+
expect(getValidBackgroundColor('ffffff80')).toBe('#ffffff80');
89+
});
90+
91+
it('accepts 4-char shorthand hex with alpha', () => {
92+
expect(getValidBackgroundColor('fff8')).toBe('#fff8');
93+
});
94+
95+
it('returns undefined for empty string', () => {
96+
expect(getValidBackgroundColor('')).toBeUndefined();
97+
});
98+
99+
it('returns undefined for non-string input', () => {
100+
expect(getValidBackgroundColor(undefined)).toBeUndefined();
101+
expect(getValidBackgroundColor(123)).toBeUndefined();
102+
expect(getValidBackgroundColor(null)).toBeUndefined();
103+
});
104+
105+
it('returns undefined for invalid hex characters', () => {
106+
expect(getValidBackgroundColor('gggggg')).toBeUndefined();
107+
expect(getValidBackgroundColor('xyz')).toBeUndefined();
108+
});
109+
110+
it('returns undefined for wrong length hex', () => {
111+
expect(getValidBackgroundColor('ff')).toBeUndefined();
112+
expect(getValidBackgroundColor('fffff')).toBeUndefined();
113+
expect(getValidBackgroundColor('fffffffff')).toBeUndefined();
114+
});
115+
});
116+
117+
describe('transformImage with backgroundColor', () => {
118+
function createTestPngWithAlpha(width: number, height: number): Promise<Buffer> {
119+
return sharp({
120+
create: {
121+
width,
122+
height,
123+
channels: 4,
124+
background: { r: 255, g: 0, b: 0, alpha: 0.5 },
125+
},
126+
})
127+
.png()
128+
.toBuffer();
129+
}
130+
131+
it('flattens alpha channel with specified background color', async () => {
132+
const input = await createTestPngWithAlpha(10, 10);
133+
const result = await transformImage(input, {
134+
width: 10,
135+
height: 10,
136+
mode: 'resize',
137+
quality: undefined,
138+
format: 'png',
139+
fpx: undefined,
140+
fpy: undefined,
141+
preset: undefined,
142+
backgroundColor: '#ffffff',
143+
});
144+
const buffer = await result.toBuffer();
145+
const { channels, hasAlpha } = await sharp(buffer).metadata();
146+
expect(channels).toBe(3);
147+
expect(hasAlpha).toBe(false);
148+
});
149+
150+
it('preserves alpha channel when no backgroundColor is set', async () => {
151+
const input = await createTestPngWithAlpha(10, 10);
152+
const result = await transformImage(input, {
153+
width: 10,
154+
height: 10,
155+
mode: 'resize',
156+
quality: undefined,
157+
format: 'png',
158+
fpx: undefined,
159+
fpy: undefined,
160+
preset: undefined,
161+
backgroundColor: undefined,
162+
});
163+
const buffer = await result.toBuffer();
164+
const { channels, hasAlpha } = await sharp(buffer).metadata();
165+
expect(channels).toBe(4);
166+
expect(hasAlpha).toBe(true);
167+
});
168+
});
169+
170+
describe('PresetOnlyStrategy permittedBackgroundColors', () => {
171+
const presets = [{ name: 'thumb', width: 100, height: 100, mode: 'crop' as const }];
172+
const baseInput = {
173+
width: undefined,
174+
height: undefined,
175+
mode: undefined,
176+
quality: undefined,
177+
format: undefined,
178+
fpx: undefined,
179+
fpy: undefined,
180+
preset: 'thumb',
181+
backgroundColor: '#ffffff',
182+
};
183+
184+
it('drops backgroundColor when permittedBackgroundColors is omitted', () => {
185+
const strategy = new PresetOnlyStrategy({ defaultPreset: 'thumb' });
186+
const result = strategy.getImageTransformParameters({
187+
input: baseInput,
188+
availablePresets: presets,
189+
req: {} as any,
190+
});
191+
expect(result.backgroundColor).toBeUndefined();
192+
});
193+
194+
it('allows any backgroundColor when set to "any"', () => {
195+
const strategy = new PresetOnlyStrategy({
196+
defaultPreset: 'thumb',
197+
permittedBackgroundColors: 'any',
198+
});
199+
const result = strategy.getImageTransformParameters({
200+
input: baseInput,
201+
availablePresets: presets,
202+
req: {} as any,
203+
});
204+
expect(result.backgroundColor).toBe('#ffffff');
205+
});
206+
207+
it('allows a whitelisted backgroundColor', () => {
208+
const strategy = new PresetOnlyStrategy({
209+
defaultPreset: 'thumb',
210+
permittedBackgroundColors: ['#ffffff', '#000000'],
211+
});
212+
const result = strategy.getImageTransformParameters({
213+
input: baseInput,
214+
availablePresets: presets,
215+
req: {} as any,
216+
});
217+
expect(result.backgroundColor).toBe('#ffffff');
218+
});
219+
220+
it('drops a non-whitelisted backgroundColor', () => {
221+
const strategy = new PresetOnlyStrategy({
222+
defaultPreset: 'thumb',
223+
permittedBackgroundColors: ['#000000'],
224+
});
225+
const result = strategy.getImageTransformParameters({
226+
input: baseInput,
227+
availablePresets: presets,
228+
req: {} as any,
229+
});
230+
expect(result.backgroundColor).toBeUndefined();
231+
});
232+
233+
it('whitelist comparison is case-insensitive', () => {
234+
const strategy = new PresetOnlyStrategy({
235+
defaultPreset: 'thumb',
236+
permittedBackgroundColors: ['#FFFFFF'],
237+
});
238+
const result = strategy.getImageTransformParameters({
239+
input: { ...baseInput, backgroundColor: '#ffffff' },
240+
availablePresets: presets,
241+
req: {} as any,
242+
});
243+
expect(result.backgroundColor).toBe('#ffffff');
244+
});
245+
246+
it('whitelist comparison is case-insensitive with # prefix', () => {
247+
const strategy = new PresetOnlyStrategy({
248+
defaultPreset: 'thumb',
249+
permittedBackgroundColors: ['#ffffff'],
250+
});
251+
const result = strategy.getImageTransformParameters({
252+
input: { ...baseInput, backgroundColor: '#ffffff' },
253+
availablePresets: presets,
254+
req: {} as any,
255+
});
256+
expect(result.backgroundColor).toBe('#ffffff');
257+
});
258+
});

packages/asset-server-plugin/src/transform-image.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export async function transformImage(
2424
}
2525

2626
const image = sharp(originalImage).rotate();
27+
28+
// Merge alpha transparency channel with the specified background color
29+
if (parameters.backgroundColor) {
30+
image.flatten({ background: parameters.backgroundColor });
31+
}
32+
2733
try {
2834
await applyFormat(image, parameters.format, parameters.quality);
2935
} catch (e: any) {

packages/asset-server-plugin/src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ import { ImageTransformStrategy } from './config/image-transform-strategy';
99

1010
export type ImageTransformFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif';
1111

12+
/**
13+
* @description
14+
* A `#`-prefixed hex color string, e.g. `'#ffffff'` or `'#000'`.
15+
* Full hex-char validation at the type level is not feasible because
16+
* TypeScript's template literal unions exceed compiler limits for
17+
* 6- and 8-char hex strings. The `#` prefix is enforced at compile time;
18+
* hex-character and length validation is handled at runtime by
19+
* `getValidBackgroundColor()`.
20+
*
21+
* @docsCategory core plugins/AssetServerPlugin
22+
* @since 3.8.0
23+
*/
24+
export type HexColorString = `#${string}`;
25+
1226
/**
1327
* @description
1428
* Specifies the way in which an asset preview image will be resized to fit in the

0 commit comments

Comments
 (0)