express-storage 2.0.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +366 -34
- package/dist/cjs/config/index.d.ts +10 -0
- package/dist/cjs/config/index.d.ts.map +1 -0
- package/dist/cjs/config/index.js +19 -0
- package/dist/cjs/config/index.js.map +1 -0
- package/dist/cjs/drivers/azure.driver.d.ts +73 -0
- package/dist/cjs/drivers/azure.driver.d.ts.map +1 -0
- package/dist/cjs/drivers/azure.driver.js +390 -0
- package/dist/cjs/drivers/azure.driver.js.map +1 -0
- package/dist/cjs/drivers/base.driver.d.ts +136 -0
- package/dist/cjs/drivers/base.driver.d.ts.map +1 -0
- package/dist/cjs/drivers/base.driver.js +357 -0
- package/dist/cjs/drivers/base.driver.js.map +1 -0
- package/dist/{drivers → cjs/drivers}/gcs.driver.d.ts +20 -38
- package/dist/cjs/drivers/gcs.driver.d.ts.map +1 -0
- package/dist/cjs/drivers/gcs.driver.js +343 -0
- package/dist/cjs/drivers/gcs.driver.js.map +1 -0
- package/dist/cjs/drivers/index.d.ts +15 -0
- package/dist/cjs/drivers/index.d.ts.map +1 -0
- package/dist/cjs/drivers/index.js +26 -0
- package/dist/cjs/drivers/index.js.map +1 -0
- package/dist/cjs/drivers/local.driver.d.ts +86 -0
- package/dist/cjs/drivers/local.driver.d.ts.map +1 -0
- package/dist/cjs/drivers/local.driver.js +556 -0
- package/dist/cjs/drivers/local.driver.js.map +1 -0
- package/dist/{drivers → cjs/drivers}/s3.driver.d.ts +19 -39
- package/dist/cjs/drivers/s3.driver.d.ts.map +1 -0
- package/dist/cjs/drivers/s3.driver.js +400 -0
- package/dist/cjs/drivers/s3.driver.js.map +1 -0
- package/dist/cjs/factory/driver.factory.d.ts +43 -0
- package/dist/cjs/factory/driver.factory.d.ts.map +1 -0
- package/dist/cjs/factory/driver.factory.js +101 -0
- package/dist/cjs/factory/driver.factory.js.map +1 -0
- package/dist/cjs/index.d.ts +26 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +31 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/storage-manager.d.ts +210 -0
- package/dist/cjs/storage-manager.d.ts.map +1 -0
- package/dist/cjs/storage-manager.js +649 -0
- package/dist/cjs/storage-manager.js.map +1 -0
- package/dist/cjs/types/storage.types.d.ts +438 -0
- package/dist/cjs/types/storage.types.d.ts.map +1 -0
- package/dist/cjs/types/storage.types.js +3 -0
- package/dist/cjs/types/storage.types.js.map +1 -0
- package/dist/cjs/utils/config.utils.d.ts.map +1 -0
- package/dist/cjs/utils/config.utils.js +213 -0
- package/dist/cjs/utils/config.utils.js.map +1 -0
- package/dist/{utils → cjs/utils}/file.utils.d.ts +62 -8
- package/dist/cjs/utils/file.utils.d.ts.map +1 -0
- package/dist/cjs/utils/file.utils.js +464 -0
- package/dist/cjs/utils/file.utils.js.map +1 -0
- package/dist/cjs/utils/index.d.ts +12 -0
- package/dist/cjs/utils/index.d.ts.map +1 -0
- package/dist/cjs/utils/index.js +36 -0
- package/dist/cjs/utils/index.js.map +1 -0
- package/dist/cjs/utils/rate-limiter.d.ts +40 -0
- package/dist/cjs/utils/rate-limiter.d.ts.map +1 -0
- package/dist/cjs/utils/rate-limiter.js +87 -0
- package/dist/cjs/utils/rate-limiter.js.map +1 -0
- package/dist/esm/config/index.d.ts +10 -0
- package/dist/esm/config/index.d.ts.map +1 -0
- package/dist/esm/config/index.js +10 -0
- package/dist/esm/config/index.js.map +1 -0
- package/dist/esm/drivers/azure.driver.d.ts +73 -0
- package/dist/esm/drivers/azure.driver.d.ts.map +1 -0
- package/dist/esm/drivers/azure.driver.js +353 -0
- package/dist/esm/drivers/azure.driver.js.map +1 -0
- package/dist/esm/drivers/base.driver.d.ts +136 -0
- package/dist/esm/drivers/base.driver.d.ts.map +1 -0
- package/dist/esm/drivers/base.driver.js +350 -0
- package/dist/esm/drivers/base.driver.js.map +1 -0
- package/dist/esm/drivers/gcs.driver.d.ts +68 -0
- package/dist/esm/drivers/gcs.driver.d.ts.map +1 -0
- package/dist/esm/drivers/gcs.driver.js +306 -0
- package/dist/esm/drivers/gcs.driver.js.map +1 -0
- package/dist/esm/drivers/index.d.ts +15 -0
- package/dist/esm/drivers/index.d.ts.map +1 -0
- package/dist/esm/drivers/index.js +15 -0
- package/dist/esm/drivers/index.js.map +1 -0
- package/dist/esm/drivers/local.driver.d.ts +86 -0
- package/dist/esm/drivers/local.driver.d.ts.map +1 -0
- package/dist/esm/drivers/local.driver.js +549 -0
- package/dist/esm/drivers/local.driver.js.map +1 -0
- package/dist/esm/drivers/s3.driver.d.ts +69 -0
- package/dist/esm/drivers/s3.driver.d.ts.map +1 -0
- package/dist/esm/drivers/s3.driver.js +363 -0
- package/dist/esm/drivers/s3.driver.js.map +1 -0
- package/dist/esm/factory/driver.factory.d.ts +43 -0
- package/dist/esm/factory/driver.factory.d.ts.map +1 -0
- package/dist/esm/factory/driver.factory.js +92 -0
- package/dist/esm/factory/driver.factory.js.map +1 -0
- package/dist/esm/index.d.ts +26 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +26 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/storage-manager.d.ts +210 -0
- package/dist/esm/storage-manager.d.ts.map +1 -0
- package/dist/esm/storage-manager.js +645 -0
- package/dist/esm/storage-manager.js.map +1 -0
- package/dist/esm/types/storage.types.d.ts +438 -0
- package/dist/esm/types/storage.types.d.ts.map +1 -0
- package/dist/esm/types/storage.types.js.map +1 -0
- package/dist/esm/utils/config.utils.d.ts +45 -0
- package/dist/esm/utils/config.utils.d.ts.map +1 -0
- package/dist/esm/utils/config.utils.js.map +1 -0
- package/dist/esm/utils/file.utils.d.ts +196 -0
- package/dist/esm/utils/file.utils.d.ts.map +1 -0
- package/dist/esm/utils/file.utils.js +439 -0
- package/dist/esm/utils/file.utils.js.map +1 -0
- package/dist/esm/utils/index.d.ts +12 -0
- package/dist/esm/utils/index.d.ts.map +1 -0
- package/dist/esm/utils/index.js +11 -0
- package/dist/esm/utils/index.js.map +1 -0
- package/dist/esm/utils/rate-limiter.d.ts +40 -0
- package/dist/esm/utils/rate-limiter.d.ts.map +1 -0
- package/dist/esm/utils/rate-limiter.js +82 -0
- package/dist/esm/utils/rate-limiter.js.map +1 -0
- package/package.json +90 -52
- package/src/config/index.ts +17 -0
- package/src/drivers/azure.driver.ts +434 -0
- package/src/drivers/base.driver.ts +436 -0
- package/src/drivers/gcs.driver.ts +366 -0
- package/src/drivers/index.ts +15 -0
- package/src/drivers/local.driver.ts +626 -0
- package/src/drivers/s3.driver.ts +459 -0
- package/src/factory/driver.factory.ts +101 -0
- package/src/index.ts +72 -0
- package/src/storage-manager.ts +801 -0
- package/src/types/storage.types.ts +561 -0
- package/src/utils/config.utils.ts +229 -0
- package/src/utils/file.utils.ts +536 -0
- package/src/utils/index.ts +35 -0
- package/src/utils/rate-limiter.ts +94 -0
- package/dist/drivers/azure.driver.d.ts +0 -88
- package/dist/drivers/azure.driver.d.ts.map +0 -1
- package/dist/drivers/azure.driver.js +0 -391
- package/dist/drivers/azure.driver.js.map +0 -1
- package/dist/drivers/base.driver.d.ts +0 -170
- package/dist/drivers/base.driver.d.ts.map +0 -1
- package/dist/drivers/base.driver.js +0 -347
- package/dist/drivers/base.driver.js.map +0 -1
- package/dist/drivers/gcs.driver.d.ts.map +0 -1
- package/dist/drivers/gcs.driver.js +0 -354
- package/dist/drivers/gcs.driver.js.map +0 -1
- package/dist/drivers/local.driver.d.ts +0 -107
- package/dist/drivers/local.driver.d.ts.map +0 -1
- package/dist/drivers/local.driver.js +0 -621
- package/dist/drivers/local.driver.js.map +0 -1
- package/dist/drivers/s3.driver.d.ts.map +0 -1
- package/dist/drivers/s3.driver.js +0 -387
- package/dist/drivers/s3.driver.js.map +0 -1
- package/dist/factory/driver.factory.d.ts +0 -62
- package/dist/factory/driver.factory.d.ts.map +0 -1
- package/dist/factory/driver.factory.js +0 -177
- package/dist/factory/driver.factory.js.map +0 -1
- package/dist/index.d.ts +0 -30
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -33
- package/dist/index.js.map +0 -1
- package/dist/storage-manager.d.ts +0 -228
- package/dist/storage-manager.d.ts.map +0 -1
- package/dist/storage-manager.js +0 -715
- package/dist/storage-manager.js.map +0 -1
- package/dist/types/storage.types.d.ts +0 -295
- package/dist/types/storage.types.d.ts.map +0 -1
- package/dist/types/storage.types.js.map +0 -1
- package/dist/utils/config.utils.d.ts.map +0 -1
- package/dist/utils/config.utils.js.map +0 -1
- package/dist/utils/file.utils.d.ts.map +0 -1
- package/dist/utils/file.utils.js +0 -278
- package/dist/utils/file.utils.js.map +0 -1
- /package/dist/{utils → cjs/utils}/config.utils.d.ts +0 -0
- /package/dist/{types → esm/types}/storage.types.js +0 -0
- /package/dist/{utils → esm/utils}/config.utils.js +0 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IStorageDriver,
|
|
3
|
+
FileUploadResult,
|
|
4
|
+
DeleteResult,
|
|
5
|
+
PresignedUrlError,
|
|
6
|
+
PresignedUploadUrlResult,
|
|
7
|
+
PresignedUploadUrlSuccess,
|
|
8
|
+
PresignedViewUrlResult,
|
|
9
|
+
PresignedViewUrlSuccess,
|
|
10
|
+
StorageConfig,
|
|
11
|
+
PublicStorageConfig,
|
|
12
|
+
StorageOptions,
|
|
13
|
+
FileValidationOptions,
|
|
14
|
+
StorageDriver,
|
|
15
|
+
BlobValidationOptions,
|
|
16
|
+
BlobValidationResult,
|
|
17
|
+
ListFilesResult,
|
|
18
|
+
UploadOptions,
|
|
19
|
+
FileMetadata,
|
|
20
|
+
FileInfo,
|
|
21
|
+
BatchOptions,
|
|
22
|
+
Logger,
|
|
23
|
+
RateLimiterAdapter,
|
|
24
|
+
StorageHooks,
|
|
25
|
+
HookErrorContext
|
|
26
|
+
} from './types/storage.types.js';
|
|
27
|
+
import { createDriver, getAvailableDrivers } from './factory/driver.factory.js';
|
|
28
|
+
import { validateStorageConfig, loadEnvironmentConfig, environmentToStorageConfig } from './utils/config.utils.js';
|
|
29
|
+
import { generateUniqueFileName, validateFileName, hasPathTraversal, isValidMimeType, validateFolderPath, validateFileForUpload, withConcurrencyLimit, formatFileSize } from './utils/file.utils.js';
|
|
30
|
+
import { InMemoryRateLimiter, isRateLimiterAdapter } from './utils/rate-limiter.js';
|
|
31
|
+
|
|
32
|
+
/** 5 GB — default maximum file size */
|
|
33
|
+
const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
const noopLogger: Logger = {
|
|
36
|
+
debug: () => {},
|
|
37
|
+
info: () => {},
|
|
38
|
+
warn: () => {},
|
|
39
|
+
error: () => {},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* StorageManager - Your single point of contact for all file operations.
|
|
44
|
+
*
|
|
45
|
+
* Think of it as a universal remote that works with any storage provider.
|
|
46
|
+
* You don't need to know the specifics of S3, GCS, Azure, or local storage —
|
|
47
|
+
* just tell StorageManager what you want to do and it handles the rest.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* // The simplest setup - just reads from your .env file
|
|
51
|
+
* const storage = new StorageManager();
|
|
52
|
+
*
|
|
53
|
+
* // Full-featured setup
|
|
54
|
+
* const storage = new StorageManager({
|
|
55
|
+
* driver: 's3',
|
|
56
|
+
* credentials: { bucketName: 'my-bucket', awsRegion: 'us-east-1' },
|
|
57
|
+
* hooks: {
|
|
58
|
+
* beforeUpload: async (file) => { await virusScan(file.buffer); },
|
|
59
|
+
* afterUpload: (result) => { auditLog('file_uploaded', result); },
|
|
60
|
+
* },
|
|
61
|
+
* rateLimiter: { maxRequests: 100, windowMs: 60000 },
|
|
62
|
+
* concurrency: 5,
|
|
63
|
+
* });
|
|
64
|
+
*/
|
|
65
|
+
export class StorageManager {
|
|
66
|
+
private driver: IStorageDriver;
|
|
67
|
+
private readonly config: StorageConfig;
|
|
68
|
+
private readonly logger: Logger;
|
|
69
|
+
private rateLimiter: RateLimiterAdapter | null = null;
|
|
70
|
+
private hooks: StorageHooks;
|
|
71
|
+
private readonly concurrency: number;
|
|
72
|
+
private destroyed = false;
|
|
73
|
+
|
|
74
|
+
constructor(options?: StorageOptions) {
|
|
75
|
+
this.logger = options?.logger || noopLogger;
|
|
76
|
+
this.config = this.buildConfig(options);
|
|
77
|
+
this.hooks = options?.hooks || {};
|
|
78
|
+
this.concurrency = options?.concurrency ?? 10;
|
|
79
|
+
|
|
80
|
+
// Initialize rate limiter — accepts either plain options or a custom adapter
|
|
81
|
+
if (options?.rateLimiter) {
|
|
82
|
+
if (isRateLimiterAdapter(options.rateLimiter)) {
|
|
83
|
+
this.rateLimiter = options.rateLimiter;
|
|
84
|
+
this.logger.debug('Custom rate limiter adapter configured');
|
|
85
|
+
} else {
|
|
86
|
+
this.rateLimiter = new InMemoryRateLimiter(options.rateLimiter);
|
|
87
|
+
this.logger.debug('In-memory rate limiting enabled', {
|
|
88
|
+
maxRequests: options.rateLimiter.maxRequests,
|
|
89
|
+
windowMs: options.rateLimiter.windowMs || 60000
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.logger.debug('StorageManager initializing', { driver: this.config.driver });
|
|
95
|
+
|
|
96
|
+
const validation = validateStorageConfig(this.config);
|
|
97
|
+
if (!validation.isValid) {
|
|
98
|
+
this.logger.error('Configuration validation failed', { errors: validation.errors });
|
|
99
|
+
throw new Error(`Configuration validation failed: ${validation.errors.join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.driver = createDriver(this.config);
|
|
103
|
+
this.logger.info('StorageManager initialized', { driver: this.config.driver });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Builds the final configuration by merging environment variables with any
|
|
108
|
+
* options you passed in. Your explicit options always win over env vars.
|
|
109
|
+
*/
|
|
110
|
+
private buildConfig(options?: StorageOptions): StorageConfig {
|
|
111
|
+
const envConfig = loadEnvironmentConfig();
|
|
112
|
+
const baseConfig = environmentToStorageConfig(envConfig);
|
|
113
|
+
|
|
114
|
+
if (!options) {
|
|
115
|
+
return {
|
|
116
|
+
...baseConfig,
|
|
117
|
+
driver: baseConfig.driver ?? 'local',
|
|
118
|
+
maxFileSize: baseConfig.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const creds = options.credentials || {};
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
driver: options.driver ?? baseConfig.driver ?? 'local',
|
|
126
|
+
bucketName: creds.bucketName ?? baseConfig.bucketName,
|
|
127
|
+
bucketPath: creds.bucketPath ?? baseConfig.bucketPath ?? '',
|
|
128
|
+
localPath: creds.localPath ?? baseConfig.localPath ?? 'public/express-storage',
|
|
129
|
+
presignedUrlExpiry: creds.presignedUrlExpiry ?? baseConfig.presignedUrlExpiry ?? 600,
|
|
130
|
+
maxFileSize: creds.maxFileSize ?? baseConfig.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
|
|
131
|
+
|
|
132
|
+
awsRegion: creds.awsRegion ?? baseConfig.awsRegion,
|
|
133
|
+
awsAccessKey: creds.awsAccessKey ?? baseConfig.awsAccessKey,
|
|
134
|
+
awsSecretKey: creds.awsSecretKey ?? baseConfig.awsSecretKey,
|
|
135
|
+
|
|
136
|
+
gcsProjectId: creds.gcsProjectId ?? baseConfig.gcsProjectId,
|
|
137
|
+
gcsCredentials: creds.gcsCredentials ?? baseConfig.gcsCredentials,
|
|
138
|
+
|
|
139
|
+
azureConnectionString: creds.azureConnectionString ?? baseConfig.azureConnectionString,
|
|
140
|
+
azureAccountName: creds.azureAccountName ?? baseConfig.azureAccountName,
|
|
141
|
+
azureAccountKey: creds.azureAccountKey ?? baseConfig.azureAccountKey,
|
|
142
|
+
azureContainerName: creds.azureContainerName ?? baseConfig.azureContainerName,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private assertNotDestroyed(): void {
|
|
147
|
+
if (this.destroyed) {
|
|
148
|
+
throw new Error('StorageManager has been destroyed and cannot be reused. Create a new instance.');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Upload methods
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Uploads a single file to your configured storage.
|
|
158
|
+
*
|
|
159
|
+
* @param file - The file from Multer (req.file)
|
|
160
|
+
* @param validation - Optional rules like max size and allowed types
|
|
161
|
+
* @param uploadOptions - Optional metadata, cache headers, etc.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* const result = await storage.uploadFile(req.file, {
|
|
165
|
+
* maxSize: 5 * 1024 * 1024,
|
|
166
|
+
* allowedMimeTypes: ['image/jpeg', 'image/png'],
|
|
167
|
+
* });
|
|
168
|
+
* if (result.success) {
|
|
169
|
+
* console.log(result.reference, result.fileUrl);
|
|
170
|
+
* }
|
|
171
|
+
*/
|
|
172
|
+
async uploadFile(
|
|
173
|
+
file: Express.Multer.File,
|
|
174
|
+
validation?: FileValidationOptions,
|
|
175
|
+
uploadOptions?: UploadOptions
|
|
176
|
+
): Promise<FileUploadResult> {
|
|
177
|
+
this.assertNotDestroyed();
|
|
178
|
+
if (!file) {
|
|
179
|
+
this.logger.warn('uploadFile called with null/undefined file');
|
|
180
|
+
return { success: false, error: 'No file provided', code: 'NO_FILE' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.logger.debug('uploadFile called', {
|
|
184
|
+
originalName: file.originalname,
|
|
185
|
+
size: file.size,
|
|
186
|
+
mimeType: file.mimetype
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return this.executeSingleUpload(file, validation, uploadOptions, 'upload');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Uploads multiple files at once.
|
|
194
|
+
* Files are processed in parallel (up to concurrency limit) for speed,
|
|
195
|
+
* but each file gets its own result — one failure doesn't stop the others.
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* const results = await storage.uploadFiles(req.files, {
|
|
199
|
+
* maxSize: 10 * 1024 * 1024,
|
|
200
|
+
* });
|
|
201
|
+
* const uploaded = results.filter(r => r.success);
|
|
202
|
+
* const failed = results.filter(r => !r.success);
|
|
203
|
+
*/
|
|
204
|
+
async uploadFiles(
|
|
205
|
+
files: Express.Multer.File[],
|
|
206
|
+
validation?: FileValidationOptions,
|
|
207
|
+
uploadOptions?: UploadOptions,
|
|
208
|
+
options?: BatchOptions
|
|
209
|
+
): Promise<FileUploadResult[]> {
|
|
210
|
+
this.assertNotDestroyed();
|
|
211
|
+
if (!files || files.length === 0) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return withConcurrencyLimit(
|
|
216
|
+
files,
|
|
217
|
+
(file) => this.executeSingleUpload(file, validation, uploadOptions, 'uploadMultiple'),
|
|
218
|
+
{ maxConcurrent: this.concurrency, signal: options?.signal }
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Presigned URL methods
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Creates a presigned URL that lets clients upload directly to cloud storage.
|
|
228
|
+
*
|
|
229
|
+
* The URL is time-limited and (for S3/GCS) locked to specific file constraints.
|
|
230
|
+
*
|
|
231
|
+
* @param fileName - What the user wants to call their file
|
|
232
|
+
* @param contentType - The MIME type (e.g., 'image/jpeg')
|
|
233
|
+
* @param fileSize - Exact size in bytes (enforced by S3/GCS, advisory for Azure)
|
|
234
|
+
* @param folder - Where to put the file (overrides your default BUCKET_PATH)
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* const result = await storage.generateUploadUrl('photo.jpg', 'image/jpeg', 204800);
|
|
238
|
+
* if (result.success) {
|
|
239
|
+
* // result.uploadUrl — PUT request goes here
|
|
240
|
+
* // result.reference — save this to confirm/view/delete later
|
|
241
|
+
* // result.expiresIn — seconds until URL expires
|
|
242
|
+
* }
|
|
243
|
+
*/
|
|
244
|
+
async generateUploadUrl(
|
|
245
|
+
fileName: string,
|
|
246
|
+
contentType?: string,
|
|
247
|
+
fileSize?: number,
|
|
248
|
+
folder?: string
|
|
249
|
+
): Promise<PresignedUploadUrlResult> {
|
|
250
|
+
this.assertNotDestroyed();
|
|
251
|
+
const rateLimitError = await this.checkRateLimit();
|
|
252
|
+
if (rateLimitError) return rateLimitError;
|
|
253
|
+
|
|
254
|
+
const fileNameError = validateFileName(fileName);
|
|
255
|
+
if (fileNameError) {
|
|
256
|
+
return { success: false, error: fileNameError, code: 'INVALID_FILENAME' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (fileSize !== undefined) {
|
|
260
|
+
if (typeof fileSize !== 'number' || Number.isNaN(fileSize) || fileSize < 0) {
|
|
261
|
+
return { success: false, error: 'fileSize must be a non-negative number', code: 'INVALID_INPUT' };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const maxAllowedSize = this.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
265
|
+
const effectiveMaxSize = maxAllowedSize > 0 ? maxAllowedSize : DEFAULT_MAX_FILE_SIZE;
|
|
266
|
+
|
|
267
|
+
if (fileSize > effectiveMaxSize) {
|
|
268
|
+
return {
|
|
269
|
+
success: false,
|
|
270
|
+
error: `fileSize cannot exceed ${effectiveMaxSize} bytes (${formatFileSize(effectiveMaxSize)})`,
|
|
271
|
+
code: 'FILE_TOO_LARGE',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (contentType && !isValidMimeType(contentType)) {
|
|
277
|
+
return {
|
|
278
|
+
success: false,
|
|
279
|
+
error: `Invalid contentType format: '${contentType}'. Expected format: type/subtype (e.g., 'image/jpeg')`,
|
|
280
|
+
code: 'INVALID_INPUT',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const uniqueFileName = generateUniqueFileName(fileName);
|
|
285
|
+
const effectiveFolder = folder !== undefined ? folder : (this.config.bucketPath || '');
|
|
286
|
+
|
|
287
|
+
if (effectiveFolder) {
|
|
288
|
+
const folderValidationError = validateFolderPath(effectiveFolder);
|
|
289
|
+
if (folderValidationError) {
|
|
290
|
+
return { success: false, error: folderValidationError, code: 'PATH_TRAVERSAL' };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const reference = this.buildFilePath(uniqueFileName, effectiveFolder);
|
|
295
|
+
const result = await this.driver.generateUploadUrl(reference, contentType, fileSize);
|
|
296
|
+
|
|
297
|
+
if (result.success) {
|
|
298
|
+
const response: PresignedUploadUrlSuccess = {
|
|
299
|
+
success: true,
|
|
300
|
+
fileName: uniqueFileName,
|
|
301
|
+
reference,
|
|
302
|
+
uploadUrl: result.uploadUrl ?? '',
|
|
303
|
+
expiresIn: this.config.presignedUrlExpiry || 600,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
if (effectiveFolder) {
|
|
307
|
+
response.filePath = effectiveFolder;
|
|
308
|
+
}
|
|
309
|
+
if (contentType) {
|
|
310
|
+
response.contentType = contentType;
|
|
311
|
+
}
|
|
312
|
+
if (fileSize !== undefined) {
|
|
313
|
+
response.fileSize = fileSize;
|
|
314
|
+
}
|
|
315
|
+
if (this.config.driver === 'azure-presigned') {
|
|
316
|
+
response.requiresValidation = true;
|
|
317
|
+
}
|
|
318
|
+
return response;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Creates a presigned URL for viewing/downloading an existing file.
|
|
326
|
+
*
|
|
327
|
+
* @param reference - The full path you got from generateUploadUrl
|
|
328
|
+
*/
|
|
329
|
+
async generateViewUrl(reference: string): Promise<PresignedViewUrlResult> {
|
|
330
|
+
this.assertNotDestroyed();
|
|
331
|
+
const rateLimitError = await this.checkRateLimit();
|
|
332
|
+
if (rateLimitError) return rateLimitError;
|
|
333
|
+
|
|
334
|
+
if (hasPathTraversal(reference)) {
|
|
335
|
+
return {
|
|
336
|
+
success: false,
|
|
337
|
+
error: 'Invalid reference: path traversal sequences are not allowed',
|
|
338
|
+
code: 'PATH_TRAVERSAL',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const result = await this.driver.generateViewUrl(reference);
|
|
343
|
+
|
|
344
|
+
if (result.success) {
|
|
345
|
+
const response: PresignedViewUrlSuccess = {
|
|
346
|
+
success: true,
|
|
347
|
+
reference,
|
|
348
|
+
viewUrl: result.viewUrl ?? '',
|
|
349
|
+
expiresIn: this.config.presignedUrlExpiry || 600,
|
|
350
|
+
};
|
|
351
|
+
return response;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Verifies that a presigned upload actually happened and the file is valid.
|
|
359
|
+
*
|
|
360
|
+
* For Azure, this is essential — Azure doesn't enforce file constraints at
|
|
361
|
+
* the URL level, so we check the actual blob properties here.
|
|
362
|
+
* For S3/GCS, this confirms the file exists and optionally validates it.
|
|
363
|
+
*/
|
|
364
|
+
async validateAndConfirmUpload(
|
|
365
|
+
reference: string,
|
|
366
|
+
options?: BlobValidationOptions
|
|
367
|
+
): Promise<BlobValidationResult> {
|
|
368
|
+
this.assertNotDestroyed();
|
|
369
|
+
if (hasPathTraversal(reference)) {
|
|
370
|
+
return {
|
|
371
|
+
success: false,
|
|
372
|
+
error: 'Invalid reference: path traversal sequences are not allowed',
|
|
373
|
+
code: 'PATH_TRAVERSAL',
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return this.driver.validateAndConfirmUpload(reference, options);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Returns true if you're using Azure presigned mode.
|
|
382
|
+
* Your hint that you MUST call validateAndConfirmUpload() after presigned uploads.
|
|
383
|
+
*/
|
|
384
|
+
requiresPostUploadValidation(): boolean {
|
|
385
|
+
return this.config.driver === 'azure-presigned';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Creates presigned upload URLs for multiple files at once.
|
|
390
|
+
*/
|
|
391
|
+
async generateUploadUrls(
|
|
392
|
+
files: (string | FileMetadata)[],
|
|
393
|
+
folder?: string,
|
|
394
|
+
options?: BatchOptions
|
|
395
|
+
): Promise<PresignedUploadUrlResult[]> {
|
|
396
|
+
this.assertNotDestroyed();
|
|
397
|
+
if (!files || files.length === 0) {
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const effectiveFolder = folder !== undefined ? folder : (this.config.bucketPath || '');
|
|
402
|
+
|
|
403
|
+
return withConcurrencyLimit(
|
|
404
|
+
files,
|
|
405
|
+
async (file): Promise<PresignedUploadUrlResult> => {
|
|
406
|
+
if (file === null || file === undefined) {
|
|
407
|
+
return {
|
|
408
|
+
success: false,
|
|
409
|
+
error: 'Invalid input: file entry cannot be null or undefined',
|
|
410
|
+
code: 'INVALID_INPUT',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (typeof file === 'string') {
|
|
415
|
+
return this.generateUploadUrl(file, undefined, undefined, effectiveFolder);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (typeof file !== 'object') {
|
|
419
|
+
return {
|
|
420
|
+
success: false,
|
|
421
|
+
error: `Invalid input type: expected string or FileMetadata object, got ${typeof file}`,
|
|
422
|
+
code: 'INVALID_INPUT',
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!file.fileName || typeof file.fileName !== 'string') {
|
|
427
|
+
return {
|
|
428
|
+
success: false,
|
|
429
|
+
error: 'FileMetadata must have a valid fileName property',
|
|
430
|
+
code: 'INVALID_INPUT',
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return this.generateUploadUrl(
|
|
435
|
+
file.fileName,
|
|
436
|
+
file.contentType,
|
|
437
|
+
file.fileSize,
|
|
438
|
+
effectiveFolder
|
|
439
|
+
);
|
|
440
|
+
},
|
|
441
|
+
{ maxConcurrent: this.concurrency, signal: options?.signal }
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Creates presigned view URLs for multiple files at once.
|
|
447
|
+
*/
|
|
448
|
+
async generateViewUrls(
|
|
449
|
+
references: string[],
|
|
450
|
+
options?: BatchOptions
|
|
451
|
+
): Promise<PresignedViewUrlResult[]> {
|
|
452
|
+
this.assertNotDestroyed();
|
|
453
|
+
if (!references || references.length === 0) {
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return withConcurrencyLimit(
|
|
458
|
+
references,
|
|
459
|
+
async (reference): Promise<PresignedViewUrlResult> => {
|
|
460
|
+
if (reference === null || reference === undefined || typeof reference !== 'string') {
|
|
461
|
+
return {
|
|
462
|
+
success: false,
|
|
463
|
+
error: 'Invalid reference: must be a non-null string',
|
|
464
|
+
code: 'INVALID_INPUT',
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
return this.generateViewUrl(reference);
|
|
468
|
+
},
|
|
469
|
+
{ maxConcurrent: this.concurrency, signal: options?.signal }
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
// Delete methods
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Deletes a single file from storage.
|
|
479
|
+
*
|
|
480
|
+
* @param reference - The full path from uploadFile result or generateUploadUrl
|
|
481
|
+
* @returns DeleteResult with success status and error details on failure
|
|
482
|
+
*
|
|
483
|
+
* @example
|
|
484
|
+
* const result = await storage.deleteFile(uploadResult.reference);
|
|
485
|
+
* if (!result.success) {
|
|
486
|
+
* console.log(result.error, result.code); // e.g., 'FILE_NOT_FOUND'
|
|
487
|
+
* }
|
|
488
|
+
*/
|
|
489
|
+
async deleteFile(reference: string): Promise<DeleteResult> {
|
|
490
|
+
this.assertNotDestroyed();
|
|
491
|
+
this.logger.debug('deleteFile called', { reference });
|
|
492
|
+
return this.executeSingleDelete(reference, 'delete');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Deletes multiple files at once.
|
|
497
|
+
*/
|
|
498
|
+
async deleteFiles(references: string[], options?: BatchOptions): Promise<DeleteResult[]> {
|
|
499
|
+
this.assertNotDestroyed();
|
|
500
|
+
if (!references || references.length === 0) {
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return withConcurrencyLimit(
|
|
505
|
+
references,
|
|
506
|
+
(reference) => this.executeSingleDelete(reference, 'deleteMultiple'),
|
|
507
|
+
{ maxConcurrent: this.concurrency, signal: options?.signal }
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
// List files
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Lists files in your storage with optional filtering and pagination.
|
|
517
|
+
*
|
|
518
|
+
* @param prefix - Only show files starting with this path
|
|
519
|
+
* @param maxResults - How many files to return per page (default: 1000)
|
|
520
|
+
* @param continuationToken - Pass nextToken from previous response for next page
|
|
521
|
+
*/
|
|
522
|
+
async listFiles(
|
|
523
|
+
prefix?: string,
|
|
524
|
+
maxResults?: number,
|
|
525
|
+
continuationToken?: string
|
|
526
|
+
): Promise<ListFilesResult> {
|
|
527
|
+
this.assertNotDestroyed();
|
|
528
|
+
if (prefix && hasPathTraversal(prefix)) {
|
|
529
|
+
return {
|
|
530
|
+
success: false,
|
|
531
|
+
error: 'Invalid prefix: path traversal sequences are not allowed',
|
|
532
|
+
code: 'PATH_TRAVERSAL',
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return this.driver.listFiles(prefix, maxResults, continuationToken);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
// File metadata
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Returns metadata about a file without downloading it.
|
|
545
|
+
*
|
|
546
|
+
* @param reference - The full path from uploadFile result or generateUploadUrl
|
|
547
|
+
* @returns FileInfo with name, size, contentType, lastModified — or null if not found
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* const info = await storage.getMetadata(uploadResult.reference);
|
|
551
|
+
* if (info) {
|
|
552
|
+
* console.log(`${info.name}: ${info.size} bytes, ${info.contentType}`);
|
|
553
|
+
* }
|
|
554
|
+
*/
|
|
555
|
+
async getMetadata(reference: string): Promise<FileInfo | null> {
|
|
556
|
+
this.assertNotDestroyed();
|
|
557
|
+
if (hasPathTraversal(reference)) {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
return this.driver.getMetadata(reference);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Returns true if a file exists at the given reference.
|
|
565
|
+
*
|
|
566
|
+
* @param reference - The full path from uploadFile result or generateUploadUrl
|
|
567
|
+
*/
|
|
568
|
+
async exists(reference: string): Promise<boolean> {
|
|
569
|
+
const metadata = await this.getMetadata(reference);
|
|
570
|
+
return metadata !== null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// Configuration accessors
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Returns a copy of the current configuration without credentials.
|
|
579
|
+
* Safe to log, expose in admin panels, or include in error reports.
|
|
580
|
+
*/
|
|
581
|
+
getConfig(): PublicStorageConfig {
|
|
582
|
+
return {
|
|
583
|
+
driver: this.config.driver,
|
|
584
|
+
bucketName: this.config.bucketName,
|
|
585
|
+
bucketPath: this.config.bucketPath,
|
|
586
|
+
localPath: this.config.localPath,
|
|
587
|
+
presignedUrlExpiry: this.config.presignedUrlExpiry,
|
|
588
|
+
maxFileSize: this.config.maxFileSize,
|
|
589
|
+
awsRegion: this.config.awsRegion,
|
|
590
|
+
gcsProjectId: this.config.gcsProjectId,
|
|
591
|
+
azureAccountName: this.config.azureAccountName,
|
|
592
|
+
azureContainerName: this.config.azureContainerName,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
getDriverType(): StorageDriver {
|
|
597
|
+
return this.config.driver;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Returns true if the driver operates in presigned mode.
|
|
602
|
+
* In presigned mode, upload() returns URLs instead of uploading directly.
|
|
603
|
+
*/
|
|
604
|
+
isPresignedUploadMode(): boolean {
|
|
605
|
+
return this.config.driver.includes('-presigned');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Returns rate limit status information.
|
|
610
|
+
* Returns null if rate limiting is not configured.
|
|
611
|
+
*/
|
|
612
|
+
async getRateLimitStatus(): Promise<{ remainingRequests: number; resetTimeMs: number } | null> {
|
|
613
|
+
this.assertNotDestroyed();
|
|
614
|
+
if (!this.rateLimiter) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
return {
|
|
618
|
+
remainingRequests: await this.rateLimiter.getRemainingRequests(),
|
|
619
|
+
resetTimeMs: await this.rateLimiter.getResetTime(),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
static getAvailableDrivers(): StorageDriver[] {
|
|
624
|
+
return getAvailableDrivers() as StorageDriver[];
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Releases resources held by this StorageManager instance.
|
|
629
|
+
* Clears the rate limiter and hooks. The instance should not be reused
|
|
630
|
+
* after calling this method.
|
|
631
|
+
*
|
|
632
|
+
* @example
|
|
633
|
+
* const storage = new StorageManager({ driver: 's3' });
|
|
634
|
+
* // ... use storage ...
|
|
635
|
+
* storage.destroy(); // free resources
|
|
636
|
+
*/
|
|
637
|
+
destroy(): void {
|
|
638
|
+
if (this.destroyed) return;
|
|
639
|
+
this.destroyed = true;
|
|
640
|
+
this.driver.destroy();
|
|
641
|
+
this.rateLimiter = null;
|
|
642
|
+
this.hooks = {};
|
|
643
|
+
this.logger.info('StorageManager destroyed');
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
isDestroyed(): boolean {
|
|
647
|
+
return this.destroyed;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
// Private helpers
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Shared upload orchestration: validation → beforeUpload hook → driver.upload → afterUpload hook.
|
|
656
|
+
* Used by both uploadFile() and uploadFiles() to eliminate duplication.
|
|
657
|
+
*/
|
|
658
|
+
private async executeSingleUpload(
|
|
659
|
+
file: Express.Multer.File,
|
|
660
|
+
validation: FileValidationOptions | undefined,
|
|
661
|
+
uploadOptions: UploadOptions | undefined,
|
|
662
|
+
operation: 'upload' | 'uploadMultiple'
|
|
663
|
+
): Promise<FileUploadResult> {
|
|
664
|
+
if (validation) {
|
|
665
|
+
const validationResult = validateFileForUpload(file, validation);
|
|
666
|
+
if (validationResult) {
|
|
667
|
+
const error = operation === 'uploadMultiple'
|
|
668
|
+
? `File '${file.originalname || 'unknown'}': ${validationResult.error}`
|
|
669
|
+
: validationResult.error;
|
|
670
|
+
this.logger.warn('File validation failed', { error: validationResult.error });
|
|
671
|
+
return { success: false, error, code: validationResult.code };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (uploadOptions?.contentType && !isValidMimeType(uploadOptions.contentType)) {
|
|
676
|
+
const error = `Invalid contentType format: '${uploadOptions.contentType}'. Expected format: type/subtype (e.g., 'image/jpeg')`;
|
|
677
|
+
return { success: false, error, code: 'INVALID_INPUT' };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
await this.hooks.beforeUpload?.(file, uploadOptions);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
const hookError = error instanceof Error ? error : new Error(String(error));
|
|
684
|
+
await this.invokeOnError(hookError, { operation, file });
|
|
685
|
+
const msg = operation === 'uploadMultiple'
|
|
686
|
+
? `File '${file.originalname}': Upload aborted by hook: ${hookError.message}`
|
|
687
|
+
: `Upload aborted by hook: ${hookError.message}`;
|
|
688
|
+
this.logger.warn('beforeUpload hook aborted upload', { error: hookError.message });
|
|
689
|
+
return { success: false, error: msg, code: 'HOOK_ABORTED' };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
let result: FileUploadResult;
|
|
693
|
+
try {
|
|
694
|
+
result = await this.driver.upload(file, uploadOptions);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
const errorMsg = error instanceof Error ? error.message : 'Failed to upload file';
|
|
697
|
+
result = {
|
|
698
|
+
success: false,
|
|
699
|
+
error: operation === 'uploadMultiple' ? `File '${file.originalname}': ${errorMsg}` : errorMsg,
|
|
700
|
+
code: 'PROVIDER_ERROR',
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (result.success) {
|
|
705
|
+
this.logger.info('File uploaded successfully', { reference: result.reference });
|
|
706
|
+
} else {
|
|
707
|
+
this.logger.error('File upload failed', { error: result.error });
|
|
708
|
+
await this.invokeOnError(new Error(result.error), { operation, file });
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
await this.hooks.afterUpload?.(result, file);
|
|
713
|
+
} catch (hookError) {
|
|
714
|
+
this.logger.warn('afterUpload hook threw', { error: hookError instanceof Error ? hookError.message : String(hookError) });
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return result;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Shared delete orchestration: path check → beforeDelete hook → driver.delete → afterDelete hook.
|
|
722
|
+
* Used by both deleteFile() and deleteFiles() to eliminate duplication.
|
|
723
|
+
*/
|
|
724
|
+
private async executeSingleDelete(reference: string, operation: 'delete' | 'deleteMultiple'): Promise<DeleteResult> {
|
|
725
|
+
if (hasPathTraversal(reference)) {
|
|
726
|
+
this.logger.warn('delete rejected: path traversal attempt', { reference });
|
|
727
|
+
return { success: false, reference, error: 'Invalid reference: path traversal sequences are not allowed', code: 'PATH_TRAVERSAL' };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
await this.hooks.beforeDelete?.(reference);
|
|
732
|
+
} catch (error) {
|
|
733
|
+
const hookError = error instanceof Error ? error : new Error(String(error));
|
|
734
|
+
await this.invokeOnError(hookError, { operation, reference });
|
|
735
|
+
return { success: false, reference, error: `Deletion aborted by hook: ${hookError.message}`, code: 'HOOK_ABORTED' };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
let result: DeleteResult;
|
|
739
|
+
try {
|
|
740
|
+
result = await this.driver.delete(reference);
|
|
741
|
+
} catch (error) {
|
|
742
|
+
result = { success: false, reference, error: error instanceof Error ? error.message : 'Failed to delete file', code: 'PROVIDER_ERROR' };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
try {
|
|
746
|
+
await this.hooks.afterDelete?.(reference, result.success);
|
|
747
|
+
} catch {
|
|
748
|
+
// afterDelete hook errors are non-fatal
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (result.success) {
|
|
752
|
+
this.logger.info('File deleted successfully', { reference });
|
|
753
|
+
} else {
|
|
754
|
+
this.logger.warn('File deletion failed', { reference, error: result.error });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return result;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private async checkRateLimit(): Promise<PresignedUrlError | null> {
|
|
761
|
+
if (!this.rateLimiter) return null;
|
|
762
|
+
|
|
763
|
+
const allowed = await this.rateLimiter.tryAcquire();
|
|
764
|
+
if (!allowed) {
|
|
765
|
+
const resetTime = await this.rateLimiter.getResetTime();
|
|
766
|
+
this.logger.warn('Rate limit exceeded for presigned URL generation', { resetTimeMs: resetTime });
|
|
767
|
+
return {
|
|
768
|
+
success: false,
|
|
769
|
+
error: `Rate limit exceeded. Try again in ${Math.ceil(resetTime / 1000)} seconds.`,
|
|
770
|
+
code: 'RATE_LIMITED',
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private buildFilePath(fileName: string, folder?: string): string {
|
|
778
|
+
if (!folder) {
|
|
779
|
+
return fileName;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const normalizedFolder = folder.replace(/^\/+|\/+$/g, '');
|
|
783
|
+
if (!normalizedFolder) {
|
|
784
|
+
return fileName;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return `${normalizedFolder}/${fileName}`;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Safely invokes the onError hook. Swallows hook exceptions to prevent
|
|
792
|
+
* error-in-error-handler cascades.
|
|
793
|
+
*/
|
|
794
|
+
private async invokeOnError(error: Error, context: HookErrorContext): Promise<void> {
|
|
795
|
+
try {
|
|
796
|
+
await this.hooks.onError?.(error, context);
|
|
797
|
+
} catch {
|
|
798
|
+
// Never let an error hook crash the caller
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|