diff --git a/README.md b/README.md index ec69a61..972076f 100644 --- a/README.md +++ b/README.md @@ -125,16 +125,22 @@ https://你的網域或 NAS IP:3501 Docker Compose 會掛載以下目錄: ```text -/volume1/homes/JianMiau/www/certificate/ +/volume1/docker/certs/ ``` 需包含: -- `RSA-cert.pem` -- `RSA-chain.pem` -- `RSA-privkey.pem` +- `cert.pem` +- `chain.pem` +- `privkey.pem` -之後只要更新這個目錄內的憑證檔案,再重新部署容器即可套用新 SSL。 +nginx 容器會監看這個目錄,更新憑證檔案後會自動重新載入,不需要重啟容器。 + +### 憑證到期偵測 + +- 後端會讀取 `cert.pem` 的到期日,透過 `/api/version` 與 `/api/health` 回報給前端。 +- 前端會把到期日存在 localStorage;當 API 出現 `Failed to fetch` 時,若存下來的到期日已過,會明確顯示「SSL 憑證已於某日過期」而不是通用錯誤。 +- 憑證到期前 `7` 天,頁面會跳出提醒更新憑證的通知。 ## 資料表格式 diff --git a/docker-compose.yml b/docker-compose.yml index c289881..8ed17a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,10 @@ services: DB_DATABASE: ${DB_DATABASE:-badminton} DB_TABLE: ${DB_TABLE:-badminton} DB_HISTORY_TABLE: ${DB_HISTORY_TABLE:-history} + SSL_CERT_DIR: /certs + SSL_CERT_FILE_NAME: ${SSL_CERT_FILE_NAME:-cert.pem} + volumes: + - /volume1/docker/certs:/certs:ro badminton-scoreboard-web: container_name: badminton-scoreboard-web @@ -33,10 +37,10 @@ services: NGINX_PORT: 3501 NGINX_SERVER_NAME: ${NGINX_SERVER_NAME:-_} SSL_CERT_DIR: /etc/nginx/certs - SSL_CERT_FILE_NAME: ${SSL_CERT_FILE_NAME:-RSA-cert.pem} - SSL_CHAIN_FILE_NAME: ${SSL_CHAIN_FILE_NAME:-RSA-chain.pem} - SSL_KEY_FILE_NAME: ${SSL_KEY_FILE_NAME:-RSA-privkey.pem} + SSL_CERT_FILE_NAME: ${SSL_CERT_FILE_NAME:-cert.pem} + SSL_CHAIN_FILE_NAME: ${SSL_CHAIN_FILE_NAME:-chain.pem} + SSL_KEY_FILE_NAME: ${SSL_KEY_FILE_NAME:-privkey.pem} UPSTREAM_HOST: badminton-scoreboard UPSTREAM_PORT: 8788 volumes: - - /volume1/homes/JianMiau/www/certificate:/etc/nginx/certs:ro + - /volume1/docker/certs:/etc/nginx/certs:ro diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh index 9f4881f..036d1e7 100644 --- a/docker/nginx/entrypoint.sh +++ b/docker/nginx/entrypoint.sh @@ -4,9 +4,9 @@ set -eu NGINX_PORT="${NGINX_PORT:-3501}" NGINX_SERVER_NAME="${NGINX_SERVER_NAME:-_}" SSL_CERT_DIR="${SSL_CERT_DIR:-/etc/nginx/certs}" -SSL_CERT_FILE_NAME="${SSL_CERT_FILE_NAME:-RSA-cert.pem}" -SSL_CHAIN_FILE_NAME="${SSL_CHAIN_FILE_NAME:-RSA-chain.pem}" -SSL_KEY_FILE_NAME="${SSL_KEY_FILE_NAME:-RSA-privkey.pem}" +SSL_CERT_FILE_NAME="${SSL_CERT_FILE_NAME:-cert.pem}" +SSL_CHAIN_FILE_NAME="${SSL_CHAIN_FILE_NAME:-chain.pem}" +SSL_KEY_FILE_NAME="${SSL_KEY_FILE_NAME:-privkey.pem}" UPSTREAM_HOST="${UPSTREAM_HOST:-badminton-scoreboard}" UPSTREAM_PORT="${UPSTREAM_PORT:-8788}" diff --git a/server/server.mjs b/server/server.mjs index fae5c34..144febb 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -1,6 +1,7 @@ import 'dotenv/config' import express from 'express' import mysql from 'mysql2/promise' +import { X509Certificate } from 'node:crypto' import path from 'node:path' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -21,6 +22,14 @@ const roomDataDir = path.join(projectRoot, 'server', 'data') const roomsFilePath = path.join(roomDataDir, 'live-rooms.json') const distReady = existsSync(path.join(distDir, 'index.html')) +// SSL 憑證由 nginx 處理,這裡只讀取到期日回報給前端; +// 前端存下來後,遇到 Failed to fetch 就能判斷是不是憑證過期造成的。 +const sslCertDir = process.env.SSL_CERT_DIR ?? '' +const sslCertFileName = process.env.SSL_CERT_FILE_NAME ?? 'cert.pem' +const sslCertPath = sslCertDir ? path.join(sslCertDir, sslCertFileName) : '' +const CERT_INFO_TTL_MS = 60_000 +let certInfoCache = { checkedAt: 0, notAfter: null } + const requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE'] const missingEnv = requiredEnv.filter((key) => !process.env[key]) @@ -47,6 +56,7 @@ app.get('/api/health', (_request, response) => { response.json({ appStartedAt, appVersion, + certNotAfter: getCertNotAfter(), ok: true, dbReady: Boolean(pool), distReady, @@ -65,6 +75,7 @@ app.get('/api/version', (_request, response) => { }) response.json({ + certNotAfter: getCertNotAfter(), ok: true, startedAt: appStartedAt, version: appVersion, @@ -427,7 +438,7 @@ app.post('/api/history', async (request, response) => { } }) -app.get('/api/history', async (_request, response) => { +app.get('/api/history', async (request, response) => { if (!pool) { response.status(500).json({ ok: false, @@ -436,19 +447,33 @@ app.get('/api/history', async (_request, response) => { return } + // 分頁參數已驗證為整數並限制範圍,直接內插避免 mysql2 execute 對 LIMIT 佔位符的相容問題。 + const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1) + const pageSize = Math.min(100, Math.max(1, Number.parseInt(request.query.pageSize, 10) || 20)) + const offset = (page - 1) * pageSize + try { await ensureHistoryTable(pool, historyTableName) + const [[countRow]] = await pool.execute( + `SELECT COUNT(*) AS total FROM \`${historyTableName}\``, + ) + const total = Number(countRow?.total ?? 0) const [rows] = await pool.execute( ` SELECT id, time, dayOfWeek, score, winScore, type, players, team, scoreList FROM \`${historyTableName}\` ORDER BY id DESC + LIMIT ${pageSize} OFFSET ${offset} `, ) response.json({ ok: true, data: rows, + page, + pageSize, + total, + totalPages: Math.max(1, Math.ceil(total / pageSize)), }) } catch (error) { console.error('history load error:', error) @@ -521,6 +546,35 @@ app.listen(port, () => { } }) +// 憑證檔可能隨時被換新,帶 60 秒快取避免每次請求都讀檔。 +function getCertNotAfter() { + if (!sslCertPath) { + return null + } + + const now = Date.now() + + if (now - certInfoCache.checkedAt < CERT_INFO_TTL_MS) { + return certInfoCache.notAfter + } + + let notAfter = null + + try { + const certificate = new X509Certificate(readFileSync(sslCertPath)) + const notAfterTime = Date.parse(certificate.validTo) + + if (Number.isFinite(notAfterTime)) { + notAfter = new Date(notAfterTime).toISOString() + } + } catch (error) { + console.error('read ssl cert error:', error instanceof Error ? error.message : error) + } + + certInfoCache = { checkedAt: now, notAfter } + return notAfter +} + function createHostToken() { return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}` } diff --git a/src/App.css b/src/App.css index d652465..bdccf10 100644 --- a/src/App.css +++ b/src/App.css @@ -489,6 +489,48 @@ opacity: 0.62; } +.history-pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 20px; +} + +.pagination-pages { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.pagination-button { + min-width: 44px; + padding: 10px 14px; + border-radius: 12px; +} + +.pagination-button:disabled { + cursor: default; + opacity: 0.5; +} + +.pagination-button-active { + color: #f8fff8; + background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92)); +} + +.pagination-ellipsis { + padding: 0 4px; + color: var(--panel-soft); +} + +.pagination-summary { + color: var(--panel-soft); + font-size: 0.92rem; +} + .scoreboard-screen { display: grid; grid-template-columns: minmax(0, 1fr) 160px; diff --git a/src/App.tsx b/src/App.tsx index 0ad38f1..02afa91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { createLiveRoom, loadMatchResults, releaseLiveRoom, + rememberCertNotAfter, saveMatchHistory, sendLiveRoomHeartbeat, updateLiveRoom, @@ -106,6 +107,8 @@ const STREAK_TITLES: Record = { const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready' const APP_VERSION_POLL_MS = 30000 const LIVE_ROOM_HEARTBEAT_MS = 10_000 +// 憑證到期前 7 天開始提醒,避免到期當天才突然全部連不上。 +const CERT_EXPIRY_WARNING_MS = 7 * 24 * 60 * 60 * 1000 function App() { const location = useLocation() @@ -143,6 +146,8 @@ function App() { const [victoryAnnouncement, setVictoryAnnouncement] = useState(null) const [voiceAnnouncement, setVoiceAnnouncement] = useState(null) const [pwaUpdateReady, setPwaUpdateReady] = useState(false) + const [certExpiryWarning, setCertExpiryWarning] = useState('') + const certWarningDismissedRef = useRef(false) const [liveRoomSession, setLiveRoomSession] = useState(null) const [navigationLockMessage, setNavigationLockMessage] = useState('') // 結算完成後遞增,通知記分板自動打開選隊伍面板讓人選下一場。 @@ -256,12 +261,36 @@ function App() { } const payload = (await response.json()) as { + certNotAfter?: string | null ok?: boolean version?: string } const nextVersion = payload.version?.trim() - if (!active || !nextVersion) { + if (!active) { + return + } + + rememberCertNotAfter(payload.certNotAfter) + + const certNotAfterTime = payload.certNotAfter ? Date.parse(payload.certNotAfter) : NaN + + if ( + Number.isFinite(certNotAfterTime) && + certNotAfterTime - Date.now() < CERT_EXPIRY_WARNING_MS && + !certWarningDismissedRef.current + ) { + const expiryLabel = new Date(certNotAfterTime).toLocaleString('zh-TW', { + hour12: false, + }) + setCertExpiryWarning( + certNotAfterTime < Date.now() + ? `SSL 憑證已於 ${expiryLabel} 過期,請盡快更新伺服器憑證。` + : `SSL 憑證將於 ${expiryLabel} 到期,請記得更新伺服器憑證。`, + ) + } + + if (!nextVersion) { return } @@ -1096,6 +1125,25 @@ function App() { {navigationLockMessage} ) : null} + + {certExpiryWarning && !pwaUpdateReady ? ( +
+
+ SSL 憑證提醒 + {certExpiryWarning} +
+ +
+ ) : null} ) } diff --git a/src/lib/api.ts b/src/lib/api.ts index f376dba..c7e50ac 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,5 +1,6 @@ import type { HistoryListItem, + HistoryListPage, HistoryRecord, HistoryUploadPayload, HistoryUploadResponse, @@ -11,8 +12,63 @@ import type { MatchResultsRecord, } from '../types' +const CERT_NOT_AFTER_STORAGE_KEY = 'badminton-scoreboard::cert-not-after' + +// 連線正常時(/api/version 輪詢)把後端回報的憑證到期日存起來, +// 之後 fetch 直接失敗(瀏覽器只給 Failed to fetch,看不出原因)時, +// 就能比對到期日,把「憑證過期」明確顯示給使用者。 +export function rememberCertNotAfter(value: string | null | undefined) { + if (!value || Number.isNaN(Date.parse(value))) { + return + } + + try { + window.localStorage.setItem(CERT_NOT_AFTER_STORAGE_KEY, value) + } catch { + // localStorage 不可用時直接略過,只是少了憑證判斷。 + } +} + +export function getRememberedCertNotAfter() { + try { + const raw = window.localStorage.getItem(CERT_NOT_AFTER_STORAGE_KEY) + + if (!raw) { + return null + } + + const time = Date.parse(raw) + return Number.isNaN(time) ? null : new Date(time) + } catch { + return null + } +} + +function buildConnectionErrorMessage() { + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + return '目前沒有網路連線,請確認網路後再試。' + } + + const certNotAfter = getRememberedCertNotAfter() + + if (certNotAfter && certNotAfter.getTime() < Date.now()) { + const expiredLabel = certNotAfter.toLocaleString('zh-TW', { hour12: false }) + return `無法連線到伺服器:SSL 憑證已於 ${expiredLabel} 過期,請更新伺服器憑證後再試。` + } + + return '無法連線到伺服器,可能是網路不穩或伺服器離線,請稍後再試。' +} + +async function apiFetch(input: string, init?: RequestInit) { + try { + return await fetch(input, init) + } catch { + throw new Error(buildConnectionErrorMessage()) + } +} + export async function loadMatchResults(time: string) { - const response = await fetch(`/api/match-results/${time}`) + const response = await apiFetch(`/api/match-results/${time}`) const payload = (await readJsonSafely(response)) as { ok?: boolean message?: string @@ -31,7 +87,7 @@ export async function loadMatchResults(time: string) { } export async function saveMatchHistory(payload: HistoryUploadPayload) { - const response = await fetch('/api/history', { + const response = await apiFetch('/api/history', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -52,23 +108,34 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) { return result.data } -export async function loadHistoryList() { - const response = await fetch('/api/history') +export async function loadHistoryList(page = 1, pageSize = 20): Promise { + const response = await apiFetch(`/api/history?page=${page}&pageSize=${pageSize}`) const payload = (await readJsonSafely(response)) as { ok?: boolean message?: string data?: HistoryRecord[] + page?: number + pageSize?: number + total?: number + totalPages?: number } if (!response.ok || !payload.ok) { throw new Error(payload.message ?? '讀取歷史戰績失敗。') } - return (payload.data ?? []).map(normalizeHistoryRecord) + const items = (payload.data ?? []).map(normalizeHistoryRecord) + + return { + items, + page: payload.page ?? page, + total: payload.total ?? items.length, + totalPages: payload.totalPages ?? 1, + } } export async function deleteHistoryItem(id: number) { - const response = await fetch(`/api/history/${id}`, { + const response = await apiFetch(`/api/history/${id}`, { method: 'DELETE', }) @@ -83,7 +150,7 @@ export async function deleteHistoryItem(id: number) { } export async function createLiveRoom(payload: LiveRoomPayload) { - const response = await fetch('/api/rooms', { + const response = await apiFetch('/api/rooms', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -105,7 +172,7 @@ export async function createLiveRoom(payload: LiveRoomPayload) { } export async function updateLiveRoom(roomId: string, payload: LiveRoomUpdatePayload) { - const response = await fetch(`/api/rooms/${roomId}`, { + const response = await apiFetch(`/api/rooms/${roomId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -127,7 +194,7 @@ export async function updateLiveRoom(roomId: string, payload: LiveRoomUpdatePayl } export async function releaseLiveRoom(roomId: string, hostToken: string) { - const response = await fetch(`/api/rooms/${roomId}/release`, { + const response = await apiFetch(`/api/rooms/${roomId}/release`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -147,7 +214,7 @@ export async function releaseLiveRoom(roomId: string, hostToken: string) { } export async function sendLiveRoomHeartbeat(roomId: string, hostToken: string) { - const response = await fetch(`/api/rooms/${roomId}/heartbeat`, { + const response = await apiFetch(`/api/rooms/${roomId}/heartbeat`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -167,7 +234,7 @@ export async function sendLiveRoomHeartbeat(roomId: string, hostToken: string) { } export async function reconcileLiveRooms() { - const response = await fetch('/api/rooms/reconcile', { + const response = await apiFetch('/api/rooms/reconcile', { method: 'POST', }) @@ -187,7 +254,7 @@ export async function reconcileLiveRooms() { } export async function loadLiveRoomList() { - const response = await fetch('/api/rooms') + const response = await apiFetch('/api/rooms') const result = (await readJsonSafely(response)) as { ok?: boolean message?: string @@ -206,7 +273,7 @@ export async function loadLiveRoomList() { } export async function loadLiveRoom(roomId: string) { - const response = await fetch(`/api/rooms/${roomId}`) + const response = await apiFetch(`/api/rooms/${roomId}`) const result = (await readJsonSafely(response)) as { ok?: boolean message?: string diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx index 98c1592..7803e88 100644 --- a/src/pages/HistoryPage.tsx +++ b/src/pages/HistoryPage.tsx @@ -2,12 +2,19 @@ import { useEffect, useState } from 'react' import { deleteHistoryItem, loadHistoryList } from '../lib/api' import type { HistoryListItem } from '../types' +const HISTORY_PAGE_SIZE = 20 + export function HistoryPage() { const [history, setHistory] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [selectedItem, setSelectedItem] = useState(null) const [deletingId, setDeletingId] = useState(null) + const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) + const [totalPages, setTotalPages] = useState(1) + // 刪除後遞增,讓目前頁面重新讀取一次。 + const [reloadSignal, setReloadSignal] = useState(0) useEffect(() => { let active = true @@ -17,13 +24,21 @@ export function HistoryPage() { setError('') try { - const nextHistory = await loadHistoryList() + const result = await loadHistoryList(page, HISTORY_PAGE_SIZE) if (!active) { return } - setHistory(nextHistory) + // 刪除或資料變動後,目前頁碼可能超出範圍,往回跳到最後一頁。 + if (result.items.length === 0 && result.total > 0 && page > result.totalPages) { + setPage(result.totalPages) + return + } + + setHistory(result.items) + setTotal(result.total) + setTotalPages(result.totalPages) } catch (fetchError) { if (!active) { return @@ -42,7 +57,7 @@ export function HistoryPage() { return () => { active = false } - }, []) + }, [page, reloadSignal]) const handleDelete = async (item: HistoryListItem) => { const confirmed = window.confirm( @@ -58,8 +73,8 @@ export function HistoryPage() { try { await deleteHistoryItem(item.id) - setHistory((current) => current.filter((entry) => entry.id !== item.id)) setSelectedItem((current) => (current?.id === item.id ? null : current)) + setReloadSignal((current) => current + 1) } catch (deleteError) { setError(deleteError instanceof Error ? deleteError.message : '刪除戰績失敗。') } finally { @@ -67,6 +82,14 @@ export function HistoryPage() { } } + const goToPage = (nextPage: number) => { + if (nextPage < 1 || nextPage > totalPages || nextPage === page || loading) { + return + } + + setPage(nextPage) + } + return ( <>
@@ -95,6 +118,7 @@ export function HistoryPage() {

資料庫 `history` 表目前還沒有可顯示的資料。

) : ( + <>
{history.map((item) => (
@@ -135,6 +159,53 @@ export function HistoryPage() {
))}
+ + + )}
@@ -214,3 +285,29 @@ function HistoryReplayModal({ item, onClose }: HistoryReplayModalProps) { function getStarterName(item: HistoryListItem, starter: number) { return item.players[starter] ?? '未知球員' } + +// 產生分頁按鈕:固定顯示第一頁與最後一頁,目前頁前後各一頁,其餘收成省略號。 +function getPageItems(currentPage: number, totalPages: number) { + const pages = new Set([1, totalPages]) + + for (let offset = -1; offset <= 1; offset += 1) { + const candidate = currentPage + offset + + if (candidate >= 1 && candidate <= totalPages) { + pages.add(candidate) + } + } + + const sorted = Array.from(pages).sort((left, right) => left - right) + const items: Array = [] + + sorted.forEach((pageNumber, index) => { + if (index > 0 && pageNumber - sorted[index - 1] > 1) { + items.push('ellipsis') + } + + items.push(pageNumber) + }) + + return items +} diff --git a/src/types.ts b/src/types.ts index 31e4b89..eab78d5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -131,6 +131,13 @@ export type HistoryListItem = { winnerTeamName: string } +export type HistoryListPage = { + items: HistoryListItem[] + page: number + total: number + totalPages: number +} + export type LiveRoomStatus = 'live' | 'finished' export type LiveRoomSession = {