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

render.gno

23.72 Kb · 618 lines
  1package zones
  2
  3import (
  4	"chain/runtime"
  5	"strconv"
  6	"strings"
  7
  8	"gno.land/p/moul/kit/ui/v0"
  9	"gno.land/p/moul/md/v0"
 10	"gno.land/p/moul/realmpath/v0"
 11	"gno.land/p/moul/zones/v0"
 12)
 13
 14// realmURL is this realm as gnoweb serves it. Absolute, because the path ends in
 15// /v0 and a relative link would resolve against the parent directory. Not
 16// derived at runtime: a Render has no cur, and CurrentRealm there is the caller.
 17// TestRealmURLMatchesTheModule pins it to the module line.
 18const realmURL = "/r/moul/zones/v0"
 19
 20// pkgPath is this realm's package path, for the action links.
 21const pkgPath = "gno.land" + realmURL
 22
 23// action is a call link to a function of THIS realm, named explicitly.
 24//
 25// Not ui.Action: that resolves its target through unsafe.CurrentRealm, which in
 26// a borrowed Render is the caller, so a realm embedding this page would get
 27// links that call its own functions. filetests/z_borrowed_render_filetest.gno
 28// pins it from a caller realm, the only place the difference shows.
 29func action(title, fn string, args ...string) string {
 30	return ui.ActionIn(pkgPath, title, fn, args...)
 31}
 32
 33// PageSize is how many rows any table here shows at once. Every list is
 34// paginated and every page reads only its own records, because vm/qrender is
 35// gas-metered (3B per query) and a Render that outgrows it stops answering
 36// instead of slowing down. At the caps a page reads its 25 records, one lookup
 37// per URL it may mark flagged (each row's main RPC on the index; the zone's
 38// RPC and gnoweb on its page), the zone serving this chain for the printed
 39// command, the curators, and a few index nodes. Escaping free text is
 40// what dominates: about 97k gas a character on a node, so a page of full-length
 41// fields costs on the order of a billion, still under the ceiling.
 42const PageSize = 25
 43
 44// Render is the whole public surface, three pages: the zone list (official,
 45// or retired with ?status=retired), one zone (?kind= narrows its endpoints),
 46// and the proposals (pending, or rejected with ?status=rejected). Every list
 47// takes ?page=.
 48func Render(path string) string {
 49	req := realmpath.Parse(path)
 50	// Exact paths only: a page that also answers zone/x/approved-by-gno-core
 51	// lends its content to whatever the extra segment claims.
 52	parts := len(req.PathParts())
 53	switch req.PathPart(0) {
 54	case "":
 55		if parts <= 1 {
 56			return renderIndex(req)
 57		}
 58	case "zone":
 59		if parts == 2 {
 60			return renderZone(req.PathPart(1), req)
 61		}
 62	case "proposals":
 63		if parts == 1 {
 64			return renderProposals(req)
 65		}
 66	}
 67	return notFound("No such page.")
 68}
 69
 70func renderIndex(req *realmpath.Request) string {
 71	status, heading := zones.Approved, "Official"
 72	switch req.Query.Get("status") {
 73	case "", "approved":
 74	case "retired":
 75		status, heading = zones.Retired, "Retired"
 76	default:
 77		return notFound("No such list.")
 78	}
 79	query := ""
 80	if status == zones.Retired {
 81		query = "status=retired&"
 82	}
 83	table, pages := zoneTable(status, req, "")
 84
 85	links := []string{}
 86	if status == zones.Retired {
 87		links = append(links, md.Link("official zones", realmURL))
 88	} else if n := reg.ZoneCount(zones.Retired); n > 0 {
 89		links = append(links, md.Link(strconv.Itoa(n)+" retired zone(s)", realmURL+":?status=retired"))
 90	}
 91	links = append(links,
 92		md.Link(strconv.Itoa(reg.ZoneCount(zones.Pending))+" proposal(s) waiting for review", realmURL+":proposals"))
 93	// Offered only while it can succeed: a link certain to fail is a dead end,
 94	// and the note says which cap is full (not what would free it: that
 95	// depends on who acts, and what frees one cap can fill another). A
 96	// full review queue still takes a curator's proposal, so the link stays,
 97	// labelled for them.
 98	propose := func(title string) string {
 99		return action(title, "ProposeZone",
100			"slug", "", "chainID", "", "title", "", "description", "", "kind", "testnet",
101			"gnowebURL", "", "rpcURL", "", "genesisURL", "")
102	}
103	switch {
104	case reg.Live() >= zones.MaxZones:
105		links = append(links, "the registry is full at "+strconv.Itoa(zones.MaxZones)+" live zones")
106	case reg.Live() >= zones.MaxZones-zones.ReservedForReviewers:
107		links = append(links, "the registry's last "+strconv.Itoa(zones.ReservedForReviewers)+" places are kept for curators",
108			propose("Propose a zone (curators only)"))
109	case reg.ZoneCount(zones.Pending) >= zones.MaxPending:
110		links = append(links, "the review queue is full at "+strconv.Itoa(zones.MaxPending)+" proposals",
111			propose("Propose a zone (curators only, while full)"))
112	default:
113		links = append(links, propose("Propose a zone"))
114	}
115
116	return ui.Join("\n",
117		md.H1("Zones"),
118		para("A curated registry of gno.land networks and the endpoints that reach them. "+
119			"Anybody can propose a zone, and register endpoints on an official one; a curator approves, "+
120			"rejects or retires a zone and verifies or flags its endpoints, and every rejection, "+
121			"retirement and flag says why, in public."),
122		md.H2(heading),
123		table,
124		pager("", query, pageNum(req, pages), pages),
125		para(strings.Join(links, " · ")),
126		md.H2("Read it from a node"),
127		para("Every list is a plain function, so a node operator or a script can ask for it directly. "+
128			"An empty kind or status matches anything."),
129		md.CodeBlock(queryCommand(`ListAddresses("`+exampleSlug()+`", "peer", "verified")`)),
130		md.BulletList([]string{
131			md.InlineCode(`ListZones(status, kind)`) + ": `approved` is the official list",
132			md.InlineCode(`GetZone(slug)`),
133			md.InlineCode(`ListEndpoints(slug, kind, status)`) + ": kind is rpc, gnoweb, seed, peer, indexer, faucet or explorer",
134			md.InlineCode(`ListAddresses(slug, kind, status)`) + ": the same, reduced to what a config file wants",
135		}),
136		para("Curated by "+curatorList()+"."),
137	)
138}
139
140// zoneTable renders one page of the zones with that status, and returns how
141// many pages there are.
142func zoneTable(status zones.Status, req *realmpath.Request, empty string) (string, int) {
143	pages := pageCount(reg.ZoneCount(status))
144	var t *ui.Table
145	switch status {
146	case zones.Approved:
147		t = ui.NewTable("zone", "chain id", "kind", "rpc", "endpoints")
148	case zones.Pending:
149		t = ui.NewTable("zone", "chain id", "kind", "proposed by", "review")
150	case zones.Rejected:
151		t = ui.NewTable("zone", "chain id", "proposed by", "why", "")
152	default:
153		t = ui.NewTable("zone", "chain id", "proposed by", "why")
154	}
155	for _, z := range reg.ZonePage(status, pageNum(req, pages), PageSize) {
156		switch status {
157		case zones.Approved:
158			verified, total := reg.Count(z.Slug)
159			t.Row(zoneLink(z), md.InlineCode(z.ChainID), string(z.Kind), md.InlineCode(z.RPCURL)+rpcFlag(z),
160				strconv.Itoa(verified)+" verified of "+strconv.Itoa(total))
161		case zones.Pending:
162			// Review on the zone page, which shows what is being approved; the
163			// Approve link there carries the revision it was rendered from.
164			t.Row(zoneLink(z), md.InlineCode(z.ChainID), string(z.Kind), ui.AddrFull(z.Proposer),
165				md.Link("review", realmURL+":zone/"+z.Slug))
166		case zones.Rejected:
167			t.Row(zoneLink(z), md.InlineCode(z.ChainID), ui.AddrFull(z.Proposer), cell(z.Reason),
168				action("Remove", "RemoveZone", "slug", z.Slug, "revision", revStr(z)))
169		default:
170			t.Row(zoneLink(z), md.InlineCode(z.ChainID), ui.AddrFull(z.Proposer), cell(z.Reason))
171		}
172	}
173	if empty == "" {
174		empty = "No " + string(status) + " zone yet."
175	}
176	return t.OrEmpty(empty), pages
177}
178
179func renderZone(slug string, req *realmpath.Request) string {
180	z, ok := reg.Zone(slug)
181	if !ok {
182		return notFound("No such zone.")
183	}
184	kind := zones.EndpointKind(req.Query.Get("kind"))
185	if kind != "" && !isKind(kind) {
186		return notFound("No such endpoint kind.")
187	}
188
189	facts := []string{
190		md.Bold("chain id") + ": " + md.InlineCode(z.ChainID),
191		md.Bold("kind") + ": " + string(z.Kind),
192		md.Bold("rpc") + ": " + md.InlineCode(z.RPCURL) + rpcFlag(z),
193	}
194	if z.GnowebURL != "" {
195		facts = append(facts, md.Bold("gnoweb")+": "+urlFact(z, zones.Gnoweb, z.GnowebURL))
196	}
197	if z.GenesisURL != "" {
198		facts = append(facts, md.Bold("genesis")+": "+urlFact(z, "", z.GenesisURL))
199	}
200	// Addresses in full, here and on every page: an 8+4 shortening is about 50
201	// bits, within reach of a vanity grinder who wants to look like a curator.
202	facts = append(facts, md.Bold("proposed")+" by "+ui.AddrFull(z.Proposer)+" at block "+strconv.FormatInt(z.ProposedAt, 10))
203	if z.EditedBy != "" {
204		facts = append(facts, md.Bold("edited")+" by "+ui.AddrFull(z.EditedBy)+" at block "+strconv.FormatInt(z.EditedAt, 10))
205	}
206	facts = append(facts, md.Bold("revision")+": "+strconv.FormatInt(z.Revision, 10))
207	if z.Reviewed() {
208		facts = append(facts, md.Bold("last reviewed")+" "+reviewText(z.ReviewedBy, z.ReviewedAt, z.Reason))
209	}
210
211	parts := []string{
212		md.H1(prose(z.Title)),
213		para(statusBadge(z.Status) + " · " + md.InlineCode(z.Slug)),
214	}
215	if z.Description != "" {
216		parts = append(parts, para(prose(z.Description)))
217	}
218	parts = append(parts, md.BulletList(facts), md.H2("Endpoints"))
219
220	// One link per kind the zone has, from the index counts: no endpoint is
221	// read to draw them.
222	base := realmURL + ":zone/" + z.Slug
223	kinds := []string{}
224	if all := reg.EndpointCount(z.Slug, ""); all > 0 {
225		kinds = append(kinds, kindLink("all", base, all, kind == ""))
226		for _, k := range zones.EndpointKinds() {
227			if n := reg.EndpointCount(z.Slug, k); n > 0 {
228				kinds = append(kinds, kindLink(string(k), base+"?kind="+string(k), n, kind == k))
229			}
230		}
231		parts = append(parts, para(strings.Join(kinds, " · ")))
232	}
233
234	pages := pageCount(reg.EndpointCount(z.Slug, kind))
235	// The revision column is what a verdict or a removal names; a verification
236	// also names the zone's revision, shown above.
237	t := ui.NewTable("#", "revision", "kind", "address", "label", "status", "registered by")
238	for _, e := range reg.EndpointPage(z.Slug, kind, pageNum(req, pages), PageSize) {
239		t.Row(
240			strconv.FormatInt(e.ID, 10),
241			strconv.FormatInt(e.Revision, 10),
242			string(e.Kind),
243			md.InlineCode(e.Address),
244			cell(e.Label),
245			endpointStatus(e),
246			ui.AddrFull(e.Registrant),
247		)
248	}
249	query := ""
250	if kind != "" {
251		query = "kind=" + string(kind) + "&"
252	}
253	empty := "No endpoint registered yet."
254	if kind != "" {
255		empty = "No " + string(kind) + " endpoint registered yet."
256	}
257	parts = append(parts, t.OrEmpty(empty), pager("zone/"+z.Slug, query, pageNum(req, pages), pages))
258
259	actions := []string{}
260	open := z.Status == zones.Approved || z.Status == zones.Pending
261	switch {
262	case open && reg.EndpointCount(z.Slug, "") >= zones.MaxEndpointsPerZone:
263		actions = append(actions, "endpoints are full at "+strconv.Itoa(zones.MaxEndpointsPerZone))
264	case open && reg.EndpointCount(z.Slug, "") >= zones.MaxEndpointsPerZone-zones.ReservedForReviewers:
265		actions = append(actions, "the last "+strconv.Itoa(zones.ReservedForReviewers)+" endpoint places are kept for curators",
266			action("Register an endpoint (curators only)", "RegisterEndpoint",
267				"slug", z.Slug, "kind", "rpc", "addr", "", "label", ""))
268	case open && reg.Awaiting(z.Slug) >= zones.MaxUnverifiedPerZone:
269		actions = append(actions, "the review queue is full at "+strconv.Itoa(zones.MaxUnverifiedPerZone)+" endpoints awaiting review",
270			action("Register an endpoint (curators only, while full)", "RegisterEndpoint",
271				"slug", z.Slug, "kind", "rpc", "addr", "", "label", ""))
272	case z.Status == zones.Approved:
273		actions = append(actions, action("Register an endpoint", "RegisterEndpoint",
274			"slug", z.Slug, "kind", "rpc", "addr", "", "label", ""))
275	case z.Status == zones.Pending && !curators.Has(z.Proposer) && reg.OwnerCount(z.Slug, z.Proposer) >= zones.MaxEndpointsPerAddress:
276		// On a pending zone the proposer is the one gated registrant.
277		actions = append(actions, "the proposer has registered "+strconv.Itoa(zones.MaxEndpointsPerAddress)+" endpoints here, the limit per address",
278			action("Register an endpoint (curators only)", "RegisterEndpoint",
279				"slug", z.Slug, "kind", "rpc", "addr", "", "label", ""))
280	case z.Status == zones.Pending:
281		actions = append(actions, action("Register an endpoint (proposer or curator)", "RegisterEndpoint",
282			"slug", z.Slug, "kind", "rpc", "addr", "", "label", ""))
283	}
284	// A flood clears in one call, on any zone the call takes (a rejected or
285	// retired zone's records are a curator's to remove too). The call reaches
286	// every page and every kind, so it is offered only on the unfiltered view,
287	// only while it would clear something (the clearable count, no endpoint
288	// read), labelled with its real scope, and bounded in time: an endpoint
289	// registered after this page was rendered has a later revision and is kept.
290	if n := reg.Clearable(z.Slug); kind == "" && n > 0 {
291		actions = append(actions, action("Clear "+strconv.Itoa(n)+" never-reviewed endpoint(s) registered through the review queue, all pages and kinds (curators only)", "ClearUnreviewed",
292			"slug", z.Slug, "throughRevision", strconv.FormatInt(reg.Revision(), 10)))
293	}
294	// Every decision carries the revision this page was rendered from, so acting
295	// on what you read here fails if the zone changed after you read it.
296	rev := revStr(z)
297	approve := action("Approve revision "+rev, "ApproveZone", "slug", z.Slug, "revision", rev, "reason", "")
298	remove := action("Remove", "RemoveZone", "slug", z.Slug, "revision", rev)
299	switch z.Status {
300	case zones.Pending:
301		actions = append(actions, approve,
302			action("Reject", "RejectZone", "slug", z.Slug, "revision", rev, "reason", ""), remove)
303		if reg.ZoneCount(zones.Rejected) >= zones.MaxRejected {
304			actions = append(actions, "rejecting it drops the zone rejected longest ago, endpoints and all")
305		}
306	case zones.Approved:
307		actions = append(actions, action("Retire", "RetireZone", "slug", z.Slug, "revision", rev, "reason", ""))
308		if reg.ZoneCount(zones.Retired) >= zones.MaxRetired {
309			actions = append(actions, "retiring it drops the zone retired longest ago, endpoints and all")
310		}
311	case zones.Rejected, zones.Retired:
312		// Back into the live registry only while it has room.
313		if reg.Live() < zones.MaxZones {
314			actions = append(actions, approve)
315		} else {
316			actions = append(actions, "approving it waits for a free place: the registry is full at "+strconv.Itoa(zones.MaxZones)+" live zones")
317		}
318		if z.Status == zones.Rejected {
319			actions = append(actions, remove)
320		}
321	}
322	parts = append(parts,
323		para(strings.Join(actions, " · ")),
324		md.CodeBlock(queryCommand(`GetZone("`+z.Slug+`")`)),
325		para(md.Link("All zones", realmURL)),
326	)
327	return ui.Join("\n", parts...)
328}
329
330func renderProposals(req *realmpath.Request) string {
331	status, heading, other := zones.Pending, "Pending", ""
332	switch req.Query.Get("status") {
333	case "", "pending":
334		if n := reg.ZoneCount(zones.Rejected); n > 0 {
335			other = md.Link(strconv.Itoa(n)+" rejected", realmURL+":proposals?status=rejected")
336		}
337	case "rejected":
338		status, heading = zones.Rejected, "Rejected"
339		other = md.Link("pending", realmURL+":proposals")
340	default:
341		return notFound("No such list.")
342	}
343	query := ""
344	if status == zones.Rejected {
345		query = "status=rejected&"
346	}
347	empty := "Nothing waiting for review."
348	if status == zones.Rejected {
349		empty = "Nothing rejected."
350	}
351	table, pages := zoneTable(status, req, empty)
352	links := []string{}
353	if other != "" {
354		links = append(links, other)
355	}
356	links = append(links, md.Link("All zones", realmURL))
357	return ui.Join("\n",
358		md.H1("Proposals"),
359		para("Zones somebody proposed and no curator has approved. Only an approved zone is official."),
360		md.H2(heading),
361		table,
362		pager("proposals", query, pageNum(req, pages), pages),
363		para(strings.Join(links, " · ")),
364	)
365}
366
367// pageCount is how many pages n rows make, at least one.
368func pageCount(n int) int {
369	pages := n / PageSize
370	if n%PageSize != 0 {
371		pages++
372	}
373	if pages < 1 {
374		pages = 1
375	}
376	return pages
377}
378
379// pageNum reads ?page= and clamps it into 1..pages: it is a reader's input,
380// and ?page=-1 must not render a footer saying "page -1 of 2".
381func pageNum(req *realmpath.Request, pages int) int {
382	page := 1
383	raw := req.Query.Get("page")
384	if n, err := strconv.Atoi(raw); err == nil {
385		page = n
386	} else if digits := strings.TrimPrefix(raw, "+"); digits != "" && strings.Trim(digits, "0123456789") == "" {
387		// All digits and still unparseable: a positive number past int. It is
388		// past the last page too, so it lands there, as any big number does.
389		page = pages
390	}
391	if page < 1 {
392		page = 1
393	}
394	if page > pages {
395		page = pages
396	}
397	return page
398}
399
400// pager links the neighbouring pages of a list, keeping the list's other query
401// parameters (query ends in "&" when not empty). One page needs no pager.
402func pager(path, query string, page, pages int) string {
403	if pages < 2 {
404		return ""
405	}
406	link := func(n int) string { return realmURL + ":" + path + "?" + query + "page=" + strconv.Itoa(n) }
407	out := ""
408	if page > 1 {
409		out += md.Link("previous", link(page-1)) + " · "
410	}
411	out += "page " + strconv.Itoa(page) + " of " + strconv.Itoa(pages)
412	if page < pages {
413		out += " · " + md.Link("next", link(page+1))
414	}
415	return para(out)
416}
417
418func kindLink(label, url string, n int, current bool) string {
419	text := label + " (" + strconv.Itoa(n) + ")"
420	if current {
421		return md.Bold(text)
422	}
423	return md.Link(text, url)
424}
425
426func notFound(msg string) string { return md.H1("Not found") + ui.Empty(msg) }
427
428// queryCommand is the gnokey line that evaluates call against this realm.
429//
430// The remote is looked up in the registry itself, by the chain id this realm is
431// running on, so the command a reader copies points at the chain they are
432// reading. A chain the registry does not list gets no -remote, rather than a
433// guessed one, and so does a chain id two approved zones share (every gnodev
434// is "dev"): picking one would be the same guess.
435//
436// A reader pastes this into a shell, so the remote must never carry shell
437// syntax. Two layers make sure: the main RPC is validated down to
438// <scheme>://<host>[:<port>], host [A-Za-z0-9.-] and port digits, which leaves
439// nothing a shell reads as syntax; and it is single-quoted anyway, a quote being
440// the one character no URL here can contain, so a later, wider validator does
441// not reopen it. The call is built from charset-checked slugs and literals.
442func queryCommand(call string) string {
443	remote := ""
444	if z, ok := reg.SoleApproved(runtime.ChainID()); ok && !rpcFlagged(z) {
445		// Not one the same page flags: printing a remote the endpoint table says
446		// not to use would contradict the page.
447		remote = " -remote '" + z.RPCURL + "'"
448	}
449	return "gnokey query vm/qeval" + remote + " -data '" + pkgPath + "." + call + "'"
450}
451
452// exampleSlug is the zone the index's example asks for peers of: the one
453// serving the chain this page is read on, when there is exactly one and it
454// lists a peer, else onyx (one zone and an index count read).
455func exampleSlug() string {
456	if z, ok := reg.SoleApproved(runtime.ChainID()); ok && reg.EndpointCount(z.Slug, zones.Peer) > 0 {
457		return z.Slug
458	}
459	return "onyx"
460}
461
462// para is one paragraph as a Join part. Not md.Paragraph, whose trailing blank
463// line plus Join's separator makes two in a row, which an example cannot pin.
464func para(s string) string { return s + "\n" }
465
466// zoneLink is a zone's title linking to its page, for a TABLE cell. Built by
467// hand rather than with md.Link, whose text escape is for prose and leaves a
468// pipe literal, so a title like "Alice|official" would open a column in every
469// zone table. ui.Cell escapes the pipe too; the destination is a constant plus
470// a slug checked to [a-z0-9-] at write time.
471func zoneLink(z zones.Zone) string {
472	return "[" + cell(z.Title) + "](" + realmURL + ":zone/" + z.Slug + ")"
473}
474
475// urlFact shows a zone's URL as its own text, never behind a label, so a
476// reader sees where it goes: a proposal's "genesis.json" link could otherwise
477// open anything, a pre-filled transaction form included, right under the
478// Approve button. Only an approved zone's URLs are links; a proposal's are
479// code, to copy and check, not to click.
480//
481// A URL the zone also lists, under the kind it is shown as, as a flagged
482// endpoint is code and marked, never a link: the page does not offer what its
483// own endpoint table says not to use. kind is what the URL is shown as, ""
484// for the genesis URL, which no endpoint kind lists (flaggedAs).
485func urlFact(z zones.Zone, kind zones.EndpointKind, url string) string {
486	if kind != "" && flaggedAs(z, kind, url) {
487		return md.InlineCode(url) + " ⚠️ flagged"
488	}
489	if z.Status == zones.Approved {
490		return md.Link(url, url)
491	}
492	return md.InlineCode(url)
493}
494
495// prose and cell escape a caller's free text for a sentence and a table cell,
496// plus what ui.Inline and ui.Cell leave alone: gnoweb turns an @name, and a
497// bare g1 address after a space or at the start, into a user-profile link with
498// an icon, so "run by @gnocore" or "run by g1…" would vouch for an account the
499// registrant chose.
500func prose(s string) string { return escapeMentions(ui.Inline(s)) }
501
502func cell(s string) string { return escapeMentions(ui.Cell(s)) }
503
504// escapeMentions backslash-escapes every @, and writes the 1 of every g1 as a
505// character reference. It runs after ui.Inline or ui.Cell, which escape every
506// & the caller typed, so the only reference in the output is this one; goldmark
507// decodes it back to "1" after the mention parser has looked and not matched.
508//
509// One pass, and none at all when there is nothing to escape: it runs on every
510// free-text field of every page.
511func escapeMentions(s string) string {
512	if !strings.Contains(s, "@") && !strings.Contains(s, "g1") {
513		return s
514	}
515	// Copy the runs between matches in one piece each: a byte at a time costs a
516	// native call per byte, every page, on text a stranger chose.
517	var b strings.Builder
518	n, last := len(s), 0
519	for i := 0; i < n; i++ {
520		switch {
521		case s[i] == '@':
522			b.WriteString(s[last:i])
523			b.WriteString("\\@")
524			last = i + 1
525		case s[i] == 'g' && i+1 < n && s[i+1] == '1':
526			b.WriteString(s[last:i])
527			b.WriteString("g&#49;")
528			i++
529			last = i + 1
530		}
531	}
532	b.WriteString(s[last:])
533	return b.String()
534}
535
536// rpcFlag marks a zone's main RPC when the endpoint table flags it, so the
537// table, the zone page and the printed command never disagree.
538func rpcFlag(z zones.Zone) string {
539	if rpcFlagged(z) {
540		return " ⚠️ flagged"
541	}
542	return ""
543}
544
545func rpcFlagged(z zones.Zone) bool { return flaggedAs(z, zones.RPC, z.RPCURL) }
546
547// flaggedAs reports whether a URL the page shows as a kind (the main RPC as
548// rpc, the gnoweb URL as gnoweb) is listed under that kind and flagged. Only
549// that kind's verdict counts. A listing under another kind is anybody's to
550// make on an approved zone, so letting its flag mark the zone's own URL would
551// let a stranger's mis-kinded entry, flagged as mis-kinded, mark an official
552// URL; a curator who means the zone's URL flags it under its own kind. The
553// lookup compares in Canonical form, which reads an rpc tcp:// as the http://
554// gnokey dials, so either spelling finds the other.
555func flaggedAs(z zones.Zone, shown zones.EndpointKind, url string) bool {
556	e, ok := reg.EndpointByAddress(z.Slug, shown, url)
557	return ok && e.Status == zones.Flagged
558}
559
560func revStr(z zones.Zone) string { return strconv.FormatInt(z.Revision, 10) }
561
562func isKind(k zones.EndpointKind) bool {
563	for _, x := range zones.EndpointKinds() {
564		if k == x {
565			return true
566		}
567	}
568	return false
569}
570
571func statusBadge(s zones.Status) string {
572	switch s {
573	case zones.Approved:
574		return "✅ " + md.Bold("official")
575	case zones.Pending:
576		return "⏳ " + md.Bold("pending review")
577	case zones.Rejected:
578		return "❌ " + md.Bold("rejected")
579	case zones.Retired:
580		return "⏹️ " + md.Bold("retired")
581	}
582	return string(s)
583}
584
585func endpointStatus(e zones.Endpoint) string {
586	s := ""
587	switch e.Status {
588	case zones.Verified:
589		s = "✅ verified"
590	case zones.Flagged:
591		s = "⚠️ flagged"
592	default:
593		s = "unverified"
594	}
595	if e.Reason != "" {
596		s += ": " + cell(e.Reason)
597	}
598	if e.ReviewedBy != "" {
599		s += " (" + ui.AddrFull(e.ReviewedBy) + ", block " + strconv.FormatInt(e.ReviewedAt, 10) + ")"
600	}
601	return s
602}
603
604func reviewText(by address, at int64, reason string) string {
605	s := "by " + ui.AddrFull(by) + " at block " + strconv.FormatInt(at, 10)
606	if reason != "" {
607		s += ": " + prose(reason)
608	}
609	return s
610}
611
612func curatorList() string {
613	out := []string{}
614	for _, a := range Curators() {
615		out = append(out, ui.AddrFull(a))
616	}
617	return strings.Join(out, ", ")
618}