Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}