@kumix/storage 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,1042 @@
1
+ import { lookup } from "mime-types";
2
+ //#region src/helpers.ts
3
+ /**
4
+ * Storage helper utilities
5
+ * Provides utility functions for file operations, MIME type detection, and path manipulation
6
+ */
7
+ /**
8
+ * Generate a unique file key with timestamp and random string
9
+ * @param originalName Original file name
10
+ * @param prefix Optional prefix for the key
11
+ * @returns Unique file key
12
+ * @public
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { generateFileKey } from '@kumix/storage/helpers';
17
+ *
18
+ * const key1 = generateFileKey('document.pdf');
19
+ * // Returns: "document-1703123456789-abc123.pdf"
20
+ *
21
+ * const key2 = generateFileKey('photo.jpg', 'uploads/images');
22
+ * // Returns: "uploads/images/photo-1703123456789-def456.jpg"
23
+ * ```
24
+ */
25
+ function generateFileKey(originalName, prefix) {
26
+ const timestamp = Date.now();
27
+ const random = Math.random().toString(36).substring(2, 8);
28
+ const extension = originalName.split(".").pop();
29
+ const fileName = `${originalName.split(".").slice(0, -1).join(".")}-${timestamp}-${random}${extension ? `.${extension}` : ""}`;
30
+ return prefix ? `${prefix}/${fileName}` : fileName;
31
+ }
32
+ /**
33
+ * Get MIME type from file name or extension
34
+ * @param fileName File name or path
35
+ * @returns MIME type string
36
+ * @public
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * import { getMimeType } from '@kumix/storage/helpers';
41
+ *
42
+ * const type1 = getMimeType('document.pdf');
43
+ * // Returns: "application/pdf"
44
+ *
45
+ * const type2 = getMimeType('photo.jpg');
46
+ * // Returns: "image/jpeg"
47
+ *
48
+ * const type3 = getMimeType('unknown.xyz');
49
+ * // Returns: "application/octet-stream"
50
+ * ```
51
+ */
52
+ function getMimeType(fileName) {
53
+ return lookup(fileName) || "application/octet-stream";
54
+ }
55
+ /**
56
+ * Validate file size against maximum allowed size
57
+ * @param fileSize File size in bytes
58
+ * @param maxSize Maximum allowed size in bytes
59
+ * @returns Validation result with error message if invalid
60
+ * @public
61
+ *
62
+ * @example
63
+ * ```typescript
64
+ * import { validateFileSize } from '@kumix/storage/helpers';
65
+ *
66
+ * const result1 = validateFileSize(1024 * 1024, 5 * 1024 * 1024); // 1MB vs 5MB limit
67
+ * // Returns: { valid: true }
68
+ *
69
+ * const result2 = validateFileSize(10 * 1024 * 1024, 5 * 1024 * 1024); // 10MB vs 5MB limit
70
+ * // Returns: { valid: false, error: "File size 10 MB exceeds maximum allowed size 5 MB" }
71
+ * ```
72
+ */
73
+ function validateFileSize(fileSize, maxSize) {
74
+ if (fileSize > maxSize) return {
75
+ valid: false,
76
+ error: `File size ${formatFileSize(fileSize)} exceeds maximum allowed size ${formatFileSize(maxSize)}`
77
+ };
78
+ return { valid: true };
79
+ }
80
+ /**
81
+ * Validate file type against allowed types (MIME types or extensions)
82
+ * @param fileName File name or path
83
+ * @param allowedTypes Array of allowed MIME types or file extensions
84
+ * @returns Validation result with error message if invalid
85
+ * @public
86
+ *
87
+ * @example
88
+ * ```typescript
89
+ * import { validateFileType } from '@kumix/storage/helpers';
90
+ *
91
+ * // Using MIME types
92
+ * const result1 = validateFileType('photo.jpg', ['image/*', 'application/pdf']);
93
+ * // Returns: { valid: true }
94
+ *
95
+ * // Using file extensions
96
+ * const result2 = validateFileType('document.txt', ['.pdf', '.doc', '.docx']);
97
+ * // Returns: { valid: false, error: "File type not allowed. Allowed types: .pdf, .doc, .docx" }
98
+ *
99
+ * // Mixed MIME types and extensions
100
+ * const result3 = validateFileType('video.mp4', ['image/*', '.pdf', 'video/mp4']);
101
+ * // Returns: { valid: true }
102
+ * ```
103
+ */
104
+ function validateFileType(fileName, allowedTypes) {
105
+ const mimeType = getMimeType(fileName);
106
+ const extension = fileName.split(".").pop()?.toLowerCase();
107
+ const isValidMime = allowedTypes.some((type) => {
108
+ if (type.includes("*")) {
109
+ const baseType = type.split("/")[0];
110
+ return mimeType.startsWith(baseType);
111
+ }
112
+ return mimeType === type;
113
+ });
114
+ const isValidExtension = extension && allowedTypes.includes(`.${extension}`);
115
+ if (!isValidMime && !isValidExtension) return {
116
+ valid: false,
117
+ error: `File type not allowed. Allowed types: ${allowedTypes.join(", ")}`
118
+ };
119
+ return { valid: true };
120
+ }
121
+ /**
122
+ * Format file size in human readable format
123
+ * @param bytes File size in bytes
124
+ * @returns Formatted file size string
125
+ * @public
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * import { formatFileSize } from '@kumix/storage/helpers';
130
+ *
131
+ * const size1 = formatFileSize(1024);
132
+ * // Returns: "1 KB"
133
+ *
134
+ * const size2 = formatFileSize(1048576);
135
+ * // Returns: "1 MB"
136
+ *
137
+ * const size3 = formatFileSize(1073741824);
138
+ * // Returns: "1 GB"
139
+ * ```
140
+ */
141
+ function formatFileSize(bytes) {
142
+ if (bytes === 0) return "0 Bytes";
143
+ const k = 1024;
144
+ const sizes = [
145
+ "Bytes",
146
+ "KB",
147
+ "MB",
148
+ "GB",
149
+ "TB"
150
+ ];
151
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
152
+ return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
153
+ }
154
+ /**
155
+ * Sanitize file name for S3 key by removing special characters
156
+ * @param fileName Original file name
157
+ * @returns Sanitized file name safe for S3 keys
158
+ * @public
159
+ *
160
+ * @example
161
+ * ```typescript
162
+ * import { sanitizeFileName } from '@kumix/storage/helpers';
163
+ *
164
+ * const safe1 = sanitizeFileName('My Document (2023).pdf');
165
+ * // Returns: "my_document_2023.pdf"
166
+ *
167
+ * const safe2 = sanitizeFileName('file@#$%name.txt');
168
+ * // Returns: "file_name.txt"
169
+ * ```
170
+ */
171
+ function sanitizeFileName(fileName) {
172
+ return fileName.replace(/[^a-zA-Z0-9.-]/g, "_").replace(/_{2,}/g, "_").replace(/^_|_$/g, "").toLowerCase();
173
+ }
174
+ /**
175
+ * Extract file extension from file name
176
+ * @param fileName File name or path
177
+ * @returns File extension with dot (e.g., '.pdf')
178
+ * @public
179
+ *
180
+ * @example
181
+ * ```typescript
182
+ * import { getFileExtension } from '@kumix/storage/helpers';
183
+ *
184
+ * const ext1 = getFileExtension('document.pdf');
185
+ * // Returns: ".pdf"
186
+ *
187
+ * const ext2 = getFileExtension('photo.JPEG');
188
+ * // Returns: ".jpeg"
189
+ *
190
+ * const ext3 = getFileExtension('noextension');
191
+ * // Returns: ""
192
+ * ```
193
+ */
194
+ function getFileExtension(fileName) {
195
+ const extension = fileName.split(".").pop();
196
+ return extension ? `.${extension.toLowerCase()}` : "";
197
+ }
198
+ /**
199
+ * Generate cache control header for HTTP responses
200
+ * @param maxAge Cache max age in seconds (default: 1 year)
201
+ * @param isPublic Whether cache should be public or private
202
+ * @returns Cache control header string
203
+ * @public
204
+ *
205
+ * @example
206
+ * ```typescript
207
+ * import { generateCacheControl } from '@kumix/storage/helpers';
208
+ *
209
+ * const cache1 = generateCacheControl();
210
+ * // Returns: "public, max-age=31536000"
211
+ *
212
+ * const cache2 = generateCacheControl(3600, false);
213
+ * // Returns: "private, max-age=3600"
214
+ *
215
+ * const cache3 = generateCacheControl(86400); // 1 day
216
+ * // Returns: "public, max-age=86400"
217
+ * ```
218
+ */
219
+ function generateCacheControl(maxAge = 31536e3, isPublic = true) {
220
+ return `${isPublic ? "public" : "private"}, max-age=${maxAge}`;
221
+ }
222
+ /**
223
+ * Parse S3 URL to extract bucket and key
224
+ * @param url S3 URL to parse
225
+ * @returns Object containing bucket and key
226
+ * @public
227
+ *
228
+ * @example
229
+ * ```typescript
230
+ * import { parseS3Url } from '@kumix/storage/helpers';
231
+ *
232
+ * // Virtual hosted-style URL
233
+ * const result1 = parseS3Url('https://my-bucket.s3.us-east-1.amazonaws.com/folder/file.pdf');
234
+ * // Returns: { bucket: "my-bucket", key: "folder/file.pdf" }
235
+ *
236
+ * // Path-style URL
237
+ * const result2 = parseS3Url('https://s3.us-east-1.amazonaws.com/my-bucket/folder/file.pdf');
238
+ * // Returns: { bucket: "my-bucket", key: "folder/file.pdf" }
239
+ *
240
+ * // Custom endpoint (R2, MinIO)
241
+ * const result3 = parseS3Url('https://my-endpoint.com/my-bucket/folder/file.pdf');
242
+ * // Returns: { bucket: "my-bucket", key: "folder/file.pdf" }
243
+ * ```
244
+ */
245
+ function parseS3Url(url) {
246
+ try {
247
+ const urlObj = new URL(url);
248
+ if (urlObj.hostname.includes("amazonaws.com")) if (urlObj.hostname.startsWith("s3")) {
249
+ const pathParts = urlObj.pathname.split("/").filter(Boolean);
250
+ return {
251
+ bucket: pathParts[0],
252
+ key: pathParts.slice(1).join("/")
253
+ };
254
+ } else return {
255
+ bucket: urlObj.hostname.split(".")[0],
256
+ key: urlObj.pathname.substring(1)
257
+ };
258
+ const pathParts = urlObj.pathname.split("/").filter(Boolean);
259
+ return {
260
+ bucket: pathParts[0],
261
+ key: pathParts.slice(1).join("/")
262
+ };
263
+ } catch {
264
+ return {};
265
+ }
266
+ }
267
+ /**
268
+ * Build public URL for a file
269
+ * @param baseUrl Base URL of the storage service
270
+ * @param bucket Bucket name
271
+ * @param key File key/path
272
+ * @returns Complete public URL
273
+ * @public
274
+ *
275
+ * @example
276
+ * ```typescript
277
+ * import { buildPublicUrl } from '@kumix/storage/helpers';
278
+ *
279
+ * const url1 = buildPublicUrl('https://cdn.example.com', 'my-bucket', 'folder/file.pdf');
280
+ * // Returns: "https://cdn.example.com/my-bucket/folder/file.pdf"
281
+ *
282
+ * const url2 = buildPublicUrl('https://storage.example.com/', 'assets', 'images/logo.png');
283
+ * // Returns: "https://storage.example.com/assets/images/logo.png"
284
+ * ```
285
+ */
286
+ function buildPublicUrl(baseUrl, bucket, key, supabase) {
287
+ let cleanBaseUrl;
288
+ cleanBaseUrl = baseUrl.replace(/\/$/, "");
289
+ const cleanKey = key.replace(/^\//, "");
290
+ if (supabase) {
291
+ cleanBaseUrl = cleanBaseUrl.replace(/\.storage/, "").replace(/\/storage\/v1\/s3$/, "");
292
+ cleanBaseUrl = `${cleanBaseUrl}/storage/v1/object/public`;
293
+ }
294
+ return `${cleanBaseUrl}/${bucket === "" ? "" : `${bucket}/`}${cleanKey}`;
295
+ }
296
+ /**
297
+ * Validate S3 key format according to AWS S3 naming rules
298
+ * @param key S3 object key to validate
299
+ * @returns Validation result with error message if invalid
300
+ * @public
301
+ *
302
+ * @example
303
+ * ```typescript
304
+ * import { validateS3Key } from '@kumix/storage/helpers';
305
+ *
306
+ * const result1 = validateS3Key('documents/report.pdf');
307
+ * // Returns: { valid: true }
308
+ *
309
+ * const result2 = validateS3Key('/invalid/key/');
310
+ * // Returns: { valid: false, error: "Key cannot start or end with forward slash" }
311
+ * ```
312
+ */
313
+ function validateS3Key(key) {
314
+ if (!key || key.length === 0) return {
315
+ valid: false,
316
+ error: "Key cannot be empty"
317
+ };
318
+ if (key.length > 1024) return {
319
+ valid: false,
320
+ error: "Key cannot exceed 1024 characters"
321
+ };
322
+ if (key.startsWith("/") || key.endsWith("/")) return {
323
+ valid: false,
324
+ error: "Key cannot start or end with forward slash"
325
+ };
326
+ if (/[^\w\-_./]/.test(key)) return {
327
+ valid: false,
328
+ error: "Key contains invalid characters"
329
+ };
330
+ return { valid: true };
331
+ }
332
+ /**
333
+ * Convert File object to Buffer for upload
334
+ * @param file File object from browser input
335
+ * @returns Promise that resolves to Buffer
336
+ * @public
337
+ *
338
+ * @example
339
+ * ```typescript
340
+ * import { fileToBuffer } from '@kumix/storage/helpers';
341
+ *
342
+ * // In browser with file input
343
+ * const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
344
+ * const file = fileInput.files?.[0];
345
+ *
346
+ * if (file) {
347
+ * const buffer = await fileToBuffer(file);
348
+ * // Use buffer for upload
349
+ * await storage.uploadFile('uploads/file.pdf', buffer);
350
+ * }
351
+ * ```
352
+ */
353
+ async function fileToBuffer(file) {
354
+ const arrayBuffer = await file.arrayBuffer();
355
+ return new Uint8Array(arrayBuffer);
356
+ }
357
+ /**
358
+ * Get file information from File object
359
+ * @param file File object from browser input
360
+ * @returns Object containing file metadata
361
+ * @public
362
+ *
363
+ * @example
364
+ * ```typescript
365
+ * import { getFileInfo } from '@kumix/storage/helpers';
366
+ *
367
+ * // In browser with file input
368
+ * const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
369
+ * const file = fileInput.files?.[0];
370
+ *
371
+ * if (file) {
372
+ * const info = getFileInfo(file);
373
+ * console.log(info);
374
+ * // Returns: {
375
+ * // name: "document.pdf",
376
+ * // size: 1048576,
377
+ * // type: "application/pdf",
378
+ * // lastModified: Date
379
+ * // }
380
+ * }
381
+ * ```
382
+ */
383
+ function getFileInfo(file) {
384
+ return {
385
+ name: file.name,
386
+ size: file.size,
387
+ type: file.type || getMimeType(file.name),
388
+ lastModified: new Date(file.lastModified)
389
+ };
390
+ }
391
+ /**
392
+ * Generate unique key with prefix and timestamp
393
+ * @param fileName Original file name
394
+ * @param prefix Optional prefix for the key
395
+ * @param includeTimestamp Whether to include timestamp for uniqueness
396
+ * @returns Unique file key
397
+ * @public
398
+ *
399
+ * @example
400
+ * ```typescript
401
+ * import { generateUniqueKey } from '@kumix/storage/helpers';
402
+ *
403
+ * const key1 = generateUniqueKey('report.pdf', 'documents');
404
+ * // Returns: "documents/report-1703123456789-abc123.pdf"
405
+ *
406
+ * const key2 = generateUniqueKey('photo.jpg', 'images', false);
407
+ * // Returns: "images/photo.jpg"
408
+ * ```
409
+ */
410
+ function generateUniqueKey(fileName, prefix, includeTimestamp = true) {
411
+ const sanitized = sanitizeFileName(fileName);
412
+ const extension = getFileExtension(sanitized);
413
+ const baseName = sanitized.replace(extension, "");
414
+ let uniquePart = "";
415
+ if (includeTimestamp) uniquePart = `-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
416
+ const finalName = `${baseName}${uniquePart}${extension}`;
417
+ return prefix ? `${prefix}/${finalName}` : finalName;
418
+ }
419
+ /**
420
+ * Extract bucket, key, and region from S3 URL
421
+ * @param url S3 URL to parse
422
+ * @returns Object containing bucket, key, and region information
423
+ * @public
424
+ *
425
+ * @example
426
+ * ```typescript
427
+ * import { extractS3Info } from '@kumix/storage/helpers';
428
+ *
429
+ * // AWS S3 virtual hosted-style
430
+ * const result1 = extractS3Info('https://my-bucket.s3.us-west-2.amazonaws.com/folder/file.pdf');
431
+ * // Returns: { bucket: "my-bucket", key: "folder/file.pdf", region: "us-west-2" }
432
+ *
433
+ * // AWS S3 path-style
434
+ * const result2 = extractS3Info('https://s3.eu-central-1.amazonaws.com/my-bucket/file.pdf');
435
+ * // Returns: { bucket: "my-bucket", key: "file.pdf", region: "eu-central-1" }
436
+ *
437
+ * // Custom endpoint
438
+ * const result3 = extractS3Info('https://minio.example.com/my-bucket/folder/file.pdf');
439
+ * // Returns: { bucket: "my-bucket", key: "folder/file.pdf" }
440
+ * ```
441
+ */
442
+ function extractS3Info(url) {
443
+ try {
444
+ const urlObj = new URL(url);
445
+ if (urlObj.hostname.includes("amazonaws.com")) if (urlObj.hostname.startsWith("s3")) {
446
+ const pathParts = urlObj.pathname.split("/").filter(Boolean);
447
+ const region = urlObj.hostname.split(".")[1];
448
+ return {
449
+ bucket: pathParts[0],
450
+ key: pathParts.slice(1).join("/"),
451
+ region
452
+ };
453
+ } else {
454
+ const hostParts = urlObj.hostname.split(".");
455
+ const bucket = hostParts[0];
456
+ const region = hostParts[2];
457
+ return {
458
+ bucket,
459
+ key: urlObj.pathname.substring(1),
460
+ region
461
+ };
462
+ }
463
+ const pathParts = urlObj.pathname.split("/").filter(Boolean);
464
+ return {
465
+ bucket: pathParts[0],
466
+ key: pathParts.slice(1).join("/")
467
+ };
468
+ } catch {
469
+ return {};
470
+ }
471
+ }
472
+ /**
473
+ * Validate S3 configuration object
474
+ * @param config S3 configuration object to validate
475
+ * @returns Validation result with list of errors
476
+ * @public
477
+ *
478
+ * @example
479
+ * ```typescript
480
+ * import { validateS3Config } from '@kumix/storage/helpers';
481
+ *
482
+ * const config = {
483
+ * provider: 'aws',
484
+ * region: 'us-east-1',
485
+ * bucket: 'my-bucket',
486
+ * accessKeyId: 'AKIA...',
487
+ * secretAccessKey: 'secret...'
488
+ * };
489
+ *
490
+ * const result = validateS3Config(config);
491
+ * // Returns: { valid: true, errors: [] }
492
+ *
493
+ * const invalidConfig = { provider: 'aws' }; // Missing required fields
494
+ * const result2 = validateS3Config(invalidConfig);
495
+ * // Returns: { valid: false, errors: ["Region is required", "Bucket is required", ...] }
496
+ * ```
497
+ */
498
+ function validateS3Config(config) {
499
+ const errors = [];
500
+ if (!config.provider) errors.push("Provider is required");
501
+ if (!config.region) errors.push("Region is required");
502
+ if (!config.bucket) errors.push("Bucket is required");
503
+ if (!config.accessKeyId) errors.push("Access Key ID is required");
504
+ if (!config.secretAccessKey) errors.push("Secret Access Key is required");
505
+ if (config.bucket && typeof config.bucket === "string") {
506
+ const bucketValidation = validateBucketName(config.bucket);
507
+ if (!bucketValidation.valid) errors.push(`Invalid bucket name: ${bucketValidation.error}`);
508
+ }
509
+ return {
510
+ valid: errors.length === 0,
511
+ errors
512
+ };
513
+ }
514
+ /**
515
+ * Validate S3 bucket name according to AWS naming rules
516
+ * @param bucketName Bucket name to validate
517
+ * @returns Validation result with error message if invalid
518
+ * @public
519
+ *
520
+ * @example
521
+ * ```typescript
522
+ * import { validateBucketName } from '@kumix/storage/helpers';
523
+ *
524
+ * const result1 = validateBucketName('my-valid-bucket');
525
+ * // Returns: { valid: true }
526
+ *
527
+ * const result2 = validateBucketName('My-Invalid-Bucket');
528
+ * // Returns: { valid: false, error: "Bucket name can only contain lowercase letters, numbers, dots, and hyphens" }
529
+ *
530
+ * const result3 = validateBucketName('ab');
531
+ * // Returns: { valid: false, error: "Bucket name must be between 3 and 63 characters" }
532
+ * ```
533
+ */
534
+ function validateBucketName(bucketName) {
535
+ if (!bucketName || bucketName.length === 0) return {
536
+ valid: false,
537
+ error: "Bucket name cannot be empty"
538
+ };
539
+ if (bucketName.length < 3 || bucketName.length > 63) return {
540
+ valid: false,
541
+ error: "Bucket name must be between 3 and 63 characters"
542
+ };
543
+ if (!/^[a-z0-9.-]+$/.test(bucketName)) return {
544
+ valid: false,
545
+ error: "Bucket name can only contain lowercase letters, numbers, dots, and hyphens"
546
+ };
547
+ if (bucketName.startsWith(".") || bucketName.endsWith(".")) return {
548
+ valid: false,
549
+ error: "Bucket name cannot start or end with a dot"
550
+ };
551
+ if (bucketName.startsWith("-") || bucketName.endsWith("-")) return {
552
+ valid: false,
553
+ error: "Bucket name cannot start or end with a hyphen"
554
+ };
555
+ if (bucketName.includes("..")) return {
556
+ valid: false,
557
+ error: "Bucket name cannot contain consecutive dots"
558
+ };
559
+ return { valid: true };
560
+ }
561
+ /**
562
+ * Generate content disposition header for file downloads
563
+ * @param fileName File name for the download
564
+ * @param disposition Whether to display inline or as attachment
565
+ * @returns Content disposition header string
566
+ * @public
567
+ *
568
+ * @example
569
+ * ```typescript
570
+ * import { getContentDisposition } from '@kumix/storage/helpers';
571
+ *
572
+ * const header1 = getContentDisposition('document.pdf');
573
+ * // Returns: 'attachment; filename="document.pdf"'
574
+ *
575
+ * const header2 = getContentDisposition('image.jpg', 'inline');
576
+ * // Returns: 'inline; filename="image.jpg"'
577
+ *
578
+ * const header3 = getContentDisposition('file@#$name.txt');
579
+ * // Returns: 'attachment; filename="filename.txt"'
580
+ * ```
581
+ */
582
+ function getContentDisposition(fileName, disposition = "attachment") {
583
+ return `${disposition}; filename="${fileName.replace(/[^\w\s.-]/gi, "")}"`;
584
+ }
585
+ /**
586
+ * Check if file is an image based on MIME type
587
+ * @param fileName File name or path
588
+ * @returns True if file is an image
589
+ * @public
590
+ *
591
+ * @example
592
+ * ```typescript
593
+ * import { isImageFile } from '@kumix/storage/helpers';
594
+ *
595
+ * const result1 = isImageFile('photo.jpg');
596
+ * // Returns: true
597
+ *
598
+ * const result2 = isImageFile('document.pdf');
599
+ * // Returns: false
600
+ * ```
601
+ */
602
+ function isImageFile(fileName) {
603
+ return getMimeType(fileName).startsWith("image/");
604
+ }
605
+ /**
606
+ * Check if file is a video based on MIME type
607
+ * @param fileName File name or path
608
+ * @returns True if file is a video
609
+ * @public
610
+ *
611
+ * @example
612
+ * ```typescript
613
+ * import { isVideoFile } from '@kumix/storage/helpers';
614
+ *
615
+ * const result1 = isVideoFile('movie.mp4');
616
+ * // Returns: true
617
+ *
618
+ * const result2 = isVideoFile('document.pdf');
619
+ * // Returns: false
620
+ * ```
621
+ */
622
+ function isVideoFile(fileName) {
623
+ return getMimeType(fileName).startsWith("video/");
624
+ }
625
+ /**
626
+ * Check if file is an audio file based on MIME type
627
+ * @param fileName File name or path
628
+ * @returns True if file is an audio file
629
+ * @public
630
+ *
631
+ * @example
632
+ * ```typescript
633
+ * import { isAudioFile } from '@kumix/storage/helpers';
634
+ *
635
+ * const result1 = isAudioFile('song.mp3');
636
+ * // Returns: true
637
+ *
638
+ * const result2 = isAudioFile('document.pdf');
639
+ * // Returns: false
640
+ * ```
641
+ */
642
+ function isAudioFile(fileName) {
643
+ return getMimeType(fileName).startsWith("audio/");
644
+ }
645
+ /**
646
+ * Check if file is a document based on MIME type
647
+ * @param fileName File name or path
648
+ * @returns True if file is a document
649
+ * @public
650
+ *
651
+ * @example
652
+ * ```typescript
653
+ * import { isDocumentFile } from '@kumix/storage/helpers';
654
+ *
655
+ * const result1 = isDocumentFile('report.pdf');
656
+ * // Returns: true
657
+ *
658
+ * const result2 = isDocumentFile('spreadsheet.xlsx');
659
+ * // Returns: true
660
+ *
661
+ * const result3 = isDocumentFile('photo.jpg');
662
+ * // Returns: false
663
+ * ```
664
+ */
665
+ function isDocumentFile(fileName) {
666
+ const mimeType = getMimeType(fileName);
667
+ return [
668
+ "application/pdf",
669
+ "application/msword",
670
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
671
+ "application/vnd.ms-excel",
672
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
673
+ "application/vnd.ms-powerpoint",
674
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
675
+ "text/plain",
676
+ "text/csv"
677
+ ].includes(mimeType);
678
+ }
679
+ /**
680
+ * Normalize path separators and remove redundant slashes
681
+ * @param path Path to normalize
682
+ * @returns Normalized path
683
+ * @public
684
+ *
685
+ * @example
686
+ * ```typescript
687
+ * import { normalizePath } from '@kumix/storage/helpers';
688
+ *
689
+ * const path1 = normalizePath('folder//subfolder///file.txt');
690
+ * // Returns: "folder/subfolder/file.txt"
691
+ *
692
+ * const path2 = normalizePath('\\folder\\file.txt');
693
+ * // Returns: "folder/file.txt"
694
+ *
695
+ * const path3 = normalizePath('/folder/file.txt/');
696
+ * // Returns: "folder/file.txt"
697
+ * ```
698
+ */
699
+ function normalizePath(path) {
700
+ return path.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
701
+ }
702
+ /**
703
+ * Join path segments safely with proper separators
704
+ * @param segments Path segments to join
705
+ * @returns Joined path
706
+ * @public
707
+ *
708
+ * @example
709
+ * ```typescript
710
+ * import { joinPath } from '@kumix/storage/helpers';
711
+ *
712
+ * const path1 = joinPath('folder', 'subfolder', 'file.txt');
713
+ * // Returns: "folder/subfolder/file.txt"
714
+ *
715
+ * const path2 = joinPath('/folder/', '/subfolder/', '/file.txt/');
716
+ * // Returns: "folder/subfolder/file.txt"
717
+ *
718
+ * const path3 = joinPath('', 'folder', '', 'file.txt');
719
+ * // Returns: "folder/file.txt"
720
+ * ```
721
+ */
722
+ function joinPath(...segments) {
723
+ return segments.filter((segment) => segment && segment.trim() !== "").map((segment) => segment.replace(/^\/+|\/+$/g, "")).filter((segment) => segment !== "").join("/");
724
+ }
725
+ /**
726
+ * Get parent directory path from a file key
727
+ * @param key File key or path
728
+ * @returns Parent directory path
729
+ * @public
730
+ *
731
+ * @example
732
+ * ```typescript
733
+ * import { getParentPath } from '@kumix/storage/helpers';
734
+ *
735
+ * const parent1 = getParentPath('folder/subfolder/file.txt');
736
+ * // Returns: "folder/subfolder"
737
+ *
738
+ * const parent2 = getParentPath('file.txt');
739
+ * // Returns: ""
740
+ *
741
+ * const parent3 = getParentPath('folder/file.txt');
742
+ * // Returns: "folder"
743
+ * ```
744
+ */
745
+ function getParentPath(key) {
746
+ const normalizedKey = normalizePath(key);
747
+ const lastSlashIndex = normalizedKey.lastIndexOf("/");
748
+ return lastSlashIndex === -1 ? "" : normalizedKey.substring(0, lastSlashIndex);
749
+ }
750
+ /**
751
+ * Get filename from a file key or path
752
+ * @param key File key or path
753
+ * @returns Filename without path
754
+ * @public
755
+ *
756
+ * @example
757
+ * ```typescript
758
+ * import { getFileName } from '@kumix/storage/helpers';
759
+ *
760
+ * const name1 = getFileName('folder/subfolder/document.pdf');
761
+ * // Returns: "document.pdf"
762
+ *
763
+ * const name2 = getFileName('file.txt');
764
+ * // Returns: "file.txt"
765
+ *
766
+ * const name3 = getFileName('folder/subfolder/');
767
+ * // Returns: ""
768
+ * ```
769
+ */
770
+ function getFileName(key) {
771
+ const normalizedKey = normalizePath(key);
772
+ const lastSlashIndex = normalizedKey.lastIndexOf("/");
773
+ return lastSlashIndex === -1 ? normalizedKey : normalizedKey.substring(lastSlashIndex + 1);
774
+ }
775
+ /**
776
+ * Convert base64 string to Buffer
777
+ * @param base64 Base64 encoded string
778
+ * @returns Buffer containing the decoded data
779
+ * @public
780
+ *
781
+ * @example
782
+ * ```typescript
783
+ * import { base64ToBuffer } from '@kumix/storage/helpers';
784
+ *
785
+ * const base64 = 'SGVsbG8gV29ybGQ='; // "Hello World" in base64
786
+ * const buffer = base64ToBuffer(base64);
787
+ * console.log(buffer.toString()); // "Hello World"
788
+ *
789
+ * // For file uploads from base64 data
790
+ * const imageBase64 = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...';
791
+ * const cleanBase64 = imageBase64.split(',')[1]; // Remove data URL prefix
792
+ * const imageBuffer = base64ToBuffer(cleanBase64);
793
+ * await storage.uploadFile('images/photo.jpg', imageBuffer);
794
+ * ```
795
+ */
796
+ function base64ToBuffer(base64) {
797
+ const cleanBase64 = base64.includes(",") ? base64.split(",")[1] : base64;
798
+ if (typeof Buffer !== "undefined") return Buffer.from(cleanBase64, "base64");
799
+ const binary = atob(cleanBase64);
800
+ const bytes = new Uint8Array(binary.length);
801
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
802
+ return bytes;
803
+ }
804
+ /**
805
+ * Convert Buffer to base64 string
806
+ * @param buffer Buffer to encode
807
+ * @param includeDataUrl Whether to include data URL prefix
808
+ * @param mimeType MIME type for data URL (required if includeDataUrl is true)
809
+ * @returns Base64 encoded string
810
+ * @public
811
+ *
812
+ * @example
813
+ * ```typescript
814
+ * import { bufferToBase64 } from '@kumix/storage/helpers';
815
+ *
816
+ * const buffer = Buffer.from('Hello World');
817
+ * const base64 = bufferToBase64(buffer);
818
+ * // Returns: "SGVsbG8gV29ybGQ="
819
+ *
820
+ * // With data URL for images
821
+ * const imageBuffer = await storage.downloadFile('images/photo.jpg');
822
+ * const dataUrl = bufferToBase64(imageBuffer.content!, true, 'image/jpeg');
823
+ * // Returns: "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD..."
824
+ *
825
+ * // Use in HTML img tag
826
+ * document.querySelector('img').src = dataUrl;
827
+ * ```
828
+ */
829
+ function bufferToBase64(buffer, includeDataUrl = false, mimeType) {
830
+ let base64;
831
+ if (typeof Buffer !== "undefined" && buffer instanceof Buffer) base64 = buffer.toString("base64");
832
+ else {
833
+ let binary = "";
834
+ for (let i = 0; i < buffer.length; i++) binary += String.fromCharCode(buffer[i]);
835
+ base64 = btoa(binary);
836
+ }
837
+ if (includeDataUrl) {
838
+ if (!mimeType) throw new Error("MIME type is required when includeDataUrl is true");
839
+ return `data:${mimeType};base64,${base64}`;
840
+ }
841
+ return base64;
842
+ }
843
+ /**
844
+ * Generate file hash/checksum for integrity verification
845
+ * @param content File content as Buffer or string
846
+ * @param algorithm Hash algorithm to use
847
+ * @returns Hash string
848
+ * @public
849
+ *
850
+ * @example
851
+ * ```typescript
852
+ * import { generateFileHash } from '@kumix/storage/helpers';
853
+ *
854
+ * const content = Buffer.from('Hello World');
855
+ *
856
+ * const md5Hash = generateFileHash(content, 'md5');
857
+ * // Returns: "b10a8db164e0754105b7a99be72e3fe5"
858
+ *
859
+ * const sha256Hash = generateFileHash(content, 'sha256');
860
+ * // Returns: "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e"
861
+ *
862
+ * // For file integrity checking
863
+ * const fileBuffer = await storage.downloadFile('document.pdf');
864
+ * const currentHash = generateFileHash(fileBuffer.content!, 'sha256');
865
+ * const expectedHash = 'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e';
866
+ *
867
+ * if (currentHash === expectedHash) {
868
+ * console.log('File integrity verified');
869
+ * } else {
870
+ * console.log('File may be corrupted');
871
+ * }
872
+ * ```
873
+ */
874
+ async function generateFileHash(content, algorithm = "md5") {
875
+ if (algorithm !== "md5" && typeof crypto !== "undefined" && crypto.subtle) {
876
+ const rawData = typeof content === "string" ? new TextEncoder().encode(content) : content;
877
+ const hashBuffer = await crypto.subtle.digest(algorithm === "sha1" ? "SHA-1" : "SHA-256", rawData);
878
+ return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
879
+ }
880
+ const hash = (await import("node:crypto")).createHash(algorithm);
881
+ hash.update(content);
882
+ return hash.digest("hex");
883
+ }
884
+ /**
885
+ * Generate multiple unique keys at once for batch operations
886
+ * @param fileNames Array of file names
887
+ * @param prefix Optional prefix for all keys
888
+ * @returns Array of unique keys
889
+ * @public
890
+ *
891
+ * @example
892
+ * ```typescript
893
+ * import { generateBatchKeys } from '@kumix/storage/helpers';
894
+ *
895
+ * const fileNames = ['document1.pdf', 'document2.pdf', 'image.jpg'];
896
+ * const keys = generateBatchKeys(fileNames, 'uploads');
897
+ * // Returns: [
898
+ * // "uploads/document1-1703123456789-abc123.pdf",
899
+ * // "uploads/document2-1703123456790-def456.pdf",
900
+ * // "uploads/image-1703123456791-ghi789.jpg"
901
+ * // ]
902
+ *
903
+ * // Use for batch uploads
904
+ * const files = [file1, file2, file3];
905
+ * const keys = generateBatchKeys(files.map(f => f.name), 'batch-upload');
906
+ *
907
+ * for (let i = 0; i < files.length; i++) {
908
+ * await storage.uploadFile(keys[i], files[i]);
909
+ * }
910
+ * ```
911
+ */
912
+ function generateBatchKeys(fileNames, prefix) {
913
+ return fileNames.map((fileName) => generateFileKey(fileName, prefix));
914
+ }
915
+ /**
916
+ * Validate multiple files at once with consistent options
917
+ * @param files Array of File objects to validate
918
+ * @param options Validation options
919
+ * @returns Array of validation results
920
+ * @public
921
+ *
922
+ * @example
923
+ * ```typescript
924
+ * import { validateBatchFiles } from '@kumix/storage/helpers';
925
+ *
926
+ * const files = [file1, file2, file3]; // File objects from input
927
+ * const options = {
928
+ * maxSize: 5 * 1024 * 1024, // 5MB
929
+ * allowedTypes: ['image/*', 'application/pdf'],
930
+ * maxFiles: 10
931
+ * };
932
+ *
933
+ * const results = validateBatchFiles(files, options);
934
+ * // Returns: [
935
+ * // { valid: true, fileName: "image.jpg" },
936
+ * // { valid: false, fileName: "large.pdf", errors: ["File size exceeds limit"] },
937
+ * // { valid: true, fileName: "document.pdf" }
938
+ * // ]
939
+ *
940
+ * const validFiles = files.filter((_, index) => results[index].valid);
941
+ * const invalidFiles = files.filter((_, index) => !results[index].valid);
942
+ * ```
943
+ */
944
+ function validateBatchFiles(files, options) {
945
+ const results = [];
946
+ if (options.maxFiles && files.length > options.maxFiles) return files.map((file) => ({
947
+ valid: false,
948
+ fileName: file.name,
949
+ errors: [`Maximum ${options.maxFiles} files allowed, but ${files.length} files provided`]
950
+ }));
951
+ for (const file of files) {
952
+ const errors = [];
953
+ if (options.maxSize) {
954
+ const sizeResult = validateFileSize(file.size, options.maxSize);
955
+ if (!sizeResult.valid && sizeResult.error) errors.push(sizeResult.error);
956
+ }
957
+ if (options.allowedTypes) {
958
+ const typeResult = validateFileType(file.name, options.allowedTypes);
959
+ if (!typeResult.valid && typeResult.error) errors.push(typeResult.error);
960
+ }
961
+ results.push({
962
+ valid: errors.length === 0,
963
+ fileName: file.name,
964
+ errors: errors.length > 0 ? errors : void 0
965
+ });
966
+ }
967
+ return results;
968
+ }
969
+ /**
970
+ * Detect file type from content using magic numbers/file signatures
971
+ * @param buffer File content buffer
972
+ * @returns Detected MIME type or 'application/octet-stream' if unknown
973
+ * @public
974
+ *
975
+ * @example
976
+ * ```typescript
977
+ * import { detectFileTypeFromContent } from '@kumix/storage/helpers';
978
+ *
979
+ * // Read file content
980
+ * const fileBuffer = await storage.downloadFile('unknown-file');
981
+ * const detectedType = detectFileTypeFromContent(fileBuffer.content!);
982
+ *
983
+ * console.log(detectedType);
984
+ * // Returns: "image/jpeg" for JPEG files
985
+ * // Returns: "application/pdf" for PDF files
986
+ * // Returns: "image/png" for PNG files
987
+ * // Returns: "application/octet-stream" for unknown types
988
+ *
989
+ * // Verify file type matches extension
990
+ * const fileName = 'document.pdf';
991
+ * const expectedType = getMimeType(fileName);
992
+ * const actualType = detectFileTypeFromContent(fileBuffer.content!);
993
+ *
994
+ * if (expectedType !== actualType) {
995
+ * console.warn('File extension does not match content type');
996
+ * }
997
+ * ```
998
+ */
999
+ function detectFileTypeFromContent(buffer) {
1000
+ if (buffer.length === 0) return "application/octet-stream";
1001
+ const header = buffer.subarray(0, 16);
1002
+ if (header.subarray(0, 4).reduce((s, b) => s + String.fromCharCode(b), "") === "%PDF") return "application/pdf";
1003
+ if (header[0] === 255 && header[1] === 216 && header[2] === 255) return "image/jpeg";
1004
+ const pngSig = new Uint8Array([
1005
+ 137,
1006
+ 80,
1007
+ 78,
1008
+ 71,
1009
+ 13,
1010
+ 10,
1011
+ 26,
1012
+ 10
1013
+ ]);
1014
+ if (header.length >= 8 && header.slice(0, 8).every((b, i) => b === pngSig[i])) return "image/png";
1015
+ const gifHead = header.subarray(0, 6).reduce((s, b) => s + String.fromCharCode(b), "");
1016
+ if (gifHead === "GIF87a" || gifHead === "GIF89a") return "image/gif";
1017
+ if (header.subarray(0, 4).reduce((s, b) => s + String.fromCharCode(b), "") === "RIFF" && header.subarray(8, 12).reduce((s, b) => s + String.fromCharCode(b), "") === "WEBP") return "image/webp";
1018
+ if (header[0] === 80 && header[1] === 75 && (header[2] === 3 || header[2] === 5)) {
1019
+ const zipContent = new TextDecoder().decode(buffer.slice(0, Math.min(buffer.length, 1e3)));
1020
+ if (zipContent.includes("word/")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
1021
+ if (zipContent.includes("xl/")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
1022
+ if (zipContent.includes("ppt/")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
1023
+ return "application/zip";
1024
+ }
1025
+ if (new TextDecoder().decode(header.subarray(4, 8)) === "ftyp") return "video/mp4";
1026
+ if (header[0] === 255 && (header[1] & 224) === 224 || new TextDecoder().decode(header.subarray(0, 3)) === "ID3") return "audio/mpeg";
1027
+ let isText = true;
1028
+ const sampleSize = Math.min(buffer.length, 512);
1029
+ for (let i = 0; i < sampleSize; i++) {
1030
+ const byte = buffer[i];
1031
+ if (byte === 0 || byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
1032
+ isText = false;
1033
+ break;
1034
+ }
1035
+ }
1036
+ if (isText) return "text/plain";
1037
+ return "application/octet-stream";
1038
+ }
1039
+ //#endregion
1040
+ export { base64ToBuffer, bufferToBase64, buildPublicUrl, detectFileTypeFromContent, extractS3Info, fileToBuffer, formatFileSize, generateBatchKeys, generateCacheControl, generateFileHash, generateFileKey, generateUniqueKey, getContentDisposition, getFileExtension, getFileInfo, getFileName, getMimeType, getParentPath, isAudioFile, isDocumentFile, isImageFile, isVideoFile, joinPath, normalizePath, parseS3Url, sanitizeFileName, validateBatchFiles, validateBucketName, validateFileSize, validateFileType, validateS3Config, validateS3Key };
1041
+
1042
+ //# sourceMappingURL=helpers.js.map