# PDF Signage 工具 — 交接文件

**專案**：SignStudio PDF 顯示方案(HTML component + PDF.js)
**負責**：Contex Digital / Hugo
**狀態**：本地開發 + 測試已完成，待部署到 `sb.contex.com.hk`
**文件用途**：俾接手嘅 programmer 快速理解原理、架構、同埋接落去要做啲乜

---

## 1. 要解決嘅問題

MediaSignage 嘅 SignStudio（數碼廣告牌軟件）新版冇原生 PDF component，客人上載唔到 PDF 落個 signage screen 度播。

**方案**：唔靠 SignStudio 原生支援，而係用 SignStudio 本身有嘅 **HTML component**（可以貼一條網址、播網頁內容），去顯示一個自己寫嘅網頁 —— 呢個網頁入面用 **PDF.js**（Mozilla 出嘅 JS PDF render library）將 PDF 逐頁畫落 `<canvas>`，再自動輪流換頁，睇落好似播緊 PDF 做 signage 內容咁。

一句講晒個原理：**PDF 檔案（存喺 server）→ viewer.html（用 PDF.js 讀取 + render 落 canvas，自動輪流換頁）→ 呢條 viewer.html 嘅網址就貼入 SignStudio 嘅 HTML component**。

---

## 2. 三個檔案點分工（架構）

```
客人（用家）操作                          →  builder.html
                                              │ 上載 PDF (XHR POST, multipart/form-data)
                                              ▼
                                           upload.php
                                              │ 驗證 + 存檔
                                              ▼
                                    /files/<sanitized-name>.pdf   ← 靜態存放，任何人憑 URL 都可以讀（公開，冇認證）
                                              ▲
                                              │ pdfUrl=... (fetch)
                                              │
SignStudio HTML component  ──載入──→      viewer.html
                                              │ PDF.js 逐頁 render 落 <canvas>，setTimeout 輪流換頁
                                              ▼
                                         Signage 屏幕顯示
```

### 2.1 `builder.html` — 客人自助介面

- 純前端（HTML/CSS/vanilla JS，冇任何 framework），用 `XMLHttpRequest` 上載檔案去同目錄嘅 `upload.php`（寫死喺 [builder.html:212](builder.html:212) `UPLOAD_ENDPOINT = 'upload.php'`）。
- 有拖放上載、進度條（用 `xhr.upload.addEventListener('progress', ...)`）。
- 上載成功後，用返回嘅 `data.url`（即係 PDF 喺 server 嘅公開網址）加埋顯示設定（停留秒數、fit mode、頁數範圍、loop、transition、refresh 分鐘），組成一條 query string，砌出最終畀 SignStudio 用嘅 `viewer.html?...` 連結。
- 呢個連結淨係喺瀏覽器度組成，**冇存落 server 任何地方** —— 即係話 builder.html 淨係一個「產生連結」嘅工具，唔負責記住產生過幾多條連結。
- `REQUIRE_KEY`（[builder.html:214](builder.html:214)）：如果 `upload.php` 有設 `UPLOAD_KEY`，呢度要同步改做 `true`，先會顯示密碼輸入欄，並喺上載時夾埋個 key 一齊送。

### 2.2 `upload.php` — 上載端點

> **架構更新（2026-07-04）**：密碼 / 客戶分類 / 配額設定已抽出去 `config.php`（連共用 helper），俾 `upload.php` 同 `manage.php` 兩個 endpoint 共用 —— **所有設定只需要改 `config.php` 一個檔**。

驗證流程（由頭到尾）：
1. 一定要 `POST`，否則 405（[upload.php:22](upload.php:22)）
2. 如果設咗 `UPLOAD_KEY`（[upload.php:10](upload.php:10)），用 `hash_equals()` 比對 `$_POST['key']`（timing-safe 比較，防 timing attack）
3. 檔案大小 ≤ 10 MB（`MAX_BYTES`，[upload.php:11](upload.php:11)）—— 兩層檢查：PHP-level `upload_max_filesize/post_max_size`（server ini 設定）+ code 自己再驗一次 `$f['size']`
4. 副檔名一定要 `.pdf`
5. **Magic bytes 驗證**：讀檔案頭 5 個 byte 一定要係 `%PDF-`（[upload.php:66](upload.php:66)），呢個先係防止假 PDF 嘅真正防線，唔淨係睇副檔名
6. **檔名清洗**：`preg_replace('/[^\p{L}\p{N}\-_\.]+/u', '_', ...)`（[upload.php:73](upload.php:73)）—— 保留晒 Unicode 文字（包括中文）同數字，其他符號（空格、括號等）轉底線，防 path traversal / 奇怪字元累事
7. **同名覆蓋**：故意設計成咁 —— 客人換咗份新 PDF、用返同一個檔名上載，就會覆蓋舊檔，SignStudio 嗰條連結（URL 冇變）自動顯示新內容，唔使再入去改 SignStudio。`overwritten` 呢個 flag 就係話俾 builder.html 知道啱啱係咪蓋咗舊檔，去提示用家。

