@strav/kernel 0.1.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.
Files changed (64) hide show
  1. package/package.json +59 -0
  2. package/src/cache/cache_manager.ts +60 -0
  3. package/src/cache/cache_store.ts +31 -0
  4. package/src/cache/helpers.ts +74 -0
  5. package/src/cache/index.ts +4 -0
  6. package/src/cache/memory_store.ts +63 -0
  7. package/src/config/configuration.ts +105 -0
  8. package/src/config/index.ts +2 -0
  9. package/src/config/loaders/base_loader.ts +69 -0
  10. package/src/config/loaders/env_loader.ts +112 -0
  11. package/src/config/loaders/typescript_loader.ts +56 -0
  12. package/src/config/types.ts +8 -0
  13. package/src/core/application.ts +241 -0
  14. package/src/core/container.ts +113 -0
  15. package/src/core/index.ts +4 -0
  16. package/src/core/inject.ts +39 -0
  17. package/src/core/service_provider.ts +44 -0
  18. package/src/encryption/encryption_manager.ts +215 -0
  19. package/src/encryption/helpers.ts +158 -0
  20. package/src/encryption/index.ts +3 -0
  21. package/src/encryption/types.ts +6 -0
  22. package/src/events/emitter.ts +101 -0
  23. package/src/events/index.ts +2 -0
  24. package/src/exceptions/errors.ts +71 -0
  25. package/src/exceptions/exception_handler.ts +140 -0
  26. package/src/exceptions/helpers.ts +25 -0
  27. package/src/exceptions/http_exception.ts +132 -0
  28. package/src/exceptions/index.ts +23 -0
  29. package/src/exceptions/strav_error.ts +11 -0
  30. package/src/helpers/compose.ts +104 -0
  31. package/src/helpers/crypto.ts +4 -0
  32. package/src/helpers/env.ts +50 -0
  33. package/src/helpers/index.ts +6 -0
  34. package/src/helpers/strings.ts +67 -0
  35. package/src/helpers/ulid.ts +28 -0
  36. package/src/i18n/defaults/en/validation.json +20 -0
  37. package/src/i18n/helpers.ts +76 -0
  38. package/src/i18n/i18n_manager.ts +157 -0
  39. package/src/i18n/index.ts +3 -0
  40. package/src/i18n/translator.ts +96 -0
  41. package/src/i18n/types.ts +17 -0
  42. package/src/index.ts +11 -0
  43. package/src/logger/index.ts +5 -0
  44. package/src/logger/logger.ts +113 -0
  45. package/src/logger/sinks/console_sink.ts +24 -0
  46. package/src/logger/sinks/file_sink.ts +24 -0
  47. package/src/logger/sinks/sink.ts +36 -0
  48. package/src/providers/cache_provider.ts +16 -0
  49. package/src/providers/config_provider.ts +26 -0
  50. package/src/providers/encryption_provider.ts +16 -0
  51. package/src/providers/i18n_provider.ts +17 -0
  52. package/src/providers/index.ts +8 -0
  53. package/src/providers/logger_provider.ts +16 -0
  54. package/src/providers/storage_provider.ts +16 -0
  55. package/src/storage/index.ts +32 -0
  56. package/src/storage/local_driver.ts +46 -0
  57. package/src/storage/ostra_client.ts +432 -0
  58. package/src/storage/ostra_driver.ts +58 -0
  59. package/src/storage/s3_driver.ts +51 -0
  60. package/src/storage/storage.ts +43 -0
  61. package/src/storage/storage_manager.ts +70 -0
  62. package/src/storage/types.ts +49 -0
  63. package/src/storage/upload.ts +91 -0
  64. package/tsconfig.json +5 -0
