@open-mercato/storage-s3 0.6.7 → 0.6.8-develop.6874.1.982d6097d8
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 +20 -5
- 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 +7 -5
- 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 +6 -4
- 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 +70 -8
- 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 +92 -15
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js +145 -0
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js.map +7 -0
- package/dist/modules/storage_s3/di.js +6 -2
- 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/endpoint-safety.js +133 -0
- package/dist/modules/storage_s3/lib/endpoint-safety.js.map +7 -0
- package/dist/modules/storage_s3/lib/health.js +18 -1
- 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/quota-accounting.js +24 -0
- package/dist/modules/storage_s3/lib/quota-accounting.js.map +7 -0
- package/dist/modules/storage_s3/lib/quota-recovery-queue.js +9 -0
- package/dist/modules/storage_s3/lib/quota-recovery-queue.js.map +7 -0
- package/dist/modules/storage_s3/lib/s3-driver.js +44 -15
- package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
- package/dist/modules/storage_s3/lib/storage-service.js +126 -22
- package/dist/modules/storage_s3/lib/storage-service.js.map +3 -3
- package/dist/modules/storage_s3/workers/quota-recovery.js +70 -0
- package/dist/modules/storage_s3/workers/quota-recovery.js.map +7 -0
- package/jest.config.cjs +1 -0
- package/package.json +13 -6
- package/src/modules/storage_s3/__tests__/endpoint-safety.test.ts +71 -0
- package/src/modules/storage_s3/__tests__/health.test.ts +38 -0
- package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +13 -0
- package/src/modules/storage_s3/__tests__/quota-lifecycle.test.ts +216 -0
- package/src/modules/storage_s3/__tests__/quota-recovery.test.ts +83 -0
- package/src/modules/storage_s3/__tests__/s3Driver.test.ts +23 -8
- package/src/modules/storage_s3/__tests__/s3ListRoute.test.ts +7 -1
- package/src/modules/storage_s3/__tests__/s3Routes.test.ts +26 -0
- package/src/modules/storage_s3/__tests__/storageService.test.ts +20 -5
- package/src/modules/storage_s3/__tests__/upload.test.ts +93 -0
- package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +22 -5
- package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +7 -5
- package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +6 -4
- package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +77 -6
- package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +101 -16
- package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +150 -0
- package/src/modules/storage_s3/di.ts +14 -2
- package/src/modules/storage_s3/i18n/de.json +19 -0
- package/src/modules/storage_s3/i18n/en.json +19 -0
- package/src/modules/storage_s3/i18n/es.json +19 -0
- package/src/modules/storage_s3/i18n/ko.json +19 -0
- package/src/modules/storage_s3/i18n/pl.json +19 -0
- package/src/modules/storage_s3/integration.ts +1 -1
- package/src/modules/storage_s3/lib/endpoint-safety.ts +179 -0
- package/src/modules/storage_s3/lib/health.ts +18 -0
- package/src/modules/storage_s3/lib/key-scope.ts +4 -4
- package/src/modules/storage_s3/lib/quota-accounting.ts +29 -0
- package/src/modules/storage_s3/lib/quota-recovery-queue.ts +17 -0
- package/src/modules/storage_s3/lib/s3-driver.ts +65 -10
- package/src/modules/storage_s3/lib/storage-service.ts +131 -22
- package/src/modules/storage_s3/workers/quota-recovery.ts +82 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, randomBytes } 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'
|
|
3
6
|
import {
|
|
4
7
|
assertS3KeyAddressableByTenantScope,
|
|
5
8
|
assertS3KeyScopedToTenant,
|
|
@@ -34,6 +37,7 @@ type SignedUrlInput = {
|
|
|
34
37
|
operation: 'upload' | 'download'
|
|
35
38
|
expiresIn?: number
|
|
36
39
|
contentType?: string
|
|
40
|
+
size?: number
|
|
37
41
|
scope: TenantScope
|
|
38
42
|
}
|
|
39
43
|
type ListInput = {
|
|
@@ -53,6 +57,11 @@ type S3Config = {
|
|
|
53
57
|
accessKeyId?: string
|
|
54
58
|
secretAccessKey?: string
|
|
55
59
|
sessionToken?: string
|
|
60
|
+
quotaService?: AttachmentQuotaService
|
|
61
|
+
quotaRecoveryScheduler?: (
|
|
62
|
+
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
63
|
+
delayMs: number,
|
|
64
|
+
) => Promise<void>
|
|
56
65
|
pathPrefix?: string
|
|
57
66
|
organizationId?: string | null
|
|
58
67
|
tenantId?: string | null
|
|
@@ -62,19 +71,11 @@ function sanitizeSegment(value: string): string {
|
|
|
62
71
|
return value.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
63
72
|
}
|
|
64
73
|
|
|
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
|
-
|
|
73
74
|
export interface StorageService {
|
|
74
75
|
upload(input: UploadInput): Promise<StorageResult>
|
|
75
76
|
download(input: DownloadInput): Promise<{ buffer: Buffer; contentType?: string }>
|
|
76
77
|
delete(input: DeleteInput): Promise<void>
|
|
77
|
-
getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date }>
|
|
78
|
+
getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date; reservationId?: string }>
|
|
78
79
|
list(input: ListInput): Promise<{
|
|
79
80
|
files: Array<{ key: string; size: number; lastModified: Date }>
|
|
80
81
|
truncated: boolean
|
|
@@ -85,23 +86,86 @@ export interface StorageService {
|
|
|
85
86
|
|
|
86
87
|
export function createStorageService(config: S3Config): StorageService {
|
|
87
88
|
const driver = new S3StorageDriver(config as Record<string, unknown>)
|
|
89
|
+
const quotaService = config.quotaService
|
|
90
|
+
const quotaRecoveryScheduler = config.quotaRecoveryScheduler
|
|
88
91
|
const pathPrefix = config.pathPrefix
|
|
89
92
|
|
|
90
93
|
return {
|
|
91
94
|
async upload({ namespace, fileName, buffer, contentType, scope }): Promise<StorageResult> {
|
|
92
|
-
const
|
|
93
|
-
partitionCode: namespace,
|
|
95
|
+
const storagePath = driver.prepareStoragePath({
|
|
96
|
+
partitionCode: sanitizeSegment(namespace),
|
|
94
97
|
orgId: scope.organizationId,
|
|
95
98
|
tenantId: scope.tenantId,
|
|
96
99
|
fileName,
|
|
97
|
-
buffer,
|
|
98
100
|
})
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
105
169
|
}
|
|
106
170
|
},
|
|
107
171
|
|
|
@@ -112,17 +176,62 @@ export function createStorageService(config: S3Config): StorageService {
|
|
|
112
176
|
|
|
113
177
|
async delete({ key, scope }): Promise<void> {
|
|
114
178
|
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)
|
|
115
|
-
return driver.
|
|
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
|
+
})
|
|
116
189
|
},
|
|
117
190
|
|
|
118
|
-
async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope })
|
|
191
|
+
async getSignedUrl({ key, operation, expiresIn = 3600, contentType, size, scope }) {
|
|
119
192
|
if (operation === 'upload') {
|
|
120
193
|
assertS3KeyScopedToTenant(key, scope, pathPrefix)
|
|
121
194
|
} else {
|
|
122
195
|
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)
|
|
123
196
|
}
|
|
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
|
+
}
|
|
124
233
|
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope)
|
|
125
|
-
return { url, expiresAt
|
|
234
|
+
return { url, expiresAt }
|
|
126
235
|
},
|
|
127
236
|
|
|
128
237
|
async list({ prefix, maxKeys = 100, continuationToken, scope }): Promise<{
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
|
|
2
|
+
import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
|
|
3
|
+
import { S3StorageDriver } from '../lib/s3-driver'
|
|
4
|
+
import { scheduleStorageS3QuotaRecovery, type StorageS3QuotaRecoveryJob } from '../lib/quota-recovery-queue'
|
|
5
|
+
|
|
6
|
+
export const metadata: WorkerMeta = {
|
|
7
|
+
queue: 'storage-s3-quota-recovery',
|
|
8
|
+
id: 'storage_s3:quota-recovery',
|
|
9
|
+
concurrency: 2,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type HandlerContext = JobContext & { resolve: <T = unknown>(name: string) => T }
|
|
13
|
+
|
|
14
|
+
type IntegrationCredentialsService = {
|
|
15
|
+
resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default async function handle(
|
|
19
|
+
job: QueuedJob<StorageS3QuotaRecoveryJob>,
|
|
20
|
+
ctx: HandlerContext,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const quotaService = ctx.resolve<AttachmentQuotaService>('attachmentQuotaService')
|
|
23
|
+
const scope = {
|
|
24
|
+
tenantId: job.payload.tenantId,
|
|
25
|
+
organizationId: job.payload.organizationId,
|
|
26
|
+
}
|
|
27
|
+
const record = await quotaService.getReservation(job.payload.reservationId, scope)
|
|
28
|
+
if (!record || record.status === 'committed') return
|
|
29
|
+
if (record.expiresAt && record.expiresAt.getTime() > Date.now()) {
|
|
30
|
+
await scheduleStorageS3QuotaRecovery(job.payload, record.expiresAt.getTime() - Date.now())
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const claimed = await quotaService.claimExpired(record.id, scope)
|
|
35
|
+
if (!claimed) return
|
|
36
|
+
try {
|
|
37
|
+
const credentialsService = ctx.resolve<IntegrationCredentialsService>('integrationCredentialsService')
|
|
38
|
+
const credentials = await credentialsService.resolve('storage_s3', {
|
|
39
|
+
tenantId: claimed.tenantId,
|
|
40
|
+
organizationId: claimed.organizationId,
|
|
41
|
+
})
|
|
42
|
+
if (!credentials) throw new Error('S3 integration is not configured for quota recovery.')
|
|
43
|
+
const driver = new S3StorageDriver({
|
|
44
|
+
...credentials,
|
|
45
|
+
organizationId: claimed.organizationId,
|
|
46
|
+
tenantId: claimed.tenantId,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
if (claimed.source === 'storage_s3_signed') {
|
|
50
|
+
const listed = await driver.listObjects(claimed.storagePath, 1)
|
|
51
|
+
const object = listed.files.find((file) => file.key === claimed.storagePath)
|
|
52
|
+
if (!object) {
|
|
53
|
+
if (record.status === 'storing' && (job.payload.absenceCheck ?? 0) === 0 && claimed.expiresAt) {
|
|
54
|
+
await scheduleStorageS3QuotaRecovery({
|
|
55
|
+
...job.payload,
|
|
56
|
+
absenceCheck: 1,
|
|
57
|
+
},
|
|
58
|
+
claimed.expiresAt.getTime() - Date.now(),
|
|
59
|
+
)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
await quotaService.release(claimed.id, claimed.leaseToken)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const validSize = claimed.uploadTokenHash
|
|
66
|
+
? object.size <= claimed.reservedBytes
|
|
67
|
+
: object.size === claimed.reservedBytes
|
|
68
|
+
if (validSize) {
|
|
69
|
+
await quotaService.commitRecoveredStandalone(claimed.id, claimed.leaseToken, object.size)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await driver.deleteStrict('', claimed.storagePath)
|
|
75
|
+
await quotaService.release(claimed.id, claimed.leaseToken)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (claimed.expiresAt) {
|
|
78
|
+
await scheduleStorageS3QuotaRecovery(job.payload, claimed.expiresAt.getTime() - Date.now())
|
|
79
|
+
}
|
|
80
|
+
throw error
|
|
81
|
+
}
|
|
82
|
+
}
|