二进制及自命令命名统一简化

This commit is contained in:
2026-09-08 17:01:09 +08:00
parent 6bfe20ee00
commit edfc787e1a
18 changed files with 105 additions and 105 deletions
+364
View File
@@ -0,0 +1,364 @@
// 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
}
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.Open(cfg.DBPath(), false)
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.Open(cfg.DBPath(), false)
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.Open(cfg.DBPath(), false)
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
}