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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}