mirror of
https://github.com/directus/directus.git
synced 2026-01-23 07:17:55 -05:00
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:
@@ -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",
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
68
api/src/utils/transformations.ts
Normal file
68
api/src/utils/transformations.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user