@open-mercato/storage-s3 0.6.7-develop.6795.1.8a3f27921c → 0.6.7-develop.6824.1.21fff90682
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/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js +6 -4
- 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 +6 -4
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js.map +2 -2
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +13 -11
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
- package/dist/modules/storage_s3/di.js +1 -1
- package/dist/modules/storage_s3/di.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 +2 -2
- package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
- package/package.json +9 -4
- package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +13 -0
- package/src/modules/storage_s3/__tests__/s3Driver.test.ts +8 -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 +5 -5
- package/src/modules/storage_s3/__tests__/upload.test.ts +9 -1
- package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +6 -4
- 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 +6 -4
- package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +13 -11
- package/src/modules/storage_s3/di.ts +1 -1
- package/src/modules/storage_s3/i18n/de.json +17 -0
- package/src/modules/storage_s3/i18n/en.json +17 -0
- package/src/modules/storage_s3/i18n/es.json +17 -0
- package/src/modules/storage_s3/i18n/pl.json +17 -0
- package/src/modules/storage_s3/lib/key-scope.ts +4 -4
- package/src/modules/storage_s3/lib/s3-driver.ts +2 -2
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
5
6
|
import { isS3KeyAddressableByScope } from "../../../../lib/key-scope.js";
|
|
6
7
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
7
8
|
const metadata = {
|
|
@@ -19,21 +20,22 @@ async function resolveDriver(tenantId, orgId) {
|
|
|
19
20
|
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId });
|
|
20
21
|
}
|
|
21
22
|
async function DELETE(req) {
|
|
23
|
+
const { t } = await resolveTranslations();
|
|
22
24
|
const auth = await getAuthFromRequest(req);
|
|
23
25
|
if (!auth?.tenantId || !auth.orgId) {
|
|
24
|
-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
26
|
+
return NextResponse.json({ error: t("storage_s3.errors.unauthorized", "Unauthorized") }, { status: 401 });
|
|
25
27
|
}
|
|
26
28
|
const json = await req.json().catch(() => null);
|
|
27
29
|
const parsed = requestSchema.safeParse(json);
|
|
28
30
|
if (!parsed.success) {
|
|
29
|
-
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
|
31
|
+
return NextResponse.json({ error: t("storage_s3.errors.invalidPayload", "Invalid payload") }, { status: 400 });
|
|
30
32
|
}
|
|
31
33
|
if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {
|
|
32
|
-
return NextResponse.json({ error: "Access denied: key is not scoped to this tenant." }, { status: 403 });
|
|
34
|
+
return NextResponse.json({ error: t("storage_s3.errors.keyAccessDenied", "Access denied: key is not scoped to this tenant.") }, { status: 403 });
|
|
33
35
|
}
|
|
34
36
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
35
37
|
if (!driver) {
|
|
36
|
-
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
38
|
+
return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
|
|
37
39
|
}
|
|
38
40
|
await driver.delete("", parsed.data.key);
|
|
39
41
|
return new NextResponse(null, { status: 204 });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/delete/storage-providers/s3/delete.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 { isS3KeyAddressableByScope } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/delete',\n DELETE: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\n})\n\nasync function resolveDriver(tenantId: string, orgId: string): 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\nexport async function DELETE(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 const json = await req.json().catch(() => null)\n const parsed = requestSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })\n }\n\n if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {\n return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })\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 await driver.delete('', parsed.data.key)\n return new NextResponse(null, { status: 204 })\n}\n\nexport default DELETE\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Delete file from S3',\n methods: {\n DELETE: {\n summary: 'Delete a file from S3 by key',\n description: 'Permanently removes the object at the given key from the configured S3 bucket.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 204, description: 'File deleted', schema: z.null() }],\n errors: [\n { status: 400, description: 'Invalid payload 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 not scoped to this tenant', 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,SAAS,iCAAiC;AAC1C,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,QAAQ,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC7E;AAEA,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC;AACvB,CAAC;AAED,eAAe,cAAc,UAAkB,OAAgD;AAC7F,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,QAAQ,MAAM,mBAAmB,QAAQ,cAAc,EAAE,UAAU,gBAAgB,MAAM,CAAC;AAChG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,IAAI,gBAAgB,EAAE,GAAG,OAAO,gBAAgB,OAAO,SAAS,CAAC;AAC1E;AAEA,eAAsB,OAAO,KAAc;AACzC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,
|
|
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 { isS3KeyAddressableByScope } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/delete',\n DELETE: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\n})\n\nasync function resolveDriver(tenantId: string, orgId: string): 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\nexport async function DELETE(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 const json = await req.json().catch(() => null)\n const parsed = requestSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })\n }\n\n if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {\n return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })\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 await driver.delete('', parsed.data.key)\n return new NextResponse(null, { status: 204 })\n}\n\nexport default DELETE\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Delete file from S3',\n methods: {\n DELETE: {\n summary: 'Delete a file from S3 by key',\n description: 'Permanently removes the object at the given key from the configured S3 bucket.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 204, description: 'File deleted', schema: z.null() }],\n errors: [\n { status: 400, description: 'Invalid payload 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 not scoped to this tenant', 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,SAAS,2BAA2B;AACpC,SAAS,iCAAiC;AAC1C,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,QAAQ,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC7E;AAEA,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC;AACvB,CAAC;AAED,eAAe,cAAc,UAAkB,OAAgD;AAC7F,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,QAAQ,MAAM,mBAAmB,QAAQ,cAAc,EAAE,UAAU,gBAAgB,MAAM,CAAC;AAChG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,IAAI,gBAAgB,EAAE,GAAG,OAAO,gBAAgB,OAAO,SAAS,CAAC;AAC1E;AAEA,eAAsB,OAAO,KAAc;AACzC,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,kCAAkC,cAAc,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1G;AAEA,QAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI;AAC9C,QAAM,SAAS,cAAc,UAAU,IAAI;AAC3C,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,oCAAoC,iBAAiB,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC/G;AAEA,MAAI,CAAC,0BAA0B,OAAO,KAAK,KAAK,KAAK,OAAO,KAAK,QAAQ,GAAG;AAC1E,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,qCAAqC,kDAAkD,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACjJ;AAEA,QAAM,SAAS,MAAM,cAAc,KAAK,UAAU,KAAK,KAAK;AAC5D,MAAI,CAAC,QAAQ;AACX,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,8CAA8C,mCAAmC,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC3I;AAEA,QAAM,OAAO,OAAO,IAAI,OAAO,KAAK,GAAG;AACvC,SAAO,IAAI,aAAa,MAAM,EAAE,QAAQ,IAAI,CAAC;AAC/C;AAEA,IAAO,iBAAQ;AAER,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,QAAQ;AAAA,MACN,SAAS;AAAA,MACT,aAAa;AAAA,MACb,aAAa,EAAE,aAAa,oBAAoB,QAAQ,cAAc;AAAA,MACtE,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,KAAK,EAAE,CAAC;AAAA,MAC1E,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,wCAAwC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QAC5G,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QACpF,EAAE,QAAQ,KAAK,aAAa,iCAAiC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,MACvG;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
5
6
|
import {
|
|
6
7
|
buildAttachmentContentDisposition,
|
|
7
8
|
canRenderInlineAttachment,
|
|
@@ -21,20 +22,21 @@ async function resolveDriver(tenantId, orgId) {
|
|
|
21
22
|
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId });
|
|
22
23
|
}
|
|
23
24
|
async function GET(req) {
|
|
25
|
+
const { t } = await resolveTranslations();
|
|
24
26
|
const auth = await getAuthFromRequest(req);
|
|
25
27
|
if (!auth?.tenantId || !auth.orgId) {
|
|
26
|
-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
28
|
+
return NextResponse.json({ error: t("storage_s3.errors.unauthorized", "Unauthorized") }, { status: 401 });
|
|
27
29
|
}
|
|
28
30
|
const key = new URL(req.url).searchParams.get("key");
|
|
29
31
|
if (!key) {
|
|
30
|
-
return NextResponse.json({ error: "key query param is required" }, { status: 400 });
|
|
32
|
+
return NextResponse.json({ error: t("storage_s3.errors.keyRequired", "key query param is required") }, { status: 400 });
|
|
31
33
|
}
|
|
32
34
|
if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {
|
|
33
|
-
return NextResponse.json({ error: "Access denied: key is not scoped to this tenant." }, { status: 403 });
|
|
35
|
+
return NextResponse.json({ error: t("storage_s3.errors.keyAccessDenied", "Access denied: key is not scoped to this tenant.") }, { status: 403 });
|
|
34
36
|
}
|
|
35
37
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
36
38
|
if (!driver) {
|
|
37
|
-
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
39
|
+
return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
|
|
38
40
|
}
|
|
39
41
|
let buffer;
|
|
40
42
|
let contentType;
|
|
@@ -43,7 +45,7 @@ async function GET(req) {
|
|
|
43
45
|
buffer = result.buffer;
|
|
44
46
|
contentType = result.contentType;
|
|
45
47
|
} catch {
|
|
46
|
-
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
|
48
|
+
return NextResponse.json({ error: t("storage_s3.errors.fileNotFound", "File not found") }, { status: 404 });
|
|
47
49
|
}
|
|
48
50
|
const fileName = sanitizeUploadedFileName(key.split("/").pop() || "download");
|
|
49
51
|
const renderInline = canRenderInlineAttachment(contentType);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/get/storage-providers/s3/download.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 buildAttachmentContentDisposition,\n canRenderInlineAttachment,\n sanitizeUploadedFileName,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport { isS3KeyAddressableByScope } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/download',\n GET: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nasync function resolveDriver(tenantId: string, orgId: string): 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\nexport async function GET(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 const key = new URL(req.url).searchParams.get('key')\n if (!key) {\n return NextResponse.json({ error: 'key query param is required' }, { status: 400 })\n }\n\n if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {\n return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })\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 let buffer: Buffer\n let contentType: string | undefined\n try {\n const result = await driver.read('', key)\n buffer = result.buffer\n contentType = result.contentType\n } catch {\n return NextResponse.json({ error: 'File not found' }, { status: 404 })\n }\n\n const fileName = sanitizeUploadedFileName(key.split('/').pop() || 'download')\n const renderInline = canRenderInlineAttachment(contentType)\n const responseContentType = renderInline ? (contentType ?? 'application/octet-stream') : 'application/octet-stream'\n\n return new NextResponse(new Uint8Array(buffer), {\n status: 200,\n headers: {\n 'Content-Security-Policy': \"default-src 'none'; sandbox\",\n 'Content-Disposition': buildAttachmentContentDisposition(fileName, renderInline ? 'inline' : 'attachment'),\n 'Content-Length': String(buffer.length),\n 'Content-Type': responseContentType,\n 'X-Content-Type-Options': 'nosniff',\n },\n })\n}\n\nexport default GET\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Download file from S3',\n methods: {\n GET: {\n summary: 'Download a file from S3 by key',\n description: 'Streams the file content for the given S3 key. Requires storage_providers.manage feature.',\n query: z.object({ key: z.string().describe('S3 object key') }),\n responses: [{ status: 200, description: 'File content stream', schema: z.any() }],\n errors: [\n { status: 400, description: 'Missing key 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 not scoped to this tenant', schema: z.object({ error: z.string() }) },\n { status: 404, description: 'File not found', 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,SAAS,iCAAiC;AAC1C,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC1E;AAEA,eAAe,cAAc,UAAkB,OAAgD;AAC7F,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,QAAQ,MAAM,mBAAmB,QAAQ,cAAc,EAAE,UAAU,gBAAgB,MAAM,CAAC;AAChG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,IAAI,gBAAgB,EAAE,GAAG,OAAO,gBAAgB,OAAO,SAAS,CAAC;AAC1E;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,
|
|
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 buildAttachmentContentDisposition,\n canRenderInlineAttachment,\n sanitizeUploadedFileName,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport { isS3KeyAddressableByScope } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/download',\n GET: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nasync function resolveDriver(tenantId: string, orgId: string): 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\nexport async function GET(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 const key = new URL(req.url).searchParams.get('key')\n if (!key) {\n return NextResponse.json({ error: t('storage_s3.errors.keyRequired', 'key query param is required') }, { status: 400 })\n }\n\n if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {\n return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })\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 let buffer: Buffer\n let contentType: string | undefined\n try {\n const result = await driver.read('', key)\n buffer = result.buffer\n contentType = result.contentType\n } catch {\n return NextResponse.json({ error: t('storage_s3.errors.fileNotFound', 'File not found') }, { status: 404 })\n }\n\n const fileName = sanitizeUploadedFileName(key.split('/').pop() || 'download')\n const renderInline = canRenderInlineAttachment(contentType)\n const responseContentType = renderInline ? (contentType ?? 'application/octet-stream') : 'application/octet-stream'\n\n return new NextResponse(new Uint8Array(buffer), {\n status: 200,\n headers: {\n 'Content-Security-Policy': \"default-src 'none'; sandbox\",\n 'Content-Disposition': buildAttachmentContentDisposition(fileName, renderInline ? 'inline' : 'attachment'),\n 'Content-Length': String(buffer.length),\n 'Content-Type': responseContentType,\n 'X-Content-Type-Options': 'nosniff',\n },\n })\n}\n\nexport default GET\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Download file from S3',\n methods: {\n GET: {\n summary: 'Download a file from S3 by key',\n description: 'Streams the file content for the given S3 key. Requires storage_providers.manage feature.',\n query: z.object({ key: z.string().describe('S3 object key') }),\n responses: [{ status: 200, description: 'File content stream', schema: z.any() }],\n errors: [\n { status: 400, description: 'Missing key 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 not scoped to this tenant', schema: z.object({ error: z.string() }) },\n { status: 404, description: 'File not found', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
5
6
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
6
7
|
const metadata = {
|
|
7
8
|
path: "/storage-providers/s3/list",
|
|
@@ -51,25 +52,26 @@ function resolveTenantScopedPrefix(prefix, tenantId, orgId) {
|
|
|
51
52
|
return tenantPrefix + namespaceRelativePrefix;
|
|
52
53
|
}
|
|
53
54
|
async function GET(req) {
|
|
55
|
+
const { t } = await resolveTranslations();
|
|
54
56
|
const auth = await getAuthFromRequest(req);
|
|
55
57
|
if (!auth?.tenantId || !auth.orgId) {
|
|
56
|
-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
58
|
+
return NextResponse.json({ error: t("storage_s3.errors.unauthorized", "Unauthorized") }, { status: 401 });
|
|
57
59
|
}
|
|
58
60
|
const params = Object.fromEntries(new URL(req.url).searchParams.entries());
|
|
59
61
|
const parsed = querySchema.safeParse(params);
|
|
60
62
|
if (!parsed.success) {
|
|
61
|
-
return NextResponse.json({ error: "Invalid query parameters" }, { status: 400 });
|
|
63
|
+
return NextResponse.json({ error: t("storage_s3.errors.invalidQuery", "Invalid query parameters") }, { status: 400 });
|
|
62
64
|
}
|
|
63
65
|
const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId);
|
|
64
66
|
if (effectivePrefix === null) {
|
|
65
67
|
return NextResponse.json(
|
|
66
|
-
{ error: "Access denied: prefix is not scoped to this tenant." },
|
|
68
|
+
{ error: t("storage_s3.errors.prefixAccessDenied", "Access denied: prefix is not scoped to this tenant.") },
|
|
67
69
|
{ status: 403 }
|
|
68
70
|
);
|
|
69
71
|
}
|
|
70
72
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
71
73
|
if (!driver) {
|
|
72
|
-
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
74
|
+
return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
|
|
73
75
|
}
|
|
74
76
|
const result = await driver.listObjects(
|
|
75
77
|
effectivePrefix,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/get/storage-providers/s3/list.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 { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/list',\n GET: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst querySchema = z.object({\n prefix: z.string().optional().default(''),\n maxKeys: z.coerce.number().int().min(1).max(1000).optional().default(100),\n continuationToken: z.string().optional(),\n})\n\nconst fileSchema = z.object({\n key: z.string(),\n size: z.number().int(),\n lastModified: z.string(),\n})\n\nconst responseSchema = z.object({\n files: z.array(fileSchema),\n truncated: z.boolean(),\n nextContinuationToken: z.string().optional(),\n})\n\nconst DEFAULT_LIST_NAMESPACE = 'uploads'\n\nasync function resolveDriver(tenantId: string, orgId: string): 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\nfunction buildTenantPrefix(namespace: string, tenantId: string, orgId: string): string {\n return `${namespace}/org_${orgId}/tenant_${tenantId}/`\n}\n\nfunction resolveTenantScopedPrefix(prefix: string, tenantId: string, orgId: string): string | null {\n const normalized = prefix.replace(/^\\/+/, '')\n const parts = normalized.split('/')\n const namespace = parts[0] || DEFAULT_LIST_NAMESPACE\n const tenantPrefix = buildTenantPrefix(namespace, tenantId, orgId)\n\n if (!normalized || normalized === tenantPrefix.slice(0, -1)) {\n return tenantPrefix\n }\n\n if (normalized.startsWith(tenantPrefix)) {\n return normalized\n }\n\n if (\n parts[1]?.startsWith('org_') ||\n parts[2]?.startsWith('tenant_') ||\n normalized.includes(`org_${orgId}/tenant_${tenantId}`)\n ) {\n return null\n }\n\n const namespaceRelativePrefix = normalized.slice(namespace.length).replace(/^\\/+/, '')\n return tenantPrefix + namespaceRelativePrefix\n}\n\nexport async function GET(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 const params = Object.fromEntries(new URL(req.url).searchParams.entries())\n const parsed = querySchema.safeParse(params)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })\n }\n\n // Always scope list operations to the tenant namespace to prevent cross-tenant enumeration.\n const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId)\n if (effectivePrefix === null) {\n return NextResponse.json(\n { error: 'Access denied: prefix is not scoped to this tenant.' },\n { status: 403 },\n )\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 result = await driver.listObjects(\n effectivePrefix,\n parsed.data.maxKeys,\n parsed.data.continuationToken,\n )\n\n return NextResponse.json({\n files: result.files.map((f) => ({\n key: f.key,\n size: f.size,\n lastModified: f.lastModified.toISOString(),\n })),\n truncated: result.truncated,\n nextContinuationToken: result.nextContinuationToken,\n })\n}\n\nexport default GET\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'List S3 objects',\n methods: {\n GET: {\n summary: 'List files in S3 by prefix',\n description: 'Returns a paginated list of S3 objects scoped to the authenticated tenant namespace.',\n query: querySchema,\n responses: [{ status: 200, description: 'File listing', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Invalid params 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: 'Prefix not scoped to this tenant', 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,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC1E;AAEA,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE;AAAA,EACxC,SAAS,EAAE,OAAO,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAI,EAAE,SAAS,EAAE,QAAQ,GAAG;AAAA,EACxE,mBAAmB,EAAE,OAAO,EAAE,SAAS;AACzC,CAAC;AAED,MAAM,aAAa,EAAE,OAAO;AAAA,EAC1B,KAAK,EAAE,OAAO;AAAA,EACd,MAAM,EAAE,OAAO,EAAE,IAAI;AAAA,EACrB,cAAc,EAAE,OAAO;AACzB,CAAC;AAED,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,OAAO,EAAE,MAAM,UAAU;AAAA,EACzB,WAAW,EAAE,QAAQ;AAAA,EACrB,uBAAuB,EAAE,OAAO,EAAE,SAAS;AAC7C,CAAC;AAED,MAAM,yBAAyB;AAE/B,eAAe,cAAc,UAAkB,OAAgD;AAC7F,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,QAAQ,MAAM,mBAAmB,QAAQ,cAAc,EAAE,UAAU,gBAAgB,MAAM,CAAC;AAChG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,IAAI,gBAAgB,EAAE,GAAG,OAAO,gBAAgB,OAAO,SAAS,CAAC;AAC1E;AAEA,SAAS,kBAAkB,WAAmB,UAAkB,OAAuB;AACrF,SAAO,GAAG,SAAS,QAAQ,KAAK,WAAW,QAAQ;AACrD;AAEA,SAAS,0BAA0B,QAAgB,UAAkB,OAA8B;AACjG,QAAM,aAAa,OAAO,QAAQ,QAAQ,EAAE;AAC5C,QAAM,QAAQ,WAAW,MAAM,GAAG;AAClC,QAAM,YAAY,MAAM,CAAC,KAAK;AAC9B,QAAM,eAAe,kBAAkB,WAAW,UAAU,KAAK;AAEjE,MAAI,CAAC,cAAc,eAAe,aAAa,MAAM,GAAG,EAAE,GAAG;AAC3D,WAAO;AAAA,EACT;AAEA,MAAI,WAAW,WAAW,YAAY,GAAG;AACvC,WAAO;AAAA,EACT;AAEA,MACE,MAAM,CAAC,GAAG,WAAW,MAAM,KAC3B,MAAM,CAAC,GAAG,WAAW,SAAS,KAC9B,WAAW,SAAS,OAAO,KAAK,WAAW,QAAQ,EAAE,GACrD;AACA,WAAO;AAAA,EACT;AAEA,QAAM,0BAA0B,WAAW,MAAM,UAAU,MAAM,EAAE,QAAQ,QAAQ,EAAE;AACrF,SAAO,eAAe;AACxB;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,
|
|
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 { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/list',\n GET: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst querySchema = z.object({\n prefix: z.string().optional().default(''),\n maxKeys: z.coerce.number().int().min(1).max(1000).optional().default(100),\n continuationToken: z.string().optional(),\n})\n\nconst fileSchema = z.object({\n key: z.string(),\n size: z.number().int(),\n lastModified: z.string(),\n})\n\nconst responseSchema = z.object({\n files: z.array(fileSchema),\n truncated: z.boolean(),\n nextContinuationToken: z.string().optional(),\n})\n\nconst DEFAULT_LIST_NAMESPACE = 'uploads'\n\nasync function resolveDriver(tenantId: string, orgId: string): 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\nfunction buildTenantPrefix(namespace: string, tenantId: string, orgId: string): string {\n return `${namespace}/org_${orgId}/tenant_${tenantId}/`\n}\n\nfunction resolveTenantScopedPrefix(prefix: string, tenantId: string, orgId: string): string | null {\n const normalized = prefix.replace(/^\\/+/, '')\n const parts = normalized.split('/')\n const namespace = parts[0] || DEFAULT_LIST_NAMESPACE\n const tenantPrefix = buildTenantPrefix(namespace, tenantId, orgId)\n\n if (!normalized || normalized === tenantPrefix.slice(0, -1)) {\n return tenantPrefix\n }\n\n if (normalized.startsWith(tenantPrefix)) {\n return normalized\n }\n\n if (\n parts[1]?.startsWith('org_') ||\n parts[2]?.startsWith('tenant_') ||\n normalized.includes(`org_${orgId}/tenant_${tenantId}`)\n ) {\n return null\n }\n\n const namespaceRelativePrefix = normalized.slice(namespace.length).replace(/^\\/+/, '')\n return tenantPrefix + namespaceRelativePrefix\n}\n\nexport async function GET(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 const params = Object.fromEntries(new URL(req.url).searchParams.entries())\n const parsed = querySchema.safeParse(params)\n if (!parsed.success) {\n return NextResponse.json({ error: t('storage_s3.errors.invalidQuery', 'Invalid query parameters') }, { status: 400 })\n }\n\n // Always scope list operations to the tenant namespace to prevent cross-tenant enumeration.\n const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId)\n if (effectivePrefix === null) {\n return NextResponse.json(\n { error: t('storage_s3.errors.prefixAccessDenied', 'Access denied: prefix is not scoped to this tenant.') },\n { status: 403 },\n )\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 result = await driver.listObjects(\n effectivePrefix,\n parsed.data.maxKeys,\n parsed.data.continuationToken,\n )\n\n return NextResponse.json({\n files: result.files.map((f) => ({\n key: f.key,\n size: f.size,\n lastModified: f.lastModified.toISOString(),\n })),\n truncated: result.truncated,\n nextContinuationToken: result.nextContinuationToken,\n })\n}\n\nexport default GET\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'List S3 objects',\n methods: {\n GET: {\n summary: 'List files in S3 by prefix',\n description: 'Returns a paginated list of S3 objects scoped to the authenticated tenant namespace.',\n query: querySchema,\n responses: [{ status: 200, description: 'File listing', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Invalid params 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: 'Prefix not scoped to this tenant', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
5
6
|
import { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from "../../../../lib/key-scope.js";
|
|
6
7
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
7
8
|
const metadata = {
|
|
@@ -26,23 +27,24 @@ async function resolveDriver(tenantId, orgId) {
|
|
|
26
27
|
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId });
|
|
27
28
|
}
|
|
28
29
|
async function POST(req) {
|
|
30
|
+
const { t } = await resolveTranslations();
|
|
29
31
|
const auth = await getAuthFromRequest(req);
|
|
30
32
|
if (!auth?.tenantId || !auth.orgId) {
|
|
31
|
-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
33
|
+
return NextResponse.json({ error: t("storage_s3.errors.unauthorized", "Unauthorized") }, { status: 401 });
|
|
32
34
|
}
|
|
33
35
|
const json = await req.json().catch(() => null);
|
|
34
36
|
const parsed = requestSchema.safeParse(json);
|
|
35
37
|
if (!parsed.success) {
|
|
36
|
-
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
|
38
|
+
return NextResponse.json({ error: t("storage_s3.errors.invalidPayload", "Invalid payload") }, { status: 400 });
|
|
37
39
|
}
|
|
38
40
|
const { key, operation, expiresIn, contentType } = parsed.data;
|
|
39
41
|
const canAccessKey = operation === "download" ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId) : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId);
|
|
40
42
|
if (!canAccessKey) {
|
|
41
|
-
return NextResponse.json({ error: "Access denied: key is not scoped to this tenant." }, { status: 403 });
|
|
43
|
+
return NextResponse.json({ error: t("storage_s3.errors.keyAccessDenied", "Access denied: key is not scoped to this tenant.") }, { status: 403 });
|
|
42
44
|
}
|
|
43
45
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
44
46
|
if (!driver) {
|
|
45
|
-
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
47
|
+
return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
|
|
46
48
|
}
|
|
47
49
|
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType);
|
|
48
50
|
const expiresAt = new Date(Date.now() + expiresIn * 1e3).toISOString();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/post/storage-providers/s3/signed-url.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 { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/signed-url',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\n operation: z.enum(['upload', 'download']),\n expiresIn: z.number().int().min(60).max(604800).optional().default(3600),\n contentType: z.string().optional(),\n})\n\nconst responseSchema = z.object({\n url: z.string(),\n expiresAt: z.string(),\n})\n\nasync function resolveDriver(tenantId: string, orgId: string): 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\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 const json = await req.json().catch(() => null)\n const parsed = requestSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })\n }\n\n const { key, operation, expiresIn, contentType } = parsed.data\n const canAccessKey = operation === 'download'\n ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)\n : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId)\n if (!canAccessKey) {\n return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })\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 url = await driver.getSignedUrl(key, operation, expiresIn, contentType)\n const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()\n\n return NextResponse.json({ url, expiresAt })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Generate S3 pre-signed URL',\n methods: {\n POST: {\n summary: 'Generate a pre-signed URL for direct browser upload or download',\n description: 'Returns a time-limited URL that allows a browser to directly upload or download a file from S3.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 200, description: 'Pre-signed URL and expiry', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload 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 not scoped to this tenant', 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,SAAS,2BAA2B,6BAA6B;AACjE,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC3E;AAEA,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACrB,WAAW,EAAE,KAAK,CAAC,UAAU,UAAU,CAAC;AAAA,EACxC,WAAW,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,IAAI,MAAM,EAAE,SAAS,EAAE,QAAQ,IAAI;AAAA,EACvE,aAAa,EAAE,OAAO,EAAE,SAAS;AACnC,CAAC;AAED,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,KAAK,EAAE,OAAO;AAAA,EACd,WAAW,EAAE,OAAO;AACtB,CAAC;AAED,eAAe,cAAc,UAAkB,OAAgD;AAC7F,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,QAAQ,MAAM,mBAAmB,QAAQ,cAAc,EAAE,UAAU,gBAAgB,MAAM,CAAC;AAChG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,IAAI,gBAAgB,EAAE,GAAG,OAAO,gBAAgB,OAAO,SAAS,CAAC;AAC1E;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,
|
|
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 { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/signed-url',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\n operation: z.enum(['upload', 'download']),\n expiresIn: z.number().int().min(60).max(604800).optional().default(3600),\n contentType: z.string().optional(),\n})\n\nconst responseSchema = z.object({\n url: z.string(),\n expiresAt: z.string(),\n})\n\nasync function resolveDriver(tenantId: string, orgId: string): 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\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 const json = await req.json().catch(() => null)\n const parsed = requestSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })\n }\n\n const { key, operation, expiresIn, contentType } = parsed.data\n const canAccessKey = operation === 'download'\n ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)\n : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId)\n if (!canAccessKey) {\n return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })\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 url = await driver.getSignedUrl(key, operation, expiresIn, contentType)\n const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()\n\n return NextResponse.json({ url, expiresAt })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Generate S3 pre-signed URL',\n methods: {\n POST: {\n summary: 'Generate a pre-signed URL for direct browser upload or download',\n description: 'Returns a time-limited URL that allows a browser to directly upload or download a file from S3.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 200, description: 'Pre-signed URL and expiry', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload 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 not scoped to this tenant', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
5
6
|
import {
|
|
6
7
|
detectAttachmentMimeType,
|
|
7
8
|
hasDangerousExecutableExtension,
|
|
@@ -52,57 +53,58 @@ async function readTenantStorageUsageBytes(driver, tenantId, orgId) {
|
|
|
52
53
|
return totalBytes;
|
|
53
54
|
}
|
|
54
55
|
async function POST(req) {
|
|
56
|
+
const { t } = await resolveTranslations();
|
|
55
57
|
const auth = await getAuthFromRequest(req);
|
|
56
58
|
if (!auth?.tenantId || !auth.orgId) {
|
|
57
|
-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
59
|
+
return NextResponse.json({ error: t("storage_s3.errors.unauthorized", "Unauthorized") }, { status: 401 });
|
|
58
60
|
}
|
|
59
61
|
if (!isMultipartRequestWithinUploadLimit(req.headers.get("content-length"))) {
|
|
60
|
-
return NextResponse.json({ error: "Attachment exceeds the maximum upload size." }, { status: 413 });
|
|
62
|
+
return NextResponse.json({ error: t("storage_s3.errors.maxUploadSize", "Attachment exceeds the maximum upload size.") }, { status: 413 });
|
|
61
63
|
}
|
|
62
64
|
const contentType = req.headers.get("content-type") ?? "";
|
|
63
65
|
if (!contentType.includes("multipart/form-data")) {
|
|
64
|
-
return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 });
|
|
66
|
+
return NextResponse.json({ error: t("storage_s3.errors.multipartRequired", "Expected multipart/form-data") }, { status: 400 });
|
|
65
67
|
}
|
|
66
68
|
let form;
|
|
67
69
|
try {
|
|
68
70
|
form = await parseMultipartFormDataWithinUploadLimit(req);
|
|
69
71
|
} catch (error) {
|
|
70
72
|
if (isMultipartUploadLimitError(error)) {
|
|
71
|
-
return NextResponse.json({ error: "Attachment exceeds the maximum upload size." }, { status: 413 });
|
|
73
|
+
return NextResponse.json({ error: t("storage_s3.errors.maxUploadSize", "Attachment exceeds the maximum upload size.") }, { status: 413 });
|
|
72
74
|
}
|
|
73
75
|
throw error;
|
|
74
76
|
}
|
|
75
77
|
const file = form.get("file");
|
|
76
78
|
if (!file) {
|
|
77
|
-
return NextResponse.json({ error: "file field is required" }, { status: 400 });
|
|
79
|
+
return NextResponse.json({ error: t("storage_s3.errors.fileRequired", "file field is required") }, { status: 400 });
|
|
78
80
|
}
|
|
79
81
|
const keyOverride = form.get("key") ? String(form.get("key")) : null;
|
|
80
82
|
if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {
|
|
81
83
|
return NextResponse.json(
|
|
82
|
-
{ error: "Access denied: key override is not scoped to this tenant." },
|
|
84
|
+
{ error: t("storage_s3.errors.keyOverrideAccessDenied", "Access denied: key override is not scoped to this tenant.") },
|
|
83
85
|
{ status: 403 }
|
|
84
86
|
);
|
|
85
87
|
}
|
|
86
88
|
if (hasDangerousExecutableExtension(file.name)) {
|
|
87
|
-
return NextResponse.json({ error: "Executable file types are not allowed as attachments." }, { status: 400 });
|
|
89
|
+
return NextResponse.json({ error: t("storage_s3.errors.dangerousExecutable", "Executable file types are not allowed as attachments.") }, { status: 400 });
|
|
88
90
|
}
|
|
89
91
|
const effectiveMaxBytes = resolveAttachmentMaxBytes();
|
|
90
92
|
if (file.size > effectiveMaxBytes) {
|
|
91
|
-
return NextResponse.json({ error: "Attachment exceeds the maximum upload size." }, { status: 413 });
|
|
93
|
+
return NextResponse.json({ error: t("storage_s3.errors.maxUploadSize", "Attachment exceeds the maximum upload size.") }, { status: 413 });
|
|
92
94
|
}
|
|
93
95
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
94
96
|
if (!driver) {
|
|
95
|
-
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
97
|
+
return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
|
|
96
98
|
}
|
|
97
99
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
98
100
|
const safeName = sanitizeFileName(file.name);
|
|
99
101
|
const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null);
|
|
100
102
|
if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {
|
|
101
|
-
return NextResponse.json({ error: "Active content uploads are not allowed." }, { status: 400 });
|
|
103
|
+
return NextResponse.json({ error: t("storage_s3.errors.activeContentBlocked", "Active content uploads are not allowed.") }, { status: 400 });
|
|
102
104
|
}
|
|
103
105
|
const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId);
|
|
104
106
|
if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
|
|
105
|
-
return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
|
|
107
|
+
return NextResponse.json({ error: t("storage_s3.errors.quotaExceeded", "Attachment storage quota exceeded for this tenant.") }, { status: 413 });
|
|
106
108
|
}
|
|
107
109
|
const key = keyOverride ?? `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
|
|
108
110
|
await driver.putObject(key, buffer, trustedMimeType);
|
|
@@ -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 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 { 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 let form: FormData\n try {\n form = await parseMultipartFormDataWithinUploadLimit(req)\n } catch (error) {\n if (isMultipartUploadLimitError(error)) {\n return NextResponse.json({ error: '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: '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,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,6BAA6B;AACtC,SAAS,uBAAuB;AAChC,SAAS,kBAAkB;AAEpB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC3E;AAEA,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,KAAK,EAAE,OAAO;AAAA,EACd,QAAQ,EAAE,OAAO;AAAA,EACjB,MAAM,EAAE,OAAO,EAAE,IAAI;AAAA,EACrB,aAAa,EAAE,OAAO,EAAE,SAAS;AACnC,CAAC;AAED,SAAS,iBAAiB,MAAsB;AAC9C,SAAO,KAAK,QAAQ,oBAAoB,GAAG,KAAK;AAClD;AAEA,eAAe,cACb,UACA,OACiC;AACjC,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,QAAQ,MAAM,mBAAmB,QAAQ,cAAc,EAAE,UAAU,gBAAgB,MAAM,CAAC;AAChG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,IAAI,gBAAgB,EAAE,GAAG,OAAO,gBAAgB,OAAO,SAAS,CAAC;AAC1E;AAEA,eAAe,4BACb,QACA,UACA,OACiB;AACjB,MAAI,aAAa;AACjB,MAAI;AAEJ,KAAG;AACD,UAAM,OAAO,MAAM,OAAO,YAAY,IAAI,KAAM,iBAAiB;AACjE,eAAW,QAAQ,KAAK,OAAO;AAC7B,UAAI,sBAAsB,KAAK,KAAK,OAAO,QAAQ,GAAG;AACpD,sBAAc,KAAK;AAAA,MACrB;AAAA,IACF;AACA,wBAAoB,KAAK,YAAY,KAAK,wBAAwB;AAAA,EACpE,SAAS;AAET,SAAO;AACT;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,
|
|
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 { 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 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 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": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -59,7 +59,7 @@ function register(container) {
|
|
|
59
59
|
return {
|
|
60
60
|
async _resolveService(scope) {
|
|
61
61
|
const creds = await integrationCredentialsService.resolve("storage_s3", scope);
|
|
62
|
-
if (!creds) throw new Error("S3 storage integration is not configured for this tenant.");
|
|
62
|
+
if (!creds) throw new Error("[internal] S3 storage integration is not configured for this tenant.");
|
|
63
63
|
return createStorageService({
|
|
64
64
|
authMode: creds.authMode === "ambient" || creds.authMode === "access_keys" ? creds.authMode : void 0,
|
|
65
65
|
bucket: String(creds.bucket ?? ""),
|
|
@@ -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 }: { integrationCredentialsService: IntegrationCredentialsService }) => {\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('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 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": "AAAA,SAAS,YAAY,eAAe;AAEpC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,uBAAuB;AAChC,SAAS,4BAA4B;AACrC,SAAS,qBAAqB;AAC9B,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,YAAY;AAQxC,8BAA8B,MAAM,CAAC,WAAoC;AACvE,SAAO,MAAM,4BAA4B;AAAA,IACvC,QAAQ,OAAO;AAAA,IACf,QAAQ,OAAO;AAAA,IACf,UAAU,OAAO;AAAA,IACjB,gBAAgB,OAAO;AAAA,IACvB,cAAc,QAAQ,OAAO,WAAW;AAAA,IACxC,yBAAyB,QAAQ,OAAO,oBAAoB;AAAA,EAC9D,CAAC;AACD,SAAO,IAAI,gBAAgB,MAAM;AACnC,CAAC;AAEM,SAAS,SAAS,WAAyB;AAGhD,qCAAmC,MAAM,OAAO,QAAQ,UAAU;AAChE,UAAM,eAAe;AAAA,MACnB,GAAG;AAAA,MACH,gBAAgB,MAAM;AAAA,MACtB,UAAU,MAAM;AAAA,IAClB;AACA,QAAI,OAAO,wBAAwB,OAAO,eAAe,OAAO,SAAU,QAAO;AACjF,QAAI;AACF,YAAM,WAAW,UAAU,QAAQ,+BAA+B;AAClE,YAAM,QAAQ,MAAM,SAAS,QAAQ,cAAc,KAAK;AACxD,UAAI,CAAC,OAAO;AACV,eAAO,MAAM,8CAA8C,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe,CAAC;AAC7H,eAAO;AAAA,MACT;AACA,aAAO,MAAM,yDAAyD;AACtE,aAAO;AAAA,QACL,gBAAgB,MAAM;AAAA,QACtB,UAAU,MAAM;AAAA,QAChB,UAAU,MAAM;AAAA,QAChB,QAAQ,OAAO,WAAW,MAAM,SAAS,OAAO,MAAM,MAAM,IAAI;AAAA,QAChE,QAAQ,OAAO,WAAW,MAAM,SAAS,OAAO,MAAM,MAAM,IAAI;AAAA,QAChE,UAAU,OAAO,aAAa,MAAM,WAAW,OAAO,MAAM,QAAQ,IAAI;AAAA,QACxE,gBAAgB,OAAO,kBAAkB,QAAQ,MAAM,cAAc;AAAA,QACrE,aAAa,MAAM,cAAc,OAAO,MAAM,WAAW,IAAI;AAAA,QAC7D,iBAAiB,MAAM,kBAAkB,OAAO,MAAM,eAAe,IAAI;AAAA,QACzE,cAAc,MAAM,eAAe,OAAO,MAAM,YAAY,IAAI;AAAA,MAClE;AAAA,IACF,SAAS,KAAK;AACZ,aAAO,KAAK,6DAA6D,EAAE,IAAI,CAAC;AAChF,aAAO;AAAA,IACT;AAAA,EACF,CAAC;AAED,YAAU,SAAS;AAAA,IACjB,eAAe,QAAQ,aAAa;AAAA,IACpC,gBAAgB;AAAA,MACd,CAAC,EAAE,8BAA8B,MAAwE;AAGvG,eAAO;AAAA,UACL,MAAM,gBAAgB,OAAqD;AACzE,kBAAM,QAAQ,MAAM,8BAA8B,QAAQ,cAAc,KAAK;AAC7E,gBAAI,CAAC,MAAO,OAAM,IAAI,MAAM,
|
|
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 }: { integrationCredentialsService: IntegrationCredentialsService }) => {\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 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
|
}
|
|
@@ -57,12 +57,12 @@ function isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix) {
|
|
|
57
57
|
}
|
|
58
58
|
function assertS3KeyScopedToTenant(storagePath, scope, pathPrefix) {
|
|
59
59
|
if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {
|
|
60
|
-
throw new Error("S3 key is not scoped to the active tenant");
|
|
60
|
+
throw new Error("[internal] S3 key is not scoped to the active tenant");
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
function assertS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix) {
|
|
64
64
|
if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {
|
|
65
|
-
throw new Error("S3 key is not scoped to the active tenant");
|
|
65
|
+
throw new Error("[internal] S3 key is not scoped to the active tenant");
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
function assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix) {
|
|
@@ -72,12 +72,12 @@ function assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix) {
|
|
|
72
72
|
if (parts.length === 0) return;
|
|
73
73
|
if (parts[0]?.startsWith("org_") || parts[1]?.startsWith("tenant_")) {
|
|
74
74
|
if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {
|
|
75
|
-
throw new Error("S3 prefix is not scoped to the active tenant");
|
|
75
|
+
throw new Error("[internal] S3 prefix is not scoped to the active tenant");
|
|
76
76
|
}
|
|
77
77
|
return;
|
|
78
78
|
}
|
|
79
79
|
if (parts[1]?.startsWith("org_") && parts[1] !== segments.orgSegment || parts[2]?.startsWith("tenant_") && parts[2] !== segments.tenantSegment) {
|
|
80
|
-
throw new Error("S3 prefix is not scoped to the active tenant");
|
|
80
|
+
throw new Error("[internal] S3 prefix is not scoped to the active tenant");
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
function filterS3ObjectsToTenant(files, scope, pathPrefix) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/storage_s3/lib/key-scope.ts"],
|
|
4
|
-
"sourcesContent": ["export type S3TenantScope = {\n organizationId?: string | null\n tenantId?: string | null\n}\n\nexport type S3ScopedObject = {\n key: string\n}\n\ntype ScopeSegments = {\n orgSegment: string\n tenantSegment: string\n}\n\nconst SHARED_ORG_SEGMENT = 'org_shared'\nconst SHARED_TENANT_SEGMENT = 'tenant_shared'\n\nfunction resolveSegment(value: string | null | undefined, prefix: 'org' | 'tenant'): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (!trimmed) return null\n return `${prefix}_${trimmed}`\n}\n\nfunction resolveScopeSegments(scope: S3TenantScope | null | undefined): ScopeSegments | null {\n const orgSegment = resolveSegment(scope?.organizationId, 'org')\n const tenantSegment = resolveSegment(scope?.tenantId, 'tenant')\n if (!orgSegment || !tenantSegment) return null\n return { orgSegment, tenantSegment }\n}\n\nfunction normalizePath(value: string): string {\n return value.replace(/^\\/+/, '')\n}\n\nfunction stripConfiguredPrefix(value: string, pathPrefix?: string): string {\n const normalized = normalizePath(value)\n const normalizedPrefix = normalizePath(pathPrefix ?? '')\n if (!normalizedPrefix) return normalized\n return normalized.startsWith(normalizedPrefix)\n ? normalized.slice(normalizedPrefix.length).replace(/^\\/+/, '')\n : normalized\n}\n\nfunction splitS3Path(value: string, pathPrefix?: string): string[] {\n return stripConfiguredPrefix(value, pathPrefix).split('/').filter(Boolean)\n}\n\nfunction findScopeSegmentIndex(parts: string[]): number {\n return parts.findIndex((part, index) => (\n part.startsWith('org_') && parts[index + 1]?.startsWith('tenant_')\n ))\n}\n\nfunction hasScopeSegments(parts: string[], orgSegment: string, tenantSegment: string): boolean {\n const scopeIndex = findScopeSegmentIndex(parts)\n return scopeIndex >= 0\n && parts[scopeIndex] === orgSegment\n && parts[scopeIndex + 1] === tenantSegment\n}\n\nfunction resolveScopeSegmentsFromArgs(\n scopeOrOrgId: S3TenantScope | string | null | undefined,\n tenantId?: string,\n): ScopeSegments | null {\n if (typeof scopeOrOrgId === 'string') {\n return resolveScopeSegments({ organizationId: scopeOrOrgId, tenantId })\n }\n return resolveScopeSegments(scopeOrOrgId)\n}\n\nexport function isS3KeyScopedToTenant(key: string, orgId: string, tenantId: string): boolean\nexport function isS3KeyScopedToTenant(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): boolean\nexport function isS3KeyScopedToTenant(\n storagePath: string,\n scopeOrOrgId: S3TenantScope | string | null | undefined,\n pathPrefixOrTenantId?: string,\n): boolean {\n const segments = resolveScopeSegmentsFromArgs(scopeOrOrgId, pathPrefixOrTenantId)\n if (!segments) return typeof scopeOrOrgId === 'string' ? false : true\n\n const pathPrefix = typeof scopeOrOrgId === 'string' ? undefined : pathPrefixOrTenantId\n const parts = splitS3Path(storagePath, pathPrefix)\n return hasScopeSegments(parts, segments.orgSegment, segments.tenantSegment)\n}\n\nexport function isS3KeyShared(key: string): boolean {\n return hasScopeSegments(splitS3Path(key), SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function isS3KeyAddressableByScope(key: string, orgId: string, tenantId: string): boolean {\n return isS3KeyScopedToTenant(key, orgId, tenantId) || isS3KeyShared(key)\n}\n\nexport function isS3KeyAddressableByTenantScope(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): boolean {\n if (!resolveScopeSegments(scope)) return true\n const parts = splitS3Path(storagePath, pathPrefix)\n return isS3KeyScopedToTenant(storagePath, scope, pathPrefix)\n || hasScopeSegments(parts, SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function assertS3KeyScopedToTenant(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {\n throw new Error('S3 key is not scoped to the active tenant')\n }\n}\n\nexport function assertS3KeyAddressableByTenantScope(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {\n throw new Error('S3 key is not scoped to the active tenant')\n }\n}\n\nexport function assertS3ListPrefixScopedToTenant(\n prefix: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n const segments = resolveScopeSegments(scope)\n if (!segments) return\n\n const parts = splitS3Path(prefix, pathPrefix)\n if (parts.length === 0) return\n\n if (parts[0]?.startsWith('org_') || parts[1]?.startsWith('tenant_')) {\n if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {\n throw new Error('S3 prefix is not scoped to the active tenant')\n }\n return\n }\n\n if (\n (parts[1]?.startsWith('org_') && parts[1] !== segments.orgSegment)\n || (parts[2]?.startsWith('tenant_') && parts[2] !== segments.tenantSegment)\n ) {\n throw new Error('S3 prefix is not scoped to the active tenant')\n }\n}\n\nexport function filterS3ObjectsToTenant<T extends S3ScopedObject>(\n files: T[],\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): T[] {\n if (!resolveScopeSegments(scope)) return files\n return files.filter((file) => isS3KeyScopedToTenant(file.key, scope, pathPrefix))\n}\n"],
|
|
5
|
-
"mappings": "AAcA,MAAM,qBAAqB;AAC3B,MAAM,wBAAwB;AAE9B,SAAS,eAAe,OAAkC,QAAyC;AACjG,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,CAAC,QAAS,QAAO;AACrB,SAAO,GAAG,MAAM,IAAI,OAAO;AAC7B;AAEA,SAAS,qBAAqB,OAA+D;AAC3F,QAAM,aAAa,eAAe,OAAO,gBAAgB,KAAK;AAC9D,QAAM,gBAAgB,eAAe,OAAO,UAAU,QAAQ;AAC9D,MAAI,CAAC,cAAc,CAAC,cAAe,QAAO;AAC1C,SAAO,EAAE,YAAY,cAAc;AACrC;AAEA,SAAS,cAAc,OAAuB;AAC5C,SAAO,MAAM,QAAQ,QAAQ,EAAE;AACjC;AAEA,SAAS,sBAAsB,OAAe,YAA6B;AACzE,QAAM,aAAa,cAAc,KAAK;AACtC,QAAM,mBAAmB,cAAc,cAAc,EAAE;AACvD,MAAI,CAAC,iBAAkB,QAAO;AAC9B,SAAO,WAAW,WAAW,gBAAgB,IACzC,WAAW,MAAM,iBAAiB,MAAM,EAAE,QAAQ,QAAQ,EAAE,IAC5D;AACN;AAEA,SAAS,YAAY,OAAe,YAA+B;AACjE,SAAO,sBAAsB,OAAO,UAAU,EAAE,MAAM,GAAG,EAAE,OAAO,OAAO;AAC3E;AAEA,SAAS,sBAAsB,OAAyB;AACtD,SAAO,MAAM,UAAU,CAAC,MAAM,UAC5B,KAAK,WAAW,MAAM,KAAK,MAAM,QAAQ,CAAC,GAAG,WAAW,SAAS,CAClE;AACH;AAEA,SAAS,iBAAiB,OAAiB,YAAoB,eAAgC;AAC7F,QAAM,aAAa,sBAAsB,KAAK;AAC9C,SAAO,cAAc,KAChB,MAAM,UAAU,MAAM,cACtB,MAAM,aAAa,CAAC,MAAM;AACjC;AAEA,SAAS,6BACP,cACA,UACsB;AACtB,MAAI,OAAO,iBAAiB,UAAU;AACpC,WAAO,qBAAqB,EAAE,gBAAgB,cAAc,SAAS,CAAC;AAAA,EACxE;AACA,SAAO,qBAAqB,YAAY;AAC1C;AAQO,SAAS,sBACd,aACA,cACA,sBACS;AACT,QAAM,WAAW,6BAA6B,cAAc,oBAAoB;AAChF,MAAI,CAAC,SAAU,QAAO,OAAO,iBAAiB,WAAW,QAAQ;AAEjE,QAAM,aAAa,OAAO,iBAAiB,WAAW,SAAY;AAClE,QAAM,QAAQ,YAAY,aAAa,UAAU;AACjD,SAAO,iBAAiB,OAAO,SAAS,YAAY,SAAS,aAAa;AAC5E;AAEO,SAAS,cAAc,KAAsB;AAClD,SAAO,iBAAiB,YAAY,GAAG,GAAG,oBAAoB,qBAAqB;AACrF;AAEO,SAAS,0BAA0B,KAAa,OAAe,UAA2B;AAC/F,SAAO,sBAAsB,KAAK,OAAO,QAAQ,KAAK,cAAc,GAAG;AACzE;AAEO,SAAS,gCACd,aACA,OACA,YACS;AACT,MAAI,CAAC,qBAAqB,KAAK,EAAG,QAAO;AACzC,QAAM,QAAQ,YAAY,aAAa,UAAU;AACjD,SAAO,sBAAsB,aAAa,OAAO,UAAU,KACtD,iBAAiB,OAAO,oBAAoB,qBAAqB;AACxE;AAEO,SAAS,0BACd,aACA,OACA,YACM;AACN,MAAI,CAAC,sBAAsB,aAAa,OAAO,UAAU,GAAG;AAC1D,UAAM,IAAI,MAAM,
|
|
4
|
+
"sourcesContent": ["export type S3TenantScope = {\n organizationId?: string | null\n tenantId?: string | null\n}\n\nexport type S3ScopedObject = {\n key: string\n}\n\ntype ScopeSegments = {\n orgSegment: string\n tenantSegment: string\n}\n\nconst SHARED_ORG_SEGMENT = 'org_shared'\nconst SHARED_TENANT_SEGMENT = 'tenant_shared'\n\nfunction resolveSegment(value: string | null | undefined, prefix: 'org' | 'tenant'): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (!trimmed) return null\n return `${prefix}_${trimmed}`\n}\n\nfunction resolveScopeSegments(scope: S3TenantScope | null | undefined): ScopeSegments | null {\n const orgSegment = resolveSegment(scope?.organizationId, 'org')\n const tenantSegment = resolveSegment(scope?.tenantId, 'tenant')\n if (!orgSegment || !tenantSegment) return null\n return { orgSegment, tenantSegment }\n}\n\nfunction normalizePath(value: string): string {\n return value.replace(/^\\/+/, '')\n}\n\nfunction stripConfiguredPrefix(value: string, pathPrefix?: string): string {\n const normalized = normalizePath(value)\n const normalizedPrefix = normalizePath(pathPrefix ?? '')\n if (!normalizedPrefix) return normalized\n return normalized.startsWith(normalizedPrefix)\n ? normalized.slice(normalizedPrefix.length).replace(/^\\/+/, '')\n : normalized\n}\n\nfunction splitS3Path(value: string, pathPrefix?: string): string[] {\n return stripConfiguredPrefix(value, pathPrefix).split('/').filter(Boolean)\n}\n\nfunction findScopeSegmentIndex(parts: string[]): number {\n return parts.findIndex((part, index) => (\n part.startsWith('org_') && parts[index + 1]?.startsWith('tenant_')\n ))\n}\n\nfunction hasScopeSegments(parts: string[], orgSegment: string, tenantSegment: string): boolean {\n const scopeIndex = findScopeSegmentIndex(parts)\n return scopeIndex >= 0\n && parts[scopeIndex] === orgSegment\n && parts[scopeIndex + 1] === tenantSegment\n}\n\nfunction resolveScopeSegmentsFromArgs(\n scopeOrOrgId: S3TenantScope | string | null | undefined,\n tenantId?: string,\n): ScopeSegments | null {\n if (typeof scopeOrOrgId === 'string') {\n return resolveScopeSegments({ organizationId: scopeOrOrgId, tenantId })\n }\n return resolveScopeSegments(scopeOrOrgId)\n}\n\nexport function isS3KeyScopedToTenant(key: string, orgId: string, tenantId: string): boolean\nexport function isS3KeyScopedToTenant(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): boolean\nexport function isS3KeyScopedToTenant(\n storagePath: string,\n scopeOrOrgId: S3TenantScope | string | null | undefined,\n pathPrefixOrTenantId?: string,\n): boolean {\n const segments = resolveScopeSegmentsFromArgs(scopeOrOrgId, pathPrefixOrTenantId)\n if (!segments) return typeof scopeOrOrgId === 'string' ? false : true\n\n const pathPrefix = typeof scopeOrOrgId === 'string' ? undefined : pathPrefixOrTenantId\n const parts = splitS3Path(storagePath, pathPrefix)\n return hasScopeSegments(parts, segments.orgSegment, segments.tenantSegment)\n}\n\nexport function isS3KeyShared(key: string): boolean {\n return hasScopeSegments(splitS3Path(key), SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function isS3KeyAddressableByScope(key: string, orgId: string, tenantId: string): boolean {\n return isS3KeyScopedToTenant(key, orgId, tenantId) || isS3KeyShared(key)\n}\n\nexport function isS3KeyAddressableByTenantScope(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): boolean {\n if (!resolveScopeSegments(scope)) return true\n const parts = splitS3Path(storagePath, pathPrefix)\n return isS3KeyScopedToTenant(storagePath, scope, pathPrefix)\n || hasScopeSegments(parts, SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function assertS3KeyScopedToTenant(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {\n throw new Error('[internal] S3 key is not scoped to the active tenant')\n }\n}\n\nexport function assertS3KeyAddressableByTenantScope(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {\n throw new Error('[internal] S3 key is not scoped to the active tenant')\n }\n}\n\nexport function assertS3ListPrefixScopedToTenant(\n prefix: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n const segments = resolveScopeSegments(scope)\n if (!segments) return\n\n const parts = splitS3Path(prefix, pathPrefix)\n if (parts.length === 0) return\n\n if (parts[0]?.startsWith('org_') || parts[1]?.startsWith('tenant_')) {\n if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {\n throw new Error('[internal] S3 prefix is not scoped to the active tenant')\n }\n return\n }\n\n if (\n (parts[1]?.startsWith('org_') && parts[1] !== segments.orgSegment)\n || (parts[2]?.startsWith('tenant_') && parts[2] !== segments.tenantSegment)\n ) {\n throw new Error('[internal] S3 prefix is not scoped to the active tenant')\n }\n}\n\nexport function filterS3ObjectsToTenant<T extends S3ScopedObject>(\n files: T[],\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): T[] {\n if (!resolveScopeSegments(scope)) return files\n return files.filter((file) => isS3KeyScopedToTenant(file.key, scope, pathPrefix))\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|