Repository navigation
Expand file tree
/
Copy pathinit.sql
More file actions
7454 lines (6950 loc) · 304 KB
/
Copy pathinit.sql
File metadata and controls
7454 lines (6950 loc) · 304 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- ============================================================
-- Securibit-Web:資料庫初始化腳本
--
-- 用途:在「全新、空白」的 Supabase 專案建立完整的 schema(資料表、函式、觸發器、
-- RLS policy、權限、Realtime publication 與內建種子資料)。
-- 用法:到 Supabase 後台 → SQL Editor,整份貼上執行一次即可(不要重複執行)。
-- 內容:等同於依序套用開發時的 0001 ~ 0025 migration 之後的最終狀態,
-- 但只保留最終結果,不含搬移舊資料的步驟。
-- 已經有資料的專案不要執行這份(資料表已存在會失敗)。
-- 依賴:Supabase 內建的 auth schema(auth.users、auth.sessions、auth.uid()、auth.jwt())、
-- anon / authenticated / service_role 角色、extensions schema 與 supabase_realtime publication。
-- 推播(選用):pg_net、Supabase Vault(project_url、push_webhook_secret)、pg_cron(每分鐘補送),沒有就略過。
--
-- 權限模型(規格:docs/groups-and-permissions.md)
-- - 全站身分組:Discord 式 bitfield,多身分組權限 OR,有 @everyone、有階層
-- - 群組:等於 Discord 的伺服器;群組裡的每筆驗證碼等於頻道,可設定允許 / 拒絕覆寫
-- - 除了 profiles、sync_signals、session_devices 之外,資料表都開 RLS 且不建 policy,
-- 讀寫一律走 security definer 的 RPC,權限在函式裡檢查。
-- - 全站擁有者(site_owner)是唯一不受全站階層限制的人;群組擁有者不受群組階層限制。
--
-- 全站權限:1 ADMINISTRATOR、2 VIEW_ADMIN、4 VIEW_USERS、8 MANAGE_USERS、16 VIEW_USER_TOTP、
-- 32 MANAGE_USER_TOTP、64 MANAGE_ROLES、128 VIEW_AUDIT_LOG、256 CREATE_GROUPS、512 MANAGE_GROUPS、
-- 1024 SEND_NOTIFICATIONS
-- SITE_ALL = 2047
-- 群組權限:1 ADMINISTRATOR、2 VIEW_CODES、4 VIEW_SECRETS、8 MANAGE_CODES、16 CREATE_INVITE、
-- 32 KICK_MEMBERS、64 MANAGE_ROLES、128 MANAGE_GROUP、256 VIEW_AUDIT_LOG、512 BAN_MEMBERS
-- GROUP_ALL = 1023,可覆寫 GROUP_OVERWRITABLE = 14
--
-- 段落:
-- 1. 擴充套件
-- 2. 資料表(個人資料/全站身分組/群組/驗證碼/邀請/稽核紀錄/即時同步/登入裝置/通知)
-- 3. 共用 helper
-- 4. 全站身分組與權限
-- 5. 個人資料與 @ID
-- 6. 帳號建立與登入
-- 7. 群組
-- 8. 驗證碼
-- 9. 金鑰加密
-- 10. 邀請
-- 11. 後台
-- 12. 稽核紀錄與登入紀錄
-- 13. 即時同步
-- 14. 登入裝置
-- 15. 通知、公告、最低版本
-- 16. 權限設定(GRANT / REVOKE)
-- 17. 種子資料(含 Storage bucket、推播用的 Vault secret 與排程)
-- 18. (選用)指定第一位管理員與全站擁有者
-- ============================================================
-- ============================================================
-- 1. 擴充套件
-- ============================================================
-- pgcrypto:new_invite_code 用 extensions.gen_random_bytes 產生邀請碼(Supabase 預設已裝在 extensions schema)。
-- 沒有 pgcrypto 時 new_invite_code 會自動改用 gen_random_uuid。
create extension if not exists pgcrypto with schema extensions;
-- pg_net:通知有新的推播時,資料庫用它非同步呼叫 Edge Function push(見「15. 通知」)。
-- 沒有 pg_net 的環境(例如本機 PGlite 測試)略過,推播就只是留在 pending,其他功能照常
do $$
begin
create extension if not exists pg_net;
exception when others then
raise notice '沒有 pg_net,略過推播呼叫:%', sqlerrm;
end;
$$;
-- ============================================================
-- 2. 資料表
-- ============================================================
-- ---------- 個人資料 ----------
-- 使用者個人資料表,主鍵對應 auth.users.id
create table public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
email text, -- 一律以 auth.users.email 為準(ensure_profile 同步)
name text,
photo_url text,
bg_photo_url text default '', -- 空字串 = 前端用內建的漸層
email_verified boolean default false,
phone_number text default '',
creation_time text default '',
last_sign_in_time text default '',
-- 舊版的驗證碼陣列;寫進來的資料會由 profiles_totp_to_accounts 觸發器轉存到群組後清空
totp jsonb not null default '[]'::jsonb,
updated_at timestamptz default now(),
-- migrated:此帳號第一次登入時有接到 legacy_import 的舊資料
-- migration_notified:是否已經提示過(避免重複跳)
migrated boolean not null default false,
migration_notified boolean not null default false,
-- 最後登入資訊:時間 / IP / 裝置
last_login_at timestamptz,
last_login_ip text,
last_login_device text,
-- 金鑰加密設定 { v, salt, iterations, check },null 代表未啟用。
-- 金鑰在瀏覽器裡用使用者的密碼加密後才存進 totp_secrets.secret(格式 enc:v1:<iv>:<密文>),
-- 資料庫只看得到密文;密碼本身不會送到伺服器。
vault jsonb,
-- @ID(存小寫)
username text,
-- 預設群組:不加 FK(群組刪除時由 RPC 處理預設;也避免刪帳號時 FK 動作改到正在刪除的 profile)。
-- 只能指向自己擁有的群組(profiles_default_group_guard 觸發器)
default_group_id uuid,
-- 自己上傳的大頭照(/api/profile/photo):true 時舊版前端每次登入送來的 Google 大頭照不會蓋掉它
photo_custom boolean not null default false,
constraint profiles_username_format check (username is null or username ~ '^[a-z0-9_.]{3,32}$')
);
create unique index profiles_username_key on public.profiles (username);
alter table public.profiles enable row level security;
-- 使用者只能讀、改自己那列(能改哪些欄位見「15. 權限設定」,改的值由 profiles_guard 觸發器把關);
-- 新增 / 刪除 profile 只由觸發器、ensure_profile 與後端處理
create policy "profiles_select_own"
on public.profiles for select
using (auth.uid() = id);
create policy "profiles_update_own"
on public.profiles for update
using (auth.uid() = id)
with check (auth.uid() = id);
-- 從 Firestore 匯入的暫存資料,以 email 為對應鍵(Google uid 在 Supabase 會是全新 uuid)。
-- email 驗證過的使用者登入時由 claim_verified_email 接回並刪除。
-- 啟用 RLS 但不建任何 policy → 一般使用者讀不到任何資料,只有 service_role 與 security definer 函式能讀取。
create table public.legacy_import (
email text primary key,
name text,
photo_url text,
bg_photo_url text,
phone_number text,
totp jsonb not null default '[]'::jsonb
);
alter table public.legacy_import enable row level security;
-- ---------- 全站身分組 ----------
-- 全站身分組
create table public.site_roles (
id uuid primary key default gen_random_uuid(),
name text not null,
color text not null default '', -- '#rrggbb' 或空字串(無顏色)
position int not null default 0,
permissions bigint not null default 0,
is_default boolean not null default false, -- @everyone,只有一個
created_at timestamptz not null default now()
);
create unique index site_roles_one_default on public.site_roles ((true)) where is_default;
create table public.site_user_roles (
user_id uuid references public.profiles (id) on delete cascade,
role_id uuid references public.site_roles (id) on delete cascade,
primary key (user_id, role_id)
);
create index site_user_roles_role_idx on public.site_user_roles (role_id);
-- 還沒註冊的人先記著,第一次登入且 email 已驗證時(claim_verified_email → apply_pending_roles)套上
create table public.site_pending_roles (
email text not null, -- 小寫
role_id uuid references public.site_roles (id) on delete cascade,
primary key (email, role_id)
);
-- 全站擁有者(只有一列):唯一不受全站階層限制的人;還沒有擁有者時由「最高位身分組的持有者」不受限。
-- pending_email:還沒註冊時先記 email(小寫),第一次登入且 email 已驗證時(apply_pending_roles)接上
create table public.site_owner (
id boolean primary key default true check (id),
user_id uuid references public.profiles (id) on delete set null,
pending_email text
);
-- ---------- 群組 ----------
-- 群組。每個使用者都有一個預設群組(profiles.default_group_id,原本的「我的驗證碼」)。
-- encrypted:群組加密,只有擁有者能切換,用擁有者自己的 vault(profiles.vault)加解密;
-- 加密的群組只能有擁有者一個成員(不能邀請、加入),伺服器也不替它產碼。
-- category_order:群組自訂的類別順序(所有成員共用,由前到後);不在裡面的類別由前端照字母順序接在後面。
create table public.groups (
id uuid primary key default gen_random_uuid(),
name text not null check (char_length(name) between 1 and 50),
description text not null default '' check (char_length(description) <= 300),
owner_id uuid not null references public.profiles (id),
created_at timestamptz not null default now(),
encrypted boolean not null default false,
category_order text[] not null default '{}'
);
create index groups_owner_idx on public.groups (owner_id);
create table public.group_roles (
id uuid primary key default gen_random_uuid(),
group_id uuid not null references public.groups (id) on delete cascade,
name text not null,
color text not null default '',
position int not null default 0,
permissions bigint not null default 0,
is_default boolean not null default false,
created_at timestamptz not null default now()
);
create index group_roles_group_idx on public.group_roles (group_id, position);
create unique index group_roles_one_default on public.group_roles (group_id) where is_default;
-- position:每位成員自己排的群組順序(同一個群組在每位成員的清單裡可以排在不同位置,只影響自己看到的順序)
create table public.group_members (
group_id uuid references public.groups (id) on delete cascade,
user_id uuid references public.profiles (id) on delete cascade,
joined_at timestamptz not null default now(),
position int not null default 0,
primary key (group_id, user_id)
);
create index group_members_user_idx on public.group_members (user_id);
create index group_members_user_position_idx on public.group_members (user_id, position);
create table public.group_member_roles (
group_id uuid,
user_id uuid,
role_id uuid references public.group_roles (id) on delete cascade,
primary key (group_id, user_id, role_id),
foreign key (group_id, user_id) references public.group_members (group_id, user_id) on delete cascade
);
create index group_member_roles_role_idx on public.group_member_roles (role_id);
-- 封鎖名單:被封鎖的人不能再透過邀請連結、Email / @ID 邀請加入,也不能被全站管理員直接加入
create table public.group_bans (
group_id uuid references public.groups (id) on delete cascade,
user_id uuid references public.profiles (id) on delete cascade,
reason text not null default '' check (char_length(reason) <= 200),
banned_by uuid references public.profiles (id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (group_id, user_id)
);
create index group_bans_user_idx on public.group_bans (user_id);
-- ---------- 驗證碼 ----------
-- 驗證碼與金鑰分兩張表,才能做到「看得到驗證碼、拿不到金鑰」。
-- 驗證碼一律屬於群組(owner_id 只是舊版個人驗證碼留下的欄位,totp_accounts_group_only 約束它一律為 null)。
create table public.totp_accounts (
id uuid primary key default gen_random_uuid(),
owner_id uuid references public.profiles (id) on delete cascade,
group_id uuid references public.groups (id) on delete cascade,
name text not null default '',
category text not null default '',
digits int not null default 6 check (digits in (6, 8)),
period int not null default 30 check (period in (30, 60)),
algorithm text not null default 'SHA-1' check (algorithm in ('SHA-1', 'SHA-256', 'SHA-512')),
pinned boolean not null default false,
position int not null default 0, -- 小的排前面
created_by uuid references public.profiles (id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
check ((owner_id is null) <> (group_id is null)),
constraint totp_accounts_group_only check (owner_id is null)
);
create index totp_accounts_owner_position_idx on public.totp_accounts (owner_id, position);
create index totp_accounts_group_position_idx on public.totp_accounts (group_id, position);
create table public.totp_secrets (
account_id uuid primary key references public.totp_accounts (id) on delete cascade,
secret text not null -- Base32,或加密群組的 enc:v1:...
);
-- 單筆驗證碼覆寫
create table public.group_account_overwrites (
account_id uuid references public.totp_accounts (id) on delete cascade,
target_type text check (target_type in ('role', 'member')),
target_id uuid, -- group_roles.id 或 profiles.id
allow bigint not null default 0,
deny bigint not null default 0,
primary key (account_id, target_type, target_id)
);
-- ---------- 邀請 ----------
-- 邀請連結
create table public.group_invites (
code text primary key, -- 16 碼 URL 安全 base64(new_invite_code)
group_id uuid not null references public.groups (id) on delete cascade,
created_by uuid references public.profiles (id) on delete set null,
created_at timestamptz not null default now(),
expires_at timestamptz, -- group_invite_create 一律會設(預設 7 天、最長 30 天)
max_uses int, -- null = 不限
uses int not null default 0,
revoked_at timestamptz
);
create index group_invites_group_idx on public.group_invites (group_id);
-- Email / @ID 邀請(對方接受或拒絕後刪除)
create table public.group_email_invites (
id uuid primary key default gen_random_uuid(),
group_id uuid not null references public.groups (id) on delete cascade,
email text not null, -- 小寫
invited_by uuid references public.profiles (id) on delete set null,
created_at timestamptz not null default now(),
unique (group_id, email)
);
create index group_email_invites_email_idx on public.group_email_invites (email);
-- ---------- 稽核紀錄 ----------
-- 操作紀錄。跟某位使用者有關的帳號事件寫成 group_id null、target_type 'user'、target_id = 使用者 id
-- (所以有 VIEW_AUDIT_LOG 的人在全站操作紀錄也看得到)
create table public.audit_logs (
id bigint generated always as identity primary key,
group_id uuid references public.groups (id) on delete cascade, -- null = 全站
actor_id uuid references public.profiles (id) on delete set null,
action text not null, -- 例如 'group.role.update'
target_type text,
target_id text,
detail jsonb not null default '{}',
created_at timestamptz not null default now()
);
create index audit_logs_group_idx on public.audit_logs (group_id, id desc);
-- 「跟這位使用者有關的紀錄」查詢用
create index audit_logs_target_idx on public.audit_logs (target_type, target_id, id desc);
create index audit_logs_actor_idx on public.audit_logs (actor_id, id desc);
-- 登入紀錄(量大,獨立一張表):每次登入一筆(同一個 session 只記一筆),由 /api/record-login 用 service role 寫入。
-- 為了追查重複開帳號、濫用後刪帳號,刪除帳號後仍保留:user_id 不設 FK(不跟著 profile 刪),
-- 並記下登入當時的 email;一律保留一年,由 /api/record-login 每次寫入時刪掉一年前的紀錄
create table public.login_history (
id bigserial primary key,
user_id uuid not null,
created_at timestamptz not null default now(),
ip text,
device text, -- 「OS · 瀏覽器」
user_agent text, -- 完整 user-agent(截斷 512 字)
provider text, -- 登入方式:google / email(auth user 的 app_metadata.provider)
email text, -- 登入當時的 email(帳號刪除後靠它辨識是誰)
session_id uuid -- access token 的 session_id claim;同一個 session 已經有紀錄就不再新增
);
create index login_history_user_idx on public.login_history (user_id, created_at desc);
create index login_history_created_idx on public.login_history (created_at);
create index login_history_session_idx on public.login_history (user_id, session_id);
-- 加密密碼的託管密文(esc:v1:...,用團隊的公鑰加密,只有離線的私鑰解得開;格式見 scripts/escrow.mjs)。
-- 設定 / 變更加密密碼時由 totp_set_vault 寫入,舊資料由 vault_escrow_put 補登。
-- user_id 不設 FK:刪除帳號時記下 account_deleted_at,之後保留一年,由 prune_retention()(/api/record-login 呼叫)刪掉
create table public.vault_escrow (
id bigserial primary key,
user_id uuid not null,
email text, -- 寫入當時的 email(帳號刪除後靠它辨識是誰)
key_id text not null, -- 團隊公鑰的 key id(esc:v1:<kid>:...)
envelope text not null,
vault_check text not null, -- 這組密碼對應的 profiles.vault.check
created_at timestamptz not null default now(),
account_deleted_at timestamptz -- 帳號刪除的時間(profiles_delete_owned_groups 寫入);null = 帳號還在
);
create index vault_escrow_user_idx on public.vault_escrow (user_id, created_at);
-- ---------- 即時同步 ----------
-- 每位使用者「哪裡變了」的訊號,一列一個 topic,只存時間(設計說明見「13. 即時同步」)
create table public.sync_signals (
id bigint generated always as identity primary key,
user_id uuid not null, -- 不設 FK:刪 profile 時由觸發器清掉,避免 cascade 順序擋到刪帳號
topic text not null,
changed_at timestamptz not null default now(),
unique (user_id, topic)
);
-- App 用 Realtime 訂閱自己的列:authenticated 只能 select,policy 只看得到自己的列(不依賴任何函式)
create policy "sync_signals_select_own"
on public.sync_signals for select
to authenticated
using ((select auth.uid()) = user_id);
-- ---------- 登入裝置 ----------
-- App 記下「這個 session 是哪台裝置」(設計說明見「14. 登入裝置」)。key 是 access token 的 session_id claim。
-- 不對 auth.sessions 建外鍵(不依賴 auth schema 的 REFERENCES 權限):session 被刪掉後留下的列
-- 在 list_my_sessions 不會出現,下次寫入時由觸發器清掉。
create table public.session_devices (
session_id uuid primary key default (nullif(auth.jwt() ->> 'session_id', '')::uuid),
user_id uuid not null default auth.uid() references public.profiles (id) on delete cascade,
device_name text check (char_length(device_name) <= 100),
-- ios / android / macos / linux / windows
platform text check (char_length(platform) <= 32),
app_version text check (char_length(app_version) <= 32),
created_at timestamptz not null default now(),
-- App 啟動、登入、換 token 時更新
last_seen_at timestamptz not null default now(),
-- 推播 token(Android 的 FCM token):只能透過 register_push_token 寫(session_devices_guard 擋直接改);
-- 同一個 token 只會留在一列(換帳號登入時清掉舊的那列)
push_token text check (char_length(push_token) <= 4096),
push_updated_at timestamptz
);
create index session_devices_user_idx on public.session_devices (user_id);
-- token 可能很長,用 md5 建唯一索引(同一個 token 只能在一列)
create unique index session_devices_push_token_key on public.session_devices (md5(push_token)) where push_token is not null;
-- RLS:只能讀自己的、只能寫「目前這個 session」的那一列(別的 session 的裝置資料改不到)
create policy "session_devices_select_own"
on public.session_devices for select
using (user_id = auth.uid());
create policy "session_devices_insert_own"
on public.session_devices for insert
with check (user_id = auth.uid() and session_id = (nullif(auth.jwt() ->> 'session_id', '')::uuid));
create policy "session_devices_update_own"
on public.session_devices for update
using (user_id = auth.uid() and session_id = (nullif(auth.jwt() ->> 'session_id', '')::uuid))
with check (user_id = auth.uid() and session_id = (nullif(auth.jwt() ->> 'session_id', '')::uuid));
-- ---------- 通知、公告、全站設定 ----------
-- 全站設定(key → jsonb):notify_admin_changes(後台修改帳號時要不要通知本人)、min_app_version(App 最低版本)
create table public.site_settings (
key text primary key,
value jsonb not null default 'null'::jsonb,
updated_by uuid references public.profiles (id) on delete set null,
updated_at timestamptz not null default now()
);
-- 後台發的公告(操作紀錄,一律保留);filter 是發送時的對象條件
create table public.broadcasts (
id bigint generated always as identity primary key,
kind text not null check (kind in ('important', 'update', 'promo', 'other')),
title text not null check (char_length(title) between 1 and 100),
body text not null default '' check (char_length(body) <= 500),
link text check (char_length(link) <= 500 and link ~ '^/' and link !~ '^//'),
filter jsonb not null default '{}'::jsonb,
created_by uuid references public.profiles (id) on delete set null,
created_at timestamptz not null default now(),
user_count int not null default 0,
device_count int not null default 0,
push_count int not null default 0
);
create index broadcasts_created_idx on public.broadcasts (created_by, created_at desc);
-- 每位使用者的通知(鈴鐺)。標題與內文由資料庫產生(固定的中文句型),網站與 App 直接顯示。
-- push_state:pending 等推播、sending Edge Function 領取中、sent 處理完、skipped 不推播(使用者關掉這一種,或太舊)
-- push_sessions:公告有裝置條件時只推播到這些 session(null = 他所有的裝置);exclude_session:推播不送的 session
-- 保留一年(prune_retention);刪帳號時跟著刪除
create table public.notifications (
id bigint generated always as identity primary key,
user_id uuid not null references public.profiles (id) on delete cascade,
category text not null check (category in ('system', 'account', 'feature')),
type text not null check (char_length(type) <= 64),
title text not null check (char_length(title) <= 100),
body text not null default '' check (char_length(body) <= 500),
link text check (char_length(link) <= 500 and link ~ '^/' and link !~ '^//'),
data jsonb not null default '{}'::jsonb,
broadcast_id bigint references public.broadcasts (id) on delete set null,
exclude_session uuid,
push_sessions uuid[],
created_at timestamptz not null default now(),
read_at timestamptz,
push_state text not null default 'pending' check (push_state in ('pending', 'sending', 'sent', 'skipped')),
push_claimed_at timestamptz
);
create index notifications_user_idx on public.notifications (user_id, created_at desc);
create index notifications_push_idx on public.notifications (push_state, created_at) where push_state in ('pending', 'sending');
create index notifications_created_idx on public.notifications (created_at);
-- 每位使用者的通知開關:overrides 只存和預設不同的({ "<type>": true | false })
create table public.notification_prefs (
user_id uuid primary key references public.profiles (id) on delete cascade,
overrides jsonb not null default '{}'::jsonb,
updated_at timestamptz not null default now()
);
-- ---------- Row Level Security ----------
-- 上面有 policy 的三張表(profiles、sync_signals、session_devices)以外都不建 policy:
-- 一般使用者無法直接讀寫,一律走 security definer 的 RPC
alter table public.totp_accounts enable row level security;
alter table public.totp_secrets enable row level security;
alter table public.site_roles enable row level security;
alter table public.site_user_roles enable row level security;
alter table public.site_pending_roles enable row level security;
alter table public.site_owner enable row level security;
alter table public.groups enable row level security;
alter table public.group_roles enable row level security;
alter table public.group_members enable row level security;
alter table public.group_member_roles enable row level security;
alter table public.group_bans enable row level security;
alter table public.group_invites enable row level security;
alter table public.group_email_invites enable row level security;
alter table public.group_account_overwrites enable row level security;
alter table public.audit_logs enable row level security;
alter table public.login_history enable row level security;
alter table public.vault_escrow enable row level security;
alter table public.sync_signals enable row level security;
alter table public.session_devices enable row level security;
alter table public.site_settings enable row level security;
alter table public.broadcasts enable row level security;
alter table public.notifications enable row level security;
alter table public.notification_prefs enable row level security;
-- ============================================================
-- 3. 共用 helper(只給 security definer 函式用,一般使用者不能直接呼叫)
-- ============================================================
-- 目前登入者;未登入就 raise
create function public.require_uid()
returns uuid
language plpgsql
stable
security definer
set search_path = public
as $$
declare
uid uuid := auth.uid();
begin
if uid is null then
raise exception '請先登入';
end if;
return uid;
end;
$$;
-- 使用者的 email(小寫):只回傳已驗證的 auth.users.email;沒有 auth.users 那列時才用 profiles.email
create function public.user_email(p_uid uuid)
returns text
language sql
stable
security definer
set search_path = public
as $$
select lower(case
when exists (select 1 from auth.users u where u.id = p_uid)
then (select u.email from auth.users u where u.id = p_uid and u.email_confirmed_at is not null)
else (select p.email from profiles p where p.id = p_uid)
end);
$$;
-- 使用者顯示名稱(紀錄 detail.name 用):名稱 → @ID → email(auth.users 優先)
create function public.display_name(p_uid uuid)
returns text
language sql
stable
security definer
set search_path = public
as $$
select coalesce(nullif(p.name, ''), p.username, (select u.email from auth.users u where u.id = p.id), p.email)
from profiles p where p.id = p_uid;
$$;
-- SiteUserBrief:{ id, name, username, photo_url, email };with_email false 時 email 是 null(email 以 auth.users 為準)
create function public.user_brief(p_uid uuid, p_with_email boolean)
returns jsonb
language sql
stable
security definer
set search_path = public
as $$
select jsonb_build_object(
'id', p.id, 'name', p.name, 'username', p.username, 'photo_url', p.photo_url,
'email', case when p_with_email then coalesce((select u.email from auth.users u where u.id = p.id), p.email) end
)
from profiles p
where p.id = p_uid;
$$;
-- 寫一筆操作紀錄(gid null = 全站)
create function public.audit(p_gid uuid, p_actor uuid, p_action text, p_target_type text, p_target_id text, p_detail jsonb)
returns void
language sql
security definer
set search_path = public
as $$
insert into audit_logs (group_id, actor_id, action, target_type, target_id, detail)
values (p_gid, p_actor, p_action, p_target_type, p_target_id, coalesce(p_detail, '{}'::jsonb));
$$;
-- 網站管理員(管理員、管理所有群組)不是群組成員、靠網站權限查看時,操作紀錄的 detail 加上 via_site:
-- 群組的操作紀錄與使用者自己的紀錄不顯示,只在後台看得到
create function public.audit_via_site(p_gid uuid, p_uid uuid)
returns jsonb
language sql
stable
security definer
set search_path = public
as $$
select case when exists (select 1 from group_members m where m.group_id = p_gid and m.user_id = p_uid)
then '{}'::jsonb else '{"via_site": true}'::jsonb end;
$$;
-- 檢查身分組顏色:'' 或 '#rrggbb'(存小寫)
create function public.norm_color(v text)
returns text
language plpgsql
immutable
as $$
declare
c text := lower(trim(coalesce(v, '')));
begin
if c <> '' and c !~ '^#[0-9a-f]{6}$' then
raise exception '顏色格式不正確,請使用 #rrggbb';
end if;
return c;
end;
$$;
-- 檢查身分組名稱:去頭尾空白、1~50 字
create function public.norm_role_name(v text)
returns text
language plpgsql
immutable
as $$
declare
n text := trim(coalesce(v, ''));
begin
if n = '' then
raise exception '請輸入身分組名稱';
end if;
if char_length(n) > 50 then
raise exception '身分組名稱最多 50 個字';
end if;
return n;
end;
$$;
-- ============================================================
-- 4. 全站身分組與權限
-- 階層:只有全站擁有者(site_owner)不受限;其他人(含 ADMINISTRATOR)只能管理最高身分組比自己低的使用者、
-- 比自己最高身分組低的身分組,同位的互相管不到,除了擁有者沒有人能管理擁有者。
-- 還沒有擁有者時,照舊由「最高位身分組的持有者」不受限。
-- 最後一位管理員保護:拿掉最後一位擁有 ADMINISTRATOR 的使用者時 raise(整個 RPC 回滾)
-- ============================================================
-- 全站權限中文名稱(錯誤訊息用)
create function public.site_perm_name(p_bit bigint)
returns text
language sql
immutable
as $$
select case p_bit
when 1 then '管理員' when 2 then '進入後台' when 4 then '查看使用者' when 8 then '管理使用者'
when 16 then '查看使用者的 2FA' when 32 then '管理使用者的 2FA' when 64 then '管理身分組'
when 128 then '查看操作紀錄' when 256 then '建立群組' when 512 then '管理所有群組'
when 1024 then '發送通知'
else p_bit::text end;
$$;
-- 全站權限:@everyone OR 該使用者所有身分組;含 ADMINISTRATOR → SITE_ALL
create function public.site_permissions(p_uid uuid)
returns bigint
language plpgsql
stable
security definer
set search_path = public
as $$
declare
perms bigint;
begin
if p_uid is null then
return 0;
end if;
select coalesce(bit_or(r.permissions), 0) into perms
from site_roles r
where r.is_default
or r.id in (select ur.role_id from site_user_roles ur where ur.user_id = p_uid);
if perms & 1 <> 0 then
return 2047;
end if;
return perms & 2047;
end;
$$;
-- 使用者在全站的最高位置(沒有身分組時 0)
create function public.site_top_position(p_uid uuid)
returns int
language sql
stable
security definer
set search_path = public
as $$
select coalesce(max(r.position), 0)
from site_user_roles ur
join site_roles r on r.id = ur.role_id
where ur.user_id = p_uid and not r.is_default;
$$;
-- 是否為全站擁有者
create function public.site_is_owner(p_uid uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select p_uid is not null and exists (select 1 from site_owner o where o.user_id = p_uid);
$$;
-- 不受全站階層限制的人:有擁有者時只有擁有者;還沒有擁有者時是「最高位身分組的持有者」:
-- 自己有身分組,而且沒有人的最高身分組比自己高(只看有人持有的身分組,避免最上面的身分組沒人時整個後台沒人管得到)
create function public.site_is_root(p_uid uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select case
when exists (select 1 from site_owner o where o.user_id is not null) then site_is_owner(p_uid)
else site_top_position(p_uid) > 0
and site_top_position(p_uid) >= (
select coalesce(max(r.position), 0)
from site_user_roles ur join site_roles r on r.id = ur.role_id
where not r.is_default
)
end;
$$;
-- 沒有指定的全站權限就 raise;回傳展開後的權限
create function public.site_require(p_uid uuid, p_bit bigint)
returns bigint
language plpgsql
stable
security definer
set search_path = public
as $$
declare
perms bigint := site_permissions(p_uid);
begin
if perms & p_bit = 0 then
raise exception '權限不足:需要「%」', site_perm_name(p_bit);
end if;
return perms;
end;
$$;
-- 全站階層:能不能管理這個 position 的身分組(擁有者不受限)
create function public.site_can_manage_position(p_uid uuid, p_position int)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select site_is_root(p_uid) or p_position < site_top_position(p_uid);
$$;
-- 全站階層:能不能管理這位使用者(擁有者不受限;其他人只能管最高身分組比自己低的人,而且管不到擁有者)
create function public.site_can_manage_user(p_uid uuid, p_target uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select site_is_root(p_uid)
or (not site_is_owner(p_target) and site_top_position(p_target) < site_top_position(p_uid));
$$;
-- 目前擁有全站 ADMINISTRATOR 的使用者數量
create function public.site_admin_count()
returns bigint
language sql
stable
security definer
set search_path = public
as $$
select case
when exists (select 1 from site_roles where is_default and permissions & 1 <> 0)
then (select count(*) from profiles)
else (
select count(distinct ur.user_id)
from site_user_roles ur
join site_roles r on r.id = ur.role_id
where r.permissions & 1 <> 0
)
end;
$$;
-- 最後一位管理員保護:變更前有管理員、變更後沒有 → raise(整個 RPC 會回滾)。
-- 先拿 advisory lock 再數,兩個交易同時拿掉管理員時,後面那個會看到前面的結果
-- (volatile,lock 之後的查詢才會拿到新的 snapshot)
create function public.site_assert_admin_left(p_before bigint)
returns void
language plpgsql
volatile
security definer
set search_path = public
as $$
begin
perform pg_advisory_xact_lock(hashtext('site_admins'));
if p_before > 0 and site_admin_count() = 0 then
raise exception '至少要保留一位擁有「管理員」權限的使用者';
end if;
end;
$$;
-- 全站身分組:依 position 由高到低重新編號為 n..1(@everyone 維持 0)
create function public.site_roles_renumber()
returns void
language sql
security definer
set search_path = public
as $$
update site_roles r
set position = s.rn
from (
select id, (count(*) over ()) - row_number() over (order by position desc, created_at, id) + 1 as rn
from site_roles where not is_default
) s
where r.id = s.id and r.position <> s.rn;
$$;
-- 自己的全站權限(已展開)、最高位置、是不是全站擁有者、身分組(不含 @everyone,由高到低);未登入回傳 null
create function public.me_permissions()
returns jsonb
language plpgsql
stable
security definer
set search_path = public
as $$
declare
uid uuid := auth.uid();
begin
if uid is null then
return null;
end if;
return jsonb_build_object(
'permissions', site_permissions(uid),
'top_position', site_top_position(uid),
'is_owner', site_is_owner(uid),
'roles', coalesce((
select jsonb_agg(jsonb_build_object('id', r.id, 'name', r.name, 'color', r.color, 'position', r.position)
order by r.position desc, r.created_at)
from site_user_roles ur join site_roles r on r.id = ur.role_id
where ur.user_id = uid and not r.is_default
), '[]'::jsonb)
);
end;
$$;
-- 全站擁有者的使用者 id(VIEW_ADMIN;還沒有擁有者時 null),後台顯示「擁有者」用
create function public.site_owner()
returns uuid
language plpgsql
stable
security definer
set search_path = public
as $$
declare
uid uuid := require_uid();
begin
perform site_require(uid, 2);
return (select o.user_id from site_owner o limit 1);
end;
$$;
-- 全站身分組列表(VIEW_ADMIN):由高到低
create function public.site_roles_list()
returns jsonb
language plpgsql
stable
security definer
set search_path = public
as $$
declare
uid uuid := require_uid();
begin
perform site_require(uid, 2);
return coalesce((
select jsonb_agg(jsonb_build_object(
'id', r.id, 'name', r.name, 'color', r.color, 'position', r.position, 'permissions', r.permissions,
'is_default', r.is_default,
'member_count', case when r.is_default then (select count(*) from profiles)
else (select count(*) from site_user_roles ur where ur.role_id = r.id) end
) order by r.is_default, r.position desc, r.created_at)
from site_roles r
), '[]'::jsonb);
end;
$$;
-- 建立全站身分組(MANAGE_ROLES;只能開自己有的位元):position = 1,其他 ≥ 1 的全部 +1
create function public.site_role_create(name text, color text, permissions bigint)
returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
uid uuid := require_uid();
p_name text := norm_role_name(site_role_create.name);
p_color text := norm_color(site_role_create.color);
p_perms bigint := coalesce(site_role_create.permissions, 0) & 2047;
mine bigint;
rid uuid;
begin
mine := site_require(uid, 64);
if p_perms & ~mine <> 0 then
raise exception '只能開啟你自己擁有的權限';
end if;
update site_roles r set position = r.position + 1 where not r.is_default and r.position >= 1;
insert into site_roles (name, color, position, permissions)
values (p_name, p_color, 1, p_perms) returning id into rid;
perform audit(null, uid, 'site.role.create', 'role', rid::text,
jsonb_build_object('name', p_name, 'color', p_color, 'permissions', p_perms));
return rid;
end;
$$;
-- 編輯全站身分組(MANAGE_ROLES + 階層 + 只能開自己有的位元);@everyone 只能改 permissions(position 0,有身分組的人都管得到);
-- 最後一位管理員保護
create function public.site_role_update(role_id uuid, name text, color text, permissions bigint)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
uid uuid := require_uid();
r site_roles%rowtype;
p_name text;
p_color text;
p_perms bigint;
mine bigint;
admins bigint := site_admin_count();
begin
select * into r from site_roles where id = site_role_update.role_id;
if r.id is null then
raise exception '找不到這個身分組';
end if;
mine := site_require(uid, 64);
if not site_can_manage_position(uid, r.position) then
raise exception '只能管理比你目前最高身分組還低的身分組 / 成員';
end if;
p_perms := coalesce(site_role_update.permissions, r.permissions) & 2047;
if p_perms & ~r.permissions & ~mine <> 0 then
raise exception '只能開啟你自己擁有的權限';
end if;
if r.is_default then
p_name := r.name;
p_color := r.color;
else
p_name := case when site_role_update.name is null then r.name else norm_role_name(site_role_update.name) end;
p_color := case when site_role_update.color is null then r.color else norm_color(site_role_update.color) end;
end if;
update site_roles x set name = p_name, color = p_color, permissions = p_perms where x.id = r.id;
perform site_assert_admin_left(admins);
perform audit(null, uid, 'site.role.update', 'role', r.id::text, jsonb_build_object(
'name', p_name, 'color', p_color, 'permissions', p_perms, 'old_permissions', r.permissions));
end;
$$;
-- 刪除全站身分組(MANAGE_ROLES + 階層);@everyone 不能刪;最後一位管理員保護
create function public.site_role_delete(role_id uuid)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
uid uuid := require_uid();
r site_roles%rowtype;
admins bigint := site_admin_count();
holders uuid[];
begin
select * into r from site_roles where id = site_role_delete.role_id;
if r.id is null then
raise exception '找不到這個身分組';
end if;
perform site_require(uid, 64);
if r.is_default then
raise exception '@everyone 不能刪除';
end if;
if not site_can_manage_position(uid, r.position) then
raise exception '只能管理比你目前最高身分組還低的身分組 / 成員';
end if;
holders := array(select ur.user_id from site_user_roles ur where ur.role_id = r.id);
delete from site_roles x where x.id = r.id;
perform site_assert_admin_left(admins);
perform site_roles_renumber();
perform audit(null, uid, 'site.role.delete', 'role', r.id::text, jsonb_build_object('name', r.name));
-- 持有這個身分組的人:網站身分組變了(後台可以整個關掉)
if notify_admin_change_enabled('role') then
perform notify(holders, 'admin_changed_role', '網站身分組已變更',
format('%s 刪除了網站身分組「%s」。', notify_name(uid), r.name), '/me/update_info',
jsonb_build_object('actor_id', uid, 'role_id', r.id, 'role_name', r.name, 'deleted', true));
end if;
end;
$$;
-- 全站身分組排序(MANAGE_ROLES):role_ids = 由高到低的完整順序(不含 @everyone);
-- 擁有者不受限,其他人(含一般管理員)管不到的身分組排名不能變
create function public.site_roles_reorder(role_ids uuid[])
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
uid uuid := require_uid();
p_ids uuid[] := coalesce(site_roles_reorder.role_ids, '{}');
n int;
is_root boolean;
top int;