Files
Scoreboard/server/internal/httpapi/server.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

402 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package httpapi 提供 REST 接口、静态资源托管与统一响应结构。
package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"path/filepath"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/webui"
)
// DisplaySender 把大屏控制命令推给实时层;未接入时命令只记审计、不影响接口可用性。
type DisplaySender interface {
// SendDisplayCommand 下发一条大屏指令。targets 为空表示广播给所有大屏(公告靠它),
// 否则只发给这些实例 id(GET /api/display/instances 里的 id),并返回命中的实例数。
SendDisplayCommand(action string, payload any, targets []string) (matched int, err error)
}
// Options 是构建路由所需的依赖。
type Options struct {
Logger *slog.Logger
Version string
// System 是系统库连接(users/sessions)。
System *sql.DB
// Event 是赛事库连接(teams/events/results/ledger/audit…)。
Event *sql.DB
// Auth 可选;为空时用 System 自动构建。
Auth *auth.Service
// DataDir 用于备份目录 data/backups。
DataDir string
// Backup 可选,手动/发布前备份共用的服务;为空时按 System/Event/DataDir 自动构建。
Backup *export.Service
// Shutdown 可选,由 main 注入:触发与 SIGINT/SIGTERM 相同的优雅关闭流程。
// 为空时 POST /api/shutdown 返回 503,避免把「没接关闭」误报成已受理。
Shutdown func()
// Display 可选,实时层接入后传入。
Display DisplaySender
// Realtime 可选,实时层接入后传入:写事务提交后推送变更,并提供在线大屏实例。
Realtime Realtime
// WS 可选,实时层的 WebSocket 处理器,挂到 /ws。
WS http.Handler
// ProjectDir 可选,当前赛事项目目录(含 uploads/),用于赛事包导出。
ProjectDir string
// WebFS 可选,默认使用内嵌前端资源。
WebFS fs.FS
}
type server struct {
logger *slog.Logger
version string
sys *sql.DB
ev *sql.DB
auth *auth.Service
dataDir string
projectDir string
backups *export.Service
shutdown func()
display DisplaySender
realtime Realtime
idem *idempotencyStore
}
// New 组装并返回服务端的 HTTP 处理器。
func New(opts Options) http.Handler {
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
svc := opts.Auth
if svc == nil {
svc = auth.New(opts.System, auth.Options{Logger: logger})
}
backups := opts.Backup
if backups == nil {
dir := ""
if strings.TrimSpace(opts.DataDir) != "" {
dir = filepath.Join(opts.DataDir, "backups")
}
backups = export.NewService(opts.System, opts.Event, dir, 0, logger)
}
s := &server{
logger: logger,
version: opts.Version,
sys: opts.System,
ev: opts.Event,
auth: svc,
dataDir: opts.DataDir,
projectDir: opts.ProjectDir,
backups: backups,
shutdown: opts.Shutdown,
display: opts.Display,
realtime: opts.Realtime,
idem: newIdempotencyStore(10 * time.Minute),
}
mux := http.NewServeMux()
s.routes(mux)
s.exportRoutes(mux)
s.projectRoutes(mux)
s.rulesRoutes(mux)
s.systemRoutes(mux)
if opts.WS != nil {
mux.Handle("/ws", opts.WS)
}
assets := opts.WebFS
if assets == nil {
var err error
assets, err = webui.FS()
if err != nil {
logger.Error("前端资源不可用", "err", err)
}
}
if assets != nil {
mux.Handle("/", staticHandler(assets))
}
var h http.Handler = mux
h = s.broadcastChanges(h)
h = middleware.NewAuditor(opts.Event, logger).Middleware(h)
h = middleware.RateLimitWrite(svc, logger)(h)
h = middleware.CSRF(logger, h)
h = middleware.MustChangePassword(logger, h)
h = middleware.Authenticate(svc, logger)(h)
h = middleware.Log(logger)(h)
h = middleware.Recover(logger)(h)
h = middleware.RequestID(h)
return h
}
func middlewareUserID(r *http.Request) int64 {
u := middleware.User(r.Context())
if u.Role == auth.RoleGuest {
return 0
}
return u.ID
}
// public 注册公开接口(guest 可读)。
func (s *server) public(fn handler) http.HandlerFunc { return s.handle(fn) }
// read 注册「guest 也不可读」的接口,权限不足一律 403。
func (s *server) read(role auth.Role, fn handler) http.HandlerFunc {
return middleware.RequireRole(s.logger, role)(s.handle(fn)).ServeHTTP
}
// write 注册写接口:未登录 401,角色不足 403;成功后按 X-Request-Id 支持幂等回放。
func (s *server) write(role auth.Role, fn handler) http.HandlerFunc {
return middleware.RequireWrite(s.logger, role)(s.cached(s.handle(fn))).ServeHTTP
}
// writeVoid 注册无返回体的写接口。
func (s *server) writeVoid(role auth.Role, fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return middleware.RequireWrite(s.logger, role)(s.cached(s.handleVoid(fn))).ServeHTTP
}
func (s *server) routes(mux *http.ServeMux) {
// 健康检查不走 {ok,data} 包装(05 第 7 节)。
//
// 语义分两层,写进不同字段,谁也不覆盖谁:
// ok —— HTTP 服务活着(**恒为 true,状态码也恒 200**);
// db —— 数据库能不能应答(ok / busy / error)。
// 状态码不做区分:Console 只在**进程真的退出**时才拉起(`service.js` 的 handleExit),
// 但前端 `health()` 一见到 ok:false 就直接抛错(管理端「关于」页会显示
// 「未知(/api/health 不可达)」),一次数据库抖动不该让界面宣称版本未知。
// 进程存活与依赖状态本来就是两件事,分字段表达比塞进一个布尔值诚实。
// 不返回最近备份时间:本接口会被 Console 每 5 秒轮询,不做目录扫描。
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout)
defer cancel()
state, err := s.probeDatabases(ctx)
resp := map[string]any{"ok": true, "version": s.version, "db": state}
if err != nil {
resp["dbError"] = err.Error()
}
writeJSON(w, http.StatusOK, resp)
})
// 认证
mux.HandleFunc("POST /api/auth/login", s.handle(s.login))
mux.HandleFunc("POST /api/auth/logout", s.handle(s.logout))
mux.HandleFunc("GET /api/auth/me", s.handle(s.me))
mux.HandleFunc("POST /api/auth/password", s.handle(s.changePassword))
// 系统配置(guest 一律 403)
mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler))
mux.HandleFunc("PUT /api/settings", s.write(auth.RoleAdmin, s.updateSettingsHandler))
// 登录有效期是机器级配置(系统库 settings),与上面的赛事级 /api/settings 不共用;
// 它决定所有人的会话寿命,所以读写都限 admin。
mux.HandleFunc("GET /api/system/session", s.read(auth.RoleAdmin, s.getSessionSettingsHandler))
mux.HandleFunc("PUT /api/system/session", s.write(auth.RoleAdmin, s.updateSessionSettingsHandler))
// 基础配置
mux.HandleFunc("GET /api/teams", s.public(s.listTeamsHandler))
mux.HandleFunc("POST /api/teams", s.write(auth.RoleRecorder, s.createTeamHandler))
mux.HandleFunc("GET /api/teams/{id}", s.public(s.getTeamHandler))
mux.HandleFunc("PUT /api/teams/{id}", s.write(auth.RoleRecorder, s.updateTeamHandler))
mux.HandleFunc("DELETE /api/teams/{id}", s.writeVoid(auth.RoleRecorder, s.deleteTeamHandler))
// 「彻底删除」是假删除(只标记不可见),这条把标记撤掉、单位回到界面。权限与删除同档。
mux.HandleFunc("POST /api/teams/{id}/restore", s.write(auth.RoleRecorder, s.restoreTeamHandler))
mux.HandleFunc("GET /api/sports", s.public(s.listSportsHandler))
mux.HandleFunc("POST /api/sports", s.write(auth.RoleRecorder, s.createSportHandler))
mux.HandleFunc("GET /api/sports/{id}", s.public(s.getSportHandler))
mux.HandleFunc("PUT /api/sports/{id}", s.write(auth.RoleRecorder, s.updateSportHandler))
mux.HandleFunc("DELETE /api/sports/{id}", s.writeVoid(auth.RoleRecorder, s.deleteSportHandler))
mux.HandleFunc("GET /api/events", s.public(s.listEventsHandler))
mux.HandleFunc("POST /api/events", s.write(auth.RoleRecorder, s.createEventHandler))
mux.HandleFunc("GET /api/events/{id}", s.public(s.getEventHandler))
mux.HandleFunc("PUT /api/events/{id}", s.write(auth.RoleRecorder, s.updateEventHandler))
mux.HandleFunc("DELETE /api/events/{id}", s.writeVoid(auth.RoleRecorder, s.deleteEventHandler))
mux.HandleFunc("GET /api/events/{id}/entries", s.public(s.listEntriesHandler))
mux.HandleFunc("GET /api/events/{id}/categories", s.public(s.listCategoriesHandler))
mux.HandleFunc("PUT /api/events/{id}/categories", s.write(auth.RoleRecorder, s.replaceCategoriesHandler))
// 单个组别的就地更新:有报名数据时改名 / 改年龄 / 改性别 / 改权重只走这条,不重建组别。
mux.HandleFunc("PUT /api/events/{id}/categories/{categoryId}", s.write(auth.RoleRecorder, s.updateCategoryHandler))
mux.HandleFunc("GET /api/events/{id}/channels", s.public(s.listChannelsHandler))
mux.HandleFunc("PUT /api/events/{id}/channels", s.write(auth.RoleRecorder, s.replaceChannelsHandler))
// 团体手工名次(devdocs/17 §七):成绩页要读(guest 也能看成绩),录入员可写。
// 与成绩读取同权限:组别下没有团体手工通道时回空视图,不是 404。
mux.HandleFunc("GET /api/events/{id}/categories/{categoryId}/team-ranks", s.public(s.teamRanksHandler))
mux.HandleFunc("PUT /api/events/{id}/categories/{categoryId}/team-ranks", s.write(auth.RoleRecorder, s.updateTeamRanksHandler))
mux.HandleFunc("GET /api/scoring-profiles", s.public(s.listProfilesHandler))
mux.HandleFunc("POST /api/scoring-profiles", s.write(auth.RoleRecorder, s.createProfileHandler))
mux.HandleFunc("GET /api/scoring-profiles/{id}", s.public(s.getProfileHandler))
mux.HandleFunc("PUT /api/scoring-profiles/{id}", s.write(auth.RoleRecorder, s.updateProfileHandler))
mux.HandleFunc("GET /api/athletes", s.public(s.listAthletesHandler))
mux.HandleFunc("POST /api/athletes", s.write(auth.RoleRecorder, s.createAthleteHandler))
mux.HandleFunc("GET /api/athletes/{id}", s.public(s.getAthleteHandler))
mux.HandleFunc("PUT /api/athletes/{id}", s.write(auth.RoleRecorder, s.updateAthleteHandler))
mux.HandleFunc("DELETE /api/athletes/{id}", s.write(auth.RoleRecorder, s.deleteAthleteHandler))
// 批量动作端点:一次请求完成整批停用 / 彻底删除,逐条回报成败;逐个删会逐个消耗写配额。
mux.HandleFunc("POST /api/athletes/batch", s.write(auth.RoleRecorder, s.batchAthletesHandler))
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler))
// 批次(heat)是录入 / 展示分组,不参与算分,所以没有独立的「发布」概念。
mux.HandleFunc("PUT /api/entries/heat", s.write(auth.RoleRecorder, s.setEntriesHeatHandler))
// 报名项批量操作:退赛(scratched,可恢复)/ 恢复 / 移出报名(物理删除,不可恢复)。
// 权限与发布同档(recorder),不新增角色;跨小项混选允许,服务端按 (小项, 组别) 分组重算。
mux.HandleFunc("POST /api/entries/batch", s.write(auth.RoleRecorder, s.batchEntriesHandler))
// 成绩
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
mux.HandleFunc("PUT /api/results/draft", s.write(auth.RoleRecorder, s.saveDraftHandler))
mux.HandleFunc("POST /api/results/publish", s.write(auth.RoleRecorder, s.publishHandler))
mux.HandleFunc("POST /api/results/unpublish", s.write(auth.RoleRecorder, s.unpublishHandler))
// 分批延迟结算:settlement 给录入页查「哪些组还没结算」,settle 是人工兜底(写审计)。
mux.HandleFunc("GET /api/results/settlement", s.read(auth.RoleRecorder, s.settlementStatusHandler))
mux.HandleFunc("POST /api/results/settle", s.write(auth.RoleRecorder, s.settleResultsHandler))
mux.HandleFunc("POST /api/results/{id}/void", s.write(auth.RoleRecorder, s.voidResultHandler))
// 积分与排名
mux.HandleFunc("GET /api/standings", s.public(s.standingsHandler))
mux.HandleFunc("GET /api/standings/athletes", s.public(s.athleteStandingsHandler))
mux.HandleFunc("GET /api/standings/teams/{id}/breakdown", s.public(s.breakdownHandler))
mux.HandleFunc("GET /api/adjustments", s.public(s.listAdjustmentsHandler))
mux.HandleFunc("POST /api/adjustments", s.write(auth.RoleRecorder, s.createAdjustmentHandler))
mux.HandleFunc("POST /api/adjustments/{id}/void", s.write(auth.RoleRecorder, s.voidAdjustmentHandler))
// 纪录
mux.HandleFunc("GET /api/records", s.public(s.listRecordsHandler))
mux.HandleFunc("POST /api/record-breaks", s.write(auth.RoleRecorder, s.createRecordBreakHandler))
mux.HandleFunc("POST /api/record-breaks/{id}/approve", s.write(auth.RoleRecorder, s.approveRecordBreakHandler))
// 大屏控制与运维
mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler))
mux.HandleFunc("GET /api/display/instances", s.read(auth.RoleRecorder, s.displayInstancesHandler))
// 「实时赛况」页的当前项目与已发布成绩;大屏(guest)要能直接拉,故对外只读开放。
mux.HandleFunc("GET /api/display/live", s.public(s.liveEventsHandler))
mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler))
// 恢复是停机动作:校验 + 拍安全副本 + 换文件后优雅重启,详见 restoreBackupHandler。
mux.HandleFunc("POST /api/backup/restore", s.write(auth.RoleAdmin, s.restoreBackupHandler))
// 用户管理(系统配置,仅 admin;guest 读 403、写 401)
mux.HandleFunc("GET /api/users", s.read(auth.RoleAdmin, s.listUsersHandler))
mux.HandleFunc("POST /api/users", s.write(auth.RoleAdmin, s.createUserHandler))
mux.HandleFunc("PUT /api/users/{id}", s.write(auth.RoleAdmin, s.updateUserHandler))
mux.HandleFunc("POST /api/users/{id}/password", s.write(auth.RoleAdmin, s.resetUserPasswordHandler))
// 变更历史(recorder 及以上可读)
mux.HandleFunc("GET /api/revisions", s.read(auth.RoleRecorder, s.listRevisionsHandler))
mux.HandleFunc("GET /api/revisions/{id}", s.read(auth.RoleRecorder, s.getRevisionDetailHandler))
// 图片上传与静态访问(会标/背景/单位图标)。上传落 <ProjectDir>/uploads/,随赛事包导出;
// 静态访问要匿名放行,大屏的 <img> 才能不带会话直接取图。
mux.HandleFunc("POST /api/uploads", s.write(auth.RoleRecorder, s.uploadHandler))
mux.HandleFunc("GET /uploads/{name}", s.serveUpload)
// 图片的删除与孤儿清理(admin,与字体删除同一口径):上传是录入员的日常操作,
// 删文件是系统级资源管理。删除接口复用 uploadContentType 的名字校验与防穿越;
// 孤儿清单只列「没有任何引用点用到」的图片,批量删除只动请求里点名的文件。
mux.HandleFunc("DELETE /api/uploads/{name}", s.writeVoid(auth.RoleAdmin, s.deleteUploadHandler))
mux.HandleFunc("GET /api/uploads/orphans", s.read(auth.RoleAdmin, s.listUploadOrphansHandler))
mux.HandleFunc("POST /api/uploads/orphans/delete", s.write(auth.RoleAdmin, s.deleteUploadsHandler))
// 字体:4 款内置字体(前端 @font-face)由 GET /api/fonts 列出,上传的落
// <项目目录>/uploads/fonts/,随赛事包导出;静态访问同样是匿名放行——大屏的
// @font-face 不带会话。字体目录比图片多一层,静态路由必须单独注册。
mux.HandleFunc("GET /api/fonts", s.read(auth.RoleRecorder, s.listFontsHandler))
mux.HandleFunc("POST /api/fonts", s.write(auth.RoleAdmin, s.uploadFontHandler))
mux.HandleFunc("DELETE /api/fonts/{name}", s.writeVoid(auth.RoleAdmin, s.deleteFontHandler))
mux.HandleFunc("GET /uploads/fonts/{name}", s.serveFont)
// 由其他模块负责的接口:先占位,避免落到静态资源处理器。
mux.HandleFunc("/api/export/", s.notWired("导出"))
mux.HandleFunc("/api/rules/", s.notWired("规则导入导出"))
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
s.writeAPIError(w, r, notFound("接口不存在"))
})
}
// notWired 为待接线的接口返回 501,避免请求被静态资源兜底成 HTML。
func (s *server) notWired(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.writeAPIError(w, r, notImplemented(name+"接口尚未接入"))
}
}
// healthProbeTimeout 是健康检查探库的超时。
//
// **必须明显小于 Console 的健康检查超时**(`console/main/service.js` 里 `timeout: 2500`):
// 数据库是单连接(`SetMaxOpenConns(1)`),被备份(`VACUUM INTO`)或整包重算这类长操作
// 占着的时候,探针会一直等到自己的超时——探针超时就是**整个 /api/health 请求超时**,
// Console 会把服务标成「未就绪」(状态灯变红、托盘提示异常;`console/main/index.js:671`
// 在未就绪时还会跳过重排大屏)。1 秒对一次 `SELECT 1` 绰绰有余——正常是亚毫秒,
// 走满超时只可能是「连接被占用」这一种情况,那就如实报 busy,别拖垮探活请求本身。
const healthProbeTimeout = time.Second
// probeDatabases 探活系统库与赛事库,返回状态与原因:
//
// "ok" —— 两个库都答得上话;
// "busy" —— 超时前没拿到连接(多半是备份 / 重算占着,未必是故障);
// "error" —— 真的报错了(例如库文件被删、损坏)。
//
// 用 `SELECT 1` 而不是 Ping:sqlite 驱动的 Ping 可能只到连接池就返回,
// 探不到「文件还在、库已经读不动」这种情况。
func (s *server) probeDatabases(ctx context.Context) (string, error) {
for _, target := range []struct {
name string
db *sql.DB
}{{"system", s.sys}, {"event", s.ev}} {
if target.db == nil {
continue
}
var one int
if err := target.db.QueryRowContext(ctx, `SELECT 1`).Scan(&one); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "busy", fmt.Errorf("%s 库在 %s 内没应答(连接被备份/重算占用?): %w",
target.name, healthProbeTimeout, err)
}
return "error", fmt.Errorf("%s 库无响应: %w", target.name, err)
}
}
return "ok", nil
}
// staticHandler 把 URL 映射到多页面前端产物。
//
// / → index.html
// /display → display.html
// /admin → admin.html
// /其他 → 按文件路径查找
func staticHandler(assets fs.FS) http.Handler {
files := http.FileServerFS(assets)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
switch p {
case "", "index.html":
http.ServeFileFS(w, r, assets, "index.html")
case "display", "display/":
http.ServeFileFS(w, r, assets, "display.html")
case "admin", "admin/":
http.ServeFileFS(w, r, assets, "admin.html")
default:
if _, err := fs.Stat(assets, p); err != nil {
http.NotFound(w, r)
return
}
files.ServeHTTP(w, r)
}
})
}