Browse Source

feat(upload): enhance file upload handling with improved MIME type validation and file size limits

auth
npmrun 2 weeks ago
parent
commit
b7d080b176
  1. 116
      server/api/file/upload.post.ts
  2. 9
      server/constants/upload.ts
  3. 17
      server/utils/handler.ts

116
server/api/file/upload.post.ts

@ -2,85 +2,69 @@ import multer from 'multer';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { callNodeListener } from 'h3'; import { callNodeListener } from 'h3';
import {
RELATIVE_ASSETS_DIR,
POST_MEDIA_PUBLIC_PREFIX,
ALLOWED_MIME_TYPES,
MAX_FILE_SIZE,
MAX_FILE_COUNT,
} from '#server/constants/upload';
import { getContextUser } from '#server/utils/context';
// 类型定义
interface IFile { interface IFile {
name: string; name: string;
url: string; // 前端可直接访问的 URL url: string;
path: string; // 服务器存储路径
mimeType: string; mimeType: string;
size: number; size: number;
} }
export default defineWrappedResponseHandler({ auth: 'optional' }, async (event) => { export default defineWrappedResponseHandler({ auth: 'required' }, async (event) => {
try { const user = getContextUser(event)!;
// 存储目录
const uploadDir = path.join(process.cwd(), 'public/assets');
// 自动创建目录 const uploadDir = path.resolve(RELATIVE_ASSETS_DIR);
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 配置存储 if (!fs.existsSync(uploadDir)) {
const storage = multer.diskStorage({ fs.mkdirSync(uploadDir, { recursive: true });
destination: uploadDir, }
filename: (req, file, cb) => {
// 生成唯一文件名:时间戳 + 原始文件名(安全处理)
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
// 获取文件后缀
const ext = path.extname(file.originalname).toLowerCase();
// 干净的文件名(只保留字母数字)
const baseName = path.basename(file.originalname, ext).replace(/[^a-z0-9]/gi, '-');
// 最终文件名(带后缀)
const filename = `${uniqueSuffix}-${baseName}${ext}`;
cb(null, filename);
},
});
// 上传配置 const storage = multer.diskStorage({
const upload = multer({ destination: uploadDir,
storage, filename: (_req, file, cb) => {
limits: { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
fileSize: 5 * 1024 * 1024, // 5MB 限制 const ext = path.extname(file.originalname).toLowerCase();
}, const baseName = path.basename(file.originalname, ext).replace(/[^a-z0-9]/gi, '-');
fileFilter: (req, file, cb) => { cb(null, `${uniqueSuffix}-${baseName}${ext}`);
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']; },
if (allowedTypes.includes(file.mimetype)) { });
cb(null, true);
} else {
cb(new Error('只支持 PNG/JPG/WebP 格式图片'));
}
},
});
// 执行上传(最多 10 个文件) const upload = multer({
await callNodeListener( storage,
// @ts-expect-error Nuxt 类型兼容 limits: { fileSize: MAX_FILE_SIZE },
upload.array('file', 10), fileFilter: (_req, file, cb) => {
event.node.req, if ((ALLOWED_MIME_TYPES as readonly string[]).includes(file.mimetype)) {
event.node.res cb(null, true);
); } else {
cb(new Error('只支持 PNG/JPG/WebP 格式图片'));
}
},
});
// 获取上传后的文件 await callNodeListener(
// @ts-expect-error // @ts-expect-error Nuxt 类型兼容
const uploadedFiles = event.node.req.files || []; upload.array('file', MAX_FILE_COUNT),
event.node.req,
event.node.res,
);
// 格式化返回数据 // @ts-expect-error
const result: IFile[] = uploadedFiles.map((file: any) => ({ const uploadedFiles = event.node.req.files || [];
name: file.originalname,
url: `/public/assets/${file.filename}`, // ✅ 前端可直接访问
mimeType: file.mimetype,
size: file.size,
}));
return result; const result: IFile[] = uploadedFiles.map((file: any) => ({
name: file.originalname,
url: `${POST_MEDIA_PUBLIC_PREFIX}${file.filename}`,
mimeType: file.mimetype,
size: file.size,
}));
} catch (err: any) { return result;
console.error('上传失败:', err);
return createError({
statusCode: 400,
statusMessage: err.message || '上传失败',
});
}
}); });

9
server/constants/upload.ts

@ -54,3 +54,12 @@ export const RELATIVE_ASSETS_DIR = path.join(STATIC_DIR, UPLOAD_SUBDIR);
/** 与 `media` 返回及静态路径一致,无前导 host */ /** 与 `media` 返回及静态路径一致,无前导 host */
export const POST_MEDIA_PUBLIC_PREFIX = `${STATIC_PUBLIC_PREFIX}/${UPLOAD_SUBDIR}/`; export const POST_MEDIA_PUBLIC_PREFIX = `${STATIC_PUBLIC_PREFIX}/${UPLOAD_SUBDIR}/`;
/** 允许的上传文件 MIME 类型 */
export const ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'] as const;
/** 单文件最大大小(字节) */
export const MAX_FILE_SIZE = 5 * 1024 * 1024;
/** 单次上传最大文件数 */
export const MAX_FILE_COUNT = 10;

17
server/utils/handler.ts

@ -1,10 +1,12 @@
import log4js from "logger"; import log4js from "logger";
import { getUserFromEvent } from "#server/utils/jwt"; import { getUserFromEvent } from "#server/utils/jwt";
import { getCurrentUser } from "#server/service/auth"; import { getCurrentUser } from "#server/service/auth";
import { setContextUser } from "#server/utils/context"; import { setContextUser, getContextUser } from "#server/utils/context";
interface IConfig { interface IConfig {
auth?: 'required' | 'public' | 'optional'; auth?: 'required' | 'public' | 'optional';
/** 允许的角色列表,不指定则不校验角色 */
role?: string | string[];
} }
const defaultConfig: IConfig = { const defaultConfig: IConfig = {
@ -42,6 +44,19 @@ export const defineWrappedResponseHandler = <T extends EventHandlerRequest, D>(
} }
// ---- end auth guard ---- // ---- end auth guard ----
// ---- role guard ----
if (config.role) {
const user = getContextUser(event);
if (!user) {
return R.error("未登录", null);
}
const allowedRoles = Array.isArray(config.role) ? config.role : [config.role];
if (!allowedRoles.includes(user.role)) {
return R.error("无权限", null);
}
}
// ---- end role guard ----
const response = await handler(event) const response = await handler(event)
return response return response
}) })

Loading…
Cancel
Save