@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
|
@@ -3,7 +3,6 @@ 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'
|
|
7
6
|
import {
|
|
8
7
|
buildAttachmentContentDisposition,
|
|
9
8
|
canRenderInlineAttachment,
|
|
@@ -28,24 +27,23 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
export async function GET(req: Request) {
|
|
31
|
-
const { t } = await resolveTranslations()
|
|
32
30
|
const auth = await getAuthFromRequest(req)
|
|
33
31
|
if (!auth?.tenantId || !auth.orgId) {
|
|
34
|
-
return NextResponse.json({ error:
|
|
32
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
const key = new URL(req.url).searchParams.get('key')
|
|
38
36
|
if (!key) {
|
|
39
|
-
return NextResponse.json({ error:
|
|
37
|
+
return NextResponse.json({ error: 'key query param is required' }, { status: 400 })
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {
|
|
43
|
-
return NextResponse.json({ error:
|
|
41
|
+
return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
const driver = await resolveDriver(auth.tenantId, auth.orgId)
|
|
47
45
|
if (!driver) {
|
|
48
|
-
return NextResponse.json({ error:
|
|
46
|
+
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
49
47
|
}
|
|
50
48
|
|
|
51
49
|
let buffer: Buffer
|
|
@@ -55,7 +53,7 @@ export async function GET(req: Request) {
|
|
|
55
53
|
buffer = result.buffer
|
|
56
54
|
contentType = result.contentType
|
|
57
55
|
} catch {
|
|
58
|
-
return NextResponse.json({ error:
|
|
56
|
+
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
|
59
57
|
}
|
|
60
58
|
|
|
61
59
|
const fileName = sanitizeUploadedFileName(key.split('/').pop() || 'download')
|
|
@@ -3,7 +3,6 @@ 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'
|
|
7
6
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
8
7
|
|
|
9
8
|
export const metadata = {
|
|
@@ -72,30 +71,29 @@ function resolveTenantScopedPrefix(prefix: string, tenantId: string, orgId: stri
|
|
|
72
71
|
}
|
|
73
72
|
|
|
74
73
|
export async function GET(req: Request) {
|
|
75
|
-
const { t } = await resolveTranslations()
|
|
76
74
|
const auth = await getAuthFromRequest(req)
|
|
77
75
|
if (!auth?.tenantId || !auth.orgId) {
|
|
78
|
-
return NextResponse.json({ error:
|
|
76
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
79
77
|
}
|
|
80
78
|
|
|
81
79
|
const params = Object.fromEntries(new URL(req.url).searchParams.entries())
|
|
82
80
|
const parsed = querySchema.safeParse(params)
|
|
83
81
|
if (!parsed.success) {
|
|
84
|
-
return NextResponse.json({ error:
|
|
82
|
+
return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })
|
|
85
83
|
}
|
|
86
84
|
|
|
87
85
|
// Always scope list operations to the tenant namespace to prevent cross-tenant enumeration.
|
|
88
86
|
const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId)
|
|
89
87
|
if (effectivePrefix === null) {
|
|
90
88
|
return NextResponse.json(
|
|
91
|
-
{ error:
|
|
89
|
+
{ error: 'Access denied: prefix is not scoped to this tenant.' },
|
|
92
90
|
{ status: 403 },
|
|
93
91
|
)
|
|
94
92
|
}
|
|
95
93
|
|
|
96
94
|
const driver = await resolveDriver(auth.tenantId, auth.orgId)
|
|
97
95
|
if (!driver) {
|
|
98
|
-
return NextResponse.json({ error:
|
|
96
|
+
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
99
97
|
}
|
|
100
98
|
|
|
101
99
|
const result = await driver.listObjects(
|
|
@@ -3,13 +3,8 @@ 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'
|
|
7
6
|
import { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'
|
|
8
7
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
9
|
-
import { createHash, randomBytes } from 'node:crypto'
|
|
10
|
-
import { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'
|
|
11
|
-
import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
|
|
12
|
-
import { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'
|
|
13
8
|
|
|
14
9
|
export const metadata = {
|
|
15
10
|
path: '/storage-providers/s3/signed-url',
|
|
@@ -21,13 +16,11 @@ const requestSchema = z.object({
|
|
|
21
16
|
operation: z.enum(['upload', 'download']),
|
|
22
17
|
expiresIn: z.number().int().min(60).max(604800).optional().default(3600),
|
|
23
18
|
contentType: z.string().optional(),
|
|
24
|
-
size: z.number().int().nonnegative().optional(),
|
|
25
19
|
})
|
|
26
20
|
|
|
27
21
|
const responseSchema = z.object({
|
|
28
22
|
url: z.string(),
|
|
29
23
|
expiresAt: z.string(),
|
|
30
|
-
reservationId: z.string().optional(),
|
|
31
24
|
})
|
|
32
25
|
|
|
33
26
|
async function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {
|
|
@@ -41,96 +34,32 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
41
34
|
}
|
|
42
35
|
|
|
43
36
|
export async function POST(req: Request) {
|
|
44
|
-
const { t } = await resolveTranslations()
|
|
45
37
|
const auth = await getAuthFromRequest(req)
|
|
46
38
|
if (!auth?.tenantId || !auth.orgId) {
|
|
47
|
-
return NextResponse.json({ error:
|
|
39
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
48
40
|
}
|
|
49
41
|
|
|
50
42
|
const json = await req.json().catch(() => null)
|
|
51
43
|
const parsed = requestSchema.safeParse(json)
|
|
52
44
|
if (!parsed.success) {
|
|
53
|
-
return NextResponse.json({ error:
|
|
45
|
+
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
|
54
46
|
}
|
|
55
47
|
|
|
56
|
-
const { key, operation, expiresIn, contentType
|
|
48
|
+
const { key, operation, expiresIn, contentType } = parsed.data
|
|
57
49
|
const canAccessKey = operation === 'download'
|
|
58
50
|
? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)
|
|
59
51
|
: isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId)
|
|
60
52
|
if (!canAccessKey) {
|
|
61
|
-
return NextResponse.json({ error:
|
|
53
|
+
return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
|
|
62
54
|
}
|
|
63
55
|
|
|
64
56
|
const driver = await resolveDriver(auth.tenantId, auth.orgId)
|
|
65
57
|
if (!driver) {
|
|
66
|
-
return NextResponse.json({ error:
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()
|
|
70
|
-
|
|
71
|
-
if (operation === 'upload') {
|
|
72
|
-
const { resolve } = await createRequestContainer()
|
|
73
|
-
let attachmentQuotaService: AttachmentQuotaService | null = null
|
|
74
|
-
let recoveryScheduler: ((
|
|
75
|
-
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
76
|
-
delayMs: number,
|
|
77
|
-
) => Promise<void>) | null = null
|
|
78
|
-
try {
|
|
79
|
-
attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService
|
|
80
|
-
recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (
|
|
81
|
-
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
82
|
-
delayMs: number,
|
|
83
|
-
) => Promise<void>
|
|
84
|
-
} catch {
|
|
85
|
-
// Fail closed below when the attachments quota service is not registered.
|
|
86
|
-
}
|
|
87
|
-
if (!attachmentQuotaService) {
|
|
88
|
-
return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
|
|
89
|
-
}
|
|
90
|
-
const compatibilityToken = randomBytes(32).toString('base64url')
|
|
91
|
-
const reservedBytes = size ?? resolveAttachmentMaxBytes()
|
|
92
|
-
let reservation: Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null = null
|
|
93
|
-
try {
|
|
94
|
-
await reconcileTenantS3Objects({
|
|
95
|
-
driver,
|
|
96
|
-
quotaService: attachmentQuotaService,
|
|
97
|
-
tenantId: auth.tenantId,
|
|
98
|
-
organizationId: auth.orgId,
|
|
99
|
-
})
|
|
100
|
-
reservation = await attachmentQuotaService.reserve({
|
|
101
|
-
tenantId: auth.tenantId,
|
|
102
|
-
organizationId: auth.orgId,
|
|
103
|
-
bytes: reservedBytes,
|
|
104
|
-
source: 'storage_s3_signed',
|
|
105
|
-
storageDriver: 's3',
|
|
106
|
-
storagePath: key,
|
|
107
|
-
uploadTokenHash: createHash('sha256').update(compatibilityToken).digest('hex'),
|
|
108
|
-
ttlMs: expiresIn * 1000,
|
|
109
|
-
})
|
|
110
|
-
if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
|
|
111
|
-
await recoveryScheduler({
|
|
112
|
-
reservationId: reservation.id,
|
|
113
|
-
tenantId: auth.tenantId,
|
|
114
|
-
organizationId: auth.orgId,
|
|
115
|
-
}, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))
|
|
116
|
-
const url = new URL(`/api/storage-providers/s3/signed-upload/${compatibilityToken}`, req.url).toString()
|
|
117
|
-
return NextResponse.json({ url, expiresAt, reservationId: reservation.id })
|
|
118
|
-
} catch (error) {
|
|
119
|
-
if (reservation) {
|
|
120
|
-
await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
121
|
-
}
|
|
122
|
-
const code = (error as { code?: unknown })?.code
|
|
123
|
-
if (code === 'quota_exceeded') {
|
|
124
|
-
return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })
|
|
125
|
-
}
|
|
126
|
-
if (code === 'quota_target_exists') {
|
|
127
|
-
return NextResponse.json({ error: 'The target storage key already exists.' }, { status: 409 })
|
|
128
|
-
}
|
|
129
|
-
return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
|
|
130
|
-
}
|
|
58
|
+
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
131
59
|
}
|
|
132
60
|
|
|
133
61
|
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)
|
|
62
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()
|
|
134
63
|
|
|
135
64
|
return NextResponse.json({ url, expiresAt })
|
|
136
65
|
}
|
|
@@ -3,23 +3,18 @@ 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'
|
|
7
6
|
import {
|
|
8
7
|
detectAttachmentMimeType,
|
|
9
8
|
hasDangerousExecutableExtension,
|
|
10
9
|
isActiveContentAttachment,
|
|
11
10
|
} from '@open-mercato/core/modules/attachments/lib/security'
|
|
12
11
|
import {
|
|
13
|
-
isMultipartUploadLimitError,
|
|
14
12
|
isMultipartRequestWithinUploadLimit,
|
|
15
|
-
parseMultipartFormDataWithinUploadLimit,
|
|
16
13
|
resolveAttachmentMaxBytes,
|
|
17
14
|
willExceedAttachmentTenantQuota,
|
|
18
15
|
} from '@open-mercato/core/modules/attachments/lib/upload-limits'
|
|
19
16
|
import { isS3KeyScopedToTenant } from '../../../../lib/key-scope'
|
|
20
17
|
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'
|
|
23
18
|
import { randomUUID } from 'crypto'
|
|
24
19
|
|
|
25
20
|
export const metadata = {
|
|
@@ -73,146 +68,66 @@ async function readTenantStorageUsageBytes(
|
|
|
73
68
|
}
|
|
74
69
|
|
|
75
70
|
export async function POST(req: Request) {
|
|
76
|
-
const { t } = await resolveTranslations()
|
|
77
71
|
const auth = await getAuthFromRequest(req)
|
|
78
72
|
if (!auth?.tenantId || !auth.orgId) {
|
|
79
|
-
return NextResponse.json({ error:
|
|
73
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
80
74
|
}
|
|
81
75
|
|
|
82
76
|
if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {
|
|
83
|
-
return NextResponse.json({ error:
|
|
77
|
+
return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })
|
|
84
78
|
}
|
|
85
79
|
|
|
86
80
|
const contentType = req.headers.get('content-type') ?? ''
|
|
87
81
|
if (!contentType.includes('multipart/form-data')) {
|
|
88
|
-
return NextResponse.json({ error:
|
|
82
|
+
return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })
|
|
89
83
|
}
|
|
90
84
|
|
|
91
|
-
|
|
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
|
-
}
|
|
85
|
+
const form = await req.formData()
|
|
100
86
|
const file = form.get('file') as File | null
|
|
101
87
|
if (!file) {
|
|
102
|
-
return NextResponse.json({ error:
|
|
88
|
+
return NextResponse.json({ error: 'file field is required' }, { status: 400 })
|
|
103
89
|
}
|
|
104
90
|
|
|
105
91
|
const keyOverride = form.get('key') ? String(form.get('key')) : null
|
|
106
92
|
|
|
107
93
|
if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {
|
|
108
94
|
return NextResponse.json(
|
|
109
|
-
{ error:
|
|
95
|
+
{ error: 'Access denied: key override is not scoped to this tenant.' },
|
|
110
96
|
{ status: 403 },
|
|
111
97
|
)
|
|
112
98
|
}
|
|
113
99
|
|
|
114
100
|
if (hasDangerousExecutableExtension(file.name)) {
|
|
115
|
-
return NextResponse.json({ error:
|
|
101
|
+
return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })
|
|
116
102
|
}
|
|
117
103
|
|
|
118
104
|
const effectiveMaxBytes = resolveAttachmentMaxBytes()
|
|
119
105
|
if (file.size > effectiveMaxBytes) {
|
|
120
|
-
return NextResponse.json({ error:
|
|
106
|
+
return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })
|
|
121
107
|
}
|
|
122
108
|
|
|
123
109
|
const driver = await resolveDriver(auth.tenantId, auth.orgId)
|
|
124
110
|
if (!driver) {
|
|
125
|
-
return NextResponse.json({ error:
|
|
111
|
+
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
126
112
|
}
|
|
127
113
|
|
|
128
114
|
const buffer = Buffer.from(await file.arrayBuffer())
|
|
129
115
|
const safeName = sanitizeFileName(file.name)
|
|
130
116
|
const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)
|
|
131
117
|
if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {
|
|
132
|
-
return NextResponse.json({ error:
|
|
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 })
|
|
133
124
|
}
|
|
134
125
|
|
|
135
126
|
const key =
|
|
136
127
|
keyOverride ??
|
|
137
128
|
`uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`
|
|
138
129
|
|
|
139
|
-
|
|
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: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })
|
|
186
|
-
}
|
|
187
|
-
if (code === 'quota_target_exists') {
|
|
188
|
-
return NextResponse.json({ error: 'The target storage key already exists.' }, { status: 409 })
|
|
189
|
-
}
|
|
190
|
-
return NextResponse.json({ error: '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: '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
|
-
}
|
|
130
|
+
await driver.putObject(key, buffer, trustedMimeType)
|
|
216
131
|
|
|
217
132
|
return NextResponse.json({
|
|
218
133
|
key,
|
|
@@ -6,10 +6,8 @@ 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'
|
|
10
9
|
import { s3HealthCheck } from './lib/health'
|
|
11
10
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
12
|
-
import { scheduleStorageS3QuotaRecovery } from './lib/quota-recovery-queue'
|
|
13
11
|
|
|
14
12
|
const logger = createLogger('storage_s3')
|
|
15
13
|
|
|
@@ -69,22 +67,14 @@ export function register(container: AppContainer) {
|
|
|
69
67
|
|
|
70
68
|
container.register({
|
|
71
69
|
s3HealthCheck: asValue(s3HealthCheck),
|
|
72
|
-
storageS3QuotaRecoveryScheduler: asValue(scheduleStorageS3QuotaRecovery),
|
|
73
70
|
storageService: asFunction(
|
|
74
|
-
({ integrationCredentialsService
|
|
75
|
-
integrationCredentialsService: IntegrationCredentialsService
|
|
76
|
-
attachmentQuotaService: AttachmentQuotaService
|
|
77
|
-
storageS3QuotaRecoveryScheduler: (
|
|
78
|
-
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
79
|
-
delayMs: number,
|
|
80
|
-
) => Promise<void>
|
|
81
|
-
}) => {
|
|
71
|
+
({ integrationCredentialsService }: { integrationCredentialsService: IntegrationCredentialsService }) => {
|
|
82
72
|
// StorageService factory — builds the service lazily using credentials
|
|
83
73
|
// resolved from the Integration Marketplace per request.
|
|
84
74
|
return {
|
|
85
75
|
async _resolveService(scope: { tenantId: string; organizationId: string }) {
|
|
86
76
|
const creds = await integrationCredentialsService.resolve('storage_s3', scope)
|
|
87
|
-
if (!creds) throw new Error('
|
|
77
|
+
if (!creds) throw new Error('S3 storage integration is not configured for this tenant.')
|
|
88
78
|
return createStorageService({
|
|
89
79
|
authMode: creds.authMode === 'ambient' || creds.authMode === 'access_keys'
|
|
90
80
|
? (creds.authMode as 'ambient' | 'access_keys')
|
|
@@ -96,8 +86,6 @@ export function register(container: AppContainer) {
|
|
|
96
86
|
accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : undefined,
|
|
97
87
|
secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : undefined,
|
|
98
88
|
sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,
|
|
99
|
-
quotaService: attachmentQuotaService,
|
|
100
|
-
quotaRecoveryScheduler: storageS3QuotaRecoveryScheduler,
|
|
101
89
|
organizationId: scope.organizationId,
|
|
102
90
|
tenantId: scope.tenantId,
|
|
103
91
|
// Credentials are resolved from the Integration Marketplace (encrypted at rest)
|
|
@@ -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. Required for MinIO, DigitalOcean Spaces, and other providers.',
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
key: 'forcePathStyle',
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3'
|
|
2
|
-
import { assertSafeS3Endpoint, createSafeS3RequestHandler } from './endpoint-safety'
|
|
3
2
|
import type { S3DriverConfig } from './s3-driver'
|
|
4
3
|
|
|
5
4
|
export const s3HealthCheck = {
|
|
@@ -68,28 +67,11 @@ export const s3HealthCheck = {
|
|
|
68
67
|
}
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
try {
|
|
72
|
-
await assertSafeS3Endpoint(cfg.endpoint)
|
|
73
|
-
} catch (err: unknown) {
|
|
74
|
-
const message = err instanceof Error ? err.message : 'Unsafe S3 endpoint'
|
|
75
|
-
const reason = err && typeof err === 'object' && 'reason' in err ? (err as { reason?: unknown }).reason : undefined
|
|
76
|
-
return {
|
|
77
|
-
status: 'unhealthy',
|
|
78
|
-
message,
|
|
79
|
-
details: {
|
|
80
|
-
...(typeof reason === 'string' ? { reason } : {}),
|
|
81
|
-
},
|
|
82
|
-
checkedAt,
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const requestHandler = createSafeS3RequestHandler(cfg.endpoint)
|
|
87
70
|
const client = new S3Client({
|
|
88
71
|
region: cfg.region ?? 'us-east-1',
|
|
89
72
|
endpoint: cfg.endpoint,
|
|
90
73
|
forcePathStyle: cfg.forcePathStyle ?? false,
|
|
91
74
|
credentials: credentialConfig,
|
|
92
|
-
...(requestHandler ? { requestHandler } : {}),
|
|
93
75
|
})
|
|
94
76
|
|
|
95
77
|
try {
|
|
@@ -113,7 +113,7 @@ export function assertS3KeyScopedToTenant(
|
|
|
113
113
|
pathPrefix?: string,
|
|
114
114
|
): void {
|
|
115
115
|
if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {
|
|
116
|
-
throw new Error('
|
|
116
|
+
throw new Error('S3 key is not scoped to the active tenant')
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -123,7 +123,7 @@ export function assertS3KeyAddressableByTenantScope(
|
|
|
123
123
|
pathPrefix?: string,
|
|
124
124
|
): void {
|
|
125
125
|
if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {
|
|
126
|
-
throw new Error('
|
|
126
|
+
throw new Error('S3 key is not scoped to the active tenant')
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -140,7 +140,7 @@ export function assertS3ListPrefixScopedToTenant(
|
|
|
140
140
|
|
|
141
141
|
if (parts[0]?.startsWith('org_') || parts[1]?.startsWith('tenant_')) {
|
|
142
142
|
if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {
|
|
143
|
-
throw new Error('
|
|
143
|
+
throw new Error('S3 prefix is not scoped to the active tenant')
|
|
144
144
|
}
|
|
145
145
|
return
|
|
146
146
|
}
|
|
@@ -149,7 +149,7 @@ export function assertS3ListPrefixScopedToTenant(
|
|
|
149
149
|
(parts[1]?.startsWith('org_') && parts[1] !== segments.orgSegment)
|
|
150
150
|
|| (parts[2]?.startsWith('tenant_') && parts[2] !== segments.tenantSegment)
|
|
151
151
|
) {
|
|
152
|
-
throw new Error('
|
|
152
|
+
throw new Error('S3 prefix is not scoped to the active tenant')
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
|