@open-mercato/storage-s3 0.6.7-develop.6860.1.f796b22e80 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js +5 -20
- package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js.map +2 -2
- package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js +5 -7
- package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js.map +2 -2
- package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js +4 -6
- package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js.map +2 -2
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +8 -70
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js.map +3 -3
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +15 -92
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
- package/dist/modules/storage_s3/di.js +2 -6
- package/dist/modules/storage_s3/di.js.map +2 -2
- package/dist/modules/storage_s3/integration.js +1 -1
- package/dist/modules/storage_s3/integration.js.map +1 -1
- package/dist/modules/storage_s3/lib/health.js +1 -18
- package/dist/modules/storage_s3/lib/health.js.map +2 -2
- package/dist/modules/storage_s3/lib/key-scope.js +4 -4
- package/dist/modules/storage_s3/lib/key-scope.js.map +2 -2
- package/dist/modules/storage_s3/lib/s3-driver.js +15 -44
- package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
- package/dist/modules/storage_s3/lib/storage-service.js +22 -126
- package/dist/modules/storage_s3/lib/storage-service.js.map +3 -3
- package/jest.config.cjs +0 -1
- package/package.json +6 -13
- package/src/modules/storage_s3/__tests__/s3Driver.test.ts +8 -23
- package/src/modules/storage_s3/__tests__/s3ListRoute.test.ts +1 -7
- package/src/modules/storage_s3/__tests__/s3Routes.test.ts +0 -26
- package/src/modules/storage_s3/__tests__/storageService.test.ts +5 -20
- package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +5 -22
- package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +5 -7
- package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +4 -6
- package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +6 -77
- package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +16 -101
- package/src/modules/storage_s3/di.ts +2 -14
- package/src/modules/storage_s3/integration.ts +1 -1
- package/src/modules/storage_s3/lib/health.ts +0 -18
- package/src/modules/storage_s3/lib/key-scope.ts +4 -4
- package/src/modules/storage_s3/lib/s3-driver.ts +10 -65
- package/src/modules/storage_s3/lib/storage-service.ts +22 -131
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js +0 -145
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js.map +0 -7
- package/dist/modules/storage_s3/lib/endpoint-safety.js +0 -133
- package/dist/modules/storage_s3/lib/endpoint-safety.js.map +0 -7
- package/dist/modules/storage_s3/lib/quota-accounting.js +0 -24
- package/dist/modules/storage_s3/lib/quota-accounting.js.map +0 -7
- package/dist/modules/storage_s3/lib/quota-recovery-queue.js +0 -9
- package/dist/modules/storage_s3/lib/quota-recovery-queue.js.map +0 -7
- package/dist/modules/storage_s3/workers/quota-recovery.js +0 -70
- package/dist/modules/storage_s3/workers/quota-recovery.js.map +0 -7
- package/src/modules/storage_s3/__tests__/endpoint-safety.test.ts +0 -71
- package/src/modules/storage_s3/__tests__/health.test.ts +0 -38
- package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +0 -13
- package/src/modules/storage_s3/__tests__/quota-lifecycle.test.ts +0 -216
- package/src/modules/storage_s3/__tests__/quota-recovery.test.ts +0 -83
- package/src/modules/storage_s3/__tests__/upload.test.ts +0 -93
- package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +0 -150
- package/src/modules/storage_s3/i18n/de.json +0 -17
- package/src/modules/storage_s3/i18n/en.json +0 -17
- package/src/modules/storage_s3/i18n/es.json +0 -17
- package/src/modules/storage_s3/i18n/ko.json +0 -17
- package/src/modules/storage_s3/i18n/pl.json +0 -17
- package/src/modules/storage_s3/lib/endpoint-safety.ts +0 -179
- package/src/modules/storage_s3/lib/quota-accounting.ts +0 -29
- package/src/modules/storage_s3/lib/quota-recovery-queue.ts +0 -17
- package/src/modules/storage_s3/workers/quota-recovery.ts +0 -82
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
Found
|
|
1
|
+
Found 19 entry points
|
|
2
2
|
storage-s3 built successfully
|
|
@@ -2,7 +2,6 @@ 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";
|
|
6
5
|
import { isS3KeyAddressableByScope } from "../../../../lib/key-scope.js";
|
|
7
6
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
8
7
|
const metadata = {
|
|
@@ -20,37 +19,23 @@ async function resolveDriver(tenantId, orgId) {
|
|
|
20
19
|
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId });
|
|
21
20
|
}
|
|
22
21
|
async function DELETE(req) {
|
|
23
|
-
const { t } = await resolveTranslations();
|
|
24
22
|
const auth = await getAuthFromRequest(req);
|
|
25
23
|
if (!auth?.tenantId || !auth.orgId) {
|
|
26
|
-
return NextResponse.json({ error:
|
|
24
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
27
25
|
}
|
|
28
26
|
const json = await req.json().catch(() => null);
|
|
29
27
|
const parsed = requestSchema.safeParse(json);
|
|
30
28
|
if (!parsed.success) {
|
|
31
|
-
return NextResponse.json({ error:
|
|
29
|
+
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
|
32
30
|
}
|
|
33
31
|
if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {
|
|
34
|
-
return NextResponse.json({ error:
|
|
32
|
+
return NextResponse.json({ error: "Access denied: key is not scoped to this tenant." }, { status: 403 });
|
|
35
33
|
}
|
|
36
34
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
37
35
|
if (!driver) {
|
|
38
|
-
return NextResponse.json({ error:
|
|
39
|
-
}
|
|
40
|
-
await (driver.deleteStrict?.("", parsed.data.key) ?? driver.delete("", parsed.data.key));
|
|
41
|
-
const { resolve } = await createRequestContainer();
|
|
42
|
-
let attachmentQuotaService = null;
|
|
43
|
-
try {
|
|
44
|
-
attachmentQuotaService = resolve("attachmentQuotaService");
|
|
45
|
-
} catch {
|
|
46
|
-
}
|
|
47
|
-
if (attachmentQuotaService) {
|
|
48
|
-
await attachmentQuotaService.releaseCommittedByPath({
|
|
49
|
-
tenantId: auth.tenantId,
|
|
50
|
-
storageDriver: "s3",
|
|
51
|
-
storagePath: parsed.data.key
|
|
52
|
-
});
|
|
36
|
+
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
53
37
|
}
|
|
38
|
+
await driver.delete("", parsed.data.key);
|
|
54
39
|
return new NextResponse(null, { status: 204 });
|
|
55
40
|
}
|
|
56
41
|
var delete_default = DELETE;
|
|
@@ -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 {
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,
|
|
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,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;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,kBAAkB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACxE;AAEA,MAAI,CAAC,0BAA0B,OAAO,KAAK,KAAK,KAAK,OAAO,KAAK,QAAQ,GAAG;AAC1E,WAAO,aAAa,KAAK,EAAE,OAAO,mDAAmD,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACzG;AAEA,QAAM,SAAS,MAAM,cAAc,KAAK,UAAU,KAAK,KAAK;AAC5D,MAAI,CAAC,QAAQ;AACX,WAAO,aAAa,KAAK,EAAE,OAAO,oCAAoC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1F;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,7 +2,6 @@ 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";
|
|
6
5
|
import {
|
|
7
6
|
buildAttachmentContentDisposition,
|
|
8
7
|
canRenderInlineAttachment,
|
|
@@ -22,21 +21,20 @@ async function resolveDriver(tenantId, orgId) {
|
|
|
22
21
|
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId });
|
|
23
22
|
}
|
|
24
23
|
async function GET(req) {
|
|
25
|
-
const { t } = await resolveTranslations();
|
|
26
24
|
const auth = await getAuthFromRequest(req);
|
|
27
25
|
if (!auth?.tenantId || !auth.orgId) {
|
|
28
|
-
return NextResponse.json({ error:
|
|
26
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
29
27
|
}
|
|
30
28
|
const key = new URL(req.url).searchParams.get("key");
|
|
31
29
|
if (!key) {
|
|
32
|
-
return NextResponse.json({ error:
|
|
30
|
+
return NextResponse.json({ error: "key query param is required" }, { status: 400 });
|
|
33
31
|
}
|
|
34
32
|
if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {
|
|
35
|
-
return NextResponse.json({ error:
|
|
33
|
+
return NextResponse.json({ error: "Access denied: key is not scoped to this tenant." }, { status: 403 });
|
|
36
34
|
}
|
|
37
35
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
38
36
|
if (!driver) {
|
|
39
|
-
return NextResponse.json({ error:
|
|
37
|
+
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
40
38
|
}
|
|
41
39
|
let buffer;
|
|
42
40
|
let contentType;
|
|
@@ -45,7 +43,7 @@ async function GET(req) {
|
|
|
45
43
|
buffer = result.buffer;
|
|
46
44
|
contentType = result.contentType;
|
|
47
45
|
} catch {
|
|
48
|
-
return NextResponse.json({ error:
|
|
46
|
+
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
|
49
47
|
}
|
|
50
48
|
const fileName = sanitizeUploadedFileName(key.split("/").pop() || "download");
|
|
51
49
|
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 {
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC
|
|
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": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,7 +2,6 @@ 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";
|
|
6
5
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
7
6
|
const metadata = {
|
|
8
7
|
path: "/storage-providers/s3/list",
|
|
@@ -52,26 +51,25 @@ function resolveTenantScopedPrefix(prefix, tenantId, orgId) {
|
|
|
52
51
|
return tenantPrefix + namespaceRelativePrefix;
|
|
53
52
|
}
|
|
54
53
|
async function GET(req) {
|
|
55
|
-
const { t } = await resolveTranslations();
|
|
56
54
|
const auth = await getAuthFromRequest(req);
|
|
57
55
|
if (!auth?.tenantId || !auth.orgId) {
|
|
58
|
-
return NextResponse.json({ error:
|
|
56
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
59
57
|
}
|
|
60
58
|
const params = Object.fromEntries(new URL(req.url).searchParams.entries());
|
|
61
59
|
const parsed = querySchema.safeParse(params);
|
|
62
60
|
if (!parsed.success) {
|
|
63
|
-
return NextResponse.json({ error:
|
|
61
|
+
return NextResponse.json({ error: "Invalid query parameters" }, { status: 400 });
|
|
64
62
|
}
|
|
65
63
|
const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId);
|
|
66
64
|
if (effectivePrefix === null) {
|
|
67
65
|
return NextResponse.json(
|
|
68
|
-
{ error:
|
|
66
|
+
{ error: "Access denied: prefix is not scoped to this tenant." },
|
|
69
67
|
{ status: 403 }
|
|
70
68
|
);
|
|
71
69
|
}
|
|
72
70
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
73
71
|
if (!driver) {
|
|
74
|
-
return NextResponse.json({ error:
|
|
72
|
+
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
75
73
|
}
|
|
76
74
|
const result = await driver.listObjects(
|
|
77
75
|
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 {
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,
|
|
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": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,12 +2,8 @@ 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";
|
|
6
5
|
import { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from "../../../../lib/key-scope.js";
|
|
7
6
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
8
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
-
import { resolveAttachmentMaxBytes } from "@open-mercato/core/modules/attachments/lib/upload-limits";
|
|
10
|
-
import { reconcileTenantS3Objects } from "../../../../lib/quota-accounting.js";
|
|
11
7
|
const metadata = {
|
|
12
8
|
path: "/storage-providers/s3/signed-url",
|
|
13
9
|
POST: { requireAuth: true, requireFeatures: ["storage_providers.manage"] }
|
|
@@ -16,13 +12,11 @@ const requestSchema = z.object({
|
|
|
16
12
|
key: z.string().min(1),
|
|
17
13
|
operation: z.enum(["upload", "download"]),
|
|
18
14
|
expiresIn: z.number().int().min(60).max(604800).optional().default(3600),
|
|
19
|
-
contentType: z.string().optional()
|
|
20
|
-
size: z.number().int().nonnegative().optional()
|
|
15
|
+
contentType: z.string().optional()
|
|
21
16
|
});
|
|
22
17
|
const responseSchema = z.object({
|
|
23
18
|
url: z.string(),
|
|
24
|
-
expiresAt: z.string()
|
|
25
|
-
reservationId: z.string().optional()
|
|
19
|
+
expiresAt: z.string()
|
|
26
20
|
});
|
|
27
21
|
async function resolveDriver(tenantId, orgId) {
|
|
28
22
|
const { resolve } = await createRequestContainer();
|
|
@@ -32,82 +26,26 @@ async function resolveDriver(tenantId, orgId) {
|
|
|
32
26
|
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId });
|
|
33
27
|
}
|
|
34
28
|
async function POST(req) {
|
|
35
|
-
const { t } = await resolveTranslations();
|
|
36
29
|
const auth = await getAuthFromRequest(req);
|
|
37
30
|
if (!auth?.tenantId || !auth.orgId) {
|
|
38
|
-
return NextResponse.json({ error:
|
|
31
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
39
32
|
}
|
|
40
33
|
const json = await req.json().catch(() => null);
|
|
41
34
|
const parsed = requestSchema.safeParse(json);
|
|
42
35
|
if (!parsed.success) {
|
|
43
|
-
return NextResponse.json({ error:
|
|
36
|
+
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
|
44
37
|
}
|
|
45
|
-
const { key, operation, expiresIn, contentType
|
|
38
|
+
const { key, operation, expiresIn, contentType } = parsed.data;
|
|
46
39
|
const canAccessKey = operation === "download" ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId) : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId);
|
|
47
40
|
if (!canAccessKey) {
|
|
48
|
-
return NextResponse.json({ error:
|
|
41
|
+
return NextResponse.json({ error: "Access denied: key is not scoped to this tenant." }, { status: 403 });
|
|
49
42
|
}
|
|
50
43
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
51
44
|
if (!driver) {
|
|
52
|
-
return NextResponse.json({ error:
|
|
53
|
-
}
|
|
54
|
-
const expiresAt = new Date(Date.now() + expiresIn * 1e3).toISOString();
|
|
55
|
-
if (operation === "upload") {
|
|
56
|
-
const { resolve } = await createRequestContainer();
|
|
57
|
-
let attachmentQuotaService = null;
|
|
58
|
-
let recoveryScheduler = null;
|
|
59
|
-
try {
|
|
60
|
-
attachmentQuotaService = resolve("attachmentQuotaService");
|
|
61
|
-
recoveryScheduler = resolve("storageS3QuotaRecoveryScheduler");
|
|
62
|
-
} catch {
|
|
63
|
-
}
|
|
64
|
-
if (!attachmentQuotaService) {
|
|
65
|
-
return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
|
|
66
|
-
}
|
|
67
|
-
const compatibilityToken = randomBytes(32).toString("base64url");
|
|
68
|
-
const reservedBytes = size ?? resolveAttachmentMaxBytes();
|
|
69
|
-
let reservation = null;
|
|
70
|
-
try {
|
|
71
|
-
await reconcileTenantS3Objects({
|
|
72
|
-
driver,
|
|
73
|
-
quotaService: attachmentQuotaService,
|
|
74
|
-
tenantId: auth.tenantId,
|
|
75
|
-
organizationId: auth.orgId
|
|
76
|
-
});
|
|
77
|
-
reservation = await attachmentQuotaService.reserve({
|
|
78
|
-
tenantId: auth.tenantId,
|
|
79
|
-
organizationId: auth.orgId,
|
|
80
|
-
bytes: reservedBytes,
|
|
81
|
-
source: "storage_s3_signed",
|
|
82
|
-
storageDriver: "s3",
|
|
83
|
-
storagePath: key,
|
|
84
|
-
uploadTokenHash: createHash("sha256").update(compatibilityToken).digest("hex"),
|
|
85
|
-
ttlMs: expiresIn * 1e3
|
|
86
|
-
});
|
|
87
|
-
if (!recoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
|
|
88
|
-
await recoveryScheduler({
|
|
89
|
-
reservationId: reservation.id,
|
|
90
|
-
tenantId: auth.tenantId,
|
|
91
|
-
organizationId: auth.orgId
|
|
92
|
-
}, Math.max(1e3, reservation.expiresAt.getTime() - Date.now()));
|
|
93
|
-
const url2 = new URL(`/api/storage-providers/s3/signed-upload/${compatibilityToken}`, req.url).toString();
|
|
94
|
-
return NextResponse.json({ url: url2, expiresAt, reservationId: reservation.id });
|
|
95
|
-
} catch (error) {
|
|
96
|
-
if (reservation) {
|
|
97
|
-
await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
const code = error?.code;
|
|
101
|
-
if (code === "quota_exceeded") {
|
|
102
|
-
return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
|
|
103
|
-
}
|
|
104
|
-
if (code === "quota_target_exists") {
|
|
105
|
-
return NextResponse.json({ error: "The target storage key already exists." }, { status: 409 });
|
|
106
|
-
}
|
|
107
|
-
return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
|
|
108
|
-
}
|
|
45
|
+
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
109
46
|
}
|
|
110
47
|
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType);
|
|
48
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1e3).toISOString();
|
|
111
49
|
return NextResponse.json({ url, expiresAt });
|
|
112
50
|
}
|
|
113
51
|
var signed_url_default = POST;
|
|
@@ -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 {
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,2BAA2B
|
|
6
|
-
"names": [
|
|
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": "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",
|
|
6
|
+
"names": []
|
|
7
7
|
}
|
|
@@ -2,22 +2,18 @@ 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";
|
|
6
5
|
import {
|
|
7
6
|
detectAttachmentMimeType,
|
|
8
7
|
hasDangerousExecutableExtension,
|
|
9
8
|
isActiveContentAttachment
|
|
10
9
|
} from "@open-mercato/core/modules/attachments/lib/security";
|
|
11
10
|
import {
|
|
12
|
-
isMultipartUploadLimitError,
|
|
13
11
|
isMultipartRequestWithinUploadLimit,
|
|
14
|
-
parseMultipartFormDataWithinUploadLimit,
|
|
15
12
|
resolveAttachmentMaxBytes,
|
|
16
13
|
willExceedAttachmentTenantQuota
|
|
17
14
|
} from "@open-mercato/core/modules/attachments/lib/upload-limits";
|
|
18
15
|
import { isS3KeyScopedToTenant } from "../../../../lib/key-scope.js";
|
|
19
16
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
20
|
-
import { reconcileTenantS3Objects } from "../../../../lib/quota-accounting.js";
|
|
21
17
|
import { randomUUID } from "crypto";
|
|
22
18
|
const metadata = {
|
|
23
19
|
path: "/storage-providers/s3/upload",
|
|
@@ -54,125 +50,52 @@ async function readTenantStorageUsageBytes(driver, tenantId, orgId) {
|
|
|
54
50
|
return totalBytes;
|
|
55
51
|
}
|
|
56
52
|
async function POST(req) {
|
|
57
|
-
const { t } = await resolveTranslations();
|
|
58
53
|
const auth = await getAuthFromRequest(req);
|
|
59
54
|
if (!auth?.tenantId || !auth.orgId) {
|
|
60
|
-
return NextResponse.json({ error:
|
|
55
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
61
56
|
}
|
|
62
57
|
if (!isMultipartRequestWithinUploadLimit(req.headers.get("content-length"))) {
|
|
63
|
-
return NextResponse.json({ error:
|
|
58
|
+
return NextResponse.json({ error: "Attachment exceeds the maximum upload size." }, { status: 413 });
|
|
64
59
|
}
|
|
65
60
|
const contentType = req.headers.get("content-type") ?? "";
|
|
66
61
|
if (!contentType.includes("multipart/form-data")) {
|
|
67
|
-
return NextResponse.json({ error:
|
|
68
|
-
}
|
|
69
|
-
let form;
|
|
70
|
-
try {
|
|
71
|
-
form = await parseMultipartFormDataWithinUploadLimit(req);
|
|
72
|
-
} catch (error) {
|
|
73
|
-
if (isMultipartUploadLimitError(error)) {
|
|
74
|
-
return NextResponse.json({ error: t("storage_s3.errors.maxUploadSize", "Attachment exceeds the maximum upload size.") }, { status: 413 });
|
|
75
|
-
}
|
|
76
|
-
throw error;
|
|
62
|
+
return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 });
|
|
77
63
|
}
|
|
64
|
+
const form = await req.formData();
|
|
78
65
|
const file = form.get("file");
|
|
79
66
|
if (!file) {
|
|
80
|
-
return NextResponse.json({ error:
|
|
67
|
+
return NextResponse.json({ error: "file field is required" }, { status: 400 });
|
|
81
68
|
}
|
|
82
69
|
const keyOverride = form.get("key") ? String(form.get("key")) : null;
|
|
83
70
|
if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {
|
|
84
71
|
return NextResponse.json(
|
|
85
|
-
{ error:
|
|
72
|
+
{ error: "Access denied: key override is not scoped to this tenant." },
|
|
86
73
|
{ status: 403 }
|
|
87
74
|
);
|
|
88
75
|
}
|
|
89
76
|
if (hasDangerousExecutableExtension(file.name)) {
|
|
90
|
-
return NextResponse.json({ error:
|
|
77
|
+
return NextResponse.json({ error: "Executable file types are not allowed as attachments." }, { status: 400 });
|
|
91
78
|
}
|
|
92
79
|
const effectiveMaxBytes = resolveAttachmentMaxBytes();
|
|
93
80
|
if (file.size > effectiveMaxBytes) {
|
|
94
|
-
return NextResponse.json({ error:
|
|
81
|
+
return NextResponse.json({ error: "Attachment exceeds the maximum upload size." }, { status: 413 });
|
|
95
82
|
}
|
|
96
83
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
97
84
|
if (!driver) {
|
|
98
|
-
return NextResponse.json({ error:
|
|
85
|
+
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
99
86
|
}
|
|
100
87
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
101
88
|
const safeName = sanitizeFileName(file.name);
|
|
102
89
|
const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null);
|
|
103
90
|
if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {
|
|
104
|
-
return NextResponse.json({ error:
|
|
91
|
+
return NextResponse.json({ error: "Active content uploads are not allowed." }, { status: 400 });
|
|
105
92
|
}
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
let recoveryScheduler = null;
|
|
110
|
-
try {
|
|
111
|
-
attachmentQuotaService = resolve("attachmentQuotaService");
|
|
112
|
-
recoveryScheduler = resolve("storageS3QuotaRecoveryScheduler");
|
|
113
|
-
} catch {
|
|
114
|
-
}
|
|
115
|
-
let reservation = null;
|
|
116
|
-
if (attachmentQuotaService) {
|
|
117
|
-
try {
|
|
118
|
-
await reconcileTenantS3Objects({
|
|
119
|
-
driver,
|
|
120
|
-
quotaService: attachmentQuotaService,
|
|
121
|
-
tenantId: auth.tenantId,
|
|
122
|
-
organizationId: auth.orgId
|
|
123
|
-
});
|
|
124
|
-
reservation = await attachmentQuotaService.reserve({
|
|
125
|
-
tenantId: auth.tenantId,
|
|
126
|
-
organizationId: auth.orgId,
|
|
127
|
-
bytes: buffer.length,
|
|
128
|
-
source: "storage_s3_upload",
|
|
129
|
-
storageDriver: "s3",
|
|
130
|
-
storagePath: key
|
|
131
|
-
});
|
|
132
|
-
if (!recoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
|
|
133
|
-
await recoveryScheduler({
|
|
134
|
-
reservationId: reservation.id,
|
|
135
|
-
tenantId: auth.tenantId,
|
|
136
|
-
organizationId: auth.orgId
|
|
137
|
-
}, Math.max(1e3, reservation.expiresAt.getTime() - Date.now()));
|
|
138
|
-
await attachmentQuotaService.beginStorage(reservation.id, reservation.leaseToken);
|
|
139
|
-
} catch (error) {
|
|
140
|
-
if (reservation) {
|
|
141
|
-
await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
142
|
-
});
|
|
143
|
-
reservation = null;
|
|
144
|
-
}
|
|
145
|
-
const code = error?.code;
|
|
146
|
-
if (code === "quota_exceeded") {
|
|
147
|
-
return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
|
|
148
|
-
}
|
|
149
|
-
if (code === "quota_target_exists") {
|
|
150
|
-
return NextResponse.json({ error: "The target storage key already exists." }, { status: 409 });
|
|
151
|
-
}
|
|
152
|
-
return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
|
|
153
|
-
}
|
|
154
|
-
} else {
|
|
155
|
-
const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId);
|
|
156
|
-
if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
|
|
157
|
-
return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
try {
|
|
161
|
-
await driver.putObject(key, buffer, trustedMimeType);
|
|
162
|
-
if (reservation) {
|
|
163
|
-
await attachmentQuotaService.markStored(reservation.id, reservation.leaseToken);
|
|
164
|
-
await attachmentQuotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length);
|
|
165
|
-
}
|
|
166
|
-
} catch (error) {
|
|
167
|
-
if (reservation) {
|
|
168
|
-
try {
|
|
169
|
-
await driver.deleteStrict("", key);
|
|
170
|
-
await attachmentQuotaService.release(reservation.id, reservation.leaseToken);
|
|
171
|
-
} catch {
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
return NextResponse.json({ error: "Failed to persist attachment." }, { status: 500 });
|
|
93
|
+
const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId);
|
|
94
|
+
if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
|
|
95
|
+
return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
|
|
175
96
|
}
|
|
97
|
+
const key = keyOverride ?? `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
|
|
98
|
+
await driver.putObject(key, buffer, trustedMimeType);
|
|
176
99
|
return NextResponse.json({
|
|
177
100
|
key,
|
|
178
101
|
bucket: driver.getBucket(),
|