NextJS和Koa的文件上传&下载以及二进制加密&解密

2023 年 12 月 24 日 星期日(已编辑)
/ ,
39
这篇文章上次修改于 2024 年 1 月 3 日 星期三,可能部分内容已经不适用,如有疑问可询问作者。

阅读此文章之前,你可能需要首先阅读以下的文章才能更好的理解上下文。

NextJS和Koa的文件上传&下载以及二进制加密&解密

最近,我的毕设来到了对容器的文件管理的需求。而在管理文件中,最让人头大的则是文件的上传与下载了。

首先先回顾一下项目的整体架构吧,Next 提供 ssr 渲染出的前端页面&handle API 请求;基于 Koa 的 Daemon 部署在各个可以公网访问到的 VPS 上,作为容器运行的环境。Daemon 提供 Http API 供 Next 调用。目前 Daemon 会有一个固定的 token,作为与 Next 通信的对称加密的密钥。因为部署 Next 的机器肯定有 https,而 Daemon 所在的节点可能没有,所以这第二程(Next->Koa)数据是一定需要加密的,而第一程则不需要。

所以一个请求的流程是,客户在浏览器发起请求到 Next route handler,Next 鉴权无误后利用 token 加密请求到 Daemon,Daemon 解密后执行请求的操作,后返回数据给 Next(同样有加解密),然后 Next 再把请求给客户。

文件上传到 Next

首先第一步是将文件上传至 Next,前端方面我直接使用 Antd 的<Upload />组件,并通过自定义请求customRequest附带上想要操作的实例和目标路径到 params。请求体使用 FormData:

export function uploadFile(
  params: {
    id: number
    path: string
  },
  data: { file: File },
): Promise<'success'> {
  const formData = new FormData()
  formData.append('file', data.file)
  return request({
    url: '/instance/file',
    method: 'post',
    params,
    data: formData,
  })
}

在 Next 端,我使用的是 13 的 APP router,提供了解析 FormData 的方法:

export async function POST(req: NextRequest) {
    const file = (await req.formData()).get('file') as File
    const fileName = file.name
    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)
}

阅读File 的文档可知,File 其实是继承自Blob,而 Blob 的方法arrayBuffer()则会返回一个 promise,其会兑现一个包含 Blob 所有内容的二进制格式的 ArrayBuffer。

Buffer.from()则是 node 的方法,它将一个 ArrayBuffer 以 Uint8Array 方式读取出来,这时候如果我们使用console.log(buffer.toString())的话就可以在终端打印出来上传的文件的内容了(如果上传的是文本文件的话)。

加解密二进制

下一步就是把文件从 Next 加密后发去 Daemon,所以接下来就要研究如何加解密二进制了。

CryptoJS 支持加密WordArray类型的二进制数据,而这个 WordArray 其实就是存着 32-bit 数的数组,大概长这样:[0x00010203, 0x04050607]。 如何将我们的 ArrayBuffer 转为 WordArray,我参考了这篇文章所提供的方法:

const ArrayBufferToWordArray = (arrayBuffer: ArrayBuffer) => {
  const u8 = new Uint8Array(arrayBuffer, 0, arrayBuffer.byteLength)
  const len = u8.length
  const words: number[] = []
  for (let i = 0; i < len; i += 1) {
    words[i >>> 2] |= (u8[i] & 0xff) << (24 - (i % 4) * 8)
  }
  return CryptoJS.lib.WordArray.create(words, len)
}

const WordArrayToArrayBuffer = (wordArray: CryptoJS.lib.WordArray) => {
  const { words } = wordArray
  const { sigBytes } = wordArray
  const u8 = new Uint8Array(sigBytes)
  for (let i = 0; i < sigBytes; i += 1) {
    const byte = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff
    u8[i] = byte
  }
  return u8
}

那么,就可以进行一个加解密测试了:

const file = (await req.formData()).get('file') as File
const fileName = file.name
const bytes = await file.arrayBuffer()
const ArrayBufferToWordArray = (arrayBuffer: ArrayBuffer) => {
  const u8 = new Uint8Array(arrayBuffer, 0, arrayBuffer.byteLength)
  const len = u8.length
  const words: number[] = []
  for (let i = 0; i < len; i += 1) {
    words[i >>> 2] |= (u8[i] & 0xff) << (24 - (i % 4) * 8)
  }
  return CryptoJS.lib.WordArray.create(words, len)
}
const wordBuffer = ArrayBufferToWordArray(bytes)
const iv = CryptoJS.lib.WordArray.random(16)
const token =
  '预设的Token'
