blog.gno
10.02 Kb · 361 lines
1package memba_blog_v1
2
3// memba_blog_v1 — the on-chain Memba blog (backlog item 8).
4//
5// The Memba /blog is a build-time static pipeline today; this realm makes the
6// published articles chain-native while the frontend keeps its /blog/<slug>
7// URLs (slugs are the stable key). Design constraints:
8//
9// - FUNDS-FREE. No banker import, no OriginSend read anywhere — nothing to
10// drain, trivially passes the mainnet fund-safety gate.
11// - Editor-gated writes: the admin (samcrew 2-of-2 multisig) manages an
12// editors allowlist; only admin/editors publish. Everything else is read.
13// - Honest permanence: posts are never erased — Unpublish flips visibility
14// (readers stop listing it) but the revision history stays on-chain.
15// - Bounded reads: paged meta getters (JSON — titles are free text, so the
16// pipe-row format used by the market getters is not injection-safe here),
17// body fetched per-slug.
18
19import (
20 "strings"
21
22 "chain"
23 "chain/runtime"
24 "chain/runtime/unsafe"
25
26 "gno.land/p/nt/avl/v0"
27 "gno.land/p/nt/ufmt/v0"
28)
29
30// AdminAddress is the samcrew-core 2-of-2 multisig (same admin as the fee
31// spine and the app store).
32const AdminAddress = "g1x7k4628w93a7wzdhqc06atzx0v50rnshweuxu0"
33
34const (
35 // MaxBodyBytes bounds one article (64 KiB of markdown is a very long post;
36 // the bound exists so a compromised editor key can't bloat realm storage).
37 MaxBodyBytes = 64 * 1024
38 MaxTitleLen = 200
39 MaxSlugLen = 100
40 MaxTagsLen = 200
41 // MaxPageLimit bounds one meta page (gas/DoS surface).
42 MaxPageLimit = 50
43)
44
45// Post is one article. Body is raw markdown; the frontend renders it with the
46// same renderer as the static pipeline. CreatedBlk/UpdatedBlk are BLOCK
47// HEIGHTS (chain-native timestamps), resolved to dates client-side.
48type Post struct {
49 Slug string
50 Title string
51 Author address // the editor who published it
52 Tags string // comma-separated, display-only
53 Date string // editorial date YYYY-MM-DD (migration keeps original dates)
54 Body string
55 CreatedBlk int64
56 UpdatedBlk int64
57 Published bool
58}
59
60var (
61 posts *avl.Tree // slug -> *Post
62 postOrder []string // insertion-ordered slugs (stable pagination)
63 editors *avl.Tree // address string -> true
64)
65
66func init() {
67 posts = avl.NewTree()
68 editors = avl.NewTree()
69}
70
71// ── Auth ──────────────────────────────────────────────────────────────────────
72
73func assertAdmin() {
74 if unsafe.PreviousRealm().Address() != address(AdminAddress) {
75 panic("admin only")
76 }
77}
78
79func assertEditor() {
80 caller := unsafe.PreviousRealm().Address()
81 if caller == address(AdminAddress) {
82 return
83 }
84 if editors.Has(caller.String()) {
85 return
86 }
87 panic("editor only")
88}
89
90// AddEditor allowlists an address for publishing. Admin-only.
91func AddEditor(cur realm, addrStr string) {
92 assertAdmin()
93 if addrStr == "" {
94 panic("empty address")
95 }
96 editors.Set(addrStr, true)
97 chain.Emit("EditorAdded", "addr", addrStr)
98}
99
100// RemoveEditor revokes publishing rights. Admin-only.
101func RemoveEditor(cur realm, addrStr string) {
102 assertAdmin()
103 editors.Remove(addrStr)
104 chain.Emit("EditorRemoved", "addr", addrStr)
105}
106
107// ── Validation ────────────────────────────────────────────────────────────────
108
109// validSlug enforces the exact shape the frontend's /blog/<slug> routes use:
110// lowercase kebab-case, no leading/trailing/double hyphen.
111func validSlug(s string) bool {
112 if s == "" || len(s) > MaxSlugLen {
113 return false
114 }
115 prev := byte('-')
116 for i := 0; i < len(s); i++ {
117 c := s[i]
118 switch {
119 case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
120 prev = c
121 case c == '-':
122 if prev == '-' {
123 return false // leading or doubled hyphen
124 }
125 prev = c
126 default:
127 return false
128 }
129 }
130 return s[len(s)-1] != '-'
131}
132
133// validDate accepts exactly YYYY-MM-DD (display/sort fidelity for migrated
134// articles; block heights are recorded separately for chain-native ordering).
135func validDate(d string) bool {
136 if len(d) != 10 || d[4] != '-' || d[7] != '-' {
137 return false
138 }
139 for i, c := range []byte(d) {
140 if i == 4 || i == 7 {
141 continue
142 }
143 if c < '0' || c > '9' {
144 return false
145 }
146 }
147 return true
148}
149
150func validateContent(title, tags, body string) {
151 if strings.TrimSpace(title) == "" || len(title) > MaxTitleLen {
152 panic("invalid title")
153 }
154 if len(tags) > MaxTagsLen {
155 panic("tags too long")
156 }
157 if strings.TrimSpace(body) == "" {
158 panic("empty body")
159 }
160 if len(body) > MaxBodyBytes {
161 panic("body too large")
162 }
163}
164
165// ── Writes (editor-gated) ─────────────────────────────────────────────────────
166
167// PublishPost publishes a new article under a stable slug.
168func PublishPost(cur realm, slug, title, tags, date, body string) {
169 assertEditor()
170 if !validSlug(slug) {
171 panic("invalid slug (lowercase kebab-case)")
172 }
173 if _, dup := posts.Get(slug); dup {
174 panic("slug already exists")
175 }
176 if !validDate(date) {
177 panic("invalid date (YYYY-MM-DD)")
178 }
179 validateContent(title, tags, body)
180 h := runtime.ChainHeight()
181 posts.Set(slug, &Post{
182 Slug: slug,
183 Title: title,
184 Author: unsafe.PreviousRealm().Address(),
185 Tags: tags,
186 Date: date,
187 Body: body,
188 CreatedBlk: h,
189 UpdatedBlk: h,
190 Published: true,
191 })
192 postOrder = append(postOrder, slug)
193 chain.Emit("PostPublished", "slug", slug)
194}
195
196// UpdatePost revises an existing article in place (same slug — URLs stay
197// stable). The revision height is recorded; prior content stays in chain
198// history by nature.
199func UpdatePost(cur realm, slug, title, tags, date, body string) {
200 assertEditor()
201 v, ok := posts.Get(slug)
202 if !ok {
203 panic("unknown slug")
204 }
205 if !validDate(date) {
206 panic("invalid date (YYYY-MM-DD)")
207 }
208 validateContent(title, tags, body)
209 p := v.(*Post)
210 p.Title = title
211 p.Tags = tags
212 p.Date = date
213 p.Body = body
214 p.UpdatedBlk = runtime.ChainHeight()
215 chain.Emit("PostUpdated", "slug", slug)
216}
217
218// SetPublished flips an article's visibility (readers stop listing it; the
219// content itself is on-chain forever — that is disclosed, not hidden).
220func SetPublished(cur realm, slug string, published bool) {
221 assertEditor()
222 v, ok := posts.Get(slug)
223 if !ok {
224 panic("unknown slug")
225 }
226 p := v.(*Post)
227 p.Published = published
228 p.UpdatedBlk = runtime.ChainHeight()
229 if published {
230 chain.Emit("PostRepublished", "slug", slug)
231 } else {
232 chain.Emit("PostUnpublished", "slug", slug)
233 }
234}
235
236// ── Reads (pure, non-failing) ─────────────────────────────────────────────────
237
238// PostCount returns the number of PUBLISHED posts.
239func PostCount() int {
240 n := 0
241 for _, slug := range postOrder {
242 if v, ok := posts.Get(slug); ok && v.(*Post).Published {
243 n++
244 }
245 }
246 return n
247}
248
249// GetPostsPage returns a JSON array of published post META (no bodies),
250// newest-first, windowed by offset/limit (limit clamped to [1, MaxPageLimit]).
251// JSON because titles/tags are editor free text — a delimited row format
252// would be injectable.
253func GetPostsPage(offset, limit int) string {
254 if offset < 0 {
255 offset = 0
256 }
257 if limit <= 0 || limit > MaxPageLimit {
258 limit = MaxPageLimit
259 }
260 // newest-first = reverse insertion order, published only
261 published := []*Post{}
262 for i := len(postOrder) - 1; i >= 0; i-- {
263 if v, ok := posts.Get(postOrder[i]); ok {
264 if p := v.(*Post); p.Published {
265 published = append(published, p)
266 }
267 }
268 }
269 var sb strings.Builder
270 sb.WriteString("[")
271 wrote := 0
272 for i := offset; i < len(published) && wrote < limit; i++ {
273 if wrote > 0 {
274 sb.WriteString(",")
275 }
276 sb.WriteString(postMetaJSON(published[i]))
277 wrote++
278 }
279 sb.WriteString("]")
280 return sb.String()
281}
282
283// GetPostJSON returns one published post as JSON INCLUDING the body, or ""
284// when the slug is unknown or unpublished (non-failing read).
285func GetPostJSON(slug string) string {
286 v, ok := posts.Get(slug)
287 if !ok {
288 return ""
289 }
290 p := v.(*Post)
291 if !p.Published {
292 return ""
293 }
294 meta := postMetaJSON(p)
295 // splice the body into the meta object
296 return meta[:len(meta)-1] + `,"body":"` + jsonEscape(p.Body) + `"}`
297}
298
299func postMetaJSON(p *Post) string {
300 return ufmt.Sprintf(
301 `{"slug":"%s","title":"%s","author":"%s","tags":"%s","date":"%s","createdBlk":%d,"updatedBlk":%d}`,
302 jsonEscape(p.Slug), jsonEscape(p.Title), p.Author.String(), jsonEscape(p.Tags),
303 jsonEscape(p.Date), p.CreatedBlk, p.UpdatedBlk,
304 )
305}
306
307// jsonEscape escapes a string for embedding in a JSON string literal (same
308// helper as memba_appstore_v3 — editor text is still untrusted input on the
309// read path).
310func jsonEscape(s string) string {
311 var sb strings.Builder
312 for _, r := range s {
313 switch r {
314 case '"':
315 sb.WriteString("\\\"")
316 case '\\':
317 sb.WriteString("\\\\")
318 case '\n':
319 sb.WriteString("\\n")
320 case '\r':
321 sb.WriteString("\\r")
322 case '\t':
323 sb.WriteString("\\t")
324 default:
325 if r < 0x20 {
326 sb.WriteString(ufmt.Sprintf("\\u%04x", r))
327 } else {
328 sb.WriteString(string(r))
329 }
330 }
331 }
332 return sb.String()
333}
334
335// ── Render ────────────────────────────────────────────────────────────────────
336
337func Render(path string) string {
338 if path != "" {
339 if v, ok := posts.Get(path); ok {
340 p := v.(*Post)
341 if p.Published {
342 return ufmt.Sprintf("# %s\n\n_%s · by %s_\n\n%s\n",
343 p.Title, p.Date, p.Author.String(), p.Body)
344 }
345 }
346 return "post not found\n"
347 }
348 var sb strings.Builder
349 sb.WriteString("# Memba Blog\n\n")
350 sb.WriteString(ufmt.Sprintf("**%d published posts**\n\n", PostCount()))
351 for i := len(postOrder) - 1; i >= 0; i-- {
352 if v, ok := posts.Get(postOrder[i]); ok {
353 p := v.(*Post)
354 if p.Published {
355 sb.WriteString(ufmt.Sprintf("- [%s](/r/samcrew/memba_blog_v1:%s) · %s\n",
356 p.Title, p.Slug, p.Date))
357 }
358 }
359 }
360 return sb.String()
361}