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