Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}