@datrix/api-upload 0.1.0 → 0.2.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 +21 -2
- package/dist/index.js +335 -292
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +331 -288
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -8
package/dist/index.js
CHANGED
|
@@ -36,10 +36,17 @@ __export(index_exports, {
|
|
|
36
36
|
});
|
|
37
37
|
module.exports = __toCommonJS(index_exports);
|
|
38
38
|
|
|
39
|
+
// src/upload.ts
|
|
40
|
+
var import_core4 = require("@datrix/core");
|
|
41
|
+
|
|
39
42
|
// src/schema.ts
|
|
40
43
|
var import_core = require("@datrix/core");
|
|
41
|
-
function createMediaSchema(
|
|
42
|
-
const
|
|
44
|
+
function createMediaSchema(modelName, permission) {
|
|
45
|
+
const effectivePermission = {
|
|
46
|
+
create: false,
|
|
47
|
+
update: false,
|
|
48
|
+
...permission
|
|
49
|
+
};
|
|
43
50
|
return (0, import_core.defineSchema)({
|
|
44
51
|
name: modelName,
|
|
45
52
|
fields: {
|
|
@@ -50,7 +57,7 @@ function createMediaSchema(options, permission) {
|
|
|
50
57
|
key: { type: "string", required: true },
|
|
51
58
|
variants: { type: "json" }
|
|
52
59
|
},
|
|
53
|
-
|
|
60
|
+
permission: effectivePermission
|
|
54
61
|
});
|
|
55
62
|
}
|
|
56
63
|
|
|
@@ -68,6 +75,16 @@ var IMAGE_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
|
68
75
|
"image/gif",
|
|
69
76
|
"image/tiff"
|
|
70
77
|
]);
|
|
78
|
+
var FORMAT_TO_MIME = {
|
|
79
|
+
jpeg: "image/jpeg",
|
|
80
|
+
png: "image/png",
|
|
81
|
+
webp: "image/webp",
|
|
82
|
+
avif: "image/avif",
|
|
83
|
+
heif: "image/avif",
|
|
84
|
+
gif: "image/gif",
|
|
85
|
+
tiff: "image/tiff",
|
|
86
|
+
svg: "image/svg+xml"
|
|
87
|
+
};
|
|
71
88
|
function isImage(mimetype) {
|
|
72
89
|
return IMAGE_MIME_TYPES.has(mimetype);
|
|
73
90
|
}
|
|
@@ -83,6 +100,22 @@ function getMimeType(format) {
|
|
|
83
100
|
function getExtension(format) {
|
|
84
101
|
return format === "jpeg" ? "jpg" : format;
|
|
85
102
|
}
|
|
103
|
+
var sharpModule = null;
|
|
104
|
+
async function loadSharp() {
|
|
105
|
+
if (sharpModule === null) {
|
|
106
|
+
sharpModule = (await import("sharp")).default;
|
|
107
|
+
}
|
|
108
|
+
return sharpModule;
|
|
109
|
+
}
|
|
110
|
+
async function detectImageMime(buffer) {
|
|
111
|
+
const sharp = await loadSharp();
|
|
112
|
+
try {
|
|
113
|
+
const { format } = await sharp(buffer).metadata();
|
|
114
|
+
return format !== void 0 ? FORMAT_TO_MIME[format] : void 0;
|
|
115
|
+
} catch {
|
|
116
|
+
return void 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
86
119
|
async function convertFormat(file, format, quality) {
|
|
87
120
|
if (!isImage(file.mimetype)) {
|
|
88
121
|
return {
|
|
@@ -91,15 +124,7 @@ async function convertFormat(file, format, quality) {
|
|
|
91
124
|
filename: file.filename
|
|
92
125
|
};
|
|
93
126
|
}
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
sharp = (await import("sharp")).default;
|
|
97
|
-
} catch (error) {
|
|
98
|
-
throw new import_core2.DatrixError("sharp is not installed", {
|
|
99
|
-
code: "SHARP_NOT_FOUND",
|
|
100
|
-
operation: "upload:convertFormat"
|
|
101
|
-
});
|
|
102
|
-
}
|
|
127
|
+
const sharp = await loadSharp();
|
|
103
128
|
try {
|
|
104
129
|
const converted = await sharp(file.buffer).toFormat(format, { quality }).toBuffer();
|
|
105
130
|
const ext = getExtension(format);
|
|
@@ -122,15 +147,7 @@ async function generateVariants(file, resolutions, format, quality, uploadFn) {
|
|
|
122
147
|
if (!isImage(file.mimetype)) {
|
|
123
148
|
return {};
|
|
124
149
|
}
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
sharp = (await import("sharp")).default;
|
|
128
|
-
} catch (error) {
|
|
129
|
-
throw new import_core2.DatrixError("sharp is not installed", {
|
|
130
|
-
code: "SHARP_NOT_FOUND",
|
|
131
|
-
operation: "upload:generateVariants"
|
|
132
|
-
});
|
|
133
|
-
}
|
|
150
|
+
const sharp = await loadSharp();
|
|
134
151
|
const targetFormat = format ?? "jpeg";
|
|
135
152
|
const outputMime = getMimeType(targetFormat);
|
|
136
153
|
const outputExt = getExtension(targetFormat);
|
|
@@ -158,8 +175,7 @@ async function generateVariants(file, resolutions, format, quality, uploadFn) {
|
|
|
158
175
|
width: variantBuffer.info.width,
|
|
159
176
|
height: variantBuffer.info.height,
|
|
160
177
|
size: variantBuffer.data.length,
|
|
161
|
-
mimeType: outputMime
|
|
162
|
-
url: void 0
|
|
178
|
+
mimeType: outputMime
|
|
163
179
|
};
|
|
164
180
|
} catch (error) {
|
|
165
181
|
if (error instanceof import_core2.DatrixError) throw error;
|
|
@@ -174,18 +190,37 @@ async function generateVariants(file, resolutions, format, quality, uploadFn) {
|
|
|
174
190
|
}
|
|
175
191
|
|
|
176
192
|
// src/handler.ts
|
|
193
|
+
var MULTIPART_OVERHEAD = 64 * 1024;
|
|
177
194
|
async function handleUploadRequest(request, options) {
|
|
178
195
|
try {
|
|
179
196
|
const { method } = request;
|
|
180
197
|
const url = new URL(request.url);
|
|
181
198
|
const pathAfterUpload = url.pathname.replace(/.*\/upload/, "");
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
if (
|
|
199
|
+
const segments = pathAfterUpload.split("/").filter(Boolean);
|
|
200
|
+
const idSegment = segments[0];
|
|
201
|
+
if (segments.length > 1) {
|
|
202
|
+
return (0, import_api.datrixErrorResponse)(
|
|
203
|
+
import_api.handlerError.recordNotFound(options.modelName, segments.join("/"))
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (method === "POST") {
|
|
207
|
+
if (idSegment !== void 0) {
|
|
208
|
+
return (0, import_api.datrixErrorResponse)(
|
|
209
|
+
import_api.handlerError.recordNotFound(options.modelName, idSegment)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
185
212
|
return await handleUpload(request, options);
|
|
186
213
|
}
|
|
187
|
-
if (method === "DELETE"
|
|
188
|
-
|
|
214
|
+
if (method === "DELETE") {
|
|
215
|
+
if (idSegment === void 0) {
|
|
216
|
+
return (0, import_api.datrixErrorResponse)(import_api.handlerError.missingId("delete"));
|
|
217
|
+
}
|
|
218
|
+
if (!/^\d+$/.test(idSegment)) {
|
|
219
|
+
return (0, import_api.datrixErrorResponse)(
|
|
220
|
+
import_api.handlerError.recordNotFound(options.modelName, idSegment)
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return await handleDeleteMedia(parseInt(idSegment, 10), options);
|
|
189
224
|
}
|
|
190
225
|
return (0, import_api.datrixErrorResponse)(import_api.handlerError.methodNotAllowed(method));
|
|
191
226
|
} catch (error) {
|
|
@@ -201,15 +236,26 @@ async function handleUploadRequest(request, options) {
|
|
|
201
236
|
);
|
|
202
237
|
}
|
|
203
238
|
}
|
|
239
|
+
function isFileEntry(entry) {
|
|
240
|
+
return entry !== null && typeof entry === "object" && typeof entry.arrayBuffer === "function" && typeof entry.name === "string" && typeof entry.size === "number" && typeof entry.type === "string";
|
|
241
|
+
}
|
|
204
242
|
async function handleUpload(request, options) {
|
|
205
|
-
const { datrix, uploadOptions } = options;
|
|
206
|
-
const modelName = uploadOptions.modelName ?? "media";
|
|
243
|
+
const { datrix, modelName, uploadOptions } = options;
|
|
207
244
|
const contentType = request.headers.get("content-type") ?? "";
|
|
208
245
|
if (!contentType.includes("multipart/form-data")) {
|
|
209
246
|
return (0, import_api.datrixErrorResponse)(
|
|
210
247
|
import_api.handlerError.invalidBody("Expected multipart/form-data")
|
|
211
248
|
);
|
|
212
249
|
}
|
|
250
|
+
if (uploadOptions.maxSize !== void 0) {
|
|
251
|
+
const contentLength = Number(request.headers.get("content-length"));
|
|
252
|
+
if (Number.isFinite(contentLength) && contentLength > uploadOptions.maxSize + MULTIPART_OVERHEAD) {
|
|
253
|
+
throw new import_api.DatrixApiError(
|
|
254
|
+
`Request size ${contentLength} exceeds maximum allowed size ${uploadOptions.maxSize}`,
|
|
255
|
+
{ code: "FILE_TOO_LARGE", status: 413 }
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
213
259
|
let formData;
|
|
214
260
|
try {
|
|
215
261
|
formData = await request.formData();
|
|
@@ -221,21 +267,39 @@ async function handleUpload(request, options) {
|
|
|
221
267
|
...cause !== void 0 && { cause }
|
|
222
268
|
});
|
|
223
269
|
}
|
|
224
|
-
const
|
|
225
|
-
if (!(
|
|
270
|
+
const fileEntries = formData.getAll("file");
|
|
271
|
+
if (fileEntries.length === 0 || !isFileEntry(fileEntries[0])) {
|
|
226
272
|
return (0, import_api.datrixErrorResponse)(
|
|
227
273
|
import_api.handlerError.invalidBody("No file field in form data")
|
|
228
274
|
);
|
|
229
275
|
}
|
|
230
|
-
|
|
276
|
+
if (fileEntries.length > 1) {
|
|
277
|
+
return (0, import_api.datrixErrorResponse)(
|
|
278
|
+
import_api.handlerError.invalidBody(
|
|
279
|
+
"Multiple file entries \u2014 upload a single file per request"
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const fileEntry = fileEntries[0];
|
|
284
|
+
validateFileLimits(fileEntry.size, fileEntry.type, uploadOptions);
|
|
285
|
+
const buffer = new Uint8Array(await fileEntry.arrayBuffer());
|
|
286
|
+
const declaredMime = fileEntry.type;
|
|
287
|
+
if (declaredMime.startsWith("image/")) {
|
|
288
|
+
const actualMime = await detectImageMime(buffer);
|
|
289
|
+
if (actualMime !== declaredMime) {
|
|
290
|
+
throw new import_api.DatrixApiError(
|
|
291
|
+
`Declared MIME type ${declaredMime} does not match file content`,
|
|
292
|
+
{ code: "INVALID_MIME_TYPE", status: 400 }
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
231
296
|
const rawFile = {
|
|
232
297
|
filename: fileEntry.name,
|
|
233
298
|
originalName: fileEntry.name,
|
|
234
|
-
mimetype:
|
|
299
|
+
mimetype: declaredMime,
|
|
235
300
|
size: fileEntry.size,
|
|
236
|
-
buffer
|
|
301
|
+
buffer
|
|
237
302
|
};
|
|
238
|
-
validateFileLimits(rawFile, uploadOptions);
|
|
239
303
|
const quality = uploadOptions.quality ?? 80;
|
|
240
304
|
const fileToUpload = uploadOptions.format !== void 0 && isImage(rawFile.mimetype) ? await convertFormat(rawFile, uploadOptions.format, quality) : rawFile;
|
|
241
305
|
const uploadFile = {
|
|
@@ -245,57 +309,78 @@ async function handleUpload(request, options) {
|
|
|
245
309
|
size: fileToUpload.buffer.length,
|
|
246
310
|
buffer: fileToUpload.buffer
|
|
247
311
|
};
|
|
312
|
+
const uploadedKeys = [];
|
|
248
313
|
const result = await uploadOptions.provider.upload(uploadFile);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
314
|
+
uploadedKeys.push(result.key);
|
|
315
|
+
try {
|
|
316
|
+
let variants = null;
|
|
317
|
+
if (uploadOptions.resolutions !== void 0 && isImage(uploadFile.mimetype)) {
|
|
318
|
+
variants = await generateVariants(
|
|
319
|
+
uploadFile,
|
|
320
|
+
uploadOptions.resolutions,
|
|
321
|
+
uploadOptions.format,
|
|
322
|
+
quality,
|
|
323
|
+
async (variantFile) => {
|
|
324
|
+
const variantResult = await uploadOptions.provider.upload(variantFile);
|
|
325
|
+
uploadedKeys.push(variantResult.key);
|
|
326
|
+
return { key: variantResult.key };
|
|
327
|
+
}
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const mediaRecord = await datrix.raw.create(modelName, {
|
|
331
|
+
filename: result.key,
|
|
332
|
+
originalName: uploadFile.originalName,
|
|
333
|
+
mimeType: uploadFile.mimetype,
|
|
334
|
+
size: uploadFile.size,
|
|
335
|
+
key: result.key,
|
|
336
|
+
...variants !== null && { variants }
|
|
337
|
+
});
|
|
338
|
+
const data = options.injectUrls ? await options.injectUrls(mediaRecord) : mediaRecord;
|
|
339
|
+
return (0, import_api.jsonResponse)({ data }, 201);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
await cleanupKeys(uploadOptions.provider, uploadedKeys);
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
273
344
|
}
|
|
274
345
|
async function handleDeleteMedia(id, options) {
|
|
275
|
-
const { datrix, uploadOptions } = options;
|
|
276
|
-
const modelName = uploadOptions.modelName ?? "media";
|
|
346
|
+
const { datrix, modelName, uploadOptions } = options;
|
|
277
347
|
const record = await datrix.raw.findById(modelName, id);
|
|
278
348
|
if (record === null) {
|
|
279
349
|
return (0, import_api.datrixErrorResponse)(import_api.handlerError.recordNotFound(modelName, id));
|
|
280
350
|
}
|
|
351
|
+
await datrix.raw.delete(modelName, id);
|
|
352
|
+
const keys = [record.key];
|
|
281
353
|
if (record.variants !== null && record.variants !== void 0) {
|
|
282
354
|
for (const variant of Object.values(record.variants)) {
|
|
283
|
-
|
|
355
|
+
if (typeof variant?.key === "string") {
|
|
356
|
+
keys.push(variant.key);
|
|
357
|
+
}
|
|
284
358
|
}
|
|
285
359
|
}
|
|
286
|
-
await uploadOptions.provider
|
|
287
|
-
await datrix.raw.delete(modelName, id);
|
|
360
|
+
await cleanupKeys(uploadOptions.provider, keys);
|
|
288
361
|
return (0, import_api.jsonResponse)({ data: { id } });
|
|
289
362
|
}
|
|
290
|
-
function
|
|
291
|
-
|
|
363
|
+
async function cleanupKeys(provider, keys) {
|
|
364
|
+
for (const key of keys) {
|
|
365
|
+
try {
|
|
366
|
+
await provider.delete(key);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
369
|
+
console.warn(
|
|
370
|
+
`[Datrix Upload] Failed to delete storage object '${key}': ${message}`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function validateFileLimits(size, mimetype, options) {
|
|
376
|
+
if (options.maxSize !== void 0 && size > options.maxSize) {
|
|
292
377
|
throw new import_api.DatrixApiError(
|
|
293
|
-
`File size ${
|
|
378
|
+
`File size ${size} exceeds maximum allowed size ${options.maxSize}`,
|
|
294
379
|
{ code: "FILE_TOO_LARGE", status: 400 }
|
|
295
380
|
);
|
|
296
381
|
}
|
|
297
|
-
if (options.allowedMimeTypes !== void 0 && options.allowedMimeTypes.length > 0 && !isMimeTypeAllowed(
|
|
298
|
-
throw new import_api.DatrixApiError(`MIME type ${
|
|
382
|
+
if (options.allowedMimeTypes !== void 0 && options.allowedMimeTypes.length > 0 && !isMimeTypeAllowed(mimetype, options.allowedMimeTypes)) {
|
|
383
|
+
throw new import_api.DatrixApiError(`MIME type ${mimetype} is not allowed`, {
|
|
299
384
|
code: "INVALID_MIME_TYPE",
|
|
300
385
|
status: 400
|
|
301
386
|
});
|
|
@@ -317,22 +402,29 @@ var Upload = class {
|
|
|
317
402
|
options;
|
|
318
403
|
provider;
|
|
319
404
|
constructor(options) {
|
|
405
|
+
const quality = options.quality;
|
|
406
|
+
if (quality !== void 0 && (quality < 1 || quality > 100)) {
|
|
407
|
+
throw new import_core4.DatrixError(
|
|
408
|
+
`Upload quality must be between 1 and 100, got ${quality}`,
|
|
409
|
+
{ code: "INVALID_UPLOAD_CONFIG", operation: "upload:config" }
|
|
410
|
+
);
|
|
411
|
+
}
|
|
320
412
|
this.options = options;
|
|
321
413
|
this.provider = options.provider;
|
|
322
414
|
}
|
|
323
415
|
getModelName() {
|
|
324
416
|
return this.options.modelName ?? "media";
|
|
325
417
|
}
|
|
418
|
+
getPermission() {
|
|
419
|
+
return this.options.permission;
|
|
420
|
+
}
|
|
326
421
|
getSchemas() {
|
|
327
|
-
|
|
328
|
-
this.options,
|
|
329
|
-
this.options.permission
|
|
330
|
-
);
|
|
331
|
-
return [mediaSchema];
|
|
422
|
+
return [createMediaSchema(this.getModelName(), this.options.permission)];
|
|
332
423
|
}
|
|
333
424
|
async handleRequest(request, datrix) {
|
|
334
425
|
return handleUploadRequest(request, {
|
|
335
426
|
datrix,
|
|
427
|
+
modelName: this.getModelName(),
|
|
336
428
|
uploadOptions: this.options,
|
|
337
429
|
injectUrls: (data) => this.injectUrls(data)
|
|
338
430
|
});
|
|
@@ -343,47 +435,51 @@ var Upload = class {
|
|
|
343
435
|
getUrl(key) {
|
|
344
436
|
return this.options.provider.getUrl(key);
|
|
345
437
|
}
|
|
346
|
-
|
|
438
|
+
/**
|
|
439
|
+
* Recursively inject `url` into media-shaped objects (`key` + `mimeType` +
|
|
440
|
+
* numeric `size` — the media record signature; variant entries match it
|
|
441
|
+
* too). Non-plain objects (Date, class instances) pass through untouched,
|
|
442
|
+
* and unchanged subtrees keep their original reference.
|
|
443
|
+
*/
|
|
444
|
+
traverse(node) {
|
|
347
445
|
if (Array.isArray(node)) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
446
|
+
let changed2 = false;
|
|
447
|
+
const results = node.map((item) => {
|
|
448
|
+
const result2 = this.traverse(item);
|
|
449
|
+
if (result2 !== item) changed2 = true;
|
|
450
|
+
return result2;
|
|
451
|
+
});
|
|
452
|
+
return changed2 ? results : node;
|
|
353
453
|
}
|
|
354
|
-
if (node
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const v = variant;
|
|
368
|
-
if (typeof v["key"] === "string" && v["url"] === void 0) {
|
|
369
|
-
variants[name] = {
|
|
370
|
-
...v,
|
|
371
|
-
url: this.options.provider.getUrl(v["key"])
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
return result;
|
|
454
|
+
if (!isPlainObject(node)) {
|
|
455
|
+
return node;
|
|
456
|
+
}
|
|
457
|
+
let changed = false;
|
|
458
|
+
const result = {};
|
|
459
|
+
for (const [k, v] of Object.entries(node)) {
|
|
460
|
+
const traversed = this.traverse(v);
|
|
461
|
+
if (traversed !== v) changed = true;
|
|
462
|
+
result[k] = traversed;
|
|
463
|
+
}
|
|
464
|
+
if (typeof result["key"] === "string" && typeof result["mimeType"] === "string" && typeof result["size"] === "number" && result["url"] === void 0) {
|
|
465
|
+
result["url"] = this.options.provider.getUrl(result["key"]);
|
|
466
|
+
changed = true;
|
|
378
467
|
}
|
|
379
|
-
return node;
|
|
468
|
+
return changed ? result : node;
|
|
380
469
|
}
|
|
381
470
|
};
|
|
471
|
+
function isPlainObject(node) {
|
|
472
|
+
if (node === null || typeof node !== "object") {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
const proto = Object.getPrototypeOf(node);
|
|
476
|
+
return proto === Object.prototype || proto === null;
|
|
477
|
+
}
|
|
382
478
|
|
|
383
479
|
// src/providers/local.ts
|
|
384
|
-
var import_core4 = require("@datrix/core");
|
|
385
480
|
var import_core5 = require("@datrix/core");
|
|
386
|
-
var
|
|
481
|
+
var import_core6 = require("@datrix/core");
|
|
482
|
+
var UploadError = class extends import_core6.DatrixError {
|
|
387
483
|
constructor(message, cause) {
|
|
388
484
|
super(message, {
|
|
389
485
|
code: "UPLOAD_ERROR",
|
|
@@ -407,8 +503,8 @@ var LocalStorageProvider = class {
|
|
|
407
503
|
try {
|
|
408
504
|
const fs = await import("fs/promises");
|
|
409
505
|
const path = await import("path");
|
|
410
|
-
const sanitized = (0,
|
|
411
|
-
const uniqueFilename = (0,
|
|
506
|
+
const sanitized = (0, import_core5.sanitizeFilename)(file.originalName);
|
|
507
|
+
const uniqueFilename = (0, import_core5.generateUniqueFilename)(sanitized);
|
|
412
508
|
const fullPath = path.join(this.basePath, uniqueFilename);
|
|
413
509
|
if (this.ensureDirectory) {
|
|
414
510
|
const dirPath = path.dirname(fullPath);
|
|
@@ -426,19 +522,18 @@ var LocalStorageProvider = class {
|
|
|
426
522
|
throw new UploadError("Failed to upload file to local filesystem", cause);
|
|
427
523
|
}
|
|
428
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Delete is idempotent — a missing file is treated as already deleted.
|
|
527
|
+
*/
|
|
429
528
|
async delete(key) {
|
|
529
|
+
const fullPath = await this.resolveContainedPath(key);
|
|
430
530
|
const fs = await import("fs/promises");
|
|
431
|
-
const path = await import("path");
|
|
432
|
-
const fullPath = path.join(this.basePath, key);
|
|
433
|
-
try {
|
|
434
|
-
await fs.access(fullPath);
|
|
435
|
-
} catch (error) {
|
|
436
|
-
const cause = error instanceof Error ? error : void 0;
|
|
437
|
-
throw new UploadError("File not found", cause);
|
|
438
|
-
}
|
|
439
531
|
try {
|
|
440
532
|
await fs.unlink(fullPath);
|
|
441
533
|
} catch (error) {
|
|
534
|
+
if (error.code === "ENOENT") {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
442
537
|
const cause = error instanceof Error ? error : void 0;
|
|
443
538
|
throw new UploadError(
|
|
444
539
|
"Failed to delete file from local filesystem",
|
|
@@ -447,19 +542,40 @@ var LocalStorageProvider = class {
|
|
|
447
542
|
}
|
|
448
543
|
}
|
|
449
544
|
getUrl(key) {
|
|
545
|
+
this.assertSafeKey(key);
|
|
450
546
|
return this.buildUrl(key);
|
|
451
547
|
}
|
|
452
548
|
async exists(key) {
|
|
453
549
|
try {
|
|
550
|
+
const fullPath = await this.resolveContainedPath(key);
|
|
454
551
|
const fs = await import("fs/promises");
|
|
455
|
-
const path = await import("path");
|
|
456
|
-
const fullPath = path.join(this.basePath, key);
|
|
457
552
|
await fs.access(fullPath);
|
|
458
553
|
return true;
|
|
459
554
|
} catch {
|
|
460
555
|
return false;
|
|
461
556
|
}
|
|
462
557
|
}
|
|
558
|
+
/**
|
|
559
|
+
* Keys must be relative paths that stay inside basePath — reject
|
|
560
|
+
* traversal segments and absolute paths before touching the filesystem.
|
|
561
|
+
*/
|
|
562
|
+
assertSafeKey(key) {
|
|
563
|
+
const isAbsolute = key.startsWith("/") || /^[A-Za-z]:/.test(key);
|
|
564
|
+
const hasTraversal = key.split(/[/\\]/).some((segment) => segment === "..");
|
|
565
|
+
if (key === "" || isAbsolute || hasTraversal) {
|
|
566
|
+
throw new UploadError(`Invalid storage key: ${key}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async resolveContainedPath(key) {
|
|
570
|
+
this.assertSafeKey(key);
|
|
571
|
+
const path = await import("path");
|
|
572
|
+
const base = path.resolve(this.basePath);
|
|
573
|
+
const fullPath = path.resolve(base, key);
|
|
574
|
+
if (!fullPath.startsWith(base + path.sep)) {
|
|
575
|
+
throw new UploadError(`Invalid storage key: ${key}`);
|
|
576
|
+
}
|
|
577
|
+
return fullPath;
|
|
578
|
+
}
|
|
463
579
|
buildUrl(key) {
|
|
464
580
|
const cleanBaseUrl = this.baseUrl.replace(/\/$/, "");
|
|
465
581
|
const cleanKey = key.replace(/^\//, "");
|
|
@@ -468,9 +584,9 @@ var LocalStorageProvider = class {
|
|
|
468
584
|
};
|
|
469
585
|
|
|
470
586
|
// src/providers/s3.ts
|
|
471
|
-
var import_core6 = require("@datrix/core");
|
|
472
587
|
var import_core7 = require("@datrix/core");
|
|
473
|
-
var
|
|
588
|
+
var import_core8 = require("@datrix/core");
|
|
589
|
+
var UploadError2 = class extends import_core8.DatrixError {
|
|
474
590
|
constructor(message, cause) {
|
|
475
591
|
super(message, {
|
|
476
592
|
code: "UPLOAD_ERROR",
|
|
@@ -488,6 +604,8 @@ var S3StorageProvider = class {
|
|
|
488
604
|
secretAccessKey;
|
|
489
605
|
endpoint;
|
|
490
606
|
pathPrefix;
|
|
607
|
+
sessionToken;
|
|
608
|
+
forcePathStyle;
|
|
491
609
|
constructor(options) {
|
|
492
610
|
this.bucket = options.bucket;
|
|
493
611
|
this.region = options.region;
|
|
@@ -495,13 +613,18 @@ var S3StorageProvider = class {
|
|
|
495
613
|
this.secretAccessKey = options.secretAccessKey;
|
|
496
614
|
this.endpoint = options.endpoint ?? `s3.${options.region}.amazonaws.com`;
|
|
497
615
|
this.pathPrefix = options.pathPrefix ?? "uploads";
|
|
616
|
+
this.sessionToken = options.sessionToken;
|
|
617
|
+
this.forcePathStyle = options.forcePathStyle ?? false;
|
|
498
618
|
}
|
|
499
619
|
async upload(file) {
|
|
500
620
|
try {
|
|
501
|
-
const sanitized = (0,
|
|
502
|
-
const filename = (0,
|
|
621
|
+
const sanitized = (0, import_core7.sanitizeFilename)(file.originalName);
|
|
622
|
+
const filename = (0, import_core7.generateUniqueFilename)(sanitized);
|
|
503
623
|
const key = this.pathPrefix ? `${this.pathPrefix}/${filename}` : filename;
|
|
504
|
-
await this.
|
|
624
|
+
await this.sendRequest("PUT", key, {
|
|
625
|
+
body: file.buffer,
|
|
626
|
+
contentType: file.mimetype
|
|
627
|
+
});
|
|
505
628
|
return {
|
|
506
629
|
key,
|
|
507
630
|
size: file.size,
|
|
@@ -516,7 +639,7 @@ var S3StorageProvider = class {
|
|
|
516
639
|
}
|
|
517
640
|
async delete(key) {
|
|
518
641
|
try {
|
|
519
|
-
await this.
|
|
642
|
+
await this.sendRequest("DELETE", key, {});
|
|
520
643
|
} catch (error) {
|
|
521
644
|
if (error instanceof UploadError2) throw error;
|
|
522
645
|
const cause = error instanceof Error ? error : void 0;
|
|
@@ -524,209 +647,129 @@ var S3StorageProvider = class {
|
|
|
524
647
|
}
|
|
525
648
|
}
|
|
526
649
|
getUrl(key) {
|
|
527
|
-
|
|
650
|
+
const encodedKey = encodePath(key);
|
|
651
|
+
return this.forcePathStyle ? `https://${this.endpoint}/${this.bucket}/${encodedKey}` : `https://${this.bucket}.${this.endpoint}/${encodedKey}`;
|
|
528
652
|
}
|
|
529
653
|
async exists(key) {
|
|
530
654
|
try {
|
|
531
|
-
await this.
|
|
655
|
+
await this.sendRequest("HEAD", key, { readErrorBody: false });
|
|
532
656
|
return true;
|
|
533
657
|
} catch {
|
|
534
658
|
return false;
|
|
535
659
|
}
|
|
536
660
|
}
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
const host = `${this.bucket}.${this.endpoint}`;
|
|
541
|
-
const urlPath = `/${key}`;
|
|
542
|
-
const method = "PUT";
|
|
543
|
-
const date = (/* @__PURE__ */ new Date()).toUTCString();
|
|
544
|
-
const contentHash = crypto.createHash("sha256").update(buffer).digest("hex");
|
|
545
|
-
const authorization = await this.signRequest(
|
|
546
|
-
method,
|
|
547
|
-
urlPath,
|
|
548
|
-
host,
|
|
549
|
-
date,
|
|
550
|
-
contentType,
|
|
551
|
-
contentHash
|
|
552
|
-
);
|
|
553
|
-
await new Promise((resolve, reject) => {
|
|
554
|
-
const req = https.request(
|
|
555
|
-
{
|
|
556
|
-
hostname: host,
|
|
557
|
-
port: 443,
|
|
558
|
-
path: urlPath,
|
|
559
|
-
method,
|
|
560
|
-
headers: {
|
|
561
|
-
Host: host,
|
|
562
|
-
Date: date,
|
|
563
|
-
"Content-Type": contentType,
|
|
564
|
-
"Content-Length": buffer.length,
|
|
565
|
-
"x-amz-content-sha256": contentHash,
|
|
566
|
-
Authorization: authorization
|
|
567
|
-
}
|
|
568
|
-
},
|
|
569
|
-
(res) => {
|
|
570
|
-
const status = res.statusCode ?? 0;
|
|
571
|
-
if (status >= 200 && status < 300) {
|
|
572
|
-
resolve();
|
|
573
|
-
} else {
|
|
574
|
-
let body = "";
|
|
575
|
-
res.on("data", (chunk) => {
|
|
576
|
-
body += chunk.toString();
|
|
577
|
-
});
|
|
578
|
-
res.on("end", () => {
|
|
579
|
-
reject(new UploadError2(`S3 upload failed: ${status} ${body}`));
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
);
|
|
584
|
-
req.on("error", (error) => {
|
|
585
|
-
reject(new UploadError2("S3 request failed", error));
|
|
586
|
-
});
|
|
587
|
-
req.write(buffer);
|
|
588
|
-
req.end();
|
|
589
|
-
});
|
|
661
|
+
hostAndPath(key) {
|
|
662
|
+
const encodedKey = encodePath(key);
|
|
663
|
+
return this.forcePathStyle ? { host: this.endpoint, urlPath: `/${this.bucket}/${encodedKey}` } : { host: `${this.bucket}.${this.endpoint}`, urlPath: `/${encodedKey}` };
|
|
590
664
|
}
|
|
591
|
-
|
|
665
|
+
/**
|
|
666
|
+
* Send a SigV4-signed request to S3, resolving on 2xx and rejecting with
|
|
667
|
+
* the response body otherwise.
|
|
668
|
+
*/
|
|
669
|
+
async sendRequest(method, key, options) {
|
|
592
670
|
const https = await import("https");
|
|
593
671
|
const crypto = await import("crypto");
|
|
594
|
-
const host =
|
|
595
|
-
const
|
|
596
|
-
const
|
|
597
|
-
const
|
|
598
|
-
const contentHash = crypto.createHash("sha256").update("").digest("hex");
|
|
599
|
-
const
|
|
672
|
+
const { host, urlPath } = this.hostAndPath(key);
|
|
673
|
+
const { body, contentType, readErrorBody = true } = options;
|
|
674
|
+
const amzDate = (/* @__PURE__ */ new Date()).toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
675
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
676
|
+
const contentHash = crypto.createHash("sha256").update(body ?? "").digest("hex");
|
|
677
|
+
const signedHeaderEntries = [
|
|
678
|
+
["host", host],
|
|
679
|
+
["x-amz-content-sha256", contentHash],
|
|
680
|
+
["x-amz-date", amzDate]
|
|
681
|
+
];
|
|
682
|
+
if (this.sessionToken !== void 0) {
|
|
683
|
+
signedHeaderEntries.push(["x-amz-security-token", this.sessionToken]);
|
|
684
|
+
}
|
|
685
|
+
const canonicalHeaders = signedHeaderEntries.map(([name, value]) => `${name}:${value}
|
|
686
|
+
`).join("");
|
|
687
|
+
const signedHeaders = signedHeaderEntries.map(([name]) => name).join(";");
|
|
688
|
+
const canonicalRequest = [
|
|
600
689
|
method,
|
|
601
690
|
urlPath,
|
|
602
|
-
host,
|
|
603
|
-
date,
|
|
604
691
|
"",
|
|
692
|
+
canonicalHeaders,
|
|
693
|
+
signedHeaders,
|
|
605
694
|
contentHash
|
|
606
|
-
);
|
|
695
|
+
].join("\n");
|
|
696
|
+
const algorithm = "AWS4-HMAC-SHA256";
|
|
697
|
+
const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`;
|
|
698
|
+
const canonicalRequestHash = crypto.createHash("sha256").update(canonicalRequest).digest("hex");
|
|
699
|
+
const stringToSign = [
|
|
700
|
+
algorithm,
|
|
701
|
+
amzDate,
|
|
702
|
+
credentialScope,
|
|
703
|
+
canonicalRequestHash
|
|
704
|
+
].join("\n");
|
|
705
|
+
const signature = this.calculateSignature(crypto, stringToSign, dateStamp);
|
|
706
|
+
const authorization = `${algorithm} Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
707
|
+
const headers = {
|
|
708
|
+
Host: host,
|
|
709
|
+
"x-amz-date": amzDate,
|
|
710
|
+
"x-amz-content-sha256": contentHash,
|
|
711
|
+
Authorization: authorization
|
|
712
|
+
};
|
|
713
|
+
if (this.sessionToken !== void 0) {
|
|
714
|
+
headers["x-amz-security-token"] = this.sessionToken;
|
|
715
|
+
}
|
|
716
|
+
if (body !== void 0) {
|
|
717
|
+
headers["Content-Length"] = body.length;
|
|
718
|
+
if (contentType !== void 0) {
|
|
719
|
+
headers["Content-Type"] = contentType;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
607
722
|
await new Promise((resolve, reject) => {
|
|
608
723
|
const req = https.request(
|
|
609
|
-
{
|
|
610
|
-
hostname: host,
|
|
611
|
-
port: 443,
|
|
612
|
-
path: urlPath,
|
|
613
|
-
method,
|
|
614
|
-
headers: {
|
|
615
|
-
Host: host,
|
|
616
|
-
Date: date,
|
|
617
|
-
"x-amz-content-sha256": contentHash,
|
|
618
|
-
Authorization: authorization
|
|
619
|
-
}
|
|
620
|
-
},
|
|
724
|
+
{ hostname: host, port: 443, path: urlPath, method, headers },
|
|
621
725
|
(res) => {
|
|
622
726
|
const status = res.statusCode ?? 0;
|
|
623
727
|
if (status >= 200 && status < 300) {
|
|
728
|
+
res.resume();
|
|
624
729
|
resolve();
|
|
625
|
-
|
|
626
|
-
let body = "";
|
|
627
|
-
res.on("data", (chunk) => {
|
|
628
|
-
body += chunk.toString();
|
|
629
|
-
});
|
|
630
|
-
res.on("end", () => {
|
|
631
|
-
reject(new UploadError2(`S3 delete failed: ${status} ${body}`));
|
|
632
|
-
});
|
|
730
|
+
return;
|
|
633
731
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
});
|
|
639
|
-
req.end();
|
|
640
|
-
});
|
|
641
|
-
}
|
|
642
|
-
async headObject(key) {
|
|
643
|
-
const https = await import("https");
|
|
644
|
-
const crypto = await import("crypto");
|
|
645
|
-
const host = `${this.bucket}.${this.endpoint}`;
|
|
646
|
-
const urlPath = `/${key}`;
|
|
647
|
-
const method = "HEAD";
|
|
648
|
-
const date = (/* @__PURE__ */ new Date()).toUTCString();
|
|
649
|
-
const contentHash = crypto.createHash("sha256").update("").digest("hex");
|
|
650
|
-
const authorization = await this.signRequest(
|
|
651
|
-
method,
|
|
652
|
-
urlPath,
|
|
653
|
-
host,
|
|
654
|
-
date,
|
|
655
|
-
"",
|
|
656
|
-
contentHash
|
|
657
|
-
);
|
|
658
|
-
await new Promise((resolve, reject) => {
|
|
659
|
-
const req = https.request(
|
|
660
|
-
{
|
|
661
|
-
hostname: host,
|
|
662
|
-
port: 443,
|
|
663
|
-
path: urlPath,
|
|
664
|
-
method,
|
|
665
|
-
headers: {
|
|
666
|
-
Host: host,
|
|
667
|
-
Date: date,
|
|
668
|
-
"x-amz-content-sha256": contentHash,
|
|
669
|
-
Authorization: authorization
|
|
732
|
+
if (!readErrorBody) {
|
|
733
|
+
res.resume();
|
|
734
|
+
reject(new UploadError2(`S3 ${method} failed: ${status}`));
|
|
735
|
+
return;
|
|
670
736
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
737
|
+
let responseBody = "";
|
|
738
|
+
res.on("data", (chunk) => {
|
|
739
|
+
responseBody += chunk.toString();
|
|
740
|
+
});
|
|
741
|
+
res.on("end", () => {
|
|
742
|
+
reject(
|
|
743
|
+
new UploadError2(`S3 ${method} failed: ${status} ${responseBody}`)
|
|
744
|
+
);
|
|
745
|
+
});
|
|
676
746
|
}
|
|
677
747
|
);
|
|
678
748
|
req.on("error", (error) => {
|
|
679
749
|
reject(new UploadError2("S3 request failed", error));
|
|
680
750
|
});
|
|
751
|
+
if (body !== void 0) {
|
|
752
|
+
req.write(body);
|
|
753
|
+
}
|
|
681
754
|
req.end();
|
|
682
755
|
});
|
|
683
756
|
}
|
|
684
|
-
|
|
685
|
-
const
|
|
686
|
-
const canonicalHeaders = `host:${host}
|
|
687
|
-
x-amz-content-sha256:${contentHash}
|
|
688
|
-
x-amz-date:${date}
|
|
689
|
-
`;
|
|
690
|
-
const signedHeaders = "host;x-amz-content-sha256;x-amz-date";
|
|
691
|
-
const canonicalRequest = [
|
|
692
|
-
method,
|
|
693
|
-
urlPath,
|
|
694
|
-
"",
|
|
695
|
-
canonicalHeaders,
|
|
696
|
-
signedHeaders,
|
|
697
|
-
contentHash
|
|
698
|
-
].join("\n");
|
|
699
|
-
const algorithm = "AWS4-HMAC-SHA256";
|
|
700
|
-
const amzDate = this.getAmzDate();
|
|
701
|
-
const credentialScope = `${this.getDateStamp()}/${this.region}/s3/aws4_request`;
|
|
702
|
-
const canonicalRequestHash = crypto.createHash("sha256").update(canonicalRequest).digest("hex");
|
|
703
|
-
const stringToSign = [
|
|
704
|
-
algorithm,
|
|
705
|
-
amzDate,
|
|
706
|
-
credentialScope,
|
|
707
|
-
canonicalRequestHash
|
|
708
|
-
].join("\n");
|
|
709
|
-
const signature = this.calculateSignature(crypto, stringToSign);
|
|
710
|
-
return `${algorithm} Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
711
|
-
}
|
|
712
|
-
calculateSignature(crypto, stringToSign) {
|
|
713
|
-
const kDate = crypto.createHmac("sha256", `AWS4${this.secretAccessKey}`).update(this.getDateStamp()).digest();
|
|
757
|
+
calculateSignature(crypto, stringToSign, dateStamp) {
|
|
758
|
+
const kDate = crypto.createHmac("sha256", `AWS4${this.secretAccessKey}`).update(dateStamp).digest();
|
|
714
759
|
const kRegion = crypto.createHmac("sha256", kDate).update(this.region).digest();
|
|
715
760
|
const kService = crypto.createHmac("sha256", kRegion).update("s3").digest();
|
|
716
761
|
const kSigning = crypto.createHmac("sha256", kService).update("aws4_request").digest();
|
|
717
762
|
return crypto.createHmac("sha256", kSigning).update(stringToSign).digest("hex");
|
|
718
763
|
}
|
|
719
|
-
getAmzDate() {
|
|
720
|
-
return (/* @__PURE__ */ new Date()).toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
721
|
-
}
|
|
722
|
-
getDateStamp() {
|
|
723
|
-
const now = /* @__PURE__ */ new Date();
|
|
724
|
-
const year = now.getUTCFullYear();
|
|
725
|
-
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
726
|
-
const day = String(now.getUTCDate()).padStart(2, "0");
|
|
727
|
-
return `${year}${month}${day}`;
|
|
728
|
-
}
|
|
729
764
|
};
|
|
765
|
+
function encodePath(key) {
|
|
766
|
+
return key.split("/").map(
|
|
767
|
+
(segment) => encodeURIComponent(segment).replace(
|
|
768
|
+
/[!'()*]/g,
|
|
769
|
+
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
|
770
|
+
)
|
|
771
|
+
).join("/");
|
|
772
|
+
}
|
|
730
773
|
// Annotate the CommonJS export names for ESM import in node:
|
|
731
774
|
0 && (module.exports = {
|
|
732
775
|
LocalStorageProvider,
|