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

test.gno

17.94 Kb · 918 lines
  1package test
  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(id int) address {
249	return mustItem(id).owner
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(id int) string {
297	mustItem(id)
298	if metaView != nil {
299		u, err := metaView.TokenURI(itemTid(id))
300		if err == nil && u != "" {
301			return u
302		}
303	}
304	return tokenJSON(mustItem(id))
305}
306
307func guardUnlisted(id int) {
308	if mustItem(id).listed {
309		panic("col: listed")
310	}
311}
312
313func Approve(cur realm, to string, id int) {
314	caller := requireUser(cur)
315	guardUnlisted(id)
316	who := address(to)
317	if to != "" && !who.IsValid() {
318		panic("col: addr")
319	}
320	if err := ledger.Approve(caller, who, itemTid(id)); err != nil {
321		panic("col: approve")
322	}
323}
324
325func SetApprovalForAll(cur realm, operator string, approved bool) {
326	caller := requireUser(cur)
327	op := address(operator)
328	if !op.IsValid() {
329		panic("col: addr")
330	}
331	if err := ledger.SetApprovalForAll(caller, op, approved); err != nil {
332		panic("col: approve")
333	}
334}
335
336func TransferFrom(cur realm, from, to string, id int) {
337	caller := requireUser(cur)
338	guardUnlisted(id)
339	src := address(from)
340	dst := address(to)
341	if !src.IsValid() || !dst.IsValid() {
342		panic("col: addr")
343	}
344	if err := ledger.TransferFrom(caller, src, dst, itemTid(id)); err != nil {
345		panic("col: transfer")
346	}
347	it := mustItem(id)
348	it.owner = dst
349	items.Set(itemKey(id), it)
350}
351
352func GetApproved(id int) string {
353	requireInit()
354	who, err := token.GetApproved(itemTid(id))
355	if err != nil {
356		return ""
357	}
358	return who.String()
359}
360
361func IsApprovedForAll(owner, operator string) bool {
362	requireInit()
363	a := address(owner)
364	b := address(operator)
365	if !a.IsValid() || !b.IsValid() {
366		return false
367	}
368	return token.IsApprovedForAll(a, b)
369}
370
371func TotalSupply() int64 {
372	requireInit()
373	return token.TotalSupply()
374}
375
376func SellerOf(id int) address {
377	return mustItem(id).seller
378}
379
380func PriceOf(id int) int64 {
381	return mustItem(id).price
382}
383
384func NameOf(id int) string {
385	return mustItem(id).name
386}
387
388func ImageOf(id int) string {
389	return mustItem(id).image
390}
391
392func Listed(id int) bool {
393	return mustItem(id).listed
394}
395
396func NextID() int {
397	return nextID
398}
399
400func CollectionOf(_ int) string {
401	requireInit()
402	return slug
403}
404
405func ItemLine(id int) string {
406	it := mustItem(id)
407	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)
408}
409
410func ListOpen() string {
411	requireInit()
412	out := ""
413	n := 0
414	items.Iterate("", "", func(_ string, value any) bool {
415		it := value.(*item)
416		if !it.listed {
417			return false
418		}
419		if n >= 50 {
420			return true
421		}
422		if out != "" {
423			out += "\n"
424		}
425		out += ItemLine(it.id)
426		n++
427		return false
428	})
429	return out
430}
431
432func TokensOf(owner address) string {
433	requireInit()
434	out := ""
435	items.Iterate("", "", func(_ string, value any) bool {
436		it := value.(*item)
437		if it.owner != owner {
438			return false
439		}
440		if out != "" {
441			out += ","
442		}
443		out += strconv.Itoa(it.id)
444		return false
445	})
446	return out
447}
448
449func Creator() string {
450	requireInit()
451	return creator.String()
452}
453
454func MintPrice() int64 {
455	requireInit()
456	return mintPrice
457}
458
459func MaxSupply() int {
460	requireInit()
461	return maxSupply
462}
463
464func Minted() int {
465	requireInit()
466	return minted
467}
468
469func RoyaltyBps() int {
470	requireInit()
471	return royaltyBps
472}
473
474func SetRoyalty(cur realm, bps int) {
475	caller := requireUser(cur)
476	if caller != creator {
477		panic("col: not creator")
478	}
479	if minted != 0 {
480		panic("col: minted")
481	}
482	if bps < 0 || bps > maxRoyaltyBps {
483		panic("col: royalty")
484	}
485	royaltyBps = bps
486	factory.NoteRoyalty(cross(cur), bps)
487	chain.Emit("SetRoyalty", "slug", slug, "bps", strconv.Itoa(bps))
488}
489
490func RarityOf(id int) string {
491	return mustItem(id).rarity
492}
493
494func TraitsOf(id int) string {
495	return mustItem(id).traits
496}
497
498func Render(_ string) string {
499	if !inited {
500		return "# Collection\n\nCall Init first.\n"
501	}
502	out := "# " + colName + " ($" + colSymbol + ")\n\n"
503	out += "- minted: " + strconv.Itoa(minted) + "\n"
504	out += "- mint price: " + strconv.FormatInt(mintPrice, 10) + " ugnot\n\n"
505	open := ListOpen()
506	if open == "" {
507		out += "_No open listings._\n"
508	} else {
509		out += "```\n" + open + "\n```\n"
510	}
511	return out
512}
513
514func mintTo(cur realm, caller address, name, imageURL string) int {
515	requireInit()
516	if !cur.IsCurrent() {
517		panic("col: spoofed realm")
518	}
519	if paused {
520		panic("col: paused")
521	}
522	if maxSupply > 0 && minted >= maxSupply {
523		panic("col: sold out")
524	}
525	if !validName(name) {
526		panic("col: name")
527	}
528	if !validImage(imageURL) {
529		panic("col: image")
530	}
531	nextID++
532	id := nextID
533	it := &item{
534		id:       id,
535		name:     name,
536		image:    imageURL,
537		owner:    caller,
538		rarity:   rarityOf(id),
539		revealed: true,
540		slot:     -1,
541	}
542	items.Set(itemKey(id), it)
543	err := ledger.Mint(caller, itemTid(id))
544	if err != nil {
545		panic("col: mint")
546	}
547	minted++
548	factory.NoteMinted(cross(cur), minted)
549	return id
550}
551
552func takeLaunchFee(cur realm) {
553	fee := factory.LaunchFee()
554	who := factory.ReservedCreator(lastElem(cur.PkgPath()))
555	if who != "" && who == cur.Previous().Address().String() {
556		takeExact(0)
557		return
558	}
559	takeExact(fee)
560	if fee > 0 {
561		bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
562		bk.SendCoins(cur.Address(), factory.Address(), chain.Coins{{Denom: nativeDenom, Amount: fee}})
563	}
564}
565
566func takeExact(want int64) {
567	sent := unsafe.OriginSend()
568	if want == 0 {
569		if len(sent) != 0 {
570			panic("col: extra denoms")
571		}
572		return
573	}
574	if len(sent) != 1 || sent[0].Denom != nativeDenom || sent[0].Amount != want {
575		panic("col: wrong payment")
576	}
577}
578
579func publicImage() string {
580	img := "/samples/" + slug + "-0" + strconv.Itoa(minted+1) + ".png"
581	if validImage(img) {
582		return img
583	}
584	return colCover
585}
586
587func requireInit() {
588	if !inited {
589		panic("col: call Init first")
590	}
591}
592
593func requireUser(cur realm) address {
594	requireInit()
595	if !cur.IsCurrent() {
596		panic("col: spoofed realm")
597	}
598	if !cur.Previous().IsUserCall() {
599		panic("col: EOA only")
600	}
601	return cur.Previous().Address()
602}
603
604func itemTid(id int) grc721.TokenID {
605	return grc721.TokenID(strconv.Itoa(id))
606}
607
608func mustItem(id int) *item {
609	v := items.Get(itemKey(id))
610	if v == nil {
611		panic("col: unknown item")
612	}
613	return v.(*item)
614}
615
616func itemKey(id int) string {
617	return strconv.Itoa(id)
618}
619
620func lastElem(pkg string) string {
621	i := strings.LastIndex(pkg, "/")
622	if i < 0 || i+1 >= len(pkg) {
623		panic("col: pkg")
624	}
625	return pkg[i+1:]
626}
627
628func slotKey(i int) string {
629	return strconv.Itoa(i)
630}
631
632func slotCap() int {
633	if maxSupply > 0 {
634		return maxSupply
635	}
636	return dropSlotHardCap
637}
638
639func applySlot(id int, s *dropSlot, slotIndex int) {
640	it := mustItem(id)
641	it.name = s.name
642	it.image = s.image
643	if s.rarity != "" {
644		it.rarity = s.rarity
645	}
646	it.traits = s.traits
647	it.revealed = true
648	it.slot = slotIndex
649	items.Set(itemKey(id), it)
650}
651
652func writeTokenMeta(id int) {
653	it := mustItem(id)
654	uri := tokenJSON(it)
655	if metaLed == nil {
656		return
657	}
658	if err := metaLed.SetTokenURI(itemTid(id), uri); err != nil {
659		panic("col: tokenURI")
660	}
661	if err := metaLed.SetTokenMetadata(itemTid(id), metadata.Data{
662		Name:        it.name,
663		Description: colName + " #" + strconv.Itoa(id),
664		Image:       absImage(it.image),
665		Attributes:  traitAttrs(it),
666	}); err != nil {
667		panic("col: metadata")
668	}
669}
670
671func tokenJSON(it *item) string {
672	attrs := ""
673	for _, tr := range traitAttrs(it) {
674		if attrs != "" {
675			attrs += ","
676		}
677		attrs += `{"trait_type":` + jsonStr(tr.TraitType) + `,"value":` + jsonStr(tr.Value) + `}`
678	}
679	body := `{"name":` + jsonStr(it.name) + `,"description":` + jsonStr(colName+" #"+strconv.Itoa(it.id)) + `,"image":` + jsonStr(absImage(it.image)) + `,"attributes":[` + attrs + `]}`
680	return "data:application/json;charset=utf-8," + pctURI(body)
681}
682
683func traitAttrs(it *item) []metadata.Trait {
684	out := []metadata.Trait{}
685	if it.rarity != "" {
686		out = append(out, metadata.Trait{TraitType: "Rarity", Value: it.rarity})
687	}
688	for _, part := range strings.Split(it.traits, ";") {
689		part = strings.TrimSpace(part)
690		colon := strings.Index(part, ":")
691		if colon <= 0 || colon >= len(part)-1 {
692			continue
693		}
694		key := strings.TrimSpace(part[:colon])
695		val := strings.TrimSpace(part[colon+1:])
696		if key == "" || val == "" {
697			continue
698		}
699		out = append(out, metadata.Trait{TraitType: key, Value: val})
700	}
701	return out
702}
703
704func absImage(u string) string {
705	if u == "" {
706		return u
707	}
708	if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "ipfs://") {
709		return u
710	}
711	if strings.HasPrefix(u, "/") {
712		return publicOrigin + u
713	}
714	return u
715}
716
717func jsonStr(s string) string {
718	out := `"`
719	for _, r := range s {
720		switch r {
721		case '"':
722			out += `\"`
723		case '\\':
724			out += `\\`
725		case '\n':
726			out += `\n`
727		case '\r':
728			out += `\r`
729		case '\t':
730			out += `\t`
731		default:
732			if r < 0x20 {
733				out += `\u00`
734				hex := "0123456789abcdef"
735				out += string([]byte{hex[r>>4], hex[r&15]})
736			} else {
737				out += string(r)
738			}
739		}
740	}
741	return out + `"`
742}
743
744func pctURI(s string) string {
745	out := ""
746	for i := 0; i < len(s); i++ {
747		c := s[i]
748		if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' {
749			out += string(c)
750			continue
751		}
752		hex := "0123456789ABCDEF"
753		out += "%"
754		out += string([]byte{hex[c>>4], hex[c&15]})
755	}
756	return out
757}
758
759func splitDropLines(blob string) []string {
760	raw := strings.Split(blob, "\n")
761	out := make([]string, 0, len(raw))
762	for _, line := range raw {
763		line = strings.TrimSpace(line)
764		if line == "" {
765			continue
766		}
767		out = append(out, line)
768	}
769	return out
770}
771
772func parseDropLine(line string) (string, string, string, string) {
773	parts := strings.Split(line, "|")
774	name := ""
775	image := ""
776	rarity := ""
777	traits := ""
778	if len(parts) > 0 {
779		name = strings.TrimSpace(parts[0])
780	}
781	if len(parts) > 1 {
782		image = strings.TrimSpace(parts[1])
783	}
784	if len(parts) > 2 {
785		rarity = strings.TrimSpace(parts[2])
786	}
787	if len(parts) > 3 {
788		traits = strings.TrimSpace(parts[3])
789	}
790	return name, image, rarity, traits
791}
792
793func validRarity(s string) bool {
794	if s == "" {
795		return true
796	}
797	return s == "Common" || s == "Uncommon" || s == "Rare" || s == "Epic" || s == "Legendary"
798}
799
800func validTraits(s string) bool {
801	if s == "" {
802		return true
803	}
804	if len(s) > 200 {
805		return false
806	}
807	if strings.ContainsAny(s, "\n\r|") {
808		return false
809	}
810	n := 0
811	for _, part := range strings.Split(s, ";") {
812		part = strings.TrimSpace(part)
813		if part == "" {
814			continue
815		}
816		colon := strings.Index(part, ":")
817		if colon <= 0 || colon >= len(part)-1 {
818			return false
819		}
820		key := strings.TrimSpace(part[:colon])
821		val := strings.TrimSpace(part[colon+1:])
822		if key == "" || val == "" {
823			return false
824		}
825		if !asciiSet(key, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-") {
826			return false
827		}
828		if !asciiSet(val, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-") {
829			return false
830		}
831		n++
832		if n > 8 {
833			return false
834		}
835	}
836	return n >= 1
837}
838
839func validPkgName(s string) bool {
840	if len(s) < minSlugLen || len(s) > grc721.MaxSymbolLen {
841		return false
842	}
843	if s[0] < 'a' || s[0] > 'z' {
844		return false
845	}
846	return asciiSet(s, "abcdefghijklmnopqrstuvwxyz0123456789")
847}
848
849func rarityOf(id int) string {
850	r := id % 10
851	if r == 0 {
852		return "Legendary"
853	}
854	if r == 1 {
855		return "Common"
856	}
857	if r == 2 {
858		return "Uncommon"
859	}
860	if r == 3 {
861		return "Rare"
862	}
863	if r == 4 {
864		return "Epic"
865	}
866	if r <= 7 {
867		return "Uncommon"
868	}
869	return "Common"
870}
871
872func validName(name string) bool {
873	if len(name) < 1 || len(name) > maxNameLen {
874		return false
875	}
876	if strings.ContainsAny(name, "\n\r|\t\"\\") {
877		return false
878	}
879	for _, r := range name {
880		if r < 0x20 {
881			return false
882		}
883	}
884	return true
885}
886
887func validImage(u string) bool {
888	if u == "" {
889		return true
890	}
891	if len(u) > maxImageLen {
892		return false
893	}
894	if strings.ContainsAny(u, " \t\n\r|<>\"'`") {
895		return false
896	}
897	if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") {
898		return true
899	}
900	const prefix = "/samples/"
901	if !strings.HasPrefix(u, prefix) {
902		return false
903	}
904	rest := u[len(prefix):]
905	if rest == "" {
906		return false
907	}
908	return asciiSet(rest, "abcdefghijklmnopqrstuvwxyz0123456789._-")
909}
910
911func asciiSet(s, allowed string) bool {
912	for i := 0; i < len(s); i++ {
913		if !strings.ContainsRune(allowed, rune(s[i])) {
914			return false
915		}
916	}
917	return true
918}