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