@muhgholy/next-drive 4.5.0 → 4.7.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/README.md CHANGED
@@ -55,17 +55,6 @@ This package uses [subpath exports](https://nodejs.org/api/packages.html#subpath
55
55
  }
56
56
  ```
57
57
 
58
- **For projects using bundlers (Vite, Webpack, etc.):**
59
-
60
- ```json
61
- {
62
- "compilerOptions": {
63
- "module": "esnext",
64
- "moduleResolution": "bundler"
65
- }
66
- }
67
- ```
68
-
69
58
  > ⚠️ The legacy `"moduleResolution": "node"` is **not supported** and will cause build errors with subpath imports like `@muhgholy/next-drive/server`.
70
59
 
71
60
  **FFmpeg** (for video thumbnails):
@@ -280,18 +269,28 @@ Upload files programmatically from server-side code:
280
269
  ```typescript
281
270
  import { driveUpload } from "@muhgholy/next-drive/server";
282
271
 
283
- // Upload from file path
272
+ // Upload to specific folder by ID
284
273
  const file = await driveUpload(
285
274
  "/tmp/photo.jpg",
286
275
  { userId: "123" },
287
276
  {
288
277
  name: "photo.jpg",
289
- parentId: "folderId", // Optional: folder ID or null for root
278
+ folder: { id: "folderId" }, // Optional: folder ID
290
279
  accountId: "LOCAL", // Optional: storage account ID
291
280
  enforce: false, // Optional: bypass quota check
292
281
  }
293
282
  );
294
283
 
284
+ // Upload to folder by path (creates folders if not exist)
285
+ const file = await driveUpload(
286
+ "/tmp/photo.jpg",
287
+ { userId: "123" },
288
+ {
289
+ name: "photo.jpg",
290
+ folder: { path: "images/2024/january" }, // Creates folders recursively
291
+ }
292
+ );
293
+
295
294
  // Upload from stream
296
295
  import fs from "fs";
297
296
  const stream = fs.createReadStream("/tmp/video.mp4");
