// pn 单二进制入口:start / init / passwd / backup / gc / version(§7.6)。 package main import ( "bufio" "context" "errors" "fmt" "log/slog" "net/http" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "golang.org/x/term" "pure-note/internal/auth" "pure-note/internal/config" "pure-note/internal/httpapi" "pure-note/internal/store" "pure-note/internal/webui" ) // 构建时注入(Makefile -ldflags)。 var ( version = "dev" commit = "none" buildDate = "unknown" ) func main() { if len(os.Args) < 2 { usage() os.Exit(2) } var err error switch os.Args[1] { case "start": err = runStart(os.Args[2:]) case "init": err = runInit(os.Args[2:]) case "passwd": err = runResetPassword(os.Args[2:]) case "backup": err = runBackup(os.Args[2:]) case "gc": err = runGC(os.Args[2:]) case "version", "-v", "--version": fmt.Printf("pn %s (commit=%s, built=%s)\n", version, commit, buildDate) case "help", "-h", "--help": usage() default: fmt.Fprintf(os.Stderr, "未知子命令: %s\n\n", os.Args[1]) usage() os.Exit(2) } if err != nil { fmt.Fprintln(os.Stderr, "错误:", err) os.Exit(1) } } func usage() { fmt.Print(`pn — 极简高安全私人笔记 + 博客 用法: pn start [--addr :8080] [--behind-proxy] [--dev] [--allow-newer] pn init pn passwd # 重设管理员口令并吊销全部会话 pn backup [输出路径] pn gc [--commit] # 默认 dry-run pn version # 亦可 -v / --version # --data-dir 可指定数据目录,默认 ./data `) } func newLogger(cfg *config.Config) *slog.Logger { level := slog.LevelInfo switch strings.ToLower(cfg.LogLevel) { case "debug": level = slog.LevelDebug case "warn": level = slog.LevelWarn case "error": level = slog.LevelError } opts := &slog.HandlerOptions{Level: level} var h slog.Handler if cfg.LogFormat == "json" { h = slog.NewJSONHandler(os.Stderr, opts) } else { h = slog.NewTextHandler(os.Stderr, opts) } return slog.New(h) } // runStart 启动 HTTP 服务(§7.2 服务器参数 + §10.5 优雅停机)。 func runStart(args []string) error { cfg, err := config.ParseStart(args) if err != nil { return err } log := newLogger(cfg) if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { return err } st, err := store.Open(cfg.DBPath(), cfg.AllowNewer) if err != nil { return err } defer st.Close() // 幂等启动守卫:未初始化拒绝启动(§10.2) if _, ok, err := st.GetSetting("admin_password_hash"); err != nil { return err } else if !ok { return errors.New("尚未初始化管理员口令:请先执行 `pn init --data-dir " + cfg.DataDir + "`") } srv := httpapi.New(st, cfg, log) ui, err := webui.New() if err != nil { return err } httpServer := &http.Server{ Addr: cfg.Addr, Handler: srv.Handler(ui.Handler(srv.ResolveMeta)), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 120 * time.Second, WriteTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second, } // 每小时清理过期会话(§6.2) stop := make(chan struct{}) go func() { t := time.NewTicker(time.Hour) defer t.Stop() for { select { case <-t.C: if n, err := st.DeleteExpiredSessions(time.Now().Unix()); err == nil && n > 0 { log.Info("清理过期会话", "count", n) } case <-stop: return } } }() // 优雅停机:SIGTERM/SIGINT → Shutdown(10s) → 关库 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer cancel() errCh := make(chan error, 1) go func() { log.Info("pn 已启动", "addr", cfg.Addr, "data_dir", cfg.DataDir, "dev", cfg.Dev) errCh <- httpServer.ListenAndServe() }() select { case err := <-errCh: if !errors.Is(err, http.ErrServerClosed) { close(stop) return err } case <-ctx.Done(): log.Info("收到停止信号,开始优雅停机…") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutdownCancel() _ = httpServer.Shutdown(shutdownCtx) } close(stop) return st.Close() } // runInit 首次初始化:设口令(Argon2id+PHC)与站点标题(§7.3-1)。 func runInit(args []string) error { cfg, err := config.ParseInit(args) if err != nil { return err } if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { return err } st, err := store.Open(cfg.DBPath(), cfg.AllowNewer) if err != nil { return err } defer st.Close() if _, ok, err := st.GetSetting(store.KeyAdminPasswordHash); err != nil { return err } else if ok { return errors.New("管理员口令已设置;如需修改请在登录后通过管理界面改密") } var password string if env := os.Getenv("PN_ADMIN_PASSWORD"); env != "" { password = env fmt.Fprintln(os.Stderr, "已从环境变量 PN_ADMIN_PASSWORD 读取口令") } else { password, err = promptPassword() if err != nil { return err } } if err := auth.CheckPasswordStrength(password); err != nil { return err } hash, err := auth.HashPassword(password) if err != nil { return err } title := strings.TrimSpace(os.Getenv("PN_SITE_TITLE")) if title == "" { title = promptLine("站点标题", store.DefaultSiteTitle) } if err := st.SetSetting(store.KeyAdminPasswordHash, hash); err != nil { return err } if err := st.SetSetting(store.KeySiteTitle, title); err != nil { return err } // 种子内容:一篇 Markdown 语法示例文档(公开)+ 自动生成的示例图片 if _, err := seedWelcome(st); err != nil { return fmt.Errorf("写入初始文档失败: %w", err) } fmt.Println("初始化完成。现在可以启动:pn start --data-dir", cfg.DataDir) return nil } // runResetPassword 重设管理员口令:CLI 可达即具备服务器权限,允许直接覆盖。 // 口令来源与 init 一致:环境变量 PN_ADMIN_PASSWORD 或交互输入(避免出现在 ps 参数里)。 // 重设成功后吊销全部会话,强制所有端重新登录。 func runResetPassword(args []string) error { cfg, _, err := config.ParseMaint("passwd", args) if err != nil { return err } st, err := store.OpenData(cfg.DBPath()) if err != nil { return err } defer st.Close() var password string if env := os.Getenv("PN_ADMIN_PASSWORD"); env != "" { password = env fmt.Fprintln(os.Stderr, "已从环境变量 PN_ADMIN_PASSWORD 读取口令") } else { password, err = promptPassword() if err != nil { return err } } if err := auth.CheckPasswordStrength(password); err != nil { return err } hash, err := auth.HashPassword(password) if err != nil { return err } if err := st.SetSetting(store.KeyAdminPasswordHash, hash); err != nil { return err } revoked, err := st.DeleteAllSessions() if err != nil { return err } fmt.Printf("口令已重设;已吊销 %d 个会话,所有端需重新登录。\n", revoked) return nil } func promptPassword() (string, error) { fmt.Print("设置管理员口令(≥12 字符): ") b1, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() if err != nil { return "", err } fmt.Print("再次输入确认: ") b2, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() if err != nil { return "", err } if string(b1) != string(b2) { return "", errors.New("两次输入不一致") } return string(b1), nil } func promptLine(label, def string) string { fmt.Printf("%s [%s]: ", label, def) line, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil || strings.TrimSpace(line) == "" { return def } return strings.TrimSpace(line) } // runBackup 在线备份:VACUUM INTO 一致快照(§10.3)。默认输出到数据目录,0600。 func runBackup(args []string) error { cfg, rest, err := config.ParseMaint("backup", args) if err != nil { return err } dest := "" if len(rest) > 0 { dest = rest[0] } if dest == "" { dest = filepath.Join(cfg.DataDir, fmt.Sprintf("pn-backup-%s.db", time.Now().Format("20060102-150405"))) } st, err := store.OpenData(cfg.DBPath()) if err != nil { return err } defer st.Close() if err := st.Backup(dest); err != nil { return err } fmt.Println("备份完成:", dest) return nil } // runGC 回收站/孤儿图片/过期会话清理;默认 dry-run,--commit 才执行(§6.3)。 func runGC(args []string) error { cfg, _, err := config.ParseMaint("gc", args) if err != nil { return err } commit := false for _, a := range args { if a == "--commit" { commit = true } } st, err := store.OpenData(cfg.DBPath()) if err != nil { return err } defer st.Close() rep, err := st.GC(time.Now(), !commit) if err != nil { return err } mode := "dry-run(未改动数据;加 --commit 执行)" if commit { mode = "已提交" } fmt.Printf("gc 报告(%s):\n", mode) fmt.Printf(" 过期回收站笔记(>30 天): %d 篇\n", len(rep.ExpiredNotes)) for _, n := range rep.ExpiredNotes { fmt.Printf(" - id=%d slug=%s deleted_at=%s\n", n.ID, n.Slug, time.Unix(n.DeletedAt, 0).Format("2006-01-02")) } fmt.Printf(" 孤儿图片(>7 天宽限期): %d 张\n", len(rep.OrphanImages)) for _, img := range rep.OrphanImages { fmt.Printf(" - id=%d %s %d bytes\n", img.ID, img.MIME, img.Size) } fmt.Printf(" 过期会话: %d 条\n", rep.ExpiredSessons) return nil }