// 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, " 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 }