@@ -318,13 +317,14 @@ const file = await driveUpload(
318
317
 
319
318
  **Options:**
320
319
 
321
- | Option | Type | Required | Description |
322
- | ----------- | ---------------- | -------- | -------------------------------------------------------- |
323
- | `name` | `string` | Yes | File name with extension |
324
- | `parentId` | `string \| null` | No | Parent folder ID (null or 'root' for root) |
325
- | `accountId` | `string` | No | Storage account ID ('LOCAL' for local storage) |
326
- | `mime` | `string` | No | MIME type (auto-detected from extension if not provided) |
327
- | `enforce` | `boolean` | No | Bypass quota check (default: false) |
320
+ | Option | Type | Required | Description |
321
+ | ------------ | --------------------------------- | -------- | -------------------------------------------------------- |
322
+ | `name` | `string` | Yes | File name with extension |
323
+ | `folder.id` | `string` | No | Parent folder ID |
324
+ | `folder.path`| `string` | No | Folder path (e.g., `images/2024`) - creates if not exist |
325
+ | `accountId` | `string` | No | Storage account ID ('LOCAL' for local storage) |
326
+ | `mime` | `string` | No | MIME type (auto-detected from extension if not provided) |
327
+ | `enforce` | `boolean` | No | Bypass quota check (default: false) |
328
328
 
329
329
  ### Get Signed URL
330
330
 
@@ -583,25 +583,122 @@ GOOGLE_REDIRECT_URI=http://localhost:3000/api/drive?action=callback
583
583
 
584
584
  ---
585
585
 
586
- ## API Endpoints
587
-
588
- All operations use `?action=` query parameter:
589
-
590
- | Action | Method | Description |
591
- | ----------------- | ------ | -------------------- |
592
- | `upload` | POST | Chunked file upload |
593
- | `list` | GET | List folder contents |
594
- | `serve` | GET | Serve file content |
595
- | `thumbnail` | GET | Get file thumbnail |
596
- | `rename` | PATCH | Rename file/folder |
597
- | `trash` | POST | Move to trash |
598
- | `deletePermanent` | DELETE | Delete permanently |
599
- | `restore` | POST | Restore from trash |
600
- | `emptyTrash` | DELETE | Empty all trash |
601
- | `createFolder` | POST | Create folder |
602
- | `move` | POST | Move to new parent |
603
- | `search` | GET | Search by name |
604
- | `quota` | GET | Get storage usage |
586
+ ## Image Optimization
587
+
588
+ Serve optimized images with dynamic compression, resizing, and format conversion using query parameters.
589
+
590
+ ### URL Format
591
+
592
+ ```
593
+ /api/drive?action=serve&id={fileId}&quality={preset}&display={context}&size={preset}&format={format}
594
+ ```
595
+
596
+ ### Parameters
597
+
598
+ | Parameter | Type | Description |
599
+ |-----------|------|-------------|
600
+ | `quality` | `low` / `medium` / `high` / `1-100` | Compression level |
601
+ | `display` | string | Context-based quality adjustment |
602
+ | `size` | string | Predefined dimensions preset |
603
+ | `format` | `jpeg` / `webp` / `avif` / `png` | Output format |
604
+
605
+ ### Quality Presets
606
+
607
+ | Preset | Base Quality | Use Case |
608
+ |--------|--------------|----------|
609
+ | `low` | 30 | Thumbnails, previews |
610
+ | `medium` | 50 | General content |
611
+ | `high` | 75 | High-quality display |
612
+ | `1-100` | Custom | Fine-tuned control |
613
+
614
+ > Quality is dynamically adjusted based on file size. Larger files get more aggressive compression.
615
+
616
+ ### Display Presets (Quality Context)
617
+
618
+ | Display | Quality Factor | Use Case |
619
+ |---------|----------------|----------|
620
+ | `article-header` | 0.9 | Hero/banner images |
621
+ | `article-image` | 0.85 | In-content images |
622
+ | `thumbnail` | 0.7 | Small previews |
623
+ | `avatar` | 0.8 | Profile pictures |
624
+ | `logo` | 0.95 | Branding/logos |
625
+ | `card` | 0.8 | Card components |
626
+ | `gallery` | 0.85 | Gallery/grid |
627
+ | `og` | 0.9 | Open Graph/social |
628
+ | `icon` | 0.75 | Small icons |
629
+ | `cover` | 0.9 | Full-width covers |
630
+ | `story` | 0.85 | Story/vertical |
631
+
632
+ ### Size Presets (Dimensions)
633
+
634
+ **Square Sizes:**
635
+ | Size | Dimensions |
636
+ |------|------------|
637
+ | `xs` | 64×64 |
638
+ | `sm` | 128×128 |
639
+ | `md` | 256×256 |
640
+ | `lg` | 512×512 |
641
+ | `xl` | 1024×1024 |
642
+ | `2xl` | 1600×1600 |
643
+ | `icon` | 48×48 |
644
+ | `thumb` | 150×150 |
645
+ | `square` | 600×600 |
646
+ | `avatar-sm` | 64×64 |
647
+ | `avatar-md` | 128×128 |
648
+ | `avatar-lg` | 256×256 |
649
+
650
+ **Landscape (16:9):**
651
+ | Size | Dimensions |
652
+ |------|------------|
653
+ | `landscape-sm` | 480×270 |
654
+ | `landscape` | 800×450 |
655
+ | `landscape-lg` | 1280×720 |
656
+ | `landscape-xl` | 1920×1080 |
657
+
658
+ **Portrait (9:16):**
659
+ | Size | Dimensions |
660
+ |------|------------|
661
+ | `portrait-sm` | 270×480 |
662
+ | `portrait` | 450×800 |
663
+ | `portrait-lg` | 720×1280 |
664
+
665
+ **Wide/Banner:**
666
+ | Size | Dimensions | Ratio |
667
+ |------|------------|-------|
668
+ | `wide` | 1200×630 | OG standard |
669
+ | `banner` | 1200×400 | 3:1 |
670
+ | `banner-sm` | 800×200 | 4:1 |
671
+
672
+ **Other:**
673
+ | Size | Dimensions | Ratio |
674
+ |------|------------|-------|
675
+ | `photo-4x3` | 800×600 | 4:3 |
676
+ | `photo-3x2` | 900×600 | 3:2 |
677
+ | `story` | 1080×1920 | 9:16 |
678
+ | `video` | 1280×720 | 16:9 |
679
+ | `video-sm` | 640×360 | 16:9 |
680
+ | `card-sm` | 300×200 | 3:2 |
681
+ | `card` | 400×300 | 4:3 |
682
+ | `card-lg` | 600×400 | 3:2 |
683
+
684
+ ### Examples
685
+
686
+ ```html
687
+ <!-- Article header with OG dimensions -->
688
+ <img src="/api/drive?action=serve&id=123&display=article-header&size=wide&format=webp">
689
+
690
+ <!-- Thumbnail with aggressive compression -->
691
+ <img src="/api/drive?action=serve&id=123&display=thumbnail&size=thumb&format=webp">
692
+
693
+ <!-- Avatar -->
694
+ <img src="/api/drive?action=serve&id=123&display=avatar&size=avatar-md&format=webp">
695
+
696
+ <!-- Gallery image -->
697
+ <img src="/api/drive?action=serve&id=123&display=gallery&size=landscape&format=webp">
698
+
699
+ <!-- Just quality, no resize -->
700
+ <img src="/api/drive?action=serve&id=123&quality=medium&format=webp">
701
+ ```
605
702
 
606
703
  ---
607
704
 
@@ -356,13 +356,120 @@ var extractImageMetadata = async (filePath) => {
356
356
  return null;
357
357
  }
358
358
  };
