stdcol.gno
18.67 Kb · 962 lines
1package stdcol
2
3import (
4 "chain"
5 "chain/banker"
6 "chain/runtime/unsafe"
7 "strconv"
8 "strings"
9
10 "gno.land/p/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/bazaar/grc721/v0"
11 "gno.land/p/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/bazaar/grc721/metadata/v0"
12 "gno.land/p/nt/avl/v0"
13 "gno.land/p/nt/seqid/v0"
14 "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/bazaar/factory"
15)
16
17const (
18 maxNameLen = 64
19 maxImageLen = 200
20 maxSupplyBound = 1_000_000
21 maxRoyaltyBps = 1000
22 nativeDenom = "ugnot"
23 publicOrigin = "https://bazaar-gno.vercel.app"
24 dropAddCap = 20
25 dropSlotHardCap = 10000
26 minSlugLen = 2
27)
28
29type item struct {
30 id int
31 name string
32 image string
33 owner address
34 seller address
35 price int64
36 listed bool
37 rarity string
38 traits string
39 revealed bool
40 slot int
41}
42
43type dropSlot struct {
44 name string
45 image string
46 rarity string
47 traits string
48}
49
50var (
51 items avl.Tree
52 dropSlots avl.Tree
53 token *grc721.Token
54 ledger *grc721.PrivateLedger
55 metaView *metadata.Metadata
56 metaLed *metadata.Ledger
57 tokenSeq seqid.ID
58 nextID int
59 minted int
60 loaded int
61 maxSupply int
62 mintPrice int64
63 royaltyBps int
64 paused bool
65 inited bool
66 creator address
67 self address
68 colName string
69 colSymbol string
70 colCover string
71 slug string
72)
73
74func Init(cur realm, name, symbol, cover string, maxSupplyN int, mintPriceUgnot int64, royalty int64) {
75 if !cur.Previous().IsUserCall() {
76 panic("col: EOA only")
77 }
78 if !cur.IsCurrent() {
79 panic("col: spoofed realm")
80 }
81 if len(symbol) < 1 || len(symbol) > grc721.MaxSymbolLen {
82 panic("col: symbol")
83 }
84 pathSlug := lastElem(cur.PkgPath())
85 if !validPkgName(pathSlug) {
86 panic("col: slug")
87 }
88 if maxSupplyN < 0 || maxSupplyN > maxSupplyBound {
89 panic("col: maxSupply")
90 }
91 if mintPriceUgnot < 0 {
92 panic("col: mintPrice")
93 }
94 if royalty < 0 || royalty > maxRoyaltyBps {
95 panic("col: royalty")
96 }
97 if !validName(name) {
98 panic("col: name")
99 }
100 if !validImage(cover) {
101 panic("col: cover")
102 }
103 if inited {
104 panic("col: already initialized")
105 }
106 slug = pathSlug
107 reservedWho := factory.ReservedCreator(slug)
108 takeLaunchFee(cur)
109 tok, led := grc721.NewToken(name, symbol, tokenSeq.Next(), cur)
110 token = tok
111 ledger = led
112 metaView, metaLed = metadata.NewMetadata(led)
113 creator = cur.Previous().Address()
114 self = cur.Address()
115 colName = name
116 colSymbol = symbol
117 colCover = cover
118 maxSupply = maxSupplyN
119 mintPrice = mintPriceUgnot
120 royaltyBps = int(royalty)
121 items = avl.Tree{}
122 dropSlots = avl.Tree{}
123 loaded = 0
124 inited = true
125 if reservedWho == "" {
126 factory.CreditLaunchFee(cross(cur))
127 }
128 factory.Register(cross(cur), name, symbol, cover, maxSupplyN, mintPriceUgnot, royalty)
129 chain.Emit("Init", "creator", creator.String(), "slug", slug)
130}
131
132func Mint(cur realm, name, imageURL string) int {
133 caller := requireUser(cur)
134 if caller != creator {
135 panic("col: not creator")
136 }
137 id := mintTo(cur, caller, name, imageURL)
138 writeTokenMeta(id)
139 chain.Emit("Mint", "id", strconv.Itoa(id), "owner", caller.String())
140 return id
141}
142
143func PublicMint(cur realm) int {
144 caller := requireUser(cur)
145 takeExact(mintPrice)
146 name := colName + " #" + strconv.Itoa(minted+1)
147 image := publicImage()
148 var slot *dropSlot
149 slotIndex := -1
150 if loaded > 0 {
151 if minted >= loaded {
152 panic("col: no slot")
153 }
154 v := dropSlots.Get(slotKey(minted))
155 if v == nil {
156 panic("col: no slot")
157 }
158 slot = v.(*dropSlot)
159 name = slot.name
160 image = slot.image
161 slotIndex = minted
162 }
163 id := mintTo(cur, caller, name, image)
164 if slot != nil {
165 applySlot(id, slot, slotIndex)
166 }
167 writeTokenMeta(id)
168 if mintPrice > 0 {
169 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
170 bk.SendCoins(self, creator, chain.Coins{{Denom: nativeDenom, Amount: mintPrice}})
171 }
172 chain.Emit("PublicMint", "id", strconv.Itoa(id), "n", strconv.Itoa(minted))
173 return id
174}
175
176func AddDropItems(cur realm, blob string) {
177 caller := requireUser(cur)
178 if caller != creator {
179 panic("col: not creator")
180 }
181 lines := splitDropLines(blob)
182 if len(lines) < 1 || len(lines) > dropAddCap {
183 panic("col: slots")
184 }
185 if loaded+len(lines) > slotCap() {
186 panic("col: slots")
187 }
188 for _, line := range lines {
189 name, image, rarity, traits := parseDropLine(line)
190 if !validName(name) {
191 panic("col: name")
192 }
193 if !validImage(image) {
194 panic("col: image")
195 }
196 if !validRarity(rarity) {
197 panic("col: rarity")
198 }
199 if !validTraits(traits) {
200 panic("col: traits")
201 }
202 dropSlots.Set(slotKey(loaded), &dropSlot{name: name, image: image, rarity: rarity, traits: traits})
203 loaded++
204 }
205 chain.Emit("AddDropItems", "slug", slug, "loaded", strconv.Itoa(loaded))
206}
207
208func ListDropSlots() string {
209 requireInit()
210 out := ""
211 for i := 0; i < loaded; i++ {
212 v := dropSlots.Get(slotKey(i))
213 if v == nil {
214 continue
215 }
216 s := v.(*dropSlot)
217 if out != "" {
218 out += "\n"
219 }
220 out += strconv.Itoa(i) + "|" + s.name + "|" + s.image + "|" + s.rarity + "|" + s.traits
221 }
222 return out
223}
224
225func LoadedOf() int {
226 requireInit()
227 return loaded
228}
229
230func PauseMint(cur realm) {
231 caller := requireUser(cur)
232 if caller != creator {
233 panic("col: not creator")
234 }
235 paused = true
236 chain.Emit("PauseMint", "slug", slug)
237}
238
239func ResumeMint(cur realm) {
240 caller := requireUser(cur)
241 if caller != creator {
242 panic("col: not creator")
243 }
244 paused = false
245 chain.Emit("ResumeMint", "slug", slug)
246}
247
248func OwnerOf(tid string) string {
249 return mustItem(parseTid(tid)).owner.String()
250}
251
252func GrcName() string {
253 requireInit()
254 return token.GetName()
255}
256
257func GrcSymbol() string {
258 requireInit()
259 return token.GetSymbol()
260}
261
262func GrcBalanceOf(addr string) int64 {
263 requireInit()
264 who := address(addr)
265 if !who.IsValid() {
266 return 0
267 }
268 n, err := token.BalanceOf(who)
269 if err != nil {
270 return 0
271 }
272 return n
273}
274
275func GrcOwnerOf(id int) string {
276 requireInit()
277 owner, err := token.OwnerOf(itemTid(id))
278 if err != nil {
279 panic("col: owner")
280 }
281 return owner.String()
282}
283
284func Name() string {
285 return GrcName()
286}
287
288func Symbol() string {
289 return GrcSymbol()
290}
291
292func BalanceOf(addr string) int64 {
293 return GrcBalanceOf(addr)
294}
295
296func TokenURI(tid string) string {
297 id := parseTid(tid)
298 mustItem(id)
299 if metaView != nil {
300 u, err := metaView.TokenURI(itemTid(id))
301 if err == nil && u != "" {
302 return u
303 }
304 }
305 return tokenJSON(mustItem(id))
306}
307
308func guardUnlisted(id int) {
309 if mustItem(id).listed {
310 panic("col: listed")
311 }
312}
313
314func Approve(cur realm, to, tid string) {
315 caller := requireUser(cur)
316 id := parseTid(tid)
317 guardUnlisted(id)
318 who := address(to)
319 if to != "" && !who.IsValid() {
320 panic("col: addr")
321 }
322 if err := ledger.Approve(caller, who, itemTid(id)); err != nil {
323 panic("col: approve")
324 }
325}
326
327func SetApprovalForAll(cur realm, operator string, approved bool) {
328 caller := requireUser(cur)
329 op := address(operator)
330 if !op.IsValid() {
331 panic("col: addr")
332 }
333 if err := ledger.SetApprovalForAll(caller, op, approved); err != nil {
334 panic("col: approve")
335 }
336}
337
338func TransferFrom(cur realm, from, to, tid string) {
339 caller := requireUser(cur)
340 id := parseTid(tid)
341 guardUnlisted(id)
342 src := address(from)
343 dst := address(to)
344 if !src.IsValid() || !dst.IsValid() {
345 panic("col: addr")
346 }
347 if err := ledger.TransferFrom(caller, src, dst, itemTid(id)); err != nil {
348 panic("col: transfer")
349 }
350 it := mustItem(id)
351 it.owner = dst
352 items.Set(itemKey(id), it)
353}
354
355func GetApproved(id int) string {
356 requireInit()
357 who, err := token.GetApproved(itemTid(id))
358 if err != nil {
359 return ""
360 }
361 return who.String()
362}
363
364func IsApprovedForAll(owner, operator string) bool {
365 requireInit()
366 a := address(owner)
367 b := address(operator)
368 if !a.IsValid() || !b.IsValid() {
369 return false
370 }
371 return token.IsApprovedForAll(a, b)
372}
373
374func TotalSupply() int64 {
375 requireInit()
376 return token.TotalSupply()
377}
378
379func SellerOf(id int) address {
380 return mustItem(id).seller
381}
382
383func PriceOf(id int) int64 {
384 return mustItem(id).price
385}
386
387func NameOf(id int) string {
388 return mustItem(id).name
389}
390
391func ImageOf(id int) string {
392 return mustItem(id).image
393}
394
395func Listed(id int) bool {
396 return mustItem(id).listed
397}
398
399func NextID() int {
400 return nextID
401}
402
403func CollectionOf(_ int) string {
404 requireInit()
405 return slug
406}
407
408func ItemLine(id int) string {
409 it := mustItem(id)
410 return strconv.Itoa(it.id) + "|" + it.name + "|" + it.owner.String() + "|" + it.seller.String() + "|" + strconv.FormatInt(it.price, 10) + "|" + strconv.FormatBool(it.listed) + "|" + it.image + "|" + slug + "|" + it.rarity + "|" + it.traits + "|" + strconv.FormatBool(it.revealed) + "|" + strconv.Itoa(it.slot)
411}
412
413func ListOpen() string {
414 requireInit()
415 out := ""
416 n := 0
417 items.Iterate("", "", func(_ string, value any) bool {
418 it := value.(*item)
419 if !it.listed {
420 return false
421 }
422 if n >= 50 {
423 return true
424 }
425 if out != "" {
426 out += "\n"
427 }
428 out += ItemLine(it.id)
429 n++
430 return false
431 })
432 return out
433}
434
435func TokensOf(owner string) string {
436 requireInit()
437 who := address(owner)
438 if !who.IsValid() {
439 return ""
440 }
441 out := ""
442 items.Iterate("", "", func(_ string, value any) bool {
443 it := value.(*item)
444 if it.owner != who {
445 return false
446 }
447 if out != "" {
448 out += ","
449 }
450 out += strconv.Itoa(it.id)
451 return false
452 })
453 return out
454}
455
456func TokenOfOwnerByIndex(owner string, index int64) string {
457 requireInit()
458 who := address(owner)
459 if !who.IsValid() {
460 panic("col: addr")
461 }
462 if index < 0 {
463 panic("col: index")
464 }
465 var n int64
466 found := ""
467 items.Iterate("", "", func(_ string, value any) bool {
468 it := value.(*item)
469 if it.owner != who {
470 return false
471 }
472 if n == index {
473 found = strconv.Itoa(it.id)
474 return true
475 }
476 n++
477 return false
478 })
479 if found == "" {
480 panic("col: index")
481 }
482 return found
483}
484
485func Creator() string {
486 requireInit()
487 return creator.String()
488}
489
490func MintPrice() int64 {
491 requireInit()
492 return mintPrice
493}
494
495func MaxSupply() int {
496 requireInit()
497 return maxSupply
498}
499
500func Minted() int {
501 requireInit()
502 return minted
503}
504
505func RoyaltyBps() int {
506 requireInit()
507 return royaltyBps
508}
509
510func SetRoyalty(cur realm, bps int) {
511 caller := requireUser(cur)
512 if caller != creator {
513 panic("col: not creator")
514 }
515 if minted != 0 {
516 panic("col: minted")
517 }
518 if bps < 0 || bps > maxRoyaltyBps {
519 panic("col: royalty")
520 }
521 royaltyBps = bps
522 factory.NoteRoyalty(cross(cur), bps)
523 chain.Emit("SetRoyalty", "slug", slug, "bps", strconv.Itoa(bps))
524}
525
526func RarityOf(id int) string {
527 return mustItem(id).rarity
528}
529
530func TraitsOf(id int) string {
531 return mustItem(id).traits
532}
533
534func Render(_ string) string {
535 if !inited {
536 return "# Collection\n\nCall Init first.\n"
537 }
538 out := "# " + colName + " ($" + colSymbol + ")\n\n"
539 out += "- minted: " + strconv.Itoa(minted) + "\n"
540 out += "- mint price: " + strconv.FormatInt(mintPrice, 10) + " ugnot\n\n"
541 open := ListOpen()
542 if open == "" {
543 out += "_No open listings._\n"
544 } else {
545 out += "```\n" + open + "\n```\n"
546 }
547 return out
548}
549
550func mintTo(cur realm, caller address, name, imageURL string) int {
551 requireInit()
552 if !cur.IsCurrent() {
553 panic("col: spoofed realm")
554 }
555 if paused {
556 panic("col: paused")
557 }
558 if maxSupply > 0 && minted >= maxSupply {
559 panic("col: sold out")
560 }
561 if !validName(name) {
562 panic("col: name")
563 }
564 if !validImage(imageURL) {
565 panic("col: image")
566 }
567 nextID++
568 id := nextID
569 it := &item{
570 id: id,
571 name: name,
572 image: imageURL,
573 owner: caller,
574 rarity: rarityOf(id),
575 revealed: true,
576 slot: -1,
577 }
578 items.Set(itemKey(id), it)
579 err := ledger.Mint(caller, itemTid(id))
580 if err != nil {
581 panic("col: mint")
582 }
583 minted++
584 factory.NoteMinted(cross(cur), minted)
585 return id
586}
587
588func takeLaunchFee(cur realm) {
589 fee := factory.LaunchFee()
590 who := factory.ReservedCreator(lastElem(cur.PkgPath()))
591 if who != "" && who == cur.Previous().Address().String() {
592 takeExact(0)
593 return
594 }
595 takeExact(fee)
596 if fee > 0 {
597 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
598 bk.SendCoins(cur.Address(), factory.Address(), chain.Coins{{Denom: nativeDenom, Amount: fee}})
599 }
600}
601
602func takeExact(want int64) {
603 sent := unsafe.OriginSend()
604 if want == 0 {
605 if len(sent) != 0 {
606 panic("col: extra denoms")
607 }
608 return
609 }
610 if len(sent) != 1 || sent[0].Denom != nativeDenom || sent[0].Amount != want {
611 panic("col: wrong payment")
612 }
613}
614
615func publicImage() string {
616 img := "/samples/" + slug + "-0" + strconv.Itoa(minted+1) + ".png"
617 if validImage(img) {
618 return img
619 }
620 return colCover
621}
622
623func requireInit() {
624 if !inited {
625 panic("col: call Init first")
626 }
627}
628
629func requireUser(cur realm) address {
630 requireInit()
631 if !cur.IsCurrent() {
632 panic("col: spoofed realm")
633 }
634 if !cur.Previous().IsUserCall() {
635 panic("col: EOA only")
636 }
637 return cur.Previous().Address()
638}
639
640func itemTid(id int) grc721.TokenID {
641 return grc721.TokenID(strconv.Itoa(id))
642}
643
644func parseTid(tid string) int {
645 id, err := strconv.Atoi(tid)
646 if err != nil || id < 1 {
647 panic("col: tid")
648 }
649 return id
650}
651
652func mustItem(id int) *item {
653 v := items.Get(itemKey(id))
654 if v == nil {
655 panic("col: unknown item")
656 }
657 return v.(*item)
658}
659
660func itemKey(id int) string {
661 return strconv.Itoa(id)
662}
663
664func lastElem(pkg string) string {
665 i := strings.LastIndex(pkg, "/")
666 if i < 0 || i+1 >= len(pkg) {
667 panic("col: pkg")
668 }
669 return pkg[i+1:]
670}
671
672func slotKey(i int) string {
673 return strconv.Itoa(i)
674}
675
676func slotCap() int {
677 if maxSupply > 0 {
678 return maxSupply
679 }
680 return dropSlotHardCap
681}
682
683func applySlot(id int, s *dropSlot, slotIndex int) {
684 it := mustItem(id)
685 it.name = s.name
686 it.image = s.image
687 if s.rarity != "" {
688 it.rarity = s.rarity
689 }
690 it.traits = s.traits
691 it.revealed = true
692 it.slot = slotIndex
693 items.Set(itemKey(id), it)
694}
695
696func writeTokenMeta(id int) {
697 it := mustItem(id)
698 uri := tokenJSON(it)
699 if metaLed == nil {
700 return
701 }
702 if err := metaLed.SetTokenURI(itemTid(id), uri); err != nil {
703 panic("col: tokenURI")
704 }
705 if err := metaLed.SetTokenMetadata(itemTid(id), metadata.Data{
706 Name: it.name,
707 Description: colName + " #" + strconv.Itoa(id),
708 Image: absImage(it.image),
709 Attributes: traitAttrs(it),
710 }); err != nil {
711 panic("col: metadata")
712 }
713}
714
715func tokenJSON(it *item) string {
716 attrs := ""
717 for _, tr := range traitAttrs(it) {
718 if attrs != "" {
719 attrs += ","
720 }
721 attrs += `{"trait_type":` + jsonStr(tr.TraitType) + `,"value":` + jsonStr(tr.Value) + `}`
722 }
723 body := `{"name":` + jsonStr(it.name) + `,"description":` + jsonStr(colName+" #"+strconv.Itoa(it.id)) + `,"image":` + jsonStr(absImage(it.image)) + `,"attributes":[` + attrs + `]}`
724 return "data:application/json;charset=utf-8," + pctURI(body)
725}
726
727func traitAttrs(it *item) []metadata.Trait {
728 out := []metadata.Trait{}
729 if it.rarity != "" {
730 out = append(out, metadata.Trait{TraitType: "Rarity", Value: it.rarity})
731 }
732 for _, part := range strings.Split(it.traits, ";") {
733 part = strings.TrimSpace(part)
734 colon := strings.Index(part, ":")
735 if colon <= 0 || colon >= len(part)-1 {
736 continue
737 }
738 key := strings.TrimSpace(part[:colon])
739 val := strings.TrimSpace(part[colon+1:])
740 if key == "" || val == "" {
741 continue
742 }
743 out = append(out, metadata.Trait{TraitType: key, Value: val})
744 }
745 return out
746}
747
748func absImage(u string) string {
749 if u == "" {
750 return u
751 }
752 if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "ipfs://") {
753 return u
754 }
755 if strings.HasPrefix(u, "/") {
756 return publicOrigin + u
757 }
758 return u
759}
760
761func jsonStr(s string) string {
762 out := `"`
763 for _, r := range s {
764 switch r {
765 case '"':
766 out += `\"`
767 case '\\':
768 out += `\\`
769 case '\n':
770 out += `\n`
771 case '\r':
772 out += `\r`
773 case '\t':
774 out += `\t`
775 default:
776 if r < 0x20 {
777 out += `\u00`
778 hex := "0123456789abcdef"
779 out += string([]byte{hex[r>>4], hex[r&15]})
780 } else {
781 out += string(r)
782 }
783 }
784 }
785 return out + `"`
786}
787
788func pctURI(s string) string {
789 out := ""
790 for i := 0; i < len(s); i++ {
791 c := s[i]
792 if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' {
793 out += string(c)
794 continue
795 }
796 hex := "0123456789ABCDEF"
797 out += "%"
798 out += string([]byte{hex[c>>4], hex[c&15]})
799 }
800 return out
801}
802
803func splitDropLines(blob string) []string {
804 raw := strings.Split(blob, "\n")
805 out := make([]string, 0, len(raw))
806 for _, line := range raw {
807 line = strings.TrimSpace(line)
808 if line == "" {
809 continue
810 }
811 out = append(out, line)
812 }
813 return out
814}
815
816func parseDropLine(line string) (string, string, string, string) {
817 parts := strings.Split(line, "|")
818 name := ""
819 image := ""
820 rarity := ""
821 traits := ""
822 if len(parts) > 0 {
823 name = strings.TrimSpace(parts[0])
824 }
825 if len(parts) > 1 {
826 image = strings.TrimSpace(parts[1])
827 }
828 if len(parts) > 2 {
829 rarity = strings.TrimSpace(parts[2])
830 }
831 if len(parts) > 3 {
832 traits = strings.TrimSpace(parts[3])
833 }
834 return name, image, rarity, traits
835}
836
837func validRarity(s string) bool {
838 if s == "" {
839 return true
840 }
841 return s == "Common" || s == "Uncommon" || s == "Rare" || s == "Epic" || s == "Legendary"
842}
843
844func validTraits(s string) bool {
845 if s == "" {
846 return true
847 }
848 if len(s) > 200 {
849 return false
850 }
851 if strings.ContainsAny(s, "\n\r|") {
852 return false
853 }
854 n := 0
855 for _, part := range strings.Split(s, ";") {
856 part = strings.TrimSpace(part)
857 if part == "" {
858 continue
859 }
860 colon := strings.Index(part, ":")
861 if colon <= 0 || colon >= len(part)-1 {
862 return false
863 }
864 key := strings.TrimSpace(part[:colon])
865 val := strings.TrimSpace(part[colon+1:])
866 if key == "" || val == "" {
867 return false
868 }
869 if !asciiSet(key, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-") {
870 return false
871 }
872 if !asciiSet(val, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-") {
873 return false
874 }
875 n++
876 if n > 8 {
877 return false
878 }
879 }
880 return n >= 1
881}
882
883func validPkgName(s string) bool {
884 if len(s) < minSlugLen || len(s) > grc721.MaxSymbolLen {
885 return false
886 }
887 if s[0] < 'a' || s[0] > 'z' {
888 return false
889 }
890 return asciiSet(s, "abcdefghijklmnopqrstuvwxyz0123456789")
891}
892
893func rarityOf(id int) string {
894 r := id % 10
895 if r == 0 {
896 return "Legendary"
897 }
898 if r == 1 {
899 return "Common"
900 }
901 if r == 2 {
902 return "Uncommon"
903 }
904 if r == 3 {
905 return "Rare"
906 }
907 if r == 4 {
908 return "Epic"
909 }
910 if r <= 7 {
911 return "Uncommon"
912 }
913 return "Common"
914}
915
916func validName(name string) bool {
917 if len(name) < 1 || len(name) > maxNameLen {
918 return false
919 }
920 if strings.ContainsAny(name, "\n\r|\t\"\\") {
921 return false
922 }
923 for _, r := range name {
924 if r < 0x20 {
925 return false
926 }
927 }
928 return true
929}
930
931func validImage(u string) bool {
932 if u == "" {
933 return true
934 }
935 if len(u) > maxImageLen {
936 return false
937 }
938 if strings.ContainsAny(u, " \t\n\r|<>\"'`") {
939 return false
940 }
941 if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") {
942 return true
943 }
944 const prefix = "/samples/"
945 if !strings.HasPrefix(u, prefix) {
946 return false
947 }
948 rest := u[len(prefix):]
949 if rest == "" {
950 return false
951 }
952 return asciiSet(rest, "abcdefghijklmnopqrstuvwxyz0123456789._-")
953}
954
955func asciiSet(s, allowed string) bool {
956 for i := 0; i < len(s); i++ {
957 if !strings.ContainsRune(allowed, rune(s[i])) {
958 return false
959 }
960 }
961 return true
962}