@kollors/deep-json-server 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/files.js CHANGED
@@ -1,19 +1,69 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { createReadStream, createWriteStream } from 'node:fs';
3
- import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
- import { basename, dirname, join, resolve } from 'node:path';
5
- import { Transform } from 'node:stream';
3
+ import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
5
+ import { Readable, Transform } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
- import { createHttpError, isObject } from './utils.js';
7
+ import { createHttpError, createSerialQueue, isObject } from './utils.js';
8
+
9
+ /** @typedef {{ directory: string, mimeType: string, name: string }} StoredFileMetadata */
10
+ /** @typedef {StoredFileMetadata & { size: number }} FileRecord */
11
+ /** @typedef {FileRecord & { downloadUrl: string, metadataUrl: string, url: string }} FileMetadata */
12
+ /** @typedef {{ directory: string, maxFileSize: number, mimeType: string, name: string, override: boolean, stream: Readable }} FileUpload */
13
+ /** @typedef {{ directory?: string, name?: string }} FileUpdate */
14
+ /**
15
+ * @typedef {object} FileStore
16
+ * @property {(path: string) => Promise<{ file: FileRecord, stream: Readable }>} get Returns file metadata and contents.
17
+ * @property {(path: string) => Promise<FileRecord>} metadata Returns file metadata.
18
+ * @property {(path: string) => Promise<void>} remove Deletes a file.
19
+ * @property {(path: string, update: FileUpdate) => Promise<FileRecord>} update Renames or moves a file.
20
+ * @property {(upload: FileUpload) => Promise<{ created: boolean, file: FileRecord }>} upload Stores a file.
21
+ */
22
+
23
+ const PATCH_BODY_LIMIT = 64 * 1024;
24
+
25
+ const getFileKey = ({ directory, name }) => [directory, name].filter(Boolean).join('/');
26
+ const hasValidFileFields = (file) =>
27
+ isObject(file) && typeof file.directory === 'string' && typeof file.mimeType === 'string' && file.mimeType !== '' && typeof file.name === 'string' && file.name !== '';
28
+
29
+ const validateName = (value, source) => {
30
+ if (typeof value !== 'string' || value === '' || value === '.' || value === '..' || value.includes('/') || value.includes('\\') || value.includes('\0')) {
31
+ throw createHttpError(400, `${source} должен содержать безопасное имя файла`);
32
+ }
8
33
 
9
- const createFileId = (files) => {
10
- let id;
34
+ return value;
35
+ };
11
36
 
12
- do {
13
- id = randomBytes(8).toString('base64url');
14
- } while (files.some((file) => file.id === id));
37
+ const validateDirectory = (value, source) => {
38
+ if (typeof value !== 'string') {
39
+ throw createHttpError(400, `${source} должен содержать безопасный относительный путь`);
40
+ }
15
41
 
16
- return id;
42
+ if (value === '') {
43
+ return value;
44
+ }
45
+
46
+ const parts = value.split('/');
47
+
48
+ if (value.startsWith('/') || value.endsWith('/') || value.includes('\\') || value.includes('\0') || parts.some((part) => part === '' || part === '.' || part === '..')) {
49
+ throw createHttpError(400, `${source} должен содержать безопасный относительный путь`);
50
+ }
51
+
52
+ return value;
53
+ };
54
+
55
+ const validateStoredMetadata = (file) => {
56
+ if (!hasValidFileFields(file)) {
57
+ return false;
58
+ }
59
+
60
+ try {
61
+ validateName(file.name, 'Имя файла');
62
+ validateDirectory(file.directory, 'Директория файла');
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
17
67
  };
18
68
 
19
69
  const validateFileMetadata = (files, metadataPath) => {
@@ -21,29 +71,20 @@ const validateFileMetadata = (files, metadataPath) => {
21
71
  throw new Error(`Файл метаданных ${metadataPath} должен содержать JSON-массив`);
22
72
  }
23
73
 
24
- const ids = new Set();
74
+ const paths = new Set();
25
75
 
26
76
  files.forEach((file, index) => {
27
- if (
28
- !isObject(file) ||
29
- typeof file.id !== 'string' ||
30
- file.id === '' ||
31
- typeof file.mimeType !== 'string' ||
32
- file.mimeType === '' ||
33
- typeof file.name !== 'string' ||
34
- file.name === '' ||
35
- !Number.isInteger(file.size) ||
36
- file.size < 0 ||
37
- file.url !== `/_files/${file.id}`
38
- ) {
77
+ if (!validateStoredMetadata(file)) {
39
78
  throw new Error(`Некорректная запись ${index} в файле метаданных ${metadataPath}`);
40
79
  }
41
80
 
42
- if (ids.has(file.id)) {
43
- throw new Error(`Файл метаданных ${metadataPath} содержит повторяющийся id «${file.id}»`);
81
+ const path = getFileKey(file);
82
+
83
+ if (paths.has(path)) {
84
+ throw new Error(`Файл метаданных ${metadataPath} содержит повторяющийся путь «${path}»`);
44
85
  }
45
86
 
46
- ids.add(file.id);
87
+ paths.add(path);
47
88
  });
48
89
 
49
90
  return files;
@@ -65,6 +106,7 @@ const writeMetadata = async (metadataPath, files) => {
65
106
  const temporaryPath = `${metadataPath}.${randomBytes(6).toString('hex')}.tmp`;
66
107
 
67
108
  try {
109
+ // Atomic replacement prevents readers from seeing partially written JSON.
68
110
  await writeFile(temporaryPath, JSON.stringify(files, null, 2), { encoding: 'utf8', flag: 'wx' });
69
111
  await rename(temporaryPath, metadataPath);
70
112
  } catch (error) {
@@ -73,26 +115,31 @@ const writeMetadata = async (metadataPath, files) => {
73
115
  }
74
116
  };
75
117
 
76
- const getContentName = (value) => {
77
- if (typeof value !== 'string' || value === '') {
78
- throw createHttpError(400, 'Заголовок Content-Name обязателен');
118
+ const decodeHeader = (value, name) => {
119
+ if (typeof value !== 'string') {
120
+ throw createHttpError(400, `Заголовок ${name} обязателен`);
79
121
  }
80
122
 
81
- let name;
82
-
83
123
  try {
84
- name = decodeURIComponent(value).replaceAll('\\', '/');
124
+ return decodeURIComponent(value);
85
125
  } catch {
86
- throw createHttpError(400, 'Заголовок Content-Name содержит некорректное значение');
126
+ throw createHttpError(400, `Заголовок ${name} содержит некорректное значение`);
87
127
  }
128
+ };
129
+
130
+ const getContentName = (value) => validateName(decodeHeader(value, 'Content-Name'), 'Заголовок Content-Name');
131
+ const getContentDirectory = (value) => (value == null ? '' : validateDirectory(decodeHeader(value, 'Content-Directory'), 'Заголовок Content-Directory'));
88
132
 
89
- const parts = name.split('/');
133
+ const getContentOverride = (value) => {
134
+ if (value == null || value === 'false') {
135
+ return false;
136
+ }
90
137
 
91
- if (name.startsWith('/') || parts.some((part) => part === '' || part === '.' || part === '..' || part.includes('\0'))) {
92
- throw createHttpError(400, 'Заголовок Content-Name должен содержать безопасный относительный путь');
138
+ if (value === 'true') {
139
+ return true;
93
140
  }
94
141
 
95
- return name;
142
+ throw createHttpError(400, 'Заголовок Content-Override должен содержать true или false');
96
143
  };
97
144
 
98
145
  const getMimeType = (value) => {
@@ -105,6 +152,27 @@ const getMimeType = (value) => {
105
152
  return mimeType;
106
153
  };
107
154
 
155
+ const getPathLocation = (path) => {
156
+ const normalizedPath = validateDirectory(path, 'Путь файла');
157
+ const parts = normalizedPath.split('/');
158
+ const name = validateName(parts.pop(), 'Путь файла');
159
+
160
+ return { directory: parts.join('/'), name };
161
+ };
162
+
163
+ const encodeFilePath = (file) => getFileKey(file).split('/').map(encodeURIComponent).join('/');
164
+
165
+ const createFileMetadata = (file) => {
166
+ const path = encodeFilePath(file);
167
+
168
+ return {
169
+ ...file,
170
+ downloadUrl: `/_files/download/${path}`,
171
+ metadataUrl: `/_files/metadata/${path}`,
172
+ url: `/_files/storage/${path}`,
173
+ };
174
+ };
175
+
108
176
  const createSizeLimiter = (maxFileSize, onSize) => {
109
177
  let size = 0;
110
178
 
@@ -123,7 +191,32 @@ const createSizeLimiter = (maxFileSize, onSize) => {
123
191
  });
124
192
  };
125
193
 
126
- const createFileStore = async ({ directoryPath: sourceDirectoryPath, metadataPath: sourceMetadataPath }) => {
194
+ const pathExists = async (path) => {
195
+ try {
196
+ await access(path);
197
+ return true;
198
+ } catch (error) {
199
+ if (error?.code === 'ENOENT') {
200
+ return false;
201
+ }
202
+
203
+ throw error;
204
+ }
205
+ };
206
+
207
+ const getFileSize = async (path) => {
208
+ try {
209
+ return (await stat(path)).size;
210
+ } catch (error) {
211
+ if (error?.code === 'ENOENT') {
212
+ throw createHttpError(404, 'Файл не найден');
213
+ }
214
+
215
+ throw error;
216
+ }
217
+ };
218
+
219
+ const createDiskFileStore = async ({ directory: sourceDirectoryPath, metadata: sourceMetadataPath }) => {
127
220
  if (typeof sourceDirectoryPath !== 'string' || sourceDirectoryPath.trim() === '') {
128
221
  throw new Error('Путь к директории файлов не должен быть пустым');
129
222
  }
@@ -134,86 +227,162 @@ const createFileStore = async ({ directoryPath: sourceDirectoryPath, metadataPat
134
227
 
135
228
  const directoryPath = resolve(sourceDirectoryPath);
136
229
  const metadataPath = resolve(sourceMetadataPath);
137
- let operationQueue = Promise.resolve();
230
+ const schedule = createSerialQueue();
138
231
 
139
232
  await Promise.all([mkdir(directoryPath, { recursive: true }), mkdir(dirname(metadataPath), { recursive: true })]);
140
233
  await readMetadata(metadataPath);
141
234
 
142
- const schedule = (operation) => {
143
- const pendingOperation = operationQueue.then(operation);
235
+ const resolveFilePath = (path) => {
236
+ const filePath = resolve(directoryPath, path);
237
+ const relativePath = relative(directoryPath, filePath);
144
238
 
145
- operationQueue = pendingOperation.catch(() => undefined);
239
+ if (relativePath === '' || relativePath.startsWith('..') || isAbsolute(relativePath) || filePath === metadataPath) {
240
+ throw createHttpError(400, 'Путь файла выходит за пределы директории хранения');
241
+ }
146
242
 
147
- return pendingOperation;
243
+ return filePath;
148
244
  };
149
245
 
150
- const get = (id) =>
151
- schedule(async () => {
152
- const files = await readMetadata(metadataPath);
153
- const file = files.find((item) => item.id === id);
246
+ const getMetadata = async (path) => {
247
+ const files = await readMetadata(metadataPath);
248
+ const file = files.find((item) => getFileKey(item) === path);
154
249
 
155
- if (file == null) {
156
- throw createHttpError(404, 'Файл не найден');
157
- }
250
+ if (file == null) {
251
+ throw createHttpError(404, 'Файл не найден');
252
+ }
158
253
 
159
- const path = join(directoryPath, file.id);
254
+ const filePath = resolveFilePath(path);
160
255
 
161
- try {
162
- await access(path);
163
- } catch (error) {
164
- if (error?.code === 'ENOENT') {
165
- throw createHttpError(404, 'Файл не найден');
166
- }
256
+ return { ...file, size: await getFileSize(filePath) };
257
+ };
167
258
 
168
- throw error;
169
- }
259
+ const metadata = (path) => schedule(async () => getMetadata(path));
260
+ const get = (path) =>
261
+ schedule(async () => {
262
+ const file = await getMetadata(path);
170
263
 
171
- return { file, path };
264
+ return { file, stream: createReadStream(resolveFilePath(path)) };
172
265
  });
173
266
 
174
- const upload = ({ maxFileSize, mimeType, name, stream }) =>
267
+ const upload = ({ directory, maxFileSize, mimeType, name, override, stream }) =>
175
268
  schedule(async () => {
176
269
  const files = await readMetadata(metadataPath);
177
- const id = createFileId(files);
178
- const path = join(directoryPath, id);
179
- const temporaryPath = `${path}.upload`;
180
- let size = 0;
270
+ const storedFile = { directory, mimeType, name };
271
+ const file = { ...storedFile, size: 0 };
272
+ const key = getFileKey(file);
273
+ const index = files.findIndex((item) => getFileKey(item) === key);
274
+ const path = resolveFilePath(key);
275
+ const exists = index !== -1 || (await pathExists(path));
276
+
277
+ if (exists && !override) {
278
+ throw createHttpError(409, 'Файл уже существует');
279
+ }
280
+
281
+ await mkdir(dirname(path), { recursive: true });
282
+
283
+ const temporaryPath = `${path}.${randomBytes(6).toString('hex')}.upload`;
284
+ const backupPath = `${path}.${randomBytes(6).toString('hex')}.backup`;
285
+ let backedUp = false;
286
+ let installed = false;
181
287
 
182
288
  try {
183
289
  await pipeline(
184
290
  stream,
185
- createSizeLimiter(maxFileSize, (value) => (size = value)),
291
+ createSizeLimiter(maxFileSize, (size) => (file.size = size)),
186
292
  createWriteStream(temporaryPath, { flags: 'wx' }),
187
293
  );
294
+
295
+ if (await pathExists(path)) {
296
+ await rename(path, backupPath);
297
+ backedUp = true;
298
+ }
299
+
188
300
  await rename(temporaryPath, path);
301
+ installed = true;
189
302
 
190
- const file = { id, mimeType, name, size, url: `/_files/${id}` };
303
+ if (index === -1) {
304
+ files.push(storedFile);
305
+ } else {
306
+ files.splice(index, 1, storedFile);
307
+ }
191
308
 
192
- files.push(file);
193
309
  await writeMetadata(metadataPath, files);
310
+ await rm(backupPath, { force: true });
311
+
312
+ return { created: !exists, file };
313
+ } catch (error) {
314
+ await rm(temporaryPath, { force: true });
315
+
316
+ if (installed) {
317
+ await rm(path, { force: true });
318
+ }
194
319
 
195
- return file;
320
+ if (backedUp) {
321
+ await rename(backupPath, path);
322
+ }
323
+
324
+ throw error;
325
+ }
326
+ });
327
+
328
+ const update = (sourcePath, updates) =>
329
+ schedule(async () => {
330
+ const files = await readMetadata(metadataPath);
331
+ const index = files.findIndex((file) => getFileKey(file) === sourcePath);
332
+
333
+ if (index === -1) {
334
+ throw createHttpError(404, 'Файл не найден');
335
+ }
336
+
337
+ const file = files[index];
338
+ const updatedFile = { ...file, ...updates };
339
+ const targetPath = getFileKey(updatedFile);
340
+ const sourceFilePath = resolveFilePath(sourcePath);
341
+
342
+ if (!(await pathExists(sourceFilePath))) {
343
+ throw createHttpError(404, 'Файл не найден');
344
+ }
345
+
346
+ if (sourcePath === targetPath) {
347
+ return { ...file, size: await getFileSize(sourceFilePath) };
348
+ }
349
+
350
+ if (files.some((item, itemIndex) => itemIndex !== index && getFileKey(item) === targetPath) || (await pathExists(resolveFilePath(targetPath)))) {
351
+ throw createHttpError(409, 'Файл с таким путём уже существует');
352
+ }
353
+
354
+ const targetFilePath = resolveFilePath(targetPath);
355
+
356
+ await mkdir(dirname(targetFilePath), { recursive: true });
357
+ await rename(sourceFilePath, targetFilePath);
358
+ files.splice(index, 1, updatedFile);
359
+
360
+ try {
361
+ await writeMetadata(metadataPath, files);
196
362
  } catch (error) {
197
- await Promise.all([rm(temporaryPath, { force: true }), rm(path, { force: true })]);
363
+ await rename(targetFilePath, sourceFilePath);
198
364
  throw error;
199
365
  }
366
+
367
+ return { ...updatedFile, size: await getFileSize(targetFilePath) };
200
368
  });
201
369
 
202
- const remove = (id) =>
370
+ const remove = (path) =>
203
371
  schedule(async () => {
204
372
  const files = await readMetadata(metadataPath);
205
- const index = files.findIndex((file) => file.id === id);
373
+ const index = files.findIndex((file) => getFileKey(file) === path);
206
374
 
207
375
  if (index === -1) {
208
376
  throw createHttpError(404, 'Файл не найден');
209
377
  }
210
378
 
211
- const [file] = files.splice(index, 1);
212
- const path = join(directoryPath, file.id);
213
- const temporaryPath = `${path}.${randomBytes(6).toString('hex')}.delete`;
379
+ files.splice(index, 1);
380
+
381
+ const filePath = resolveFilePath(path);
382
+ const temporaryPath = `${filePath}.${randomBytes(6).toString('hex')}.delete`;
214
383
 
215
384
  try {
216
- await rename(path, temporaryPath);
385
+ await rename(filePath, temporaryPath);
217
386
  } catch (error) {
218
387
  if (error?.code === 'ENOENT') {
219
388
  throw createHttpError(404, 'Файл не найден');
@@ -225,54 +394,240 @@ const createFileStore = async ({ directoryPath: sourceDirectoryPath, metadataPat
225
394
  try {
226
395
  await writeMetadata(metadataPath, files);
227
396
  } catch (error) {
228
- await rename(temporaryPath, path);
397
+ await rename(temporaryPath, filePath);
229
398
  throw error;
230
399
  }
231
400
 
232
401
  await rm(temporaryPath, { force: true }).catch(() => undefined);
402
+ });
403
+
404
+ return { get, metadata, remove, update, upload };
405
+ };
406
+
407
+ const createMemoryFileStore = (sourceFiles) => {
408
+ const paths = new Set();
409
+ const storedFiles = sourceFiles.map((sourceFile, index) => {
410
+ const directory = sourceFile.directory ?? '';
411
+ const file = { directory, mimeType: sourceFile.mimeType, name: sourceFile.name, size: sourceFile.content?.length };
412
+
413
+ if (!validateStoredMetadata(file) || !Number.isInteger(file.size) || file.size < 0 || !(sourceFile.content instanceof Uint8Array)) {
414
+ throw new Error(`Некорректная запись ${index} в config.files.data`);
415
+ }
416
+
417
+ const path = getFileKey(file);
418
+
419
+ if (paths.has(path)) {
420
+ throw new Error(`config.files.data содержит повторяющийся путь «${path}»`);
421
+ }
422
+
423
+ paths.add(path);
424
+
425
+ return { content: Buffer.from(sourceFile.content), file };
426
+ });
427
+ const schedule = createSerialQueue();
428
+
429
+ const findStoredFile = (path) => {
430
+ const storedFile = storedFiles.find(({ file }) => getFileKey(file) === path);
431
+
432
+ if (storedFile == null) {
433
+ throw createHttpError(404, 'Файл не найден');
434
+ }
435
+
436
+ return storedFile;
437
+ };
438
+
439
+ const metadata = (path) => schedule(async () => findStoredFile(path).file);
440
+
441
+ const get = (path) =>
442
+ schedule(async () => {
443
+ const storedFile = findStoredFile(path);
444
+
445
+ return { file: storedFile.file, stream: Readable.from([storedFile.content]) };
446
+ });
447
+
448
+ const upload = ({ directory, maxFileSize, mimeType, name, override, stream }) =>
449
+ schedule(async () => {
450
+ const chunks = [];
451
+ let size = 0;
452
+
453
+ for await (const chunk of stream) {
454
+ const buffer = Buffer.from(chunk);
455
+
456
+ size += buffer.length;
457
+
458
+ if (size > maxFileSize) {
459
+ throw createHttpError(413, `Размер файла не должен превышать ${maxFileSize} байт`);
460
+ }
461
+
462
+ chunks.push(buffer);
463
+ }
464
+
465
+ const file = { directory, mimeType, name, size };
466
+ const path = getFileKey(file);
467
+ const index = storedFiles.findIndex((item) => getFileKey(item.file) === path);
468
+
469
+ if (index !== -1 && !override) {
470
+ throw createHttpError(409, 'Файл уже существует');
471
+ }
472
+
473
+ const storedFile = { content: Buffer.concat(chunks), file };
474
+
475
+ if (index === -1) {
476
+ storedFiles.push(storedFile);
477
+ } else {
478
+ storedFiles.splice(index, 1, storedFile);
479
+ }
480
+
481
+ return { created: index === -1, file };
482
+ });
483
+
484
+ const update = (sourcePath, updates) =>
485
+ schedule(async () => {
486
+ const index = storedFiles.findIndex(({ file }) => getFileKey(file) === sourcePath);
487
+
488
+ if (index === -1) {
489
+ throw createHttpError(404, 'Файл не найден');
490
+ }
491
+
492
+ const storedFile = storedFiles[index];
493
+ const file = { ...storedFile.file, ...updates };
494
+ const targetPath = getFileKey(file);
495
+
496
+ if (sourcePath !== targetPath && storedFiles.some((item, itemIndex) => itemIndex !== index && getFileKey(item.file) === targetPath)) {
497
+ throw createHttpError(409, 'Файл с таким путём уже существует');
498
+ }
499
+
500
+ storedFiles.splice(index, 1, { ...storedFile, file });
233
501
 
234
502
  return file;
235
503
  });
236
504
 
237
- return { get, remove, upload };
505
+ const remove = (path) =>
506
+ schedule(async () => {
507
+ const index = storedFiles.findIndex(({ file }) => getFileKey(file) === path);
508
+
509
+ if (index === -1) {
510
+ throw createHttpError(404, 'Файл не найден');
511
+ }
512
+
513
+ storedFiles.splice(index, 1);
514
+ });
515
+
516
+ return { get, metadata, remove, update, upload };
517
+ };
518
+
519
+ const readJsonObject = async (stream) => {
520
+ const chunks = [];
521
+ let size = 0;
522
+
523
+ for await (const chunk of stream) {
524
+ const buffer = Buffer.from(chunk);
525
+
526
+ size += buffer.length;
527
+
528
+ if (size > PATCH_BODY_LIMIT) {
529
+ throw createHttpError(413, `Размер тела запроса не должен превышать ${PATCH_BODY_LIMIT} байт`);
530
+ }
531
+
532
+ chunks.push(buffer);
533
+ }
534
+
535
+ let body;
536
+
537
+ try {
538
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
539
+ } catch {
540
+ throw createHttpError(400, 'Тело запроса должно содержать корректный JSON');
541
+ }
542
+
543
+ if (!isObject(body)) {
544
+ throw createHttpError(400, 'Тело запроса должно быть JSON-объектом');
545
+ }
546
+
547
+ const unknownKey = Object.keys(body).find((key) => key !== 'directory' && key !== 'name');
548
+
549
+ if (unknownKey != null) {
550
+ throw createHttpError(400, `Неизвестный ключ body.${unknownKey}`);
551
+ }
552
+
553
+ if (body.directory == null && body.name == null) {
554
+ throw createHttpError(400, 'Укажите новое имя или директорию файла');
555
+ }
556
+
557
+ return {
558
+ ...(body.directory != null && { directory: validateDirectory(body.directory, 'Ключ body.directory') }),
559
+ ...(body.name != null && { name: validateName(body.name, 'Ключ body.name') }),
560
+ };
238
561
  };
239
562
 
563
+ /**
564
+ * Creates a disk- or memory-backed file store with serialized operations.
565
+ * @param {import('./config.js').FilesConfig} config File storage configuration.
566
+ * @returns {Promise<FileStore>} File store.
567
+ */
568
+ export const createFileStore = async (config) => ('data' in config ? createMemoryFileStore(config.data) : createDiskFileStore(config));
569
+
240
570
  const getDownloadName = (name) => encodeURIComponent(basename(name)).replaceAll("'", '%27');
241
571
 
242
- export const registerFileRoutes = async (server, { directoryPath, maxFileSize, metadataPath }) => {
243
- const store = await createFileStore({ directoryPath, metadataPath });
572
+ const sendFile = async (store, path, reply, disposition) => {
573
+ const { file, stream } = await store.get(path);
574
+
575
+ reply.header('Content-Disposition', `${disposition}; filename*=UTF-8''${getDownloadName(file.name)}`);
576
+ reply.type(file.mimeType);
577
+
578
+ return reply.send(stream);
579
+ };
244
580
 
245
- server.register((fileServer, _options, done) => {
581
+ export const registerFileRoutes = (fastify, { maxFileSize, store }) => {
582
+ fastify.register((fileServer, _options, done) => {
246
583
  fileServer.removeAllContentTypeParsers();
247
584
  fileServer.addContentTypeParser('*', (_request, payload, parserDone) => parserDone(null, payload));
248
585
 
249
- fileServer.post('/_files', async (request, reply) => {
586
+ fileServer.post('/_files/storage', async (request, reply) => {
250
587
  const contentLength = Number(request.headers['content-length']);
251
588
 
252
589
  if (Number.isFinite(contentLength) && contentLength > maxFileSize) {
253
590
  throw createHttpError(413, `Размер файла не должен превышать ${maxFileSize} байт`);
254
591
  }
255
592
 
256
- const file = await store.upload({
593
+ const result = await store.upload({
594
+ directory: getContentDirectory(request.headers['content-directory']),
257
595
  maxFileSize,
258
596
  mimeType: getMimeType(request.headers['content-type']),
259
597
  name: getContentName(request.headers['content-name']),
598
+ override: getContentOverride(request.headers['content-override']),
260
599
  stream: request.body,
261
600
  });
262
601
 
263
- return reply.code(201).send(file);
602
+ return reply.code(result.created ? 201 : 200).send(createFileMetadata(result.file));
603
+ });
604
+
605
+ fileServer.get('/_files/storage/*', async (request, reply) => sendFile(store, getFileKey(getPathLocation(request.params['*'])), reply, 'inline'));
606
+ fileServer.get('/_files/download/*', async (request, reply) => sendFile(store, getFileKey(getPathLocation(request.params['*'])), reply, 'attachment'));
607
+
608
+ fileServer.get('/_files/metadata/*', async (request) => {
609
+ const file = await store.metadata(getFileKey(getPathLocation(request.params['*'])));
610
+
611
+ return createFileMetadata(file);
264
612
  });
265
613
 
266
- fileServer.get('/_files/:id', async (request, reply) => {
267
- const { file, path } = await store.get(request.params.id);
614
+ fileServer.patch('/_files/storage/*', async (request) => {
615
+ if (getMimeType(request.headers['content-type']) !== 'application/json') {
616
+ throw createHttpError(415, 'Для изменения файла используйте Content-Type: application/json');
617
+ }
618
+
619
+ const sourcePath = getFileKey(getPathLocation(request.params['*']));
620
+ const update = await readJsonObject(request.body);
621
+
622
+ return createFileMetadata(await store.update(sourcePath, update));
623
+ });
268
624
 
269
- reply.header('Content-Disposition', `inline; filename*=UTF-8''${getDownloadName(file.name)}`);
270
- reply.type(file.mimeType);
625
+ fileServer.delete('/_files/storage/*', async (request, reply) => {
626
+ await store.remove(getFileKey(getPathLocation(request.params['*'])));
271
627
 
272
- return reply.send(createReadStream(path));
628
+ return reply.code(204).send();
273
629
  });
274
630
 
275
- fileServer.delete('/_files/:id', async (request) => store.remove(request.params.id));
276
631
  done();
277
632
  });
278
633
  };