From a663462e2744e04f7d2cab58894049e3579cb0c6 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Tue, 23 Aug 2022 22:55:19 -0700 Subject: [PATCH] Forums: Basic Support WIP Adds initial code for basically functional forums: * Forums landing page shows hard-coded list of Categories along with any forums in the DB that use those categories. * Admin: Create, Edit forums and view forums you own or have admin rights to modify. * Landing page forums list shows the title/description and dynamic count of number of Topics and total number of Posts in each forum. TODO: distinct count of Users who posted in each forum. * Board Index page shows list of Threads (posts) with a Replies count and Views count on each thread. * Thread view is basically an array of Comments. Users can post, edit and delete (their own) comments. Deleting the first comment removes the entire Thread - the thread points to a first Comment to provide its body. * Reply and Quote-Reply options working. --- pkg/config/enum.go | 9 ++ pkg/config/page_sizes.go | 7 +- pkg/controller/forum/add_edit.go | 139 +++++++++++++++++ pkg/controller/forum/forum.go | 74 +++++++++ pkg/controller/forum/forums.go | 77 ++++++++++ pkg/controller/forum/manage.go | 48 ++++++ pkg/controller/forum/new_post.go | 170 +++++++++++++++++++++ pkg/controller/forum/thread.go | 88 +++++++++++ pkg/markdown/markdown.go | 11 ++ pkg/models/comment.go | 70 +++++++++ pkg/models/forum.go | 156 +++++++++++++++++++ pkg/models/forum_stats.go | 157 +++++++++++++++++++ pkg/models/models.go | 3 + pkg/models/thread.go | 215 +++++++++++++++++++++++++++ pkg/router/router.go | 7 + pkg/templates/template_funcs.go | 6 + web/templates/base.html | 6 +- web/templates/forum/add_edit.html | 143 ++++++++++++++++++ web/templates/forum/admin.html | 120 +++++++++++++++ web/templates/forum/board_index.html | 127 ++++++++++++++++ web/templates/forum/index.html | 121 +++++++++++++++ web/templates/forum/new_post.html | 148 ++++++++++++++++++ web/templates/forum/thread.html | 153 +++++++++++++++++++ 23 files changed, 2050 insertions(+), 5 deletions(-) create mode 100644 pkg/controller/forum/add_edit.go create mode 100644 pkg/controller/forum/forum.go create mode 100644 pkg/controller/forum/forums.go create mode 100644 pkg/controller/forum/manage.go create mode 100644 pkg/controller/forum/new_post.go create mode 100644 pkg/controller/forum/thread.go create mode 100644 pkg/models/comment.go create mode 100644 pkg/models/forum.go create mode 100644 pkg/models/forum_stats.go create mode 100644 pkg/models/thread.go create mode 100644 web/templates/forum/add_edit.html create mode 100644 web/templates/forum/admin.html create mode 100644 web/templates/forum/board_index.html create mode 100644 web/templates/forum/index.html create mode 100644 web/templates/forum/new_post.html create mode 100644 web/templates/forum/thread.html diff --git a/pkg/config/enum.go b/pkg/config/enum.go index 116b976..2a01ff9 100644 --- a/pkg/config/enum.go +++ b/pkg/config/enum.go @@ -82,9 +82,18 @@ var ( {"report.user", "Report a problematic user"}, {"report.photo", "Report a problematic photo"}, {"report.message", "Report a direct message conversation"}, + {"report.comment", "Report a forum post or comment"}, }, }, } + + // Default forum categories for forum landing page. + ForumCategories = []string{ + "Rules and Announcements", + "Nudists", + "Exhibitionists", + "Anything Goes", + } ) // ContactUs choices for the subject drop-down. diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go index df1c39d..9c07eec 100644 --- a/pkg/config/page_sizes.go +++ b/pkg/config/page_sizes.go @@ -9,6 +9,9 @@ var ( PageSizeAdminFeedback = 20 PageSizeSiteGallery = 18 PageSizeUserGallery = 18 - PageSizeInboxList = 20 // sidebar list - PageSizeInboxThread = 20 // conversation view + PageSizeInboxList = 20 // sidebar list + PageSizeInboxThread = 20 // conversation view + PageSizeForums = 100 // TODO: for main category index view + PageSizeThreadList = 20 + PageSizeForumAdmin = 20 ) diff --git a/pkg/controller/forum/add_edit.go b/pkg/controller/forum/add_edit.go new file mode 100644 index 0000000..8dcf5be --- /dev/null +++ b/pkg/controller/forum/add_edit.go @@ -0,0 +1,139 @@ +package forum + +import ( + "net/http" + "strconv" + "strings" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// AddEdit page. +func AddEdit() http.HandlerFunc { + tmpl := templates.Must("forum/add_edit.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Are we editing a forum or creating a new one? + var editID uint64 + if editStr := r.FormValue("id"); editStr != "" { + if i, err := strconv.Atoi(editStr); err == nil { + editID = uint64(i) + } else { + session.FlashError(w, r, "Edit parameter: id was not an integer") + templates.Redirect(w, "/forum/admin") + return + } + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get current user: %s", err) + templates.Redirect(w, "/") + return + } + + // If editing, look up the existing forum. + var forum *models.Forum + if editID > 0 { + if found, err := models.GetForum(editID); err != nil { + session.FlashError(w, r, "Couldn't get forum: %s", err) + templates.Redirect(w, "/forum/admin") + return + } else { + // Do we have permission? + if found.OwnerID != currentUser.ID && !currentUser.IsAdmin { + templates.ForbiddenPage(w, r) + return + } + + forum = found + } + } + + // Saving? + if r.Method == http.MethodPost { + var ( + title = strings.TrimSpace(r.PostFormValue("title")) + fragment = strings.TrimSpace(strings.ToLower(r.PostFormValue("fragment"))) + description = strings.TrimSpace(r.PostFormValue("description")) + category = strings.TrimSpace(r.PostFormValue("category")) + isExplicit = r.PostFormValue("explicit") == "true" + isPrivileged = r.PostFormValue("privileged") == "true" + isPermitPhotos = r.PostFormValue("permit_photos") == "true" + ) + + // Sanity check admin-only settings. + if !currentUser.IsAdmin { + isPrivileged = false + isPermitPhotos = false + } + + // Were we editing an existing forum? + if forum != nil { + forum.Title = title + forum.Description = description + forum.Category = category + forum.Explicit = isExplicit + forum.Privileged = isPrivileged + forum.PermitPhotos = isPermitPhotos + + // Save it. + if err := forum.Save(); err == nil { + session.Flash(w, r, "Forum has been updated!") + templates.Redirect(w, "/forum/admin") + return + } else { + session.FlashError(w, r, "Error saving the forum: %s", err) + } + } else { + // Validate the fragment. Front-end enforces the pattern so this + // is just a sanity check. + if m := FragmentRegexp.FindStringSubmatch(fragment); m == nil { + session.FlashError(w, r, "The fragment format is invalid.") + templates.Redirect(w, "/forum/admin") + return + } + + // Ensure the fragment is unique. + if _, err := models.ForumByFragment(fragment); err == nil { + session.FlashError(w, r, "The forum fragment is already in use.") + } else { + // Create the forum. + forum = &models.Forum{ + Owner: *currentUser, + Category: category, + Fragment: fragment, + Title: title, + Description: description, + Explicit: isExplicit, + Privileged: isPrivileged, + PermitPhotos: isPermitPhotos, + } + + if err := models.CreateForum(forum); err == nil { + session.Flash(w, r, "The forum has been created!") + templates.Redirect(w, "/forum/admin") + return + } else { + session.FlashError(w, r, "Error creating the forum: %s", err) + } + } + } + } + + _ = editID + + var vars = map[string]interface{}{ + "EditID": editID, + "EditForum": forum, + "Categories": config.ForumCategories, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/forum/forum.go b/pkg/controller/forum/forum.go new file mode 100644 index 0000000..91049ea --- /dev/null +++ b/pkg/controller/forum/forum.go @@ -0,0 +1,74 @@ +package forum + +import ( + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Forum view for a specific board index. +func Forum() http.HandlerFunc { + tmpl := templates.Must("forum/board_index.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Parse the path parameters + var ( + forum *models.Forum + ) + + if m := ForumPathRegexp.FindStringSubmatch(r.URL.Path); m == nil { + log.Error("Regexp failed to parse: %s", r.URL.Path) + templates.NotFoundPage(w, r) + return + } else { + // Look up the forum itself. + if found, err := models.ForumByFragment(m[1]); err != nil { + templates.NotFoundPage(w, r) + return + } else { + forum = found + } + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get current user: %s", err) + templates.Redirect(w, "/") + return + } + + // Get all the categorized index forums. + // XXX: we get a large page size to get ALL official forums + var pager = &models.Pagination{ + Page: 1, + PerPage: config.PageSizeThreadList, + Sort: "updated_at desc", + } + pager.ParsePage(r) + + threads, err := models.PaginateThreads(currentUser, forum, pager) + if err != nil { + session.FlashError(w, r, "Couldn't paginate threads: %s", err) + templates.Redirect(w, "/") + return + } + + // Map the statistics (replies, views) of these threads. + threadMap := models.MapThreadStatistics(threads) + + var vars = map[string]interface{}{ + "Forum": forum, + "Threads": threads, + "ThreadMap": threadMap, + "Pager": pager, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/forum/forums.go b/pkg/controller/forum/forums.go new file mode 100644 index 0000000..51ff5fb --- /dev/null +++ b/pkg/controller/forum/forums.go @@ -0,0 +1,77 @@ +package forum + +import ( + "fmt" + "net/http" + "regexp" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Regular expressions +var ( + FragmentPattern = `[a-z0-9._-]{1,30}` + FragmentRegexp = regexp.MustCompile( + fmt.Sprintf(`^(%s)$`, FragmentPattern), + ) + + // Forum path parameters. + ForumPathRegexp = regexp.MustCompile( + fmt.Sprintf(`^/f/(%s)`, FragmentPattern), + ) + ForumPostRegexp = regexp.MustCompile( + fmt.Sprintf(`^/f/(%s)/(post)`, FragmentPattern), + ) + ForumThreadRegexp = regexp.MustCompile( + fmt.Sprintf(`^/f/(%s)/(thread)/(\d+)`, FragmentPattern), + ) +) + +// Landing page for forums. +func Landing() http.HandlerFunc { + tmpl := templates.Must("forum/index.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get current user: %s", err) + templates.Redirect(w, "/") + return + } + + // Get all the categorized index forums. + // XXX: we get a large page size to get ALL official forums + var pager = &models.Pagination{ + Page: 1, + PerPage: config.PageSizeForums, + Sort: "title asc", + } + pager.ParsePage(r) + + forums, err := models.PaginateForums(currentUser.ID, config.ForumCategories, pager) + if err != nil { + session.FlashError(w, r, "Couldn't paginate forums: %s", err) + templates.Redirect(w, "/") + return + } + + // Bucket the forums into their categories for easy front-end. + categorized := models.CategorizeForums(forums, config.ForumCategories) + + // Map statistics for these forums. + forumMap := models.MapForumStatistics(forums) + + var vars = map[string]interface{}{ + "Pager": pager, + "Categories": categorized, + "ForumMap": forumMap, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/forum/manage.go b/pkg/controller/forum/manage.go new file mode 100644 index 0000000..7401105 --- /dev/null +++ b/pkg/controller/forum/manage.go @@ -0,0 +1,48 @@ +package forum + +import ( + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Manage page for forums -- admin only for now but may open up later. +func Manage() http.HandlerFunc { + tmpl := templates.Must("forum/admin.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get current user: %s", err) + templates.Redirect(w, "/") + return + } + + // Get forums the user owns or can manage. + var pager = &models.Pagination{ + Page: 1, + PerPage: config.PageSizeForumAdmin, + Sort: "updated_at desc", + } + pager.ParsePage(r) + + forums, err := models.PaginateOwnedForums(currentUser.ID, currentUser.IsAdmin, pager) + if err != nil { + session.FlashError(w, r, "Couldn't paginate owned forums: %s", err) + templates.Redirect(w, "/") + return + } + + var vars = map[string]interface{}{ + "Pager": pager, + "Forums": forums, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go new file mode 100644 index 0000000..c67e92e --- /dev/null +++ b/pkg/controller/forum/new_post.go @@ -0,0 +1,170 @@ +package forum + +import ( + "fmt" + "net/http" + "strconv" + + "git.kirsle.net/apps/gosocial/pkg/markdown" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// NewPost view. +func NewPost() http.HandlerFunc { + tmpl := templates.Must("forum/new_post.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Query params. + var ( + fragment = r.FormValue("to") // forum to (new post) + toThreadID = r.FormValue("thread") // add reply to a thread ID + quoteCommentID = r.FormValue("quote") // add reply to thread while quoting a comment + editCommentID = r.FormValue("edit") // edit your comment + intent = r.FormValue("intent") // preview or submit + title = r.FormValue("title") // for new forum post only + message = r.PostFormValue("message") // comment body + isExplicit = r.PostFormValue("explicit") == "true" // for thread only + isNoReply = r.PostFormValue("noreply") == "true" // for thread only + isDelete = r.FormValue("delete") == "true" // delete comment (along with edit=$id) + forum *models.Forum + thread *models.Thread // if replying to a thread + comment *models.Comment // if editing a comment + ) + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get current user: %s", err) + templates.Redirect(w, "/") + return + } + + // Look up the forum itself. + if found, err := models.ForumByFragment(fragment); err != nil { + session.FlashError(w, r, "Couldn't post to forum %s: not found.", fragment) + templates.Redirect(w, "/forum") + return + } else { + forum = found + } + + // Are we manipulating a reply to an existing thread? + if len(toThreadID) > 0 { + if i, err := strconv.Atoi(toThreadID); err == nil { + if found, err := models.GetThread(uint64(i)); err != nil { + session.FlashError(w, r, "Couldn't find that thread ID!") + templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment)) + return + } else { + thread = found + } + } + } + + // Are we pre-filling the message with a quotation of an existing comment? + if len(quoteCommentID) > 0 { + if i, err := strconv.Atoi(quoteCommentID); err == nil { + if comment, err := models.GetComment(uint64(i)); err == nil { + message = markdown.Quotify(comment.Message) + "\n\n" + } + } + } + + // Are we editing or deleting our comment? + if len(editCommentID) > 0 { + if i, err := strconv.Atoi(editCommentID); err == nil { + if found, err := models.GetComment(uint64(i)); err == nil { + comment = found + + // Verify that it is indeed OUR comment. + if currentUser.ID != comment.UserID && !currentUser.IsAdmin { + templates.ForbiddenPage(w, r) + return + } + + // Initialize the form w/ the content of this message. + if r.Method == http.MethodGet { + message = comment.Message + } + + // Are we DELETING this comment? + if isDelete { + if err := thread.DeleteReply(comment); err != nil { + session.FlashError(w, r, "Error deleting your post: %s", err) + } else { + session.Flash(w, r, "Your post has been deleted.") + } + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) + return + } + } else { + // Comment not found - show the Forbidden page anyway. + templates.ForbiddenPage(w, r) + return + } + } else { + templates.NotFoundPage(w, r) + return + } + } + + // Submitting the form. + if r.Method == http.MethodPost { + // Default intent is preview unless told to submit. + if intent == "submit" { + // Are we modifying an existing comment? + if comment != nil { + comment.Message = message + if err := comment.Save(); err != nil { + session.FlashError(w, r, "Couldn't save comment: %s", err) + } else { + session.Flash(w, r, "Comment updated!") + } + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) + return + } + + // Are we replying to an existing thread? + if thread != nil { + if _, err := thread.Reply(currentUser, message); err != nil { + session.FlashError(w, r, "Couldn't add reply to thread: %s", err) + } else { + session.Flash(w, r, "Reply added to the thread!") + } + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) + return + } + + // Create a new thread? + if thread, err := models.CreateThread( + currentUser, + forum.ID, + title, + message, + isExplicit, + isNoReply, + ); err != nil { + session.FlashError(w, r, "Couldn't create thread: %s", err) + } else { + session.Flash(w, r, "Thread created!") + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) + return + } + } + } + + var vars = map[string]interface{}{ + "Forum": forum, + "Thread": thread, + "Intent": intent, + "PostTitle": title, + "EditCommentID": editCommentID, + "Message": message, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/forum/thread.go b/pkg/controller/forum/thread.go new file mode 100644 index 0000000..f822402 --- /dev/null +++ b/pkg/controller/forum/thread.go @@ -0,0 +1,88 @@ +package forum + +import ( + "net/http" + "regexp" + "strconv" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +var ThreadPathRegexp = regexp.MustCompile(`^/forum/thread/(\d+)$`) + +// Thread view for a specific board index. +func Thread() http.HandlerFunc { + tmpl := templates.Must("forum/thread.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Parse the path parameters + var ( + forum *models.Forum + thread *models.Thread + ) + + if m := ThreadPathRegexp.FindStringSubmatch(r.URL.Path); m == nil { + log.Error("Regexp failed to parse: %s", r.URL.Path) + templates.NotFoundPage(w, r) + return + } else { + if threadID, err := strconv.Atoi(m[1]); err != nil { + session.FlashError(w, r, "Invalid thread ID in the address bar.") + templates.Redirect(w, "/forum") + return + } else { + // Load the thread. + if found, err := models.GetThread(uint64(threadID)); err != nil { + session.FlashError(w, r, "That thread does not exist.") + templates.Redirect(w, "/forum") + return + } else { + thread = found + forum = &thread.Forum + } + } + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get current user: %s", err) + templates.Redirect(w, "/") + return + } + + // Ping the view count on this thread. + if err := thread.View(); err != nil { + log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err) + } + + // Paginate the comments on this thread. + var pager = &models.Pagination{ + Page: 1, + PerPage: config.PageSizeThreadList, + Sort: "created_at asc", + } + pager.ParsePage(r) + + comments, err := models.PaginateComments(currentUser, "threads", thread.ID, pager) + if err != nil { + session.FlashError(w, r, "Couldn't paginate comments: %s", err) + templates.Redirect(w, "/") + return + } + + var vars = map[string]interface{}{ + "Forum": forum, + "Thread": thread, + "Comments": comments, + "Pager": pager, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/markdown/markdown.go b/pkg/markdown/markdown.go index eaa69fc..31dede1 100644 --- a/pkg/markdown/markdown.go +++ b/pkg/markdown/markdown.go @@ -2,6 +2,8 @@ package markdown import ( + "strings" + "github.com/microcosm-cc/bluemonday" "github.com/shurcooL/github_flavored_markdown" ) @@ -16,3 +18,12 @@ func Render(input string) string { safened := p.SanitizeBytes(html) return string(safened) } + +// Quotify a message putting it into a Markdown "> quotes" block. +func Quotify(input string) string { + var lines = []string{} + for _, line := range strings.Split(input, "\n") { + lines = append(lines, "> "+line) + } + return strings.Join(lines, "\n") +} diff --git a/pkg/models/comment.go b/pkg/models/comment.go new file mode 100644 index 0000000..4197fd7 --- /dev/null +++ b/pkg/models/comment.go @@ -0,0 +1,70 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Comment table - in forum threads, on profiles or photos, etc. +type Comment struct { + ID uint64 `gorm:"primaryKey"` + TableName string `gorm:"index"` + TableID uint64 `gorm:"index"` + UserID uint64 `gorm:"index"` + User User + Message string + CreatedAt time.Time + UpdatedAt time.Time +} + +// Preload related tables for the forum (classmethod). +func (c *Comment) Preload() *gorm.DB { + return DB.Preload("User.ProfilePhoto") +} + +// GetComment by ID. +func GetComment(id uint64) (*Comment, error) { + c := &Comment{} + result := c.Preload().First(&c, id) + return c, result.Error +} + +// AddComment about anything. +func AddComment(user *User, tableName string, tableID uint64, message string) (*Comment, error) { + c := &Comment{ + TableName: tableName, + TableID: tableID, + User: *user, + Message: message, + } + result := DB.Create(c) + return c, result.Error +} + +// PaginateComments provides a page of comments on something. +func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagination) ([]*Comment, error) { + var ( + cs = []*Comment{} + query = (&Comment{}).Preload() + ) + + query = query.Where( + "table_name = ? AND table_id = ?", + tableName, tableID, + ).Order(pager.Sort) + + query.Model(&Comment{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&cs) + return cs, result.Error +} + +// Save a comment. +func (c *Comment) Save() error { + return DB.Save(c).Error +} + +// Delete a comment. +func (c *Comment) Delete() error { + return DB.Delete(c).Error +} diff --git a/pkg/models/forum.go b/pkg/models/forum.go new file mode 100644 index 0000000..44a6b8d --- /dev/null +++ b/pkg/models/forum.go @@ -0,0 +1,156 @@ +package models + +import ( + "errors" + "strings" + "time" + + "gorm.io/gorm" +) + +// Forum table. +type Forum struct { + ID uint64 `gorm:"primaryKey"` + OwnerID uint64 `gorm:"index"` + Owner User `gorm:"foreignKey:owner_id"` + Category string `gorm:"index"` + Fragment string `gorm:"uniqueIndex"` + Title string + Description string + Explicit bool `gorm:"index"` + Privileged bool + PermitPhotos bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// Preload related tables for the forum (classmethod). +func (f *Forum) Preload() *gorm.DB { + return DB.Preload("Owner") +} + +// GetForum by ID. +func GetForum(id uint64) (*Forum, error) { + forum := &Forum{} + result := forum.Preload().First(&forum, id) + return forum, result.Error +} + +// ForumByFragment looks up a forum by its URL fragment. +func ForumByFragment(fragment string) (*Forum, error) { + if fragment == "" { + return nil, errors.New("the URL fragment is required") + } + + var ( + f = &Forum{} + result = f.Preload().Where( + "fragment = ?", + fragment, + ).First(&f) + ) + + return f, result.Error +} + +/* +PaginateForums scans over the available forums for a user. + +Parameters: + +- userID: of who is looking +- categories: optional, filter within categories +- pager +*/ +func PaginateForums(userID uint64, categories []string, pager *Pagination) ([]*Forum, error) { + var ( + fs = []*Forum{} + query = (&Forum{}).Preload() + wheres = []string{} + placeholders = []interface{}{} + ) + + if categories != nil && len(categories) > 0 { + wheres = append(wheres, "category IN ?") + placeholders = append(placeholders, categories) + } + + // Filters? + if len(wheres) > 0 { + query = query.Where( + strings.Join(wheres, " AND "), + placeholders..., + ) + } + + query = query.Order(pager.Sort) + query.Model(&Forum{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs) + return fs, result.Error +} + +// PaginateOwnedForums returns forums the user owns (or all forums to admins). +func PaginateOwnedForums(userID uint64, isAdmin bool, pager *Pagination) ([]*Forum, error) { + var ( + fs = []*Forum{} + query = (&Forum{}).Preload() + ) + + if !isAdmin { + query = query.Where( + "owner_id = ?", + userID, + ) + } + + query = query.Order(pager.Sort) + query.Model(&Forum{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs) + return fs, result.Error +} + +// CreateForum. +func CreateForum(f *Forum) error { + result := DB.Create(f) + return result.Error +} + +// Save a forum. +func (f *Forum) Save() error { + return DB.Save(f).Error +} + +// CategorizedForum supports the main index page with custom categories. +type CategorizedForum struct { + Category string + Forums []*Forum +} + +// CategorizeForums buckets forums into categories for front-end. +func CategorizeForums(fs []*Forum, categories []string) []*CategorizedForum { + var ( + result = []*CategorizedForum{} + idxMap = map[string]int{} + ) + + // Initialize the result set. + for i, category := range categories { + result = append(result, &CategorizedForum{ + Category: category, + Forums: []*Forum{}, + }) + idxMap[category] = i + } + + // Bucket the forums into their categories. + for _, forum := range fs { + category := forum.Category + if category == "" { + continue + } + idx := idxMap[category] + result[idx].Forums = append(result[idx].Forums, forum) + } + + return result +} diff --git a/pkg/models/forum_stats.go b/pkg/models/forum_stats.go new file mode 100644 index 0000000..86aeb0f --- /dev/null +++ b/pkg/models/forum_stats.go @@ -0,0 +1,157 @@ +package models + +import "git.kirsle.net/apps/gosocial/pkg/log" + +// ForumStatistics queries for forum-level statistics. +type ForumStatistics struct { + RecentThread *Thread + Threads uint64 + Posts uint64 + Users uint64 +} + +type ForumStatsMap map[uint64]*ForumStatistics + +// MapForumStatistics looks up statistics for a set of forums. +func MapForumStatistics(forums []*Forum) ForumStatsMap { + var ( + result = ForumStatsMap{} + IDs = []uint64{} + ) + + // Collect forum IDs and initialize the map. + for _, forum := range forums { + IDs = append(IDs, forum.ID) + result[forum.ID] = &ForumStatistics{} + } + + // FIRST: count the threads in each forum. + { + // Hold the result of the count/group by query. + type group struct { + ID uint64 + Threads uint64 + } + var groups = []group{} + + // Count comments grouped by thread IDs. + err := DB.Table( + "threads", + ).Select( + "forum_id AS id, count(id) AS threads", + ).Where( + "forum_id IN ?", + IDs, + ).Group("forum_id").Scan(&groups) + + if err.Error != nil { + log.Error("MapForumStatistics: SQL error: %s", err.Error) + } + + // Map the results in. + for _, row := range groups { + log.Error("Got row: %+v", row) + if stats, ok := result[row.ID]; ok { + stats.Threads = row.Threads + } + } + } + + // THEN: count all posts in all those threads. + { + type group struct { + ID uint64 + Posts uint64 + } + var groups = []group{} + + err := DB.Table( + "comments", + ).Joins( + "JOIN threads ON (table_name = 'threads' AND table_id = threads.id)", + ).Joins( + "JOIN forums ON (threads.forum_id = forums.id)", + ).Select( + "forums.id AS id, count(comments.id) AS posts", + ).Where( + `table_name = 'threads' AND EXISTS ( + SELECT 1 + FROM threads + WHERE table_id = threads.id + AND threads.forum_id IN ? + )`, + IDs, + ).Group("forums.id").Scan(&groups) + + if err.Error != nil { + log.Error("SQL error collecting posts for forum: %s", err.Error) + } + + // Map the results in. + for _, row := range groups { + log.Error("Got row2: %+v", row) + if stats, ok := result[row.ID]; ok { + stats.Posts = row.Posts + } + } + } + + // THEN: count all distinct users in those threads. + // TODO: hairy + // { + // type group struct { + // ID uint64 + // Users uint64 + // } + // var groups = []group{} + + // err := DB.Table( + // "comments", + // ).Joins( + // "JOIN threads ON (table_name = 'threads' AND table_id = threads.id)", + // ).Joins( + // "JOIN forums ON (threads.forum_id = forums.id)", + // ).Select( + // "forums.id AS forum_id, count(distinct(comments.user_id)) AS users", + // ).Where( + // // `table_name = 'threads' AND EXISTS ( + // // SELECT 1 + // // FROM threads + // // WHERE table_id = threads.id + // // AND threads.forum_id IN ? + // // )`, + // "forums.id IN ?", + // IDs, + // ).Group("forums.id").Scan(&groups) + + // if err.Error != nil { + // log.Error("SQL error collecting users for forum: %s", err.Error) + // } + + // // Map the results in. + // for _, row := range groups { + // log.Error("Got row2: %+v", row) + // if stats, ok := result[row.ID]; ok { + // stats.Users = row.Users + // } + // } + // } + + // Get THE most recent thread on this forum. + + return result +} + +// Has stats for this thread? (we should..) +func (ts ForumStatsMap) Has(threadID uint64) bool { + _, ok := ts[threadID] + return ok +} + +// Get thread stats. +func (ts ForumStatsMap) Get(threadID uint64) *ForumStatistics { + if stats, ok := ts[threadID]; ok { + return stats + } + return nil +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 01a8ea3..d8d07df 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -16,4 +16,7 @@ func AutoMigrate() { DB.AutoMigrate(&Friend{}) DB.AutoMigrate(&Block{}) DB.AutoMigrate(&Feedback{}) + DB.AutoMigrate(&Forum{}) + DB.AutoMigrate(&Thread{}) + DB.AutoMigrate(&Comment{}) } diff --git a/pkg/models/thread.go b/pkg/models/thread.go new file mode 100644 index 0000000..07972c3 --- /dev/null +++ b/pkg/models/thread.go @@ -0,0 +1,215 @@ +package models + +import ( + "errors" + "fmt" + "time" + + "git.kirsle.net/apps/gosocial/pkg/log" + "gorm.io/gorm" +) + +// Thread table - a post within a Forum. +type Thread struct { + ID uint64 `gorm:"primaryKey"` + ForumID uint64 `gorm:"index"` + Forum Forum + Explicit bool `gorm:"index"` + NoReply bool + Title string + CommentID uint64 `gorm:"index"` + Comment Comment // first comment of the thread + Views uint64 + CreatedAt time.Time + UpdatedAt time.Time +} + +// Preload related tables for the forum (classmethod). +func (f *Thread) Preload() *gorm.DB { + return DB.Preload("Forum").Preload("Comment.User.ProfilePhoto") +} + +// GetThread by ID. +func GetThread(id uint64) (*Thread, error) { + t := &Thread{} + result := t.Preload().First(&t, id) + return t, result.Error +} + +// CreateThread creates a new thread with proper Comment structure. +func CreateThread(user *User, forumID uint64, title, message string, explicit, noReply bool) (*Thread, error) { + thread := &Thread{ + ForumID: forumID, + Title: title, + Explicit: explicit, + NoReply: noReply && user.IsAdmin, + Comment: Comment{ + User: *user, + Message: message, + }, + } + + log.Error("CreateThread: Going to post %+v", thread) + + // Create the thread & comment first... + result := DB.Create(thread) + if result.Error != nil { + return nil, result.Error + } + + // Fill out the Comment with proper reverse foreign keys. + thread.Comment.TableName = "threads" + thread.Comment.TableID = thread.ID + log.Error("Saving updated comment: %+v", thread) + result = DB.Save(&thread.Comment) + return thread, result.Error +} + +// Reply to a thread, adding an additional comment. +func (t *Thread) Reply(user *User, message string) (*Comment, error) { + // Save the thread on reply, updating its timestamp. + if err := t.Save(); err != nil { + log.Error("Thread.Reply: couldn't ping UpdatedAt on thread: %s", err) + } + + return AddComment(user, "threads", t.ID, message) +} + +// DeleteReply removes a comment from a thread. If it is the primary comment, deletes the whole thread. +func (t *Thread) DeleteReply(comment *Comment) error { + // Sanity check that this reply is one of ours. + if !(comment.TableName == "threads" && comment.TableID == t.ID) { + return errors.New("that comment doesn't belong to this thread") + } + + // Is this the primary comment that started the thread? If so, delete the whole thread. + if comment.ID == t.CommentID { + log.Error("DeleteReply(%d): this is the parent comment of a thread (%d '%s'), remove the whole thread", comment.ID, t.ID, t.Title) + return t.Delete() + } + + // Remove just this comment. + return comment.Delete() +} + +// PaginateThreads provides a forum index view of posts. +func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) { + var ( + ts = []*Thread{} + query = (&Thread{}).Preload() + ) + + query = query.Where( + "forum_id = ?", + forum.ID, + ).Order(pager.Sort) + + query.Model(&Thread{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ts) + return ts, result.Error +} + +// View a thread, incrementing its View count but not its UpdatedAt. +func (t *Thread) View() error { + return DB.Model(&Thread{}).Where( + "id = ?", + t.ID, + ).Updates(map[string]interface{}{ + "views": t.Views + 1, + "updated_at": t.UpdatedAt, + }).Error +} + +// Save a thread, updating its timestamp. +func (t *Thread) Save() error { + return DB.Save(t).Error +} + +// Delete a thread and all of its comments. +func (t *Thread) Delete() error { + // Remove all comments. + result := DB.Where( + "table_name = ? AND table_id = ?", + "threads", t.ID, + ).Delete(&Comment{}) + if result.Error != nil { + return fmt.Errorf("deleting comments for thread: %s", result.Error) + } + + // Remove the thread itself. + return DB.Delete(t).Error +} + +// ThreadStatistics queries for reply/view count for threads. +type ThreadStatistics struct { + Replies uint64 + Views uint64 +} + +type ThreadStatsMap map[uint64]*ThreadStatistics + +// MapThreadStatistics looks up statistics for a set of threads. +func MapThreadStatistics(threads []*Thread) ThreadStatsMap { + var ( + result = ThreadStatsMap{} + IDs = []uint64{} + ) + + // Collect thread IDs and initialize the map. + for _, thread := range threads { + IDs = append(IDs, thread.ID) + result[thread.ID] = &ThreadStatistics{ + Views: thread.Views, + } + } + + // Hold the result of the count/group by query. + type group struct { + ID uint64 + Replies uint64 + } + var groups = []group{} + + // Count comments grouped by thread IDs. + err := DB.Table( + "comments", + ).Select( + "table_id AS id, count(id) AS replies", + ).Where( + "table_name = ? AND table_id IN ?", + "threads", IDs, + ).Group("table_id").Scan(&groups) + + if err != nil { + log.Error("MapThreadStatistics: SQL error: %s") + } + + // Map the results in. + for _, row := range groups { + log.Error("Got row: %+v", row) + if stats, ok := result[row.ID]; ok { + stats.Replies = row.Replies + + // Remove the OG comment from the count. + if stats.Replies > 0 { + stats.Replies-- + } + } + } + + return result +} + +// Has stats for this thread? (we should..) +func (ts ThreadStatsMap) Has(threadID uint64) bool { + _, ok := ts[threadID] + return ok +} + +// Get thread stats. +func (ts ThreadStatsMap) Get(threadID uint64) *ThreadStatistics { + if stats, ok := ts[threadID]; ok { + return stats + } + return nil +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 413ef00..fbfc73a 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -9,6 +9,7 @@ import ( "git.kirsle.net/apps/gosocial/pkg/controller/admin" "git.kirsle.net/apps/gosocial/pkg/controller/api" "git.kirsle.net/apps/gosocial/pkg/controller/block" + "git.kirsle.net/apps/gosocial/pkg/controller/forum" "git.kirsle.net/apps/gosocial/pkg/controller/friend" "git.kirsle.net/apps/gosocial/pkg/controller/inbox" "git.kirsle.net/apps/gosocial/pkg/controller/index" @@ -55,12 +56,18 @@ func New() http.Handler { // Certification Required. Pages that only full (verified) members can access. mux.Handle("/photo/gallery", middleware.CertRequired(photo.SiteGallery())) mux.Handle("/members", middleware.CertRequired(account.Search())) + mux.Handle("/forum", middleware.CertRequired(forum.Landing())) + mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost())) + mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread())) + mux.Handle("/f/", middleware.CertRequired(forum.Forum())) // Admin endpoints. mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard())) mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification())) mux.Handle("/admin/feedback", middleware.AdminRequired(admin.Feedback())) mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions())) + mux.Handle("/forum/admin", middleware.AdminRequired(forum.Manage())) + mux.Handle("/forum/admin/edit", middleware.AdminRequired(forum.AddEdit())) // JSON API endpoints. mux.HandleFunc("/v1/version", api.Version()) diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index f280d73..3065d6e 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -58,6 +58,12 @@ func TemplateFuncs(r *http.Request) template.FuncMap { } return value[:n] }, + "TrimEllipses": func(value string, n int) string { + if n > len(value) { + return value + } + return value[:n] + "…" + }, "IterRange": func(start, n int) []int { var result = []int{} for i := start; i <= n; i++ { diff --git a/web/templates/base.html b/web/templates/base.html index f9bb1f8..1854c7f 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -52,10 +52,10 @@ Gallery - + Forum + diff --git a/web/templates/forum/add_edit.html b/web/templates/forum/add_edit.html new file mode 100644 index 0000000..e609c08 --- /dev/null +++ b/web/templates/forum/add_edit.html @@ -0,0 +1,143 @@ +{{define "title"}}Forums{{end}} +{{define "content"}} +
+
+
+
+

