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

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}