Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ npm-debug.log*
database_backup_*.json
.env
backend/.env
frontend/dist/

# Playwright
e2e/playwright-report/
Expand Down
23 changes: 18 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ MongoDB 集合清單(`scheduleApp` 資料庫):
- `holidays`:台灣國定假日(`_id` 為日期字串如 `2025-01-01`)
- `schoolEvents`:學校行事曆快取(`_id` 為學期代碼如 `1142`,7 天 TTL)
- `scheduleShares`:班表分享連結(`_id` 為隨機 token,記錄對應的 profile/scheduleName/personFilter,無過期機制)
- `pdfPayloadKeyState`:PDF 隱藏資料加密目前的金鑰版本(單一文件,`_id` 固定為 `pdf_payload_key_state`),記錄 `currentVersion`、`rotatedAt`,每 30 天自動輪替

---

Expand All @@ -92,17 +93,19 @@ backend/
app.js # Express 設定、middleware、路由掛載
config.js # 環境變數
validators.js # 輸入驗證(profile/schedule 名稱、settings)
db/connect.js # MongoDB 連線管理(profiles、holidays、schoolEvents、scheduleShares 四個 collection)
db/connect.js # MongoDB 連線管理(profiles、holidays、schoolEvents、scheduleShares、pdfPayloadKeyState 五個 collection)
routes/ # status, holidays, profiles, schedules, generate, schoolCalendar, pdfPayload, shares
services/
scheduleAlgorithm.js # 排班核心演算法(純函數)
scheduleRenderer.js # HTML 渲染
holidayService.js # 假日快取、CDN 更新
schoolCalendar.js # 學校行事曆(記憶體 6h + MongoDB 7 天持久快取)
pdfPayloadCrypto.js # PDF 匯入/匯出隱藏資料的 AES-256-GCM 加解密(金鑰來自 PDF_PAYLOAD_SECRET)
pdfPayloadCrypto.js # PDF 匯入/匯出隱藏資料的 AES-256-GCM 加解密(Root Key + HKDF 版本衍生,見下方環境變數說明)
pdfPayloadKeyRotation.js # PDF 加密金鑰版本的記憶體快取 + 每 30 天自動輪替(DB 為真相來源,跟 holidayService 同一種快取模式)
repositories/
profileRepository.js # MongoDB CRUD(profiles、schedules)
shareRepository.js # 班表分享連結的 token 產生/查詢(scheduleShares collection)
pdfPayloadKeyStateRepository.js # PDF 加密目前金鑰版本狀態的讀寫(pdfPayloadKeyState collection)
tests/