359
- var parseQuality = (q) => {
360
- if (!q) return 80;
361
- if (q === "low") return 40;
362
- if (q === "medium") return 60;
363
- if (q === "high") return 80;
364
- const n = parseInt(q, 10);
365
- return isNaN(n) ? 80 : Math.min(100, Math.max(1, n));
359
+ var DISPLAY_PRESETS = {
360
+ "article-header": 0.9,
361
+ // Hero/banner images - high quality
362
+ "article-image": 0.85,
363
+ // In-content images
364
+ "thumbnail": 0.7,
365
+ // Small previews - lower quality ok
366
+ "avatar": 0.8,
367
+ // Profile pictures
368
+ "logo": 0.95,
369
+ // Branding - needs clarity
370
+ "card": 0.8,
371
+ // Card components
372
+ "gallery": 0.85,
373
+ // Gallery/grid images
374
+ "og": 0.9,
375
+ // Open Graph/social sharing
376
+ "icon": 0.75,
377
+ // Small icons
378
+ "cover": 0.9,
379
+ // Full-width covers
380
+ "story": 0.85
381
+ // Story/vertical format
382
+ };
383
+ var SIZE_PRESETS = {
384
+ // Square sizes
385
+ "xs": { width: 64, height: 64 },
386
+ "sm": { width: 128, height: 128 },
387
+ "md": { width: 256, height: 256 },
388
+ "lg": { width: 512, height: 512 },
389
+ "xl": { width: 1024, height: 1024 },
390
+ "2xl": { width: 1600, height: 1600 },
391
+ // Named squares
392
+ "icon": { width: 48, height: 48 },
393
+ "thumb": { width: 150, height: 150 },
394
+ "square": { width: 600, height: 600 },
395
+ "avatar-sm": { width: 64, height: 64 },
396
+ "avatar-md": { width: 128, height: 128 },
397
+ "avatar-lg": { width: 256, height: 256 },
398
+ // Landscape (16:9)
399
+ "landscape-sm": { width: 480, height: 270 },
400
+ "landscape": { width: 800, height: 450 },
401
+ "landscape-lg": { width: 1280, height: 720 },
402
+ "landscape-xl": { width: 1920, height: 1080 },
403
+ // Portrait (9:16)
404
+ "portrait-sm": { width: 270, height: 480 },
405
+ "portrait": { width: 450, height: 800 },
406
+ "portrait-lg": { width: 720, height: 1280 },
407
+ // Wide/Banner (OG, social)
408
+ "wide": { width: 1200, height: 630 },
409
+ // Open Graph standard
410
+ "banner": { width: 1200, height: 400 },
411
+ // Banner/header
412
+ "banner-sm": { width: 800, height: 200 },
413
+ // Classic photo ratios
414
+ "photo-4x3": { width: 800, height: 600 },
415
+ // 4:3
416
+ "photo-3x2": { width: 900, height: 600 },
417
+ // 3:2
418
+ // Story/vertical (9:16)
419
+ "story": { width: 1080, height: 1920 },
420
+ // Video thumbnails
421
+ "video": { width: 1280, height: 720 },
422
+ "video-sm": { width: 640, height: 360 },
423
+ // Card sizes
424
+ "card-sm": { width: 300, height: 200 },
425
+ "card": { width: 400, height: 300 },
426
+ "card-lg": { width: 600, height: 400 }
427
+ };
428
+ var getImageSettings = (fileSizeInBytes, qualityPreset, display, size) => {
429
+ let baseQuality = 80;
430
+ if (qualityPreset === "low") baseQuality = 30;
431
+ else if (qualityPreset === "medium") baseQuality = 50;
432
+ else if (qualityPreset === "high") baseQuality = 75;
433
+ else if (qualityPreset) {
434
+ const n = parseInt(qualityPreset, 10);
435
+ if (!isNaN(n)) baseQuality = Math.min(100, Math.max(1, n));
436
+ }
437
+ const displayFactor = display && DISPLAY_PRESETS[display] ? DISPLAY_PRESETS[display] : 1;
438
+ baseQuality = Math.round(baseQuality * displayFactor);
439
+ let quality = baseQuality;
440
+ let effort = 4;
441
+ let pngCompression = 6;
442
+ if (fileSizeInBytes) {
443
+ const sizeInKB = fileSizeInBytes / 1024;
444
+ if (sizeInKB > 500) {
445
+ quality = Math.min(baseQuality, 25);
446
+ effort = 9;
447
+ pngCompression = 9;
448
+ } else if (sizeInKB > 300) {
449
+ quality = Math.min(baseQuality, 30);
450
+ effort = 8;
451
+ pngCompression = 9;
452
+ } else if (sizeInKB > 150) {
453
+ quality = Math.min(baseQuality, 35);
454
+ effort = 7;
455
+ pngCompression = 8;
456
+ } else if (sizeInKB > 90) {
457
+ quality = Math.min(baseQuality, 40);
458
+ effort = 6;
459
+ pngCompression = 8;
460
+ } else if (sizeInKB > 50) {
461
+ quality = Math.min(baseQuality, 50);
462
+ effort = 5;
463
+ pngCompression = 7;
464
+ }
465
+ }
466
+ const dimensions = size && SIZE_PRESETS[size] ? SIZE_PRESETS[size] : void 0;
467
+ return {
468
+ quality: Math.max(1, Math.min(100, quality)),
469
+ effort,
470
+ pngCompression,
471
+ ...dimensions && { width: dimensions.width, height: dimensions.height }
472
+ };
366
473
  };
367
474
  var objectIdSchema = z.string().refine((val) => isValidObjectId(val), {
368
475
  message: "Invalid ObjectId format"
@@ -1310,6 +1417,46 @@ var driveDelete = async (source, options) => {
1310
1417
  const owner = drive.owner;
1311
1418
  await provider.delete([driveId], owner, accountId);
1312
1419
  };
1420
+ var resolveFolderByPath = async (folderPath, owner, accountId) => {
1421
+ const normalizedPath = folderPath.replace(/^\/+|\/+$/g, "");
1422
+ if (!normalizedPath) {
1423
+ throw new Error("Folder path cannot be empty");
1424
+ }
1425
+ const segments = normalizedPath.split("/").filter((s) => s.length > 0);
1426
+ if (segments.length === 0) {
1427
+ throw new Error("Invalid folder path");
1428
+ }
1429
+ let providerName = "LOCAL";
1430
+ if (accountId && accountId !== "LOCAL") {
1431
+ const account = await drive_default.db.model("StorageAccount").findOne({ _id: accountId, owner });
1432
+ if (!account) {
1433
+ throw new Error("Invalid Storage Account");
1434
+ }
1435
+ if (account.metadata.provider === "GOOGLE") {
1436
+ providerName = "GOOGLE";
1437
+ }
1438
+ }
1439
+ let currentParentId = null;
1440
+ for (const segmentName of segments) {
1441
+ const existingFolder = await drive_default.findOne({
1442
+ owner,
1443
+ "provider.type": providerName,
1444
+ storageAccountId: accountId || null,
1445
+ parentId: currentParentId,
1446
+ name: segmentName,
1447
+ "information.type": "FOLDER",
1448
+ trashedAt: null
1449
+ });
1450
+ if (existingFolder) {
1451
+ currentParentId = String(existingFolder._id);
1452
+ } else {
1453
+ const provider = providerName === "GOOGLE" ? GoogleDriveProvider : LocalStorageProvider;
1454
+ const newFolder = await provider.createFolder(segmentName, currentParentId, owner, accountId);
1455
+ currentParentId = newFolder.id;
1456
+ }
1457
+ }
1458
+ return currentParentId;
1459
+ };
1313
1460
  var driveUpload = async (source, key, options) => {
1314
1461
  const config = getDriveConfig();
1315
1462
  let provider = LocalStorageProvider;
@@ -1399,12 +1546,20 @@ var driveUpload = async (source, key, options) => {
1399
1546
  throw new Error("Storage quota exceeded");
1400
1547
  }
1401
1548
  }
1549
+ let resolvedParentId = null;
1550
+ if (options.folder?.path) {
1551
+ resolvedParentId = await resolveFolderByPath(options.folder.path, key, accountId);
1552
+ } else if (options.folder?.id && options.folder.id !== "root") {
1553
+ resolvedParentId = options.folder.id;
1554
+ } else if (options.parentId && options.parentId !== "root") {
1555
+ resolvedParentId = options.parentId;
1556
+ }
1402
1557
  const drive = new drive_default({
1403
1558
  owner: key,
1404
1559
  storageAccountId: accountId || null,
1405
1560
  provider: { type: provider.name },
1406
1561
  name: options.name,
1407
- parentId: options.parentId === "root" || !options.parentId ? null : options.parentId,
1562
+ parentId: resolvedParentId,
1408
1563
  order: await getNextOrderValue(key),
1409
1564
  information: { type: "FILE", sizeInBytes: fileSize, mime: mimeType, path: "" },
1410
1565
  status: "UPLOADING"
@@ -1548,24 +1703,32 @@ var driveAPIHandler = async (req, res) => {
1548
1703
  return;
1549
1704
  }
1550
1705
  if (action === "serve") {
1551
- const { stream, mime, size } = await itemProvider.openStream(drive, itemAccountId);
1706
+ const { stream, mime, size: fileSize } = await itemProvider.openStream(drive, itemAccountId);
1552
1707
  const safeFilename = sanitizeContentDispositionFilename(drive.name);
1553
1708
  const format = req.query.format;
1554
1709
  const quality = req.query.quality;
1710
+ const display = req.query.display;
1711
+ const sizePreset = req.query.size;
1555
1712
  const isImage = mime.startsWith("image/");
1556
- const shouldTransform = isImage && (format || quality);
1713
+ const shouldTransform = isImage && (format || quality || display || sizePreset);
1557
1714
  res.setHeader("Content-Disposition", `inline; filename="${safeFilename}"`);
1558
1715
  if (config.cors?.enabled) {
1559
1716
  res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
1560
1717
  }
1561
1718
  if (shouldTransform) {
1562
1719
  try {
1563
- const qValue = parseQuality(quality);
1720
+ const settings = getImageSettings(fileSize, quality, display, sizePreset);
1564
1721
  let targetFormat = format || mime.split("/")[1];
1565
1722
  if (targetFormat === "jpg") targetFormat = "jpeg";
1566
1723
  const cacheDir = path.join(config.storage.path, "file", drive._id.toString(), "cache");
1567
- const cacheFilename = `optimized_q${qValue}_${targetFormat}.bin`;
1568
- const cachePath = path.join(cacheDir, cacheFilename);
1724
+ const cacheKey = [
1725
+ "opt",
1726
+ `q${settings.quality}`,
1727
+ `e${settings.effort}`,
1728
+ settings.width ? `${settings.width}x${settings.height}` : "orig",
1729
+ targetFormat
1730
+ ].join("_");
1731
+ const cachePath = path.join(cacheDir, `${cacheKey}.bin`);
1569
1732
  if (fs.existsSync(cachePath)) {
1570
1733
  const cacheStat = fs.statSync(cachePath);
1571
1734
  res.setHeader("Content-Type", `image/${targetFormat}`);
@@ -1579,18 +1742,24 @@ var driveAPIHandler = async (req, res) => {
1579
1742
  return;
1580
1743
  }
1581
1744
  if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
1582
- const pipeline = sharp();
1745
+ let pipeline = sharp();
1746
+ if (settings.width && settings.height) {
1747
+ pipeline = pipeline.resize(settings.width, settings.height, {
1748
+ fit: "inside",
1749
+ withoutEnlargement: true
1750
+ });
1751
+ }
1583
1752
  if (targetFormat === "jpeg") {
1584
- pipeline.jpeg({ quality: qValue, mozjpeg: true });
1753
+ pipeline = pipeline.jpeg({ quality: settings.quality, mozjpeg: true });
1585
1754
  res.setHeader("Content-Type", "image/jpeg");
1586
1755
  } else if (targetFormat === "png") {
1587
- pipeline.png({ quality: qValue });
1756
+ pipeline = pipeline.png({ compressionLevel: settings.pngCompression, adaptiveFiltering: true });
1588
1757
  res.setHeader("Content-Type", "image/png");
1589
1758
  } else if (targetFormat === "webp") {
1590
- pipeline.webp({ quality: qValue });
1759
+ pipeline = pipeline.webp({ quality: settings.quality, effort: settings.effort });
1591
1760
  res.setHeader("Content-Type", "image/webp");
1592
1761
  } else if (targetFormat === "avif") {
1593
- pipeline.avif({ quality: qValue });
1762
+ pipeline = pipeline.avif({ quality: settings.quality, effort: settings.effort });
1594
1763
  res.setHeader("Content-Type", "image/avif");
1595
1764
  }
1596
1765
  res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
@@ -1603,7 +1772,7 @@ var driveAPIHandler = async (req, res) => {
1603
1772
  }
1604
1773
  }
1605
1774
  res.setHeader("Content-Type", mime);
1606
- if (size) res.setHeader("Content-Length", size);
1775
+ if (fileSize) res.setHeader("Content-Length", fileSize);
1607
1776
  stream.pipe(res);
1608
1777
  return;
1609
1778
  }
@@ -2137,5 +2306,5 @@ var driveAPIHandler = async (req, res) => {
2137
2306
  };
2138
2307
 
2139
2308
  export { driveAPIHandler, driveConfiguration, driveDelete, driveFilePath, driveFileSchemaZod, driveGetUrl, driveInfo, driveList, driveReadFile, driveUpload, getDriveConfig, getDriveInformation };
2140
- //# sourceMappingURL=chunk-YUU5BFE7.js.map
2141
- //# sourceMappingURL=chunk-YUU5BFE7.js.map
2309
+ //# sourceMappingURL=chunk-OWKTTRQC.js.map
2310
+ //# sourceMappingURL=chunk-OWKTTRQC.js.map