public.gno
21.36 Kb · 822 lines
1package boards2
2
3import (
4 "chain"
5 "chain/runtime"
6 "regexp"
7 "strconv"
8 "strings"
9 "time"
10
11 "gno.land/p/nt/avl"
12)
13
14type BoardInfo struct {
15 ID BoardID
16 Name string
17 Aliases []string
18 Creator address
19 Readonly bool
20 NThreads int
21 // Format of Time.MarshalJSON()
22 CreatedAt string
23}
24
25type PostInfo struct {
26 ID PostID
27 BoardID BoardID
28 Creator address
29 Title string
30 Body string
31 Hidden bool
32 Readonly bool
33 ThreadID PostID
34 ParentID PostID
35 RepostBoardID BoardID
36 NReplies int
37 NRepliesAll int
38 NReposts int
39 // Format of Time.MarshalJSON()
40 CreatedAt string
41}
42
43const (
44 // MaxBoardNameLength defines the maximum length allowed for board names.
45 MaxBoardNameLength = 50
46
47 // MaxThreadTitleLength defines the maximum length allowed for thread titles.
48 MaxThreadTitleLength = 100
49
50 // MaxReplyLength defines the maximum length allowed for replies.
51 MaxReplyLength = 300
52)
53
54var (
55 reBoardName = regexp.MustCompile(`(?i)^[a-z]+[a-z0-9_\-]{2,50}$`)
56
57 // Minimalistic Markdown line prefix checks that if allowed would
58 // break the current UI when submitting a reply. It denies replies
59 // with headings, blockquotes or horizontal lines.
60 reDeniedReplyLinePrefixes = regexp.MustCompile(`(?m)^\s*(#|---|>)+`)
61)
62
63// SetHelp sets or updates boards realm help content.
64func SetHelp(_ realm, content string) {
65 content = strings.TrimSpace(content)
66 caller := runtime.PreviousRealm().Address()
67 args := Args{content}
68 gPerms.WithPermission(cross, caller, PermissionRealmHelp, args, func(realm) {
69 gHelp = content
70 })
71}
72
73// SetPermissions sets a permissions implementation for boards2 realm or a board.
74func SetPermissions(_ realm, bid BoardID, p Permissions) {
75 assertRealmIsNotLocked()
76 assertBoardExists(bid)
77
78 if p == nil {
79 panic("permissions is required")
80 }
81
82 caller := runtime.PreviousRealm().Address()
83 args := Args{bid}
84 gPerms.WithPermission(cross, caller, PermissionPermissionsUpdate, args, func(realm) {
85 assertRealmIsNotLocked()
86
87 // When board ID is zero it means that realm permissions are being updated
88 if bid == 0 {
89 gPerms = p
90
91 chain.Emit(
92 "RealmPermissionsUpdated",
93 "caller", caller.String(),
94 )
95 return
96 }
97
98 // Otherwise update the permissions of a single board
99 board := mustGetBoard(bid)
100 board.perms = p
101
102 chain.Emit(
103 "BoardPermissionsUpdated",
104 "caller", caller.String(),
105 "boardID", bid.String(),
106 )
107 })
108}
109
110// SetRealmNotice sets a notice to be displayed globally by the realm.
111// An empty message removes the realm notice.
112func SetRealmNotice(_ realm, message string) {
113 caller := runtime.PreviousRealm().Address()
114 assertHasPermission(gPerms, caller, PermissionThreadCreate)
115
116 gNotice = strings.TrimSpace(message)
117
118 chain.Emit(
119 "RealmNoticeChanged",
120 "caller", caller.String(),
121 "message", gNotice,
122 )
123}
124
125// GetBoardIDFromName searches a board by name and returns it's ID.
126func GetBoardIDFromName(_ realm, name string) (_ BoardID, found bool) {
127 board, found := getBoardByName(name)
128 if !found {
129 return 0, false
130 }
131 return board.ID, true
132}
133
134// CreateBoard creates a new board.
135//
136// Listed boards are included in the list of boards.
137func CreateBoard(_ realm, name string, listed bool) BoardID {
138 assertRealmIsNotLocked()
139
140 name = strings.TrimSpace(name)
141 assertIsValidBoardName(name)
142 assertBoardNameNotExists(name)
143
144 caller := runtime.PreviousRealm().Address()
145 id := reserveBoardID()
146 args := Args{caller, name, id, listed}
147 gPerms.WithPermission(cross, caller, PermissionBoardCreate, args, func(realm) {
148 assertRealmIsNotLocked()
149 assertBoardNameNotExists(name)
150
151 perms := createBasicBoardPermissions(caller)
152 board := newBoard(id, name, caller, perms)
153 key := id.Key()
154 gBoardsByID.Set(key, board)
155 gBoardsByName.Set(strings.ToLower(name), board)
156
157 // Listed boards are also indexed separately for easier iteration and pagination
158 if listed {
159 gListedBoardsByID.Set(key, board)
160 }
161
162 chain.Emit(
163 "BoardCreated",
164 "caller", caller.String(),
165 "boardID", id.String(),
166 "name", name,
167 )
168 })
169 return id
170}
171
172// RenameBoard changes the name of an existing board.
173//
174// A history of previous board names is kept when boards are renamed.
175// Because of that boards are also accesible using previous name(s).
176func RenameBoard(_ realm, name, newName string) {
177 assertRealmIsNotLocked()
178
179 newName = strings.TrimSpace(newName)
180 assertIsValidBoardName(newName)
181 assertBoardNameNotExists(newName)
182
183 board := mustGetBoardByName(name)
184 assertBoardIsNotFrozen(board)
185
186 bid := board.ID
187 caller := runtime.PreviousRealm().Address()
188 args := Args{caller, bid, name, newName}
189 board.perms.WithPermission(cross, caller, PermissionBoardRename, args, func(realm) {
190 assertRealmIsNotLocked()
191 assertBoardNameNotExists(newName)
192
193 board := mustGetBoard(bid)
194 assertBoardIsNotFrozen(board)
195
196 board.Aliases = append(board.Aliases, board.Name)
197 board.Name = newName
198
199 // Index board for the new name keeping previous indexes for older names
200 gBoardsByName.Set(strings.ToLower(newName), board)
201
202 chain.Emit(
203 "BoardRenamed",
204 "caller", caller.String(),
205 "boardID", bid.String(),
206 "name", name,
207 "newName", newName,
208 )
209 })
210}
211
212// CreateThread creates a new thread within a board.
213func CreateThread(_ realm, boardID BoardID, title, body string) PostID {
214 assertRealmIsNotLocked()
215
216 title = strings.TrimSpace(title)
217 assertTitleIsValid(title)
218
219 body = strings.TrimSpace(body)
220 assertBodyIsNotEmpty(body)
221
222 board := mustGetBoard(boardID)
223 assertBoardIsNotFrozen(board)
224
225 caller := runtime.PreviousRealm().Address()
226 assertUserIsNotBanned(board.ID, caller)
227 assertHasPermission(board.perms, caller, PermissionThreadCreate)
228
229 thread := board.AddThread(caller, title, body)
230
231 chain.Emit(
232 "ThreadCreated",
233 "caller", caller.String(),
234 "boardID", boardID.String(),
235 "threadID", thread.ID.String(),
236 "title", title,
237 )
238
239 return thread.ID
240}
241
242// CreateReply creates a new comment or reply within a thread.
243//
244// The value of `replyID` is only required when creating a reply of another reply.
245func CreateReply(_ realm, boardID BoardID, threadID, replyID PostID, body string) PostID {
246 assertRealmIsNotLocked()
247
248 body = strings.TrimSpace(body)
249 assertReplyBodyIsValid(body)
250
251 board := mustGetBoard(boardID)
252 assertBoardIsNotFrozen(board)
253
254 caller := runtime.PreviousRealm().Address()
255 assertHasPermission(board.perms, caller, PermissionReplyCreate)
256 assertUserIsNotBanned(boardID, caller)
257
258 thread := mustGetThread(board, threadID)
259 assertThreadIsVisible(thread)
260 assertThreadIsNotFrozen(thread)
261
262 var reply *Post
263 if replyID == 0 {
264 // When the parent reply is the thread just add reply to thread
265 reply = thread.AddReply(caller, body)
266 } else {
267 // Try to get parent reply and add a new child reply
268 parent := mustGetReply(thread, replyID)
269 if parent.Hidden || parent.Readonly {
270 panic("replying to a hidden or frozen reply is not allowed")
271 }
272
273 reply = parent.AddReply(caller, body)
274 }
275
276 chain.Emit(
277 "ReplyCreate",
278 "caller", caller.String(),
279 "boardID", boardID.String(),
280 "threadID", threadID.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 BoardID, threadID PostID, title, body string, destinationBoardID BoardID) PostID {
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 assertHasPermission(dst.perms, caller, PermissionThreadRepost)
300
301 board := mustGetBoard(boardID)
302 thread := mustGetThread(board, threadID)
303 assertThreadIsVisible(thread)
304
305 if thread.IsRepost() {
306 panic("reposting a thread that is a repost is not allowed")
307 }
308
309 body = strings.TrimSpace(body)
310 repost := thread.Repost(caller, dst, title, body)
311
312 chain.Emit(
313 "Repost",
314 "caller", caller.String(),
315 "boardID", boardID.String(),
316 "threadID", threadID.String(),
317 "destinationBoardID", destinationBoardID.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 BoardID, threadID PostID) {
329 // Council members should always be able to delete
330 caller := runtime.PreviousRealm().Address()
331 isRealmOwner := gPerms.HasRole(caller, RoleOwner) // TODO: Add DeleteThread filetest cases for realm owners
332 if !isRealmOwner {
333 assertRealmIsNotLocked()
334 }
335
336 board := mustGetBoard(boardID)
337 assertUserIsNotBanned(boardID, caller)
338
339 thread := mustGetThread(board, threadID)
340
341 if !isRealmOwner {
342 assertBoardIsNotFrozen(board)
343 assertThreadIsNotFrozen(thread)
344
345 if caller != thread.Creator {
346 assertHasPermission(board.perms, caller, PermissionThreadDelete)
347 }
348 }
349
350 // Hard delete thread and all its replies
351 board.DeleteThread(threadID)
352
353 chain.Emit(
354 "ThreadDeleted",
355 "caller", caller.String(),
356 "boardID", boardID.String(),
357 "threadID", threadID.String(),
358 )
359}
360
361// DeleteReply deletes a reply from a thread.
362//
363// Replies can be deleted by the users who created them or otherwise by users with special permissions.
364// Soft deletion is used when the deleted reply contains sub replies, in which case the reply content
365// is replaced by a text informing that reply has been deleted to avoid deleting sub-replies.
366func DeleteReply(_ realm, boardID BoardID, threadID, replyID PostID) {
367 // Council members should always be able to delete
368 caller := runtime.PreviousRealm().Address()
369 isRealmOwner := gPerms.HasRole(caller, RoleOwner) // TODO: Add DeleteReply filetest cases for realm owners
370 if !isRealmOwner {
371 assertRealmIsNotLocked()
372 }
373
374 board := mustGetBoard(boardID)
375 assertUserIsNotBanned(boardID, caller)
376
377 thread := mustGetThread(board, threadID)
378 reply := mustGetReply(thread, replyID)
379
380 if !isRealmOwner {
381 assertBoardIsNotFrozen(board)
382 assertThreadIsNotFrozen(thread)
383 assertReplyIsVisible(reply)
384 assertReplyIsNotFrozen(reply)
385
386 if caller != reply.Creator {
387 assertHasPermission(board.perms, caller, PermissionReplyDelete)
388 }
389 }
390
391 // Soft delete reply by changing its body when it contains
392 // sub-replies, otherwise hard delete it.
393 if reply.HasReplies() {
394 reply.Body = "This reply has been deleted"
395 reply.UpdatedAt = time.Now()
396 } else {
397 thread.DeleteReply(replyID)
398 }
399
400 chain.Emit(
401 "ReplyDeleted",
402 "caller", caller.String(),
403 "boardID", boardID.String(),
404 "threadID", threadID.String(),
405 "replyID", replyID.String(),
406 )
407}
408
409// EditThread updates the title and body of thread.
410//
411// Threads can be updated by the users who created them or otherwise by users with special permissions.
412func EditThread(_ realm, boardID BoardID, threadID PostID, title, body string) {
413 assertRealmIsNotLocked()
414
415 title = strings.TrimSpace(title)
416 assertTitleIsValid(title)
417
418 board := mustGetBoard(boardID)
419 assertBoardIsNotFrozen(board)
420
421 caller := runtime.PreviousRealm().Address()
422 assertUserIsNotBanned(boardID, caller)
423
424 thread := mustGetThread(board, threadID)
425 assertThreadIsNotFrozen(thread)
426
427 body = strings.TrimSpace(body)
428 if !thread.IsRepost() {
429 assertBodyIsNotEmpty(body)
430 }
431
432 if caller != thread.Creator {
433 assertHasPermission(board.perms, caller, PermissionThreadEdit)
434 }
435
436 thread.Title = title
437 thread.Body = body
438 thread.UpdatedAt = time.Now()
439
440 chain.Emit(
441 "ThreadEdited",
442 "caller", caller.String(),
443 "boardID", boardID.String(),
444 "threadID", threadID.String(),
445 "title", title,
446 )
447}
448
449// EditReply updates the body of comment or reply.
450//
451// Replies can be updated only by the users who created them.
452func EditReply(_ realm, boardID BoardID, threadID, replyID PostID, body string) {
453 assertRealmIsNotLocked()
454
455 body = strings.TrimSpace(body)
456 assertReplyBodyIsValid(body)
457
458 board := mustGetBoard(boardID)
459 assertBoardIsNotFrozen(board)
460
461 caller := runtime.PreviousRealm().Address()
462 assertUserIsNotBanned(boardID, caller)
463
464 thread := mustGetThread(board, threadID)
465 assertThreadIsNotFrozen(thread)
466
467 reply := mustGetReply(thread, replyID)
468 assertReplyIsVisible(reply)
469 assertReplyIsNotFrozen(reply)
470
471 if caller != reply.Creator {
472 panic("only the reply creator is allowed to edit it")
473 }
474
475 reply.Body = body
476 reply.UpdatedAt = time.Now()
477
478 chain.Emit(
479 "ReplyEdited",
480 "caller", caller.String(),
481 "boardID", boardID.String(),
482 "threadID", threadID.String(),
483 "replyID", replyID.String(),
484 "body", body,
485 )
486}
487
488// RemoveMember removes a member from the realm or a boards.
489//
490// Board ID is only required when removing a member from board.
491func RemoveMember(_ realm, boardID BoardID, member address) {
492 assertMembersUpdateIsEnabled(boardID)
493 assertMemberAddressIsValid(member)
494
495 perms := mustGetPermissions(boardID)
496 caller := runtime.PreviousRealm().Address()
497 perms.WithPermission(cross, caller, PermissionMemberRemove, Args{member}, func(realm) {
498 assertMembersUpdateIsEnabled(boardID)
499
500 if !perms.RemoveUser(cross, member) {
501 panic("member not found")
502 }
503
504 chain.Emit(
505 "MemberRemoved",
506 "caller", caller.String(),
507 "boardID", boardID.String(),
508 "member", member.String(),
509 )
510 })
511}
512
513// IsMember checks if an user is a member of the realm or a board.
514//
515// Board ID is only required when checking if a user is a member of a board.
516func IsMember(boardID BoardID, user address) bool {
517 assertUserAddressIsValid(user)
518
519 if boardID != 0 {
520 board := mustGetBoard(boardID)
521 assertBoardIsNotFrozen(board)
522 }
523
524 perms := mustGetPermissions(boardID)
525 return perms.HasUser(user)
526}
527
528// HasMemberRole checks if a realm or board member has a specific role assigned.
529//
530// Board ID is only required when checking a member of a board.
531func HasMemberRole(boardID BoardID, member address, role Role) bool {
532 assertMemberAddressIsValid(member)
533
534 if boardID != 0 {
535 board := mustGetBoard(boardID)
536 assertBoardIsNotFrozen(board)
537 }
538
539 perms := mustGetPermissions(boardID)
540 return perms.HasRole(member, role)
541}
542
543// ChangeMemberRole changes the role of a realm or board member.
544//
545// Board ID is only required when changing the role for a member of a board.
546func ChangeMemberRole(_ realm, boardID BoardID, member address, role Role) {
547 assertMemberAddressIsValid(member)
548 assertMembersUpdateIsEnabled(boardID)
549
550 perms := mustGetPermissions(boardID)
551 caller := runtime.PreviousRealm().Address()
552 args := Args{caller, boardID, member, role}
553 perms.WithPermission(cross, caller, PermissionRoleChange, args, func(realm) {
554 assertMembersUpdateIsEnabled(boardID)
555
556 perms.SetUserRoles(cross, member, role)
557
558 chain.Emit(
559 "RoleChanged",
560 "caller", caller.String(),
561 "boardID", boardID.String(),
562 "member", member.String(),
563 "newRole", string(role),
564 )
565 })
566}
567
568// IterateRealmMembers iterates boards realm members.
569// The iteration is done only for realm members, board members are not iterated.
570func IterateRealmMembers(offset int, fn UsersIterFn) (halted bool) {
571 count := gPerms.UsersCount() - offset
572 return gPerms.IterateUsers(offset, count, fn)
573}
574
575// GetBoard returns a single board.
576func GetBoard(boardID BoardID) *Board {
577 board := mustGetBoard(boardID)
578 if !board.perms.HasRole(runtime.OriginCaller(), RoleOwner) {
579 panic("forbidden")
580 }
581 return board
582}
583
584func assertMemberAddressIsValid(member address) {
585 if !member.IsValid() {
586 panic("invalid member address: " + member.String())
587 }
588}
589
590func assertUserAddressIsValid(user address) {
591 if !user.IsValid() {
592 panic("invalid user address: " + user.String())
593 }
594}
595
596func assertHasPermission(perms Permissions, user address, p Permission) {
597 if !perms.HasPermission(user, p) {
598 panic("unauthorized")
599 }
600}
601
602func assertBoardExists(id BoardID) {
603 if id == 0 { // ID zero is used to refer to the realm
604 return
605 }
606
607 if _, found := getBoard(id); !found {
608 panic("board not found: " + id.String())
609 }
610}
611
612func assertBoardIsNotFrozen(b *Board) {
613 if b.Readonly {
614 panic("board is frozen")
615 }
616}
617
618func assertIsValidBoardName(name string) {
619 size := len(name)
620 if size == 0 {
621 panic("board name is empty")
622 }
623
624 if size < 3 {
625 panic("board name is too short, minimum length is 3 characters")
626 }
627
628 if size > MaxBoardNameLength {
629 n := strconv.Itoa(MaxBoardNameLength)
630 panic("board name is too long, maximum allowed is " + n + " characters")
631 }
632
633 if !reBoardName.MatchString(name) {
634 panic("board name contains invalid characters")
635 }
636}
637
638func assertThreadIsNotFrozen(t *Post) {
639 if t.Readonly {
640 panic("thread is frozen")
641 }
642}
643
644func assertReplyIsNotFrozen(r *Post) {
645 if r.Readonly {
646 panic("reply is frozen")
647 }
648}
649
650func assertNameIsNotEmpty(name string) {
651 if name == "" {
652 panic("name is empty")
653 }
654}
655
656func assertTitleIsValid(title string) {
657 if title == "" {
658 panic("title is empty")
659 }
660
661 if len(title) > MaxThreadTitleLength {
662 n := strconv.Itoa(MaxThreadTitleLength)
663 panic("thread title is too long, maximum allowed is " + n + " characters")
664 }
665}
666
667func assertBodyIsNotEmpty(body string) {
668 if body == "" {
669 panic("body is empty")
670 }
671}
672
673func assertBoardNameNotExists(name string) {
674 name = strings.ToLower(name)
675 if gBoardsByName.Has(name) {
676 panic("board already exists")
677 }
678}
679
680func assertThreadExists(b *Board, threadID PostID) {
681 if _, found := b.GetThread(threadID); !found {
682 panic("thread not found: " + threadID.String())
683 }
684}
685
686func assertReplyExists(thread *Post, replyID PostID) {
687 if _, found := thread.GetReply(replyID); !found {
688 panic("reply not found: " + replyID.String())
689 }
690}
691
692func assertThreadIsVisible(thread *Post) {
693 if thread.Hidden {
694 panic("thread is hidden")
695 }
696}
697
698func assertReplyIsVisible(thread *Post) {
699 if thread.Hidden {
700 panic("reply is hidden")
701 }
702}
703
704func assertReplyBodyIsValid(body string) {
705 assertBodyIsNotEmpty(body)
706
707 if len(body) > MaxReplyLength {
708 n := strconv.Itoa(MaxReplyLength)
709 panic("reply is too long, maximum allowed is " + n + " characters")
710 }
711
712 if reDeniedReplyLinePrefixes.MatchString(body) {
713 panic("using Markdown headings, blockquotes or horizontal lines is not allowed in replies")
714 }
715}
716
717func assertMembersUpdateIsEnabled(boardID BoardID) {
718 if boardID != 0 {
719 assertRealmIsNotLocked()
720 } else {
721 assertRealmMembersAreNotLocked()
722 }
723}
724
725// GetListedBoards gets a sequence of boards in a range. Only listed boards are returned.
726// The first return value is the total count (independent of startIndex and endIndex).
727// While each board has an an arbitrary id, it also has an index within the returned sequence of boards starting from 0.
728// Limit the response to boards from startIndex up to (not including) endIndex.
729// If you just want the total count, set startIndex and endIndex to 0 and see the first return value.
730func GetListedBoards(startIndex int, endIndex int) (int, []BoardInfo) {
731 infos := []BoardInfo{}
732
733 for i := startIndex; i < endIndex && i < gListedBoardsByID.Size(); i++ {
734 _, boardI := gListedBoardsByID.GetByIndex(i)
735 board := boardI.(*Board)
736
737 createdAtJSON, err := board.createdAt.MarshalJSON()
738 if err != nil {
739 panic("Can't get createdAt JSON: " + err.Error())
740 }
741 createdAt := string(createdAtJSON)[1 : len(createdAtJSON)-1]
742
743 infos = append(infos, BoardInfo{
744 ID: board.ID,
745 Name: board.Name,
746 Aliases: board.Aliases,
747 Creator: board.Creator,
748 Readonly: board.Readonly,
749 NThreads: board.threads.Size(),
750 // Format of Time.MarshalJSON()
751 CreatedAt: string(createdAt),
752 })
753 }
754
755 return gListedBoardsByID.Size(), infos
756}
757
758// GetPosts gets a sequence of posts without replies. The first return value is the total count (independent of startIndex and endIndex).
759// While each post has an an arbitrary id, it also has an index within the returned sequence of posts starting from 0.
760// Limit the response to posts from startIndex up to (not including) endIndex within the thread.
761// If Hidden is true, then Title and Body are empty.
762// If you just want the total count, set startIndex and endIndex to 0 and see the first return value.
763// If threadID is 0 then return the user's top-level posts. (Like render args "board".)
764// If threadID is X and replyID is 0, then return the posts (without replies) in that thread. (Like render args "board/X".)
765// If threadID is X and replyID is Y, then return the posts in the thread starting with replyID. (Like render args "board/X/Y".)
766func GetPosts(boardID BoardID, threadID PostID, replyID PostID, startIndex int, endIndex int) (int, []PostInfo) {
767 board := mustGetBoard(boardID)
768
769 if threadID == 0 {
770 return getPostInfos(&board.threads, startIndex, endIndex)
771 }
772
773 thread := mustGetThread(board, threadID)
774 if replyID == 0 {
775 return getPostInfos(&thread.replies, startIndex, endIndex)
776 } else {
777 reply := mustGetReply(thread, replyID)
778 return getPostInfos(&reply.replies, startIndex, endIndex)
779 }
780}
781
782func getPostInfos(posts *avl.Tree, startIndex int, endIndex int) (int, []PostInfo) {
783 infos := []PostInfo{}
784
785 for i := startIndex; i < endIndex && i < posts.Size(); i++ {
786 _, postI := posts.GetByIndex(i)
787 post := postI.(*Post)
788
789 createdAtJSON, err := post.createdAt.MarshalJSON()
790 if err != nil {
791 panic("Can't get createdAt JSON: " + err.Error())
792 }
793 createdAt := string(createdAtJSON)[1 : len(createdAtJSON)-1]
794
795 title := post.Title
796 body := post.Body
797 if post.Hidden {
798 // The caller can check for Hidden and display the appropriate message
799 title = ""
800 body = ""
801 }
802
803 infos = append(infos, PostInfo{
804 ID: post.ID,
805 BoardID: post.Board.ID,
806 Creator: post.Creator,
807 Title: title,
808 Body: body,
809 Hidden: post.Hidden,
810 Readonly: post.Readonly,
811 ThreadID: post.ThreadID,
812 ParentID: post.ParentID,
813 RepostBoardID: post.RepostBoardID,
814 NReplies: post.replies.Size(),
815 NRepliesAll: post.repliesAll.Size(),
816 NReposts: post.reposts.Size(),
817 CreatedAt: string(createdAt),
818 })
819 }
820
821 return posts.Size(), infos
822}