diff --git a/api/package.json b/api/package.json index 7c37f8b8d0..34ff72a897 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/src/constants.ts b/api/src/constants.ts index 1c42e1d632..1b28049235 100644 --- a/api/src/constants.ts +++ b/api/src/constants.ts @@ -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']; diff --git a/api/src/controllers/assets.ts b/api/src/controllers/assets.ts index b64775af98..b48e939aa8 100644 --- a/api/src/controllers/assets.ts +++ b/api/src/controllers/assets.ts @@ -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; diff --git a/api/src/database/system-data/fields/settings.yaml b/api/src/database/system-data/fields/settings.yaml index 12f5f99944..6a1965dc23 100644 --- a/api/src/database/system-data/fields/settings.yaml +++ b/api/src/database/system-data/fields/settings.yaml @@ -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 diff --git a/api/src/env.ts b/api/src/env.ts index 40295d36dd..0a810c3b71 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -68,6 +68,7 @@ const defaults: Record = { 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 diff --git a/api/src/services/assets.ts b/api/src/services/assets.ts index baae16fb31..99caa582a9 100644 --- a/api/src/services/assets.ts +++ b/api/src/services/assets.ts @@ -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)}`; +}; diff --git a/api/src/services/files.ts b/api/src/services/files.ts index d761076a71..b1c7e89f67 100644 --- a/api/src/services/files.ts +++ b/api/src/services/files.ts @@ -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'; diff --git a/api/src/types/assets.ts b/api/src/types/assets.ts index 8fa6718c59..b97c495527 100644 --- a/api/src/types/assets.ts +++ b/api/src/types/assets.ts @@ -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; + +export type TransformationMap = { + [M in keyof AllowedSharpMethods]: readonly [M, ...Parameters]; +}; + +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; + +// @NOTE Keys used in TransformationParams should match ASSET_GENERATION_QUERY_KEYS in constants.ts diff --git a/api/src/utils/transformations.ts b/api/src/utils/transformations.ts new file mode 100644 index 0000000000..7d8eac17a1 --- /dev/null +++ b/api/src/utils/transformations.ts @@ -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>(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(['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( + ['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; +} diff --git a/app/src/styles/lib/_codemirror.scss b/app/src/styles/lib/_codemirror.scss index 62d8307dcd..5005ccd270 100644 --- a/app/src/styles/lib/_codemirror.scss +++ b/app/src/styles/lib/_codemirror.scss @@ -22,7 +22,7 @@ } .CodeMirror .CodeMirror-placeholder { - color: var(--foreground-subdued); + color: var(--foreground-subdued) !important; } .CodeMirror:hover { diff --git a/docs/reference/api/assets.md b/docs/reference/api/assets.md index fc641cb397..025d54a459 100644 --- a/docs/reference/api/assets.md +++ b/docs/reference/api/assets.md @@ -35,9 +35,15 @@ permissions and other built-in features. ## Requesting a Thumbnail -Fetching thumbnails is as easy as adding query parameters to the original file's URL. If a requested thumbnail doesn't -yet exist, it is dynamically generated and immediately returned. When requesting a thumbnail, the following parameters -are all required, supports thumbnail for `jpeg`,`png` and `webp` +Fetching thumbnails is as easy as adding a `key` query parameter to the original file's URL. In the Admin App, you can +configure different asset presets that control the output of any given image. If a requested thumbnail doesn't yet +exist, it is dynamically generated and immediately returned. + +- **`key`** — This **key** of the [Storage Asset Preset](/guides/files#creating-thumbnail-presets), a shortcut for the + below parameters + +Alternatively, if you have "Storage Asset Transform" set to all, you can use the following parameters for more fine +grained control: - **`fit`** — The **fit** of the thumbnail while always preserving the aspect ratio, can be any of the following options: @@ -51,23 +57,31 @@ are all required, supports thumbnail for `jpeg`,`png` and `webp` - **`height`** — The **height** of the thumbnail in pixels - **`quality`** — The **quality** of the thumbnail (`1` to `100`) is `Optional` - **`withoutEnlargement`** — Disable image up-scaling -- **`download`** — Add `Content-Disposition` header and force browser to download file +- **`format`** — What file format to return the thumbnail in. One of `jpg`, `png`, `webp`, `tiff` ``` example.com/assets/?fit=&width=&height=&quality= example.com/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?fit=cover&width=200&height=200&quality=80 ``` -Alternatively, you can reference a specific thumbnail by its preset key. - -- **`key`** — This **key** of the [Storage Asset Preset](/guides/files#creating-thumbnail-presets), a shortcut for the - above parameters +For even more advanced control over the file generation, Directus exposes +[the full `sharp` API](https://sharp.pixelplumbing.com/api-operation) through the `transform` query parameter. This +parameter accepts a two-dimensional array with the format `[Operation, ...arguments]`, for example: ``` -example.com/assets/?key= -example.com/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?key=card +?transforms=[ + ["blur", 45], + ["tint", "rgb(255, 0, 0)"], + ["expand", { "right": 200, "bottom": 150 }] +] ``` +### Downloading an asset + +To automatically download the file when opening it in a browser, add the `download` query parameter. + +- **`download`** — Add `Content-Disposition` header and force browser to download file + ### Cover vs Contain For easier comparison, both of the examples below were requested at `200` width, `200` height, and `75` quality. The diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 06eb7b777e..0b732fdcf5 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -267,6 +267,7 @@ STORAGE_LOCAL_ROOT="./uploads" | `ASSETS_CACHE_TTL` | How long assets will be cached for in the browser. Sets the `max-age` value of the `Cache-Control` header. | `30m` | | `ASSETS_TRANSFORM_MAX_CONCURRENT` | How many file transformations can be done simultaneously | 4 | | `ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION` | The max pixel dimensions size (width/height) that is allowed to be transformed | 6000 | +| `ASSETS_TRANSFORM_MAX_OPERATIONS` | The max number of transform operations that is allowed to be processed (excludes saved presets) | 5 | Image transformations can be fairly heavy on memory usage. If you're using a system with 1GB or less available memory, we recommend lowering the allowed concurrent transformations to prevent you from overflowing your server. diff --git a/package-lock.json b/package-lock.json index 13d825ecec..8b5e05e9e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -159,6 +159,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", @@ -182,6 +183,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" @@ -281,6 +283,15 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "api/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "optional": true, + "engines": { + "node": ">= 6" + } + }, "api/node_modules/rxjs": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.2.0.tgz", @@ -7684,6 +7695,12 @@ "integrity": "sha512-Nd8y/5t/7CRakPYiyPzr/IAfYusy1FkcZYFEAcoMZkwpJv2n4Wm+olW+e7xBdHEXhOnWdG9ddbar0gqZWS4x5Q==", "dev": true }, + "node_modules/@types/object-hash": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-2.1.1.tgz", + "integrity": "sha512-X2LV33+tSr9LQXcQBatyPGtWOOranhjGYjEnphsdtNRdviCY/3ciCkahwohx1/ary2dMHIKIRoncBF/69Wr38A==", + "dev": true + }, "node_modules/@types/object-path": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.1.tgz", @@ -66811,6 +66828,12 @@ "integrity": "sha512-Nd8y/5t/7CRakPYiyPzr/IAfYusy1FkcZYFEAcoMZkwpJv2n4Wm+olW+e7xBdHEXhOnWdG9ddbar0gqZWS4x5Q==", "dev": true }, + "@types/object-hash": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-2.1.1.tgz", + "integrity": "sha512-X2LV33+tSr9LQXcQBatyPGtWOOranhjGYjEnphsdtNRdviCY/3ciCkahwohx1/ary2dMHIKIRoncBF/69Wr38A==", + "dev": true + }, "@types/object-path": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.1.tgz", @@ -75317,6 +75340,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", @@ -75376,6 +75400,7 @@ "node-machine-id": "^1.1.12", "nodemailer": "^6.6.1", "nodemailer-mailgun-transport": "^2.1.3", + "object-hash": "^2.2.0", "openapi3-ts": "^2.0.0", "ora": "^5.4.0", "otplib": "^12.0.1", @@ -75467,6 +75492,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "optional": true + }, "rxjs": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.2.0.tgz", diff --git a/packages/specs/src/components/setting.yaml b/packages/specs/src/components/setting.yaml index 5774a160ad..91086bb19a 100644 --- a/packages/specs/src/components/setting.yaml +++ b/packages/specs/src/components/setting.yaml @@ -85,5 +85,34 @@ properties: quality: description: Quality of the compression used. type: integer + format: + description: Reformat output image + type: string + enum: + - '' + - jpeg + - png + - webp + - tiff + transforms: + description: Additional transformations to apply + type: list + nullable: true + items: + type: object + properties: + method: + description: The Sharp method name + type: string + arguments: + description: A list of arguments to pass to the Sharp method + type: array + nullable: true + items: + type: object + properties: + argument: + description: A JSON representation of the argument value + type: string example: null nullable: true diff --git a/packages/specs/src/paths/assets/assets.yaml b/packages/specs/src/paths/assets/assets.yaml index c5a3438352..855d8b4fc7 100644 --- a/packages/specs/src/paths/assets/assets.yaml +++ b/packages/specs/src/paths/assets/assets.yaml @@ -16,34 +16,11 @@ get: description: The key of the asset size configured in settings. schema: type: string - - name: width + - name: transforms in: query - description: Width of the file in pixels. - schema: - type: integer - - name: height - in: query - description: Height of the file in pixels. - schema: - type: integer - - name: fit - in: query - description: Fit of the file + description: A JSON array of image transformations schema: type: string - enum: [crop, contain, inside, outside] - - name: withoutEnlargement - in: query - description: No image upscale. - schema: - type: boolean - - name: quality - in: query - description: Quality of compression. - schema: - type: integer - minimum: 1 - maximum: 100 - name: download in: query description: Download the asset to your computer