fix: clarify upload errors and raise upload limits

This commit is contained in:
john
2026-07-04 00:17:58 +08:00
parent 702871a98c
commit 1ac9ad0277
8 changed files with 122 additions and 26 deletions
+4 -3
View File
@@ -6,6 +6,7 @@ import test from 'node:test';
import sharp from 'sharp';
import { assetInternals, createAssetService, validateUploadRequest } from './mindspace-assets.mjs';
import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES } from './user-image-normalize.mjs';
function createMockPool(state) {
return {
@@ -240,7 +241,7 @@ test('validateUploadRequest rejects traversal filenames', () => {
);
});
test('validateUploadRequest allows 5MB uploads by default', () => {
test('validateUploadRequest allows 30MB document uploads by default', () => {
assert.doesNotThrow(() =>
validateUploadRequest({
filename: 'handbook.pdf',
@@ -261,14 +262,14 @@ test('validateUploadRequest keeps image uploads below the image-specific limit',
assert.doesNotThrow(() =>
validateUploadRequest({
filename: 'cover.jpg',
sizeBytes: 4 * 1024 * 1024,
sizeBytes: DEFAULT_IMAGE_UPLOAD_MAX_BYTES,
}),
);
assert.throws(
() =>
validateUploadRequest({
filename: 'cover.jpg',
sizeBytes: 4 * 1024 * 1024 + 1,
sizeBytes: DEFAULT_IMAGE_UPLOAD_MAX_BYTES + 1,
}),
(error) => error.code === 'file_too_large',
);
+1 -1
View File
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
import { loadMindSpaceConfig } from './mindspace-config.mjs';
export const DEFAULT_SPACE_QUOTA_BYTES = 5 * 1024 * 1024;
export const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
export const DEFAULT_MAX_FILE_BYTES = 30 * 1024 * 1024;
export const SYSTEM_CATEGORIES = Object.freeze([
{
+26 -1
View File
@@ -148,6 +148,7 @@ import {
} from './mindspace-chat-docx-package.mjs';
import { scanContent } from './mindspace-content-scan.mjs';
import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs';
import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES } from './user-image-normalize.mjs';
import { createRechargeService } from './billing-recharge.mjs';
import { createSubscriptionService, createPlanCatalogService, ensurePlanCatalogSchema, PLAN_CATALOG } from './billing-subscription.mjs';
import {
@@ -268,7 +269,7 @@ const jsonUnlessMultipart = (req, res, next) => {
};
const rawUploadBody = express.raw({
type: 'application/octet-stream',
limit: mindSpaceServerRuntime.maxFileBytes,
limit: Math.max(mindSpaceServerRuntime.maxFileBytes, DEFAULT_IMAGE_UPLOAD_MAX_BYTES),
});
const wikiAuth = createWikiAuth(path.join(resolveMindSpacePublishRoot(__dirname), 'wiki-db'));
@@ -2188,6 +2189,28 @@ function mindSpaceError(res, req, error) {
return sendError(res, req, status, code, message, error?.details);
}
function isRequestBodyTooLarge(error) {
return (
error?.type === 'entity.too.large' ||
error?.status === 413 ||
error?.statusCode === 413
);
}
function apiRequestBodyError(error, req, res, next) {
if (!isRequestBodyTooLarge(error)) return next(error);
const isMindSpaceUpload = req.path.startsWith('/mindspace/v1/uploads/');
return sendError(
res,
req,
413,
isMindSpaceUpload ? 'file_too_large' : 'request_body_too_large',
isMindSpaceUpload
? '上传文件超过单文件大小限制,请压缩后重试'
: '请求内容过大,请缩小后重试',
);
}
function ensureMindSpaceEnabled(res, req, { upload = false, agent = false } = {}) {
try {
assertMindSpaceRoute(msFlags, upload ? 'upload' : agent ? 'agent' : undefined);
@@ -4573,6 +4596,8 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
});
});
api.use(apiRequestBodyError);
api.use(async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
+81 -12
View File
@@ -139,6 +139,70 @@ export class ApiError extends Error {
}
}
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes < 0) return '0B';
if (bytes >= 1024 * 1024) {
const mb = bytes / 1024 / 1024;
return `${Number.isInteger(mb) ? mb : mb.toFixed(1)}MB`;
}
if (bytes >= 1024) {
const kb = bytes / 1024;
return `${Number.isInteger(kb) ? kb : kb.toFixed(1)}KB`;
}
return `${bytes}B`;
}
function readNumberDetail(details: ApiError['details'], key: string) {
if (!details || typeof details !== 'object') return null;
const value = (details as Record<string, unknown>)[key];
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function isImageUploadFile(file: File) {
if (file.type.startsWith('image/')) return true;
return /\.(png|jpe?g|webp|gif)$/i.test(file.name);
}
function normalizeMindSpaceUploadError(error: unknown, file: File): Error {
if (!(error instanceof ApiError)) {
return error instanceof Error ? error : new Error('上传失败,请重试');
}
const imageMax = formatBytes(CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES);
if (error.code === 'quota_exceeded' || error.status === 429) {
const requiredBytes = readNumberDetail(error.details, 'requiredBytes');
const availableBytes = readNumberDetail(error.details, 'availableBytes');
const detail =
requiredBytes !== null && availableBytes !== null
? `当前剩余 ${formatBytes(availableBytes)},本次需要 ${formatBytes(requiredBytes)}`
: '';
return new ApiError(
error.status,
`剩余空间不足,${detail}请减少图片数量或压缩后重试。`,
error.code,
error.details,
);
}
if (error.code === 'file_too_large' || error.status === 413) {
const message = isImageUploadFile(file)
? `图片文件过大,单张图片不能超过 ${imageMax},请压缩后重试。`
: `文件过大,请压缩到单文件上限以内后重试。`;
return new ApiError(error.status, message, error.code, error.details);
}
if (/MindSpace\s*服务异常/.test(error.message) || error.code === 'internal_error') {
return new ApiError(
error.status,
`上传失败,请减少图片数量或压缩图片后重试;单张图片上限 ${imageMax}`,
error.code,
error.details,
);
}
return error;
}
function sanitizeUserFacingErrorMessage(message: string) {
const normalized = String(message ?? '').trim();
if (!normalized) return normalized;
@@ -722,17 +786,22 @@ export async function uploadMindSpaceAsset(
);
}
const created = await apiFetch<{ data: MindSpaceUpload }>('/mindspace/v1/uploads', {
method: 'POST',
body: JSON.stringify({
category_id: categoryId,
filename: file.name,
size_bytes: file.size,
declared_mime_type: file.type || null,
...(options.sessionId ? { session_id: options.sessionId } : {}),
...(options.messageId ? { message_id: options.messageId } : {}),
}),
});
let created: { data: MindSpaceUpload };
try {
created = await apiFetch<{ data: MindSpaceUpload }>('/mindspace/v1/uploads', {
method: 'POST',
body: JSON.stringify({
category_id: categoryId,
filename: file.name,
size_bytes: file.size,
declared_mime_type: file.type || null,
...(options.sessionId ? { session_id: options.sessionId } : {}),
...(options.messageId ? { message_id: options.messageId } : {}),
}),
});
} catch (error) {
throw normalizeMindSpaceUploadError(error, file);
}
try {
await uploadFileContent(created.data.uploadUrl, file, options.onProgress);
@@ -745,7 +814,7 @@ export async function uploadMindSpaceAsset(
await apiFetch(`/mindspace/v1/uploads/${created.data.id}`, {
method: 'DELETE',
}).catch(() => {});
throw error;
throw normalizeMindSpaceUploadError(error, file);
}
}
+5 -4
View File
@@ -91,7 +91,7 @@ function sortPagesByCreatedAt(pages: MindSpacePage[]) {
});
}
const IMAGE_PAGE_SIZE = 10;
const DEFAULT_MAX_UPLOAD_FILE_BYTES = 5 * 1024 * 1024;
const DEFAULT_MAX_UPLOAD_FILE_BYTES = 30 * 1024 * 1024;
const UPLOAD_FILE_EXTENSIONS = [
'.doc',
'.docx',
@@ -361,7 +361,7 @@ function validateSelectedUploadFile(file: File, maxBytes: number) {
return `暂不支持 ${extension || '无扩展名'} 文件,请选择支持的资料格式。`;
}
const effectiveMaxBytes = isUploadImageFile(file)
? Math.min(maxBytes, MINDSPACE_IMAGE_UPLOAD_MAX_BYTES)
? MINDSPACE_IMAGE_UPLOAD_MAX_BYTES
: maxBytes;
if (file.size > effectiveMaxBytes) {
return `文件不能超过 ${formatBytes(effectiveMaxBytes)},当前为 ${formatBytes(file.size)}`;
@@ -2118,7 +2118,8 @@ export function MindSpaceView({
</>
)}
{formatBytes(space.quota.availableBytes)} ·{' '}
{formatBytes(space.quota.maxFileBytes)} · {' '}
{formatBytes(space.quota.maxFileBytes)} · {' '}
{formatBytes(MINDSPACE_IMAGE_UPLOAD_MAX_BYTES)} · {' '}
{space.quota.publicPageUsed}/{space.quota.publicPageLimit} · AI{' '}
{space.quota.aiDailyUsed}/{space.quota.aiDailyLimit}
</p>
@@ -3089,7 +3090,7 @@ export function MindSpaceView({
</div>
<p className="mindspace-upload-dialog-desc">
{UPLOAD_FILE_TYPE_LABEL} {formatBytes(maxUploadFileBytes)}{' '}
{formatBytes(Math.min(maxUploadFileBytes, MINDSPACE_IMAGE_UPLOAD_MAX_BYTES))}
{formatBytes(MINDSPACE_IMAGE_UPLOAD_MAX_BYTES)}
</p>
<input
type="file"
+1 -1
View File
@@ -25,7 +25,7 @@ export const PREVIEW_SPACE: MindSpace = {
usedBytes: 1.2 * 1024 * 1024,
reservedBytes: 0,
availableBytes: 3.8 * 1024 * 1024,
maxFileBytes: 5 * 1024 * 1024,
maxFileBytes: 30 * 1024 * 1024,
publicPageLimit: 3,
publicPageUsed: 1,
aiDailyLimit: 20,
+3 -3
View File
@@ -1,8 +1,8 @@
export const CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES = 4 * 1024 * 1024;
export const CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES = 10 * 1024 * 1024;
export const CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES = 1.5 * 1024 * 1024;
export const MINDSPACE_IMAGE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024;
export const MINDSPACE_IMAGE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
export const CHAT_IMAGE_UPLOAD_MAX_COUNT = 10;
export const CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
export const CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES = 30 * 1024 * 1024;
export const CHAT_IMAGE_MAX_SIDE = 1920;
const MAX_PIXELS = 2_500_000;
const ACCEPTED_IMAGE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
+1 -1
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import sharp from 'sharp';
export const DEFAULT_IMAGE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024;
export const DEFAULT_IMAGE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
export const DEFAULT_IMAGE_MASTER_MAX_BYTES = 6 * 1024 * 1024;
export const DEFAULT_IMAGE_MASTER_MAX_SIDE = 2560;
export const DEFAULT_IMAGE_INPUT_MAX_PIXELS = 40_000_000;