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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,8 @@ frontend/
scheduleCompare.js # 比較已儲存班表 Modal(人員異動/填補率/勤務設定差異)
exportWeekFilter.js # 匯出週次篩選(getExportData,供複製/Excel/PDF/人員Excel 共用)
personTaskStats.js # 值勤統計 Modal(人員 × 勤務次數,加總 + 每週明細,Excel/PDF 匯出共用同一份計算)
pdfExport.js # PDF 匯出(html2canvas + jsPDF,支援每頁 N 週自動分頁,末頁附值勤統計;若後端有設定 PDF_PAYLOAD_ROOT_SECRET 會額外把班表資料加密嵌入 PDF metadata)
pdfImport.js # 從匯出的 PDF 檔案讀出隱藏資料還原班表(需搭配 pdf-lib 解析 metadata + 後端解密)
pdfExport.js # PDF 匯出(html2canvas + jsPDF,支援每頁 N 週自動分頁,末頁附值勤統計;若後端有設定 PDF_PAYLOAD_ROOT_SECRET 會額外把 { schedule, settings }(班表+目前設定檔的人員/勤務設定)加密嵌入 PDF metadata)
pdfImport.js # 從匯出的 PDF 檔案讀出隱藏資料還原班表(需搭配 pdf-lib 解析 metadata + 後端解密);若內含 settings 會詢問要套用到目前設定檔還是建立新設定檔(splitPayload() 相容改版前只有班表陣列的舊格式 PDF)
scheduleShare.js # 分享班表 Modal:產生免登入連結(可選只分享單一人員、可設有效期限),並列出/撤銷已產生的連結
sharedView.js # 免登入分享連結的頁面(接管整個 document.body,不含後台功能)
scheduleGenerator.js # 前端排班產生流程
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/features/schedule/pdfExport.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,14 @@ export async function exportToPdf() {
activeStyle = null;
}

// 若後端有設定 PDF_PAYLOAD_SECRET,把這份班表資料加密後嵌入 PDF 的 keywords 欄位——
// 一般開啟 PDF 完全看不到,只有本系統的「從 PDF 匯入班表」能解密還原。後端未設定
// 若後端有設定 PDF_PAYLOAD_SECRET,把這份班表資料連同目前設定檔的人員/勤務設定
// 一起加密後嵌入 PDF 的 keywords 欄位——一般開啟 PDF 完全看不到,只有本系統的
// 「從 PDF 匯入班表」能解密還原(連設定檔一起還原,不是只有班表本身)。後端未設定
// 該金鑰、或加密請求失敗,都靜默略過,不影響 PDF 本身正常匯出。
const keywords = await buildKeywordsWithPayload(exportData);
const keywords = await buildKeywordsWithPayload({
schedule: exportData,
settings: getActiveProfile()?.settings || null,
});
if (keywords) pdf.setProperties({ keywords });

pdf.save(buildExportFilename(exportData, 'pdf'));
Expand Down
53 changes: 50 additions & 3 deletions frontend/src/features/schedule/pdfImport.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,51 @@
import { setGeneratedData, setCurrentScheduleName } from '../../state/appState.js';
import { setGeneratedData, setCurrentScheduleName, getAppState, setAppState } from '../../state/appState.js';
import { extractScheduleFromPdfBytes } from '../../utils/pdfPayload.js';
import { api } from '../../api/client.js';
import { showToast } from '../../ui/toast.js';
import { showConfirm, showInput } from '../../ui/modal.js';
import { renderAll } from '../settings/settingsRenderer.js';
import { displaySchedule } from './scheduleGenerator.js';

// 匯出時的隱藏資料格式是 { schedule, settings };改版前(舊格式)匯出的 PDF
// 沒有這層包裝,data 本身就是班表陣列——沒有 settings 可還原,只還原班表。
export const splitPayload = (data) => {
if (Array.isArray(data)) return { schedule: data, settings: null };
return { schedule: data?.schedule ?? null, settings: data?.settings ?? null };
};

// PDF 內含設定檔時,問使用者要套用到目前設定檔,還是另外建立一個新的;
// 都不要就維持原設定檔不動(僅還原班表)。重新抓一次 profiles 列表+重繪,
// 讓左側設定檔/人員/勤務 UI 跟後端同步(不呼叫 main.js 的 initApp,避免
// 循環 import 和重複啟動狀態檢查計時器等 initApp 專屬的一次性副作用)。
const applyImportedSettings = async (settings) => {
const applyToCurrent = await showConfirm(
`這份 PDF 內含設定檔(人員/勤務設定),要套用到目前的設定檔「${getAppState().activeProfile}」嗎?`
);

if (applyToCurrent) {
await api.put(`profiles/${getAppState().activeProfile}`, { settings });
showToast('已套用設定檔內容', 'success');
} else {
const name = await showInput('建立新設定檔', '');
if (!name) return;
if (getAppState().profiles[name]) {
showToast('該名稱已存在,設定檔內容未套用(僅還原班表)', 'warning');
return;
}
const created = await api.post('profiles', { name, settings });
if (!created) return;
await api.put('profiles/active', { name });
showToast(`已建立設定檔「${name}」`, 'success');
}

const data = await api.get('profiles');
if (data?.profiles) {
setAppState({ activeProfile: data.activeProfile, profiles: data.profiles });
sessionStorage.setItem('activeProfile', data.activeProfile);
renderAll();
}
};

