render.gno
9.67 Kb · 279 lines
1// Package nsview is the human-facing view — the third realm in the
2// split, and the newest (2026-08-21).
3//
4// It exists because an adversarial review found three defects in the old
5// Render(), which lived inside the permanent `nsdata` vault: unescaped
6// profile text injected into the page markdown, an O(domains × names)
7// home page any user could inflate into a permanent denial of service,
8// and navigation links that 404'd. All three were display bugs, and none
9// of them could ever have been fixed, because that realm is deployed
10// exactly once. The NFT artwork moved here for the same reason.
11//
12// Display code is swappable now. This realm holds no state, reads
13// everything from nsdata's exported API, and can be redeployed and
14// re-pointed at any time with nothing to migrate.
15package nsview
16
17import (
18 "strings"
19 "time"
20
21 "gno.land/p/nt/ufmt/v0"
22 "gno.land/r/nym-mnsrehearsal001/nsdata/v2"
23 "gno.land/r/nym-mnsrehearsal001/nsskin/v2"
24)
25
26// Profile keys inside a name's extension slot. Conventions owned by this
27// realm and the logic realm, not by the vault — the vault stores strings
28// and has no opinion about what they mean.
29const (
30 keyDisplayName = "displayName"
31 keyBio = "bio"
32 keyWebsite = "website"
33)
34
35func Render(path string) string {
36 switch {
37 case path == "":
38 return renderHome()
39 case strings.HasPrefix(path, "token/"):
40 return "```\n" + BuildSVG(strings.TrimPrefix(path, "token/")) + "\n```\n"
41 /* nsskin.TokenURI hands out "/r/.../nsview:skin/<id>#<n>" and this
42 case did not exist, so every skin token resolved to the literal
43 string "404: unknown name" — splitTokenID found no "*", the whole
44 path became a label, and renderName 404'd. All 226 lines of
45 skincard.gno, including the matted frame and the SKIN label its
46 own header calls the mitigation for "the single most likely way
47 this product hurts somebody", never ran for anybody. */
48 case strings.HasPrefix(path, "skin/"):
49 return "```\n" + renderSkin(strings.TrimPrefix(path, "skin/")) + "\n```\n"
50 default:
51 // A domain page carries its cursor as "?after=<key>". Split it
52 // off before the token id is parsed, or the cursor lands in the
53 // label and every paged link 404s.
54 route, after := path, ""
55 if q := strings.Index(path, "?after="); q >= 0 {
56 route, after = path[:q], path[q+len("?after="):]
57 }
58 label, domainLabel := splitTokenID(route)
59 if label == "" {
60 return renderDomain(domainLabel, after)
61 }
62 return renderName(label, domainLabel)
63 }
64}
65
66func renderHome() string {
67 out := "# Meme Name Service\n\n"
68 out += ufmt.Sprintf("%d domains registered.\n\n", nsdata.CountDomains())
69 for _, label := range splitCSV(nsdata.ListDomains()) {
70 owner, _, _, _, _ := nsdata.GetDomainInfo(label)
71 // The link target must carry the "*" prefix — Render dispatches a
72 // bare label to renderName, which 404s. That was the old home
73 // page's bug: every link on it was dead.
74 /* NO COUNT HERE. This called nsdata.CountNames per domain, and
75 CountNames iterates that domain's whole key span — so the home
76 page was O(every name in the registry) on every single view,
77 and anyone could inflate it by registering names. That is the
78 exact denial of service this package's own doc comment says
79 forced the renderer out of the vault; moving it here made it
80 repairable and nobody repaired it. The count lives on the
81 domain's own page, which walks one domain and pages. */
82 out += ufmt.Sprintf("- [*%s](*%s) — owner `%s`\n",
83 label, label, owner.String())
84 }
85 return out
86}
87
88func renderDomain(domainLabel, after string) string {
89 if !nsdata.DomainExists(domainLabel) {
90 return "404: unknown domain\n"
91 }
92 owner, registered, expires, streak, frozen := nsdata.GetDomainInfo(domainLabel)
93 status, _ := statusFields(expires, frozen)
94 daysLine := remaining(expires, time.Now().Unix())
95
96 out := ufmt.Sprintf("# *%s\n\n", domainLabel)
97 out += ufmt.Sprintf("- owner: `%s`\n", owner.String())
98 out += ufmt.Sprintf("- status: **%s** (%s)\n", status, daysLine)
99 if frozen {
100 out += "- **frozen**\n"
101 }
102 out += ufmt.Sprintf("- registered: %s\n", time.Unix(registered, 0).UTC().Format("2006-01-02"))
103 out += ufmt.Sprintf("- renewal streak: %d year(s)\n\n", streak)
104
105 /* PAGED. This listed every name under the domain in one pass and
106 built a markdown line for each, so a domain with enough names made
107 its own page too expensive to render — permanently, for everyone,
108 until this realm was redeployed. nsdata.ExportNames already pages;
109 nsmarket already uses the same cursor idea for listings. */
110 out += renderDomainNames(domainLabel, after)
111 return out
112}
113
114// namePage is how many names one domain page shows. Small enough that
115// the work is bounded whatever the registry does, large enough that an
116// ordinary domain is one page.
117const namePage = 100
118
119func renderDomainNames(domainLabel, after string) string {
120 keys, next := nsdata.ExportNames(domainLabel, after, namePage)
121 labels := splitCSV(keys)
122 if len(labels) == 0 {
123 if after == "" {
124 return "## names under this domain\n\n_none yet_\n"
125 }
126 return "## names under this domain\n\n_no more_\n"
127 }
128
129 out := "## names under this domain\n\n"
130 // ExportNames returns full tree keys ("domain*label"); the page wants
131 // the label, which is everything after the separator.
132 cut := len(domainLabel) + 1
133 for _, k := range labels {
134 label := k
135 if len(k) > cut {
136 label = k[cut:]
137 }
138 out += ufmt.Sprintf("- [%s*%s](%s*%s)\n", label, domainLabel, label, domainLabel)
139 }
140 if next != "" {
141 out += ufmt.Sprintf("\n[more](*%s?after=%s)\n", domainLabel, next)
142 }
143 return out
144}
145
146/*
147renderSkin draws a skin DEFINITION's own card, matted and labelled, from
148a token id of the form "<definition>#<serial>".
149
150Deliberately not the name card: a skin for sale and a name for sale must
151not look alike, which is what skincard.gno's frame is for.
152*/
153func renderSkin(tid string) string {
154 id := tid
155 if h := strings.Index(tid, "#"); h > 0 {
156 id = tid[:h]
157 }
158 if !nsskin.SkinExists(id) {
159 return "404: unknown skin"
160 }
161 name, art, lock, _, _, _ := nsskin.SkinInfo(id)
162 return SkinCardSVG(name, art, lock, !inkForArt(art))
163}
164
165func renderName(label, domainLabel string) string {
166 if !nsdata.NameExists(label, domainLabel) {
167 return "404: unknown name\n"
168 }
169 owner, registered, expires, streak, frozen, paid := nsdata.GetNameInfo(label, domainLabel)
170 status, _ := statusFields(expires, frozen)
171 daysLine := remaining(expires, time.Now().Unix())
172
173 out := ufmt.Sprintf("# %s*%s\n\n", label, domainLabel)
174 out += ufmt.Sprintf("- owner: `%s`\n", owner.String())
175 out += ufmt.Sprintf("- status: **%s** (%s)\n", status, daysLine)
176 if frozen {
177 out += "- **frozen**\n"
178 }
179 if !paid {
180 out += "- free tier (not sellable until upgraded)\n"
181 }
182 out += ufmt.Sprintf("- registered: %s\n", time.Unix(registered, 0).UTC().Format("2006-01-02"))
183 out += ufmt.Sprintf("- renewal streak: %d year(s)\n\n", streak)
184 out += ufmt.Sprintf("- [*%s](*%s)\n\n", domainLabel, domainLabel)
185
186 displayName := nsdata.GetNameExtra(label, domainLabel, keyDisplayName)
187 bio := nsdata.GetNameExtra(label, domainLabel, keyBio)
188 website := nsdata.GetNameExtra(label, domainLabel, keyWebsite)
189
190 if displayName != "" || bio != "" || website != "" {
191 out += "## Profile\n\n"
192 // Every one of these is attacker-authored. Escaping them is the
193 // whole reason this realm exists separately.
194 if displayName != "" {
195 out += ufmt.Sprintf("**%s**\n\n", escapeMarkdown(displayName))
196 }
197 if bio != "" {
198 // A blockquote so even a crafted multi-line bio cannot break
199 // out into block-level constructs.
200 out += "> " + escapeMarkdown(bio) + "\n\n"
201 }
202 if website != "" {
203 if isSafeURL(website) {
204 out += ufmt.Sprintf("[%s](%s)\n\n", escapeMarkdown(website), website)
205 } else {
206 out += ufmt.Sprintf("website (unverified): `%s`\n\n", escapeMarkdown(website))
207 }
208 }
209 }
210 return out
211}
212
213// -- helpers --
214
215func splitCSV(s string) []string {
216 if s == "" {
217 return nil
218 }
219 return strings.Split(s, ",")
220}
221
222// splitTokenID splits `label*domain`; a leading "*" yields an empty
223// label, which is how a domain page is addressed.
224func splitTokenID(tid string) (label, domain string) {
225 for i := 0; i < len(tid); i++ {
226 if tid[i] == '*' {
227 return tid[:i], tid[i+1:]
228 }
229 }
230 return tid, ""
231}
232
233// escapeMarkdown neutralises user-authored text so it renders as the
234// literal characters typed rather than as markup. Backslash-escapes the
235// CommonMark ASCII punctuation set and folds newlines, which stops
236// headings, lists, tables, images, links, and raw HTML alike.
237func escapeMarkdown(s string) string {
238 s = strings.ReplaceAll(s, "\r\n", " ")
239 s = strings.ReplaceAll(s, "\n", " ")
240 s = strings.ReplaceAll(s, "\r", " ")
241 var b strings.Builder
242 for i := 0; i < len(s); i++ {
243 c := s[i]
244 if strings.IndexByte("\\`*_{}[]()#+-.!|<>&~\"'", c) >= 0 {
245 b.WriteByte('\\')
246 }
247 b.WriteByte(c)
248 }
249 return b.String()
250}
251
252// isSafeURL only admits plain http/https. Everything else — javascript:,
253// data:, anything unrecognised — is refused a live link. Checked on read
254// rather than on write so tightening the rule later also applies to
255// profiles that already exist.
256func isSafeURL(u string) bool {
257 if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
258 return false
259 }
260 if len(u) > 256 {
261 return false
262 }
263 // This value is emitted RAW as the markdown link destination, so it is
264 // the one attacker-authored string on the page that escapeMarkdown
265 // does not neutralise. Allow-list instead of deny-list: every byte
266 // must be printable, non-space ASCII. A deny-list missed newlines,
267 // which let a crafted `website` terminate the link and inject
268 // arbitrary markdown into the page below it.
269 for i := 0; i < len(u); i++ {
270 c := u[i]
271 if c <= 0x20 || c >= 0x7f {
272 return false
273 }
274 if strings.IndexByte("()<>\"'\\`", c) >= 0 {
275 return false
276 }
277 }
278 return true
279}