@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.
- package/package.json +59 -0
- package/src/cache/cache_manager.ts +60 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/index.ts +4 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/index.ts +2 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +241 -0
- package/src/core/container.ts +113 -0
- package/src/core/index.ts +4 -0
- package/src/core/inject.ts +39 -0
- package/src/core/service_provider.ts +44 -0
- package/src/encryption/encryption_manager.ts +215 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +71 -0
- package/src/exceptions/exception_handler.ts +140 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +132 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/helpers/compose.ts +104 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/index.ts +6 -0
- package/src/helpers/strings.ts +67 -0
- package/src/helpers/ulid.ts +28 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +76 -0
- package/src/i18n/i18n_manager.ts +157 -0
- package/src/i18n/index.ts +3 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/index.ts +11 -0
- package/src/logger/index.ts +5 -0
- package/src/logger/logger.ts +113 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/providers/cache_provider.ts +16 -0
- package/src/providers/config_provider.ts +26 -0
- package/src/providers/encryption_provider.ts +16 -0
- package/src/providers/i18n_provider.ts +17 -0
- package/src/providers/index.ts +8 -0
- package/src/providers/logger_provider.ts +16 -0
- package/src/providers/storage_provider.ts +16 -0
- package/src/storage/index.ts +32 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/ostra_client.ts +432 -0
- package/src/storage/ostra_driver.ts +58 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +70 -0
- package/src/storage/types.ts +49 -0
- package/src/storage/upload.ts +91 -0
- 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
|
+
}
|