@nextlyhq/storage-uploadthing 0.0.1
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 +75 -0
- package/dist/chunk-3GDQP6AS.mjs +14 -0
- package/dist/chunk-3GDQP6AS.mjs.map +1 -0
- package/dist/chunk-CV4XIHXE.mjs +255 -0
- package/dist/chunk-CV4XIHXE.mjs.map +1 -0
- package/dist/index.cjs +242 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +475 -0
- package/dist/index.d.ts +475 -0
- package/dist/index.mjs +142 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lib-D34FQA4J.mjs +6285 -0
- package/dist/lib-D34FQA4J.mjs.map +1 -0
- package/dist/local-plugin-PTET4NAT-P6OHYYH6.mjs +4 -0
- package/dist/local-plugin-PTET4NAT-P6OHYYH6.mjs.map +1 -0
- package/dist/main-GMP6CIN5.mjs +400 -0
- package/dist/main-GMP6CIN5.mjs.map +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../nextly/dist/chunk-G2AA4QLC.mjs","../../nextly/dist/chunk-7P6ASYW6.mjs","../../nextly/dist/chunk-EGXBZCGC.mjs","../../nextly/dist/storage/index.mjs","../src/adapter.ts","../src/plugin.ts"],"names":["UTApi"],"mappings":"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","file":"index.cjs","sourcesContent":["// src/storage/adapters/local-adapter.ts\nimport * as fs from \"fs/promises\";\nimport * as path from \"path\";\n\n// src/storage/adapters/base-adapter.ts\nvar BaseStorageAdapter = class {\n /**\n * Get adapter info including capabilities.\n *\n * Default implementation that auto-detects capabilities by checking\n * if getSignedUrl and getPresignedUploadUrl methods are implemented.\n * Override in subclasses for more accurate capability reporting.\n *\n * @returns Adapter info with type, name, and capability flags\n */\n getInfo() {\n const hasSignedUrls = \"getSignedUrl\" in this && typeof this.getSignedUrl === \"function\";\n const hasClientUploads = \"getPresignedUploadUrl\" in this && typeof this.getPresignedUploadUrl === \"function\";\n return {\n type: this.getType(),\n name: this.constructor.name,\n supportsSignedUrls: hasSignedUrls,\n supportsClientUploads: hasClientUploads\n };\n }\n /**\n * Sanitize filename to prevent directory traversal and storage issues.\n *\n * Security measures:\n * - Remove path separators (/, \\)\n * - Keep only basename (no directories)\n * - Replace problematic characters with hyphens\n * - Preserve alphanumeric, dots, underscores, hyphens\n *\n * @param filename - Original filename to sanitize\n * @returns Sanitized filename safe for storage\n *\n * @example\n * ```typescript\n * this.sanitizeFilename('../../../etc/passwd') // 'passwd'\n * this.sanitizeFilename('my file (1).jpg') // 'my-file--1-.jpg'\n * this.sanitizeFilename('photo.jpg') // 'photo.jpg'\n * ```\n */\n sanitizeFilename(filename) {\n const basename = filename.split(/[/\\\\]/).pop() || filename;\n return basename.replace(/[^a-zA-Z0-9._-]/g, \"-\");\n }\n /**\n * Generate a unique storage key with date-based prefix.\n *\n * Creates keys in format: {folder}/{year}/{month}/{uuid}-{sanitized-filename}\n * This provides:\n * - Unique keys via UUID to prevent collisions\n * - Date-based organization for easier management\n * - Readable filenames for debugging\n *\n * @param filename - Original filename (will be sanitized)\n * @param folder - Optional folder/prefix for organizing uploads\n * @returns Generated storage key\n *\n * @example\n * ```typescript\n * this.generateKey('photo.jpg')\n * // 'uploads/2026/01/abc-123-...-photo.jpg'\n *\n * this.generateKey('doc.pdf', 'documents')\n * // 'documents/2026/01/abc-123-...-doc.pdf'\n * ```\n */\n generateKey(filename, folder) {\n const sanitized = this.sanitizeFilename(filename);\n const uuid = crypto.randomUUID();\n const date = /* @__PURE__ */ new Date();\n const year = date.getFullYear();\n const month = String(date.getMonth() + 1).padStart(2, \"0\");\n const prefix = folder ? `${folder}/${year}/${month}` : `uploads/${year}/${month}`;\n return `${prefix}/${uuid}-${sanitized}`;\n }\n};\n\n// src/storage/adapters/local-adapter.ts\nvar gitignoreUpdated = false;\nvar LocalStorageAdapter = class extends BaseStorageAdapter {\n basePath;\n baseUrl;\n constructor(config) {\n super();\n this.basePath = path.resolve(config.basePath);\n this.baseUrl = config.baseUrl.replace(/\\/+$/, \"\");\n }\n /**\n * Upload file to local disk.\n * Creates directories as needed and writes the file buffer.\n */\n async upload(buffer, options) {\n const key = this.generateKey(options.filename, options.folder);\n const fullPath = this.resolveAndValidate(key);\n await fs.mkdir(path.dirname(fullPath), { recursive: true });\n await fs.writeFile(fullPath, buffer);\n await this.ensureGitignore();\n return {\n url: this.getPublicUrl(key),\n path: key\n };\n }\n /**\n * Delete file from local disk.\n * Silently succeeds if the file doesn't exist.\n */\n async delete(filePath) {\n let fullPath;\n try {\n fullPath = this.resolveAndValidate(filePath);\n } catch {\n return;\n }\n try {\n await fs.unlink(fullPath);\n } catch (err) {\n if (err.code !== \"ENOENT\") {\n throw err;\n }\n }\n }\n /**\n * Bulk delete files from local disk.\n * Uses parallel unlinks with Promise.allSettled for best performance.\n */\n async bulkDelete(filePaths) {\n const results = await Promise.allSettled(\n filePaths.map(async (filePath) => {\n await this.delete(filePath);\n return filePath;\n })\n );\n const successful = [];\n const failed = [];\n results.forEach((result, index) => {\n if (result.status === \"fulfilled\") {\n successful.push(filePaths[index]);\n } else {\n failed.push({\n filePath: filePaths[index],\n error: result.reason?.message || \"Unknown error\"\n });\n }\n });\n return { successful, failed };\n }\n /**\n * Check if file exists on local disk.\n */\n async exists(filePath) {\n try {\n const fullPath = this.resolveAndValidate(filePath);\n await fs.access(fullPath);\n return true;\n } catch {\n return false;\n }\n }\n /**\n * Get public URL for a file.\n * Returns baseUrl + relative path for Next.js static file serving.\n */\n getPublicUrl(filePath) {\n const cleanPath = filePath.replace(/^\\/+/, \"\");\n return `${this.baseUrl}/${cleanPath}`;\n }\n /**\n * Get storage type identifier.\n */\n getType() {\n return \"local\";\n }\n /**\n * Read file contents from local disk.\n * Returns the file buffer, or null if file not found.\n */\n async read(filePath) {\n try {\n const fullPath = this.resolveAndValidate(filePath);\n return await fs.readFile(fullPath);\n } catch {\n return null;\n }\n }\n // ============================================================\n // Private Helpers\n // ============================================================\n /**\n * Resolve a relative file path to an absolute path within basePath.\n * Throws if the resolved path would escape basePath (path traversal attack).\n */\n resolveAndValidate(filePath) {\n const sanitized = filePath.replace(/^[/\\\\]+/, \"\").replace(/\\.\\.[/\\\\]/g, \"\");\n const fullPath = path.resolve(this.basePath, sanitized);\n if (!fullPath.startsWith(this.basePath)) {\n throw new Error(\n `Path traversal detected: ${filePath} resolves outside of storage directory`\n );\n }\n return fullPath;\n }\n /**\n * Auto-add the uploads directory to .gitignore on first upload.\n * Prevents accidentally committing uploaded files to git.\n */\n async ensureGitignore() {\n if (gitignoreUpdated) return;\n gitignoreUpdated = true;\n try {\n const projectRoot = path.resolve(this.basePath, \"..\", \"..\");\n const gitignorePath = path.join(projectRoot, \".gitignore\");\n let content = \"\";\n try {\n content = await fs.readFile(gitignorePath, \"utf-8\");\n } catch {\n }\n const uploadsDirRelative = path.relative(projectRoot, this.basePath);\n const ignorePattern = uploadsDirRelative + \"/\";\n if (!content.includes(ignorePattern)) {\n const newEntry = `\n# Nextly local uploads (auto-added)\n${ignorePattern}\n`;\n await fs.writeFile(gitignorePath, content + newEntry, \"utf-8\");\n }\n } catch {\n }\n }\n};\n\n// src/storage/adapters/local-plugin.ts\nfunction localStorage(config) {\n if (config.enabled === false) {\n return {\n name: \"local-storage\",\n type: \"local\",\n collections: {},\n adapter: null\n };\n }\n const adapter = new LocalStorageAdapter({\n basePath: config.basePath ?? \"./public/uploads\",\n baseUrl: config.baseUrl ?? \"/uploads\"\n });\n return {\n name: \"local-storage\",\n type: \"local\",\n collections: config.collections,\n adapter\n // Local storage doesn't support presigned URLs or signed downloads\n };\n}\n\nexport {\n BaseStorageAdapter,\n LocalStorageAdapter,\n localStorage\n};\n","var __defProp = Object.defineProperty;\nvar __export = (target, all) => {\n for (var name in all)\n __defProp(target, name, { get: all[name], enumerable: true });\n};\n\nexport {\n __export\n};\n","import {\n LocalStorageAdapter\n} from \"./chunk-G2AA4QLC.mjs\";\n\n// src/storage/storage.ts\nvar MediaStorage = class {\n /** Registered storage plugins by name */\n plugins = /* @__PURE__ */ new Map();\n /** Storage adapter per collection */\n collectionAdapters = /* @__PURE__ */ new Map();\n /** Storage configuration per collection */\n collectionConfigs = /* @__PURE__ */ new Map();\n /** Local storage adapter (always available as fallback) */\n localAdapter;\n /**\n * Create a new MediaStorage instance.\n *\n * @param config - Optional configuration for storage initialization\n */\n constructor(config) {\n this.localAdapter = new LocalStorageAdapter({\n basePath: config?.local?.uploadDir ?? \"./public/uploads\",\n baseUrl: config?.local?.publicPath ?? \"/uploads\"\n });\n if (config?.plugins) {\n for (const plugin of config.plugins) {\n this.registerPlugin(plugin);\n }\n }\n }\n // ============================================================\n // Plugin Registration\n // ============================================================\n /**\n * Register a storage plugin.\n *\n * Plugins provide storage adapters for specific collections.\n * When a collection is registered with a plugin, uploads for that\n * collection will be routed to the plugin's adapter.\n *\n * @param plugin - The storage plugin to register\n *\n * @example\n * ```typescript\n * const storage = new MediaStorage();\n *\n * storage.registerPlugin(s3Storage({\n * bucket: 'my-bucket',\n * region: 'us-east-1',\n * collections: {\n * media: true,\n * 'private-docs': { prefix: 'private/' }\n * }\n * }));\n * ```\n */\n registerPlugin(plugin) {\n if (!plugin.adapter) {\n return;\n }\n this.plugins.set(plugin.name, plugin);\n for (const [collectionSlug, config] of Object.entries(plugin.collections)) {\n const collectionConfig = typeof config === \"boolean\" ? {} : config;\n this.collectionAdapters.set(collectionSlug, plugin.adapter);\n this.collectionConfigs.set(collectionSlug, collectionConfig);\n }\n }\n // ============================================================\n // Adapter Resolution\n // ============================================================\n /**\n * Check if any storage adapter is configured.\n *\n * @returns True if at least one storage plugin is registered\n */\n hasAdapter() {\n return true;\n }\n /**\n * Get the storage adapter if available, or null if not configured.\n *\n * Unlike getAdapter(), this method does not throw an error if no storage\n * is configured. Useful for optional storage scenarios.\n *\n * @param collection - The collection slug (optional)\n * @returns The storage adapter instance, or null if not configured\n */\n getAdapterOrNull(collection) {\n if (collection && this.collectionAdapters.has(collection)) {\n return this.collectionAdapters.get(collection);\n }\n return this.localAdapter;\n }\n /**\n * Get the storage adapter for a specific collection.\n *\n * If a plugin is configured for the collection, returns the plugin's adapter.\n * Otherwise, returns the default adapter (first registered plugin).\n *\n * @param collection - The collection slug (optional)\n * @returns The appropriate storage adapter\n * @throws Error if no storage plugin is configured\n */\n getAdapterForCollection(collection) {\n if (collection && this.collectionAdapters.has(collection)) {\n return this.collectionAdapters.get(collection);\n }\n return this.localAdapter;\n }\n /**\n * Get configuration for a specific collection.\n *\n * @param collection - The collection slug\n * @returns The collection's storage configuration, or undefined\n */\n getCollectionConfig(collection) {\n return this.collectionConfigs.get(collection);\n }\n // ============================================================\n // Core Storage Operations\n // ============================================================\n /**\n * Upload file to appropriate storage based on collection.\n *\n * Routes the upload to the correct adapter based on collection\n * configuration. Applies collection-specific prefix if configured.\n *\n * @param buffer - The file buffer to upload\n * @param options - Upload options including filename, mimeType, collection\n * @returns Upload result with URL and path\n *\n * @example\n * ```typescript\n * const result = await storage.upload(buffer, {\n * filename: 'photo.jpg',\n * mimeType: 'image/jpeg',\n * collection: 'media'\n * });\n * console.log(result.url); // Public URL\n * console.log(result.path); // Storage path for deletion\n * ```\n */\n async upload(buffer, options) {\n const adapter = this.getAdapterForCollection(options.collection);\n const config = options.collection ? this.getCollectionConfig(options.collection) : void 0;\n const uploadOptions = { ...options };\n if (config?.prefix) {\n uploadOptions.folder = config.prefix + (options.folder || \"\");\n }\n return adapter.upload(buffer, uploadOptions);\n }\n /**\n * Delete file from storage.\n *\n * Determines correct adapter based on collection.\n *\n * @param filePath - The storage path/key of the file\n * @param collection - The collection slug (optional, for routing)\n */\n async delete(filePath, collection) {\n const adapter = this.getAdapterForCollection(collection);\n return adapter.delete(filePath);\n }\n /**\n * Bulk delete files from storage.\n * Uses adapter's native bulkDelete if available, otherwise falls back to\n * sequential individual deletes in chunks of 10.\n */\n async bulkDelete(filePaths, collection) {\n const adapter = this.getAdapterForCollection(collection);\n if (adapter.bulkDelete) {\n return adapter.bulkDelete(filePaths);\n }\n const successful = [];\n const failed = [];\n const chunkSize = 10;\n for (let i = 0; i < filePaths.length; i += chunkSize) {\n const chunk = filePaths.slice(i, i + chunkSize);\n const results = await Promise.allSettled(\n chunk.map((fp) => adapter.delete(fp))\n );\n results.forEach((result, idx) => {\n const fp = chunk[idx];\n if (result.status === \"fulfilled\") {\n successful.push(fp);\n } else {\n failed.push({\n filePath: fp,\n error: result.reason instanceof Error ? result.reason.message : String(result.reason)\n });\n }\n });\n }\n return { successful, failed };\n }\n /**\n * Check if file exists in storage.\n *\n * @param filePath - The storage path/key to check\n * @param collection - The collection slug (optional, for routing)\n * @returns True if file exists\n */\n async exists(filePath, collection) {\n const adapter = this.getAdapterForCollection(collection);\n return adapter.exists(filePath);\n }\n /**\n * Get public URL for file.\n *\n * @param filePath - The storage path/key\n * @param collection - The collection slug (optional, for routing)\n * @returns Public URL to access the file\n */\n getPublicUrl(filePath, collection) {\n const adapter = this.getAdapterForCollection(collection);\n return adapter.getPublicUrl(filePath);\n }\n /**\n * Get storage type for a collection.\n *\n * @param collection - The collection slug (optional)\n * @returns Storage type identifier ('s3', 'vercel-blob')\n */\n getStorageType(collection) {\n const adapter = this.getAdapterForCollection(collection);\n return adapter.getType();\n }\n // ============================================================\n // Client Upload Support\n // ============================================================\n /**\n * Check if collection supports client-side uploads.\n *\n * Client-side uploads allow direct-to-storage uploads, bypassing\n * the server. This is essential for serverless platforms with\n * request body size limits (e.g., Vercel's 4.5MB limit).\n *\n * @param collection - The collection slug\n * @returns True if client uploads are enabled and supported\n */\n supportsClientUploads(collection) {\n const config = this.getCollectionConfig(collection);\n if (!config?.clientUploads) return false;\n const adapter = this.getAdapterForCollection(collection);\n const info = adapter.getInfo?.();\n return info?.supportsClientUploads ?? false;\n }\n /**\n * Get client upload URL for direct-to-storage uploads.\n *\n * Generates a pre-signed URL that allows the client to upload\n * directly to the storage backend, bypassing the server.\n *\n * Only available if:\n * 1. Collection is configured with `clientUploads: true`\n * 2. The storage adapter supports client uploads\n *\n * @param filename - Original filename\n * @param mimeType - File MIME type\n * @param collection - Collection slug\n * @returns Client upload data, or null if not supported\n *\n * @example\n * ```typescript\n * // Server-side: generate upload URL\n * const uploadData = await storage.getClientUploadUrl(\n * 'photo.jpg',\n * 'image/jpeg',\n * 'media'\n * );\n *\n * // Client-side: upload directly to storage\n * await fetch(uploadData.uploadUrl, {\n * method: uploadData.method,\n * headers: uploadData.headers,\n * body: file\n * });\n * ```\n */\n async getClientUploadUrl(filename, mimeType, collection) {\n if (!this.supportsClientUploads(collection)) {\n return null;\n }\n for (const plugin of this.plugins.values()) {\n if (collection in plugin.collections && plugin.getClientUploadUrl) {\n return plugin.getClientUploadUrl(filename, mimeType, collection);\n }\n }\n return null;\n }\n // ============================================================\n // Signed Download Support\n // ============================================================\n /**\n * Check if collection supports signed download URLs.\n *\n * @param collection - The collection slug\n * @returns True if signed downloads are enabled and supported\n */\n supportsSignedDownloads(collection) {\n const config = this.getCollectionConfig(collection);\n if (!config?.signedDownloads) return false;\n const adapter = this.getAdapterForCollection(collection);\n const info = adapter.getInfo?.();\n return info?.supportsSignedUrls ?? false;\n }\n /**\n * Get signed download URL for secure file access.\n *\n * Generates a time-limited signed URL for accessing files in\n * private storage buckets. Only works if:\n * 1. Collection is configured with `signedDownloads: true`\n * 2. The storage adapter supports signed URLs\n *\n * @param filePath - Storage path/key of the file\n * @param collection - Collection slug\n * @param expiresIn - URL expiry time in seconds (optional)\n * @returns Signed URL, or null if not supported\n *\n * @example\n * ```typescript\n * const signedUrl = await storage.getSignedDownloadUrl(\n * 'private/doc.pdf',\n * 'private-docs',\n * 3600 // 1 hour\n * );\n * ```\n */\n async getSignedDownloadUrl(filePath, collection, expiresIn) {\n if (!this.supportsSignedDownloads(collection)) {\n return null;\n }\n const config = this.getCollectionConfig(collection);\n for (const plugin of this.plugins.values()) {\n if (collection in plugin.collections && plugin.getSignedDownloadUrl) {\n return plugin.getSignedDownloadUrl(\n filePath,\n expiresIn ?? config?.signedUrlExpiresIn ?? 3600\n );\n }\n }\n return null;\n }\n // ============================================================\n // Accessor Methods\n // ============================================================\n /**\n * Get the default storage adapter.\n *\n * @returns The default storage adapter (first registered plugin)\n * @throws Error if no storage plugin is configured\n */\n getDefaultAdapter() {\n return this.localAdapter;\n }\n /**\n * Get list of registered plugins.\n *\n * @returns Array of registered storage plugins\n */\n getPlugins() {\n return Array.from(this.plugins.values());\n }\n /**\n * Get the underlying storage adapter for a collection.\n *\n * Useful for passing to registerServices() which requires IStorageAdapter.\n *\n * @param collection - The collection slug (optional)\n * @returns The storage adapter instance\n */\n getAdapter(collection) {\n return this.getAdapterForCollection(collection);\n }\n /**\n * Check if a collection has a configured storage adapter.\n *\n * @param collection - The collection slug\n * @returns True if a plugin is configured for this collection\n */\n hasCollectionAdapter(collection) {\n return this.collectionAdapters.has(collection);\n }\n /**\n * Get list of collections with configured storage.\n *\n * @returns Array of collection slugs that have plugin storage\n */\n getConfiguredCollections() {\n return Array.from(this.collectionAdapters.keys());\n }\n /**\n * Check if any storage plugin is configured.\n *\n * @returns True if at least one storage plugin is registered\n */\n hasPlugins() {\n return this.plugins.size > 0;\n }\n};\nvar storageInstance = null;\nfunction initializeMediaStorage(config) {\n storageInstance = new MediaStorage(config);\n return storageInstance;\n}\nfunction getMediaStorage() {\n if (!storageInstance) {\n storageInstance = new MediaStorage();\n }\n return storageInstance;\n}\nfunction resetMediaStorage() {\n storageInstance = null;\n}\n\n// src/storage/image-processor.ts\nvar sharpModule = null;\nasync function getSharp() {\n if (!sharpModule) {\n sharpModule = (await import(\"sharp\")).default;\n }\n return sharpModule;\n}\nvar ImageProcessor = class {\n /**\n * Get image metadata without loading full image\n */\n async getMetadata(buffer) {\n const sharp = await getSharp();\n const metadata = await sharp(buffer).metadata();\n return {\n width: metadata.width || 0,\n height: metadata.height || 0,\n format: metadata.format || \"unknown\",\n size: buffer.length\n };\n }\n /**\n * Generate thumbnail (300x300 by default, cropped to center)\n *\n * Uses \"cover\" fit to fill the entire 300x300 area while maintaining aspect ratio\n */\n async generateThumbnail(buffer, size = 300) {\n const sharp = await getSharp();\n const processed = await sharp(buffer).resize(size, size, {\n fit: \"cover\",\n // Crop to fill entire area\n position: \"center\"\n // Crop from center\n }).jpeg({ quality: 80, progressive: true }).toBuffer({ resolveWithObject: true });\n return {\n buffer: processed.data,\n metadata: {\n width: processed.info.width,\n height: processed.info.height,\n format: processed.info.format,\n size: processed.data.length\n }\n };\n }\n /**\n * Optimize image (compress, convert to WebP if beneficial)\n *\n * Strategy:\n * - Small images (<100KB) and already WebP: return as-is\n * - Otherwise: convert to WebP with quality 80\n */\n async optimize(buffer, quality = 80) {\n const sharp = await getSharp();\n const metadata = await sharp(buffer).metadata();\n if (buffer.length < 100 * 1024 && metadata.format === \"webp\") {\n return {\n buffer,\n metadata: {\n width: metadata.width || 0,\n height: metadata.height || 0,\n format: metadata.format,\n size: buffer.length\n }\n };\n }\n const processed = await sharp(buffer).webp({ quality, effort: 4 }).toBuffer({ resolveWithObject: true });\n return {\n buffer: processed.data,\n metadata: {\n width: processed.info.width,\n height: processed.info.height,\n format: \"webp\",\n size: processed.data.length\n }\n };\n }\n /**\n * Resize image to specific dimensions\n *\n * @param maxWidth Maximum width (maintains aspect ratio)\n * @param maxHeight Maximum height (maintains aspect ratio)\n */\n async resize(buffer, maxWidth, maxHeight) {\n const sharp = await getSharp();\n const processed = await sharp(buffer).resize(maxWidth, maxHeight, {\n fit: \"inside\",\n // Fit within bounds, maintaining aspect ratio\n withoutEnlargement: true\n // Don't upscale small images\n }).toBuffer({ resolveWithObject: true });\n return {\n buffer: processed.data,\n metadata: {\n width: processed.info.width,\n height: processed.info.height,\n format: processed.info.format,\n size: processed.data.length\n }\n };\n }\n /**\n * Resize an image with focal point awareness and format conversion.\n *\n * When fit is 'cover' and a focal point is set, the crop anchors at that\n * point instead of center. Supports format conversion ('auto' outputs webp\n * for jpeg/png/tiff sources, keeps original for gif).\n */\n async resizeWithFocalPoint(buffer, options) {\n const sharp = await getSharp();\n const quality = options.quality ?? 80;\n const metadata = await sharp(buffer).metadata();\n const originalFormat = metadata.format || \"jpeg\";\n let outputFormat = originalFormat;\n if (options.format && options.format !== \"auto\") {\n outputFormat = options.format;\n } else if (options.format === \"auto\") {\n const convertibleFormats = [\"jpeg\", \"png\", \"tiff\", \"jpg\"];\n if (convertibleFormats.includes(originalFormat)) {\n outputFormat = \"webp\";\n }\n }\n let pipeline = sharp(buffer);\n const hasFocalPoint = options.fit === \"cover\" && (options.focalX !== void 0 || options.focalY !== void 0) && options.width && options.height && metadata.width && metadata.height;\n if (hasFocalPoint) {\n const srcW = metadata.width;\n const srcH = metadata.height;\n const tgtW = options.width;\n const tgtH = options.height;\n const fx = (options.focalX ?? 50) / 100;\n const fy = (options.focalY ?? 50) / 100;\n const tgtAspect = tgtW / tgtH;\n let cropW;\n let cropH;\n if (srcW / srcH > tgtAspect) {\n cropH = srcH;\n cropW = Math.round(srcH * tgtAspect);\n } else {\n cropW = srcW;\n cropH = Math.round(srcW / tgtAspect);\n }\n let left = Math.round(fx * srcW - cropW / 2);\n let top = Math.round(fy * srcH - cropH / 2);\n left = Math.max(0, Math.min(srcW - cropW, left));\n top = Math.max(0, Math.min(srcH - cropH, top));\n pipeline = pipeline.extract({ left, top, width: cropW, height: cropH }).resize(tgtW, tgtH, { fit: \"fill\" });\n } else {\n pipeline = pipeline.resize(\n options.width || void 0,\n options.height || void 0,\n {\n fit: options.fit,\n position: \"center\",\n withoutEnlargement: true\n }\n );\n }\n switch (outputFormat) {\n case \"webp\":\n pipeline = pipeline.webp({ quality, effort: 4 });\n break;\n case \"jpeg\":\n case \"jpg\":\n pipeline = pipeline.jpeg({ quality, progressive: true });\n outputFormat = \"jpeg\";\n break;\n case \"png\":\n pipeline = pipeline.png({ quality });\n break;\n case \"avif\":\n pipeline = pipeline.avif({ quality });\n break;\n default:\n break;\n }\n const result = await pipeline.toBuffer({ resolveWithObject: true });\n return {\n buffer: result.data,\n width: result.info.width,\n height: result.info.height,\n format: outputFormat,\n size: result.data.length\n };\n }\n /**\n * Check if buffer is a valid image\n */\n async isValidImage(buffer) {\n try {\n const sharp = await getSharp();\n await sharp(buffer).metadata();\n return true;\n } catch {\n return false;\n }\n }\n /**\n * Get image dimensions quickly (without full processing)\n */\n async getDimensions(buffer) {\n try {\n const sharp = await getSharp();\n const metadata = await sharp(buffer).metadata();\n if (metadata.width && metadata.height) {\n return { width: metadata.width, height: metadata.height };\n }\n return null;\n } catch {\n return null;\n }\n }\n};\nvar processorInstance = null;\nfunction getImageProcessor() {\n if (!processorInstance) {\n processorInstance = new ImageProcessor();\n }\n return processorInstance;\n}\nfunction resetImageProcessor() {\n processorInstance = null;\n}\n\n// src/storage/image-sizes.ts\nfunction getExtensionForFormat(format) {\n switch (format) {\n case \"jpeg\":\n case \"jpg\":\n return \"jpg\";\n case \"webp\":\n return \"webp\";\n case \"png\":\n return \"png\";\n case \"avif\":\n return \"avif\";\n default:\n return format;\n }\n}\nfunction getMimeTypeForFormat(format) {\n switch (format) {\n case \"jpeg\":\n case \"jpg\":\n return \"image/jpeg\";\n case \"webp\":\n return \"image/webp\";\n case \"png\":\n return \"image/png\";\n case \"avif\":\n return \"image/avif\";\n default:\n return `image/${format}`;\n }\n}\nfunction buildVariantFilename(originalFilename, sizeName, format) {\n const lastDot = originalFilename.lastIndexOf(\".\");\n const baseName = lastDot > 0 ? originalFilename.substring(0, lastDot) : originalFilename;\n const ext = getExtensionForFormat(format);\n return `${baseName}-${sizeName}.${ext}`;\n}\nasync function generateImageSizes(originalBuffer, originalFilename, sizes, uploadFn, options = {}) {\n if (sizes.length === 0) return {};\n const processor = getImageProcessor();\n const results = {};\n for (const sizeConfig of sizes) {\n try {\n if (!sizeConfig.width && !sizeConfig.height) continue;\n const resized = await processor.resizeWithFocalPoint(originalBuffer, {\n width: sizeConfig.width ?? void 0,\n height: sizeConfig.height ?? void 0,\n fit: sizeConfig.fit,\n quality: sizeConfig.quality,\n format: sizeConfig.format,\n focalX: options.focalX ?? void 0,\n focalY: options.focalY ?? void 0\n });\n const variantFilename = buildVariantFilename(\n originalFilename,\n sizeConfig.name,\n resized.format\n );\n const mimeType = getMimeTypeForFormat(resized.format);\n const uploadResult = await uploadFn(resized.buffer, {\n filename: variantFilename,\n mimeType,\n folder: options.folder,\n collection: options.collection\n });\n results[sizeConfig.name] = {\n url: uploadResult.url,\n path: uploadResult.path,\n width: resized.width,\n height: resized.height,\n filesize: resized.size,\n mimeType,\n filename: variantFilename\n };\n } catch (error) {\n console.warn(\n `[ImageSizes] Failed to generate size \"${sizeConfig.name}\":`,\n error instanceof Error ? error.message : error\n );\n }\n }\n return results;\n}\nasync function deleteImageSizes(sizes, deleteFn) {\n if (!sizes) return;\n const paths = Object.values(sizes).map((v) => v.path).filter(Boolean);\n await Promise.allSettled(paths.map((path) => deleteFn(path)));\n}\n\n// src/storage/retry.ts\nvar DEFAULT_OPTIONS = {\n maxAttempts: 3,\n baseDelayMs: 1e3,\n maxDelayMs: 3e4,\n backoffFactor: 2,\n jitter: true\n};\nfunction isTransientError(error) {\n if (!error) return false;\n if (error instanceof Error) {\n const message = error.message.toLowerCase();\n const name = error.name.toLowerCase();\n 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\")) {\n return true;\n }\n if (message.includes(\"rate limit\") || message.includes(\"too many\")) {\n return true;\n }\n }\n const errorAny = error;\n const metadata = errorAny.$metadata;\n if (errorAny.statusCode || errorAny.status || metadata?.httpStatusCode) {\n const status = errorAny.statusCode || errorAny.status || metadata?.httpStatusCode;\n const statusNum = typeof status === \"number\" ? status : Number(status);\n if (statusNum === 429 || statusNum >= 500 && statusNum < 600) {\n return true;\n }\n }\n if (errorAny.code) {\n const code = String(errorAny.code).toLowerCase();\n if (code.includes(\"timeout\") || code.includes(\"throttl\") || code.includes(\"serviceunavailable\") || code.includes(\"slowdown\") || code === \"econnreset\" || code === \"epipe\") {\n return true;\n }\n }\n return false;\n}\nfunction calculateDelay(attempt, options) {\n const { baseDelayMs, maxDelayMs, backoffFactor, jitter } = options;\n const exponentialDelay = baseDelayMs * Math.pow(backoffFactor, attempt - 1);\n const cappedDelay = Math.min(exponentialDelay, maxDelayMs);\n if (jitter) {\n const jitterAmount = cappedDelay * Math.random() * 0.5;\n return Math.floor(cappedDelay + jitterAmount);\n }\n return Math.floor(cappedDelay);\n}\nfunction sleep(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\nasync function withRetry(fn, options = {}) {\n const config = { ...DEFAULT_OPTIONS, ...options };\n const { maxAttempts, shouldRetry, onRetry } = {\n ...config,\n shouldRetry: options.shouldRetry ?? isTransientError,\n onRetry: options.onRetry\n };\n let lastError;\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n return await fn();\n } catch (error) {\n lastError = error;\n const isLastAttempt = attempt >= maxAttempts;\n const canRetry = !isLastAttempt && shouldRetry(error, attempt);\n if (!canRetry) {\n throw error;\n }\n const delayMs = calculateDelay(attempt, config);\n if (onRetry) {\n onRetry(error, attempt, delayMs);\n }\n await sleep(delayMs);\n }\n }\n throw lastError;\n}\nfunction createRetryable(fn, options = {}) {\n return (...args) => withRetry(() => fn(...args), options);\n}\n\n// src/storage/svg-security.ts\nvar SVG_CSP_HEADER = \"script-src 'none'; style-src 'unsafe-inline'\";\nfunction isSvgMimeType(mimeType) {\n return mimeType.toLowerCase().trim() === \"image/svg+xml\";\n}\nfunction getSvgSecurityHeaders() {\n return {\n \"Content-Security-Policy\": SVG_CSP_HEADER,\n \"X-Content-Type-Options\": \"nosniff\"\n };\n}\n\n// src/storage/env-config.ts\nasync function ensureEnvLoaded() {\n if (typeof process !== \"undefined\" && !process.env._NEXTLY_ENV_LOADED) {\n try {\n const dotenv = await import(\"dotenv\");\n dotenv.config();\n process.env._NEXTLY_ENV_LOADED = \"true\";\n } catch {\n }\n }\n}\nasync function getStorageFromEnv() {\n await ensureEnvLoaded();\n const blobToken = process.env.BLOB_READ_WRITE_TOKEN;\n if (blobToken) {\n try {\n const pkg = \"@nextlyhq/storage-vercel-blob\";\n const { vercelBlobStorage } = await import(\n /* webpackIgnore: true */\n pkg\n );\n console.log(\n \"[Nextly] Storage: Vercel Blob (auto-detected from BLOB_READ_WRITE_TOKEN)\"\n );\n return [\n vercelBlobStorage({ token: blobToken, collections: { media: true } })\n ];\n } catch {\n console.warn(\n \"[Nextly] BLOB_READ_WRITE_TOKEN set but @nextlyhq/storage-vercel-blob not installed. Run: pnpm add @nextlyhq/storage-vercel-blob\"\n );\n }\n }\n const s3Bucket = process.env.S3_BUCKET;\n if (s3Bucket) {\n const region = process.env.S3_REGION;\n const accessKeyId = process.env.AWS_ACCESS_KEY_ID;\n const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;\n if (!region || !accessKeyId || !secretAccessKey) {\n const missing = [];\n if (!region) missing.push(\"S3_REGION\");\n if (!accessKeyId) missing.push(\"AWS_ACCESS_KEY_ID\");\n if (!secretAccessKey) missing.push(\"AWS_SECRET_ACCESS_KEY\");\n console.warn(\n `[Nextly] S3_BUCKET set but missing: ${missing.join(\", \")}. Falling back to local storage.`\n );\n } else {\n try {\n const pkg = \"@nextlyhq/storage-s3\";\n const { s3Storage } = await import(\n /* webpackIgnore: true */\n pkg\n );\n console.log(\"[Nextly] Storage: S3 (auto-detected from S3_BUCKET)\");\n return [\n s3Storage({\n bucket: s3Bucket,\n region,\n accessKeyId,\n secretAccessKey,\n endpoint: process.env.S3_ENDPOINT,\n // eslint-disable-next-line turbo/no-undeclared-env-vars\n publicUrl: process.env.S3_PUBLIC_URL,\n // eslint-disable-next-line turbo/no-undeclared-env-vars\n forcePathStyle: process.env.S3_FORCE_PATH_STYLE === \"true\",\n collections: { media: true }\n })\n ];\n } catch {\n console.warn(\n \"[Nextly] S3_BUCKET set but @nextlyhq/storage-s3 not installed. Run: pnpm add @nextlyhq/storage-s3\"\n );\n }\n }\n }\n const uploadthingToken = process.env.UPLOADTHING_TOKEN;\n if (uploadthingToken) {\n try {\n const pkg = \"@nextlyhq/storage-uploadthing\";\n const { uploadthingStorage } = await import(\n /* webpackIgnore: true */\n pkg\n );\n console.log(\n \"[Nextly] Storage: Uploadthing (auto-detected from UPLOADTHING_TOKEN)\"\n );\n return [\n uploadthingStorage({\n token: uploadthingToken,\n collections: { media: true }\n })\n ];\n } catch {\n console.warn(\n \"[Nextly] UPLOADTHING_TOKEN set but @nextlyhq/storage-uploadthing not installed. Run: pnpm add @nextlyhq/storage-uploadthing\"\n );\n }\n }\n const { localStorage: localStorage2 } = await import(\"./local-plugin-PTET4NAT.mjs\");\n console.log(\n \"[Nextly] Storage: Local disk (no cloud env vars detected, using ./public/uploads)\"\n );\n return [localStorage2({ collections: { media: true } })];\n}\n\nexport {\n MediaStorage,\n initializeMediaStorage,\n getMediaStorage,\n resetMediaStorage,\n ImageProcessor,\n getImageProcessor,\n resetImageProcessor,\n generateImageSizes,\n deleteImageSizes,\n isTransientError,\n withRetry,\n createRetryable,\n SVG_CSP_HEADER,\n isSvgMimeType,\n getSvgSecurityHeaders,\n getStorageFromEnv\n};\n","import {\n ImageProcessor,\n MediaStorage,\n SVG_CSP_HEADER,\n createRetryable,\n deleteImageSizes,\n generateImageSizes,\n getImageProcessor,\n getMediaStorage,\n getStorageFromEnv,\n getSvgSecurityHeaders,\n initializeMediaStorage,\n isSvgMimeType,\n isTransientError,\n resetImageProcessor,\n resetMediaStorage,\n withRetry\n} from \"../chunk-EGXBZCGC.mjs\";\nimport {\n BaseStorageAdapter,\n LocalStorageAdapter,\n localStorage\n} from \"../chunk-G2AA4QLC.mjs\";\nimport \"../chunk-7P6ASYW6.mjs\";\nexport {\n BaseStorageAdapter,\n ImageProcessor,\n LocalStorageAdapter,\n MediaStorage,\n SVG_CSP_HEADER,\n createRetryable,\n deleteImageSizes,\n generateImageSizes,\n getImageProcessor,\n getMediaStorage,\n getStorageFromEnv,\n getSvgSecurityHeaders,\n initializeMediaStorage,\n isSvgMimeType,\n isTransientError,\n localStorage,\n resetImageProcessor,\n resetMediaStorage,\n withRetry\n};\n","/**\n * Uploadthing Storage Adapter\n *\n * Implements the Nextly storage adapter interface using Uploadthing's UTApi\n * for server-side file operations. Files are served via Uploadthing's CDN.\n *\n * @example\n * ```typescript\n * const adapter = new UploadthingStorageAdapter({ token: process.env.UPLOADTHING_TOKEN });\n * const result = await adapter.upload(buffer, {\n * filename: 'photo.jpg',\n * mimeType: 'image/jpeg',\n * });\n * // result.url = 'https://utfs.io/f/abc123-photo.jpg'\n * ```\n */\n\nimport { BaseStorageAdapter } from \"nextly/storage\";\nimport type {\n UploadOptions,\n UploadResult,\n BulkDeleteResult,\n} from \"nextly/storage\";\nimport { UTApi } from \"uploadthing/server\";\n\n// ============================================================\n// Adapter Implementation\n// ============================================================\n\nexport class UploadthingStorageAdapter extends BaseStorageAdapter {\n private readonly utapi: UTApi;\n\n constructor(config: { token?: string }) {\n super();\n // UTApi reads UPLOADTHING_TOKEN from env if not provided\n this.utapi = new UTApi({\n ...(config.token ? { token: config.token } : {}),\n });\n }\n\n /**\n * Upload file to Uploadthing.\n * Creates a File object from the buffer and uploads via UTApi.\n */\n async upload(buffer: Buffer, options: UploadOptions): Promise<UploadResult> {\n const sanitized = this.sanitizeFilename(options.filename);\n\n // UTApi.uploadFiles expects File objects\n // Cast buffer to BlobPart to satisfy TS 5.9 strict ArrayBuffer typing\n const file = new File([buffer as unknown as BlobPart], sanitized, {\n type: options.mimeType,\n });\n\n // uploadFiles returns UploadFileResult[] — one result per file.\n // Default contentDisposition flipped from \"inline\" to \"attachment\".\n // An \"inline\" disposition lets the browser render the file\n // in-context (HTML, SVG, PDF with embedded JS) which can land as\n // XSS or drive-by; \"attachment\" forces the download dialog so the\n // user has to opt in to opening it. Adopters who genuinely want\n // inline rendering can still pass `contentDisposition: \"inline\"`\n // explicitly.\n const results = await this.utapi.uploadFiles([file], {\n contentDisposition: options.contentDisposition ?? \"attachment\",\n });\n\n // results is an array of { data: { key, url, ... } | null, error: ... | null }\n const result = results[0] as {\n data: { key: string; url: string } | null;\n error: { message: string } | null;\n };\n\n if (!result?.data) {\n const errorMsg = result?.error?.message ?? \"Unknown error\";\n throw new Error(`Uploadthing upload failed: ${errorMsg}`);\n }\n\n return {\n url: result.data.url,\n // Use the file key as the storage path (needed for deletion)\n path: result.data.key,\n };\n }\n\n /**\n * Delete file from Uploadthing by its file key.\n */\n async delete(filePath: string): Promise<void> {\n try {\n await this.utapi.deleteFiles([filePath], { keyType: \"fileKey\" });\n } catch {\n // Silently ignore deletion errors (file may already be gone)\n }\n }\n\n /**\n * Bulk delete files from Uploadthing.\n * UTApi natively supports batch deletion.\n */\n async bulkDelete(filePaths: string[]): Promise<BulkDeleteResult> {\n try {\n await this.utapi.deleteFiles(filePaths, { keyType: \"fileKey\" });\n return {\n successful: filePaths,\n failed: [],\n };\n } catch (error: unknown) {\n // If bulk delete fails entirely, report all as failed\n const message =\n error instanceof Error ? error.message : \"Bulk delete failed\";\n return {\n successful: [],\n failed: filePaths.map(fp => ({\n filePath: fp,\n error: message,\n })),\n };\n }\n }\n\n /**\n * Check if file exists on Uploadthing.\n * Uses getFileUrls - if it returns data with URLs, the file exists.\n */\n async exists(filePath: string): Promise<boolean> {\n try {\n const result = await this.utapi.getFileUrls([filePath], {\n keyType: \"fileKey\",\n });\n // getFileUrls returns { data: readonly [{ url, key }] }\n const items = Array.from(result.data);\n return items.length > 0 && !!items[0]?.url;\n } catch {\n return false;\n }\n }\n\n /**\n * Get public URL for a file.\n * Uploadthing files are served from utfs.io CDN.\n * The URL is stored at upload time, so this reconstructs it from the key.\n */\n getPublicUrl(filePath: string): string {\n // Uploadthing URLs follow the pattern: https://utfs.io/f/{fileKey}\n return `https://utfs.io/f/${filePath}`;\n }\n\n /**\n * Get storage type identifier.\n */\n getType(): string {\n return \"uploadthing\";\n }\n\n /**\n * Keep filename sanitization local so this adapter remains stable\n * even if upstream base adapter type declarations drift.\n */\n protected sanitizeFilename(filename: string): string {\n const basename = filename.split(/[/\\\\]/).pop() || filename;\n return basename.replace(/[^a-zA-Z0-9._-]/g, \"-\");\n }\n}\n","/**\n * Uploadthing Storage Plugin\n *\n * Factory function that creates a storage plugin for Uploadthing.\n * Returns a StoragePlugin that can be registered with MediaStorage.\n *\n * @example\n * ```typescript\n * import { uploadthingStorage } from '@nextlyhq/storage-uploadthing'\n * import { defineConfig } from 'nextly/config'\n *\n * export default defineConfig({\n * storage: [\n * uploadthingStorage({\n * token: process.env.UPLOADTHING_TOKEN,\n * collections: { media: true }\n * })\n * ]\n * })\n * ```\n */\n\nimport type { StoragePlugin } from \"nextly/storage\";\n\nimport { UploadthingStorageAdapter } from \"./adapter\";\nimport type { UploadthingStorageConfig } from \"./types\";\n\n/**\n * Create an Uploadthing storage plugin for Nextly.\n *\n * @param config - Uploadthing storage configuration\n * @returns A StoragePlugin that MediaStorage can register\n */\nexport function uploadthingStorage(\n config: UploadthingStorageConfig\n): StoragePlugin {\n // Handle disabled plugin\n if (config.enabled === false) {\n return {\n name: \"uploadthing-storage\",\n type: \"uploadthing\",\n collections: {},\n adapter: null as unknown as StoragePlugin[\"adapter\"],\n };\n }\n\n // Token from config or env\n const token = config.token ?? process.env.UPLOADTHING_TOKEN;\n\n if (!token) {\n console.warn(\n \"[Nextly] Uploadthing token not provided. Set UPLOADTHING_TOKEN env var or pass token in config.\"\n );\n return {\n name: \"uploadthing-storage\",\n type: \"uploadthing\",\n collections: {},\n adapter: null as unknown as StoragePlugin[\"adapter\"],\n };\n }\n\n const adapter = new UploadthingStorageAdapter({ token });\n\n return {\n name: \"uploadthing-storage\",\n type: \"uploadthing\",\n collections: config.collections,\n adapter,\n // Uploadthing supports client-side uploads via its own pattern\n // but we don't implement getClientUploadUrl here as it requires\n // Uploadthing's specific route handler setup\n };\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Storage Types
|
|
3
|
+
*
|
|
4
|
+
* Defines interfaces and types for the unified media storage system.
|
|
5
|
+
* Supports cloud storage adapters via plugins.
|
|
6
|
+
*
|
|
7
|
+
* Storage Backends:
|
|
8
|
+
* - AWS S3 / Cloudflare R2 / MinIO (via @nextlyhq/storage-s3)
|
|
9
|
+
* - Vercel Blob (via @nextlyhq/storage-vercel-blob)
|
|
10
|
+
*/
|
|
11
|
+
interface UploadOptions {
|
|
12
|
+
/** Original filename from user */
|
|
13
|
+
filename: string;
|
|
14
|
+
/** MIME type (e.g., 'image/png', 'video/mp4') */
|
|
15
|
+
mimeType: string;
|
|
16
|
+
/** Optional content type override */
|
|
17
|
+
contentType?: string;
|
|
18
|
+
/** Optional folder/prefix for organizing uploads */
|
|
19
|
+
folder?: string;
|
|
20
|
+
/** Collection slug this upload belongs to (for collection-specific storage) */
|
|
21
|
+
collection?: string;
|
|
22
|
+
/** Optional Content-Disposition header value (e.g., 'attachment' for SVG security) */
|
|
23
|
+
contentDisposition?: "inline" | "attachment";
|
|
24
|
+
}
|
|
25
|
+
interface UploadResult {
|
|
26
|
+
/** Public URL to access the file */
|
|
27
|
+
url: string;
|
|
28
|
+
/** Storage path/key (for deletion and metadata retrieval) */
|
|
29
|
+
path: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Extended file metadata returned by getMetadata()
|
|
33
|
+
*
|
|
34
|
+
* Contains comprehensive information about an uploaded file,
|
|
35
|
+
* including dimensions for images and creation timestamps.
|
|
36
|
+
*/
|
|
37
|
+
interface FileMetadata {
|
|
38
|
+
/** Unique identifier (typically the storage path/key) */
|
|
39
|
+
id: string;
|
|
40
|
+
/** Storage filename (may differ from original) */
|
|
41
|
+
filename: string;
|
|
42
|
+
/** Original filename as uploaded by user */
|
|
43
|
+
originalFilename: string;
|
|
44
|
+
/** MIME type (e.g., 'image/jpeg', 'application/pdf') */
|
|
45
|
+
mimeType: string;
|
|
46
|
+
/** File size in bytes */
|
|
47
|
+
size: number;
|
|
48
|
+
/** Public URL to access the file */
|
|
49
|
+
url: string;
|
|
50
|
+
/** Thumbnail URL for images (if generated) */
|
|
51
|
+
thumbnailUrl?: string;
|
|
52
|
+
/** Image width in pixels (for images only) */
|
|
53
|
+
width?: number;
|
|
54
|
+
/** Image height in pixels (for images only) */
|
|
55
|
+
height?: number;
|
|
56
|
+
/** ISO timestamp when file was uploaded */
|
|
57
|
+
createdAt: string;
|
|
58
|
+
/** ISO timestamp when file was last modified */
|
|
59
|
+
updatedAt?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Storage type identifier.
|
|
63
|
+
* - "s3": AWS S3 or S3-compatible services (R2, MinIO, DigitalOcean Spaces)
|
|
64
|
+
* - "vercel-blob": Vercel Blob Storage
|
|
65
|
+
* - "local": Local disk storage (default for development)
|
|
66
|
+
* - "uploadthing": Uploadthing cloud storage
|
|
67
|
+
*/
|
|
68
|
+
type StorageType = "s3" | "vercel-blob" | "local" | "uploadthing";
|
|
69
|
+
/**
|
|
70
|
+
* Information about a storage adapter's capabilities.
|
|
71
|
+
* Returned by adapter.getInfo() method.
|
|
72
|
+
*/
|
|
73
|
+
interface StorageAdapterInfo {
|
|
74
|
+
/** Storage type identifier */
|
|
75
|
+
type: StorageType;
|
|
76
|
+
/** Human-readable adapter name */
|
|
77
|
+
name: string;
|
|
78
|
+
/** Whether this adapter supports signed URLs for private access */
|
|
79
|
+
supportsSignedUrls: boolean;
|
|
80
|
+
/** Whether this adapter supports client-side (direct) uploads */
|
|
81
|
+
supportsClientUploads: boolean;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Per-collection storage configuration.
|
|
85
|
+
* Allows customizing storage behavior for specific upload collections.
|
|
86
|
+
*/
|
|
87
|
+
interface CollectionStorageConfig {
|
|
88
|
+
/** Prefix/folder for this collection's uploads */
|
|
89
|
+
prefix?: string;
|
|
90
|
+
/** Enable client-side uploads (for serverless platforms with body size limits) */
|
|
91
|
+
clientUploads?: boolean;
|
|
92
|
+
/** Generate signed URLs for downloads (for private buckets) */
|
|
93
|
+
signedDownloads?: boolean;
|
|
94
|
+
/** Signed URL expiry time in seconds (default: 3600) */
|
|
95
|
+
signedUrlExpiresIn?: number;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Collection storage map - maps collection slugs to their config.
|
|
99
|
+
* Used in storage plugin configuration.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```typescript
|
|
103
|
+
* {
|
|
104
|
+
* media: true, // Use default config
|
|
105
|
+
* 'private-docs': {
|
|
106
|
+
* prefix: 'private/',
|
|
107
|
+
* signedDownloads: true,
|
|
108
|
+
* signedUrlExpiresIn: 900
|
|
109
|
+
* }
|
|
110
|
+
* }
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
type CollectionStorageMap = Record<string, boolean | CollectionStorageConfig>;
|
|
114
|
+
/**
|
|
115
|
+
* Base configuration for storage plugins.
|
|
116
|
+
* Extended by specific adapter configs (S3StorageConfig, etc.)
|
|
117
|
+
*/
|
|
118
|
+
interface StoragePluginConfig {
|
|
119
|
+
/** Enable/disable the plugin (default: true) */
|
|
120
|
+
enabled?: boolean;
|
|
121
|
+
/** Collections to apply this storage adapter to */
|
|
122
|
+
collections: CollectionStorageMap;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Storage plugin returned by adapter plugin functions.
|
|
126
|
+
* These are processed during Nextly initialization.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```typescript
|
|
130
|
+
* // From @nextlyhq/storage-s3
|
|
131
|
+
* const plugin = s3Storage({
|
|
132
|
+
* bucket: 'my-bucket',
|
|
133
|
+
* region: 'us-east-1',
|
|
134
|
+
* collections: { media: true }
|
|
135
|
+
* });
|
|
136
|
+
* // plugin implements StoragePlugin
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
interface StoragePlugin {
|
|
140
|
+
/** Plugin name for identification */
|
|
141
|
+
name: string;
|
|
142
|
+
/** Storage type */
|
|
143
|
+
type: StorageType;
|
|
144
|
+
/** Collections this plugin handles */
|
|
145
|
+
collections: CollectionStorageMap;
|
|
146
|
+
/** The storage adapter instance */
|
|
147
|
+
adapter: IStorageAdapter;
|
|
148
|
+
/**
|
|
149
|
+
* Handler for generating client-side upload URLs.
|
|
150
|
+
* Called when clientUploads is enabled for a collection.
|
|
151
|
+
*/
|
|
152
|
+
getClientUploadUrl?: (filename: string, mimeType: string, collection: string) => Promise<ClientUploadData>;
|
|
153
|
+
/**
|
|
154
|
+
* Handler for generating signed download URLs.
|
|
155
|
+
* Called when signedDownloads is enabled for a collection.
|
|
156
|
+
*/
|
|
157
|
+
getSignedDownloadUrl?: (path: string, expiresIn?: number) => Promise<string>;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Data returned for client-side (direct) uploads.
|
|
161
|
+
* Contains pre-signed URL and headers for direct-to-storage uploads.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```typescript
|
|
165
|
+
* // Usage in frontend
|
|
166
|
+
* const uploadData = await fetch('/api/nextly/storage/upload-url', {
|
|
167
|
+
* method: 'POST',
|
|
168
|
+
* body: JSON.stringify({ filename: 'photo.jpg', mimeType: 'image/jpeg', collection: 'media' })
|
|
169
|
+
* }).then(r => r.json());
|
|
170
|
+
*
|
|
171
|
+
* // Direct upload to storage
|
|
172
|
+
* await fetch(uploadData.uploadUrl, {
|
|
173
|
+
* method: uploadData.method,
|
|
174
|
+
* headers: uploadData.headers,
|
|
175
|
+
* body: file
|
|
176
|
+
* });
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
interface ClientUploadData {
|
|
180
|
+
/** Pre-signed URL for direct upload */
|
|
181
|
+
uploadUrl: string;
|
|
182
|
+
/** Storage path/key that will be used */
|
|
183
|
+
path: string;
|
|
184
|
+
/** HTTP method to use (usually PUT for S3, POST for some services) */
|
|
185
|
+
method: "PUT" | "POST";
|
|
186
|
+
/** Headers to include in upload request */
|
|
187
|
+
headers?: Record<string, string>;
|
|
188
|
+
/** Form fields for multipart uploads (some services require this) */
|
|
189
|
+
fields?: Record<string, string>;
|
|
190
|
+
/** URL expiry timestamp */
|
|
191
|
+
expiresAt: Date;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Base storage adapter interface.
|
|
195
|
+
* All storage adapters must implement this interface.
|
|
196
|
+
*
|
|
197
|
+
* Core methods (required):
|
|
198
|
+
* - upload: Store file buffer
|
|
199
|
+
* - delete: Remove file from storage
|
|
200
|
+
* - exists: Check if file exists
|
|
201
|
+
* - getPublicUrl: Get public URL for file access
|
|
202
|
+
* - getType: Get storage type identifier
|
|
203
|
+
*
|
|
204
|
+
* Optional methods:
|
|
205
|
+
* - getInfo: Get adapter capabilities (recommended)
|
|
206
|
+
* - getMetadata: Retrieve file metadata
|
|
207
|
+
* - getSignedUrl: Generate temporary signed URLs for private access
|
|
208
|
+
* - getPresignedUploadUrl: Generate pre-signed URL for client uploads
|
|
209
|
+
*/
|
|
210
|
+
interface BulkDeleteResult {
|
|
211
|
+
successful: string[];
|
|
212
|
+
failed: Array<{
|
|
213
|
+
filePath: string;
|
|
214
|
+
error: string;
|
|
215
|
+
}>;
|
|
216
|
+
}
|
|
217
|
+
interface IStorageAdapter {
|
|
218
|
+
/** Upload file buffer to storage */
|
|
219
|
+
upload(buffer: Buffer, options: UploadOptions): Promise<UploadResult>;
|
|
220
|
+
/** Delete file from storage */
|
|
221
|
+
delete(filePath: string): Promise<void>;
|
|
222
|
+
/** Bulk delete files from storage. Optional — adapters that support batch operations should implement this. */
|
|
223
|
+
bulkDelete?(filePaths: string[]): Promise<BulkDeleteResult>;
|
|
224
|
+
/** Check if file exists in storage */
|
|
225
|
+
exists(filePath: string): Promise<boolean>;
|
|
226
|
+
/** Get public URL for file */
|
|
227
|
+
getPublicUrl(filePath: string): string;
|
|
228
|
+
/** Get storage type identifier */
|
|
229
|
+
getType(): string;
|
|
230
|
+
/** Read file contents from storage (optional - not all adapters support this) */
|
|
231
|
+
read?(filePath: string): Promise<Buffer | null>;
|
|
232
|
+
/** Get adapter info including capabilities (optional but recommended) */
|
|
233
|
+
getInfo?(): StorageAdapterInfo;
|
|
234
|
+
/** Get file metadata (optional - not all adapters support this) */
|
|
235
|
+
getMetadata?(filePath: string): Promise<FileMetadata | null>;
|
|
236
|
+
/** Generate signed URL for temporary private access (optional) */
|
|
237
|
+
getSignedUrl?(filePath: string, expiresIn?: number): Promise<string>;
|
|
238
|
+
/** Generate pre-signed upload URL for client-side uploads (optional) */
|
|
239
|
+
getPresignedUploadUrl?(key: string, mimeType: string, expiresIn?: number): Promise<ClientUploadData>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Base Storage Adapter
|
|
244
|
+
*
|
|
245
|
+
* BaseStorageAdapter abstract class with common functionality for storage
|
|
246
|
+
* adapters. Concrete adapters extend this class to inherit auto-detected
|
|
247
|
+
* capabilities, sanitizeFilename(), generateKey(), etc.
|
|
248
|
+
*
|
|
249
|
+
* For the IStorageAdapter interface itself, import from ../types directly.
|
|
250
|
+
*/
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Abstract base class for storage adapters.
|
|
254
|
+
*
|
|
255
|
+
* Provides common functionality and helper methods that all storage adapters
|
|
256
|
+
* can use. Concrete adapters should extend this class to inherit:
|
|
257
|
+
* - Default getInfo() implementation with auto-detected capabilities
|
|
258
|
+
* - sanitizeFilename() helper for secure filename handling
|
|
259
|
+
* - generateKey() helper for unique storage key generation
|
|
260
|
+
*
|
|
261
|
+
* @example
|
|
262
|
+
* ```typescript
|
|
263
|
+
* class MyStorageAdapter extends BaseStorageAdapter {
|
|
264
|
+
* async upload(buffer: Buffer, options: UploadOptions): Promise<UploadResult> {
|
|
265
|
+
* const key = this.generateKey(options.filename, options.folder);
|
|
266
|
+
* const sanitized = this.sanitizeFilename(options.filename);
|
|
267
|
+
* // ... upload logic
|
|
268
|
+
* }
|
|
269
|
+
*
|
|
270
|
+
* async delete(filePath: string): Promise<void> { ... }
|
|
271
|
+
* async exists(filePath: string): Promise<boolean> { ... }
|
|
272
|
+
* getPublicUrl(filePath: string): string { ... }
|
|
273
|
+
* getType(): string { return 'my-storage'; }
|
|
274
|
+
* }
|
|
275
|
+
* ```
|
|
276
|
+
*/
|
|
277
|
+
declare abstract class BaseStorageAdapter implements IStorageAdapter {
|
|
278
|
+
/**
|
|
279
|
+
* Upload file buffer to storage.
|
|
280
|
+
* Must be implemented by concrete adapters.
|
|
281
|
+
*/
|
|
282
|
+
abstract upload(buffer: Buffer, options: UploadOptions): Promise<UploadResult>;
|
|
283
|
+
/**
|
|
284
|
+
* Delete file from storage.
|
|
285
|
+
* Must be implemented by concrete adapters.
|
|
286
|
+
*/
|
|
287
|
+
abstract delete(filePath: string): Promise<void>;
|
|
288
|
+
/**
|
|
289
|
+
* Check if file exists in storage.
|
|
290
|
+
* Must be implemented by concrete adapters.
|
|
291
|
+
*/
|
|
292
|
+
abstract exists(filePath: string): Promise<boolean>;
|
|
293
|
+
/**
|
|
294
|
+
* Get public URL for file.
|
|
295
|
+
* Must be implemented by concrete adapters.
|
|
296
|
+
*/
|
|
297
|
+
abstract getPublicUrl(filePath: string): string;
|
|
298
|
+
/**
|
|
299
|
+
* Get storage type identifier.
|
|
300
|
+
* Must be implemented by concrete adapters.
|
|
301
|
+
*/
|
|
302
|
+
abstract getType(): string;
|
|
303
|
+
/**
|
|
304
|
+
* Get adapter info including capabilities.
|
|
305
|
+
*
|
|
306
|
+
* Default implementation that auto-detects capabilities by checking
|
|
307
|
+
* if getSignedUrl and getPresignedUploadUrl methods are implemented.
|
|
308
|
+
* Override in subclasses for more accurate capability reporting.
|
|
309
|
+
*
|
|
310
|
+
* @returns Adapter info with type, name, and capability flags
|
|
311
|
+
*/
|
|
312
|
+
getInfo(): StorageAdapterInfo;
|
|
313
|
+
/**
|
|
314
|
+
* Sanitize filename to prevent directory traversal and storage issues.
|
|
315
|
+
*
|
|
316
|
+
* Security measures:
|
|
317
|
+
* - Remove path separators (/, \)
|
|
318
|
+
* - Keep only basename (no directories)
|
|
319
|
+
* - Replace problematic characters with hyphens
|
|
320
|
+
* - Preserve alphanumeric, dots, underscores, hyphens
|
|
321
|
+
*
|
|
322
|
+
* @param filename - Original filename to sanitize
|
|
323
|
+
* @returns Sanitized filename safe for storage
|
|
324
|
+
*
|
|
325
|
+
* @example
|
|
326
|
+
* ```typescript
|
|
327
|
+
* this.sanitizeFilename('../../../etc/passwd') // 'passwd'
|
|
328
|
+
* this.sanitizeFilename('my file (1).jpg') // 'my-file--1-.jpg'
|
|
329
|
+
* this.sanitizeFilename('photo.jpg') // 'photo.jpg'
|
|
330
|
+
* ```
|
|
331
|
+
*/
|
|
332
|
+
protected sanitizeFilename(filename: string): string;
|
|
333
|
+
/**
|
|
334
|
+
* Generate a unique storage key with date-based prefix.
|
|
335
|
+
*
|
|
336
|
+
* Creates keys in format: {folder}/{year}/{month}/{uuid}-{sanitized-filename}
|
|
337
|
+
* This provides:
|
|
338
|
+
* - Unique keys via UUID to prevent collisions
|
|
339
|
+
* - Date-based organization for easier management
|
|
340
|
+
* - Readable filenames for debugging
|
|
341
|
+
*
|
|
342
|
+
* @param filename - Original filename (will be sanitized)
|
|
343
|
+
* @param folder - Optional folder/prefix for organizing uploads
|
|
344
|
+
* @returns Generated storage key
|
|
345
|
+
*
|
|
346
|
+
* @example
|
|
347
|
+
* ```typescript
|
|
348
|
+
* this.generateKey('photo.jpg')
|
|
349
|
+
* // 'uploads/2026/01/abc-123-...-photo.jpg'
|
|
350
|
+
*
|
|
351
|
+
* this.generateKey('doc.pdf', 'documents')
|
|
352
|
+
* // 'documents/2026/01/abc-123-...-doc.pdf'
|
|
353
|
+
* ```
|
|
354
|
+
*/
|
|
355
|
+
protected generateKey(filename: string, folder?: string): string;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Uploadthing Storage Types
|
|
360
|
+
*
|
|
361
|
+
* Configuration for the @nextlyhq/storage-uploadthing package.
|
|
362
|
+
*/
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Uploadthing storage adapter configuration.
|
|
366
|
+
*
|
|
367
|
+
* @example
|
|
368
|
+
* ```typescript
|
|
369
|
+
* uploadthingStorage({
|
|
370
|
+
* token: process.env.UPLOADTHING_TOKEN,
|
|
371
|
+
* collections: { media: true }
|
|
372
|
+
* })
|
|
373
|
+
* ```
|
|
374
|
+
*/
|
|
375
|
+
interface UploadthingStorageConfig extends StoragePluginConfig {
|
|
376
|
+
/** Enable/disable this storage plugin (default: true). */
|
|
377
|
+
enabled?: boolean;
|
|
378
|
+
/** Collections this plugin handles. */
|
|
379
|
+
collections: Record<string, boolean | CollectionStorageConfig>;
|
|
380
|
+
/**
|
|
381
|
+
* Uploadthing API token.
|
|
382
|
+
* If not provided, reads from UPLOADTHING_TOKEN env var.
|
|
383
|
+
*/
|
|
384
|
+
token?: string;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Uploadthing Storage Plugin
|
|
389
|
+
*
|
|
390
|
+
* Factory function that creates a storage plugin for Uploadthing.
|
|
391
|
+
* Returns a StoragePlugin that can be registered with MediaStorage.
|
|
392
|
+
*
|
|
393
|
+
* @example
|
|
394
|
+
* ```typescript
|
|
395
|
+
* import { uploadthingStorage } from '@nextlyhq/storage-uploadthing'
|
|
396
|
+
* import { defineConfig } from 'nextly/config'
|
|
397
|
+
*
|
|
398
|
+
* export default defineConfig({
|
|
399
|
+
* storage: [
|
|
400
|
+
* uploadthingStorage({
|
|
401
|
+
* token: process.env.UPLOADTHING_TOKEN,
|
|
402
|
+
* collections: { media: true }
|
|
403
|
+
* })
|
|
404
|
+
* ]
|
|
405
|
+
* })
|
|
406
|
+
* ```
|
|
407
|
+
*/
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Create an Uploadthing storage plugin for Nextly.
|
|
411
|
+
*
|
|
412
|
+
* @param config - Uploadthing storage configuration
|
|
413
|
+
* @returns A StoragePlugin that MediaStorage can register
|
|
414
|
+
*/
|
|
415
|
+
declare function uploadthingStorage(config: UploadthingStorageConfig): StoragePlugin;
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Uploadthing Storage Adapter
|
|
419
|
+
*
|
|
420
|
+
* Implements the Nextly storage adapter interface using Uploadthing's UTApi
|
|
421
|
+
* for server-side file operations. Files are served via Uploadthing's CDN.
|
|
422
|
+
*
|
|
423
|
+
* @example
|
|
424
|
+
* ```typescript
|
|
425
|
+
* const adapter = new UploadthingStorageAdapter({ token: process.env.UPLOADTHING_TOKEN });
|
|
426
|
+
* const result = await adapter.upload(buffer, {
|
|
427
|
+
* filename: 'photo.jpg',
|
|
428
|
+
* mimeType: 'image/jpeg',
|
|
429
|
+
* });
|
|
430
|
+
* // result.url = 'https://utfs.io/f/abc123-photo.jpg'
|
|
431
|
+
* ```
|
|
432
|
+
*/
|
|
433
|
+
|
|
434
|
+
declare class UploadthingStorageAdapter extends BaseStorageAdapter {
|
|
435
|
+
private readonly utapi;
|
|
436
|
+
constructor(config: {
|
|
437
|
+
token?: string;
|
|
438
|
+
});
|
|
439
|
+
/**
|
|
440
|
+
* Upload file to Uploadthing.
|
|
441
|
+
* Creates a File object from the buffer and uploads via UTApi.
|
|
442
|
+
*/
|
|
443
|
+
upload(buffer: Buffer, options: UploadOptions): Promise<UploadResult>;
|
|
444
|
+
/**
|
|
445
|
+
* Delete file from Uploadthing by its file key.
|
|
446
|
+
*/
|
|
447
|
+
delete(filePath: string): Promise<void>;
|
|
448
|
+
/**
|
|
449
|
+
* Bulk delete files from Uploadthing.
|
|
450
|
+
* UTApi natively supports batch deletion.
|
|
451
|
+
*/
|
|
452
|
+
bulkDelete(filePaths: string[]): Promise<BulkDeleteResult>;
|
|
453
|
+
/**
|
|
454
|
+
* Check if file exists on Uploadthing.
|
|
455
|
+
* Uses getFileUrls - if it returns data with URLs, the file exists.
|
|
456
|
+
*/
|
|
457
|
+
exists(filePath: string): Promise<boolean>;
|
|
458
|
+
/**
|
|
459
|
+
* Get public URL for a file.
|
|
460
|
+
* Uploadthing files are served from utfs.io CDN.
|
|
461
|
+
* The URL is stored at upload time, so this reconstructs it from the key.
|
|
462
|
+
*/
|
|
463
|
+
getPublicUrl(filePath: string): string;
|
|
464
|
+
/**
|
|
465
|
+
* Get storage type identifier.
|
|
466
|
+
*/
|
|
467
|
+
getType(): string;
|
|
468
|
+
/**
|
|
469
|
+
* Keep filename sanitization local so this adapter remains stable
|
|
470
|
+
* even if upstream base adapter type declarations drift.
|
|
471
|
+
*/
|
|
472
|
+
protected sanitizeFilename(filename: string): string;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export { UploadthingStorageAdapter, type UploadthingStorageConfig, uploadthingStorage };
|