[Next.js][App router]使用Route Handler處理檔案上傳(Firebase hosting/Cloud functions)

一個會員網站提供上傳個人圖檔是一個很基本的應用。現在如AI解析及互動上也會需要用到圖檔、聲音,甚至是影片來提供服務,所以上傳檔案是一件稀鬆平常的事情。

當然,firebase中,可以透過使用Firebase storage的前端套件來快速的完成這件事情,不需要額外思考檔案上傳背後的問題。然而,當你沒有使用Firebase Auth來作會員登入系統,你馬上就面臨資料隱私的兩難問題。

你的Firebase storage存取規則無法檢驗存取的是否為用戶,且無法依照會員角色來區分存取權限。直到最後你只能妥協,將storage存取權限完全開放,使得上傳功能與用戶資料完全裸奔在網際網路上。在App router中透過Route Handler,我們可以管控會員是否有上傳與存取的權利。

但是Firebase hosting部屬Next.js應用是使用Cloud function作為後端處理,過去node.js常用的一些上傳檔案的套件,如express-fileupload、multer以及formidable,都會失敗。

這裡參考了GCP的文件,該文件中有提到該怎麼在cloud function處理,使用的是busboy這個套件,只是在Next.js中有幾個小問題,這裡將他們修整成一個範例,使用Route Handler處理檔案上傳。


安裝busboy套件

npm install busboy

pnpm add busboy


安裝type

npm install -D @types/busboy 

pnpm add -D @types/busboy


寫一個上傳表單

這裡使用/api/storage作為上傳檔案的API,使用POST方法並將檔案multipart/form-data表單傳送

export default function UploadForm() {
return (
<form
method='post'
action='/api/storage'
encType='multipart/form-data'
acceptCharset='utf-8'
>
<input
type="file"
name="file"
accept="image/png, image/jpeg, image/gif"
/>
<button type="submit">上傳</button>
</form>
);
}


寫一個Route Handler

由於雲端空間都會提供各自的上傳檔案套件,這裡就先縮略成uploadSteam()這個async函數,接受3個引數: 1.檔名 2.檔案串流 3.MIME類型,去來操作雲端上傳套件。這裡得程式碼則專注在處理multipart/form-data表單的問題。

src/app/api/storage/route.ts程式碼如下

import { type NextRequest, NextResponse } from 'next/server';
import Busboy, { type FileInfo } from 'busboy';
import type { Readable } from 'node:stream';

// 處理form encType為"multipart/form-data"的檔案上傳
export async function POST(req: NextRequest) {
try {
const contentType =
req.headers.get('content-type') ||
req.headers.get('Content-Type');

const buf = Buffer.from(await req.arrayBuffer());

const fields: {[key: string]: string;} = {};
const files: any = [];

const busboy = Busboy({
headers: {
"content-type": contentType || undefined, // busboy只需要這個
},
defParamCharset: 'utf8', // 用以解決中文檔名亂碼的問題
});

await new Promise((resolve, reject) => {
busboy
.on('finish', async () => {
const results = await Promise.all(files);
resolve({ counts: results.length });
})
.on('error', (e: any) => reject(e))
.on('field', (field: string, val: string) => {
console.log(`欄位 ${field}=${val}`);
fields[field] = val;
})
.on('file', (field: string, stream: Readable, info: FileInfo) => {
console.log(`檔案 ${field}, ${JSON.stringify(info, null, 2)}`);
// 若不想處理檔案內容, 請呼叫官方建議的 stream.resume(); 略過處理;
// 否則, busboy不會繼續跑下去(=卡住)
files.push(uploadSteam(
info.filename,
stream,
info.mimeType
));
})
.end(buf);
});

return NextResponse.json({ status: 'success' });
}
catch (error: any) {
return NextResponse.json({ status: 'error', message: error.message });
}
}


從上面的Route Handler程式碼中,可以看到三點

  1. 提取request的content type並設定給Busboy的headers。
  2. 使用req.arrayBuffer()取得,並轉換成Busboy可接受的Buffer類別。
  3. 設定Busboy的defParamCharset屬性,以解決中文檔名亂碼的問題。


使用Busboy的處理重點

  1. 欄位
    當field事件發生時,我們將欄位名稱與它的內容放置到fields變數中。
  2. 檔案
    當file事件發生時,呼叫uploadStream()這個async函數並將Promise放置到files變數;
    最後當busboy處理完所有表單內容,觸發finish事件時,等待所有的Promise處理完成。
  3. 錯誤
    當error事件發生時,呼叫Promise的reject回呼函數(Callback function)。


參考資料


留言