const encryptedData = CryptoJS.AES.encrypt(wordBuffer, token, {
  iv,
  mode: CryptoJS.mode.CBC,
  padding: CryptoJS.pad.Pkcs7,
})
console.log('en', encryptedData)
const decryptedData = CryptoJS.AES.decrypt(encryptedData, token, {
  iv,
  mode: CryptoJS.mode.CBC,
  padding: CryptoJS.pad.Pkcs7,
})
const WordArrayToArrayBuffer = (wordArray: CryptoJS.lib.WordArray) => {
  const { words } = wordArray
  const { sigBytes } = wordArray
  const u8 = new Uint8Array(sigBytes)
  for (let i = 0; i < sigBytes; i += 1) {
    const byte = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff
    u8[i] = byte
  }
  return u8
}
const decryptedArrayBuffer = WordArrayToArrayBuffer(decryptedData)
console.log('de', Buffer.from(decryptedArrayBuffer).toString())
fs.writeFileSync('./' + fileName, decryptedArrayBuffer)

经测试,上传的文本文件成功打印出解密的内容,上传的图片文件成功写入到指定位置且正常打开!二进制的加解密工作完成!

改写 axios 和 koa 中间件

上面的工作只是在 Next 本地测试了一下,要实际使用,需要改写 next 和 koa 的中间件。

对于请求,把 ArrayBuffer 放在 contentBuffer 字段里:

export async function POST(req: NextRequest) {
  const { request, instanceID, path } = await verify(req)
  const file = (await req.formData()).get('file') as File
  const fileName = file.name
  const bytes = await file.arrayBuffer()
  
  const res = await request.post('/file/upload', {
    path,
    id: instanceID,
    contentBuffer: bytes,
    fileName,
  })
  return response({ data: res })
}

然后在请求的中间件中,将contentBuffer抽离出来单独加密,再在其他内容加密完成后再塞进去。

request.interceptors.request.use((req) => {
  let encryptedBuffer: string | undefined
  const iv = Crypto.lib.WordArray.random(16)
  if (
    req.data.contentBuffer &&
    req.data.contentBuffer instanceof ArrayBuffer
  ) { // 存在待加密的二进制,加密!
    const wordBuffer = ArrayBufferToWordArray(req.data.contentBuffer)
    encryptedBuffer = Crypto.AES.encrypt(wordBuffer, token, {
      iv,
      mode: Crypto.mode.CBC,
      padding: Crypto.pad.Pkcs7,
    }).toString() // 加密,并把 CipherParams 转为文本
    delete req.data.contentBuffer // 从请求体中删去
  }
  // 进行加密其他普通文本类数据的工作……
  req.data = { data: encryptedData, iv, encryptedBuffer } //把加密后的文本和加密后的二进制放进请求体中
  return req
})

对于 Koa 的中间件,也要相应的进行处理:

app.use(async (ctx, next) => {
  if (ctx.method == 'POST') {
    try {
      // 获取请求体
      const encryptedBody = ctx.request.body as {
        data: string
        iv: CryptoJS.lib.WordArray
        encryptedBuffer?: string
      }

      let decryptedArrayBuffer: Uint8Array | undefined
      if (encryptedBody.encryptedBuffer) {
        // 解密二进制部分
        const decryptedWordArr = CryptoJS.AES.decrypt(
          encryptedBody.encryptedBuffer,
          config.token,
          {
            iv: encryptedBody.iv,
            mode: CryptoJS.mode.CBC,
            padding: CryptoJS.pad.Pkcs7,
          },
        )
        decryptedArrayBuffer = WordArrayToArrayBuffer(decryptedWordArr)
      }

      // 解密请求体
      const decryptedBodyStr = CryptoJS.AES.decrypt(
        encryptedBody.data,
        config.token,
        {
          iv: encryptedBody.iv,
          mode: CryptoJS.mode.CBC,
          padding: CryptoJS.pad.Pkcs7,
        },
      ).toString(CryptoJS.enc.Utf8)
      const decryptedBody = JSON.parse(decryptedBodyStr)
      if (decryptedBody._validate !== 114) {
        throw new Error()
      }

      // 将解密后的数据放回请求体中
      ctx.request.body = {
        ...decryptedBody,
        contentBuffer: decryptedArrayBuffer,
      }
      // 后续操作……

然后在 Koa 的相应路由中写上相应的把 buffer 写入对应地址的文件中,文件的上传就大功告成啦!
文件的下载也同理,这个就之后在写啦。

可以优化的地方

至此,虽然文件的上传可以正确无误的达到目标了,但是却代价巨大。为何?因为我们在 Next 直接把文件从客户端拿了上来并存在了 buffer 里,然后把这个 buffer 整个又发给了 koa,在测试中我们还发生了“413request entity too large”的问题,通过改 bodyParser 的配置解决了。但是 buffer 这种整存整取的方式对内存的消耗实在是太大了!试想,如果用户要把一个 10G 的压缩包(比如说地图很大的服务端)传上来,传输过程中 Next 和 Daemon 都要占用 10G 的内存!!!!这显然是代价巨大难以接受的。

所以如果我完成毕设的基本功能后如果还有时间精力,需要对这个点进行一下优化,改为使用浏览器到 Next 相同的“流式传输”来节省内存。那么流式传输的对称加密也是在优化中要攻克的一个知识点了,想必届时我还会再写一篇文章罢!


240103 更新:上面这个流式传输的 Feature 搞定了,详见:https://yuzi.dev/posts/frontend/stream-upload

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...