mbkbucket 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.env.example +3 -2
  2. package/README.md +96 -44
  3. package/index.js +4 -1
  4. package/lib/cli.js +1 -1
  5. package/lib/public/bucketadmin.js +255 -33
  6. package/lib/public/mbkbucket-helper-demo.html +0 -58
  7. package/lib/public/mbkbucket-helper.js +1 -9
  8. package/lib/src/app.js +10 -24
  9. package/lib/src/config/validation.js +6 -1
  10. package/lib/src/controllers/api.controller.js +34 -6
  11. package/lib/src/controllers/dashboard.controller.js +2 -2
  12. package/lib/src/controllers/info.controller.js +15 -14
  13. package/lib/src/controllers/view.controller.js +24 -22
  14. package/lib/src/core/metrics.js +134 -0
  15. package/lib/src/core/storage/capabilities.js +58 -0
  16. package/lib/src/core/storage/errors.js +0 -5
  17. package/lib/src/core/storage/gdrive-auth.js +0 -36
  18. package/lib/src/core/storage/gdrive-provider.js +103 -44
  19. package/lib/src/core/storage/index.js +4 -1
  20. package/lib/src/core/storage/local-provider.js +508 -0
  21. package/lib/src/core/storage/models.js +91 -50
  22. package/lib/src/core/storage/path-strategy.js +22 -2
  23. package/lib/src/core/storage/s3-provider.js +19 -38
  24. package/lib/src/core/storage/storage-manager.js +76 -53
  25. package/lib/src/core/storage/storage-provider.js +49 -120
  26. package/lib/src/middleware/error-handler.js +15 -52
  27. package/lib/src/middleware/rate-limiter.js +1 -1
  28. package/lib/src/routes/api.routes.js +4 -1
  29. package/lib/src/services/s3.service.js +2 -2
  30. package/lib/src/services/storage.service.js +118 -20
  31. package/lib/src/utils/cleanup-scheduler.js +91 -0
  32. package/lib/src/utils/helpers.js +8 -51
  33. package/package.json +4 -4
  34. package/views/bucket.hbs +0 -5
  35. package/views/bucketadmin_preview_modal.hbs +3 -3
@@ -29,6 +29,17 @@
29
29
  let selectedFiles = new Set();
30
30
  let starredFiles = new Set(JSON.parse(localStorage.getItem('mbk_starred_keys') || '[]'));
31
31
  let currentPreviewKey = '';
32
+ let currentCapabilities = {
33
+ multipart: true,
34
+ copy: true,
35
+ move: true,
36
+ search: true,
37
+ storageQuota: false,
38
+ hierarchicalFolders: true,
39
+ deleteBatch: true,
40
+ publicUrls: false
41
+ };
42
+ let currentStorageQuota = null;
32
43
 
33
44
  // Upload State
34
45
  let uploadQueue = [];
@@ -134,18 +145,26 @@
134
145
  return `${url}${sep}bucket=${encodeURIComponent(activeBucket)}`;
135
146
  }
136
147
 
148
+ function encodeKeyForUrl(key) {
149
+ return String(key || '')
150
+ .split('/')
151
+ .map(segment => encodeURIComponent(segment))
152
+ .join('/');
153
+ }
154
+
137
155
  function getViewUrl(key) {
138
- return withBucketUrl(`/mbkbucket/view/${encodeURIComponent(key)}`);
156
+ return withBucketUrl(`/mbkbucket/view/${encodeKeyForUrl(key)}`);
139
157
  }
140
158
 
141
159
  function getPublicViewUrl(key) {
142
- return withBucketUrl(`/mbkbucket/p_view/${encodeURIComponent(key)}`);
160
+ return withBucketUrl(`/mbkbucket/p_view/${encodeKeyForUrl(key)}`);
143
161
  }
144
162
 