frontend/
Expand Down Expand Up @@ -134,7 +137,7 @@ frontend/
scheduleCompare.js # 比較已儲存班表 Modal(人員異動/填補率/勤務設定差異)
exportWeekFilter.js # 匯出週次篩選(getExportData,供複製/Excel/PDF/人員Excel 共用)
personTaskStats.js # 值勤統計 Modal(人員 × 勤務次數,加總 + 每週明細,Excel/PDF 匯出共用同一份計算)
pdfExport.js # PDF 匯出(html2canvas + jsPDF,支援每頁 N 週自動分頁,末頁附值勤統計;若後端有設定 PDF_PAYLOAD_SECRET 會額外把班表資料加密嵌入 PDF metadata)
pdfExport.js # PDF 匯出(html2canvas + jsPDF,支援每頁 N 週自動分頁,末頁附值勤統計;若後端有設定 PDF_PAYLOAD_ROOT_SECRET 會額外把班表資料加密嵌入 PDF metadata)
pdfImport.js # 從匯出的 PDF 檔案讀出隱藏資料還原班表(需搭配 pdf-lib 解析 metadata + 後端解密)
scheduleShare.js # 分享班表 Modal:產生免登入連結(可選只分享單一人員、可設有效期限),並列出/撤銷已產生的連結
sharedView.js # 免登入分享連結的頁面(接管整個 document.body,不含後台功能)
Expand Down Expand Up @@ -198,11 +201,21 @@ MONGODB_URI=mongodb+srv://<user>:<password>@<cluster>.mongodb.net/
DB_NAME=scheduleApp # 預設值
PORT=3000 # 預設值
CORS_ORIGIN=* # 預設值;未設定時 config.js 也會 fallback 成 *,僅適合開發環境
PDF_PAYLOAD_SECRET= # 選填,64 字元 hex(32 bytes)。未設定時 PDF 匯入/匯出隱藏資料功能自動停用,其他功能不受影響
PDF_PAYLOAD_SECRET= # 選填,改版前的舊制單一金鑰,現在只用來解密沒有版號的舊格式 PDF
PDF_PAYLOAD_ROOT_SECRET= # 選填,64 字元 hex(32 bytes),唯一需要手動備份的值,見下方說明
PDF_PAYLOAD_KEK_CURRENT=v1 # 選填,只在 MongoDB 完全沒有版本狀態時當「初始種子值」用一次,之後由 DB 自動輪替接管
```

若未提供 `MONGODB_URI`,伺服器仍會啟動,但所有資料庫功能停用(會顯示警告)。
若未提供 `PDF_PAYLOAD_SECRET`,`/api/pdf-payload/encrypt`、`/api/pdf-payload/decrypt` 會回傳 503,前端的 PDF 匯入按鈕與匯出時的隱藏資料嵌入都會靜默跳過(不影響一般匯出/檢視)。

**PDF 隱藏資料加密機制(Root Key + HKDF 版本衍生,含每 30 天自動輪替)**:三個環境變數都選填,完全不設定就整個功能停用(`/api/pdf-payload/encrypt`、`/api/pdf-payload/decrypt` 回 503),前端的 PDF 匯入按鈕與匯出時的隱藏資料嵌入都會靜默跳過,不影響一般匯出/檢視。

- 加密**新資料**只用 `PDF_PAYLOAD_ROOT_SECRET` + 「目前版本號」:實際 KEK=`HKDF(Root, info=版本號)` 現場算出來,不另外存成環境變數。
- **目前版本號存在 MongoDB(`pdfPayloadKeyState` collection),不是環境變數**:`pdfPayloadKeyRotation.js` 在伺服器啟動時初始化記憶體快取(DB 已有狀態就直接載入;DB 是空的才會讀一次 `PDF_PAYLOAD_KEK_CURRENT` 當初始種子值,之後這個環境變數就不會再被讀取),並且每 30 天自動把版本號往上遞增(例如 v1→v2)、寫回 DB——不需要手動改任何環境變數或重啟伺服器。啟動時與每 24 小時都會檢查一次是否已到 30 天(若伺服器曾經關機超過 30 天,啟動時會立刻補做一次輪替)。目前版本、上次輪替時間、下次預計輪替時間可從 `GET /api/status` 的 `pdfPayloadKeyRotation` 欄位查看。只要 `PDF_PAYLOAD_ROOT_SECRET` 沒變,舊版本號加密過的 PDF 換了幾輪版本都一樣算得出對應 KEK、正常解密。
- MongoDB 未連線時,「目前版本號」無法初始化/輪替,等同 `isConfigured()` 為 false(無法加密新資料),但解密已存在的 PDF 不受影響(解密走的是 payload 自帶的版號 + Root 現算 KEK,不依賴 DB;v0 舊格式更是完全不需要 DB 或 Root,只需要 `PDF_PAYLOAD_SECRET`)。
- `PDF_PAYLOAD_SECRET`(改版前的舊制單一金鑰)現在**只**用來解密「沒有版號欄位」的舊格式 PDF(視為隱含 v0,金鑰直接當 KEK 用、不經 HKDF),不會再用來加密任何新資料。這是唯一的重點:只要這批舊 PDF 還有人在用,這個環境變數就不能拿掉。
- **唯一需要手動備份的值是 `PDF_PAYLOAD_ROOT_SECRET`**(存進密碼管理器/雲端筆記,跟主機分開放)。它一旦產生就永遠不能變、不能重新產生——重新產生等於所有版本的 KEK 一起失效,所有 Root 機制下匯出的 PDF 全部解不開。
- 詳細機制見 `backend/src/services/pdfPayloadCrypto.js`、`backend/src/services/pdfPayloadKeyRotation.js` 開頭的註解與 `backend/.env.example`。

---

Expand Down
20 changes: 18 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,23 @@ DB_NAME=scheduleApp
PORT=3000
CORS_ORIGIN=*

# 選填:PDF 匯入/匯出的隱藏資料加密金鑰。未設定時該功能停用(其他功能不受影響)。
# 必須是 64 字元 hex 字串(32 bytes),可用下列指令產生:
# PDF 匯入/匯出的隱藏資料加密——Root Key + HKDF 版本衍生機制。三者都選填,
# 完全不設定的話該功能停用,其他功能不受影響。

# 舊制單一金鑰(改版前的機制)。現在只用來解密「改版前就已經匯出、沒有版號欄位」
# 的舊格式 PDF,不會再用來加密任何新資料。若沒有這種舊 PDF 需要相容,可以不設定。
# 64 字元 hex 字串(32 bytes):
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
PDF_PAYLOAD_SECRET=

# 唯一需要手動備份的值(存進密碼管理器/雲端筆記,跟主機分開放)。產生後永遠不變、
# 不重新產生——重新產生等於所有版本的金鑰全部跟著變,舊 PDF 全部解不開。
# 64 字元 hex 字串(32 bytes),產生方式同上。
PDF_PAYLOAD_ROOT_SECRET=

# 目前加密要用哪一版的「初始值」,例如 v1、v2……只有資料庫裡完全沒有版本狀態
# (系統第一次啟用這個機制)時才會讀這個值當起點,之後 DB 就是唯一的真相來源,
# 這個環境變數不會再被讀取——版本會每 30 天自動輪替,不需要手動改這裡或重啟。
# 實際加解密用的金鑰=HKDF(PDF_PAYLOAD_ROOT_SECRET, info=版本號) 現場算出來,
# 不另外存成環境變數。
PDF_PAYLOAD_KEK_CURRENT=v1
8 changes: 8 additions & 0 deletions backend/server.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const debug = require('debug');
const { connect, disconnect, ensureConfigDocument } = require('./src/db/connect');
const { seedHolidays, refreshHolidaysFromCDN } = require('./src/services/holidayService');
const { initKeyState, rotateIfDue } = require('./src/services/pdfPayloadKeyRotation');
const app = require('./src/app');
const { PORT, MONGODB_URI } = require('./src/config');

Expand All @@ -22,6 +23,13 @@ const startServer = async () => {
await refreshHolidaysFromCDN();
setInterval(refreshHolidaysFromCDN, 24 * 60 * 60 * 1000);

// PDF 隱藏資料加密金鑰版本:啟動時載入/初始化狀態,之後每天檢查一次是否
// 已達 30 天輪替間隔(真正的輪替判斷在 rotateIfDue() 內部,這裡只是固定
// 頻率去問一次,不代表每天都會真的輪替)。
await initKeyState();
await rotateIfDue();
setInterval(rotateIfDue, 24 * 60 * 60 * 1000);

app.listen(PORT, () => {
debugServer(`伺服器正在 http://localhost:${PORT} 上運行`);
});
Expand Down
11 changes: 10 additions & 1 deletion backend/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@ const PORT = process.env.PORT || 3000;
const MONGODB_URI = process.env.MONGODB_URI;
const DB_NAME = process.env.DB_NAME || 'scheduleApp';
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
// PDF 匯入/匯出的隱藏資料加密金鑰:64 字元 hex 字串(32 bytes)。未設定時該功能停用。
// PDF 匯入/匯出隱藏資料加密——Root Key + HKDF 版本衍生機制:
// - PDF_PAYLOAD_SECRET:改版前的舊制單一金鑰,現在只作為 v0(無版號的舊格式)解密相容用,
// 不再用來加密新資料。
// - PDF_PAYLOAD_ROOT_SECRET:唯一需要手動備份的值,永遠不變、不重新產生。
// - PDF_PAYLOAD_KEK_CURRENT:目前加密要用哪一版(例如 "v1"),換版本只改這個值。
// 實際加解密用的金鑰=HKDF(PDF_PAYLOAD_ROOT_SECRET, info=該版本號) 現場算出來,不另外存。
const PDF_PAYLOAD_SECRET = process.env.PDF_PAYLOAD_SECRET;
const PDF_PAYLOAD_ROOT_SECRET = process.env.PDF_PAYLOAD_ROOT_SECRET;
const PDF_PAYLOAD_KEK_CURRENT = process.env.PDF_PAYLOAD_KEK_CURRENT;

