@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/README.md +229 -113
- package/README.ru.md +229 -113
- package/index.js +12 -2
- package/package.json +9 -2
- package/src/cli.js +45 -44
- package/src/config.js +141 -35
- package/src/constants.js +1 -1
- package/src/database.js +40 -20
- package/src/files.js +445 -90
- package/src/openapi/config.js +15 -1
- package/src/openapi/document.js +124 -61
- package/src/openapi/index.js +29 -18
- package/src/query/filter.js +11 -10
- package/src/query/pagination.js +3 -9
- package/src/relation-metadata.js +1 -0
- package/src/server.js +104 -92
- package/src/utils.js +30 -0
- package/types/index.d.ts +21 -2
- package/types/src/config.d.ts +73 -0
- package/types/src/constants.d.ts +1 -1
- package/types/src/database.d.ts +28 -3
- package/types/src/files.d.ts +63 -4
- package/types/src/openapi/config.d.ts +1 -1
- package/types/src/openapi/document.d.ts +6 -7
- package/types/src/openapi/index.d.ts +9 -14
- package/types/src/query/pagination.d.ts +1 -6
- package/types/src/server.d.ts +12 -28
- package/types/src/utils.d.ts +8 -0
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,
|
|
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
|
-
|
|
10
|
-
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
11
36
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
37
|
+
const validateDirectory = (value, source) => {
|
|
38
|
+
if (typeof value !== 'string') {
|
|
39
|
+
throw createHttpError(400, `${source} должен содержать безопасный относительный путь`);
|
|
40
|
+
}
|
|
15
41
|
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
81
|
+
const path = getFileKey(file);
|
|
82
|
+
|
|
83
|
+
if (paths.has(path)) {
|
|
84
|
+
throw new Error(`Файл метаданных ${metadataPath} содержит повторяющийся путь «${path}»`);
|
|
44
85
|
}
|
|
45
86
|
|
|
46
|
-
|
|
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
|
|
77
|
-
if (typeof value !== 'string'
|
|
78
|
-
throw createHttpError(400,
|
|
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
|
-
|
|
124
|
+
return decodeURIComponent(value);
|
|
85
125
|
} catch {
|
|
86
|
-
throw createHttpError(400,
|
|
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
|
-
|
|
133
|
+
const getContentOverride = (value) => {
|
|
134
|
+
if (value == null || value === 'false') {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
90
137
|
|
|
91
|
-
if (
|
|
92
|
-
|
|
138
|
+
if (value === 'true') {
|
|
139
|
+
return true;
|
|
93
140
|
}
|
|
94
141
|
|
|
95
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
143
|
-
const
|
|
235
|
+
const resolveFilePath = (path) => {
|
|
236
|
+
const filePath = resolve(directoryPath, path);
|
|
237
|
+
const relativePath = relative(directoryPath, filePath);
|
|
144
238
|
|
|
145
|
-
|
|
239
|
+
if (relativePath === '' || relativePath.startsWith('..') || isAbsolute(relativePath) || filePath === metadataPath) {
|
|
240
|
+
throw createHttpError(400, 'Путь файла выходит за пределы директории хранения');
|
|
241
|
+
}
|
|
146
242
|
|
|
147
|
-
return
|
|
243
|
+
return filePath;
|
|
148
244
|
};
|
|
149
245
|
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
250
|
+
if (file == null) {
|
|
251
|
+
throw createHttpError(404, 'Файл не найден');
|
|
252
|
+
}
|
|
158
253
|
|
|
159
|
-
|
|
254
|
+
const filePath = resolveFilePath(path);
|
|
160
255
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
} catch (error) {
|
|
164
|
-
if (error?.code === 'ENOENT') {
|
|
165
|
-
throw createHttpError(404, 'Файл не найден');
|
|
166
|
-
}
|
|
256
|
+
return { ...file, size: await getFileSize(filePath) };
|
|
257
|
+
};
|
|
167
258
|
|
|
168
|
-
|
|
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
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
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, (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = (
|
|
370
|
+
const remove = (path) =>
|
|
203
371
|
schedule(async () => {
|
|
204
372
|
const files = await readMetadata(metadataPath);
|
|
205
|
-
const index = files.findIndex((file) => file
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
const
|
|
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
|
-
|
|
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
|
|
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.
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
625
|
+
fileServer.delete('/_files/storage/*', async (request, reply) => {
|
|
626
|
+
await store.remove(getFileKey(getPathLocation(request.params['*'])));
|
|
271
627
|
|
|
272
|
-
return reply.send(
|
|
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
|
};
|