@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
|
@@ -3,18 +3,23 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
6
7
|
import {
|
|
7
8
|
detectAttachmentMimeType,
|
|
8
9
|
hasDangerousExecutableExtension,
|
|
9
10
|
isActiveContentAttachment,
|
|
10
11
|
} from '@open-mercato/core/modules/attachments/lib/security'
|
|
11
12
|
import {
|
|
13
|
+
isMultipartUploadLimitError,
|
|
12
14
|
isMultipartRequestWithinUploadLimit,
|
|
15
|
+
parseMultipartFormDataWithinUploadLimit,
|
|
13
16
|
resolveAttachmentMaxBytes,
|
|
14
17
|
willExceedAttachmentTenantQuota,
|
|
15
18
|
} from '@open-mercato/core/modules/attachments/lib/upload-limits'
|
|
16
19
|
import { isS3KeyScopedToTenant } from '../../../../lib/key-scope'
|
|
17
20
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
21
|
+
import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
|
|
22
|
+
import { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'
|
|
18
23
|
import { randomUUID } from 'crypto'
|
|
19
24
|
|
|
20
25
|
export const metadata = {
|
|
@@ -68,66 +73,146 @@ async function readTenantStorageUsageBytes(
|
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
export async function POST(req: Request) {
|
|
76
|
+
const { t } = await resolveTranslations()
|
|
71
77
|
const auth = await getAuthFromRequest(req)
|
|
72
78
|
if (!auth?.tenantId || !auth.orgId) {
|
|
73
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
79
|
+
return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
|
|
74
80
|
}
|
|
75
81
|
|
|
76
82
|
if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {
|
|
77
|
-
return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })
|
|
83
|
+
return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
const contentType = req.headers.get('content-type') ?? ''
|
|
81
87
|
if (!contentType.includes('multipart/form-data')) {
|
|
82
|
-
return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })
|
|
88
|
+
return NextResponse.json({ error: t('storage_s3.errors.multipartRequired', 'Expected multipart/form-data') }, { status: 400 })
|
|
83
89
|
}
|
|
84
90
|
|
|
85
|
-
|
|
91
|
+
let form: FormData
|
|
92
|
+
try {
|
|
93
|
+
form = await parseMultipartFormDataWithinUploadLimit(req)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (isMultipartUploadLimitError(error)) {
|
|
96
|
+
return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })
|
|
97
|
+
}
|
|
98
|
+
throw error
|
|
99
|
+
}
|
|
86
100
|
const file = form.get('file') as File | null
|
|
87
101
|
if (!file) {
|
|
88
|
-
return NextResponse.json({ error: 'file field is required' }, { status: 400 })
|
|
102
|
+
return NextResponse.json({ error: t('storage_s3.errors.fileRequired', 'file field is required') }, { status: 400 })
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
const keyOverride = form.get('key') ? String(form.get('key')) : null
|
|
92
106
|
|
|
93
107
|
if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {
|
|
94
108
|
return NextResponse.json(
|
|
95
|
-
{ error: 'Access denied: key override is not scoped to this tenant.' },
|
|
109
|
+
{ error: t('storage_s3.errors.keyOverrideAccessDenied', 'Access denied: key override is not scoped to this tenant.') },
|
|
96
110
|
{ status: 403 },
|
|
97
111
|
)
|
|
98
112
|
}
|
|
99
113
|
|
|
100
114
|
if (hasDangerousExecutableExtension(file.name)) {
|
|
101
|
-
return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })
|
|
115
|
+
return NextResponse.json({ error: t('storage_s3.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.') }, { status: 400 })
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
const effectiveMaxBytes = resolveAttachmentMaxBytes()
|
|
105
119
|
if (file.size > effectiveMaxBytes) {
|
|
106
|
-
return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })
|
|
120
|
+
return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })
|
|
107
121
|
}
|
|
108
122
|
|
|
109
123
|
const driver = await resolveDriver(auth.tenantId, auth.orgId)
|
|
110
124
|
if (!driver) {
|
|
111
|
-
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
125
|
+
return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
|
|
112
126
|
}
|
|
113
127
|
|
|
114
128
|
const buffer = Buffer.from(await file.arrayBuffer())
|
|
115
129
|
const safeName = sanitizeFileName(file.name)
|
|
116
130
|
const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)
|
|
117
131
|
if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {
|
|
118
|
-
return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)
|
|
122
|
-
if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
|
|
123
|
-
return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })
|
|
132
|
+
return NextResponse.json({ error: t('storage_s3.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })
|
|
124
133
|
}
|
|
125
134
|
|
|
126
135
|
const key =
|
|
127
136
|
keyOverride ??
|
|
128
137
|
`uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`
|
|
129
138
|
|
|
130
|
-
await
|
|
139
|
+
const { resolve } = await createRequestContainer()
|
|
140
|
+
let attachmentQuotaService: AttachmentQuotaService | null = null
|
|
141
|
+
let recoveryScheduler: ((
|
|
142
|
+
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
143
|
+
delayMs: number,
|
|
144
|
+
) => Promise<void>) | null = null
|
|
145
|
+
try {
|
|
146
|
+
attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService
|
|
147
|
+
recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (
|
|
148
|
+
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
149
|
+
delayMs: number,
|
|
150
|
+
) => Promise<void>
|
|
151
|
+
} catch {
|
|
152
|
+
// Backward-compatible fallback below when the attachments quota service is not registered.
|
|
153
|
+
}
|
|
154
|
+
let reservation: { id: string; leaseToken: string; expiresAt: Date } | null = null
|
|
155
|
+
if (attachmentQuotaService) {
|
|
156
|
+
try {
|
|
157
|
+
await reconcileTenantS3Objects({
|
|
158
|
+
driver,
|
|
159
|
+
quotaService: attachmentQuotaService,
|
|
160
|
+
tenantId: auth.tenantId,
|
|
161
|
+
organizationId: auth.orgId,
|
|
162
|
+
})
|
|
163
|
+
reservation = await attachmentQuotaService.reserve({
|
|
164
|
+
tenantId: auth.tenantId,
|
|
165
|
+
organizationId: auth.orgId,
|
|
166
|
+
bytes: buffer.length,
|
|
167
|
+
source: 'storage_s3_upload',
|
|
168
|
+
storageDriver: 's3',
|
|
169
|
+
storagePath: key,
|
|
170
|
+
})
|
|
171
|
+
if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
|
|
172
|
+
await recoveryScheduler({
|
|
173
|
+
reservationId: reservation.id,
|
|
174
|
+
tenantId: auth.tenantId,
|
|
175
|
+
organizationId: auth.orgId,
|
|
176
|
+
}, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))
|
|
177
|
+
await attachmentQuotaService.beginStorage(reservation.id, reservation.leaseToken)
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (reservation) {
|
|
180
|
+
await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
181
|
+
reservation = null
|
|
182
|
+
}
|
|
183
|
+
const code = (error as { code?: unknown })?.code
|
|
184
|
+
if (code === 'quota_exceeded') {
|
|
185
|
+
return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })
|
|
186
|
+
}
|
|
187
|
+
if (code === 'quota_target_exists') {
|
|
188
|
+
return NextResponse.json({ error: t('storage_s3.errors.quotaTargetExists', 'The target storage key already exists.') }, { status: 409 })
|
|
189
|
+
}
|
|
190
|
+
return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)
|
|
194
|
+
if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
|
|
195
|
+
return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await driver.putObject(key, buffer, trustedMimeType)
|
|
201
|
+
if (reservation) {
|
|
202
|
+
await attachmentQuotaService!.markStored(reservation.id, reservation.leaseToken)
|
|
203
|
+
await attachmentQuotaService!.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (reservation) {
|
|
207
|
+
try {
|
|
208
|
+
await driver.deleteStrict('', key)
|
|
209
|
+
await attachmentQuotaService!.release(reservation.id, reservation.leaseToken)
|
|
210
|
+
} catch {
|
|
211
|
+
// Retain the reservation when object absence cannot be proven.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })
|
|
215
|
+
}
|
|
131
216
|
|
|
132
217
|
return NextResponse.json({
|
|
133
218
|
key,
|
|
@@ -0,0 +1,150 @@
|
|
|
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 type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
6
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
|
+
import {
|
|
8
|
+
detectAttachmentMimeType,
|
|
9
|
+
hasDangerousExecutableExtension,
|
|
10
|
+
isActiveContentAttachment,
|
|
11
|
+
} from '@open-mercato/core/modules/attachments/lib/security'
|
|
12
|
+
import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
|
|
13
|
+
import { S3StorageDriver } from '../../../../../lib/s3-driver'
|
|
14
|
+
|
|
15
|
+
export const metadata = {
|
|
16
|
+
path: '/storage-providers/s3/signed-upload/:token',
|
|
17
|
+
PUT: { requireAuth: false },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RouteContext = { params?: Promise<{ token?: string }> | { token?: string } }
|
|
21
|
+
|
|
22
|
+
class UploadBodyTooLargeError extends Error {}
|
|
23
|
+
|
|
24
|
+
async function readBoundedBody(req: Request, maxBytes: number): Promise<Buffer> {
|
|
25
|
+
if (!req.body) return Buffer.alloc(0)
|
|
26
|
+
const reader = req.body.getReader()
|
|
27
|
+
const chunks: Buffer[] = []
|
|
28
|
+
let totalBytes = 0
|
|
29
|
+
try {
|
|
30
|
+
while (true) {
|
|
31
|
+
const { done, value } = await reader.read()
|
|
32
|
+
if (done) break
|
|
33
|
+
totalBytes += value.byteLength
|
|
34
|
+
if (totalBytes > maxBytes) {
|
|
35
|
+
await reader.cancel()
|
|
36
|
+
throw new UploadBodyTooLargeError('Attachment exceeds the reserved upload size.')
|
|
37
|
+
}
|
|
38
|
+
chunks.push(Buffer.from(value))
|
|
39
|
+
}
|
|
40
|
+
} finally {
|
|
41
|
+
reader.releaseLock()
|
|
42
|
+
}
|
|
43
|
+
return Buffer.concat(chunks, totalBytes)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function PUT(req: Request, ctx: RouteContext) {
|
|
47
|
+
const params = ctx.params instanceof Promise ? await ctx.params : ctx.params
|
|
48
|
+
const token = params?.token?.trim()
|
|
49
|
+
if (!token) return NextResponse.json({ error: 'Upload token is required.' }, { status: 400 })
|
|
50
|
+
|
|
51
|
+
const { resolve } = await createRequestContainer()
|
|
52
|
+
let quotaService: AttachmentQuotaService | null = null
|
|
53
|
+
try {
|
|
54
|
+
quotaService = resolve('attachmentQuotaService') as AttachmentQuotaService
|
|
55
|
+
} catch {
|
|
56
|
+
// Fail closed below when the attachments quota service is not registered.
|
|
57
|
+
}
|
|
58
|
+
if (!quotaService) return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
|
|
59
|
+
|
|
60
|
+
const tokenHash = createHash('sha256').update(token).digest('hex')
|
|
61
|
+
const reservation = await quotaService.claimPendingByUploadTokenHash(tokenHash)
|
|
62
|
+
if (!reservation || reservation.source !== 'storage_s3_signed') {
|
|
63
|
+
return NextResponse.json({ error: 'Upload token is invalid or expired.' }, { status: 410 })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const declaredLengthHeader = req.headers.get('content-length')
|
|
67
|
+
const declaredLength = declaredLengthHeader == null ? null : Number(declaredLengthHeader)
|
|
68
|
+
if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > reservation.reservedBytes) {
|
|
69
|
+
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
70
|
+
return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })
|
|
71
|
+
}
|
|
72
|
+
let buffer: Buffer
|
|
73
|
+
try {
|
|
74
|
+
buffer = await readBoundedBody(req, reservation.reservedBytes)
|
|
75
|
+
} catch (error) {
|
|
76
|
+
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
77
|
+
if (error instanceof UploadBodyTooLargeError) {
|
|
78
|
+
return NextResponse.json({ error: error.message }, { status: 413 })
|
|
79
|
+
}
|
|
80
|
+
return NextResponse.json({ error: 'Failed to read attachment.' }, { status: 400 })
|
|
81
|
+
}
|
|
82
|
+
if (buffer.length > reservation.reservedBytes) {
|
|
83
|
+
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
84
|
+
return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })
|
|
85
|
+
}
|
|
86
|
+
const fileName = path.posix.basename(reservation.storagePath)
|
|
87
|
+
if (hasDangerousExecutableExtension(fileName)) {
|
|
88
|
+
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
89
|
+
return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })
|
|
90
|
+
}
|
|
91
|
+
const contentType = detectAttachmentMimeType(buffer, fileName, req.headers.get('content-type'))
|
|
92
|
+
if (isActiveContentAttachment(buffer, fileName, contentType)) {
|
|
93
|
+
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
94
|
+
return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const credentialsService = resolve('integrationCredentialsService') as {
|
|
98
|
+
resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>
|
|
99
|
+
}
|
|
100
|
+
const credentials = await credentialsService.resolve('storage_s3', {
|
|
101
|
+
tenantId: reservation.tenantId,
|
|
102
|
+
organizationId: reservation.organizationId,
|
|
103
|
+
})
|
|
104
|
+
if (!credentials) {
|
|
105
|
+
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
106
|
+
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const driver = new S3StorageDriver({
|
|
110
|
+
...credentials,
|
|
111
|
+
organizationId: reservation.organizationId,
|
|
112
|
+
tenantId: reservation.tenantId,
|
|
113
|
+
})
|
|
114
|
+
let objectStored = false
|
|
115
|
+
try {
|
|
116
|
+
await driver.putObject(reservation.storagePath, buffer, contentType)
|
|
117
|
+
objectStored = true
|
|
118
|
+
await quotaService.markStored(reservation.id, reservation.leaseToken)
|
|
119
|
+
await quotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)
|
|
120
|
+
return new NextResponse(null, { status: 200 })
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (objectStored) {
|
|
123
|
+
try {
|
|
124
|
+
await driver.deleteStrict('', reservation.storagePath)
|
|
125
|
+
await quotaService.release(reservation.id, reservation.leaseToken)
|
|
126
|
+
} catch {
|
|
127
|
+
// Retain the reservation when object absence cannot be proven.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default PUT
|
|
135
|
+
|
|
136
|
+
export const openApi: OpenApiRouteDoc = {
|
|
137
|
+
tag: 'Storage',
|
|
138
|
+
summary: 'Upload through a bounded one-time compatibility URL',
|
|
139
|
+
methods: {
|
|
140
|
+
PUT: {
|
|
141
|
+
summary: 'Upload an object using a bounded one-time token',
|
|
142
|
+
responses: [{ status: 200, description: 'Upload completed', schema: z.any() }],
|
|
143
|
+
errors: [
|
|
144
|
+
{ status: 400, description: 'Invalid upload', schema: z.object({ error: z.string() }) },
|
|
145
|
+
{ status: 410, description: 'Token invalid or expired', schema: z.object({ error: z.string() }) },
|
|
146
|
+
{ status: 413, description: 'Upload exceeds reserved size', schema: z.object({ error: z.string() }) },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
}
|
|
@@ -6,8 +6,10 @@ import {
|
|
|
6
6
|
} from '@open-mercato/core/modules/attachments/lib/drivers'
|
|
7
7
|
import { S3StorageDriver } from './lib/s3-driver'
|
|
8
8
|
import { createStorageService } from './lib/storage-service'
|
|
9
|
+
import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
|
|
9
10
|
import { s3HealthCheck } from './lib/health'
|
|
10
11
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
12
|
+
import { scheduleStorageS3QuotaRecovery } from './lib/quota-recovery-queue'
|
|
11
13
|
|
|
12
14
|
const logger = createLogger('storage_s3')
|
|
13
15
|
|
|
@@ -67,14 +69,22 @@ export function register(container: AppContainer) {
|
|
|
67
69
|
|
|
68
70
|
container.register({
|
|
69
71
|
s3HealthCheck: asValue(s3HealthCheck),
|
|
72
|
+
storageS3QuotaRecoveryScheduler: asValue(scheduleStorageS3QuotaRecovery),
|
|
70
73
|
storageService: asFunction(
|
|
71
|
-
({ integrationCredentialsService
|
|
74
|
+
({ integrationCredentialsService, attachmentQuotaService, storageS3QuotaRecoveryScheduler }: {
|
|
75
|
+
integrationCredentialsService: IntegrationCredentialsService
|
|
76
|
+
attachmentQuotaService: AttachmentQuotaService
|
|
77
|
+
storageS3QuotaRecoveryScheduler: (
|
|
78
|
+
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
79
|
+
delayMs: number,
|
|
80
|
+
) => Promise<void>
|
|
81
|
+
}) => {
|
|
72
82
|
// StorageService factory — builds the service lazily using credentials
|
|
73
83
|
// resolved from the Integration Marketplace per request.
|
|
74
84
|
return {
|
|
75
85
|
async _resolveService(scope: { tenantId: string; organizationId: string }) {
|
|
76
86
|
const creds = await integrationCredentialsService.resolve('storage_s3', scope)
|
|
77
|
-
if (!creds) throw new Error('S3 storage integration is not configured for this tenant.')
|
|
87
|
+
if (!creds) throw new Error('[internal] S3 storage integration is not configured for this tenant.')
|
|
78
88
|
return createStorageService({
|
|
79
89
|
authMode: creds.authMode === 'ambient' || creds.authMode === 'access_keys'
|
|
80
90
|
? (creds.authMode as 'ambient' | 'access_keys')
|
|
@@ -86,6 +96,8 @@ export function register(container: AppContainer) {
|
|
|
86
96
|
accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : undefined,
|
|
87
97
|
secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : undefined,
|
|
88
98
|
sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,
|
|
99
|
+
quotaService: attachmentQuotaService,
|
|
100
|
+
quotaRecoveryScheduler: storageS3QuotaRecoveryScheduler,
|
|
89
101
|
organizationId: scope.organizationId,
|
|
90
102
|
tenantId: scope.tenantId,
|
|
91
103
|
// Credentials are resolved from the Integration Marketplace (encrypted at rest)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"storage_s3.errors.activeContentBlocked": "Das Hochladen aktiver Inhalte ist nicht erlaubt.",
|
|
3
|
+
"storage_s3.errors.dangerousExecutable": "Ausführbare Dateitypen sind als Anhänge nicht erlaubt.",
|
|
4
|
+
"storage_s3.errors.fileNotFound": "Datei nicht gefunden",
|
|
5
|
+
"storage_s3.errors.fileRequired": "Das Feld „file“ ist erforderlich",
|
|
6
|
+
"storage_s3.errors.integrationNotConfigured": "Die S3-Integration ist nicht konfiguriert.",
|
|
7
|
+
"storage_s3.errors.invalidPayload": "Ungültige Anfragedaten",
|
|
8
|
+
"storage_s3.errors.invalidQuery": "Ungültige Abfrageparameter",
|
|
9
|
+
"storage_s3.errors.keyAccessDenied": "Zugriff verweigert: Der Schlüssel ist nicht auf diesen Tenant beschränkt.",
|
|
10
|
+
"storage_s3.errors.keyOverrideAccessDenied": "Zugriff verweigert: Die Schlüsselüberschreibung ist nicht auf diesen Tenant beschränkt.",
|
|
11
|
+
"storage_s3.errors.keyRequired": "Der Abfrageparameter „key“ ist erforderlich",
|
|
12
|
+
"storage_s3.errors.maxUploadSize": "Der Anhang überschreitet die maximal zulässige Upload-Größe.",
|
|
13
|
+
"storage_s3.errors.multipartRequired": "multipart/form-data wurde erwartet",
|
|
14
|
+
"storage_s3.errors.prefixAccessDenied": "Zugriff verweigert: Das Präfix ist nicht auf diesen Tenant beschränkt.",
|
|
15
|
+
"storage_s3.errors.quotaExceeded": "Das Speicherkontingent für Anhänge dieses Mandanten ist erschöpft.",
|
|
16
|
+
"storage_s3.errors.quotaTargetExists": "Der Ziel-Speicherschlüssel existiert bereits.",
|
|
17
|
+
"storage_s3.errors.quotaUnavailable": "Die Kontingentabrechnung des Speichers ist nicht verfügbar.",
|
|
18
|
+
"storage_s3.errors.unauthorized": "Nicht autorisiert"
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"storage_s3.errors.activeContentBlocked": "Active content uploads are not allowed.",
|
|
3
|
+
"storage_s3.errors.dangerousExecutable": "Executable file types are not allowed as attachments.",
|
|
4
|
+
"storage_s3.errors.fileNotFound": "File not found",
|
|
5
|
+
"storage_s3.errors.fileRequired": "file field is required",
|
|
6
|
+
"storage_s3.errors.integrationNotConfigured": "S3 integration is not configured.",
|
|
7
|
+
"storage_s3.errors.invalidPayload": "Invalid payload",
|
|
8
|
+
"storage_s3.errors.invalidQuery": "Invalid query parameters",
|
|
9
|
+
"storage_s3.errors.keyAccessDenied": "Access denied: key is not scoped to this tenant.",
|
|
10
|
+
"storage_s3.errors.keyOverrideAccessDenied": "Access denied: key override is not scoped to this tenant.",
|
|
11
|
+
"storage_s3.errors.keyRequired": "key query param is required",
|
|
12
|
+
"storage_s3.errors.maxUploadSize": "Attachment exceeds the maximum upload size.",
|
|
13
|
+
"storage_s3.errors.multipartRequired": "Expected multipart/form-data",
|
|
14
|
+
"storage_s3.errors.prefixAccessDenied": "Access denied: prefix is not scoped to this tenant.",
|
|
15
|
+
"storage_s3.errors.quotaExceeded": "Attachment storage quota exceeded for this tenant.",
|
|
16
|
+
"storage_s3.errors.quotaTargetExists": "The target storage key already exists.",
|
|
17
|
+
"storage_s3.errors.quotaUnavailable": "Storage quota accounting is unavailable.",
|
|
18
|
+
"storage_s3.errors.unauthorized": "Unauthorized"
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"storage_s3.errors.activeContentBlocked": "No se permite la carga de contenido activo.",
|
|
3
|
+
"storage_s3.errors.dangerousExecutable": "No se permiten archivos ejecutables como adjuntos.",
|
|
4
|
+
"storage_s3.errors.fileNotFound": "Archivo no encontrado",
|
|
5
|
+
"storage_s3.errors.fileRequired": "El campo file es obligatorio",
|
|
6
|
+
"storage_s3.errors.integrationNotConfigured": "La integración de S3 no está configurada.",
|
|
7
|
+
"storage_s3.errors.invalidPayload": "Contenido de solicitud no válido",
|
|
8
|
+
"storage_s3.errors.invalidQuery": "Parámetros de consulta no válidos",
|
|
9
|
+
"storage_s3.errors.keyAccessDenied": "Acceso denegado: la clave no está limitada a este tenant.",
|
|
10
|
+
"storage_s3.errors.keyOverrideAccessDenied": "Acceso denegado: la sobrescritura de la clave no está limitada a este tenant.",
|
|
11
|
+
"storage_s3.errors.keyRequired": "Se requiere el parámetro de consulta key",
|
|
12
|
+
"storage_s3.errors.maxUploadSize": "El archivo adjunto supera el tamaño máximo de carga permitido.",
|
|
13
|
+
"storage_s3.errors.multipartRequired": "Se esperaba multipart/form-data",
|
|
14
|
+
"storage_s3.errors.prefixAccessDenied": "Acceso denegado: el prefijo no está limitado a este tenant.",
|
|
15
|
+
"storage_s3.errors.quotaExceeded": "Se superó la cuota de almacenamiento de adjuntos para este inquilino.",
|
|
16
|
+
"storage_s3.errors.quotaTargetExists": "La clave de almacenamiento de destino ya existe.",
|
|
17
|
+
"storage_s3.errors.quotaUnavailable": "La contabilidad de la cuota de almacenamiento no está disponible.",
|
|
18
|
+
"storage_s3.errors.unauthorized": "No autorizado"
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"storage_s3.errors.activeContentBlocked": "활성 콘텐츠 업로드는 허용되지 않습니다.",
|
|
3
|
+
"storage_s3.errors.dangerousExecutable": "실행 파일 형식은 첨부가 허용되지 않습니다.",
|
|
4
|
+
"storage_s3.errors.fileNotFound": "파일을 찾을 수 없습니다",
|
|
5
|
+
"storage_s3.errors.fileRequired": "파일 필드는 필수입니다",
|
|
6
|
+
"storage_s3.errors.integrationNotConfigured": "S3 통합이 구성되지 않았습니다.",
|
|
7
|
+
"storage_s3.errors.invalidPayload": "잘못된 페이로드",
|
|
8
|
+
"storage_s3.errors.invalidQuery": "잘못된 쿼리 매개변수",
|
|
9
|
+
"storage_s3.errors.keyAccessDenied": "액세스 거부됨: 키 범위가 이 테넌트로 지정되지 않았습니다.",
|
|
10
|
+
"storage_s3.errors.keyOverrideAccessDenied": "액세스 거부됨: 키 재정의 범위가 이 테넌트로 지정되지 않았습니다.",
|
|
11
|
+
"storage_s3.errors.keyRequired": "주요 쿼리 매개변수가 필요합니다.",
|
|
12
|
+
"storage_s3.errors.maxUploadSize": "첨부파일이 최대 업로드 크기를 초과합니다.",
|
|
13
|
+
"storage_s3.errors.multipartRequired": "예상되는 멀티파트/양식 데이터",
|
|
14
|
+
"storage_s3.errors.prefixAccessDenied": "액세스 거부됨: 접두사의 범위가 이 테넌트로 지정되지 않았습니다.",
|
|
15
|
+
"storage_s3.errors.quotaExceeded": "이 테넌트의 첨부 파일 저장 용량 한도를 초과했습니다.",
|
|
16
|
+
"storage_s3.errors.quotaTargetExists": "대상 저장 키가 이미 존재합니다.",
|
|
17
|
+
"storage_s3.errors.quotaUnavailable": "저장 용량 정산을 사용할 수 없습니다.",
|
|
18
|
+
"storage_s3.errors.unauthorized": "승인되지 않은"
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"storage_s3.errors.activeContentBlocked": "Przesyłanie treści aktywnych nie jest dozwolone.",
|
|
3
|
+
"storage_s3.errors.dangerousExecutable": "Pliki wykonywalne nie są dozwolone jako załączniki.",
|
|
4
|
+
"storage_s3.errors.fileNotFound": "Nie znaleziono pliku",
|
|
5
|
+
"storage_s3.errors.fileRequired": "Pole file jest wymagane",
|
|
6
|
+
"storage_s3.errors.integrationNotConfigured": "Integracja S3 nie jest skonfigurowana.",
|
|
7
|
+
"storage_s3.errors.invalidPayload": "Nieprawidłowe dane żądania",
|
|
8
|
+
"storage_s3.errors.invalidQuery": "Nieprawidłowe parametry zapytania",
|
|
9
|
+
"storage_s3.errors.keyAccessDenied": "Odmowa dostępu: klucz nie jest ograniczony do tego tenantu.",
|
|
10
|
+
"storage_s3.errors.keyOverrideAccessDenied": "Odmowa dostępu: nadpisany klucz nie jest ograniczony do tego tenantu.",
|
|
11
|
+
"storage_s3.errors.keyRequired": "Wymagany jest parametr zapytania key",
|
|
12
|
+
"storage_s3.errors.maxUploadSize": "Załącznik przekracza maksymalny dopuszczalny rozmiar przesyłania.",
|
|
13
|
+
"storage_s3.errors.multipartRequired": "Oczekiwano multipart/form-data",
|
|
14
|
+
"storage_s3.errors.prefixAccessDenied": "Odmowa dostępu: prefiks nie jest ograniczony do tego tenantu.",
|
|
15
|
+
"storage_s3.errors.quotaExceeded": "Przekroczono limit przestrzeni na załączniki dla tego najemcy.",
|
|
16
|
+
"storage_s3.errors.quotaTargetExists": "Docelowy klucz w magazynie już istnieje.",
|
|
17
|
+
"storage_s3.errors.quotaUnavailable": "Rozliczanie limitu magazynu jest niedostępne.",
|
|
18
|
+
"storage_s3.errors.unauthorized": "Brak autoryzacji"
|
|
19
|
+
}
|
|
@@ -79,7 +79,7 @@ export const integration: IntegrationDefinition = {
|
|
|
79
79
|
type: 'url',
|
|
80
80
|
required: false,
|
|
81
81
|
placeholder: 'https://fra1.digitaloceanspaces.com',
|
|
82
|
-
helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3.
|
|
82
|
+
helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3. Internal/private endpoints require OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS=true.',
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
key: 'forcePathStyle',
|