Raise non-integrated upload limit

This commit is contained in:
WuYongJie 2026-05-07 14:07:21 +08:00
parent 028faaf0af
commit fa27245c66
5 changed files with 125 additions and 20 deletions

View File

@ -1,18 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
# Verifies that the public production entrypoint allows product image uploads
# below 10MB to reach the app instead of being blocked by the outer nginx layer.
# Verifies that the public production entrypoint allows large intake uploads
# to reach the app instead of being blocked by the outer proxy/nginx layer.
#
# Usage:
# bash scripts/check-prod-upload-limit.sh
# bash scripts/check-prod-upload-limit.sh https://cp.jiansun.vip 9
# bash scripts/check-prod-upload-limit.sh https://cp.jiansun.vip 200
#
# Expected result for an unauthenticated request is usually 401 from the app.
# A 413 response means the public nginx/body-size limit is still too low.
# A 413 response means the public proxy/body-size limit is still too low.
BASE_URL="${1:-https://cp.jiansun.vip}"
TEST_SIZE_MB="${2:-9}"
TEST_SIZE_MB="${2:-200}"
TEST_PRODUCT_ID="upload-limit-check"
UPLOAD_URL="${BASE_URL%/}/api/intake/non-integrated/${TEST_PRODUCT_ID}/upload"
@ -43,8 +43,8 @@ echo "HTTP status: $status"
case "$status" in
413)
echo "FAILED: public nginx still blocks uploads below 10MB."
echo "Set client_max_body_size 10m; on the cp.jiansun.vip nginx server block, then reload nginx."
echo "FAILED: public proxy still blocks ${TEST_SIZE_MB}MB uploads."
echo "Set client_max_body_size 500m; or higher on the cp.jiansun.vip proxy/nginx entry, then reload it."
exit 1
;;
401|403|400|404|405|422|200|201)

View File

