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

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}