檔案儲存

預設使用 R2 儲存上傳檔案,由 Worker 透過 /uploaded 代理讀取。調整儲存設定後,在業務 API 呼叫統一的上傳函式即可。

Saavo 範本已提供統一的檔案上傳與存取功能。頭像、客服案件圖片、通知配圖,以及 OAuth 用戶端 Logo 等檔案,都透過同一套檔案服務儲存與讀取。

開發自己的產品時,通常不必直接操作 R2 或 KV。完成檔案儲存設定後,在需要上傳檔案的業務 API 中呼叫 uploadFileOrThrow 即可。如果需要直接上傳大型檔案,或為私有物件產生短效下載位址,再使用 createR2TemporaryUploadUrl 與 createR2TemporaryDownloadUrl。

既有功能

範本初始化後,即可使用下列檔案儲存功能:

功能預設值說明
預設儲存後端R2在 config/deploy.ts 的 upload.storage.default 設定
檔案讀取代理/uploadedWorker 透過此路徑讀取物件並回傳檔案
大小上限5 MiB設定路徑為 upload.maxSize
應用程式產生 R2 直接存取位址停用預設透過 Worker 的 /uploaded 讀取
MIME 允許清單圖片、常用文件、壓縮檔、音訊與影片不包含 HTML、JS、SVG
臨時上傳連結無現成 HTTP 路由createR2TemporaryUploadUrl,需要 Access Key
臨時下載連結無現成 HTTP 路由createR2TemporaryDownloadUrl,需要 Access Key

檔案儲存依賴 wrangler.jsonc 宣告的 MAIN_R2 與 MAIN_KV 執行階段繫結。一般表單上傳使用 Worker Binding,不需要將 R2 Access Key 傳入上傳 API。

雖然專案宣告了 R2_ACCOUNT_ID、R2_ACCESS_KEY_ID 與 R2_SECRET_ACCESS_KEY,src/libs/storage 中的上傳與 /uploaded 讀取並不會使用它們。頭像、客服案件圖片等既有功能,只需要 Worker Binding。只有簽發臨時上傳或下載連結時,才需要這三項驗證資訊。

先體驗檔案上傳

修改設定前,可以先透過現有功能確認儲存服務可用:

  • 登入後,在帳號中心上傳頭像。
  • 提交一筆附有圖片的客服案件。
  • 在瀏覽器開啟回傳的 /uploaded/... 位址,確認可以存取。

帳號頭像上傳

本機開發時,應用程式透過 Wrangler 的 MAIN_R2 Binding 使用 R2。遇到上傳失敗時,先確認繫結與開發伺服器記錄是否正常,再檢查業務程式碼。

設定檔案儲存

檔案上傳相關設定集中在 config/deploy.ts 的 upload 區段,可依實際儲存方式與上傳需求調整。

upload: {
    maxSize: 5 * 1024 * 1024,
    delivery: {
        proxyPath: '/uploaded',
        cache: {
            public: 'public, max-age=31536000, immutable',
        },
    },
    storage: {
        default: 'r2',
        kv: {
            defaultTTL: false,
        },
        r2: {
            publicBaseUrl: false,
        },
    },
}

上述欄位在 config/deploy.ts 修改。儲存貯體與 KV 的 ID 則在 wrangler.jsonc 修改,請參考下一節。

上線前需要處理的事項:

  • maxSize:uploadFileOrThrow 的全域上限,預設為 5 MiB,請依產品允許的檔案大小調整。客服案件與通知配圖還有各自較小的限制,調高這裡的值,不會自動放寬那些業務限制。
  • proxyPath:沒有特殊需求時,保持 /uploaded 即可。Worker 會依此值自動掛載讀取路由,不必再修改 src/pages/routes.tsx。一旦變更前綴,已儲存在資料庫與格式化文字中的舊 URL 不會自動更新。
  • publicBaseUrl:預設為 false,表示應用程式只產生 Worker 位址。只有綁定並啟用 R2 Custom Domain 後,才填入 URL。此設定不會關閉 Cloudflare 上既有的 r2.dev 或自訂網域。

