@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.
@@ -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-Type": contentType ?? "application/octet-stream",
50
- "Content-Length": String(buffer.length)
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-Type': contentType ?? 'application/octet-stream',\n 'Content-Length': String(buffer.length),\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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",
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, contentTypeHeader);
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: contentTypeHeader
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 override")
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 const contentTypeHeader = form.get('contentType') ? String(form.get('contentType')) : file.type || undefined\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 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 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, contentTypeHeader)\n\n return NextResponse.json({\n key,\n bucket: driver.getBucket(),\n size: buffer.length,\n contentType: contentTypeHeader,\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 content-type override'),\n }),\n },\n responses: [{ status: 200, description: 'Upload result', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Missing file 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 ],\n },\n },\n}\n"],
5
- "mappings": "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",
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.4152.1.1c429e5200",
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.4152.1.1c429e5200"
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.4152.1.1c429e5200"
75
+ "@open-mercato/shared": "0.6.4-develop.4169.1.9f207b27f2"
76
76
  },
77
77
  "devDependencies": {
78
- "@open-mercato/shared": "0.6.4-develop.4152.1.1c429e5200",
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-Type': contentType ?? 'application/octet-stream',
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, contentTypeHeader)
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: contentTypeHeader,
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 override'),
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
  },