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}