Search Apps Documentation Source Content File Folder Download Copy Actions Download

public.gno

20.09 Kb · 774 lines
  1package boards2
  2
  3import (
  4	"chain"
  5	"chain/runtime"
  6	"regexp"
  7	"strconv"
  8	"strings"
  9	"time"
 10
 11	"gno.land/p/gnoland/boards"
 12
 13	"gno.land/r/gnoland/boards2/v1/permissions"
 14)
 15
 16const (
 17	// MaxBoardNameLength defines the maximum length allowed for board names.
 18	MaxBoardNameLength = 50
 19
 20	// MaxThreadTitleLength defines the maximum length allowed for thread titles.
 21	MaxThreadTitleLength = 100
 22
 23	// MaxReplyLength defines the maximum length allowed for replies.
 24	MaxReplyLength = 1000
 25)
 26
 27var (
 28	reBoardName = regexp.MustCompile(`(?i)^[a-z]+[a-z0-9_\-]{2,50}$`)
 29
 30	// Minimalistic Markdown line prefix checks that if allowed would
 31	// break the current UI when submitting a reply. It denies replies
 32	// with headings, blockquotes or horizontal lines.
 33	reDeniedReplyLinePrefixes = regexp.MustCompile(`(?m)^\s*(#|---|>)+`)
 34)
 35
 36// SetHelp sets or updates boards realm help content.
 37func SetHelp(_ realm, content string) {
 38	content = strings.TrimSpace(content)
 39	caller := runtime.PreviousRealm().Address()
 40	args := boards.Args{content}
 41	gPerms.WithPermission(cross, caller, permissions.PermissionRealmHelp, args, func(realm) {
 42		gHelp = content
 43	})
 44}
 45
 46// SetPermissions sets a permissions implementation for boards2 realm or a board.
 47func SetPermissions(_ realm, boardID boards.ID, p boards.Permissions) {
 48	assertRealmIsNotLocked()
 49	assertBoardExists(boardID)
 50
 51	if p == nil {
 52		panic("permissions is required")
 53	}
 54
 55	caller := runtime.PreviousRealm().Address()
 56	args := boards.Args{boardID}
 57	gPerms.WithPermission(cross, caller, permissions.PermissionPermissionsUpdate, args, func(realm) {
 58		assertRealmIsNotLocked()
 59
 60		// When board ID is zero it means that realm permissions are being updated
 61		if boardID == 0 {
 62			gPerms = p
 63
 64			chain.Emit(
 65				"RealmPermissionsUpdated",
 66				"caller", caller.String(),
 67			)
 68			return
 69		}
 70
 71		// Otherwise update the permissions of a single board
 72		board := mustGetBoard(boardID)
 73		board.Permissions = p
 74
 75		chain.Emit(
 76			"BoardPermissionsUpdated",
 77			"caller", caller.String(),
 78			"boardID", board.ID.String(),
 79		)
 80	})
 81}
 82
 83// SetRealmNotice sets a notice to be displayed globally by the realm.
 84// An empty message removes the realm notice.
 85func SetRealmNotice(_ realm, message string) {
 86	message = strings.TrimSpace(message)
 87	caller := runtime.PreviousRealm().Address()
 88	args := boards.Args{message}
 89	gPerms.WithPermission(cross, caller, permissions.PermissionRealmNotice, args, func(realm) {
 90		gNotice = message
 91
 92		chain.Emit(
 93			"RealmNoticeChanged",
 94			"caller", caller.String(),
 95			"message", message,
 96		)
 97	})
 98}
 99
