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/spam" "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 } } } // If the current user can moderate the forum thread, e.g. edit or delete posts. // Admins can edit always, user owners of forums can only delete. var canModerate = currentUser.HasAdminScope(config.ScopeForumModerator) || (forum.OwnerID == currentUser.ID && isDelete) // 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 && !canModerate { 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.") // Log the change. models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf( "Deleted a forum comment on thread %d forum /f/%s", thread.ID, forum.Fragment, ), comment) } 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 { // Look for spammy links to video sites or things. if err := spam.DetectSpamMessage(title + message); err != nil { session.FlashError(w, r, err.Error()) 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 } // 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!") // Log the change. models.LogUpdated(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf( "Edited their comment on thread %d (in /f/%s):\n\n%s", thread.ID, forum.Fragment, message, ), nil) } 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!") // Log the change. models.LogCreated(currentUser, "comments", reply.ID, fmt.Sprintf( "Commented on thread %d:\n\n%s", thread.ID, message, )) // 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. Filter by blocked user IDs. for _, userID := range models.FilterBlockingUserIDs(currentUser, 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("/go/comment?id=%d", 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 !currentUser.NotificationOptOut(config.NotificationOptOutSubscriptions) { 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 !currentUser.NotificationOptOut(config.NotificationOptOutSubscriptions) { 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) } } // Log the change. models.LogCreated(currentUser, "threads", thread.ID, fmt.Sprintf( "Started a new forum thread on forum /f/%s (%s)\n\n"+ "* Has poll? %v\n"+ "* Title: %s\n\n%s", forum.Fragment, forum.Title, isPoll, thread.Title, message, )) 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 } }) }