github.com/Azareal/Gosora@v0.0.0-20210729070923-553e66b59003/routes/reply.go (about)

     1  package routes
     2  
     3  import (
     4  	"database/sql"
     5  	"encoding/json"
     6  	"errors"
     7  	"net/http"
     8  	"strconv"
     9  	"strings"
    10  
    11  	c "github.com/Azareal/Gosora/common"
    12  	"github.com/Azareal/Gosora/common/counters"
    13  	p "github.com/Azareal/Gosora/common/phrases"
    14  	qgen "github.com/Azareal/Gosora/query_gen"
    15  )
    16  
    17  type ReplyStmts struct {
    18  	createReplyPaging *sql.Stmt
    19  }
    20  
    21  var replyStmts ReplyStmts
    22  
    23  // TODO: Move this statement somewhere else
    24  func init() {
    25  	c.DbInits.Add(func(acc *qgen.Accumulator) error {
    26  		replyStmts = ReplyStmts{
    27  			createReplyPaging: acc.Select("replies").Cols("rid").Where("rid >= ? - 1 AND tid=?").Orderby("rid ASC").Prepare(),
    28  		}
    29  		return acc.FirstError()
    30  	})
    31  }
    32  
    33  type JsonReply struct {
    34  	Content string
    35  }
    36  
    37  func CreateReplySubmit(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {
    38  	// TODO: Use this
    39  	js := r.FormValue("js") == "1"
    40  	tid, err := strconv.Atoi(r.PostFormValue("tid"))
    41  	if err != nil {
    42  		return c.PreErrorJSQ("Failed to convert the Topic ID", w, r, js)
    43  	}
    44  	topic, err := c.Topics.Get(tid)
    45  	if err == sql.ErrNoRows {
    46  		return c.PreErrorJSQ("Couldn't find the parent topic", w, r, js)
    47  	} else if err != nil {
    48  		return c.InternalErrorJSQ(err, w, r, js)
    49  	}
    50  
    51  	// TODO: Add hooks to make use of headerLite
    52  	lite, ferr := c.SimpleForumUserCheck(w, r, user, topic.ParentID)
    53  	if ferr != nil {
    54  		return ferr
    55  	}
    56  	if !user.Perms.ViewTopic || !user.Perms.CreateReply {
    57  		return c.NoPermissionsJSQ(w, r, user, js)
    58  	}
    59  	if topic.IsClosed && !user.Perms.CloseTopic {
    60  		return c.NoPermissionsJSQ(w, r, user, js)
    61  	}
    62  
    63  	content := c.PreparseMessage(r.PostFormValue("content"))
    64  	// TODO: Fully parse the post and put that in the parsed column
    65  	rid, err := c.Rstore.Create(topic, content, user.GetIP(), user.ID)
    66  	if err != nil {
    67  		return c.InternalErrorJSQ(err, w, r, js)
    68  	}
    69  
    70  	reply, err := c.Rstore.Get(rid)
    71  	if err != nil {
    72  		return c.LocalErrorJSQ("Unable to load the reply", w, r, user, js)
    73  	}
    74  
    75  	// Handle the file attachments
    76  	// TODO: Stop duplicating this code
    77  	if user.Perms.UploadFiles {
    78  		_, rerr := uploadAttachment(w, r, user, topic.ParentID, "forums", rid, "replies", strconv.Itoa(topic.ID))
    79  		if rerr != nil {
    80  			return rerr
    81  		}
    82  	}
    83  
    84  	if r.PostFormValue("has_poll") == "1" {
    85  		maxPollOptions := 10
    86  		pollInputItems := make(map[int]string)
    87  		for key, values := range r.Form {
    88  			//c.DebugDetail("key: ", key)
    89  			//c.DebugDetailf("values: %+v\n", values)
    90  			if !strings.HasPrefix(key, "pollinputitem[") {
    91  				continue
    92  			}
    93  			halves := strings.Split(key, "[")
    94  			if len(halves) != 2 {
    95  				return c.LocalErrorJSQ("Malformed pollinputitem", w, r, user, js)
    96  			}
    97  			halves[1] = strings.TrimSuffix(halves[1], "]")
    98  
    99  			index, err := strconv.Atoi(halves[1])
   100  			if err != nil {
   101  				return c.LocalErrorJSQ("Malformed pollinputitem", w, r, user, js)
   102  			}
   103  			for _, value := range values {
   104  				// If there are duplicates, then something has gone horribly wrong, so let's ignore them, this'll likely happen during an attack
   105  				_, exists := pollInputItems[index]
   106  				// TODO: Should we use SanitiseBody instead to keep the newlines?
   107  				if !exists && len(c.SanitiseSingleLine(value)) != 0 {
   108  					pollInputItems[index] = c.SanitiseSingleLine(value)
   109  					if len(pollInputItems) >= maxPollOptions {
   110  						break
   111  					}
   112  				}
   113  			}
   114  		}
   115  
   116  		// Make sure the indices are sequential to avoid out of bounds issues
   117  		seqPollInputItems := make(map[int]string)
   118  		for i := 0; i < len(pollInputItems); i++ {
   119  			seqPollInputItems[i] = pollInputItems[i]
   120  		}
   121  
   122  		pollType := 0 // Basic single choice
   123  		_, err := c.Polls.Create(reply, pollType, seqPollInputItems)
   124  		if err != nil {
   125  			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?
   126  		}
   127  	}
   128  	_ = c.Rstore.GetCache().Remove(reply.ID)
   129  
   130  	err = c.Forums.UpdateLastTopic(tid, user.ID, topic.ParentID)
   131  	if err != nil && err != sql.ErrNoRows {
   132  		return c.InternalErrorJSQ(err, w, r, js)
   133  	}
   134  
   135  	c.AddActivityAndNotifyAll(c.Alert{ActorID: user.ID, TargetUserID: topic.CreatedBy, Event: "reply", ElementType: "topic", ElementID: tid, Extra: strconv.Itoa(rid)})
   136  	if err != nil {
   137  		return c.InternalErrorJSQ(err, w, r, js)
   138  	}
   139  
   140  	err = user.IncreasePostStats(c.WordCount(content), false)
   141  	if err != nil {
   142  		return c.InternalErrorJSQ(err, w, r, js)
   143  	}
   144  
   145  	nTopic, err := c.Topics.Get(tid)
   146  	if err == sql.ErrNoRows {
   147  		return c.PreErrorJSQ("Couldn't find the parent topic", w, r, js)
   148  	} else if err != nil {
   149  		return c.InternalErrorJSQ(err, w, r, js)
   150  	}
   151  	page := c.LastPage(nTopic.PostCount, c.Config.ItemsPerPage)
   152  
   153  	rows, err := replyStmts.createReplyPaging.Query(reply.ID, topic.ID)
   154  	if err != nil && err != sql.ErrNoRows {
   155  		return c.InternalErrorJSQ(err, w, r, js)
   156  	}
   157  	defer rows.Close()
   158  
   159  	var rids []int
   160  	for rows.Next() {
   161  		var rid int
   162  		if err := rows.Scan(&rid); err != nil {
   163  			return c.InternalErrorJSQ(err, w, r, js)
   164  		}
   165  		rids = append(rids, rid)
   166  	}
   167  	if err := rows.Err(); err != nil {
   168  		return c.InternalErrorJSQ(err, w, r, js)
   169  	}
   170  	if len(rids) == 0 {
   171  		return c.NotFoundJSQ(w, r, nil, js)
   172  	}
   173  
   174  	if page > 1 {
   175  		var offset int
   176  		if rids[0] == reply.ID {
   177  			offset = 1
   178  		} else if len(rids) == 2 && rids[1] == reply.ID {
   179  			offset = 2
   180  		}
   181  		page = c.LastPage(nTopic.PostCount-(len(rids)+offset), c.Config.ItemsPerPage)
   182  	}
   183  
   184  	counters.PostCounter.Bump()
   185  	skip, rerr := lite.Hooks.VhookSkippable("action_end_create_reply", reply.ID, user)
   186  	if skip || rerr != nil {
   187  		return rerr
   188  	}
   189  
   190  	prid, _ := strconv.Atoi(r.FormValue("prid"))
   191  	if js && (prid == 0 || rids[0] == prid) {
   192  		outBytes, err := json.Marshal(JsonReply{c.ParseMessage(reply.Content, topic.ParentID, "forums", user.ParseSettings, user)})
   193  		if err != nil {
   194  			return c.InternalErrorJSQ(err, w, r, js)
   195  		}
   196  		w.Write(outBytes)
   197  	} else {
   198  		var spage string
   199  		if page > 1 {
   200  			spage = "?page=" + strconv.Itoa(page)
   201  		}
   202  		http.Redirect(w, r, "/topic/"+strconv.Itoa(tid)+spage+"#post-"+strconv.Itoa(reply.ID), http.StatusSeeOther)
   203  	}
   204  	return nil
   205  }
   206  
   207  // TODO: Disable stat updates in posts handled by plugin_guilds
   208  // TODO: Update the stats after edits so that we don't under or over decrement stats during deletes
   209  func ReplyEditSubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {
   210  	js := r.PostFormValue("js") == "1"
   211  	reply, topic, lite, ferr := ReplyActPre(w, r, u, srid, js)
   212  	if ferr != nil {
   213  		return ferr
   214  	}
   215  	if !u.Perms.ViewTopic || !u.Perms.EditReply {
   216  		return c.NoPermissionsJSQ(w, r, u, js)
   217  	}
   218  	if topic.IsClosed && !u.Perms.CloseTopic {
   219  		return c.NoPermissionsJSQ(w, r, u, js)
   220  	}
   221  
   222  	err := reply.SetPost(r.PostFormValue("edit_item"))
   223  	if err == sql.ErrNoRows {
   224  		return c.PreErrorJSQ("The parent topic doesn't exist.", w, r, js)
   225  	} else if err != nil {
   226  		return c.InternalErrorJSQ(err, w, r, js)
   227  	}
   228  	if !c.Rstore.Exists(reply.ID) {
   229  		return c.PreErrorJSQ("The updated reply doesn't exist.", w, r, js)
   230  	}
   231  
   232  	skip, rerr := lite.Hooks.VhookSkippable("action_end_edit_reply", reply.ID, u)
   233  	if skip || rerr != nil {
   234  		return rerr
   235  	}
   236  
   237  	if !js {
   238  		http.Redirect(w, r, "/topic/"+strconv.Itoa(topic.ID)+"#reply-"+strconv.Itoa(reply.ID), http.StatusSeeOther)
   239  	} else {
   240  		outBytes, err := json.Marshal(JsonReply{c.ParseMessage(reply.Content, topic.ParentID, "forums", u.ParseSettings, u)})
   241  		if err != nil {
   242  			return c.InternalErrorJSQ(err, w, r, js)
   243  		}
   244  		w.Write(outBytes)
   245  	}
   246  
   247  	return nil
   248  }
   249  
   250  // TODO: Refactor this
   251  // TODO: Disable stat updates in posts handled by plugin_guilds
   252  func ReplyDeleteSubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {
   253  	js := r.PostFormValue("js") == "1"
   254  	reply, _, lite, ferr := ReplyActPre(w, r, u, srid, js)
   255  	if ferr != nil {
   256  		return ferr
   257  	}
   258  	if reply.CreatedBy != u.ID {
   259  		if !u.Perms.ViewTopic || !u.Perms.DeleteReply {
   260  			return c.NoPermissionsJSQ(w, r, u, js)
   261  		}
   262  	}
   263  	if e := reply.Delete(); e != nil {
   264  		return c.InternalErrorJSQ(e, w, r, js)
   265  	}
   266  
   267  	skip, rerr := lite.Hooks.VhookSkippable("action_end_delete_reply", reply.ID, u)
   268  	if skip || rerr != nil {
   269  		return rerr
   270  	}
   271  
   272  	//log.Printf("Reply #%d was deleted by c.User #%d", rid, u.ID)
   273  	if !js {
   274  		http.Redirect(w, r, "/topic/"+strconv.Itoa(reply.ParentID), http.StatusSeeOther)
   275  	} else {
   276  		w.Write(successJSONBytes)
   277  	}
   278  
   279  	// ? - What happens if an error fires after a redirect...?
   280  	/*creator, e := c.Users.Get(reply.CreatedBy)
   281  	if e == nil {
   282  		e = creator.DecreasePostStats(c.WordCount(reply.Content), false)
   283  		if e != nil {
   284  			return c.InternalErrorJSQ(e, w, r, js)
   285  		}
   286  	} else if e != sql.ErrNoRows {
   287  		return c.InternalErrorJSQ(e, w, r, js)
   288  	}*/
   289  
   290  	e := c.ModLogs.Create("delete", reply.ParentID, "reply", u.GetIP(), u.ID)
   291  	if e != nil {
   292  		return c.InternalErrorJSQ(e, w, r, js)
   293  	}
   294  	return nil
   295  }
   296  
   297  // 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
   298  // TODO: Enforce the max request limit on all of this topic's attachments
   299  // TODO: Test this route
   300  func AddAttachToReplySubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {
   301  	reply, topic, lite, ferr := ReplyActPre(w, r, u, srid, true)
   302  	if ferr != nil {
   303  		return ferr
   304  	}
   305  	if !u.Perms.ViewTopic || !u.Perms.EditReply || !u.Perms.UploadFiles {
   306  		return c.NoPermissionsJS(w, r, u)
   307  	}
   308  	if topic.IsClosed && !u.Perms.CloseTopic {
   309  		return c.NoPermissionsJS(w, r, u)
   310  	}
   311  
   312  	// Handle the file attachments
   313  	pathMap, rerr := uploadAttachment(w, r, u, topic.ParentID, "forums", reply.ID, "replies", strconv.Itoa(topic.ID))
   314  	if rerr != nil {
   315  		// TODO: This needs to be a JS error...
   316  		return rerr
   317  	}
   318  	if len(pathMap) == 0 {
   319  		return c.InternalErrorJS(errors.New("no paths for attachment add"), w, r)
   320  	}
   321  
   322  	skip, rerr := lite.Hooks.VhookSkippable("action_end_add_attach_to_reply", reply.ID, u)
   323  	if skip || rerr != nil {
   324  		return rerr
   325  	}
   326  
   327  	var elemStr string
   328  	for path, aids := range pathMap {
   329  		elemStr += "\"" + path + "\":\"" + aids + "\","
   330  	}
   331  	if len(elemStr) > 1 {
   332  		elemStr = elemStr[:len(elemStr)-1]
   333  	}
   334  
   335  	w.Write([]byte(`{"success":1,"elems":{` + elemStr + `}}`))
   336  	return nil
   337  }
   338  
   339  // TODO: Reduce the amount of duplication between this and RemoveAttachFromTopicSubmit
   340  func RemoveAttachFromReplySubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {
   341  	reply, topic, lite, ferr := ReplyActPre(w, r, u, srid, true)
   342  	if ferr != nil {
   343  		return ferr
   344  	}
   345  	if !u.Perms.ViewTopic || !u.Perms.EditReply {
   346  		return c.NoPermissionsJS(w, r, u)
   347  	}
   348  	if topic.IsClosed && !u.Perms.CloseTopic {
   349  		return c.NoPermissionsJS(w, r, u)
   350  	}
   351  
   352  	saids := strings.Split(r.PostFormValue("aids"), ",")
   353  	if len(saids) == 0 {
   354  		return c.LocalErrorJS("No aids provided", w, r)
   355  	}
   356  	for _, said := range saids {
   357  		aid, err := strconv.Atoi(said)
   358  		if err != nil {
   359  			return c.LocalErrorJS(p.GetErrorPhrase("id_must_be_integer"), w, r)
   360  		}
   361  		rerr := deleteAttachment(w, r, u, aid, true)
   362  		if rerr != nil {
   363  			// TODO: This needs to be a JS error...
   364  			return rerr
   365  		}
   366  	}
   367  
   368  	skip, rerr := lite.Hooks.VhookSkippable("action_end_remove_attach_from_reply", reply.ID, u)
   369  	if skip || rerr != nil {
   370  		return rerr
   371  	}
   372  
   373  	w.Write(successJSONBytes)
   374  	return nil
   375  }
   376  
   377  func ReplyActPre(w http.ResponseWriter, r *http.Request, u *c.User, srid string, js bool) (rep *c.Reply, t *c.Topic, l *c.HeaderLite, ferr c.RouteError) {
   378  	rid, err := strconv.Atoi(srid)
   379  	if err != nil {
   380  		return rep, t, l, c.PreErrorJSQ("The provided Reply ID is not a valid number.", w, r, js)
   381  	}
   382  	rep, err = c.Rstore.Get(rid)
   383  	if err == sql.ErrNoRows {
   384  		return rep, t, l, c.PreErrorJSQ("The linked reply doesn't exist.", w, r, js)
   385  	} else if err != nil {
   386  		return rep, t, l, c.InternalErrorJSQ(err, w, r, js)
   387  	}
   388  
   389  	t, err = rep.Topic()
   390  	if err == sql.ErrNoRows {
   391  		return rep, t, l, c.PreErrorJSQ("The parent topic doesn't exist.", w, r, js)
   392  	} else if err != nil {
   393  		return rep, t, l, c.InternalErrorJSQ(err, w, r, js)
   394  	}
   395  
   396  	// TODO: Add hooks to make use of headerLite
   397  	l, ferr = c.SimpleForumUserCheck(w, r, u, t.ParentID)
   398  	return rep, t, l, ferr
   399  }
   400  
   401  func ReplyLikeSubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {
   402  	js := r.PostFormValue("js") == "1"
   403  	reply, _, lite, ferr := ReplyActPre(w, r, u, srid, js)
   404  	if ferr != nil {
   405  		return ferr
   406  	}
   407  	if !u.Perms.ViewTopic || !u.Perms.LikeItem {
   408  		return c.NoPermissionsJSQ(w, r, u, js)
   409  	}
   410  	if reply.CreatedBy == u.ID {
   411  		return c.LocalErrorJSQ("You can't like your own replies", w, r, u, js)
   412  	}
   413  
   414  	_, err := c.Users.Get(reply.CreatedBy)
   415  	if err != nil && err != sql.ErrNoRows {
   416  		return c.LocalErrorJSQ("The target user doesn't exist", w, r, u, js)
   417  	} else if err != nil {
   418  		return c.InternalErrorJSQ(err, w, r, js)
   419  	}
   420  
   421  	err = reply.Like(u.ID)
   422  	if err == c.ErrAlreadyLiked {
   423  		return c.LocalErrorJSQ("You've already liked this!", w, r, u, js)
   424  	} else if err != nil {
   425  		return c.InternalErrorJSQ(err, w, r, js)
   426  	}
   427  
   428  	// ! Be careful about leaking per-route permission state with user ptr
   429  	alert := c.Alert{ActorID: u.ID, TargetUserID: reply.CreatedBy, Event: "like", ElementType: "post", ElementID: reply.ID, Actor: u}
   430  	err = c.AddActivityAndNotifyTarget(alert)
   431  	if err != nil {
   432  		return c.InternalErrorJSQ(err, w, r, js)
   433  	}
   434  
   435  	skip, rerr := lite.Hooks.VhookSkippable("action_end_like_reply", reply.ID, u)
   436  	if skip || rerr != nil {
   437  		return rerr
   438  	}
   439  	return actionSuccess(w, r, "/topic/"+strconv.Itoa(reply.ParentID), js)
   440  }
   441  
   442  func ReplyUnlikeSubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {
   443  	js := r.PostFormValue("js") == "1"
   444  	reply, _, lite, ferr := ReplyActPre(w, r, u, srid, js)
   445  	if ferr != nil {
   446  		return ferr
   447  	}
   448  	if !u.Perms.ViewTopic || !u.Perms.LikeItem {
   449  		return c.NoPermissionsJSQ(w, r, u, js)
   450  	}
   451  
   452  	_, err := c.Users.Get(reply.CreatedBy)
   453  	if err != nil && err != sql.ErrNoRows {
   454  		return c.LocalErrorJSQ("The target user doesn't exist", w, r, u, js)
   455  	} else if err != nil {
   456  		return c.InternalErrorJSQ(err, w, r, js)
   457  	}
   458  	err = reply.Unlike(u.ID)
   459  	if err != nil {
   460  		return c.InternalErrorJSQ(err, w, r, js)
   461  	}
   462  
   463  	// TODO: Better coupling between the two params queries
   464  	aids, err := c.Activity.AidsByParams("like", reply.ID, "post")
   465  	if err != nil {
   466  		return c.InternalErrorJSQ(err, w, r, js)
   467  	}
   468  	for _, aid := range aids {
   469  		c.DismissAlert(reply.CreatedBy, aid)
   470  	}
   471  	err = c.Activity.DeleteByParams("like", reply.ID, "post")
   472  	if err != nil {
   473  		return c.InternalErrorJSQ(err, w, r, js)
   474  	}
   475  
   476  	skip, rerr := lite.Hooks.VhookSkippable("action_end_unlike_reply", reply.ID, u)
   477  	if skip || rerr != nil {
   478  		return rerr
   479  	}
   480  	return actionSuccess(w, r, "/topic/"+strconv.Itoa(reply.ParentID), js)
   481  }