public.gno
17.22 Kb · 692 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(cross, caller, PermissionRealmHelp, args, func(realm) {
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 assertBoardExists(bid)
46
47 if p == nil {
48 panic("permissions is required")
49 }
50
51 caller := runtime.PreviousRealm().Address()
52 args := Args{bid}
53 gPerms.WithPermission(cross, caller, PermissionPermissionsUpdate, args, func(realm) {
54 assertRealmIsNotLocked()
55
56 // When board ID is zero it means that realm permissions are being updated
57 if bid == 0 {
58 gPerms = p
59
60 chain.Emit(
61 "RealmPermissionsUpdated",
62 "caller", caller.String(),
63 )
64 return
65 }
66
67 // Otherwise update the permissions of a single board
68 board := mustGetBoard(bid)
69 board.perms = p
70
71 chain.Emit(
72 "BoardPermissionsUpdated",
73 "caller", caller.String(),
74 "boardID", bid.String(),
75 )
76 })
77}
78
79// SetRealmNotice sets a notice to be displayed globally by the realm.
80// An empty message removes the realm notice.
81func SetRealmNotice(_ realm, message string) {
82 caller := runtime.PreviousRealm().Address()
83 assertHasPermission(gPerms, caller, PermissionThreadCreate)
84
85 gNotice = strings.TrimSpace(message)
86
87 chain.Emit(
88 "RealmNoticeChanged",
89 "caller", caller.String(),
90 "message", gNotice,
91 )
92}
93
94// GetBoardIDFromName searches a board by name and returns it's ID.
95func GetBoardIDFromName(_ realm, name string) (_ BoardID, found bool) {
96 board, found := getBoardByName(name)
97 if !found {
98 return 0, false
99 }
100 return board.ID, true
101}
102
103// CreateBoard creates a new board.
104//
105// Listed boards are included in the list of boards.
106func CreateBoard(_ realm, name string, listed bool) BoardID {
107 assertRealmIsNotLocked()
108
109 name = strings.TrimSpace(name)
110 assertIsValidBoardName(name)
111 assertBoardNameNotExists(name)
112
113 caller := runtime.PreviousRealm().Address()
114 id := reserveBoardID()
115 args := Args{caller, name, id, listed}
116 gPerms.WithPermission(cross, caller, PermissionBoardCreate, args, func(realm) {
117 assertRealmIsNotLocked()
118 assertBoardNameNotExists(name)
119
120 perms := createBasicBoardPermissions(caller)
121 board := newBoard(id, name, caller, perms)
122 key := id.Key()
123 gBoardsByID.Set(key, board)
124 gBoardsByName.Set(strings.ToLower(name), board)
125
126 // Listed boards are also indexed separately for easier iteration and pagination
127 if listed {
128 gListedBoardsByID.Set(key, board)
129 }
130
131 chain.Emit(
132 "BoardCreated",
133 "caller", caller.String(),
134 "boardID", id.String(),
135 "name", name,
136 )
137 })
138 return id
139}
140
141// RenameBoard changes the name of an existing board.
142//
143// A history of previous board names is kept when boards are renamed.
144// Because of that boards are also accesible using previous name(s).
145func RenameBoard(_ realm, name, newName string) {
146 assertRealmIsNotLocked()
147
148 newName = strings.TrimSpace(newName)
149 assertIsValidBoardName(newName)
150 assertBoardNameNotExists(newName)
151
152 board := mustGetBoardByName(name)
153 assertBoardIsNotFrozen(board)
154
155 bid := board.ID
156 caller := runtime.PreviousRealm().Address()
157 args := Args{caller, bid, name, newName}
158 board.perms.WithPermission(cross, caller, PermissionBoardRename, args, func(realm) {
159 assertRealmIsNotLocked()
160 assertBoardNameNotExists(newName)
161
162 board := mustGetBoard(bid)
163 assertBoardIsNotFrozen(board)
164
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(strings.ToLower(newName), board)
170
171 chain.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 := runtime.PreviousRealm().Address()
195 assertUserIsNotBanned(board.ID, caller)
196 assertHasPermission(board.perms, caller, PermissionThreadCreate)
197
198 thread := board.AddThread(caller, title, body)
199
200 chain.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 := runtime.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 chain.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 := runtime.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 chain.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 := runtime.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 chain.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 := runtime.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 chain.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 := runtime.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 chain.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 := runtime.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 chain.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 address) {
461 assertMembersUpdateIsEnabled(boardID)
462 assertMemberAddressIsValid(member)
463
464 perms := mustGetPermissions(boardID)
465 caller := runtime.PreviousRealm().Address()
466 perms.WithPermission(cross, caller, PermissionMemberRemove, Args{member}, func(realm) {
467 assertMembersUpdateIsEnabled(boardID)
468
469 if !perms.RemoveUser(cross, member) {
470 panic("member not found")
471 }
472
473 chain.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 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 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 address, role Role) {
516 assertMemberAddressIsValid(member)
517 assertMembersUpdateIsEnabled(boardID)
518
519 perms := mustGetPermissions(boardID)
520 caller := runtime.PreviousRealm().Address()
521 args := Args{caller, boardID, member, role}
522 perms.WithPermission(cross, caller, PermissionRoleChange, args, func(realm) {
523 assertMembersUpdateIsEnabled(boardID)
524
525 perms.SetUserRoles(cross, member, role)
526
527 chain.Emit(
528 "RoleChanged",
529 "caller", caller.String(),
530 "boardID", boardID.String(),
531 "member", member.String(),
532 "newRole", string(role),
533 )
534 })
535}
536
537// IterateRealmMembers iterates boards realm members.
538// The iteration is done only for realm members, board members are not iterated.
539func IterateRealmMembers(offset int, fn UsersIterFn) (halted bool) {
540 count := gPerms.UsersCount() - offset
541 return gPerms.IterateUsers(offset, count, fn)
542}
543
544// GetBoard returns a single board.
545func GetBoard(boardID BoardID) *Board {
546 board := mustGetBoard(boardID)
547 if !board.perms.HasRole(runtime.OriginCaller(), RoleOwner) {
548 panic("forbidden")
549 }
550 return board
551}
552
553func assertMemberAddressIsValid(member address) {
554 if !member.IsValid() {
555 panic("invalid member address: " + member.String())
556 }
557}
558
559func assertUserAddressIsValid(user address) {
560 if !user.IsValid() {
561 panic("invalid user address: " + user.String())
562 }
563}
564
565func assertHasPermission(perms Permissions, user address, p Permission) {
566 if !perms.HasPermission(user, p) {
567 panic("unauthorized")
568 }
569}
570
571func assertBoardExists(id BoardID) {
572 if id == 0 { // ID zero is used to refer to the realm
573 return
574 }
575
576 if _, found := getBoard(id); !found {
577 panic("board not found: " + id.String())
578 }
579}
580
581func assertBoardIsNotFrozen(b *Board) {
582 if b.Readonly {
583 panic("board is frozen")
584 }
585}
586
587func assertIsValidBoardName(name string) {
588 size := len(name)
589 if size == 0 {
590 panic("board name is empty")
591 }
592
593 if size < 3 {
594 panic("board name is too short, minimum length is 3 characters")
595 }
596
597 if size > MaxBoardNameLength {
598 n := strconv.Itoa(MaxBoardNameLength)
599 panic("board name is too long, maximum allowed is " + n + " characters")
600 }
601
602 if !reBoardName.MatchString(name) {
603 panic("board name contains invalid characters")
604 }
605}
606
607func assertThreadIsNotFrozen(t *Post) {
608 if t.Readonly {
609 panic("thread is frozen")
610 }
611}
612
613func assertReplyIsNotFrozen(r *Post) {
614 if r.Readonly {
615 panic("reply is frozen")
616 }
617}
618
619func assertNameIsNotEmpty(name string) {
620 if name == "" {
621 panic("name is empty")
622 }
623}
624
625func assertTitleIsValid(title string) {
626 if title == "" {
627 panic("title is empty")
628 }
629
630 if len(title) > MaxThreadTitleLength {
631 n := strconv.Itoa(MaxThreadTitleLength)
632 panic("thread title is too long, maximum allowed is " + n + " characters")
633 }
634}
635
636func assertBodyIsNotEmpty(body string) {
637 if body == "" {
638 panic("body is empty")
639 }
640}
641
642func assertBoardNameNotExists(name string) {
643 name = strings.ToLower(name)
644 if gBoardsByName.Has(name) {
645 panic("board already exists")
646 }
647}
648
649func assertThreadExists(b *Board, threadID PostID) {
650 if _, found := b.GetThread(threadID); !found {
651 panic("thread not found: " + threadID.String())
652 }
653}
654
655func assertReplyExists(thread *Post, replyID PostID) {
656 if _, found := thread.GetReply(replyID); !found {
657 panic("reply not found: " + replyID.String())
658 }
659}
660
661func assertThreadIsVisible(thread *Post) {
662 if thread.Hidden {
663 panic("thread is hidden")
664 }
665}
666
667func assertReplyIsVisible(thread *Post) {
668 if thread.Hidden {
669 panic("reply is hidden")
670 }
671}
672
673func assertReplyBodyIsValid(body string) {
674 assertBodyIsNotEmpty(body)
675
676 if len(body) > MaxReplyLength {
677 n := strconv.Itoa(MaxReplyLength)
678 panic("reply is too long, maximum allowed is " + n + " characters")
679 }
680
681 if reDeniedReplyLinePrefixes.MatchString(body) {
682 panic("using Markdown headings, blockquotes or horizontal lines is not allowed in replies")
683 }
684}
685
686func assertMembersUpdateIsEnabled(boardID BoardID) {
687 if boardID != 0 {
688 assertRealmIsNotLocked()
689 } else {
690 assertRealmMembersAreNotLocked()
691 }
692}