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("<")
30 case r == '>':
31 sb.WriteString(">")
32 case r == '&':
33 sb.WriteString("&")
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}