100// GetBoardIDFromName searches a board by name and returns it's ID.
101func GetBoardIDFromName(_ realm, name string) (_ boards.ID, found bool) {
102	board, found := gBoards.GetByName(name)
103	if !found {
104		return 0, false
105	}
106	return board.ID, true
107}
108
109// CreateBoard creates a new board.
110//
111// Listed boards are included in the realm's list of boards.
112// Open boards allow anyone to create threads and comment.
113func CreateBoard(_ realm, name string, listed, open bool) boards.ID {
114	assertRealmIsNotLocked()
115
116	name = strings.TrimSpace(name)
117	assertIsValidBoardName(name)
118	assertBoardNameNotExists(name)
119
120	caller := runtime.PreviousRealm().Address()
121	id := gBoardsSequence.Next()
122	board := boards.New(id)
123	args := boards.Args{caller, name, board.ID, listed, open}
124	gPerms.WithPermission(cross, caller, permissions.PermissionBoardCreate, args, func(realm) {
125		assertRealmIsNotLocked()
126		assertBoardNameNotExists(name)
127
128		board.Name = name
129		board.Creator = caller
130
131		if open {
132			board.Permissions = createOpenBoardPermissions(caller)
133		} else {
134			board.Permissions = createBasicBoardPermissions(caller)
135		}
136
137		if err := gBoards.Add(board); err != nil {
138			panic(err)
139		}
140
141		// Listed boards are also indexed separately for easier iteration and pagination
142		if listed {
143			gListedBoardsByID.Set(board.ID.Key(), board)
144		}
145
146		chain.Emit(
147			"BoardCreated",
148			"caller", caller.String(),
149			"boardID", board.ID.String(),
150			"name", name,
151		)
152	})
153	return board.ID
154}
155
156// RenameBoard changes the name of an existing board.
157//
158// A history of previous board names is kept when boards are renamed.
159// Because of that boards are also accesible using previous name(s).
160func RenameBoard(_ realm, name, newName string) {
161	assertRealmIsNotLocked()
162
163	newName = strings.TrimSpace(newName)
164	assertIsValidBoardName(newName)
165	assertBoardNameNotExists(newName)
166
167	board := mustGetBoardByName(name)
168	assertBoardIsNotFrozen(board)
169
170	caller := runtime.PreviousRealm().Address()
171	args := boards.Args{caller, board.ID, name, newName}
172	board.Permissions.WithPermission(cross, caller, permissions.PermissionBoardRename, args, func(realm) {
173		assertRealmIsNotLocked()
174		assertBoardNameNotExists(newName)
175
176		board.Aliases = append(board.Aliases, board.Name)
177		board.Name = newName
178
179		// Index board for the new name keeping previous indexes for older names
180		gBoards.Add(board)
181
182		chain.Emit(
183			"BoardRenamed",
184			"caller", caller.String(),
185			"boardID", board.ID.String(),
186			"name", name,
187			"newName", newName,
188		)
189	})
190}
191
192// CreateThread creates a new thread within a board.
193func CreateThread(_ realm, boardID boards.ID, title, body string) boards.ID {
194	assertRealmIsNotLocked()
195
196	title = strings.TrimSpace(title)
197	assertTitleIsValid(title)
198
199	caller := runtime.PreviousRealm().Address()
200	assertUserIsNotBanned(boardID, caller)
201
202	board := mustGetBoard(boardID)
203	assertBoardIsNotFrozen(board)
204
205	thread := boards.MustNewThread(board, caller, title, body)
206	args := boards.Args{caller, board.ID, thread.ID, title, body}
207	board.Permissions.WithPermission(cross, caller, permissions.PermissionThreadCreate, args, func(realm) {
208		assertRealmIsNotLocked()
209		assertUserIsNotBanned(board.ID, caller)
210
211		thread.Replies = NewReplyStorage()
212
213		if err := board.Threads.Add(thread); err != nil {
214			panic(err)
215		}
216
217		chain.Emit(
218			"ThreadCreated",
219			"caller", caller.String(),
220			"boardID", board.ID.String(),
221			"threadID", thread.ID.String(),
222			"title", title,
223		)
224	})
225	return thread.ID
226}
227
228// CreateReply creates a new comment or reply within a thread.
229//
230// The value of `replyID` is only required when creating a reply of another reply.
231func CreateReply(_ realm, boardID, threadID, replyID boards.ID, body string) boards.ID {
232	assertRealmIsNotLocked()
233
234	body = strings.TrimSpace(body)
235	assertReplyBodyIsValid(body)
236
237	caller := runtime.PreviousRealm().Address()
238	assertUserIsNotBanned(boardID, caller)
239
240	board := mustGetBoard(boardID)
241	assertBoardIsNotFrozen(board)
242
243	thread := mustGetThread(board, threadID)
244	assertThreadIsVisible(thread)
245	assertThreadIsNotFrozen(thread)
246
247	// By default consider that reply's parent is the thread.
248	// Or when replyID is assigned use that reply as the parent.
249	parent := thread
250	if replyID > 0 {
251		parent = mustGetReply(thread, replyID)
252		if parent.Hidden || parent.Readonly {
253			panic("replying to a hidden or frozen reply is not allowed")
254		}
255	}
256
257	reply := boards.MustNewReply(parent, caller, body)
258	args := boards.Args{caller, board.ID, thread.ID, parent.ID, reply.ID, body}
259	board.Permissions.WithPermission(cross, caller, permissions.PermissionReplyCreate, args, func(realm) {
260		assertRealmIsNotLocked()
261
262		// Add reply to its parent
263		if err := parent.Replies.Add(reply); err != nil {
264			panic(err)
265		}
266
267		// When parent is not a thread also add reply to the thread.
268		// The thread contains all replies and sub-replies, while each
269		// reply only contains direct sub-replies.
270		if parent.ID != thread.ID {
271			if err := thread.Replies.Add(reply); err != nil {
272				panic(err)
273			}
274		}
275
276		chain.Emit(
277			"ReplyCreate",
278			"caller", caller.String(),
279			"boardID", board.ID.String(),
280			"threadID", thread.ID.String(),
281			"replyID", reply.ID.String(),
282		)
283	})
284	return reply.ID
285}
286
287// CreateRepost reposts a thread into another board.
288func CreateRepost(_ realm, boardID, threadID, destinationBoardID boards.ID, title, body string) boards.ID {
289	assertRealmIsNotLocked()
290
291	title = strings.TrimSpace(title)
292	assertTitleIsValid(title)
293
294	caller := runtime.PreviousRealm().Address()
295	assertUserIsNotBanned(destinationBoardID, caller)
296
297	dst := mustGetBoard(destinationBoardID)
298	assertBoardIsNotFrozen(dst)
299
300	board := mustGetBoard(boardID)
301	thread := mustGetThread(board, threadID)
302	assertThreadIsVisible(thread)
303
304	repost := boards.MustNewRepost(thread, dst, caller)
305	args := boards.Args{caller, board.ID, thread.ID, dst.ID, repost.ID, title, body}
306	dst.Permissions.WithPermission(cross, caller, permissions.PermissionThreadRepost, args, func(realm) {
307		assertRealmIsNotLocked()
308
309		repost.Title = title
310		repost.Body = strings.TrimSpace(body)
311
312		if err := dst.Threads.Add(repost); err != nil {
313			panic(err)
314		}
315
316		if err := thread.Reposts.Add(repost); err != nil {
317			panic(err)
318		}
319
320		chain.Emit(
321			"Repost",
322			"caller", caller.String(),
323			"boardID", board.ID.String(),
324			"threadID", thread.ID.String(),
325			"destinationBoardID", dst.ID.String(),
326			"repostID", repost.ID.String(),
327			"title", title,
328		)
329	})
330	return repost.ID
331}
332
333// DeleteThread deletes a thread from a board.
334//
335// Threads can be deleted by the users who created them or otherwise by users with special permissions.
336func DeleteThread(_ realm, boardID, threadID boards.ID) {
337	assertRealmIsNotLocked()
338
339	caller := runtime.PreviousRealm().Address()
340	board := mustGetBoard(boardID)
341	assertUserIsNotBanned(boardID, caller)
342
343	isRealmOwner := gPerms.HasRole(caller, permissions.RoleOwner) // TODO: Add DeleteThread filetest cases for realm owners
344	if !isRealmOwner {
345		assertBoardIsNotFrozen(board)
346	}
347
348	thread := mustGetThread(board, threadID)
349	deleteThread := func() {
350		board.Threads.Remove(thread.ID)
351
352		chain.Emit(
353			"ThreadDeleted",
354			"caller", caller.String(),
355			"boardID", board.ID.String(),
356			"threadID", thread.ID.String(),
357		)
358	}
359
360	// Thread can be directly deleted by user that created it.
361	// It can also be deleted by realm owners, to be able to delete inappropriate content.
362	// TODO: Discuss and decide if realm owners should be able to delete threads.
363	if isRealmOwner || caller == thread.Creator {
364		deleteThread()
365		return
366	}
367
368	args := boards.Args{caller, board.ID, thread.ID}
369	board.Permissions.WithPermission(cross, caller, permissions.PermissionThreadDelete, args, func(realm) {
370		assertRealmIsNotLocked()
371		deleteThread()
372	})
373}
374
375// DeleteReply deletes a reply from a thread.
376//
377// Replies can be deleted by the users who created them or otherwise by users with special permissions.
378// Soft deletion is used when the deleted reply contains sub replies, in which case the reply content
379// is replaced by a text informing that reply has been deleted to avoid deleting sub-replies.
380func DeleteReply(_ realm, boardID, threadID, replyID boards.ID) {
381	assertRealmIsNotLocked()
382
383	caller := runtime.PreviousRealm().Address()
384	board := mustGetBoard(boardID)
385	assertUserIsNotBanned(boardID, caller)
386
387	thread := mustGetThread(board, threadID)
388	reply := mustGetReply(thread, replyID)
389	isRealmOwner := gPerms.HasRole(caller, permissions.RoleOwner) // TODO: Add DeleteReply filetest cases for realm owners
390	if !isRealmOwner {
391		assertBoardIsNotFrozen(board)
392		assertThreadIsNotFrozen(thread)
393		assertReplyIsVisible(reply)
394	}
395
396	deleteReply := func() {
397		// Soft delete reply by changing its body when it contains
398		// sub-replies, otherwise hard delete it.
399		if reply.Replies.Size() > 0 {
400			reply.Body = "*This reply has been deleted*"
401			reply.UpdatedAt = time.Now()
402		} else {
403			// Remove reply from the thread
404			reply, removed := thread.Replies.Remove(replyID)
405			if !removed {
406				panic("reply not found")
407			}
408
409			// Remove reply from reply's parent
410			if reply.ParentID != thread.ID {
411				parent, found := thread.Replies.Get(reply.ParentID)
412				if found {
413					parent.Replies.Remove(replyID)
414				}
415			}
416		}
417
418		chain.Emit(
419			"ReplyDeleted",
420			"caller", caller.String(),
421			"boardID", board.ID.String(),
422			"threadID", thread.ID.String(),
423			"replyID", reply.ID.String(),
424		)
425	}
426
427	// Reply can be directly deleted by user that created it.
428	// It can also be deleted by realm owners, to be able to delete inappropriate content.
429	// TODO: Discuss and decide if realm owners should be able to delete replies.
430	if isRealmOwner || caller == reply.Creator {
431		deleteReply()
432		return
433	}
434
435	args := boards.Args{caller, board.ID, thread.ID, reply.ID}
436	board.Permissions.WithPermission(cross, caller, permissions.PermissionReplyDelete, args, func(realm) {
437		assertRealmIsNotLocked()
438		deleteReply()
439	})
440}
441
442// EditThread updates the title and body of thread.
443//
444// Threads can be updated by the users who created them or otherwise by users with special permissions.
445func EditThread(_ realm, boardID, threadID boards.ID, title, body string) {
446	assertRealmIsNotLocked()
447
448	title = strings.TrimSpace(title)
449	assertTitleIsValid(title)
450
451	board := mustGetBoard(boardID)
452	assertBoardIsNotFrozen(board)
453
454	caller := runtime.PreviousRealm().Address()
455	assertUserIsNotBanned(boardID, caller)
456
457	thread := mustGetThread(board, threadID)
458	assertThreadIsNotFrozen(thread)
459
460	body = strings.TrimSpace(body)
461	if !boards.IsRepost(thread) {
462		assertBodyIsNotEmpty(body)
463	}
464
465	editThread := func() {
466		thread.Title = title
467		thread.Body = body
468		thread.UpdatedAt = time.Now()
469
470		chain.Emit(
471			"ThreadEdited",
472			"caller", caller.String(),
473			"boardID", board.ID.String(),
474			"threadID", thread.ID.String(),
475			"title", title,
476		)
477	}
478
479	if caller == thread.Creator {
480		editThread()
481		return
482	}
483
484	args := boards.Args{caller, board.ID, thread.ID, title, body}
485	board.Permissions.WithPermission(cross, caller, permissions.PermissionThreadEdit, args, func(realm) {
486		assertRealmIsNotLocked()
487		editThread()
488	})
489}
490
491// EditReply updates the body of comment or reply.
492//
493// Replies can be updated only by the users who created them.
494func EditReply(_ realm, boardID, threadID, replyID boards.ID, body string) {
495	assertRealmIsNotLocked()
496
497	body = strings.TrimSpace(body)
498	assertReplyBodyIsValid(body)
499
500	board := mustGetBoard(boardID)
501	assertBoardIsNotFrozen(board)
502
503	caller := runtime.PreviousRealm().Address()
504	assertUserIsNotBanned(boardID, caller)
505
506	thread := mustGetThread(board, threadID)
507	assertThreadIsNotFrozen(thread)
508
509	reply := mustGetReply(thread, replyID)
510	assertReplyIsVisible(reply)
511
512	if caller != reply.Creator {
513		panic("only the reply creator is allowed to edit it")
514	}
515
516	reply.Body = body
517	reply.UpdatedAt = time.Now()
518
519	chain.Emit(
520		"ReplyEdited",
521		"caller", caller.String(),
522		"boardID", board.ID.String(),
523		"threadID", thread.ID.String(),
524		"replyID", reply.ID.String(),
525		"body", body,
526	)
527}
528
529// RemoveMember removes a member from the realm or a boards.
530//
531// Board ID is only required when removing a member from board.
532func RemoveMember(_ realm, boardID boards.ID, member address) {
533	assertMembersUpdateIsEnabled(boardID)
534	assertMemberAddressIsValid(member)
535
536	perms := mustGetPermissions(boardID)
537	origin := runtime.OriginCaller()
538	caller := runtime.PreviousRealm().Address()
539	removeMember := func() {
540		if !perms.RemoveUser(cross, member) {
541			panic("member not found")
542		}
543
544		chain.Emit(
545			"MemberRemoved",
546			"caller", caller.String(),
547			"origin", origin.String(), // When origin and caller match it means self removal
548			"boardID", boardID.String(),
549			"member", member.String(),
550		)
551	}
552
553	// Members can remove themselves without permission
554	if origin == member {
555		removeMember()
556		return
557	}
558
559	args := boards.Args{boardID, member}
560	perms.WithPermission(cross, caller, permissions.PermissionMemberRemove, args, func(realm) {
561		assertMembersUpdateIsEnabled(boardID)
562		removeMember()
563	})
564}
565
566// IsMember checks if an user is a member of the realm or a board.
567//
568// Board ID is only required when checking if a user is a member of a board.
569func IsMember(boardID boards.ID, user address) bool {
570	assertUserAddressIsValid(user)
571
572	if boardID != 0 {
573		board := mustGetBoard(boardID)
574		assertBoardIsNotFrozen(board)
575	}
576
577	perms := mustGetPermissions(boardID)
578	return perms.HasUser(user)
579}
580
581// HasMemberRole checks if a realm or board member has a specific role assigned.
582//
583// Board ID is only required when checking a member of a board.
584func HasMemberRole(boardID boards.ID, member address, role boards.Role) bool {
585	assertMemberAddressIsValid(member)
586
587	if boardID != 0 {
588		board := mustGetBoard(boardID)
589		assertBoardIsNotFrozen(board)
590	}
591
592	perms := mustGetPermissions(boardID)
593	return perms.HasRole(member, role)
594}
595
596// ChangeMemberRole changes the role of a realm or board member.
597//
598// Board ID is only required when changing the role for a member of a board.
599func ChangeMemberRole(_ realm, boardID boards.ID, member address, role boards.Role) {
600	assertMemberAddressIsValid(member)
601	assertMembersUpdateIsEnabled(boardID)
602
603	if role == "" {
604		role = permissions.RoleGuest
605	}
606
607	perms := mustGetPermissions(boardID)
608	caller := runtime.PreviousRealm().Address()
609	args := boards.Args{caller, boardID, member, role}
610	perms.WithPermission(cross, caller, permissions.PermissionRoleChange, args, func(realm) {
611		assertMembersUpdateIsEnabled(boardID)
612
613		perms.SetUserRoles(cross, member, role)
614
615		chain.Emit(
616			"RoleChanged",
617			"caller", caller.String(),
618			"boardID", boardID.String(),
619			"member", member.String(),
620			"newRole", string(role),
621		)
622	})
623}
624
625// IterateRealmMembers iterates boards realm members.
626// The iteration is done only for realm members, board members are not iterated.
627func IterateRealmMembers(offset int, fn boards.UsersIterFn) (halted bool) {
628	count := gPerms.UsersCount() - offset
629	return gPerms.IterateUsers(offset, count, fn)
630}
631
632// GetBoard returns a single board.
633func GetBoard(boardID boards.ID) *boards.Board {
634	board := mustGetBoard(boardID)
635	if !board.Permissions.HasRole(runtime.OriginCaller(), permissions.RoleOwner) {
636		panic("forbidden")
637	}
638	return board
639}
640
641func assertMemberAddressIsValid(member address) {
642	if !member.IsValid() {
643		panic("invalid member address: " + member.String())
644	}
645}
646
647func assertUserAddressIsValid(user address) {
648	if !user.IsValid() {
649		panic("invalid user address: " + user.String())
650	}
651}
652
653func assertHasPermission(perms boards.Permissions, user address, p boards.Permission) {
654	if !perms.HasPermission(user, p) {
655		panic("unauthorized")
656	}
657}
658
659func assertBoardExists(id boards.ID) {
660	if id == 0 { // ID zero is used to refer to the realm
661		return
662	}
663
664	if _, found := gBoards.Get(id); !found {
665		panic("board not found: " + id.String())
666	}
667}
668
669func assertBoardIsNotFrozen(b *boards.Board) {
670	if b.Readonly {
671		panic("board is frozen")
672	}
673}
674
675func assertIsValidBoardName(name string) {
676	size := len(name)
677	if size == 0 {
678		panic("board name is empty")
679	}
680
681	if size < 3 {
682		panic("board name is too short, minimum length is 3 characters")
683	}
684
685	if size > MaxBoardNameLength {
686		n := strconv.Itoa(MaxBoardNameLength)
687		panic("board name is too long, maximum allowed is " + n + " characters")
688	}
689
690	if !reBoardName.MatchString(name) {
691		panic("board name contains invalid characters")
692	}
693}
694
695func assertThreadIsNotFrozen(t *boards.Post) {
696	if t.Readonly {
697		panic("thread is frozen")
698	}
699}
700
701func assertNameIsNotEmpty(name string) {
702	if name == "" {
703		panic("name is empty")
704	}
705}
706
707func assertTitleIsValid(title string) {
708	if title == "" {
709		panic("title is empty")
710	}
711
712	if len(title) > MaxThreadTitleLength {
713		n := strconv.Itoa(MaxThreadTitleLength)
714		panic("title is too long, maximum allowed is " + n + " characters")
715	}
716}
717
718func assertBodyIsNotEmpty(body string) {
719	if body == "" {
720		panic("body is empty")
721	}
722}
723
724func assertBoardNameNotExists(name string) {
725	name = strings.ToLower(name)
726	if _, found := gBoards.GetByName(name); found {
727		panic("board already exists")
728	}
729}
730
731func assertThreadExists(b *boards.Board, threadID boards.ID) {
732	if _, found := b.Threads.Get(threadID); !found {
733		panic("thread not found: " + threadID.String())
734	}
735}
736
737func assertReplyExists(thread *boards.Post, replyID boards.ID) {
738	if _, found := thread.Replies.Get(replyID); !found {
739		panic("reply not found: " + replyID.String())
740	}
741}
742
743func assertThreadIsVisible(thread *boards.Post) {
744	if thread.Hidden {
745		panic("thread is hidden")
746	}
747}
748
749func assertReplyIsVisible(thread *boards.Post) {
750	if thread.Hidden {
751		panic("reply is hidden")
752	}
753}
754
755func assertReplyBodyIsValid(body string) {
756	assertBodyIsNotEmpty(body)
757
758	if len(body) > MaxReplyLength {
759		n := strconv.Itoa(MaxReplyLength)
760		panic("reply is too long, maximum allowed is " + n + " characters")
761	}
762
763	if reDeniedReplyLinePrefixes.MatchString(body) {
764		panic("using Markdown headings, blockquotes or horizontal lines is not allowed in replies")
765	}
766}
767
768func assertMembersUpdateIsEnabled(boardID boards.ID) {
769	if boardID != 0 {
770		assertRealmIsNotLocked()
771	} else {
772		assertRealmMembersAreNotLocked()
773	}
774}