Part 3|第 9/30 篇
今日要做的事: 開 Cloud Storage、寫私有路徑與 Storage Rules,並留一條「沒照片也能記餐」的手動後門。
今天要解決的目的: App 可以存照給 Vision 用,但外人拿不到檔;分析掛了,今天的紀錄也不能斷。
餐點照看起來普通,連著拍幾天其實很能洩漏作息、消費習慣,甚至家裡長什麼樣。
我不想為了讓 Vision 讀圖,就把檔案變成「誰拿到網址都能看」的公開資源。
Day 7 才剛把門衛裝上 Firestore——照片若走公開連結,等於側門沒鎖。XDD
官方同一套觀念:Firebase 安全性規則 也管 Cloud Storage。今天把規則套到檔案上。
| 項目 | 內容 |
|---|---|
| 產出 | 私有 object path、storage.rules、上傳流程、手動後門契約 |
| 工具 | Cloud Storage for Firebase、Auth、Storage Emulator |
| 不使用 | 公開 bucket、長效公開 download URL、前端塞 Gemini API key |
| 完成條件 | A 能上下自己的圖;A 讀不到 B;失敗可改手動記餐 |
本機程式:material/code/day8
為什麼不能公開網址
→ 路徑只存 users/{uid}/meals/{date}/…
→ Storage Rules(本人+圖檔限制)
→ Emulator 測 allow/deny
→ 手動後門:photo_path = null 也能寫 meal
→ (之後)App Check/Function 再擋濫用
把圖片丟公開網址測試很爽,但那會直接繞過 Day 7 的 uid 邊界。
連結一進 log、截圖、瀏覽器紀錄——照片就離開原本情境了。

今天只解照片怎麼存、誰能碰:上傳、讀、刪。
推薦模型、App Check 正式 enforcement、Function 商業邏輯——先當附錄,不當正文主角。
物件路徑固定:
users/{uid}/meals/{yyyy-mm-dd}/{photoId}.jpg
uid 只來自 Auth,不接受表單自稱photo_path
流程長這樣:

Auth uid
→ 壓縮/檢查檔案
→ 私有 Storage 上傳
→ Vision 分析
→ Day 5 Verifier
ACCEPT → 寫 daily meal(含 photo_path)
CONFIRM → 問人後再驗
REJECT/失敗 → 手動表單(photo_path = null)
外食照還是要寫進 meals(那是當日記錄),但不能因為辨識到雞腿,就從 pantry 扣一份——便當雞腿 ≠ 冰箱少一隻雞。

const pantryDelta =
verifiedMeal.food_source === "home"
? buildConfirmedPantryDelta(verifiedMeal)
: [];
eat_out/convenience_store/supermarket → pantry_delta 必須是 []。
只有 home,而且人確認真的吃掉,才有資格建立 delta。
原本以為跟 Firestore 一樣,Storage 點「開始使用」就結束。結果 Console 直接擋下來:

如要使用「Storage」,請升級專案的定價方案。
為了讓鐵人賽路徑更接近正式產品,我把專案升到 Blaze(隨用隨付)。
(Auth 那邊 Google 登入還在,使用者列表也看得到——Day 6 的身份門沒掉。)

| 事實 | 人話 |
|---|---|
| Blaze=綁帳單的隨用隨付 | 不是「一升就收固定月費」,但超過免費額度會扣款 |
| Storage/傳輸通常有免費額度 | 鐵人賽這種「幾張小圖+Emulator 測試」多半是很小的 $$ |
| 但 Blaze 一開,專案裡其他帳單產品也可能開始計費 | 別隨手開一堆服務;設好 預算警示 比較安心 |
| Emulator 測試不吃正式 Storage 流量 | 本機 npm test 不會因為測 Rules 就狂刷雲端帳單 |
一句話:真實性可以付錢買;日常測試仍優先 Emulator,正式 bucket 只放假圖/小檔。
升完後才能設定預設 bucket。我的是:
gs://dishflow-14e19.firebasestorage.app
位置選了 免付費位置 → us-east1、Standard(地區一旦定了通常改不了,選得到能用的就先建):

