1
import { useEnv } from '@directus/env';
4
IllegalAssetTransformationError,
5
RangeNotSatisfiableError,
6
ServiceUnavailableError,
7
} from '@directus/errors';
8
import type { Range, Stat } from '@directus/storage';
9
import type { Accountability, File } from '@directus/types';
10
import type { Knex } from 'knex';
11
import { clamp } from 'lodash-es';
12
import { contentType } from 'mime-types';
13
import type { Readable } from 'node:stream';
14
import hash from 'object-hash';
15
import path from 'path';
16
import type { FailOnOptions } from 'sharp';
17
import sharp from 'sharp';
18
import { SUPPORTED_IMAGE_TRANSFORM_FORMATS } from '../constants.js';
19
import getDatabase from '../database/index.js';
20
import { useLogger } from '../logger.js';
21
import { getStorage } from '../storage/index.js';
22
import type { AbstractServiceOptions, Transformation, TransformationSet } from '../types/index.js';
23
import { getMilliseconds } from '../utils/get-milliseconds.js';
24
import { isValidUuid } from '../utils/is-valid-uuid.js';
25
import * as TransformationUtils from '../utils/transformations.js';
26
import { AuthorizationService } from './authorization.js';
27
import { FilesService } from './files.js';
30
const logger = useLogger();
32
export class AssetsService {
34
accountability: Accountability | null;
35
authorizationService: AuthorizationService;
36
filesService: FilesService;
38
constructor(options: AbstractServiceOptions) {
39
this.knex = options.knex || getDatabase();
40
this.accountability = options.accountability || null;
41
this.filesService = new FilesService({ ...options, accountability: null });
42
this.authorizationService = new AuthorizationService(options);
47
transformation?: TransformationSet,
49
): Promise<{ stream: Readable; file: any; stat: Stat }> {
50
const storage = await getStorage();
52
const publicSettings = await this.knex
53
.select('project_logo', 'public_background', 'public_foreground', 'public_favicon')
54
.from('directus_settings')
57
const systemPublicKeys = Object.values(publicSettings || {});
60
* This is a little annoying. Postgres will error out if you're trying to search in `where`
61
* with a wrong type. In case of directus_files where id is a uuid, we'll have to verify the
62
* validity of the uuid ahead of time.
64
if (!isValidUuid(id)) throw new ForbiddenError();
66
if (systemPublicKeys.includes(id) === false && this.accountability?.admin !== true) {
67
await this.authorizationService.checkAccess('read', 'directus_files', id);
70
const file = (await this.filesService.readOne(id, { limit: 1 })) as File;
72
const exists = await storage.location(file.storage).exists(file.filename_disk);
74
if (!exists) throw new ForbiddenError();
77
const missingRangeLimits = range.start === undefined && range.end === undefined;
78
const endBeforeStart = range.start !== undefined && range.end !== undefined && range.end <= range.start;
79
const startOverflow = range.start !== undefined && range.start >= file.filesize;
80
const endUnderflow = range.end !== undefined && range.end <= 0;
82
if (missingRangeLimits || endBeforeStart || startOverflow || endUnderflow) {
83
throw new RangeNotSatisfiableError({ range });
86
const lastByte = file.filesize - 1;
89
if (range.start === undefined) {
90
// fetch chunk from tail
91
range.start = file.filesize - range.end;
95
if (range.end >= file.filesize) {
102
if (range.end === undefined) {
104
range.end = lastByte;
107
if (range.start < 0) {
108
// fetch file from head
114
const type = file.type;
115
const transforms = transformation ? TransformationUtils.resolvePreset(transformation, file) : [];
117
if (type && transforms.length > 0 && SUPPORTED_IMAGE_TRANSFORM_FORMATS.includes(type)) {
118
const maybeNewFormat = TransformationUtils.maybeExtractFormat(transforms);
120
const assetFilename =
121
path.basename(file.filename_disk, path.extname(file.filename_disk)) +
122
getAssetSuffix(transforms) +
123
(maybeNewFormat ? `.${maybeNewFormat}` : path.extname(file.filename_disk));
125
const exists = await storage.location(file.storage).exists(assetFilename);
127
if (maybeNewFormat) {
128
file.type = contentType(assetFilename) || null;
133
stream: await storage.location(file.storage).read(assetFilename, range),
135
stat: await storage.location(file.storage).stat(assetFilename),
139
// Check image size before transforming. Processing an image that's too large for the
140
// system memory will kill the API. Sharp technically checks for this too in it's
141
// limitInputPixels, but we should have that check applied before starting the read streams
142
const { width, height } = file;
147
width > (env['ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION'] as number) ||
148
height > (env['ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION'] as number)
150
logger.warn(`Image is too large to be transformed, or image size couldn't be determined.`);
151
throw new IllegalAssetTransformationError({ invalidTransformations: ['width', 'height'] });
154
const { queue, process } = sharp.counters();
156
if (queue + process > (env['ASSETS_TRANSFORM_MAX_CONCURRENT'] as number)) {
157
throw new ServiceUnavailableError({
159
reason: 'Server too busy',
163
const readStream = await storage.location(file.storage).read(file.filename_disk, range);
165
const transformer = sharp({
166
limitInputPixels: Math.pow(env['ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION'] as number, 2),
167
sequentialRead: true,
168
failOn: env['ASSETS_INVALID_IMAGE_SENSITIVITY_LEVEL'] as FailOnOptions,
171
transformer.timeout({
172
seconds: clamp(Math.round(getMilliseconds(env['ASSETS_TRANSFORM_TIMEOUT'], 0) / 1000), 1, 3600),
175
if (transforms.find((transform) => transform[0] === 'rotate') === undefined) transformer.rotate();
177
transforms.forEach(([method, ...args]) => (transformer[method] as any).apply(transformer, args));
179
readStream.on('error', (e: Error) => {
180
logger.error(e, `Couldn't transform file ${file.id}`);
181
readStream.unpipe(transformer);
185
await storage.location(file.storage).write(assetFilename, readStream.pipe(transformer), type);
188
await storage.location(file.storage).delete(assetFilename);
190
// Ignored to prevent original error from being overwritten
193
if ((error as Error)?.message?.includes('timeout')) {
194
throw new ServiceUnavailableError({ service: 'assets', reason: `Transformation timed out` });
201
stream: await storage.location(file.storage).read(assetFilename, range),
202
stat: await storage.location(file.storage).stat(assetFilename),
206
const readStream = await storage.location(file.storage).read(file.filename_disk, range);
207
const stat = await storage.location(file.storage).stat(file.filename_disk);
208
return { stream: readStream, file, stat };
213
const getAssetSuffix = (transforms: Transformation[]) => {
214
if (Object.keys(transforms).length === 0) return '';
215
return `__${hash(transforms)}`;