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/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 // 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: ¬es.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), 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.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/datasets/{id}/activate", s.requireAdmin(s.adminActivate)) s.mux.HandleFunc("POST /admin/datasets/{id}/delete", s.requireAdmin(s.adminDelete)) 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)) // 照片导入: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)) 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", 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/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)) } }) } func (s *Server) recover(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { log.Printf("panic %s %s: %v", r.Method, r.URL.Path, rec) http.Error(w, "服务器内部错误", http.StatusInternalServerError) } }() next.ServeHTTP(w, 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 } func (s *Server) login(w http.ResponseWriter, userID int64, hash string) { http.SetCookie(w, &http.Cookie{ Name: s.cookie, Value: s.auth.SignToken(userID, hash), Path: "/", HttpOnly: true, 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}) 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) } } func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) { log.Printf("处理 %s 出错: %v", r.URL.Path, err) http.Error(w, "服务器内部错误: "+err.Error(), 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 }