module.exports = {
SAFE_PROFILE_NAME_REGEX,
Expand All @@ -22,4 +29,6 @@ module.exports = {
DB_NAME,
CORS_ORIGIN,
PDF_PAYLOAD_SECRET,
PDF_PAYLOAD_ROOT_SECRET,
PDF_PAYLOAD_KEK_CURRENT,
};
5 changes: 5 additions & 0 deletions backend/src/db/connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ let configCollection;
let holidaysCollection;
let schoolEventsCollection;
let scheduleSharesCollection;
let pdfPayloadKeyStateCollection;
let isDbConnected = false;

if (MONGODB_URI) {
Expand Down Expand Up @@ -41,6 +42,8 @@ const getSchoolEventsCollection = () => schoolEventsCollection;

const getScheduleSharesCollection = () => scheduleSharesCollection;

const getPdfPayloadKeyStateCollection = () => pdfPayloadKeyStateCollection;

const connect = async () => {
if (!client) return;
debugServer('正在連線至 MongoDB...');
Expand All @@ -52,6 +55,7 @@ const connect = async () => {
holidaysCollection = db.collection('holidays');
schoolEventsCollection = db.collection('schoolEvents');
scheduleSharesCollection = db.collection('scheduleShares');
pdfPayloadKeyStateCollection = db.collection('pdfPayloadKeyState');
// TTL index:只會刪除「有 expiresAt 欄位且已過期」的文件,沒有這個欄位的永久
// 分享連結不受影響。MongoDB 背景清除任務約每 60 秒跑一次,不是精準即時刪除,
// 所以 GET /api/schedule-shares/:token 仍須自行檢查 expiresAt,不能只靠這個 index。
Expand Down Expand Up @@ -96,6 +100,7 @@ module.exports = {
getHolidaysCollection,
getSchoolEventsCollection,
getScheduleSharesCollection,
getPdfPayloadKeyStateCollection,
connect,
disconnect,
ensureConfigDocument,
Expand Down
20 changes: 20 additions & 0 deletions backend/src/repositories/pdfPayloadKeyStateRepository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const { getPdfPayloadKeyStateCollection } = require('../db/connect');

const STATE_ID = 'pdf_payload_key_state';

const getState = async () => {
const collection = getPdfPayloadKeyStateCollection();
return collection.findOne({ _id: STATE_ID });
};

// upsert:第一次呼叫(尚無文件)會建立,之後每次輪替都是更新同一筆文件。
const setState = async (currentVersion, rotatedAt) => {
const collection = getPdfPayloadKeyStateCollection();
await collection.updateOne(
{ _id: STATE_ID },
{ $set: { currentVersion, rotatedAt } },
{ upsert: true }
);
};

module.exports = { getState, setState };
8 changes: 5 additions & 3 deletions backend/src/routes/pdfPayload.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

const express = require('express');
const debug = require('debug')('app:pdfPayload');
const { encryptPayload, decryptPayload, isConfigured } = require('../services/pdfPayloadCrypto');
const { encryptPayload, decryptPayload, isConfigured, isDecryptionPossible } = require('../services/pdfPayloadCrypto');

const router = express.Router();

Expand All @@ -20,9 +20,11 @@ router.post('/api/pdf-payload/encrypt', (req, res) => {
}
});

// 把從 PDF metadata 讀出的 base64 字串解密還原成班表資料。
// 把從 PDF metadata 讀出的字串解密還原成班表資料。
// 這裡刻意用 isDecryptionPossible() 而非 isConfigured():只設定了舊制
// PDF_PAYLOAD_SECRET、還沒設定 Root 的部署,仍然要能解開改版前匯出的舊格式 PDF。
router.post('/api/pdf-payload/decrypt', (req, res) => {
if (!isConfigured()) return res.status(503).json({ message: 'PDF 加密功能未設定' });
if (!isDecryptionPossible()) return res.status(503).json({ message: 'PDF 加密功能未設定' });
try {
const { payload } = req.body;
if (typeof payload !== 'string' || !payload) {
Expand Down
7 changes: 4 additions & 3 deletions backend/src/routes/schedules.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const express = require('express');
const debug = require('debug');
const { getIsDbConnected } = require('../db/connect');
const { validateScheduleName, validateProfileName } = require('../validators');
const { validateScheduleName, validateProfileName, validateScheduleData } = require('../validators');
const repo = require('../repositories/profileRepository');

const debugDb = debug('app:db');
Expand All @@ -18,8 +18,9 @@ router.post('/api/schedules', async (req, res) => {
return res.status(400).json({ message: validation.error });
}

if (!Array.isArray(data) || data.length === 0) {
return res.status(400).json({ message: '班表數據必須是非空數組' });
const dataValidation = validateScheduleData(data);
if (!dataValidation.valid) {
return res.status(400).json({ message: dataValidation.error });
}

// 優先使用請求帶來的 profile,向下兼容未帶 profile 的舊呼叫
Expand Down
4 changes: 4 additions & 0 deletions backend/src/routes/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const express = require('express');
const { getIsDbConnected, getHolidaysCollection, getConfigCollection } = require('../db/connect');
const { holidaysCache, lastRefreshStatus } = require('../services/holidayService');
const { getLastFetchStatus } = require('../services/schoolCalendar');
const { getRotationStatus } = require('../services/pdfPayloadKeyRotation');

const router = express.Router();

Expand All @@ -15,6 +16,9 @@ router.get('/api/status', async (req, res) => {
// 讓人可以直接從 /api/status 看出這兩個機制是不是還正常。
holidaysLastRefresh: lastRefreshStatus,
schoolCalendarLastFetch: getLastFetchStatus(),
// PDF 隱藏資料加密金鑰版本目前輪替到哪一版、上次/下次輪替時間;DB 未連線
// 或狀態尚未初始化時全部欄位為 null,不代表輪替失敗,只是還沒開始追蹤。
pdfPayloadKeyRotation: getRotationStatus(),
};

if (isDbConnected) {
Expand Down
Loading
Loading