// Package markdown 提供帖子正文隐藏块(行级 BBCode [hide]…[/hide])的解析、校验与脱敏。 package markdown import ( "errors" "fmt" "regexp" "strconv" "strings" "unicode/utf8" ) const ( HideKindLogin = "login" HideKindReply = "reply" HideKindPoints = "points" HideKindPassword = "password" ) const ( // MaxHidePasswordLen 密码可见块密码最大长度(字符) MaxHidePasswordLen = 64 // MinHidePasswordLen 密码最小长度 MinHidePasswordLen = 1 ) // HideBlock 正文中的一段隐藏内容 type HideBlock struct { Kind string // login | reply | points | password Points int // 仅 points 有效 Password string // 仅 password 有效(原文;脱敏输出时清空) Body string // 块内 Markdown(不含开闭标记行) Start int // 开标记行在 lines 中的下标 End int // 闭标记行在 lines 中的下标(含) Locked bool // 开标记是否已带 locked(脱敏输出) Index int // 在全文隐藏块列表中的下标(0-based) } // DerivedAccess 由正文隐藏块派生的帖级可见性 type DerivedAccess struct { Access string // public | login | reply | points | password | mixed Points int // 所有积分块价格之和 } // ViewerCaps 读者对隐藏块的能力(由 service 填充) type ViewerCaps struct { Bypass bool // 作者 / 版主 LoggedIn bool HasReplied bool PointsPaid bool // 已支付本帖积分解锁 PasswordUnlocked map[int]bool // 已凭密码解锁的隐藏块下标 } var ( ErrHideNested = errors.New("隐藏块不可嵌套") ErrHideUnclosed = errors.New("隐藏块未正确闭合") ErrHideInvalid = errors.New("隐藏块语法无效") ErrHidePointsNeed = errors.New("积分可见块须指定 1–100000 的积分") ErrHidePasswordNeed = errors.New("密码可见块须设置 1–64 字符且不含空格的密码") ) // 开标记:整行 [hide …];闭标记:整行 [/hide] var hideOpenRe = regexp.MustCompile(`(?i)^\[hide(?:\s+(.+))?\]$`) // ParseHideBlocks 扫描正文中的 [hide]…[/hide] 块(忽略代码围栏内伪语法)。 func ParseHideBlocks(content string) ([]HideBlock, error) { lines := splitLines(content) var blocks []HideBlock inCode := false i := 0 for i < len(lines) { trimmed := strings.TrimSpace(lines[i]) if strings.HasPrefix(trimmed, "```") { inCode = !inCode i++ continue } if inCode { i++ continue } if kind, pts, pwd, locked, ok := parseOpenMarker(trimmed); ok { j := i + 1 bodyLines := make([]string, 0) foundClose := false innerCode := false for j < len(lines) { inner := strings.TrimSpace(lines[j]) if strings.HasPrefix(inner, "```") { innerCode = !innerCode bodyLines = append(bodyLines, lines[j]) j++ continue } if innerCode { bodyLines = append(bodyLines, lines[j]) j++ continue } if _, _, _, _, isOpen := parseOpenMarker(inner); isOpen { return nil, ErrHideNested } if isCloseMarker(inner) { foundClose = true break } bodyLines = append(bodyLines, lines[j]) j++ } if !foundClose { return nil, ErrHideUnclosed } if kind == HideKindPoints { if pts < 1 || pts > 100000 { return nil, ErrHidePointsNeed } } if kind == HideKindPassword { // 已 locked 的脱敏行无密码,仅服务端原文必须带合法密码 if !locked { if err := validatePassword(pwd); err != nil { return nil, err } } } blocks = append(blocks, HideBlock{ Kind: kind, Points: pts, Password: pwd, Body: strings.Join(bodyLines, "\n"), Start: i, End: j, Locked: locked, Index: len(blocks), }) i = j + 1 continue } if isCloseMarker(trimmed) { return nil, ErrHideInvalid } i++ } return blocks, nil } // DeriveAccess 由隐藏块列表派生帖级 content_access / access_points func DeriveAccess(blocks []HideBlock) DerivedAccess { if len(blocks) == 0 { return DerivedAccess{Access: "public", Points: 0} } kinds := map[string]struct{}{} totalPts := 0 for _, b := range blocks { kinds[b.Kind] = struct{}{} if b.Kind == HideKindPoints { totalPts += b.Points } } if len(kinds) > 1 { return DerivedAccess{Access: "mixed", Points: totalPts} } for k := range kinds { return DerivedAccess{Access: k, Points: totalPts} } return DerivedAccess{Access: "public", Points: 0} } // DeriveAccessFromContent 解析并派生;校验失败返回 error func DeriveAccessFromContent(content string) (DerivedAccess, error) { blocks, err := ParseHideBlocks(content) if err != nil { return DerivedAccess{}, err } return DeriveAccess(blocks), nil } // SanitizeForViewer 按读者能力脱敏:未满足条件的块清空正文并加 locked。 // 密码块即使已解锁,也不向读者回传明文密码(开标记写成 [hide password] 或 [hide password locked])。 func SanitizeForViewer(content string, caps ViewerCaps) (sanitized string, fullyLocked bool) { blocks, err := ParseHideBlocks(content) if err != nil { return "", true } if len(blocks) == 0 { return content, strings.TrimSpace(content) == "" } if caps.Bypass { return content, false } lines := splitLines(content) var out []string cursor := 0 anyVisible := false for _, b := range blocks { for i := cursor; i < b.Start; i++ { out = append(out, lines[i]) if strings.TrimSpace(lines[i]) != "" { anyVisible = true } } if blockUnlocked(b, caps) { out = append(out, openMarkerLine(b.Kind, b.Points, "", false)) if b.Body != "" { out = append(out, splitLines(b.Body)...) if strings.TrimSpace(b.Body) != "" { anyVisible = true } } out = append(out, "[/hide]") } else { out = append(out, openMarkerLine(b.Kind, b.Points, "", true)) out = append(out, "[/hide]") } cursor = b.End + 1 } for i := cursor; i < len(lines); i++ { out = append(out, lines[i]) if strings.TrimSpace(lines[i]) != "" { anyVisible = true } } return strings.Join(out, "\n"), !anyVisible } // MatchPasswordBlocks 返回密码匹配的隐藏块下标 func MatchPasswordBlocks(content, password string) ([]int, error) { blocks, err := ParseHideBlocks(content) if err != nil { return nil, err } var hit []int for _, b := range blocks { if b.Kind != HideKindPassword { continue } if constantTimeEqual(b.Password, password) { hit = append(hit, b.Index) } } return hit, nil } // WrapContentAsHide 将整篇正文包进单一隐藏块 func WrapContentAsHide(kind string, points int, content string) string { body := strings.TrimRight(content, "\n") open := openMarkerLine(kind, points, "", false) if body == "" { return open + "\n[/hide]\n" } return open + "\n" + body + "\n[/hide]\n" } // HasHideBlocks 判断正文是否已含隐藏块 func HasHideBlocks(content string) bool { blocks, err := ParseHideBlocks(content) if err != nil { return strings.Contains(strings.ToLower(content), "[hide") } return len(blocks) > 0 } func blockUnlocked(b HideBlock, caps ViewerCaps) bool { switch b.Kind { case HideKindLogin: return caps.LoggedIn case HideKindReply: return caps.HasReplied case HideKindPoints: return caps.PointsPaid case HideKindPassword: return caps.PasswordUnlocked != nil && caps.PasswordUnlocked[b.Index] default: return true } } // openMarkerLine 生成开标记。密码仅在原文存储时写入;对外脱敏输出传空 password。 func openMarkerLine(kind string, points int, password string, locked bool) string { var b strings.Builder b.WriteString("[hide ") b.WriteString(kind) if kind == HideKindPoints && points > 0 { b.WriteString("=") b.WriteString(strconv.Itoa(points)) } if kind == HideKindPassword && password != "" && !locked { b.WriteString("=") b.WriteString(password) } if locked { b.WriteString(" locked") } b.WriteByte(']') return b.String() } // parseOpenMarker 解析 [hide [=arg] [locked]] func parseOpenMarker(trimmed string) (kind string, points int, password string, locked bool, ok bool) { m := hideOpenRe.FindStringSubmatch(trimmed) if m == nil { return "", 0, "", false, false } rest := strings.TrimSpace(m[1]) if rest == "" { return "", 0, "", false, false } parts := strings.Fields(rest) if len(parts) == 0 { return "", 0, "", false, false } head := parts[0] kind = head arg := "" if i := strings.IndexByte(head, '='); i >= 0 { kind = head[:i] arg = head[i+1:] } kind = strings.ToLower(kind) switch kind { case HideKindLogin, HideKindReply, HideKindPoints, HideKindPassword: default: return "", 0, "", false, false } idx := 1 if kind == HideKindPoints { if arg == "" { return "", 0, "", false, false } n, err := strconv.Atoi(arg) if err != nil { return "", 0, "", false, false } points = n } else if kind == HideKindPassword { // [hide password=secret] 或脱敏 [hide password locked] if arg != "" { password = arg } } else if arg != "" { // login/reply 不应带 =arg return "", 0, "", false, false } for ; idx < len(parts); idx++ { if strings.EqualFold(parts[idx], "locked") { locked = true } else { return "", 0, "", false, false } } return kind, points, password, locked, true } func validatePassword(pwd string) error { if pwd == "" || strings.ContainsAny(pwd, " \t") { return ErrHidePasswordNeed } n := utf8.RuneCountInString(pwd) if n < MinHidePasswordLen || n > MaxHidePasswordLen { return ErrHidePasswordNeed } return nil } func isCloseMarker(trimmed string) bool { return strings.EqualFold(trimmed, "[/hide]") } func splitLines(s string) []string { if s == "" { return []string{} } s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") return strings.Split(s, "\n") } // ValidateHideContent 校验正文隐藏块;失败返回用户可读错误 func ValidateHideContent(content string) error { _, err := ParseHideBlocks(content) if err == nil { return nil } switch { case errors.Is(err, ErrHideNested): return fmt.Errorf("隐藏块不可嵌套") case errors.Is(err, ErrHideUnclosed): return fmt.Errorf("隐藏块未正确闭合,请检查 [/hide] 标记") case errors.Is(err, ErrHidePointsNeed): return fmt.Errorf("积分可见块须指定 1–100000 的积分") case errors.Is(err, ErrHidePasswordNeed): return fmt.Errorf("密码可见块须设置 1–64 字符且不含空格的密码") case errors.Is(err, ErrHideInvalid): return fmt.Errorf("隐藏块语法无效") default: return err } } func constantTimeEqual(a, b string) bool { if len(a) != len(b) { return false } var v byte for i := 0; i < len(a); i++ { v |= a[i] ^ b[i] } return v == 0 }