回傳 JSON 例子：
```json
{"ok":true,"url":"https://sb.contex.com.hk/pdf-signage/files/menu.pdf","name":"menu.pdf","size":18234,"overwritten":false}
```

**呢個 endpoint 冇任何 database，冇 session，係完全 stateless 嘅檔案處理 script。**

另外有**每客配額**（`config.php`：預設每客 20 個檔 / 100 MB）：超過就拒收並提示刪舊檔；同名覆蓋唔會佔新配額（舊檔空間當釋放計）。

### 2.2b `manage.php` — 檔案管理端點

俾客人自助管理自己上載咗嘅檔案（builder 嘅「檔案管理」區用呢個）：

- `POST action=list` → 列出該客人所有 PDF（檔名、大小、修改日期、URL）+ 配額使用量
- `POST action=delete&name=x.pdf` → 刪除一個檔（冇得復原）

Key 規則同 `upload.php` 完全一樣（同一個 `resolve_client()`）：multi-tenant 模式下**只能列出／刪除自己資料夾嘅檔案**，`basename()` 清洗檔名防 path traversal（已測 `../upload.php` 呢類輸入會被拒）。

**設計決定**：刪檔係顯式操作（builder 有獨立「刪除」掣 + confirm 警告「所有正在播放呢份 PDF 嘅屏幕會即時停播」），**唔係** playlist 移除嘅副作用 —— 因為播放清單只係砌連結嘅 session 概念，同一份 PDF 可能俾幾條連結／幾個屏幕用緊，playlist 移除唔代表個檔冇用。

### 2.3 `viewer.html` — 播放端（獨立單一檔案，SignStudio 實際載入嘅嘢）

- 淨係靠 URL query string 控制行為（因為 SignStudio HTML component 淨係俾你填一條 URL，冇得傳其他 config）：
  `viewer.html?pdfUrl=<url>&pageDuration=10&fitMode=fit-page&pageRange=1-5&loop=yes&transition=fade&refreshInterval=30&backgroundColor=%23000000`
- 用 **cdnjs 嘅 PDF.js 3.11.174（UMD build）**（[viewer.html:7](viewer.html:7)）—— 呢個係外部依賴，player 一定要連到 `cdnjs.cloudflare.com`。如果將來要完全唔靠外網（例如展場冇外網、或者想避免 CDN 出事），可以將 `pdf.min.js` + `pdf.worker.min.js` 下載落嚟同目錄，改返兩個 `<script src>` / `workerSrc` 路徑就得（見 [viewer.html:109](viewer.html:109)）。
- 用兩個 `<canvas>`（`canvas-a` / `canvas-b`）互相 crossfade（`opacity` transition）去做「換頁冇黑閃」嘅效果 —— render 緊下一頁去睇唔到嗰個 canvas，render 完先 fade 出嚟，同時舊嗰個 fade 走。
- `fitMode` 四選一：`fit-page`（等比縮到啱哂）/`fit-width`/`fit-height`/`fill`（拉滿唔理比例）—— 睇 `computeScale()`（[viewer.html:156](viewer.html:156)）。
- `pageRange`：支援 `1-5`、`1,3,5`、混合 `1-3,7` 呢啲寫法（[viewer.html:135](viewer.html:135)）。
- **定時重新拉 PDF**（`refreshInterval`，預設 30 分鐘）：因為 upload.php 支援同名覆蓋，viewer 需要定期重新 fetch 個 PDF 先會執到新版本；每次 fetch 都加 `_cb=<timestamp>` cache-bust query param（[viewer.html:118](viewer.html:118)）避免瀏覽器 cache 舊檔。
- **離線韌性**：如果 fetch 唔到（斷網 / server 冇反應），viewer 會保留＋繼續播緊手上舊有嘅 PDF（`pdfDoc` 冇清），background 用 exponential backoff（5s → 10s → 20s ... 上限 60s）不斷 retry 拉新版，*唔會*因為一次拉失敗就令個屏幕黑咗或者停播 —— 呢個設計係因應 signage 屏幕通常冇人睇住，一斷網就播唔到嘢會好尷尬。

