Files
jiang13-bbs/backend/markdown/hide.go
freefire 01e2fd05ca feat(compose): 正文内嵌隐藏块并升级 Markdown 编辑器
去掉整帖可见性表单,改为 :::hide 按块脱敏;工具栏补齐代码块、链接/图片对话框与粘贴拖拽上传。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 04:58:20 +08:00

325 lines
7.8 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 提供帖子正文隐藏块(:::hide)的解析、校验与脱敏。
package markdown
import (
"errors"
"fmt"
"strconv"
"strings"
)
const (
HideKindLogin = "login"
HideKindReply = "reply"
HideKindPoints = "points"
)
// HideBlock 正文中的一段隐藏内容
type HideBlock struct {
Kind string // login | reply | points
Points int // 仅 points 有效
Body string // 块内 Markdown(不含开闭标记行)
Start int // 开标记行在 lines 中的下标
End int // 闭标记行在 lines 中的下标(含)
Locked bool // 开标记是否已带 locked(脱敏输出)
}
// DerivedAccess 由正文隐藏块派生的帖级可见性
type DerivedAccess struct {
Access string // public | login | reply | points | mixed
Points int // 所有积分块价格之和
}
// ViewerCaps 读者对隐藏块的能力(由 service 填充)
type ViewerCaps struct {
Bypass bool // 作者 / 版主
LoggedIn bool
HasReplied bool
PointsPaid bool // 已支付本帖积分解锁
}
var (
ErrHideNested = errors.New("隐藏块不可嵌套")
ErrHideUnclosed = errors.New("隐藏块未正确闭合")
ErrHideInvalid = errors.New("隐藏块语法无效")
ErrHidePointsNeed = errors.New("积分可见块须指定 1–100000 的积分")
)
// ParseHideBlocks 扫描正文中的 :::hide 块(忽略代码围栏内伪语法)。
// 校验失败返回 error(用于 Create/Update)。
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, 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
}
}
blocks = append(blocks, HideBlock{
Kind: kind,
Points: pts,
Body: strings.Join(bodyLines, "\n"),
Start: i,
End: j,
Locked: locked,
})
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。
// fullyLocked 表示读者当前看不到任何可见正文(整篇都在锁块里或公开区为空)。
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, ":::")
} else {
out = append(out, openMarkerLine(b.Kind, b.Points, true))
out = append(out, ":::")
}
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
}
// 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:::\n"
}
return open + "\n" + body + "\n:::\n"
}
// HasHideBlocks 快速判断正文是否已含隐藏块(迁移幂等)
func HasHideBlocks(content string) bool {
blocks, err := ParseHideBlocks(content)
if err != nil {
return strings.Contains(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
default:
return true
}
}
func openMarkerLine(kind string, points int, locked bool) string {
var b strings.Builder
b.WriteString(":::hide ")
b.WriteString(kind)
if kind == HideKindPoints && points > 0 {
b.WriteByte(' ')
b.WriteString(strconv.Itoa(points))
}
if locked {
b.WriteString(" locked")
}
return b.String()
}
func parseOpenMarker(trimmed string) (kind string, points int, locked bool, ok bool) {
if !strings.HasPrefix(trimmed, ":::hide") {
return "", 0, false, false
}
rest := strings.TrimSpace(trimmed[len(":::hide"):])
if rest == "" {
return "", 0, false, false
}
parts := strings.Fields(rest)
if len(parts) == 0 {
return "", 0, false, false
}
kind = parts[0]
switch kind {
case HideKindLogin, HideKindReply, HideKindPoints:
default:
return "", 0, false, false
}
idx := 1
if kind == HideKindPoints {
if idx >= len(parts) {
return "", 0, false, false
}
n, err := strconv.Atoi(parts[idx])
if err != nil {
return "", 0, false, false
}
points = n
idx++
}
for ; idx < len(parts); idx++ {
if parts[idx] == "locked" {
locked = true
} else {
return "", 0, false, false
}
}
return kind, points, locked, true
}
func isCloseMarker(trimmed string) bool {
return trimmed == ":::"
}
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("隐藏块未正确闭合,请检查 ::: 标记")
case errors.Is(err, ErrHidePointsNeed):
return fmt.Errorf("积分可见块须指定 1–100000 的积分")
case errors.Is(err, ErrHideInvalid):
return fmt.Errorf("隐藏块语法无效")
default:
return err
}
}