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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

wiki.gno

13.51 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/nym-moultest123/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/nym-moultest123/addrset/v1"
 20	"gno.land/p/nym-moultest123/md/v0"
 21	"gno.land/p/nym-moultest123/realmpath/v0"
 22	"gno.land/p/nym-moultest123/txlink/v0"
 23	"gno.land/p/nym-moultest123/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/nym-moultest123/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}