@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.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js +5 -20
  3. package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js.map +2 -2
  4. package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js +5 -7
  5. package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js.map +2 -2
  6. package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js +4 -6
  7. package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js.map +2 -2
  8. package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +8 -70
  9. package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js.map +3 -3
  10. package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +15 -92
  11. package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
  12. package/dist/modules/storage_s3/di.js +2 -6
  13. package/dist/modules/storage_s3/di.js.map +2 -2
  14. package/dist/modules/storage_s3/integration.js +1 -1
  15. package/dist/modules/storage_s3/integration.js.map +1 -1
  16. package/dist/modules/storage_s3/lib/health.js +1 -18
  17. package/dist/modules/storage_s3/lib/health.js.map +2 -2
  18. package/dist/modules/storage_s3/lib/key-scope.js +4 -4
  19. package/dist/modules/storage_s3/lib/key-scope.js.map +2 -2
  20. package/dist/modules/storage_s3/lib/s3-driver.js +15 -44
  21. package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
  22. package/dist/modules/storage_s3/lib/storage-service.js +22 -126
  23. package/dist/modules/storage_s3/lib/storage-service.js.map +3 -3
  24. package/jest.config.cjs +0 -1
  25. package/package.json +6 -13
  26. package/src/modules/storage_s3/__tests__/s3Driver.test.ts +8 -23
  27. package/src/modules/storage_s3/__tests__/s3ListRoute.test.ts +1 -7
  28. package/src/modules/storage_s3/__tests__/s3Routes.test.ts +0 -26
  29. package/src/modules/storage_s3/__tests__/storageService.test.ts +5 -20
  30. package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +5 -22
  31. package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +5 -7
  32. package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +4 -6
  33. package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +6 -77
  34. package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +16 -101
  35. package/src/modules/storage_s3/di.ts +2 -14
  36. package/src/modules/storage_s3/integration.ts +1 -1
  37. package/src/modules/storage_s3/lib/health.ts +0 -18
  38. package/src/modules/storage_s3/lib/key-scope.ts +4 -4
  39. package/src/modules/storage_s3/lib/s3-driver.ts +10 -65
  40. package/src/modules/storage_s3/lib/storage-service.ts +22 -131
  41. package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js +0 -145
  42. package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js.map +0 -7
  43. package/dist/modules/storage_s3/lib/endpoint-safety.js +0 -133
  44. package/dist/modules/storage_s3/lib/endpoint-safety.js.map +0 -7
  45. package/dist/modules/storage_s3/lib/quota-accounting.js +0 -24
  46. package/dist/modules/storage_s3/lib/quota-accounting.js.map +0 -7
  47. package/dist/modules/storage_s3/lib/quota-recovery-queue.js +0 -9
  48. package/dist/modules/storage_s3/lib/quota-recovery-queue.js.map +0 -7
  49. package/dist/modules/storage_s3/workers/quota-recovery.js +0 -70
  50. package/dist/modules/storage_s3/workers/quota-recovery.js.map +0 -7
  51. package/src/modules/storage_s3/__tests__/endpoint-safety.test.ts +0 -71
  52. package/src/modules/storage_s3/__tests__/health.test.ts +0 -38
  53. package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +0 -13
  54. package/src/modules/storage_s3/__tests__/quota-lifecycle.test.ts +0 -216
  55. package/src/modules/storage_s3/__tests__/quota-recovery.test.ts +0 -83
  56. package/src/modules/storage_s3/__tests__/upload.test.ts +0 -93
  57. package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +0 -150
  58. package/src/modules/storage_s3/i18n/de.json +0 -17
  59. package/src/modules/storage_s3/i18n/en.json +0 -17
  60. package/src/modules/storage_s3/i18n/es.json +0 -17
  61. package/src/modules/storage_s3/i18n/ko.json +0 -17
  62. package/src/modules/storage_s3/i18n/pl.json +0 -17
  63. package/src/modules/storage_s3/lib/endpoint-safety.ts +0 -179
  64. package/src/modules/storage_s3/lib/quota-accounting.ts +0 -29
  65. package/src/modules/storage_s3/lib/quota-recovery-queue.ts +0 -17
  66. 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 { PrepareFilePayload, StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'
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('[internal] S3 key is not scoped to the requested partition')
135
+ throw new Error('S3 key is not scoped to the requested partition')
147
136
  }
148
137
  }
149
138
 
150
- prepareStoragePath(payload: PrepareFilePayload): string {
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
- return `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`
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(`[internal] S3 object body is empty for key: ${storagePath}`)
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.deleteStrict(partitionCode, storagePath)
176
+ await this.client.send(
177
+ new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),
178
+ )
197
179
  } catch {
198
- // Backward-compatible best-effort deletion.
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
- contentLengthOrScope?: number | S3TenantScope | null,
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 { createHash, randomBytes } from 'crypto'
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; reservationId?: string }>
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 storagePath = driver.prepareStoragePath({
96
- partitionCode: sanitizeSegment(namespace),
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
- assertS3KeyScopedToTenant(storagePath, scope, pathPrefix)
102
- let reservation = null as Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null
103
- let storageStarted = false
104
- let objectStored = false
105
- try {
106
- if (quotaService && scope.tenantId && scope.organizationId) {
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.deleteStrict('', key)
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, size, scope }) {
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
- }