storage.default 決定所有一般上傳使用 KV 還是 R2,上傳 API 無法個別覆寫。預設為 r2,既有的頭像、客服案件圖片與通知配圖,都會寫入 R2。

選擇 KV 後,可以透過 storage.kv.defaultTTL 設定預設 TTL,也可以在單次上傳時,透過 expiresIn 設定該檔案的有效期間。R2 不會自動刪除個別到期物件。傳入 expiresIn 後,Worker 會在到期後拒絕代理讀取,但物件仍保留在儲存貯體中,需要另外安排清理工作。

準備儲存服務

正式環境必須將 wrangler.jsonc 中的 MAIN_R2、MAIN_KV,換成自己帳戶中的儲存貯體與 KV 命名空間。儲存庫預填的 ID 是範例,部署到你的帳戶時可能無效,或指向他人的資源。

預設檔案上傳會寫入 MAIN_R2。MAIN_KV 仍須換成自己的資源,因為客服案件內文、記錄等內容也會使用它。只有將 storage.default 改成 kv 後,一般上傳檔案才會寫入 MAIN_KV。

如果正式環境需要讓應用程式回傳 R2 公開位址,請先綁定自訂網域並確認 HTTPS 正常,再填寫 storage.r2.publicBaseUrl。不需要產生公開位址時,保持 false 即可。如果要讓儲存貯體確實不再公開,還需要在 Cloudflare R2 關閉 r2.dev,並移除自訂網域。

本機開發透過 Worker 的 MAIN_R2 Binding 上傳即可,一般表單上傳不需要 R2 Access Key。只有簽發臨時上傳或下載連結時,才需要設定 R2_ACCOUNT_ID、R2_ACCESS_KEY_ID 與 R2_SECRET_ACCESS_KEY。

MAIN_R2 設定錯誤時,頭像、客服案件圖片與通知配圖會上傳失敗,或無法開啟。MAIN_KV 設定錯誤時,預設使用 R2 的檔案上傳不受影響,但客服案件內文、記錄等 KV 資料會出問題。

在業務功能中存取檔案

請在已完成身分驗證與授權檢查的 API 中,使用統一函式處理業務上傳:

import { resolveFetchWorkerCtx } from '@/ctx';
import { uploadFileOrThrow, purgeFileOrThrow } from '@/libs/storage';

const ctx = resolveFetchWorkerCtx(c);
const uploaded = await uploadFileOrThrow(ctx, {
    file,
    path: `user/${userId}`,
    name: 'photo.png',
});

預設回傳相對位址 /uploaded/{key}。業務功能需要絕對位址時,可以傳入 urlMode: 'absolute'。

設定 publicBaseUrl 後,永久儲存的 R2 物件會在正式環境回傳該公開位址。臨時物件必須經過 Worker 檢查有效期間,因此仍會回傳目前網站的 /uploaded/{key} 絕對位址。呼叫端不能自行啟用 R2 公開存取。

刪除物件:

await purgeFileOrThrow(ctx, {
    store: uploaded.store,
    key: uploaded.key,
});

產生臨時上傳與下載連結

uploadFileOrThrow 會先將檔案傳到 Worker,再寫入儲存服務。如果檔案較大,或物件不應透過 /uploaded 公開讀取,可以簽發短效的 R2 預先簽署 URL,讓瀏覽器或呼叫端直接讀寫儲存貯體。

這兩個函式位於 src/libs/utils/r2-presigned-url.ts:

  • createR2TemporaryUploadUrl:簽發 PUT 上傳連結。
  • createR2TemporaryDownloadUrl:簽發 GET 下載連結。

它們不是現成的 HTTP 路由,需要在你自己已完成身分驗證的 API 中呼叫,再將結果回傳給前端。連結指向 *.r2.cloudflarestorage.com,不經過 /uploaded,也不使用 publicBaseUrl。

簽發時需要 R2 S3 API 驗證資訊。請從 Worker 環境讀取,不要回傳給前端:

const credentials = {
    accountId: workerCtx.env.R2_ACCOUNT_ID,
    bucketName: workerCtx.env.R2_BUCKET_NAME,
    accessKeyId: workerCtx.env.R2_ACCESS_KEY_ID,
    secretAccessKey: workerCtx.env.R2_SECRET_ACCESS_KEY,
};