145
163
  function getDownloadUrl(key) {
146
- return withBucketUrl(`/mbkbucket/download/${encodeURIComponent(key)}`);
164
+ return withBucketUrl(`/mbkbucket/download/${encodeKeyForUrl(key)}`);
147
165
  }
148
166
 
167
+
149
168
  function isImage(fileName) {
150
169
  const ext = getFileExt(fileName);
151
170
  return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext);
@@ -258,6 +277,56 @@
258
277
  }
259
278
  }
260
279
 
280
+ // Clipboard System with Visual Feedback & Fallback
281
+ function copyToClipboard(text, successMsg = 'Copied to clipboard', btnEl = null) {
282
+ if (!text) return;
283
+ const executeCopy = () => {
284
+ if (navigator.clipboard && window.isSecureContext) {
285
+ return navigator.clipboard.writeText(text);
286
+ }
287
+ return new Promise((resolve, reject) => {
288
+ const textArea = document.createElement('textarea');
289
+ textArea.value = text;
290
+ textArea.style.position = 'fixed';
291
+ textArea.style.left = '-9999px';
292
+ textArea.style.top = '-9999px';
293
+ textArea.setAttribute('readonly', '');
294
+ document.body.appendChild(textArea);
295
+ textArea.select();
296
+ try {
297
+ const ok = document.execCommand('copy');
298
+ textArea.remove();
299
+ ok ? resolve() : reject(new Error('Copy failed'));
300
+ } catch (e) {
301
+ textArea.remove();
302
+ reject(e);
303
+ }
304
+ });
305
+ };
306
+
307
+ executeCopy().then(() => {
308
+ showToast(successMsg, 'success');
309
+ if (btnEl && btnEl instanceof HTMLElement) {
310
+ const originalHtml = btnEl.innerHTML;
311
+ const originalBorder = btnEl.style.borderColor;
312
+ const originalColor = btnEl.style.color;
313
+ btnEl.classList.add('is-copied');
314
+ btnEl.innerHTML = `<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--green);"><path d="M2 8.5L6 12.5L14 4.5"/></svg> <span style="color: var(--green); font-weight: 600;">Copied!</span>`;
315
+ btnEl.style.borderColor = 'var(--green)';
316
+
317
+ setTimeout(() => {
318
+ btnEl.classList.remove('is-copied');
319
+ btnEl.innerHTML = originalHtml;
320
+ btnEl.style.borderColor = originalBorder;
321
+ btnEl.style.color = originalColor;
322
+ }, 1600);
323
+ }
324
+ }).catch((err) => {
325
+ console.error('Clipboard copy error:', err);
326
+ showToast('Failed to copy to clipboard', 'error');
327
+ });
328
+ }
329
+
261
330
  // Toast System
262
331
  function showToast(message, type = 'success') {
263
332
  const container = document.getElementById('toast-container');
@@ -283,6 +352,67 @@
283
352
  }, 4000);
284
353
  }
285
354
 
