wiki.gno
13.44 Kb · 421 lines
1// Package wiki is an open, on-chain encyclopedia: anyone can create and edit
2// a page, every edit is a signed revision, and the whole history is public.
3//
4// It is a thin realm over gno.land/p/moul/x/wiki/v0. The library owns content
5// (titles, revisions, wikilinks, categories, diffs, rendering); this realm
6// owns authority (who may edit what) and the chain wiring (block height, block
7// time, the calling address, transaction links).
8//
9// The split matters for reading the code: there is no policy in the library
10// and no content logic here.
11package wiki
12
13import (
14 "strings"
15 "time"
16
17 "chain/runtime"
18
19 "gno.land/p/moul/addrset/v1"
20 "gno.land/p/moul/md/v0"
21 "gno.land/p/moul/realmpath/v0"
22 "gno.land/p/moul/txlink/v0"
23 "gno.land/p/moul/x/wiki/v0"
24 "gno.land/p/nt/avl/v0"
25 "gno.land/p/nt/ownable/v0"
26 "gno.land/p/nt/ufmt/v0"
27)
28
29// basePath is this realm's render path. It is a constant rather than a lookup
30// so link building stays a pure string operation, and it is asserted against
31// the real path by TestBasePathMatchesTheRealm.
32const basePath = "/r/moul/x/wiki/v0"
33
34// steward is the address that starts out able to protect, move, blank and
35// purge pages. Ownable makes it transferable, so the wiki can be handed to a
36// DAO realm without redeploying.
37const steward address = "g1manfred47kzduec920z88wfr64ylksmdcedlf5" // @moul
38
39var (
40 site *wiki.Wiki
41 Ownable *ownable.Ownable
42
43 // editors may write to semi-protected pages; banned may write nowhere.
44 editors addrset.Set
45 banned addrset.Set
46
47 // cooldown is the minimum number of blocks between two writes by the same
48 // address, 0 to disable.
49 //
50 // The storage deposit makes ADDING bytes cost the adder, but it does not
51 // make removing them cost anything: the chain refunds released storage to
52 // the caller of the transaction that frees it, at the realm's blended
53 // deposit rate, not to whoever originally paid (processStorageDeposit in
54 // gno.land/pkg/sdk/vm/keeper.go). So replacing a long article with a short
55 // one can pay the editor who does it. Blank and Purge are steward-gated
56 // for that reason, and this cooldown plus the ban list and the protection
57 // levels are what is left for ordinary edits. See #140 Q2.
58 cooldown int64
59 lastEdit avl.Tree // address -> the height of that address's last edit
60 editCount int
61)
62
63func init() {
64 Ownable = ownable.NewWithAddress(steward)
65 site = wiki.New(wiki.DefaultRetention, wiki.DefaultMaxBody)
66 seed()
67}
68
69// Edit creates or updates a page and returns the new revision id.
70func Edit(cur realm, title, body, summary string) int {
71 caller := cur.Previous().Address()
72 assertMayEdit(caller, title)
73 rev, err := site.Edit(caller, time.Now(), runtime.ChainHeight(), title, body, summary, false)
74 if err != nil {
75 panic(err)
76 }
77 noteEdit(caller)
78 return int(rev.ID)
79}
80
81// Revert restores an earlier revision of a page as a new revision.
82func Revert(cur realm, title string, rev int, summary string) int {
83 caller := cur.Previous().Address()
84 assertMayEdit(caller, title)
85 r, err := site.Revert(caller, time.Now(), runtime.ChainHeight(), title, uint64(rev), summary)
86 if err != nil {
87 panic(err)
88 }
89 noteEdit(caller)
90 return int(r.ID)
91}
92
93// Comment appends a message to a page's discussion and returns its id. Pass
94// replyTo = 0 for a new thread, or the id of a top-level message to reply to.
95//
96// Commenting deliberately ignores the page's protection level: locking an
97// article is how a steward stops an edit war, and the discussion is where that
98// war is supposed to move. A banned address still cannot comment, and the
99// cooldown still applies.
100func Comment(cur realm, title, body string, replyTo int) int {
101 caller := cur.Previous().Address()
102 assertNotBanned(caller)
103 c, err := site.Comment(caller, time.Now(), runtime.ChainHeight(), title, body, uint64(replyTo))
104 if err != nil {
105 panic(err)
106 }
107 noteEdit(caller)
108 return int(c.ID)
109}
110
111// HideComment clears one message's body and returns the bytes released. The
112// message stays in the thread, marked as removed.
113func HideComment(cur realm, title string, id int) int {
114 Ownable.AssertOwnedBy(cur.Previous().Address())
115 n, err := site.HideComment(title, uint64(id))
116 if err != nil {
117 panic(err)
118 }
119 return n
120}
121
122// Protect sets a page's edit gate: "open", "semi" or "locked".
123func Protect(cur realm, title, level string) {
124 Ownable.AssertOwnedBy(cur.Previous().Address())
125 if err := site.SetProtection(cur.Previous().Address(), time.Now(), runtime.ChainHeight(), title, level); err != nil {
126 panic(err)
127 }
128}
129
130// Move renames a page, keeping its history and leaving a redirect behind.
131func Move(cur realm, from, to, reason string) {
132 Ownable.AssertOwnedBy(cur.Previous().Address())
133 if err := site.Move(cur.Previous().Address(), time.Now(), runtime.ChainHeight(), from, to, reason); err != nil {
134 panic(err)
135 }
136}
137
138// Blank replaces a page's content with a tombstone revision. The history stays.
139func Blank(cur realm, title, reason string) {
140 Ownable.AssertOwnedBy(cur.Previous().Address())
141 if _, err := site.Blank(cur.Previous().Address(), time.Now(), runtime.ChainHeight(), title, reason); err != nil {
142 panic(err)
143 }
144}
145
146// Purge drops every body this realm still holds for a page and returns the
147// number of bytes released. Use it for content that must stop being served
148// from realm state; it cannot remove the transactions that wrote it.
149func Purge(cur realm, title string) int {
150 Ownable.AssertOwnedBy(cur.Previous().Address())
151 n, err := site.Purge(title)
152 if err != nil {
153 panic(err)
154 }
155 return n
156}
157
158// AddEditor lets an address write to semi-protected pages.
159func AddEditor(cur realm, addr address) {
160 Ownable.AssertOwnedBy(cur.Previous().Address())
161 editors.Add(addr)
162}
163
164// RemoveEditor revokes semi-protected write access.
165func RemoveEditor(cur realm, addr address) {
166 Ownable.AssertOwnedBy(cur.Previous().Address())
167 editors.Remove(addr)
168}
169
170// Ban stops an address from editing anything.
171func Ban(cur realm, addr address) {
172 Ownable.AssertOwnedBy(cur.Previous().Address())
173 banned.Add(addr)
174}
175
176// Unban lifts a ban.
177func Unban(cur realm, addr address) {
178 Ownable.AssertOwnedBy(cur.Previous().Address())
179 banned.Remove(addr)
180}
181
182// SetCooldown sets the minimum number of blocks between two edits by the same
183// address; 0 disables it.
184func SetCooldown(cur realm, blocks int) {
185 Ownable.AssertOwnedBy(cur.Previous().Address())
186 if blocks < 0 {
187 panic("cooldown must not be negative")
188 }
189 cooldown = int64(blocks)
190}
191
192// assertNotBanned is the floor every write shares: the ban list and the
193// per-address cooldown.
194func assertNotBanned(caller address) {
195 if banned.Has(caller) {
196 panic("this address is banned from editing")
197 }
198 if cooldown > 0 {
199 if v := lastEdit.Get(caller.String()); v != nil {
200 if wait := cooldown - (runtime.ChainHeight() - v.(int64)); wait > 0 {
201 panic(ufmt.Sprintf("edit cooldown: wait %d more blocks", wait))
202 }
203 }
204 }
205}
206
207// assertMayEdit is the whole authority model of this wiki, in one function.
208func assertMayEdit(caller address, title string) {
209 assertNotBanned(caller)
210
211 p, err := site.Page(title)
212 if err != nil {
213 return // a page that does not exist yet is open to anyone
214 }
215 switch p.Protection {
216 case wiki.Locked:
217 Ownable.AssertOwnedBy(caller)
218 case wiki.SemiProtected:
219 if !editors.Has(caller) && !Ownable.OwnedBy(caller) {
220 panic("this page is semi-protected: ask a steward for edit access")
221 }
222 }
223}
224
225func noteEdit(caller address) {
226 lastEdit.Set(caller.String(), runtime.ChainHeight())
227 editCount++
228}
229
230// ctx is the render context handed to the library on every read.
231func ctx() wiki.Ctx {
232 return wiki.Ctx{Base: basePath, Exists: site.Exists}
233}
234
235// Render is the whole read surface of the wiki.
236//
237// Routes:
238//
239// the front page
240// Title an article
241// Title/history[?offset=] its revisions, newest first
242// Title/raw the current source, with its hash
243// Title/rev/<id> one stored revision
244// Title/diff?from=&to= a line diff between two revisions
245// Title/talk[?offset=] the page's discussion
246// Category:Name a category page and its members
247// Special:AllPages[?ns=] the page index for a namespace
248// Special:Categories every category with at least one member
249// Special:RecentChanges the change feed
250// Special:Backlinks?page= what links to a page
251// Special:Stats size and storage cost
252func Render(path string) string {
253 req := realmpath.Parse(path)
254 c := ctx()
255
256 first := req.PathPart(0)
257 if first == "" {
258 return wiki.RenderIndex(c, site, 20)
259 }
260
261 t, err := wiki.ParseTitle(first)
262 if err != nil {
263 return "400: " + err.Error()
264 }
265 if t.NS == wiki.NSSpecial {
266 return renderSpecial(c, t.Name, req)
267 }
268
269 p, perr := site.PageByTitle(t)
270
271 // A category renders its members even with no description page of its
272 // own: membership is an index, not a page, so "the page does not exist"
273 // would hide every member.
274 if t.NS == wiki.NSCategory && req.PathPart(1) == "" {
275 if perr != nil {
276 p = nil
277 }
278 return wiki.RenderCategory(c, site, t, p, txlink.Call)
279 }
280 if perr != nil {
281 return wiki.RenderMissing(c, site, t, txlink.Call)
282 }
283
284 switch req.PathPart(1) {
285 case "":
286 // Follow a redirect only for a bare read, so history and source
287 // always address the page that was asked for.
288 if dest, _, rerr := site.Resolve(first); rerr == nil {
289 p = dest
290 }
291 return wiki.RenderArticle(c, site, p, txlink.Call)
292
293 case "history":
294 offset := intParam(req.Query.Get("offset"), 0)
295 return wiki.RenderHistory(c, p, offset, 20, txlink.Call)
296
297 case "raw":
298 if p.Head() == nil {
299 return "404: no revision"
300 }
301 return wiki.RenderRaw(c, p, p.Head())
302
303 case "rev":
304 r := p.Revision(uint64(intParam(req.PathPart(2), 0)))
305 if r == nil {
306 return "404: no such revision"
307 }
308 return wiki.RenderRevision(c, p, r)
309
310 case "talk":
311 offset := intParam(req.Query.Get("offset"), 0)
312 return wiki.RenderTalk(c, site, p, offset, 20, txlink.Call)
313
314 case "diff":
315 from := p.Revision(uint64(intParam(req.Query.Get("from"), 0)))
316 to := p.Revision(uint64(intParam(req.Query.Get("to"), 0)))
317 if from == nil || to == nil {
318 return "404: no such revision"
319 }
320 return wiki.RenderDiff(c, p, from, to)
321 }
322 return "404: unknown route"
323}
324
325func renderSpecial(c wiki.Ctx, name string, req *realmpath.Request) string {
326 switch strings.ToLower(name) {
327 case "allpages":
328 ns := wiki.Namespace(intParam(req.Query.Get("ns"), 0))
329 offset := intParam(req.Query.Get("offset"), 0)
330 return wiki.RenderAllPages(c, site, ns, offset, 50)
331 case "categories":
332 return wiki.RenderCategories(c, site)
333 case "recentchanges":
334 return wiki.RenderRecent(c, site, 50)
335 case "backlinks":
336 t, err := wiki.ParseTitle(req.Query.Get("page"))
337 if err != nil {
338 return "400: " + err.Error()
339 }
340 return wiki.RenderBacklinks(c, site, t)
341 case "stats":
342 return wiki.RenderStats(c, site) + md.BulletList([]string{
343 ufmt.Sprintf("edits since deploy: %d", editCount),
344 ufmt.Sprintf("editors on the semi-protected list: %d", editors.Size()),
345 ufmt.Sprintf("banned addresses: %d", banned.Size()),
346 ufmt.Sprintf("edit cooldown: %d blocks", cooldown),
347 ufmt.Sprintf("max comment length: %d bytes", wiki.MaxCommentLen),
348 "steward: `" + Ownable.Owner().String() + "`",
349 })
350 }
351 return "404: unknown special page"
352}
353
354// intParam parses a decimal parameter, falling back to def. It never panics:
355// Render is reached from a URL, and a malformed query must render a page, not
356// abort the query.
357func intParam(s string, def int) int {
358 if s == "" {
359 return def
360 }
361 n := 0
362 for i := 0; i < len(s); i++ {
363 if s[i] < '0' || s[i] > '9' {
364 return def
365 }
366 n = n*10 + int(s[i]-'0')
367 }
368 return n
369}
370
371// seed writes the pages the wiki starts with, so a fresh deploy renders
372// something a reader can follow instead of an empty index.
373func seed() {
374 pages := []struct{ title, body, summary string }{
375 {
376 "Gno land",
377 "gno.land is a smart-contract platform that runs [[Gno]], a deterministic " +
378 "interpretation of Go, on top of [[Tendermint2]].\n\n" +
379 "Realms keep their state as live objects rather than as a key-value blob, " +
380 "which is what lets this wiki store its articles in the contract itself.\n\n" +
381 "[[Category:Chains]]\n",
382 "seed",
383 },
384 {
385 "Gno",
386 "Gno is the language realms are written in: Go's syntax and semantics, minus " +
387 "the sources of non-determinism a chain cannot tolerate.\n\n" +
388 "See [[Gno land]].\n\n[[Category:Languages]]\n",
389 "seed",
390 },
391 {
392 "Tendermint2",
393 "Tendermint2 is the consensus engine under [[Gno land]].\n\n[[Category:Chains]]\n",
394 "seed",
395 },
396 {
397 "Help:Editing",
398 "Anyone may create or edit a page by calling `Edit(title, body, summary)`.\n\n" +
399 "The body is markdown with two additions:\n\n" +
400 "- `[[Target]]` or `[[Target|label]]` links to another page. A link to a " +
401 "page that does not exist yet is marked, and creating that page turns every " +
402 "such link live.\n" +
403 "- `[[Category:Name]]` puts the page in a category instead of rendering inline.\n\n" +
404 "A page whose first line is `#REDIRECT [[Target]]` redirects.\n\n" +
405 "Every page has a discussion thread at `<Title>/talk`, written with " +
406 "`Comment(title, body, replyTo)`. It stays open even when the article " +
407 "itself is locked, because that is where a disagreement should go.\n\n" +
408 "Your transaction locks a storage deposit for the bytes you add, and releases " +
409 "it when they are removed, so the wiki charges the author of a page rather " +
410 "than its readers.\n\n[[Category:Help]]\n",
411 "seed",
412 },
413 }
414 now := time.Now()
415 h := runtime.ChainHeight()
416 for _, p := range pages {
417 if _, err := site.Edit(steward, now, h, p.title, p.body, p.summary, false); err != nil {
418 panic(err)
419 }
420 }
421}