@open-mercato/storage-s3 0.6.7-develop.6860.1.f796b22e80 → 0.6.7
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js +5 -20
- package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js.map +2 -2
- package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js +5 -7
- package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js.map +2 -2
- package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js +4 -6
- package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js.map +2 -2
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +8 -70
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js.map +3 -3
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +15 -92
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
- package/dist/modules/storage_s3/di.js +2 -6
- package/dist/modules/storage_s3/di.js.map +2 -2
- package/dist/modules/storage_s3/integration.js +1 -1
- package/dist/modules/storage_s3/integration.js.map +1 -1
- package/dist/modules/storage_s3/lib/health.js +1 -18
- package/dist/modules/storage_s3/lib/health.js.map +2 -2
- package/dist/modules/storage_s3/lib/key-scope.js +4 -4
- package/dist/modules/storage_s3/lib/key-scope.js.map +2 -2
- package/dist/modules/storage_s3/lib/s3-driver.js +15 -44
- package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
- package/dist/modules/storage_s3/lib/storage-service.js +22 -126
- package/dist/modules/storage_s3/lib/storage-service.js.map +3 -3
- package/jest.config.cjs +0 -1
- package/package.json +6 -13
- package/src/modules/storage_s3/__tests__/s3Driver.test.ts +8 -23
- package/src/modules/storage_s3/__tests__/s3ListRoute.test.ts +1 -7
- package/src/modules/storage_s3/__tests__/s3Routes.test.ts +0 -26
- package/src/modules/storage_s3/__tests__/storageService.test.ts +5 -20
- package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +5 -22
- package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +5 -7
- package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +4 -6
- package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +6 -77
- package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +16 -101
- package/src/modules/storage_s3/di.ts +2 -14
- package/src/modules/storage_s3/integration.ts +1 -1
- package/src/modules/storage_s3/lib/health.ts +0 -18
- package/src/modules/storage_s3/lib/key-scope.ts +4 -4
- package/src/modules/storage_s3/lib/s3-driver.ts +10 -65
- package/src/modules/storage_s3/lib/storage-service.ts +22 -131
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js +0 -145
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js.map +0 -7
- package/dist/modules/storage_s3/lib/endpoint-safety.js +0 -133
- package/dist/modules/storage_s3/lib/endpoint-safety.js.map +0 -7
- package/dist/modules/storage_s3/lib/quota-accounting.js +0 -24
- package/dist/modules/storage_s3/lib/quota-accounting.js.map +0 -7
- package/dist/modules/storage_s3/lib/quota-recovery-queue.js +0 -9
- package/dist/modules/storage_s3/lib/quota-recovery-queue.js.map +0 -7
- package/dist/modules/storage_s3/workers/quota-recovery.js +0 -70
- package/dist/modules/storage_s3/workers/quota-recovery.js.map +0 -7
- package/src/modules/storage_s3/__tests__/endpoint-safety.test.ts +0 -71
- package/src/modules/storage_s3/__tests__/health.test.ts +0 -38
- package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +0 -13
- package/src/modules/storage_s3/__tests__/quota-lifecycle.test.ts +0 -216
- package/src/modules/storage_s3/__tests__/quota-recovery.test.ts +0 -83
- package/src/modules/storage_s3/__tests__/upload.test.ts +0 -93
- package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +0 -150
- package/src/modules/storage_s3/i18n/de.json +0 -17
- package/src/modules/storage_s3/i18n/en.json +0 -17
- package/src/modules/storage_s3/i18n/es.json +0 -17
- package/src/modules/storage_s3/i18n/ko.json +0 -17
- package/src/modules/storage_s3/i18n/pl.json +0 -17
- package/src/modules/storage_s3/lib/endpoint-safety.ts +0 -179
- package/src/modules/storage_s3/lib/quota-accounting.ts +0 -29
- package/src/modules/storage_s3/lib/quota-recovery-queue.ts +0 -17
- package/src/modules/storage_s3/workers/quota-recovery.ts +0 -82
|
@@ -10,12 +10,7 @@ import {
|
|
|
10
10
|
ListObjectsV2Command,
|
|
11
11
|
} from '@aws-sdk/client-s3'
|
|
12
12
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
13
|
-
import type {
|
|
14
|
-
import {
|
|
15
|
-
assertSafeS3Endpoint,
|
|
16
|
-
assertStaticallySafeS3Endpoint,
|
|
17
|
-
createSafeS3RequestHandler,
|
|
18
|
-
} from './endpoint-safety'
|
|
13
|
+
import type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'
|
|
19
14
|
import {
|
|
20
15
|
assertS3KeyAddressableByTenantScope,
|
|
21
16
|
assertS3KeyScopedToTenant,
|
|
@@ -76,14 +71,12 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
76
71
|
readonly key = 's3'
|
|
77
72
|
private readonly client: S3Client
|
|
78
73
|
private readonly bucket: string
|
|
79
|
-
private readonly endpoint?: string
|
|
80
74
|
private readonly pathPrefix: string
|
|
81
75
|
private readonly scope: S3TenantScope | null
|
|
82
76
|
|
|
83
77
|
constructor(config: Record<string, unknown>) {
|
|
84
78
|
const cfg = config as S3DriverConfig
|
|
85
79
|
this.bucket = cfg.bucket
|
|
86
|
-
this.endpoint = cfg.endpoint
|
|
87
80
|
this.pathPrefix = cfg.pathPrefix ?? ''
|
|
88
81
|
this.scope = cfg.organizationId && cfg.tenantId
|
|
89
82
|
? { organizationId: cfg.organizationId, tenantId: cfg.tenantId }
|
|
@@ -116,15 +109,11 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
116
109
|
}
|
|
117
110
|
}
|
|
118
111
|
|
|
119
|
-
assertStaticallySafeS3Endpoint(cfg.endpoint)
|
|
120
|
-
const requestHandler = createSafeS3RequestHandler(cfg.endpoint)
|
|
121
|
-
|
|
122
112
|
this.client = new S3Client({
|
|
123
113
|
region: cfg.region ?? 'us-east-1',
|
|
124
114
|
endpoint: cfg.endpoint,
|
|
125
115
|
forcePathStyle: cfg.forcePathStyle ?? false,
|
|
126
116
|
credentials,
|
|
127
|
-
...(requestHandler ? { requestHandler } : {}),
|
|
128
117
|
})
|
|
129
118
|
}
|
|
130
119
|
|
|
@@ -143,24 +132,17 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
143
132
|
: storagePath.replace(/^\/+/, '')
|
|
144
133
|
const firstSegment = prefix.split('/').filter(Boolean)[0]
|
|
145
134
|
if (firstSegment !== partitionCode) {
|
|
146
|
-
throw new Error('
|
|
135
|
+
throw new Error('S3 key is not scoped to the requested partition')
|
|
147
136
|
}
|
|
148
137
|
}
|
|
149
138
|
|
|
150
|
-
|
|
139
|
+
async store(payload: StoreFilePayload): Promise<StoredFile> {
|
|
151
140
|
const orgSegment = resolveOrgSegment(payload.orgId ?? null)
|
|
152
141
|
const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)
|
|
153
142
|
const safeName = sanitizeFileName(payload.fileName || 'file')
|
|
154
143
|
const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)
|
|
155
144
|
const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async store(payload: StoreFilePayload): Promise<StoredFile> {
|
|
160
|
-
const key = payload.storagePath ?? this.prepareStoragePath(payload)
|
|
161
|
-
this.assertPartitionScoped(payload.partitionCode, key)
|
|
162
|
-
this.assertKeyScoped(key)
|
|
163
|
-
await this.assertEndpointSafe()
|
|
145
|
+
const key = `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`
|
|
164
146
|
|
|
165
147
|
await this.client.send(
|
|
166
148
|
new PutObjectCommand({
|
|
@@ -168,7 +150,6 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
168
150
|
Key: key,
|
|
169
151
|
Body: payload.buffer,
|
|
170
152
|
ContentLength: payload.buffer.length,
|
|
171
|
-
IfNoneMatch: '*',
|
|
172
153
|
}),
|
|
173
154
|
)
|
|
174
155
|
|
|
@@ -178,12 +159,11 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
178
159
|
async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {
|
|
179
160
|
this.assertPartitionScoped(partitionCode, storagePath)
|
|
180
161
|
this.assertKeyAddressable(storagePath)
|
|
181
|
-
await this.assertEndpointSafe()
|
|
182
162
|
const response = await this.client.send(
|
|
183
163
|
new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),
|
|
184
164
|
)
|
|
185
165
|
if (!response.Body) {
|
|
186
|
-
throw new Error(`
|
|
166
|
+
throw new Error(`S3 object body is empty for key: ${storagePath}`)
|
|
187
167
|
}
|
|
188
168
|
const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream)
|
|
189
169
|
return { buffer, contentType: response.ContentType }
|
|
@@ -193,19 +173,14 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
193
173
|
this.assertPartitionScoped(partitionCode, storagePath)
|
|
194
174
|
this.assertKeyAddressable(storagePath)
|
|
195
175
|
try {
|
|
196
|
-
await this.
|
|
176
|
+
await this.client.send(
|
|
177
|
+
new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),
|
|
178
|
+
)
|
|
197
179
|
} catch {
|
|
198
|
-
//
|
|
180
|
+
// best-effort removal
|
|
199
181
|
}
|
|
200
182
|
}
|
|
201
183
|
|
|
202
|
-
async deleteStrict(partitionCode: string, storagePath: string): Promise<void> {
|
|
203
|
-
this.assertPartitionScoped(partitionCode, storagePath)
|
|
204
|
-
this.assertKeyAddressable(storagePath)
|
|
205
|
-
await this.assertEndpointSafe()
|
|
206
|
-
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }))
|
|
207
|
-
}
|
|
208
|
-
|
|
209
184
|
async toLocalPath(
|
|
210
185
|
partitionCode: string,
|
|
211
186
|
storagePath: string,
|
|
@@ -232,7 +207,6 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
232
207
|
*/
|
|
233
208
|
async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {
|
|
234
209
|
this.assertKeyScoped(key)
|
|
235
|
-
await this.assertEndpointSafe()
|
|
236
210
|
await this.client.send(
|
|
237
211
|
new PutObjectCommand({
|
|
238
212
|
Bucket: this.bucket,
|
|
@@ -240,7 +214,6 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
240
214
|
Body: buffer,
|
|
241
215
|
ContentType: contentType,
|
|
242
216
|
ContentLength: buffer.length,
|
|
243
|
-
IfNoneMatch: '*',
|
|
244
217
|
}),
|
|
245
218
|
)
|
|
246
219
|
}
|
|
@@ -261,7 +234,6 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
261
234
|
}> {
|
|
262
235
|
const activeScope = scope ?? this.scope
|
|
263
236
|
assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix)
|
|
264
|
-
await this.assertEndpointSafe()
|
|
265
237
|
const response = await this.client.send(
|
|
266
238
|
new ListObjectsV2Command({
|
|
267
239
|
Bucket: this.bucket,
|
|
@@ -285,54 +257,27 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
285
257
|
/**
|
|
286
258
|
* Generate a pre-signed URL for direct browser upload or download.
|
|
287
259
|
*/
|
|
288
|
-
async getSignedUrl(
|
|
289
|
-
storagePath: string,
|
|
290
|
-
operation: 'upload' | 'download',
|
|
291
|
-
expiresIn?: number,
|
|
292
|
-
contentType?: string,
|
|
293
|
-
contentLength?: number,
|
|
294
|
-
createOnly?: boolean,
|
|
295
|
-
scope?: S3TenantScope | null,
|
|
296
|
-
): Promise<string>
|
|
297
|
-
async getSignedUrl(
|
|
298
|
-
storagePath: string,
|
|
299
|
-
operation: 'upload' | 'download',
|
|
300
|
-
expiresIn: number,
|
|
301
|
-
contentType: string | undefined,
|
|
302
|
-
scope?: S3TenantScope | null,
|
|
303
|
-
): Promise<string>
|
|
304
260
|
async getSignedUrl(
|
|
305
261
|
storagePath: string,
|
|
306
262
|
operation: 'upload' | 'download',
|
|
307
263
|
expiresIn = 3600,
|
|
308
264
|
contentType?: string,
|
|
309
|
-
|
|
310
|
-
createOnly = false,
|
|
311
|
-
explicitScope?: S3TenantScope | null,
|
|
265
|
+
scope?: S3TenantScope | null,
|
|
312
266
|
): Promise<string> {
|
|
313
|
-
const contentLength = typeof contentLengthOrScope === 'number' ? contentLengthOrScope : undefined
|
|
314
|
-
const scope = typeof contentLengthOrScope === 'number' ? explicitScope : contentLengthOrScope
|
|
315
267
|
if (operation === 'upload') {
|
|
316
268
|
this.assertKeyScoped(storagePath, scope)
|
|
317
269
|
} else {
|
|
318
270
|
this.assertKeyAddressable(storagePath, scope)
|
|
319
271
|
}
|
|
320
|
-
await this.assertEndpointSafe()
|
|
321
272
|
if (operation === 'upload') {
|
|
322
273
|
const command = new PutObjectCommand({
|
|
323
274
|
Bucket: this.bucket,
|
|
324
275
|
Key: storagePath,
|
|
325
276
|
ContentType: contentType,
|
|
326
|
-
ContentLength: contentLength,
|
|
327
|
-
...(createOnly ? { IfNoneMatch: '*' } : {}),
|
|
328
277
|
})
|
|
329
278
|
return getSignedUrl(this.client, command, { expiresIn })
|
|
330
279
|
}
|
|
331
280
|
const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })
|
|
332
281
|
return getSignedUrl(this.client, command, { expiresIn })
|
|
333
282
|
}
|
|
334
|
-
|
|
335
|
-
private async assertEndpointSafe(): Promise<void> {
|
|
336
|
-
await assertSafeS3Endpoint(this.endpoint)
|
|
337
|
-
}
|
|
338
283
|
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'crypto'
|
|
2
2
|
import { S3StorageDriver } from './s3-driver'
|
|
3
|
-
import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
|
|
4
|
-
import { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'
|
|
5
|
-
import { reconcileTenantS3Objects } from './quota-accounting'
|
|
6
3
|
import {
|
|
7
4
|
assertS3KeyAddressableByTenantScope,
|
|
8
5
|
assertS3KeyScopedToTenant,
|
|
@@ -37,7 +34,6 @@ type SignedUrlInput = {
|
|
|
37
34
|
operation: 'upload' | 'download'
|
|
38
35
|
expiresIn?: number
|
|
39
36
|
contentType?: string
|
|
40
|
-
size?: number
|
|
41
37
|
scope: TenantScope
|
|
42
38
|
}
|
|
43
39
|
type ListInput = {
|
|
@@ -57,11 +53,6 @@ type S3Config = {
|
|
|
57
53
|
accessKeyId?: string
|
|
58
54
|
secretAccessKey?: string
|
|
59
55
|
sessionToken?: string
|
|
60
|
-
quotaService?: AttachmentQuotaService
|
|
61
|
-
quotaRecoveryScheduler?: (
|
|
62
|
-
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
63
|
-
delayMs: number,
|
|
64
|
-
) => Promise<void>
|
|
65
56
|
pathPrefix?: string
|
|
66
57
|
organizationId?: string | null
|
|
67
58
|
tenantId?: string | null
|
|
@@ -71,11 +62,19 @@ function sanitizeSegment(value: string): string {
|
|
|
71
62
|
return value.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
72
63
|
}
|
|
73
64
|
|
|
65
|
+
function buildKey(namespace: string, scope: TenantScope, fileName: string): string {
|
|
66
|
+
const orgSeg = scope.organizationId ? `org_${scope.organizationId}` : 'org_shared'
|
|
67
|
+
const tenantSeg = scope.tenantId ? `tenant_${scope.tenantId}` : 'tenant_shared'
|
|
68
|
+
const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)
|
|
69
|
+
const safeName = sanitizeSegment(fileName || 'file')
|
|
70
|
+
return `${sanitizeSegment(namespace)}/${orgSeg}/${tenantSeg}/${Date.now()}_${uniqueSuffix}_${safeName}`
|
|
71
|
+
}
|
|
72
|
+
|
|
74
73
|
export interface StorageService {
|
|
75
74
|
upload(input: UploadInput): Promise<StorageResult>
|
|
76
75
|
download(input: DownloadInput): Promise<{ buffer: Buffer; contentType?: string }>
|
|
77
76
|
delete(input: DeleteInput): Promise<void>
|
|
78
|
-
getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date
|
|
77
|
+
getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date }>
|
|
79
78
|
list(input: ListInput): Promise<{
|
|
80
79
|
files: Array<{ key: string; size: number; lastModified: Date }>
|
|
81
80
|
truncated: boolean
|
|
@@ -86,86 +85,23 @@ export interface StorageService {
|
|
|
86
85
|
|
|
87
86
|
export function createStorageService(config: S3Config): StorageService {
|
|
88
87
|
const driver = new S3StorageDriver(config as Record<string, unknown>)
|
|
89
|
-
const quotaService = config.quotaService
|
|
90
|
-
const quotaRecoveryScheduler = config.quotaRecoveryScheduler
|
|
91
88
|
const pathPrefix = config.pathPrefix
|
|
92
89
|
|
|
93
90
|
return {
|
|
94
91
|
async upload({ namespace, fileName, buffer, contentType, scope }): Promise<StorageResult> {
|
|
95
|
-
const
|
|
96
|
-
partitionCode:
|
|
92
|
+
const stored = await driver.store({
|
|
93
|
+
partitionCode: namespace,
|
|
97
94
|
orgId: scope.organizationId,
|
|
98
95
|
tenantId: scope.tenantId,
|
|
99
96
|
fileName,
|
|
97
|
+
buffer,
|
|
100
98
|
})
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
await reconcileTenantS3Objects({
|
|
108
|
-
driver,
|
|
109
|
-
quotaService,
|
|
110
|
-
tenantId: scope.tenantId,
|
|
111
|
-
organizationId: scope.organizationId,
|
|
112
|
-
})
|
|
113
|
-
reservation = await quotaService.reserve({
|
|
114
|
-
tenantId: scope.tenantId,
|
|
115
|
-
organizationId: scope.organizationId,
|
|
116
|
-
bytes: buffer.length,
|
|
117
|
-
source: 'storage_service',
|
|
118
|
-
storageDriver: 's3',
|
|
119
|
-
storagePath,
|
|
120
|
-
partitionCode: namespace,
|
|
121
|
-
})
|
|
122
|
-
}
|
|
123
|
-
if (reservation) {
|
|
124
|
-
if (!quotaRecoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
|
|
125
|
-
await quotaRecoveryScheduler({
|
|
126
|
-
reservationId: reservation.id,
|
|
127
|
-
tenantId: scope.tenantId!,
|
|
128
|
-
organizationId: scope.organizationId!,
|
|
129
|
-
},
|
|
130
|
-
Math.max(1_000, reservation.expiresAt.getTime() - Date.now()),
|
|
131
|
-
)
|
|
132
|
-
await quotaService!.beginStorage(reservation.id, reservation.leaseToken)
|
|
133
|
-
storageStarted = true
|
|
134
|
-
}
|
|
135
|
-
const stored = await driver.store({
|
|
136
|
-
partitionCode: namespace,
|
|
137
|
-
orgId: scope.organizationId,
|
|
138
|
-
tenantId: scope.tenantId,
|
|
139
|
-
fileName,
|
|
140
|
-
buffer,
|
|
141
|
-
storagePath,
|
|
142
|
-
})
|
|
143
|
-
objectStored = true
|
|
144
|
-
if (reservation) {
|
|
145
|
-
await quotaService!.markStored(reservation.id, reservation.leaseToken)
|
|
146
|
-
await quotaService!.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)
|
|
147
|
-
}
|
|
148
|
-
return {
|
|
149
|
-
key: stored.storagePath,
|
|
150
|
-
namespace,
|
|
151
|
-
fileName,
|
|
152
|
-
size: buffer.length,
|
|
153
|
-
contentType,
|
|
154
|
-
}
|
|
155
|
-
} catch (error) {
|
|
156
|
-
if (reservation) {
|
|
157
|
-
if (objectStored) {
|
|
158
|
-
try {
|
|
159
|
-
await driver.deleteStrict('', storagePath)
|
|
160
|
-
await quotaService!.release(reservation.id, reservation.leaseToken)
|
|
161
|
-
} catch {
|
|
162
|
-
// Keep the reservation counted for recovery when absence is ambiguous.
|
|
163
|
-
}
|
|
164
|
-
} else if (!storageStarted) {
|
|
165
|
-
await quotaService!.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
throw error
|
|
99
|
+
return {
|
|
100
|
+
key: stored.storagePath,
|
|
101
|
+
namespace,
|
|
102
|
+
fileName,
|
|
103
|
+
size: buffer.length,
|
|
104
|
+
contentType,
|
|
169
105
|
}
|
|
170
106
|
},
|
|
171
107
|
|
|
@@ -176,62 +112,17 @@ export function createStorageService(config: S3Config): StorageService {
|
|
|
176
112
|
|
|
177
113
|
async delete({ key, scope }): Promise<void> {
|
|
178
114
|
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)
|
|
179
|
-
return driver.
|
|
180
|
-
.then(async () => {
|
|
181
|
-
if (quotaService && scope.tenantId) {
|
|
182
|
-
await quotaService.releaseCommittedByPath({
|
|
183
|
-
tenantId: scope.tenantId,
|
|
184
|
-
storageDriver: 's3',
|
|
185
|
-
storagePath: key,
|
|
186
|
-
})
|
|
187
|
-
}
|
|
188
|
-
})
|
|
115
|
+
return driver.delete('', key)
|
|
189
116
|
},
|
|
190
117
|
|
|
191
|
-
async getSignedUrl({ key, operation, expiresIn = 3600, contentType,
|
|
118
|
+
async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope }): Promise<{ url: string; expiresAt: Date }> {
|
|
192
119
|
if (operation === 'upload') {
|
|
193
120
|
assertS3KeyScopedToTenant(key, scope, pathPrefix)
|
|
194
121
|
} else {
|
|
195
122
|
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)
|
|
196
123
|
}
|
|
197
|
-
const expiresAt = new Date(Date.now() + expiresIn * 1000)
|
|
198
|
-
if (operation === 'upload' && quotaService && scope.tenantId && scope.organizationId) {
|
|
199
|
-
const compatibilityToken = randomBytes(32).toString('base64url')
|
|
200
|
-
let reservation: Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null = null
|
|
201
|
-
try {
|
|
202
|
-
await reconcileTenantS3Objects({
|
|
203
|
-
driver,
|
|
204
|
-
quotaService,
|
|
205
|
-
tenantId: scope.tenantId,
|
|
206
|
-
organizationId: scope.organizationId,
|
|
207
|
-
})
|
|
208
|
-
reservation = await quotaService.reserve({
|
|
209
|
-
tenantId: scope.tenantId,
|
|
210
|
-
organizationId: scope.organizationId,
|
|
211
|
-
bytes: size ?? resolveAttachmentMaxBytes(),
|
|
212
|
-
source: 'storage_s3_signed',
|
|
213
|
-
storageDriver: 's3',
|
|
214
|
-
storagePath: key,
|
|
215
|
-
uploadTokenHash: createHash('sha256').update(compatibilityToken).digest('hex'),
|
|
216
|
-
ttlMs: expiresIn * 1000,
|
|
217
|
-
})
|
|
218
|
-
if (!quotaRecoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
|
|
219
|
-
await quotaRecoveryScheduler({
|
|
220
|
-
reservationId: reservation.id,
|
|
221
|
-
tenantId: scope.tenantId!,
|
|
222
|
-
organizationId: scope.organizationId!,
|
|
223
|
-
}, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))
|
|
224
|
-
const url = `/api/storage-providers/s3/signed-upload/${compatibilityToken}`
|
|
225
|
-
return { url, expiresAt, reservationId: reservation.id }
|
|
226
|
-
} catch (error) {
|
|
227
|
-
if (reservation) {
|
|
228
|
-
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
229
|
-
}
|
|
230
|
-
throw error
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
124
|
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope)
|
|
234
|
-
return { url, expiresAt }
|
|
125
|
+
return { url, expiresAt: new Date(Date.now() + expiresIn * 1000) }
|
|
235
126
|
},
|
|
236
127
|
|
|
237
128
|
async list({ prefix, maxKeys = 100, continuationToken, scope }): Promise<{
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { NextResponse } from "next/server";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
6
|
-
import {
|
|
7
|
-
detectAttachmentMimeType,
|
|
8
|
-
hasDangerousExecutableExtension,
|
|
9
|
-
isActiveContentAttachment
|
|
10
|
-
} from "@open-mercato/core/modules/attachments/lib/security";
|
|
11
|
-
import { S3StorageDriver } from "../../../../../lib/s3-driver.js";
|
|
12
|
-
const metadata = {
|
|
13
|
-
path: "/storage-providers/s3/signed-upload/:token",
|
|
14
|
-
PUT: { requireAuth: false }
|
|
15
|
-
};
|
|
16
|
-
class UploadBodyTooLargeError extends Error {
|
|
17
|
-
}
|
|
18
|
-
async function readBoundedBody(req, maxBytes) {
|
|
19
|
-
if (!req.body) return Buffer.alloc(0);
|
|
20
|
-
const reader = req.body.getReader();
|
|
21
|
-
const chunks = [];
|
|
22
|
-
let totalBytes = 0;
|
|
23
|
-
try {
|
|
24
|
-
while (true) {
|
|
25
|
-
const { done, value } = await reader.read();
|
|
26
|
-
if (done) break;
|
|
27
|
-
totalBytes += value.byteLength;
|
|
28
|
-
if (totalBytes > maxBytes) {
|
|
29
|
-
await reader.cancel();
|
|
30
|
-
throw new UploadBodyTooLargeError("Attachment exceeds the reserved upload size.");
|
|
31
|
-
}
|
|
32
|
-
chunks.push(Buffer.from(value));
|
|
33
|
-
}
|
|
34
|
-
} finally {
|
|
35
|
-
reader.releaseLock();
|
|
36
|
-
}
|
|
37
|
-
return Buffer.concat(chunks, totalBytes);
|
|
38
|
-
}
|
|
39
|
-
async function PUT(req, ctx) {
|
|
40
|
-
const params = ctx.params instanceof Promise ? await ctx.params : ctx.params;
|
|
41
|
-
const token = params?.token?.trim();
|
|
42
|
-
if (!token) return NextResponse.json({ error: "Upload token is required." }, { status: 400 });
|
|
43
|
-
const { resolve } = await createRequestContainer();
|
|
44
|
-
let quotaService = null;
|
|
45
|
-
try {
|
|
46
|
-
quotaService = resolve("attachmentQuotaService");
|
|
47
|
-
} catch {
|
|
48
|
-
}
|
|
49
|
-
if (!quotaService) return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
|
|
50
|
-
const tokenHash = createHash("sha256").update(token).digest("hex");
|
|
51
|
-
const reservation = await quotaService.claimPendingByUploadTokenHash(tokenHash);
|
|
52
|
-
if (!reservation || reservation.source !== "storage_s3_signed") {
|
|
53
|
-
return NextResponse.json({ error: "Upload token is invalid or expired." }, { status: 410 });
|
|
54
|
-
}
|
|
55
|
-
const declaredLengthHeader = req.headers.get("content-length");
|
|
56
|
-
const declaredLength = declaredLengthHeader == null ? null : Number(declaredLengthHeader);
|
|
57
|
-
if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > reservation.reservedBytes) {
|
|
58
|
-
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
59
|
-
});
|
|
60
|
-
return NextResponse.json({ error: "Attachment exceeds the reserved upload size." }, { status: 413 });
|
|
61
|
-
}
|
|
62
|
-
let buffer;
|
|
63
|
-
try {
|
|
64
|
-
buffer = await readBoundedBody(req, reservation.reservedBytes);
|
|
65
|
-
} catch (error) {
|
|
66
|
-
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
67
|
-
});
|
|
68
|
-
if (error instanceof UploadBodyTooLargeError) {
|
|
69
|
-
return NextResponse.json({ error: error.message }, { status: 413 });
|
|
70
|
-
}
|
|
71
|
-
return NextResponse.json({ error: "Failed to read attachment." }, { status: 400 });
|
|
72
|
-
}
|
|
73
|
-
if (buffer.length > reservation.reservedBytes) {
|
|
74
|
-
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
75
|
-
});
|
|
76
|
-
return NextResponse.json({ error: "Attachment exceeds the reserved upload size." }, { status: 413 });
|
|
77
|
-
}
|
|
78
|
-
const fileName = path.posix.basename(reservation.storagePath);
|
|
79
|
-
if (hasDangerousExecutableExtension(fileName)) {
|
|
80
|
-
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
81
|
-
});
|
|
82
|
-
return NextResponse.json({ error: "Executable file types are not allowed as attachments." }, { status: 400 });
|
|
83
|
-
}
|
|
84
|
-
const contentType = detectAttachmentMimeType(buffer, fileName, req.headers.get("content-type"));
|
|
85
|
-
if (isActiveContentAttachment(buffer, fileName, contentType)) {
|
|
86
|
-
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
87
|
-
});
|
|
88
|
-
return NextResponse.json({ error: "Active content uploads are not allowed." }, { status: 400 });
|
|
89
|
-
}
|
|
90
|
-
const credentialsService = resolve("integrationCredentialsService");
|
|
91
|
-
const credentials = await credentialsService.resolve("storage_s3", {
|
|
92
|
-
tenantId: reservation.tenantId,
|
|
93
|
-
organizationId: reservation.organizationId
|
|
94
|
-
});
|
|
95
|
-
if (!credentials) {
|
|
96
|
-
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
97
|
-
});
|
|
98
|
-
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
99
|
-
}
|
|
100
|
-
const driver = new S3StorageDriver({
|
|
101
|
-
...credentials,
|
|
102
|
-
organizationId: reservation.organizationId,
|
|
103
|
-
tenantId: reservation.tenantId
|
|
104
|
-
});
|
|
105
|
-
let objectStored = false;
|
|
106
|
-
try {
|
|
107
|
-
await driver.putObject(reservation.storagePath, buffer, contentType);
|
|
108
|
-
objectStored = true;
|
|
109
|
-
await quotaService.markStored(reservation.id, reservation.leaseToken);
|
|
110
|
-
await quotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length);
|
|
111
|
-
return new NextResponse(null, { status: 200 });
|
|
112
|
-
} catch (error) {
|
|
113
|
-
if (objectStored) {
|
|
114
|
-
try {
|
|
115
|
-
await driver.deleteStrict("", reservation.storagePath);
|
|
116
|
-
await quotaService.release(reservation.id, reservation.leaseToken);
|
|
117
|
-
} catch {
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return NextResponse.json({ error: "Failed to persist attachment." }, { status: 500 });
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
var token_default = PUT;
|
|
124
|
-
const openApi = {
|
|
125
|
-
tag: "Storage",
|
|
126
|
-
summary: "Upload through a bounded one-time compatibility URL",
|
|
127
|
-
methods: {
|
|
128
|
-
PUT: {
|
|
129
|
-
summary: "Upload an object using a bounded one-time token",
|
|
130
|
-
responses: [{ status: 200, description: "Upload completed", schema: z.any() }],
|
|
131
|
-
errors: [
|
|
132
|
-
{ status: 400, description: "Invalid upload", schema: z.object({ error: z.string() }) },
|
|
133
|
-
{ status: 410, description: "Token invalid or expired", schema: z.object({ error: z.string() }) },
|
|
134
|
-
{ status: 413, description: "Upload exceeds reserved size", schema: z.object({ error: z.string() }) }
|
|
135
|
-
]
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
|
-
export {
|
|
140
|
-
PUT,
|
|
141
|
-
token_default as default,
|
|
142
|
-
metadata,
|
|
143
|
-
openApi
|
|
144
|
-
};
|
|
145
|
-
//# sourceMappingURL=%5Btoken%5D.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../../../../../../src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/%5Btoken%5D.ts"],
|
|
4
|
-
"sourcesContent": ["import { createHash } from 'node:crypto'\nimport path from 'node:path'\nimport { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { S3StorageDriver } from '../../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/signed-upload/:token',\n PUT: { requireAuth: false },\n}\n\ntype RouteContext = { params?: Promise<{ token?: string }> | { token?: string } }\n\nclass UploadBodyTooLargeError extends Error {}\n\nasync function readBoundedBody(req: Request, maxBytes: number): Promise<Buffer> {\n if (!req.body) return Buffer.alloc(0)\n const reader = req.body.getReader()\n const chunks: Buffer[] = []\n let totalBytes = 0\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n totalBytes += value.byteLength\n if (totalBytes > maxBytes) {\n await reader.cancel()\n throw new UploadBodyTooLargeError('Attachment exceeds the reserved upload size.')\n }\n chunks.push(Buffer.from(value))\n }\n } finally {\n reader.releaseLock()\n }\n return Buffer.concat(chunks, totalBytes)\n}\n\nexport async function PUT(req: Request, ctx: RouteContext) {\n const params = ctx.params instanceof Promise ? await ctx.params : ctx.params\n const token = params?.token?.trim()\n if (!token) return NextResponse.json({ error: 'Upload token is required.' }, { status: 400 })\n\n const { resolve } = await createRequestContainer()\n let quotaService: AttachmentQuotaService | null = null\n try {\n quotaService = resolve('attachmentQuotaService') as AttachmentQuotaService\n } catch {\n // Fail closed below when the attachments quota service is not registered.\n }\n if (!quotaService) return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })\n\n const tokenHash = createHash('sha256').update(token).digest('hex')\n const reservation = await quotaService.claimPendingByUploadTokenHash(tokenHash)\n if (!reservation || reservation.source !== 'storage_s3_signed') {\n return NextResponse.json({ error: 'Upload token is invalid or expired.' }, { status: 410 })\n }\n\n const declaredLengthHeader = req.headers.get('content-length')\n const declaredLength = declaredLengthHeader == null ? null : Number(declaredLengthHeader)\n if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > reservation.reservedBytes) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })\n }\n let buffer: Buffer\n try {\n buffer = await readBoundedBody(req, reservation.reservedBytes)\n } catch (error) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n if (error instanceof UploadBodyTooLargeError) {\n return NextResponse.json({ error: error.message }, { status: 413 })\n }\n return NextResponse.json({ error: 'Failed to read attachment.' }, { status: 400 })\n }\n if (buffer.length > reservation.reservedBytes) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })\n }\n const fileName = path.posix.basename(reservation.storagePath)\n if (hasDangerousExecutableExtension(fileName)) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })\n }\n const contentType = detectAttachmentMimeType(buffer, fileName, req.headers.get('content-type'))\n if (isActiveContentAttachment(buffer, fileName, contentType)) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })\n }\n\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const credentials = await credentialsService.resolve('storage_s3', {\n tenantId: reservation.tenantId,\n organizationId: reservation.organizationId,\n })\n if (!credentials) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })\n }\n\n const driver = new S3StorageDriver({\n ...credentials,\n organizationId: reservation.organizationId,\n tenantId: reservation.tenantId,\n })\n let objectStored = false\n try {\n await driver.putObject(reservation.storagePath, buffer, contentType)\n objectStored = true\n await quotaService.markStored(reservation.id, reservation.leaseToken)\n await quotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)\n return new NextResponse(null, { status: 200 })\n } catch (error) {\n if (objectStored) {\n try {\n await driver.deleteStrict('', reservation.storagePath)\n await quotaService.release(reservation.id, reservation.leaseToken)\n } catch {\n // Retain the reservation when object absence cannot be proven.\n }\n }\n return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })\n }\n}\n\nexport default PUT\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload through a bounded one-time compatibility URL',\n methods: {\n PUT: {\n summary: 'Upload an object using a bounded one-time token',\n responses: [{ status: 200, description: 'Upload completed', schema: z.any() }],\n errors: [\n { status: 400, description: 'Invalid upload', schema: z.object({ error: z.string() }) },\n { status: 410, description: 'Token invalid or expired', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload exceeds reserved size', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|