355
+ // Storage Quota & Usage Meter
356
+ function updateStorageUsageMeter(quota = null) {
357
+ const usageBar = document.getElementById('storageUsageBar');
358
+ const filterInfo = document.getElementById('filterInfo');
359
+
360
+ if (quota && quota.limit && Number(quota.limit) > 0) {
361
+ currentStorageQuota = quota;
362
+ const usage = Number(quota.usage || 0);
363
+ const limit = Number(quota.limit);
364
+ const percent = Math.min(100, Math.max(0, Math.round((usage / limit) * 100)));
365
+
366
+ if (usageBar) {
367
+ usageBar.style.width = `${percent}%`;
368
+ usageBar.style.background = percent > 90 ? 'var(--red)' : percent > 75 ? 'var(--orange)' : 'var(--primary)';
369
+ }
370
+ if (filterInfo) {
371
+ filterInfo.textContent = `${formatFileSize(usage)} of ${formatFileSize(limit)} (${percent}%)`;
372
+ filterInfo.title = `Storage: ${formatFileSize(usage)} of ${formatFileSize(limit)}`;
373
+ }
374
+ } else {
375
+ const totalBytes = (pageFiles || []).reduce((acc, f) => acc + Number(f.Size || 0), 0);
376
+ if (usageBar) {
377
+ usageBar.style.width = '100%';
378
+ usageBar.style.background = 'var(--primary)';
379
+ }
380
+ if (filterInfo && !currentStorageQuota) {
381
+ filterInfo.textContent = activeBucket ? `${activeBucket} · ${formatFileSize(totalBytes)}` : `${formatFileSize(totalBytes)} loaded`;
382
+ }
383
+ }
384
+ }
385
+
386
+ // Dynamic UI Adaptation Based on Provider Capabilities
387
+ function applyCapabilitiesUI() {
388
+ const batchMoveBtn = document.getElementById('batchMoveBtn');
389
+ if (batchMoveBtn) {
390
+ batchMoveBtn.style.display = currentCapabilities.move === false ? 'none' : '';
391
+ }
392
+
393
+ const newFolderBtn = document.getElementById('new-folder-btn');
394
+ if (newFolderBtn) {
395
+ if (currentCapabilities.hierarchicalFolders === false) {
396
+ newFolderBtn.style.opacity = '0.5';
397
+ newFolderBtn.title = 'Folders not supported by current provider';
398
+ } else {
399
+ newFolderBtn.style.opacity = '';
400
+ newFolderBtn.title = '';
401
+ }
402
+ }
403
+
404
+ const searchInput = document.getElementById('searchInput');
405
+ if (searchInput) {
406
+ if (currentCapabilities.search === false) {
407
+ searchInput.placeholder = 'Search not supported';
408
+ searchInput.disabled = true;
409
+ } else {
410
+ searchInput.placeholder = 'Search files…';
411
+ searchInput.disabled = false;
412
+ }
413
+ }
414
+ }
415
+
286
416
  // Live Health Check
287
417
  async function checkConnectionHealth(silent = true) {
288
418
  const healthBadge = document.getElementById('topHealthBadge');
@@ -306,6 +436,11 @@
306
436
  if (healthText) healthText.textContent = 'Healthy';
307
437
  }
308
438
  if (latencyTag) latencyTag.textContent = `${latency}ms`;
439
+ if (data.storageQuota) {
440
+ updateStorageUsageMeter(data.storageQuota);
441
+ } else {
442
+ updateStorageUsageMeter(null);
443
+ }
309
444
  if (!silent) showToast(`Storage connection healthy (${latency}ms)`, 'success');
