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("g1")
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}