---

## 3. `files/.htaccess` — 上載目錄嘅安全設定

放喺 `/files/` 入面（即係啲上載完嘅 PDF 存放嘅目錄），兩個作用：

1. **禁止呢個目錄執行任何 script**（PHP / CGI / Perl / Python / shell）——就算有人靠上載漏洞成功放咗一個 `.php` 入去，Apache 都唔會執行佢，淨係會當 static file 處理 / 拒絕。呢個係 defense-in-depth，防止上載功能一旦被繞過都唔會變成 RCE。
2. **加 CORS header**（`Access-Control-Allow-Origin: *`）畀 `.pdf` 檔 —— 因為 PDF.js 用 `fetch`/XHR 讀 PDF byte，如果 viewer.html 同 PDF 檔案唔係同一 origin（例如日後想俾其他 domain 嘅 viewer 讀呢度啲 PDF），冇呢個 header 會俾瀏覽器 CORS 政策擋咗。

**重要**：`.htaccess` 淨係喺 **Apache** 有效（`AllowOverride` 要開）。如果 hosting 用 Nginx，呢份 `.htaccess` 完全冇用，要用 Nginx 的 `location` block 做等效設定 —— 部署前要確認 `sb.contex.com.hk` 用邊個 web server。

---

## 4. 已經完成咗嘅嘢（本地，Hugo 部機上）

- ✅ 修好咗 Homebrew PATH（`~/.zprofile` 冇 `brew shellenv` 嗰行，已補返）
- ✅ 裝咗 PHP 8.5.8（`brew install php`）
- ✅ 起咗 local PHP built-in server：
  ```bash
  php -d upload_max_filesize=12M -d post_max_size=12M -S 127.0.0.1:8080
  ```
- ✅ 7 個自動化 curl 測試全部通過：正常上載、同名覆蓋、假 PDF 拒收（magic bytes）、超過 10MB 拒收、GET method 拒收、中文檔名清洗、下載返嚟嘅檔案內容驗證
- ✅ Hugo 人手用 `builder.html` 拖放上載、進度條、預覽 iframe、複製連結 —— 全部行為正常

**未做**：實際部署到 `sb.contex.com.hk`。

---

## 5. 接手要做嘅嘢（部署到 sb.contex.com.hk）

呢個係 WordPress / Apache hosting。**呢個工具同 WordPress 完全無關**，淨係借用同一個 web root 底下一個獨立目錄 `pdf-signage/`，四個檔案原封不動上載上去：

```
public_html/pdf-signage/builder.html        ← 客戶工具（登入後用）
public_html/pdf-signage/viewer.html         ← 播放端（公開，唔使登入）
public_html/pdf-signage/login.html          ← 登入頁（帳號 + 密碼 + 2FA 第二步）
public_html/pdf-signage/admin.html          ← Admin 後台（客戶 CRUD + 跨客戶檔案/連結管理）
public_html/pdf-signage/config.php          ← 設定（ADMIN_PASSWORD 只係 bootstrap，見下）
public_html/pdf-signage/auth.php            ← login / logout / 2FA / 改密碼
public_html/pdf-signage/admin-api.php       ← admin 專用 API
public_html/pdf-signage/links.php           ← 固定連結 CRUD + 公開設定讀取（屏幕 poll 呢度）
public_html/pdf-signage/upload.php
public_html/pdf-signage/manage.php
public_html/pdf-signage/qrcode.min.js       ← 本地 QR code 生成（2FA 註冊用，唔靠 CDN）
public_html/pdf-signage/files/.htaccess     ← dotfile，FTP 上載時確認冇被 skip
public_html/pdf-signage/data/.htaccess      ← dotfile；保護 data/ 內所有 json
```

`data/` 入面嘅 json（`clients.json` 客戶帳戶、`links.json` 固定連結、`admin.json` admin 密碼 hash、`lockout.json` 登入鎖定）全部由程式自動建立，唔使上載。**部署後必須驗證 `https://.../pdf-signage/data/clients.json` 直接開係 403**（靠 `data/.htaccess`）。

`/files/` 目錄本身唔使預先建立 —— `upload.php` 第一次上載時會自己 `mkdir()`（[upload.php:79](upload.php:79)），但 `.htaccess` 一定要跟埋去，唔係上載目錄冇保護。

### 部署步驟