// 從一份由本系統匯出的 PDF 檔案讀出隱藏(加密)的班表資料並還原到畫面上,
// 效果等同重新產生一份班表:不綁定任何已儲存班表名稱,使用者可另外選擇「儲存班表」。
export async function importScheduleFromPdfFile(file) {
Expand All @@ -14,9 +56,14 @@ export async function importScheduleFromPdfFile(file) {
return;
}

setGeneratedData(result.data);
const { schedule, settings } = splitPayload(result.data);
if (settings) {
await applyImportedSettings(settings);
}

setGeneratedData(schedule);
setCurrentScheduleName(null);
const response = await api.post('render-schedule', result.data);
const response = await api.post('render-schedule', schedule);
if (response?.html) {
displaySchedule(response.html);
showToast('已從 PDF 匯入班表!', 'success');
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/features/schedule/pdfImport.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { splitPayload } from './pdfImport.js';

describe('splitPayload', () => {
it('舊格式(純班表陣列)沒有 settings 可還原', () => {
const legacy = [{ week: 1 }];
expect(splitPayload(legacy)).toEqual({ schedule: legacy, settings: null });
});

it('新格式({ schedule, settings })正確拆出兩部分', () => {
const schedule = [{ week: 1 }];
const settings = { tasks: [], personnel: [] };
expect(splitPayload({ schedule, settings })).toEqual({ schedule, settings });
});

it('新格式但沒有 settings 欄位時 settings 為 null', () => {
const schedule = [{ week: 1 }];
expect(splitPayload({ schedule })).toEqual({ schedule, settings: null });
});

it('新格式但 settings 為 null(匯出時沒有作用中設定檔)時保持 null', () => {
const schedule = [{ week: 1 }];
expect(splitPayload({ schedule, settings: null })).toEqual({ schedule, settings: null });
});
});
7 changes: 5 additions & 2 deletions frontend/src/utils/escapeHtml.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
/**
* HTML 轉義,防止 XSS 注入。
* 一律先用 String() 轉成字串再轉義,不能只在 typeof === 'string' 時才處理——
* 否則陣列/物件會原樣通過這個函式,後面樣板字串插值時呼叫它們自己的
* toString()(例如陣列會 join 內容),繞過所有跳脫直接把原始 HTML 注入。
* @param {*} unsafe
* @returns {*}
*/
export const escapeHtml = (unsafe) => {
if (typeof unsafe !== 'string') return unsafe;
return unsafe
if (unsafe === null || unsafe === undefined) return unsafe;
return String(unsafe)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/utils/escapeHtml.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { escapeHtml } from './escapeHtml.js';

describe('escapeHtml', () => {
it('轉義 &', () => expect(escapeHtml('a&b')).toBe('a&amp;b'));
it('轉義 <', () => expect(escapeHtml('<script>')).toBe('&lt;script&gt;'));
it('轉義 >', () => expect(escapeHtml('x>y')).toBe('x&gt;y'));
it('轉義 "', () => expect(escapeHtml('"hello"')).toBe('&quot;hello&quot;'));
it("轉義 '", () => expect(escapeHtml("it's")).toBe('it&#039;s'));
it('安全字串不變', () => expect(escapeHtml('hello world 123')).toBe('hello world 123'));
it('數字轉成字串後回傳(樣板字串插值結果不變)', () => expect(escapeHtml(42)).toBe('42'));
it('null/undefined 原樣回傳', () => {
expect(escapeHtml(null)).toBe(null);
expect(escapeHtml(undefined)).toBe(undefined);
});
it('陣列型別會被字串化後轉義,不能繞過跳脫(曾經是真實可利用的漏洞)', () => {
const result = escapeHtml(['<img src=x onerror=alert(1)>']);
expect(result).not.toContain('<img');
expect(result).toContain('&lt;img');
});
it('物件型別會被字串化後轉義,不會原樣輸出', () => {
const result = escapeHtml({ toString: () => '<script>alert(1)</script>' });
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
});
Loading