Kind Realm (renderable)
Name v0
Namespace gnoland / boards2
Exported functions 51
Module gno.land/r/gnoland/boards2/v0
gno 0.9 public.gno gno
⧉
1 package boards2 2 3 import ( 4 "chain" 5 "chain/runtime/unsafe" 6 "regexp" 7 "strconv" 8 AcceptInvite (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, user string)
AreRealmMembersLocked () bool
Ban (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, user string, hours uint, reason string)
BoardCount () int
ChangeMemberRole (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, member string, role string)
CreateBoard (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, name string, listed bool, open bool) uint64
CreateReply (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, threadID uint64, replyID uint64, body string) uint64
CreateRepost (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, threadID uint64, destinationBoardID uint64, title string, body string) uint64
CreateThread (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, title string, body string) uint64
DeleteReply (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, threadID uint64, replyID uint64)
DeleteThread (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, threadID uint64)
EditReply (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, threadID uint64, replyID uint64, body string)
EditThread (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, threadID uint64, title string, body string)
FlagReply (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, threadID uint64, replyID uint64, reason string)
FlagThread (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, threadID uint64, reason string)
FreezeBoard (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64)
FreezeThread (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, threadID uint64)
GetBoard (id uint64) (struct{id uint64; name string; aliases []string; readonly bool; threadCount int; memberCount int; creator .uverse.address; createdAt int64; updatedAt int64}, bool)
GetBoardIDFromName (interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, name string) (uint64, found bool)
GetBoards (start int, count int) []gno.land/p/gnoland/boards/exts/hub/v0.Board
GetComment (boardID uint64, threadID uint64, commentID uint64) (struct{id uint64; boardID uint64; threadID uint64; parentID uint64; body string; hidden bool; replyCount int; flagCount int; creator .uverse.address; createdAt int64; updatedAt int64}, bool)
GetComments (boardID uint64, threadID uint64, start int, count int) []gno.land/p/gnoland/boards/exts/hub/v0.Comment
GetFlaggingThreshold (boardID uint64) int
GetFlags (boardID uint64, threadID uint64, commentID uint64, start int, count int) []gno.land/p/gnoland/boards/exts/hub/v0.Flag
GetMembers (boardID uint64, start int, count int) []gno.land/p/gnoland/boards/exts/hub/v0.Member
GetReplies (boardID uint64, threadID uint64, commentID uint64, start int, count int) []gno.land/p/gnoland/boards/exts/hub/v0.Comment
GetReposts (boardID uint64, threadID uint64, start int, count int) []gno.land/p/gnoland/boards/exts/hub/v0.Thread
GetThread (boardID uint64, threadID uint64) (struct{id uint64; originalBoardID uint64; originalThreadID uint64; boardID uint64; title string; body string; hidden bool; readonly bool; commentCount int; repostCount int; flagCount int; creator .uverse.address; createdAt int64; updatedAt int64}, bool)
GetThreads (boardID uint64, start int, count int) []gno.land/p/gnoland/boards/exts/hub/v0.Thread
HasMemberRole (boardID uint64, member string, role string) bool
InviteMember (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, user string, role string)
InviteMembers (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, invites ...gno.land/r/gnoland/boards2/v0.Invite)
IsBanned (boardID uint64, user string) bool
IsBoardFrozen (boardID uint64) bool
IsMember (boardID uint64, user string) bool
IsRealmLocked () bool
IsThreadFrozen (boardID uint64, threadID uint64) bool
LockRealm (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, lockRealmMembers bool)
RemoveMember (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, member string)
RenameBoard (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, name string, newName string)
Render (path string) string
RequestInvite (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64)
RevokeInvite (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, user string)
SetFlaggingThreshold (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, threshold int)
SetHelp (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, content string)
SetPermissions (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, p interface {HasPermission func(.uverse.address, gno.land/p/gnoland/boards/v0.Permission) bool; HasRole func(.uverse.address, gno.land/p/gnoland/boards/v0.Role) bool; HasUser func(.uverse.address) bool; IterateUsers func(int, int, gno.land/p/gnoland/boards/v0.UsersIterFn) bool; RemoveUser func(.uverse.address) bool; SetUserRoles func(.uverse.address, ...gno.land/p/gnoland/boards/v0.Role); UsersCount func() int; WithPermission func(.uverse.address, gno.land/p/gnoland/boards/v0.Permission, gno.land/p/gnoland/boards/v0.Args, func())})
SetRealmNotice (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, message string)
SetRequiredAccountAmount (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, amount int64)
Unban (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, user string, reason string)
UnfreezeBoard (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64)
UnfreezeThread (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, boardID uint64, threadID uint64)
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.
"strings"
9 "time"
10
11 "gno.land/p/gnoland/boards/v0"
12 )
13
14 const (
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 // MaxThreadBodyLength defines the maximum length allowed for thread bodies.
22 // 40,000 mirrors Reddit's self-post body cap.
23 MaxThreadBodyLength = 40000
24
25 // MaxReplyLength defines the maximum length allowed for replies.
26 // 10,000 mirrors Reddit's comment cap.
27 MaxReplyLength = 10000
28 )
29
30 var reBoardName = regexp.MustCompile( `(?i)^[a-z]+[a-z0-9_\-]{2,50}$` )
31
32 // SetHelp sets or updates boards realm help content.
33 func SetHelp(cur realm, content string ) {
34 content = strings.TrimSpace(content)
35 caller := cur.Previous().Address()
36 args := boards.Args{content}
37 gPerms.WithPermission(caller, PermissionRealmHelpChange, args, func () {
38 Help = content
39 })
40 }
41
42 // SetRequiredAccountAmount sets the required account amount to interact as a non member with open boards.
43 // Amount must be given as ugnot.
44 // The amount requirement is not applied to members that were invited to an open board.
45 func SetRequiredAccountAmount(cur realm, amount int64 ) {
46 if amount < 0 {
47 panic( "invalid amount" )
48 }
49
50 caller := cur.Previous().Address()
51 args := boards.Args{amount}
52 gPerms.WithPermission(caller, PermissionAccountRequiredAmountChange, args, func () {
53 RequiredAccountAmount = amount
54
55 chain.Emit(
56 "RequiredAccountAmountChanged" ,
57 "caller" , caller.String(),
58 "amount" , strconv.FormatInt(amount, 10 ),
59 )
60 })
61 }
62
63 // SetPermissions sets a permissions implementation for boards2 realm or a board.
64 func SetPermissions(cur realm, boardID boards.ID, p boards.Permissions) {
65 assertRealmIsNotLocked()
66 assertBoardExists(boardID)
67
68 if p == nil {
69 panic( "permissions is required" )
70 }
71
72 caller := cur.Previous().Address()
73 args := boards.Args{boardID}
74 gPerms.WithPermission(caller, PermissionPermissionsUpdate, args, func () {
75 assertRealmIsNotLocked()
76
77 // When board ID is zero it means that realm permissions are being updated
78 if boardID == 0 {
79 gPerms = p
80
81 chain.Emit(
82 "RealmPermissionsChanged" ,
83 "caller" , caller.String(),
84 )
85 return
86 }
87
88 // Otherwise update the permissions of a single board
89 board := mustGetBoard(boardID)
90 board.Permissions = p
91
92 chain.Emit(
93 "BoardPermissionsChanged" ,
94 "caller" , caller.String(),
95 "boardID" , board.ID.String(),
96 )
97 })
98 }
99
100 // SetRealmNotice sets a notice to be displayed globally within the realm.
101 // An empty message removes the realm notice.
102 func SetRealmNotice(cur realm, message string ) {
103 message = strings.TrimSpace(message)
104 caller := cur.Previous().Address()
105 args := boards.Args{message}
106 gPerms.WithPermission(caller, PermissionRealmNotice, args, func () {
107 Notice = message
108
109 chain.Emit(
110 "RealmNoticeChanged" ,
111 "caller" , caller.String(),
112 "message" , message,
113 )
114 })
115 }
116
117 // GetBoardIDFromName searches a board by name and returns its ID.
118 func GetBoardIDFromName(_ realm, name string ) (_ boards.ID, found bool ) {
119 board, found := gBoards.GetByName(name)
120 if !found {
121 return 0 , false
122 }
123 return board.ID, true
124 }
125
126 // BoardCount returns the total number of boards.
127 func BoardCount() int {
128 return gBoards.Size()
129 }
130
131 // CreateBoard creates a new board.
132 //
133 // Listed boards are included in the realm's list of boards.
134 // Open boards allow anyone to create threads and comment.
135 func CreateBoard(cur realm, name string , listed, open bool ) boards.ID {
136 assertRealmIsNotLocked()
137
138 name = strings.TrimSpace(name)
139 assertIsValidBoardName(name)
140 assertBoardNameNotExists(name)
141
142 caller := cur.Previous().Address()
143 id := gBoardsSequence.Next()
144 board := boards.New(id)
145 args := boards.Args{caller, name, board.ID, listed, open}
146 gPerms.WithPermission(caller, PermissionBoardCreate, args, func () {
147 assertRealmIsNotLocked()
148 assertBoardNameNotExists(name)
149
150 board.Name = name
151 board.Creator = caller
152 board.Meta = &BoardMeta{
153 HiddenThreads: boards.NewPostStorage(),
154 }
155
156 if open {
157 board.Permissions = createOpenBoardPermissions(caller)
158 } else {
159 board.Permissions = createBasicBoardPermissions(caller)
160 }
161
162 if err := gBoards.Add(board); err != nil {
163 panic(err)
164 }
165
166 // Listed boards are also indexed separately for easier iteration and pagination
167 if listed {
168 gListedBoardsByID.Set(board.ID.Key(), board)
169 }
170
171 chain.Emit(
172 "BoardCreated" ,
173 "caller" , caller.String(),
174 "boardID" , board.ID.String(),
175 "name" , name,
176 )
177 })
178 return board.ID
179 }
180
181 // RenameBoard changes the name of an existing board.
182 //
183 // A history of previous board names is kept when boards are renamed.
184 // Because of that boards are also accessible using previous name(s).
185 func RenameBoard(cur realm, name, newName string ) {
186 assertRealmIsNotLocked()
187
188 newName = strings.TrimSpace(newName)
189 assertIsValidBoardName(newName)
190 assertBoardNameNotExists(newName)
191
192 board := mustGetBoardByName(name)
193 assertBoardIsNotFrozen(board)
194
195 caller := cur.Previous().Address()
196 args := boards.Args{caller, board.ID, name, newName}
197 board.Permissions.WithPermission(caller, PermissionBoardRename, args, func () {
198 assertRealmIsNotLocked()
199 assertBoardNameNotExists(newName)
200
201 board.Aliases = append(board.Aliases, board.Name)
202 board.Name = newName
203 board.UpdatedAt = time.Now()
204
205 // Index board for the new name keeping previous indexes for older names
206 gBoards.Add(board)
207
208 chain.Emit(
209 "BoardRenamed" ,
210 "caller" , caller.String(),
211 "boardID" , board.ID.String(),
212 "name" , name,
213 "newName" , newName,
214 )
215 })
216 }
217
218 // CreateThread creates a new thread within a board.
219 func CreateThread(cur realm, boardID boards.ID, title, body string ) boards.ID {
220 assertRealmIsNotLocked()
221
222 title = strings.TrimSpace(title)
223 assertTitleIsValid(title)
224 assertThreadBodyIsValid(body)
225
226 caller := cur.Previous().Address()
227 assertUserIsNotBanned(boardID, caller)
228
229 board := mustGetBoard(boardID)
230 assertBoardIsNotFrozen(board)
231
232 thread := boards.MustNewThread(board, caller, title, body)
233 args := boards.Args{caller, board.ID, thread.ID, title, body}
234 board.Permissions.WithPermission(caller, PermissionThreadCreate, args, func () {
235 assertRealmIsNotLocked()
236 assertUserIsNotBanned(board.ID, caller)
237
238 thread.Meta = &ThreadMeta{
239 AllReplies: boards.NewPostStorage(),
240 }
241
242 if err := board.Threads.Add(thread); err != nil {
243 panic(err)
244 }
245
246 chain.Emit(
247 "ThreadCreated" ,
248 "caller" , caller.String(),
249 "boardID" , board.ID.String(),
250 "threadID" , thread.ID.String(),
251 "title" , title,
252 )
253 })
254 return thread.ID
255 }
256
257 // CreateReply creates a new comment or reply within a thread.
258 //
259 // The value of `replyID` is only required when creating a reply of another reply.
260 func CreateReply(cur realm, boardID, threadID, replyID boards.ID, body string ) boards.ID {
261 assertRealmIsNotLocked()
262
263 body = strings.TrimSpace(body)
264 assertReplyBodyIsValid(body)
265
266 caller := cur.Previous().Address()
267 assertUserIsNotBanned(boardID, caller)
268
269 board := mustGetBoard(boardID)
270 assertBoardIsNotFrozen(board)
271
272 thread := mustGetThread(board, threadID)
273 assertThreadIsVisible(thread)
274 assertThreadIsNotFrozen(thread)
275
276 // By default consider that reply's parent is the thread.
277 // Or when replyID is assigned use that reply as the parent.
278 parent := thread
279 if replyID > 0 {
280 parent = mustGetReply(thread, replyID)
281 if parent.Hidden || parent.Readonly {
282 panic( "replying to a hidden or frozen reply is not allowed" )
283 }
284 }
285
286 reply := boards.MustNewReply(parent, caller, body)
287 args := boards.Args{caller, board.ID, thread.ID, parent.ID, reply.ID, body}
288 board.Permissions.WithPermission(caller, PermissionReplyCreate, args, func () {
289 assertRealmIsNotLocked()
290
291 // Add reply to its parent
292 if err := parent.Replies.Add(reply); err != nil {
293 panic(err)
294 }
295
296 // Always add reply to the thread so it contains all comments and replies.
297 // Comment and reply only contains direct replies.
298 meta := thread.Meta.(*ThreadMeta)
299 if err := meta.AllReplies.Add(reply); err != nil {
300 panic(err)
301 }
302
303 chain.Emit(
304 "ReplyCreate" ,
305 "caller" , caller.String(),
306 "boardID" , board.ID.String(),
307 "threadID" , thread.ID.String(),
308 "replyID" , reply.ID.String(),
309 )
310 })
311 return reply.ID
312 }
313
314 // CreateRepost reposts a thread into another board.
315 func CreateRepost(cur realm, boardID, threadID, destinationBoardID boards.ID, title, body string ) boards.ID {
316 assertRealmIsNotLocked()
317
318 title = strings.TrimSpace(title)
319 assertTitleIsValid(title)
320 assertThreadBodyIsValid(body)
321
322 caller := cur.Previous().Address()
323 assertUserIsNotBanned(destinationBoardID, caller)
324
325 dst := mustGetBoard(destinationBoardID)
326 assertBoardIsNotFrozen(dst)
327
328 board := mustGetBoard(boardID)
329 thread := mustGetThread(board, threadID)
330 assertThreadIsVisible(thread)
331
332 repost := boards.MustNewRepost(thread, dst, caller)
333 args := boards.Args{caller, board.ID, thread.ID, dst.ID, repost.ID, title, body}
334 dst.Permissions.WithPermission(caller, PermissionThreadRepost, args, func () {
335 assertRealmIsNotLocked()
336
337 repost.Title = title
338 repost.Body = strings.TrimSpace(body)
339 repost.Meta = &ThreadMeta{
340 AllReplies: boards.NewPostStorage(),
341 }
342
343 if err := dst.Threads.Add(repost); err != nil {
344 panic(err)
345 }
346
347 if err := thread.Reposts.Add(repost); err != nil {
348 panic(err)
349 }
350
351 chain.Emit(
352 "Repost" ,
353 "caller" , caller.String(),
354 "boardID" , board.ID.String(),
355 "threadID" , thread.ID.String(),
356 "destinationBoardID" , dst.ID.String(),
357 "repostID" , repost.ID.String(),
358 "title" , title,
359 )
360 })
361 return repost.ID
362 }
363
364 // DeleteThread deletes a thread from a board.
365 //
366 // Threads can be deleted by the users who created them or otherwise by users with special permissions.
367 func DeleteThread(cur realm, boardID, threadID boards.ID) {
368 assertRealmIsNotLocked()
369
370 caller := cur.Previous().Address()
371 board := mustGetBoard(boardID)
372 assertUserIsNotBanned(boardID, caller)
373
374 isRealmOwner := gPerms.HasRole(caller, RoleOwner) // TODO: Add DeleteThread filetest cases for realm owners
375 if !isRealmOwner {
376 assertBoardIsNotFrozen(board)
377 }
378
379 thread := mustGetThread(board, threadID)
380 deleteThread := func () {
381 board.Threads.Remove(thread.ID)
382
383 chain.Emit(
384 "ThreadDeleted" ,
385 "caller" , caller.String(),
386 "boardID" , board.ID.String(),
387 "threadID" , thread.ID.String(),
388 )
389 }
390
391 // Thread can be directly deleted by user that created it.
392 // It can also be deleted by realm owners, to be able to delete inappropriate content.
393 // TODO: Discuss and decide if realm owners should be able to delete threads.
394 if isRealmOwner || caller == thread.Creator {
395 deleteThread()
396 return
397 }
398
399 args := boards.Args{caller, board.ID, thread.ID}
400 board.Permissions.WithPermission(caller, PermissionThreadDelete, args, func () {
401 assertRealmIsNotLocked()
402 deleteThread()
403 })
404 }
405
406 // DeleteReply deletes a reply from a thread.
407 //
408 // Replies can be deleted by the users who created them or otherwise by users with special permissions.
409 // Soft deletion is used when the deleted reply contains sub replies, in which case the reply content
410 // is replaced by a text informing that reply has been deleted to avoid deleting sub-replies.
411 func DeleteReply(cur realm, boardID, threadID, replyID boards.ID) {
412 assertRealmIsNotLocked()
413
414 caller := cur.Previous().Address()
415 board := mustGetBoard(boardID)
416 assertUserIsNotBanned(boardID, caller)
417
418 thread := mustGetThread(board, threadID)
419 reply := mustGetReply(thread, replyID)
420 isRealmOwner := gPerms.HasRole(caller, RoleOwner) // TODO: Add DeleteReply filetest cases for realm owners
421 if !isRealmOwner {
422 assertBoardIsNotFrozen(board)
423 assertThreadIsNotFrozen(thread)
424 assertReplyIsVisible(reply)
425 }
426
427 deleteReply := func () {
428 // Soft delete comment/reply by changing its body when
429 // it contains replies, otherwise hard delete it.
430 if reply.Replies.Size() > 0 {
431 reply.Body = "⚠ This comment has been deleted"
432 reply.UpdatedAt = time.Now()
433 } else {
434 // Remove reply from the flat thread index.
435 meta := thread.Meta.(*ThreadMeta)
436 reply, removed := meta.AllReplies.Remove(replyID)
437 if !removed {
438 panic( "reply not found" )
439 }
440
441 // Remove reply from its parent's direct-children list too. A
442 // direct thread reply's parent is the thread itself; a nested
443 // reply's parent is another reply (resolved via the flat index).
444 // Missing the thread-parent case left a ghost in thread.Replies
445 // (rendered in the threaded view, with a stale reply count).
446 if reply.ParentID == thread.ID {
447 thread.Replies.Remove(replyID)
448 } else if parent, found := meta.AllReplies.Get(reply.ParentID); found {
449 parent.Replies.Remove(replyID)
450 }
451 }
452
453 chain.Emit(
454 "ReplyDeleted" ,
455 "caller" , caller.String(),
456 "boardID" , board.ID.String(),
457 "threadID" , thread.ID.String(),
458 "replyID" , reply.ID.String(),
459 )
460 }
461
462 // Reply can be directly deleted by user that created it.
463 // It can also be deleted by realm owners, to be able to delete inappropriate content.
464 // TODO: Discuss and decide if realm owners should be able to delete replies.
465 if isRealmOwner || caller == reply.Creator {
466 deleteReply()
467 return
468 }
469
470 args := boards.Args{caller, board.ID, thread.ID, reply.ID}
471 board.Permissions.WithPermission(caller, PermissionReplyDelete, args, func () {
472 assertRealmIsNotLocked()
473 deleteReply()
474 })
475 }
476
477 // EditThread updates the title and body of a thread.
478 //
479 // Threads can be updated by the users who created them or otherwise by users with special permissions.
480 func EditThread(cur realm, boardID, threadID boards.ID, title, body string ) {
481 assertRealmIsNotLocked()
482
483 title = strings.TrimSpace(title)
484 assertTitleIsValid(title)
485 assertThreadBodyIsValid(body)
486
487 board := mustGetBoard(boardID)
488 assertBoardIsNotFrozen(board)
489
490 caller := cur.Previous().Address()
491 assertUserIsNotBanned(boardID, caller)
492
493 thread := mustGetThread(board, threadID)
494 assertThreadIsNotFrozen(thread)
495
496 body = strings.TrimSpace(body)
497 if !boards.IsRepost(thread) {
498 assertBodyIsNotEmpty(body)
499 }
500
501 editThread := func () {
502 thread.Title = title
503 thread.Body = body
504 thread.UpdatedAt = time.Now()
505
506 chain.Emit(
507 "ThreadEdited" ,
508 "caller" , caller.String(),
509 "boardID" , board.ID.String(),
510 "threadID" , thread.ID.String(),
511 "title" , title,
512 )
513 }
514
515 if caller == thread.Creator {
516 editThread()
517 return
518 }
519
520 args := boards.Args{caller, board.ID, thread.ID, title, body}
521 board.Permissions.WithPermission(caller, PermissionThreadEdit, args, func () {
522 assertRealmIsNotLocked()
523 editThread()
524 })
525 }
526
527 // EditReply updates the body of a comment or reply.
528 //
529 // Replies can be updated only by the users who created them.
530 func EditReply(cur realm, boardID, threadID, replyID boards.ID, body string ) {
531 assertRealmIsNotLocked()
532
533 body = strings.TrimSpace(body)
534 assertReplyBodyIsValid(body)
535
536 board := mustGetBoard(boardID)
537 assertBoardIsNotFrozen(board)
538
539 caller := cur.Previous().Address()
540 assertUserIsNotBanned(boardID, caller)
541
542 thread := mustGetThread(board, threadID)
543 assertThreadIsNotFrozen(thread)
544
545 reply := mustGetReply(thread, replyID)
546 assertReplyIsVisible(reply)
547
548 if caller != reply.Creator {
549 panic( "only the reply creator is allowed to edit it" )
550 }
551
552 reply.Body = body
553 reply.UpdatedAt = time.Now()
554
555 chain.Emit(
556 "ReplyEdited" ,
557 "caller" , caller.String(),
558 "boardID" , board.ID.String(),
559 "threadID" , thread.ID.String(),
560 "replyID" , reply.ID.String(),
561 "body" , body,
562 )
563 }
564
565 // RemoveMember removes a member from the realm or a board.
566 //
567 // Board ID is only required when removing a member from board.
568 func RemoveMember(cur realm, boardID boards.ID, member address) {
569 assertMembersUpdateIsEnabled(boardID)
570 assertMemberAddressIsValid(member)
571
572 perms := mustGetPermissions(boardID)
573 origin := unsafe.OriginCaller()
574 caller := cur.Previous().Address()
575 removeMember := func () {
576 if !perms.RemoveUser(member) {
577 panic( "member not found" )
578 }
579
580 chain.Emit(
581 "MemberRemoved" ,
582 "caller" , caller.String(),
583 "origin" , origin.String(), // When origin and caller match it means self removal
584 "boardID" , boardID.String(),
585 "member" , member.String(),
586 )
587 }
588
589 // Members can remove themselves without permission
590 if origin == member {
591 removeMember()
592 return
593 }
594
595 args := boards.Args{boardID, member}
596 perms.WithPermission(caller, PermissionMemberRemove, args, func () {
597 assertMembersUpdateIsEnabled(boardID)
598 removeMember()
599 })
600 }
601
602 // IsMember checks if a user is a member of the realm or a board.
603 //
604 // Board ID is only required when checking if a user is a member of a board.
605 func IsMember(boardID boards.ID, user address) bool {
606 assertUserAddressIsValid(user)
607
608 if boardID != 0 {
609 board := mustGetBoard(boardID)
610 assertBoardIsNotFrozen(board)
611 }
612
613 perms := mustGetPermissions(boardID)
614 return perms.HasUser(user)
615 }
616
617 // HasMemberRole checks if a realm or board member has a specific role assigned.
618 //
619 // Board ID is only required when checking a member of a board.
620 func HasMemberRole(boardID boards.ID, member address, role boards.Role) bool {
621 assertMemberAddressIsValid(member)
622
623 if boardID != 0 {
624 board := mustGetBoard(boardID)
625 assertBoardIsNotFrozen(board)
626 }
627
628 perms := mustGetPermissions(boardID)
629 return perms.HasRole(member, role)
630 }
631
632 // ChangeMemberRole changes the role of a realm or board member.
633 //
634 // Board ID is only required when changing the role for a member of a board.
635 func ChangeMemberRole(cur realm, boardID boards.ID, member address, role boards.Role) {
636 assertMemberAddressIsValid(member)
637 assertMembersUpdateIsEnabled(boardID)
638
639 if role == "" {
640 role = RoleGuest
641 }
642
643 perms := mustGetPermissions(boardID)
644 caller := cur.Previous().Address()
645 args := boards.Args{caller, boardID, member, role}
646 perms.WithPermission(caller, PermissionRoleChange, args, func () {
647 assertMembersUpdateIsEnabled(boardID)
648
649 perms.SetUserRoles(member, role)
650
651 chain.Emit(
652 "RoleChanged" ,
653 "caller" , caller.String(),
654 "boardID" , boardID.String(),
655 "member" , member.String(),
656 "newRole" , string (role),
657 )
658 })
659 }
660
661 func assertMemberAddressIsValid(member address) {
662 if !member.IsValid() {
663 panic( "invalid member address: " + member.String())
664 }
665 }
666
667 func assertUserAddressIsValid(user address) {
668 if !user.IsValid() {
669 panic( "invalid user address: " + user.String())
670 }
671 }
672
673 func assertBoardExists(id boards.ID) {
674 if id == 0 { // ID zero is used to refer to the realm
675 return
676 }
677
678 if _, found := gBoards.Get(id); !found {
679 panic( "board not found: " + id.String())
680 }
681 }
682
683 func assertBoardIsNotFrozen(b *boards.Board) {
684 if b.Readonly {
685 panic( "board is frozen" )
686 }
687 }
688
689 func assertIsValidBoardName(name string ) {
690 size := len(name)
691 if size == 0 {
692 panic( "board name is empty" )
693 }
694
695 if size < 3 {
696 panic( "board name is too short, minimum length is 3 characters" )
697 }
698
699 if size > MaxBoardNameLength {
700 n := strconv.Itoa(MaxBoardNameLength)
701 panic( "board name is too long, maximum allowed is " + n + " characters" )
702 }
703
704 if !reBoardName.MatchString(name) {
705 panic( "board name must start with a letter and have letters, numbers, \"-\" and \"_\"" )
706 }
707 }
708
709 func assertThreadIsNotFrozen(t *boards.Post) {
710 if t.Readonly {
711 panic( "thread is frozen" )
712 }
713 }
714
715 func assertNameIsNotEmpty(name string ) {
716 if name == "" {
717 panic( "name is empty" )
718 }
719 }
720
721 func assertTitleIsValid(title string ) {
722 if title == "" {
723 panic( "title is empty" )
724 }
725
726 if len(title) > MaxThreadTitleLength {
727 n := strconv.Itoa(MaxThreadTitleLength)
728 panic( "title is too long, maximum allowed is " + n + " characters" )
729 }
730 }
731
732 func assertBodyIsNotEmpty(body string ) {
733 if body == "" {
734 panic( "body is empty" )
735 }
736 }
737
738 func assertBoardNameNotExists(name string ) {
739 name = strings.ToLower(name)
740 if _, found := gBoards.GetByName(name); found {
741 panic( "board already exists" )
742 }
743 }
744
745 func assertThreadExists(b *boards.Board, threadID boards.ID) {
746 if _, found := getThread(b, threadID); !found {
747 panic( "thread not found: " + threadID.String())
748 }
749 }
750
751 func assertReplyExists(thread *boards.Post, replyID boards.ID) {
752 if _, found := getReply(thread, replyID); !found {
753 panic( "reply not found: " + replyID.String())
754 }
755 }
756
757 func assertThreadIsVisible(thread *boards.Post) {
758 if thread.Hidden {
759 panic( "thread is hidden" )
760 }
761 }
762
763 func assertReplyIsVisible(thread *boards.Post) {
764 if thread.Hidden {
765 panic( "reply is hidden" )
766 }
767 }
768
769 func assertThreadBodyIsValid(body string ) {
770 if len(body) > MaxThreadBodyLength {
771 n := strconv.Itoa(MaxThreadBodyLength)
772 panic( "thread body is too long, maximum allowed is " + n + " characters" )
773 }
774 }
775
776 func assertReplyBodyIsValid(body string ) {
777 assertBodyIsNotEmpty(body)
778
779 if len(body) > MaxReplyLength {
780 n := strconv.Itoa(MaxReplyLength)
781 panic( "reply is too long, maximum allowed is " + n + " characters" )
782 }
783
784 // No markdown-structure or gno-form blacklist here: reply bodies
785 // render inside a <gno-foreign> sandbox (see indentForeignBody),
786 // which contains block structure and omits forms at render time.
787 }
788
789 func assertMembersUpdateIsEnabled(boardID boards.ID) {
790 if boardID != 0 {
791 assertRealmIsNotLocked()
792 } else {
793 assertRealmMembersAreNotLocked()
794 }
795 }
796