Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}