home.gno
12.29 Kb · 367 lines
1// Package home is the realm behind https://gno.land/u/clockwork.
2//
3// gnoweb builds a user profile page by calling Render("") on the realm at the
4// exact path /r/<username>/home (gno.land/pkg/gnoweb/handler_http.go,
5// GetUserView). That lookup does no version resolution, so this path is the
6// interface with gnoweb and can never carry a /vN suffix. Everything that
7// might want to change therefore lives behind the path, in four layers:
8//
9// 1. CONTENT is data. The page is assembled from SLOTS: named markdown
10// fragments in an avl tree, each written by its own Set call. Updating
11// one paragraph is one small transaction.
12// 2. LAYOUT is data. The slot named "layout" is the page template; every
13// :slug: placeholder in it is filled from the slot of that name.
14// 3. STYLE is data. Slots under the "style." prefix are knobs (colors,
15// header mode) that the theme reads. Changing the accent color is a Set.
16// 4. RENDERING is code, but replaceable. A THEME is a separate realm nested
17// under this one that implements Theme and registers itself from its own
18// init. The authority accepts it through p/clockwork/upgradeable and can
19// roll it back. The slots never move: an upgrade replaces the code that
20// reads them, not the data. When no theme is live the built-in renderer
21// in render.gno serves the page, and the operator views (system, slots,
22// edit, manifest) are always served by this realm, so a theme that
23// panics can be rolled back from the page it cannot break.
24//
25// This realm holds state and forwards. It is deliberately small, because it
26// is the one piece that can never be redeployed: it is importable by its
27// themes, so it cannot be private, so AddPackage refuses a second deploy.
28package home
29
30import (
31 "crypto/sha256"
32 "encoding/hex"
33 "strconv"
34 "strings"
35
36 "chain/runtime"
37
38 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/upgradeable/v0"
39 "gno.land/p/nt/avl/v0"
40)
41
42// Admin is the initial authority: the only address that may write slots and
43// accept, roll back or freeze a theme. It is a constant so a reviewer can read
44// who controls the page without decoding chain state; TransferAuthority moves
45// it afterwards.
46//
47// The value here is the standard test1 account, so the gnodev walkthrough in
48// the README works unedited. `make build ADMIN=g1...` rewrites it for a real
49// deployment.
50const Admin = address("g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm")
51
52const (
53 // LayoutSlug names the slot used as the page template.
54 LayoutSlug = "layout"
55
56 // StylePrefix marks slots that are style knobs rather than content.
57 // Style("accent", ...) reads the slot "style.accent".
58 StylePrefix = "style."
59
60 // maxSlugLen bounds a slot name so an index row stays readable.
61 maxSlugLen = 64
62)
63
64// Theme is what a renderer realm promises. It is declared here, in the file
65// that can never change, so every theme version agrees on one type identity.
66//
67// Render receives the render path ("" is the profile page) and returns
68// markdown. The operator views (system, slots, edit, manifest) never reach a
69// theme; for any other path it does not draw itself, a theme should return
70// Fallback(path). A panic here aborts the query: there is no recovering a
71// foreign realm's panic, so a broken theme shows an error until Rollback.
72type Theme interface {
73 Render(path string) string
74}
75
76// Slot is one named fragment of the page.
77type Slot struct {
78 Body string
79 Rev int // Revision() at the time of the last write to this slot
80 Height int64 // block height of that write
81}
82
83var (
84 proxy *upgradeable.Proxy
85 selfPath string
86
87 slots = avl.NewTree() // slug -> *Slot
88 rev int // total writes accepted; bumps on every mutation
89 lastHeight int64 // height of the most recent write
90)
91
92func init(cur realm) {
93 // Captured rather than written as a constant, so links and the nesting
94 // rule follow the path this code was actually deployed at.
95 selfPath = cur.PkgPath()
96 proxy = upgradeable.New(upgradeable.NewAddrAuthority(Admin))
97}
98
99// ---------------------------------------------------------------------------
100// Authority
101
102// assertAuthority panics unless the caller of the crossing function that owns
103// cur is the current authority. It asks the proxy's Authority directly rather
104// than going through proxy.AssertAuthorized, so content writes keep working
105// after Freeze: freezing ends code upgrades, not editing.
106func assertAuthority(cur realm) {
107 caller := cur.Previous()
108 if !proxy.Authority().Authorized(caller.Address(), caller.PkgPath()) {
109 panic("home: restricted to the authority")
110 }
111}
112
113// ---------------------------------------------------------------------------
114// Slugs
115
116// reservedSlugs are placeholders computed at render time (see render.gno). A
117// slot may not take one of these names, so the page never depends on which
118// of the two would win.
119var reservedSlugs = []string{"chainid", "height", "owner", "realm", "rev", "slots", "theme", "updated"}
120
121// validSlug reports whether slug is a legal slot name: 1..maxSlugLen bytes of
122// [a-z0-9._-]. No ':' so a slug can never break out of its own :slug:
123// placeholder, and no uppercase so a slot has exactly one name.
124func validSlug(slug string) bool {
125 if len(slug) == 0 || len(slug) > maxSlugLen {
126 return false
127 }
128 for i := 0; i < len(slug); i++ {
129 c := slug[i]
130 switch {
131 case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
132 case c == '-', c == '_', c == '.':
133 default:
134 return false
135 }
136 }
137 return true
138}
139
140func assertWritableSlug(slug string) {
141 if !validSlug(slug) {
142 panic("home: invalid slug " + strconv.Quote(slug) +
143 ": want 1-" + strconv.Itoa(maxSlugLen) + " bytes of [a-z0-9._-]")
144 }
145 for _, r := range reservedSlugs {
146 if slug == r {
147 panic("home: reserved slug " + slug + " is computed at render time")
148 }
149 }
150}
151
152// ---------------------------------------------------------------------------
153// Writes (authority only)
154
155// Set creates or replaces the slot named slug. This is the ordinary update:
156// one slot, one transaction. An empty body keeps the slot but empties it; use
157// Delete to remove it.
158func Set(cur realm, slug, body string) {
159 assertAuthority(cur)
160 assertWritableSlug(slug)
161 write(slug, body)
162}
163
164// Append adds to the end of a slot, creating it when absent. It exists for a
165// body too large for one transaction: Set the first chunk, Append the rest.
166func Append(cur realm, slug, body string) {
167 assertAuthority(cur)
168 assertWritableSlug(slug)
169 write(slug, Get(slug)+body)
170}
171
172// Delete removes a slot. Deleting the layout slot restores the theme's
173// default layout.
174func Delete(cur realm, slug string) {
175 assertAuthority(cur)
176 if _, removed := slots.Remove(slug); !removed {
177 panic("home: no such slot: " + slug)
178 }
179 bump()
180}
181
182func write(slug, body string) {
183 bump()
184 slots.Set(slug, &Slot{Body: body, Rev: rev, Height: lastHeight})
185}
186
187func bump() {
188 rev++
189 lastHeight = runtime.ChainHeight()
190}
191
192// ---------------------------------------------------------------------------
193// Reads (anyone, including themes)
194
195// Get returns a slot body, or "" when the slot does not exist.
196func Get(slug string) string {
197 v := slots.Get(slug)
198 if v == nil {
199 return ""
200 }
201 return v.(*Slot).Body
202}
203
204// Has reports whether a slot exists, empty or not.
205func Has(slug string) bool { return slots.Has(slug) }
206
207// Lookup returns a copy of a slot and whether it exists.
208func Lookup(slug string) (Slot, bool) {
209 v := slots.Get(slug)
210 if v == nil {
211 return Slot{}, false
212 }
213 return *v.(*Slot), true
214}
215
216// Slugs returns every slot name, sorted.
217func Slugs() []string {
218 out := make([]string, 0, slots.Size())
219 slots.Iterate("", "", func(key string, _ any) bool {
220 out = append(out, key)
221 return false
222 })
223 return out
224}
225
226// Each visits every slot in name order until fn returns true.
227func Each(fn func(slug string, s Slot) bool) {
228 slots.Iterate("", "", func(key string, value any) bool {
229 return fn(key, *value.(*Slot))
230 })
231}
232
233// Style returns the style knob named key (the slot "style."+key), or fallback
234// when it is absent or empty.
235func Style(key, fallback string) string {
236 if v := Get(StylePrefix + key); v != "" {
237 return v
238 }
239 return fallback
240}
241
242// Layout returns the layout slot, or "" when none has been set. Themes
243// substitute their own default in that case.
244func Layout() string { return Get(LayoutSlug) }
245
246// Revision returns the total number of writes this realm has accepted. It
247// changes on every mutation, so a client can cheaply tell "nothing moved".
248func Revision() int { return rev }
249
250// LastHeight returns the block height of the most recent write, or 0.
251func LastHeight() int64 { return lastHeight }
252
253// Manifest returns one tab-separated line per slot:
254//
255// <slug>\t<rev>\t<bytes>\t<sha256 of body, hex>
256//
257// It is the diff surface scripts/sync.sh reads: one query tells it exactly
258// which local file is out of date, without downloading any body.
259func Manifest() string {
260 var b strings.Builder
261 slots.Iterate("", "", func(key string, value any) bool {
262 s := value.(*Slot)
263 sum := sha256.Sum256([]byte(s.Body))
264 b.WriteString(key)
265 b.WriteString("\t")
266 b.WriteString(strconv.Itoa(s.Rev))
267 b.WriteString("\t")
268 b.WriteString(strconv.Itoa(len(s.Body)))
269 b.WriteString("\t")
270 b.WriteString(hex.EncodeToString(sum[:]))
271 b.WriteString("\n")
272 return false
273 })
274 return b.String()
275}
276
277// Path returns this realm's package path, e.g. "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home".
278func Path() string { return selfPath }
279
280// Link returns the gnoweb path of a render path on this realm:
281// Link("") is "/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home", Link("slots") is "/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home:slots".
282func Link(sub string) string {
283 p := selfPath
284 if i := strings.Index(p, "/"); i >= 0 {
285 p = p[i:]
286 }
287 if sub == "" {
288 return p
289 }
290 return p + ":" + sub
291}
292
293// ---------------------------------------------------------------------------
294// Themes: the upgradeable part.
295//
296// A theme realm nested under this path (…/home/theme/vN) calls Register from
297// its own init, so deploying it is what nominates it. Nothing serves until the
298// authority runs Accept with that path, which is a separate transaction naming
299// code anyone can go and read. Rollback puts the previous theme back.
300
301// Register records the calling realm's theme as a candidate. The path filed
302// is read off the crossing frame, never taken as an argument, so a candidate
303// cannot claim to live somewhere it does not. Only realms nested under this
304// one are admitted; a direct user call is refused.
305func Register(cur realm, t Theme) { proxy.Propose(0, cur, t) }
306
307// Accept makes the candidate at pkgPath the live theme. Authority only.
308func Accept(cur realm, pkgPath string) { proxy.Accept(0, cur, pkgPath) }
309
310// Withdraw drops a candidate. Authority only, except that a theme realm may
311// always drop its own.
312func Withdraw(cur realm, pkgPath string) { proxy.Withdraw(0, cur, pkgPath) }
313
314// Rollback restores the previous theme and returns the current one to the
315// pending set. Authority only.
316func Rollback(cur realm) { proxy.Rollback(0, cur) }
317
318// Forget drops the rollback history and the storage it holds. Authority only.
319func Forget(cur realm) { proxy.Forget(0, cur) }
320
321// Freeze ends theme upgrades permanently. Slots stay editable. Authority
322// only, and there is no way back.
323func Freeze(cur realm) { proxy.Freeze(0, cur) }
324
325// TransferAuthority hands both editing and upgrading to another address.
326func TransferAuthority(cur realm, to address) {
327 proxy.TransferAuthority(0, cur, upgradeable.NewAddrAuthority(to))
328}
329
330// LivePath returns the realm path of the theme currently serving, or "".
331func LivePath() string { return proxy.LivePath() }
332
333// PendingPaths returns the candidate theme paths awaiting acceptance.
334func PendingPaths() []string {
335 out := []string{}
336 for _, r := range proxy.Pending() {
337 out = append(out, r.PkgPath())
338 }
339 return out
340}
341
342// HistoryPaths returns the themes this realm has already served, oldest
343// first, excluding the live one.
344func HistoryPaths() []string {
345 out := []string{}
346 for _, r := range proxy.History() {
347 out = append(out, r.PkgPath())
348 }
349 return out
350}
351
352// Frozen reports whether theme upgrades have ended.
353func Frozen() bool { return proxy.Frozen() }
354
355// Authority returns a human-readable description of who may write and
356// upgrade.
357func Authority() string { return proxy.Authority().String() }
358
359// liveTheme returns the accepted theme, if any.
360func liveTheme() (Theme, bool) {
361 v, ok := proxy.TryImpl()
362 if !ok {
363 return nil, false
364 }
365 t, ok := v.(Theme)
366 return t, ok
367}