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

8.71 Kb · 255 lines
  1package impl
  2
  3import (
  4	"chain/runtime"
  5	"strconv"
  6	"strings"
  7
  8	"gno.land/p/moul/helplink/v0"
  9	"gno.land/p/moul/md/v0"
 10	"gno.land/p/nt/bptree/pager/v0"
 11	"gno.land/p/nt/markdown/sanitize/v0"
 12	"gno.land/p/nt/mux/v0"
 13	"gno.land/p/nt/seqid/v0"
 14	"gno.land/p/nt/ufmt/v0"
 15	"gno.land/r/gov/dao"
 16	"gno.land/r/sys/users"
 17)
 18
 19type render struct {
 20	relativeRealmPath string
 21	router            *mux.Router
 22	pssPager          *pager.Pager
 23}
 24
 25func NewRender(d *GovDAO) *render {
 26	ren := &render{
 27		pssPager: pager.NewPager(d.pss.BPTree, 5, true),
 28	}
 29
 30	r := mux.NewRouter()
 31
 32	// Handlers use mux's rlm-aware shape: rlm is supplied at RenderRlm
 33	// dispatch time rather than captured at NewRender time. This lets
 34	// downstream crossing reads (dao.GetProposal etc.) use cross(rlm)
 35	// without relying on bare cross or restructuring the router.
 36	r.HandleFuncRlm("", func(_ int, rlm realm, rw *mux.ResponseWriter, req *mux.Request) {
 37		rw.Write(ren.renderActiveProposals(0, rlm, req.RawPath, d))
 38	})
 39
 40	r.HandleFuncRlm("{pid}", func(_ int, rlm realm, rw *mux.ResponseWriter, req *mux.Request) {
 41		rw.Write(ren.renderProposalPage(0, rlm, req.GetVar("pid"), d))
 42	})
 43
 44	r.HandleFuncRlm("{pid}/votes", func(_ int, rlm realm, rw *mux.ResponseWriter, req *mux.Request) {
 45		rw.Write(ren.renderVotesForProposal(0, rlm, req.GetVar("pid"), d))
 46	})
 47
 48	ren.router = r
 49
 50	return ren
 51}
 52
 53func (ren *render) Render(_ int, rlm realm, pkgPath string, path string) string {
 54	relativePath, found := strings.CutPrefix(pkgPath, runtime.ChainDomain())
 55	if !found {
 56		panic(ufmt.Sprintf(
 57			"realm package with unexpected name found: %v in chain domain %v",
 58			pkgPath, runtime.ChainDomain()))
 59	}
 60	ren.relativeRealmPath = relativePath
 61	return ren.router.RenderRlm(0, rlm, path)
 62}
 63
 64func (ren *render) renderActiveProposals(_ int, rlm realm, url string, d *GovDAO) string {
 65	out := "# GovDAO\n"
 66	out += "## Members\n"
 67	out += "[> Go to Memberstore <](/r/gov/dao/memberstore/v0)\n"
 68	out += "## Proposals\n"
 69	page, perr := ren.pssPager.GetPageByPath(url)
 70	if perr != nil {
 71		// A query url.Parse rejects (e.g. a control byte) is the caller's own
 72		// malformed input; render the default first page instead of aborting
 73		// this read-only render. ParseQuery already tolerates bad page/size
 74		// values, so every currently-working input is unaffected.
 75		page = ren.pssPager.GetPage(1)
 76	}
 77	if len(page.Items) == 0 {
 78		out += "\nNo proposals yet.\n\n"
 79		return out
 80	}
 81
 82	for _, item := range page.Items {
 83		seqpid, err := seqid.FromString(item.Key)
 84		if err != nil {
 85			continue
 86		}
 87		out += ren.renderProposalListItem(0, rlm, ufmt.Sprintf("%v", int64(seqpid)), d)
 88		out += "---\n\n"
 89	}
 90
 91	out += page.Picker("")
 92
 93	return out
 94}
 95
 96func (ren *render) renderProposalPage(_ int, rlm realm, sPid string, d *GovDAO) string {
 97	pid, err := strconv.ParseInt(sPid, 10, 64)
 98	if err != nil {
 99		// err echoes the caller's raw pid segment (strconv quotes it but leaves
100		// markdown/HTML metachars); escape+clamp it like every other untrusted
101		// slot on this page, since Render is reachable unauthenticated.
102		return ufmt.Sprintf("# Error: Invalid proposal ID format.\n\n\n%s\n\n", md.EscapeText(clampField(err.Error(), maxRenderedError)))
103	}
104
105	p, err := dao.GetProposal(dao.ProposalID(pid))
106	if err != nil {
107		return ufmt.Sprintf("# Proposal not found\n\n%s", err.Error())
108	}
109
110	// pid is user-supplied and the proposal itself lives on the proxy, so
111	// GetProposal can succeed for a proposal this implementation has no status
112	// for — the same post-upgrade case PreExecuteProposal guards (statuses live
113	// on the instance, so a fresh instance has none). Without this the page
114	// panics at ps.String below. renderProposalListItem needs no such guard:
115	// its pids are iterated out of pss itself, so a status always exists.
116	ps := d.pss.GetStatus(dao.ProposalID(pid))
117	if ps == nil {
118		return ufmt.Sprintf("# Proposal #%v not available\n\nThis proposal was not created by the current govDAO implementation, so it has no voting status here.", pid)
119	}
120
121	out := ufmt.Sprintf("## Prop #%v - %v\n", pid, md.EscapeText(clampField(p.Title(), maxRenderedTitle)))
122	out += "Author: " + tryResolveAddr(p.Author()) + "\n\n"
123
124	out += p.Description()
125	out += "\n\n"
126
127	// Add executor metadata if available
128	if p.ExecutorString() != "" {
129		out += ufmt.Sprintf(`This proposal contains the following metadata:
130
131%s
132
133`, p.ExecutorString())
134	}
135
136	// Disclosed independently of the description: this names the realm whose
137	// code runs if the proposal passes. Sharing the gate above hid it for every
138	// proposal built with an empty executor description — 16 call sites across
139	// 7 production realms, nearly all r/sys/* governance actions.
140	//
141	// Escaped, not trusted: CreationRealm() is dispatched through the public
142	// dao.Executor interface, so only SimpleExecutor's value is VM-captured;
143	// a third-party executor returns any string it likes.
144	// Guarded on the sanitized value, not the raw one: InlineCode returns ""
145	// for input that strips to nothing (bidi/zero-width only), and TrimSpace
146	// catches plain whitespace, so neither prints a label with nothing after
147	// it. This is tidiness, not a boundary: a zero-width/space mixture still
148	// renders an empty-looking span, and a hostile executor can always return
149	// a plausible-looking lie. The security property is the escaping below.
150	if cr := sanitize.InlineCode(strings.TrimSpace(clampField(p.ExecutorCreationRealm(), maxRenderedRealm))); cr != "" {
151		out += ufmt.Sprintf("Executor created in: %s\n", cr)
152		out += "\n\n"
153	}
154
155	out += "\n\n---\n\n"
156	out += ps.String(0, rlm)
157	out += "\n"
158	out += ufmt.Sprintf("[Detailed voting list](%v:%v/votes)", ren.relativeRealmPath, pid)
159	out += "\n\n---\n\n"
160
161	out += renderActionBar(ufmt.Sprintf("%v", pid))
162
163	return out
164}
165
166func (ren *render) renderProposalListItem(_ int, rlm realm, sPid string, d *GovDAO) string {
167	pid, err := strconv.ParseInt(sPid, 10, 64)
168	if err != nil {
169		// err echoes the caller's raw pid segment (strconv quotes it but leaves
170		// markdown/HTML metachars); escape+clamp it like every other untrusted
171		// slot on this page, since Render is reachable unauthenticated.
172		return ufmt.Sprintf("# Error: Invalid proposal ID format.\n\n\n%s\n\n", md.EscapeText(clampField(err.Error(), maxRenderedError)))
173	}
174
175	p, err := dao.GetProposal(dao.ProposalID(pid))
176	if err != nil {
177		return ufmt.Sprintf("# Proposal not found\n\n%s\n\n", err.Error())
178	}
179
180	ps := d.pss.GetStatus(dao.ProposalID(pid))
181	out := ufmt.Sprintf("### [Prop #%v - %v](%v:%v)\n", pid, md.EscapeText(clampField(p.Title(), maxRenderedTitle)), ren.relativeRealmPath, pid)
182	out += ufmt.Sprintf("Author: %s\n\n", tryResolveAddr(p.Author()))
183
184	out += "Status: " + getPropStatus(ps)
185	out += "\n\n"
186
187	out += "Tiers eligible to vote: "
188	out += strings.Join(ps.TiersAllowedToVote, ", ")
189
190	out += "\n\n"
191	return out
192}
193
194func (ren *render) renderVotesForProposal(_ int, rlm realm, sPid string, d *GovDAO) string {
195	pid, err := strconv.ParseInt(sPid, 10, 64)
196	if err != nil {
197		// err echoes the caller's raw pid segment (strconv quotes it but leaves
198		// markdown/HTML metachars); escape+clamp it like every other untrusted
199		// slot on this page, since Render is reachable unauthenticated.
200		return ufmt.Sprintf("# Error: Invalid proposal ID format.\n\n\n%s\n\n", md.EscapeText(clampField(err.Error(), maxRenderedError)))
201	}
202
203	ps := d.pss.GetStatus(dao.ProposalID(pid))
204	if ps == nil {
205		return ufmt.Sprintf("# Proposal not found\n\nProposal %v does not exist.", pid)
206	}
207
208	out := ""
209	out += ufmt.Sprintf("# Proposal #%v - Vote List\n\n", pid)
210	out += StringifyVotes(0, rlm, ps)
211
212	return out
213}
214
215func isPropActive(ps *proposalStatus) bool {
216	return !ps.Accepted && !ps.Denied
217}
218
219func getPropStatus(ps *proposalStatus) string {
220	if ps == nil {
221		return "UNKNOWN"
222	}
223	if ps.Accepted {
224		return "ACCEPTED"
225	} else if ps.Denied {
226		return "REJECTED"
227	}
228	return "ACTIVE"
229}
230
231func renderActionBar(sPid string) string {
232	out := "### Actions\n"
233
234	proxy := helplink.Realm("gno.land/r/gov/dao")
235	out += proxy.Func("Vote YES", "MustVoteOnProposalSimple", "pid", sPid, "option", "YES") + " | "
236	out += proxy.Func("Vote NO", "MustVoteOnProposalSimple", "pid", sPid, "option", "NO") + " | "
237	out += proxy.Func("Vote ABSTAIN", "MustVoteOnProposalSimple", "pid", sPid, "option", "ABSTAIN")
238
239	out += "\n\n"
240	out += "WARNING: Please double check transaction data before voting."
241	return out
242}
243
244// tryResolveAddr renders the author/voter as a username link. RenderLink
245// interpolates the username raw into "[@name](/u/name)" (r/sys/users), so this
246// is markdown-safe ONLY because r/sys/users validateName restricts names to
247// ^[a-z][a-z0-9]*([_-][a-z0-9]+)*$ (max 64) — no markdown/HTML metachar. If that
248// charset ever loosens, this line and writeVotes in types.gno need escaping.
249func tryResolveAddr(addr address) string {
250	userData := users.ResolveAddress(addr)
251	if userData == nil {
252		return addr.String()
253	}
254	return userData.RenderLink("")
255}