+ {{if .EditForum}}Edit Forum{{else}}New Forum{{end}} +

+
+
+
+
+ +{{$Root := .}} + +
+
+
+
+
+

Forum Properties

+
+ +
+
+ {{InputCSRF}} + + +
+ + +
+ +
+ + +

+ A unique URL path component for this forum. You can not modify this + after the forum is created. Acceptable characters in the range a-z, 0-9 and . - _ +

+
+ +
+ + +

+ Write a short description of the forum. Markdown formatting + is supported here. +

+
+ + {{if .CurrentUser.IsAdmin}} +
+ +
+ +
+
+ {{end}} + +
+ + + +

+ Check this box if the forum is intended for explicit content. Users must + opt-in to see explicit content. +

+ + {{if .CurrentUser.IsAdmin}} + +

+ Check this box if only privileged users are allowed to create new threads + in this forum. Privileged users include the forum owner, site admins, and + forum moderators. +

+ {{end}} + + {{if .CurrentUser.IsAdmin}} + +

+ Check this box if the forum allows photos to be uploaded (not implemented) +

+ {{end}} +
+ +
+ +
+
+
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/forum/admin.html b/web/templates/forum/admin.html new file mode 100644 index 0000000..b6906ac --- /dev/null +++ b/web/templates/forum/admin.html @@ -0,0 +1,120 @@ +{{define "title"}}Forums{{end}} +{{define "content"}} +
+
+
+
+

