Files
pure-note/cmd/pn/main.go
T
wangairnan 5457aeaa33 feat: 新增发布日期自选、站点 Logo、回收站清空等十项改进
- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建)
- 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期
- 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案
- 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验
- 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小
- 后台各页顶栏固定高度,切换页面不再抖动
- 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片,
  博客 header 站点名前展示,设置页支持上传/更换/清除
- 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗)
- pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
2026-09-09 11:10:30 +08:00

369 lines
9.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}