@kollors/deep-json-server 0.6.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';
3
+ import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
5
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
+ }
33
+
34
+ return value;
35
+ };
8
36
 
9
- const createFileId = (files) => {
10
- let id;
37
+ const validateDirectory = (value, source) => {
38
+ if (typeof value !== 'string') {
39
+ throw createHttpError(400, `${source} должен содержать безопасный относительный путь`);
40
+ }
41
+
42
+ if (value === '') {
43
+ return value;
44
+ }
11
45
 
12
- do {
13
- id = randomBytes(8).toString('base64url');
14
- } while (files.some((file) => file.id === id));
46
+ const parts = value.split('/');
15
47
 
16
- return id;
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
+ };
88
129
 
89
- const parts = name.split('/');
130
+ const getContentName = (value) => validateName(decodeHeader(value, 'Content-Name'), 'Заголовок Content-Name');
131
+ const getContentDirectory = (value) => (value == null ? '' : validateDirectory(decodeHeader(value, 'Content-Directory'), 'Заголовок Content-Directory'));
90
132
 
91
- if (name.startsWith('/') || parts.some((part) => part === '' || part === '.' || part === '..' || part.includes('\0'))) {
92
- throw createHttpError(400, 'Заголовок Content-Name должен содержать безопасный относительный путь');
133
+ const getContentOverride = (value) => {
134
+ if (value == null || value === 'false') {
135
+ return false;
93
136
  }
94
137
 
95
- return name;
138
+ if (value === 'true') {
139
+ return true;
140
+ }
141
+
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,6 +191,31 @@ const createSizeLimiter = (maxFileSize, onSize) => {
123
191
  });
124
192
  };
125
193
 
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
+
126
219
  const createDiskFileStore = async ({ directory: sourceDirectoryPath, metadata: sourceMetadataPath }) => {
127
220
  if (typeof sourceDirectoryPath !== 'string' || sourceDirectoryPath.trim() === '') {
128
221
  throw new Error('Путь к директории файлов не должен быть пустым');
@@ -134,86 +227,162 @@ const createDiskFileStore = async ({ directory: sourceDirectoryPath, metadata: s
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, stream: createReadStream(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 });
194
311
 
195
- return file;
312
+ return { created: !exists, file };
196
313
  } catch (error) {
197
- await Promise.all([rm(temporaryPath, { force: true }), rm(path, { force: true })]);
314
+ await rm(temporaryPath, { force: true });
315
+
316
+ if (installed) {
317
+ await rm(path, { force: true });
318
+ }
319
+
320
+ if (backedUp) {
321
+ await rename(backupPath, path);
322
+ }
323
+
198
324
  throw error;
199
325
  }
200
326
  });
201
327
 
202
- const remove = (id) =>
328
+ const update = (sourcePath, updates) =>
203
329
  schedule(async () => {
204
330
  const files = await readMetadata(metadataPath);
205
- const index = files.findIndex((file) => file.id === id);
331
+ const index = files.findIndex((file) => getFileKey(file) === sourcePath);
206
332
 
207
333
  if (index === -1) {
208
334
  throw createHttpError(404, 'Файл не найден');
209
335
  }
210
336
 
211
- const [file] = files.splice(index, 1);
212
- const path = join(directoryPath, file.id);
213
- const temporaryPath = `${path}.${randomBytes(6).toString('hex')}.delete`;
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);
214
359
 
215
360
  try {
216
- await rename(path, temporaryPath);
361
+ await writeMetadata(metadataPath, files);
362
+ } catch (error) {
363
+ await rename(targetFilePath, sourceFilePath);
364
+ throw error;
365
+ }
366
+
367
+ return { ...updatedFile, size: await getFileSize(targetFilePath) };
368
+ });
369
+
370
+ const remove = (path) =>
371
+ schedule(async () => {
372
+ const files = await readMetadata(metadataPath);
373
+ const index = files.findIndex((file) => getFileKey(file) === path);
374
+
375
+ if (index === -1) {
376
+ throw createHttpError(404, 'Файл не найден');
377
+ }
378
+
379
+ files.splice(index, 1);
380
+
381
+ const filePath = resolveFilePath(path);
382
+ const temporaryPath = `${filePath}.${randomBytes(6).toString('hex')}.delete`;
383
+
384
+ try {
385
+ await rename(filePath, temporaryPath);
217
386
  } catch (error) {
218
387
  if (error?.code === 'ENOENT') {
219
388
  throw createHttpError(404, 'Файл не найден');
@@ -225,69 +394,58 @@ const createDiskFileStore = async ({ directory: sourceDirectoryPath, metadata: s
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);
233
-
234
- return file;
235
402
  });
236
403
 
237
- return { get, remove, upload };
404
+ return { get, metadata, remove, update, upload };
238
405
  };
239
406
 
240
407
  const createMemoryFileStore = (sourceFiles) => {
241
- const ids = new Set();
408
+ const paths = new Set();
242
409
  const storedFiles = sourceFiles.map((sourceFile, index) => {
243
- if (
244
- !isObject(sourceFile) ||
245
- typeof sourceFile.id !== 'string' ||
246
- sourceFile.id === '' ||
247
- typeof sourceFile.mimeType !== 'string' ||
248
- sourceFile.mimeType === '' ||
249
- typeof sourceFile.name !== 'string' ||
250
- sourceFile.name === '' ||
251
- !(sourceFile.content instanceof Uint8Array)
252
- ) {
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)) {
253
414
  throw new Error(`Некорректная запись ${index} в config.files.data`);
254
415
  }
255
416
 
256
- if (ids.has(sourceFile.id)) {
257
- throw new Error(`config.files.data содержит повторяющийся id «${sourceFile.id}»`);
258
- }
417
+ const path = getFileKey(file);
259
418
 
260
- ids.add(sourceFile.id);
419
+ if (paths.has(path)) {
420
+ throw new Error(`config.files.data содержит повторяющийся путь «${path}»`);
421
+ }
261
422
 
262
- const content = Buffer.from(sourceFile.content);
423
+ paths.add(path);
263
424
 
264
- return {
265
- content,
266
- file: { id: sourceFile.id, mimeType: sourceFile.mimeType, name: sourceFile.name, size: content.length, url: `/_files/${sourceFile.id}` },
267
- };
425
+ return { content: Buffer.from(sourceFile.content), file };
268
426
  });
269
- let operationQueue = Promise.resolve();
427
+ const schedule = createSerialQueue();
270
428
 
271
- const schedule = (operation) => {
272
- const pendingOperation = operationQueue.then(operation);
429
+ const findStoredFile = (path) => {
430
+ const storedFile = storedFiles.find(({ file }) => getFileKey(file) === path);
273
431
 
274
- operationQueue = pendingOperation.catch(() => undefined);
432
+ if (storedFile == null) {
433
+ throw createHttpError(404, 'Файл не найден');
434
+ }
275
435
 
276
- return pendingOperation;
436
+ return storedFile;
277
437
  };
278
438
 
279
- const get = (id) =>
280
- schedule(async () => {
281
- const storedFile = storedFiles.find(({ file }) => file.id === id);
439
+ const metadata = (path) => schedule(async () => findStoredFile(path).file);
282
440
 
283
- if (storedFile == null) {
284
- throw createHttpError(404, 'Файл не найден');
285
- }
441
+ const get = (path) =>
442
+ schedule(async () => {
443
+ const storedFile = findStoredFile(path);
286
444
 
287
445
  return { file: storedFile.file, stream: Readable.from([storedFile.content]) };
288
446
  });
289
447
 
290
- const upload = ({ maxFileSize, mimeType, name, stream }) =>
448
+ const upload = ({ directory, maxFileSize, mimeType, name, override, stream }) =>
291
449
  schedule(async () => {
292
450
  const chunks = [];
293
451
  let size = 0;
@@ -304,65 +462,172 @@ const createMemoryFileStore = (sourceFiles) => {
304
462
  chunks.push(buffer);
305
463
  }
306
464
 
307
- const id = createFileId(storedFiles.map(({ file }) => file));
308
- const file = { id, mimeType, name, size, url: `/_files/${id}` };
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
+ });
309
483
 
310
- storedFiles.push({ content: Buffer.concat(chunks), file });
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 });
311
501
 
312
502
  return file;
313
503
  });
314
504
 
315
- const remove = (id) =>
505
+ const remove = (path) =>
316
506
  schedule(async () => {
317
- const index = storedFiles.findIndex(({ file }) => file.id === id);
507
+ const index = storedFiles.findIndex(({ file }) => getFileKey(file) === path);
318
508
 
319
509
  if (index === -1) {
320
510
  throw createHttpError(404, 'Файл не найден');
321
511
  }
322
512
 
323
- return storedFiles.splice(index, 1)[0].file;
513
+ storedFiles.splice(index, 1);
324
514
  });
325
515
 
326
- return { get, remove, upload };
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
+ };
327
561
  };