+ + Forum Administration +

+
+
+
+
+ +{{$Root := .}} + +
+ + + Create New Forum + +
+ +

+ Found {{.Pager.Total}} forum{{Pluralize64 .Pager.Total}} you can manage + (page {{.Pager.Page}} of {{.Pager.Pages}}). +

+ +
+ +
+ +
+ {{range .Forums}} +
+
+
+

{{.Title}}

+

+ /f/{{.Fragment}} + {{if .Category}}{{.Category}}{{end}} + + by {{.Owner.Username}} + {{if .Owner.IsAdmin}}{{end}} + +

+ +
+

+ +

+ + {{if eq .Description ""}} +

No description

+ {{else}} + {{ToMarkdown .Description}} + {{end}} +
+ +
+ {{if .Explicit}} +
+ + Explicit +
+ {{end}} + + {{if .Privileged}} +
+ + Privileged +
+ {{end}} + + {{if .PermitPhotos}} +
+ + PermitPhotos +
+ {{end}} + +
+ Created {{.CreatedAt.Format "2006-01-02 15:04:05"}} +
+
+ Updated {{.UpdatedAt.Format "2006-01-02 15:04:05"}} +
+
+
+ + +
+ +
+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/web/templates/forum/board_index.html b/web/templates/forum/board_index.html new file mode 100644 index 0000000..7e900b4 --- /dev/null +++ b/web/templates/forum/board_index.html @@ -0,0 +1,127 @@ +{{define "title"}}{{.Forum.Title}}{{end}} +{{define "content"}} +
+
+
+
+

