Search Apps Documentation Source Content File Folder Download Copy Actions Download

public.gno

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