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}