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}