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.

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}