+ + {{.Forum.Title}} +

+
+
+
+
+ +{{$Root := .}} + +
+
+
+ +
+ +
+
+ +

+ Found {{.Pager.Total}} post{{Pluralize64 .Pager.Total}} on this forum + (page {{.Pager.Page}} of {{.Pager.Pages}}). +

+ +
+ +
+ +{{$Root := .}} +
+{{range .Threads}} + {{$Stats := $Root.ThreadMap.Get .ID}} + +{{end}} +
+ +{{end}} \ No newline at end of file diff --git a/web/templates/forum/index.html b/web/templates/forum/index.html new file mode 100644 index 0000000..662b952 --- /dev/null +++ b/web/templates/forum/index.html @@ -0,0 +1,121 @@ +{{define "title"}}Forums{{end}} +{{define "content"}} +
+
+
+
+

+ + Forums +

+
+
+
+
+ +
+
+
To Do
+ {{if .CurrentUser.IsAdmin}} + + {{end}} +
+
+ +{{$Root := .}} +{{range .Categories}} +
+

{{.Category}}

+ + {{if eq (len .Forums) 0}} + There are no forums under this category. + {{else}} + {{range .Forums}} + {{$Stats := $Root.ForumMap.Get .ID}} +
+ +
+
+
+ +

+ {{.Title}} +

