iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0

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 再擋濫用

1. 問題:Vision 要圖,不代表圖要公開

把圖片丟公開網址測試很爽,但那會直接繞過 Day 7 的 uid 邊界。
連結一進 log、截圖、瀏覽器紀錄——照片就離開原本情境了。

https://ithelp.ithome.com.tw/upload/images/20260923/20121052ClpGiwOW9F.jpg
今天只解照片怎麼存、誰能碰:上傳、讀、刪。
推薦模型、App Check 正式 enforcement、Function 商業邏輯——先當附錄,不當正文主角。


2. 設計:只存 object path,不存公開 URL

物件路徑固定:

users/{uid}/meals/{yyyy-mm-dd}/{photoId}.jpg
  • uid 只來自 Auth,不接受表單自稱
  • Firestore 的 meal 只存同一條 photo_path
  • 前端用已登入的 Storage SDK 讀圖;分析端用 path 取圖
  • 不為了方便另開長效公開 URL

流程長這樣:

https://ithelp.ithome.com.tw/upload/images/20260923/20121052bHbbCmlxsj.jpg

Auth uid
  → 壓縮/檢查檔案
  → 私有 Storage 上傳
  → Vision 分析
  → Day 5 Verifier
      ACCEPT  → 寫 daily meal(含 photo_path)
      CONFIRM → 問人後再驗
      REJECT/失敗 → 手動表單(photo_path = null)

外食會記帳,不扣家裡庫存

外食照還是要寫進 meals(那是當日記錄),但不能因為辨識到雞腿,就從 pantry 扣一份——便當雞腿 ≠ 冰箱少一隻雞。

https://ithelp.ithome.com.tw/upload/images/20260923/201210526VKqSMIGCU.jpg

const pantryDelta =
  verifiedMeal.food_source === "home"
    ? buildConfirmedPantryDelta(verifiedMeal)
    : [];

eat_out/convenience_store/supermarket → pantry_delta 必須是 []。
只有 home,而且人確認真的吃掉,才有資格建立 delta。


3. 實作:開 Storage、鎖 Rules、留後門

3.1 真實踩坑:Spark 開不了 Storage,我升了 Blaze

原本以為跟 Firestore 一樣,Storage 點「開始使用」就結束。結果 Console 直接擋下來:

https://ithelp.ithome.com.tw/upload/images/20260923/20121052GugNSF2myp.png

如要使用「Storage」,請升級專案的定價方案。

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

https://ithelp.ithome.com.tw/upload/images/20260923/20121052xvfa5mA0sr.png

付費/帳單要先講清楚(很重要)

事實 人話
Blaze=綁帳單的隨用隨付 不是「一升就收固定月費」,但超過免費額度會扣款
Storage/傳輸通常有免費額度 鐵人賽這種「幾張小圖+Emulator 測試」多半是很小的 $$
但 Blaze 一開,專案裡其他帳單產品也可能開始計費 別隨手開一堆服務;設好 預算警示 比較安心
Emulator 測試不吃正式 Storage 流量 本機 npm test 不會因為測 Rules 就狂刷雲端帳單

一句話:真實性可以付錢買;日常測試仍優先 Emulator,正式 bucket 只放假圖/小檔。

建 bucket

升完後才能設定預設 bucket。我的是:

gs://dishflow-14e19.firebasestorage.app

位置選了 免付費位置 → us-east1、Standard(地區一旦定了通常改不了,選得到能用的就先建):

https://ithelp.ithome.com.tw/upload/images/20260923/20121052XKJbnYlJRb.png

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

https://ithelp.ithome.com.tw/upload/images/20260923/20121052yPCEyhWelE.png

所以接下來兩件事要分開:

  1. 本機 storage.rules+npm test → 證明私有規則對不對(已過)
  2. 正式 Console → Storage → Rules → 貼上同一份並點上方 「發布」 → 關掉測試模式

我已把本機那份貼進 Console(內容對:isOwner、只允許 jpeg/png/webp、禁止 update)。
畫面上藍條寫「有變更尚未發布」——這時要按的是上面的 發布,不是左邊 Playground 的「執行」。

https://ithelp.ithome.com.tw/upload/images/20260923/20121052lkuwlBu10G.jpg

按鈕 做什麼
上方 發布 讓正式 bucket 真的套用這份門衛 ← 按這個
左邊 執行 只是 Rules Playground 模擬一次 get/write,不會取代發布

3.1.1 Console Playground:我也實測 allow/deny

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,再按完成:

https://ithelp.ithome.com.tw/upload/images/20260923/20121052BeNmtW3ZbU.jpg

本人上傳 → 核准

  • 驗證:開
  • UID:與路徑上的 {userId} 同一個
  • 中繼資料:jpeg、小 size
  • 執行 → 綠條「模擬寫入作業已核准」

