功能:SSL 憑證過期偵測與提醒、歷史戰績改為分頁載入
根本原因: 1. 憑證過期時瀏覽器只回 Failed to fetch,前端無法辨識原因,使用者誤以為是 DB 連不上(本次 7/1 憑證過期即是如此)。 2. 歷史戰績一次撈出全部資料(400+ 筆、逾 100KB),筆數持續成長會越來越慢。 影響:所有 API 的連線錯誤訊息、歷史戰績頁載入速度、Docker 憑證掛載路徑與檔名。 修法: - 後端讀取 cert.pem 到期日(60 秒快取),由 /api/version 與 /api/health 回傳 certNotAfter - 前端把到期日存進 localStorage;fetch 失敗時依離線、憑證已過期、其他連線問題分別顯示明確訊息 - 憑證到期前 7 天於頁面跳出「SSL 憑證提醒」通知,可手動關閉 - /api/history 支援 page/pageSize(預設 20 筆、上限 100),回傳 total 與 totalPages - 歷史戰績頁加入分頁控制列(上一頁/頁碼含省略號/下一頁/總筆數),刪除後自動重新載入並修正超出範圍的頁碼 - 憑證掛載目錄改為 /volume1/docker/certs,檔名改為 cert.pem / chain.pem / privkey.pem,app 容器同步掛載以讀取到期日 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+55
-1
@@ -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)}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user