// Command one-server is the ONE blog backend: public API, admin API, RSS and // (when built) the static frontend. package main import ( "context" "errors" "flag" "fmt" "log" "net/http" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "oneblog/internal/admin" "oneblog/internal/api" "oneblog/internal/auth" "oneblog/internal/config" "oneblog/internal/db" "oneblog/internal/hub" "oneblog/internal/store" "oneblog/internal/thumbs" ) func main() { addr := flag.String("addr", "", "listen address (overrides ONE_ADDR)") flag.Parse() cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) } if *addr != "" { cfg.Addr = *addr } pool, err := db.Open(cfg.Driver, cfg.DSN) if err != nil { log.Fatalf("database: %v", err) } defer pool.Close() st, err := store.New(pool) if err != nil { log.Fatalf("store: %v", err) } // 有效配置 = DB 里的系统设置叠加环境变量(DB 优先)。站点地址、 // OAuth 凭据、R2 存储、管理员账号都已迁进后台「设置」页, // 这里只保留 env 兜底值,改设置不再需要重启。 res := config.NewResolver(cfg, st) cfgEff := res.Config() // 评论区的读者会话(cookie one_reader),30 天有效 readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour) // 后台管理员会话(cookie one_session),前台评论也用它识别站主身份 adminSessions := admin.NewSessions(cfg.SessionSec, 7*24*time.Hour) // 评论变更广播(SSE):公开端订阅、双端发布,共用同一实例 commentHub := hub.New() // 缩略图懒生成 + 磁盘缓存(首次请求生成一次,之后直接供缓存) thumbCache := thumbs.NewStore(filepath.Join(cfg.DataDir, ".thumbnail_cache")) // Passkey(WebAuthn):只有显式配了 ONE_WEBAUTHN_ORIGINS 才启用。 // 没配就不构造 —— 相关端点保持 404,前端也不会显示入口。 // 显示名固定 "ONE":站点标题存在 settings 表里,为浏览器弹窗去读库不值当。 var passkeys *auth.Passkeys if cfg.WebauthnRPID != "" && len(cfg.WebauthnOrigins) > 0 { passkeys, err = auth.NewPasskeys("ONE", cfg.WebauthnRPID, cfg.WebauthnOrigins) if err != nil { log.Fatalf("passkeys: %v", err) } log.Printf("passkey 登录已启用 (rp_id=%s origins=%s)", cfg.WebauthnRPID, strings.Join(cfg.WebauthnOrigins, ",")) } public := &api.API{ Store: st, Cfg: cfg, Res: res, Thumbs: thumbCache, ReaderSessions: readerSessions, AdminSessions: adminSessions, Passkeys: passkeys, Hub: commentHub, } adminAPI := admin.NewAPI(st, cfg, adminSessions) adminAPI.Res = res adminAPI.Hub = commentHub adminAPI.Thumbs = thumbCache // 删文件时连带清掉它的缩略图 adminAPI.Passkeys = passkeys root := http.NewServeMux() root.Handle("/api/admin/", adminAPI.Routes()) public.Mount(root) // 更长的前缀优先匹配:/uploads/thumb/ 走缩略图,其余 /uploads/ 走原图流式 root.Handle("/uploads/thumb/", public.ThumbHandler()) root.Handle("/uploads/", public.UploadsHandler()) root.Handle("/", spaHandler(cfg.WebDist)) srv := &http.Server{ Addr: cfg.Addr, Handler: requestLog(root), ReadTimeout: 15 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second, } go func() { log.Printf("ONE server listening on %s (db=%s)", cfg.Addr, cfg.Driver) // 登录提示按有效配置报:后台改过密码/用户名后,这里说的是 DB 里的那套 sysSt, err := st.GetSettings() passNote := "(set ONE_ADMIN_PASSWORD to change)" if err == nil && sysSt.AdminPasswordHash != "" { passNote = "(password set in admin settings)" } if cfgEff.InsecureDev && (err != nil || sysSt.AdminPasswordHash == "") { log.Printf("admin login: %s / %s (set ONE_ADMIN_PASSWORD to change)", cfgEff.AdminUser, cfg.AdminPass) } else { log.Printf("admin login: %s / %s", cfgEff.AdminUser, passNote) } if cfgEff.StorageDriver == "r2" { log.Printf("storage: R2 (%s)", cfgEff.R2Bucket) } else { log.Print("storage: local disk (在后台「设置 → 对象存储」配置 R2 后无需重启即生效)") } if !dirExists(cfg.WebDist) { log.Printf("frontend build not found at %s · run `make web` or `make dev` (API still available)", cfg.WebDist) } if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("listen: %v", err) } }() stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = srv.Shutdown(ctx) log.Print("bye") } // spaHandler serves the built Vue app and falls back to index.html so client // routes like /post/foo work on refresh. func spaHandler(dist string) http.Handler { fs := http.Dir(dist) fileServer := http.FileServer(fs) index := filepath.Join(dist, "index.html") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !dirExists(dist) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, placeholderPage()) return } p := strings.TrimPrefix(r.URL.Path, "/") if p == "" { p = "index.html" } if _, err := fs.Open(p); err == nil && hasExt(p) { // 缓存策略分两类: // assets/ 下的文件名带内容 hash(Vite 产物),内容变了文件名就变, // 所以可以长期强缓存;其余(主要是 index.html)必须每次回源校验, // 否则新构建不会生效 : 页面「改了但看着还是旧的」通常就是它。 if strings.HasPrefix(p, "assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache, must-revalidate") } fileServer.ServeHTTP(w, r) return } w.Header().Set("Cache-Control", "no-cache, must-revalidate") http.ServeFile(w, r, index) }) } func hasExt(p string) bool { return strings.Contains(filepath.Base(p), ".") } func dirExists(p string) bool { fi, err := os.Stat(p) return err == nil && fi.IsDir() } func requestLog(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r) if strings.HasPrefix(r.URL.Path, "/api") || r.URL.Path == "/rss.xml" { log.Printf("%s %s %d %s", r.Method, r.URL.RequestURI(), rec.status, time.Since(start).Truncate(time.Millisecond)) } }) } type statusRecorder struct { http.ResponseWriter status int } func (s *statusRecorder) WriteHeader(code int) { s.status = code; s.ResponseWriter.WriteHeader(code) } // Flush 透传给底层 writer——SSE 这类流式响应靠它判定可刷新, // 包装器不实现 Flusher 的话流式端点会直接不可用。 func (s *statusRecorder) Flush() { if f, ok := s.ResponseWriter.(http.Flusher); ok { f.Flush() } } func placeholderPage() string { return `
后端已启动,但前端还没构建。
在项目根目录执行 make web 构建前端,或 make dev 同时起前后端。
接口可用:/api/posts、/api/archive、/api/tags、/rss.xml;后台接口在 /api/admin/。