@digibuffer/file-manager 1.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,490 @@
1
+ import { deleteObject } from "@digibuffer/upload-lib-server/helpers";
2
+ import { z } from "zod";
3
+
4
+ //#region src/server/file-manager.ts
5
+ /**
6
+ * S3 List Objects V2 XML response parser
7
+ */
8
+ function parseListObjectsV2(xml) {
9
+ const objects = [];
10
+ const contentRegex = /<Contents>([\s\S]*?)<\/Contents>/g;
11
+ let match;
12
+ while ((match = contentRegex.exec(xml)) !== null) {
13
+ const content = match[1];
14
+ const key = content.match(/<Key>(.*?)<\/Key>/)?.[1];
15
+ const size = content.match(/<Size>(.*?)<\/Size>/)?.[1];
16
+ const lastModified = content.match(/<LastModified>(.*?)<\/LastModified>/)?.[1];
17
+ const etag = content.match(/<ETag>(.*?)<\/ETag>/)?.[1];
18
+ const storageClass = content.match(/<StorageClass>(.*?)<\/StorageClass>/)?.[1];
19
+ if (key && size && lastModified && etag) objects.push({
20
+ key,
21
+ size: parseInt(size, 10),
22
+ lastModified,
23
+ etag: etag.replace(/"/g, ""),
24
+ storageClass
25
+ });
26
+ }
27
+ return {
28
+ objects,
29
+ isTruncated: xml.includes("<IsTruncated>true</IsTruncated>"),
30
+ nextContinuationToken: xml.match(/<NextContinuationToken>(.*?)<\/NextContinuationToken>/)?.[1]
31
+ };
32
+ }
33
+ /**
34
+ * File Manager for managing files in R2/S3
35
+ */
36
+ var FileManager = class {
37
+ client;
38
+ bucketName;
39
+ database;
40
+ defaultLimit;
41
+ maxLimit;
42
+ constructor(config) {
43
+ this.client = config.client;
44
+ this.bucketName = config.bucketName;
45
+ this.database = config.database;
46
+ this.defaultLimit = config.defaultLimit ?? 50;
47
+ this.maxLimit = config.maxLimit ?? 1e3;
48
+ }
49
+ /**
50
+ * List files from storage with pagination
51
+ */
52
+ async listFromStorage(options = {}) {
53
+ const limit = Math.min(options.limit ?? this.defaultLimit, this.maxLimit);
54
+ const params = new URLSearchParams({
55
+ "list-type": "2",
56
+ "max-keys": limit.toString()
57
+ });
58
+ if (options.cursor) params.append("continuation-token", options.cursor);
59
+ if (options.prefix) params.append("prefix", options.prefix);
60
+ if (options.delimiter) params.append("delimiter", options.delimiter);
61
+ const url = this.buildUrl(void 0, params);
62
+ try {
63
+ const response = await this.client.fetch(url, { method: "GET" });
64
+ if (!response.ok) {
65
+ const text = await response.text();
66
+ throw this.createError("Failed to list files from storage", "STORAGE_LIST_ERROR", {
67
+ status: response.status,
68
+ statusText: response.statusText,
69
+ body: text
70
+ });
71
+ }
72
+ const parsed = parseListObjectsV2(await response.text());
73
+ const items = parsed.objects.map((obj) => ({
74
+ key: obj.key,
75
+ size: obj.size,
76
+ lastModified: new Date(obj.lastModified),
77
+ etag: obj.etag,
78
+ storageClass: obj.storageClass
79
+ }));
80
+ if (options.sortBy) this.sortFiles(items, options.sortBy, options.sortOrder);
81
+ return {
82
+ items,
83
+ nextCursor: parsed.nextContinuationToken,
84
+ hasMore: parsed.isTruncated
85
+ };
86
+ } catch (error) {
87
+ if (error instanceof Error && error.name === "FileManagerError") throw error;
88
+ throw this.createError("Failed to list files", "LIST_ERROR", { originalError: error });
89
+ }
90
+ }
91
+ /**
92
+ * List files from database with pagination
93
+ */
94
+ async listFromDatabase(options = {}) {
95
+ if (!this.database?.getFiles) throw this.createError("Database getFiles callback not configured", "DATABASE_NOT_CONFIGURED");
96
+ try {
97
+ const limit = Math.min(options.limit ?? this.defaultLimit, this.maxLimit);
98
+ const result = await this.database.getFiles({
99
+ limit,
100
+ cursor: options.cursor,
101
+ prefix: options.prefix,
102
+ delimiter: options.delimiter
103
+ });
104
+ if (options.sortBy && result.items.length > 0) this.sortFiles(result.items, options.sortBy, options.sortOrder);
105
+ return result;
106
+ } catch (error) {
107
+ throw this.createError("Failed to list files from database", "DATABASE_LIST_ERROR", { originalError: error });
108
+ }
109
+ }
110
+ /**
111
+ * List files from both database and storage, merging results
112
+ */
113
+ async listFromBoth(options = {}) {
114
+ const [dbResult, storageResult] = await Promise.all([this.listFromDatabase(options).catch(() => ({
115
+ items: [],
116
+ hasMore: false
117
+ })), this.listFromStorage(options).catch(() => ({
118
+ items: [],
119
+ hasMore: false
120
+ }))]);
121
+ const storageMap = new Map(storageResult.items.map((file) => [file.key, file]));
122
+ return {
123
+ items: dbResult.items.map((dbFile) => {
124
+ const storageFile = storageMap.get(dbFile.key);
125
+ if (storageFile) return {
126
+ ...dbFile,
127
+ ...storageFile,
128
+ id: dbFile.id
129
+ };
130
+ return dbFile;
131
+ }),
132
+ nextCursor: dbResult.nextCursor,
133
+ hasMore: dbResult.hasMore,
134
+ totalCount: dbResult.totalCount
135
+ };
136
+ }
137
+ /**
138
+ * List files with automatic source selection
139
+ */
140
+ async list(options = {}) {
141
+ const source = options.source ?? (this.database?.getFiles ? "database" : "storage");
142
+ switch (source) {
143
+ case "database": return this.listFromDatabase(options);
144
+ case "storage": return this.listFromStorage(options);
145
+ case "both": return this.listFromBoth(options);
146
+ default: throw this.createError("Invalid source option", "INVALID_SOURCE", { source });
147
+ }
148
+ }
149
+ /**
150
+ * Delete a single file
151
+ */
152
+ async delete(key) {
153
+ let file;
154
+ try {
155
+ if (this.database?.getFileByKey) file = await this.database.getFileByKey(key);
156
+ if (this.database?.onBeforeDelete) {
157
+ if (!await this.database.onBeforeDelete({
158
+ key,
159
+ file: file ?? void 0
160
+ })) return {
161
+ success: false,
162
+ key,
163
+ error: "Delete prevented by onBeforeDelete hook"
164
+ };
165
+ }
166
+ await deleteObject(this.client, {
167
+ bucket: this.bucketName,
168
+ key
169
+ });
170
+ if (this.database?.onAfterDelete) await this.database.onAfterDelete({
171
+ key,
172
+ file: file ?? void 0,
173
+ success: true
174
+ });
175
+ return {
176
+ success: true,
177
+ key
178
+ };
179
+ } catch (error) {
180
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
181
+ if (this.database?.onAfterDelete) await this.database.onAfterDelete({
182
+ key,
183
+ file: file ?? void 0,
184
+ success: false,
185
+ error: errorMessage
186
+ });
187
+ return {
188
+ success: false,
189
+ key,
190
+ error: errorMessage
191
+ };
192
+ }
193
+ }
194
+ /**
195
+ * Delete multiple files in batch
196
+ */
197
+ async deleteBatch(keys) {
198
+ if (keys.length === 0) return {
199
+ deleted: [],
200
+ errors: []
201
+ };
202
+ const results = await Promise.allSettled(keys.map((key) => this.delete(key)));
203
+ const deleted = [];
204
+ const errors = [];
205
+ results.forEach((result, index) => {
206
+ const key = keys[index];
207
+ if (result.status === "fulfilled" && result.value.success) deleted.push(key);
208
+ else {
209
+ const error = result.status === "fulfilled" ? result.value.error ?? "Unknown error" : result.reason instanceof Error ? result.reason.message : "Unknown error";
210
+ errors.push({
211
+ key,
212
+ error
213
+ });
214
+ }
215
+ });
216
+ if (this.database?.onAfterBatchDelete) await this.database.onAfterBatchDelete({
217
+ deleted,
218
+ errors
219
+ });
220
+ return {
221
+ deleted,
222
+ errors
223
+ };
224
+ }
225
+ /**
226
+ * Generate a presigned download URL
227
+ */
228
+ async getDownloadUrl(key, options = {}) {
229
+ const expiresIn = options.expiresIn ?? 3600;
230
+ const params = new URLSearchParams();
231
+ if (options.filename || options.disposition) {
232
+ const disposition = options.disposition ?? "attachment";
233
+ const filename = options.filename ?? key.split("/").pop();
234
+ params.append("response-content-disposition", `${disposition}; filename="${filename}"`);
235
+ }
236
+ const url = this.buildUrl(key, params.toString() ? params : void 0);
237
+ Math.floor(Date.now() / 1e3) + expiresIn;
238
+ const request = new Request(url, { method: "GET" });
239
+ return (await this.client.sign(request, { aws: {
240
+ signQuery: true,
241
+ allHeaders: true
242
+ } })).url;
243
+ }
244
+ /**
245
+ * Check if a file exists
246
+ */
247
+ async exists(key) {
248
+ try {
249
+ const url = this.buildUrl(key);
250
+ return (await this.client.fetch(url, { method: "HEAD" })).ok;
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+ /**
256
+ * Get file metadata from storage
257
+ */
258
+ async getMetadata(key) {
259
+ try {
260
+ const url = this.buildUrl(key);
261
+ const response = await this.client.fetch(url, { method: "HEAD" });
262
+ if (!response.ok) return null;
263
+ return {
264
+ key,
265
+ size: parseInt(response.headers.get("content-length") ?? "0", 10),
266
+ lastModified: new Date(response.headers.get("last-modified") ?? Date.now()),
267
+ etag: response.headers.get("etag")?.replace(/"/g, "") ?? "",
268
+ contentType: response.headers.get("content-type") ?? void 0
269
+ };
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+ /**
275
+ * Build URL helper - supports both path-style and virtual-hosted-style
276
+ */
277
+ buildUrl(key, params) {
278
+ const baseHost = this.client.host.startsWith("http") ? this.client.host : `https://${this.client.host}`;
279
+ let url;
280
+ if (key) url = this.bucketName ? `${baseHost}/${this.bucketName}/${key}` : `${baseHost}/${key}`;
281
+ else url = this.bucketName ? `${baseHost}/${this.bucketName}` : baseHost;
282
+ if (params) url += `?${params.toString()}`;
283
+ return url;
284
+ }
285
+ /**
286
+ * Sort files helper
287
+ */
288
+ sortFiles(files, sortBy, sortOrder = "desc") {
289
+ files.sort((a, b) => {
290
+ let comparison = 0;
291
+ switch (sortBy) {
292
+ case "lastModified":
293
+ comparison = a.lastModified.getTime() - b.lastModified.getTime();
294
+ break;
295
+ case "size":
296
+ comparison = a.size - b.size;
297
+ break;
298
+ case "name":
299
+ comparison = a.key.localeCompare(b.key);
300
+ break;
301
+ }
302
+ return sortOrder === "asc" ? comparison : -comparison;
303
+ });
304
+ }
305
+ /**
306
+ * Create a FileManagerError
307
+ */
308
+ createError(message, code, details) {
309
+ const error = new Error(message);
310
+ error.name = "FileManagerError";
311
+ error.code = code;
312
+ error.details = details;
313
+ return error;
314
+ }
315
+ };
316
+
317
+ //#endregion
318
+ //#region src/server/router.ts
319
+ /**
320
+ * Request schemas
321
+ */
322
+ const ListRequestSchema = z.object({
323
+ action: z.literal("list"),
324
+ limit: z.number().int().positive().optional(),
325
+ cursor: z.string().optional(),
326
+ prefix: z.string().optional(),
327
+ delimiter: z.string().optional(),
328
+ source: z.enum([
329
+ "storage",
330
+ "database",
331
+ "both"
332
+ ]).optional(),
333
+ sortBy: z.enum([
334
+ "lastModified",
335
+ "size",
336
+ "name"
337
+ ]).optional(),
338
+ sortOrder: z.enum(["asc", "desc"]).optional()
339
+ });
340
+ const DeleteRequestSchema = z.object({
341
+ action: z.literal("delete"),
342
+ key: z.string().min(1)
343
+ });
344
+ const BatchDeleteRequestSchema = z.object({
345
+ action: z.literal("batchDelete"),
346
+ keys: z.array(z.string().min(1)).min(1)
347
+ });
348
+ const GetUrlRequestSchema = z.object({
349
+ action: z.literal("getUrl"),
350
+ key: z.string().min(1),
351
+ expiresIn: z.number().int().positive().optional(),
352
+ filename: z.string().optional(),
353
+ disposition: z.enum(["attachment", "inline"]).optional()
354
+ });
355
+ const ExistsRequestSchema = z.object({
356
+ action: z.literal("exists"),
357
+ key: z.string().min(1)
358
+ });
359
+ const GetMetadataRequestSchema = z.object({
360
+ action: z.literal("getMetadata"),
361
+ key: z.string().min(1)
362
+ });
363
+ const FileManagerRequestSchema = z.discriminatedUnion("action", [
364
+ ListRequestSchema,
365
+ DeleteRequestSchema,
366
+ BatchDeleteRequestSchema,
367
+ GetUrlRequestSchema,
368
+ ExistsRequestSchema,
369
+ GetMetadataRequestSchema
370
+ ]);
371
+ /**
372
+ * Router for file management operations
373
+ */
374
+ var FileManagerRouter = class {
375
+ routes = /* @__PURE__ */ new Map();
376
+ /**
377
+ * Register a route
378
+ */
379
+ route(path, config) {
380
+ this.routes.set(path, config);
381
+ return this;
382
+ }
383
+ /**
384
+ * Handle a request
385
+ */
386
+ async handle(path, body, context) {
387
+ const route = this.routes.get(path);
388
+ if (!route) return this.errorResponse("Route not found", 404);
389
+ try {
390
+ const request = FileManagerRequestSchema.parse(body);
391
+ if (route.onAuthorize) {
392
+ if (!await route.onAuthorize(request, context)) return this.errorResponse("Unauthorized", 403);
393
+ }
394
+ switch (request.action) {
395
+ case "list": {
396
+ let options = {
397
+ limit: request.limit,
398
+ cursor: request.cursor,
399
+ prefix: request.prefix,
400
+ delimiter: request.delimiter,
401
+ source: request.source,
402
+ sortBy: request.sortBy,
403
+ sortOrder: request.sortOrder
404
+ };
405
+ if (route.onBeforeList) {
406
+ const result$1 = await route.onBeforeList(options, context);
407
+ if (result$1 === false) return this.errorResponse("List operation denied", 403);
408
+ options = result$1;
409
+ }
410
+ const result = await route.fileManager.list(options);
411
+ return this.successResponse(result);
412
+ }
413
+ case "delete": {
414
+ if (route.onBeforeDelete) {
415
+ if (!await route.onBeforeDelete(request.key, context)) return this.errorResponse("Delete operation denied", 403);
416
+ }
417
+ const result = await route.fileManager.delete(request.key);
418
+ return this.successResponse(result);
419
+ }
420
+ case "batchDelete": {
421
+ let keys = request.keys;
422
+ if (route.onBeforeBatchDelete) {
423
+ const result$1 = await route.onBeforeBatchDelete(keys, context);
424
+ if (result$1 === false) return this.errorResponse("Batch delete operation denied", 403);
425
+ keys = result$1;
426
+ }
427
+ const result = await route.fileManager.deleteBatch(keys);
428
+ return this.successResponse(result);
429
+ }
430
+ case "getUrl": {
431
+ const url = await route.fileManager.getDownloadUrl(request.key, {
432
+ expiresIn: request.expiresIn,
433
+ filename: request.filename,
434
+ disposition: request.disposition
435
+ });
436
+ return this.successResponse({ url });
437
+ }
438
+ case "exists": {
439
+ const exists = await route.fileManager.exists(request.key);
440
+ return this.successResponse({ exists });
441
+ }
442
+ case "getMetadata": {
443
+ const metadata = await route.fileManager.getMetadata(request.key);
444
+ return this.successResponse({ metadata });
445
+ }
446
+ default: return this.errorResponse("Invalid action", 400);
447
+ }
448
+ } catch (error) {
449
+ if (error instanceof z.ZodError) return this.errorResponse("Invalid request", 400, { errors: error.errors });
450
+ if (error instanceof Error) return this.errorResponse(error.message, 500, { name: error.name });
451
+ return this.errorResponse("Internal server error", 500);
452
+ }
453
+ }
454
+ /**
455
+ * Success response helper
456
+ */
457
+ successResponse(data) {
458
+ return new Response(JSON.stringify({
459
+ success: true,
460
+ data
461
+ }), {
462
+ status: 200,
463
+ headers: { "Content-Type": "application/json" }
464
+ });
465
+ }
466
+ /**
467
+ * Error response helper
468
+ */
469
+ errorResponse(message, status, details) {
470
+ return new Response(JSON.stringify({
471
+ success: false,
472
+ error: {
473
+ message,
474
+ details
475
+ }
476
+ }), {
477
+ status,
478
+ headers: { "Content-Type": "application/json" }
479
+ });
480
+ }
481
+ };
482
+ /**
483
+ * Create a file manager router
484
+ */
485
+ function createFileManagerRouter() {
486
+ return new FileManagerRouter();
487
+ }
488
+
489
+ //#endregion
490
+ export { createFileManagerRouter as n, FileManager as r, FileManagerRouter as t };
@@ -0,0 +1,177 @@
1
+ import { Client } from "@digibuffer/upload-lib-server/clients";
2
+
3
+ //#region src/shared/types.d.ts
4
+
5
+ /**
6
+ * Pagination options for listing files
7
+ */
8
+ type PaginationOptions = {
9
+ /** Maximum number of items to return per page */
10
+ limit?: number;
11
+ /** Pagination cursor (continuation token from previous response) */
12
+ cursor?: string;
13
+ /** Optional prefix to filter files by key prefix */
14
+ prefix?: string;
15
+ /** Optional delimiter for grouping keys (commonly '/') */
16
+ delimiter?: string;
17
+ };
18
+ /**
19
+ * Paginated response wrapper
20
+ */
21
+ type PaginatedResponse<T> = {
22
+ /** Array of items for current page */
23
+ items: T[];
24
+ /** Cursor for next page, undefined if no more pages */
25
+ nextCursor?: string;
26
+ /** Whether there are more pages available */
27
+ hasMore: boolean;
28
+ /** Total count if available (may not be accurate for large datasets) */
29
+ totalCount?: number;
30
+ };
31
+ /**
32
+ * File metadata from storage
33
+ */
34
+ type StorageFile = {
35
+ /** Object key in storage */
36
+ key: string;
37
+ /** File size in bytes */
38
+ size: number;
39
+ /** Last modified timestamp */
40
+ lastModified: Date;
41
+ /** ETag for the object */
42
+ etag: string;
43
+ /** Content type */
44
+ contentType?: string;
45
+ /** Storage class */
46
+ storageClass?: string;
47
+ /** Custom metadata */
48
+ metadata?: Record<string, string>;
49
+ };
50
+ /**
51
+ * File with database information
52
+ */
53
+ type ManagedFile = StorageFile & {
54
+ /** Database ID */
55
+ id: string;
56
+ /** User ID who owns the file */
57
+ userId?: string;
58
+ /** Original filename */
59
+ originalName?: string;
60
+ /** Additional database fields */
61
+ [key: string]: any;
62
+ };
63
+ /**
64
+ * Delete operation result
65
+ */
66
+ type DeleteResult = {
67
+ /** Whether the delete was successful */
68
+ success: boolean;
69
+ /** The key that was deleted */
70
+ key: string;
71
+ /** Error message if failed */
72
+ error?: string;
73
+ };
74
+ /**
75
+ * Batch delete result
76
+ */
77
+ type BatchDeleteResult = {
78
+ /** Successfully deleted keys */
79
+ deleted: string[];
80
+ /** Failed deletions with errors */
81
+ errors: Array<{
82
+ key: string;
83
+ error: string;
84
+ }>;
85
+ };
86
+ /**
87
+ * Download URL options
88
+ */
89
+ type DownloadUrlOptions = {
90
+ /** Expiration time in seconds (default: 3600) */
91
+ expiresIn?: number;
92
+ /** Optional filename for Content-Disposition header */
93
+ filename?: string;
94
+ /** Whether to force download (attachment) vs inline */
95
+ disposition?: 'attachment' | 'inline';
96
+ };
97
+ /**
98
+ * Database callbacks for file operations
99
+ */
100
+ type DatabaseCallbacks<TFile = ManagedFile> = {
101
+ /**
102
+ * Called after a file is deleted from storage
103
+ * Use this to update your database
104
+ */
105
+ onAfterDelete?: (params: {
106
+ key: string;
107
+ file?: TFile;
108
+ success: boolean;
109
+ error?: string;
110
+ }) => Promise<void> | void;
111
+ /**
112
+ * Called before a file is deleted from storage
113
+ * Return false to prevent deletion
114
+ */
115
+ onBeforeDelete?: (params: {
116
+ key: string;
117
+ file?: TFile;
118
+ }) => Promise<boolean> | boolean;
119
+ /**
120
+ * Called after batch delete operation
121
+ */
122
+ onAfterBatchDelete?: (params: {
123
+ deleted: string[];
124
+ errors: Array<{
125
+ key: string;
126
+ error: string;
127
+ }>;
128
+ }) => Promise<void> | void;
129
+ /**
130
+ * Fetch file metadata from database by key
131
+ */
132
+ getFileByKey?: (key: string) => Promise<TFile | null> | TFile | null;
133
+ /**
134
+ * Fetch files from database with pagination
135
+ */
136
+ getFiles?: (options: PaginationOptions) => Promise<PaginatedResponse<TFile>> | PaginatedResponse<TFile>;
137
+ /**
138
+ * Update file metadata in database
139
+ */
140
+ updateFile?: (key: string, data: Partial<TFile>) => Promise<TFile> | TFile;
141
+ };
142
+ /**
143
+ * File manager configuration
144
+ */
145
+ type FileManagerConfig<TFile = ManagedFile> = {
146
+ /** S3 client instance */
147
+ client: Client;
148
+ /** Default bucket name */
149
+ bucketName: string;
150
+ /** Database callbacks */
151
+ database?: DatabaseCallbacks<TFile>;
152
+ /** Default pagination limit */
153
+ defaultLimit?: number;
154
+ /** Maximum pagination limit */
155
+ maxLimit?: number;
156
+ };
157
+ /**
158
+ * List files options
159
+ */
160
+ type ListFilesOptions = PaginationOptions & {
161
+ /** Whether to fetch from database or storage */
162
+ source?: 'storage' | 'database' | 'both';
163
+ /** Sort order */
164
+ sortBy?: 'lastModified' | 'size' | 'name';
165
+ /** Sort direction */
166
+ sortOrder?: 'asc' | 'desc';
167
+ };
168
+ /**
169
+ * File operation error
170
+ */
171
+ declare class FileManagerError extends Error {
172
+ code: string;
173
+ details?: Record<string, any> | undefined;
174
+ constructor(message: string, code: string, details?: Record<string, any> | undefined);
175
+ }
176
+ //#endregion
177
+ export { FileManagerConfig as a, ManagedFile as c, StorageFile as d, DownloadUrlOptions as i, PaginatedResponse as l, DatabaseCallbacks as n, FileManagerError as o, DeleteResult as r, ListFilesOptions as s, BatchDeleteResult as t, PaginationOptions as u };