@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.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(options, permission) {
4
- const modelName = options.modelName ?? "media";
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
- ...permission !== void 0 && { permission }
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
- let sharp;
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
- let sharp;
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 idSegment = pathAfterUpload.replace(/^\//, "").split("/")[0];
150
- const id = idSegment !== void 0 && idSegment !== "" ? Number(idSegment) : null;
151
- if (method === "POST" && id === null) {
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" && id !== null) {
155
- return await handleDeleteMedia(id, options);
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 fileEntry = formData.get("file");
192
- if (!(fileEntry instanceof File)) {
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
- const buffer = await fileEntry.arrayBuffer();
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: fileEntry.type,
266
+ mimetype: declaredMime,
202
267
  size: fileEntry.size,
203
- buffer: new Uint8Array(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
- let variants = null;
217
- if (uploadOptions.resolutions !== void 0 && isImage(uploadFile.mimetype)) {
218
- const generated = await generateVariants(
219
- uploadFile,
220
- uploadOptions.resolutions,
221
- uploadOptions.format,
222
- quality,
223
- async (variantFile) => {
224
- const variantResult = await uploadOptions.provider.upload(variantFile);
225
- return { key: variantResult.key };
226
- }
227
- );
228
- variants = generated;
229
- }
230
- const mediaRecord = await datrix.raw.create(modelName, {
231
- filename: result.key,
232
- originalName: uploadFile.originalName,
233
- mimeType: uploadFile.mimetype,
234
- size: uploadFile.size,
235
- key: result.key,
236
- ...variants !== null && { variants }
237
- });
238
- const data = options.injectUrls ? await options.injectUrls(mediaRecord) : mediaRecord;
239
- return jsonResponse({ data }, 201);
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
- await uploadOptions.provider.delete(variant.key);
322
+ if (typeof variant?.key === "string") {
323
+ keys.push(variant.key);
324
+ }
251
325
  }
252
326
  }
253
- await uploadOptions.provider.delete(record.key);
254
- await datrix.raw.delete(modelName, id);
327
+ await cleanupKeys(uploadOptions.provider, keys);
255
328
  return jsonResponse({ data: { id } });
256
329
  }
257
- function validateFileLimits(file, options) {
258
- if (options.maxSize !== void 0 && file.size > options.maxSize) {
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 ${file.size} exceeds maximum allowed size ${options.maxSize}`,
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(file.mimetype, options.allowedMimeTypes)) {
265
- throw new DatrixApiError(`MIME type ${file.mimetype} is not allowed`, {
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
- const mediaSchema = createMediaSchema(
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
- async traverse(node) {
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
- const results = [];
316
- for (const item of node) {
317
- results.push(await this.traverse(item));
318
- }
319
- return results;
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 !== null && typeof node === "object") {
322
- const obj = node;
323
- const result = {};
324
- for (const [k, v] of Object.entries(obj)) {
325
- result[k] = await this.traverse(v);
326
- }
327
- if (typeof result["key"] === "string" && result["url"] === void 0) {
328
- result["url"] = this.options.provider.getUrl(result["key"]);
329
- }
330
- if (result["variants"] !== null && typeof result["variants"] === "object") {
331
- const variants = result["variants"];
332
- for (const [name, variant] of Object.entries(variants)) {
333
- if (variant !== null && typeof variant === "object") {
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 DatrixError3 } from "@datrix/core";
353
- var UploadError = class extends DatrixError3 {
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 DatrixError4 } from "@datrix/core";
440
- var UploadError2 = class extends DatrixError4 {
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.putObject(key, file.buffer, file.mimetype);
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.deleteObject(key);
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
- return `https://${this.bucket}.${this.endpoint}/${key}`;
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.headObject(key);
622
+ await this.sendRequest("HEAD", key, { readErrorBody: false });
499
623
  return true;
500
624
  } catch {
501
625
  return false;
502
626
  }
503
627
  }
504
- async putObject(key, buffer, contentType) {
505
- const https = await import("https");
506
- const crypto = await import("crypto");
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
- async deleteObject(key) {
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 = `${this.bucket}.${this.endpoint}`;
562
- const urlPath = `/${key}`;
563
- const method = "DELETE";
564
- const date = (/* @__PURE__ */ new Date()).toUTCString();
565
- const contentHash = crypto.createHash("sha256").update("").digest("hex");
566
- const authorization = await this.signRequest(
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
- } else {
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
- req.on("error", (error) => {
604
- reject(new UploadError2("S3 request failed", error));
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
- (res) => {
640
- const status = res.statusCode ?? 0;
641
- if (status >= 200 && status < 300) resolve();
642
- else reject(new UploadError2(`Object not found: ${status}`));
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
- async signRequest(method, urlPath, host, date, _contentType, contentHash) {
652
- const crypto = await import("crypto");
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,