iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Vibe Coding

《我與 AI 的奇幻漂流:30 天,把「能跑」變成「能上線」》系列 第 19 篇

【Day 19|食人島】收藏功能實作與 RLS:登入了,就能看別人的資料嗎?

  • 分享至 

  • xImage
  •  

昨天實作完登入功能,網站終於可以知道你是誰了。

但知道你是誰不代表你可以做任何事情。登入之後,我能不能看到別人的收藏?如果我把請求裡的使用者 id 換成別人的,會不會就可以刪掉別人的資料?

在電影裡,Pi 漂到一座小島。島上有淡水、有植物、有成群的狐獴,看起來什麼都能隨手拿。直到他在一顆果實裡剝出一顆牙齒,才明白這座島有它自己的規則。

資料庫也是這樣。畫面上只顯示「我的收藏」,不代表資料庫真的只會給你你的收藏。必須將真正的規則寫在資料庫裡,才能確保看不到別人的資料。


一、知道你是誰,不等於能做什麼

這兩件事常常被混在一起:

回答的問題 誰負責
身分驗證(Day 18) 你是誰? Supabase Auth
權限(今天) 你可以碰哪些資料? 資料庫的規則

最直覺的寫法,是在前端查詢時加一個條件:「只拿 user_id 等於我的資料」。

但我們已經知道,只要知道 Supabase 的 Data API 網址和公開金鑰,任何人都可以繞過網站,直接發請求, 前端的條件,別人拿掉就沒了。

公開金鑰本來就不是用來保密的。真正重要的是:就算有人拿到它,資料庫也不能因此把不該給的資料交出去。

畫面只顯示自己的收藏,是功能;就算繞過畫面,也拿不到別人的收藏,是權限。


二、先定義:收藏到底是什麼?

動手之前,Codex 先列了十點規格給我確認。它特別標出最需要我回答的是這一題:

目前的收藏,要代表「我擁有」,還是單純「我喜歡/想收藏」?

它讀完專案後發現,介面文字其實混在一起:有的地方寫「我的實體收藏架」,有的地方只是一顆愛心。後來我選「我喜歡/想收藏」。接著 Codex 提議把資料表設計得更有彈性,多一個狀態欄位,可以同時支援「願望清單」和「已擁有」。

最後我的決定:

  • 收藏 = 喜歡、想先存起來。「已擁有」之後如果要做,再另開一張表或添加欄位
  • 介面名稱維持「收藏」,「我的實體收藏架」改成「我的收藏」
  • 資料表叫 user_favorites,不放用不到的欄位

AI 會替未來預留空間,但預留的東西也要有人維護。

看到 AI 加了一個欄位,可以先問它:這個欄位,這次會用到嗎?

資料庫怎麼設計,不只是技術問題。需要先確認,在這個產品裡,一筆資料到底代表什麼?


三、收藏表長什麼樣子

這次的資料庫變更,Codex 寫在一個 migration 檔案裡。

每筆收藏代表一個專輯版本;如果這個版本還分不同款式,就另外記下是哪一款。先看資料表本身:

create table public.user_favorites (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users (id) on delete cascade,
  album_id text not null,
  version_id text not null,
  design_id text,
  created_at timestamptz not null default now(),
  -- 省略兩個外鍵:版本必須屬於這張專輯、款式必須屬於這個版本
  constraint user_favorites_identity_key
    unique nulls not distinct (user_id, album_id, version_id, design_id)
);
欄位/規則 意思
user_id 這筆收藏是誰的。指向 Supabase Auth 的使用者
on delete cascade 帳號被刪除時,他的收藏也一起刪掉
album_id、version_id 收藏的是哪張專輯的哪個版本
design_id 指定款,可以是空的(有些版本沒有分款)
unique nulls not distinct 同一個人、同一個版本、同一款,只能收藏一次

四、RLS:每一列資料,都要先過一道檢查

4.1 RLS 是什麼

RLS(Row Level Security,列層級安全)的意思是:資料庫在回傳資料之前,會一列一列地問:「這一列,可以給這個人嗎?」

一般的權限是以「整張表」為單位:你能不能讀這張表。
RLS 更細,是以「每一列」為單位:你能讀這張表裡的哪幾列。

收藏需要這種粒度。大家的收藏都在同一張表裡,但每個人只能碰到屬於自己的那幾列。

4.2 資料庫怎麼知道「這個人」是誰?

登入之後,瀏覽器每次對 Supabase 發請求,都會帶上一張代表你身分的通行證(access token)。

資料庫可以用 auth.uid() 讀出這張通行證上的使用者 id。這張通行證由 Supabase 簽發,資料庫會先確認真偽,不是瀏覽器說自己是誰就算數。所以規則可以寫成:

這一列的 user_id,要等於目前請求者的 auth.uid()。

沒登入的請求沒有通行證,auth.uid() 就是空的,什麼都對不上。