R2_BUCKET_NAME 位於 wrangler.jsonc 的 vars,其餘三項寫在 .env / Cloudflare Secret,欄位名稱與 example.vars 一致。驗證資訊缺少或格式錯誤時,簽署函式會拋出錯誤。expiresInSeconds 必須是 1~604800 之間的整數,單位為秒,也就是最短 1 秒、最長 7 天。

簽署函式不會套用 upload.maxSize 與 MIME 允許清單。簽發前,必須在自己的 API 中完成登入、授權、路徑、類型與大小檢查。key 不能以 / 開頭,也不能包含 ..。

臨時上傳連結

PUT 連結會將檔案大小、Content-Type 與 SHA-256 納入請求標頭的簽章,並附上 if-none-match: *,因此無法覆寫已存在的同名物件。sha256 必須是 64 個小寫十六進位字元。

import { createR2TemporaryUploadUrl } from '@/libs/utils/r2-presigned-url';

const upload = await createR2TemporaryUploadUrl({
    ...credentials,
    key: `user/${userId}/photo.png`,
    expiresInSeconds: 600,
    contentType: 'image/png',
    sizeBytes: fileSize,
    sha256: fileSha256Hex,
});

將 method、url 與 headers 回傳給前端。用戶端必須原樣使用回傳的請求標頭發出 PUT,請求主體就是檔案本身:

await fetch(upload.url, {
    method: upload.method,
    headers: upload.headers,
    body: file,
});

瀏覽器直接上傳時,還需要在 R2 儲存貯體設定 CORS,允許你的網站 Origin 傳送 PUT,並允許簽署時使用的請求標頭。如果由伺服器端代為發出 PUT,則不需要 CORS。

臨時下載連結

GET 連結只綁定物件 key 與到期時間,適合私有物件的短效下載。任何取得連結的人,都能在到期前讀取,因此 TTL 應盡量縮短。

import { createR2TemporaryDownloadUrl } from '@/libs/utils/r2-presigned-url';

const download = await createR2TemporaryDownloadUrl({
    ...credentials,
    key: `user/${userId}/photo.png`,
    expiresInSeconds: 60,
});

用戶端對 download.url 發出 GET 即可,不必附上額外的請求標頭。

檔案路徑禁止包含 ..。回傳給前端的應是檔案代理路徑、已設定的 R2 公開位址,或上述短效連結,不要直接提供內部 Object Key。帳戶層級的儲存驗證資訊也只能留在伺服器端,不能回傳給前端或寫入公開文件。

客服案件的圖片上傳已提供完整實作範例,可以直接參考。新增檔案上傳功能時,應沿用既有的身分驗證與權限控制,不要另外建立能略過這些檢查的通用上傳 API。

上線檢查

儲存功能會直接影響使用者內容,上線前建議至少確認:

  • MAIN_R2 / MAIN_KV 已換成自己的 Cloudflare 資源。
  • 不公開 R2 時,storage.r2.publicBaseUrl 維持 false,Cloudflare 中的 r2.dev 與自訂網域也已關閉。
  • 頭像或業務上傳可以成功,並可透過 /uploaded 或已設定的 R2 公開位址存取。
  • 超過 maxSize,或使用 HTML / JS / SVG 的檔案會被拒絕。
  • 刪除流程會確實清除物件。
  • 上傳 API 要求登入,且只能寫入目前使用者自己的路徑。
  • 若使用臨時連結,三項 R2 Secret 已設定,簽發 API 已檢查權限,回傳給前端的只有 url 與必要的請求標頭。

建議使用一般帳號實際上傳一次,不要只在本機模擬 File 物件。

常見問題

接下來

依接下來要開發的功能,可以繼續閱讀:

大多數產品完成本章後,只需要換成自己的儲存貯體。確實需要公開直接存取 R2 時,再設定獨立的檔案網域。

實際接收業務檔案時,在受保護的 API 中呼叫 uploadFileOrThrow 即可。如果需要直接上傳或私有下載,再新增臨時連結簽發 API。