二进制及自命令命名统一简化
This commit is contained in:
+364
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user