@datrix/api-upload 0.1.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/LICENSE +21 -0
- package/README.md +180 -0
- package/dist/index.js +736 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +702 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
LocalStorageProvider: () => LocalStorageProvider,
|
|
34
|
+
S3StorageProvider: () => S3StorageProvider,
|
|
35
|
+
Upload: () => Upload
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(index_exports);
|
|
38
|
+
|
|
39
|
+
// src/schema.ts
|
|
40
|
+
var import_core = require("@datrix/core");
|
|
41
|
+
function createMediaSchema(options, permission) {
|
|
42
|
+
const modelName = options.modelName ?? "media";
|
|
43
|
+
return (0, import_core.defineSchema)({
|
|
44
|
+
name: modelName,
|
|
45
|
+
fields: {
|
|
46
|
+
filename: { type: "string", required: true },
|
|
47
|
+
originalName: { type: "string", required: true },
|
|
48
|
+
mimeType: { type: "string", required: true },
|
|
49
|
+
size: { type: "number", required: true, integer: true },
|
|
50
|
+
key: { type: "string", required: true },
|
|
51
|
+
variants: { type: "json" }
|
|
52
|
+
},
|
|
53
|
+
...permission !== void 0 && { permission }
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/handler.ts
|
|
58
|
+
var import_api = require("@datrix/api");
|
|
59
|
+
var import_core3 = require("@datrix/core");
|
|
60
|
+
|
|
61
|
+
// src/processor.ts
|
|
62
|
+
var import_core2 = require("@datrix/core");
|
|
63
|
+
var IMAGE_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
64
|
+
"image/jpeg",
|
|
65
|
+
"image/png",
|
|
66
|
+
"image/webp",
|
|
67
|
+
"image/avif",
|
|
68
|
+
"image/gif",
|
|
69
|
+
"image/tiff"
|
|
70
|
+
]);
|
|
71
|
+
function isImage(mimetype) {
|
|
72
|
+
return IMAGE_MIME_TYPES.has(mimetype);
|
|
73
|
+
}
|
|
74
|
+
function getMimeType(format) {
|
|
75
|
+
const map = {
|
|
76
|
+
webp: "image/webp",
|
|
77
|
+
jpeg: "image/jpeg",
|
|
78
|
+
png: "image/png",
|
|
79
|
+
avif: "image/avif"
|
|
80
|
+
};
|
|
81
|
+
return map[format];
|
|
82
|
+
}
|
|
83
|
+
function getExtension(format) {
|
|
84
|
+
return format === "jpeg" ? "jpg" : format;
|
|
85
|
+
}
|
|
86
|
+
async function convertFormat(file, format, quality) {
|
|
87
|
+
if (!isImage(file.mimetype)) {
|
|
88
|
+
return {
|
|
89
|
+
buffer: file.buffer,
|
|
90
|
+
mimetype: file.mimetype,
|
|
91
|
+
filename: file.filename
|
|
92
|
+
};
|
|
93
|
+
}
|
|
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
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const converted = await sharp(file.buffer).toFormat(format, { quality }).toBuffer();
|
|
105
|
+
const ext = getExtension(format);
|
|
106
|
+
const baseName = file.filename.replace(/\.[^.]+$/, "");
|
|
107
|
+
const newFilename = `${baseName}.${ext}`;
|
|
108
|
+
return {
|
|
109
|
+
buffer: new Uint8Array(converted),
|
|
110
|
+
mimetype: getMimeType(format),
|
|
111
|
+
filename: newFilename
|
|
112
|
+
};
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new import_core2.DatrixError("Failed to convert image format", {
|
|
115
|
+
code: "IMAGE_CONVERT_ERROR",
|
|
116
|
+
operation: "upload:convertFormat",
|
|
117
|
+
cause: error instanceof Error ? error : void 0
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function generateVariants(file, resolutions, format, quality, uploadFn) {
|
|
122
|
+
if (!isImage(file.mimetype)) {
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
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
|
+
}
|
|
134
|
+
const targetFormat = format ?? "jpeg";
|
|
135
|
+
const outputMime = getMimeType(targetFormat);
|
|
136
|
+
const outputExt = getExtension(targetFormat);
|
|
137
|
+
const variants = {};
|
|
138
|
+
for (const [name, config] of Object.entries(resolutions)) {
|
|
139
|
+
try {
|
|
140
|
+
const resizeOptions = {
|
|
141
|
+
width: config.width,
|
|
142
|
+
...config.height !== void 0 && { height: config.height },
|
|
143
|
+
...config.fit !== void 0 && config.height !== void 0 && { fit: config.fit }
|
|
144
|
+
};
|
|
145
|
+
const variantBuffer = await sharp(file.buffer).resize(resizeOptions).toFormat(targetFormat, { quality }).toBuffer({ resolveWithObject: true });
|
|
146
|
+
const baseName = file.filename.replace(/\.[^.]+$/, "");
|
|
147
|
+
const variantFilename = `${baseName}-${name}.${outputExt}`;
|
|
148
|
+
const variantFile = {
|
|
149
|
+
filename: variantFilename,
|
|
150
|
+
originalName: variantFilename,
|
|
151
|
+
mimetype: outputMime,
|
|
152
|
+
size: variantBuffer.data.length,
|
|
153
|
+
buffer: new Uint8Array(variantBuffer.data)
|
|
154
|
+
};
|
|
155
|
+
const uploaded = await uploadFn(variantFile);
|
|
156
|
+
variants[name] = {
|
|
157
|
+
key: uploaded.key,
|
|
158
|
+
width: variantBuffer.info.width,
|
|
159
|
+
height: variantBuffer.info.height,
|
|
160
|
+
size: variantBuffer.data.length,
|
|
161
|
+
mimeType: outputMime,
|
|
162
|
+
url: void 0
|
|
163
|
+
};
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof import_core2.DatrixError) throw error;
|
|
166
|
+
throw new import_core2.DatrixError(`Failed to generate variant: ${name}`, {
|
|
167
|
+
code: "VARIANT_GENERATE_ERROR",
|
|
168
|
+
operation: "upload:generateVariants",
|
|
169
|
+
cause: error instanceof Error ? error : void 0
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return variants;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/handler.ts
|
|
177
|
+
async function handleUploadRequest(request, options) {
|
|
178
|
+
try {
|
|
179
|
+
const { method } = request;
|
|
180
|
+
const url = new URL(request.url);
|
|
181
|
+
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) {
|
|
185
|
+
return await handleUpload(request, options);
|
|
186
|
+
}
|
|
187
|
+
if (method === "DELETE" && id !== null) {
|
|
188
|
+
return await handleDeleteMedia(id, options);
|
|
189
|
+
}
|
|
190
|
+
return (0, import_api.datrixErrorResponse)(import_api.handlerError.methodNotAllowed(method));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error instanceof import_core3.DatrixValidationError || error instanceof import_core3.DatrixError) {
|
|
193
|
+
return (0, import_api.datrixErrorResponse)(error);
|
|
194
|
+
}
|
|
195
|
+
const message = error instanceof Error ? error.message : "Upload failed";
|
|
196
|
+
return (0, import_api.datrixErrorResponse)(
|
|
197
|
+
import_api.handlerError.internalError(
|
|
198
|
+
message,
|
|
199
|
+
error instanceof Error ? error : void 0
|
|
200
|
+
)
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function handleUpload(request, options) {
|
|
205
|
+
const { datrix, uploadOptions } = options;
|
|
206
|
+
const modelName = uploadOptions.modelName ?? "media";
|
|
207
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
208
|
+
if (!contentType.includes("multipart/form-data")) {
|
|
209
|
+
return (0, import_api.datrixErrorResponse)(
|
|
210
|
+
import_api.handlerError.invalidBody("Expected multipart/form-data")
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
let formData;
|
|
214
|
+
try {
|
|
215
|
+
formData = await request.formData();
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const cause = error instanceof Error ? error : void 0;
|
|
218
|
+
throw new import_api.DatrixApiError("Failed to parse multipart form data", {
|
|
219
|
+
code: "MULTIPART_PARSE_ERROR",
|
|
220
|
+
status: 400,
|
|
221
|
+
...cause !== void 0 && { cause }
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const fileEntry = formData.get("file");
|
|
225
|
+
if (!(fileEntry instanceof File)) {
|
|
226
|
+
return (0, import_api.datrixErrorResponse)(
|
|
227
|
+
import_api.handlerError.invalidBody("No file field in form data")
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const buffer = await fileEntry.arrayBuffer();
|
|
231
|
+
const rawFile = {
|
|
232
|
+
filename: fileEntry.name,
|
|
233
|
+
originalName: fileEntry.name,
|
|
234
|
+
mimetype: fileEntry.type,
|
|
235
|
+
size: fileEntry.size,
|
|
236
|
+
buffer: new Uint8Array(buffer)
|
|
237
|
+
};
|
|
238
|
+
validateFileLimits(rawFile, uploadOptions);
|
|
239
|
+
const quality = uploadOptions.quality ?? 80;
|
|
240
|
+
const fileToUpload = uploadOptions.format !== void 0 && isImage(rawFile.mimetype) ? await convertFormat(rawFile, uploadOptions.format, quality) : rawFile;
|
|
241
|
+
const uploadFile = {
|
|
242
|
+
filename: fileToUpload.filename,
|
|
243
|
+
originalName: rawFile.originalName,
|
|
244
|
+
mimetype: fileToUpload.mimetype,
|
|
245
|
+
size: fileToUpload.buffer.length,
|
|
246
|
+
buffer: fileToUpload.buffer
|
|
247
|
+
};
|
|
248
|
+
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);
|
|
273
|
+
}
|
|
274
|
+
async function handleDeleteMedia(id, options) {
|
|
275
|
+
const { datrix, uploadOptions } = options;
|
|
276
|
+
const modelName = uploadOptions.modelName ?? "media";
|
|
277
|
+
const record = await datrix.raw.findById(modelName, id);
|
|
278
|
+
if (record === null) {
|
|
279
|
+
return (0, import_api.datrixErrorResponse)(import_api.handlerError.recordNotFound(modelName, id));
|
|
280
|
+
}
|
|
281
|
+
if (record.variants !== null && record.variants !== void 0) {
|
|
282
|
+
for (const variant of Object.values(record.variants)) {
|
|
283
|
+
await uploadOptions.provider.delete(variant.key);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
await uploadOptions.provider.delete(record.key);
|
|
287
|
+
await datrix.raw.delete(modelName, id);
|
|
288
|
+
return (0, import_api.jsonResponse)({ data: { id } });
|
|
289
|
+
}
|
|
290
|
+
function validateFileLimits(file, options) {
|
|
291
|
+
if (options.maxSize !== void 0 && file.size > options.maxSize) {
|
|
292
|
+
throw new import_api.DatrixApiError(
|
|
293
|
+
`File size ${file.size} exceeds maximum allowed size ${options.maxSize}`,
|
|
294
|
+
{ code: "FILE_TOO_LARGE", status: 400 }
|
|
295
|
+
);
|
|
296
|
+
}
|
|
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`, {
|
|
299
|
+
code: "INVALID_MIME_TYPE",
|
|
300
|
+
status: 400
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function isMimeTypeAllowed(mimetype, allowedTypes) {
|
|
305
|
+
for (const allowed of allowedTypes) {
|
|
306
|
+
if (allowed === mimetype) return true;
|
|
307
|
+
if (allowed.endsWith("/*")) {
|
|
308
|
+
const prefix = allowed.slice(0, -2);
|
|
309
|
+
if (mimetype.startsWith(prefix + "/")) return true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/upload.ts
|
|
316
|
+
var Upload = class {
|
|
317
|
+
options;
|
|
318
|
+
provider;
|
|
319
|
+
constructor(options) {
|
|
320
|
+
this.options = options;
|
|
321
|
+
this.provider = options.provider;
|
|
322
|
+
}
|
|
323
|
+
getModelName() {
|
|
324
|
+
return this.options.modelName ?? "media";
|
|
325
|
+
}
|
|
326
|
+
getSchemas() {
|
|
327
|
+
const mediaSchema = createMediaSchema(
|
|
328
|
+
this.options,
|
|
329
|
+
this.options.permission
|
|
330
|
+
);
|
|
331
|
+
return [mediaSchema];
|
|
332
|
+
}
|
|
333
|
+
async handleRequest(request, datrix) {
|
|
334
|
+
return handleUploadRequest(request, {
|
|
335
|
+
datrix,
|
|
336
|
+
uploadOptions: this.options,
|
|
337
|
+
injectUrls: (data) => this.injectUrls(data)
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
async injectUrls(data) {
|
|
341
|
+
return this.traverse(data);
|
|
342
|
+
}
|
|
343
|
+
getUrl(key) {
|
|
344
|
+
return this.options.provider.getUrl(key);
|
|
345
|
+
}
|
|
346
|
+
async traverse(node) {
|
|
347
|
+
if (Array.isArray(node)) {
|
|
348
|
+
const results = [];
|
|
349
|
+
for (const item of node) {
|
|
350
|
+
results.push(await this.traverse(item));
|
|
351
|
+
}
|
|
352
|
+
return results;
|
|
353
|
+
}
|
|
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;
|
|
378
|
+
}
|
|
379
|
+
return node;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// src/providers/local.ts
|
|
384
|
+
var import_core4 = require("@datrix/core");
|
|
385
|
+
var import_core5 = require("@datrix/core");
|
|
386
|
+
var UploadError = class extends import_core5.DatrixError {
|
|
387
|
+
constructor(message, cause) {
|
|
388
|
+
super(message, {
|
|
389
|
+
code: "UPLOAD_ERROR",
|
|
390
|
+
operation: "upload:local",
|
|
391
|
+
...cause !== void 0 && { cause }
|
|
392
|
+
});
|
|
393
|
+
this.name = "UploadError";
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
var LocalStorageProvider = class {
|
|
397
|
+
name = "local";
|
|
398
|
+
basePath;
|
|
399
|
+
baseUrl;
|
|
400
|
+
ensureDirectory;
|
|
401
|
+
constructor(options) {
|
|
402
|
+
this.basePath = options.basePath;
|
|
403
|
+
this.baseUrl = options.baseUrl;
|
|
404
|
+
this.ensureDirectory = options.ensureDirectory ?? true;
|
|
405
|
+
}
|
|
406
|
+
async upload(file) {
|
|
407
|
+
try {
|
|
408
|
+
const fs = await import("fs/promises");
|
|
409
|
+
const path = await import("path");
|
|
410
|
+
const sanitized = (0, import_core4.sanitizeFilename)(file.originalName);
|
|
411
|
+
const uniqueFilename = (0, import_core4.generateUniqueFilename)(sanitized);
|
|
412
|
+
const fullPath = path.join(this.basePath, uniqueFilename);
|
|
413
|
+
if (this.ensureDirectory) {
|
|
414
|
+
const dirPath = path.dirname(fullPath);
|
|
415
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
416
|
+
}
|
|
417
|
+
await fs.writeFile(fullPath, file.buffer);
|
|
418
|
+
return {
|
|
419
|
+
key: uniqueFilename,
|
|
420
|
+
size: file.size,
|
|
421
|
+
mimetype: file.mimetype,
|
|
422
|
+
uploadedAt: /* @__PURE__ */ new Date()
|
|
423
|
+
};
|
|
424
|
+
} catch (error) {
|
|
425
|
+
const cause = error instanceof Error ? error : void 0;
|
|
426
|
+
throw new UploadError("Failed to upload file to local filesystem", cause);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async delete(key) {
|
|
430
|
+
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
|
+
try {
|
|
440
|
+
await fs.unlink(fullPath);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
const cause = error instanceof Error ? error : void 0;
|
|
443
|
+
throw new UploadError(
|
|
444
|
+
"Failed to delete file from local filesystem",
|
|
445
|
+
cause
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
getUrl(key) {
|
|
450
|
+
return this.buildUrl(key);
|
|
451
|
+
}
|
|
452
|
+
async exists(key) {
|
|
453
|
+
try {
|
|
454
|
+
const fs = await import("fs/promises");
|
|
455
|
+
const path = await import("path");
|
|
456
|
+
const fullPath = path.join(this.basePath, key);
|
|
457
|
+
await fs.access(fullPath);
|
|
458
|
+
return true;
|
|
459
|
+
} catch {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
buildUrl(key) {
|
|
464
|
+
const cleanBaseUrl = this.baseUrl.replace(/\/$/, "");
|
|
465
|
+
const cleanKey = key.replace(/^\//, "");
|
|
466
|
+
return `${cleanBaseUrl}/${cleanKey}`;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// src/providers/s3.ts
|
|
471
|
+
var import_core6 = require("@datrix/core");
|
|
472
|
+
var import_core7 = require("@datrix/core");
|
|
473
|
+
var UploadError2 = class extends import_core7.DatrixError {
|
|
474
|
+
constructor(message, cause) {
|
|
475
|
+
super(message, {
|
|
476
|
+
code: "UPLOAD_ERROR",
|
|
477
|
+
operation: "upload:s3",
|
|
478
|
+
...cause !== void 0 && { cause }
|
|
479
|
+
});
|
|
480
|
+
this.name = "UploadError";
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
var S3StorageProvider = class {
|
|
484
|
+
name = "s3";
|
|
485
|
+
bucket;
|
|
486
|
+
region;
|
|
487
|
+
accessKeyId;
|
|
488
|
+
secretAccessKey;
|
|
489
|
+
endpoint;
|
|
490
|
+
pathPrefix;
|
|
491
|
+
constructor(options) {
|
|
492
|
+
this.bucket = options.bucket;
|
|
493
|
+
this.region = options.region;
|
|
494
|
+
this.accessKeyId = options.accessKeyId;
|
|
495
|
+
this.secretAccessKey = options.secretAccessKey;
|
|
496
|
+
this.endpoint = options.endpoint ?? `s3.${options.region}.amazonaws.com`;
|
|
497
|
+
this.pathPrefix = options.pathPrefix ?? "uploads";
|
|
498
|
+
}
|
|
499
|
+
async upload(file) {
|
|
500
|
+
try {
|
|
501
|
+
const sanitized = (0, import_core6.sanitizeFilename)(file.originalName);
|
|
502
|
+
const filename = (0, import_core6.generateUniqueFilename)(sanitized);
|
|
503
|
+
const key = this.pathPrefix ? `${this.pathPrefix}/${filename}` : filename;
|
|
504
|
+
await this.putObject(key, file.buffer, file.mimetype);
|
|
505
|
+
return {
|
|
506
|
+
key,
|
|
507
|
+
size: file.size,
|
|
508
|
+
mimetype: file.mimetype,
|
|
509
|
+
uploadedAt: /* @__PURE__ */ new Date()
|
|
510
|
+
};
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (error instanceof UploadError2) throw error;
|
|
513
|
+
const cause = error instanceof Error ? error : void 0;
|
|
514
|
+
throw new UploadError2("Failed to upload file to S3", cause);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async delete(key) {
|
|
518
|
+
try {
|
|
519
|
+
await this.deleteObject(key);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (error instanceof UploadError2) throw error;
|
|
522
|
+
const cause = error instanceof Error ? error : void 0;
|
|
523
|
+
throw new UploadError2("Failed to delete file from S3", cause);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
getUrl(key) {
|
|
527
|
+
return `https://${this.bucket}.${this.endpoint}/${key}`;
|
|
528
|
+
}
|
|
529
|
+
async exists(key) {
|
|
530
|
+
try {
|
|
531
|
+
await this.headObject(key);
|
|
532
|
+
return true;
|
|
533
|
+
} catch {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
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
|
+
});
|
|
590
|
+
}
|
|
591
|
+
async deleteObject(key) {
|
|
592
|
+
const https = await import("https");
|
|
593
|
+
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(
|
|
600
|
+
method,
|
|
601
|
+
urlPath,
|
|
602
|
+
host,
|
|
603
|
+
date,
|
|
604
|
+
"",
|
|
605
|
+
contentHash
|
|
606
|
+
);
|
|
607
|
+
await new Promise((resolve, reject) => {
|
|
608
|
+
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
|
+
},
|
|
621
|
+
(res) => {
|
|
622
|
+
const status = res.statusCode ?? 0;
|
|
623
|
+
if (status >= 200 && status < 300) {
|
|
624
|
+
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
|
+
});
|
|
633
|
+
}
|
|
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
|
|
670
|
+
}
|
|
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}`));
|
|
676
|
+
}
|
|
677
|
+
);
|
|
678
|
+
req.on("error", (error) => {
|
|
679
|
+
reject(new UploadError2("S3 request failed", error));
|
|
680
|
+
});
|
|
681
|
+
req.end();
|
|
682
|
+
});
|
|
683
|
+
}
|
|
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();
|
|
714
|
+
const kRegion = crypto.createHmac("sha256", kDate).update(this.region).digest();
|
|
715
|
+
const kService = crypto.createHmac("sha256", kRegion).update("s3").digest();
|
|
716
|
+
const kSigning = crypto.createHmac("sha256", kService).update("aws4_request").digest();
|
|
717
|
+
return crypto.createHmac("sha256", kSigning).update(stringToSign).digest("hex");
|
|
718
|
+
}
|
|
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
|
+
};
|
|
730
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
731
|
+
0 && (module.exports = {
|
|
732
|
+
LocalStorageProvider,
|
|
733
|
+
S3StorageProvider,
|
|
734
|
+
Upload
|
|
735
|
+
});
|
|
736
|
+
//# sourceMappingURL=index.js.map
|