建立當下 Console 會問安全性規則。我先用 測試模式 把 bucket 建起來(30 天內誰拿到參照 theoretically 都能動——黃條寫得很兇):

所以接下來兩件事要分開:
storage.rules+npm test → 證明私有規則對不對(已過)我已把本機那份貼進 Console(內容對:isOwner、只允許 jpeg/png/webp、禁止 update)。
畫面上藍條寫「有變更尚未發布」——這時要按的是上面的 發布,不是左邊 Playground 的「執行」。

| 按鈕 | 做什麼 |
|---|---|
| 上方 發布 | 讓正式 bucket 真的套用這份門衛 ← 按這個 |
| 左邊 執行 | 只是 Rules Playground 模擬一次 get/write,不會取代發布 |
Playground 不會上傳真照片,只是假裝「有人要 create 這個路徑」。
位置欄位只要填後半段,例如:
users/{uid}/meals/2026-09-23/test.jpg
(前面的 /b/dishflow-14e19.firebasestorage.app/o/ Console 已幫你帶好。)
模擬類型選 create 時,規則會檢查 request.resource.size 與 contentType。
若沒開「建立中繼資料」,會直接報:
Property size is undefined on object
解法:點 建立中繼資料,填例如 size=1234、contentType=image/jpeg,再按完成:

{userId} 同一個

路徑仍是某個真實使用者的 users/{uid}/meals/...,但驗證 UID 故意填成 iamfaker:
→ 紅條「模擬寫入作業已遭拒」——門衛認的是 token,不是你自介。

這兩張跟本機 npm test 講的是同一件事:自己的便當照可以進;別人的路徑滾出去。
storage.rules(門衛)——已放在 material/code/day8本機檔案已經建好(不是空專案):material/code/day8/storage.rules。
內容如下——之後正式環境也該貼同一份:
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
function isOwner(userId) {
return request.auth != null && request.auth.uid == userId;
}
function isAllowedMealImage() {
return request.resource.size < 10 * 1024 * 1024
&& request.resource.contentType.matches('image/(jpeg|png|webp)');
}
match /users/{userId}/meals/{date}/{fileName} {
allow read: if isOwner(userId);
allow create: if isOwner(userId) && isAllowedMealImage();
allow update: if false;
allow delete: if isOwner(userId);
}
}
}
人話:本人才能讀/建/刪;只收小於 10MB 的 JPEG/PNG/WebP;v1 禁止覆寫(想換圖就刪了再傳)。
client 組 path 時再擋一次:
function mealPhotoPath(uid, date, photoId, ext) {
if (uid !== auth.currentUser?.uid) throw new Error("UID_MISMATCH");
return `users/${uid}/meals/${date}/${photoId}.${ext}`;
}
material/code/day8/
storage.rules ← 已建立
firebase.json
tests/storage.rules.test.js
一次跑完:
cd material/code/day8
npm install # 第一次才需要
npm test # = Storage Emulator + 五案 allow/deny
對——rules 檔先在本機、再 npm test;測過再決定要不要把同一份貼上正式 Console 發布。
(npm test 用 Emulator,不依賴你有沒有升 Blaze;Blaze 是為了「正式 bucket 真實性」。)
測的是同一套「別偷看我庫存/別偷看我便當照」心智:
| 案例 | 預期 |
|---|---|
| A 上傳/讀自己的 JPEG | allow |
| A 讀 B 的 object | deny |
| 未登入讀 | deny |
上傳 text/plain 冒充圖 |
deny |
本機跑完——五案全綠(Emulator,≠ 正式已發布):

