// 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/config" "oneblog/internal/db" "oneblog/internal/store" ) 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) } public := &api.API{Store: st, Cfg: cfg} adminAPI := admin.NewAPI(st, cfg, admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)) root := http.NewServeMux() root.Handle("/api/admin/", adminAPI.Routes()) public.Mount(root) 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) if cfg.InsecureDev { log.Printf("admin login: %s / %s (set ONE_ADMIN_PASSWORD to change)", cfg.AdminUser, cfg.AdminPass) } else { log.Printf("admin login: %s / (from ONE_ADMIN_PASSWORD)", cfg.AdminUser) } 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) } func placeholderPage() string { return `
后端已启动,但前端还没构建。
在项目根目录执行 make web 构建前端,或 make dev 同时起前后端。
接口可用:/api/posts、/api/archive、/api/tags、/rss.xml;后台接口在 /api/admin/。