@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.
@@ -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 };
package/dist/index.mjs ADDED
@@ -0,0 +1,142 @@
1
+ import { BaseStorageAdapter } from './chunk-CV4XIHXE.mjs';
2
+ import './chunk-3GDQP6AS.mjs';
3
+ import { UTApi } from 'uploadthing/server';
4
+
5
+ var UploadthingStorageAdapter = class extends BaseStorageAdapter {
6
+ utapi;
7
+ constructor(config) {
8
+ super();
9
+ this.utapi = new UTApi({
10
+ ...config.token ? { token: config.token } : {}
11
+ });
12
+ }
13
+ /**
14
+ * Upload file to Uploadthing.
15
+ * Creates a File object from the buffer and uploads via UTApi.
16
+ */
17
+ async upload(buffer, options) {
18
+ const sanitized = this.sanitizeFilename(options.filename);
19
+ const file = new File([buffer], sanitized, {
20
+ type: options.mimeType
21
+ });
22
+ const results = await this.utapi.uploadFiles([file], {
23
+ contentDisposition: options.contentDisposition ?? "attachment"
24
+ });
25
+ const result = results[0];
26
+ if (!result?.data) {
27
+ const errorMsg = result?.error?.message ?? "Unknown error";
28
+ throw new Error(`Uploadthing upload failed: ${errorMsg}`);
29
+ }
30
+ return {
31
+ url: result.data.url,
32
+ // Use the file key as the storage path (needed for deletion)
33
+ path: result.data.key
34
+ };
35
+ }
36
+ /**
37
+ * Delete file from Uploadthing by its file key.
38
+ */
39
+ async delete(filePath) {
40
+ try {
41
+ await this.utapi.deleteFiles([filePath], { keyType: "fileKey" });
42
+ } catch {
43
+ }
44
+ }
45
+ /**
46
+ * Bulk delete files from Uploadthing.
47
+ * UTApi natively supports batch deletion.
48
+ */
49
+ async bulkDelete(filePaths) {
50
+ try {
51
+ await this.utapi.deleteFiles(filePaths, { keyType: "fileKey" });
52
+ return {
53
+ successful: filePaths,
54
+ failed: []
55
+ };
56
+ } catch (error) {
57
+ const message = error instanceof Error ? error.message : "Bulk delete failed";
58
+ return {
59
+ successful: [],
60
+ failed: filePaths.map((fp) => ({
61
+ filePath: fp,
62
+ error: message
63
+ }))
64
+ };
65
+ }
66
+ }
67
+ /**
68
+ * Check if file exists on Uploadthing.
69
+ * Uses getFileUrls - if it returns data with URLs, the file exists.
70
+ */
71
+ async exists(filePath) {
72
+ try {
73
+ const result = await this.utapi.getFileUrls([filePath], {
74
+ keyType: "fileKey"
75
+ });
76
+ const items = Array.from(result.data);
77
+ return items.length > 0 && !!items[0]?.url;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+ /**
83
+ * Get public URL for a file.
84
+ * Uploadthing files are served from utfs.io CDN.
85
+ * The URL is stored at upload time, so this reconstructs it from the key.
86
+ */
87
+ getPublicUrl(filePath) {
88
+ return `https://utfs.io/f/${filePath}`;
89
+ }
90
+ /**
91
+ * Get storage type identifier.
92
+ */
93
+ getType() {
94
+ return "uploadthing";
95
+ }
96
+ /**
97
+ * Keep filename sanitization local so this adapter remains stable
98
+ * even if upstream base adapter type declarations drift.
99
+ */
100
+ sanitizeFilename(filename) {
101
+ const basename = filename.split(/[/\\]/).pop() || filename;
102
+ return basename.replace(/[^a-zA-Z0-9._-]/g, "-");
103
+ }
104
+ };
105
+
106
+ // src/plugin.ts
107
+ function uploadthingStorage(config) {
108
+ if (config.enabled === false) {
109
+ return {
110
+ name: "uploadthing-storage",
111
+ type: "uploadthing",
112
+ collections: {},
113
+ adapter: null
114
+ };
115
+ }
116
+ const token = config.token ?? process.env.UPLOADTHING_TOKEN;
117
+ if (!token) {
118
+ console.warn(
119
+ "[Nextly] Uploadthing token not provided. Set UPLOADTHING_TOKEN env var or pass token in config."
120
+ );
121
+ return {
122
+ name: "uploadthing-storage",
123
+ type: "uploadthing",
124
+ collections: {},
125
+ adapter: null
126
+ };
127
+ }
128
+ const adapter = new UploadthingStorageAdapter({ token });
129
+ return {
130
+ name: "uploadthing-storage",
131
+ type: "uploadthing",
132
+ collections: config.collections,
133
+ adapter
134
+ // Uploadthing supports client-side uploads via its own pattern
135
+ // but we don't implement getClientUploadUrl here as it requires
136
+ // Uploadthing's specific route handler setup
137
+ };
138
+ }
139
+
140
+ export { UploadthingStorageAdapter, uploadthingStorage };
141
+ //# sourceMappingURL=index.mjs.map
142
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/adapter.ts","../src/plugin.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\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"]}