gallery.gno
7.30 Kb · 208 lines
1// Package gallery is the platform's gnoweb face (design §2.5.7): the
2// product stays browsable on gno.land even without the website. It is
3// private (re-deployable in place), read-only and holds nothing.
4//
5// Routes:
6//
7// "" latest collections
8// c/<cid>[?page=N] one collection, 20 tokens per page
9// c/<cid>/t/<tid> one token: art, traits, owner, listing, provenance
10// e/<engine pkgpath> an engine's manifest and status
11// u/<name> a registered user's collections and listings
12//
13// Every user-supplied string is rendered as plain text (plainText: RA M-9,
14// SA-M4): no heading, link, image, HTML or autolink can come out of a name,
15// description, trait or manifest field. Every list is paginated.
16package gallery
17
18import (
19 "strconv"
20 "strings"
21
22 "gno.land/p/nt/markdown/sanitize/v0"
23 "gno.land/p/nt/ufmt/v0"
24 "gno.land/r/sys/users"
25
26 "gno.land/p/g1pq6pxhgxk2uw5shg5le6glfydpxcu74k0zh069/sinopia/types/v1"
27 "gno.land/r/g1pq6pxhgxk2uw5shg5le6glfydpxcu74k0zh069/sinopia/core"
28 market "gno.land/r/g1pq6pxhgxk2uw5shg5le6glfydpxcu74k0zh069/sinopia/market/v1"
29)
30
31const (
32 pageSize = 20
33 homeLimit = 20
34 maxPageNum = 500
35)
36
37func Render(path string) string {
38 route, query, _ := strings.Cut(path, "?")
39 parts := strings.Split(route, "/")
40 switch {
41 case route == "":
42 return home()
43 case len(parts) == 2 && parts[0] == "c":
44 return collectionPage(parts[1], pageFrom(query))
45 case len(parts) == 4 && parts[0] == "c" && parts[2] == "t":
46 return tokenPage(parts[1], parts[3])
47 case len(parts) > 1 && parts[0] == "e":
48 return enginePage(strings.TrimPrefix(route, "e/"))
49 case len(parts) == 2 && parts[0] == "u":
50 return userPage(parts[1])
51 }
52 return "# Not found\n\n[Back to the gallery](" + base() + ")\n"
53}
54
55func home() string {
56 n := core.CollectionCount()
57 out := ufmt.Sprintf("# Gallery\n\n%d collections. Art is computed on chain from each collection's engine and revealed seed.\n\n", n)
58 for id := n; id >= 1 && id > n-homeLimit; id-- {
59 info := core.Info(itoa(id))
60 out += ufmt.Sprintf("- [%s](%s) — %s, %d/%d minted\n",
61 plainText(info.Name), link("c/"+info.ID), info.State.String(), info.Minted, info.MaxSupply)
62 }
63 return out
64}
65
66func collectionPage(cid string, page int64) string {
67 info := core.Info(cid)
68 out := "# " + plainText(info.Name) + " (" + plainText(info.Symbol) + ")\n\n"
69 if info.Description != "" {
70 out += plainText(info.Description) + "\n\n"
71 }
72 out += ufmt.Sprintf("- State: **%s**, %d/%d minted, royalty %d bps\n", info.State.String(), info.Minted, info.MaxSupply, info.RoyaltyBps)
73 out += "- Creator: " + who(info.Creator) + "\n"
74 out += "- Engine: [" + plainText(info.EngineID) + "](" + link("e/"+info.EngineID) + ")\n"
75 if info.Badge != "" {
76 out += "- Provenance: **" + info.Badge + "** — " + fairness(cid) + "\n"
77 }
78 if f := market.Floor(cid); f > 0 {
79 out += ufmt.Sprintf("- Floor: %d ugnot\n", f)
80 }
81 out += "\n## Tokens\n\n"
82 first := (page-1)*pageSize + 1
83 for n := first; n < first+pageSize && n <= info.Minted; n++ {
84 tid := itoa(n)
85 owner, err := core.OwnerOf(cid, tid)
86 if err != nil {
87 continue
88 }
89 line := "- [#" + tid + "](" + link("c/"+cid+"/t/"+tid) + ") owned by " + who(owner)
90 if _, price, ok := market.ActiveListing(cid, tid); ok {
91 line += ufmt.Sprintf(", listed at %d ugnot", price)
92 }
93 out += line + "\n"
94 }
95 if first+pageSize <= info.Minted {
96 out += "\n[Next page](" + link(ufmt.Sprintf("c/%s?page=%d", cid, page+1)) + ")\n"
97 }
98 return out
99}
100
101func tokenPage(cid, tid string) string {
102 info := core.Info(cid)
103 owner, err := core.OwnerOf(cid, tid)
104 if err != nil {
105 return "# Token not found\n"
106 }
107 uri, _ := core.TokenURI(cid, tid)
108 out := "# " + plainText(info.Name) + " #" + tid + "\n\n"
109 out += " + ")\n\n"
110 out += "- Owner: " + who(owner) + "\n"
111 if id, price, ok := market.ActiveListing(cid, tid); ok {
112 out += ufmt.Sprintf("- Listed: %d ugnot (listing %s)\n", price, id)
113 }
114 out += "\n## Traits\n\n"
115 for _, t := range core.TokenTraitList(cid, tid) {
116 out += "- " + plainText(t.Type) + ": " + plainText(t.Value) + "\n"
117 }
118 out += "\n## How to verify\n\n"
119 if info.State != types.StateFinal {
120 out += "The collection is not revealed yet; every token shows the same placeholder.\n"
121 return out
122 }
123 out += "1. Read `Provenance(\"" + cid + "\")` on core from another RPC and recompute the commitments and the salt.\n"
124 out += "2. Seed: `TokenSeed(\"" + cid + "\", \"" + tid + "\")` = sha256(\"" + "art|seed|v1|" + cid + "|" + tid + "|\" + salt).\n"
125 out += "3. Render it yourself: `RenderWith(engine, params, seed)` must return this image.\n\n"
126 out += "Provenance badge **" + info.Badge + "**, on-chain check: " + strconv.FormatBool(core.VerifyProvenance(cid)) +
127 "; " + fairness(cid) + "\n"
128 return out
129}
130
131// fairness never calls a collection "verified" unless it is a fair launch
132// (core.FairLaunch): a consistent record is not enough when one party could
133// choose the map (SA-M2).
134func fairness(cid string) string {
135 if core.FairLaunch(cid) {
136 return "fair launch, verified on chain"
137 }
138 if !core.VerifyProvenance(cid) {
139 return "NOT verified: the provenance record does not check out"
140 }
141 return "not a fair launch: one party could choose the map (record consistent)"
142}
143
144// plainText renders user text as inert markdown: sanitize.InlineText escapes
145// every inline-active character (\ * _ [ ] ( ) ~ > - + . ! ` # < &) and folds
146// newlines, and escaping ':' breaks bare-URL autolinks ("https\://…"), which
147// InlineText leaves alone (SA-M4).
148func plainText(s string) string {
149 return strings.ReplaceAll(sanitize.InlineText(s), ":", "\\:")
150}
151
152func enginePage(id string) string {
153 m := core.EngineManifest(id)
154 out := "# Engine " + plainText(m.Name) + " " + plainText(m.Version) + "\n\n"
155 out += "- Realm: " + sanitize.InlineCode(id) + "\n"
156 out += "- Author: " + who(m.Author) + "\n"
157 out += "- Status: **" + core.EngineStatus(id) + "**, sample fingerprint intact: " + strconv.FormatBool(core.VerifyEngine(id)) + "\n"
158 out += "- License: " + plainText(m.License) + "\n"
159 out += "- Traits: " + plainText(strings.Join(m.TraitTypes, ", ")) + "\n\n"
160 if m.Description != "" {
161 out += plainText(m.Description) + "\n"
162 }
163 return out
164}
165
166func userPage(name string) string {
167 u, _ := users.ResolveName(name)
168 if u == nil {
169 return "# Unknown user\n"
170 }
171 addr := u.Addr()
172 return "# @" + sanitize.InlineText(u.Name()) + "\n\n`" + addr.String() + "`\n\n" +
173 "Collections and listings of this address: reader realm, `Profile(" + addr.String() + ")`.\n"
174}
175
176// who renders an address as its r/sys/users name when it has one.
177func who(addr address) string {
178 if u := users.ResolveAddress(addr); u != nil {
179 return "[@" + sanitize.InlineText(u.Name()) + "](/u/" + sanitize.InlineText(u.Name()) + ")"
180 }
181 return "`" + addr.String() + "`"
182}
183
184func pageFrom(query string) int64 {
185 for _, kv := range strings.Split(query, "&") {
186 if strings.HasPrefix(kv, "page=") {
187 if n, err := strconv.ParseInt(strings.TrimPrefix(kv, "page="), 10, 64); err == nil && n >= 1 && n <= maxPageNum {
188 return n
189 }
190 }
191 }
192 return 1
193}
194
195// selfPath is captured at deploy; links are gnoweb paths without the domain.
196var selfPath string
197
198func init(cur realm) {
199 selfPath = cur.PkgPath()
200}
201
202func base() string {
203 return strings.TrimPrefix(selfPath, "gno.land")
204}
205
206func link(route string) string { return base() + ":" + route }
207
208func itoa(n int64) string { return strconv.FormatInt(n, 10) }