KOA技术分享

专注 Koa.js 框架的编程知识分享

Koa.js 文件上传与图片处理实战

文件上传概述

文件上传是 Web 应用中的常见功能,Koa 生态中有丰富的中间件可以处理文件上传。本文将介绍如何实现安全的文件上传功能,并进行图片处理(压缩、裁剪、格式转换)。

依赖安装

npm install koa-router koa-body @koa/multer sharp imagemin

基础文件上传实现

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 加速访问,提高系统的可扩展性和访问速度。

← 下一篇:Koa 项目单元测试与接口测试实战