Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}