Files
jiang13-bbs/backend/markdown/hide.go
freefire a83276060f feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 19:52:06 +08:00

420 lines
11 KiB
Go
Raw 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.
// 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 <kind> …];闭标记:整行 [/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 <kind>[=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
}