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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

render.gno

10.35 Kb · 390 lines
  1// Package render is the Remix Lab rendering block: escaping for untrusted
  2// text, Markdown tables, paginated lists, route parsing and gnoweb
  3// transaction links.
  4//
  5// Every string that came from a user must pass through Text or Paragraph
  6// before it reaches Markdown. Escaped output cannot open links, images,
  7// HTML, headings, tables, code spans, bare URLs or @mentions. (gnoweb still
  8// links a bare g1 address; that target is a first-party user page.)
  9// Invisible and direction-changing format characters are dropped.
 10package render
 11
 12import (
 13	"net/url"
 14	"strconv"
 15	"strings"
 16)
 17
 18// Text escapes s for inline Markdown. Newlines and control characters become
 19// spaces, invisible and direction-changing format characters are dropped,
 20// every ASCII punctuation character Markdown or gnoweb could interpret is
 21// backslash-escaped, and "<", ">" and "&" become entities.
 22func Text(s string) string {
 23	var sb strings.Builder
 24	for _, r := range s {
 25		switch {
 26		case isHiddenFormat(r):
 27			// dropped
 28		case r == '<':
 29			sb.WriteString("&lt;")
 30		case r == '>':
 31			sb.WriteString("&gt;")
 32		case r == '&':
 33			sb.WriteString("&amp;")
 34		case r < 0x20 || r == 0x7f || (r >= 0x80 && r < 0xa0) || r == 0x2028 || r == 0x2029:
 35			sb.WriteByte(' ')
 36		case r < 0x80 && isMarkdownPunct(byte(r)):
 37			sb.WriteByte('\\')
 38			sb.WriteRune(r)
 39		default:
 40			sb.WriteRune(r)
 41		}
 42	}
 43	return sb.String()
 44}
 45
 46// Paragraph escapes multi-line text, keeping line breaks as hard breaks.
 47// Leading indentation is removed so no line becomes a code block.
 48func Paragraph(s string) string {
 49	lines := strings.Split(s, "\n")
 50	for i, l := range lines {
 51		lines[i] = Text(strings.TrimLeft(l, " \t"))
 52	}
 53	return strings.Join(lines, "  \n")
 54}
 55
 56// Code renders s as an inline code span that cannot be closed early. Control
 57// characters become spaces, format characters are dropped, and "|" becomes
 58// "¦" so the span is also safe inside a table cell.
 59func Code(s string) string {
 60	s = strings.Map(func(r rune) rune {
 61		switch {
 62		case r < 0x20 || r == 0x7f || (r >= 0x80 && r < 0xa0) || r == 0x2028 || r == 0x2029:
 63			return ' '
 64		case isHiddenFormat(r):
 65			return -1
 66		case r == '|':
 67			return '¦'
 68		}
 69		return r
 70	}, s)
 71	fence := "`"
 72	for strings.Contains(s, fence) {
 73		fence += "`"
 74	}
 75	return fence + " " + s + " " + fence
 76}
 77
 78func isMarkdownPunct(c byte) bool {
 79	return strings.IndexByte("\\`*_{}[]()#+-.!|~:\"'=$%^/@", c) >= 0
 80}
 81
 82// isHiddenFormat matches core.IsHiddenFormat: invisible or
 83// direction-changing format characters (ZWNJ and ZWJ are kept).
 84func isHiddenFormat(r rune) bool {
 85	switch {
 86	case r == 0x00ad, r == 0x061c, r == 0x180e, r == 0x200b, r == 0x200e, r == 0x200f, r == 0xfeff:
 87		return true
 88	case r >= 0x202a && r <= 0x202e:
 89		return true
 90	case r >= 0x2060 && r <= 0x206f:
 91		return true
 92	case r >= 0xfff9 && r <= 0xfffb:
 93		return true
 94	}
 95	return false
 96}
 97
 98// Heading returns a Markdown heading with escaped text. level is clamped to
 99// 1..6.
