47aaf15078
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
459 lines
15 KiB
Go
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
|
|
}
|
|
})
|
|
}
|