+ +
+ {{if .Description}} + {{ToMarkdown .Description}} + {{else}} + No description + {{end}} +
+ +
+ {{if .Explicit}} + + + Explicit + + {{end}} +
+ +
+
+
+

Latest Post

+ Hello world! +
+
+
+
+
+
+

Topics

+ {{if $Stats}} + {{$Stats.Threads}} + {{else}} + err + {{end}} +
+
+
+
+

Posts

+ {{if $Stats}} + {{$Stats.Posts}} + {{else}} + err + {{end}} +
+
+ +
+
+ +
+
+
+ {{end}} + {{end}} +
+{{end}} + +{{end}} \ No newline at end of file diff --git a/web/templates/forum/new_post.html b/web/templates/forum/new_post.html new file mode 100644 index 0000000..f732c8e --- /dev/null +++ b/web/templates/forum/new_post.html @@ -0,0 +1,148 @@ +{{define "title"}} + {{if .EditCommentID}} + Edit Comment + {{else if .Thread}} + Reply to Thread + {{else}} + New Forum Post + {{end}} +{{end}} +{{define "content"}} +
+
+
+
+

+ {{if .EditCommentID}} + Edit comment on: {{or .Thread.Title "Untitled Thread"}} + {{else if .Thread}} + Reply to: {{or .Thread.Title "Untitled Thread"}} + {{else}} + Post to: {{.Forum.Title}} + {{end}} +

