linktree.gno
4.86 Kb · 192 lines
1package linktree
2
3import (
4 "strconv"
5 "strings"
6
7 "chain"
8
9 "gno.land/p/moul/kit/ui/v0"
10 "gno.land/p/nt/avl/v0"
11)
12
13// selfPath is this realm's own page. A root-relative link resolves against
14// the domain, not the realm, so every link back into it names it in full.
15const selfPath = "/r/moul/x/daily/linktree/v1"
16
17// link is a single labeled URL on a profile.
18type link struct {
19 Label string
20 URL string
21}
22
23// profile is one address's personal links page.
24type profile struct {
25 Bio string
26 Links []link
27}
28
29// profiles maps an address (string key) to its *profile.
30// avl.Tree keeps iteration deterministic for Render.
31var profiles avl.Tree
32
33// The bounds on what a caller may store. Anyone may create a profile, and the
34// root page lists every profile while a profile page lists every link.
35const (
36 MaxProfiles = 1000
37 MaxLinks = 20
38 MaxBioLen = 280
39 MaxLabelLen = 64
40 MaxURLLen = 512
41)
42
43// SetBio sets (or replaces) the calling address's bio.
44func SetBio(cur realm, bio string) {
45 addr := caller(cur)
46 if len(bio) > MaxBioLen {
47 panic("bio too long")
48 }
49 p := getOrCreate(addr.String())
50 p.Bio = bio
51 chain.Emit("BioSet", "addr", addr.String())
52}
53
54// AddLink appends a labeled URL to the calling address's profile.
55func AddLink(cur realm, label string, url string) {
56 addr := caller(cur)
57 if label == "" {
58 panic("label must not be empty")
59 }
60 if url == "" {
61 panic("url must not be empty")
62 }
63 // The URL becomes a markdown link destination as written, so it is
64 // checked here rather than escaped there: http(s) only, and nothing that
65 // could close the destination or start another.
66 if !safeURL(url) {
67 panic("url must be http:// or https:// with no spaces, parentheses or angle brackets")
68 }
69 if len(label) > MaxLabelLen || len(url) > MaxURLLen {
70 panic("label or url too long")
71 }
72 p := getOrCreate(addr.String())
73 if len(p.Links) >= MaxLinks {
74 panic("a profile holds at most " + strconv.Itoa(MaxLinks) + " links")
75 }
76 p.Links = append(p.Links, link{Label: label, URL: url})
77 chain.Emit("LinkAdded", "addr", addr.String(), "label", label)
78}
79
80// RemoveLink deletes the link at index from the calling address's profile.
81func RemoveLink(cur realm, index int) {
82 addr := caller(cur)
83 key := addr.String()
84 v := profiles.Get(key)
85 if v == nil {
86 panic("no profile for caller")
87 }
88 p := v.(*profile)
89 if index < 0 || index >= len(p.Links) {
90 panic("index out of range")
91 }
92 // A fresh slice, not append(s[:i], s[i+1:]...): the in-place form keeps the
93 // old backing array, so the deposit for a removed link never comes back.
94 kept := make([]link, 0, len(p.Links)-1)
95 kept = append(kept, p.Links[:index]...)
96 kept = append(kept, p.Links[index+1:]...)
97 p.Links = kept
98 chain.Emit("LinkRemoved", "addr", key, "index", strconv.Itoa(index))
99}
100
101// getOrCreate returns the profile for key, creating an empty one if absent.
102func getOrCreate(key string) *profile {
103 if v := profiles.Get(key); v != nil {
104 return v.(*profile)
105 }
106 if profiles.Size() >= MaxProfiles {
107 panic("linktree is full at " + strconv.Itoa(MaxProfiles) + " profiles")
108 }
109 p := &profile{}
110 profiles.Set(key, p)
111 return p
112}
113
114// Render shows all profiles at root, or a single profile at "/<address>".
115func Render(path string) string {
116 addr := strings.Trim(path, "/")
117 if addr == "" {
118 return renderIndex()
119 }
120 return renderProfile(addr)
121}
122
123func renderIndex() string {
124 var b strings.Builder
125 b.WriteString("# Linktree\n\n")
126 if profiles.Size() == 0 {
127 b.WriteString("_No profiles yet. Call `SetBio` or `AddLink` to create one._\n")
128 return b.String()
129 }
130 b.WriteString("Profiles:\n\n")
131 profiles.Iterate("", "", func(key string, _ interface{}) bool {
132 b.WriteString("- [")
133 b.WriteString(key)
134 b.WriteString("](" + selfPath + ":")
135 b.WriteString(key)
136 b.WriteString(")\n")
137 return false
138 })
139 return b.String()
140}
141
142func renderProfile(addr string) string {
143 var b strings.Builder
144 b.WriteString("# ")
145 b.WriteString(ui.Inline(addr))
146 b.WriteString("\n\n")
147
148 v := profiles.Get(addr)
149 if v == nil {
150 b.WriteString("_No profile for this address._\n")
151 return b.String()
152 }
153 p := v.(*profile)
154
155 if p.Bio != "" {
156 b.WriteString(ui.Inline(p.Bio))
157 b.WriteString("\n\n")
158 }
159
160 if len(p.Links) == 0 {
161 b.WriteString("_No links yet._\n")
162 return b.String()
163 }
164
165 b.WriteString("## Links\n\n")
166 for _, l := range p.Links {
167 b.WriteString("- [")
168 b.WriteString(ui.Inline(l.Label))
169 b.WriteString("](")
170 b.WriteString(l.URL)
171 b.WriteString(")\n")
172 }
173 return b.String()
174}
175
176// caller is the address that crossed into this realm, after checking that the
177// realm token is this frame's own.
178func caller(cur realm) address {
179 if !cur.IsCurrent() {
180 panic("linktree: spoofed realm")
181 }
182 return cur.Previous().Address()
183}
184
185// safeURL reports whether u can be written into a markdown link destination
186// unescaped.
187func safeURL(u string) bool {
188 if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
189 return false
190 }
191 return !strings.ContainsAny(u, " \t\r\n()<>[]`\\")
192}