Files
directus/api/src/services/files.ts
Rijk van Zanten 44082c60e1 Add schema caching (#6456)
* Rework cache handler to be function export

* Add default schema caching

* Add schema cache

* Auto purge schema cache on schema change from api

* Only set last_access value on login

* Add note on schema cache setting
2021-06-22 20:50:20 -04:00

250 lines
7.3 KiB
TypeScript

import formatTitle from '@directus/format-title';
import axios, { AxiosResponse } from 'axios';
import parseEXIF from 'exif-reader';
import { parse as parseICC } from 'icc';
import { clone } from 'lodash';
import { extension } from 'mime-types';
import path from 'path';
import sharp from 'sharp';
import url from 'url';
import { emitAsyncSafe } from '../emitter';
import env from '../env';
import { ForbiddenException, ServiceUnavailableException } from '../exceptions';
import logger from '../logger';
import storage from '../storage';
import { AbstractServiceOptions, File, PrimaryKey } from '../types';
import parseIPTC from '../utils/parse-iptc';
import { toArray } from '../utils/to-array';
import { ItemsService, MutationOptions } from './items';
export class FilesService extends ItemsService {
constructor(options: AbstractServiceOptions) {
super('directus_files', options);
}
/**
* Upload a single new file to the configured storage adapter
*/
async uploadOne(
stream: NodeJS.ReadableStream,
data: Partial<File> & { filename_download: string; storage: string },
primaryKey?: PrimaryKey
): Promise<PrimaryKey> {
const payload = clone(data);
if (primaryKey !== undefined) {
await this.updateOne(primaryKey, payload, { emitEvents: false });
// If the file you're uploading already exists, we'll consider this upload a replace. In that case, we'll
// delete the previously saved file and thumbnails to ensure they're generated fresh
const disk = storage.disk(payload.storage);
for await (const file of disk.flatList(String(primaryKey))) {
await disk.delete(file.path);
}
} else {
primaryKey = await this.createOne(payload, { emitEvents: false });
}
const fileExtension = path.extname(payload.filename_download) || (payload.type && extension(payload.type));
payload.filename_disk = primaryKey + '.' + fileExtension;
if (!payload.type) {
payload.type = 'application/octet-stream';
}
try {
await storage.disk(data.storage).put(payload.filename_disk, stream, payload.type);
} catch (err) {
logger.warn(`Couldn't save file ${payload.filename_disk}`);
logger.warn(err);
throw new ServiceUnavailableException(`Couldn't save file ${payload.filename_disk}`, { service: 'files' });
}
const { size } = await storage.disk(data.storage).getStat(payload.filename_disk);
payload.filesize = size;
if (['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/tiff'].includes(payload.type)) {
const buffer = await storage.disk(data.storage).getBuffer(payload.filename_disk);
const meta = await sharp(buffer.content, {}).metadata();
if (meta.orientation && meta.orientation >= 5) {
payload.height = meta.width;
payload.width = meta.height;
} else {
payload.width = meta.width;
payload.height = meta.height;
}
payload.filesize = meta.size;
payload.metadata = {};
if (meta.icc) {
try {
payload.metadata.icc = parseICC(meta.icc);
} catch (err) {
logger.warn(`Couldn't extract ICC information from file`);
logger.warn(err);
}
}
if (meta.exif) {
try {
payload.metadata.exif = parseEXIF(meta.exif);
} catch (err) {
logger.warn(`Couldn't extract EXIF information from file`);
logger.warn(err);
}
}
if (meta.iptc) {
try {
payload.metadata.iptc = parseIPTC(meta.iptc);
payload.title = payload.metadata.iptc.headline || payload.title;
payload.description = payload.description || payload.metadata.iptc.caption;
payload.tags = payload.metadata.iptc.keywords;
} catch (err) {
logger.warn(`Couldn't extract IPTC information from file`);
logger.warn(err);
}
}
}
// We do this in a service without accountability. Even if you don't have update permissions to the file,
// we still want to be able to set the extracted values from the file on create
const sudoService = new ItemsService('directus_files', {
knex: this.knex,
schema: this.schema,
});
await sudoService.updateOne(primaryKey, payload, { emitEvents: false });
if (this.cache && env.CACHE_AUTO_PURGE) {
await this.cache.clear();
}
emitAsyncSafe(`files.upload`, {
event: `files.upload`,
accountability: this.accountability,
collection: this.collection,
item: primaryKey,
action: 'upload',
payload,
schema: this.schema,
database: this.knex,
});
return primaryKey;
}
/**
* Import a single file from an external URL
*/
async importOne(importURL: string, body: Partial<File>): Promise<PrimaryKey> {
const fileCreatePermissions = this.schema.permissions.find(
(permission) => permission.collection === 'directus_files' && permission.action === 'create'
);
if (this.accountability?.admin !== true && !fileCreatePermissions) {
throw new ForbiddenException();
}
let fileResponse: AxiosResponse<NodeJS.ReadableStream>;
try {
fileResponse = await axios.get<NodeJS.ReadableStream>(importURL, {
responseType: 'stream',
});
} catch (err) {
logger.warn(`Couldn't fetch file from url "${importURL}"`);
logger.warn(err);
throw new ServiceUnavailableException(`Couldn't fetch file from url "${importURL}"`, {
service: 'external-file',
});
}
const parsedURL = url.parse(fileResponse.request.res.responseUrl);
const filename = path.basename(parsedURL.pathname as string);
const payload = {
filename_download: filename,
storage: toArray(env.STORAGE_LOCATIONS)[0],
type: fileResponse.headers['content-type'],
title: formatTitle(filename),
...(body || {}),
};
return await this.uploadOne(fileResponse.data, payload);
}
/**
* Delete a file
*/
async deleteOne(key: PrimaryKey, opts?: MutationOptions): Promise<PrimaryKey> {
await this.deleteMany([key], opts);
return key;
}
/**
* Delete multiple files
*/
async deleteMany(keys: PrimaryKey[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const files = await super.readMany(keys, { fields: ['id', 'storage'] });
if (!files) {
throw new ForbiddenException();
}
await super.deleteMany(keys);
for (const file of files) {
const disk = storage.disk(file.storage);
// Delete file + thumbnails
for await (const { path } of disk.flatList(file.id)) {
await disk.delete(path);
}
}
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
await this.cache.clear();
}
return keys;
}
/**
* @deprecated Use `uploadOne` instead
*/
async upload(
stream: NodeJS.ReadableStream,
data: Partial<File> & { filename_download: string; storage: string },
primaryKey?: PrimaryKey
): Promise<PrimaryKey> {
logger.warn('FilesService.upload is deprecated and will be removed before v9.0.0. Use uploadOne instead.');
return await this.uploadOne(stream, data, primaryKey);
}
/**
* @deprecated Use `importOne` instead
*/
async import(importURL: string, body: Partial<File>): Promise<PrimaryKey> {
return await this.importOne(importURL, body);
}
/**
* @deprecated Use `deleteOne` or `deleteMany` instead
*/
delete(key: PrimaryKey): Promise<PrimaryKey>;
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
logger.warn(
'FilesService.delete is deprecated and will be removed before v9.0.0. Use deleteOne or deleteMany instead.'
);
if (Array.isArray(key)) return await this.deleteMany(key);
return await this.deleteOne(key);
}
}