nextly 0.0.1 → 0.0.2-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +122 -0
- package/dist/_dts-chunks/collections-handler.d-DjgO74Wt.d.ts +20540 -0
- package/dist/_dts-chunks/config.d-DNwsDnjs.d.ts +2589 -0
- package/dist/_dts-chunks/define-component.d-BUgTHmt3.d.ts +1149 -0
- package/dist/_dts-chunks/image-processor.d-OO1PmMrv.d.ts +335 -0
- package/dist/_dts-chunks/index.d-axCAzZ7m.d.ts +17842 -0
- package/dist/_dts-chunks/media.d-DjDOZo4B.d.ts +117 -0
- package/dist/_dts-chunks/on-error.d-CHIKWNxd.d.ts +38 -0
- package/dist/_dts-chunks/storage.d-BUhQ2we_.d.ts +404 -0
- package/dist/actions/index.d.ts +239 -0
- package/dist/actions/index.mjs +281 -0
- package/dist/api/auth-state.d.ts +5 -0
- package/dist/api/auth-state.mjs +131 -0
- package/dist/api/collections-schema-detail.d.ts +56 -0
- package/dist/api/collections-schema-detail.mjs +244 -0
- package/dist/api/collections-schema-export.d.ts +56 -0
- package/dist/api/collections-schema-export.mjs +129 -0
- package/dist/api/collections-schema.d.ts +59 -0
- package/dist/api/collections-schema.mjs +207 -0
- package/dist/api/components-detail.d.ts +50 -0
- package/dist/api/components-detail.mjs +132 -0
- package/dist/api/components.d.ts +69 -0
- package/dist/api/components.mjs +144 -0
- package/dist/api/email-providers-default.d.ts +40 -0
- package/dist/api/email-providers-default.mjs +75 -0
- package/dist/api/email-providers-detail.d.ts +81 -0
- package/dist/api/email-providers-detail.mjs +109 -0
- package/dist/api/email-providers-test.d.ts +43 -0
- package/dist/api/email-providers-test.mjs +114 -0
- package/dist/api/email-providers.d.ts +69 -0
- package/dist/api/email-providers.mjs +110 -0
- package/dist/api/email-send-template.d.ts +41 -0
- package/dist/api/email-send-template.mjs +58 -0
- package/dist/api/email-send.d.ts +42 -0
- package/dist/api/email-send.mjs +58 -0
- package/dist/api/email-templates-detail.d.ts +74 -0
- package/dist/api/email-templates-detail.mjs +112 -0
- package/dist/api/email-templates-layout.d.ts +55 -0
- package/dist/api/email-templates-layout.mjs +92 -0
- package/dist/api/email-templates-preview.d.ts +48 -0
- package/dist/api/email-templates-preview.mjs +93 -0
- package/dist/api/email-templates.d.ts +61 -0
- package/dist/api/email-templates.mjs +118 -0
- package/dist/api/health.d.ts +68 -0
- package/dist/api/health.mjs +67 -0
- package/dist/api/index.d.ts +54 -0
- package/dist/api/index.mjs +16 -0
- package/dist/api/media-bulk.d.ts +74 -0
- package/dist/api/media-bulk.mjs +196 -0
- package/dist/api/media-folders.d.ts +112 -0
- package/dist/api/media-folders.mjs +187 -0
- package/dist/api/media-handlers.d.ts +102 -0
- package/dist/api/media-handlers.mjs +437 -0
- package/dist/api/media.d.ts +117 -0
- package/dist/api/media.mjs +242 -0
- package/dist/api/singles-detail.d.ts +87 -0
- package/dist/api/singles-detail.mjs +170 -0
- package/dist/api/singles-schema-detail.d.ts +54 -0
- package/dist/api/singles-schema-detail.mjs +182 -0
- package/dist/api/singles.d.ts +34 -0
- package/dist/api/singles.mjs +94 -0
- package/dist/api/storage-upload-url.d.ts +48 -0
- package/dist/api/storage-upload-url.mjs +202 -0
- package/dist/api/uploads.d.ts +109 -0
- package/dist/api/uploads.mjs +359 -0
- package/dist/auth/index.d.ts +425 -0
- package/dist/auth/index.mjs +199 -0
- package/dist/boot-apply-PQSYLDIN.mjs +7 -0
- package/dist/chunk-2OALJTK6.mjs +489 -0
- package/dist/chunk-2Q2SX2CS.mjs +365 -0
- package/dist/chunk-2TFX4ND3.mjs +13 -0
- package/dist/chunk-2TWPDSYD.mjs +87 -0
- package/dist/chunk-2W3DVD7S.mjs +647 -0
- package/dist/chunk-2ZFKXPQM.mjs +88 -0
- package/dist/chunk-3FA7FKAV.mjs +832 -0
- package/dist/chunk-3NZ2KMBL.mjs +58 -0
- package/dist/chunk-4MJLT6PZ.mjs +0 -0
- package/dist/chunk-56WO4WX7.mjs +0 -0
- package/dist/chunk-5APFUGAD.mjs +89 -0
- package/dist/chunk-5HMZ644B.mjs +108 -0
- package/dist/chunk-67GXH6PR.mjs +32 -0
- package/dist/chunk-6JNEPWRW.mjs +14368 -0
- package/dist/chunk-6NFHQIJD.mjs +45 -0
- package/dist/chunk-7P6ASYW6.mjs +9 -0
- package/dist/chunk-A3WPLSDT.mjs +1364 -0
- package/dist/chunk-AGJ6F2T3.mjs +144 -0
- package/dist/chunk-AK6Z23OX.mjs +1464 -0
- package/dist/chunk-APKKRD2G.mjs +102 -0
- package/dist/chunk-B2GV2BWH.mjs +73 -0
- package/dist/chunk-D5HQBNUB.mjs +74 -0
- package/dist/chunk-DNNG377Z.mjs +204 -0
- package/dist/chunk-DP3G27G5.mjs +135 -0
- package/dist/chunk-DV6WVX2Q.mjs +0 -0
- package/dist/chunk-DXGGXIUZ.mjs +57 -0
- package/dist/chunk-EGXBZCGC.mjs +943 -0
- package/dist/chunk-ERCNLX3V.mjs +176 -0
- package/dist/chunk-FQULBZ53.mjs +850 -0
- package/dist/chunk-G2AA4QLC.mjs +262 -0
- package/dist/chunk-GDBJ5JCU.mjs +488 -0
- package/dist/chunk-GJNSJU4S.mjs +19 -0
- package/dist/chunk-GZ6DCQKC.mjs +69 -0
- package/dist/chunk-H26B4FYG.mjs +167 -0
- package/dist/chunk-I4JMR3UR.mjs +21 -0
- package/dist/chunk-INV7QKLG.mjs +508 -0
- package/dist/chunk-IUDOC7N7.mjs +46 -0
- package/dist/chunk-IZWPRDC3.mjs +206 -0
- package/dist/chunk-KIMNCZGV.mjs +15 -0
- package/dist/chunk-L6HW2DA7.mjs +15 -0
- package/dist/chunk-LAZXX4HR.mjs +100 -0
- package/dist/chunk-LDKCUMHK.mjs +95 -0
- package/dist/chunk-LRXMECUA.mjs +0 -0
- package/dist/chunk-M52VMPGA.mjs +119 -0
- package/dist/chunk-MGUWEEI6.mjs +160 -0
- package/dist/chunk-NRUWQ5Z7.mjs +419 -0
- package/dist/chunk-NSEFNNU4.mjs +25360 -0
- package/dist/chunk-NTHVDFGO.mjs +138 -0
- package/dist/chunk-O3QHXMOX.mjs +3166 -0
- package/dist/chunk-P7NH2OSC.mjs +2605 -0
- package/dist/chunk-PKMABBB5.mjs +184 -0
- package/dist/chunk-PWS6XGJK.mjs +76 -0
- package/dist/chunk-R6JJQHFC.mjs +20 -0
- package/dist/chunk-RJLLGGPG.mjs +0 -0
- package/dist/chunk-SBACDPNX.mjs +689 -0
- package/dist/chunk-TO5AFLVQ.mjs +124 -0
- package/dist/chunk-TS7GHTG2.mjs +5436 -0
- package/dist/chunk-UJ2IMJ4W.mjs +133 -0
- package/dist/chunk-UOP63Q54.mjs +102 -0
- package/dist/chunk-UUOFWCM6.mjs +78 -0
- package/dist/chunk-V4EQTOA4.mjs +893 -0
- package/dist/chunk-VJ66NCL4.mjs +193 -0
- package/dist/chunk-VQJQHVEV.mjs +29 -0
- package/dist/chunk-VTJADRO3.mjs +141 -0
- package/dist/chunk-VWF3JO32.mjs +0 -0
- package/dist/chunk-W4MGXIRR.mjs +27 -0
- package/dist/chunk-W5KKPZT5.mjs +1204 -0
- package/dist/chunk-WD34YQ6T.mjs +381 -0
- package/dist/chunk-WZBYMYVW.mjs +14 -0
- package/dist/chunk-X23WKS3Z.mjs +50 -0
- package/dist/chunk-X7TXCYYN.mjs +6496 -0
- package/dist/chunk-XGI4EMS3.mjs +140 -0
- package/dist/chunk-XZKLBMN6.mjs +1153 -0
- package/dist/chunk-YB7INWPY.mjs +0 -0
- package/dist/chunk-YV4Y7SDL.mjs +83 -0
- package/dist/chunk-YZNBLFIW.mjs +1688 -0
- package/dist/chunk-YZZCTONM.mjs +263 -0
- package/dist/chunk-ZE6A3FYH.mjs +289 -0
- package/dist/cli/nextly.mjs +68 -0
- package/dist/cli/utils/index.d.ts +449 -0
- package/dist/cli/utils/index.mjs +49 -0
- package/dist/component-schema-service-5577KVW6.mjs +11 -0
- package/dist/config-loader-23YEMC3Z.mjs +23 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.mjs +109 -0
- package/dist/container-ORGFGYSZ.mjs +9 -0
- package/dist/database/index.d.ts +12 -0
- package/dist/database/index.mjs +40 -0
- package/dist/database/seeders/index.d.ts +93 -0
- package/dist/database/seeders/index.mjs +47 -0
- package/dist/db-sync-demote-LJGKLB3S.mjs +117 -0
- package/dist/db-sync-promote-B26VSYQF.mjs +113 -0
- package/dist/dev-reload-broadcaster-B73IQ53V.mjs +25 -0
- package/dist/dist-M2NOU37V.mjs +19 -0
- package/dist/drizzle-kit-lazy-D2M2PXR2.mjs +13 -0
- package/dist/dynamic-collection-schema-service-IEXTPIZ7.mjs +8 -0
- package/dist/errors/index.d.ts +159 -0
- package/dist/errors/index.mjs +10 -0
- package/dist/factory-IWMBKUJM.mjs +15 -0
- package/dist/first-run-QIVKWJIF.mjs +63 -0
- package/dist/fresh-push-NR67DC3R.mjs +8 -0
- package/dist/index.d.ts +4175 -0
- package/dist/index.mjs +1336 -0
- package/dist/local-plugin-PTET4NAT.mjs +7 -0
- package/dist/logger-NU46DXNY.mjs +15 -0
- package/dist/logger-YE4TC7ZN.mjs +9 -0
- package/dist/migration-journal-EP532Y4L.mjs +139 -0
- package/dist/migrations/mysql/0000_eager_sentry.sql +174 -0
- package/dist/migrations/mysql/0001_soft_giant_girl.sql +27 -0
- package/dist/migrations/mysql/0002_media_table.sql +24 -0
- package/dist/migrations/mysql/0003_dynamic_singles.sql +37 -0
- package/dist/migrations/mysql/0004_dynamic_components.sql +35 -0
- package/dist/migrations/mysql/0005_user_management_tables.sql +92 -0
- package/dist/migrations/mysql/0006_api_keys.sql +36 -0
- package/dist/migrations/mysql/0007_general_settings.sql +20 -0
- package/dist/migrations/mysql/0008_site_settings_logo_url.sql +9 -0
- package/dist/migrations/mysql/0009_activity_log.sql +30 -0
- package/dist/migrations/mysql/0010_site_settings_sidebar.sql +13 -0
- package/dist/migrations/mysql/0011_missing_tables_and_columns.sql +54 -0
- package/dist/migrations/mysql/0012_image_sizes_and_focal_point.sql +30 -0
- package/dist/migrations/mysql/0012_media_folders.sql +43 -0
- package/dist/migrations/mysql/0013_user_brute_force_protection.sql +31 -0
- package/dist/migrations/mysql/0014_email_template_attachments.sql +12 -0
- package/dist/migrations/mysql/0015_media_uploaded_by_nullable.sql +15 -0
- package/dist/migrations/mysql/20260429_000000_000_initial_journal.sql +22 -0
- package/dist/migrations/mysql/20260501_000000_journal_batch.sql +17 -0
- package/dist/migrations/mysql/20260501_000001_audit_log.sql +24 -0
- package/dist/migrations/mysql/20260504_000000_nextly_meta.sql +21 -0
- package/dist/migrations/mysql/meta/0000_snapshot.json +1005 -0
- package/dist/migrations/mysql/meta/0001_snapshot.json +1099 -0
- package/dist/migrations/mysql/meta/_journal.json +41 -0
- package/dist/migrations/postgresql/0000_misty_king_bedlam.sql +169 -0
- package/dist/migrations/postgresql/0001_perpetual_captain_marvel.sql +8 -0
- package/dist/migrations/postgresql/0002_sad_spectrum.sql +16 -0
- package/dist/migrations/postgresql/0003_hesitant_ultron.sql +17 -0
- package/dist/migrations/postgresql/0004_media_table.sql +24 -0
- package/dist/migrations/postgresql/0005_media_folders.sql +36 -0
- package/dist/migrations/postgresql/0006_dynamic_collections_update.sql +50 -0
- package/dist/migrations/postgresql/0007_dynamic_singles.sql +38 -0
- package/dist/migrations/postgresql/0008_dynamic_components.sql +37 -0
- package/dist/migrations/postgresql/0009_user_management_tables.sql +95 -0
- package/dist/migrations/postgresql/0010_api_keys.sql +34 -0
- package/dist/migrations/postgresql/0011_general_settings.sql +20 -0
- package/dist/migrations/postgresql/0012_site_settings_logo_url.sql +9 -0
- package/dist/migrations/postgresql/0013_activity_log.sql +29 -0
- package/dist/migrations/postgresql/0014_image_sizes_and_focal_point.sql +33 -0
- package/dist/migrations/postgresql/0014_site_settings_sidebar.sql +13 -0
- package/dist/migrations/postgresql/0015_user_brute_force_protection.sql +29 -0
- package/dist/migrations/postgresql/0016_email_template_attachments.sql +12 -0
- package/dist/migrations/postgresql/0017_media_uploaded_by_nullable.sql +15 -0
- package/dist/migrations/postgresql/20260429_000000_000_initial_journal.sql +24 -0
- package/dist/migrations/postgresql/20260501_000000_journal_batch.sql +17 -0
- package/dist/migrations/postgresql/20260501_000001_audit_log.sql +24 -0
- package/dist/migrations/postgresql/20260504_000000_nextly_meta.sql +22 -0
- package/dist/migrations/postgresql/meta/0000_snapshot.json +1286 -0
- package/dist/migrations/postgresql/meta/0001_snapshot.json +1407 -0
- package/dist/migrations/postgresql/meta/0002_snapshot.json +1552 -0
- package/dist/migrations/postgresql/meta/0003_snapshot.json +1695 -0
- package/dist/migrations/postgresql/meta/0010_snapshot.json +2345 -0
- package/dist/migrations/postgresql/meta/_journal.json +90 -0
- package/dist/migrations/sqlite/0000_api_keys.sql +34 -0
- package/dist/migrations/sqlite/0001_general_settings.sql +20 -0
- package/dist/migrations/sqlite/0002_site_settings_logo_url.sql +9 -0
- package/dist/migrations/sqlite/0003_activity_log.sql +29 -0
- package/dist/migrations/sqlite/0004_image_sizes_and_focal_point.sql +29 -0
- package/dist/migrations/sqlite/0004_site_settings_sidebar.sql +11 -0
- package/dist/migrations/sqlite/0005_user_brute_force_protection.sql +29 -0
- package/dist/migrations/sqlite/0006_email_template_attachments.sql +12 -0
- package/dist/migrations/sqlite/0007_media_uploaded_by_nullable.sql +111 -0
- package/dist/migrations/sqlite/20260429_000000_000_initial_journal.sql +24 -0
- package/dist/migrations/sqlite/20260501_000000_journal_batch.sql +19 -0
- package/dist/migrations/sqlite/20260501_000001_audit_log.sql +24 -0
- package/dist/migrations/sqlite/20260504_000000_nextly_meta.sql +21 -0
- package/dist/migrations/sqlite/20260505_000000_user_management_tables.sql +77 -0
- package/dist/next.d.ts +57 -0
- package/dist/next.mjs +55 -0
- package/dist/observability/index.d.ts +87 -0
- package/dist/observability/index.mjs +57 -0
- package/dist/permissions-3DZZQZMI.mjs +39 -0
- package/dist/pipeline-YOML7SWF.mjs +29 -0
- package/dist/preview-ZZTR3QGS.mjs +9 -0
- package/dist/program-PW6UB2ZC.mjs +5934 -0
- package/dist/reconcile-single-tables-7ENVXJGB.mjs +7 -0
- package/dist/register-SF6E6FVU.mjs +49 -0
- package/dist/reload-config-HWQ4G5MM.mjs +23 -0
- package/dist/resolve-single-table-name-JSOMUB3R.mjs +7 -0
- package/dist/routeHandler-UNMMJIBM.mjs +77 -0
- package/dist/runtime-schema-generator-NRA6A6Z6.mjs +8 -0
- package/dist/runtime.d.ts +120 -0
- package/dist/runtime.mjs +73 -0
- package/dist/schema-hash-FMMG6VPJ.mjs +13 -0
- package/dist/schema-registry-EQ36FZDP.mjs +7 -0
- package/dist/scripts/load-env.mjs +42 -0
- package/dist/storage/index.d.ts +566 -0
- package/dist/storage/index.mjs +45 -0
- package/dist/super-admin-G5ZK5F4T.mjs +39 -0
- package/dist/system-table-service-WGSRVEGT.mjs +17 -0
- package/dist/users-7KELGRYJ.mjs +38 -0
- package/package.json +308 -9
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LocalStorageAdapter
|
|
3
|
+
} from "./chunk-G2AA4QLC.mjs";
|
|
4
|
+
|
|
5
|
+
// src/storage/storage.ts
|
|
6
|
+
var MediaStorage = class {
|
|
7
|
+
/** Registered storage plugins by name */
|
|
8
|
+
plugins = /* @__PURE__ */ new Map();
|
|
9
|
+
/** Storage adapter per collection */
|
|
10
|
+
collectionAdapters = /* @__PURE__ */ new Map();
|
|
11
|
+
/** Storage configuration per collection */
|
|
12
|
+
collectionConfigs = /* @__PURE__ */ new Map();
|
|
13
|
+
/** Local storage adapter (always available as fallback) */
|
|
14
|
+
localAdapter;
|
|
15
|
+
/**
|
|
16
|
+
* Create a new MediaStorage instance.
|
|
17
|
+
*
|
|
18
|
+
* @param config - Optional configuration for storage initialization
|
|
19
|
+
*/
|
|
20
|
+
constructor(config) {
|
|
21
|
+
this.localAdapter = new LocalStorageAdapter({
|
|
22
|
+
basePath: config?.local?.uploadDir ?? "./public/uploads",
|
|
23
|
+
baseUrl: config?.local?.publicPath ?? "/uploads"
|
|
24
|
+
});
|
|
25
|
+
if (config?.plugins) {
|
|
26
|
+
for (const plugin of config.plugins) {
|
|
27
|
+
this.registerPlugin(plugin);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// ============================================================
|
|
32
|
+
// Plugin Registration
|
|
33
|
+
// ============================================================
|
|
34
|
+
/**
|
|
35
|
+
* Register a storage plugin.
|
|
36
|
+
*
|
|
37
|
+
* Plugins provide storage adapters for specific collections.
|
|
38
|
+
* When a collection is registered with a plugin, uploads for that
|
|
39
|
+
* collection will be routed to the plugin's adapter.
|
|
40
|
+
*
|
|
41
|
+
* @param plugin - The storage plugin to register
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* const storage = new MediaStorage();
|
|
46
|
+
*
|
|
47
|
+
* storage.registerPlugin(s3Storage({
|
|
48
|
+
* bucket: 'my-bucket',
|
|
49
|
+
* region: 'us-east-1',
|
|
50
|
+
* collections: {
|
|
51
|
+
* media: true,
|
|
52
|
+
* 'private-docs': { prefix: 'private/' }
|
|
53
|
+
* }
|
|
54
|
+
* }));
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
registerPlugin(plugin) {
|
|
58
|
+
if (!plugin.adapter) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.plugins.set(plugin.name, plugin);
|
|
62
|
+
for (const [collectionSlug, config] of Object.entries(plugin.collections)) {
|
|
63
|
+
const collectionConfig = typeof config === "boolean" ? {} : config;
|
|
64
|
+
this.collectionAdapters.set(collectionSlug, plugin.adapter);
|
|
65
|
+
this.collectionConfigs.set(collectionSlug, collectionConfig);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ============================================================
|
|
69
|
+
// Adapter Resolution
|
|
70
|
+
// ============================================================
|
|
71
|
+
/**
|
|
72
|
+
* Check if any storage adapter is configured.
|
|
73
|
+
*
|
|
74
|
+
* @returns True if at least one storage plugin is registered
|
|
75
|
+
*/
|
|
76
|
+
hasAdapter() {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Get the storage adapter if available, or null if not configured.
|
|
81
|
+
*
|
|
82
|
+
* Unlike getAdapter(), this method does not throw an error if no storage
|
|
83
|
+
* is configured. Useful for optional storage scenarios.
|
|
84
|
+
*
|
|
85
|
+
* @param collection - The collection slug (optional)
|
|
86
|
+
* @returns The storage adapter instance, or null if not configured
|
|
87
|
+
*/
|
|
88
|
+
getAdapterOrNull(collection) {
|
|
89
|
+
if (collection && this.collectionAdapters.has(collection)) {
|
|
90
|
+
return this.collectionAdapters.get(collection);
|
|
91
|
+
}
|
|
92
|
+
return this.localAdapter;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Get the storage adapter for a specific collection.
|
|
96
|
+
*
|
|
97
|
+
* If a plugin is configured for the collection, returns the plugin's adapter.
|
|
98
|
+
* Otherwise, returns the default adapter (first registered plugin).
|
|
99
|
+
*
|
|
100
|
+
* @param collection - The collection slug (optional)
|
|
101
|
+
* @returns The appropriate storage adapter
|
|
102
|
+
* @throws Error if no storage plugin is configured
|
|
103
|
+
*/
|
|
104
|
+
getAdapterForCollection(collection) {
|
|
105
|
+
if (collection && this.collectionAdapters.has(collection)) {
|
|
106
|
+
return this.collectionAdapters.get(collection);
|
|
107
|
+
}
|
|
108
|
+
return this.localAdapter;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Get configuration for a specific collection.
|
|
112
|
+
*
|
|
113
|
+
* @param collection - The collection slug
|
|
114
|
+
* @returns The collection's storage configuration, or undefined
|
|
115
|
+
*/
|
|
116
|
+
getCollectionConfig(collection) {
|
|
117
|
+
return this.collectionConfigs.get(collection);
|
|
118
|
+
}
|
|
119
|
+
// ============================================================
|
|
120
|
+
// Core Storage Operations
|
|
121
|
+
// ============================================================
|
|
122
|
+
/**
|
|
123
|
+
* Upload file to appropriate storage based on collection.
|
|
124
|
+
*
|
|
125
|
+
* Routes the upload to the correct adapter based on collection
|
|
126
|
+
* configuration. Applies collection-specific prefix if configured.
|
|
127
|
+
*
|
|
128
|
+
* @param buffer - The file buffer to upload
|
|
129
|
+
* @param options - Upload options including filename, mimeType, collection
|
|
130
|
+
* @returns Upload result with URL and path
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```typescript
|
|
134
|
+
* const result = await storage.upload(buffer, {
|
|
135
|
+
* filename: 'photo.jpg',
|
|
136
|
+
* mimeType: 'image/jpeg',
|
|
137
|
+
* collection: 'media'
|
|
138
|
+
* });
|
|
139
|
+
* console.log(result.url); // Public URL
|
|
140
|
+
* console.log(result.path); // Storage path for deletion
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
async upload(buffer, options) {
|
|
144
|
+
const adapter = this.getAdapterForCollection(options.collection);
|
|
145
|
+
const config = options.collection ? this.getCollectionConfig(options.collection) : void 0;
|
|
146
|
+
const uploadOptions = { ...options };
|
|
147
|
+
if (config?.prefix) {
|
|
148
|
+
uploadOptions.folder = config.prefix + (options.folder || "");
|
|
149
|
+
}
|
|
150
|
+
return adapter.upload(buffer, uploadOptions);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Delete file from storage.
|
|
154
|
+
*
|
|
155
|
+
* Determines correct adapter based on collection.
|
|
156
|
+
*
|
|
157
|
+
* @param filePath - The storage path/key of the file
|
|
158
|
+
* @param collection - The collection slug (optional, for routing)
|
|
159
|
+
*/
|
|
160
|
+
async delete(filePath, collection) {
|
|
161
|
+
const adapter = this.getAdapterForCollection(collection);
|
|
162
|
+
return adapter.delete(filePath);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Bulk delete files from storage.
|
|
166
|
+
* Uses adapter's native bulkDelete if available, otherwise falls back to
|
|
167
|
+
* sequential individual deletes in chunks of 10.
|
|
168
|
+
*/
|
|
169
|
+
async bulkDelete(filePaths, collection) {
|
|
170
|
+
const adapter = this.getAdapterForCollection(collection);
|
|
171
|
+
if (adapter.bulkDelete) {
|
|
172
|
+
return adapter.bulkDelete(filePaths);
|
|
173
|
+
}
|
|
174
|
+
const successful = [];
|
|
175
|
+
const failed = [];
|
|
176
|
+
const chunkSize = 10;
|
|
177
|
+
for (let i = 0; i < filePaths.length; i += chunkSize) {
|
|
178
|
+
const chunk = filePaths.slice(i, i + chunkSize);
|
|
179
|
+
const results = await Promise.allSettled(
|
|
180
|
+
chunk.map((fp) => adapter.delete(fp))
|
|
181
|
+
);
|
|
182
|
+
results.forEach((result, idx) => {
|
|
183
|
+
const fp = chunk[idx];
|
|
184
|
+
if (result.status === "fulfilled") {
|
|
185
|
+
successful.push(fp);
|
|
186
|
+
} else {
|
|
187
|
+
failed.push({
|
|
188
|
+
filePath: fp,
|
|
189
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return { successful, failed };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Check if file exists in storage.
|
|
198
|
+
*
|
|
199
|
+
* @param filePath - The storage path/key to check
|
|
200
|
+
* @param collection - The collection slug (optional, for routing)
|
|
201
|
+
* @returns True if file exists
|
|
202
|
+
*/
|
|
203
|
+
async exists(filePath, collection) {
|
|
204
|
+
const adapter = this.getAdapterForCollection(collection);
|
|
205
|
+
return adapter.exists(filePath);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Get public URL for file.
|
|
209
|
+
*
|
|
210
|
+
* @param filePath - The storage path/key
|
|
211
|
+
* @param collection - The collection slug (optional, for routing)
|
|
212
|
+
* @returns Public URL to access the file
|
|
213
|
+
*/
|
|
214
|
+
getPublicUrl(filePath, collection) {
|
|
215
|
+
const adapter = this.getAdapterForCollection(collection);
|
|
216
|
+
return adapter.getPublicUrl(filePath);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Get storage type for a collection.
|
|
220
|
+
*
|
|
221
|
+
* @param collection - The collection slug (optional)
|
|
222
|
+
* @returns Storage type identifier ('s3', 'vercel-blob')
|
|
223
|
+
*/
|
|
224
|
+
getStorageType(collection) {
|
|
225
|
+
const adapter = this.getAdapterForCollection(collection);
|
|
226
|
+
return adapter.getType();
|
|
227
|
+
}
|
|
228
|
+
// ============================================================
|
|
229
|
+
// Client Upload Support
|
|
230
|
+
// ============================================================
|
|
231
|
+
/**
|
|
232
|
+
* Check if collection supports client-side uploads.
|
|
233
|
+
*
|
|
234
|
+
* Client-side uploads allow direct-to-storage uploads, bypassing
|
|
235
|
+
* the server. This is essential for serverless platforms with
|
|
236
|
+
* request body size limits (e.g., Vercel's 4.5MB limit).
|
|
237
|
+
*
|
|
238
|
+
* @param collection - The collection slug
|
|
239
|
+
* @returns True if client uploads are enabled and supported
|
|
240
|
+
*/
|
|
241
|
+
supportsClientUploads(collection) {
|
|
242
|
+
const config = this.getCollectionConfig(collection);
|
|
243
|
+
if (!config?.clientUploads) return false;
|
|
244
|
+
const adapter = this.getAdapterForCollection(collection);
|
|
245
|
+
const info = adapter.getInfo?.();
|
|
246
|
+
return info?.supportsClientUploads ?? false;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Get client upload URL for direct-to-storage uploads.
|
|
250
|
+
*
|
|
251
|
+
* Generates a pre-signed URL that allows the client to upload
|
|
252
|
+
* directly to the storage backend, bypassing the server.
|
|
253
|
+
*
|
|
254
|
+
* Only available if:
|
|
255
|
+
* 1. Collection is configured with `clientUploads: true`
|
|
256
|
+
* 2. The storage adapter supports client uploads
|
|
257
|
+
*
|
|
258
|
+
* @param filename - Original filename
|
|
259
|
+
* @param mimeType - File MIME type
|
|
260
|
+
* @param collection - Collection slug
|
|
261
|
+
* @returns Client upload data, or null if not supported
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* ```typescript
|
|
265
|
+
* // Server-side: generate upload URL
|
|
266
|
+
* const uploadData = await storage.getClientUploadUrl(
|
|
267
|
+
* 'photo.jpg',
|
|
268
|
+
* 'image/jpeg',
|
|
269
|
+
* 'media'
|
|
270
|
+
* );
|
|
271
|
+
*
|
|
272
|
+
* // Client-side: upload directly to storage
|
|
273
|
+
* await fetch(uploadData.uploadUrl, {
|
|
274
|
+
* method: uploadData.method,
|
|
275
|
+
* headers: uploadData.headers,
|
|
276
|
+
* body: file
|
|
277
|
+
* });
|
|
278
|
+
* ```
|
|
279
|
+
*/
|
|
280
|
+
async getClientUploadUrl(filename, mimeType, collection) {
|
|
281
|
+
if (!this.supportsClientUploads(collection)) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
for (const plugin of this.plugins.values()) {
|
|
285
|
+
if (collection in plugin.collections && plugin.getClientUploadUrl) {
|
|
286
|
+
return plugin.getClientUploadUrl(filename, mimeType, collection);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
// ============================================================
|
|
292
|
+
// Signed Download Support
|
|
293
|
+
// ============================================================
|
|
294
|
+
/**
|
|
295
|
+
* Check if collection supports signed download URLs.
|
|
296
|
+
*
|
|
297
|
+
* @param collection - The collection slug
|
|
298
|
+
* @returns True if signed downloads are enabled and supported
|
|
299
|
+
*/
|
|
300
|
+
supportsSignedDownloads(collection) {
|
|
301
|
+
const config = this.getCollectionConfig(collection);
|
|
302
|
+
if (!config?.signedDownloads) return false;
|
|
303
|
+
const adapter = this.getAdapterForCollection(collection);
|
|
304
|
+
const info = adapter.getInfo?.();
|
|
305
|
+
return info?.supportsSignedUrls ?? false;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Get signed download URL for secure file access.
|
|
309
|
+
*
|
|
310
|
+
* Generates a time-limited signed URL for accessing files in
|
|
311
|
+
* private storage buckets. Only works if:
|
|
312
|
+
* 1. Collection is configured with `signedDownloads: true`
|
|
313
|
+
* 2. The storage adapter supports signed URLs
|
|
314
|
+
*
|
|
315
|
+
* @param filePath - Storage path/key of the file
|
|
316
|
+
* @param collection - Collection slug
|
|
317
|
+
* @param expiresIn - URL expiry time in seconds (optional)
|
|
318
|
+
* @returns Signed URL, or null if not supported
|
|
319
|
+
*
|
|
320
|
+
* @example
|
|
321
|
+
* ```typescript
|
|
322
|
+
* const signedUrl = await storage.getSignedDownloadUrl(
|
|
323
|
+
* 'private/doc.pdf',
|
|
324
|
+
* 'private-docs',
|
|
325
|
+
* 3600 // 1 hour
|
|
326
|
+
* );
|
|
327
|
+
* ```
|
|
328
|
+
*/
|
|
329
|
+
async getSignedDownloadUrl(filePath, collection, expiresIn) {
|
|
330
|
+
if (!this.supportsSignedDownloads(collection)) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
const config = this.getCollectionConfig(collection);
|
|
334
|
+
for (const plugin of this.plugins.values()) {
|
|
335
|
+
if (collection in plugin.collections && plugin.getSignedDownloadUrl) {
|
|
336
|
+
return plugin.getSignedDownloadUrl(
|
|
337
|
+
filePath,
|
|
338
|
+
expiresIn ?? config?.signedUrlExpiresIn ?? 3600
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
// ============================================================
|
|
345
|
+
// Accessor Methods
|
|
346
|
+
// ============================================================
|
|
347
|
+
/**
|
|
348
|
+
* Get the default storage adapter.
|
|
349
|
+
*
|
|
350
|
+
* @returns The default storage adapter (first registered plugin)
|
|
351
|
+
* @throws Error if no storage plugin is configured
|
|
352
|
+
*/
|
|
353
|
+
getDefaultAdapter() {
|
|
354
|
+
return this.localAdapter;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Get list of registered plugins.
|
|
358
|
+
*
|
|
359
|
+
* @returns Array of registered storage plugins
|
|
360
|
+
*/
|
|
361
|
+
getPlugins() {
|
|
362
|
+
return Array.from(this.plugins.values());
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Get the underlying storage adapter for a collection.
|
|
366
|
+
*
|
|
367
|
+
* Useful for passing to registerServices() which requires IStorageAdapter.
|
|
368
|
+
*
|
|
369
|
+
* @param collection - The collection slug (optional)
|
|
370
|
+
* @returns The storage adapter instance
|
|
371
|
+
*/
|
|
372
|
+
getAdapter(collection) {
|
|
373
|
+
return this.getAdapterForCollection(collection);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Check if a collection has a configured storage adapter.
|
|
377
|
+
*
|
|
378
|
+
* @param collection - The collection slug
|
|
379
|
+
* @returns True if a plugin is configured for this collection
|
|
380
|
+
*/
|
|
381
|
+
hasCollectionAdapter(collection) {
|
|
382
|
+
return this.collectionAdapters.has(collection);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Get list of collections with configured storage.
|
|
386
|
+
*
|
|
387
|
+
* @returns Array of collection slugs that have plugin storage
|
|
388
|
+
*/
|
|
389
|
+
getConfiguredCollections() {
|
|
390
|
+
return Array.from(this.collectionAdapters.keys());
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Check if any storage plugin is configured.
|
|
394
|
+
*
|
|
395
|
+
* @returns True if at least one storage plugin is registered
|
|
396
|
+
*/
|
|
397
|
+
hasPlugins() {
|
|
398
|
+
return this.plugins.size > 0;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
var storageInstance = null;
|
|
402
|
+
function initializeMediaStorage(config) {
|
|
403
|
+
storageInstance = new MediaStorage(config);
|
|
404
|
+
return storageInstance;
|
|
405
|
+
}
|
|
406
|
+
function getMediaStorage() {
|
|
407
|
+
if (!storageInstance) {
|
|
408
|
+
storageInstance = new MediaStorage();
|
|
409
|
+
}
|
|
410
|
+
return storageInstance;
|
|
411
|
+
}
|
|
412
|
+
function resetMediaStorage() {
|
|
413
|
+
storageInstance = null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// src/storage/image-processor.ts
|
|
417
|
+
var sharpModule = null;
|
|
418
|
+
async function getSharp() {
|
|
419
|
+
if (!sharpModule) {
|
|
420
|
+
sharpModule = (await import("sharp")).default;
|
|
421
|
+
}
|
|
422
|
+
return sharpModule;
|
|
423
|
+
}
|
|
424
|
+
var ImageProcessor = class {
|
|
425
|
+
/**
|
|
426
|
+
* Get image metadata without loading full image
|
|
427
|
+
*/
|
|
428
|
+
async getMetadata(buffer) {
|
|
429
|
+
const sharp = await getSharp();
|
|
430
|
+
const metadata = await sharp(buffer).metadata();
|
|
431
|
+
return {
|
|
432
|
+
width: metadata.width || 0,
|
|
433
|
+
height: metadata.height || 0,
|
|
434
|
+
format: metadata.format || "unknown",
|
|
435
|
+
size: buffer.length
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Generate thumbnail (300x300 by default, cropped to center)
|
|
440
|
+
*
|
|
441
|
+
* Uses "cover" fit to fill the entire 300x300 area while maintaining aspect ratio
|
|
442
|
+
*/
|
|
443
|
+
async generateThumbnail(buffer, size = 300) {
|
|
444
|
+
const sharp = await getSharp();
|
|
445
|
+
const processed = await sharp(buffer).resize(size, size, {
|
|
446
|
+
fit: "cover",
|
|
447
|
+
// Crop to fill entire area
|
|
448
|
+
position: "center"
|
|
449
|
+
// Crop from center
|
|
450
|
+
}).jpeg({ quality: 80, progressive: true }).toBuffer({ resolveWithObject: true });
|
|
451
|
+
return {
|
|
452
|
+
buffer: processed.data,
|
|
453
|
+
metadata: {
|
|
454
|
+
width: processed.info.width,
|
|
455
|
+
height: processed.info.height,
|
|
456
|
+
format: processed.info.format,
|
|
457
|
+
size: processed.data.length
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Optimize image (compress, convert to WebP if beneficial)
|
|
463
|
+
*
|
|
464
|
+
* Strategy:
|
|
465
|
+
* - Small images (<100KB) and already WebP: return as-is
|
|
466
|
+
* - Otherwise: convert to WebP with quality 80
|
|
467
|
+
*/
|
|
468
|
+
async optimize(buffer, quality = 80) {
|
|
469
|
+
const sharp = await getSharp();
|
|
470
|
+
const metadata = await sharp(buffer).metadata();
|
|
471
|
+
if (buffer.length < 100 * 1024 && metadata.format === "webp") {
|
|
472
|
+
return {
|
|
473
|
+
buffer,
|
|
474
|
+
metadata: {
|
|
475
|
+
width: metadata.width || 0,
|
|
476
|
+
height: metadata.height || 0,
|
|
477
|
+
format: metadata.format,
|
|
478
|
+
size: buffer.length
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
const processed = await sharp(buffer).webp({ quality, effort: 4 }).toBuffer({ resolveWithObject: true });
|
|
483
|
+
return {
|
|
484
|
+
buffer: processed.data,
|
|
485
|
+
metadata: {
|
|
486
|
+
width: processed.info.width,
|
|
487
|
+
height: processed.info.height,
|
|
488
|
+
format: "webp",
|
|
489
|
+
size: processed.data.length
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Resize image to specific dimensions
|
|
495
|
+
*
|
|
496
|
+
* @param maxWidth Maximum width (maintains aspect ratio)
|
|
497
|
+
* @param maxHeight Maximum height (maintains aspect ratio)
|
|
498
|
+
*/
|
|
499
|
+
async resize(buffer, maxWidth, maxHeight) {
|
|
500
|
+
const sharp = await getSharp();
|
|
501
|
+
const processed = await sharp(buffer).resize(maxWidth, maxHeight, {
|
|
502
|
+
fit: "inside",
|
|
503
|
+
// Fit within bounds, maintaining aspect ratio
|
|
504
|
+
withoutEnlargement: true
|
|
505
|
+
// Don't upscale small images
|
|
506
|
+
}).toBuffer({ resolveWithObject: true });
|
|
507
|
+
return {
|
|
508
|
+
buffer: processed.data,
|
|
509
|
+
metadata: {
|
|
510
|
+
width: processed.info.width,
|
|
511
|
+
height: processed.info.height,
|
|
512
|
+
format: processed.info.format,
|
|
513
|
+
size: processed.data.length
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Resize an image with focal point awareness and format conversion.
|
|
519
|
+
*
|
|
520
|
+
* When fit is 'cover' and a focal point is set, the crop anchors at that
|
|
521
|
+
* point instead of center. Supports format conversion ('auto' outputs webp
|
|
522
|
+
* for jpeg/png/tiff sources, keeps original for gif).
|
|
523
|
+
*/
|
|
524
|
+
async resizeWithFocalPoint(buffer, options) {
|
|
525
|
+
const sharp = await getSharp();
|
|
526
|
+
const quality = options.quality ?? 80;
|
|
527
|
+
const metadata = await sharp(buffer).metadata();
|
|
528
|
+
const originalFormat = metadata.format || "jpeg";
|
|
529
|
+
let outputFormat = originalFormat;
|
|
530
|
+
if (options.format && options.format !== "auto") {
|
|
531
|
+
outputFormat = options.format;
|
|
532
|
+
} else if (options.format === "auto") {
|
|
533
|
+
const convertibleFormats = ["jpeg", "png", "tiff", "jpg"];
|
|
534
|
+
if (convertibleFormats.includes(originalFormat)) {
|
|
535
|
+
outputFormat = "webp";
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
let pipeline = sharp(buffer);
|
|
539
|
+
const hasFocalPoint = options.fit === "cover" && (options.focalX !== void 0 || options.focalY !== void 0) && options.width && options.height && metadata.width && metadata.height;
|
|
540
|
+
if (hasFocalPoint) {
|
|
541
|
+
const srcW = metadata.width;
|
|
542
|
+
const srcH = metadata.height;
|
|
543
|
+
const tgtW = options.width;
|
|
544
|
+
const tgtH = options.height;
|
|
545
|
+
const fx = (options.focalX ?? 50) / 100;
|
|
546
|
+
const fy = (options.focalY ?? 50) / 100;
|
|
547
|
+
const tgtAspect = tgtW / tgtH;
|
|
548
|
+
let cropW;
|
|
549
|
+
let cropH;
|
|
550
|
+
if (srcW / srcH > tgtAspect) {
|
|
551
|
+
cropH = srcH;
|
|
552
|
+
cropW = Math.round(srcH * tgtAspect);
|
|
553
|
+
} else {
|
|
554
|
+
cropW = srcW;
|
|
555
|
+
cropH = Math.round(srcW / tgtAspect);
|
|
556
|
+
}
|
|
557
|
+
let left = Math.round(fx * srcW - cropW / 2);
|
|
558
|
+
let top = Math.round(fy * srcH - cropH / 2);
|
|
559
|
+
left = Math.max(0, Math.min(srcW - cropW, left));
|
|
560
|
+
top = Math.max(0, Math.min(srcH - cropH, top));
|
|
561
|
+
pipeline = pipeline.extract({ left, top, width: cropW, height: cropH }).resize(tgtW, tgtH, { fit: "fill" });
|
|
562
|
+
} else {
|
|
563
|
+
pipeline = pipeline.resize(
|
|
564
|
+
options.width || void 0,
|
|
565
|
+
options.height || void 0,
|
|
566
|
+
{
|
|
567
|
+
fit: options.fit,
|
|
568
|
+
position: "center",
|
|
569
|
+
withoutEnlargement: true
|
|
570
|
+
}
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
switch (outputFormat) {
|
|
574
|
+
case "webp":
|
|
575
|
+
pipeline = pipeline.webp({ quality, effort: 4 });
|
|
576
|
+
break;
|
|
577
|
+
case "jpeg":
|
|
578
|
+
case "jpg":
|
|
579
|
+
pipeline = pipeline.jpeg({ quality, progressive: true });
|
|
580
|
+
outputFormat = "jpeg";
|
|
581
|
+
break;
|
|
582
|
+
case "png":
|
|
583
|
+
pipeline = pipeline.png({ quality });
|
|
584
|
+
break;
|
|
585
|
+
case "avif":
|
|
586
|
+
pipeline = pipeline.avif({ quality });
|
|
587
|
+
break;
|
|
588
|
+
default:
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
const result = await pipeline.toBuffer({ resolveWithObject: true });
|
|
592
|
+
return {
|
|
593
|
+
buffer: result.data,
|
|
594
|
+
width: result.info.width,
|
|
595
|
+
height: result.info.height,
|
|
596
|
+
format: outputFormat,
|
|
597
|
+
size: result.data.length
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Check if buffer is a valid image
|
|
602
|
+
*/
|
|
603
|
+
async isValidImage(buffer) {
|
|
604
|
+
try {
|
|
605
|
+
const sharp = await getSharp();
|
|
606
|
+
await sharp(buffer).metadata();
|
|
607
|
+
return true;
|
|
608
|
+
} catch {
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Get image dimensions quickly (without full processing)
|
|
614
|
+
*/
|
|
615
|
+
async getDimensions(buffer) {
|
|
616
|
+
try {
|
|
617
|
+
const sharp = await getSharp();
|
|
618
|
+
const metadata = await sharp(buffer).metadata();
|
|
619
|
+
if (metadata.width && metadata.height) {
|
|
620
|
+
return { width: metadata.width, height: metadata.height };
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
} catch {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
var processorInstance = null;
|
|
629
|
+
function getImageProcessor() {
|
|
630
|
+
if (!processorInstance) {
|
|
631
|
+
processorInstance = new ImageProcessor();
|
|
632
|
+
}
|
|
633
|
+
return processorInstance;
|
|
634
|
+
}
|
|
635
|
+
function resetImageProcessor() {
|
|
636
|
+
processorInstance = null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// src/storage/image-sizes.ts
|
|
640
|
+
function getExtensionForFormat(format) {
|
|
641
|
+
switch (format) {
|
|
642
|
+
case "jpeg":
|
|
643
|
+
case "jpg":
|
|
644
|
+
return "jpg";
|
|
645
|
+
case "webp":
|
|
646
|
+
return "webp";
|
|
647
|
+
case "png":
|
|
648
|
+
return "png";
|
|
649
|
+
case "avif":
|
|
650
|
+
return "avif";
|
|
651
|
+
default:
|
|
652
|
+
return format;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function getMimeTypeForFormat(format) {
|
|
656
|
+
switch (format) {
|
|
657
|
+
case "jpeg":
|
|
658
|
+
case "jpg":
|
|
659
|
+
return "image/jpeg";
|
|
660
|
+
case "webp":
|
|
661
|
+
return "image/webp";
|
|
662
|
+
case "png":
|
|
663
|
+
return "image/png";
|
|
664
|
+
case "avif":
|
|
665
|
+
return "image/avif";
|
|
666
|
+
default:
|
|
667
|
+
return `image/${format}`;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function buildVariantFilename(originalFilename, sizeName, format) {
|
|
671
|
+
const lastDot = originalFilename.lastIndexOf(".");
|
|
672
|
+
const baseName = lastDot > 0 ? originalFilename.substring(0, lastDot) : originalFilename;
|
|
673
|
+
const ext = getExtensionForFormat(format);
|
|
674
|
+
return `${baseName}-${sizeName}.${ext}`;
|
|
675
|
+
}
|
|
676
|
+
async function generateImageSizes(originalBuffer, originalFilename, sizes, uploadFn, options = {}) {
|
|
677
|
+
if (sizes.length === 0) return {};
|
|
678
|
+
const processor = getImageProcessor();
|
|
679
|
+
const results = {};
|
|
680
|
+
for (const sizeConfig of sizes) {
|
|
681
|
+
try {
|
|
682
|
+
if (!sizeConfig.width && !sizeConfig.height) continue;
|
|
683
|
+
const resized = await processor.resizeWithFocalPoint(originalBuffer, {
|
|
684
|
+
width: sizeConfig.width ?? void 0,
|
|
685
|
+
height: sizeConfig.height ?? void 0,
|
|
686
|
+
fit: sizeConfig.fit,
|
|
687
|
+
quality: sizeConfig.quality,
|
|
688
|
+
format: sizeConfig.format,
|
|
689
|
+
focalX: options.focalX ?? void 0,
|
|
690
|
+
focalY: options.focalY ?? void 0
|
|
691
|
+
});
|
|
692
|
+
const variantFilename = buildVariantFilename(
|
|
693
|
+
originalFilename,
|
|
694
|
+
sizeConfig.name,
|
|
695
|
+
resized.format
|
|
696
|
+
);
|
|
697
|
+
const mimeType = getMimeTypeForFormat(resized.format);
|
|
698
|
+
const uploadResult = await uploadFn(resized.buffer, {
|
|
699
|
+
filename: variantFilename,
|
|
700
|
+
mimeType,
|
|
701
|
+
folder: options.folder,
|
|
702
|
+
collection: options.collection
|
|
703
|
+
});
|
|
704
|
+
results[sizeConfig.name] = {
|
|
705
|
+
url: uploadResult.url,
|
|
706
|
+
path: uploadResult.path,
|
|
707
|
+
width: resized.width,
|
|
708
|
+
height: resized.height,
|
|
709
|
+
filesize: resized.size,
|
|
710
|
+
mimeType,
|
|
711
|
+
filename: variantFilename
|
|
712
|
+
};
|
|
713
|
+
} catch (error) {
|
|
714
|
+
console.warn(
|
|
715
|
+
`[ImageSizes] Failed to generate size "${sizeConfig.name}":`,
|
|
716
|
+
error instanceof Error ? error.message : error
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return results;
|
|
721
|
+
}
|
|
722
|
+
async function deleteImageSizes(sizes, deleteFn) {
|
|
723
|
+
if (!sizes) return;
|
|
724
|
+
const paths = Object.values(sizes).map((v) => v.path).filter(Boolean);
|
|
725
|
+
await Promise.allSettled(paths.map((path) => deleteFn(path)));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/storage/retry.ts
|
|
729
|
+
var DEFAULT_OPTIONS = {
|
|
730
|
+
maxAttempts: 3,
|
|
731
|
+
baseDelayMs: 1e3,
|
|
732
|
+
maxDelayMs: 3e4,
|
|
733
|
+
backoffFactor: 2,
|
|
734
|
+
jitter: true
|
|
735
|
+
};
|
|
736
|
+
function isTransientError(error) {
|
|
737
|
+
if (!error) return false;
|
|
738
|
+
if (error instanceof Error) {
|
|
739
|
+
const message = error.message.toLowerCase();
|
|
740
|
+
const name = error.name.toLowerCase();
|
|
741
|
+
if (message.includes("timeout") || message.includes("timed out") || message.includes("etimedout") || message.includes("econnreset") || message.includes("econnrefused") || message.includes("enotfound") || message.includes("enetunreach") || message.includes("socket hang up") || message.includes("network") || name.includes("timeout") || name.includes("abort")) {
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
if (message.includes("rate limit") || message.includes("too many")) {
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const errorAny = error;
|
|
749
|
+
const metadata = errorAny.$metadata;
|
|
750
|
+
if (errorAny.statusCode || errorAny.status || metadata?.httpStatusCode) {
|
|
751
|
+
const status = errorAny.statusCode || errorAny.status || metadata?.httpStatusCode;
|
|
752
|
+
const statusNum = typeof status === "number" ? status : Number(status);
|
|
753
|
+
if (statusNum === 429 || statusNum >= 500 && statusNum < 600) {
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (errorAny.code) {
|
|
758
|
+
const code = String(errorAny.code).toLowerCase();
|
|
759
|
+
if (code.includes("timeout") || code.includes("throttl") || code.includes("serviceunavailable") || code.includes("slowdown") || code === "econnreset" || code === "epipe") {
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
function calculateDelay(attempt, options) {
|
|
766
|
+
const { baseDelayMs, maxDelayMs, backoffFactor, jitter } = options;
|
|
767
|
+
const exponentialDelay = baseDelayMs * Math.pow(backoffFactor, attempt - 1);
|
|
768
|
+
const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
|
|
769
|
+
if (jitter) {
|
|
770
|
+
const jitterAmount = cappedDelay * Math.random() * 0.5;
|
|
771
|
+
return Math.floor(cappedDelay + jitterAmount);
|
|
772
|
+
}
|
|
773
|
+
return Math.floor(cappedDelay);
|
|
774
|
+
}
|
|
775
|
+
function sleep(ms) {
|
|
776
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
777
|
+
}
|
|
778
|
+
async function withRetry(fn, options = {}) {
|
|
779
|
+
const config = { ...DEFAULT_OPTIONS, ...options };
|
|
780
|
+
const { maxAttempts, shouldRetry, onRetry } = {
|
|
781
|
+
...config,
|
|
782
|
+
shouldRetry: options.shouldRetry ?? isTransientError,
|
|
783
|
+
onRetry: options.onRetry
|
|
784
|
+
};
|
|
785
|
+
let lastError;
|
|
786
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
787
|
+
try {
|
|
788
|
+
return await fn();
|
|
789
|
+
} catch (error) {
|
|
790
|
+
lastError = error;
|
|
791
|
+
const isLastAttempt = attempt >= maxAttempts;
|
|
792
|
+
const canRetry = !isLastAttempt && shouldRetry(error, attempt);
|
|
793
|
+
if (!canRetry) {
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
const delayMs = calculateDelay(attempt, config);
|
|
797
|
+
if (onRetry) {
|
|
798
|
+
onRetry(error, attempt, delayMs);
|
|
799
|
+
}
|
|
800
|
+
await sleep(delayMs);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
throw lastError;
|
|
804
|
+
}
|
|
805
|
+
function createRetryable(fn, options = {}) {
|
|
806
|
+
return (...args) => withRetry(() => fn(...args), options);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// src/storage/svg-security.ts
|
|
810
|
+
var SVG_CSP_HEADER = "script-src 'none'; style-src 'unsafe-inline'";
|
|
811
|
+
function isSvgMimeType(mimeType) {
|
|
812
|
+
return mimeType.toLowerCase().trim() === "image/svg+xml";
|
|
813
|
+
}
|
|
814
|
+
function getSvgSecurityHeaders() {
|
|
815
|
+
return {
|
|
816
|
+
"Content-Security-Policy": SVG_CSP_HEADER,
|
|
817
|
+
"X-Content-Type-Options": "nosniff"
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// src/storage/env-config.ts
|
|
822
|
+
async function ensureEnvLoaded() {
|
|
823
|
+
if (typeof process !== "undefined" && !process.env._NEXTLY_ENV_LOADED) {
|
|
824
|
+
try {
|
|
825
|
+
const dotenv = await import("dotenv");
|
|
826
|
+
dotenv.config();
|
|
827
|
+
process.env._NEXTLY_ENV_LOADED = "true";
|
|
828
|
+
} catch {
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
async function getStorageFromEnv() {
|
|
833
|
+
await ensureEnvLoaded();
|
|
834
|
+
const blobToken = process.env.BLOB_READ_WRITE_TOKEN;
|
|
835
|
+
if (blobToken) {
|
|
836
|
+
try {
|
|
837
|
+
const pkg = "@nextlyhq/storage-vercel-blob";
|
|
838
|
+
const { vercelBlobStorage } = await import(
|
|
839
|
+
/* webpackIgnore: true */
|
|
840
|
+
pkg
|
|
841
|
+
);
|
|
842
|
+
console.log(
|
|
843
|
+
"[Nextly] Storage: Vercel Blob (auto-detected from BLOB_READ_WRITE_TOKEN)"
|
|
844
|
+
);
|
|
845
|
+
return [
|
|
846
|
+
vercelBlobStorage({ token: blobToken, collections: { media: true } })
|
|
847
|
+
];
|
|
848
|
+
} catch {
|
|
849
|
+
console.warn(
|
|
850
|
+
"[Nextly] BLOB_READ_WRITE_TOKEN set but @nextlyhq/storage-vercel-blob not installed. Run: pnpm add @nextlyhq/storage-vercel-blob"
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const s3Bucket = process.env.S3_BUCKET;
|
|
855
|
+
if (s3Bucket) {
|
|
856
|
+
const region = process.env.S3_REGION;
|
|
857
|
+
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
|
858
|
+
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
|
859
|
+
if (!region || !accessKeyId || !secretAccessKey) {
|
|
860
|
+
const missing = [];
|
|
861
|
+
if (!region) missing.push("S3_REGION");
|
|
862
|
+
if (!accessKeyId) missing.push("AWS_ACCESS_KEY_ID");
|
|
863
|
+
if (!secretAccessKey) missing.push("AWS_SECRET_ACCESS_KEY");
|
|
864
|
+
console.warn(
|
|
865
|
+
`[Nextly] S3_BUCKET set but missing: ${missing.join(", ")}. Falling back to local storage.`
|
|
866
|
+
);
|
|
867
|
+
} else {
|
|
868
|
+
try {
|
|
869
|
+
const pkg = "@nextlyhq/storage-s3";
|
|
870
|
+
const { s3Storage } = await import(
|
|
871
|
+
/* webpackIgnore: true */
|
|
872
|
+
pkg
|
|
873
|
+
);
|
|
874
|
+
console.log("[Nextly] Storage: S3 (auto-detected from S3_BUCKET)");
|
|
875
|
+
return [
|
|
876
|
+
s3Storage({
|
|
877
|
+
bucket: s3Bucket,
|
|
878
|
+
region,
|
|
879
|
+
accessKeyId,
|
|
880
|
+
secretAccessKey,
|
|
881
|
+
endpoint: process.env.S3_ENDPOINT,
|
|
882
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
883
|
+
publicUrl: process.env.S3_PUBLIC_URL,
|
|
884
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
885
|
+
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
|
886
|
+
collections: { media: true }
|
|
887
|
+
})
|
|
888
|
+
];
|
|
889
|
+
} catch {
|
|
890
|
+
console.warn(
|
|
891
|
+
"[Nextly] S3_BUCKET set but @nextlyhq/storage-s3 not installed. Run: pnpm add @nextlyhq/storage-s3"
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const uploadthingToken = process.env.UPLOADTHING_TOKEN;
|
|
897
|
+
if (uploadthingToken) {
|
|
898
|
+
try {
|
|
899
|
+
const pkg = "@nextlyhq/storage-uploadthing";
|
|
900
|
+
const { uploadthingStorage } = await import(
|
|
901
|
+
/* webpackIgnore: true */
|
|
902
|
+
pkg
|
|
903
|
+
);
|
|
904
|
+
console.log(
|
|
905
|
+
"[Nextly] Storage: Uploadthing (auto-detected from UPLOADTHING_TOKEN)"
|
|
906
|
+
);
|
|
907
|
+
return [
|
|
908
|
+
uploadthingStorage({
|
|
909
|
+
token: uploadthingToken,
|
|
910
|
+
collections: { media: true }
|
|
911
|
+
})
|
|
912
|
+
];
|
|
913
|
+
} catch {
|
|
914
|
+
console.warn(
|
|
915
|
+
"[Nextly] UPLOADTHING_TOKEN set but @nextlyhq/storage-uploadthing not installed. Run: pnpm add @nextlyhq/storage-uploadthing"
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const { localStorage: localStorage2 } = await import("./local-plugin-PTET4NAT.mjs");
|
|
920
|
+
console.log(
|
|
921
|
+
"[Nextly] Storage: Local disk (no cloud env vars detected, using ./public/uploads)"
|
|
922
|
+
);
|
|
923
|
+
return [localStorage2({ collections: { media: true } })];
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
export {
|
|
927
|
+
MediaStorage,
|
|
928
|
+
initializeMediaStorage,
|
|
929
|
+
getMediaStorage,
|
|
930
|
+
resetMediaStorage,
|
|
931
|
+
ImageProcessor,
|
|
932
|
+
getImageProcessor,
|
|
933
|
+
resetImageProcessor,
|
|
934
|
+
generateImageSizes,
|
|
935
|
+
deleteImageSizes,
|
|
936
|
+
isTransientError,
|
|
937
|
+
withRetry,
|
|
938
|
+
createRetryable,
|
|
939
|
+
SVG_CSP_HEADER,
|
|
940
|
+
isSvgMimeType,
|
|
941
|
+
getSvgSecurityHeaders,
|
|
942
|
+
getStorageFromEnv
|
|
943
|
+
};
|