需要 Emulator UI 時:npm run emulators → http://127.0.0.1:4000(Storage On)。
小心得:Day 7 測 Firestore、Day 8 測 Storage,劇本幾乎同一個——本人綠燈、跨帳號紅燈、沒登入滾出去。換的只是戰場從文件變成檔案。Rules 寫短沒關係,重點是你敢不敢用測試逼它紅燈;綠燈自拍比較爽,紅燈才證明門衛有在上班。
Rules 測完若沒真傳一張,總覺得「空間開了卻沒人用」。
所以做了一個超薄上傳頁:Google 登入 → client SDK 走 Rules 上傳 → Console Storage 真的看得到檔。

index.html + main.js → 按鈕、登入、log
↓
Firebase Auth → 拿到真正的 uid
↓
uploadMealPhoto.js → 組 path + uploadBytes
↓
Cloud Storage → users/{uid}/meals/{date}/xxx.jpg
↑
storage.rules → isOwner + 只准 jpeg/png/webp
坦白講:這種「登入+上傳」的膠水程式,丟給 Gemini 寫真的很輕鬆 XDD——不用自己從零手敲每一行。
重點是核心概念要通:
users/{自己的uid}/...
photo_path 字串善用 AI 工具沒問題,別傻傻造輪子;門衛邏輯自己要懂,程式骨架可以讓 AI 幫忙生。
cd material/code/day8
npm install
npm run dev
瀏覽器開 http://localhost:5173:



// material/code/day8/src/uploadMealPhoto.js
import { ref, uploadBytes } from "firebase/storage";
import { auth, storage } from "./firebase.js";
export async function uploadMealPhoto(file, date = today()) {
const user = auth.currentUser;
if (!user) throw new Error("AUTH_REQUIRED");
const ext = extFromFile(file);
const photoId = `bento-duck-${Date.now()}`;
const path = `users/${user.uid}/meals/${date}/${photoId}.${ext}`;
await uploadBytes(ref(storage, path), file, {
contentType: file.type || "image/jpeg"
});
return { path, uid: user.uid, date };
}
main.js 負責:登入狀態、按按鈕、fetch('/bento-duck.jpg') 轉成 File、呼叫上面這支、把路徑打到 log。
其餘 Firebase 初始化跟 Day 6 同一套 .env——又是「不要重複造輪子」的一集。
注意:這裡只上傳到 Storage。Firestore 的 meal/photo_path 要等 Verifier ACCEPT 再寫——今天先證明「私有路徑真的進得去、後台看得到」。
只有 Verifier ACCEPT 才寫 meal。欄位範本:
{
"slot": "lunch",
"label": "待實際辨識後填入",
"food_source": "eat_out",
"source": "photo",
"photo_path": "users/{uid}/meals/{date}/{photoId}.jpg",
"tags": [],
"verifier_decision": "ACCEPT",
"needs_confirmation": false,
"pantry_delta": []
}
needs_confirmation 仍由 decision 推導(Day 5 契約)。
照片壞了、Storage 拒收、Vision 掛了、人懶得拍——都不能讓「今天沒紀錄」。
手動表單最少要:slot、label、food_source;tags 可選;photo_path 固定 null。
這不是偷懶例外,是維持連續紀錄的正式降級路徑——後面評比 C 靠的就是「天數還在」。
| 測試 | 預期 | 實際 |
|---|---|---|
| uid-A 上傳/讀自己的 JPEG | allow | ✓(本機 npm test) |
| uid-A 讀 uid-B object | deny | ✓ |
| 未登入讀 object | deny | ✓ |
| 上傳非 image | deny | ✓ |
| Playground:本人 create+jpeg 中繼資料 | allow | ✓ |
Playground:假 uid(如 iamfaker) |
deny | ✓ |
food_source=eat_out |
pantry_delta=[] |
契約寫死(程式層) |
| 照片失敗 → 手動 | meal 可存、photo_path=null |
契約已定;畫面留給 Stitch |
容易踩的坑:
npm test 都過了。users/{uid}/meals/...,Console 看得到——這才叫「空間有在用」。下一篇: 用 Stitch 把拍照、最多兩題確認與手動後門收進一個不麻煩的畫面。