原文地址:https://yuzi.dev/posts/frontend/next-koa-file-upload-and-encrypt
最近,我的毕设来到了对容器的文件管理的需求。而在管理文件中,最让人头大的则是文件的上传与下载了。
首先先回顾一下项目的整体架构吧,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相同的“流式传输”来节省内存。那么流式传输的对称加密也是在优化中要攻克的一个知识点了,想必届时我还会再写一篇文章罢!