@open-mercato/storage-s3 0.6.4-develop.4152.1.1c429e5200 → 0.6.4-develop.4169.1.9f207b27f2
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/get/storage-providers/s3/download.js +13 -2
- package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js.map +2 -2
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +48 -6
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
- package/package.json +4 -4
- package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +13 -1
- package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +59 -5
|
@@ -2,6 +2,11 @@ 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 {
|
|
6
|
+
buildAttachmentContentDisposition,
|
|
7
|
+
canRenderInlineAttachment,
|
|
8
|
+
sanitizeUploadedFileName
|
|
9
|
+
} from "@open-mercato/core/modules/attachments/lib/security";
|
|
5
10
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
6
11
|
const metadata = {
|
|
7
12
|
path: "/storage-providers/s3/download",
|
|
@@ -43,11 +48,17 @@ async function GET(req) {
|
|
|
43
48
|
} catch {
|
|
44
49
|
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
|
45
50
|
}
|
|
51
|
+
const fileName = sanitizeUploadedFileName(key.split("/").pop() || "download");
|
|
52
|
+
const renderInline = canRenderInlineAttachment(contentType);
|
|
53
|
+
const responseContentType = renderInline ? contentType ?? "application/octet-stream" : "application/octet-stream";
|
|
46
54
|
return new NextResponse(new Uint8Array(buffer), {
|
|
47
55
|
status: 200,
|
|
48
56
|
headers: {
|
|
49
|
-
"Content-
|
|
50
|
-
"Content-
|
|
57
|
+
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
58
|
+
"Content-Disposition": buildAttachmentContentDisposition(fileName, renderInline ? "inline" : "attachment"),
|
|
59
|
+
"Content-Length": String(buffer.length),
|
|
60
|
+
"Content-Type": responseContentType,
|
|
61
|
+
"X-Content-Type-Options": "nosniff"
|
|
51
62
|
}
|
|
52
63
|
});
|
|
53
64
|
}
|
|
@@ -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 { 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)\n}\n\nfunction isKeyScoped(key: string, orgId: string, tenantId: string): boolean {\n const parts = key.split('/')\n return parts.length >= 3 && parts[1] === `org_${orgId}` && parts[2] === `tenant_${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 (!isKeyScoped(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 return new NextResponse(new Uint8Array(buffer), {\n status: 200,\n headers: {\n 'Content-
|
|
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,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,KAAK;AAClC;AAEA,SAAS,YAAY,KAAa,OAAe,UAA2B;AAC1E,QAAM,QAAQ,IAAI,MAAM,GAAG;AAC3B,SAAO,MAAM,UAAU,KAAK,MAAM,CAAC,MAAM,OAAO,KAAK,MAAM,MAAM,CAAC,MAAM,UAAU,QAAQ;AAC5F;AAEA,eAAsB,IAAI,KAAc;AACtC,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,MAAM,IAAI,IAAI,IAAI,GAAG,EAAE,aAAa,IAAI,KAAK;AACnD,MAAI,CAAC,KAAK;AACR,WAAO,aAAa,KAAK,EAAE,OAAO,8BAA8B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACpF;AAEA,MAAI,CAAC,YAAY,KAAK,KAAK,OAAO,KAAK,QAAQ,GAAG;AAChD,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,MAAI;AACJ,MAAI;AACJ,MAAI;AACF,UAAM,SAAS,MAAM,OAAO,KAAK,IAAI,GAAG;AACxC,aAAS,OAAO;AAChB,kBAAc,OAAO;AAAA,EACvB,QAAQ;AACN,WAAO,aAAa,KAAK,EAAE,OAAO,iBAAiB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACvE;AAEA,SAAO,IAAI,aAAa,IAAI,WAAW,MAAM,GAAG;AAAA,IAC9C,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,
|
|
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 { 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)\n}\n\nfunction isKeyScoped(key: string, orgId: string, tenantId: string): boolean {\n const parts = key.split('/')\n return parts.length >= 3 && parts[1] === `org_${orgId}` && parts[2] === `tenant_${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 (!isKeyScoped(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,6 +2,16 @@ 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 {
|
|
6
|
+
detectAttachmentMimeType,
|
|
7
|
+
hasDangerousExecutableExtension,
|
|
8
|
+
isActiveContentAttachment
|
|
9
|
+
} from "@open-mercato/core/modules/attachments/lib/security";
|
|
10
|
+
import {
|
|
11
|
+
isMultipartRequestWithinUploadLimit,
|
|
12
|
+
resolveAttachmentMaxBytes,
|
|
13
|
+
willExceedAttachmentTenantQuota
|
|
14
|
+
} from "@open-mercato/core/modules/attachments/lib/upload-limits";
|
|
5
15
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
6
16
|
import { randomUUID } from "crypto";
|
|
7
17
|
const metadata = {
|
|
@@ -28,11 +38,28 @@ async function resolveDriver(tenantId, orgId) {
|
|
|
28
38
|
if (!creds) return null;
|
|
29
39
|
return new S3StorageDriver(creds);
|
|
30
40
|
}
|
|
41
|
+
async function readTenantStorageUsageBytes(driver, tenantId, orgId) {
|
|
42
|
+
let totalBytes = 0;
|
|
43
|
+
let continuationToken;
|
|
44
|
+
do {
|
|
45
|
+
const page = await driver.listObjects("", 1e3, continuationToken);
|
|
46
|
+
for (const file of page.files) {
|
|
47
|
+
if (isKeyScoped(file.key, orgId, tenantId)) {
|
|
48
|
+
totalBytes += file.size;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
continuationToken = page.truncated ? page.nextContinuationToken : void 0;
|
|
52
|
+
} while (continuationToken);
|
|
53
|
+
return totalBytes;
|
|
54
|
+
}
|
|
31
55
|
async function POST(req) {
|
|
32
56
|
const auth = await getAuthFromRequest(req);
|
|
33
57
|
if (!auth?.tenantId || !auth.orgId) {
|
|
34
58
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
35
59
|
}
|
|
60
|
+
if (!isMultipartRequestWithinUploadLimit(req.headers.get("content-length"))) {
|
|
61
|
+
return NextResponse.json({ error: "Attachment exceeds the maximum upload size." }, { status: 413 });
|
|
62
|
+
}
|
|
36
63
|
const contentType = req.headers.get("content-type") ?? "";
|
|
37
64
|
if (!contentType.includes("multipart/form-data")) {
|
|
38
65
|
return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 });
|
|
@@ -43,26 +70,40 @@ async function POST(req) {
|
|
|
43
70
|
return NextResponse.json({ error: "file field is required" }, { status: 400 });
|
|
44
71
|
}
|
|
45
72
|
const keyOverride = form.get("key") ? String(form.get("key")) : null;
|
|
46
|
-
const contentTypeHeader = form.get("contentType") ? String(form.get("contentType")) : file.type || void 0;
|
|
47
73
|
if (keyOverride !== null && !isKeyScoped(keyOverride, auth.orgId, auth.tenantId)) {
|
|
48
74
|
return NextResponse.json(
|
|
49
75
|
{ error: "Access denied: key override is not scoped to this tenant." },
|
|
50
76
|
{ status: 403 }
|
|
51
77
|
);
|
|
52
78
|
}
|
|
79
|
+
if (hasDangerousExecutableExtension(file.name)) {
|
|
80
|
+
return NextResponse.json({ error: "Executable file types are not allowed as attachments." }, { status: 400 });
|
|
81
|
+
}
|
|
82
|
+
const effectiveMaxBytes = resolveAttachmentMaxBytes();
|
|
83
|
+
if (file.size > effectiveMaxBytes) {
|
|
84
|
+
return NextResponse.json({ error: "Attachment exceeds the maximum upload size." }, { status: 413 });
|
|
85
|
+
}
|
|
53
86
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
54
87
|
if (!driver) {
|
|
55
88
|
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
56
89
|
}
|
|
57
90
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
58
91
|
const safeName = sanitizeFileName(file.name);
|
|
92
|
+
const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null);
|
|
93
|
+
if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {
|
|
94
|
+
return NextResponse.json({ error: "Active content uploads are not allowed." }, { status: 400 });
|
|
95
|
+
}
|
|
96
|
+
const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId);
|
|
97
|
+
if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
|
|
98
|
+
return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
|
|
99
|
+
}
|
|
59
100
|
const key = keyOverride ?? `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
|
|
60
|
-
await driver.putObject(key, buffer,
|
|
101
|
+
await driver.putObject(key, buffer, trustedMimeType);
|
|
61
102
|
return NextResponse.json({
|
|
62
103
|
key,
|
|
63
104
|
bucket: driver.getBucket(),
|
|
64
105
|
size: buffer.length,
|
|
65
|
-
contentType:
|
|
106
|
+
contentType: trustedMimeType
|
|
66
107
|
});
|
|
67
108
|
}
|
|
68
109
|
var upload_default = POST;
|
|
@@ -78,14 +119,15 @@ const openApi = {
|
|
|
78
119
|
schema: z.object({
|
|
79
120
|
file: z.any().describe("File to upload"),
|
|
80
121
|
key: z.string().optional().describe("Optional S3 key override (must be scoped to org/tenant)"),
|
|
81
|
-
contentType: z.string().optional().describe("Optional content-type
|
|
122
|
+
contentType: z.string().optional().describe("Optional client-provided content-type hint; the server derives the trusted MIME type.")
|
|
82
123
|
})
|
|
83
124
|
},
|
|
84
125
|
responses: [{ status: 200, description: "Upload result", schema: responseSchema }],
|
|
85
126
|
errors: [
|
|
86
|
-
{ status: 400, description: "Missing file or S3 not configured", schema: z.object({ error: z.string() }) },
|
|
127
|
+
{ status: 400, description: "Missing file, blocked file type, or S3 not configured", schema: z.object({ error: z.string() }) },
|
|
87
128
|
{ status: 401, description: "Unauthorized", schema: z.object({ error: z.string() }) },
|
|
88
|
-
{ status: 403, description: "Key override not scoped to this tenant", schema: z.object({ error: z.string() }) }
|
|
129
|
+
{ status: 403, description: "Key override not scoped to this tenant", schema: z.object({ error: z.string() }) },
|
|
130
|
+
{ status: 413, description: "Upload too large or tenant storage quota exceeded", schema: z.object({ error: z.string() }) }
|
|
89
131
|
]
|
|
90
132
|
}
|
|
91
133
|
}
|
|
@@ -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 { 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\nfunction isKeyScoped(key: string, orgId: string, tenantId: string): boolean {\n const parts = key.split('/')\n return parts.length >= 3 && parts[1] === `org_${orgId}` && parts[2] === `tenant_${tenantId}`\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)\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 contentType = req.headers.get('content-type') ?? ''\n if (!contentType.includes('multipart/form-data')) {\n return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })\n }\n\n const form = await req.formData()\n const file = form.get('file') as File | null\n if (!file) {\n return NextResponse.json({ error: 'file field is required' }, { status: 400 })\n }\n\n const keyOverride = form.get('key') ? String(form.get('key')) : null\n
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,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,SAAS,YAAY,KAAa,OAAe,UAA2B;AAC1E,QAAM,QAAQ,IAAI,MAAM,GAAG;AAC3B,SAAO,MAAM,UAAU,KAAK,MAAM,CAAC,MAAM,OAAO,KAAK,MAAM,MAAM,CAAC,MAAM,UAAU,QAAQ;AAC5F;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,KAAK;AAClC;AAEA,eAAsB,KAAK,KAAc;AACvC,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,cAAc,IAAI,QAAQ,IAAI,cAAc,KAAK;AACvD,MAAI,CAAC,YAAY,SAAS,qBAAqB,GAAG;AAChD,WAAO,aAAa,KAAK,EAAE,OAAO,+BAA+B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrF;AAEA,QAAM,OAAO,MAAM,IAAI,SAAS;AAChC,QAAM,OAAO,KAAK,IAAI,MAAM;AAC5B,MAAI,CAAC,MAAM;AACT,WAAO,aAAa,KAAK,EAAE,OAAO,yBAAyB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC/E;AAEA,QAAM,cAAc,KAAK,IAAI,KAAK,IAAI,OAAO,KAAK,IAAI,KAAK,CAAC,IAAI;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport {\n isMultipartRequestWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { 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\nfunction isKeyScoped(key: string, orgId: string, tenantId: string): boolean {\n const parts = key.split('/')\n return parts.length >= 3 && parts[1] === `org_${orgId}` && parts[2] === `tenant_${tenantId}`\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)\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 (isKeyScoped(file.key, orgId, tenantId)) {\n totalBytes += file.size\n }\n }\n continuationToken = page.truncated ? page.nextContinuationToken : undefined\n } while (continuationToken)\n\n return totalBytes\n}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {\n return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })\n }\n\n const contentType = req.headers.get('content-type') ?? ''\n if (!contentType.includes('multipart/form-data')) {\n return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })\n }\n\n const form = await req.formData()\n const file = form.get('file') as File | null\n if (!file) {\n return NextResponse.json({ error: 'file field is required' }, { status: 400 })\n }\n\n const keyOverride = form.get('key') ? String(form.get('key')) : null\n\n if (keyOverride !== null && !isKeyScoped(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": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/storage-s3",
|
|
3
|
-
"version": "0.6.4-develop.
|
|
3
|
+
"version": "0.6.4-develop.4169.1.9f207b27f2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -68,14 +68,14 @@
|
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@aws-sdk/client-s3": "^3.1053.0",
|
|
70
70
|
"@aws-sdk/s3-request-presigner": "^3.1053.0",
|
|
71
|
-
"@open-mercato/core": "0.6.4-develop.
|
|
71
|
+
"@open-mercato/core": "0.6.4-develop.4169.1.9f207b27f2"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"@mikro-orm/postgresql": "^6.6.10",
|
|
75
|
-
"@open-mercato/shared": "0.6.4-develop.
|
|
75
|
+
"@open-mercato/shared": "0.6.4-develop.4169.1.9f207b27f2"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@open-mercato/shared": "0.6.4-develop.
|
|
78
|
+
"@open-mercato/shared": "0.6.4-develop.4169.1.9f207b27f2",
|
|
79
79
|
"@types/jest": "^30.0.0",
|
|
80
80
|
"esbuild": "^0.28.0",
|
|
81
81
|
"glob": "^13.0.6",
|
|
@@ -3,6 +3,11 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import {
|
|
7
|
+
buildAttachmentContentDisposition,
|
|
8
|
+
canRenderInlineAttachment,
|
|
9
|
+
sanitizeUploadedFileName,
|
|
10
|
+
} from '@open-mercato/core/modules/attachments/lib/security'
|
|
6
11
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
7
12
|
|
|
8
13
|
export const metadata = {
|
|
@@ -55,11 +60,18 @@ export async function GET(req: Request) {
|
|
|
55
60
|
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
|
56
61
|
}
|
|
57
62
|
|
|
63
|
+
const fileName = sanitizeUploadedFileName(key.split('/').pop() || 'download')
|
|
64
|
+
const renderInline = canRenderInlineAttachment(contentType)
|
|
65
|
+
const responseContentType = renderInline ? (contentType ?? 'application/octet-stream') : 'application/octet-stream'
|
|
66
|
+
|
|
58
67
|
return new NextResponse(new Uint8Array(buffer), {
|
|
59
68
|
status: 200,
|
|
60
69
|
headers: {
|
|
61
|
-
'Content-
|
|
70
|
+
'Content-Security-Policy': "default-src 'none'; sandbox",
|
|
71
|
+
'Content-Disposition': buildAttachmentContentDisposition(fileName, renderInline ? 'inline' : 'attachment'),
|
|
62
72
|
'Content-Length': String(buffer.length),
|
|
73
|
+
'Content-Type': responseContentType,
|
|
74
|
+
'X-Content-Type-Options': 'nosniff',
|
|
63
75
|
},
|
|
64
76
|
})
|
|
65
77
|
}
|
|
@@ -3,6 +3,16 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import {
|
|
7
|
+
detectAttachmentMimeType,
|
|
8
|
+
hasDangerousExecutableExtension,
|
|
9
|
+
isActiveContentAttachment,
|
|
10
|
+
} from '@open-mercato/core/modules/attachments/lib/security'
|
|
11
|
+
import {
|
|
12
|
+
isMultipartRequestWithinUploadLimit,
|
|
13
|
+
resolveAttachmentMaxBytes,
|
|
14
|
+
willExceedAttachmentTenantQuota,
|
|
15
|
+
} from '@open-mercato/core/modules/attachments/lib/upload-limits'
|
|
6
16
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
7
17
|
import { randomUUID } from 'crypto'
|
|
8
18
|
|
|
@@ -40,12 +50,37 @@ async function resolveDriver(
|
|
|
40
50
|
return new S3StorageDriver(creds)
|
|
41
51
|
}
|
|
42
52
|
|
|
53
|
+
async function readTenantStorageUsageBytes(
|
|
54
|
+
driver: S3StorageDriver,
|
|
55
|
+
tenantId: string,
|
|
56
|
+
orgId: string,
|
|
57
|
+
): Promise<number> {
|
|
58
|
+
let totalBytes = 0
|
|
59
|
+
let continuationToken: string | undefined
|
|
60
|
+
|
|
61
|
+
do {
|
|
62
|
+
const page = await driver.listObjects('', 1000, continuationToken)
|
|
63
|
+
for (const file of page.files) {
|
|
64
|
+
if (isKeyScoped(file.key, orgId, tenantId)) {
|
|
65
|
+
totalBytes += file.size
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
continuationToken = page.truncated ? page.nextContinuationToken : undefined
|
|
69
|
+
} while (continuationToken)
|
|
70
|
+
|
|
71
|
+
return totalBytes
|
|
72
|
+
}
|
|
73
|
+
|
|
43
74
|
export async function POST(req: Request) {
|
|
44
75
|
const auth = await getAuthFromRequest(req)
|
|
45
76
|
if (!auth?.tenantId || !auth.orgId) {
|
|
46
77
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
47
78
|
}
|
|
48
79
|
|
|
80
|
+
if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {
|
|
81
|
+
return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })
|
|
82
|
+
}
|
|
83
|
+
|
|
49
84
|
const contentType = req.headers.get('content-type') ?? ''
|
|
50
85
|
if (!contentType.includes('multipart/form-data')) {
|
|
51
86
|
return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })
|
|
@@ -58,7 +93,6 @@ export async function POST(req: Request) {
|
|
|
58
93
|
}
|
|
59
94
|
|
|
60
95
|
const keyOverride = form.get('key') ? String(form.get('key')) : null
|
|
61
|
-
const contentTypeHeader = form.get('contentType') ? String(form.get('contentType')) : file.type || undefined
|
|
62
96
|
|
|
63
97
|
if (keyOverride !== null && !isKeyScoped(keyOverride, auth.orgId, auth.tenantId)) {
|
|
64
98
|
return NextResponse.json(
|
|
@@ -67,6 +101,15 @@ export async function POST(req: Request) {
|
|
|
67
101
|
)
|
|
68
102
|
}
|
|
69
103
|
|
|
104
|
+
if (hasDangerousExecutableExtension(file.name)) {
|
|
105
|
+
return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const effectiveMaxBytes = resolveAttachmentMaxBytes()
|
|
109
|
+
if (file.size > effectiveMaxBytes) {
|
|
110
|
+
return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })
|
|
111
|
+
}
|
|
112
|
+
|
|
70
113
|
const driver = await resolveDriver(auth.tenantId, auth.orgId)
|
|
71
114
|
if (!driver) {
|
|
72
115
|
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
@@ -74,17 +117,27 @@ export async function POST(req: Request) {
|
|
|
74
117
|
|
|
75
118
|
const buffer = Buffer.from(await file.arrayBuffer())
|
|
76
119
|
const safeName = sanitizeFileName(file.name)
|
|
120
|
+
const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)
|
|
121
|
+
if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {
|
|
122
|
+
return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)
|
|
126
|
+
if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
|
|
127
|
+
return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })
|
|
128
|
+
}
|
|
129
|
+
|
|
77
130
|
const key =
|
|
78
131
|
keyOverride ??
|
|
79
132
|
`uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`
|
|
80
133
|
|
|
81
|
-
await driver.putObject(key, buffer,
|
|
134
|
+
await driver.putObject(key, buffer, trustedMimeType)
|
|
82
135
|
|
|
83
136
|
return NextResponse.json({
|
|
84
137
|
key,
|
|
85
138
|
bucket: driver.getBucket(),
|
|
86
139
|
size: buffer.length,
|
|
87
|
-
contentType:
|
|
140
|
+
contentType: trustedMimeType,
|
|
88
141
|
})
|
|
89
142
|
}
|
|
90
143
|
|
|
@@ -102,14 +155,15 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
102
155
|
schema: z.object({
|
|
103
156
|
file: z.any().describe('File to upload'),
|
|
104
157
|
key: z.string().optional().describe('Optional S3 key override (must be scoped to org/tenant)'),
|
|
105
|
-
contentType: z.string().optional().describe('Optional content-type
|
|
158
|
+
contentType: z.string().optional().describe('Optional client-provided content-type hint; the server derives the trusted MIME type.'),
|
|
106
159
|
}),
|
|
107
160
|
},
|
|
108
161
|
responses: [{ status: 200, description: 'Upload result', schema: responseSchema }],
|
|
109
162
|
errors: [
|
|
110
|
-
{ status: 400, description: 'Missing file or S3 not configured', schema: z.object({ error: z.string() }) },
|
|
163
|
+
{ status: 400, description: 'Missing file, blocked file type, or S3 not configured', schema: z.object({ error: z.string() }) },
|
|
111
164
|
{ status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },
|
|
112
165
|
{ status: 403, description: 'Key override not scoped to this tenant', schema: z.object({ error: z.string() }) },
|
|
166
|
+
{ status: 413, description: 'Upload too large or tenant storage quota exceeded', schema: z.object({ error: z.string() }) },
|
|
113
167
|
],
|
|
114
168
|
},
|
|
115
169
|
},
|