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