落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
3.7 KiB
Go
185 lines
3.7 KiB
Go
// Package textsafe validates user free-text at the API boundary (Spec input-compliance).
|
||
package textsafe
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// Kind selects length limits and whether empty is allowed.
|
||
type Kind int
|
||
|
||
const (
|
||
Nickname Kind = iota
|
||
DisplayName
|
||
AskContent
|
||
Note // optional
|
||
Title
|
||
Focus // optional
|
||
Scene
|
||
)
|
||
|
||
// ErrRejected is returned for compliance failures (map to HTTP 400 / code 40060).
|
||
var ErrRejected = errors.New("文案不合规")
|
||
|
||
type limits struct {
|
||
min, max int
|
||
allowNL bool
|
||
}
|
||
|
||
func lim(k Kind) limits {
|
||
switch k {
|
||
case Nickname:
|
||
return limits{1, 16, false}
|
||
case DisplayName:
|
||
return limits{1, 24, false}
|
||
case AskContent:
|
||
return limits{1, 2000, true}
|
||
case Note:
|
||
return limits{0, 200, true}
|
||
case Title:
|
||
return limits{1, 40, false}
|
||
case Focus:
|
||
return limits{0, 40, false}
|
||
case Scene:
|
||
return limits{1, 80, false}
|
||
default:
|
||
return limits{1, 200, false}
|
||
}
|
||
}
|
||
|
||
var banned = []string{
|
||
"占卜", "算命", "改命", "测测",
|
||
"疗效", "包治", "根治", "治疗疾病", "治愈癌症",
|
||
"不测就有灾", "大师算卦", "神棍",
|
||
}
|
||
|
||
// Check normalizes and validates. Empty optional kinds return ("", nil).
|
||
func Check(kind Kind, raw string) (string, error) {
|
||
s := Normalize(raw, lim(kind).allowNL)
|
||
l := lim(kind)
|
||
n := utf8.RuneCountInString(s)
|
||
if n < l.min {
|
||
if l.min == 0 {
|
||
return "", nil
|
||
}
|
||
return "", reject("请填写内容")
|
||
}
|
||
if n > l.max {
|
||
return "", reject("内容过长")
|
||
}
|
||
if err := rejectControls(s, l.allowNL); err != nil {
|
||
return "", err
|
||
}
|
||
if err := rejectMarkup(s); err != nil {
|
||
return "", err
|
||
}
|
||
if err := rejectBanned(s); err != nil {
|
||
return "", err
|
||
}
|
||
if err := rejectSpam(s); err != nil {
|
||
return "", err
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// Normalize trims; optionally keeps internal newlines.
|
||
func Normalize(s string, allowNL bool) string {
|
||
s = strings.TrimSpace(s)
|
||
if !allowNL {
|
||
s = strings.Map(func(r rune) rune {
|
||
if r == '\n' || r == '\r' || r == '\t' {
|
||
return ' '
|
||
}
|
||
return r
|
||
}, s)
|
||
s = strings.Join(strings.Fields(s), " ")
|
||
}
|
||
return s
|
||
}
|
||
|
||
func reject(msg string) error {
|
||
return fmt.Errorf("%w:%s", ErrRejected, msg)
|
||
}
|
||
|
||
func rejectControls(s string, allowNL bool) error {
|
||
for _, r := range s {
|
||
if r == 0 {
|
||
return reject("含非法字符")
|
||
}
|
||
if r == '\n' || r == '\r' || r == '\t' {
|
||
if !allowNL {
|
||
return reject("含非法空白")
|
||
}
|
||
continue
|
||
}
|
||
if r < 0x20 || r == 0x7f {
|
||
return reject("含非法字符")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func rejectMarkup(s string) error {
|
||
low := strings.ToLower(s)
|
||
if strings.Contains(low, "<script") || strings.Contains(low, "javascript:") {
|
||
return reject("含不安全内容")
|
||
}
|
||
if strings.Contains(low, "onerror=") || strings.Contains(low, "onload=") {
|
||
return reject("含不安全内容")
|
||
}
|
||
// bare tags like <img ...>
|
||
for i := 0; i < len(s); i++ {
|
||
if s[i] != '<' {
|
||
continue
|
||
}
|
||
if i+1 < len(s) {
|
||
c := s[i+1]
|
||
if c == '/' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '!' {
|
||
return reject("含不安全内容")
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func rejectBanned(s string) error {
|
||
for _, b := range banned {
|
||
if b != "" && strings.Contains(s, b) {
|
||
return reject("含不允许的表述,请换一种说法")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func rejectSpam(s string) error {
|
||
runes := []rune(s)
|
||
if len(runes) >= 6 {
|
||
allSame := true
|
||
for i := 1; i < len(runes); i++ {
|
||
if runes[i] != runes[0] {
|
||
allSame = false
|
||
break
|
||
}
|
||
}
|
||
if allSame && !unicode.IsSpace(runes[0]) {
|
||
return reject("请勿重复刷屏")
|
||
}
|
||
}
|
||
streak := 1
|
||
for i := 1; i < len(runes); i++ {
|
||
if runes[i] == runes[i-1] && !unicode.IsSpace(runes[i]) {
|
||
streak++
|
||
if streak >= 8 {
|
||
return reject("请勿重复刷屏")
|
||
}
|
||
} else {
|
||
streak = 1
|
||
}
|
||
}
|
||
return nil
|
||
}
|