kitindexdemo.gno
5.79 Kb · 179 lines
1// untrusted-render: the tag is charset-checked to [a-z0-9-] at write time and
2// is the only stored string interpolated raw; the body is a caller's text and
3// goes through ui.Cell at every call site that renders it.
4
5// Package kitindexdemo is a notes board whose only job is to show
6// gno.land/p/moul/kit/index doing the thing a realm with a store always ends
7// up needing: answering "every note tagged gno" as cheaply as it answers
8// "note 7".
9//
10// There is no logic of its own here. The records are a kit/store, the two
11// lookups are kit/index, the page is kit/ui, and what is worth reading is the
12// shape: every write touches the store and both indexes in ONE function, which
13// is the entire correctness argument for keeping them in separate containers.
14//
15// Demo of the p/moul/kit/index library.
16package kitindexdemo
17
18import (
19 "strconv"
20 "strings"
21
22 "chain/runtime"
23
24 "gno.land/p/moul/kit/index/v0"
25 "gno.land/p/moul/kit/store/v0"
26)
27
28// MaxBody is the longest note this realm accepts, in bytes.
29//
30// A bound rather than none: every byte stored here locks a storage deposit,
31// paid by whoever signs the write and refunded to whoever signs the delete
32// (EFFECTIVE_GNO.md section 9.2), and an unbounded write is an unbounded
33// state the realm carries forever.
34const MaxBody = 280
35
36// MaxTagLen bounds the index key for the same reason, and small because a tag
37// is a label rather than a sentence.
38const MaxTagLen = 24
39
40// PageSize is how many tags the root page shows.
41const PageSize = 20
42
43// MaxNotes and MaxPerAuthor bound the WHOLE board, which MaxBody does not.
44//
45// A per-call length limit bounds one write and nothing else: a caller posting
46// one-byte notes in a loop grows the store and both indexes without limit. "An unbounded container a third party can grow is an
47// unbounded storage deposit someone is paying for" is a rule this realm exists
48// to demonstrate, so it had better obey it.
49//
50// Two caps rather than one, and what each buys is narrower than it looks.
51// MaxNotes bounds the state, which is the property this realm promises.
52// MaxPerAuthor stops one ADDRESS, not one actor: addresses are free, so fifty
53// fresh ones at twenty notes each still fill the board and lock everyone else
54// out until somebody retracts. Keeping a board open to strangers needs
55// something a griefer cannot mint for free, a deposit or an expiry, which this
56// demo does not carry. Retracting frees a slot, so neither cap is a one-way door.
57//
58// MaxPerAuthor is answered by byAuthor.Count, which is the index doing the job
59// it was added for.
60const (
61 MaxNotes = 1000
62 MaxPerAuthor = 20
63)
64
65type note struct {
66 Author address
67 Tag string
68 Body string
69 Height int64
70}
71
72var (
73 notes = store.Named("note")
74 byTag index.Index
75 byAuthor index.Index
76)
77
78// Post files a note under a tag and returns its id.
79//
80// The store write and both index writes happen here, in this order, in one
81// frame. An abort anywhere in it leaves none of them applied, which is what
82// makes three containers safe to keep apart.
83func Post(cur realm, tag, body string) int64 {
84 who := caller(cur)
85 tag = normaliseTag(tag)
86 assertBody(body)
87 assertRoom(who)
88
89 id := notes.Add(¬e{
90 Author: who,
91 Tag: tag,
92 Body: body,
93 Height: runtime.ChainHeight(),
94 })
95 mustIndex(byTag.Add(tag, id))
96 mustIndex(byAuthor.Add(who.String(), id))
97 return int64(id)
98}
99
100// Retract removes a note. Only its author may, and the store and both indexes
101// are unwound together.
102func Retract(cur realm, id int64) {
103 who := caller(cur)
104 sid, ok := store.ParseID(strconv.FormatInt(id, 10))
105 if !ok {
106 panic("kitindexdemo: not an id: " + strconv.FormatInt(id, 10))
107 }
108 n, ok := notes.Get(sid)
109 if !ok {
110 panic("kitindexdemo: note #" + sid.String() + " not found")
111 }
112 rec := n.(*note)
113 if rec.Author != who {
114 panic("kitindexdemo: note #" + sid.String() + " is not yours")
115 }
116 notes.Remove(sid)
117 byTag.Remove(rec.Tag, sid)
118 byAuthor.Remove(rec.Author.String(), sid)
119}
120
121// caller is the one place this realm decides who is acting: the realm token is
122// checked before it is walked, because an unchecked token is not a caller.
123func caller(cur realm) address {
124 if !cur.IsCurrent() {
125 panic("kitindexdemo: spoofed realm")
126 }
127 return cur.Previous().Address()
128}
129
130func mustIndex(err error) {
131 if err != nil {
132 panic(err)
133 }
134}
135
136// normaliseTag lowercases and validates the tag.
137//
138// Validated at WRITE time rather than escaped at render time, deliberately: a
139// tag is also an index key and a path segment, so a tag that could carry a
140// pipe or a slash would break the table and the URL as well as the page. The
141// charset is the narrowest thing that still reads as a label.
142func normaliseTag(tag string) string {
143 tag = strings.ToLower(strings.TrimSpace(tag))
144 if tag == "" || len(tag) > MaxTagLen {
145 panic("kitindexdemo: a tag is 1 to " + strconv.Itoa(MaxTagLen) + " characters")
146 }
147 for _, r := range tag {
148 switch {
149 case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
150 default:
151 panic("kitindexdemo: a tag is [a-z0-9-], got " + strconv.Quote(tag))
152 }
153 }
154 return tag
155}
156
157// assertRoom refuses a write the board has no room for, globally or for this
158// address. Checked before the store write, so a refusal costs the caller the
159// gas of three reads and nothing is half-applied.
160func assertRoom(who address) {
161 if notes.Len() >= MaxNotes {
162 panic("kitindexdemo: the board is full at " + strconv.Itoa(MaxNotes) +
163 " notes; retract one to free a slot")
164 }
165 if n := byAuthor.Count(who.String()); n >= MaxPerAuthor {
166 panic("kitindexdemo: " + who.String() + " already has " + strconv.Itoa(n) +
167 " notes, the limit is " + strconv.Itoa(MaxPerAuthor))
168 }
169}
170
171func assertBody(body string) {
172 if strings.TrimSpace(body) == "" {
173 panic("kitindexdemo: the body is empty")
174 }
175 if len(body) > MaxBody {
176 panic("kitindexdemo: the body is " + strconv.Itoa(len(body)) +
177 " bytes, the limit is " + strconv.Itoa(MaxBody))
178 }
179}