Files
rond-with-you/internal/web/server.go
T
tamakyi 6ff6f944a1 换基线:把「底座换代」做进后台(站点数据一行不动)
导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走,
手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。
新动作 /admin/baseline(后台首页加入口)就是给同一份数据的底座换代用的。

三道闸,顺序不能挪:
1. 重合判据:新包里的到访 UUID 与站点侧带 UUID 的到访比。一条都对不上直接拒
   (连 force 也不给过)——干跑只验结构,一份不相干的包照样导得出,但换下去站点
   每行都在新底座里认不出身份,全按新行插到最大编号之后,真机上就是「这些到访
   全变成新的」。重合率不到一半要显式确认;判据没过不当场报错了事:包已经收进
   uploads,页面给一个「我确认,强制替换」按钮,不必重传几十 MB。
2. 干跑导出:底座缺表/缺实体编号在这步挡住,不会出现「换完了才发现导不出、
   原来那份已经不知去哪」。
3. 旧底座留档 + 记档,换错了可一键回滚。

两个坑:
- 留档名不能只用秒级时间戳。「替换后紧接着回滚」落在同一秒时,回滚那次 rename 会把
  刚生成的留档覆盖掉,于是回滚回了个寂寞(TestRollbackBaselineRestoresPrevious 当场红)。
  改用 baselineBackupName():20060102-150405 + 冲突自增。
- ensureBaseline 必须先看记档。只认 datasets.sha256(= 最初那份包)的话,底座一丢就静默
  重建回旧包,上一次换基线白做且毫无提示。抽出 ensureBaselineTo(ctx, dsID, dst) 共用;
  「重建」入口写临时文件再 rename,不做破坏性删除。

回归用例 7 条(internal/web/baseline_swap_test.go):成功路径断言「换底座前后导出包里
ZVISIT 的 UUID 集合逐条一致」——编号会换、身份不该换,谁在底座里没对上立刻亮;
记档优先重建带反证(清掉记档后同一条路必须走不通);部分重合需确认、完全对不上 force
也不给过、非包文件第一步就挡住、回滚还原且当前那份也留档、页面渲染到底。

验收:./scripts/verify.sh 全量 PASS=318 FAIL=0 SKIP=0。
2026-09-30 19:00:18 +08:00

