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.02 Kb · 182 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// maxSlugLen bounds a slug so an index row stays readable and a key stays cheap.
 57const maxSlugLen = 64
 58
 59var (
 60	slots = avl.NewTree() // slug -> *slot
 61	rev   int             // total number of writes, bumped by every mutation
 62)
 63
 64type slot struct {
 65	body    string
 66	rev     int   // rev at the time of the last write to this slot
 67	updated int64 // block height of the last write
 68}
 69
 70// reservedSlugs are placeholder names computed at render time from chain state.
 71// A slot may not take one, because it would shadow the computed value and make
 72// the page depend on callback registration order.
 73var reservedSlugs = []string{"chainid", "height", "owner", "realm", "rev", "slots"}
 74
 75func assertAdmin(cur realm) {
 76	if cur.Previous().Address() != admin {
 77		panic("restricted to admin")
 78	}
 79}
 80
 81// validSlug reports whether slug is a legal slot name: 1..maxSlugLen bytes of
 82// [a-z0-9._-]. The character set excludes ':' so a slug can never break out of
 83// its own :slug: placeholder, and excludes uppercase so a slot has one name.
 84func validSlug(slug string) bool {
 85	if len(slug) == 0 || len(slug) > maxSlugLen {
 86		return false
 87	}
 88	for i := 0; i < len(slug); i++ {
 89		c := slug[i]
 90		switch {
 91		case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
 92		case c == '-', c == '_', c == '.':
 93		default:
 94			return false
 95		}
 96	}
 97	return true
 98}
 99
100func assertWritableSlug(slug string) {
101	if !validSlug(slug) {
102		panic("invalid slug: want 1-" + strconv.Itoa(maxSlugLen) + " bytes of [a-z0-9._-], got " + strconv.Quote(slug))
103	}
104	for _, r := range reservedSlugs {
105		if slug == r {
106			panic("reserved slug: " + slug + " is computed at render time")
107		}
108	}
109}
110
111// Set creates or replaces the slot named slug. This is the ordinary update: one
112// slot, one transaction. Passing the empty body keeps the slot but empties it;
113// use Delete to remove it.
114func Set(cur realm, slug, body string) {
115	assertAdmin(cur)
116	assertWritableSlug(slug)
117	rev++
118	slots.Set(slug, &slot{body: body, rev: rev, updated: chainHeight()})
119}
120
121// Append adds to the end of a slot, creating it when absent. It exists for
122// content too large to fit in one transaction: Set the first chunk, Append the
123// rest. Ordinary updates should use Set, which is idempotent.
124func Append(cur realm, slug, body string) {
125	assertAdmin(cur)
126	assertWritableSlug(slug)
127	rev++
128	prev := ""
129	if v := slots.Get(slug); v != nil {
130		prev = v.(*slot).body
131	}
132	slots.Set(slug, &slot{body: prev + body, rev: rev, updated: chainHeight()})
133}
134
135// Delete removes a slot. Deleting the layout slot restores the built-in
136// default layout.
137func Delete(cur realm, slug string) {
138	assertAdmin(cur)
139	if _, removed := slots.Remove(slug); !removed {
140		panic("no such slot: " + slug)
141	}
142	rev++
143}
144
145// Get returns a slot body, or "" when the slot does not exist.
146func Get(slug string) string {
147	v := slots.Get(slug)
148	if v == nil {
149		return ""
150	}
151	return v.(*slot).body
152}
153
154// Revision returns the total number of writes this realm has accepted. It
155// changes on every mutation, so a client can cheaply tell "nothing moved".
156func Revision() int {
157	return rev
158}
159
160// Manifest returns one tab-separated line per slot:
161//
162//	<slug>\t<rev>\t<len>\t<sha256 of body, hex>
163//
164// It is the diff surface tools/gnohome queries with vm/qeval: one read tells it
165// exactly which local file is out of date, without downloading any body.
166func Manifest() string {
167	var b strings.Builder
168	slots.Iterate("", "", func(key string, value any) bool {
169		s := value.(*slot)
170		sum := sha256.Sum256([]byte(s.body))
171		b.WriteString(key)
172		b.WriteString("\t")
173		b.WriteString(strconv.Itoa(s.rev))
174		b.WriteString("\t")
175		b.WriteString(strconv.Itoa(len(s.body)))
176		b.WriteString("\t")
177		b.WriteString(hex.EncodeToString(sum[:]))
178		b.WriteString("\n")
179		return false
180	})
181	return b.String()
182}