1. **上載 4 個檔案**（SSH scp/rsync 或 FTP/SFTP，睇邊種方便）
2. **檢查 PHP 上載上限**：
   ```bash
   php -i | grep -E "upload_max_filesize|post_max_size"
   ```
   兩個都要 ≥ 12M（要大過 10MB 嘅業務上限，留少少 buffer 俾 multipart overhead）。如果唔夠：
   - 試喺 `pdf-signage/` 加 `.user.ini`：
     ```ini
     upload_max_filesize = 12M
     post_max_size = 12M
     ```
   - 或者 Apache 用 `.htaccess` 加：
     ```apache
     php_value upload_max_filesize 12M
     php_value post_max_size 12M
     ```
   - 邊種生效睇 hosting（PHP-FPM vs mod_php），逐樣試，唔得就要去 cPanel／hosting 控制台手動改 PHP 設定
3. **Admin 密碼 bootstrap 流程**：`config.php` 嘅 `ADMIN_PASSWORD`（本地測試值 `admin1234`）只係**首次登入用嘅 bootstrap 密碼**。部署後即刻用佢登入 admin → 「帳戶設定」→ 設定正式密碼（bcrypt hash 存 `data/admin.json`，設定後 bootstrap 密碼自動失效）→ 建議同時開埋 2FA。客戶帳戶用 admin 後台新增。
4. **唔好改動** `upload.php` 嘅驗證邏輯（magic bytes、10MB 上限、檔名清洗）—— 呢啲係刻意設計嘅安全防線。
5. **驗證 `data/` 受保護**：`curl -sI https://.../pdf-signage/data/clients.json` 必須係 403/404，唔可以係 200。

### 部署後驗證（curl）

```bash
# 頁面可達
curl -sI https://sb.contex.com.hk/pdf-signage/builder.html | head -1   # 應該 200
curl -sI https://sb.contex.com.hk/pdf-signage/viewer.html | head -1    # 應該 200

# 上載功能（如有 UPLOAD_KEY 要加 -F "key=<key>"）
curl -s -F "pdf=@/tmp/test.pdf" https://sb.contex.com.hk/pdf-signage/upload.php
# 應該 {"ok":true,...}

# CORS header 生效（假設已上載一個 test.pdf）
curl -sI -H "Origin: https://example.com" https://sb.contex.com.hk/pdf-signage/files/test.pdf | grep -i access-control
# 應該見到 access-control-allow-origin: *

# .htaccess 生效：files/ 目錄唔會執行 PHP
# （手動放一個 .php 入去 files/，用瀏覽器/curl 開應該被拒絕執行，唔係輸出 PHP 執行結果）

# 大檔上限喺 server 都生效
head -c 11000000 /dev/urandom > /tmp/big.bin
{ printf '%%PDF-'; cat /tmp/big.bin; } > /tmp/big.pdf
curl -s -F "pdf=@/tmp/big.pdf" https://sb.contex.com.hk/pdf-signage/upload.php
# 應該 {"ok":false, "error":"...10 MB..."}，唔係 500 error
```

---

## 6. 幾個要留意嘅限制 / 唔算 bug 嘅行為

- **SignPlayer 限制**：HTML component 淨係支援 Desktop PWA 版嘅 SignPlayer，純瀏覽器 Web player 唔支援 —— 呢個係 SignStudio 本身嘅限制，唔係呢個工具嘅 bug。
- **PDF.js 依賴外部 CDN**：`viewer.html` 需要 signage player 個裝置可以連到 `cdnjs.cloudflare.com`。如果部署場地冇外網 / 公司防火牆擋咗 CDN，播放器會 fail —— 到時要 self-host PDF.js（見第 2.3 節做法）。
- **同名覆蓋係 feature**：客人換 PDF 時特登用返同一個檔名，一定會覆蓋（無版本歷史），SignStudio 嗰邊 URL 唔使改。呢個係設計原意，唔好改成自動 rename。
- **冇 database，冇上載歷史**：檔案本身就係唯一紀錄（stateless）。客人可以經 builder 嘅「檔案管理」區列出／刪除自己嘅檔案（見 2.2b），但冇「邊個幾時上載過幾多次」嘅 audit log —— 要嘅話需另外開發。
- **冇 rate limiting / 上載頻率限制**：`upload.php` 冇做 IP throttling。每客配額（20 檔 / 100 MB）已經封頂咗儲存濫用，但冇擋高頻請求；設 `$CLIENT_KEYS` 後風險已好低。

---

## 7. 第二階段功能（已實現，本地已測）

以下三個功能已經寫入程式並喺本地全部測試通過（2026-07-04）。

### 7.1 客戶分類（multi-tenant）✅ 已實現