https://ithelp.ithome.com.tw/upload/images/20260923/20121052FIsE7bvK5a.png

假 uid 去寫別人的路徑 → 拒絕

路徑仍是某個真實使用者的 users/{uid}/meals/...,但驗證 UID 故意填成 iamfaker:

→ 紅條「模擬寫入作業已遭拒」——門衛認的是 token,不是你自介。

https://ithelp.ithome.com.tw/upload/images/20260923/20121052uQprhKPV8B.png

這兩張跟本機 npm test 講的是同一件事:自己的便當照可以進;別人的路徑滾出去。

3.2 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}`;
}

3.3 本機怎麼測(先測完再考慮發布)

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,≠ 正式已發布):

https://ithelp.ithome.com.tw/upload/images/20260923/20121052KjeWQUb5Kt.jpg

需要 Emulator UI 時:npm run emulators → http://127.0.0.1:4000(Storage On)。

小心得:Day 7 測 Firestore、Day 8 測 Storage,劇本幾乎同一個——本人綠燈、跨帳號紅燈、沒登入滾出去。換的只是戰場從文件變成檔案。Rules 寫短沒關係,重點是你敢不敢用測試逼它紅燈;綠燈自拍比較爽,紅燈才證明門衛有在上班。

3.4 用程式真的上傳鴨腿便當照(後台看得到)

Rules 測完若沒真傳一張,總覺得「空間開了卻沒人用」。
所以做了一個超薄上傳頁:Google 登入 → client SDK 走 Rules 上傳 → Console Storage 真的看得到檔。

架構其實沒多神秘

https://ithelp.ithome.com.tw/upload/images/20260923/20121052qSEmI20pjy.jpg

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——不用自己從零手敲每一行。
重點是核心概念要通:

  1. path 必須掛在 users/{自己的uid}/...
  2. Rules 認的是 Auth token,不是你很會開 Console
  3. 照片進 Storage;Firestore 之後只存 photo_path 字串

善用 AI 工具沒問題,別傻傻造輪子;門衛邏輯自己要懂,程式骨架可以讓 AI 幫忙生。

怎麼跑

cd material/code/day8
npm install
npm run dev

瀏覽器開 http://localhost:5173:

  1. Google 登入
  2. 按「上傳鴨腿便當照」
  3. log 出現上傳成功路徑
  4. Console → Storage → 檔案 → 同一路徑看得到圖

https://ithelp.ithome.com.tw/upload/images/20260923/20121052MDklVBxLud.png

https://ithelp.ithome.com.tw/upload/images/20260923/20121052IYo8OoIe7f.png

https://ithelp.ithome.com.tw/upload/images/20260923/20121052isElpuK0FM.png

核心程式(就這段最重要)

// 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 契約)。

3.5 手動後門:沒照片也能過一天

照片壞了、Storage 拒收、Vision 掛了、人懶得拍——都不能讓「今天沒紀錄」。

手動表單最少要:slot、label、food_source;tags 可選;photo_path 固定 null。
這不是偷懶例外,是維持連續紀錄的正式降級路徑——後面評比 C 靠的就是「天數還在」。


4. 驗證與翻車

測試 預期 實際
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

容易踩的坑:

  1. Rules 很私有,卻把 download URL 貼進公開 log/文章——等於白鎖。
  2. 圖上傳成功就當 meal 進庫——忘記還要過 Day 5 Verifier。
  3. 前端塞 Gemini key——照片管線一接 Vision 就爆。App Check/Function 之後再補;App Check 不能取代 Auth 或 Storage Rules。

5. 今日結論

  1. 正式 Storage 在 Spark 開不了——我升 Blaze 換真實性;小測多半 $$ 很小,但仍要盯帳單。
  2. bucket+私有 Rules+Playground allow/deny+本機 npm test 都過了。
  3. 再用程式真的把鴨腿便當照傳上 users/{uid}/meals/...,Console 看得到——這才叫「空間有在用」。
  4. 上傳膠水可以丟給 Gemini 幫忙寫 XDD;path 與 Rules 的核心概念自己要通,別只會按產生。
  5. 照片在 Storage;Firestore 的 meal 還是要等 Verifier——手動後門也別忘。

下一篇: 用 Stitch 把拍照、最多兩題確認與手動後門收進一個不麻煩的畫面。


上一篇
[Day 7] 路徑有 uid 還不夠:我要看到跨帳號被拒絕
下一篇
[Day 9] 三頁夠不夠把晚餐決策壓進 60 秒?Google Stitch AI 幫你把想法轉成實際畫面
系列文
DishFlow AI Agent:用 Google AI 打造 Eat-Cost Balance 的下一餐決策系統 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言