@rezwanul7/storage 0.0.0-stage → 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.
@@ -0,0 +1,545 @@
1
+ 'use strict';
2
+
3
+ var fileType = require('file-type');
4
+ var fs = require('fs/promises');
5
+ var fs$1 = require('fs');
6
+ var path = require('path');
7
+ var stream = require('stream');
8
+ var crypto = require('crypto');
9
+
10
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
11
+
12
+ var fs__default = /*#__PURE__*/_interopDefault(fs);
13
+ var path__default = /*#__PURE__*/_interopDefault(path);
14
+ var crypto__default = /*#__PURE__*/_interopDefault(crypto);
15
+
16
+ // src/server/storage.errors.ts
17
+ var StorageError = class extends Error {
18
+ /** Always `"StorageError"`. */
19
+ name = "StorageError";
20
+ /** Stable code naming what went wrong. */
21
+ code;
22
+ /** Context for logs, such as the Provider's name. */
23
+ meta;
24
+ /**
25
+ * @param code - The stable code.
26
+ * @param opts - The message, the underlying `cause` and context for logs.
27
+ */
28
+ constructor(code, opts) {
29
+ super(opts.message, { cause: opts.cause });
30
+ this.code = code;
31
+ this.meta = opts.meta;
32
+ }
33
+ };
34
+
35
+ // src/shared/mimeTypes.ts
36
+ var DANGEROUS_EXTENSIONS = /* @__PURE__ */ new Set([
37
+ "sh",
38
+ "bash",
39
+ "zsh",
40
+ "exe",
41
+ "dll",
42
+ "com",
43
+ "scr",
44
+ "bat",
45
+ "cmd",
46
+ "ps1",
47
+ "jar",
48
+ "class"
49
+ ]);
50
+ var DANGEROUS_MIME = /* @__PURE__ */ new Set([
51
+ "application/x-sh",
52
+ "application/x-shellscript",
53
+ "text/x-shellscript",
54
+ "text/x-sh",
55
+ "application/x-dosexec",
56
+ "application/java-archive"
57
+ ]);
58
+ var MIME_BY_EXT = {
59
+ // Image
60
+ png: "image/png",
61
+ jpg: "image/jpeg",
62
+ jpeg: "image/jpeg",
63
+ gif: "image/gif",
64
+ bmp: "image/bmp",
65
+ webp: "image/webp",
66
+ tiff: "image/tiff",
67
+ tif: "image/tiff",
68
+ svg: "image/svg+xml",
69
+ ico: "image/vnd.microsoft.icon",
70
+ avif: "image/avif",
71
+ heic: "image/heic",
72
+ // Audio
73
+ mp3: "audio/mpeg",
74
+ wav: "audio/wav",
75
+ ogg: "audio/ogg",
76
+ flac: "audio/x-flac",
77
+ m4a: "audio/mp4",
78
+ // Video
79
+ mp4: "video/mp4",
80
+ mkv: "video/x-matroska",
81
+ mov: "video/quicktime",
82
+ avi: "video/x-msvideo",
83
+ webm: "video/webm",
84
+ // Documents
85
+ pdf: "application/pdf",
86
+ xls: "application/vnd.ms-excel",
87
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
88
+ ods: "application/vnd.oasis.opendocument.spreadsheet",
89
+ ppt: "application/vnd.ms-powerpoint",
90
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
91
+ odp: "application/vnd.oasis.opendocument.presentation",
92
+ doc: "application/msword",
93
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
94
+ odt: "application/vnd.oasis.opendocument.text",
95
+ rtf: "application/rtf",
96
+ // Datasets (JSON Lines has no registered MIME, so it is served as JSON)
97
+ csv: "text/csv",
98
+ tsv: "text/tab-separated-values",
99
+ json: "application/json",
100
+ jsonl: "application/json",
101
+ ndjson: "application/json",
102
+ // Archives
103
+ zip: "application/zip",
104
+ rar: "application/x-rar-compressed",
105
+ "7z": "application/x-7z-compressed",
106
+ tar: "application/x-tar",
107
+ gz: "application/gzip",
108
+ // Code
109
+ js: "text/javascript",
110
+ jsx: "text/jsx",
111
+ ts: "text/typescript",
112
+ tsx: "text/tsx",
113
+ py: "text/x-python",
114
+ rb: "text/x-ruby",
115
+ php: "application/x-httpd-php",
116
+ java: "text/x-java-source",
117
+ kt: "text/x-kotlin",
118
+ c: "text/x-c",
119
+ cpp: "text/x-c",
120
+ h: "text/x-c",
121
+ hpp: "text/x-c",
122
+ cs: "text/x-csharp",
123
+ go: "text/x-go",
124
+ rs: "text/x-rust",
125
+ html: "text/html",
126
+ htm: "text/html",
127
+ css: "text/css",
128
+ scss: "text/x-scss",
129
+ yaml: "text/yaml",
130
+ yml: "text/yaml",
131
+ sql: "application/sql",
132
+ xml: "application/xml",
133
+ // Text
134
+ txt: "text/plain",
135
+ md: "text/markdown",
136
+ markdown: "text/markdown",
137
+ log: "text/plain",
138
+ ini: "text/plain",
139
+ cfg: "text/plain",
140
+ conf: "text/plain",
141
+ env: "text/plain",
142
+ // Executables (blocked by DANGEROUS_EXTENSIONS, mapped so they report a real MIME)
143
+ sh: "application/x-sh",
144
+ bash: "application/x-sh",
145
+ zsh: "application/x-sh",
146
+ exe: "application/x-msdos-program",
147
+ dll: "application/x-msdownload",
148
+ com: "application/x-msdownload",
149
+ bat: "application/x-msdownload",
150
+ jar: "application/java-archive",
151
+ class: "application/java-vm"
152
+ };
153
+ var EXT_BY_MIME = {};
154
+ for (const [ext, mime] of Object.entries(MIME_BY_EXT)) {
155
+ EXT_BY_MIME[mime] ??= ext;
156
+ }
157
+ function mimeFromExtension(ext) {
158
+ if (!ext) return void 0;
159
+ return MIME_BY_EXT[ext.toLowerCase()];
160
+ }
161
+ function extensionFromMime(mime) {
162
+ if (!mime) return void 0;
163
+ return EXT_BY_MIME[mime.toLowerCase()];
164
+ }
165
+
166
+ // src/shared/fileExtension.ts
167
+ function getFileExtension(name) {
168
+ const dot = name.lastIndexOf(".");
169
+ if (dot === -1) return void 0;
170
+ return name.slice(dot + 1).toLowerCase() || void 0;
171
+ }
172
+
173
+ // src/server/internal/detectFileType.ts
174
+ async function detectFileType(buffer, fileName, declaredMime) {
175
+ const declaredExt = getFileExtension(fileName);
176
+ const detected = await fileType.fileTypeFromBuffer(buffer);
177
+ if (detected) {
178
+ return {
179
+ declaredExt,
180
+ declaredMime,
181
+ detectedExt: detected.ext,
182
+ detectedMime: detected.mime,
183
+ ext: detected.ext,
184
+ mime: detected.mime,
185
+ confidence: "high"
186
+ };
187
+ }
188
+ if (declaredExt) {
189
+ const mime = mimeFromExtension(declaredExt);
190
+ return {
191
+ declaredExt,
192
+ declaredMime,
193
+ ext: declaredExt,
194
+ mime: mime ?? declaredMime ?? "application/octet-stream",
195
+ confidence: mime ? "medium" : "low"
196
+ };
197
+ }
198
+ if (declaredMime) {
199
+ const mimeExt = extensionFromMime(declaredMime);
200
+ return {
201
+ declaredExt,
202
+ declaredMime,
203
+ ext: mimeExt,
204
+ mime: declaredMime,
205
+ confidence: "low"
206
+ };
207
+ }
208
+ return { confidence: "low" };
209
+ }
210
+
211
+ // src/server/internal/checkTypeSpoofing.ts
212
+ function checkForSpoofing(d) {
213
+ if (d.detectedExt && d.declaredExt && d.detectedExt !== d.declaredExt) {
214
+ return {
215
+ ok: false,
216
+ spoofed: true,
217
+ reason: `Declared ".${d.declaredExt}" but file content is actually ".${d.detectedExt}".`
218
+ };
219
+ }
220
+ if (d.detectedMime && d.declaredMime && d.detectedMime !== d.declaredMime) {
221
+ return {
222
+ ok: false,
223
+ spoofed: true,
224
+ reason: `Declared MIME "${d.declaredMime}" does not match content "${d.detectedMime}".`
225
+ };
226
+ }
227
+ return { ok: true };
228
+ }
229
+
230
+ // src/server/internal/checkFileStructure.ts
231
+ function checkFileStructure(ext, buffer) {
232
+ if (buffer.includes(0)) {
233
+ return {
234
+ ok: false,
235
+ reason: "File contains binary data but expected text content."
236
+ };
237
+ }
238
+ const text = buffer.toString("utf8");
239
+ if (ext === "json") {
240
+ try {
241
+ JSON.parse(text);
242
+ return { ok: true };
243
+ } catch {
244
+ return { ok: false, reason: "Invalid JSON format." };
245
+ }
246
+ }
247
+ if (ext === "jsonl") {
248
+ const lines = text.split("\n").filter(Boolean);
249
+ for (const line of lines) {
250
+ try {
251
+ JSON.parse(line);
252
+ } catch {
253
+ return { ok: false, reason: "Invalid JSONL format." };
254
+ }
255
+ }
256
+ return { ok: true };
257
+ }
258
+ if (ext === "csv") {
259
+ const rows = text.split("\n");
260
+ if (rows.length < 1)
261
+ return { ok: false, reason: "CSV file is empty or malformed." };
262
+ return { ok: true };
263
+ }
264
+ return { ok: true };
265
+ }
266
+
267
+ // src/shared/fileSize.ts
268
+ var SIZE_UNITS = {
269
+ b: 1,
270
+ kb: 1024,
271
+ mb: 1024 ** 2,
272
+ gb: 1024 ** 3,
273
+ tb: 1024 ** 4
274
+ };
275
+ function parseFileSize(input) {
276
+ if (input == null) return void 0;
277
+ if (typeof input === "number" && Number.isFinite(input)) return input;
278
+ if (typeof input !== "string") return void 0;
279
+ const match = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/);
280
+ if (!match) return void 0;
281
+ return parseFloat(match[1]) * (SIZE_UNITS[match[2] || "b"] ?? 1);
282
+ }
283
+ function formatFileSize(bytes) {
284
+ if (bytes === 0) return "0 B";
285
+ const units = ["B", "KB", "MB", "GB", "TB"];
286
+ const index = Math.floor(Math.log(bytes) / Math.log(1024));
287
+ return `${(bytes / Math.pow(1024, index)).toFixed(1)} ${units[index]}`;
288
+ }
289
+
290
+ // src/server/internal/checkRules.ts
291
+ function checkRules(d, size, rules) {
292
+ const errors = [];
293
+ const detectedMime = d.mime ?? "application/octet-stream";
294
+ const detectedExt = d.ext ?? "unknown";
295
+ const maxSize = parseFileSize(rules.maxFileSize);
296
+ if (maxSize && size > maxSize) {
297
+ errors.push(`File exceeds maximum size of ${formatFileSize(maxSize)}.`);
298
+ }
299
+ const minSize = parseFileSize(rules.minFileSize);
300
+ if (minSize && size < minSize) {
301
+ errors.push(`File must be at least ${formatFileSize(minSize)}.`);
302
+ }
303
+ if (rules.allowedMimeTypes && !rules.allowedMimeTypes.includes(detectedMime)) {
304
+ errors.push(`MIME '${detectedMime}' is not allowed.`);
305
+ }
306
+ if (rules.blockedMimeTypes && rules.blockedMimeTypes.includes(detectedMime)) {
307
+ errors.push(`MIME '${detectedMime}' is blocked.`);
308
+ }
309
+ if (rules.allowedExtensions && !rules.allowedExtensions.includes(detectedExt)) {
310
+ errors.push(`Extension '.${detectedExt}' is not allowed.`);
311
+ }
312
+ if (rules.blockedExtensions && rules.blockedExtensions.includes(detectedExt)) {
313
+ errors.push(`Extension '.${detectedExt}' is blocked.`);
314
+ }
315
+ return errors;
316
+ }
317
+
318
+ // src/server/internal/checkDangerousTypes.ts
319
+ function checkDangerousTypes(ext, mime) {
320
+ if (ext && DANGEROUS_EXTENSIONS.has(ext)) {
321
+ return { ok: false, reason: `Extension '.${ext}' is unsafe.` };
322
+ }
323
+ if (mime && DANGEROUS_MIME.has(mime)) {
324
+ return { ok: false, reason: `MIME type '${mime}' is unsafe.` };
325
+ }
326
+ return { ok: true };
327
+ }
328
+
329
+ // src/server/core/validateFile.ts
330
+ async function validateFile(file, rules = {}) {
331
+ const errors = [];
332
+ const buffer = Buffer.from(await file.arrayBuffer());
333
+ const size = buffer.length;
334
+ const detection = await detectFileType(buffer, file.name, file.type);
335
+ const danger = checkDangerousTypes(detection.ext, detection.mime);
336
+ if (!danger.ok) errors.push(danger.reason);
337
+ const spoofing = checkForSpoofing(detection);
338
+ if (!spoofing.ok) errors.push(spoofing.reason);
339
+ if (detection.confidence !== "high") {
340
+ const struct = checkFileStructure(detection.ext ?? "", buffer);
341
+ if (!struct.ok) errors.push(struct.reason);
342
+ }
343
+ const hasDetectionFailure = !detection.mime || !detection.ext;
344
+ if (hasDetectionFailure) {
345
+ errors.push("File type detection failed");
346
+ }
347
+ errors.push(...checkRules(detection, size, rules));
348
+ if (rules.customValidators?.length) {
349
+ for (const validator of rules.customValidators) {
350
+ const msg = await validator({
351
+ buffer,
352
+ originalName: file.name,
353
+ declaredMime: file.type,
354
+ detectedMime: detection.mime,
355
+ detectedExt: detection.ext,
356
+ size
357
+ });
358
+ if (msg) errors.push(msg);
359
+ }
360
+ }
361
+ if (errors.length > 0 || hasDetectionFailure) {
362
+ return { valid: false, errors };
363
+ }
364
+ return {
365
+ valid: true,
366
+ file: {
367
+ originalName: file.name,
368
+ mime: detection.mime ?? "Unknown",
369
+ ext: detection.ext ?? "Unknown",
370
+ size,
371
+ buffer
372
+ }
373
+ };
374
+ }
375
+
376
+ // src/server/core/fileUpload.service.ts
377
+ var FileUploadService = class {
378
+ constructor(storage, logger) {
379
+ this.storage = storage;
380
+ this.logger = logger;
381
+ }
382
+ async store(files, rules, { directory }) {
383
+ const list = Array.isArray(files) ? files : [files];
384
+ const validated = [];
385
+ const errors = {};
386
+ for (const [index, file] of list.entries()) {
387
+ const result = await validateFile(file, rules);
388
+ if (result.valid) validated.push(result.file);
389
+ else
390
+ errors[index] = result.errors.length ? result.errors : ["File validation failed"];
391
+ }
392
+ if (Object.keys(errors).length > 0) {
393
+ return { ok: false, errors };
394
+ }
395
+ const stored = [];
396
+ try {
397
+ for (const file of validated) {
398
+ stored.push(await this.upload(file, directory));
399
+ }
400
+ } catch (error) {
401
+ await this.removeStored(stored);
402
+ if (error instanceof StorageError) throw error;
403
+ throw new StorageError("STORAGE_UPLOAD_FAILED", {
404
+ message: error instanceof Error ? error.message : "The storage provider couldn't store the file.",
405
+ cause: error,
406
+ meta: { provider: this.storage.provider, directory }
407
+ });
408
+ }
409
+ return { ok: true, files: stored };
410
+ }
411
+ async upload(validated, directory) {
412
+ const stored = await this.storage.upload({
413
+ buffer: validated.buffer,
414
+ mime: validated.mime,
415
+ ext: validated.ext,
416
+ directory
417
+ });
418
+ return {
419
+ originalFileName: validated.originalName,
420
+ provider: this.storage.provider,
421
+ storageId: stored.storageId,
422
+ url: stored.url,
423
+ size: validated.size,
424
+ mime: validated.mime,
425
+ ext: validated.ext
426
+ };
427
+ }
428
+ /** Deletes the files of a failed upload. A file left behind is logged, not thrown. */
429
+ async removeStored(stored) {
430
+ const outcomes = await Promise.allSettled(
431
+ stored.map(({ storageId }) => this.storage.delete(storageId))
432
+ );
433
+ outcomes.forEach((outcome, index) => {
434
+ if (outcome.status === "rejected") {
435
+ this.logger.error("storage.rollback_delete_failed", {
436
+ provider: this.storage.provider,
437
+ storageId: stored[index]?.storageId,
438
+ error: outcome.reason
439
+ });
440
+ }
441
+ });
442
+ }
443
+ };
444
+
445
+ // src/server/createFileUpload.ts
446
+ var noop = () => {
447
+ };
448
+ var SILENT_LOGGER = { error: noop };
449
+ function createFileUpload({
450
+ storage,
451
+ logger = SILENT_LOGGER
452
+ }) {
453
+ return new FileUploadService(storage, logger);
454
+ }
455
+ var LocalFileStorage = class {
456
+ /** The Provider's name, saved with each file. */
457
+ provider = "local";
458
+ root;
459
+ urlPrefix;
460
+ /**
461
+ * @param options - The folder to store under and the URL prefix.
462
+ */
463
+ constructor({ root, urlPrefix }) {
464
+ this.root = path__default.default.resolve(root);
465
+ this.urlPrefix = urlPrefix;
466
+ }
467
+ /**
468
+ * Writes a file under `directory` with a random name.
469
+ *
470
+ * @param params - The file's bytes, MIME type, extension and folder.
471
+ * @returns Where the file was stored.
472
+ */
473
+ async upload({
474
+ buffer,
475
+ mime,
476
+ ext,
477
+ directory
478
+ }) {
479
+ const fileId = crypto__default.default.randomBytes(16).toString("hex");
480
+ const storageId = `${directory}/${fileId}.${ext}`;
481
+ const filePath = this.resolve(storageId);
482
+ await fs__default.default.mkdir(path__default.default.dirname(filePath), { recursive: true });
483
+ await fs__default.default.writeFile(filePath, buffer);
484
+ return {
485
+ url: this.getUrl(storageId),
486
+ storageId,
487
+ mime,
488
+ size: buffer.length
489
+ };
490
+ }
491
+ /**
492
+ * Reads a stored file as a stream.
493
+ *
494
+ * @param storageId - The file's Storage ID.
495
+ * @returns The file, or null when nothing is stored there.
496
+ */
497
+ async read(storageId) {
498
+ const filePath = this.resolve(storageId);
499
+ try {
500
+ const stats = await fs__default.default.stat(filePath);
501
+ if (!stats.isFile()) return null;
502
+ return {
503
+ stream: stream.Readable.toWeb(
504
+ fs$1.createReadStream(filePath)
505
+ ),
506
+ size: stats.size
507
+ };
508
+ } catch (error) {
509
+ if (error.code === "ENOENT") return null;
510
+ throw error;
511
+ }
512
+ }
513
+ /**
514
+ * Deletes a stored file; succeeds when it isn't there.
515
+ *
516
+ * @param storageId - The file's Storage ID.
517
+ */
518
+ async delete(storageId) {
519
+ await fs__default.default.rm(this.resolve(storageId), { force: true });
520
+ }
521
+ /**
522
+ * The file's URL under `urlPrefix`. Nothing serves it until your app does.
523
+ *
524
+ * @param storageId - The file's Storage ID.
525
+ * @returns The URL.
526
+ */
527
+ getUrl(storageId) {
528
+ return `${this.urlPrefix}/${storageId}`;
529
+ }
530
+ /** Maps a storage ID to a path under the root, refusing IDs that would escape it. */
531
+ resolve(storageId) {
532
+ const filePath = path__default.default.resolve(this.root, storageId);
533
+ if (!filePath.startsWith(this.root + path__default.default.sep)) {
534
+ throw new StorageError("STORAGE_INVALID_ID", {
535
+ message: `Invalid storage ID: ${storageId}`,
536
+ meta: { provider: this.provider }
537
+ });
538
+ }
539
+ return filePath;
540
+ }
541
+ };
542
+
543
+ exports.LocalFileStorage = LocalFileStorage;
544
+ exports.StorageError = StorageError;
545
+ exports.createFileUpload = createFileUpload;