328
562
 
329
- /** @param {import('./config.js').FilesConfig} config File storage configuration. */
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
+ */
330
568
  export const createFileStore = async (config) => ('data' in config ? createMemoryFileStore(config.data) : createDiskFileStore(config));
331
569
 
332
570
  const getDownloadName = (name) => encodeURIComponent(basename(name)).replaceAll("'", '%27');
333
571
 
334
- export const registerFileRoutes = (server, { maxFileSize, store }) => {
335
- server.register((fileServer, _options, done) => {
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
+ };
580
+
581
+ export const registerFileRoutes = (fastify, { maxFileSize, store }) => {
582
+ fastify.register((fileServer, _options, done) => {
336
583
  fileServer.removeAllContentTypeParsers();
337
584
  fileServer.addContentTypeParser('*', (_request, payload, parserDone) => parserDone(null, payload));
338
585
 
339
- fileServer.post('/_files', async (request, reply) => {
586
+ fileServer.post('/_files/storage', async (request, reply) => {
340
587
  const contentLength = Number(request.headers['content-length']);
341
588
 
342
589
  if (Number.isFinite(contentLength) && contentLength > maxFileSize) {
343
590
  throw createHttpError(413, `Размер файла не должен превышать ${maxFileSize} байт`);
344
591
  }
345
592
 
346
- const file = await store.upload({
593
+ const result = await store.upload({
594
+ directory: getContentDirectory(request.headers['content-directory']),
347
595
  maxFileSize,
348
596
  mimeType: getMimeType(request.headers['content-type']),
349
597
  name: getContentName(request.headers['content-name']),
598
+ override: getContentOverride(request.headers['content-override']),
350
599
  stream: request.body,
351
600
  });
352
601
 
353
- 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);
354
612
  });
355
613
 
356
- fileServer.get('/_files/:id', async (request, reply) => {
357
- const { file, stream } = 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
+ });
358
624
 
359
- reply.header('Content-Disposition', `inline; filename*=UTF-8''${getDownloadName(file.name)}`);
360
- reply.type(file.mimeType);
625
+ fileServer.delete('/_files/storage/*', async (request, reply) => {
626
+ await store.remove(getFileKey(getPathLocation(request.params['*'])));
361
627
 
362
- return reply.send(stream);
628
+ return reply.code(204).send();
363
629
  });
364
630
 
365
- fileServer.delete('/_files/:id', async (request) => store.remove(request.params.id));
366
631
  done();
367
632
  });
368
633
  };