**背景**：原設計所有客人嘅 PDF 存喺同一個平面 `/files/` 目錄，加上「同名覆蓋」係 feature，客人 A 同客人 B 嘅同名檔案會互相覆蓋。

**實現**（[upload.php](upload.php)）：`$CLIENT_KEYS` 對照表 —— 每個客人一條 key，對應 `/files/` 下一個子資料夾：

```php
$CLIENT_KEYS = [
    'aK3x9mQ2' => 'client-a',   // key => 資料夾名
    'bT7p4wR8' => 'client-b',
];
```

- 有設 `$CLIENT_KEYS`：key 必填，唔啱直接 403；檔案存入 `/files/<client-folder>/`，URL 亦包含子資料夾。唔同客人同名檔案完全隔離（已測）
- `$CLIENT_KEYS` 留空：fallback 返舊模式（`UPLOAD_KEY` 單一密碼，或者兩者都空 = 唔使密碼、存 `/files/` 根目錄）——本地測試用呢個模式
- 資料夾名雖然來自 server-side 對照表，都照樣 sanitize（只允許 `[a-zA-Z0-9_-]`）
- `files/.htaccess` 對子目錄自動生效，唔使改
- **部署時**：填好 `$CLIENT_KEYS`，同時將 [builder.html](builder.html) 嘅 `REQUIRE_KEY` 改 `true`，每個客人發俾佢自己嗰條 key

### 7.2 多個 PDF 逐個播（playlist）✅ 已實現

**Viewer**（[viewer.html](viewer.html)）：新增 `pdfUrls=<url1>,<url2>,...` 參數（逗號分隔；單一 PDF 照舊用 `pdfUrl`，向後兼容）：

- 每份 PDF 播完自己嘅指定頁（`pageRange` 套用喺每一份）就切去下一份，全部播完循環（`loop=no` 就停喺最後一份最後一頁）
- **預載下一份**：播到目前份 PDF 最後一頁時，背景先 fetch 定下一份，切 document 唔會黑屏等 loading
- 有一份 load 失敗會自動跳過佢播下一份，唔會成個 playlist 卡死
- `refreshInterval` 重拉改成：目前顯示嗰份定時重拉；其他份喺輪到佢之前檢查 staleness 再重拉
- Page indicator 顯示 `PDF 2/3 · 4 / 8`（多份先顯示 PDF 序號）

**Builder**（[builder.html](builder.html)）：上載多份 PDF 會自動組成播放清單，UI 有 ▲▼ 調次序、✕ 移除；多過一份自動用 `pdfUrls` 產生連結。進階手動輸入 URL 都係加入清單模式。

**已知限制**：URL 內含逗號嘅 PDF 網址會斷錯（上載嘅檔案唔會有——檔名清洗會轉走逗號；只影響手動輸入嘅外部 URL）。SignStudio HTML component URL 欄嘅長度上限未知，PDF 好多時條連結會好長，真機要試。

### 7.3 觸控翻頁（touch/swipe）✅ 已實現

**Viewer**（[viewer.html](viewer.html)）：預設開啟（`touch=no` 關閉，builder 有選項）：

- 左/右 swipe（或撳屏幕左/右 1/3 位置）= 下/上一頁，跨 document 都翻到（手動模式永遠可以 wrap，唔理 `loop` 設定）
- 一有互動即暫停自動輪播；閒置 `idleResume` 秒（預設 60，可用 query param 改）後自動恢復輪播 —— 標準 kiosk pattern
- 手動模式期間 page indicator 常駐顯示，恢復自動播放先返回「3 秒自動隱藏」行為
- 用 Pointer Events 實現（Chromium 冇問題），`#stage` 加咗 `touch-action: none` 防瀏覽器手勢干擾

**⚠️ 未驗證：真機 touch passthrough** —— SignPlayer 嘅 HTML component 會唔會將 touch event 傳入嵌入網頁，要真機（觸控屏幕 + Desktop PWA SignPlayer）實測。瀏覽器入面已測通過。

### 7.4 檔案管理 + 每客配額 ✅ 已實現

**目的**：有效管理客人喺 server 嘅檔案，防止無限累積。

