home.gno
5.86 Kb · 225 lines
1// Package home is a personal home realm. gno.land also shows it as the
2// profile page at /u/<name>, which always calls Render("") and reads its
3// links against /u/<name>: every link here is absolute.
4package home
5
6import (
7 "chain/runtime"
8 "strconv"
9 "strings"
10
11 "gno.land/p/nt/avl/v0"
12)
13
14const (
15 homeTab = "home"
16 workTab = "work"
17 blogTab = "blog"
18 guestbookTab = "guestbook"
19
20 maxTabs = 8
21 maxTabName = 20
22 maxPageBytes = 16 * 1024 // so all pages together stay under 128 KB
23 maxLineRunes = 140
24 maxBadges = 6
25 maxSlugBytes = 60
26 maxTitleRunes = 100
27 pageSize = 20
28 latestPosts = 3
29 seqWidth = 10
30
31 errUnauthorized = "home: unauthorized"
32 errMasterKey = "home: needs the master key, not a session key"
33 errOwnerOnly = "home: owner only"
34 errPendingOnly = "home: pending owner only"
35 errInvalidInput = "home: invalid input: "
36 errSigned = "home: already signed"
37 errNoEntry = "home: no such entry"
38 errNoTab = "home: no such tab"
39 errNoPost = "home: no such post"
40 errTabsRequired = "home: home, blog and guestbook tabs are required"
41 errUgnotOnly = "home: send ugnot only"
42)
43
44type profileData struct {
45 name string
46 tagline string
47}
48
49type badge struct {
50 label string
51 value string
52 url string
53 row bool // starts a new row
54}
55
56type nowLine struct {
57 text string
58 height int64
59}
60
61type entry struct {
62 msg string
63 height int64
64 hidden bool
65}
66
67// post dates are UTC days of the block time, "2006-01-02".
68type post struct {
69 title string
70 body string
71 published string
72 edited string // "" until the first edit
73 seq string // key in postOrder
74}
75
76type tipStats struct {
77 count int
78 total int64
79}
80
81var (
82 owner address
83 pendingOwner address // set by TransferOwnership, cleared by AcceptOwnership
84 base string // "/r/<name>/home"; every generated link starts with it
85 profile profileData
86 badges []badge
87 now nowLine
88 tabs []string // tab order: home first, guestbook last
89 pages map[string]string // markdown per tab
90 waves int // wave count; anyone can wave again
91 entries *avl.Tree // address -> *entry
92 order *avl.Tree // zero-padded sequence -> address
93 posts *avl.Tree // slug -> *post
94 postOrder *avl.Tree // zero-padded sequence -> slug
95 postSeq int // last post sequence; never reused
96 tips tipStats
97 updated int64 // block height of the last owner write
98)
99
100// init records the deployer as owner. Under mainnet's approval flow it runs
101// as the submitter, not the approver. No AssertOriginCall: init isn't a call.
102func init(cur realm) {
103 owner = callerOf(cur)
104 base = strings.TrimPrefix(cur.PkgPath(), runtime.ChainDomain())
105 setDefaults()
106}
107
108// setDefaults writes the initial state (content.gno holds the personal part).
109func setDefaults() {
110 profile = defaultProfile()
111 badges = defaultBadges()
112 now = nowLine{}
113 pendingOwner = ""
114 tabs = []string{homeTab, workTab, blogTab, guestbookTab}
115 pages = map[string]string{
116 homeTab: defaultHome(),
117 workTab: defaultWork,
118 blogTab: defaultBlog,
119 guestbookTab: defaultGuestbook,
120 }
121 waves = 0
122 entries = avl.NewTree()
123 order = avl.NewTree()
124 posts = avl.NewTree()
125 postOrder = avl.NewTree()
126 postSeq = 0
127 tips = tipStats{}
128 updated = runtime.ChainHeight()
129}
130
131// callerOf is the account guard: a live cur and a direct call from an
132// account. It must not call AssertOriginCall, which refuses every call from
133// inside a named helper.
134func callerOf(cur realm) address {
135 if !cur.IsCurrent() || !cur.Previous().IsUserCall() {
136 panic(errUnauthorized)
137 }
138 return cur.Previous().Address()
139}
140
141// assertOwner is the owner guard.
142func assertOwner(cur realm) {
143 if callerOf(cur) != owner {
144 panic(errOwnerOnly)
145 }
146}
147
148// Owner returns the current owner.
149func Owner() address {
150 return owner
151}
152
153// Render shows one tab, or one post at blog/<slug>. An unknown path shows
154// Home and an unknown post shows Blog. The path is only compared against
155// tab names or looked up as a slug, and never written into the page.
156func Render(path string) string {
157 tab, query, _ := strings.Cut(path, "?")
158 if slug, ok := strings.CutPrefix(tab, blogTab+"/"); ok {
159 if p, ok := posts.Get(slug).(*post); ok {
160 return frame("", postMD(p)) // not a tab: no tab is bold
161 }
162 tab = blogTab
163 }
164 if _, ok := pages[tab]; !ok {
165 tab = homeTab
166 }
167 return frame(tab, expand(pages[tab], tab, parsePage(query)))
168}
169
170// frame puts the tab bar above a page and the footer below it.
171func frame(tab, md string) string {
172 return tabBar(tab) + "\n\n" + md + "\n\n" + footer()
173}
174
175func tabURL(t string) string {
176 if t == homeTab {
177 return base
178 }
179 return base + ":" + t
180}
181
182// tabLabel capitalizes a tab name; names are ASCII by validName.
183func tabLabel(t string) string {
184 return strings.ToUpper(t[:1]) + t[1:]
185}
186
187// tabBar draws every tab as a linked pill, the current one in brand green.
188// The current tab is linked too, because gnoweb shows an unlinked image as
189// a block and the row would break. Blog stays out until there is a post.
190func tabBar(current string) string {
191 parts := make([]string, 0, len(tabs))
192 for _, t := range tabs {
193 s := segSoft(tabLabel(t))
194 switch {
195 case t == current:
196 s = segOn(tabLabel(t))
197 case t == blogTab && posts.Size() == 0:
198 continue // no posts yet
199 }
200 parts = append(parts, pillLink(tabLabel(t), tabURL(t), tabH, 0, s))
201 }
202 return strings.Join(parts, " ")
203}
204
205func footer() string {
206 return "---\n\nOwner " + userLink(owner) +
207 " · updated at block " + strconv.FormatInt(updated, 10) +
208 " · [Source](" + base + "$source)\n"
209}
210
211// parsePage reads page=N from the query; anything invalid is page 1.
212func parsePage(query string) int {
213 for _, kv := range strings.Split(query, "&") {
214 k, v, _ := strings.Cut(kv, "=")
215 if k != "page" {
216 continue
217 }
218 n, err := strconv.Atoi(v)
219 if err != nil || n < 1 {
220 return 1
221 }
222 return n
223 }
224 return 1
225}