4.3 實際的規則

alter table public.user_favorites enable row level security;

create policy "Users can read their own favorites"
  on public.user_favorites for select to authenticated
  using ((select auth.uid()) = user_id);

create policy "Users can add their own favorites"
  on public.user_favorites for insert to authenticated
  with check ((select auth.uid()) = user_id);

create policy "Users can remove their own favorites"
  on public.user_favorites for delete to authenticated
  using ((select auth.uid()) = user_id);

第一行開啟 RLS,後面三段各是一條規則。每條規則都寫了:針對哪種操作(for select)、給誰(to authenticated,已登入的人)、條件是什麼。

條件有兩種寫法,差在檢查的時間點:

寫法 檢查什麼 用在
using 已經存在的資料,哪幾列碰得到 讀取、刪除
with check 準備寫進去的這一列,合不合格 新增

所以:

  • 讀取:只看得到 user_id 是自己的那幾列
  • 新增:寫進去的 user_id 必須是自己。想用別人的 id 新增,會被擋下
  • 刪除:只刪得到自己的那幾列

至於為什麼寫成 (select auth.uid()),而不是直接寫 auth.uid():這是 Supabase 官方建議的寫法,結果一樣,但通常能讓資料庫不用每一列都重算一次。

你可能注意到,這裡沒有「修改」的規則。這不是漏寫。對一般登入的使用者來說,RLS 一開啟,預設就是全部拒絕,只有寫了規則的操作才會放行。 收藏只需要加入和取消,沒寫 update 的規則,就沒有人能修改收藏。

反過來說,開了 RLS 卻一條規則都沒寫,這張表就誰都碰不到。遇到「資料怎麼都讀不到」,可以先往這個方向查。

4.4 兩道鎖:先能進門,再看能碰哪幾列

migration 的最後還有兩行:

revoke all on table public.user_favorites from anon, authenticated;
grant select, insert, delete on table public.user_favorites to authenticated;

這是另一道鎖,以整張表為單位:

管什麼 比喻
grant/revoke 這個角色,能不能對這張表做某種操作 能不能進這個房間
RLS policy 進來之後,能碰哪幾列 進房間後,能打開哪幾個櫃子

這裡先把所有權限收回,再只給已登入的人讀取、新增、刪除三種。沒登入的訪客(anon)什麼都沒有,連門都進不來,所以請求會直接被拒絕,也就是之前提到過的 401。

兩道鎖都要過,請求才會成功。

4.5 有一把鑰匙,可以直接穿過 RLS

Day 17 寫回報功能時,我們在伺服器端用了管理者金鑰(secret key)寫入資料。

這把鑰匙會直接略過 RLS。所以 RLS 保護的,是從瀏覽器來、帶著公開金鑰和使用者通行證的請求;凡是用 secret key 的地方,權限要靠自己的程式把關。

這也是為什麼 secret key 只能放在伺服器端,絕對不能出現在瀏覽器的程式碼裡。


五、這次,不自己寫 API

Day 17 的回報功能,我們自己寫了一個 API:驗證欄位、限流、用 secret key 寫入,再通知 Telegram。

收藏不一樣。瀏覽器透過 Supabase 的套件,呼叫 Supabase 提供的 Data API(就是 Day 16 用 Postman 打的那個)。請求會帶著公開金鑰和使用者的通行證,再由資料庫的兩道鎖決定能不能執行。

不是沒有 API,而是不需要再另外包一層 /api/favorites。

回報(Day 17) 收藏(今天)
誰會用 任何訪客,不用登入 已登入的使用者
要擋什麼 亂填的內容、大量灌資料 碰到別人的資料
規則能不能用「這列是誰的」表達 不能 能
做法 自己寫 API,在伺服器端檢查 呼叫 Supabase 的 Data API,交給 RLS

這不代表哪一種比較好。規則如果能寫成「這一列是誰的」,RLS 很適合;如果需要檢查內容、限制次數、通知第三方,通常就會放在自己的伺服器端處理。


六、登入前的收藏,怎麼辦?

因為後來決定讓未登入訪客也能收藏,這時候收藏的資料會存在瀏覽器裡。問題是登入之後,這份清單要怎麼處理。

與 AI 討論後,決定以下列方案實作:

  1. 登入後自動合併:帳號裡的收藏,加上瀏覽器裡的收藏,取聯集
  2. 已經存在的直接略過:靠前面的唯一規則,不會重複
  3. 整批寫入成功,才清掉瀏覽器裡的資料:失敗就保留,下次再試
  4. 帳號的收藏以資料庫為準:登出後畫面上會一起清掉;登出後新加的,算是一份新的訪客清單

第三點的順序很重要。如果先清除再寫入,網路剛好斷掉,收藏可能就兩邊都沒有了。

第四點是為了共用電腦。如果登出後還留著帳號的收藏,下一個人坐下來就看得到。

