gosora/routes/reply.go
Azareal 764b9904f1 Tighten validation on group promotion delete.
Shorten some variable names.
Reduce the amount of duplication in the panel group routes.
Rename isJs to js here and there.
Localise group promotions.

Phrases:

Rename create_topic_create_topic_button to create_topic_create_button
Rename quick_topic.create_topic_button to quick_topic.create_button
Rename quick_topic.create_topic_button_short to quick_topic.create_button_short
Rename panel_groups_create_create_group_button to panel_groups_create_button
Rename panel_word_filters_create_create_word_filter_button to panel_word_filters_create_button
Rename panel_pages_create_submit_button to panel_pages_create_button
Add panel_group_promotions_create_head
Add panel_group_promotions_from
Add panel_group_promotions_to
Add panel_group_promotions_two_way
Add panel_group_promotions_level
Add panel_group_promotions_create_button
2019-09-30 20:15:50 +10:00

642 lines
18 KiB
Go

package routes
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
c "github.com/Azareal/Gosora/common"
"github.com/Azareal/Gosora/common/counters"
"github.com/Azareal/Gosora/common/phrases"
"github.com/Azareal/Gosora/query_gen"
)
type ReplyStmts struct {
updateAttachs *sql.Stmt
createReplyPaging *sql.Stmt
}
var replyStmts ReplyStmts
// TODO: Move this statement somewhere else
func init() {
c.DbInits.Add(func(acc *qgen.Accumulator) error {
replyStmts = ReplyStmts{
// TODO: Less race-y attachment count updates
updateAttachs: acc.Update("replies").Set("attachCount = ?").Where("rid = ?").Prepare(),
createReplyPaging: acc.Select("replies").Cols("rid").Where("rid >= ? - 1 AND tid = ?").Orderby("rid ASC").Prepare(),
}
return acc.FirstError()
})
}
type JsonReply struct {
Content string
}
func CreateReplySubmit(w http.ResponseWriter, r *http.Request, user c.User) c.RouteError {
// TODO: Use this
js := r.FormValue("js") == "1"
tid, err := strconv.Atoi(r.PostFormValue("tid"))
if err != nil {
return c.PreErrorJSQ("Failed to convert the Topic ID", w, r, js)
}
topic, err := c.Topics.Get(tid)
if err == sql.ErrNoRows {
return c.PreErrorJSQ("Couldn't find the parent topic", w, r, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
// TODO: Add hooks to make use of headerLite
lite, ferr := c.SimpleForumUserCheck(w, r, &user, topic.ParentID)
if ferr != nil {
return ferr
}
if !user.Perms.ViewTopic || !user.Perms.CreateReply {
return c.NoPermissionsJSQ(w, r, user, js)
}
if topic.IsClosed && !user.Perms.CloseTopic {
return c.NoPermissionsJSQ(w, r, user, js)
}
content := c.PreparseMessage(r.PostFormValue("content"))
// TODO: Fully parse the post and put that in the parsed column
rid, err := c.Rstore.Create(topic, content, user.LastIP, user.ID)
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
reply, err := c.Rstore.Get(rid)
if err != nil {
return c.LocalErrorJSQ("Unable to load the reply", w, r, user, js)
}
// Handle the file attachments
// TODO: Stop duplicating this code
if user.Perms.UploadFiles {
_, rerr := uploadAttachment(w, r, user, topic.ParentID, "forums", rid, "replies", strconv.Itoa(topic.ID))
if rerr != nil {
return rerr
}
}
if r.PostFormValue("has_poll") == "1" {
maxPollOptions := 10
pollInputItems := make(map[int]string)
for key, values := range r.Form {
//c.DebugDetail("key: ", key)
//c.DebugDetailf("values: %+v\n", values)
for _, value := range values {
if strings.HasPrefix(key, "pollinputitem[") {
halves := strings.Split(key, "[")
if len(halves) != 2 {
return c.LocalErrorJSQ("Malformed pollinputitem", w, r, user, js)
}
halves[1] = strings.TrimSuffix(halves[1], "]")
index, err := strconv.Atoi(halves[1])
if err != nil {
return c.LocalErrorJSQ("Malformed pollinputitem", w, r, user, js)
}
// If there are duplicates, then something has gone horribly wrong, so let's ignore them, this'll likely happen during an attack
_, exists := pollInputItems[index]
// TODO: Should we use SanitiseBody instead to keep the newlines?
if !exists && len(c.SanitiseSingleLine(value)) != 0 {
pollInputItems[index] = c.SanitiseSingleLine(value)
if len(pollInputItems) >= maxPollOptions {
break
}
}
}
}
}
// Make sure the indices are sequential to avoid out of bounds issues
seqPollInputItems := make(map[int]string)
for i := 0; i < len(pollInputItems); i++ {
seqPollInputItems[i] = pollInputItems[i]
}
pollType := 0 // Basic single choice
_, err := c.Polls.Create(reply, pollType, seqPollInputItems)
if err != nil {
return c.LocalErrorJSQ("Failed to add poll to reply", w, r, user, js) // TODO: Might need to be an internal error as it could leave phantom polls?
}
}
err = c.Forums.UpdateLastTopic(tid, user.ID, topic.ParentID)
if err != nil && err != sql.ErrNoRows {
return c.InternalErrorJSQ(err, w, r, js)
}
c.AddActivityAndNotifyAll(user.ID, topic.CreatedBy, "reply", "topic", tid)
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
wcount := c.WordCount(content)
err = user.IncreasePostStats(wcount, false)
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
nTopic, err := c.Topics.Get(tid)
if err == sql.ErrNoRows {
return c.PreErrorJSQ("Couldn't find the parent topic", w, r, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
page := c.LastPage(nTopic.PostCount, c.Config.ItemsPerPage)
rows, err := replyStmts.createReplyPaging.Query(reply.ID, topic.ID)
if err != nil && err != sql.ErrNoRows {
return c.InternalErrorJSQ(err, w, r, js)
}
defer rows.Close()
var rids []int
for rows.Next() {
var rid int
err := rows.Scan(&rid)
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
rids = append(rids, rid)
}
err = rows.Err()
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
if len(rids) == 0 {
return c.NotFoundJSQ(w, r, nil, js)
}
if page > 1 {
var offset int
if rids[0] == reply.ID {
offset = 1
} else if len(rids) == 2 && rids[1] == reply.ID {
offset = 2
}
page = c.LastPage(nTopic.PostCount-(len(rids)+offset), c.Config.ItemsPerPage)
}
counters.PostCounter.Bump()
skip, rerr := lite.Hooks.VhookSkippable("action_end_create_reply", reply.ID, &user)
if skip || rerr != nil {
return rerr
}
prid, _ := strconv.Atoi(r.FormValue("prid"))
if js && (prid == 0 || rids[0] == prid) {
outBytes, err := json.Marshal(JsonReply{c.ParseMessage(reply.Content, topic.ParentID, "forums")})
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
w.Write(outBytes)
} else {
var spage string
if page > 1 {
spage = "?page=" + strconv.Itoa(page)
}
http.Redirect(w, r, "/topic/"+strconv.Itoa(tid)+spage+"#post-"+strconv.Itoa(reply.ID), http.StatusSeeOther)
}
return nil
}
// TODO: Disable stat updates in posts handled by plugin_guilds
// TODO: Update the stats after edits so that we don't under or over decrement stats during deletes
func ReplyEditSubmit(w http.ResponseWriter, r *http.Request, user c.User, srid string) c.RouteError {
js := (r.PostFormValue("js") == "1")
rid, err := strconv.Atoi(srid)
if err != nil {
return c.PreErrorJSQ("The provided Reply ID is not a valid number.", w, r, js)
}
reply, err := c.Rstore.Get(rid)
if err == sql.ErrNoRows {
return c.PreErrorJSQ("The target reply doesn't exist.", w, r, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
topic, err := reply.Topic()
if err == sql.ErrNoRows {
return c.PreErrorJSQ("The parent topic doesn't exist.", w, r, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
// TODO: Add hooks to make use of headerLite
lite, ferr := c.SimpleForumUserCheck(w, r, &user, topic.ParentID)
if ferr != nil {
return ferr
}
if !user.Perms.ViewTopic || !user.Perms.EditReply {
return c.NoPermissionsJSQ(w, r, user, js)
}
if topic.IsClosed && !user.Perms.CloseTopic {
return c.NoPermissionsJSQ(w, r, user, js)
}
err = reply.SetPost(r.PostFormValue("edit_item"))
if err == sql.ErrNoRows {
return c.PreErrorJSQ("The parent topic doesn't exist.", w, r, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
// TODO: Avoid the load to get this faster?
reply, err = c.Rstore.Get(rid)
if err == sql.ErrNoRows {
return c.PreErrorJSQ("The updated reply doesn't exist.", w, r, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
skip, rerr := lite.Hooks.VhookSkippable("action_end_edit_reply", reply.ID, &user)
if skip || rerr != nil {
return rerr
}
if !js {
http.Redirect(w, r, "/topic/"+strconv.Itoa(topic.ID)+"#reply-"+strconv.Itoa(rid), http.StatusSeeOther)
} else {
outBytes, err := json.Marshal(JsonReply{c.ParseMessage(reply.Content, topic.ParentID, "forums")})
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
w.Write(outBytes)
}
return nil
}
// TODO: Refactor this
// TODO: Disable stat updates in posts handled by plugin_guilds
func ReplyDeleteSubmit(w http.ResponseWriter, r *http.Request, user c.User, srid string) c.RouteError {
js := (r.PostFormValue("js") == "1")
rid, err := strconv.Atoi(srid)
if err != nil {
return c.PreErrorJSQ("The provided Reply ID is not a valid number.", w, r, js)
}
reply, err := c.Rstore.Get(rid)
if err == sql.ErrNoRows {
return c.PreErrorJSQ("The reply you tried to delete doesn't exist.", w, r, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
topic, err := c.Topics.Get(reply.ParentID)
if err == sql.ErrNoRows {
return c.PreErrorJSQ("The parent topic doesn't exist.", w, r, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
// TODO: Add hooks to make use of headerLite
lite, ferr := c.SimpleForumUserCheck(w, r, &user, topic.ParentID)
if ferr != nil {
return ferr
}
if !user.Perms.ViewTopic || !user.Perms.DeleteReply {
return c.NoPermissionsJSQ(w, r, user, js)
}
err = reply.Delete()
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
skip, rerr := lite.Hooks.VhookSkippable("action_end_delete_reply", reply.ID, &user)
if skip || rerr != nil {
return rerr
}
//log.Printf("Reply #%d was deleted by c.User #%d", rid, user.ID)
if !js {
http.Redirect(w, r, "/topic/"+strconv.Itoa(reply.ParentID), http.StatusSeeOther)
} else {
w.Write(successJSONBytes)
}
// ? - What happens if an error fires after a redirect...?
replyCreator, err := c.Users.Get(reply.CreatedBy)
if err == nil {
wcount := c.WordCount(reply.Content)
err = replyCreator.DecreasePostStats(wcount, false)
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
} else if err != sql.ErrNoRows {
return c.InternalErrorJSQ(err, w, r, js)
}
err = c.ModLogs.Create("delete", reply.ParentID, "reply", user.LastIP, user.ID)
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
return nil
}
// TODO: Avoid uploading this again if the attachment already exists? They'll resolve to the same hash either way, but we could save on some IO / bandwidth here
// TODO: Enforce the max request limit on all of this topic's attachments
// TODO: Test this route
func AddAttachToReplySubmit(w http.ResponseWriter, r *http.Request, user c.User, srid string) c.RouteError {
rid, err := strconv.Atoi(srid)
if err != nil {
return c.LocalErrorJS(phrases.GetErrorPhrase("id_must_be_integer"), w, r)
}
reply, err := c.Rstore.Get(rid)
if err == sql.ErrNoRows {
return c.PreErrorJS("You can't attach to something which doesn't exist!", w, r)
} else if err != nil {
return c.InternalErrorJS(err, w, r)
}
topic, err := c.Topics.Get(reply.ParentID)
if err != nil {
return c.NotFoundJS(w, r)
}
lite, ferr := c.SimpleForumUserCheck(w, r, &user, topic.ParentID)
if ferr != nil {
return ferr
}
if !user.Perms.ViewTopic || !user.Perms.EditReply || !user.Perms.UploadFiles {
return c.NoPermissionsJS(w, r, user)
}
if topic.IsClosed && !user.Perms.CloseTopic {
return c.NoPermissionsJS(w, r, user)
}
// Handle the file attachments
pathMap, rerr := uploadAttachment(w, r, user, topic.ParentID, "forums", rid, "replies", strconv.Itoa(topic.ID))
if rerr != nil {
// TODO: This needs to be a JS error...
return rerr
}
if len(pathMap) == 0 {
return c.InternalErrorJS(errors.New("no paths for attachment add"), w, r)
}
skip, rerr := lite.Hooks.VhookSkippable("action_end_add_attach_to_reply", reply.ID, &user)
if skip || rerr != nil {
return rerr
}
var elemStr string
for path, aids := range pathMap {
elemStr += "\"" + path + "\":\"" + aids + "\","
}
if len(elemStr) > 1 {
elemStr = elemStr[:len(elemStr)-1]
}
w.Write([]byte(`{"success":"1","elems":{` + elemStr + `}}`))
return nil
}
// TODO: Reduce the amount of duplication between this and RemoveAttachFromTopicSubmit
func RemoveAttachFromReplySubmit(w http.ResponseWriter, r *http.Request, user c.User, srid string) c.RouteError {
rid, err := strconv.Atoi(srid)
if err != nil {
return c.LocalErrorJS(phrases.GetErrorPhrase("id_must_be_integer"), w, r)
}
reply, err := c.Rstore.Get(rid)
if err == sql.ErrNoRows {
return c.PreErrorJS("You can't attach from something which doesn't exist!", w, r)
} else if err != nil {
return c.InternalErrorJS(err, w, r)
}
topic, err := c.Topics.Get(reply.ParentID)
if err != nil {
return c.NotFoundJS(w, r)
}
lite, ferr := c.SimpleForumUserCheck(w, r, &user, topic.ParentID)
if ferr != nil {
return ferr
}
if !user.Perms.ViewTopic || !user.Perms.EditReply {
return c.NoPermissionsJS(w, r, user)
}
if topic.IsClosed && !user.Perms.CloseTopic {
return c.NoPermissionsJS(w, r, user)
}
saids := strings.Split(r.PostFormValue("aids"), ",")
if len(saids) == 0 {
return c.LocalErrorJS("No aids provided", w, r)
}
for _, said := range saids {
aid, err := strconv.Atoi(said)
if err != nil {
return c.LocalErrorJS(phrases.GetErrorPhrase("id_must_be_integer"), w, r)
}
rerr := deleteAttachment(w, r, user, aid, true)
if rerr != nil {
// TODO: This needs to be a JS error...
return rerr
}
}
skip, rerr := lite.Hooks.VhookSkippable("action_end_remove_attach_from_reply", reply.ID, &user)
if skip || rerr != nil {
return rerr
}
w.Write(successJSONBytes)
return nil
}
// TODO: Move the profile reply routes to their own file?
func ProfileReplyCreateSubmit(w http.ResponseWriter, r *http.Request, user c.User) c.RouteError {
if !user.Perms.ViewTopic || !user.Perms.CreateReply {
return c.NoPermissions(w, r, user)
}
uid, err := strconv.Atoi(r.PostFormValue("uid"))
if err != nil {
return c.LocalError("Invalid UID", w, r, user)
}
profileOwner, err := c.Users.Get(uid)
if err == sql.ErrNoRows {
return c.LocalError("The profile you're trying to post on doesn't exist.", w, r, user)
} else if err != nil {
return c.InternalError(err, w, r)
}
content := c.PreparseMessage(r.PostFormValue("content"))
if len(content) == 0 {
return c.LocalError("You can't make a blank post", w, r, user)
}
// TODO: Fully parse the post and store it in the parsed column
_, err = c.Prstore.Create(profileOwner.ID, content, user.ID, user.LastIP)
if err != nil {
return c.InternalError(err, w, r)
}
// ! Be careful about leaking per-route permission state with &user
alert := c.Alert{ActorID: user.ID, TargetUserID: profileOwner.ID, Event: "reply", ElementType: "user", ElementID: profileOwner.ID, Actor: &user}
err = c.AddActivityAndNotifyTarget(alert)
if err != nil {
return c.InternalError(err, w, r)
}
counters.PostCounter.Bump()
http.Redirect(w, r, "/user/"+strconv.Itoa(uid), http.StatusSeeOther)
return nil
}
func ProfileReplyEditSubmit(w http.ResponseWriter, r *http.Request, user c.User, srid string) c.RouteError {
js := (r.PostFormValue("js") == "1")
rid, err := strconv.Atoi(srid)
if err != nil {
return c.LocalErrorJSQ("The provided Reply ID is not a valid number.", w, r, user, js)
}
reply, err := c.Prstore.Get(rid)
if err == sql.ErrNoRows {
return c.PreErrorJSQ("The target reply doesn't exist.", w, r, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
creator, err := c.Users.Get(reply.CreatedBy)
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
// ? Does the admin understand that this group perm affects this?
if user.ID != creator.ID && !user.Perms.EditReply {
return c.NoPermissionsJSQ(w, r, user, js)
}
err = reply.SetBody(r.PostFormValue("edit_item"))
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
if !js {
http.Redirect(w, r, "/user/"+strconv.Itoa(creator.ID)+"#reply-"+strconv.Itoa(rid), http.StatusSeeOther)
} else {
w.Write(successJSONBytes)
}
return nil
}
func ProfileReplyDeleteSubmit(w http.ResponseWriter, r *http.Request, user c.User, srid string) c.RouteError {
js := (r.PostFormValue("js") == "1")
rid, err := strconv.Atoi(srid)
if err != nil {
return c.LocalErrorJSQ("The provided Reply ID is not a valid number.", w, r, user, js)
}
reply, err := c.Prstore.Get(rid)
if err == sql.ErrNoRows {
return c.PreErrorJSQ("The target reply doesn't exist.", w, r, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
creator, err := c.Users.Get(reply.CreatedBy)
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
if user.ID != creator.ID && !user.Perms.DeleteReply {
return c.NoPermissionsJSQ(w, r, user, js)
}
err = reply.Delete()
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
//log.Printf("The profile post '%d' was deleted by c.User #%d", reply.ID, user.ID)
if !js {
//http.Redirect(w,r, "/user/" + strconv.Itoa(creator.ID), http.StatusSeeOther)
} else {
w.Write(successJSONBytes)
}
return nil
}
func ReplyLikeSubmit(w http.ResponseWriter, r *http.Request, user c.User, srid string) c.RouteError {
js := (r.PostFormValue("js") == "1")
rid, err := strconv.Atoi(srid)
if err != nil {
return c.PreErrorJSQ("The provided Reply ID is not a valid number.", w, r, js)
}
reply, err := c.Rstore.Get(rid)
if err == sql.ErrNoRows {
return c.PreErrorJSQ("You can't like something which doesn't exist!", w, r, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
topic, err := c.Topics.Get(reply.ParentID)
if err == sql.ErrNoRows {
return c.PreErrorJSQ("The parent topic doesn't exist.", w, r, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
// TODO: Add hooks to make use of headerLite
lite, ferr := c.SimpleForumUserCheck(w, r, &user, topic.ParentID)
if ferr != nil {
return ferr
}
if !user.Perms.ViewTopic || !user.Perms.LikeItem {
return c.NoPermissionsJSQ(w, r, user, js)
}
if reply.CreatedBy == user.ID {
return c.LocalErrorJSQ("You can't like your own replies", w, r, user, js)
}
_, err = c.Users.Get(reply.CreatedBy)
if err != nil && err != sql.ErrNoRows {
return c.LocalErrorJSQ("The target user doesn't exist", w, r, user, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
err = reply.Like(user.ID)
if err == c.ErrAlreadyLiked {
return c.LocalErrorJSQ("You've already liked this!", w, r, user, js)
} else if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
// ! Be careful about leaking per-route permission state with &user
alert := c.Alert{ActorID: user.ID, TargetUserID: reply.CreatedBy, Event: "like", ElementType: "post", ElementID: rid, Actor: &user}
err = c.AddActivityAndNotifyTarget(alert)
if err != nil {
return c.InternalErrorJSQ(err, w, r, js)
}
skip, rerr := lite.Hooks.VhookSkippable("action_end_like_reply", reply.ID, &user)
if skip || rerr != nil {
return rerr
}
if !js {
http.Redirect(w, r, "/topic/"+strconv.Itoa(reply.ParentID), http.StatusSeeOther)
} else {
_, _ = w.Write(successJSONBytes)
}
return nil
}