website/pkg/controller/forum/new_post.go
Noah Petherbridge 47aaf15078 Admin Groups & Permissions
Add a permission system for admin users so you can lock down specific admins to
a narrower set of features instead of them all having omnipotent powers.

* New page: Admin Dashboard -> Admin Permissions Management
* Permissions are handled in the form of 'scopes' relevant to each feature or
  action on the site. Scopes are assigned to Groups, and in turn, admin user
  accounts are placed in those Groups.
* The Superusers group (scope '*') has wildcard permission to all scopes. The
  permissions dashboard has a create-once action to initialize the Superusers
  for the first admin who clicks on it, and places that admin in the group.

The following are the exhaustive list of permission changes on the site:

* Moderator scopes:
    * Chat room (enter the room with Operator permission)
    * Forums (can edit or delete user posts on the forum)
    * Photo Gallery (can see all private/friends-only photos on the site
      gallery or user profile pages)
* Certification photos (with nuanced sub-action permissions)
    * Approve: has access to the Pending tab to act on incoming pictures
    * List: can paginate thru past approved/rejected photos
    * View: can bring up specific user cert photo from their profile
    * The minimum requirement is Approve or else no cert photo page
      will load for your admin user.
* User Actions (each action individually scoped)
    * Impersonate
    * Ban
    * Delete
    * Promote to admin
* Inner circle whitelist: no longer are admins automatically part of the
  inner circle unless they have a specialized scope attached.

The AdminRequired decorator may also apply scopes on an entire admin route.
The following routes have scopes to limit them:

* Forum Admin (manage forums and their settings)
* Remove from inner circle
2023-08-01 20:39:48 -07:00

459 lines
15 KiB
Go

