Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}