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}