Search Apps Documentation Source Content File Folder Download Copy Actions Download

public.gno

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