565 lines
24 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 web
import (
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net/http"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"rond-with-you/internal/auth"
"rond-with-you/internal/captcha"
"rond-with-you/internal/config"
"rond-with-you/internal/media"
"rond-with-you/internal/notes"
"rond-with-you/internal/setting"
"rond-with-you/internal/stats"
"rond-with-you/internal/votes"
)
//go:embed templates/*.html templates/admin/*.html
var tmplFS embed.FS
//go:embed static
var staticFS embed.FS
// pageFiles 每个页面单独成组解析:页面文件都定义 "content",
// 与 layout.html 一起构成一个独立模板集,避免定义名冲突。
var pageFiles = map[string]struct {
files []string
entry string
}{
"home": {[]string{"templates/layout.html", "templates/home.html"}, "layout"},
"map": {[]string{"templates/layout.html", "templates/map.html"}, "layout"},
"places": {[]string{"templates/layout.html", "templates/places.html"}, "layout"},
"place": {[]string{"templates/layout.html", "templates/place.html"}, "layout"},
"timeline": {[]string{"templates/layout.html", "templates/timeline.html"}, "layout"},
"stats": {[]string{"templates/layout.html", "templates/stats.html"}, "layout"},
"report": {[]string{"templates/layout.html", "templates/report.html"}, "layout"},
"admin/index": {[]string{"templates/layout.html", "templates/admin/index.html"}, "layout"},
"admin/settings": {[]string{"templates/layout.html", "templates/admin/settings.html"}, "layout"},
"admin/password": {[]string{"templates/layout.html", "templates/admin/password.html"}, "layout"},
"admin/login": {[]string{"templates/admin/login.html"}, "login"},
"admin/twofa-login": {[]string{"templates/admin/twofa-login.html"}, "login2"},
"admin/twofa": {[]string{"templates/layout.html", "templates/admin/twofa.html"}, "layout"},
"admin/fog-mask": {[]string{"templates/layout.html", "templates/admin/fog-mask.html"}, "layout"},
"admin/fog-preview": {[]string{"templates/layout.html", "templates/admin/fog-preview.html"}, "layout"},
"admin/backup-preview": {[]string{"templates/layout.html", "templates/admin/backup-preview.html"}, "layout"},
"admin/baseline": {[]string{"templates/layout.html", "templates/admin/baseline.html"}, "layout"},
"admin/fillup": {[]string{"templates/layout.html", "templates/admin/fillup.html"}, "layout"},
"admin/notes": {[]string{"templates/layout.html", "templates/admin/notes.html"}, "layout"},
"admin/edit": {[]string{"templates/layout.html", "templates/admin/edit.html"}, "layout"},
"admin/topics": {[]string{"templates/layout.html", "templates/admin/topics.html"}, "layout"},
"admin/photos": {[]string{"templates/layout.html", "templates/admin/photos.html"}, "layout"},
"topics": {[]string{"templates/layout.html", "templates/topics.html"}, "layout"},
"topic": {[]string{"templates/layout.html", "templates/topic.html"}, "layout"},
}
type pageSet struct {
t *template.Template
entry string
}
type Server struct {
cfg *config.Config
db *sql.DB
q stats.DB
auth *auth.Store
sets *setting.Store
notes *notes.Store
votes *votes.Store
pages map[string]pageSet
mux *http.ServeMux
cookie string
tiles tileSet
tileCache *tileCache
// fogTiles 缓存渲染好的迷雾瓦片:每张都要现画逐格位图再 PNG 编码,
// 而内容只随「迷雾版本 + 遮罩 + 底图坐标系」变化。
fogTiles *tileCache
// masks 缓存遮罩列表:它不在 fogIndex 里,不然每张瓦片都要查一次库。
masks maskCache
// captcha 存登录验证码的答案(内存、一次性、带过期)
captcha *captcha.Store
// loginGuard 是登录失败的短锁(按用户名指数退避),验证码之外的又一道闸。
// 两步验证那一步也用同一个实例,但键带 "2fa:" 前缀,两边计数互不干扰。
loginGuard *auth.Guard
// totpReplay 记住最近用过的验证码:同一个码在 ±1 步窗口内只能换一次会话
totpReplay *auth.CodeReplay
// media 管地点图片写哪(本地磁盘 / 对象存储)与怎么取址
media *media.Store
}
func New(cfg *config.Config, db *sql.DB) (*Server, error) {
initAssetVersion(cfg.Debug)
pages := make(map[string]pageSet, len(pageFiles))
for key, def := range pageFiles {
t, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()).
ParseFS(tmplFS, def.files...)
if err != nil {
return nil, fmt.Errorf("解析模板 %s 失败: %w", key, err)
}
pages[key] = pageSet{t: t, entry: def.entry}
}
s := &Server{
cfg: cfg,
db: db,
q: stats.DB{DB: db},
auth: &auth.Store{DB: db, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays},
sets: &setting.Store{DB: db},
notes: &notes.Store{DB: db},
votes: &votes.Store{DB: db},
pages: pages,
mux: http.NewServeMux(),
cookie: "rond_session",
tiles: buildTileSet(cfg),
tileCache: newTileCache(1024),
fogTiles: newTileCache(512),
captcha: captcha.NewStore(10 * time.Minute),
loginGuard: auth.NewGuard(),
totpReplay: auth.NewCodeReplay(),
media: media.New(cfg),
}
s.routes()
return s, nil
}
// guardPage 拦截「站长已关闭」的公开页面:管理员照常访问,访客拿到 404。
// withUser 在 mux 之前运行,这里能直接拿到登录态。
func (s *Server) guardPage(nav string, next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if userFrom(r.Context()) == nil {
if sets, err := s.sets.Load(r.Context()); err == nil && !sets.HasPage(nav) {
s.pausePage(w, http.StatusNotFound, "🔒", "该页面未对访客开放",
"站长还没有把这一页开放给访客。", 0)
return
}
}
next.ServeHTTP(w, r)
})
}
func (s *Server) Handler() http.Handler {
return s.recover(gzipHandler(s.logRequests(s.withUser(s.csrfOriginGuard(s.mux)))))
}
// cacheStatic 给带了当前版本号的静态资源加长期强缓存。
// 版本号随编译产物变化(静态文件内容哈希),旧链接即使被缓存也不会命中新版本。
func (s *Server) cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if v := r.URL.Query().Get("v"); v != "" && v == assetVersion {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
next.ServeHTTP(w, r)
})
}
// withUser 为所有请求解析登录态。公开页面也要知道访问者是访客还是站长,
// 才能决定是否渲染「含家/工作」这类只给自己看的开关。
// 未携带会话 cookie 时不会查库,匿名访问没有额外开销。
func (s *Server) withUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u, err := s.sessionUser(r); err == nil && u != nil {
r = r.WithContext(context.WithValue(r.Context(), userKey{}, u))
}
next.ServeHTTP(w, r)
})
}
func (s *Server) routes() {
// 静态资源:调试模式下直接读磁盘,改样式/脚本不用重新编译。
// 页面链接都带 ?v=<版本>,版本一致才给一年强缓存,重新编译后版本变了自然失效。
if s.cfg.Debug {
s.mux.Handle("GET /static/", s.cacheStatic(
http.StripPrefix("/static/", http.FileServer(http.Dir("internal/web/static")))))
} else {
sub, _ := fs.Sub(staticFS, "static")
s.mux.Handle("GET /static/", s.cacheStatic(
http.StripPrefix("/static/", http.FileServer(http.FS(sub)))))
}
// 公开页面:受「公开页面」开关控制,关闭后仅管理员可访问
s.mux.HandleFunc("GET /{$}", s.home)
s.mux.Handle("GET /map", s.guardPage("map", s.mapPage))
s.mux.Handle("GET /places", s.guardPage("places", s.placesPage))
s.mux.HandleFunc("GET /places/{id}", s.placePage)
s.mux.Handle("GET /timeline", s.guardPage("timeline", s.timelinePage))
s.mux.Handle("GET /stats", s.guardPage("stats", s.statsPage))
s.mux.HandleFunc("GET /api/points", s.apiPoints)
s.mux.HandleFunc("GET /api/track", s.apiTrack)
s.mux.HandleFunc("GET /api/cities", s.apiCityAgg)
s.mux.HandleFunc("GET /api/gpx", s.apiGpx)
s.mux.HandleFunc("GET /api/votes", s.apiVotes)
s.mux.HandleFunc("POST /api/vote", s.apiVote)
s.mux.HandleFunc("POST /api/verdict", s.requireAdmin(s.apiVerdict))
s.mux.HandleFunc("GET /api/map-config", s.mapConfig)
// 地理编码走服务端代理,高德密钥不出现在前端源码里;仅管理员可用,避免配额被外部消耗
s.mux.HandleFunc("GET /api/geocode", s.requireAdmin(s.apiGeocode))
s.mux.HandleFunc("GET /api/coords", s.requireAdmin(s.apiCoords))
s.mux.HandleFunc("POST /api/regeo", s.requireAdmin(s.apiRegeo))
s.mux.HandleFunc("GET /tiles/{layer}/{z}/{x}/{y}", s.handleTile)
s.mux.HandleFunc("GET /tiles/fog/{z}/{x}/{y}", s.handleFogTile)
// 专题:把某段时间 / 某个区域的足迹打包成可对外展示的页面。
// 不做页面级开关——访客能不能看由「每个专题的展示开关」决定(详情页对未展示的专题 404)。
s.mux.HandleFunc("GET /admin/topics", s.requireAdmin(s.topicsAdminPage))
s.mux.HandleFunc("POST /admin/topics/save", s.requireAdmin(s.adminTopicSave))
s.mux.HandleFunc("POST /admin/topics/delete", s.requireAdmin(s.adminTopicDelete))
s.mux.HandleFunc("POST /admin/topics/toggle", s.requireAdmin(s.adminTopicToggle))
s.mux.HandleFunc("GET /topics", s.topicsListPage)
s.mux.HandleFunc("GET /topics/{id}", s.topicPage)
s.mux.Handle("GET /report", s.guardPage("report", s.reportPage))
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := s.db.PingContext(r.Context()); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable)
return
}
io.WriteString(w, "ok")
})
// 后台
s.mux.HandleFunc("GET /captcha", s.captchaImage)
s.mux.HandleFunc("GET /admin/login", s.loginPage)
s.mux.HandleFunc("POST /admin/login", s.loginSubmit)
s.mux.HandleFunc("POST /admin/login/twofa", s.login2FA)
s.mux.HandleFunc("POST /admin/logout", s.logout)
s.mux.HandleFunc("GET /admin/twofa", s.requireAdmin(s.twofaPage))
s.mux.HandleFunc("POST /admin/twofa/setup", s.requireAdmin(s.twofaSetup))
s.mux.HandleFunc("POST /admin/twofa/confirm", s.requireAdmin(s.twofaConfirm))
s.mux.HandleFunc("POST /admin/twofa/disable", s.requireAdmin(s.twofaDisable))
s.mux.HandleFunc("GET /admin", s.requireAdmin(s.adminHome))
s.mux.HandleFunc("POST /admin/upload", s.requireAdmin(s.adminUpload))
s.mux.HandleFunc("POST /admin/preview", s.requireAdmin(s.adminBackupPreview))
s.mux.HandleFunc("GET /admin/preview/{token}", s.requireAdmin(s.adminBackupPreviewPage))
s.mux.HandleFunc("POST /admin/preview/{token}/merge", s.requireAdmin(s.adminBackupMerge))
s.mux.HandleFunc("POST /admin/datasets/{id}/activate", s.requireAdmin(s.adminActivate))
s.mux.HandleFunc("POST /admin/datasets/{id}/delete", s.requireAdmin(s.adminDelete))
s.mux.HandleFunc("GET /admin/baseline", s.requireAdmin(s.adminBaselinePage))
s.mux.HandleFunc("POST /admin/baseline", s.requireAdmin(s.adminBaselineUpload))
s.mux.HandleFunc("POST /admin/baseline/confirm", s.requireAdmin(s.adminBaselineConfirm))
s.mux.HandleFunc("POST /admin/baseline/rollback", s.requireAdmin(s.adminBaselineRollback))
s.mux.HandleFunc("POST /admin/baseline/rebuild", s.requireAdmin(s.adminBaselineRebuild))
s.mux.HandleFunc("POST /admin/place/note", s.requireAdmin(s.adminPlaceNote))
s.mux.HandleFunc("POST /admin/place/vote", s.requireAdmin(s.adminPlaceVote))
// 地点图片:字节由浏览器转成 WebP 后发过来,这里只负责收下、落库、取址
s.mux.HandleFunc("POST /admin/place/images", s.requireAdmin(s.adminPlaceImageUpload))
s.mux.HandleFunc("POST /admin/place/images/delete", s.requireAdmin(s.adminPlaceImageDelete))
// 本地存储的图片直出;对象存储的图走公开域名,不经过这里
s.mux.HandleFunc("GET /media/", s.serveMedia)
s.mux.HandleFunc("POST /admin/notes/bulk", s.requireAdmin(s.adminNotesBulk))
s.mux.HandleFunc("POST /admin/notes/rond", s.requireAdmin(s.adminNotesRond))
s.mux.HandleFunc("GET /admin/notes", s.requireAdmin(s.notesPage))
// 数据编辑:手动补录记录点,改完可导出回 rondbackup
s.mux.HandleFunc("GET /admin/edit", s.requireAdmin(s.editPage))
s.mux.HandleFunc("POST /admin/edit/place", s.requireAdmin(s.editPlaceCreate))
s.mux.HandleFunc("POST /admin/edit/place/update", s.requireAdmin(s.editPlaceUpdate))
s.mux.HandleFunc("POST /admin/edit/place/delete", s.requireAdmin(s.editPlaceDelete))
s.mux.HandleFunc("POST /admin/edit/visit/update", s.requireAdmin(s.editVisitUpdate))
s.mux.HandleFunc("POST /admin/edit/activity", s.requireAdmin(s.editActivityCreate))
s.mux.HandleFunc("POST /admin/edit/activity/update", s.requireAdmin(s.editActivityUpdate))
s.mux.HandleFunc("POST /admin/edit/activity/delete", s.requireAdmin(s.editActivityDelete))
s.mux.HandleFunc("POST /admin/edit/activity/merge", s.requireAdmin(s.editActivityMerge))
s.mux.HandleFunc("POST /admin/edit/category/rename", s.requireAdmin(s.editCategoryRename))
s.mux.HandleFunc("POST /admin/edit/category/clear", s.requireAdmin(s.editCategoryClear))
s.mux.HandleFunc("POST /admin/edit/category/label", s.requireAdmin(s.editCategoryLabel))
// 照片导入:EXIF 由浏览器读(不上传照片),确认后只把元数据提交上来落库
s.mux.HandleFunc("GET /admin/photos", s.requireAdmin(s.adminPhotosPage))
s.mux.HandleFunc("POST /admin/photos/import", s.requireAdmin(s.adminPhotosImport))
s.mux.HandleFunc("POST /admin/edit/visit/delete", s.requireAdmin(s.editVisitDelete))
s.mux.HandleFunc("POST /admin/edit/visit/bookmark", s.requireAdmin(s.adminVisitBookmark))
// 重复到访:清掉站点侧跟手机包重复的历史副本(判定见 edit_duplicates.go)
s.mux.HandleFunc("POST /admin/edit/dup/clean", s.requireAdmin(s.editDuplicateVisitsApply))
s.mux.HandleFunc("POST /admin/edit/movement", s.requireAdmin(s.editMovementCreate))
s.mux.HandleFunc("POST /admin/edit/movement/update", s.requireAdmin(s.editMovementUpdate))
s.mux.HandleFunc("POST /admin/edit/movement/delete", s.requireAdmin(s.editMovementDelete))
s.mux.HandleFunc("POST /admin/edit/weather", s.requireAdmin(s.editWeatherCreate))
s.mux.HandleFunc("POST /admin/gpx", s.requireAdmin(s.adminGpxUpload))
s.mux.HandleFunc("POST /admin/gpx/delete", s.requireAdmin(s.adminGpxDelete))
s.mux.HandleFunc("POST /admin/fog/preview", s.requireAdmin(s.adminFogPreview))
s.mux.HandleFunc("GET /admin/fog/preview/{token}", s.requireAdmin(s.adminFogPreviewPage))
s.mux.HandleFunc("GET /admin/fog/preview/{token}/{z}/{x}/{y}", s.requireAdmin(s.adminFogDiffTile))
s.mux.HandleFunc("POST /admin/fog", s.requireAdmin(s.adminFogUpload))
s.mux.HandleFunc("POST /admin/fog/clear", s.requireAdmin(s.adminFogClear))
s.mux.HandleFunc("GET /admin/fog/export", s.requireAdmin(s.adminFogExport))
s.mux.HandleFunc("POST /admin/places/backfill", s.requireAdmin(s.adminBackfillPlaces))
s.mux.HandleFunc("GET /admin/fog/mask", s.requireAdmin(s.fogMaskPage))
s.mux.HandleFunc("POST /admin/fog/mask/save", s.requireAdmin(s.fogMaskSave))
s.mux.HandleFunc("POST /admin/fog/mask/delete", s.requireAdmin(s.fogMaskDelete))
s.mux.HandleFunc("GET /admin/export/places.geojson", s.requireAdmin(s.exportGeoJSON))
s.mux.HandleFunc("GET /admin/export/places.gpx", s.requireAdmin(s.exportGPX))
s.mux.HandleFunc("GET /admin/export/rondbackup", s.requireAdmin(s.exportRondbackup))
s.mux.HandleFunc("GET /admin/fillup", s.requireAdmin(s.adminFillupPage))
s.mux.HandleFunc("POST /admin/fillup/apply", s.requireAdmin(s.adminFillupApply))
s.mux.HandleFunc("POST /admin/fillup/amap", s.requireAdmin(s.adminFillupAmap))
s.mux.HandleFunc("GET /admin/settings", s.requireAdmin(s.settingsPage))
s.mux.HandleFunc("POST /admin/settings", s.requireAdmin(s.settingsSubmit))
s.mux.HandleFunc("GET /admin/password", s.requireAdmin(s.passwordPage))
s.mux.HandleFunc("POST /admin/password", s.requireAdmin(s.passwordSubmit))
s.mux.HandleFunc("POST /admin/username", s.requireAdmin(s.usernameSubmit))
// 兜底:没匹配上任何路由的地址(打错的链接、爬虫乱扫)也给站点样式的中间页。
// 不注册这条的话走的是 ServeMux 默认响应,白底一行 `404 page not found`,看着像站点坏了。
// 更具体的模式优先级更高,所以静态资源、瓦片、图片这些不受影响;
// 瓦片/图片那几处仍然保留纯文本 404 —— 那些是给浏览器和爬虫取的资源,不是给人看的页面。
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
s.notFoundPage(w, "", "")
})
}
// ---------- 中间件 ----------
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
if s.cfg.Debug || strings.HasPrefix(r.URL.Path, "/admin") {
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
}
})
}
// headerSent 记录响应首部是否已经提交,供 recover 判断「还能不能写错误页」。
// 首部一旦提交,http.Error 只能再 WriteHeader 一次(Go 记一条 superfluous
// response.WriteHeader)并把错误体追加到半个页面后面,比不写更糟。
type headerSent struct {
http.ResponseWriter
sent bool
}
func (h *headerSent) WriteHeader(code int) {
h.sent = true
h.ResponseWriter.WriteHeader(code)
}
func (h *headerSent) Write(b []byte) (int, error) {
h.sent = true
return h.ResponseWriter.Write(b)
}
// Unwrap 让 http.NewResponseController 能穿过这层拿到真正的 ResponseWriter
// (SetWriteDeadline / Flush / Hijack 都靠逐层解包)。
func (h *headerSent) Unwrap() http.ResponseWriter { return h.ResponseWriter }
func (s *Server) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hw := &headerSent{ResponseWriter: w}
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic %s %s: %v", r.Method, r.URL.Path, rec)
if hw.sent {
// 已经发出去了,再写错误页只会污染响应;真正的补救是断开连接。
return
}
http.Error(hw, "服务器内部错误", http.StatusInternalServerError)
}
}()
next.ServeHTTP(hw, r)
})
}
// sessionUser 从签名 cookie 还原登录态;口令变更后旧会话自动失效。
func (s *Server) sessionUser(r *http.Request) (*auth.User, error) {
c, err := r.Cookie(s.cookie)
if err != nil || c.Value == "" {
return nil, nil
}
uidStr, _, ok := strings.Cut(c.Value, "|")
if !ok {
return nil, nil
}
uid, err := strconv.ParseInt(uidStr, 10, 64)
if err != nil {
return nil, nil
}
u, err := s.auth.UserByID(r.Context(), uid)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
if _, ok := s.auth.ParseToken(c.Value, u.Hash); !ok {
return nil, nil
}
return u, nil
}
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if u == nil {
http.Redirect(w, r, "/admin/login?next="+path.Clean(r.URL.Path), http.StatusSeeOther)
return
}
next(w, r)
}
}
type userKey struct{}
func userFrom(ctx context.Context) *auth.User {
u, _ := ctx.Value(userKey{}).(*auth.User)
return u
}
// cookieSecure 判断这次响应能不能给会话 cookie 打 Secure。
//
// 不能无条件打:本机 http 调试、以及本仓库的 CDP 验证流程都跑在 http 上,
// 打了 Secure 浏览器就不回传 cookie,等于谁都登不进去。所以按「这次请求是不是
// 走的 https」判断:直连看 r.TLS,在反向代理后面看 X-Forwarded-Proto(只取首段)。
func cookieSecure(r *http.Request) bool {
if r.TLS != nil {
return true
}
proto := r.Header.Get("X-Forwarded-Proto")
if i := strings.IndexByte(proto, ','); i >= 0 {
proto = proto[:i]
}
return strings.EqualFold(strings.TrimSpace(proto), "https")
}
func (s *Server) login(w http.ResponseWriter, r *http.Request, userID int64, hash string) {
http.SetCookie(w, &http.Cookie{
Name: s.cookie,
Value: s.auth.SignToken(userID, hash),
Path: "/",
HttpOnly: true,
Secure: cookieSecure(r),
SameSite: http.SameSiteLaxMode,
MaxAge: s.cfg.SessionDays * 86400,
})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: s.cookie, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: cookieSecure(r),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// ---------- 渲染 ----------
// render 渲染指定页面(key 见 pageFiles)。
func (s *Server) render(w http.ResponseWriter, key string, data any) {
set, err := s.pageSet(key)
if err != nil {
log.Printf("加载页面模板 %s 失败: %v", key, err)
http.Error(w, "页面不存在", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := set.t.ExecuteTemplate(w, set.entry, data); err != nil {
log.Printf("渲染 %s 失败: %v", key, err)
}
}
func (s *Server) renderStatus(w http.ResponseWriter, code int, key string, data any) {
set, err := s.pageSet(key)
if err != nil {
http.Error(w, "页面不存在", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
if err := set.t.ExecuteTemplate(w, set.entry, data); err != nil {
log.Printf("渲染 %s 失败: %v", key, err)
}
}
// pageSet 返回模板集;调试模式下每次从磁盘重新解析,改完模板刷新即可见效。
func (s *Server) pageSet(key string) (pageSet, error) {
def, ok := pageFiles[key]
if !ok {
return pageSet{}, fmt.Errorf("未知页面模板 %s", key)
}
if !s.cfg.Debug {
return s.pages[key], nil
}
files := make([]string, len(def.files))
for i, f := range def.files {
// 嵌入路径相对包目录,调试时按工作目录拼接
files[i] = filepath.Join("internal", "web", f)
}
t, err := template.New(filepath.Base(files[0])).Funcs(templateFuncs()).ParseFiles(files...)
if err != nil {
return pageSet{}, err
}
return pageSet{t: t, entry: def.entry}, nil
}
func (s *Server) json(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("JSON 输出失败: %v", err)
}
}
// serverError 统一处理内部错误。详情只回给已登录的站长:公开访客页把 err.Error()
// 原样回显,等于把 SQL 错误里的表结构、列名、以及磁盘路径直接送到门外。
func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("处理 %s 出错: %v", r.URL.Path, err)
msg := "服务器内部错误,请稍后重试"
if userFrom(r.Context()) != nil {
msg = "服务器内部错误: " + err.Error()
}
http.Error(w, msg, http.StatusInternalServerError)
}
func atoiOr(s string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return def
}
return n
}
func parseInt64s(vals []string) []int64 {
var out []int64
for _, v := range vals {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
out = append(out, n)
}
}
return out
}
// datasetFor 取当前生效的数据集;无数据时返回 nil。
func (s *Server) datasetFor(ctx context.Context) (*Dataset, error) {
var d Dataset
err := s.db.QueryRowContext(ctx, `SELECT id, original_name, uploaded_at, visit_count, place_count,
raw_visit_count, first_visit_at, last_visit_at, size_bytes
FROM datasets WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`).
Scan(&d.ID, &d.Name, &d.UploadedAt, &d.Visits, &d.Places, &d.RawVisits, &d.First, &d.Last, &d.Size)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &d, nil
}
type Dataset struct {
ID int64
Name string
UploadedAt time.Time
Visits int
Places int
RawVisits int
First *time.Time
Last *time.Time
Size int64
}