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