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 }