// Package web 提供 HTTP 路由、会话、模板渲染与页面处理器。 package web import ( "bytes" "context" "crypto/rand" "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 ConfigPath string Version string } // Server 是应用服务。 type Server struct { db *store.DB cfg Config tpl *renderer handler http.Handler started time.Time } 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()} s.handler = s.routes() return s, nil } // 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/", cacheControl(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.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) } func cacheControl(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=300") 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 Query 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, 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 }