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