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

28.62 Kb · 930 lines
  1package commondao
  2
  3import (
  4	"strconv"
  5	"strings"
  6	"time"
  7
  8	"gno.land/p/jeronimoalbi/pager/v0"
  9	"gno.land/p/moul/md/v0"
 10	"gno.land/p/moul/mdtable/v0"
 11	"gno.land/p/moul/realmpath/v0"
 12	"gno.land/p/nt/addrset/v0"
 13	"gno.land/p/nt/commondao/v0"
 14	"gno.land/p/nt/markdown/sanitize/v0"
 15	"gno.land/p/nt/mux/v0"
 16	"gno.land/p/nt/ufmt/v0"
 17
 18	"gno.land/r/sys/users"
 19)
 20
 21const dateFormat = "Mon, 02 Jan 2006 03:04pm MST"
 22
 23// trustedMarkdownBody marks proposal definitions whose Body returns
 24// markdown the definition assembled itself (escaping any embedded user
 25// fields with md.EscapeText). Those bodies are rendered verbatim. Every
 26// other body — including the execution kind, whose Body is the proposer's
 27// raw text — is treated as user-supplied and escaped by the renderer. A
 28// definition from an imported package cannot implement this realm-local
 29// marker either, so it is escaped too. The render layer is the single escape
 30// point: definitions return raw text and opt out only when they render their
 31// own markup.
 32type trustedMarkdownBody interface {
 33	isTrustedMarkdownBody()
 34}
 35
 36func Render(path string) string {
 37	router := mux.NewRouter()
 38	router.HandleFunc("", renderHome)
 39	router.HandleFunc("{daoID}", renderDAO)
 40	router.HandleFunc("{daoID}/settings", renderSettings)
 41	router.HandleFunc("{daoID}/bylaws", renderBylaws)
 42	router.HandleFunc("{daoID}/proposals", renderProposalsList)
 43	router.HandleFunc("{daoID}/proposals/{proposalID}", renderProposal)
 44	router.HandleFunc("{daoID}/proposals/{proposalID}/vote/{address}", renderProposalVote)
 45	return router.Render(path)
 46}
 47
 48func renderHome(res *mux.ResponseWriter, req *mux.Request) {
 49	res.Write(md.H1("Common DAO"))
 50	res.Write(md.HorizontalRule())
 51	res.Write(ufmt.Sprintf(
 52		md.Paragraph("This realm can be used to create CommonDAO instances based on %s package."),
 53		md.Link("commondao", "/p/nt/commondao/v0/"),
 54	))
 55
 56	// Nothing listed: render just the header. pager.New reports a total of
 57	// zero as "invalid page number" for every page including page 1, which
 58	// its own Picker links to.
 59	if listed.Size() == 0 {
 60		return
 61	}
 62
 63	// Paginate over the LISTED set, not every DAO: listing is opt-in and
 64	// off by default, so a window over all DAOs could be entirely unlisted
 65	// — rendering an empty page that also drops the pager (the len==0 guard
 66	// below), stranding the listed DAOs on unreachable later pages.
 67	pages, err := pager.New(req.RawPath, listed.Size(), pager.WithPageSize(10))
 68	if err != nil {
 69		// Its own block: an unterminated error line glues itself onto
 70		// whatever is written next (renderCouncil returns into renderDAO,
 71		// which goes straight on to write the Treasury heading).
 72		res.Write(md.Paragraph(md.EscapeText(err.Error())))
 73		return
 74	}
 75
 76	var items []string
 77	listed.ReverseIterateByOffset(pages.Offset(), pages.PageSize(), func(_ string, v any) bool {
 78		if dao := getDAO(v.(uint64)); dao != nil {
 79			items = append(items, md.Link(dao.Name(), daoURL(dao.ID())))
 80		}
 81		return false
 82	})
 83
 84	if len(items) == 0 {
 85		return
 86	}
 87
 88	res.Write(md.Paragraph("Here is a list of some of the DAOs that were created:"))
 89	res.Write(md.Paragraph(md.BulletList(items)))
 90
 91	if pages.HasPages() {
 92		res.Write(md.Paragraph(pager.Picker(pages)))
 93	}
 94}
 95
 96func renderDAO(res *mux.ResponseWriter, req *mux.Request) {
 97	dao := mustGetDAOFromRequest(req)
 98
 99	// Render header messages
100	if dao.IsDeleted() {
101		res.Write(md.Blockquote("⚠ This DAO has been dissolved"))
102		// A dissolved DAO with no live proper ancestor can never move funds
103		// again (its own council is gone and no ancestor can claw back), so
104		// warn that late deposits to its treasury address are unrecoverable.
105		if !hasLiveProperAncestor(dao) {
106			res.Write(md.Blockquote("⚠ No live ancestor DAO remains: coins sent to this treasury address can no longer be recovered."))
107		}
108	}
109
110	// Render header (Charter: purpose is required, description optional)
111	res.Write(md.H1(md.EscapeText(dao.Name())))
112	res.Write(md.Paragraph(md.Bold("Purpose:") + " " + md.EscapeText(dao.Purpose())))
113	if desc := dao.Description(); desc != "" {
114		res.Write(md.Paragraph(md.EscapeText(desc)))
115	}
116
117	// Render main menu
118	menu := []string{
119		md.Link("View Proposals", daoProposalsURL(dao.ID())),
120		md.Link("View Settings", settingsURL(dao.ID())),
121	}
122
123	if set := bylawsView(dao.ID()); set != nil && set.Size() > 0 {
124		menu = append(menu, md.Link("View Bylaws & Mandates", bylawsURL(dao.ID())))
125	}
126
127	if parentDAO := dao.Parent(); parentDAO != nil {
128		menu = append(menu, md.Link("Go to Parent DAO", daoURL(parentDAO.ID())))
129	}
130
131	res.Write(md.Paragraph(strings.Join(menu, " • ")))
132	res.Write(md.HorizontalRule())
133
134	// Render council
135	council := dao.Council()
136	if council.Size() == 0 {
137		res.Write(md.Paragraph(md.Bold("⚠ The DAO has no council members")))
138	} else {
139		renderCouncil(res, req.RawPath, council)
140	}
141
142	renderTreasury(res, dao)
143
144	// Render organization tree
145	if dao.ChildrenCount() > 0 {
146		r := parseRealmPath(req.RawPath)
147		dissolvedVisible := r.Query.Has("dissolved") || dao.IsDeleted()
148		res.Write(md.H2("Tree"))
149
150		// Render toggle only when DAO is not dissolved,
151		// otherwise render the whole tree when DAO is dissolved
152		if !dao.IsDeleted() {
153			var toggleLink string
154			if dissolvedVisible {
155				r.Query.Del("dissolved")
156				toggleLink = md.Link("hide", r.String())
157			} else {
158				r.Query.Add("dissolved", "")
159				toggleLink = md.Link("show", r.String())
160			}
161
162			res.Write(md.Paragraph("Dissolved: " + toggleLink))
163		}
164
165		renderTree(res, dao, "", dissolvedVisible)
166	}
167
168	// Render latest proposals
169	if dao.ActiveProposalsSize() > 0 {
170		res.Write(md.H2("Latest Proposals"))
171		dao.IterateActiveProposals(0, 3, true, func(p *commondao.Proposal) bool {
172			renderProposalsListItem(res, dao, p)
173			return false
174		})
175	}
176
177	// Render proposal creation links
178	if !dao.IsDeleted() {
179		renderCreateProposalSection(dao, res)
180	}
181}
182
183func renderCreateProposalSection(dao *commondao.CommonDAO, res *mux.ResponseWriter) {
184	daoID := dao.ID()
185
186	// One entry per link-encodable catalog kind, in display order (the
187	// execution kind has no entry: its closure cannot be encoded in a
188	// help link). Only the kinds registered on the DAO are rendered.
189	entries := []struct {
190		kind string
191		text string
192	}{
193		{
194			kindText,
195			md.Paragraph(textProposalLink(daoID)) +
196				md.Paragraph(
197					"This type of proposal is also known as text proposal which can be used for example "+
198						"to get consensus on initiatives without actually making any change on-chain.",
199				),
200		},
201		{
202			kindCouncilUpdate,
203			md.Paragraph(updateCouncilLink(daoID)) +
204				md.Paragraph(
205					"This type of proposal can be used to add new council members to this DAO "+
206						"and also to remove existing ones.",
207				) +
208				md.Paragraph(
209					"A single proposal allows new council members to be added and any number of existing ones "+
210						"removed within the same proposal.",
211				),
212		},
213		{
214			kindAncestorCouncilUpdate,
215			md.Paragraph(ancestorCouncilUpdateLink(daoID)) +
216				md.Paragraph(
217					"This type of proposal lets this DAO, as an ancestor, add or "+
218						"remove council members of one of its descendant DAOs — the "+
219						"rescue path for a descendant whose council is stuck or empty.",
220				),
221		},
222		{
223			kindDissolve,
224			md.Paragraph(dissolveSubDAOLink(daoID)) +
225				md.Paragraph(
226					"This type of proposal can be used to dissolve DAOs and SubDAOs.",
227				) +
228				md.Paragraph(
229					"Dissolving a DAO can't be undone, once the dissolution proposal passes "+
230						"and is executed DAO will be readonly.",
231				),
232		},
233		{
234			kindTreasurySpend,
235			md.Paragraph(treasurySpendLink(daoID)) +
236				md.Paragraph(
237					"This type of proposal sends coins from the DAO's own treasury "+
238						"when it passes and is executed.",
239				),
240		},
241		{
242			kindTreasuryClawback,
243			md.Paragraph(treasuryClawbackLink(daoID)) +
244				md.Paragraph(
245					"This type of proposal sweeps a descendant DAO's treasury to "+
246						"the descendant's parent DAO.",
247				),
248		},
249		{
250			kindTreasuryFreeze,
251			md.Paragraph(treasuryFreezeLink(daoID)) +
252				md.Paragraph(
253					"This type of proposal freezes or unfreezes a descendant DAO's "+
254						"treasury. While frozen, no funds can leave the treasury.",
255				),
256		},
257		{
258			kindSubDAO,
259			md.Paragraph(newSubDAOLink(daoID)) +
260				md.Paragraph(
261					"This type of proposal is used to create SubDAOs, "+
262						"which are used to create tree based DAOs.",
263				),
264		},
265		{
266			kindManageKinds,
267			md.Paragraph(registerKindLink(daoID)+" • "+deregisterKindLink(daoID)) +
268				md.Paragraph(
269					"This type of proposal registers one of the realm's catalog "+
270						"proposal kinds on this DAO by name, or deregisters a kind. "+
271						"Deregistering a kind blocks new proposals of that kind while "+
272						"in-flight ones still vote and execute. The manage-kinds kind "+
273						"itself cannot be deregistered.",
274				),
275		},
276		{
277			kindAmendBylaws,
278			md.Paragraph(amendBylawsLink(daoID)) +
279				md.Paragraph(
280					"This type of proposal adds, amends or removes one of the DAO's "+
281						"bylaws documents with a diff patch pinned to the current "+
282						"document text (the mandates folder is reserved: mandates are "+
283						"set from above, not by the council). Build the payload with "+
284						"the AmendBylawsPayload query function.",
285				),
286		},
287	}
288
289	var cols []string
290	for _, e := range entries {
291		if dao.HasKind(e.kind) {
292			cols = append(cols, e.text)
293		}
294	}
295
296	if len(cols) == 0 {
297		return
298	}
299
300	res.Write(md.H2("Create Proposal"))
301	res.Write(md.Paragraph("These are the proposal supported by this DAO:"))
302	// Wrap at 3 per row: gnoweb renders at most 4 columns per row, and a
303	// DAO with every kind registered has up to ten cards.
304	res.Write(md.ColumnsN(cols, 3, false))
305}
306
307func renderTreasury(res *mux.ResponseWriter, dao *commondao.CommonDAO) {
308	res.Write(md.H2("Treasury"))
309	res.Write(md.BulletItem("Address: " + md.EscapeText(string(dao.Address()))))
310
311	balance := treasuryBalance(dao)
312	if balance.IsZero() {
313		res.Write(md.BulletItem("Balance: (empty)"))
314	} else {
315		res.Write(md.BulletItem("Balance: " + md.EscapeText(balance.String())))
316	}
317
318	if dao.IsTreasuryFrozen() {
319		// Blockquote, not a bold paragraph: a paragraph directly after the
320		// balance bullet is a lazy continuation of that list item, so the
321		// warning would render inside the bullet instead of as its own
322		// block. Matches the dissolved-DAO banners.
323		frozen := "⚠ The treasury is frozen: no proposal can move funds until a proper ancestor DAO unfreezes it."
324		if dao.HasKind(kindExecution) {
325			// Freeze gates the realm's own spending paths. A closure from a
326			// past execution proposal may hold a retained banker, which
327			// reaches the bank keeper without passing through them.
328			frozen += " This DAO has the execution kind registered, so a" +
329				" capability retained by an earlier execution proposal could" +
330				" still move funds; freeze alone is not containment here."
331		}
332		res.Write(md.Blockquote(frozen))
333	}
334}
335
336func renderCouncil(res *mux.ResponseWriter, path string, council *addrset.ReadonlySet) {
337	pages, err := pager.New(path, council.Size(), pager.WithPageQueryParam("members"), pager.WithPageSize(8))
338	if err != nil {
339		// Its own block: an unterminated error line glues itself onto
340		// whatever is written next (renderCouncil returns into renderDAO,
341		// which goes straight on to write the Treasury heading).
342		res.Write(md.Paragraph(md.EscapeText(err.Error())))
343		return
344	}
345
346	table := mdtable.Table{Headers: []string{"Council"}}
347	council.IterateByOffset(pages.Offset(), pages.PageSize(), func(addr address) bool {
348		table.Append([]string{userLink(addr)})
349		return false
350	})
351
352	res.Write(md.Paragraph(table.String()))
353
354	if pages.HasPages() {
355		res.Write(md.Paragraph(pager.Picker(pages)))
356	}
357}
358
359func renderTree(res *mux.ResponseWriter, dao *commondao.CommonDAO, indent string, showDissolved bool) {
360	daoLink := md.Link(dao.Name(), daoURL(dao.ID()))
361	if dao.IsDeleted() {
362		// Strikethough dissolved DAO names
363		daoLink = md.Strikethrough(daoLink)
364	}
365
366	res.Write(indent + md.BulletItem(daoLink))
367
368	indent += "  "
369	dao.IterateChildren(func(subDAO *commondao.CommonDAO) bool {
370		if showDissolved || !subDAO.IsDeleted() {
371			renderTree(res, subDAO, indent, showDissolved)
372		}
373		return false
374	})
375}
376
377func renderSettings(res *mux.ResponseWriter, req *mux.Request) {
378	dao := mustGetDAOFromRequest(req)
379
380	// Render header
381	res.Write(md.H1(md.EscapeText(dao.Name()) + ": Settings"))
382
383	// Render main menu
384	res.Write(md.Paragraph(goToDAOLink(dao.ID())))
385	res.Write(md.HorizontalRule())
386
387	// Render info
388	table := mdtable.Table{Headers: []string{"Setting", "Value"}}
389	table.Append([]string{"Listed", strconv.FormatBool(isListed(dao.ID()))})
390	table.Append([]string{"Max active proposals", strconv.Itoa(dao.MaxActiveProposals())})
391	table.Append([]string{"Proposal kinds", md.EscapeText(strings.Join(dao.KindNames(), ", "))})
392
393	res.Write(md.H2("Info"))
394	res.Write(table.String())
395}
396
397func renderBylaws(res *mux.ResponseWriter, req *mux.Request) {
398	dao := mustGetDAOFromRequest(req)
399
400	// Render header
401	res.Write(md.H1(md.EscapeText(dao.Name()) + ": Bylaws & Mandates"))
402
403	// Render main menu
404	res.Write(md.Paragraph(goToDAOLink(dao.ID())))
405	res.Write(md.HorizontalRule())
406
407	set := bylawsView(dao.ID())
408	if set == nil || set.Size() == 0 {
409		res.Write(md.Paragraph("This DAO has no bylaws or mandates documents."))
410		return
411	}
412
413	paths := set.List("")
414	pages, err := pager.New(req.RawPath, len(paths), pager.WithPageSize(5))
415	if err != nil {
416		// Its own block: an unterminated error line glues itself onto
417		// whatever is written next (renderCouncil returns into renderDAO,
418		// which goes straight on to write the Treasury heading).
419		res.Write(md.Paragraph(md.EscapeText(err.Error())))
420		return
421	}
422
423	// Documents are multi-line plaintext with council-controlled content:
424	// the path is escaped inline and the text goes through sanitize.Block,
425	// which preserves paragraph structure (an inline escape would fold the
426	// whole document to one line) while escaping block-level hazards. The
427	// hash is what an amendment payload must pin (AmendBylawsPayload reads
428	// it for you).
429	// Clamp the offset: pager.New rejects page 0 and pages past the end but
430	// accepts a negative page, and unlike the tree-backed pagers this one
431	// indexes a slice — a raw negative offset panics the whole render.
432	start := pages.Offset()
433	if start < 0 {
434		start = 0
435	}
436	for i := start; i < len(paths) && i < start+pages.PageSize(); i++ {
437		path := paths[i]
438		text, _ := set.Get(path)
439		res.Write(md.H2(md.EscapeText(path)))
440		res.Write(md.Paragraph("sha256: " + set.Hash(path)))
441		res.Write(sanitize.Block(text))
442	}
443
444	if pages.HasPages() {
445		res.Write(md.Paragraph(pager.Picker(pages)))
446	}
447}
448
449func renderProposalsList(res *mux.ResponseWriter, req *mux.Request) {
450	dao := mustGetDAOFromRequest(req)
451
452	// Render header
453	res.Write(md.H1(md.EscapeText(dao.Name()) + ": Proposals"))
454
455	// Render main menu
456	res.Write(md.Paragraph(goToDAOLink(dao.ID())))
457	res.Write(md.HorizontalRule())
458
459	// Render proposals
460	if dao.ActiveProposalsSize() == 0 && dao.FinishedProposalsSize() == 0 {
461		res.Write(md.Paragraph(md.Bold("⚠ The DAO has no proposals")))
462		return
463	}
464
465	size := dao.ActiveProposalsSize()
466	iterate := dao.IterateActiveProposals
467	renderFinished := req.Query.Has("finished")
468	if renderFinished {
469		size = dao.FinishedProposalsSize()
470		iterate = dao.IterateFinishedProposals
471	}
472
473	var viewLink, sortLink string
474
475	r := parseRealmPath(req.RawPath)
476	r.Query.Del("page") // a view switch resets to page 1 (the other view may have fewer pages)
477	if renderFinished {
478		r.Query.Del("finished")
479		viewLink = md.Link("active", r.String())
480	} else {
481		r.Query.Add("finished", "")
482		viewLink = md.Link("finished", r.String())
483	}
484
485	r = parseRealmPath(req.RawPath)
486	r.Query.Del("page") // a sort switch resets to page 1
487	reverseSort := r.Query.Get("order") != "asc"
488	if reverseSort {
489		r.Query.Set("order", "asc")
490		sortLink = md.Link("oldest", r.String())
491	} else {
492		r.Query.Set("order", "desc")
493		sortLink = md.Link("newest", r.String())
494	}
495
496	res.Write(md.Paragraph("View: " + viewLink + " • Sort by: " + sortLink))
497
498	// The empty view is handled before the pager: pager.New reports a total
499	// of zero as "invalid page number" for every page including page 1,
500	// which the other view's Picker links to — so switching into an empty
501	// view would answer with an error instead of the empty-state message.
502	if size == 0 {
503		if renderFinished {
504			res.Write(md.Paragraph("Currently there are no finished proposals"))
505		} else {
506			res.Write(md.Paragraph("Currently there are no active proposals"))
507		}
508		return
509	}
510
511	pages, err := pager.New(req.RawPath, size, pager.WithPageSize(8))
512	if err != nil {
513		res.Write(md.Paragraph(md.EscapeText(err.Error())))
514		return
515	}
516
517	iterate(pages.Offset(), pages.PageSize(), reverseSort, func(p *commondao.Proposal) bool {
518		renderProposalsListItem(res, dao, p)
519		return false
520	})
521
522	// Render pager
523	if pages.HasPages() {
524		res.Write(md.HorizontalRule())
525		res.Write(pager.Picker(pages))
526	}
527}
528
529func renderProposalsListItem(res *mux.ResponseWriter, dao *commondao.CommonDAO, p *commondao.Proposal) {
530	def := p.Definition()
531	record := p.VotingRecord()
532
533	// Render title
534	res.Write(ufmt.Sprintf("**[#%d %s](%s)**  \n", p.ID(), md.EscapeText(def.Title()), proposalURL(dao.ID(), p.ID())))
535
536	// Render details
537	res.Write(ufmt.Sprintf("Created by %s  \n", userLink(p.Creator())))
538	res.Write(ufmt.Sprintf("Voting ends on %s  \n", p.VotingDeadline().UTC().Format(dateFormat)))
539
540	// Render status
541	status := []string{
542		ufmt.Sprintf("Votes: **%d**", record.Size()),
543		ufmt.Sprintf("Status: **%s**", string(p.Status())),
544	}
545
546	// Render actions
547	if isVotingPeriodActive(p) {
548		status = append(status, voteLink(dao.ID(), p.ID()))
549	}
550
551	if isExecutionAllowed(p) {
552		status = append(status, executeLink(dao.ID(), p.ID()))
553	}
554
555	res.Write(md.Paragraph(strings.Join(status, " • ")))
556}
557
558func renderProposal(res *mux.ResponseWriter, req *mux.Request) {
559	dao := mustGetDAOFromRequest(req)
560	p := mustGetProposalFromRequest(req, dao)
561
562	// Check that proposal has no issues
563	if err := p.Validate(); err != nil {
564		// Escape the error text (the bold label is ours): a definition's
565		// Validate may embed user-supplied values in its message.
566		res.Write(md.Blockquote("⚠ **ERROR**: " + md.EscapeText(err.Error())))
567	}
568
569	votingActive := isVotingPeriodActive(p)
570	if votingActive {
571		res.Write(
572			md.Blockquote("Voting ends on " + md.Bold(p.VotingDeadline().UTC().Format(dateFormat))),
573		)
574	}
575
576	def := p.Definition()
577
578	// Render header
579	res.Write(md.H1("#" + strconv.FormatUint(p.ID(), 10) + " " + md.EscapeText(def.Title())))
580
581	// Render main menu
582	items := []string{goToDAOLink(dao.ID())}
583	if votingActive {
584		items = append(items, voteLink(dao.ID(), p.ID()))
585	}
586
587	if isExecutionAllowed(p) {
588		items = append(items, executeLink(dao.ID(), p.ID()))
589	}
590
591	res.Write(md.Paragraph(strings.Join(items, " • ")))
592	res.Write(md.HorizontalRule())
593
594	// Render details
595	res.Write(md.H2("Details"))
596	res.Write(md.BulletItem("Proposer: " + userLink(p.Creator())))
597	res.Write(md.BulletItem("Submit Time: " + p.CreatedAt().UTC().Format(time.RFC1123)))
598
599	record := p.VotingRecord()
600	if p.Status() == commondao.StatusActive {
601		// Vote settles the outcome early — it flips a proposal to passed or
602		// dismissed the moment the tally allows — so while voting is open a
603		// still-active proposal is undecided, and "pending" is the honest
604		// label ("fail" would read as a prediction of defeat on a proposal
605		// nobody has voted on yet). Once the deadline has passed no further
606		// vote is accepted and Execute's re-tally dismisses an undecided
607		// proposal, so there "pending" would be the misleading one.
608		//
609		// The decided arms are reachable in principle: /p/ honors a
610		// non-constant Threshold (see ProposalDefinition.Threshold), though
611		// every definition in this realm returns a constant.
612		switch p.ExpectedOutcome() {
613		case commondao.OutcomePassed:
614			res.Write(md.BulletItem("Expected Outcome: **pass** ☑"))
615		case commondao.OutcomeDismissed:
616			res.Write(md.BulletItem("Expected Outcome: **dismiss** ☒"))
617		default:
618			if votingActive {
619				res.Write(md.BulletItem("Expected Outcome: **pending** ⏳"))
620			} else {
621				res.Write(md.BulletItem("Expected Outcome: **dismiss** ☒"))
622			}
623		}
624	}
625
626	statusItem := "Status: " + md.Bold(string(p.Status()))
627	if reason := p.StatusReason(); reason != "" {
628		statusItem += " • " + md.Italic(md.EscapeText(reason))
629	}
630	res.Write(md.BulletItem(statusItem))
631
632	// Render proposal body. Bodies are raw user text and escaped here by
633	// default; only definitions that assemble their own markdown (marked
634	// trustedMarkdownBody) are rendered verbatim.
635	if body := def.Body(); body != "" {
636		res.Write(md.H2("Description"))
637		if _, trusted := def.(trustedMarkdownBody); !trusted {
638			// Inline escape, deliberately: it neutralizes inline markup and
639			// links in proposer-supplied text. sanitize.Block would preserve
640			// the body's line structure but also its inline links — a
641			// phishing vector on a page councils read before voting. The
642			// cost is that a multi-line body renders as one line; a
643			// structure-preserving fix must escape inline per line rather
644			// than switch to Block (see z_10_b, which pins the escaping).
645			body = md.EscapeText(body)
646		}
647		res.Write(md.Paragraph(body))
648	}
649
650	// Render voting stats and votes
651	if record.Size() > 0 {
652		renderProposalStats(res, record)
653		renderProposalVotes(res, req.RawPath, dao, p)
654	}
655}
656
657func renderProposalStats(res *mux.ResponseWriter, record commondao.ReadonlyVotingRecord) {
658	totalCount := float64(record.Size())
659	table := mdtable.Table{Headers: []string{"Vote Choices", "Percentage of Votes"}}
660
661	record.IterateVotesCount(func(c commondao.VoteChoice, voteCount int) bool {
662		// A changed vote leaves a zeroed counter behind; skip it so the
663		// table shows only choices with live votes.
664		if voteCount == 0 {
665			return false
666		}
667
668		percentage := float64(voteCount*100) / totalCount
669		table.Append([]string{md.EscapeText(string(c)), strconv.FormatFloat(percentage, 'f', 2, 64) + "%"})
670		return false
671	})
672
673	res.Write(md.H2("Stats"))
674	res.Write(md.Paragraph(table.String()))
675}
676
677func renderProposalVotes(res *mux.ResponseWriter, path string, dao *commondao.CommonDAO, p *commondao.Proposal) {
678	res.Write(md.H2("Votes")) // Render title here so it appears before any pager errors
679
680	record := p.VotingRecord()
681	pages, err := pager.New(path, record.Size(), pager.WithPageQueryParam("votes"), pager.WithPageSize(5))
682	if err != nil {
683		// Its own block: an unterminated error line glues itself onto
684		// whatever is written next (renderCouncil returns into renderDAO,
685		// which goes straight on to write the Treasury heading).
686		res.Write(md.Paragraph(md.EscapeText(err.Error())))
687		return
688	}
689
690	table := mdtable.Table{Headers: []string{"Users", "Votes"}}
691	record.Iterate(pages.Offset(), pages.PageSize(), false, func(v commondao.Vote) bool {
692		voteDetails := md.Link(string(v.Choice()), voteURL(dao.ID(), p.ID(), v.Address()))
693		if v.Reason() != "" {
694			voteDetails += " with a reason"
695		}
696
697		table.Append([]string{userLink(v.Address()), voteDetails})
698		return false
699	})
700
701	res.Write(ufmt.Sprintf("Total number of votes: **%d**\n", record.Size()))
702	res.Write(md.Paragraph(table.String()))
703
704	if pages.HasPages() {
705		res.Write(md.Paragraph(pager.Picker(pages)))
706	}
707}
708
709func renderProposalVote(res *mux.ResponseWriter, req *mux.Request) {
710	// Resolve the DAO and proposal before validating the address, so a bad
711	// daoID is reported as such instead of being blamed on the address, and
712	// write the header first so every branch below is a navigable page
713	// rather than a bare dead-end string.
714	dao := mustGetDAOFromRequest(req)
715	p := mustGetProposalFromRequest(req, dao)
716
717	links := []string{
718		goToDAOLink(dao.ID()),
719		goToProposalLink(dao.ID(), p.ID()),
720	}
721
722	res.Write(md.H1(ufmt.Sprintf("Vote: Proposal #%d", p.ID())))
723	res.Write(md.Paragraph(strings.Join(links, " • ")))
724	res.Write(md.HorizontalRule())
725
726	member := address(req.GetVar("address"))
727	if !member.IsValid() {
728		res.Write(md.Paragraph("Invalid address."))
729		return
730	}
731
732	v, found := p.VotingRecord().GetVote(member)
733	if !found {
734		// Distinguish the two ways a vote can be absent: a member who has
735		// not voted yet is an ordinary state, an outsider is not.
736		if p.Electorate().Has(member) {
737			res.Write(md.Paragraph("This council member has not voted on this proposal yet."))
738		} else {
739			res.Write(md.Paragraph("This account is not a member of the proposal's electorate."))
740		}
741		return
742	}
743
744	res.Write(md.H2("Details"))
745	res.Write(md.BulletItem("User: " + userLink(v.Address())))
746	res.Write(md.BulletItem("Vote: " + md.EscapeText(string(v.Choice()))))
747
748	if v.Reason() != "" {
749		res.Write(md.H2("Reason"))
750		// Inline escape for the same reason as a proposal body: a voter's
751		// reason is untrusted text, so its inline links must not render.
752		res.Write(md.Paragraph(md.EscapeText(v.Reason())))
753	}
754}
755
756func mustGetDAOFromRequest(req *mux.Request) *commondao.CommonDAO {
757	rawID := req.GetVar("daoID")
758	daoID, err := strconv.ParseUint(rawID, 10, 64)
759	if err != nil {
760		panic("invalid DAO ID")
761	}
762
763	return mustGetDAO(daoID)
764}
765
766func mustGetProposalFromRequest(req *mux.Request, dao *commondao.CommonDAO) *commondao.Proposal {
767	rawID := req.GetVar("proposalID")
768	proposalID, err := strconv.ParseUint(rawID, 10, 64)
769	if err != nil {
770		panic("invalid proposal ID")
771	}
772
773	p := dao.GetProposal(proposalID)
774	if p == nil {
775		panic("proposal not found")
776	}
777	return p
778}
779
780func parseRealmPath(path string) *realmpath.Request {
781	r := realmpath.Parse(path)
782	r.Realm = string(realmLink)
783	return r
784}
785
786func voteLink(daoID, proposalID uint64) string {
787	return md.Link("Vote", realmLink.Call(
788		"Vote",
789		"daoID", strconv.FormatUint(daoID, 10),
790		"proposalID", strconv.FormatUint(proposalID, 10),
791		"vote", "",
792		"reason", "",
793	))
794}
795
796func executeLink(daoID, proposalID uint64) string {
797	return md.Link("Execute", realmLink.Call(
798		"Execute",
799		"daoID", strconv.FormatUint(daoID, 10),
800		"proposalID", strconv.FormatUint(proposalID, 10),
801	))
802}
803
804func textProposalLink(daoID uint64) string {
805	return ufmt.Sprintf("[General Proposal](%s)", realmLink.Call(
806		"CreateTextProposal",
807		"daoID", strconv.FormatUint(daoID, 10),
808		"title", "",
809		"body", "",
810		"votingDays", "7",
811	))
812}
813
814func updateCouncilLink(daoID uint64) string {
815	return md.Link("Update Council", realmLink.Call(
816		"CreateCouncilUpdateProposal",
817		"daoID", strconv.FormatUint(daoID, 10),
818		"newMembers", "",
819		"removeMembers", "",
820	))
821}
822
823func ancestorCouncilUpdateLink(daoID uint64) string {
824	return md.Link("Ancestor Council Update", realmLink.Call(
825		"CreateAncestorCouncilUpdateProposal",
826		"daoID", strconv.FormatUint(daoID, 10),
827		"targetID", "",
828		"newMembers", "",
829		"removeMembers", "",
830	))
831}
832
833func newSubDAOLink(daoID uint64) string {
834	return md.Link("New SubDAO", realmLink.Call(
835		"CreateSubDAOProposal",
836		"daoID", strconv.FormatUint(daoID, 10),
837		"name", "",
838		"purpose", "",
839		"description", "",
840		"members", "",
841	))
842}
843
844func dissolveSubDAOLink(daoID uint64) string {
845	return md.Link("Dissolve DAO", realmLink.Call(
846		"CreateDissolutionProposal",
847		"daoID", strconv.FormatUint(daoID, 10),
848		"destination", "",
849	))
850}
851
852func treasuryClawbackLink(daoID uint64) string {
853	return md.Link("Treasury Clawback", realmLink.Call(
854		"CreateTreasuryClawbackProposal",
855		"daoID", strconv.FormatUint(daoID, 10),
856		"targetID", "",
857	))
858}
859
860func treasuryFreezeLink(daoID uint64) string {
861	return md.Link("Treasury Freeze", realmLink.Call(
862		"CreateTreasuryFreezeProposal",
863		"daoID", strconv.FormatUint(daoID, 10),
864		"targetID", "",
865		"frozen", "true",
866	))
867}
868
869func treasurySpendLink(daoID uint64) string {
870	return md.Link("Treasury Spend", realmLink.Call(
871		"CreateTreasurySpendProposal",
872		"daoID", strconv.FormatUint(daoID, 10),
873		"to", "",
874		"denom", "ugnot",
875		"amount", "",
876	))
877}
878
879func registerKindLink(daoID uint64) string {
880	return md.Link("Register Proposal Kind", realmLink.Call(
881		"CreateRegisterKindProposal",
882		"daoID", strconv.FormatUint(daoID, 10),
883		"kindName", "",
884	))
885}
886
887func deregisterKindLink(daoID uint64) string {
888	return md.Link("Deregister Proposal Kind", realmLink.Call(
889		"CreateDeregisterKindProposal",
890		"daoID", strconv.FormatUint(daoID, 10),
891		"kindName", "",
892	))
893}
894
895func amendBylawsLink(daoID uint64) string {
896	return md.Link("Amend Bylaws", realmLink.Call(
897		"CreateAmendBylawsProposal",
898		"daoID", strconv.FormatUint(daoID, 10),
899		"payload", "",
900	))
901}
902
903func goToDAOLink(daoID uint64) string {
904	return md.Link("Go to DAO", daoURL(daoID))
905}
906
907func goToProposalLink(daoID, proposalID uint64) string {
908	return md.Link("Go to Proposal", proposalURL(daoID, proposalID))
909}
910
911func userLink(addr address) string {
912	user := users.ResolveAddress(addr)
913	if user != nil {
914		return user.RenderLink("")
915	}
916	return addr.String()
917}
918
919func isVotingPeriodActive(p *commondao.Proposal) bool {
920	return p.Status() == commondao.StatusActive && time.Now().Before(p.VotingDeadline())
921}
922
923func isExecutionAllowed(p *commondao.Proposal) bool {
924	// Early passed proposals can be executed right away; active proposals
925	// can be finalized once their voting deadline passes.
926	if p.Status() == commondao.StatusPassed {
927		return true
928	}
929	return p.Status() == commondao.StatusActive && !time.Now().Before(p.VotingDeadline())
930}