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

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}