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}