text.gno
6.42 Kb · 184 lines
1package bolo
2
3import (
4 "strings"
5 "unicode"
6 "unicode/utf8"
7
8 "gno.land/p/nt/markdown/sanitize/v0"
9)
10
11// Text limits in characters — Unicode code points — after normalization (API
12// §2.3). Every text also has a byte cap of four times its character limit, on
13// the argument as sent.
14const (
15 titleMin, titleMax = 4, 80
16 descriptionMax = 5000
17 messageMax = 140
18 updateMin, updateMax = 1, 2000
19 noticeMin, noticeMax = 1, 280
20 altMin, altMax = 1, 250
21 cidMaxBytes = 100
22)
23
24// normalize prepares user text for checking and storing (API C7, §2.3). Bidi and
25// zero-width characters go first, through the very function sanitize.Block calls
26// when it renders, so that no check can be dodged by what rendering removes. Line
27// breaks are unified — CR-LF, CR, NEL and the Unicode line and paragraph
28// separators all become "\n" — and kept in multiline text; elsewhere they become
29// a space. Tabs and the other whitespace controls (a vertical tab, a form feed, the
30// information separators) become a space everywhere, so words never join. Then
31// every character that only pads a text goes (invisible), and surrounding
32// whitespace is trimmed. Stripping more than Block does only makes the checks
33// stricter, and the stripped text is what gets stored.
34func normalize(s string, multiline bool) string {
35 s = sanitize.StripBidiAndZeroWidth(s)
36 s = sanitize.NormalizeBreaks(s)
37 s = strings.Map(func(r rune) rune {
38 switch {
39 case r == '\n' || r == '\u0085' || r == '\u2028' || r == '\u2029':
40 if multiline {
41 return '\n'
42 }
43 return ' '
44 case r == '\t' || r == '\v' || r == '\f' || (r >= '\x1c' && r <= '\x1f'):
45 return ' '
46 case invisible(r):
47 return -1
48 }
49 return r
50 }, s)
51 return strings.TrimSpace(s)
52}
53
54// invisible reports whether r shows nothing and only pads a text: a control or a
55// format character (Unicode Cc, Cf: a word joiner, a soft hyphen), one Unicode
56// lists as Other_Default_Ignorable_Code_Point (a Hangul filler, the combining
57// grapheme joiner), or a blank symbol (the Braille blank, the musical null
58// notehead). A Latin-1 character skips the tables, which are costly and which
59// English or French text seldom leaves: of them, only the controls and the soft
60// hyphen are invisible (TestLatin1 holds this to the tables). Every control is
61// Latin-1.
62func invisible(r rune) bool {
63 if r <= unicode.MaxLatin1 {
64 return r < 0x20 || r >= 0x7f && r <= 0x9f || r == 0xad
65 }
66 return unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Other_Default_Ignorable_Code_Point, r) ||
67 r == '\u2800' || r == '\U0001D159'
68}
69
70// checkText checks one text argument and returns it normalized, the form Bolo
71// stores (API §2.3): valid UTF-8 and at most four bytes per allowed character as
72// sent; then, normalized, at most max characters, of which at least min show on
73// their own. It panics with err otherwise.
74func checkText(s string, min, max int, multiline bool, err Error) string {
75 if !utf8.ValidString(s) || len(s) > 4*max {
76 panic(err)
77 }
78 s = normalize(s, multiline)
79 if utf8.RuneCountInString(s) > max || !visible(s, min) {
80 panic(err)
81 }
82 return s
83}
84
85// visible reports whether at least min characters of s show on their own —
86// letters, digits, punctuation, symbols and spacing marks — counting the spaces
87// between two of them. A mark that takes no space (an accent, a variation
88// selector) only changes the character before it, a private-use character or a
89// code point Unicode hasn't assigned shows nothing a reader can count on, and a
90// space shows only between characters that do. They all stay in the text, but
91// don't count toward a minimum. Latin-1 skips the tables, as in invisible: there,
92// every character shows on its own but the controls, the soft hyphen and the two
93// spaces, ' ' and U+00A0. Counting stops once min characters have shown, so a long
94// text pays the tables only until then, and a story, whose minimum is 0, never.
95func visible(s string, min int) bool {
96 n, spaces, shown := 0, 0, false
97 for _, r := range s {
98 if n >= min {
99 break
100 }
101 switch {
102 case r > ' ' && r < 0x7f, r > 0xa0 && r <= unicode.MaxLatin1 && r != 0xad,
103 r > unicode.MaxLatin1 && unicode.In(r, unicode.L, unicode.Mc, unicode.N, unicode.P, unicode.S):
104 n += 1 + spaces
105 spaces, shown = 0, true
106 case shown && (r == ' ' || r == 0xa0 || r > unicode.MaxLatin1 && unicode.Is(unicode.Zs, r)):
107 spaces++
108 }
109 }
110 return n >= min
111}
112
113// checkStory checks a story or an update, the organizer's own text, and returns it
114// normalized (API §2.3): the limits of checkText, then no image and no raw HTML,
115// judged on the normalized text that sanitize.Block will render.
116func checkStory(s string, min, max int, err Error) string {
117 s = checkText(s, min, max, true, err)
118 checkMarkup(s)
119 return s
120}
121
122// checkMarkup refuses an image or raw HTML (API §2.3): `![`, or `<` followed by a
123// letter, `/`, `!` or `?` — every way CommonMark opens raw HTML. The campaign
124// photo is the only image a campaign shows, and `<3` still passes.
125func checkMarkup(s string) {
126 if strings.Contains(s, "![") {
127 panic(ErrMarkup)
128 }
129 for i := 0; i+1 < len(s); i++ {
130 if s[i] != '<' {
131 continue
132 }
133 c := s[i+1]
134 if c == '/' || c == '!' || c == '?' || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') {
135 panic(ErrMarkup)
136 }
137 }
138}
139
140// checkImage checks a campaign photo's CID and its description (API §2.3, ADR
141// D16), and returns both, the description normalized: no photo, no description;
142// a photo, a description of 1 to 250 characters.
143func checkImage(cid, alt string) (string, string) {
144 if cid == "" {
145 if alt != "" {
146 panic(ErrImage)
147 }
148 return "", ""
149 }
150 if !validCID(cid) {
151 panic(ErrImage)
152 }
153 return cid, checkText(alt, altMin, altMax, false, ErrImage)
154}
155
156// validCID reports whether s has the shape of an IPFS CID Bolo accepts: v0, "Qm"
157// and 44 more base58btc characters; or v1 in lowercase base32, "b" then that
158// alphabet; at most 100 bytes. Render writes the CID into `(ipfs://…)` as it is,
159// so the character set is the escaping (API §2.3).
160func validCID(s string) bool {
161 switch {
162 case len(s) > cidMaxBytes:
163 return false
164 case len(s) == 46 && strings.HasPrefix(s, "Qm"):
165 return allIn(s, base58btc)
166 case len(s) > 1 && s[0] == 'b':
167 return allIn(s[1:], base32Lower)
168 }
169 return false
170}
171
172const (
173 base58btc = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
174 base32Lower = "abcdefghijklmnopqrstuvwxyz234567"
175)
176
177func allIn(s, set string) bool {
178 for i := 0; i < len(s); i++ {
179 if strings.IndexByte(set, s[i]) < 0 {
180 return false
181 }
182 }
183 return true
184}