- **`config.php`（新檔）**：所有設定（`UPLOAD_KEY` / `$CLIENT_KEYS` / 配額）+ 共用 helper 集中一處
- **`manage.php`（新檔）**：list / delete endpoint，key 認證，客人只掂到自己資料夾（詳見 2.2b）
- **`upload.php`**：加每客配額 —— 預設 20 檔 / 100 MB（`config.php` 度改），爆額拒收並提示刪舊檔；同名覆蓋照舊允許（唔佔新配額）
- **`builder.html`**：新增「檔案管理」card —— 載入檔案清單（檔名/大小/日期 + 配額使用量）、「加入清單」（直接加入播放清單，唔使重新上載）、「刪除」（confirm 警告會令播放中屏幕停播；刪除後同步由播放清單移除）
- **刻意唔做**：playlist 移除自動刪檔（一份 PDF 可能俾多條連結用緊，會整黑其他屏幕）；自動過期刪檔（檔案「舊」唔等於「冇屏幕用緊」，viewer 拉檔唔會更新檔案時間，易誤殺）

### 7.5 CMS：登入 + Admin 後台 ✅ 已實現（2026-07-05）

**目的**：唔使再手改 config 管理客戶 —— 客人有 login 頁入去用工具，Hugo 有 admin 身份管理所有客戶同檔案。

**新增檔案：**

| 檔案 | 用途 |
|---|---|
| `login.html` | 統一登入頁：入客戶 key 或 admin 密碼，`auth.php` 自動判斷角色再轉去 builder / admin |
| `auth.php` | login / logout / me（session 狀態）。PHP native session，login 時 `session_regenerate_id` 防 fixation |
| `admin.html` | Admin 後台：新增客戶（自動生成 key）、複製/重設 key、停用/啟用、刪除（可揀連檔案刪定保留）、睇每客用量、展開任何客人嘅檔案清單逐個刪、一鍵以該客戶身份開 builder（`builder.html?folder=x`） |
| `admin-api.php` | admin 專用 API（全部動作要 admin session）|
| `data/clients.json` | 客戶登記冊（名稱/資料夾/key/建立日期/停用狀態），admin 後台讀寫，**必須配 `data/.htaccess` 禁止直接訪問** |

**認證模型**（`config.php` 嘅 `resolve_request_client()`，upload/manage 共用）：

1. **Admin session** → 可以用 `folder` 參數操作任何客人資料夾
2. **客戶 session**（login 咗）→ 鎖死自己資料夾，就算送 `folder` 參數都會被無視（已測）
3. **POST key**（無 session，curl / 自動化用）→ 照舊支援
4. 完全冇設定任何認證 → 開放模式（本地測試用）

**行為細節：**

- 客戶被**停用**：現有 session 即時失效、key 登唔到入，但已上載 PDF 同 viewer 連結照播（想停播要刪檔）
- **重設 key**：舊 key 即刻死，資料夾/檔案/連結不變
- **刪除客戶**：兩段 confirm —— 連檔案刪（屏幕即停）或者只刪帳戶留檔案
- builder 開頁會查 `auth.php action=me`：有認證設定但未登入 → 自動彈去 `login.html`；viewer 同 `files/` 永遠公開（SignPlayer 唔使登入）
- 舊有 `REQUIRE_KEY` flag 同 builder 嘅密碼輸入欄已移除（session 取代）；`config.php` 嘅 `UPLOAD_KEY` / `$CLIENT_KEYS` 留返做 legacy 後備

**安全注意：**

- ⚠️ `config.php` 嘅 `ADMIN_PASSWORD` 本地測試值係 `admin1234`，**部署前必須改**；留空 = 停用 admin 登入
- `data/clients.json` 內有所有客戶 key（明文——需要顯示返俾 admin 睇先發到俾客人），靠 `data/.htaccess`（`Require all denied`）保護，部署後要 curl 驗證
- Session cookie `HttpOnly` + `SameSite=Lax`（擋跨站 POST）

### 7.6 固定連結 + 帳號密碼登入 + 2FA ✅ 已實現（2026-07-09）

客戶反映帶嚟嘅三個升級，全部本地測試通過。**呢節取代 7.5 入面「客戶用 key 登入」嘅描述**（key 登入已移除，改用帳號密碼；`data/clients.json` 唔再存 key）。

**A. 固定連結（Display Links）—— URL 永久不變**

