validate.gno
10.14 Kb · 352 lines
1package catalog
2
3import (
4 "strconv"
5 "strings"
6 "unicode"
7
8 "gno.land/p/nym-alexiscolin000/gnoradio/safe/v0"
9 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
10 "gno.land/r/nym-alexiscolin000/gnoradio/data"
11)
12
13func mustText(field, s string, min, max int) {
14 if !text.Valid(s, min, max) {
15 panic("catalog: " + field + " must be " + text.Itoa(min) + "-" + text.Itoa(max) +
16 " characters: letters, digits, spaces and . , ' - ! ? : / & only")
17 }
18 // A report reason may quote what it reports (/moderation masks it).
19 if field != "reason" && safe.Slur(s) {
20 panic("catalog: " + field + " holds a slur")
21 }
22}
23
24// validName restricts artist names to ASCII letters and digits plus the
25// Latin-1 accented letters, so look-alike names cannot impersonate an artist.
26func validName(s string) bool {
27 if strings.TrimSpace(s) != s || strings.Contains(s, " ") {
28 return false
29 }
30 n, alnum := 0, 0
31 for _, r := range s {
32 n++
33 if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
34 (r >= 0xC0 && r <= 0xFF && r != 0xD7 && r != 0xF7) {
35 alnum++
36 continue
37 }
38 switch r {
39 case ' ', '.', '\'', '-', '&':
40 continue
41 }
42 return false
43 }
44 // At least one letter or digit, so the reserved skeleton is never empty.
45 return n >= 2 && n <= 40 && alnum > 0
46}
47
48// latin1 folds the Latin-1 letters 0xC0-0xFF (lowercased) to ASCII.
49var latin1 = [64]string{
50 "a", "a", "a", "a", "a", "a", "ae", "c", "e", "e", "e", "e", "i", "i", "i", "i",
51 "d", "n", "o", "o", "o", "o", "o", "", "o", "u", "u", "u", "u", "y", "th", "ss",
52 "a", "a", "a", "a", "a", "a", "ae", "c", "e", "e", "e", "e", "i", "i", "i", "i",
53 "d", "n", "o", "o", "o", "o", "o", "", "o", "u", "u", "u", "u", "y", "th", "y",
54}
55
56// skeleton is the key an artist name is reserved under: lowercase, common
57// Latin accents stripped, fullwidth letters and digits mapped to ASCII,
58// spaces and punctuation dropped and look-alikes folded (i/l/1 → l, 0 → o,
59// 3 → e, 4 → a, 5 → s, 7 → t, 9 → g, u → v, rn → m, vv → w, cl → d), so
60// "Beyonce", "BEYONCÉ", "Beyonce", "Daft Punk", "DAFT PUNK", "Kanye
61// Vvest", "Kanye Uuest", "Clrake" and "9orillaz" collide with the real names.
62func skeleton(s string) string {
63 var b strings.Builder
64 for _, r := range s {
65 switch r {
66 case ' ', '.', '\'', '-', '&', 0x3000:
67 continue
68 }
69 switch {
70 case r >= 0xFF21 && r <= 0xFF3A:
71 r = r - 0xFF21 + 'a'
72 case r >= 0xFF41 && r <= 0xFF5A:
73 r = r - 0xFF41 + 'a'
74 case r >= 0xFF10 && r <= 0xFF19:
75 r = r - 0xFF10 + '0'
76 }
77 r = unicode.ToLower(r)
78 if r >= 0xC0 && r <= 0xFF && latin1[r-0xC0] != "" {
79 b.WriteString(confusable(latin1[r-0xC0]))
80 continue
81 }
82 b.WriteString(confusable(string(r)))
83 }
84 k := strings.ReplaceAll(b.String(), "rn", "m")
85 k = strings.ReplaceAll(k, "vv", "w")
86 return strings.ReplaceAll(k, "cl", "d")
87}
88
89// confusable folds characters that look alike in common fonts.
90func confusable(s string) string {
91 switch s {
92 case "i", "1", "|":
93 return "l"
94 case "0":
95 return "o"
96 case "3":
97 return "e"
98 case "4":
99 return "a"
100 case "5":
101 return "s"
102 case "7":
103 return "t"
104 case "9":
105 return "g"
106 case "u":
107 return "v" // before the vv → w fold: "uu" and "vu" pass for w too
108 }
109 return s
110}
111
112// Reserved names, as skeletons: a name holding a stem anywhere, or one of
113// the words as a word, could pass for GnoRadio, its staff, the chain
114// (gno.land folds to gnoland), the wallet (Adena) or the audio source
115// (Audius). Adena and Audius are prefixes, not stems: refused at the start
116// of the name or of any word in it ("AdenaWallet", "Ade-na", "Audius2"),
117// while names that only end with them pass ("Pasadena", "La Cadena",
118// "Claudius").
119var (
120 reservedStems = []string{skeleton("gnoradio"), skeleton("moderator"), skeleton("moderation"),
121 skeleton("treasury"), skeleton("administrator"), skeleton("gnoland")}
122 reservedWords = []string{skeleton("admin"), skeleton("staff"), skeleton("official")}
123 reservedPrefixes = []string{skeleton("adena"), skeleton("audius")}
124)
125
126func reserved(name string) bool {
127 k := skeleton(name)
128 for _, s := range reservedStems {
129 if strings.Contains(k, s) {
130 return true
131 }
132 }
133 for _, s := range reservedWords {
134 if strings.Contains(k, s) && anyWord(name, func(w string) bool { return w == s }) {
135 return true
136 }
137 }
138 for _, s := range reservedPrefixes {
139 if strings.HasPrefix(k, s) || strings.Contains(k, s) && anyWord(name, func(w string) bool { return strings.HasPrefix(w, s) }) {
140 return true
141 }
142 }
143 return false
144}
145
146// anyWord reports whether the skeleton of a word of name passes ok.
147func anyWord(name string, ok func(string) bool) bool {
148 for _, w := range strings.FieldsFunc(name, func(r rune) bool { return strings.ContainsRune(" .'-&", r) }) {
149 if ok(skeleton(w)) {
150 return true
151 }
152 }
153 return false
154}
155
156func mustName(s string) {
157 if !validName(s) {
158 panic("catalog: artist name must be 2-40 characters: Latin letters (a-z and Latin-1 accents), digits, spaces and . ' - &")
159 }
160 if safe.Slur(s) {
161 panic("catalog: artist name holds a slur")
162 }
163 if reserved(s) {
164 panic("catalog: this artist name is reserved for GnoRadio, gno.land, Adena or Audius")
165 }
166}
167
168func alnum(s string, min, max int) bool {
169 if len(s) < min || len(s) > max {
170 return false
171 }
172 for _, c := range s {
173 if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') {
174 return false
175 }
176 }
177 return true
178}
179
180func validHost(h string) bool {
181 if len(h) < 3 || len(h) > 100 || !strings.Contains(h, ".") {
182 return false
183 }
184 for _, c := range h {
185 if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '.' && c != '-' {
186 return false
187 }
188 }
189 return true
190}
191
192// hostOf is the authority of an https link: up to the first / ? or #
193// (lower case). It is a plain host only when validHost says so.
194func hostOf(url string) string {
195 rest := strings.TrimPrefix(url, "https://")
196 if i := strings.IndexAny(rest, "/?#"); i >= 0 {
197 rest = rest[:i]
198 }
199 return strings.ToLower(rest)
200}
201
202func isSHA256(s string) bool {
203 if len(s) != 64 {
204 return false
205 }
206 for _, c := range s {
207 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
208 return false
209 }
210 }
211 return true
212}
213
214// mustMedia checks an audio or cover reference:
215//
216// ipfs://<cid> content-addressed, sha256 optional
217// ar://<txid> Arweave, sha256 optional
218// audius:<trackId> audio only, streamed through the Audius API
219// jamendo:<trackId> audio only, streamed from Jamendo
220// https://<host>/… host must be allowed, sha256 required
221func mustMedia(field, uri, sha string, audioField bool) {
222 if sha != "" && !isSHA256(sha) {
223 panic("catalog: " + field + " sha256 must be 64 hex characters")
224 }
225 switch {
226 case strings.HasPrefix(uri, "ipfs://"):
227 if !alnum(strings.TrimPrefix(uri, "ipfs://"), 46, 100) {
228 panic("catalog: " + field + " ipfs:// needs a valid CID")
229 }
230 case strings.HasPrefix(uri, "ar://"):
231 id := strings.TrimPrefix(uri, "ar://")
232 if len(id) != 43 || !text.URLChars(id) {
233 panic("catalog: " + field + " ar:// needs a 43-character transaction id")
234 }
235 case strings.HasPrefix(uri, "audius:"), strings.HasPrefix(uri, "jamendo:"):
236 _, id, _ := strings.Cut(uri, ":")
237 if !audioField || !alnum(id, 1, 32) {
238 panic("catalog: " + field + " audius:<id> or jamendo:<id> is only valid for audio")
239 }
240 case strings.HasPrefix(uri, "https://"):
241 if !text.HTTPS(uri, 300) || !data.Has(cHosts, hostOf(uri)) {
242 panic("catalog: " + field + " host is not allowed (ask the admin to add it)")
243 }
244 if sha == "" {
245 panic("catalog: " + field + " over https needs the file's sha256")
246 }
247 default:
248 panic("catalog: " + field + " must be ipfs://, ar://, audius:, jamendo: or an allowed https:// link")
249 }
250}
251
252// normLicense maps loose input ("cc by", "cc-by-4.0", "CC0") to an SPDX id.
253func normLicense(s string) string {
254 s = strings.ToUpper(strings.TrimSpace(s))
255 s = strings.ReplaceAll(s, " ", "-")
256 s = strings.ReplaceAll(s, "_", "-")
257 if s == "CC0" {
258 s = "CC0-1.0"
259 }
260 if strings.HasPrefix(s, "CC-") && !strings.HasSuffix(s, "-4.0") {
261 s += "-4.0"
262 }
263 for _, l := range licenses {
264 if s == l {
265 return l
266 }
267 }
268 return ""
269}
270
271// parseClock parses "m:ss" (or plain seconds) into seconds.
272func parseClock(s string) (int64, bool) {
273 s = strings.TrimSpace(s)
274 m, sec, hasColon := strings.Cut(s, ":")
275 if !hasColon {
276 if !text.Digits(s) || len(s) > 5 {
277 return 0, false
278 }
279 v, _ := strconv.ParseInt(s, 10, 64)
280 return v, true
281 }
282 if len(m) > 3 || len(sec) != 2 || !text.Digits(m) || !text.Digits(sec) {
283 return 0, false
284 }
285 mv, _ := strconv.ParseInt(m, 10, 64)
286 sv, _ := strconv.ParseInt(sec, 10, 64)
287 if sv > 59 {
288 return 0, false
289 }
290 return mv*60 + sv, true
291}
292
293// parseIDs parses "12,40,7" into distinct ids, at most max of them.
294func parseIDs(s string, max int) []int {
295 s = strings.TrimSpace(s)
296 if s == "" {
297 panic("catalog: list at least one track id")
298 }
299 parts := strings.Split(s, ",")
300 if len(parts) > max {
301 panic("catalog: at most " + text.Itoa(max) + " tracks")
302 }
303 out := make([]int, 0, len(parts))
304 seen := map[int]bool{}
305 for _, p := range parts {
306 p = strings.TrimSpace(p)
307 n, err := strconv.Atoi(p)
308 if err != nil || n < 1 || !text.Digits(p) {
309 panic("catalog: track ids as 12,40,7")
310 }
311 if seen[n] {
312 panic("catalog: duplicate track " + text.Itoa(n))
313 }
314 seen[n] = true
315 out = append(out, n)
316 }
317 return out
318}
319
320// curatedLicense accepts the redistributable licenses curated imports may
321// carry, in any version and national port: CC0-1.0, CC-BY-<v>[-XX],
322// CC-BY-SA-<v>[-XX], CC-BY-NC-<v>[-XX], CC-BY-NC-SA-<v>[-XX] (v = 1.0, 2.0, 2.5, 3.0, 4.0). It returns the normalized
323// id, or "" when the license is not allowed.
324func curatedLicense(s string) string {
325 s = strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(s), " ", "-"))
326 if s == "CC0" || s == "CC0-1.0" {
327 return "CC0-1.0"
328 }
329 var rest string
330 switch {
331 case strings.HasPrefix(s, "CC-BY-NC-SA-"):
332 rest = strings.TrimPrefix(s, "CC-BY-NC-SA-")
333 case strings.HasPrefix(s, "CC-BY-NC-"):
334 rest = strings.TrimPrefix(s, "CC-BY-NC-")
335 case strings.HasPrefix(s, "CC-BY-SA-"):
336 rest = strings.TrimPrefix(s, "CC-BY-SA-")
337 case strings.HasPrefix(s, "CC-BY-"):
338 rest = strings.TrimPrefix(s, "CC-BY-")
339 default:
340 return ""
341 }
342 ver, port, _ := strings.Cut(rest, "-")
343 switch ver {
344 case "1.0", "2.0", "2.5", "3.0", "4.0":
345 default:
346 return ""
347 }
348 if port != "" && (len(port) != 2 || !alnum(port, 2, 2)) {
349 return ""
350 }
351 return s
352}