@ -13,12 +13,13 @@ import type {
NonIntegratedProductRecord,
NonIntegratedUploadFile,
} from '@/app/intake/non-integrated/nonIntegratedStorage'
import { ErrorCode } from '@/server/core/errors'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { intakeNonIntegratedProductService } from '@/server/services'
const MAX_NON_INTEGRATED_UPLOAD_SIZE_MB = 10
const MAX_NON_INTEGRATED_UPLOAD_SIZE = MAX_NON_INTEGRATED_UPLOAD_SIZE_MB * 1024 * 1024
import {
NON_INTEGRATED_UPLOAD_MAX_BYTES,
buildNonIntegratedFileTooLargeMessage,
} from '../../uploadLimit'
type RouteContext = {
params: Promise<{ id: string }>
@ -90,15 +91,25 @@ export async function POST(request: NextRequest, context: RouteContext) {
return jsonError(ErrorCode.VALIDATION_FAILED, 'The file cannot be empty.', 400, requestId)
}
if (file.size > MAX_NON_INTEGRATED_UPLOAD_SIZE) {
if (file.size > NON_INTEGRATED_UPLOAD_MAX_BYTES) {
return jsonError(
ErrorCode.FILE_TOO_LARGE,
`The file is too large. Files over ${MAX_NON_INTEGRATED_UPLOAD_SIZE_MB}MB are not supported.`,
buildNonIntegratedFileTooLargeMessage(),
400,
requestId,
)
}
try {
await intakeNonIntegratedProductService.assertCanWrite(productId, user)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, requestId)
}
throw error
}
const uploadDir = getNonIntegratedAssetDirectory()
if (!existsSync(uploadDir)) {
await mkdir(uploadDir, { recursive: true })

View File

@ -70,8 +70,16 @@ function createFormData(file: File) {
return formData
}
function createUploadFile(bytes: Uint8Array, fileName: string, type: string) {
function createUploadFile(
bytes: Uint8Array,
fileName: string,
type: string,
sizeOverride?: number,
) {
const file = new File([bytes], fileName, { type })
if (typeof sizeOverride === 'number') {
Object.defineProperty(file, 'size', { value: sizeOverride })
}
Object.defineProperty(file, 'arrayBuffer', {
value: async () =>
bytes.buffer.slice(
@ -188,4 +196,56 @@ describe('POST /api/intake/non-integrated/upload', () => {
)
expect(downloadedBytes.toString('utf8')).toBe('patent-certificate')
})
it('allows files below the configured intake upload limit', async () => {
const modelBytes = new TextEncoder().encode('model-placeholder')
const uploadResponse = await POST({
formData: async () =>
createFormData(
createUploadFile(
modelBytes,
'product-model.glb',
'model/gltf-binary',
499 * 1024 * 1024,
),
),
} as any)
expect(uploadResponse.status).toBe(201)
await expect(uploadResponse.json()).resolves.toEqual(
expect.objectContaining({
success: true,
data: expect.objectContaining({
fileName: 'product-model.glb',
size: 499 * 1024 * 1024,
storageKey: expect.stringMatching(/\.glb$/),
}),
}),
)
})
it('rejects files above the configured intake upload limit', async () => {
const uploadResponse = await POST({
formData: async () =>
createFormData(
createUploadFile(
new TextEncoder().encode('too-large'),
'too-large.glb',
'model/gltf-binary',
501 * 1024 * 1024,
),
),
} as any)
expect(uploadResponse.status).toBe(400)
await expect(uploadResponse.json()).resolves.toEqual(
expect.objectContaining({
success: false,
error: expect.objectContaining({
code: 'FILE_003',
message: '文件过大,最大允许 500MB。',
}),
}),
)
})
})

View File

@ -5,12 +5,13 @@ import { randomUUID } from 'crypto'
import { NextRequest } from 'next/server'
import { buildNonIntegratedAssetUrl } from '@/app/intake/non-integrated/nonIntegratedAssetUrl'
import { getNonIntegratedAssetDirectory } from '@/app/intake/non-integrated/nonIntegratedAssetServer'
import { ErrorCode } from '@/server/core/errors'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { intakeNonIntegratedProductService } from '@/server/services'
const MAX_NON_INTEGRATED_UPLOAD_SIZE_MB = 10
const MAX_NON_INTEGRATED_UPLOAD_SIZE = MAX_NON_INTEGRATED_UPLOAD_SIZE_MB * 1024 * 1024
import {
NON_INTEGRATED_UPLOAD_MAX_BYTES,
buildNonIntegratedFileTooLargeMessage,
} from '../uploadLimit'
function sanitizeExtension(fileName: string) {
const extension = path.extname(fileName).trim().toLowerCase()
@ -56,14 +57,27 @@ export async function POST(request: NextRequest) {
return jsonError(ErrorCode.VALIDATION_FAILED, '文件不能为空', 400, requestId)
}
if (file.size > MAX_NON_INTEGRATED_UPLOAD_SIZE) {
if (file.size > NON_INTEGRATED_UPLOAD_MAX_BYTES) {
return jsonError(
ErrorCode.FILE_TOO_LARGE,
`The file is too large. Files over ${MAX_NON_INTEGRATED_UPLOAD_SIZE_MB}MB are not supported.`,
buildNonIntegratedFileTooLargeMessage(),
400,
requestId,
)
}
if (productId) {
try {
await intakeNonIntegratedProductService.assertCanWrite(productId, user)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, requestId)
}
throw error
}
}
const uploadDir = getNonIntegratedAssetDirectory()
if (!existsSync(uploadDir)) {
await mkdir(uploadDir, { recursive: true })

View File

@ -0,0 +1,20 @@
const DEFAULT_NON_INTEGRATED_UPLOAD_MAX_MB = 500
function readConfiguredUploadLimitMb() {
const rawValue = process.env.NON_INTEGRATED_UPLOAD_MAX_MB
const parsedValue = Number(rawValue)
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
return DEFAULT_NON_INTEGRATED_UPLOAD_MAX_MB
}
return Math.floor(parsedValue)
}
export const NON_INTEGRATED_UPLOAD_MAX_MB = readConfiguredUploadLimitMb()
export const NON_INTEGRATED_UPLOAD_MAX_BYTES =
NON_INTEGRATED_UPLOAD_MAX_MB * 1024 * 1024
export function buildNonIntegratedFileTooLargeMessage() {
return `文件过大,最大允许 ${NON_INTEGRATED_UPLOAD_MAX_MB}MB。`
}