package boards2
import (
"chain"
"chain/runtime"
"regexp"
"strconv"
"strings"
"time"
"gno.land/p/gnoland/boards"
)
const (
// MaxBoardNameLength defines the maximum length allowed for board names.
MaxBoardNameLength = 50
// MaxThreadTitleLength defines the maximum length allowed for thread titles.
MaxThreadTitleLength = 100
// MaxReplyLength defines the maximum length allowed for replies.
MaxReplyLength = 1000
)
var (
reBoardName = regexp.MustCompile(`(?i)^[a-z]+[a-z0-9_\-]{2,50}$`)
// Minimalistic Markdown line prefix checks that if allowed would
// break the current UI when submitting a reply. It denies replies
// with headings, blockquotes or horizontal lines.
reDeniedReplyLinePrefixes = regexp.MustCompile(`(?m)^\s*(#|---|>)+`)
)
// SetHelp sets or updates boards realm help content.
func SetHelp(_ realm, content string) {
content = strings.TrimSpace(content)
caller := runtime.PreviousRealm().Address()
args := boards.Args{content}
gPerms.WithPermission(caller, PermissionRealmHelp, args, crossingFn(func() {
gHelp = content
}))
}
// SetPermissions sets a permissions implementation for boards2 realm or a board.
func SetPermissions(_ realm, boardID boards.ID, p boards.Permissions) {
assertRealmIsNotLocked()
assertBoardExists(boardID)
if p == nil {
panic("permissions is required")
}
caller := runtime.PreviousRealm().Address()
args := boards.Args{boardID}
gPerms.WithPermission(caller, PermissionPermissionsUpdate, args, crossingFn(func() {
assertRealmIsNotLocked()
// When board ID is zero it means that realm permissions are being updated
if boardID == 0 {
gPerms = p
chain.Emit(
"RealmPermissionsUpdated",
"caller", caller.String(),
)
return
}
// Otherwise update the permissions of a single board
board := mustGetBoard(boardID)
board.Permissions = p
chain.Emit(
"BoardPermissionsUpdated",
"caller", caller.String(),
"boardID", board.ID.String(),
)
}))
}
// SetRealmNotice sets a notice to be displayed globally by the realm.
// An empty message removes the realm notice.
func SetRealmNotice(_ realm, message string) {
message = strings.TrimSpace(message)
caller := runtime.PreviousRealm().Address()
args := boards.Args{message}
gPerms.WithPermission(caller, PermissionRealmNotice, args, crossingFn(func() {
gNotice = message
chain.Emit(
"RealmNoticeChanged",
"caller", caller.String(),
"message", message,
)
}))
}
// GetBoardIDFromName searches a board by name and returns its ID.
func GetBoardIDFromName(_ realm, name string) (_ boards.ID, found bool) {
board, found := gBoards.GetByName(name)
if !found {
return 0, false
}
return board.ID, true
}
// CreateBoard creates a new board.
//
// Listed boards are included in the realm's list of boards.
// Open boards allow anyone to create threads and comment.
func CreateBoard(_ realm, name string, listed, open bool) boards.ID {
assertRealmIsNotLocked()
name = strings.TrimSpace(name)
assertIsValidBoardName(name)
assertBoardNameNotExists(name)
caller := runtime.PreviousRealm().Address()
id := gBoardsSequence.Next()
board := boards.New(id)
args := boards.Args{caller, name, board.ID, listed, open}
gPerms.WithPermission(caller, PermissionBoardCreate, args, crossingFn(func() {
assertRealmIsNotLocked()
assertBoardNameNotExists(name)
board.Name = name
board.Creator = caller
board.Meta = &BoardMeta{
HiddenThreads: boards.NewPostStorage(),
}
if open {
board.Permissions = createOpenBoardPermissions(caller)
} else {
board.Permissions = createBasicBoardPermissions(caller)
}
if err := gBoards.Add(board); err != nil {
panic(err)
}
// Listed boards are also indexed separately for easier iteration and pagination
if listed {
gListedBoardsByID.Set(board.ID.Key(), board)
}
chain.Emit(
"BoardCreated",
"caller", caller.String(),
"boardID", board.ID.String(),
"name", name,
)
}))
return board.ID
}
// RenameBoard changes the name of an existing board.
//
// A history of previous board names is kept when boards are renamed.
// Because of that boards are also accessible using previous name(s).
func RenameBoard(_ realm, name, newName string) {
assertRealmIsNotLocked()
newName = strings.TrimSpace(newName)
assertIsValidBoardName(newName)
assertBoardNameNotExists(newName)
board := mustGetBoardByName(name)
assertBoardIsNotFrozen(board)
caller := runtime.PreviousRealm().Address()
args := boards.Args{caller, board.ID, name, newName}
board.Permissions.WithPermission(caller, PermissionBoardRename, args, crossingFn(func() {
assertRealmIsNotLocked()
assertBoardNameNotExists(newName)
board.Aliases = append(board.Aliases, board.Name)
board.Name = newName
// Index board for the new name keeping previous indexes for older names
gBoards.Add(board)
chain.Emit(
"BoardRenamed",
"caller", caller.String(),
"boardID", board.ID.String(),
"name", name,
"newName", newName,
)
}))
}
// CreateThread creates a new thread within a board.
func CreateThread(_ realm, boardID boards.ID, title, body string) boards.ID {
assertRealmIsNotLocked()
title = strings.TrimSpace(title)
assertTitleIsValid(title)
caller := runtime.PreviousRealm().Address()
assertUserIsNotBanned(boardID, caller)
board := mustGetBoard(boardID)
assertBoardIsNotFrozen(board)
thread := boards.MustNewThread(board, caller, title, body)
args := boards.Args{caller, board.ID, thread.ID, title, body}
board.Permissions.WithPermission(caller, PermissionThreadCreate, args, crossingFn(func() {
assertRealmIsNotLocked()
assertUserIsNotBanned(board.ID, caller)
thread.Meta = &ThreadMeta{
AllReplies: boards.NewPostStorage(),
}
if err := board.Threads.Add(thread); err != nil {
panic(err)
}
chain.Emit(
"ThreadCreated",
"caller", caller.String(),
"boardID", board.ID.String(),
"threadID", thread.ID.String(),
"title", title,
)
}))
return thread.ID
}
// CreateReply creates a new comment or reply within a thread.
//
// The value of `replyID` is only required when creating a reply of another reply.
func CreateReply(_ realm, boardID, threadID, replyID boards.ID, body string) boards.ID {
assertRealmIsNotLocked()
body = strings.TrimSpace(body)
assertReplyBodyIsValid(body)
caller := runtime.PreviousRealm().Address()
assertUserIsNotBanned(boardID, caller)
board := mustGetBoard(boardID)
assertBoardIsNotFrozen(board)
thread := mustGetThread(board, threadID)
assertThreadIsVisible(thread)
assertThreadIsNotFrozen(thread)
// By default consider that reply's parent is the thread.
// Or when replyID is assigned use that reply as the parent.
parent := thread
if replyID > 0 {
parent = mustGetReply(thread, replyID)
if parent.Hidden || parent.Readonly {
panic("replying to a hidden or frozen reply is not allowed")
}
}
reply := boards.MustNewReply(parent, caller, body)
args := boards.Args{caller, board.ID, thread.ID, parent.ID, reply.ID, body}
board.Permissions.WithPermission(caller, PermissionReplyCreate, args, crossingFn(func() {
assertRealmIsNotLocked()
// Add reply to its parent
if err := parent.Replies.Add(reply); err != nil {
panic(err)
}
// Always add reply to the thread so it contains all comments and replies.
// Comment and reply only contains direct replies.
meta := thread.Meta.(*ThreadMeta)
if err := meta.AllReplies.Add(reply); err != nil {
panic(err)
}
chain.Emit(
"ReplyCreate",
"caller", caller.String(),
"boardID", board.ID.String(),
"threadID", thread.ID.String(),
"replyID", reply.ID.String(),
)
}))
return reply.ID
}
// CreateRepost reposts a thread into another board.
func CreateRepost(_ realm, boardID, threadID, destinationBoardID boards.ID, title, body string) boards.ID {
assertRealmIsNotLocked()
title = strings.TrimSpace(title)
assertTitleIsValid(title)
caller := runtime.PreviousRealm().Address()
assertUserIsNotBanned(destinationBoardID, caller)
dst := mustGetBoard(destinationBoardID)
assertBoardIsNotFrozen(dst)
board := mustGetBoard(boardID)
thread := mustGetThread(board, threadID)
assertThreadIsVisible(thread)
repost := boards.MustNewRepost(thread, dst, caller)
args := boards.Args{caller, board.ID, thread.ID, dst.ID, repost.ID, title, body}
dst.Permissions.WithPermission(caller, PermissionThreadRepost, args, crossingFn(func() {
assertRealmIsNotLocked()
repost.Title = title
repost.Body = strings.TrimSpace(body)
if err := dst.Threads.Add(repost); err != nil {
panic(err)
}
if err := thread.Reposts.Add(repost); err != nil {
panic(err)
}
chain.Emit(
"Repost",
"caller", caller.String(),
"boardID", board.ID.String(),
"threadID", thread.ID.String(),
"destinationBoardID", dst.ID.String(),
"repostID", repost.ID.String(),
"title", title,
)
}))
return repost.ID
}
// DeleteThread deletes a thread from a board.
//
// Threads can be deleted by the users who created them or otherwise by users with special permissions.
func DeleteThread(_ realm, boardID, threadID boards.ID) {
assertRealmIsNotLocked()
caller := runtime.PreviousRealm().Address()
board := mustGetBoard(boardID)
assertUserIsNotBanned(boardID, caller)
isRealmOwner := gPerms.HasRole(caller, RoleOwner) // TODO: Add DeleteThread filetest cases for realm owners
if !isRealmOwner {
assertBoardIsNotFrozen(board)
}
thread := mustGetThread(board, threadID)
deleteThread := func() {
board.Threads.Remove(thread.ID)
chain.Emit(
"ThreadDeleted",
"caller", caller.String(),
"boardID", board.ID.String(),
"threadID", thread.ID.String(),
)
}
// Thread can be directly deleted by user that created it.
// It can also be deleted by realm owners, to be able to delete inappropriate content.
// TODO: Discuss and decide if realm owners should be able to delete threads.
if isRealmOwner || caller == thread.Creator {
deleteThread()
return
}
args := boards.Args{caller, board.ID, thread.ID}
board.Permissions.WithPermission(caller, PermissionThreadDelete, args, crossingFn(func() {
assertRealmIsNotLocked()
deleteThread()
}))
}
// DeleteReply deletes a reply from a thread.
//
// Replies can be deleted by the users who created them or otherwise by users with special permissions.
// Soft deletion is used when the deleted reply contains sub replies, in which case the reply content
// is replaced by a text informing that reply has been deleted to avoid deleting sub-replies.
func DeleteReply(_ realm, boardID, threadID, replyID boards.ID) {
assertRealmIsNotLocked()
caller := runtime.PreviousRealm().Address()
board := mustGetBoard(boardID)
assertUserIsNotBanned(boardID, caller)
thread := mustGetThread(board, threadID)
reply := mustGetReply(thread, replyID)
isRealmOwner := gPerms.HasRole(caller, RoleOwner) // TODO: Add DeleteReply filetest cases for realm owners
if !isRealmOwner {
assertBoardIsNotFrozen(board)
assertThreadIsNotFrozen(thread)
assertReplyIsVisible(reply)
}
deleteReply := func() {
// Soft delete comment/reply by changing its body when
// it contains replies, otherwise hard delete it.
if reply.Replies.Size() > 0 {
reply.Body = "⚠ This comment has been deleted"
reply.UpdatedAt = time.Now()
} else {
// Remove reply from the thread
meta := thread.Meta.(*ThreadMeta)
reply, removed := meta.AllReplies.Remove(replyID)
if !removed {
panic("reply not found")
}
// Remove reply from reply's parent
if reply.ParentID != thread.ID {
parent, found := meta.AllReplies.Get(reply.ParentID)
if found {
parent.Replies.Remove(replyID)
}
}
}
chain.Emit(
"ReplyDeleted",
"caller", caller.String(),
"boardID", board.ID.String(),
"threadID", thread.ID.String(),
"replyID", reply.ID.String(),
)
}
// Reply can be directly deleted by user that created it.
// It can also be deleted by realm owners, to be able to delete inappropriate content.
// TODO: Discuss and decide if realm owners should be able to delete replies.
if isRealmOwner || caller == reply.Creator {
deleteReply()
return
}
args := boards.Args{caller, board.ID, thread.ID, reply.ID}
board.Permissions.WithPermission(caller, PermissionReplyDelete, args, crossingFn(func() {
assertRealmIsNotLocked()
deleteReply()
}))
}
// EditThread updates the title and body of a thread.
//
// Threads can be updated by the users who created them or otherwise by users with special permissions.
func EditThread(_ realm, boardID, threadID boards.ID, title, body string) {
assertRealmIsNotLocked()
title = strings.TrimSpace(title)
assertTitleIsValid(title)
board := mustGetBoard(boardID)
assertBoardIsNotFrozen(board)
caller := runtime.PreviousRealm().Address()
assertUserIsNotBanned(boardID, caller)
thread := mustGetThread(board, threadID)
assertThreadIsNotFrozen(thread)
body = strings.TrimSpace(body)
if !boards.IsRepost(thread) {
assertBodyIsNotEmpty(body)
}
editThread := func() {
thread.Title = title
thread.Body = body
thread.UpdatedAt = time.Now()
chain.Emit(
"ThreadEdited",
"caller", caller.String(),
"boardID", board.ID.String(),
"threadID", thread.ID.String(),
"title", title,
)
}
if caller == thread.Creator {
editThread()
return
}
args := boards.Args{caller, board.ID, thread.ID, title, body}
board.Permissions.WithPermission(caller, PermissionThreadEdit, args, crossingFn(func() {
assertRealmIsNotLocked()
editThread()
}))
}
// EditReply updates the body of a comment or reply.
//
// Replies can be updated only by the users who created them.
func EditReply(_ realm, boardID, threadID, replyID boards.ID, body string) {
assertRealmIsNotLocked()
body = strings.TrimSpace(body)
assertReplyBodyIsValid(body)
board := mustGetBoard(boardID)
assertBoardIsNotFrozen(board)
caller := runtime.PreviousRealm().Address()
assertUserIsNotBanned(boardID, caller)
thread := mustGetThread(board, threadID)
assertThreadIsNotFrozen(thread)
reply := mustGetReply(thread, replyID)
assertReplyIsVisible(reply)
if caller != reply.Creator {
panic("only the reply creator is allowed to edit it")
}
reply.Body = body
reply.UpdatedAt = time.Now()
chain.Emit(
"ReplyEdited",
"caller", caller.String(),
"boardID", board.ID.String(),
"threadID", thread.ID.String(),
"replyID", reply.ID.String(),
"body", body,
)
}
// RemoveMember removes a member from the realm or a board.
//
// Board ID is only required when removing a member from board.
func RemoveMember(_ realm, boardID boards.ID, member address) {
assertMembersUpdateIsEnabled(boardID)
assertMemberAddressIsValid(member)
perms := mustGetPermissions(boardID)
origin := runtime.OriginCaller()
caller := runtime.PreviousRealm().Address()
removeMember := func() {
if !perms.RemoveUser(member) {
panic("member not found")
}
chain.Emit(
"MemberRemoved",
"caller", caller.String(),
"origin", origin.String(), // When origin and caller match it means self removal
"boardID", boardID.String(),
"member", member.String(),
)
}
// Members can remove themselves without permission
if origin == member {
removeMember()
return
}
args := boards.Args{boardID, member}
perms.WithPermission(caller, PermissionMemberRemove, args, crossingFn(func() {
assertMembersUpdateIsEnabled(boardID)
removeMember()
}))
}
// IsMember checks if a user is a member of the realm or a board.
//
// Board ID is only required when checking if a user is a member of a board.
func IsMember(boardID boards.ID, user address) bool {
assertUserAddressIsValid(user)
if boardID != 0 {
board := mustGetBoard(boardID)
assertBoardIsNotFrozen(board)
}
perms := mustGetPermissions(boardID)
return perms.HasUser(user)
}
// HasMemberRole checks if a realm or board member has a specific role assigned.
//
// Board ID is only required when checking a member of a board.
func HasMemberRole(boardID boards.ID, member address, role boards.Role) bool {
assertMemberAddressIsValid(member)
if boardID != 0 {
board := mustGetBoard(boardID)
assertBoardIsNotFrozen(board)
}
perms := mustGetPermissions(boardID)
return perms.HasRole(member, role)
}
// ChangeMemberRole changes the role of a realm or board member.
//
// Board ID is only required when changing the role for a member of a board.
func ChangeMemberRole(_ realm, boardID boards.ID, member address, role boards.Role) {
assertMemberAddressIsValid(member)
assertMembersUpdateIsEnabled(boardID)
if role == "" {
role = RoleGuest
}
perms := mustGetPermissions(boardID)
caller := runtime.PreviousRealm().Address()
args := boards.Args{caller, boardID, member, role}
perms.WithPermission(caller, PermissionRoleChange, args, crossingFn(func() {
assertMembersUpdateIsEnabled(boardID)
perms.SetUserRoles(member, role)
chain.Emit(
"RoleChanged",
"caller", caller.String(),
"boardID", boardID.String(),
"member", member.String(),
"newRole", string(role),
)
}))
}
// IterateRealmMembers iterates boards realm members.
// The iteration is done only for realm members, board members are not iterated.
func IterateRealmMembers(offset int, fn boards.UsersIterFn) (halted bool) {
count := gPerms.UsersCount() - offset
return gPerms.IterateUsers(offset, count, fn)
}
// GetBoard returns a single board.
func GetBoard(boardID boards.ID) *boards.Board {
board := mustGetBoard(boardID)
if !board.Permissions.HasRole(runtime.OriginCaller(), RoleOwner) {
panic("forbidden")
}
return board
}
// Wraps a function to cross back to Boards2 realm.
func crossingFn(fn func()) func() {
return func() {
func(realm) { fn() }(cross)
}
}
func assertMemberAddressIsValid(member address) {
if !member.IsValid() {
panic("invalid member address: " + member.String())
}
}
func assertUserAddressIsValid(user address) {
if !user.IsValid() {
panic("invalid user address: " + user.String())
}
}
func assertHasPermission(perms boards.Permissions, user address, p boards.Permission) {
if !perms.HasPermission(user, p) {
panic("unauthorized")
}
}
func assertBoardExists(id boards.ID) {
if id == 0 { // ID zero is used to refer to the realm
return
}
if _, found := gBoards.Get(id); !found {
panic("board not found: " + id.String())
}
}
func assertBoardIsNotFrozen(b *boards.Board) {
if b.Readonly {
panic("board is frozen")
}
}
func assertIsValidBoardName(name string) {
size := len(name)
if size == 0 {
panic("board name is empty")
}
if size < 3 {
panic("board name is too short, minimum length is 3 characters")
}
if size > MaxBoardNameLength {
n := strconv.Itoa(MaxBoardNameLength)
panic("board name is too long, maximum allowed is " + n + " characters")
}
if !reBoardName.MatchString(name) {
panic("board name must start with a letter and have letters, numbers, \"-\" and \"_\"")
}
}
func assertThreadIsNotFrozen(t *boards.Post) {
if t.Readonly {
panic("thread is frozen")
}
}
func assertNameIsNotEmpty(name string) {
if name == "" {
panic("name is empty")
}
}
func assertTitleIsValid(title string) {
if title == "" {
panic("title is empty")
}
if len(title) > MaxThreadTitleLength {
n := strconv.Itoa(MaxThreadTitleLength)
panic("title is too long, maximum allowed is " + n + " characters")
}
}
func assertBodyIsNotEmpty(body string) {
if body == "" {
panic("body is empty")
}
}
func assertBoardNameNotExists(name string) {
name = strings.ToLower(name)
if _, found := gBoards.GetByName(name); found {
panic("board already exists")
}
}
func assertThreadExists(b *boards.Board, threadID boards.ID) {
if _, found := getThread(b, threadID); !found {
panic("thread not found: " + threadID.String())
}
}
func assertReplyExists(thread *boards.Post, replyID boards.ID) {
if _, found := getReply(thread, replyID); !found {
panic("reply not found: " + replyID.String())
}
}
func assertThreadIsVisible(thread *boards.Post) {
if thread.Hidden {
panic("thread is hidden")
}
}
func assertReplyIsVisible(thread *boards.Post) {
if thread.Hidden {
panic("reply is hidden")
}
}
func assertReplyBodyIsValid(body string) {
assertBodyIsNotEmpty(body)
if len(body) > MaxReplyLength {
n := strconv.Itoa(MaxReplyLength)
panic("reply is too long, maximum allowed is " + n + " characters")
}
if reDeniedReplyLinePrefixes.MatchString(body) {
panic("using Markdown headings, blockquotes or horizontal lines is not allowed in replies")
}
}
func assertMembersUpdateIsEnabled(boardID boards.ID) {
if boardID != 0 {
assertRealmIsNotLocked()
} else {
assertRealmMembersAreNotLocked()
}
}