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

6.59 Kb · 198 lines
  1// Package home is the realm behind https://gno.land/u/moul.
  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) and embedding the result as the profile body. There is no
  6// version resolution in that lookup, which is why this realm (alone in this
  7// repo) carries no /vN suffix: the bare path IS the interface with gnoweb,
  8// and gno.land/r/moul/home/v0 would never be found.
  9//
 10// # Slots
 11//
 12// The page is not hard-coded. It is assembled from SLOTS: named markdown
 13// fragments in an avl tree, each written by its own Set call, so updating one
 14// paragraph is one small transaction instead of a redeploy.
 15//
 16// The layout is itself a slot ("layout"), so the shape of the page (headings,
 17// order, what appears at all) changes without touching the code.
 18// p/moul/dynreplacer fills every :slug: placeholder found in the layout, lazily:
 19// a slot whose placeholder is absent from the layout costs nothing to render.
 20//
 21// Substitution is SINGLE-PASS and non-recursive. A placeholder inside a slot
 22// body is left alone, so no slot can expand into another and no cycle exists.
 23// Two registered placeholders can never be prefixes of one another either
 24// (every one is :slug: and a slug may not contain ':'), so the result does not
 25// depend on registration order.
 26//
 27// A placeholder with no matching slot survives into the output verbatim. That
 28// is deliberate: a missing section should be visible, not silently blank.
 29//
 30// # Versioning
 31//
 32// gnomod.toml declares private = true, which lets the creator re-add the
 33// package at this path. That redeploy re-runs init() and RESETS everything
 34// here, so the markdown under content/ is the source of truth and
 35// tools/gnohome pushes the slots back afterwards. The intended path is that this
 36// never happens: slots cover content, and the layout slot covers presentation.
 37package home
 38
 39import (
 40	"crypto/sha256"
 41	"encoding/hex"
 42	"strconv"
 43	"strings"
 44
 45	"gno.land/p/nt/avl/v0"
 46)
 47
 48// admin is the only address allowed to write. Deliberately a constant and not
 49// transferable ownership: this realm is one person's profile page, and the
 50// same address is the only one the chain lets redeploy it.
 51const admin = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5")
 52
 53// layoutSlug names the slot used as the page template.
 54const layoutSlug = "layout"
 55
 56// realmPath is this realm's own path. Written out rather than read from
 57// unsafe.CurrentRealm() because it is also the interface with gnoweb (see the
 58// package doc): the constant is the thing that must not drift, so it is stated
 59// once and every other use points here.
 60const realmPath = "gno.land/r/moul/home"
 61
 62// maxSlugLen bounds a slug so an index row stays readable and a key stays cheap.
 63const maxSlugLen = 64
 64
 65var (
 66	slots = avl.NewTree() // slug -> *slot
 67	rev   int             // total number of writes, bumped by every mutation
 68)
 69
 70type slot struct {
 71	body    string
 72	rev     int   // rev at the time of the last write to this slot
 73	updated int64 // block height of the last write
 74}
 75
 76// reservedSlugs are placeholder names computed at render time from chain state.
 77// A slot may not take one, because it would shadow the computed value and make
 78// the page depend on callback registration order.
 79//
 80// The scan.* half comes from scan.gno's scanPlaceholders and the :reactions:
 81// one from reactions.gno, so adding a placeholder in either file reserves it
 82// here and cannot be forgotten.
 83var reservedSlugs = append(
 84	append(
 85		[]string{"chainid", "height", "owner", "realm", "rev", "slots"},
 86		scanPlaceholders...,
 87	),
 88	reactionsPlaceholders...,
 89)
 90
 91func assertAdmin(cur realm) {
 92	if cur.Previous().Address() != admin {
 93		panic("restricted to admin")
 94	}
 95}
 96
 97// validSlug reports whether slug is a legal slot name: 1..maxSlugLen bytes of
 98// [a-z0-9._-]. The character set excludes ':' so a slug can never break out of
 99// its own :slug: placeholder, and excludes uppercase so a slot has one name.
100func validSlug(slug string) bool {
101	if len(slug) == 0 || len(slug) > maxSlugLen {
102		return false
103	}
104	for i := 0; i < len(slug); i++ {
105		c := slug[i]
106		switch {
107		case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
108		case c == '-', c == '_', c == '.':
109		default:
110			return false
111		}
112	}
113	return true
114}
115
116func assertWritableSlug(slug string) {
117	if !validSlug(slug) {
118		panic("invalid slug: want 1-" + strconv.Itoa(maxSlugLen) + " bytes of [a-z0-9._-], got " + strconv.Quote(slug))
119	}
120	for _, r := range reservedSlugs {
121		if slug == r {
122			panic("reserved slug: " + slug + " is computed at render time")
123		}
124	}
125}
126
127// Set creates or replaces the slot named slug. This is the ordinary update: one
128// slot, one transaction. Passing the empty body keeps the slot but empties it;
129// use Delete to remove it.
130func Set(cur realm, slug, body string) {
131	assertAdmin(cur)
132	assertWritableSlug(slug)
133	rev++
134	slots.Set(slug, &slot{body: body, rev: rev, updated: chainHeight()})
135}
136
137// Append adds to the end of a slot, creating it when absent. It exists for
138// content too large to fit in one transaction: Set the first chunk, Append the
139// rest. Ordinary updates should use Set, which is idempotent.
140func Append(cur realm, slug, body string) {
141	assertAdmin(cur)
142	assertWritableSlug(slug)
143	rev++
144	prev := ""
145	if v := slots.Get(slug); v != nil {
146		prev = v.(*slot).body
147	}
148	slots.Set(slug, &slot{body: prev + body, rev: rev, updated: chainHeight()})
149}
150
151// Delete removes a slot. Deleting the layout slot restores the built-in
152// default layout.
153func Delete(cur realm, slug string) {
154	assertAdmin(cur)
155	if _, removed := slots.Remove(slug); !removed {
156		panic("no such slot: " + slug)
157	}
158	rev++
159}
160
161// Get returns a slot body, or "" when the slot does not exist.
162func Get(slug string) string {
163	v := slots.Get(slug)
164	if v == nil {
165		return ""
166	}
167	return v.(*slot).body
168}
169
170// Revision returns the total number of writes this realm has accepted. It
171// changes on every mutation, so a client can cheaply tell "nothing moved".
172func Revision() int {
173	return rev
174}
175
176// Manifest returns one tab-separated line per slot:
177//
178//	<slug>\t<rev>\t<len>\t<sha256 of body, hex>
179//
180// It is the diff surface tools/gnohome queries with vm/qeval: one read tells it
181// exactly which local file is out of date, without downloading any body.
182func Manifest() string {
183	var b strings.Builder
184	slots.Iterate("", "", func(key string, value any) bool {
185		s := value.(*slot)
186		sum := sha256.Sum256([]byte(s.body))
187		b.WriteString(key)
188		b.WriteString("\t")
189		b.WriteString(strconv.Itoa(s.rev))
190		b.WriteString("\t")
191		b.WriteString(strconv.Itoa(len(s.body)))
192		b.WriteString("\t")
193		b.WriteString(hex.EncodeToString(sum[:]))
194		b.WriteString("\n")
195		return false
196	})
197	return b.String()
198}