@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/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(options, permission) {
42
- const modelName = options.modelName ?? "media";
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
- ...permission !== void 0 && { permission }
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
- let sharp;
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
- let sharp;
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 idSegment = pathAfterUpload.replace(/^\//, "").split("/")[0];
183
- const id = idSegment !== void 0 && idSegment !== "" ? Number(idSegment) : null;
184
- if (method === "POST" && id === null) {
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" && id !== null) {
188
- return await handleDeleteMedia(id, options);
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 fileEntry = formData.get("file");
225
- if (!(fileEntry instanceof File)) {
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
- const buffer = await fileEntry.arrayBuffer();
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: fileEntry.type,
299
+ mimetype: declaredMime,
235
300
  size: fileEntry.size,
236
- buffer: new Uint8Array(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
- let variants = null;
250
- if (uploadOptions.resolutions !== void 0 && isImage(uploadFile.mimetype)) {
251
- const generated = await generateVariants(
252
- uploadFile,
253
- uploadOptions.resolutions,
254
- uploadOptions.format,
255
- quality,
256
- async (variantFile) => {
257
- const variantResult = await uploadOptions.provider.upload(variantFile);
258
- return { key: variantResult.key };
259
- }
260
- );
261
- variants = generated;
262
- }
263
- const mediaRecord = await datrix.raw.create(modelName, {
264
- filename: result.key,
265
- originalName: uploadFile.originalName,
266
- mimeType: uploadFile.mimetype,
267
- size: uploadFile.size,
268
- key: result.key,
269
- ...variants !== null && { variants }
270
- });
271
- const data = options.injectUrls ? await options.injectUrls(mediaRecord) : mediaRecord;
272
- return (0, import_api.jsonResponse)({ data }, 201);
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
- await uploadOptions.provider.delete(variant.key);
355
+ if (typeof variant?.key === "string") {
356
+ keys.push(variant.key);
357
+ }
284
358
  }
285
359
  }
286
- await uploadOptions.provider.delete(record.key);
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 validateFileLimits(file, options) {
291
- if (options.maxSize !== void 0 && file.size > options.maxSize) {
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 ${file.size} exceeds maximum allowed size ${options.maxSize}`,
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(file.mimetype, options.allowedMimeTypes)) {
298
- throw new import_api.DatrixApiError(`MIME type ${file.mimetype} is not allowed`, {
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
- const mediaSchema = createMediaSchema(
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
- async traverse(node) {
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
- const results = [];
349
- for (const item of node) {
350
- results.push(await this.traverse(item));
351
- }
352
- return results;
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 !== null && typeof node === "object") {
355
- const obj = node;
356
- const result = {};
357
- for (const [k, v] of Object.entries(obj)) {
358
- result[k] = await this.traverse(v);
359
- }
360
- if (typeof result["key"] === "string" && result["url"] === void 0) {
361
- result["url"] = this.options.provider.getUrl(result["key"]);
362
- }
363
- if (result["variants"] !== null && typeof result["variants"] === "object") {
364
- const variants = result["variants"];
365
- for (const [name, variant] of Object.entries(variants)) {
366
- if (variant !== null && typeof variant === "object") {
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 UploadError = class extends import_core5.DatrixError {
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, import_core4.sanitizeFilename)(file.originalName);
411
- const uniqueFilename = (0, import_core4.generateUniqueFilename)(sanitized);
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 UploadError2 = class extends import_core7.DatrixError {
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, import_core6.sanitizeFilename)(file.originalName);
502
- const filename = (0, import_core6.generateUniqueFilename)(sanitized);
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.putObject(key, file.buffer, file.mimetype);
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.deleteObject(key);
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
- return `https://${this.bucket}.${this.endpoint}/${key}`;
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.headObject(key);
655
+ await this.sendRequest("HEAD", key, { readErrorBody: false });
532
656
  return true;
533
657
  } catch {
534
658
  return false;
535
659
  }
536
660
  }
537
- async putObject(key, buffer, contentType) {
538
- const https = await import("https");
539
- const crypto = await import("crypto");
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
- async deleteObject(key) {
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 = `${this.bucket}.${this.endpoint}`;
595
- const urlPath = `/${key}`;
596
- const method = "DELETE";
597
- const date = (/* @__PURE__ */ new Date()).toUTCString();
598
- const contentHash = crypto.createHash("sha256").update("").digest("hex");
599
- const authorization = await this.signRequest(
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
- } else {
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
- req.on("error", (error) => {
637
- reject(new UploadError2("S3 request failed", error));
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
- (res) => {
673
- const status = res.statusCode ?? 0;
674
- if (status >= 200 && status < 300) resolve();
675
- else reject(new UploadError2(`Object not found: ${status}`));
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
- async signRequest(method, urlPath, host, date, _contentType, contentHash) {
685
- const crypto = await import("crypto");
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,