// Package web 提供 HTTP 路由、会话、模板渲染与页面处理器。 package web import ( "bytes" "context" "crypto/rand" "crypto/sha256" "embed" "encoding/hex" "fmt" "html/template" "io/fs" "log" "net" "net/http" "net/url" "strconv" "strings" "time" "daili/internal/domain" "daili/internal/store" ) //go:embed templates/_layout.html templates/_screen_layout.html templates/_partials.html templates/pages/*.html var templateFS embed.FS //go:embed static var staticFS embed.FS const ( cookieSession = "daili_sid" cookieFlash = "daili_flash" cookieTheme = "daili_theme" sessionTTL = 7 * 24 * time.Hour pageSize = 20 ) // Config 是服务配置。 type Config struct { Addr string DBPath string BackupKeep int BackupDir string BackupEnabled bool ScreenPublic bool PortalPublic bool ConfigPath string Version string } // Server 是应用服务。 type Server struct { db *store.DB cfg Config tpl *renderer handler http.Handler started time.Time assetVersion string } type ctxKey int const userCtxKey ctxKey = 1 // New 构造服务。 func New(db *store.DB, cfg Config) (*Server, error) { r, err := newRenderer() if err != nil { return nil, err } s := &Server{db: db, cfg: cfg, tpl: r, started: time.Now(), assetVersion: staticAssetVersion()} s.handler = s.routes() return s, nil } // staticAssetVersion 计算内嵌静态资源的内容指纹。 // 资源 URL 带上它之后可以放心长缓存;改了 CSS/JS 重新编译就会自动换 URL。 func staticAssetVersion() string { h := sha256.New() if err := fs.WalkDir(staticFS, "static", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } data, rerr := fs.ReadFile(staticFS, path) if rerr != nil { return nil } h.Write([]byte(path)) h.Write([]byte{0}) h.Write(data) return nil }); err != nil { return "dev" } return hex.EncodeToString(h.Sum(nil))[:12] } // Handler 返回 HTTP 处理器。 func (s *Server) Handler() http.Handler { return s.handler } func (s *Server) routes() http.Handler { mux := http.NewServeMux() sub, err := fs.Sub(staticFS, "static") if err != nil { panic(err) } mux.Handle("GET /static/", s.staticFiles(http.StripPrefix("/static/", http.FileServerFS(sub)))) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte("ok")) }) mux.HandleFunc("GET /login", s.handleLoginPage) mux.HandleFunc("POST /login", s.handleLoginPost) mux.HandleFunc("GET /setup", s.handleSetupPage) mux.HandleFunc("POST /setup", s.handleSetupPost) mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/calendar", http.StatusFound) }) mux.HandleFunc("GET /calendar", s.auth(s.handleCalendar)) mux.HandleFunc("GET /customers", s.auth(s.handleCustomers)) mux.HandleFunc("GET /customers/new", s.auth(s.handleCustomerNewPage)) mux.HandleFunc("POST /customers", s.auth(s.handleCustomerCreate)) mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail)) mux.HandleFunc("GET /customers/{id}/edit", s.auth(s.handleCustomerEditPage)) mux.HandleFunc("POST /customers/{id}/edit", s.auth(s.handleCustomerUpdate)) mux.HandleFunc("POST /customers/{id}/assignee", s.auth(s.handleCustomerAssignee)) mux.HandleFunc("POST /customers/{id}/archive", s.auth(s.handleCustomerArchive)) mux.HandleFunc("POST /customers/{id}/restore", s.auth(s.handleCustomerRestore)) mux.HandleFunc("POST /customers/{id}/delete", s.auth(s.handleCustomerDelete)) mux.HandleFunc("POST /customers/{id}/subscriptions", s.auth(s.handleSubCreate)) mux.HandleFunc("GET /subscriptions", s.auth(s.handleSubscriptions)) mux.HandleFunc("POST /subscriptions/{id}/extend", s.auth(s.handleSubExtend)) mux.HandleFunc("POST /subscriptions/{id}/pause", s.auth(s.handleSubAction("pause"))) mux.HandleFunc("POST /subscriptions/{id}/resume", s.auth(s.handleSubAction("resume"))) mux.HandleFunc("POST /subscriptions/{id}/close", s.auth(s.handleSubAction("close"))) mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen"))) mux.HandleFunc("POST /subscriptions/{id}/delete", s.auth(s.handleSubDelete)) mux.HandleFunc("POST /subscriptions/{id}/season-done", s.auth(s.handleSeasonDone)) mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave)) mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete)) mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch)) mux.HandleFunc("POST /checkins/quick", s.auth(s.handleQuickCheckin)) mux.HandleFunc("POST /tasks/urgent", s.auth(s.handleUrgentToggle)) mux.HandleFunc("GET /packages", s.auth(s.handlePackages)) mux.HandleFunc("GET /packages/new", s.auth(s.handlePackageNewPage)) mux.HandleFunc("POST /packages", s.auth(s.handlePackageCreate)) mux.HandleFunc("GET /packages/{id}/edit", s.auth(s.handlePackageEditPage)) mux.HandleFunc("POST /packages/{id}/edit", s.auth(s.handlePackageUpdate)) mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive)) mux.HandleFunc("GET /packages/{id}", s.auth(s.handlePackageDetail)) mux.HandleFunc("POST /packages/{id}/subscribe", s.auth(s.handlePackageBatchSubscribe)) if s.cfg.PortalPublic { // 客户自助查询:独立开关,不受大屏 screen_public 影响 mux.HandleFunc("GET /customer", s.handleCustomerPortal) } if s.cfg.ScreenPublic { // 大屏免登录:仅局域网部署时使用,内容为只读看板 mux.HandleFunc("GET /screen", s.handleScreen) mux.HandleFunc("GET /api/screen/day", s.handleScreenDay) } else { mux.HandleFunc("GET /screen", s.auth(s.handleScreen)) mux.HandleFunc("GET /api/screen/day", s.auth(s.handleScreenDay)) } mux.HandleFunc("GET /logs", s.auth(s.handleLogs)) mux.HandleFunc("GET /settings", s.auth(s.handleSettings)) mux.HandleFunc("POST /settings", s.auth(s.handleSettingsSave)) mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange)) mux.HandleFunc("POST /settings/users", s.auth(s.handleUserCreate)) mux.HandleFunc("POST /settings/users/{id}/toggle", s.auth(s.handleUserToggle)) mux.HandleFunc("POST /settings/users/{id}/color", s.auth(s.handleUserColor)) mux.HandleFunc("GET /settings/users/{id}/edit", s.auth(s.handleUserEditPage)) mux.HandleFunc("POST /settings/users/{id}/edit", s.auth(s.handleUserEditSave)) mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow)) mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport)) return s.noCacheHTML(mux) } // staticFiles 给内嵌静态资源加缓存策略: // 只有带当前内容指纹(?v=)的 URL 才允许一年不可变缓存,其它一律回源。 func (s *Server) staticFiles(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") if r.URL.Query().Get("v") == s.assetVersion { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } h.ServeHTTP(w, r) }) } func (s *Server) noCacheHTML(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/static/") { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "same-origin") } h.ServeHTTP(w, r) }) } // ---------- 会话与鉴权 ---------- func randomToken() string { b := make([]byte, 24) if _, err := rand.Read(b); err != nil { return strconv.FormatInt(time.Now().UnixNano(), 36) } return hex.EncodeToString(b) } func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User) { token := randomToken() if err := s.db.CreateSession(token, u.ID, sessionTTL, clientIP(r)); err != nil { log.Printf("创建会话失败: %v", err) } http.SetCookie(w, &http.Cookie{ Name: cookieSession, Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()), }) } func (s *Server) userFromRequest(r *http.Request) *store.User { c, err := r.Cookie(cookieSession) if err != nil || c.Value == "" { return nil } u, err := s.db.SessionUser(c.Value) if err != nil { return nil } return u } func (s *Server) currentUser(r *http.Request) *store.User { if u, ok := r.Context().Value(userCtxKey).(*store.User); ok { return u } return nil } func sameOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { return true } u, err := url.Parse(origin) if err != nil { return false } return strings.EqualFold(u.Host, r.Host) } func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && !sameOrigin(r) { http.Error(w, "跨站请求被拒绝", http.StatusForbidden) return } u := s.userFromRequest(r) if u == nil { if r.Method == http.MethodGet { next := url.QueryEscape(r.URL.RequestURI()) http.Redirect(w, r, "/login?next="+next, http.StatusFound) return } http.Redirect(w, r, "/login", http.StatusFound) return } ctx := context.WithValue(r.Context(), userCtxKey, u) h(w, r.WithContext(ctx)) } } // ---------- 渲染 ---------- type renderer struct { pages map[string]*template.Template } func newRenderer() (*renderer, error) { funcs := template.FuncMap{ "seq": func(n int) []int { out := make([]int, 0, n) for i := 0; i < n; i++ { out = append(out, i) } return out }, "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, "money": func(cents int) string { return fmt.Sprintf("%.0f", float64(cents)/100) }, "pct": func(f float64) string { return fmt.Sprintf("%.2f", f) }, "md": func(s string) string { return mdOf(s) }, "dow": dowCN, } base, err := template.New("base").Funcs(funcs).ParseFS(templateFS, "templates/_layout.html", "templates/_partials.html") if err != nil { return nil, err } screenBase, err := template.New("screen").Funcs(funcs).ParseFS(templateFS, "templates/_screen_layout.html", "templates/_partials.html") if err != nil { return nil, err } pages := map[string]*template.Template{} entries, err := fs.Glob(templateFS, "templates/pages/*.html") if err != nil { return nil, err } for _, p := range entries { name := strings.TrimSuffix(strings.TrimPrefix(p, "templates/pages/"), ".html") var baseSet *template.Template if name == "screen" { baseSet = screenBase } else { baseSet = base } clone, err := baseSet.Clone() if err != nil { return nil, err } if _, err := clone.ParseFS(templateFS, p); err != nil { return nil, fmt.Errorf("解析模板 %s 失败: %w", p, err) } pages[name] = clone } return &renderer{pages: pages}, nil } func mdOf(s string) string { if len(s) >= 10 { return s[5:10] } return s } func dowCN(s string) string { t, err := domain.ParseDate(s) if err != nil { return "" } return [...]string{"一", "二", "三", "四", "五", "六", "日"}[domain.WeekdayIndex(t)] } // page 是所有页面模板的根数据。 type page struct { User *store.User Title string Nav string Flash string Error string Today time.Time TodayStr string WarnDays int Version string // AssetVersion 是内嵌静态资源的内容指纹,用于 /static/*.css?v=... 的长缓存 AssetVersion string Query string Name string InlineError string InvalidField string Next string DueToday int Due3 int Due7 int Addr string Theme string // "" = 跟随系统;light / dark D any } func (s *Server) warnDays() int { v, err := strconv.Atoi(s.db.Setting("warn_days", "3")) if err != nil || v <= 0 { return 3 } return v } func (s *Server) screenRefresh() int { v, err := strconv.Atoi(s.db.Setting("screen_refresh", "30")) if err != nil || v < 5 { return 30 } return v } func (s *Server) newPage(r *http.Request, title, nav string) *page { u := s.currentUser(r) today := domain.Today() p := &page{ User: u, Title: title, Nav: nav, Today: today, TodayStr: domain.Date(today), WarnDays: s.warnDays(), Version: s.cfg.Version, AssetVersion: s.assetVersion, Query: r.URL.Query().Get("q"), Next: r.URL.Query().Get("next"), Theme: themeFromRequest(r), } if u != nil { if due, due3, due7, err := s.db.Badges(today); err == nil { p.DueToday, p.Due3, p.Due7 = due, due3, due7 } } return p } // themeFromRequest 读取主题偏好:"" = 跟随系统,light / dark = 手动指定。 func themeFromRequest(r *http.Request) string { c, err := r.Cookie(cookieTheme) if err != nil { return "" } switch c.Value { case "light", "dark": return c.Value } return "" } func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) string { c, err := r.Cookie(cookieFlash) if err != nil || c.Value == "" { return "" } msg, _ := url.QueryUnescape(c.Value) http.SetCookie(w, &http.Cookie{Name: cookieFlash, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) return msg } func setFlash(w http.ResponseWriter, msg string) { http.SetCookie(w, &http.Cookie{ Name: cookieFlash, Value: url.QueryEscape(msg), Path: "/", MaxAge: 30, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) } func (s *Server) redirect(w http.ResponseWriter, r *http.Request, to, flash string) { if flash != "" { setFlash(w, flash) } http.Redirect(w, r, to, http.StatusSeeOther) } func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, d *page) { if d.User == nil { d.User = s.currentUser(r) } if d.Flash == "" { d.Flash = s.popFlash(w, r) } if d.Today.IsZero() { d.Today = domain.Today() d.TodayStr = domain.Date(d.Today) } if d.WarnDays == 0 { d.WarnDays = s.warnDays() } tpl, ok := s.tpl.pages[name] if !ok { log.Printf("模板不存在: %s", name) http.Error(w, "页面不存在", http.StatusInternalServerError) return } layout := "layout" if name == "screen" { layout = "screenlayout" } var buf bytes.Buffer if err := tpl.ExecuteTemplate(&buf, layout, d); err != nil { log.Printf("渲染 %s 失败: %v", name, err) http.Error(w, "页面渲染失败: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = buf.WriteTo(w) } func (s *Server) fail(w http.ResponseWriter, r *http.Request, name string, msg string, d *page) { if d == nil { d = s.newPage(r, "出错了", "") } d.Error = msg w.WriteHeader(http.StatusBadRequest) s.render(w, r, name, d) } // ---------- 参数 ---------- func (s *Server) queryInt(r *http.Request, key string, def int) int { v, err := strconv.Atoi(r.URL.Query().Get(key)) if err != nil { return def } return v } func (s *Server) formInt(r *http.Request, key string, def int) int { v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue(key))) if err != nil { return def } return v } func pathID(r *http.Request) int64 { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { return 0 } return id } // backURL 取安全的返回地址。 func backURL(r *http.Request, def string) string { if b := strings.TrimSpace(r.PostFormValue("back")); strings.HasPrefix(b, "/") && !strings.HasPrefix(b, "//") { return b } if ref := r.Referer(); ref != "" { if u, err := url.Parse(ref); err == nil && strings.EqualFold(u.Host, r.Host) { p := u.RequestURI() if strings.HasPrefix(p, "/") { return p } } } return def }