Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}