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

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}