From 247e88c4fb101d30c3b588465de826c090327b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=81=E5=86=9C=E9=98=BF=E6=A5=A0?= Date: Tue, 8 Sep 2026 08:14:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=9F=BA=E7=A1=80=E5=B1=82?= =?UTF-8?q?=EF=BC=9A=E6=A8=A1=E5=9D=97=E5=AE=9A=E4=B9=89=E3=80=81=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E8=A7=A3=E6=9E=90=E3=80=81SQLite=20=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E5=B1=82=E3=80=81Argon2id=20=E8=AE=A4=E8=AF=81=E4=B8=8E=20Mark?= =?UTF-8?q?down=20=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/config:serve/init/backup/gc 子命令参数解析(--dev 强制 loopback 守卫) - internal/store:user_version 版本化迁移(仅追加式 + 越界拒启 + --allow-newer)、 笔记/图片/引用/会话/设置 DAO、并集可见性查询、VACUUM INTO 在线备份、 回收站 30 天 + 孤儿图 7 天宽限 gc - internal/auth:Argon2id PHC 串(m=19456,t=2,p=1)、256bit token 与 SHA-256 摘要 - internal/markdown:goldmark(默认转义)+ bluemonday 双保险,摘要纯文本提取 - internal/middleware:安全头(CSP/HSTS/nosniff 等)、错误日志、 有界令牌桶限流(per-IP 桶上限 + TTL 逐出)、Origin/Referer 同源校验 --- go.mod | 27 +++ go.sum | 66 ++++++ internal/auth/auth.go | 96 ++++++++ internal/auth/auth_test.go | 54 +++++ internal/config/config.go | 87 +++++++ internal/config/config_test.go | 23 ++ internal/markdown/markdown.go | 54 +++++ internal/markdown/markdown_test.go | 33 +++ internal/middleware/middleware.go | 136 +++++++++++ internal/middleware/ratelimit.go | 157 +++++++++++++ internal/store/images.go | 134 +++++++++++ internal/store/maint.go | 134 +++++++++++ internal/store/notes.go | 361 +++++++++++++++++++++++++++++ internal/store/sessions.go | 78 +++++++ internal/store/settings.go | 70 ++++++ internal/store/store.go | 154 ++++++++++++ internal/store/store_test.go | 122 ++++++++++ 17 files changed, 1786 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/markdown/markdown.go create mode 100644 internal/markdown/markdown_test.go create mode 100644 internal/middleware/middleware.go create mode 100644 internal/middleware/ratelimit.go create mode 100644 internal/store/images.go create mode 100644 internal/store/maint.go create mode 100644 internal/store/notes.go create mode 100644 internal/store/sessions.go create mode 100644 internal/store/settings.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3ee66e9 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module pure-note + +go 1.26.6 + +require ( + github.com/microcosm-cc/bluemonday v1.0.27 + github.com/yuin/goldmark v1.8.6 + golang.org/x/crypto v0.56.0 + golang.org/x/term v0.45.0 + golang.org/x/time v0.15.0 + modernc.org/sqlite v1.58.0 +) + +require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aab3f74 --- /dev/null +++ b/go.sum @@ -0,0 +1,66 @@ +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..f311c0c --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,96 @@ +// Package auth 提供 Argon2id 口令哈希(PHC 串)、随机 token 与 token 摘要。 +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// OWASP 现行推荐 Argon2id 参数:m=19MiB、t=2、p=1(设计 §3.1/§7.3)。 +const ( + ArgonTime = 2 + ArgonMemory = 19456 + ArgonThreads = 1 + SaltLen = 16 + KeyLen = 32 + // MinPasswordLength 新口令最小长度(§7.1 改密)。 + MinPasswordLength = 12 +) + +// HashPassword 生成 PHC 格式串: +// $argon2id$v=19$m=19456,t=2,p=1$$(参数随哈希走)。 +func HashPassword(password string) (string, error) { + salt := make([]byte, SaltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("生成盐失败: %w", err) + } + key := argon2.IDKey([]byte(password), salt, ArgonTime, ArgonMemory, ArgonThreads, KeyLen) + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + ArgonMemory, ArgonTime, ArgonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key), + ), nil +} + +// VerifyPassword 按 PHC 串内参数重派生并常量时间比较。任何解析失败均返回 false。 +func VerifyPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + // ["", "argon2id", "v=19", "m=..,t=..,p=..", salt, hash] + if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" { + return false + } + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { + return false + } + var m uint32 + var t uint32 + var p uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// ErrWeakPassword 新口令不满足强度要求。 +var ErrWeakPassword = errors.New("密码长度至少 12 个字符") + +// CheckPasswordStrength 校验新口令强度。 +func CheckPasswordStrength(password string) error { + if len(password) < MinPasswordLength { + return ErrWeakPassword + } + return nil +} + +// NewToken 生成 256bit 随机 token(base64url,43 字符)。 +func NewToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// HashToken token 明文的 SHA-256 十六进制摘要(库中只存摘要)。 +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..ced23c0 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,54 @@ +package auth + +import "testing" + +func TestPasswordHashRoundtrip(t *testing.T) { + hash, err := HashPassword("correct-horse-12") + if err != nil { + t.Fatal(err) + } + // PHC 串格式与参数(§7.3-1) + if len(hash) < len("$argon2id$v=19$m=19456,t=2,p=1$") || + hash[:len("$argon2id$v=19$m=19456,t=2,p=1$")] != "$argon2id$v=19$m=19456,t=2,p=1$" { + t.Errorf("PHC 前缀/参数错误: %s", hash) + } + if !VerifyPassword(hash, "correct-horse-12") { + t.Error("正确口令应通过校验") + } + if VerifyPassword(hash, "wrong-password") { + t.Error("错误口令不应通过") + } + // 盐随机:同口令两次哈希不同 + hash2, _ := HashPassword("correct-horse-12") + if hash == hash2 { + t.Error("同口令两次哈希应不同(随机盐)") + } + // 非法串安全返回 false + for _, bad := range []string{"", "$argon2id$", "$bcrypt$x$y$z", "$argon2id$v=19$m=1,t=1,p=1$!!$!!"} { + if VerifyPassword(bad, "x") { + t.Errorf("非法串 %q 不应通过", bad) + } + } +} + +func TestTokenAndStrength(t *testing.T) { + tok, err := NewToken() + if err != nil { + t.Fatal(err) + } + if len(tok) != 43 { + t.Errorf("256bit base64url token 应为 43 字符,实际 %d", len(tok)) + } + if HashToken(tok) == tok { + t.Error("token 摘要不应等于明文") + } + if HashToken(tok) != HashToken(tok) { + t.Error("摘要应确定") + } + if err := CheckPasswordStrength("short12"); err == nil { + t.Error("弱口令应被拒绝") + } + if err := CheckPasswordStrength("strong-enough-12"); err != nil { + t.Errorf("合格口令不应被拒绝: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5e920e1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,87 @@ +// Package config 解析各子命令的命令行开关。 +package config + +import ( + "flag" + "fmt" + "net" + "strings" +) + +// Config 运行时配置,serve 与维护子命令共用。 +type Config struct { + Addr string + DataDir string + LogLevel string + LogFormat string // text|json + BehindProxy bool + Dev bool + AllowNewer bool +} + +// DBPath SQLite 数据库文件路径。 +func (c *Config) DBPath() string { return c.DataDir + "/pure-note.db" } + +func addCommonFlags(fs *flag.FlagSet, c *Config) { + fs.StringVar(&c.DataDir, "data-dir", "./data", "数据目录(SQLite 数据库所在)") + fs.BoolVar(&c.AllowNewer, "allow-newer", false, "允许在更新的数据库 schema 版本上运行(跳过版本上界守卫)") +} + +func addServeFlags(fs *flag.FlagSet, c *Config) { + addCommonFlags(fs, c) + fs.StringVar(&c.Addr, "addr", ":8080", "HTTP 监听地址") + fs.StringVar(&c.LogLevel, "log-level", "info", "日志级别(debug|info|warn|error)") + fs.StringVar(&c.LogFormat, "log-format", "text", "日志格式(text|json)") + fs.BoolVar(&c.BehindProxy, "behind-proxy", false, "位于可信反向代理之后(取 X-Forwarded-For 最右条目作为客户端 IP)") + fs.BoolVar(&c.Dev, "dev", false, "开发模式:允许非 Secure Cookie,仅允许监听 loopback 地址") +} + +// ParseServe 解析 `pure-note serve` 参数。 +func ParseServe(args []string) (*Config, error) { + c := &Config{} + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + addServeFlags(fs, c) + if err := fs.Parse(args); err != nil { + return nil, err + } + if c.Dev && !isLoopbackAddr(c.Addr) { + return nil, fmt.Errorf("--dev 仅允许监听 loopback 地址(如 127.0.0.1:8080),当前为 %q", c.Addr) + } + return c, nil +} + +// ParseInit 解析 `pure-note init` 参数。 +func ParseInit(args []string) (*Config, error) { + c := &Config{} + fs := flag.NewFlagSet("init", flag.ContinueOnError) + addCommonFlags(fs, c) + if err := fs.Parse(args); err != nil { + return nil, err + } + return c, nil +} + +// ParseMaint 解析 `pure-note backup` / `pure-note gc` 参数(均只需数据目录)。 +// 返回 flag 解析后的剩余位置参数(如 backup 的输出路径)。 +func ParseMaint(cmd string, args []string) (*Config, []string, error) { + c := &Config{} + fs := flag.NewFlagSet(cmd, flag.ContinueOnError) + addCommonFlags(fs, c) + if err := fs.Parse(args); err != nil { + return nil, nil, err + } + return c, fs.Args(), nil +} + +func isLoopbackAddr(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil { + // 无端口:整体视为主机名 + host = addr + } + if host == "" || strings.EqualFold(host, "localhost") { + return host != "" + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..286ea50 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,23 @@ +package config + +import "testing" + +func TestDevLoopbackGuard(t *testing.T) { + // --dev 强制 loopback(§7.3-8) + if _, err := ParseServe([]string{"--dev", "--addr", ":8080"}); err == nil { + t.Error("--dev + 0.0.0.0 应拒绝启动") + } + if _, err := ParseServe([]string{"--dev", "--addr", "192.168.1.5:8080"}); err == nil { + t.Error("--dev + 局域网地址应拒绝启动") + } + if _, err := ParseServe([]string{"--dev", "--addr", "127.0.0.1:8080"}); err != nil { + t.Errorf("--dev + 127.0.0.1 应允许: %v", err) + } + if _, err := ParseServe([]string{"--dev", "--addr", "localhost:8080"}); err != nil { + t.Errorf("--dev + localhost 应允许: %v", err) + } + // 非 dev 不限制 + if _, err := ParseServe([]string{"--addr", ":8080"}); err != nil { + t.Errorf("非 dev 任意地址应允许: %v", err) + } +} diff --git a/internal/markdown/markdown.go b/internal/markdown/markdown.go new file mode 100644 index 0000000..dd3602e --- /dev/null +++ b/internal/markdown/markdown.go @@ -0,0 +1,54 @@ +// Package markdown 服务端 Markdown 渲染(goldmark + bluemonday), +// 仅用于 RSS / meta 摘要;库中始终只存 Markdown 原文(§7.5)。 +package markdown + +import ( + "bytes" + "html" + "regexp" + "strings" + "unicode/utf8" + + "github.com/microcosm-cc/bluemonday" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" +) + +var engine = goldmark.New( + goldmark.WithExtensions(extension.GFM), + // 默认 html.WithUnsafe=false:原始 HTML 与危险 URL 被转义(XSS 安全默认), + // 再经 bluemonday 白名单清洗双保险。 +) + +// ugcPolicy RSS description 用白名单清洗(双保险,与 goldmark 转义叠加)。 +var ugcPolicy = bluemonday.UGCPolicy() + +// strictPolicy 摘要提取用:剥掉全部标签。 +var strictPolicy = bluemonday.StrictPolicy() + +// Render 将 Markdown 渲染为清洗后的 HTML。 +func Render(source string) string { + var buf bytes.Buffer + if err := engine.Convert([]byte(source), &buf); err != nil { + // goldmark 对任意输入不产生错误;兜底按纯文本转义。 + return html.EscapeString(source) + } + return ugcPolicy.Sanitize(buf.String()) +} + +var wsRe = regexp.MustCompile(`\s+`) + +// Summary 从 Markdown 源提取纯文本摘要:渲染→剥标签→反转义→压空白→截断。 +func Summary(source string, limit int) string { + if limit <= 0 { + return "" + } + text := strictPolicy.Sanitize(Render(source)) + text = html.UnescapeString(text) + text = strings.TrimSpace(wsRe.ReplaceAllString(text, " ")) + if r := utf8.RuneCountInString(text); r > limit { + runes := []rune(text) + return string(runes[:limit]) + "…" + } + return text +} diff --git a/internal/markdown/markdown_test.go b/internal/markdown/markdown_test.go new file mode 100644 index 0000000..56a22e9 --- /dev/null +++ b/internal/markdown/markdown_test.go @@ -0,0 +1,33 @@ +package markdown + +import ( + "strings" + "testing" +) + +// 服务端渲染防线(§9.1-T1):goldmark 转义 + bluemonday 白名单。 +func TestRenderEscapesDangerousHTML(t *testing.T) { + out := Render("# 标题\n\n\n\n[link](javascript:alert(1))\n\n**bold**") + if strings.Contains(out, "