package forum
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/markdown"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/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
photoIntent = r.FormValue("photo_intent") // upload, remove photo attachment
photoID = r.FormValue("photo_id") // existing CommentPhoto ID
title = r.FormValue("title") // for new forum post only
message = r.PostFormValue("message") // comment body
isPinned = r.PostFormValue("pinned") == "true" // owners or admins only
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
// If we are modifying a comment (post) and it's the OG post of the
// thread, we show and accept the thread settings to be updated as
// well (pinned, explicit, noreply)
isOriginalComment bool
// Polls
pollOptions = []string{}
pollExpires = 3
pollMultipleChoice = r.FormValue("poll_multiple_choice") == "true"
isPoll bool
// Attached photo object.
commentPhoto *models.CommentPhoto
)
// 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
}
}
}
// Does the comment have an existing Photo ID?
if len(photoID) > 0 {
if i, err := strconv.Atoi(photoID); err == nil {
if found, err := models.GetCommentPhoto(uint64(i)); err != nil {
session.FlashError(w, r, "Couldn't find comment photo ID #%d!", i)
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
return
} else {
commentPhoto = 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.HasAdminScope(config.ScopeForumModerator) {
templates.ForbiddenPage(w, r)
return
}
// Initialize the form w/ the content of this message.
if r.Method == http.MethodGet {
message = comment.Message
}
// Did this comment have a picture? Load it if so.
if photos, err := comment.GetPhotos(); err == nil && len(photos) > 0 {
commentPhoto = photos[0]
}
// Is this the OG thread of the post?
if thread.CommentID == comment.ID {
isOriginalComment = true
// Restore the checkbox option form values from thread settings.
if r.Method == http.MethodGet {
isPinned = thread.Pinned
isExplicit = thread.Explicit
isNoReply = thread.NoReply
}
}
// Are we DELETING this comment?
if isDelete {
// Is there a photo attachment? Remove it, too.
if commentPhoto != nil {
if err := photo.Delete(commentPhoto.Filename); err != nil {
session.FlashError(w, r, "Error removing the photo from disk: %s", err)
}
if err := commentPhoto.Delete(); err != nil {
session.FlashError(w, r, "Couldn't remove photo from DB: %s", err)
} else {
commentPhoto = nil
}
}
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 {
// Polls: parse form parameters into a neat list of answers.
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
var distinctPollChoices = map[string]interface{}{}
for i := 0; i < config.PollMaxAnswers; i++ {
if value := r.FormValue(fmt.Sprintf("answer%d", i)); value != "" {
pollOptions = append(pollOptions, value)
isPoll = len(pollOptions) >= 2
// Make sure every option is distinct!
if _, ok := distinctPollChoices[value]; ok {
session.FlashError(w, r, "Your poll options must all be unique! Duplicate option '%s' seen in your post!", value)
intent = "preview" // do not continue to submit
}
distinctPollChoices[value] = nil
}
}
// If only one poll option, warn about it.
if len(pollOptions) == 1 {
session.FlashError(w, r, "Your poll should have at least two choices.")
intent = "preview" // do not continue to submit
}
// Is a photo coming along?
if forum.PermitPhotos {
// Removing or replacing?
if photoIntent == "remove" || photoIntent == "replace" {
// Remove the attached photo.
if commentPhoto == nil {
session.FlashError(w, r, "Couldn't remove photo from post: no photo found!")
} else {
photo.Delete(commentPhoto.Filename)
if err := commentPhoto.Delete(); err != nil {
session.FlashError(w, r, "Couldn't remove photo from DB: %s", err)
} else {
session.Flash(w, r, "Photo attachment %sd from this post.", photoIntent)
commentPhoto = nil
}
}
}
// Uploading a new picture?
if photoIntent == "upload" || photoIntent == "replace" {
log.Info("Receiving a photo upload for forum post")
// Get their file upload.
file, header, err := r.FormFile("file")
if err != nil {
session.FlashError(w, r, "Error receiving your file: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Read the file contents.
log.Debug("Receiving uploaded file (%d bytes): %s", header.Size, header.Filename)
var buf bytes.Buffer
io.Copy(&buf, file)
filename, _, err := photo.UploadPhoto(photo.UploadConfig{
Extension: filepath.Ext(header.Filename),
Data: buf.Bytes(),
})
if err != nil {
session.FlashError(w, r, "Error in UploadPhoto: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Create the PhotoComment. If we don't have a Comment ID yet, let it be empty.
ptmpl := models.CommentPhoto{
Filename: filename,
UserID: currentUser.ID,
}
if comment != nil {
ptmpl.CommentID = comment.ID
}
// Get the filesize.
if stat, err := os.Stat(photo.DiskPath(filename)); err == nil {
ptmpl.Filesize = stat.Size()
}
// Create it in DB!
p, err := models.CreateCommentPhoto(ptmpl)
if err != nil {
session.FlashError(w, r, "Couldn't create CommentPhoto in DB: %s", err)
} else {
log.Info("New photo! %+v", p)
}
commentPhoto = p
}
}
// Default intent is preview unless told to submit.
if intent == "submit" {
// A message OR a photo is required.
if forum.PermitPhotos && message == "" && commentPhoto == nil {
session.FlashError(w, r, "A message OR photo is required for this post.")
if thread != nil {
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
} else if forum != nil {
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
} else {
templates.Redirect(w, "/forum")
}
return
} else if !forum.PermitPhotos && message == "" {
session.FlashError(w, r, "A message is required for this post.")
if thread != nil {
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
} else if forum != nil {
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
} else {
templates.Redirect(w, "/forum")
}
return
}
// Are we modifying an existing comment?
if comment != nil {
comment.Message = message
// Can we update the thread props?
if isOriginalComment {
thread.Pinned = isPinned
thread.Explicit = isExplicit
thread.NoReply = isNoReply
if err := thread.Save(); err != nil {
session.FlashError(w, r, "Couldn't save thread properties: %s", err)
}
}
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 reply, 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!")
// If we're attaching a photo, link it to this reply CommentID.
if commentPhoto != nil {
commentPhoto.CommentID = reply.ID
if err := commentPhoto.Save(); err != nil {
log.Error("Couldn't save forum reply CommentPhoto.CommentID: %s", err)
}
}
// What page number of the thread will this comment appear on?
// Get the last page of the thread, and if not 1, append the
// query string - so the notification might go to e.g.
// "/forum/thread/:id?page=4#p50" to link directly to page 4
// where comment 50 can be seen.
var queryString = ""
if lastPage := thread.Pages(); lastPage > 1 {
queryString = fmt.Sprintf("?page=%d", lastPage)
}
// Notify watchers about this new post.
for _, userID := range models.GetSubscribers("threads", thread.ID) {
if userID == currentUser.ID {
continue
}
notif := &models.Notification{
UserID: userID,
AboutUser: *currentUser,
Type: models.NotificationAlsoPosted,
TableName: "threads",
TableID: thread.ID,
Message: message,
Link: fmt.Sprintf("/forum/thread/%d%s#p%d", thread.ID, queryString, reply.ID),
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create thread reply notification for subscriber %d: %s", userID, err)
}
}
// Subscribe the current user to further responses on this thread.
if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil {
log.Error("Couldn't subscribe user %d to forum thread %d: %s", currentUser.ID, thread.ID, err)
}
// Redirect the poster to the correct page number too.
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d%s", thread.ID, queryString))
return
}
// Called on the error case that the post couldn't be created -
// probably should not happen.
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,
isPinned,
isExplicit,
isNoReply,
); err != nil {
session.FlashError(w, r, "Couldn't create thread: %s", err)
} else {
session.Flash(w, r, "Thread created!")
// If we're attaching a photo, link it to this CommentID.
if commentPhoto != nil {
commentPhoto.CommentID = thread.CommentID
if err := commentPhoto.Save(); err != nil {
log.Error("Couldn't save forum post CommentPhoto.CommentID: %s", err)
}
}
// Are we attaching a poll to this new thread?
if isPoll {
log.Info("It's a Poll! Options: %+v", pollOptions)
poll := models.CreatePoll(pollOptions, pollExpires)
poll.MultipleChoice = pollMultipleChoice
if err := poll.Save(); err != nil {
session.FlashError(w, r, "Error creating poll: %s", err)
}
// Attach it to this thread.
thread.PollID = &poll.ID
if err := thread.Save(); err != nil {
log.Error("Couldn't save PollID onto thread! %s", err)
}
}
// Subscribe the current user to responses on this thread.
if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil {
log.Error("Couldn't subscribe user %d to forum thread %d: %s", currentUser.ID, thread.ID, err)
}
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,
"EditThreadSettings": isOriginalComment,
"Message": message,
// Thread settings (for editing the original comment esp.)
"IsPinned": isPinned,
"IsExplicit": isExplicit,
"IsNoReply": isNoReply,
// Polls
"PollOptions": pollOptions,
// Attached photo.
"CommentPhoto": commentPhoto,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}