@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/README.md +41 -12
- package/README.ru.md +41 -12
- package/index.js +5 -0
- package/package.json +9 -2
- package/src/cli.js +3 -3
- package/src/config.js +14 -5
- package/src/constants.js +1 -1
- package/src/database.js +19 -28
- package/src/files.js +394 -129
- package/src/openapi/document.js +109 -32
- package/src/query/filter.js +11 -10
- package/src/query/pagination.js +2 -2
- package/src/relation-metadata.js +1 -0
- package/src/server.js +47 -45
- package/src/utils.js +30 -0
- package/types/index.d.ts +10 -0
- package/types/src/config.d.ts +28 -41
- package/types/src/constants.d.ts +1 -1
- package/types/src/database.d.ts +25 -3
- package/types/src/files.d.ts +61 -21
- package/types/src/openapi/document.d.ts +2 -2
- package/types/src/server.d.ts +9 -8
- 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,
|
|
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
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
id = randomBytes(8).toString('base64url');
|
|
14
|
-
} while (files.some((file) => file.id === id));
|
|
46
|
+
const parts = value.split('/');
|
|
15
47
|
|
|
16
|
-
|
|
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
|
+
};
|
|
88
129
|
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
133
|
+
const getContentOverride = (value) => {
|
|
134
|
+
if (value == null || value === 'false') {
|
|
135
|
+
return false;
|
|
93
136
|
}
|
|
94
137
|
|
|
95
|
-
|
|
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
|
-
|
|
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, 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
|
|
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 });
|
|
194
311
|
|
|
195
|
-
return file;
|
|
312
|
+
return { created: !exists, file };
|
|
196
313
|
} catch (error) {
|
|
197
|
-
await
|
|
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
|
|
328
|
+
const update = (sourcePath, updates) =>
|
|
203
329
|
schedule(async () => {
|
|
204
330
|
const files = await readMetadata(metadataPath);
|
|
205
|
-
const index = files.findIndex((file) => file
|
|
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
|
|
212
|
-
const
|
|
213
|
-
const
|
|
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
|
|
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,
|
|
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
|
|
408
|
+
const paths = new Set();
|
|
242
409
|
const storedFiles = sourceFiles.map((sourceFile, index) => {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
257
|
-
throw new Error(`config.files.data содержит повторяющийся id «${sourceFile.id}»`);
|
|
258
|
-
}
|
|
417
|
+
const path = getFileKey(file);
|
|
259
418
|
|
|
260
|
-
|
|
419
|
+
if (paths.has(path)) {
|
|
420
|
+
throw new Error(`config.files.data содержит повторяющийся путь «${path}»`);
|
|
421
|
+
}
|
|
261
422
|
|
|
262
|
-
|
|
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
|
-
|
|
427
|
+
const schedule = createSerialQueue();
|
|
270
428
|
|
|
271
|
-
const
|
|
272
|
-
const
|
|
429
|
+
const findStoredFile = (path) => {
|
|
430
|
+
const storedFile = storedFiles.find(({ file }) => getFileKey(file) === path);
|
|
273
431
|
|
|
274
|
-
|
|
432
|
+
if (storedFile == null) {
|
|
433
|
+
throw createHttpError(404, 'Файл не найден');
|
|
434
|
+
}
|
|
275
435
|
|
|
276
|
-
return
|
|
436
|
+
return storedFile;
|
|
277
437
|
};
|
|
278
438
|
|
|
279
|
-
const
|
|
280
|
-
schedule(async () => {
|
|
281
|
-
const storedFile = storedFiles.find(({ file }) => file.id === id);
|
|
439
|
+
const metadata = (path) => schedule(async () => findStoredFile(path).file);
|
|
282
440
|
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
308
|
-
const
|
|
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
|
-
|
|
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 = (
|
|
505
|
+
const remove = (path) =>
|
|
316
506
|
schedule(async () => {
|
|
317
|
-
const index = storedFiles.findIndex(({ file }) => file
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
335
|
-
|
|
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
|
|
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.
|
|
357
|
-
|
|
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
|
-
|
|
360
|
-
|
|
625
|
+
fileServer.delete('/_files/storage/*', async (request, reply) => {
|
|
626
|
+
await store.remove(getFileKey(getPathLocation(request.params['*'])));
|
|
361
627
|
|
|
362
|
-
return reply.send(
|
|
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
|
};
|