directus
369 строк · 8.9 Кб
1import { useEnv } from '@directus/env';
2import { ErrorCode, InvalidPayloadError, isDirectusError } from '@directus/errors';
3import formatTitle from '@directus/format-title';
4import type { BusboyFileStream, PrimaryKey } from '@directus/types';
5import { toArray } from '@directus/utils';
6import Busboy from 'busboy';
7import bytes from 'bytes';
8import type { RequestHandler } from 'express';
9import express from 'express';
10import Joi from 'joi';
11import { minimatch } from 'minimatch';
12import path from 'path';
13import { respond } from '../middleware/respond.js';
14import useCollection from '../middleware/use-collection.js';
15import { validateBatch } from '../middleware/validate-batch.js';
16import { FilesService } from '../services/files.js';
17import { MetaService } from '../services/meta.js';
18import asyncHandler from '../utils/async-handler.js';
19import { sanitizeQuery } from '../utils/sanitize-query.js';
20
21const router = express.Router();
22const env = useEnv();
23
24router.use(useCollection('directus_files'));
25
26export const multipartHandler: RequestHandler = (req, res, next) => {
27if (req.is('multipart/form-data') === false) return next();
28
29let headers;
30
31if (req.headers['content-type']) {
32headers = req.headers;
33} else {
34headers = {
35...req.headers,
36'content-type': 'application/octet-stream',
37};
38}
39
40const busboy = Busboy({
41headers,
42defParamCharset: 'utf8',
43limits: {
44fileSize: env['FILES_MAX_UPLOAD_SIZE'] ? bytes(env['FILES_MAX_UPLOAD_SIZE'] as string) : undefined,
45},
46});
47
48const savedFiles: PrimaryKey[] = [];
49const service = new FilesService({ accountability: req.accountability, schema: req.schema });
50
51const existingPrimaryKey = req.params['pk'] || undefined;
52
53/**
54* The order of the fields in multipart/form-data is important. We require that all fields
55* are provided _before_ the files. This allows us to set the storage location, and create
56* the row in directus_files async during the upload of the actual file.
57*/
58
59let disk: string = toArray(env['STORAGE_LOCATIONS'] as string)[0]!;
60let payload: any = {};
61let fileCount = 0;
62
63busboy.on('field', (fieldname, val) => {
64let fieldValue: string | null | boolean = val;
65
66if (typeof fieldValue === 'string' && fieldValue.trim() === 'null') fieldValue = null;
67if (typeof fieldValue === 'string' && fieldValue.trim() === 'false') fieldValue = false;
68if (typeof fieldValue === 'string' && fieldValue.trim() === 'true') fieldValue = true;
69
70if (fieldname === 'storage') {
71disk = val;
72}
73
74payload[fieldname] = fieldValue;
75});
76
77busboy.on('file', async (_fieldname, fileStream: BusboyFileStream, { filename, mimeType }) => {
78if (!filename) {
79return busboy.emit('error', new InvalidPayloadError({ reason: `File is missing filename` }));
80}
81
82const allowedPatterns = toArray(env['FILES_MIME_TYPE_ALLOW_LIST'] as string | string[]);
83const mimeTypeAllowed = allowedPatterns.some((pattern) => minimatch(mimeType, pattern));
84
85if (mimeTypeAllowed === false) {
86return busboy.emit('error', new InvalidPayloadError({ reason: `File is of invalid content type` }));
87}
88
89fileCount++;
90
91if (!existingPrimaryKey) {
92if (!payload.title) {
93payload.title = formatTitle(path.parse(filename).name);
94}
95}
96
97payload.filename_download = filename;
98
99const payloadWithRequiredFields = {
100...payload,
101type: mimeType,
102storage: payload.storage || disk,
103};
104
105// Clear the payload for the next to-be-uploaded file
106payload = {};
107
108try {
109const primaryKey = await service.uploadOne(fileStream, payloadWithRequiredFields, existingPrimaryKey);
110savedFiles.push(primaryKey);
111tryDone();
112} catch (error: any) {
113busboy.emit('error', error);
114}
115
116return undefined;
117});
118
119busboy.on('error', (error: Error) => {
120next(error);
121});
122
123busboy.on('close', () => {
124tryDone();
125});
126
127req.pipe(busboy);
128
129function tryDone() {
130if (savedFiles.length === fileCount) {
131if (fileCount === 0) {
132return next(new InvalidPayloadError({ reason: `No files were included in the body` }));
133}
134
135res.locals['savedFiles'] = savedFiles;
136return next();
137}
138}
139};
140
141router.post(
142'/',
143asyncHandler(multipartHandler),
144asyncHandler(async (req, res, next) => {
145const service = new FilesService({
146accountability: req.accountability,
147schema: req.schema,
148});
149
150let keys: PrimaryKey | PrimaryKey[] = [];
151
152if (req.is('multipart/form-data')) {
153keys = res.locals['savedFiles'];
154} else {
155keys = await service.createOne(req.body);
156}
157
158try {
159if (Array.isArray(keys) && keys.length > 1) {
160const records = await service.readMany(keys, req.sanitizedQuery);
161
162res.locals['payload'] = {
163data: records,
164};
165} else {
166const key = Array.isArray(keys) ? keys[0]! : keys;
167const record = await service.readOne(key, req.sanitizedQuery);
168
169res.locals['payload'] = {
170data: record,
171};
172}
173} catch (error: any) {
174if (isDirectusError(error, ErrorCode.Forbidden)) {
175return next();
176}
177
178throw error;
179}
180
181return next();
182}),
183respond,
184);
185
186const importSchema = Joi.object({
187url: Joi.string().required(),
188data: Joi.object(),
189});
190
191router.post(
192'/import',
193asyncHandler(async (req, res, next) => {
194const { error } = importSchema.validate(req.body);
195
196if (error) {
197throw new InvalidPayloadError({ reason: error.message });
198}
199
200const service = new FilesService({
201accountability: req.accountability,
202schema: req.schema,
203});
204
205const primaryKey = await service.importOne(req.body.url, req.body.data);
206
207try {
208const record = await service.readOne(primaryKey, req.sanitizedQuery);
209res.locals['payload'] = { data: record || null };
210} catch (error: any) {
211if (isDirectusError(error, ErrorCode.Forbidden)) {
212return next();
213}
214
215throw error;
216}
217
218return next();
219}),
220respond,
221);
222
223const readHandler = asyncHandler(async (req, res, next) => {
224const service = new FilesService({
225accountability: req.accountability,
226schema: req.schema,
227});
228
229const metaService = new MetaService({
230accountability: req.accountability,
231schema: req.schema,
232});
233
234let result;
235
236if (req.singleton) {
237result = await service.readSingleton(req.sanitizedQuery);
238} else if (req.body.keys) {
239result = await service.readMany(req.body.keys, req.sanitizedQuery);
240} else {
241result = await service.readByQuery(req.sanitizedQuery);
242}
243
244const meta = await metaService.getMetaForQuery('directus_files', req.sanitizedQuery);
245
246res.locals['payload'] = { data: result, meta };
247return next();
248});
249
250router.get('/', validateBatch('read'), readHandler, respond);
251router.search('/', validateBatch('read'), readHandler, respond);
252
253router.get(
254'/:pk',
255asyncHandler(async (req, res, next) => {
256const service = new FilesService({
257accountability: req.accountability,
258schema: req.schema,
259});
260
261const record = await service.readOne(req.params['pk']!, req.sanitizedQuery);
262res.locals['payload'] = { data: record || null };
263return next();
264}),
265respond,
266);
267
268router.patch(
269'/',
270validateBatch('update'),
271asyncHandler(async (req, res, next) => {
272const service = new FilesService({
273accountability: req.accountability,
274schema: req.schema,
275});
276
277let keys: PrimaryKey[] = [];
278
279if (Array.isArray(req.body)) {
280keys = await service.updateBatch(req.body);
281} else if (req.body.keys) {
282keys = await service.updateMany(req.body.keys, req.body.data);
283} else {
284const sanitizedQuery = sanitizeQuery(req.body.query, req.accountability);
285keys = await service.updateByQuery(sanitizedQuery, req.body.data);
286}
287
288try {
289const result = await service.readMany(keys, req.sanitizedQuery);
290res.locals['payload'] = { data: result || null };
291} catch (error: any) {
292if (isDirectusError(error, ErrorCode.Forbidden)) {
293return next();
294}
295
296throw error;
297}
298
299return next();
300}),
301respond,
302);
303
304router.patch(
305'/:pk',
306asyncHandler(multipartHandler),
307asyncHandler(async (req, res, next) => {
308const service = new FilesService({
309accountability: req.accountability,
310schema: req.schema,
311});
312
313await service.updateOne(req.params['pk']!, req.body);
314
315try {
316const record = await service.readOne(req.params['pk']!, req.sanitizedQuery);
317res.locals['payload'] = { data: record || null };
318} catch (error: any) {
319if (isDirectusError(error, ErrorCode.Forbidden)) {
320return next();
321}
322
323throw error;
324}
325
326return next();
327}),
328respond,
329);
330
331router.delete(
332'/',
333validateBatch('delete'),
334asyncHandler(async (req, _res, next) => {
335const service = new FilesService({
336accountability: req.accountability,
337schema: req.schema,
338});
339
340if (Array.isArray(req.body)) {
341await service.deleteMany(req.body);
342} else if (req.body.keys) {
343await service.deleteMany(req.body.keys);
344} else {
345const sanitizedQuery = sanitizeQuery(req.body.query, req.accountability);
346await service.deleteByQuery(sanitizedQuery);
347}
348
349return next();
350}),
351respond,
352);
353
354router.delete(
355'/:pk',
356asyncHandler(async (req, _res, next) => {
357const service = new FilesService({
358accountability: req.accountability,
359schema: req.schema,
360});
361
362await service.deleteOne(req.params['pk']!);
363
364return next();
365}),
366respond,
367);
368
369export default router;
370