Allow custom transformations of assets (#6593)

* Allow custom transformations of assets

This exposes one query parameter `transforms`, which is a JSON array of
shard transformation operations.

It also updates the asset presets. The UX for this still needs some work

* Rename options to arguments for presets

More explicit

* options -> arguments in setting spec

* Better errors for invalid JSON in asset presets

* Add limit to transforms query parameter

* Use flattened option for extra transforms

* Fix placeholder color of code input

* Allow "simple mode" aliases

* Add documentation

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Tim
2021-07-22 07:57:47 +12:00
committed by GitHub
parent 7dffa48570
commit 2c9ff3bca6
15 changed files with 387 additions and 118 deletions

View File

@@ -150,6 +150,7 @@
"memcached": "^2.2.2",
"mysql": "^2.18.1",
"nodemailer-mailgun-transport": "^2.1.3",
"object-hash": "^2.2.0",
"pg": "^8.6.0",
"sqlite3": "^5.0.2",
"tedious": "^11.0.8"
@@ -178,6 +179,7 @@
"@types/node": "15.12.2",
"@types/node-cron": "2.0.4",
"@types/nodemailer": "6.4.4",
"@types/object-hash": "^2.1.0",
"@types/qs": "6.9.7",
"@types/sharp": "0.28.4",
"@types/stream-json": "1.7.1",

View File

@@ -1,42 +1,42 @@
import { Transformation } from './types';
import { TransformationParams } from './types';
export const SYSTEM_ASSET_ALLOW_LIST: Transformation[] = [
export const SYSTEM_ASSET_ALLOW_LIST: TransformationParams[] = [
{
key: 'system-small-cover',
width: 64,
height: 64,
fit: 'cover',
transforms: [['resize', { width: 64, height: 64, fit: 'cover' }]],
},
{
key: 'system-small-contain',
width: 64,
fit: 'contain',
transforms: [['resize', { width: 64, fit: 'contain' }]],
},
{
key: 'system-medium-cover',
width: 300,
height: 300,
fit: 'cover',
transforms: [['resize', { width: 300, height: 300, fit: 'cover' }]],
},
{
key: 'system-medium-contain',
width: 300,
fit: 'contain',
transforms: [['resize', { width: 300, fit: 'contain' }]],
},
{
key: 'system-large-cover',
width: 800,
height: 600,
fit: 'cover',
transforms: [['resize', { width: 800, height: 800, fit: 'cover' }]],
},
{
key: 'system-large-contain',
width: 800,
fit: 'contain',
transforms: [['resize', { width: 800, fit: 'contain' }]],
},
];
export const ASSET_TRANSFORM_QUERY_KEYS = ['key', 'width', 'height', 'fit', 'withoutEnlargement', 'quality'];
export const ASSET_TRANSFORM_QUERY_KEYS = [
'key',
'transforms',
'width',
'height',
'format',
'fit',
'quality',
'withoutEnlargement',
];
export const FILTER_VARIABLES = ['$NOW', '$CURRENT_USER', '$CURRENT_ROLE'];

View File

@@ -10,7 +10,7 @@ import { ForbiddenException, InvalidQueryException, RangeNotSatisfiableException
import useCollection from '../middleware/use-collection';
import { AssetsService, PayloadService } from '../services';
import storage from '../storage';
import { Transformation } from '../types/assets';
import { TransformationParams, TransformationMethods, TransformationPreset } from '../types/assets';
import asyncHandler from '../utils/async-handler';
const router = Router();
@@ -68,26 +68,63 @@ router.get(
if ('key' in transformation && Object.keys(transformation).length > 1) {
throw new InvalidQueryException(`You can't combine the "key" query parameter with any other transformation.`);
}
if ('quality' in transformation && (Number(transformation.quality) < 1 || Number(transformation.quality) > 100)) {
throw new InvalidQueryException(`"quality" Parameter has to between 1 to 100`);
if ('transforms' in transformation) {
let transforms: unknown;
// Try parse the JSON array
try {
transforms = JSON.parse(transformation['transforms'] as string);
} catch {
throw new InvalidQueryException(`"transforms" Parameter needs to be a JSON array of allowed transformations.`);
}
// Check if it is actually an array.
if (!Array.isArray(transforms)) {
throw new InvalidQueryException(`"transforms" Parameter needs to be a JSON array of allowed transformations.`);
}
// Check against ASSETS_TRANSFORM_MAX_OPERATIONS
if (transforms.length > Number(env.ASSETS_TRANSFORM_MAX_OPERATIONS)) {
throw new InvalidQueryException(
`"transforms" Parameter is only allowed ${env.ASSETS_TRANSFORM_MAX_OPERATIONS} transformations.`
);
}
// Check the transformations are valid
transforms.forEach((transform) => {
const name = transform[0];
if (!TransformationMethods.includes(name)) {
throw new InvalidQueryException(`"transforms" Parameter does not allow "${name}" as a transformation.`);
}
});
transformation.transforms = transforms;
}
const systemKeys = SYSTEM_ASSET_ALLOW_LIST.map((transformation) => transformation.key);
const systemKeys = SYSTEM_ASSET_ALLOW_LIST.map((transformation) => transformation.key!);
const allKeys: string[] = [
...systemKeys,
...(assetSettings.storage_asset_presets || []).map((transformation: Transformation) => transformation.key),
...(assetSettings.storage_asset_presets || []).map((transformation: TransformationParams) => transformation.key),
];
// For use in the next request handler
res.locals.shortcuts = [...SYSTEM_ASSET_ALLOW_LIST, ...(assetSettings.storage_asset_presets || [])];
res.locals.transformation = transformation;
if (Object.keys(transformation).length === 0) {
if (
Object.keys(transformation).length === 0 ||
('transforms' in transformation && transformation.transforms!.length === 0)
) {
return next();
}
if (assetSettings.storage_asset_transform === 'all') {
if (transformation.key && allKeys.includes(transformation.key as string) === false)
if (transformation.key && allKeys.includes(transformation.key as string) === false) {
throw new InvalidQueryException(`Key "${transformation.key}" isn't configured.`);
}
return next();
} else if (assetSettings.storage_asset_transform === 'presets') {
if (allKeys.includes(transformation.key as string)) return next();
@@ -107,9 +144,9 @@ router.get(
schema: req.schema,
});
const transformation: Transformation = res.locals.transformation.key
? res.locals.shortcuts.find(
(transformation: Transformation) => transformation.key === res.locals.transformation.key
const transformation: TransformationParams | TransformationPreset = res.locals.transformation.key
? (res.locals.shortcuts as TransformationPreset[]).find(
(transformation) => transformation.key === res.locals.transformation.key
)
: res.locals.transformation;

View File

@@ -124,7 +124,7 @@ fields:
options:
slug: true
onlyOnCreate: false
width: half
width: full
- field: fit
name: Fit
type: string
@@ -173,6 +173,7 @@ fields:
step: 1
width: half
- field: withoutEnlargement
name: Upscaling
type: boolean
schema:
default_value: false
@@ -181,6 +182,51 @@ fields:
width: half
options:
label: Don't upscale images
- field: format
name: Format
type: string
schema:
is_nullable: false
default_value: ''
meta:
interface: select-dropdown
options:
allowNone: true
choices:
- value: jpeg
text: JPEG
- value: png
text: PNG
- value: webp
text: WebP
- value: tiff
text: Tiff
width: half
- field: transforms
name: Additional Transformations
type: json
schema:
is_nullable: false
default_value: []
meta:
note:
The Sharp method name and its arguments. See https://sharp.pixelplumbing.com/api-constructor for more
information.
interface: json
options:
template: >
[
["blur", 45],
["grayscale"],
["extend", { "right": 500, "background": "rgb(255, 0, 0)" }]
]
placeholder: >
[
["blur", 45],
["grayscale"],
["extend", { "right": 500, "background": "rgb(255, 0, 0)" }]
]
width: full
template: '{{key}}'
special: json
width: full

View File

@@ -68,6 +68,7 @@ const defaults: Record<string, any> = {
ASSETS_CACHE_TTL: '30d',
ASSETS_TRANSFORM_MAX_CONCURRENT: 1,
ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION: 6000,
ASSETS_TRANSFORM_MAX_OPERATIONS: 5,
};
// Allows us to force certain environment variable into a type, instead of relying

View File

@@ -1,15 +1,24 @@
import { Range, StatResponse } from '@directus/drive';
import { Knex } from 'knex';
import path from 'path';
import sharp, { ResizeOptions } from 'sharp';
import getDatabase from '../database';
import { RangeNotSatisfiableException, IllegalAssetTransformation } from '../exceptions';
import storage from '../storage';
import { AbstractServiceOptions, Accountability, Transformation } from '../types';
import { AuthorizationService } from './authorization';
import { Semaphore } from 'async-mutex';
import { Knex } from 'knex';
import { contentType } from 'mime-types';
import ObjectHash from 'object-hash';
import path from 'path';
import sharp from 'sharp';
import getDatabase from '../database';
import env from '../env';
import { File } from '../types';
import { IllegalAssetTransformation, RangeNotSatisfiableException } from '../exceptions';
import storage from '../storage';
import {
AbstractServiceOptions,
Accountability,
File,
Transformation,
TransformationParams,
TransformationPreset,
} from '../types';
import { AuthorizationService } from './authorization';
import * as TransformationUtils from '../utils/transformations';
sharp.concurrency(1);
@@ -30,7 +39,7 @@ export class AssetsService {
async getAsset(
id: string,
transformation: Transformation,
transformation: TransformationParams | TransformationPreset,
range?: Range
): Promise<{ stream: NodeJS.ReadableStream; file: any; stat: StatResponse }> {
const publicSettings = await this.knex
@@ -53,18 +62,23 @@ export class AssetsService {
}
const type = file.type;
const transforms = TransformationUtils.resolvePreset(transformation, file);
// We can only transform JPEG, PNG, and WebP
if (type && Object.keys(transformation).length > 0 && ['image/jpeg', 'image/png', 'image/webp'].includes(type)) {
const resizeOptions = this.parseTransformation(transformation);
if (type && transforms.length > 0 && ['image/jpeg', 'image/png', 'image/webp', 'image/tiff'].includes(type)) {
const maybeNewFormat = TransformationUtils.maybeExtractFormat(transforms);
const assetFilename =
path.basename(file.filename_disk, path.extname(file.filename_disk)) +
this.getAssetSuffix(transformation) +
path.extname(file.filename_disk);
getAssetSuffix(transforms) +
(maybeNewFormat ? `.${maybeNewFormat}` : path.extname(file.filename_disk));
const { exists } = await storage.disk(file.storage).exists(assetFilename);
if (maybeNewFormat) {
file.type = contentType(assetFilename) || null;
}
if (exists) {
return {
stream: storage.disk(file.storage).getStream(assetFilename, range),
@@ -94,15 +108,9 @@ export class AssetsService {
const transformer = sharp({
limitInputPixels: Math.pow(env.ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION, 2),
sequentialRead: true,
})
.rotate()
.resize(resizeOptions);
}).rotate();
if (transformation.quality) {
transformer.toFormat(type.substring(6) as 'jpeg' | 'png' | 'webp', {
quality: Number(transformation.quality),
});
}
transforms.forEach(([method, ...args]) => (transformer[method] as any).apply(transformer, args));
await storage.disk(file.storage).put(assetFilename, readStream.pipe(transformer), type);
@@ -118,28 +126,9 @@ export class AssetsService {
return { stream: readStream, file, stat };
}
}
private parseTransformation(transformation: Transformation): ResizeOptions {
const resizeOptions: ResizeOptions = {};
if (transformation.width) resizeOptions.width = Number(transformation.width);
if (transformation.height) resizeOptions.height = Number(transformation.height);
if (transformation.fit) resizeOptions.fit = transformation.fit;
if (transformation.withoutEnlargement)
resizeOptions.withoutEnlargement = Boolean(transformation.withoutEnlargement);
return resizeOptions;
}
private getAssetSuffix(transformation: Transformation) {
if (Object.keys(transformation).length === 0) return '';
return (
'__' +
Object.entries(transformation)
.sort((a, b) => (a[0] > b[0] ? 1 : -1))
.map((e) => e.join('_'))
.join(',')
);
}
}
const getAssetSuffix = (transforms: Transformation[]) => {
if (Object.keys(transforms).length === 0) return '';
return `__${ObjectHash.sha1(transforms)}`;
};

View File

@@ -49,7 +49,7 @@ export class FilesService extends ItemsService {
const fileExtension =
path.extname(payload.filename_download) || (payload.type && '.' + extension(payload.type)) || '';
payload.filename_disk = primaryKey + fileExtension;
payload.filename_disk = primaryKey + (fileExtension || '');
if (!payload.type) {
payload.type = 'application/octet-stream';

View File

@@ -1,10 +1,84 @@
export type Transformation = {
import { ResizeOptions, Sharp } from 'sharp';
// List of allowed sharp methods to expose.
//
// This is a literal, so we can use it to validate request parameters.
export const TransformationMethods /*: readonly (keyof Sharp)[]*/ = [
// Output options
// https://sharp.pixelplumbing.com/api-output
'toFormat',
'jpeg',
'png',
'tiff',
'webp',
// Resizing
// https://sharp.pixelplumbing.com/api-resize
'resize',
'extend',
'extract',
'trim',
// Image operations
// https://sharp.pixelplumbing.com/api-operation
'rotate',
'flip',
'flop',
'sharpen',
'median',
'blur',
'flatten',
'gamma',
'negate',
'normalise',
'normalize',
'clahe',
'convolve',
'threshold',
'linear',
'recomb',
'modulate',
// Color manipulation
// https://sharp.pixelplumbing.com/api-colour
'tint',
'greyscale',
'grayscale',
'toColorspace',
'toColourspace',
// Channel manipulation
// https://sharp.pixelplumbing.com/api-channel
'removeAlpha',
'ensureAlpha',
'extractChannel',
'bandbool',
] as const;
// Helper types
type AllowedSharpMethods = Pick<Sharp, typeof TransformationMethods[number]>;
export type TransformationMap = {
[M in keyof AllowedSharpMethods]: readonly [M, ...Parameters<AllowedSharpMethods[M]>];
};
export type Transformation = TransformationMap[keyof TransformationMap];
export type TransformationParams = {
key?: string;
width?: number; // width
height?: number; // height
fit?: 'cover' | 'contain' | 'inside' | 'outside'; // fit
withoutEnlargement?: boolean; // Without Enlargement
transforms?: Transformation[];
};
// Transformation preset is defined in the admin UI.
export type TransformationPreset = TransformationPresetFormat &
TransformationPresetResize &
TransformationParams & { key: string };
export type TransformationPresetFormat = {
format?: 'jpg' | 'jpeg' | 'png' | 'webp' | 'tiff';
quality?: number;
};
// @NOTE Keys used in Transformation should match ASSET_GENERATION_QUERY_KEYS in constants.ts
export type TransformationPresetResize = Pick<ResizeOptions, 'width' | 'height' | 'fit' | 'withoutEnlargement'>;
// @NOTE Keys used in TransformationParams should match ASSET_GENERATION_QUERY_KEYS in constants.ts

View File

@@ -0,0 +1,68 @@
import { isNil } from 'lodash';
import {
File,
Transformation,
TransformationParams,
TransformationPreset,
TransformationPresetFormat,
TransformationPresetResize,
} from '../types';
// Extract transforms from a preset
export function resolvePreset(input: TransformationParams | TransformationPreset, file: File): Transformation[] {
// Do the format conversion last
return [extractResize(input), ...(input.transforms ?? []), extractToFormat(input, file)].filter(
(transform): transform is Transformation => transform !== undefined
);
}
function extractOptions<T extends Record<string, any>>(keys: (keyof T)[], numberKeys: (keyof T)[] = []) {
return function (input: TransformationParams | TransformationPreset): T {
return Object.entries(input).reduce(
(config, [key, value]) =>
keys.includes(key as any) && isNil(value) === false
? {
...config,
[key]: numberKeys.includes(key as any) ? +value : value,
}
: config,
{} as T
);
};
}
// Extract format transform from a preset
function extractToFormat(input: TransformationParams | TransformationPreset, file: File): Transformation | undefined {
const options = extractOptions<TransformationPresetFormat>(['format', 'quality'], ['quality'])(input);
return Object.keys(options).length > 0
? [
'toFormat',
options.format || (file.type!.split('/')[1] as any),
{
quality: options.quality,
},
]
: undefined;
}
function extractResize(input: TransformationParams | TransformationPreset): Transformation | undefined {
const resizable = ['width', 'height'].some((key) => key in input);
if (!resizable) return undefined;
return [
'resize',
extractOptions<TransformationPresetResize>(
['width', 'height', 'fit', 'withoutEnlargement'],
['width', 'height']
)(input),
];
}
/**
* Try to extract a file format from an array of `Transformation`'s.
*/
export function maybeExtractFormat(transforms: Transformation[]): string | undefined {
const toFormats = transforms.filter((t) => t[0] === 'toFormat');
const lastToFormat = toFormats[toFormats.length - 1];
return lastToFormat ? lastToFormat[1]?.toString() : undefined;
}