@@ -0,0 +1,432 @@
1
+ import { StravError } from '../exceptions/strav_error.ts'
2
+
3
+ type RequestBody = string | Blob | ArrayBuffer | Uint8Array | ReadableStream | FormData | null
4
+
5
+ // ─── Error ───────────────────────────────────────────────────────────
6
+
7
+ export class OstraError extends StravError {
8
+ constructor(
9
+ public readonly code: string,
10
+ message: string,
11
+ public readonly statusCode: number
12
+ ) {
13
+ super(message)
14
+ }
15
+ }
16
+
17
+ // ─── Types ───────────────────────────────────────────────────────────
18
+
19
+ export interface BucketInfo {
20
+ name: string
21
+ visibility: 'private' | 'public-read' | 'public-read-list'
22
+ versioning: 'enabled' | 'disabled'
23
+ created_at: string
24
+ }
25
+
26
+ export interface BucketStats extends BucketInfo {
27
+ object_count: number
28
+ total_size: number
29
+ version_count: number
30
+ total_version_size: number
31
+ }
32
+
33
+ export interface ObjectMeta {
34
+ key: string
35
+ size: number
36
+ content_type: string
37
+ etag: string
38
+ created_at: string
39
+ version_id?: string
40
+ }
41
+
42
+ export interface ObjectHeaders {
43
+ contentType: string
44
+ contentLength: number
45
+ etag: string
46
+ lastModified: string
47
+ versionId?: string
48
+ }
49
+
50
+ export interface ListResult {
51
+ objects: ObjectMeta[]
52
+ cursor: string | null
53
+ has_more: boolean
54
+ }
55
+
56
+ export interface VersionInfo {
57
+ version_id: string
58
+ size: number
59
+ content_type: string
60
+ etag: string
61
+ created_at: string
62
+ }
63
+
64
+ export interface VersionListResult {
65
+ key: string
66
+ versions: VersionInfo[]
67
+ cursor: string | null
68
+ has_more: boolean
69
+ }
70
+
71
+ export interface BatchDeleteResult {
72
+ deleted: string[]
73
+ errors: Array<{ key: string; code: string; message: string }>
74
+ }
75
+
76
+ export interface SignedUrlResult {
77
+ url: string
78
+ expires_at: string
79
+ }
80
+
81
+ export interface MultipartInfo {
82
+ upload_id: string
83
+ key: string
84
+ content_type: string
85
+ }
86
+
87
+ export interface PartInfo {
88
+ part_number: number
89
+ etag: string
90
+ size: number
91
+ }
92
+
93
+ export interface TokenInfo {
94
+ token: string
95
+ scope: 'root' | 'read-write' | 'read-only'
96
+ buckets: string[] | '*'
97
+ label?: string
98
+ created_at: string
99
+ }
100
+
101
+ export interface TokenRecord {
102
+ id: string
103
+ scope: 'root' | 'read-write' | 'read-only'
104
+ buckets: string[] | '*'
105
+ label?: string
106
+ created_at: string
107
+ last_used_at: string | null
108
+ }
109
+
110
+ // ─── OstraMultipart ──────────────────────────────────────────────────
111
+
112
+ export class OstraMultipart {
113
+ private parts: Array<{ part_number: number; etag: string }> = []
114
+
115
+ constructor(
116
+ private client: OstraClient,
117
+ readonly bucket: string,
118
+ readonly key: string,
119
+ readonly uploadId: string
120
+ ) {}
121
+
122
+ async part(partNumber: number, body: Blob | ArrayBuffer | Uint8Array): Promise<PartInfo> {
123
+ const result = await this.client.request<PartInfo>(
124
+ 'PUT',
125
+ `/buckets/${this.bucket}/${this.key}?partNumber=${partNumber}&uploadId=${this.uploadId}`,
126
+ body as RequestBody
127
+ )
128
+ this.parts.push({ part_number: result.part_number, etag: result.etag })
129
+ return result
130
+ }
131
+
132
+ async complete(): Promise<ObjectMeta> {
133
+ const sorted = [...this.parts].sort((a, b) => a.part_number - b.part_number)
134
+ return this.client.request<ObjectMeta>(
135
+ 'POST',
136
+ `/buckets/${this.bucket}/${this.key}?uploadId=${this.uploadId}`,
137
+ JSON.stringify({ parts: sorted }),
138
+ { 'Content-Type': 'application/json' }
139
+ )
140
+ }
141
+
142
+ async abort(): Promise<void> {
143
+ await this.client.request(
144
+ 'DELETE',
145
+ `/buckets/${this.bucket}/${this.key}?uploadId=${this.uploadId}`
146
+ )
147
+ }
148
+ }
149
+
150
+ // ─── OstraBucket ─────────────────────────────────────────────────────
151
+
152
+ export class OstraBucket {
153
+ constructor(
154
+ private client: OstraClient,
155
+ readonly name: string
156
+ ) {}
157
+
158
+ // ── Bucket management ──
159
+
160
+ async info(): Promise<BucketStats> {
161
+ return this.client.request<BucketStats>('GET', `/buckets/${this.name}`)
162
+ }
163
+
164
+ async update(options: {
165
+ visibility?: 'private' | 'public-read' | 'public-read-list'
166
+ versioning?: 'enabled' | 'disabled'
167
+ }): Promise<BucketInfo> {
168
+ return this.client.request<BucketInfo>(
169
+ 'PATCH',
170
+ `/buckets/${this.name}`,
171
+ JSON.stringify(options),
172
+ { 'Content-Type': 'application/json' }
173
+ )
174
+ }
175
+
176
+ async destroy(): Promise<void> {
177
+ await this.client.request('DELETE', `/buckets/${this.name}`)
178
+ }
179
+
180
+ // ── Objects ──
181
+
182
+ async put(
183
+ key: string,
184
+ body: Blob | File | ArrayBuffer | Uint8Array | ReadableStream,
185
+ contentType?: string
186
+ ): Promise<ObjectMeta> {
187
+ const type =
188
+ contentType ??
189
+ (body instanceof File ? body.type : undefined) ??
190
+ (body instanceof Blob ? body.type : undefined) ??
191
+ 'application/octet-stream'
192
+
193
+ const size =
194
+ body instanceof Blob || body instanceof File
195
+ ? body.size
196
+ : body instanceof ArrayBuffer || body instanceof Uint8Array
197
+ ? body.byteLength
198
+ : undefined
199
+
200
+ const headers: Record<string, string> = { 'Content-Type': type }
201
+ if (size !== undefined) headers['Content-Length'] = String(size)
202
+
203
+ return this.client.request<ObjectMeta>(
204
+ 'PUT',
205
+ `/buckets/${this.name}/${key}`,
206
+ body as RequestBody,
207
+ headers
208
+ )
209
+ }
210
+
211
+ async get(key: string, options?: { versionId?: string; range?: string }): Promise<Blob> {
212
+ const params = new URLSearchParams()
213
+ if (options?.versionId) params.set('versionId', options.versionId)
214
+ const qs = params.toString()
215
+
216
+ const headers: Record<string, string> = {}
217
+ if (options?.range) headers['Range'] = options.range
218
+
219
+ const response = await this.client.raw(
220
+ 'GET',
221
+ `/buckets/${this.name}/${key}${qs ? `?${qs}` : ''}`,
222
+ undefined,
223
+ headers
224
+ )
225
+ return response.blob()
226
+ }
227
+
228
+ async head(key: string, options?: { versionId?: string }): Promise<ObjectHeaders> {
229
+ const params = new URLSearchParams()
230
+ if (options?.versionId) params.set('versionId', options.versionId)
231
+ const qs = params.toString()
232
+
233
+ const response = await this.client.raw(
234
+ 'HEAD',
235
+ `/buckets/${this.name}/${key}${qs ? `?${qs}` : ''}`
236
+ )
237
+ return {
238
+ contentType: response.headers.get('Content-Type') ?? 'application/octet-stream',
239
+ contentLength: Number(response.headers.get('Content-Length') ?? 0),
240
+ etag: response.headers.get('ETag') ?? '',
241
+ lastModified: response.headers.get('Last-Modified') ?? '',
242
+ versionId: response.headers.get('X-Ostra-Version-Id') ?? undefined,
243
+ }
244
+ }
245
+
246
+ async delete(
247
+ key: string,
248
+ options?: { versionId?: string; allVersions?: boolean }
249
+ ): Promise<void> {
250
+ const params = new URLSearchParams()
251
+ if (options?.versionId) params.set('versionId', options.versionId)
252
+ else if (options?.allVersions) params.set('allVersions', '')
253
+ const qs = params.toString()
254
+
255
+ await this.client.request('DELETE', `/buckets/${this.name}/${key}${qs ? `?${qs}` : ''}`)
256
+ }
257
+
258
+ async list(options?: { prefix?: string; cursor?: string; limit?: number }): Promise<ListResult> {
259
+ const params = new URLSearchParams({ list: '' })
260
+ if (options?.prefix) params.set('prefix', options.prefix)
261
+ if (options?.cursor) params.set('cursor', options.cursor)
262
+ if (options?.limit) params.set('limit', String(options.limit))
263
+
264
+ return this.client.request<ListResult>('GET', `/buckets/${this.name}?${params}`)
265
+ }
266
+
267
+ async versions(
268
+ key: string,
269
+ options?: { cursor?: string; limit?: number }
270
+ ): Promise<VersionListResult> {
271
+ const params = new URLSearchParams({ versions: '' })
272
+ if (options?.cursor) params.set('cursor', options.cursor)
273
+ if (options?.limit) params.set('limit', String(options.limit))
274
+
275
+ return this.client.request<VersionListResult>('GET', `/buckets/${this.name}/${key}?${params}`)
276
+ }
277
+
278
+ async deleteMany(keys: string[]): Promise<BatchDeleteResult> {
279
+ return this.client.request<BatchDeleteResult>(
280
+ 'POST',
281
+ `/buckets/${this.name}?delete`,
282
+ JSON.stringify({ keys }),
283
+ { 'Content-Type': 'application/json' }
284
+ )
285
+ }
286
+
287
+ async copy(
288
+ key: string,
289
+ source: { bucket: string; key: string; versionId?: string }
290
+ ): Promise<ObjectMeta> {
291
+ const body: Record<string, string> = {
292
+ source_bucket: source.bucket,
293
+ source_key: source.key,
294
+ }
295
+ if (source.versionId) body.source_version_id = source.versionId
296
+
297
+ return this.client.request<ObjectMeta>(
298
+ 'POST',
299
+ `/buckets/${this.name}/${key}?copy`,
300
+ JSON.stringify(body),
301
+ { 'Content-Type': 'application/json' }
302
+ )
303
+ }
304
+
305
+ async signedUrl(key: string, method: 'GET' | 'PUT', expiresIn = 3600): Promise<SignedUrlResult> {
306
+ return this.client.request<SignedUrlResult>(
307
+ 'POST',
308
+ `/buckets/${this.name}/${key}?sign`,
309
+ JSON.stringify({ method, expires_in: expiresIn }),
310
+ { 'Content-Type': 'application/json' }
311
+ )
312
+ }
313
+
314
+ async multipart(key: string, contentType: string): Promise<OstraMultipart> {
315
+ const info = await this.client.request<MultipartInfo>(
316
+ 'POST',
317
+ `/buckets/${this.name}/${key}?uploads`,
318
+ JSON.stringify({ content_type: contentType }),
319
+ { 'Content-Type': 'application/json' }
320
+ )
321
+ return new OstraMultipart(this.client, this.name, key, info.upload_id)
322
+ }
323
+ }
324
+
325
+ // ─── OstraClient ─────────────────────────────────────────────────────
326
+
327
+ export interface OstraClientConfig {
328
+ url: string
329
+ token: string
330
+ }
331
+
332
+ export default class OstraClient {
333
+ private baseUrl: string
334
+ private token: string
335
+
336
+ constructor(config: OstraClientConfig) {
337
+ this.baseUrl = config.url.replace(/\/+$/, '')
338
+ this.token = config.token
339
+ }
340
+
341
+ // ── Buckets ──
342
+
343
+ bucket(name: string): OstraBucket {
344
+ return new OstraBucket(this, name)
345
+ }
346
+
347
+ async createBucket(
348
+ name: string,
349
+ options?: {
350
+ visibility?: 'private' | 'public-read' | 'public-read-list'
351
+ versioning?: 'enabled' | 'disabled'
352
+ }
353
+ ): Promise<BucketInfo> {
354
+ return this.request<BucketInfo>('POST', '/buckets', JSON.stringify({ name, ...options }), {
355
+ 'Content-Type': 'application/json',
356
+ })
357
+ }
358
+
359
+ async listBuckets(): Promise<BucketInfo[]> {
360
+ const result = await this.request<{ buckets: BucketInfo[] }>('GET', '/buckets')
361
+ return result.buckets
362
+ }
363
+
364
+ // ── Tokens ──
365
+
366
+ async createToken(options: {
367
+ scope: 'root' | 'read-write' | 'read-only'
368
+ buckets?: string[]
369
+ label?: string
370
+ }): Promise<TokenInfo> {
371
+ return this.request<TokenInfo>('POST', '/tokens', JSON.stringify(options), {
372
+ 'Content-Type': 'application/json',
373
+ })
374
+ }
375
+
376
+ async listTokens(): Promise<TokenRecord[]> {
377
+ const result = await this.request<{ tokens: TokenRecord[] }>('GET', '/tokens')
378
+ return result.tokens
379
+ }
380
+
381
+ async deleteToken(id: string): Promise<void> {
382
+ await this.request('DELETE', `/tokens/${id}`)
383
+ }
384
+
385
+ // ── HTTP layer ──
386
+
387
+ /** @internal Make a request and parse the JSON response. */
388
+ async request<T = void>(
389
+ method: string,
390
+ path: string,
391
+ body?: RequestBody,
392
+ headers?: Record<string, string>
393
+ ): Promise<T> {
394
+ const response = await this.raw(method, path, body, headers)
395
+ if (response.status === 204) return undefined as T
396
+ return response.json() as Promise<T>
397
+ }
398
+
399
+ /** @internal Make a request and return the raw Response. */
400
+ async raw(
401
+ method: string,
402
+ path: string,
403
+ body?: RequestBody,
404
+ headers?: Record<string, string>
405
+ ): Promise<Response> {
406
+ const response = await fetch(`${this.baseUrl}${path}`, {
407
+ method,
408
+ headers: {
409
+ Authorization: `Bearer ${this.token}`,
410
+ ...headers,
411
+ },
412
+ body: body ?? null,
413
+ })
414
+
415
+ if (!response.ok) {
416
+ let code = 'UNKNOWN'
417
+ let message = `Ostra responded with ${response.status}`
418
+ try {
419
+ const text = await response.text()
420
+ if (text) {
421
+ const json = JSON.parse(text) as { error?: { code?: string; message?: string } }
422
+ if (json.error?.code) code = json.error.code
423
+ if (json.error?.message) message = json.error.message
424
+ }
425
+ } catch {}
426
+ if (code === 'UNKNOWN' && response.status === 404) code = 'NOT_FOUND'
427
+ throw new OstraError(code, message, response.status)
428
+ }
429
+
430
+ return response
431
+ }
432
+ }
@@ -0,0 +1,58 @@
1
+ import { extname } from 'node:path'
2
+ import { randomHex } from '../helpers/crypto.ts'
3
+ import OstraClient, { OstraError } from './ostra_client.ts'
4
+ import type { StorageDriver } from './types.ts'
5
+
6
+ export interface OstraDriverConfig {
7
+ url: string
8
+ token: string
9
+ bucket: string
10
+ }
11
+
12
+ export default class OstraDriver implements StorageDriver {
13
+ readonly client: OstraClient
14
+ private bucket: string
15
+ private baseUrl: string
16
+
17
+ constructor(config: OstraDriverConfig) {
18
+ this.client = new OstraClient({ url: config.url, token: config.token })
19
+ this.bucket = config.bucket
20
+ this.baseUrl = config.url.replace(/\/+$/, '')
21
+ }
22
+
23
+ async put(directory: string, file: File, name?: string): Promise<string> {
24
+ const ext = extname(file.name)
25
+ const filename = name ?? `${randomHex(8)}${ext}`
26
+ const key = `${directory}/${filename}`
27
+
28
+ await this.client.bucket(this.bucket).put(key, file, file.type)
29
+ return key
30
+ }
31
+
32
+ async get(path: string): Promise<Blob | null> {
33
+ try {
34
+ return await this.client.bucket(this.bucket).get(path)
35
+ } catch (e) {
36
+ if (e instanceof OstraError && e.statusCode === 404) return null
37
+ throw e
38
+ }
39
+ }
40
+
41
+ async exists(path: string): Promise<boolean> {
42
+ try {
43
+ await this.client.bucket(this.bucket).head(path)
44
+ return true
45
+ } catch (e) {
46
+ if (e instanceof OstraError && e.statusCode === 404) return false
47
+ throw e
48
+ }
49
+ }
50
+
51
+ async delete(path: string): Promise<void> {
52
+ await this.client.bucket(this.bucket).delete(path)
53
+ }
54
+
55
+ url(path: string): string {
56
+ return `${this.baseUrl}/buckets/${this.bucket}/${path}`
57
+ }
58
+ }
@@ -0,0 +1,51 @@
1
+ import { S3Client } from 'bun'
2
+ import { extname } from 'node:path'
3
+ import { randomHex } from '../helpers/crypto.ts'
4
+ import type { StorageDriver, S3DriverConfig } from './types.ts'
5
+
6
+ export default class S3Driver implements StorageDriver {
7
+ private client: S3Client
8
+ private cdnUrl?: string
9
+
10
+ constructor(config: S3DriverConfig) {
11
+ this.cdnUrl = config.baseUrl ?? undefined
12
+
13
+ this.client = new S3Client({
14
+ accessKeyId: config.accessKeyId,
15
+ secretAccessKey: config.secretAccessKey,
16
+ region: config.region,
17
+ endpoint: config.endpoint ?? undefined,
18
+ bucket: config.bucket,
19
+ })
20
+ }
21
+
22
+ async put(directory: string, file: File, name?: string): Promise<string> {
23
+ const ext = extname(file.name)
24
+ const filename = name ?? `${randomHex(8)}${ext}`
25
+ const key = `${directory}/${filename}`
26
+
27
+ const s3File = this.client.file(key)
28
+ await s3File.write(file)
29
+
30
+ return key
31
+ }
32
+
33
+ async get(path: string): Promise<Blob | null> {
34
+ const s3File = this.client.file(path)
35
+ if (!(await s3File.exists())) return null
36
+ return new Blob([await s3File.arrayBuffer()])
37
+ }
38
+
39
+ async exists(path: string): Promise<boolean> {
40
+ return this.client.file(path).exists()
41
+ }
42
+
43
+ async delete(path: string): Promise<void> {
44
+ await this.client.file(path).delete()
45
+ }
46
+
47
+ url(path: string, expiresIn = 3600): string {
48
+ if (this.cdnUrl) return `${this.cdnUrl}/${path}`
49
+ return this.client.file(path).presign({ expiresIn })
50
+ }
51
+ }
@@ -0,0 +1,43 @@
1
+ import StorageManager from './storage_manager.ts'
2
+
3
+ /**
4
+ * Static file storage API.
5
+ *
6
+ * Delegates all operations to the configured driver (local or S3).
7
+ *
8
+ * @example
9
+ * const path = await Storage.put('avatars', avatarFile)
10
+ * const url = Storage.url(path)
11
+ * await Storage.delete(path)
12
+ */
13
+ export default class Storage {
14
+ /** Store a file with a random filename. */
15
+ static put(directory: string, file: File): Promise<string> {
16
+ return StorageManager.driver.put(directory, file)
17
+ }
18
+
19
+ /** Store a file with a custom filename. */
20
+ static putAs(directory: string, file: File, name: string): Promise<string> {
21
+ return StorageManager.driver.put(directory, file, name)
22
+ }
23
+
24
+ /** Retrieve file content, or null if not found. */
25
+ static get(path: string): Promise<Blob | null> {
26
+ return StorageManager.driver.get(path)
27
+ }
28
+
29
+ /** Check if a file exists. */
30
+ static exists(path: string): Promise<boolean> {
31
+ return StorageManager.driver.exists(path)
32
+ }
33
+
34
+ /** Delete a file. */
35
+ static delete(path: string): Promise<void> {
36
+ return StorageManager.driver.delete(path)
37
+ }
38
+
39
+ /** Generate a URL for the file. */
40
+ static url(path: string, expiresIn?: number): string {
41
+ return StorageManager.driver.url(path, expiresIn)
42
+ }
43
+ }
@@ -0,0 +1,70 @@
1
+ import { inject } from '../core/inject.ts'
2
+ import Configuration from '../config/configuration.ts'
3
+ import { ConfigurationError } from '../exceptions/errors.ts'
4
+ import LocalDriver from './local_driver.ts'
5
+ import S3Driver from './s3_driver.ts'
6
+ import OstraDriver from './ostra_driver.ts'
7
+ import type { StorageDriver, StorageConfig } from './types.ts'
8
+
9
+ /**
10
+ * Central storage configuration hub.
11
+ *
12
+ * Resolved once via the DI container — reads the storage config
13
+ * and initializes the appropriate driver.
14
+ *
15
+ * @example
16
+ * app.singleton(StorageManager)
17
+ * app.resolve(StorageManager)
18
+ */
19
+ @inject
20
+ export default class StorageManager {
21
+ private static _driver: StorageDriver
22
+ private static _config: StorageConfig
23
+
24
+ constructor(config: Configuration) {
25
+ const driverName = config.get('storage.default', 'local') as string
26
+
27
+ StorageManager._config = {
28
+ default: driverName as 'local' | 's3',
29
+ local: {
30
+ root: 'storage',
31
+ baseUrl: '/storage',
32
+ ...(config.get('storage.local', {}) as object),
33
+ },
34
+ s3: {
35
+ bucket: '',
36
+ region: 'us-east-1',
37
+ accessKeyId: '',
38
+ secretAccessKey: '',
39
+ ...(config.get('storage.s3', {}) as object),
40
+ },
41
+ ostra: {
42
+ url: 'http://localhost:9000',
43
+ token: '',
44
+ bucket: '',
45
+ ...(config.get('storage.ostra', {}) as object),
46
+ },
47
+ }
48
+
49
+ if (driverName === 's3') {
50
+ StorageManager._driver = new S3Driver(StorageManager._config.s3)
51
+ } else if (driverName === 'ostra') {
52
+ StorageManager._driver = new OstraDriver(StorageManager._config.ostra)
53
+ } else {
54
+ StorageManager._driver = new LocalDriver(StorageManager._config.local)
55
+ }
56
+ }
57
+
58
+ static get driver(): StorageDriver {
59
+ if (!StorageManager._driver) {
60
+ throw new ConfigurationError(
61
+ 'StorageManager not configured. Resolve it through the container first.'
62
+ )
63
+ }
64
+ return StorageManager._driver
65
+ }
66
+
67
+ static get config(): StorageConfig {
68
+ return StorageManager._config
69
+ }
70
+ }
@@ -0,0 +1,49 @@
1
+ export interface StorageDriver {
2
+ /** Store a file and return its relative path/key. */
3
+ put(directory: string, file: File, name?: string): Promise<string>
4
+
5
+ /** Retrieve file content, or null if not found. */
6
+ get(path: string): Promise<Blob | null>
7
+
8
+ /** Check if a file exists. */
9
+ exists(path: string): Promise<boolean>
10
+
11
+ /** Delete a file. */
12
+ delete(path: string): Promise<void>
13
+
14
+ /** Generate a URL for the file. */
15
+ url(path: string, expiresIn?: number): string
16
+ }
17
+
18
+ export interface FileStats {
19
+ size: number
20
+ lastModified: Date
21
+ contentType?: string
22
+ }
23
+
24
+ export interface LocalDriverConfig {
25
+ root: string
26
+ baseUrl: string
27
+ }
28
+
29
+ export interface S3DriverConfig {
30
+ bucket: string
31
+ region: string
32
+ endpoint?: string | null
33
+ accessKeyId: string
34
+ secretAccessKey: string
35
+ baseUrl?: string | null
36
+ }
37
+
38
+ export interface OstraDriverConfig {
39
+ url: string
40
+ token: string
41
+ bucket: string
42
+ }
43
+
44
+ export interface StorageConfig {
45
+ default: 'local' | 's3' | 'ostra'
46
+ local: LocalDriverConfig
47
+ s3: S3DriverConfig
48
+ ostra: OstraDriverConfig
49
+ }