media.gno
12.35 Kb · 517 lines
1package kourtv3
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("\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("kourtv3: 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("kourtv3: 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("kourtv3: 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("kourtv3: " + fault)
254 }
255 for _, seen := range out {
256 if seen == h {
257 panic("kourtv3: " + 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("kourtv3: 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("kourtv3: 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("kourtv3: 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("kourtv3: " + 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}