365 lines
9.0 KiB
Go
365 lines
9.0 KiB
Go
// 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
|
||
}
|