- 顯示設定唔再編碼喺 URL：客人喺 builder 砌好清單 + 效果 → 「儲存為固定連結」→ 生成 `viewer.html?id=<8位id>`，永久不變
- 設定存 `data/links.json`；viewer 開頁憑 id 向 `links.php`（公開 GET）攞設定，**之後每 60 秒 re-poll，一發現有變即自動 reload** —— 客人喺 builder 撳「儲存變更」，所有屏幕一分鐘內跟上，SignStudio 完全唔使掂
- **內容更新都會觸發**（2026-07-09 修正）：`links.php` GET 回應附帶 `filesStamp` —— 連結內每份本機 PDF 嘅 `mtime:size` 指紋。客人**同名覆蓋換檔**（設定 JSON 冇變）指紋照樣變，viewer 對比成個 response 一樣會 reload。修正前換檔要等 `refreshInterval`（預設 30 分鐘）先更新，客人以為要人手 refresh。外部 host 嘅 PDF stat 唔到（指紋 `-`），仍然靠 `refreshInterval`
- 測試用：viewer URL 加 `&configCheck=<秒>`（10-600）可以調快 poll 頻率，預設 60 秒
- Builder 有「我嘅連結」清單：編輯（載入設定修改再儲存）、複製 URL、刪除；admin 喺後台可以睇/刪任何客人嘅連結
- 連結歸屬客人資料夾，客人掂唔到第二個客嘅連結（已測）；刪除客戶時連結一併刪除
- 舊式 query-param URL（`?pdfUrl=...`）繼續支援，已貼咗嘅屏幕唔會壞

**B. 帳號 + 密碼登入**

- 每客一個 `username` + `password_hash`（PHP `password_hash` bcrypt，唔存明文）；登入頁改兩欄
- Admin 新增客戶時可以指定或自動生成密碼（**只喺回應顯示一次**）；「重設密碼」同樣只顯示一次
- 舊 key 年代嘅客戶紀錄：admin 撳「重設密碼」會自動補帳號（=資料夾名）並清走舊 key 欄位
- 客人登入後可以喺 builder「安全設定」自己改密碼；admin 喺「帳戶設定」改自己密碼
- **防暴力**：同一帳號連錯 5 次鎖 10 分鐘（`data/lockout.json` 檔案計數，2FA 步驟同樣受限）

**C. TOTP 2FA（自選，admin 建議開）**

- RFC 6238 標準 TOTP（Google/Microsoft Authenticator 通用），純 PHP 實現，**已用 Python 獨立實現交叉驗證算法正確**；接受前後 30 秒時鐘偏差
- 註冊流程：登入 → 安全設定 → 設定 2FA → 掃 QR（`qrcode.min.js` 本地生成，冇外部請求）→ 輸入 6 位數確認 → 啟用
- 登入流程：帳密啱 → 有開 2FA 就入第二步輸 6 位數；**只過咗第一步嘅 pending session 咩都做唔到**（已測）
- Admin 後台可以幫跌咗電話嘅客人「重設2FA」；admin 帳號都可以開 2FA
- 客人自己關 2FA 要輸入目前有效嘅 6 位數

**測試紀錄（2026-07-09）**：TOTP 算法 PHP/Python 交叉驗證 ✅；admin bootstrap 登入 → 設正式密碼 → bootstrap 即失效 ✅；新增客戶（自動密碼）✅；舊紀錄升級 ✅；客戶帳密登入 + session 上載 ✅;錯密碼×5 鎖定 ✅；連結 save/list/公開GET/更新/刪除 + 跨客戶擁有權拒絕 ✅；2FA 註冊（錯碼拒/啱碼過）→ 重新登入 need2fa → 錯碼拒/啱碼過 → pending session 無權限 ✅；viewer id 模式 render + 60 秒 config re-poll + builder 編輯→儲存→URL 不變→設定即時更新 ✅（注：headless/隱藏 tab 下 PDF.js render 會停喺 rAF，真實屏幕唔受影響）

### 7.7 Builder 流程重構：看板優先 + 產品改名 ✅ 已實現（2026-07-13）

