Files
Scoreboard/server/internal/httpapi/handlers_system.go
T
AskaEth 2c9ff65fc4 feat: 补齐第一批已知缺口(关闭握手 / 定时备份 / Origin 白名单 / DTO 收敛)
- httpapi:新增 POST /api/shutdown(admin 会话或 X-Shutdown-Token 常量时间比对)
  与 GET /api/rules/switches(recorder 可读,只回布尔开关);settings/standings 的
  重复 DTO 收敛到 internal/dto;standings.showIndividualMedals 贯通到设置读写与前端
- export:抽出 export.Service,新增自动备份定时任务(backup.intervalMinutes/keep),
  自动、手动、发布前三类备份共用同一把互斥锁与滚动清理
- realtime:/ws 去掉 InsecureSkipVerify,改用 OriginPatterns(同源与无 Origin 放行,
  跨站需命中 server.allowedOrigins)
- console:停止服务改走 POST /api/shutdown 优雅关闭(5 秒宽限后回退强杀),
  服务管理器新增「已连接的大屏实例」卡片
- deploy:build.bat 改用 for /f 读内部署名——set /p 在 chcp 65001 下返回空,
  导致最近这次构建的 exe 里根本没注入署名
- docs:备份策略、关闭超时、Origin 配置的说明与实现对齐
2026-09-09 21:41:08 +08:00

68 lines
2.7 KiB
Go

package httpapi
import (
"crypto/subtle"
"net/http"
"os"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/version"
)
// shutdownTokenHeader 是 Console 优雅关闭握手用的请求头,与环境变量比对。
const shutdownTokenHeader = "X-Shutdown-Token"
// systemRoutes 挂载系统级只读信息与优雅关闭握手。
func (s *server) systemRoutes(mux *http.ServeMux) {
// 内部署名是编译期注入的,不暴露给匿名访客:至少要登录(recorder 及以上)才能读到
mux.HandleFunc("GET /api/system/about", s.read(auth.RoleRecorder, s.systemAbout))
// 关闭接口自带鉴权(admin 会话或 X-Shutdown-Token),不能走 s.write 的角色中间件。
mux.HandleFunc("POST /api/shutdown", s.handle(s.shutdownHandler))
}
func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error) {
return map[string]any{
"internalSignature": version.InternalSignature,
}, nil
}
// shutdownHandler 处理 POST /api/shutdown:返回 202 后触发 main 注入的优雅关闭回调。
func (s *server) shutdownHandler(w http.ResponseWriter, r *http.Request) (any, error) {
method, ok := s.shutdownAuth(r)
if !ok {
// 没带任何凭据的访客按「需登录」处理,其余一律 403,避免探测。
if middleware.User(r.Context()).Role == auth.RoleGuest && r.Header.Get(shutdownTokenHeader) == "" {
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"}
}
return nil, &apiError{status: http.StatusForbidden, code: "FORBIDDEN", message: "无权关闭服务"}
}
if s.shutdown == nil {
return nil, &apiError{status: http.StatusServiceUnavailable, code: "INTERNAL", message: "服务端未接入优雅关闭"}
}
s.audit(r.Context(), "system.shutdown", "system", 0, nil, map[string]any{"auth": method})
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
// 关闭放到响应写出之后:srv.Shutdown 会等本次请求(含审计落库)结束,不会截断 202。
go s.shutdown()
return nil, errHandled
}
// shutdownAuth 校验关闭凭据:admin 会话,或与环境变量 SCOREBOARD_SHUTDOWN_TOKEN 常量时间相等的请求头。
// 环境变量为空时只认 admin 会话。
func (s *server) shutdownAuth(r *http.Request) (string, bool) {
if middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) {
return "session", true
}
expected := strings.TrimSpace(os.Getenv("SCOREBOARD_SHUTDOWN_TOKEN"))
if expected == "" {
return "", false
}
got := r.Header.Get(shutdownTokenHeader)
if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 {
return "", false
}
return "token", true
}