writes.gno
10.01 Kb · 365 lines
1package home
2
3import (
4 "chain"
5 "chain/banker"
6 "chain/runtime"
7 "chain/runtime/unsafe"
8 "strconv"
9 "strings"
10 "time"
11 "unicode"
12 "unicode/utf8"
13)
14
15// Every write function calls runtime.AssertOriginCall() first, in its own
16// body: from inside a helper it refuses every call.
17
18const maxSignBytes = 560
19
20func invalid(field string) string { return errInvalidInput + field }
21
22// checkLine enforces valid UTF-8, a rune count in [min, max], and no control
23// characters (so no newline either): the text may end up inside an SVG,
24// where they would break the XML.
25func checkLine(field, s string, min, max int) {
26 if !utf8.ValidString(s) {
27 panic(invalid(field))
28 }
29 n := 0
30 for _, r := range s {
31 if r < 0x20 || r == 0x7f || r == 0xfffe || r == 0xffff {
32 panic(invalid(field))
33 }
34 n++
35 }
36 if n < min || n > max {
37 panic(invalid(field))
38 }
39}
40
41// assertMasterKey refuses a transaction signed by a session key. Delegated
42// keys may edit content, but not move ownership or hide an entry for good.
43func assertMasterKey() {
44 if _, _, _, isSession := runtime.GetSessionInfo(); isSession {
45 panic(errMasterKey)
46 }
47}
48
49// checkBadgeURL wants a printable-ASCII https:// URL (non-ASCII must be
50// percent-encoded) with no quotes, backslashes, backticks, parentheses,
51// square brackets, < or >, so the markdown link can't end early or become
52// javascript:.
53func checkBadgeURL(u string) {
54 if !strings.HasPrefix(u, "https://") {
55 panic(invalid("url"))
56 }
57 for i := 0; i < len(u); i++ {
58 c := u[i]
59 if c <= 0x20 || c >= 0x7f || strings.IndexByte("\"'\\`()[]<>", c) >= 0 {
60 panic(invalid("url"))
61 }
62 }
63}
64
65// parseBadges reads one badge per line, "label | value | url". Blank lines
66// between two badges start a new row; other blank lines are skipped.
67func parseBadges(spec string) []badge {
68 var out []badge
69 row := false
70 for _, line := range strings.Split(spec, "\n") {
71 line = strings.TrimSpace(line)
72 if line == "" {
73 row = len(out) > 0
74 continue
75 }
76 parts := strings.Split(line, "|")
77 if len(parts) != 3 || len(out) == maxBadges {
78 panic(invalid("spec"))
79 }
80 b := badge{
81 label: strings.TrimSpace(parts[0]),
82 value: strings.TrimSpace(parts[1]),
83 url: strings.TrimSpace(parts[2]),
84 row: row,
85 }
86 row = false
87 checkLine("label", b.label, 1, 24)
88 checkLine("value", b.value, 1, 24)
89 checkBadgeURL(b.url)
90 out = append(out, b)
91 }
92 return out
93}
94
95// hasVisible reports whether s has a character that draws something: not a
96// space, a format character (zero-width, bidi), a combining mark, or one of
97// the blank-looking fillers (Hangul fillers, braille blank).
98func hasVisible(s string) bool {
99 for _, r := range s {
100 switch {
101 case unicode.IsSpace(r), unicode.In(r, unicode.Cf, unicode.Mn, unicode.Me):
102 case r == 0x115f, r == 0x1160, r == 0x3164, r == 0xffa0, r == 0x2800:
103 default:
104 return true
105 }
106 }
107 return false
108}
109
110// validName wants 1 to max characters from a-z, 0-9 and "-": safe in a
111// link, a tab label, and a path gnoweb passes to Render.
112func validName(s string, max int) bool {
113 if len(s) < 1 || len(s) > max {
114 return false
115 }
116 for _, c := range s {
117 if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
118 return false
119 }
120 }
121 return true
122}
123
124// checkPage enforces the tab name, the page size, and the tab count.
125func checkPage(tab, md string) {
126 if !validName(tab, maxTabName) {
127 panic(invalid("tab"))
128 }
129 if len(md) > maxPageBytes || !utf8.ValidString(md) {
130 panic(invalid("md"))
131 }
132 if _, exists := pages[tab]; !exists && len(tabs) >= maxTabs {
133 panic(invalid("tab"))
134 }
135}
136
137// checkPost enforces the slug, a one-line title, and the body size. Title
138// and body must each show something.
139func checkPost(slug, title, body string) {
140 if !validName(slug, maxSlugBytes) {
141 panic(invalid("slug"))
142 }
143 checkLine("title", title, 1, maxTitleRunes)
144 if !hasVisible(title) {
145 panic(invalid("title"))
146 }
147 if len(body) > maxPageBytes || !utf8.ValidString(body) || !hasVisible(body) {
148 panic(invalid("body"))
149 }
150}
151
152// seqKey zero-pads n so that keys sort in number order.
153func seqKey(n int) string {
154 s := strconv.Itoa(n)
155 return strings.Repeat("0", seqWidth-len(s)) + s
156}
157
158func touch() { updated = runtime.ChainHeight() }
159
160// ----- owner only -----
161
162// SetNow sets the one-line status shown on Home; empty clears it.
163func SetNow(cur realm, text string) {
164 runtime.AssertOriginCall()
165 assertOwner(cur)
166 checkLine("text", text, 0, maxLineRunes)
167 now = nowLine{text: text, height: runtime.ChainHeight()}
168 touch()
169 chain.Emit("NowSet", "text", text)
170}
171
172// SetProfile sets the name and tagline drawn on the banner.
173func SetProfile(cur realm, name, tagline string) {
174 runtime.AssertOriginCall()
175 assertOwner(cur)
176 checkLine("name", name, 1, 24)
177 checkLine("tagline", tagline, 0, 60)
178 profile = profileData{name: name, tagline: tagline}
179 touch()
180 chain.Emit("ProfileSet", "name", name, "tagline", tagline)
181}
182
183// SetBadges replaces every badge. One badge per line: "label | value | url";
184// a blank line starts a new row.
185func SetBadges(cur realm, spec string) {
186 runtime.AssertOriginCall()
187 assertOwner(cur)
188 badges = parseBadges(spec)
189 touch()
190 chain.Emit("BadgesSet", "count", strconv.Itoa(len(badges)))
191}
192
193// SetPage creates or replaces any tab's markdown, Blog and Guestbook
194// included. A new tab goes just before Guestbook, which stays last.
195func SetPage(cur realm, tab, md string) {
196 runtime.AssertOriginCall()
197 assertOwner(cur)
198 checkPage(tab, md)
199 if _, exists := pages[tab]; !exists {
200 tabs = append(tabs[:len(tabs)-1], tab, guestbookTab) // guestbook stays last
201 }
202 pages[tab] = md
203 touch()
204 chain.Emit("PageSet", "tab", tab)
205}
206
207// RemovePage removes any tab except home, blog and guestbook.
208func RemovePage(cur realm, tab string) {
209 runtime.AssertOriginCall()
210 assertOwner(cur)
211 if tab == homeTab || tab == blogTab || tab == guestbookTab {
212 panic(errTabsRequired)
213 }
214 if _, ok := pages[tab]; !ok {
215 panic(errNoTab)
216 }
217 delete(pages, tab)
218 kept := make([]string, 0, len(tabs)-1)
219 for _, t := range tabs {
220 if t != tab {
221 kept = append(kept, t)
222 }
223 }
224 tabs = kept
225 touch()
226 chain.Emit("PageRemoved", "tab", tab)
227}
228
229// SetPost publishes a post, or edits the post at slug. An edit keeps the
230// post's place in the list and its publish date.
231func SetPost(cur realm, slug, title, body string) {
232 runtime.AssertOriginCall()
233 assertOwner(cur)
234 checkPost(slug, title, body)
235 today := time.Now().UTC().Format("2006-01-02")
236 touch()
237 if p, ok := posts.Get(slug).(*post); ok {
238 p.title, p.body, p.edited = title, body, today
239 chain.Emit("PostEdited", "slug", slug)
240 return
241 }
242 postSeq++
243 seq := seqKey(postSeq)
244 posts.Set(slug, &post{title: title, body: body, published: today, seq: seq})
245 postOrder.Set(seq, slug)
246 chain.Emit("PostPublished", "slug", slug)
247}
248
249// RemovePost deletes a post for good; its slug can be used again.
250func RemovePost(cur realm, slug string) {
251 runtime.AssertOriginCall()
252 assertOwner(cur)
253 v, ok := posts.Remove(slug)
254 if !ok {
255 panic(errNoPost)
256 }
257 postOrder.Remove(v.(*post).seq)
258 touch()
259 chain.Emit("PostRemoved", "slug", slug)
260}
261
262// Hide hides an author's guestbook entry for good. The entry stays stored,
263// so the author can't sign again, and nobody's deposit is refunded. Hiding
264// a hidden entry does nothing.
265func Hide(cur realm, author address) {
266 runtime.AssertOriginCall()
267 assertOwner(cur)
268 assertMasterKey()
269 e, ok := entries.Get(author.String()).(*entry)
270 if !ok {
271 panic(errNoEntry)
272 }
273 if e.hidden {
274 return
275 }
276 e.hidden = true
277 touch()
278 chain.Emit("EntryHidden", "author", author.String())
279}
280
281// TransferOwnership records newOwner as pending; nothing changes until that
282// address calls AcceptOwnership. Naming the current owner cancels.
283func TransferOwnership(cur realm, newOwner address) {
284 runtime.AssertOriginCall()
285 assertOwner(cur)
286 assertMasterKey()
287 if !newOwner.IsValid() {
288 panic(invalid("newOwner"))
289 }
290 touch()
291 if newOwner == owner {
292 pendingOwner = ""
293 chain.Emit("OwnershipTransferCanceled", "owner", owner.String())
294 return
295 }
296 pendingOwner = newOwner
297 chain.Emit("OwnershipTransferStarted", "from", owner.String(), "to", newOwner.String())
298}
299
300// AcceptOwnership completes a transfer. Only the pending owner may call it,
301// and a realm address can't pass the account guard, so it never can.
302func AcceptOwnership(cur realm) {
303 runtime.AssertOriginCall()
304 caller := callerOf(cur)
305 if pendingOwner == "" || caller != pendingOwner {
306 panic(errPendingOnly)
307 }
308 prev := owner
309 owner = caller
310 pendingOwner = ""
311 touch()
312 chain.Emit("OwnershipTransferred", "from", prev.String(), "to", caller.String())
313}
314
315// ----- visitors -----
316
317// Wave adds one to the wave count. It's a plain counter: anyone can wave
318// again, and a wave stores nothing but the count.
319func Wave(cur realm) {
320 runtime.AssertOriginCall()
321 from := callerOf(cur).String()
322 waves++
323 chain.Emit("Waved", "from", from)
324}
325
326// Sign leaves one guestbook entry per address. The message is stored trimmed
327// and sanitized when rendered, and must contain a visible character.
328func Sign(cur realm, msg string) {
329 runtime.AssertOriginCall()
330 caller := callerOf(cur)
331 if len(msg) > maxSignBytes {
332 panic(invalid("msg"))
333 }
334 msg = strings.TrimSpace(msg)
335 checkLine("msg", msg, 1, maxLineRunes)
336 if !hasVisible(msg) {
337 panic(invalid("msg")) // the entry would show up blank
338 }
339 key := caller.String()
340 if entries.Has(key) {
341 panic(errSigned)
342 }
343 entries.Set(key, &entry{msg: msg, height: runtime.ChainHeight()})
344 order.Set(seqKey(order.Size()+1), caller)
345 chain.Emit("Signed", "from", key)
346}
347
348// Tip forwards exactly the ugnot sent with this call to the owner. The
349// account guard is inlined so the audit harness sees it next to OriginSend.
350// It forwards what was sent, never the realm's balance: the OriginSend
351// banker refuses to spend more than was sent.
352func Tip(cur realm) {
353 runtime.AssertOriginCall()
354 if !cur.IsCurrent() || !cur.Previous().IsUserCall() {
355 panic(errUnauthorized)
356 }
357 sent := unsafe.OriginSend()
358 if len(sent) != 1 || sent[0].Denom != "ugnot" || sent[0].Amount <= 0 {
359 panic(errUgnotOnly)
360 }
361 banker.NewBanker(banker.BankerTypeOriginSend, cur).SendCoins(cur.Address(), owner, sent)
362 tips.count++
363 tips.total += sent[0].Amount
364 chain.Emit("Tipped", "from", cur.Previous().Address().String(), "amount", strconv.FormatInt(sent[0].Amount, 10))
365}