@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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/post/storage-providers/s3/upload.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\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 {\n isMultipartRequestWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport { randomUUID } from 'crypto'\n\nexport const metadata = {\n path: '/storage-providers/s3/upload',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst responseSchema = z.object({\n key: z.string(),\n bucket: z.string(),\n size: z.number().int(),\n contentType: z.string().optional(),\n})\n\nfunction sanitizeFileName(name: string): string {\n return name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'\n}\n\nasync function resolveDriver(\n tenantId: string,\n orgId: string,\n): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nasync function readTenantStorageUsageBytes(\n driver: S3StorageDriver,\n tenantId: string,\n orgId: string,\n): Promise<number> {\n let totalBytes = 0\n let continuationToken: string | undefined\n\n do {\n const page = await driver.listObjects('', 1000, continuationToken)\n for (const file of page.files) {\n if (isS3KeyScopedToTenant(file.key, orgId, tenantId)) {\n totalBytes += file.size\n }\n }\n continuationToken = page.truncated ? page.nextContinuationToken : undefined\n } while (continuationToken)\n\n return totalBytes\n}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {\n return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })\n }\n\n const contentType = req.headers.get('content-type') ?? ''\n if (!contentType.includes('multipart/form-data')) {\n return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })\n }\n\n const form = await req.formData()\n const file = form.get('file') as File | null\n if (!file) {\n return NextResponse.json({ error: 'file field is required' }, { status: 400 })\n }\n\n const keyOverride = form.get('key') ? String(form.get('key')) : null\n\n if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {\n return NextResponse.json(\n { error: 'Access denied: key override is not scoped to this tenant.' },\n { status: 403 },\n )\n }\n\n if (hasDangerousExecutableExtension(file.name)) {\n return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })\n }\n\n const effectiveMaxBytes = resolveAttachmentMaxBytes()\n if (file.size > effectiveMaxBytes) {\n return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })\n }\n\n const buffer = Buffer.from(await file.arrayBuffer())\n const safeName = sanitizeFileName(file.name)\n const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)\n if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {\n return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })\n }\n\n const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)\n if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {\n return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })\n }\n\n const key =\n keyOverride ??\n `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`\n\n await driver.putObject(key, buffer, trustedMimeType)\n\n return NextResponse.json({\n key,\n bucket: driver.getBucket(),\n size: buffer.length,\n contentType: trustedMimeType,\n })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload file to S3',\n methods: {\n POST: {\n summary: 'Upload a file directly to S3',\n description: 'Uploads a file to the configured S3 bucket. Requires storage_providers.manage feature.',\n requestBody: {\n contentType: 'multipart/form-data',\n schema: z.object({\n file: z.any().describe('File to upload'),\n key: z.string().optional().describe('Optional S3 key override (must be scoped to org/tenant)'),\n contentType: z.string().optional().describe('Optional client-provided content-type hint; the server derives the trusted MIME type.'),\n }),\n },\n responses: [{ status: 200, description: 'Upload result', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Missing file, blocked file type, or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key override not scoped to this tenant', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload too large or tenant storage quota exceeded', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,6BAA6B;AACtC,SAAS,uBAAuB;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport {\n isMultipartUploadLimitError,\n isMultipartRequestWithinUploadLimit,\n parseMultipartFormDataWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'\nimport { randomUUID } from 'crypto'\n\nexport const metadata = {\n path: '/storage-providers/s3/upload',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst responseSchema = z.object({\n key: z.string(),\n bucket: z.string(),\n size: z.number().int(),\n contentType: z.string().optional(),\n})\n\nfunction sanitizeFileName(name: string): string {\n return name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'\n}\n\nasync function resolveDriver(\n tenantId: string,\n orgId: string,\n): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nasync function readTenantStorageUsageBytes(\n driver: S3StorageDriver,\n tenantId: string,\n orgId: string,\n): Promise<number> {\n let totalBytes = 0\n let continuationToken: string | undefined\n\n do {\n const page = await driver.listObjects('', 1000, continuationToken)\n for (const file of page.files) {\n if (isS3KeyScopedToTenant(file.key, orgId, tenantId)) {\n totalBytes += file.size\n }\n }\n continuationToken = page.truncated ? page.nextContinuationToken : undefined\n } while (continuationToken)\n\n return totalBytes\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n\n const contentType = req.headers.get('content-type') ?? ''\n if (!contentType.includes('multipart/form-data')) {\n return NextResponse.json({ error: t('storage_s3.errors.multipartRequired', 'Expected multipart/form-data') }, { status: 400 })\n }\n\n let form: FormData\n try {\n form = await parseMultipartFormDataWithinUploadLimit(req)\n } catch (error) {\n if (isMultipartUploadLimitError(error)) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n throw error\n }\n const file = form.get('file') as File | null\n if (!file) {\n return NextResponse.json({ error: t('storage_s3.errors.fileRequired', 'file field is required') }, { status: 400 })\n }\n\n const keyOverride = form.get('key') ? String(form.get('key')) : null\n\n if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {\n return NextResponse.json(\n { error: t('storage_s3.errors.keyOverrideAccessDenied', 'Access denied: key override is not scoped to this tenant.') },\n { status: 403 },\n )\n }\n\n if (hasDangerousExecutableExtension(file.name)) {\n return NextResponse.json({ error: t('storage_s3.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.') }, { status: 400 })\n }\n\n const effectiveMaxBytes = resolveAttachmentMaxBytes()\n if (file.size > effectiveMaxBytes) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n const buffer = Buffer.from(await file.arrayBuffer())\n const safeName = sanitizeFileName(file.name)\n const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)\n if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {\n return NextResponse.json({ error: t('storage_s3.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })\n }\n\n const key =\n keyOverride ??\n `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`\n\n const { resolve } = await createRequestContainer()\n let attachmentQuotaService: AttachmentQuotaService | null = null\n let recoveryScheduler: ((\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>) | null = null\n try {\n attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService\n recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\n } catch {\n // Backward-compatible fallback below when the attachments quota service is not registered.\n }\n let reservation: { id: string; leaseToken: string; expiresAt: Date } | null = null\n if (attachmentQuotaService) {\n try {\n await reconcileTenantS3Objects({\n driver,\n quotaService: attachmentQuotaService,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n reservation = await attachmentQuotaService.reserve({\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n bytes: buffer.length,\n source: 'storage_s3_upload',\n storageDriver: 's3',\n storagePath: key,\n })\n if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')\n await recoveryScheduler({\n reservationId: reservation.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n await attachmentQuotaService.beginStorage(reservation.id, reservation.leaseToken)\n } catch (error) {\n if (reservation) {\n await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n reservation = null\n }\n const code = (error as { code?: unknown })?.code\n if (code === 'quota_exceeded') {\n return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })\n }\n if (code === 'quota_target_exists') {\n return NextResponse.json({ error: t('storage_s3.errors.quotaTargetExists', 'The target storage key already exists.') }, { status: 409 })\n }\n return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })\n }\n } else {\n const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)\n if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {\n return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })\n }\n }\n\n try {\n await driver.putObject(key, buffer, trustedMimeType)\n if (reservation) {\n await attachmentQuotaService!.markStored(reservation.id, reservation.leaseToken)\n await attachmentQuotaService!.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)\n }\n } catch (error) {\n if (reservation) {\n try {\n await driver.deleteStrict('', key)\n await attachmentQuotaService!.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 return NextResponse.json({\n key,\n bucket: driver.getBucket(),\n size: buffer.length,\n contentType: trustedMimeType,\n })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload file to S3',\n methods: {\n POST: {\n summary: 'Upload a file directly to S3',\n description: 'Uploads a file to the configured S3 bucket. Requires storage_providers.manage feature.',\n requestBody: {\n contentType: 'multipart/form-data',\n schema: z.object({\n file: z.any().describe('File to upload'),\n key: z.string().optional().describe('Optional S3 key override (must be scoped to org/tenant)'),\n contentType: z.string().optional().describe('Optional client-provided content-type hint; the server derives the trusted MIME type.'),\n }),\n },\n responses: [{ status: 200, description: 'Upload result', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Missing file, blocked file type, or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key override not scoped to this tenant', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload too large or tenant storage quota exceeded', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
|
@@ -0,0 +1,7 @@
|
|
|
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
|
+
}
|
|
@@ -7,6 +7,7 @@ import { S3StorageDriver } from "./lib/s3-driver.js";
|
|
|
7
7
|
import { createStorageService } from "./lib/storage-service.js";
|
|
8
8
|
import { s3HealthCheck } from "./lib/health.js";
|
|
9
9
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
10
|
+
import { scheduleStorageS3QuotaRecovery } from "./lib/quota-recovery-queue.js";
|
|
10
11
|
const logger = createLogger("storage_s3");
|
|
11
12
|
registerExternalStorageDriver("s3", (config) => {
|
|
12
13
|
logger.debug("Creating S3StorageDriver", {
|
|
@@ -54,12 +55,13 @@ function register(container) {
|
|
|
54
55
|
});
|
|
55
56
|
container.register({
|
|
56
57
|
s3HealthCheck: asValue(s3HealthCheck),
|
|
58
|
+
storageS3QuotaRecoveryScheduler: asValue(scheduleStorageS3QuotaRecovery),
|
|
57
59
|
storageService: asFunction(
|
|
58
|
-
({ integrationCredentialsService }) => {
|
|
60
|
+
({ integrationCredentialsService, attachmentQuotaService, storageS3QuotaRecoveryScheduler }) => {
|
|
59
61
|
return {
|
|
60
62
|
async _resolveService(scope) {
|
|
61
63
|
const creds = await integrationCredentialsService.resolve("storage_s3", scope);
|
|
62
|
-
if (!creds) throw new Error("S3 storage integration is not configured for this tenant.");
|
|
64
|
+
if (!creds) throw new Error("[internal] S3 storage integration is not configured for this tenant.");
|
|
63
65
|
return createStorageService({
|
|
64
66
|
authMode: creds.authMode === "ambient" || creds.authMode === "access_keys" ? creds.authMode : void 0,
|
|
65
67
|
bucket: String(creds.bucket ?? ""),
|
|
@@ -69,6 +71,8 @@ function register(container) {
|
|
|
69
71
|
accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : void 0,
|
|
70
72
|
secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : void 0,
|
|
71
73
|
sessionToken: creds.sessionToken ? String(creds.sessionToken) : void 0,
|
|
74
|
+
quotaService: attachmentQuotaService,
|
|
75
|
+
quotaRecoveryScheduler: storageS3QuotaRecoveryScheduler,
|
|
72
76
|
organizationId: scope.organizationId,
|
|
73
77
|
tenantId: scope.tenantId
|
|
74
78
|
// Credentials are resolved from the Integration Marketplace (encrypted at rest)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/storage_s3/di.ts"],
|
|
4
|
-
"sourcesContent": ["import { asFunction, asValue } from 'awilix'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n registerExternalStorageDriver,\n registerExternalCredentialEnhancer,\n} from '@open-mercato/core/modules/attachments/lib/drivers'\nimport { S3StorageDriver } from './lib/s3-driver'\nimport { createStorageService } from './lib/storage-service'\nimport { s3HealthCheck } from './lib/health'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('storage_s3')\n\ntype IntegrationCredentialsService = {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n}\n\n// Module-level registration \u2014 runs at import time, before any DI container is built.\n// This avoids the singleton-proxy resolution issue when registering via DI.\nregisterExternalStorageDriver('s3', (config: Record<string, unknown>) => {\n logger.debug('Creating S3StorageDriver', {\n bucket: config.bucket,\n region: config.region,\n endpoint: config.endpoint,\n forcePathStyle: config.forcePathStyle,\n hasAccessKey: Boolean(config.accessKeyId),\n hasCredentialsEnvPrefix: Boolean(config.credentialsEnvPrefix),\n })\n return new S3StorageDriver(config)\n})\n\nexport function register(container: AppContainer) {\n // Register the credential enhancer via DI so it can access the request-scoped\n // integrationCredentialsService to inject marketplace credentials at upload time.\n registerExternalCredentialEnhancer('s3', async (config, scope) => {\n const scopedConfig = {\n ...config,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n }\n if (config.credentialsEnvPrefix || config.accessKeyId || config.authMode) return scopedConfig\n try {\n const credsSvc = container.resolve('integrationCredentialsService') as IntegrationCredentialsService\n const creds = await credsSvc.resolve('storage_s3', scope)\n if (!creds) {\n logger.debug('No marketplace credentials found for scope', { tenantId: scope.tenantId, organizationId: scope.organizationId })\n return scopedConfig\n }\n logger.debug('Injecting marketplace credentials into S3 driver config')\n return {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n authMode: creds.authMode,\n bucket: config.bucket ?? (creds.bucket ? String(creds.bucket) : undefined),\n region: config.region ?? (creds.region ? String(creds.region) : undefined),\n endpoint: config.endpoint ?? (creds.endpoint ? String(creds.endpoint) : undefined),\n forcePathStyle: config.forcePathStyle ?? Boolean(creds.forcePathStyle),\n accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : undefined,\n secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : undefined,\n sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,\n }\n } catch (err) {\n logger.warn('Credential enhancer failed, using scoped partition config', { err })\n return scopedConfig\n }\n })\n\n container.register({\n s3HealthCheck: asValue(s3HealthCheck),\n storageService: asFunction(\n ({ integrationCredentialsService }: {
|
|
5
|
-
"mappings": "AAAA,SAAS,YAAY,eAAe;AAEpC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,uBAAuB;AAChC,SAAS,4BAA4B;
|
|
4
|
+
"sourcesContent": ["import { asFunction, asValue } from 'awilix'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n registerExternalStorageDriver,\n registerExternalCredentialEnhancer,\n} from '@open-mercato/core/modules/attachments/lib/drivers'\nimport { S3StorageDriver } from './lib/s3-driver'\nimport { createStorageService } from './lib/storage-service'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { s3HealthCheck } from './lib/health'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { scheduleStorageS3QuotaRecovery } from './lib/quota-recovery-queue'\n\nconst logger = createLogger('storage_s3')\n\ntype IntegrationCredentialsService = {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n}\n\n// Module-level registration \u2014 runs at import time, before any DI container is built.\n// This avoids the singleton-proxy resolution issue when registering via DI.\nregisterExternalStorageDriver('s3', (config: Record<string, unknown>) => {\n logger.debug('Creating S3StorageDriver', {\n bucket: config.bucket,\n region: config.region,\n endpoint: config.endpoint,\n forcePathStyle: config.forcePathStyle,\n hasAccessKey: Boolean(config.accessKeyId),\n hasCredentialsEnvPrefix: Boolean(config.credentialsEnvPrefix),\n })\n return new S3StorageDriver(config)\n})\n\nexport function register(container: AppContainer) {\n // Register the credential enhancer via DI so it can access the request-scoped\n // integrationCredentialsService to inject marketplace credentials at upload time.\n registerExternalCredentialEnhancer('s3', async (config, scope) => {\n const scopedConfig = {\n ...config,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n }\n if (config.credentialsEnvPrefix || config.accessKeyId || config.authMode) return scopedConfig\n try {\n const credsSvc = container.resolve('integrationCredentialsService') as IntegrationCredentialsService\n const creds = await credsSvc.resolve('storage_s3', scope)\n if (!creds) {\n logger.debug('No marketplace credentials found for scope', { tenantId: scope.tenantId, organizationId: scope.organizationId })\n return scopedConfig\n }\n logger.debug('Injecting marketplace credentials into S3 driver config')\n return {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n authMode: creds.authMode,\n bucket: config.bucket ?? (creds.bucket ? String(creds.bucket) : undefined),\n region: config.region ?? (creds.region ? String(creds.region) : undefined),\n endpoint: config.endpoint ?? (creds.endpoint ? String(creds.endpoint) : undefined),\n forcePathStyle: config.forcePathStyle ?? Boolean(creds.forcePathStyle),\n accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : undefined,\n secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : undefined,\n sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,\n }\n } catch (err) {\n logger.warn('Credential enhancer failed, using scoped partition config', { err })\n return scopedConfig\n }\n })\n\n container.register({\n s3HealthCheck: asValue(s3HealthCheck),\n storageS3QuotaRecoveryScheduler: asValue(scheduleStorageS3QuotaRecovery),\n storageService: asFunction(\n ({ integrationCredentialsService, attachmentQuotaService, storageS3QuotaRecoveryScheduler }: {\n integrationCredentialsService: IntegrationCredentialsService\n attachmentQuotaService: AttachmentQuotaService\n storageS3QuotaRecoveryScheduler: (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\n }) => {\n // StorageService factory \u2014 builds the service lazily using credentials\n // resolved from the Integration Marketplace per request.\n return {\n async _resolveService(scope: { tenantId: string; organizationId: string }) {\n const creds = await integrationCredentialsService.resolve('storage_s3', scope)\n if (!creds) throw new Error('[internal] S3 storage integration is not configured for this tenant.')\n return createStorageService({\n authMode: creds.authMode === 'ambient' || creds.authMode === 'access_keys'\n ? (creds.authMode as 'ambient' | 'access_keys')\n : undefined,\n bucket: String(creds.bucket ?? ''),\n region: creds.region ? String(creds.region) : undefined,\n endpoint: creds.endpoint ? String(creds.endpoint) : undefined,\n forcePathStyle: Boolean(creds.forcePathStyle),\n accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : undefined,\n secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : undefined,\n sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,\n quotaService: attachmentQuotaService,\n quotaRecoveryScheduler: storageS3QuotaRecoveryScheduler,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n // Credentials are resolved from the Integration Marketplace (encrypted at rest)\n // and injected directly rather than via env prefix for the standalone service.\n } as Parameters<typeof createStorageService>[0])\n },\n }\n },\n )\n .scoped()\n .proxy(),\n })\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -72,7 +72,7 @@ const integration = {
|
|
|
72
72
|
type: "url",
|
|
73
73
|
required: false,
|
|
74
74
|
placeholder: "https://fra1.digitaloceanspaces.com",
|
|
75
|
-
helpText: "Custom S3 endpoint URL. Leave empty for AWS S3.
|
|
75
|
+
helpText: "Custom S3 endpoint URL. Leave empty for AWS S3. Internal/private endpoints require OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS=true."
|
|
76
76
|
},
|
|
77
77
|
{
|
|
78
78
|
key: "forcePathStyle",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/storage_s3/integration.ts"],
|
|
4
|
-
"sourcesContent": ["import type { IntegrationBundle, IntegrationDefinition } from '@open-mercato/shared/modules/integrations/types'\n\nexport const integration: IntegrationDefinition = {\n id: 'storage_s3',\n title: 'S3-Compatible Storage',\n description:\n 'Store attachments and files in AWS S3, DigitalOcean Spaces, MinIO, or any S3-compatible object storage.',\n category: 'storage',\n hub: 'storage_hubs',\n icon: 's3',\n package: '@open-mercato/storage-s3',\n version: '1.0.0',\n author: 'Open Mercato Team',\n company: 'Open Mercato',\n license: 'MIT',\n tags: ['s3', 'aws', 'storage', 'minio', 'digitalocean-spaces'],\n healthCheck: { service: 's3HealthCheck' },\n credentials: {\n fields: [\n {\n key: 'authMode',\n label: 'Authentication',\n type: 'select',\n required: false,\n helpText:\n 'Choose how the AWS SDK should authenticate. For production deployments, prefer IAM roles (IRSA/instance profile) when available.',\n options: [\n { value: 'access_keys', label: 'Access keys' },\n { value: 'ambient', label: 'Credentials provided by AWS (STS / IRSA / instance profile)' },\n ],\n },\n {\n key: 'accessKeyId',\n label: 'Access Key ID',\n type: 'text',\n required: true,\n placeholder: 'AKIAIOSFODNN7EXAMPLE',\n helpText:\n 'AWS IAM access key ID, or the equivalent key for your S3-compatible provider.',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'secretAccessKey',\n label: 'Secret Access Key',\n type: 'secret',\n required: true,\n helpText:\n 'AWS IAM secret access key, or the equivalent secret for your S3-compatible provider.',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'sessionToken',\n label: 'Session Token (optional)',\n type: 'secret',\n required: false,\n helpText:\n 'Optional AWS session token when using temporary credentials (e.g. from STS).',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'region',\n label: 'Region',\n type: 'text',\n required: true,\n placeholder: 'eu-central-1',\n helpText: 'AWS region (e.g. eu-central-1). For DigitalOcean Spaces use the slug like fra1.',\n },\n {\n key: 'bucket',\n label: 'Bucket Name',\n type: 'text',\n required: true,\n placeholder: 'my-company-attachments',\n helpText: 'The S3 bucket where files will be stored.',\n },\n {\n key: 'endpoint',\n label: 'Custom Endpoint',\n type: 'url',\n required: false,\n placeholder: 'https://fra1.digitaloceanspaces.com',\n helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3.
|
|
4
|
+
"sourcesContent": ["import type { IntegrationBundle, IntegrationDefinition } from '@open-mercato/shared/modules/integrations/types'\n\nexport const integration: IntegrationDefinition = {\n id: 'storage_s3',\n title: 'S3-Compatible Storage',\n description:\n 'Store attachments and files in AWS S3, DigitalOcean Spaces, MinIO, or any S3-compatible object storage.',\n category: 'storage',\n hub: 'storage_hubs',\n icon: 's3',\n package: '@open-mercato/storage-s3',\n version: '1.0.0',\n author: 'Open Mercato Team',\n company: 'Open Mercato',\n license: 'MIT',\n tags: ['s3', 'aws', 'storage', 'minio', 'digitalocean-spaces'],\n healthCheck: { service: 's3HealthCheck' },\n credentials: {\n fields: [\n {\n key: 'authMode',\n label: 'Authentication',\n type: 'select',\n required: false,\n helpText:\n 'Choose how the AWS SDK should authenticate. For production deployments, prefer IAM roles (IRSA/instance profile) when available.',\n options: [\n { value: 'access_keys', label: 'Access keys' },\n { value: 'ambient', label: 'Credentials provided by AWS (STS / IRSA / instance profile)' },\n ],\n },\n {\n key: 'accessKeyId',\n label: 'Access Key ID',\n type: 'text',\n required: true,\n placeholder: 'AKIAIOSFODNN7EXAMPLE',\n helpText:\n 'AWS IAM access key ID, or the equivalent key for your S3-compatible provider.',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'secretAccessKey',\n label: 'Secret Access Key',\n type: 'secret',\n required: true,\n helpText:\n 'AWS IAM secret access key, or the equivalent secret for your S3-compatible provider.',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'sessionToken',\n label: 'Session Token (optional)',\n type: 'secret',\n required: false,\n helpText:\n 'Optional AWS session token when using temporary credentials (e.g. from STS).',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'region',\n label: 'Region',\n type: 'text',\n required: true,\n placeholder: 'eu-central-1',\n helpText: 'AWS region (e.g. eu-central-1). For DigitalOcean Spaces use the slug like fra1.',\n },\n {\n key: 'bucket',\n label: 'Bucket Name',\n type: 'text',\n required: true,\n placeholder: 'my-company-attachments',\n helpText: 'The S3 bucket where files will be stored.',\n },\n {\n key: 'endpoint',\n label: 'Custom Endpoint',\n type: 'url',\n required: false,\n placeholder: 'https://fra1.digitaloceanspaces.com',\n helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3. Internal/private endpoints require OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS=true.',\n },\n {\n key: 'forcePathStyle',\n label: 'Force Path Style',\n type: 'boolean',\n required: false,\n helpText: 'Enable path-style addressing. Required for MinIO. Leave disabled for AWS S3 and DigitalOcean Spaces.',\n },\n ],\n },\n}\n\nexport const integrations: IntegrationDefinition[] = [integration]\nexport const bundles: IntegrationBundle[] = []\nexport const bundle: IntegrationBundle | undefined = undefined\n"],
|
|
5
5
|
"mappings": "AAEO,MAAM,cAAqC;AAAA,EAChD,IAAI;AAAA,EACJ,OAAO;AAAA,EACP,aACE;AAAA,EACF,UAAU;AAAA,EACV,KAAK;AAAA,EACL,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,SAAS;AAAA,EACT,MAAM,CAAC,MAAM,OAAO,WAAW,SAAS,qBAAqB;AAAA,EAC7D,aAAa,EAAE,SAAS,gBAAgB;AAAA,EACxC,aAAa;AAAA,IACX,QAAQ;AAAA,MACN;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UACE;AAAA,QACF,SAAS;AAAA,UACP,EAAE,OAAO,eAAe,OAAO,cAAc;AAAA,UAC7C,EAAE,OAAO,WAAW,OAAO,8DAA8D;AAAA,QAC3F;AAAA,MACF;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UACE;AAAA,QACF,aAAa,EAAE,OAAO,YAAY,QAAQ,cAAc;AAAA,MAC1D;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UACE;AAAA,QACF,aAAa,EAAE,OAAO,YAAY,QAAQ,cAAc;AAAA,MAC1D;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UACE;AAAA,QACF,aAAa,EAAE,OAAO,YAAY,QAAQ,cAAc;AAAA,MAC1D;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UAAU;AAAA,MACZ;AAAA,IACF;AAAA,EACF;AACF;AAEO,MAAM,eAAwC,CAAC,WAAW;AAC1D,MAAM,UAA+B,CAAC;AACtC,MAAM,SAAwC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import { Agent as HttpAgent } from "node:http";
|
|
3
|
+
import { Agent as HttpsAgent } from "node:https";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
6
|
+
import {
|
|
7
|
+
assertSafeOutboundUrl,
|
|
8
|
+
assertStaticallySafeOutboundUrl,
|
|
9
|
+
parseOutboundUrl,
|
|
10
|
+
resolveSafeOutboundUrl
|
|
11
|
+
} from "@open-mercato/shared/lib/url-safety";
|
|
12
|
+
const SUBJECT = "S3 endpoint";
|
|
13
|
+
class UnsafeS3EndpointError extends Error {
|
|
14
|
+
constructor(reason, message) {
|
|
15
|
+
super(message ?? `S3 endpoint rejected: ${reason}`);
|
|
16
|
+
this.name = "UnsafeS3EndpointError";
|
|
17
|
+
this.reason = reason;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const s3EndpointErrorFactory = (reason, message) => new UnsafeS3EndpointError(reason, message);
|
|
21
|
+
function assertStaticallySafeS3Endpoint(rawEndpoint, deps = {}) {
|
|
22
|
+
if (!rawEndpoint) return;
|
|
23
|
+
const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled();
|
|
24
|
+
assertStaticallySafeOutboundUrl(rawEndpoint, {
|
|
25
|
+
errorFactory: s3EndpointErrorFactory,
|
|
26
|
+
subject: SUBJECT,
|
|
27
|
+
allowPrivate
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async function assertSafeS3Endpoint(rawEndpoint, deps = {}) {
|
|
31
|
+
if (!rawEndpoint) return;
|
|
32
|
+
const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled();
|
|
33
|
+
await assertSafeOutboundUrl(rawEndpoint, {
|
|
34
|
+
errorFactory: s3EndpointErrorFactory,
|
|
35
|
+
subject: SUBJECT,
|
|
36
|
+
allowPrivate,
|
|
37
|
+
lookupHost: deps.lookupHost
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function createSafeS3EndpointLookup(rawEndpoint, deps = {}) {
|
|
41
|
+
if (!rawEndpoint) return void 0;
|
|
42
|
+
const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled();
|
|
43
|
+
if (allowPrivate) return void 0;
|
|
44
|
+
const { hostname } = parseOutboundUrl(rawEndpoint, {
|
|
45
|
+
errorFactory: s3EndpointErrorFactory,
|
|
46
|
+
subject: SUBJECT
|
|
47
|
+
});
|
|
48
|
+
const endpointHostname = normalizeLookupHostname(hostname);
|
|
49
|
+
return (host, options, callback) => {
|
|
50
|
+
const lookupHostname = normalizeLookupHostname(host);
|
|
51
|
+
if (!isAllowedS3LookupHost(lookupHostname, endpointHostname)) {
|
|
52
|
+
callback(new UnsafeS3EndpointError(
|
|
53
|
+
"blocked_hostname",
|
|
54
|
+
`S3 endpoint lookup host "${host}" does not match configured host "${endpointHostname}"`
|
|
55
|
+
), "", 0);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
resolveSafeOutboundUrl(buildLookupValidationUrl(rawEndpoint, lookupHostname), {
|
|
59
|
+
errorFactory: s3EndpointErrorFactory,
|
|
60
|
+
subject: SUBJECT,
|
|
61
|
+
lookupHost: deps.lookupHost ?? defaultLookupHost
|
|
62
|
+
}).then(({ addresses }) => {
|
|
63
|
+
if (!addresses || addresses.length === 0) {
|
|
64
|
+
const family = isIP(lookupHostname);
|
|
65
|
+
if (options.all) callback(null, [{ address: lookupHostname, family }]);
|
|
66
|
+
else callback(null, lookupHostname, family);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (options.all) {
|
|
70
|
+
callback(null, addresses.map((record) => ({ address: record.address, family: record.family })));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
74
|
+
}).catch((error) => {
|
|
75
|
+
callback(error instanceof Error ? error : new UnsafeS3EndpointError("dns_resolution_failed"), "", 0);
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function normalizeLookupHostname(hostname) {
|
|
80
|
+
let normalized = hostname.trim().toLowerCase();
|
|
81
|
+
if (normalized.startsWith("[") && normalized.endsWith("]")) {
|
|
82
|
+
normalized = normalized.slice(1, -1);
|
|
83
|
+
}
|
|
84
|
+
while (normalized.endsWith(".")) {
|
|
85
|
+
normalized = normalized.slice(0, -1);
|
|
86
|
+
}
|
|
87
|
+
return normalized;
|
|
88
|
+
}
|
|
89
|
+
function isAllowedS3LookupHost(lookupHostname, endpointHostname) {
|
|
90
|
+
if (lookupHostname === endpointHostname) return true;
|
|
91
|
+
if (isIP(endpointHostname)) return false;
|
|
92
|
+
return lookupHostname.endsWith(`.${endpointHostname}`);
|
|
93
|
+
}
|
|
94
|
+
function buildLookupValidationUrl(rawEndpoint, lookupHostname) {
|
|
95
|
+
const { url } = parseOutboundUrl(rawEndpoint, {
|
|
96
|
+
errorFactory: s3EndpointErrorFactory,
|
|
97
|
+
subject: SUBJECT
|
|
98
|
+
});
|
|
99
|
+
const host = isIP(lookupHostname) === 6 ? `[${lookupHostname}]` : lookupHostname;
|
|
100
|
+
const port = url.port ? `:${url.port}` : "";
|
|
101
|
+
return `${url.protocol}//${host}${port}`;
|
|
102
|
+
}
|
|
103
|
+
const S3_CONNECTION_TIMEOUT_MS = 1e4;
|
|
104
|
+
const S3_REQUEST_TIMEOUT_MS = 12e4;
|
|
105
|
+
function createSafeS3RequestHandler(rawEndpoint, deps = {}) {
|
|
106
|
+
const lookup = createSafeS3EndpointLookup(rawEndpoint, deps);
|
|
107
|
+
const timeouts = {
|
|
108
|
+
connectionTimeout: S3_CONNECTION_TIMEOUT_MS,
|
|
109
|
+
requestTimeout: S3_REQUEST_TIMEOUT_MS
|
|
110
|
+
};
|
|
111
|
+
if (!lookup) return timeouts;
|
|
112
|
+
return {
|
|
113
|
+
...timeouts,
|
|
114
|
+
httpAgent: new HttpAgent({ lookup }),
|
|
115
|
+
httpsAgent: new HttpsAgent({ lookup })
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function isAllowInternalS3EndpointsEnabled() {
|
|
119
|
+
return parseBooleanWithDefault(process.env.OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS, false);
|
|
120
|
+
}
|
|
121
|
+
const defaultLookupHost = async (hostname) => {
|
|
122
|
+
const records = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
123
|
+
return records;
|
|
124
|
+
};
|
|
125
|
+
export {
|
|
126
|
+
UnsafeS3EndpointError,
|
|
127
|
+
assertSafeS3Endpoint,
|
|
128
|
+
assertStaticallySafeS3Endpoint,
|
|
129
|
+
createSafeS3EndpointLookup,
|
|
130
|
+
createSafeS3RequestHandler,
|
|
131
|
+
isAllowInternalS3EndpointsEnabled
|
|
132
|
+
};
|
|
133
|
+
//# sourceMappingURL=endpoint-safety.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/storage_s3/lib/endpoint-safety.ts"],
|
|
4
|
+
"sourcesContent": ["import { lookup as dnsLookup } from 'node:dns/promises'\nimport { Agent as HttpAgent } from 'node:http'\nimport { Agent as HttpsAgent } from 'node:https'\nimport { isIP, type LookupFunction } from 'node:net'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport {\n assertSafeOutboundUrl,\n assertStaticallySafeOutboundUrl,\n parseOutboundUrl,\n resolveSafeOutboundUrl,\n type HostLookup,\n type ResolvedHostAddress,\n type UrlSafetyReason,\n} from '@open-mercato/shared/lib/url-safety'\n\nconst SUBJECT = 'S3 endpoint'\n\nexport class UnsafeS3EndpointError extends Error {\n public readonly reason: string\n\n constructor(reason: string, message?: string) {\n super(message ?? `S3 endpoint rejected: ${reason}`)\n this.name = 'UnsafeS3EndpointError'\n this.reason = reason\n }\n}\n\nconst s3EndpointErrorFactory = (reason: UrlSafetyReason, message: string) =>\n new UnsafeS3EndpointError(reason, message)\n\nexport type AssertStaticS3EndpointDeps = {\n allowInternal?: boolean\n}\n\nexport function assertStaticallySafeS3Endpoint(\n rawEndpoint: string | null | undefined,\n deps: AssertStaticS3EndpointDeps = {},\n): void {\n if (!rawEndpoint) return\n const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()\n assertStaticallySafeOutboundUrl(rawEndpoint, {\n errorFactory: s3EndpointErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n })\n}\n\nexport type AssertSafeS3EndpointDeps = {\n lookupHost?: HostLookup\n allowInternal?: boolean\n}\n\nexport async function assertSafeS3Endpoint(\n rawEndpoint: string | null | undefined,\n deps: AssertSafeS3EndpointDeps = {},\n): Promise<void> {\n if (!rawEndpoint) return\n const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()\n await assertSafeOutboundUrl(rawEndpoint, {\n errorFactory: s3EndpointErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n lookupHost: deps.lookupHost,\n })\n}\n\nexport type SafeS3EndpointLookupDeps = {\n lookupHost?: HostLookup\n allowInternal?: boolean\n}\n\nexport function createSafeS3EndpointLookup(\n rawEndpoint: string | null | undefined,\n deps: SafeS3EndpointLookupDeps = {},\n): LookupFunction | undefined {\n if (!rawEndpoint) return undefined\n const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()\n if (allowPrivate) return undefined\n\n const { hostname } = parseOutboundUrl(rawEndpoint, {\n errorFactory: s3EndpointErrorFactory,\n subject: SUBJECT,\n })\n const endpointHostname = normalizeLookupHostname(hostname)\n\n return (host, options, callback) => {\n const lookupHostname = normalizeLookupHostname(host)\n if (!isAllowedS3LookupHost(lookupHostname, endpointHostname)) {\n callback(new UnsafeS3EndpointError(\n 'blocked_hostname',\n `S3 endpoint lookup host \"${host}\" does not match configured host \"${endpointHostname}\"`,\n ), '', 0)\n return\n }\n\n resolveSafeOutboundUrl(buildLookupValidationUrl(rawEndpoint, lookupHostname), {\n errorFactory: s3EndpointErrorFactory,\n subject: SUBJECT,\n lookupHost: deps.lookupHost ?? defaultLookupHost,\n }).then(({ addresses }) => {\n if (!addresses || addresses.length === 0) {\n const family = isIP(lookupHostname)\n if (options.all) callback(null, [{ address: lookupHostname, family }] as ResolvedHostAddress[])\n else callback(null, lookupHostname, family)\n return\n }\n if (options.all) {\n callback(null, addresses.map((record) => ({ address: record.address, family: record.family })))\n return\n }\n callback(null, addresses[0].address, addresses[0].family)\n }).catch((error: unknown) => {\n callback(error instanceof Error ? error : new UnsafeS3EndpointError('dns_resolution_failed'), '', 0)\n })\n }\n}\n\nfunction normalizeLookupHostname(hostname: string): string {\n let normalized = hostname.trim().toLowerCase()\n if (normalized.startsWith('[') && normalized.endsWith(']')) {\n normalized = normalized.slice(1, -1)\n }\n while (normalized.endsWith('.')) {\n normalized = normalized.slice(0, -1)\n }\n return normalized\n}\n\nfunction isAllowedS3LookupHost(lookupHostname: string, endpointHostname: string): boolean {\n if (lookupHostname === endpointHostname) return true\n if (isIP(endpointHostname)) return false\n return lookupHostname.endsWith(`.${endpointHostname}`)\n}\n\nfunction buildLookupValidationUrl(rawEndpoint: string, lookupHostname: string): string {\n const { url } = parseOutboundUrl(rawEndpoint, {\n errorFactory: s3EndpointErrorFactory,\n subject: SUBJECT,\n })\n const host = isIP(lookupHostname) === 6 ? `[${lookupHostname}]` : lookupHostname\n const port = url.port ? `:${url.port}` : ''\n return `${url.protocol}//${host}${port}`\n}\n\nexport type SafeS3RequestHandlerOptions = {\n connectionTimeout: number\n requestTimeout: number\n httpAgent?: HttpAgent\n httpsAgent?: HttpsAgent\n}\n\nconst S3_CONNECTION_TIMEOUT_MS = 10_000\nconst S3_REQUEST_TIMEOUT_MS = 120_000\n\nexport function createSafeS3RequestHandler(\n rawEndpoint: string | null | undefined,\n deps: SafeS3EndpointLookupDeps = {},\n): SafeS3RequestHandlerOptions | undefined {\n const lookup = createSafeS3EndpointLookup(rawEndpoint, deps)\n const timeouts = {\n connectionTimeout: S3_CONNECTION_TIMEOUT_MS,\n requestTimeout: S3_REQUEST_TIMEOUT_MS,\n }\n if (!lookup) return timeouts\n return {\n ...timeouts,\n httpAgent: new HttpAgent({ lookup }),\n httpsAgent: new HttpsAgent({ lookup }),\n }\n}\n\nexport function isAllowInternalS3EndpointsEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS, false)\n}\n\nconst defaultLookupHost: HostLookup = async (hostname) => {\n const records = await dnsLookup(hostname, { all: true, verbatim: true })\n return records\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { S3Client, HeadBucketCommand } from "@aws-sdk/client-s3";
|
|
2
|
+
import { assertSafeS3Endpoint, createSafeS3RequestHandler } from "./endpoint-safety.js";
|
|
2
3
|
const s3HealthCheck = {
|
|
3
4
|
async check(credentials) {
|
|
4
5
|
const cfg = credentials;
|
|
@@ -49,11 +50,27 @@ const s3HealthCheck = {
|
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
}
|
|
53
|
+
try {
|
|
54
|
+
await assertSafeS3Endpoint(cfg.endpoint);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const message = err instanceof Error ? err.message : "Unsafe S3 endpoint";
|
|
57
|
+
const reason = err && typeof err === "object" && "reason" in err ? err.reason : void 0;
|
|
58
|
+
return {
|
|
59
|
+
status: "unhealthy",
|
|
60
|
+
message,
|
|
61
|
+
details: {
|
|
62
|
+
...typeof reason === "string" ? { reason } : {}
|
|
63
|
+
},
|
|
64
|
+
checkedAt
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const requestHandler = createSafeS3RequestHandler(cfg.endpoint);
|
|
52
68
|
const client = new S3Client({
|
|
53
69
|
region: cfg.region ?? "us-east-1",
|
|
54
70
|
endpoint: cfg.endpoint,
|
|
55
71
|
forcePathStyle: cfg.forcePathStyle ?? false,
|
|
56
|
-
credentials: credentialConfig
|
|
72
|
+
credentials: credentialConfig,
|
|
73
|
+
...requestHandler ? { requestHandler } : {}
|
|
57
74
|
});
|
|
58
75
|
try {
|
|
59
76
|
await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/storage_s3/lib/health.ts"],
|
|
4
|
-
"sourcesContent": ["import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3'\nimport type { S3DriverConfig } from './s3-driver'\n\nexport const s3HealthCheck = {\n async check(credentials: Record<string, unknown>): Promise<{\n status: 'healthy' | 'unhealthy'\n message: string\n details: Record<string, unknown>\n checkedAt: Date\n }> {\n const cfg = credentials as S3DriverConfig\n const checkedAt = new Date()\n\n if (!cfg.bucket || !cfg.region) {\n return {\n status: 'unhealthy',\n message: 'Missing required credentials: bucket and region are required.',\n details: { bucket: cfg.bucket, region: cfg.region },\n checkedAt,\n }\n }\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n const hasDirectKeys = Boolean(cfg.accessKeyId && cfg.secretAccessKey)\n const hasAnyKeyPrefix = Boolean(cfg.credentialsEnvPrefix)\n const hasAnyKeys = hasDirectKeys || hasAnyKeyPrefix\n\n if (authMode === 'ambient' && hasAnyKeys) {\n return {\n status: 'unhealthy',\n message: 'Invalid configuration: authMode=ambient cannot be combined with access keys.',\n details: { authMode, hasAccessKeys: true },\n checkedAt,\n }\n }\n\n let credentialConfig: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentialConfig = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentialConfig = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n } else {\n return {\n status: 'unhealthy',\n message: 'Missing required credentials: accessKeyId and secretAccessKey are required for access_keys auth mode.',\n details: { authMode: 'access_keys' },\n checkedAt,\n }\n }\n }\n\n const client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials: credentialConfig,\n })\n\n try {\n await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }))\n return {\n status: 'healthy',\n message: `Connected to bucket \"${cfg.bucket}\" in region \"${cfg.region}\".`,\n details: {\n bucket: cfg.bucket,\n region: cfg.region,\n endpoint: cfg.endpoint ?? '(AWS default)',\n },\n checkedAt,\n }\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : 'Unknown error'\n return {\n status: 'unhealthy',\n message: `S3 connection failed: ${message}`,\n details: { bucket: cfg.bucket, region: cfg.region, error: message },\n checkedAt,\n }\n }\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,UAAU,yBAAyB;
|
|
4
|
+
"sourcesContent": ["import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3'\nimport { assertSafeS3Endpoint, createSafeS3RequestHandler } from './endpoint-safety'\nimport type { S3DriverConfig } from './s3-driver'\n\nexport const s3HealthCheck = {\n async check(credentials: Record<string, unknown>): Promise<{\n status: 'healthy' | 'unhealthy'\n message: string\n details: Record<string, unknown>\n checkedAt: Date\n }> {\n const cfg = credentials as S3DriverConfig\n const checkedAt = new Date()\n\n if (!cfg.bucket || !cfg.region) {\n return {\n status: 'unhealthy',\n message: 'Missing required credentials: bucket and region are required.',\n details: { bucket: cfg.bucket, region: cfg.region },\n checkedAt,\n }\n }\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n const hasDirectKeys = Boolean(cfg.accessKeyId && cfg.secretAccessKey)\n const hasAnyKeyPrefix = Boolean(cfg.credentialsEnvPrefix)\n const hasAnyKeys = hasDirectKeys || hasAnyKeyPrefix\n\n if (authMode === 'ambient' && hasAnyKeys) {\n return {\n status: 'unhealthy',\n message: 'Invalid configuration: authMode=ambient cannot be combined with access keys.',\n details: { authMode, hasAccessKeys: true },\n checkedAt,\n }\n }\n\n let credentialConfig: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentialConfig = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentialConfig = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n } else {\n return {\n status: 'unhealthy',\n message: 'Missing required credentials: accessKeyId and secretAccessKey are required for access_keys auth mode.',\n details: { authMode: 'access_keys' },\n checkedAt,\n }\n }\n }\n\n try {\n await assertSafeS3Endpoint(cfg.endpoint)\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : 'Unsafe S3 endpoint'\n const reason = err && typeof err === 'object' && 'reason' in err ? (err as { reason?: unknown }).reason : undefined\n return {\n status: 'unhealthy',\n message,\n details: {\n ...(typeof reason === 'string' ? { reason } : {}),\n },\n checkedAt,\n }\n }\n\n const requestHandler = createSafeS3RequestHandler(cfg.endpoint)\n const client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials: credentialConfig,\n ...(requestHandler ? { requestHandler } : {}),\n })\n\n try {\n await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }))\n return {\n status: 'healthy',\n message: `Connected to bucket \"${cfg.bucket}\" in region \"${cfg.region}\".`,\n details: {\n bucket: cfg.bucket,\n region: cfg.region,\n endpoint: cfg.endpoint ?? '(AWS default)',\n },\n checkedAt,\n }\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : 'Unknown error'\n return {\n status: 'unhealthy',\n message: `S3 connection failed: ${message}`,\n details: { bucket: cfg.bucket, region: cfg.region, error: message },\n checkedAt,\n }\n }\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|