- **產品改名**：「數位看板」→「PDF 播放工具」（builder / admin / login 嘅 title 同 header chip；「看板連結」等功能用語保留）
- **流程改為看板優先**（[builder.html](builder.html)）：步驟 1 選擇／建立看板 → 步驟 2 上載 PDF → 步驟 3 顯示設定 → 步驟 4 儲存並安裝。未選看板時 dropzone／手動 URL／「加入清單」全部鎖定並提示
- **只提供固定看板連結**：唔再對外顯示臨時 `?pdfUrl=` 連結；「複製看板連結」同 QR 只會用 `viewer.html?id=<id>`。臨時 query-param URL 只用喺內部即時預覽（反映未儲存編輯）。舊式 `?pdfUrl=` viewer URL 照樣支援，已貼屏幕唔會壞
- **允許空看板**：[links.php](links.php) `save` 唔再要求 settings 有 PDF —— 建立看板嗰刻連結即時生成；[viewer.html](viewer.html) 對空看板（id 模式）顯示「此看板尚未有內容」，config poll 照行，儲存內容後一分鐘內自動開播
- **未儲存變更提示**：內容／設定改動會出「有未儲存的變更」標籤；切換看板、離開編輯、建立新看板、關閉頁面（beforeunload）都會 confirm
- **即時預覽方向切換**：預覽卡加「橫屏 16:9／直屏 9:16」掣（直屏限 320px 闊），配合整頁顯示模式一定見到成頁內容
- **檔案管理併入步驟 2**（2026-07-13 第二輪）：獨立「檔案管理」card 取消，改為步驟 2 內「已上載的檔案」區；清單**只顯示未加入播放清單嘅檔案**（加入即消失、移除即重現），開頁自動載入（登入後同開放模式都係），配額顯示 + 「重新整理」細掣保留
- **測試（2026-07-13，sandbox 開放模式）**：未選看板上載被鎖 ✅；建立空看板 → 連結即生成 → viewer 顯示「尚未有內容」✅；加入內容儲存 → viewer config poll 自動 reload 開播 ✅；切換看板載入設定 + highlight ✅；dirty 標籤出現／儲存後清除 ✅；預覽橫直切換 ✅；links.php curl（空 settings save／GET／update／delete／無名拒收）✅

### 本地測試紀錄（2026-07-04）

- upload.php：無 key 模式向後兼容 ✅；multi-tenant 模式 —— 無 key/錯 key 403 ✅、兩個客人同名檔案各自隔離 ✅、同客人重覆上載 `overwritten:true` ✅（測完已將 `$CLIENT_KEYS` 還原做空）
- viewer playlist：兩份多頁 PDF（`/files/doc-a.pdf` 3頁、`/files/doc-b.pdf` 2頁，測試檔仍在）自動輪播、跨 document 切換 ✅、builder 產生嘅 `%2C` 編碼格式解析正常 ✅、console 零錯誤 ✅
- 觸控：模擬 pointer event swipe 前後翻頁 ✅、手動模式暫停自動播 ✅、indicator 常駐 ✅
- builder：清單加入/調序/移除 ✅、單份 fallback 返 `pdfUrl` ✅、`touch=no` 只喺關閉時先加入連結 ✅
- manage.php：list（連配額）✅、delete ✅、刪唔存在檔 404 ✅、path traversal（`../upload.php`）拒絕 ✅、GET 405 ✅、multi-tenant 隔離（client-b 刪唔到 client-a 嘅檔）✅
- 配額：檔案數爆額拒收 ✅、byte 爆額拒收 ✅、同名覆蓋唔受檔案數限制 ✅（測完限額已還原 20 檔 / 100 MB）
- builder 檔案管理 UI：載入清單 + 配額顯示 ✅、「加入清單」✅、刪除（confirm → server 刪檔 → 清單即時更新 → 播放清單同步移除）✅

**CMS 測試（2026-07-05）：**

- auth：admin 登入（錯密碼拒/啱密碼過）✅、客戶 key 登入 ✅、me/logout ✅
- 權限：無 session 用 admin-api 403 ✅、客戶 session 用 admin-api 403 ✅、客戶送 folder 參數被無視（鎖自己資料夾）✅、registry 有客戶後無認證上載被拒 ✅
- 客戶 CRUD：新增（key 自動生成）✅、資料夾名格式/重複拒絕 ✅、停用（session 即時失效 + key 登唔到入）✅、重新啟用 ✅、重設 key（舊 key 即死新 key 即用）✅、刪除連檔案 ✅
- Session 上載：客戶登入後唔使 key 上載/管理 ✅；admin 用 folder 參數代客上載/列檔/刪檔 ✅；POST key 無 session 照用（curl 兼容）✅
- UI 全流程：未登入開 builder 自動彈去 login ✅、admin 登入入後台（列表/新增/檔案瀏覽）✅、登出 → 客戶 key 登入 → builder 顯示客戶名 + 檔案清單鎖自己資料夾 ✅、console 零錯誤 ✅

---

## 8. 完整 demo（跑完 curl 驗證後補一條真連結俾 Hugo）

部署完成後，用 `test.pdf` 上載一次，攞返嘅 `url` 砌成完整 viewer 連結，例如：

```
https://sb.contex.com.hk/pdf-signage/viewer.html?pdfUrl=https://sb.contex.com.hk/pdf-signage/files/test.pdf&pageDuration=10&fitMode=fit-page
```

貼呢條連結俾 Hugo 用真機 SignPlayer 做最後驗證。
