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

media.gno

12.34 Kb · 517 lines
  1package kourt
  2import (
  3	"chain"
  4	"strconv"
  5	"strings"
  6	sanitize "gno.land/p/nt/markdown/sanitize/v0"
  7)
  8func mediaDestination(m *mediaItem) string {
  9	if m.kind == mediaKindImage && m.sha256 != "" &&
 10		siteDomain != "" && siteDomainFault(siteDomain) == "" {
 11		return "https://" + siteDomain + "/m/" + m.sha256
 12	}
 13	for _, u := range m.mirrors {
 14		if mirrorFault(u) == "" {
 15			return u
 16		}
 17	}
 18	return ""
 19}
 20func verifyElsewhere() string {
 21	if siteDomain == "" || siteDomainFault(siteDomain) != "" {
 22		return ""
 23	}
 24	return " [" + siteDomain + "](https://" + siteDomain + ") checks it."
 25}
 26func writeClaimMedia(b *strings.Builder, c *Court, cs *claimState) {
 27	items := claimMediaVisible(c, cs)
 28	if len(items) == 0 {
 29		return
 30	}
 31	total := strconv.Itoa(len(items))
 32	b.WriteString("## Evidence filed with this claim\n\n")
 33	b.WriteString("_The court recorded a fingerprint of each image when the claim " +
 34		"was filed, so a swap can be detected — but not on this page._" + verifyElsewhere() + "\n\n")
 35	for i := range items {
 36		m := &items[i]
 37		pos := strconv.Itoa(i+1) + " of " + total
 38		if m.purged {
 39			b.WriteString("_Exhibit " + pos + " was taken down._\n\n")
 40			continue
 41		}
 42		dest := mediaDestination(m)
 43		if dest == "" {
 44			b.WriteString("_Exhibit " + pos + " is not currently available._\n\n")
 45			continue
 46		}
 47		caption := ""
 48		if m.caption != "" {
 49			caption = " — " + sanitize.InlineText(m.caption)
 50		}
 51		if m.kind == mediaKindVideo {
 52			b.WriteString("[▶ Exhibit " + pos + caption + "](" + dest +
 53				") _(a link; the court holds no copy and cannot vouch for it)_\n\n")
 54			continue
 55		}
 56		b.WriteString("![Exhibit " + pos + "](" + dest + ")\n\n")
 57		b.WriteString("_Exhibit " + pos + caption + "_\n\n")
 58	}
 59}
 60func ClaimMediaPage(courtSlug string, fromID uint64, count int) string {
 61	c := mustCourt(courtSlug)
 62	if count < 1 {
 63		return "[]"
 64	}
 65	if count > maxMediaPage {
 66		count = maxMediaPage
 67	}
 68	var b strings.Builder
 69	b.WriteString("[")
 70	for i := 0; i < count; i++ {
 71		if i > 0 {
 72			b.WriteString(",")
 73		}
 74		id := fromID + uint64(i)
 75		v := c.claims.Get(beClaimKey(id))
 76		if v == nil {
 77			b.WriteString("[]")
 78			continue
 79		}
 80		b.WriteString(encodeMedia(claimMediaVisible(c, v.(*claimState))))
 81	}
 82	b.WriteString("]")
 83	return b.String()
 84}
 85func PurgeClaimMedia(cur realm, courtSlug string, claimID uint64, idx uint64, categoryCode string) {
 86	if !cur.IsCurrent() {
 87		panic(errStaleRealm)
 88	}
 89	who := cur.Previous().Address()
 90	d := ensureGlobalDAO()
 91	if !d.members.Has(who.String()) {
 92		panic("kourtv2: only a global DAO member may purge")
 93	}
 94	mustCategoryCode(categoryCode)
 95	c := mustCourt(courtSlug)
 96	cs := mustClaim(c, claimID)
 97	if idx >= uint64(len(cs.media)) {
 98		panic("kourtv2: this claim carries no media item at that position")
 99	}
100	if cs.media[idx].purged {
101		return
102	}
103	fire, votedCode := approveAction(d.pending,
104		"mediapurge:"+c.id+":"+strconv.FormatUint(claimID, 10)+":"+strconv.FormatUint(idx, 10),
105		who, categoryCode, d.purgeM)
106	if !fire {
107		return
108	}
109	m := &cs.media[idx]
110	m.purged = true
111	m.sha256 = ""
112	m.mirrors = nil
113	m.caption = ""
114	m.mime = ""
115	ensureClaimMod(c, ensureMod(c), claimID).
116		appendLog(who, boardActCode("media-purge", idx, votedCode), "")
117	emitPurge(c.id, claimID, "media-item", who)
118}
119const (
120	maxClaimMediaCount = 7
121	maxMirrorsPerItem  = 4
122	maxMediaURLLen     = 300
123	maxCaptionLen      = 120
124	maxMediaBytes = 262144
125	maxMediaDim   = 20000
126	maxMediaPage = 64
127)
128const (
129	mediaKindImage = "img"
130	mediaKindVideo = "vid"
131)
132type mediaItem struct {
133	kind    string
134	sha256  string
135	mime    string
136	w, h    int
137	bytes   int
138	caption string
139	mirrors []string
140	purged bool
141}
142func mediaCharFault(s string) string {
143	for i := 0; i < len(s); i++ {
144		c := s[i]
145		if c < 0x21 || c > 0x7e {
146			return "a mirror is printable ASCII with no spaces"
147		}
148		switch c {
149		case '"', '\'', '<', '>', '(', ')', '\\', ',', '|', '`':
150			return "a mirror may not contain " + string(rune(c))
151		}
152	}
153	return ""
154}
155func isHexLower(s string) bool {
156	for i := 0; i < len(s); i++ {
157		c := s[i]
158		if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
159			continue
160		}
161		return false
162	}
163	return true
164}
165func mirrorFault(u string) string {
166	if len(u) == 0 || len(u) > maxMediaURLLen {
167		return "a mirror is 1.." + strconv.Itoa(maxMediaURLLen) + " characters"
168	}
169	if fault := mediaCharFault(u); fault != "" {
170		return fault
171	}
172	const scheme = "https://"
173	if !strings.HasPrefix(u, scheme) {
174		return "a mirror is https — plain http is blocked as mixed content"
175	}
176	host := u[len(scheme):]
177	for _, sep := range []byte{'/', '?', '#'} {
178		if i := strings.IndexByte(host, sep); i >= 0 {
179			host = host[:i]
180		}
181	}
182	if host == "" {
183		return "a mirror needs a host"
184	}
185	if !mediaHostAllowed(host) {
186		return "the browser will not load images from " + host +
187			"; see the allowed hosts on the help page"
188	}
189	return ""
190}
191var defaultMediaHostsExact = []string{
192	"gnolang.github.io",
193	"assets.gnoteam.com",
194	"sa.gno.services",
195	"imgur.com",
196	"github.com",
197	"imgflip.com",
198	"ipfs.io",
199	"cloudflare-ipfs.com",
200}
201var defaultMediaHostSuffixes = []string{
202	".imgur.com",
203	".github.io",
204	".githubusercontent.com",
205	".imgflip.com",
206}
207var mediaHostsExact = appendAll(nil, defaultMediaHostsExact)
208var mediaHostSuffixes = appendAll(nil, defaultMediaHostSuffixes)
209func appendAll(dst, src []string) []string {
210	for _, v := range src {
211		dst = append(dst, v)
212	}
213	return dst
214}
215const maxMediaHosts = 32
216func mediaHostEntryFault(h string, suffix bool) string {
217	if h == "" {
218		return "a host may not be empty"
219	}
220	if len(h) > 100 {
221		return "a host is at most 100 characters"
222	}
223	if suffix && h[0] != '.' {
224		return "a suffix begins with a dot: " + h
225	}
226	if !suffix && h[0] == '.' {
227		return "an exact host does not begin with a dot: " + h
228	}
229	for i := 0; i < len(h); i++ {
230		c := h[i]
231		ok := (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-'
232		if !ok {
233			return "a host is lowercase letters, digits, dots and hyphens: " + h
234		}
235	}
236	if strings.Contains(h, "..") || h[len(h)-1] == '.' {
237		return "a host has no empty label: " + h
238	}
239	return ""
240}
241func parseMediaHosts(list string, suffix bool) []string {
242	if strings.TrimSpace(list) == "" {
243		return nil
244	}
245	parts := strings.Split(list, ",")
246	if len(parts) > maxMediaHosts {
247		panic("kourtv2: at most " + strconv.Itoa(maxMediaHosts) + " hosts in a list")
248	}
249	out := []string{}
250	for _, p := range parts {
251		h := strings.TrimSpace(p)
252		if fault := mediaHostEntryFault(h, suffix); fault != "" {
253			panic("kourtv2: " + fault)
254		}
255		for _, seen := range out {
256			if seen == h {
257				panic("kourtv2: " + h + " is listed twice")
258			}
259		}
260		out = append(out, h)
261	}
262	return out
263}
264func SetMediaHosts(cur realm, exact, suffixes string) {
265	if !cur.IsCurrent() {
266		panic(errStaleRealm)
267	}
268	d := ensureGlobalDAO()
269	if cur.Previous().Address() != d.admin {
270		panic("kourtv2: only the global DAO admin sets the media hosts")
271	}
272	ex := parseMediaHosts(exact, false)
273	sf := parseMediaHosts(suffixes, true)
274	if len(ex) == 0 && len(sf) == 0 {
275		panic("kourtv2: to allow no third-party host, call ClearMediaHosts")
276	}
277	mediaHostsExact = ex
278	mediaHostSuffixes = sf
279	chain.Emit(globalActEvent,
280		"court", "*",
281		"claim", "0",
282		"act", "set-media-hosts:"+strconv.Itoa(len(ex))+"+"+strconv.Itoa(len(sf)),
283		"by", cur.Previous().Address().String(),
284		"height", eventHeight(),
285	)
286}
287func ClearMediaHosts(cur realm) {
288	if !cur.IsCurrent() {
289		panic(errStaleRealm)
290	}
291	d := ensureGlobalDAO()
292	if cur.Previous().Address() != d.admin {
293		panic("kourtv2: only the global DAO admin sets the media hosts")
294	}
295	mediaHostsExact = nil
296	mediaHostSuffixes = nil
297	chain.Emit(globalActEvent,
298		"court", "*",
299		"claim", "0",
300		"act", "clear-media-hosts",
301		"by", cur.Previous().Address().String(),
302		"height", eventHeight(),
303	)
304}
305func MediaHosts() string {
306	return strings.Join(mediaHostsExact, ",") + "|" + strings.Join(mediaHostSuffixes, ",")
307}
308func mediaHostAllowed(host string) bool {
309	if host == "" {
310		return false
311	}
312	if siteDomain != "" && host == siteDomain && siteDomainFault(siteDomain) == "" {
313		return true
314	}
315	for _, h := range mediaHostsExact {
316		if host == h {
317			return true
318		}
319	}
320	for _, s := range mediaHostSuffixes {
321		if len(host) > len(s) && strings.HasSuffix(host, s) {
322			return true
323		}
324	}
325	return false
326}
327func captionFault(s string) string {
328	if runeLen(s) > maxCaptionLen {
329		return "a caption is at most " + strconv.Itoa(maxCaptionLen) + " characters"
330	}
331	for _, r := range s {
332		if (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) {
333			return "a caption may not contain text-direction controls"
334		}
335	}
336	for i := 0; i < len(s); i++ {
337		switch s[i] {
338		case '\n', '\r':
339			return "a caption is one line"
340		case '|':
341			return "a caption may not contain a vertical bar"
342		}
343		if s[i] < 0x20 && s[i] != '\t' {
344			return "a caption may not contain control characters"
345		}
346	}
347	return ""
348}
349func mediaItemFault(m *mediaItem) string {
350	switch m.kind {
351	case mediaKindImage:
352		if len(m.sha256) != 64 || !isHexLower(m.sha256) {
353			return "an image needs a sha256: 64 lowercase hex characters"
354		}
355		if m.w <= 0 || m.h <= 0 || m.w > maxMediaDim || m.h > maxMediaDim {
356			return "an image needs real dimensions"
357		}
358		if m.bytes <= 0 || m.bytes > maxMediaBytes {
359			return "an image is 1.." + strconv.Itoa(maxMediaBytes) + " bytes"
360		}
361		if m.mime == "" {
362			return "an image needs a media type"
363		}
364	case mediaKindVideo:
365		if m.sha256 != "" {
366			return "a video link carries no hash"
367		}
368	default:
369		return "a media item is " + mediaKindImage + " or " + mediaKindVideo
370	}
371	if fault := captionFault(m.caption); fault != "" {
372		return fault
373	}
374	if len(m.mirrors) == 0 {
375		return "a media item needs somewhere to find it"
376	}
377	if len(m.mirrors) > maxMirrorsPerItem {
378		return "a media item lists at most " + strconv.Itoa(maxMirrorsPerItem) + " mirrors"
379	}
380	for _, u := range m.mirrors {
381		if fault := mirrorFault(u); fault != "" {
382			return fault
383		}
384	}
385	return ""
386}
387func parseMediaFault(arg string) ([]mediaItem, string) {
388	trimmed := strings.TrimSpace(arg)
389	if trimmed == "" {
390		return nil, ""
391	}
392	lines := strings.Split(trimmed, "\n")
393	if len(lines) > maxClaimMediaCount {
394		return nil, "a claim carries at most " +
395			strconv.Itoa(maxClaimMediaCount) + " media items"
396	}
397	out := make([]mediaItem, 0, len(lines))
398	for _, line := range lines {
399		line = strings.TrimSpace(line)
400		if line == "" {
401			continue
402		}
403		f := strings.Split(line, "|")
404		if len(f) != 8 {
405			return nil, "a media item has 8 fields separated by |"
406		}
407		m := mediaItem{
408			kind:    f[0],
409			sha256:  f[1],
410			mime:    f[2],
411			w:       atoiOr0(f[3]),
412			h:       atoiOr0(f[4]),
413			bytes:   atoiOr0(f[5]),
414			caption: f[6],
415		}
416		for _, u := range strings.Split(f[7], ",") {
417			if u != "" {
418				m.mirrors = append(m.mirrors, u)
419			}
420		}
421		if fault := mediaItemFault(&m); fault != "" {
422			return nil, fault
423		}
424		out = append(out, m)
425	}
426	return out, ""
427}
428func parseMediaArg(arg string) []mediaItem {
429	items, fault := parseMediaFault(arg)
430	if fault != "" {
431		panic("kourtv2: " + fault)
432	}
433	return items
434}
435func atoiOr0(s string) int {
436	n, err := strconv.Atoi(s)
437	if err != nil {
438		return 0
439	}
440	return n
441}
442func jsonString(s string) string {
443	var b strings.Builder
444	b.WriteString(`"`)
445	for i := 0; i < len(s); i++ {
446		c := s[i]
447		switch c {
448		case '"':
449			b.WriteString(`\"`)
450		case '\\':
451			b.WriteString(`\\`)
452		case '\n':
453			b.WriteString(`\n`)
454		case '\r':
455			b.WriteString(`\r`)
456		case '\t':
457			b.WriteString(`\t`)
458		default:
459			if c < 0x20 {
460				b.WriteString(`\u00`)
461				const hex = "0123456789abcdef"
462				b.WriteByte(hex[c>>4])
463				b.WriteByte(hex[c&0xf])
464				continue
465			}
466			b.WriteByte(c)
467		}
468	}
469	b.WriteString(`"`)
470	return b.String()
471}
472func encodeMedia(items []mediaItem) string {
473	if len(items) == 0 {
474		return "[]"
475	}
476	var b strings.Builder
477	b.WriteString("[")
478	for i := range items {
479		if i > 0 {
480			b.WriteString(",")
481		}
482		m := &items[i]
483		b.WriteString(`{"kind":`)
484		b.WriteString(jsonString(m.kind))
485		if m.purged {
486			b.WriteString(`,"purged":true}`)
487			continue
488		}
489		b.WriteString(`,"sha256":`)
490		b.WriteString(jsonString(m.sha256))
491		b.WriteString(`,"mime":`)
492		b.WriteString(jsonString(m.mime))
493		b.WriteString(`,"w":`)
494		b.WriteString(strconv.Itoa(m.w))
495		b.WriteString(`,"h":`)
496		b.WriteString(strconv.Itoa(m.h))
497		b.WriteString(`,"bytes":`)
498		b.WriteString(strconv.Itoa(m.bytes))
499		b.WriteString(`,"caption":`)
500		b.WriteString(jsonString(m.caption))
501		b.WriteString(`,"mirrors":[`)
502		n := 0
503		for _, u := range m.mirrors {
504			if mirrorFault(u) != "" {
505				continue
506			}
507			if n > 0 {
508				b.WriteString(",")
509			}
510			b.WriteString(jsonString(u))
511			n++
512		}
513		b.WriteString(`]}`)
514	}
515	b.WriteString("]")
516	return b.String()
517}