+

+ /f/{{.Forum.Fragment}} +

+
+
+
+ +
+
+
+ +
+ +
+ + {{if and (eq .Request.Method "POST") (ne .Message "")}} + +
+ {{ToMarkdown .Message}} +
+ {{end}} + +
+ {{InputCSRF}} + + {{if not .Thread}} +
+ + +

Required.

+
+ {{end}} + +
+ + +

+ Markdown formatting supported. +

+
+ + {{if not .Thread}} +
+
Options
+ +
+ +
+ + {{if .CurrentUser.IsAdmin}} +
+ +
+ {{end}} +
+ {{end}} + +
+ + +
+
+ +
+
+ +
+
+
+ +
+ + +{{end}} \ No newline at end of file diff --git a/web/templates/forum/thread.html b/web/templates/forum/thread.html new file mode 100644 index 0000000..05837f6 --- /dev/null +++ b/web/templates/forum/thread.html @@ -0,0 +1,153 @@ +{{define "title"}}{{.Forum.Title}}{{end}} +{{define "content"}} +
+
+
+
+

+ + {{.Forum.Title}} +

+
+
+
+
+ +{{$Root := .}} + +
+ +
+ +

{{or .Thread.Title "Untitled Thread"}}

+ +

+ Found {{.Pager.Total}} post{{Pluralize64 .Pager.Total}} on this thread + (page {{.Pager.Page}} of {{.Pager.Pages}}). +

+ +
+ +
+ +{{$Root := .}} +
+{{range .Comments}} + +{{end}} +
+ +
+ + + Add Reply + +
+ +{{end}} \ No newline at end of file