100func Heading(level int, text string) string {
101	if level < 1 {
102		level = 1
103	}
104	if level > 6 {
105		level = 6
106	}
107	return strings.Repeat("#", level) + " " + Text(text) + "\n\n"
108}
109
110// Table renders a Markdown table. Headers are escaped with Text. Row cells
111// are NOT escaped: build rows with Row (untrusted text) or TrustedRow
112// (markup from Link, TxLink, Code). As a last line of defence every cell has
113// line breaks replaced and unescaped "|" escaped, so a cell can never split
114// the row or end the table. Rows shorter than the header are padded; longer
115// rows are truncated.
116func Table(headers []string, rows [][]string) string {
117	if len(headers) == 0 {
118		return ""
119	}
120	var sb strings.Builder
121	sb.WriteString("|")
122	for _, h := range headers {
123		sb.WriteString(" " + Text(h) + " |")
124	}
125	sb.WriteString("\n|")
126	for range headers {
127		sb.WriteString(" --- |")
128	}
129	sb.WriteString("\n")
130	for _, row := range rows {
131		sb.WriteString("|")
132		for i := range headers {
133			cell := ""
134			if i < len(row) {
135				cell = tableCell(row[i])
136			}
137			sb.WriteString(" " + cell + " |")
138		}
139		sb.WriteString("\n")
140	}
141	return sb.String() + "\n"
142}
143
144// tableCell keeps a cell on one line and inside its column.
145func tableCell(c string) string {
146	var sb strings.Builder
147	backslashes := 0
148	for _, r := range c {
149		switch {
150		case r == '\n' || r == '\r' || r == 0x85 || r == 0x2028 || r == 0x2029:
151			sb.WriteByte(' ')
152		case r == '|' && backslashes%2 == 0:
153			sb.WriteString("\\|")
154		default:
155			sb.WriteRune(r)
156		}
157		if r == '\\' {
158			backslashes++
159		} else {
160			backslashes = 0
161		}
162	}
163	return sb.String()
164}
165
166// TrustedRow marks a row whose cells were already built from escaped text
167// and trusted markup (for example links from Link or TxLink). Use Row for
168// untrusted cells.
169func TrustedRow(cells ...string) []string { return cells }
170
171// Row escapes every cell.
172func Row(cells ...string) []string {
173	out := make([]string, len(cells))
174	for i, c := range cells {
175		out[i] = Text(c)
176	}
177	return out
178}
179
180// Link renders a link with an escaped label to a realm-relative target,
181// e.g. "/r/foo/bar:polls/3". External or scheme-bearing targets are refused
182// and rendered as plain escaped text.
183func Link(label, target string) string {
184	if !isRelativeRealmTarget(target) {
185		return Text(label)
186	}
187	return "[" + Text(label) + "](" + target + ")"
188}
189
190// RealmURL turns a package path into the realm-relative URL gnoweb serves,
191// optionally with a render path: RealmURL("gno.land/r/a/b", "polls/1") ->
192// "/r/a/b:polls/1".
193func RealmURL(pkgPath, renderPath string) string {
194	base, ok := relativeRealm(pkgPath)
195	if !ok {
196		return ""
197	}
198	if renderPath == "" {
199		return base
200	}
201	return base + ":" + escapePath(renderPath)
202}
203
204// RealmQueryURL is RealmURL with query parameters: keys must be Go
205// identifiers and values are URL-encoded, so any value (including "&", "="
206// or "%") reaches Render intact through ParsePath. route itself must not
207// contain "?". It returns "" on invalid input.
208func RealmQueryURL(pkgPath, route string, kv ...string) string {
209	if strings.IndexByte(route, '?') >= 0 || len(kv)%2 != 0 {
210		return ""
211	}
212	u := RealmURL(pkgPath, route)
213	if u == "" {
214		return ""
215	}
216	if route == "" {
217		u += ":"
218	}
219	for i := 0; i < len(kv); i += 2 {
220		if !isIdent(kv[i]) {
221			return ""
222		}
223		sep := "&"
224		if i == 0 {
225			sep = "?"
226		}
227		u += sep + kv[i] + "=" + url.QueryEscape(kv[i+1])
228	}
229	return u
230}
231
232// TxLink renders a gnoweb transaction link for fn on the realm at pkgPath
233// with the given key/value arguments. Function and key names must be Go
234// identifiers; values are URL-encoded. Invalid input renders the label as
235// plain text so a bad link never reaches the page.
236func TxLink(label, pkgPath, fn string, kv ...string) string {
237	u := TxURL(pkgPath, fn, kv...)
238	if u == "" {
239		return Text(label)
240	}
241	return "[" + Text(label) + "](" + u + ")"
242}
243
244// TxURL builds the gnoweb "$help" URL used by TxLink, or "" when the input
245// is invalid.
246func TxURL(pkgPath, fn string, kv ...string) string {
247	base, ok := relativeRealm(pkgPath)
248	if !ok || !isIdent(fn) || len(kv)%2 != 0 {
249		return ""
250	}
251	u := base + "$help&func=" + fn
252	for i := 0; i < len(kv); i += 2 {
253		if !isIdent(kv[i]) {
254			return ""
255		}
256		u += "&" + kv[i] + "=" + url.QueryEscape(kv[i+1])
257	}
258	return u
259}
260
261// ParsePath splits a render path such as "polls/3?page=2" into its route
262// segments and query values. Malformed queries yield empty values.
263func ParsePath(path string) ([]string, url.Values) {
264	route, rawQuery := path, ""
265	if i := strings.IndexByte(path, '?'); i >= 0 {
266		route, rawQuery = path[:i], path[i+1:]
267	}
268	var segs []string
269	for _, s := range strings.Split(route, "/") {
270		if s != "" {
271			segs = append(segs, s)
272		}
273	}
274	q, err := url.ParseQuery(rawQuery)
275	if err != nil {
276		q = url.Values{}
277	}
278	return segs, q
279}
280
281// Page reads a 1-based "page" query value, clamped to 1..maxPage (a maxPage
282// below 1 counts as 1).
283func Page(q url.Values, maxPage int) int {
284	if maxPage < 1 {
285		maxPage = 1
286	}
287	p, err := strconv.Atoi(q.Get("page"))
288	if err != nil || p < 1 {
289		return 1
290	}
291	if p > maxPage {
292		return maxPage
293	}
294	return p
295}
296
297// PageCount returns the number of pages needed for total items.
298func PageCount(total, pageSize int) int {
299	if total <= 0 || pageSize <= 0 {
300		return 1
301	}
302	return (total + pageSize - 1) / pageSize
303}
304
305// Pager renders previous/next links for a paginated route under pkgPath.
306// page is clamped to 1..pages.
307func Pager(pkgPath, route string, page, pages int) string {
308	if pages <= 1 {
309		return ""
310	}
311	if page < 1 {
312		page = 1
313	}
314	if page > pages {
315		page = pages
316	}
317	var parts []string
318	if page > 1 {
319		parts = append(parts, Link("← previous", RealmQueryURL(pkgPath, route, "page", strconv.Itoa(page-1))))
320	}
321	parts = append(parts, "page "+strconv.Itoa(page)+" of "+strconv.Itoa(pages))
322	if page < pages {
323		parts = append(parts, Link("next →", RealmQueryURL(pkgPath, route, "page", strconv.Itoa(page+1))))
324	}
325	return strings.Join(parts, " · ") + "\n\n"
326}
327
328// relativeRealm maps "gno.land/r/..." to "/r/...". Every path segment must
329// be non-empty and use only lowercase letters, digits, "_" and "-".
330func relativeRealm(pkgPath string) (string, bool) {
331	const prefix = "gno.land/r/"
332	if !strings.HasPrefix(pkgPath, prefix) || len(pkgPath) > 256 {
333		return "", false
334	}
335	for _, seg := range strings.Split(pkgPath[len(prefix):], "/") {
336		if seg == "" {
337			return "", false
338		}
339		for _, c := range seg {
340			lower := c >= 'a' && c <= 'z'
341			digit := c >= '0' && c <= '9'
342			if !(lower || digit || c == '_' || c == '-') {
343				return "", false
344			}
345		}
346	}
347	return pkgPath[len("gno.land"):], true
348}
349
350func isRelativeRealmTarget(t string) bool {
351	if !strings.HasPrefix(t, "/r/") && !strings.HasPrefix(t, "/p/") {
352		return false
353	}
354	for _, c := range t {
355		if c <= ' ' || c == '(' || c == ')' || c == '<' || c == '>' || c == '"' || c == '\\' || c == '`' {
356			return false
357		}
358	}
359	return !strings.Contains(t, "//")
360}
361
362// escapePath keeps a render path safe inside a Markdown link target.
363func escapePath(p string) string {
364	var sb strings.Builder
365	for i := 0; i < len(p); i++ {
366		c := p[i]
367		safe := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
368			strings.IndexByte("/-_.?=&:", c) >= 0
369		if safe {
370			sb.WriteByte(c)
371		} else {
372			sb.WriteString("%" + strings.ToUpper(strconv.FormatInt(int64(c)>>4, 16)) + strings.ToUpper(strconv.FormatInt(int64(c)&15, 16)))
373		}
374	}
375	return sb.String()
376}
377
378func isIdent(s string) bool {
379	if s == "" || len(s) > 64 {
380		return false
381	}
382	for i, c := range s {
383		letter := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
384		digit := c >= '0' && c <= '9'
385		if !(letter || (i > 0 && digit)) {
386			return false
387		}
388	}
389	return true
390}