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}