310
445
  } else {
311
446
  if (healthBadge) {
@@ -725,6 +860,11 @@
725
860
  currentApiPrefix = data.prefix || '';
726
861
  pageFiles = data.files || [];
727
862
 
863
+ if (data.capabilities) {
864
+ currentCapabilities = Object.assign({}, currentCapabilities, data.capabilities);
865
+ applyCapabilitiesUI();
866
+ }
867
+
728
868
  if (data.mode === 'optimized') {
729
869
  pageFolders = (data.folders || []).map(folder => stripApiPrefixFromPath(folder, currentApiPrefix)).filter(Boolean);
730
870
  } else {
@@ -739,6 +879,7 @@
739
879
  renderBreadcrumbs();
740
880
  renderFiles();
741
881
  renderPagination(data);
882
+ updateStorageUsageMeter(currentStorageQuota);
742
883
  checkConnectionHealth(true);
743
884
  } catch (err) {
744
885
  console.error('Error loading files:', err);
@@ -872,22 +1013,14 @@
872
1013
  }
873
1014
  }
874
1015
 
875
- function copyShareLink(key) {
1016
+ function copyShareLink(key, btnEl = null) {
876
1017
  const viewUrl = window.location.origin + getViewUrl(key);
877
- navigator.clipboard.writeText(viewUrl).then(() => {
878
- showToast('Link copied to clipboard', 'success');
879
- }).catch(() => {
880
- showToast('Failed to copy link', 'error');
881
- });
1018
+ copyToClipboard(viewUrl, 'Link copied to clipboard', btnEl);
882
1019
  }
883
1020
 
884
- function copyPublicLink(key) {
1021
+ function copyPublicLink(key, btnEl = null) {
885
1022
  const publicUrl = window.location.origin + getPublicViewUrl(key);
886
- navigator.clipboard.writeText(publicUrl).then(() => {
887
- showToast('Public link copied to clipboard', 'success');
888
- }).catch(() => {
889
- showToast('Failed to copy public link', 'error');
890
- });
1023
+ copyToClipboard(publicUrl, 'Public link copied to clipboard', btnEl);
891
1024
  }
892
1025
 
893
1026
  // Preview Modal
@@ -962,8 +1095,10 @@
962
1095
  } else if (isAudio(fileName)) {
963
1096
  contentEl.innerHTML = `<div class="p-4 text-center"><audio controls autoplay class="w-100"><source src="${viewUrl}"></audio></div>`;
964
1097
  } else if (isPdf(fileName)) {
965
- contentEl.innerHTML = `<iframe src="${viewUrl}" class="preview-iframe"></iframe>`;
1098
+ contentEl.innerHTML = `<iframe src="${viewUrl}" class="preview-iframe" width="100%" height="100%" frameborder="0" title="${escapeAttr(fileName)}"></iframe>`;
966
1099
  } else if (isMarkdown(fileName)) {
1100
+
1101
+
967
1102
  fetch(viewUrl).then(res => res.text()).then(text => {
968
1103
  contentEl.innerHTML = renderMarkdownToHtml(text);
969
1104
  }).catch(err => {
@@ -990,12 +1125,12 @@
990
1125
  if (modal) modal.classList.remove('show');
991
1126
  };
992
1127
 
993
- window.copyCurrentPreviewLink = function () {
994
- if (currentPreviewKey) copyShareLink(currentPreviewKey);
1128
+ window.copyCurrentPreviewLink = function (btnEl) {
1129
+ if (currentPreviewKey) copyShareLink(currentPreviewKey, btnEl || document.getElementById('previewCopyLinkBtn'));
995
1130
  };
996
1131
 
997
- window.copyCurrentPreviewPublicLink = function () {
998
- if (currentPreviewKey) copyPublicLink(currentPreviewKey);
1132
+ window.copyCurrentPreviewPublicLink = function (btnEl) {
1133
+ if (currentPreviewKey) copyPublicLink(currentPreviewKey, btnEl || document.getElementById('previewCopyPublicLinkBtn'));
999
1134
  };
1000
1135
 
1001
1136
  // Move & Rename Modal
@@ -1153,6 +1288,67 @@
1153
1288
  `).join('');
1154
1289
  }
1155
1290
 
1291
+ async function uploadFileMultipart(file, prefix, onProgress) {
1292
+ const initRes = await fetch(withBucketUrl('/mbkbucket/upload-init'), {
1293
+ method: 'POST',
1294
+ headers: { 'Content-Type': 'application/json' },
1295
+ body: JSON.stringify({
1296
+ fileName: file.name,
1297
+ prefix: prefix,
1298
+ contentType: file.type || 'application/octet-stream'
1299
+ })
1300
+ });
1301
+ const initData = await initRes.json();
1302
+ if (!initData.success) throw new Error(initData.error || 'Failed to initialize multipart upload');
1303
+
1304
+ const { uploadId, key } = initData;
1305
+ const chunkSize = 5 * 1024 * 1024;
1306
+ const totalParts = Math.ceil(file.size / chunkSize);
1307
+ const parts = [];
1308
+
1309
+ try {
1310
+ for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
1311
+ const start = (partNumber - 1) * chunkSize;
1312
+ const end = Math.min(start + chunkSize, file.size);
1313
+ const chunkBlob = file.slice(start, end);
1314
+
1315
+ const chunkForm = new FormData();
1316
+ chunkForm.append('uploadId', uploadId);
1317
+ chunkForm.append('key', key);
1318
+ chunkForm.append('partNumber', String(partNumber));
1319
+ chunkForm.append('chunk', chunkBlob, file.name);
1320
+
1321
+ const chunkRes = await fetch(withBucketUrl('/mbkbucket/upload-chunk'), {
1322
+ method: 'POST',
1323
+ body: chunkForm
1324
+ });
1325
+ const chunkData = await chunkRes.json();
1326
+ if (!chunkData.success) throw new Error(chunkData.error || `Failed chunk ${partNumber}`);
1327
+
1328
+ parts.push({ PartNumber: partNumber, ETag: chunkData.etag || chunkData.ETag });
1329
+ if (typeof onProgress === 'function') {
1330
+ onProgress(partNumber / totalParts);
1331
+ }
1332
+ }
1333
+
1334
+ const completeRes = await fetch(withBucketUrl('/mbkbucket/upload-complete'), {
1335
+ method: 'POST',
1336
+ headers: { 'Content-Type': 'application/json' },
1337
+ body: JSON.stringify({ uploadId, key, parts })
1338
+ });
1339
+ const completeData = await completeRes.json();
1340
+ if (!completeData.success) throw new Error(completeData.error || 'Failed to complete multipart upload');
1341
+ return completeData;
1342
+ } catch (uploadErr) {
1343
+ fetch(withBucketUrl('/mbkbucket/upload-abort'), {
1344
+ method: 'POST',
1345
+ headers: { 'Content-Type': 'application/json' },
1346
+ body: JSON.stringify({ uploadId, key })
1347
+ }).catch(() => {});
1348
+ throw uploadErr;
1349
+ }
1350
+ }
1351
+
1156
1352
  async function processUploads() {
1157
1353
  if (isUploading || uploadQueue.length === 0) return;
1158
1354
  isUploading = true;
@@ -1173,18 +1369,28 @@
1173
1369
  const file = uploadQueue[i];
1174
1370
  if (statusLabel) statusLabel.textContent = `Uploading ${file.name} (${i + 1}/${totalFiles})...`;
1175
1371
 
1176
- const formData = new FormData();
1177
- formData.append('file', file);
1178
- formData.append('prefix', currentPrefix);
1179
-
1180
1372
  try {
1181
- const response = await fetch(withBucketUrl('/mbkbucket/upload'), {
1182
- method: 'POST',
1183
- body: formData
1184
- });
1185
- const result = await response.json();
1186
- if (!result.success) throw new Error(result.error || 'Upload failed');
1187
- completed++;
1373
+ const useMultipart = currentCapabilities.multipart && file.size > 5 * 1024 * 1024;
1374
+ if (useMultipart) {
1375
+ await uploadFileMultipart(file, currentPrefix, (fraction) => {
1376
+ const overallPercent = Math.round(((i + fraction) / totalFiles) * 100);
1377
+ if (progressBar) progressBar.style.width = `${overallPercent}%`;
1378
+ if (percentEl) percentEl.textContent = `${overallPercent}%`;
1379
+ });
1380
+ completed++;
1381
+ } else {
1382
+ const formData = new FormData();
1383
+ formData.append('file', file);
1384
+ formData.append('prefix', currentPrefix);
1385
+
1386
+ const response = await fetch(withBucketUrl('/mbkbucket/upload'), {
1387
+ method: 'POST',
1388
+ body: formData
1389
+ });
1390
+ const result = await response.json();
1391
+ if (!result.success) throw new Error(result.error || 'Upload failed');
1392
+ completed++;
1393
+ }
1188
1394
  } catch (err) {
1189
1395
  showToast(`Failed to upload ${file.name}: ${err.message}`, 'error');
1190
1396
  }
@@ -1405,9 +1611,25 @@
1405
1611
  });
1406
1612
  }
1407
1613
 
1614
+ if (currentCapabilities.move !== false) {
1615
+ menuItems.push({
1616
+ id: 'move',
1617
+ label: 'Rename / Move',
1618
+ icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M11 2L14 5L11 8M14 5H5C3.34315 5 2 6.34315 2 8V14"/></svg>',
1619
+ action: () => promptMove(fileKey, false)
1620
+ });
1621
+ }
1622
+
1623
+ if (currentCapabilities.copy !== false) {
1624
+ menuItems.push({
1625
+ id: 'copy',
1626
+ label: 'Duplicate / Copy',
1627
+ icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M3 11V3a1.5 1.5 0 011.5-1.5h8"/></svg>',
1628
+ action: () => promptMove(fileKey, true)
1629
+ });
1630
+ }
1631
+
1408
1632
  menuItems.push(
1409
- { id: 'move', label: 'Rename / Move', icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M11 2L14 5L11 8M14 5H5C3.34315 5 2 6.34315 2 8V14"/></svg>', action: () => promptMove(fileKey, false) },
1410
- { id: 'copy', label: 'Duplicate / Copy', icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M3 11V3a1.5 1.5 0 011.5-1.5h8"/></svg>', action: () => promptMove(fileKey, true) },
1411
1633
  { id: 'star', label: isStarred ? 'Unstar' : 'Star', icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 1l2 5 5 .5-4 3.5 1 5-4-2.5-4 2.5 1-5-4-3.5 5-.5z"/></svg>', action: () => toggleStar(fileKey) },
1412
1634
  '-',
1413
1635
  { id: 'delete', label: 'Delete', icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M6 4V2h4v2M5 4l1 9h4l1-9"/></svg>', danger: true, action: () => promptDelete(fileKey, false) }
@@ -13,11 +13,6 @@
13
13
  <title>Helper Utils Demo — MBKBucket</title>
14
14
  <meta name="description" content="Interactive demo of MBKBucket frontend helper utilities: gallery picker, file input, upload widget, multi-select, live preview binding, and API helpers.">
15
15
 
16
- <!-- Fonts (same as bucket.hbs) -->
17
- <link rel="preconnect" href="https://fonts.googleapis.com">
18
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
19
- <link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
20
-
21
16
  <!-- MBKBucket design system + helper styles -->
22
17
  <link rel="stylesheet" href="/mbkbucket/bucketadmin.css">
23
18
  <link rel="stylesheet" href="/mbkbucket/mbkbucket-helper.css">
@@ -834,14 +829,12 @@ uploader.<span class="fn">clear</span>();</pre>
834
829
  <span class="chip">MBKBucket.url.*</span>
835
830
  <span class="chip">listFiles</span>
836
831
  <span class="chip">uploadFile</span>
837
- <span class="chip">getSignedUrl</span>
838
832
  </div>
839
833
  </div>
840
834
 
841
835
  <div class="demo-tabs">
842
836
  <div class="demo-tab active" data-tab="urls" data-group="api">URL Builders</div>
843
837
  <div class="demo-tab" data-tab="list" data-group="api">List Files</div>
844
- <div class="demo-tab" data-tab="signed" data-group="api">Signed URL</div>
845
838
  <div class="demo-tab" data-tab="code" data-group="api">Code Reference</div>
846
839
  </div>
847
840
 
@@ -899,30 +892,6 @@ uploader.<span class="fn">clear</span>();</pre>
899
892
  </div>
900
893
  </div>
901
894
 
902
- <!-- Signed URL -->
903
- <div class="demo-pane" data-pane="api-signed">
904
- <div class="form-row">
905
- <label class="form-label" for="api-signed-key">File key:</label>
906
- <div class="flex-row">
907
- <input id="api-signed-key"
908
- class="demo-input"
909
- type="text"
910
- style="max-width:320px"
911
- placeholder="e.g. images/photo.jpg">
912
- <button class="btn btn-primary btn-sm" onclick="demoSignedUrl()">Get URL</button>
913
- </div>
914
- <div class="form-hint">Calls <code class="inline-code">MBKBucket.api.getSignedUrl(key, { expiresIn: 3600 })</code></div>
915
- </div>
916
-
917
- <div class="api-result" id="api-signed-result" style="display:none">
918
- <div class="api-result-header">
919
- <div class="api-dot" id="api-signed-dot"></div>
920
- <span id="api-signed-status">—</span>
921
- </div>
922
- <pre id="api-signed-pre"></pre>
923
- </div>
924
- </div>
925
-
926
895
  <!-- Code reference -->
927
896
  <div class="code-pane" data-pane="api-code">
928
897
  <pre class="code-block"><span class="cm">// URL helpers</span>
@@ -1132,33 +1101,6 @@ MBKBucket.toast.<span class="fn">info</span>(<span class="str">'Loading…'</spa
1132
1101
  pre.textContent = JSON.stringify({ error: err.message, status: err.status }, null, 2);
1133
1102
  }
1134
1103
  }
1135
-
1136
- // ── 4. API: signed URL ───────────────────────────────────────
1137
- async function demoSignedUrl() {
1138
- const key = document.getElementById('api-signed-key').value.trim();
1139
- if (!key) { MBKBucket.toast.warning('Enter a file key first'); return; }
1140
-
1141
- const result = document.getElementById('api-signed-result');
1142
- const dot = document.getElementById('api-signed-dot');
1143
- const status = document.getElementById('api-signed-status');
1144
- const pre = document.getElementById('api-signed-pre');
1145
-
1146
- result.style.display = '';
1147
- dot.className = 'api-dot';
1148
- status.textContent = 'Loading…';
1149
- pre.textContent = '';
1150
-
1151
- try {
1152
- const data = await MBKBucket.api.getSignedUrl(key, { expiresIn: 3600 });
1153
- dot.className = 'api-dot ok';
1154
- status.textContent = `200 OK — expires in ${data.expiresIn}s`;
1155
- pre.textContent = JSON.stringify(data, null, 2);
1156
- } catch (err) {
1157
- dot.className = 'api-dot err';
1158
- status.textContent = `Error: ${err.message}`;
1159
- pre.textContent = JSON.stringify({ error: err.message }, null, 2);
1160
- }
1161
- }
1162
1104
  </script>
1163
1105
  </body>
1164
1106
  </html>
@@ -330,17 +330,9 @@
330
330
  }
331
331
  return this.uploadSingle(prefix, file, opts);
332
332
  },
333
-
334
- getSignedUrl(key, opts) {
335
- opts = opts || {};
336
- return apiFetch(buildUrl(`${BASE}/api/signed-url`, {
337
- key,
338
- bucket: opts.bucket || '',
339
- expiresIn: opts.expiresIn || 3600,
340
- }));
341
- },
342
333
  };
343
334
 
335
+
344
336
  // ─────────────────────────────────────────────────────────────────────────
345
337
  // Gallery Modal (with In-Modal Upload & Multi-Select)
346
338
  // ─────────────────────────────────────────────────────────────────────────
package/lib/src/app.js CHANGED
@@ -7,17 +7,15 @@
7
7
 
8
8
  import express from "express";
9
9
  import path from "path";
10
- import fs from "fs";
11
10
  import { fileURLToPath } from "url";
12
- import { engine } from "express-handlebars";
13
11
  import compression from "compression";
14
12
  import mbkautheRoutes from "mbkauthe";
13
+ import { setupViewEngine } from "mbkutils";
15
14
  import { generalLimiter } from "./middleware/rate-limiter.js";
16
15
  import { notFoundHandler, globalErrorHandler } from "./middleware/error-handler.js";
17
16
  import bucketRoutes from "./routes/index.js";
18
17
  import { packageJson } from "./config/index.js";
19
18
  import { createLogger } from "#logger";
20
- import { commonHandlebarsHelpers } from "./utils/helpers.js";
21
19
 
22
20
  const debugServer = createLogger('server');
23
21
  const __filename = fileURLToPath(import.meta.url);
@@ -50,41 +48,29 @@ export function createApp() {
50
48
  const libDir = path.resolve(__dirname, "..");
51
49
  const cwd = process.cwd();
52
50
 
53
- const viewDirs = [
54
- path.join(rootDir, "views"),
51
+ const additionalViewsDirs = [
55
52
  path.join(libDir, "views"),
56
- path.join(rootDir, "node_modules", "mbkauthe", "views"),
57
53
  path.resolve(cwd, "views"),
58
- path.resolve(cwd, "node_modules", "mbkauthe", "views"),
59
- ].filter(dir => fs.existsSync(dir));
54
+ ];
60
55
 
61
- const partialsDir = [
62
- path.join(rootDir, "views", "templates"),
63
- path.join(rootDir, "views", "templates", "notice"),
64
- path.join(rootDir, "views"),
56
+ const additionalPartialsDirs = [
65
57
  path.join(rootDir, "views", "partial"),
66
58
  path.join(libDir, "views", "templates"),
67
59
  path.join(libDir, "views", "templates", "notice"),
68
60
  path.join(libDir, "views"),
69
61
  path.join(libDir, "views", "partial"),
70
- path.join(rootDir, "node_modules", "mbkauthe", "views"),
71
- path.resolve(cwd, "node_modules", "mbkauthe", "views"),
72
- ].filter(dir => fs.existsSync(dir));
62
+ ];
73
63
 
74
64
  // Handlebars template engine
75
- server.engine("hbs", engine({
76
- extname: ".hbs",
65
+ setupViewEngine(server, {
66
+ rootDir,
77
67
  defaultLayout: false,
78
- partialsDir: [...new Set(partialsDir)],
79
- cache: process.env.NODE_ENV === "production",
68
+ additionalViewsDirs,
69
+ additionalPartialsDirs,
80
70
  helpers: {
81
- ...commonHandlebarsHelpers,
82
71
  mbkbucket_cachebuster: () => `?v=${packageJson.version}`
83
72
  }
84
- }));
85
-
86
- server.set("view engine", "hbs");
87
- server.set("views", [...new Set(viewDirs)]);
73
+ });
88
74
 
89
75
  server.use(mbkautheRoutes);
90
76
  server.use(generalLimiter);
@@ -63,8 +63,12 @@ export function parseAndValidateBucketConnection(rawValue) {
63
63
  }
64
64
 
65
65
  const providerType = String(config.type || config.provider || '').toLowerCase();
66
+ const isLocal = providerType === 'local' || providerType === 'fs' || providerType === 'filesystem' || Boolean(config.basePath || config.rootPath || config.directory);
66
67
 
67
- if (providerType === 'gdrive' || providerType === 'google-drive' || providerType === 'googledrive') {
68
+ if (isLocal) {
69
+ // Local filesystem provider needs no cloud credentials, basePath is optional (defaults to ./mbkbucket_storage)
70
+ continue;
71
+ } else if (providerType === 'gdrive' || providerType === 'google-drive' || providerType === 'googledrive') {
68
72
  const hasOAuth = Boolean(config.client_id && config.client_secret && (config.refresh_token || config.access_token || config.code));
69
73
  const hasServiceAccount = Boolean((config.client_email || config.service_account_email) && (config.private_key || config.service_account_key));
70
74
  const hasAccessTokenOnly = Boolean(config.access_token);
@@ -80,6 +84,7 @@ export function parseAndValidateBucketConnection(rawValue) {
80
84
  throw new Error(`Bucket '${bucketName}' is missing required fields: ${missingFields.join(', ')}`);
81
85
  }
82
86
  }
87
+
83
88
  }
84
89
 
85
90
  return parsed;