Search Apps Documentation Source Content File Folder Download Copy Actions Download

public.gno

20.53 Kb · 795 lines
  1package boards2
  2
  3import (
  4	"chain"
  5	"chain/runtime/unsafe"
  6	"regexp"
  7	"strconv"
  8	"strings"
  9	"time"
 10
 11	"gno.land/p/gnoland/boards"
 12)
 13
 14const (
 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
 30var reBoardName = regexp.MustCompile(`(?i)^[a-z]+[a-z0-9_\-]{2,50}$`)
 31
 32// SetHelp sets or updates boards realm help content.
 33func 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.
 45func 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.
 64func 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.
102func 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.
118func 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.
127func 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.
135func 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).
185func 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.
219func 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.
260func 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.
315func 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.
367func 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.
411func 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.
480func 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.
530func 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.
568func 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.
605func 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.
620func 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.
635func 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
661func assertMemberAddressIsValid(member address) {
662	if !member.IsValid() {
663		panic("invalid member address: " + member.String())
664	}
665}
666
667func assertUserAddressIsValid(user address) {
668	if !user.IsValid() {
669		panic("invalid user address: " + user.String())
670	}
671}
672
673func 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
683func assertBoardIsNotFrozen(b *boards.Board) {
684	if b.Readonly {
685		panic("board is frozen")
686	}
687}
688
689func 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
709func assertThreadIsNotFrozen(t *boards.Post) {
710	if t.Readonly {
711		panic("thread is frozen")
712	}
713}
714
715func assertNameIsNotEmpty(name string) {
716	if name == "" {
717		panic("name is empty")
718	}
719}
720
721func 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
732func assertBodyIsNotEmpty(body string) {
733	if body == "" {
734		panic("body is empty")
735	}
736}
737
738func assertBoardNameNotExists(name string) {
739	name = strings.ToLower(name)
740	if _, found := gBoards.GetByName(name); found {
741		panic("board already exists")
742	}
743}
744
745func assertThreadExists(b *boards.Board, threadID boards.ID) {
746	if _, found := getThread(b, threadID); !found {
747		panic("thread not found: " + threadID.String())
748	}
749}
750
751func assertReplyExists(thread *boards.Post, replyID boards.ID) {
752	if _, found := getReply(thread, replyID); !found {
753		panic("reply not found: " + replyID.String())
754	}
755}
756
757func assertThreadIsVisible(thread *boards.Post) {
758	if thread.Hidden {
759		panic("thread is hidden")
760	}
761}
762
763func assertReplyIsVisible(thread *boards.Post) {
764	if thread.Hidden {
765		panic("reply is hidden")
766	}
767}
768
769func 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
776func 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
789func assertMembersUpdateIsEnabled(boardID boards.ID) {
790	if boardID != 0 {
791		assertRealmIsNotLocked()
792	} else {
793		assertRealmMembersAreNotLocked()
794	}
795}