Koa.js 文件上传与图片处理实战
文件上传概述
文件上传是 Web 应用中的常见功能,Koa 生态中有丰富的中间件可以处理文件上传。本文将介绍如何实现安全的文件上传功能,并进行图片处理(压缩、裁剪、格式转换)。
依赖安装
npm install koa-router koa-body @koa/multer sharp imagemin
- koa-body:解析请求体,支持 multipart
- @koa/multer:Multer 的 Koa 适配器
- sharp:高性能图片处理库
基础文件上传实现
const Koa = require('koa');
const Router = require('koa-router');
const { koaBody } = require('koa-body');
const multer = require('@koa/multer');
const path = require('path');
const fs = require('fs');
const app = new Koa();
const router = new Router();
// 配置 Multer 存储
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, 'uploads', getDatePath());
// 确保目录存在
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, `${file.fieldname}-${uniqueSuffix}${ext}`);
}
});
// 文件过滤器
const fileFilter = (ctx, file) => {
// 允许的图片类型
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (allowedMimes.includes(file.mimetype)) {
return file.mimetype;
}
ctx.throw(400, '不支持的文件类型');
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
files: 5 // 最多5个文件
}
});
// 按日期创建子目录
function getDatePath() {
const now = new Date();
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
}
// 单文件上传
router.post('/upload/single', upload.single('file'), async (ctx) => {
const file = ctx.file;
ctx.body = {
code: 0,
data: {
filename: file.filename,
originalname: file.originalname,
size: file.size,
mimetype: file.mimetype,
path: file.path,
url: `/uploads/${getDatePath()}/${file.filename}`
}
};
});
// 多文件上传
router.post('/upload/multiple', upload.array('files', 5), async (ctx) => {
const files = ctx.files;
ctx.body = {
code: 0,
data: files.map(file => ({
filename: file.filename,
originalname: file.originalname,
size: file.size,
url: `/uploads/${getDatePath()}/${file.filename}`
}))
};
});
app.use(koaBody({ multipart: true }));
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000, () => console.log('Server started on port 3000'));
图片处理实现
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
class ImageService {
constructor() {
this.outputDir = path.join(__dirname, 'uploads', 'processed');
if (!fs.existsSync(this.outputDir)) {
fs.mkdirSync(this.outputDir, { recursive: true });
}
}
// 图片压缩
async compress(inputPath, options = {}) {
const {
quality = 80,
width,
height,
format = 'jpeg'
} = options;
const outputFilename = `compress-${Date.now()}.${format}`;
const outputPath = path.join(this.outputDir, outputFilename);
let pipeline = sharp(inputPath);
// 调整尺寸
if (width || height) {
pipeline = pipeline.resize(width, height, {
fit: 'inside',
withoutEnlargement: true
});
}
// 压缩质量
if (format === 'jpeg') {
pipeline = pipeline.jpeg({ quality });
} else if (format === 'png') {
pipeline = pipeline.png({ compressionLevel: Math.floor((100 - quality) / 10) });
} else if (format === 'webp') {
pipeline = pipeline.webp({ quality });
}
await pipeline.toFile(outputPath);
return outputFilename;
}
// 生成缩略图
async thumbnail(inputPath, size = 200) {
const outputFilename = `thumb-${Date.now()}.jpg`;
const outputPath = path.join(this.outputDir, outputFilename);
await sharp(inputPath)
.resize(size, size, {
fit: 'cover',
position: 'center' // 居中裁剪
})
.jpeg({ quality: 80 })
.toFile(outputPath);
return outputFilename;
}
// 图片裁剪
async crop(inputPath, { left, top, width, height }) {
const outputFilename = `crop-${Date.now()}.jpg`;
const outputPath = path.join(this.outputDir, outputFilename);
await sharp(inputPath)
.extract({ left: Math.floor(left), top: Math.floor(top), width: Math.floor(width), height: Math.floor(height) })
.toFile(outputPath);
return outputFilename;
}
// 格式转换
async convert(inputPath, targetFormat) {
const outputFilename = `convert-${Date.now()}.${targetFormat}`;
const outputPath = path.join(this.outputDir, outputFilename);
await sharp(inputPath)
.toFormat(targetFormat)
.toFile(outputPath);
return outputFilename;
}
// 获取图片信息
async getMeta(inputPath) {
const metadata = await sharp(inputPath).metadata();
return {
width: metadata.width,
height: metadata.height,
format: metadata.format,
size: fs.statSync(inputPath).size,
hasAlpha: metadata.hasAlpha
};
}
}
module.exports = new ImageService();
图片处理 API 接口
const Router = require('koa-router');
const upload = require('@koa/multer')({ dest: 'uploads/temp/' });
const imageService = require('./imageService');
const path = require('path');
const router = new Router();
// 上传并处理图片
router.post('/image/process', upload.single('image'), async (ctx) => {
const { action, width, height, quality, format } = ctx.request.body;
const inputPath = ctx.file.path;
try {
let result;
switch (action) {
case 'compress':
result = await imageService.compress(inputPath, {
width: width ? parseInt(width) : undefined,
height: height ? parseInt(height) : undefined,
quality: quality ? parseInt(quality) : 80,
format: format || 'jpeg'
});
break;
case 'thumbnail':
result = await imageService.thumbnail(inputPath, parseInt(width) || 200);
break;
case 'crop':
result = await imageService.crop(inputPath, {
left: parseInt(ctx.request.body.left),
top: parseInt(ctx.request.body.top),
width: parseInt(ctx.request.body.width),
height: parseInt(ctx.request.body.height)
});
break;
case 'convert':
result = await imageService.convert(inputPath, format || 'webp');
break;
default:
ctx.throw(400, '无效的处理操作');
}
ctx.body = {
code: 0,
data: {
filename: result,
url: `/uploads/processed/${result}`
}
};
} catch (err) {
ctx.throw(500, '图片处理失败: ' + err.message);
}
});
// 获取图片信息
router.get('/image/meta/:filename', async (ctx) => {
const filename = ctx.params.filename;
const inputPath = path.join(__dirname, 'uploads', 'processed', filename);
if (!fs.existsSync(inputPath)) {
ctx.throw(404, '文件不存在');
}
const meta = await imageService.getMeta(inputPath);
ctx.body = { code: 0, data: meta };
});
文件安全注意事项
| 安全措施 | 说明 |
|---|---|
| 文件类型验证 | 通过魔数(文件头)验证真实文件类型,而非仅依赖扩展名 |
| 文件名重命名 | 使用随机字符串重命名,防止路径穿越攻击 |
| 存储位置隔离 | 上传文件存储在 Web 根目录外,或使用对象存储 |
| 文件大小限制 | 防止恶意大文件导致磁盘耗尽 |
| 病毒扫描 | 对上传的可执行文件进行病毒扫描 |
总结
本文介绍了 Koa.js 中文件上传与图片处理的核心实现。在生产环境中,建议使用对象存储(如阿里云 OSS、AWS S3)存储上传的文件,并配合 CDN 加速访问,提高系统的可扩展性和访问速度。