情境 結果
訪客收藏了 3 張,登入一個有 5 張收藏的帳號(其中 1 張重複) 帳號變成 7 張,瀏覽器清空
合併時網路斷掉 全部留在瀏覽器,下次再試
合併成功後登出 收藏清單變成空的
登出後收藏 1 張,再登入 這 1 張合併進帳號

(當然,實作的方式也不唯一。如果怕麻煩,也是可以直接強迫使用者一定要登入才能進行收藏。通常不一定有最佳解,而是需要去評估優缺點、對比犧牲什麼/得到什麼。)


七、讓操作有回饋

收藏從直接存在瀏覽器,變成要經過網路寫進資料庫,所有操作都會多一點等待時間。為了提升使用者的體驗,這次加了三種回饋:

做法 解決什麼
立即更新(Optimistic UI) 按下愛心,畫面先變;背景同步失敗,就恢復原狀 不用等網路才看到反應
提示訊息(Toast) 成功時短暫顯示;失敗時停留久一點,告訴你已經還原 知道到底有沒有存進去
骨架畫面(Skeleton) 收藏頁載入時,先顯示卡片的灰色輪廓 不會以為網頁壞了

(這邊可以呼應之前提到 UI 名詞彙整的文章。不一定要做,但做了使用者體驗通常更好)

Next.js 可以透過 loading.tsx,在頁面還沒載入完成時,先顯示預先準備好的畫面。這次 Codex 用它做收藏頁的骨架,也替骨架保留固定尺寸,資料載入後版面不會跳動。

骨架畫面是讓等待「看起來」好一點,讓使用者知道現在正在等待中(而不是點了沒反應),不會讓網頁變快。

https://ithelp.ithome.com.tw/upload/images/20261003/20178017JPnpsz5Dk3.png
【圖1|進入收藏頁面時骨架畫面】

https://ithelp.ithome.com.tw/upload/images/20261003/20178017dXhErnMNPz.png
【圖2|收藏時出現的 Toast 通知】


驗收

權限:壞人進不來,也要確認自己人能用

測試前我創了兩隻帳號A跟B,並分別收藏某幾張專輯

身分 操作 預期結果
A 讀取收藏表,不加任何篩選條件 只拿到 A 的收藏
A 新增自己的收藏 成功
A 新增一筆,user_id 填 B 的 id 被拒絕
A 刪除自己的收藏 成功
A 刪除 B 的某一筆收藏 沒有錯誤,但 B 的資料還在
A 修改收藏 被拒絕(沒有給修改權限)
B 讀取收藏表,不加任何篩選條件 只拿到 B 的收藏
未登入 讀取或新增收藏 被拒絕

刪除 B 那一列要注意一下:RLS 擋住讀取和刪除時,通常不會報錯,只會當作那些資料不存在。 不能用「有沒有報錯」來判斷擋住了沒有,要再用 B 的身分確認資料還在。

功能與體驗

測試 預期結果
訪客收藏幾張後登入 合併進帳號,瀏覽器清空
合併成功後登出 收藏清單是空的
A 登出後,B 在同一台電腦登入 看不到 A 的收藏
在手機登入同一個帳號 看到同一份收藏
斷網時按愛心 畫面恢復原狀,出現錯誤提示
進入收藏頁 先出現骨架畫面

換個領域:規則要寫在資料那一層

產品 每個人能碰的資料
雲端相簿 只看得到自己的照片,除非對方分享給你
醫院系統 病人看自己的紀錄,醫生看自己負責的病人
公司內部系統 同一個組織的成員,看得到組織的資料

畫面上藏起來的資料,不代表拿不到。規則寫在資料那一層,讓繞過畫面的請求一樣可以擋得住。


結語與明日預告

今天,收藏終於能跟著人走了。

不過今天真正重要的不是收藏,而是那幾行規則:就算有人繞過網站,直接對資料庫發請求,也只碰得到自己的東西。 AI 寫 RLS 寫得很快,但規則對不對,還是建議要自己看過、檢查過。

回頭看目前已經實作的功能,我突然想到一個問題:同樣是從 Supabase 取得資料,專輯列表、搜尋結果、我的收藏,背後的處理方式感覺好像有一點不太一樣。有些事情在伺服器完成,有些則交給瀏覽器。

那麼,一個網頁究竟是在什麼時候、什麼地方被做出來的?這又會如何影響使用者看到畫面的過程?

明天,我們就從等待畫面出發,聊聊網頁的渲染方式,以及如何讓載入、等待,甚至失敗,都有適當的回饋。

我們明天見。


上一篇
【Day 18|辨認來者】Supabase Auth 登入與身分驗證:網站怎麼知道你是誰?
下一篇
【Day 20|捕魚的結果】網頁在哪裡被做出來?渲染方式、搜尋與資料狀態
系列文
《我與 AI 的奇幻漂流:30 天,把「能跑」變成「能上線」》 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言