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}