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}