github.com/Azareal/Gosora@v0.0.0-20210729070923-553e66b59003/common/topic.go (about) 1 /* 2 * 3 * Gosora Topic File 4 * Copyright Azareal 2017 - 2020 5 * 6 */ 7 package common 8 9 import ( 10 "database/sql" 11 "html" 12 "html/template" 13 14 "strconv" 15 "strings" 16 "time" 17 18 //"log" 19 20 p "github.com/Azareal/Gosora/common/phrases" 21 qgen "github.com/Azareal/Gosora/query_gen" 22 ) 23 24 // This is also in reply.go 25 //var ErrAlreadyLiked = errors.New("This item was already liked by this user") 26 27 // ? - Add a TopicMeta struct for *Forums? 28 29 type Topic struct { 30 ID int 31 Link string 32 Title string 33 Content string 34 CreatedBy int 35 IsClosed bool 36 Sticky bool 37 CreatedAt time.Time 38 LastReplyAt time.Time 39 LastReplyBy int 40 LastReplyID int 41 ParentID int 42 Status string // Deprecated. Marked for removal. -Is there anything we could use it for? 43 IP string 44 ViewCount int64 45 PostCount int 46 LikeCount int 47 AttachCount int 48 WeekViews int 49 ClassName string // CSS Class Name 50 Poll int 51 Data string // Used for report metadata 52 53 Rids []int 54 } 55 56 type TopicUser struct { 57 ID int 58 Link string 59 Title string 60 Content string // TODO: Avoid converting this to bytes in templates, particularly if it's long 61 CreatedBy int 62 IsClosed bool 63 Sticky bool 64 CreatedAt time.Time 65 LastReplyAt time.Time 66 LastReplyBy int 67 LastReplyID int 68 ParentID int 69 Status string // Deprecated. Marked for removal. 70 IP string 71 ViewCount int64 72 PostCount int 73 LikeCount int 74 AttachCount int 75 ClassName string 76 Poll int 77 Data string // Used for report metadata 78 79 UserLink string 80 CreatedByName string 81 Group int 82 Avatar string 83 MicroAvatar string 84 ContentLines int 85 ContentHTML string // TODO: Avoid converting this to bytes in templates, particularly if it's long 86 Tag string 87 URL string 88 //URLPrefix string 89 //URLName string 90 Level int 91 Liked bool 92 93 Attachments []*MiniAttachment 94 Rids []int 95 Deletable bool 96 } 97 98 type TopicsRowMut struct { 99 *TopicsRow 100 CanMod bool 101 } 102 103 // TODO: Embed TopicUser to simplify this structure and it's related logic? 104 type TopicsRow struct { 105 Topic 106 LastPage int 107 108 Creator *User 109 CSS template.CSS 110 ContentLines int 111 LastUser *User 112 113 ForumName string //TopicsRow 114 ForumLink string 115 } 116 117 type WsTopicsRow struct { 118 ID int 119 Link string 120 Title string 121 CreatedBy int 122 IsClosed bool 123 Sticky bool 124 CreatedAt time.Time 125 LastReplyAt time.Time 126 RelativeLastReplyAt string 127 LastReplyBy int 128 LastReplyID int 129 ParentID int 130 ViewCount int64 131 PostCount int 132 LikeCount int 133 AttachCount int 134 ClassName string 135 Creator *WsJSONUser 136 LastUser *WsJSONUser 137 ForumName string 138 ForumLink string 139 CanMod bool 140 } 141 142 // TODO: Can we get the client side to render the relative times instead? 143 func (r *TopicsRow) WebSockets() *WsTopicsRow { 144 return &WsTopicsRow{r.ID, r.Link, r.Title, r.CreatedBy, r.IsClosed, r.Sticky, r.CreatedAt, r.LastReplyAt, RelativeTime(r.LastReplyAt), r.LastReplyBy, r.LastReplyID, r.ParentID, r.ViewCount, r.PostCount, r.LikeCount, r.AttachCount, r.ClassName, r.Creator.WebSockets(), r.LastUser.WebSockets(), r.ForumName, r.ForumLink, false} 145 } 146 147 // TODO: Can we get the client side to render the relative times instead? 148 func (r *TopicsRow) WebSockets2(canMod bool) *WsTopicsRow { 149 return &WsTopicsRow{r.ID, r.Link, r.Title, r.CreatedBy, r.IsClosed, r.Sticky, r.CreatedAt, r.LastReplyAt, RelativeTime(r.LastReplyAt), r.LastReplyBy, r.LastReplyID, r.ParentID, r.ViewCount, r.PostCount, r.LikeCount, r.AttachCount, r.ClassName, r.Creator.WebSockets(), r.LastUser.WebSockets(), r.ForumName, r.ForumLink, canMod} 150 } 151 152 // TODO: Stop relying on so many struct types? 153 // ! Not quite safe as Topic doesn't contain all the data needed to constructs a TopicsRow 154 func (t *Topic) TopicsRow() *TopicsRow { 155 lastPage := 1 156 var creator *User = nil 157 contentLines := 1 158 var lastUser *User = nil 159 forumName := "" 160 forumLink := "" 161 162 //return &TopicsRow{t.ID, t.Link, t.Title, t.Content, t.CreatedBy, t.IsClosed, t.Sticky, t.CreatedAt, t.LastReplyAt, t.LastReplyBy, t.LastReplyID, t.ParentID, t.Status, t.IP, t.ViewCount, t.PostCount, t.LikeCount, t.AttachCount, lastPage, t.ClassName, t.Poll, t.Data, creator, "", contentLines, lastUser, forumName, forumLink, t.Rids} 163 return &TopicsRow{*t, lastPage, creator, "", contentLines, lastUser, forumName, forumLink} 164 } 165 166 // ! Some data may be lost in the conversion 167 /*func (t *TopicsRow) Topic() *Topic { 168 //return &Topic{t.ID, t.Link, t.Title, t.Content, t.CreatedBy, t.IsClosed, t.Sticky, t.CreatedAt, t.LastReplyAt, t.LastReplyBy, t.LastReplyID, t.ParentID, t.Status, t.IP, t.ViewCount, t.PostCount, t.LikeCount, t.AttachCount, t.ClassName, t.Poll, t.Data, t.Rids} 169 return &t.Topic 170 }*/ 171 172 // ! Not quite safe as Topic doesn't contain all the data needed to constructs a WsTopicsRow 173 /*func (t *Topic) WsTopicsRows() *WsTopicsRow { 174 var creator *User = nil 175 var lastUser *User = nil 176 forumName := "" 177 forumLink := "" 178 return &WsTopicsRow{t.ID, t.Link, t.Title, t.CreatedBy, t.IsClosed, t.Sticky, t.CreatedAt, t.LastReplyAt, RelativeTime(t.LastReplyAt), t.LastReplyBy, t.LastReplyID, t.ParentID, t.ViewCount, t.PostCount, t.LikeCount, t.AttachCount, t.ClassName, creator, lastUser, forumName, forumLink} 179 }*/ 180 181 type TopicStmts struct { 182 getRids *sql.Stmt 183 getReplies *sql.Stmt 184 getReplies2 *sql.Stmt 185 getReplies3 *sql.Stmt 186 addReplies *sql.Stmt 187 updateLastReply *sql.Stmt 188 lock *sql.Stmt 189 unlock *sql.Stmt 190 moveTo *sql.Stmt 191 stick *sql.Stmt 192 unstick *sql.Stmt 193 hasLikedTopic *sql.Stmt 194 createLike *sql.Stmt 195 addLikesToTopic *sql.Stmt 196 delete *sql.Stmt 197 deleteReplies *sql.Stmt 198 deleteLikesForTopic *sql.Stmt 199 deleteActivity *sql.Stmt 200 edit *sql.Stmt 201 setPoll *sql.Stmt 202 removePoll *sql.Stmt 203 testSetCreatedAt *sql.Stmt 204 createAction *sql.Stmt 205 206 getTopicUser *sql.Stmt // TODO: Can we get rid of this? 207 getByReplyID *sql.Stmt 208 } 209 210 var topicStmts TopicStmts 211 212 func init() { 213 DbInits.Add(func(acc *qgen.Accumulator) error { 214 t, w := "topics", "tid=?" 215 set := func(s string) *sql.Stmt { 216 return acc.Update(t).Set(s).Where(w).Prepare() 217 } 218 topicStmts = TopicStmts{ 219 getRids: acc.Select("replies").Columns("rid").Where(w).Orderby("rid ASC").Limit("?,?").Prepare(), 220 getReplies: acc.SimpleLeftJoin("replies AS r", "users AS u", "r.rid, r.content, r.createdBy, r.createdAt, r.lastEdit, r.lastEditBy, u.avatar, u.name, u.group, u.level, r.ip, r.likeCount, r.attachCount, r.actionType", "r.createdBy=u.uid", "r.tid=?", "r.rid ASC", "?,?"), 221 getReplies2: acc.SimpleLeftJoin("replies AS r", "users AS u", "r.rid, r.content, r.createdBy, r.createdAt, r.lastEdit, r.lastEditBy, u.avatar, u.name, u.group, u.level, r.likeCount, r.attachCount, r.actionType", "r.createdBy=u.uid", "r.tid=?", "r.rid ASC", "?,?"), 222 getReplies3: acc.Select("replies").Columns("rid,content,createdBy,createdAt,lastEdit,lastEditBy,likeCount,attachCount,actionType").Where(w).Orderby("rid ASC").Limit("?,?").Prepare(), 223 addReplies: set("postCount=postCount+?,lastReplyBy=?,lastReplyAt=UTC_TIMESTAMP()"), 224 updateLastReply: acc.Update(t).Set("lastReplyID=?").Where("lastReplyID<? AND tid=?").Prepare(), 225 lock: set("is_closed=1"), 226 unlock: set("is_closed=0"), 227 moveTo: set("parentID=?"), 228 stick: set("sticky=1"), 229 unstick: set("sticky=0"), 230 hasLikedTopic: acc.Select("likes").Columns("targetItem").Where("sentBy=? and targetItem=? and targetType='topics'").Prepare(), 231 createLike: acc.Insert("likes").Columns("weight,targetItem,targetType,sentBy,createdAt").Fields("?,?,?,?,UTC_TIMESTAMP()").Prepare(), 232 addLikesToTopic: set("likeCount=likeCount+?"), 233 delete: acc.Delete(t).Where(w).Prepare(), 234 deleteReplies: acc.Delete("replies").Where(w).Prepare(), 235 deleteLikesForTopic: acc.Delete("likes").Where("targetItem=? AND targetType='topics'").Prepare(), 236 deleteActivity: acc.Delete("activity_stream").Where("elementID=? AND elementType='topic'").Prepare(), 237 edit: set("title=?,content=?,parsed_content=?"), // TODO: Only run the content update bits on non-polls, does this matter? 238 setPoll: acc.Update(t).Set("poll=?").Where("tid=? AND poll=0").Prepare(), 239 removePoll: acc.Update(t).Set("poll=0").Where("tid=?").Prepare(), 240 testSetCreatedAt: set("createdAt=?"), 241 createAction: acc.Insert("replies").Columns("tid,actionType,ip,createdBy,createdAt,lastUpdated,content,parsed_content").Fields("?,?,?,?,UTC_TIMESTAMP(),UTC_TIMESTAMP(),'',''").Prepare(), 242 243 getTopicUser: acc.SimpleLeftJoin("topics AS t", "users AS u", "t.title, t.content, t.createdBy, t.createdAt, t.lastReplyAt, t.lastReplyBy, t.lastReplyID, t.is_closed, t.sticky, t.parentID, t.ip, t.views, t.postCount, t.likeCount, t.attachCount,t.poll, u.name, u.avatar, u.group, u.level", "t.createdBy=u.uid", w, "", ""), 244 getByReplyID: acc.SimpleLeftJoin("replies AS r", "topics AS t", "t.tid, t.title, t.content, t.createdBy, t.createdAt, t.is_closed, t.sticky, t.parentID, t.ip, t.views, t.postCount, t.likeCount, t.poll, t.data", "r.tid=t.tid", "rid=?", "", ""), 245 } 246 return acc.FirstError() 247 }) 248 } 249 250 // Flush the topic out of the cache 251 // ? - We do a CacheRemove() here instead of mutating the pointer to avoid creating a race condition 252 func (t *Topic) cacheRemove() { 253 if tc := Topics.GetCache(); tc != nil { 254 tc.Remove(t.ID) 255 } 256 TopicListThaw.Thaw() 257 } 258 259 // TODO: Write a test for this 260 func (t *Topic) AddReply(rid, uid int) (e error) { 261 _, e = topicStmts.addReplies.Exec(1, uid, t.ID) 262 if e != nil { 263 return e 264 } 265 _, e = topicStmts.updateLastReply.Exec(rid, rid, t.ID) 266 t.cacheRemove() 267 return e 268 } 269 270 func (t *Topic) Lock() (e error) { 271 _, e = topicStmts.lock.Exec(t.ID) 272 t.cacheRemove() 273 return e 274 } 275 276 func (t *Topic) Unlock() (e error) { 277 _, e = topicStmts.unlock.Exec(t.ID) 278 t.cacheRemove() 279 return e 280 } 281 282 func (t *Topic) MoveTo(destForum int) (e error) { 283 _, e = topicStmts.moveTo.Exec(destForum, t.ID) 284 t.cacheRemove() 285 if e != nil { 286 return e 287 } 288 e = Attachments.MoveTo(destForum, t.ID, "topics") 289 if e != nil { 290 return e 291 } 292 return Attachments.MoveToByExtra(destForum, "replies", strconv.Itoa(t.ID)) 293 } 294 295 func (t *Topic) TestSetCreatedAt(s time.Time) (e error) { 296 _, e = topicStmts.testSetCreatedAt.Exec(s, t.ID) 297 t.cacheRemove() 298 return e 299 } 300 301 // TODO: We might want more consistent terminology rather than using stick in some places and pin in others. If you don't understand the difference, there is none, they are one and the same. 302 func (t *Topic) Stick() (e error) { 303 _, e = topicStmts.stick.Exec(t.ID) 304 t.cacheRemove() 305 return e 306 } 307 308 func (t *Topic) Unstick() (e error) { 309 _, e = topicStmts.unstick.Exec(t.ID) 310 t.cacheRemove() 311 return e 312 } 313 314 // TODO: Test this 315 // TODO: Use a transaction for this 316 func (t *Topic) Like(score, uid int) (err error) { 317 var disp int // Unused 318 err = topicStmts.hasLikedTopic.QueryRow(uid, t.ID).Scan(&disp) 319 if err != nil && err != ErrNoRows { 320 return err 321 } else if err != ErrNoRows { 322 return ErrAlreadyLiked 323 } 324 _, err = topicStmts.createLike.Exec(score, t.ID, "topics", uid) 325 if err != nil { 326 return err 327 } 328 _, err = topicStmts.addLikesToTopic.Exec(1, t.ID) 329 if err != nil { 330 return err 331 } 332 _, err = userStmts.incLiked.Exec(1, uid) 333 t.cacheRemove() 334 return err 335 } 336 337 // TODO: Use a transaction 338 func (t *Topic) Unlike(uid int) error { 339 e := Likes.Delete(t.ID, "topics") 340 if e != nil { 341 return e 342 } 343 _, e = topicStmts.addLikesToTopic.Exec(-1, t.ID) 344 if e != nil { 345 return e 346 } 347 _, e = userStmts.decLiked.Exec(1, uid) 348 t.cacheRemove() 349 return e 350 } 351 352 func handleLikedTopicReplies(tid int) error { 353 rows, e := userStmts.getLikedRepliesOfTopic.Query(tid) 354 if e != nil { 355 return e 356 } 357 defer rows.Close() 358 for rows.Next() { 359 var rid int 360 if e := rows.Scan(&rid); e != nil { 361 return e 362 } 363 _, e = replyStmts.deleteLikesForReply.Exec(rid) 364 if e != nil { 365 return e 366 } 367 e = Activity.DeleteByParams("like", rid, "post") 368 if e != nil { 369 return e 370 } 371 } 372 return rows.Err() 373 } 374 375 func handleTopicAttachments(tid int) error { 376 e := handleAttachments(userStmts.getAttachmentsOfTopic, tid) 377 if e != nil { 378 return e 379 } 380 return handleAttachments(userStmts.getAttachmentsOfTopic2, tid) 381 } 382 383 func handleReplyAttachments(rid int) error { 384 return handleAttachments(replyStmts.getAidsOfReply, rid) 385 } 386 387 func handleAttachments(stmt *sql.Stmt, id int) error { 388 rows, e := stmt.Query(id) 389 if e != nil { 390 return e 391 } 392 defer rows.Close() 393 for rows.Next() { 394 var aid int 395 if e := rows.Scan(&aid); e != nil { 396 return e 397 } 398 a, e := Attachments.FGet(aid) 399 if e != nil { 400 return e 401 } 402 e = deleteAttachment(a) 403 if e != nil && e != sql.ErrNoRows { 404 return e 405 } 406 } 407 return rows.Err() 408 } 409 410 // TODO: Only load a row per createdBy, maybe with group by? 411 func handleTopicReplies(umap map[int]struct{}, uid, tid int) error { 412 rows, e := userStmts.getRepliesOfTopic.Query(uid, tid) 413 if e != nil { 414 return e 415 } 416 defer rows.Close() 417 var createdBy int 418 for rows.Next() { 419 if e := rows.Scan(&createdBy); e != nil { 420 return e 421 } 422 umap[createdBy] = struct{}{} 423 } 424 return rows.Err() 425 } 426 427 // TODO: Use a transaction here 428 func (t *Topic) Delete() error { 429 /*creator, e := Users.Get(t.CreatedBy) 430 if e == nil { 431 e = creator.DecreasePostStats(WordCount(t.Content), true) 432 if e != nil { 433 return e 434 } 435 } else if e != ErrNoRows { 436 return e 437 }*/ 438 439 // TODO: Clear reply cache too 440 _, e := topicStmts.delete.Exec(t.ID) 441 t.cacheRemove() 442 if e != nil { 443 return e 444 } 445 e = Forums.RemoveTopic(t.ParentID) 446 if e != nil && e != ErrNoRows { 447 return e 448 } 449 _, e = topicStmts.deleteLikesForTopic.Exec(t.ID) 450 if e != nil { 451 return e 452 } 453 454 if t.PostCount > 1 { 455 if e = handleLikedTopicReplies(t.ID); e != nil { 456 return e 457 } 458 umap := make(map[int]struct{}) 459 e = handleTopicReplies(umap, t.CreatedBy, t.ID) 460 if e != nil { 461 return e 462 } 463 _, e = topicStmts.deleteReplies.Exec(t.ID) 464 if e != nil { 465 return e 466 } 467 for uid := range umap { 468 e = (&User{ID: uid}).RecalcPostStats() 469 if e != nil { 470 //log.Printf("e: %+v\n", e) 471 return e 472 } 473 } 474 } 475 e = (&User{ID: t.CreatedBy}).RecalcPostStats() 476 if e != nil { 477 return e 478 } 479 e = handleTopicAttachments(t.ID) 480 if e != nil { 481 return e 482 } 483 e = Subscriptions.DeleteResource(t.ID, "topic") 484 if e != nil { 485 return e 486 } 487 _, e = topicStmts.deleteActivity.Exec(t.ID) 488 if e != nil { 489 return e 490 } 491 if t.Poll > 0 { 492 e = (&Poll{ID: t.Poll}).Delete() 493 if e != nil { 494 return e 495 } 496 } 497 return nil 498 } 499 500 // TODO: Write tests for this 501 func (t *Topic) Update(name, content string) error { 502 name = SanitiseSingleLine(html.UnescapeString(name)) 503 if name == "" { 504 return ErrNoTitle 505 } 506 // ? This number might be a little screwy with Unicode, but it's the only consistent thing we have, as Unicode characters can be any number of bytes in theory? 507 if len(name) > Config.MaxTopicTitleLength { 508 return ErrLongTitle 509 } 510 511 content = PreparseMessage(html.UnescapeString(content)) 512 parsedContent := ParseMessage(content, t.ParentID, "forums", nil, nil) 513 _, err := topicStmts.edit.Exec(name, content, parsedContent, t.ID) 514 t.cacheRemove() 515 return err 516 } 517 518 func (t *Topic) SetPoll(pollID int) error { 519 _, e := topicStmts.setPoll.Exec(pollID, t.ID) // TODO: Sniff if this changed anything to see if we hit an existing poll 520 t.cacheRemove() 521 return e 522 } 523 524 func (t *Topic) RemovePoll() error { 525 _, e := topicStmts.removePoll.Exec(t.ID) // TODO: Sniff if this changed anything to see if we hit an existing poll 526 t.cacheRemove() 527 return e 528 } 529 530 // TODO: Have this go through the ReplyStore? 531 // TODO: Return the rid? 532 func (t *Topic) CreateActionReply(action, ip string, uid int) (err error) { 533 if Config.DisablePostIP { 534 ip = "" 535 } 536 res, err := topicStmts.createAction.Exec(t.ID, action, ip, uid) 537 if err != nil { 538 return err 539 } 540 _, err = topicStmts.addReplies.Exec(1, uid, t.ID) 541 if err != nil { 542 return err 543 } 544 lid, err := res.LastInsertId() 545 if err != nil { 546 return err 547 } 548 rid := int(lid) 549 _, err = topicStmts.updateLastReply.Exec(rid, rid, t.ID) 550 t.cacheRemove() 551 // ? - Update the last topic cache for the parent forum? 552 return err 553 } 554 555 func GetRidsForTopic(tid, offset int) (rids []int, e error) { 556 rows, e := topicStmts.getRids.Query(tid, offset, Config.ItemsPerPage) 557 if e != nil { 558 return nil, e 559 } 560 defer rows.Close() 561 var rid int 562 for rows.Next() { 563 if e := rows.Scan(&rid); e != nil { 564 return nil, e 565 } 566 rids = append(rids, rid) 567 } 568 return rids, rows.Err() 569 } 570 571 var aipost = ";︎" 572 var lockai = "🔒" + aipost 573 var unlockai = "🔓" 574 var stickai = "📌" 575 var unstickai = "📌" + aipost 576 577 func (ru *ReplyUser) Init(u *User) (group *Group, err error) { 578 ru.ContentLines = strings.Count(ru.Content, "\n") 579 580 postGroup, err := Groups.Get(ru.Group) 581 if err != nil { 582 return nil, err 583 } 584 if postGroup.IsMod { 585 ru.ClassName = Config.StaffCSS 586 } 587 ru.Tag = postGroup.Tag 588 589 if u.ID != ru.CreatedBy { 590 ru.UserLink = BuildProfileURL(NameToSlug(ru.CreatedByName), ru.CreatedBy) 591 // TODO: Make a function for this? Build a more sophisticated noavatar handling system? Do bulk user loads and let the c.UserStore initialise this? 592 ru.Avatar, ru.MicroAvatar = BuildAvatar(ru.CreatedBy, ru.Avatar) 593 } else { 594 ru.UserLink = u.Link 595 ru.Avatar, ru.MicroAvatar = u.Avatar, u.MicroAvatar 596 } 597 598 // We really shouldn't have inline HTML, we should do something about this... 599 if ru.ActionType != "" { 600 aarr := strings.Split(ru.ActionType, "-") 601 action := aarr[0] 602 switch action { 603 case "lock": 604 ru.ActionIcon = lockai 605 case "unlock": 606 ru.ActionIcon = unlockai 607 case "stick": 608 ru.ActionIcon = stickai 609 case "unstick": 610 ru.ActionIcon = unstickai 611 case "move": 612 if len(aarr) == 2 { 613 fid, _ := strconv.Atoi(aarr[1]) 614 forum, err := Forums.Get(fid) 615 if err == nil { 616 ru.ActionType = p.GetTmplPhrasef("topic.action_topic_move_dest", forum.Link, forum.Name, ru.UserLink, ru.CreatedByName) 617 return postGroup, nil 618 } 619 } 620 default: 621 // TODO: Only fire this off if a corresponding phrase for the ActionType doesn't exist? Or maybe have some sort of action registry? 622 ru.ActionType = p.GetTmplPhrasef("topic.action_topic_default", ru.ActionType) 623 return postGroup, nil 624 } 625 ru.ActionType = p.GetTmplPhrasef("topic.action_topic_"+action, ru.UserLink, ru.CreatedByName) 626 } 627 628 return postGroup, nil 629 } 630 631 func (ru *ReplyUser) Init2() (group *Group, err error) { 632 //ru.UserLink = BuildProfileURL(NameToSlug(ru.CreatedByName), ru.CreatedBy) 633 ru.ContentLines = strings.Count(ru.Content, "\n") 634 635 postGroup, err := Groups.Get(ru.Group) 636 if err != nil { 637 return postGroup, err 638 } 639 if postGroup.IsMod { 640 ru.ClassName = Config.StaffCSS 641 } 642 ru.Tag = postGroup.Tag 643 644 // We really shouldn't have inline HTML, we should do something about this... 645 if ru.ActionType != "" { 646 aarr := strings.Split(ru.ActionType, "-") 647 action := aarr[0] 648 switch action { 649 case "lock": 650 ru.ActionIcon = lockai 651 case "unlock": 652 ru.ActionIcon = unlockai 653 case "stick": 654 ru.ActionIcon = stickai 655 case "unstick": 656 ru.ActionIcon = unstickai 657 case "move": 658 if len(aarr) == 2 { 659 fid, _ := strconv.Atoi(aarr[1]) 660 forum, err := Forums.Get(fid) 661 if err == nil { 662 ru.ActionType = p.GetTmplPhrasef("topic.action_topic_move_dest", forum.Link, forum.Name, ru.UserLink, ru.CreatedByName) 663 return postGroup, nil 664 } 665 } 666 default: 667 // TODO: Only fire this off if a corresponding phrase for the ActionType doesn't exist? Or maybe have some sort of action registry? 668 ru.ActionType = p.GetTmplPhrasef("topic.action_topic_default", ru.ActionType) 669 return postGroup, nil 670 } 671 ru.ActionType = p.GetTmplPhrasef("topic.action_topic_"+action, ru.UserLink, ru.CreatedByName) 672 } 673 674 return postGroup, nil 675 } 676 677 func (ru *ReplyUser) Init3(u *User, tu *TopicUser) (group *Group, err error) { 678 ru.ContentLines = strings.Count(ru.Content, "\n") 679 680 postGroup, err := Groups.Get(ru.Group) 681 if err != nil { 682 return postGroup, err 683 } 684 if postGroup.IsMod { 685 ru.ClassName = Config.StaffCSS 686 } 687 ru.Tag = postGroup.Tag 688 689 if u.ID == ru.CreatedBy { 690 ru.UserLink = u.Link 691 ru.Avatar, ru.MicroAvatar = u.Avatar, u.MicroAvatar 692 } else if tu.CreatedBy == ru.CreatedBy { 693 ru.UserLink = tu.UserLink 694 ru.Avatar, ru.MicroAvatar = tu.Avatar, tu.MicroAvatar 695 } else { 696 ru.UserLink = BuildProfileURL(NameToSlug(ru.CreatedByName), ru.CreatedBy) 697 // TODO: Make a function for this? Build a more sophisticated noavatar handling system? Do bulk user loads and let the c.UserStore initialise this? 698 ru.Avatar, ru.MicroAvatar = BuildAvatar(ru.CreatedBy, ru.Avatar) 699 } 700 701 // We really shouldn't have inline HTML, we should do something about this... 702 if ru.ActionType != "" { 703 aarr := strings.Split(ru.ActionType, "-") 704 action := aarr[0] 705 switch action { 706 case "lock": 707 ru.ActionIcon = lockai 708 action = "topic.action_topic_lock" 709 case "unlock": 710 ru.ActionIcon = unlockai 711 action = "topic.action_topic_unlock" 712 case "stick": 713 ru.ActionIcon = stickai 714 action = "topic.action_topic_stick" 715 case "unstick": 716 ru.ActionIcon = unstickai 717 action = "topic.action_topic_unstick" 718 case "move": 719 if len(aarr) == 2 { 720 fid, _ := strconv.Atoi(aarr[1]) 721 forum, err := Forums.Get(fid) 722 if err == nil { 723 ru.ActionType = p.GetTmplPhrasef("topic.action_topic_move_dest", forum.Link, forum.Name, ru.UserLink, ru.CreatedByName) 724 return postGroup, nil 725 } 726 } 727 default: 728 // TODO: Only fire this off if a corresponding phrase for the ActionType doesn't exist? Or maybe have some sort of action registry? 729 ru.ActionType = p.GetTmplPhrasef("topic.action_topic_default", ru.ActionType) 730 return postGroup, nil 731 } 732 ru.ActionType = p.GetTmplPhrasef(action, ru.UserLink, ru.CreatedByName) 733 } 734 735 return postGroup, nil 736 } 737 738 // TODO: Factor TopicUser into a *Topic and *User, as this starting to become overly complicated x.x 739 func (t *TopicUser) Replies(offset int /*pFrag int, */, user *User) (rlist []*ReplyUser /*, ogdesc string*/, externalHead bool, err error) { 740 var likedMap, attachMap map[int]int 741 var likedQueryList, attachQueryList []int 742 743 var rid int 744 if len(t.Rids) > 0 { 745 //log.Print("have rid") 746 rid = t.Rids[0] 747 } 748 re, err := Rstore.GetCache().Get(rid) 749 ucache := Users.GetCache() 750 var ruser *User 751 if ucache != nil { 752 //log.Print("ucache step") 753 if err == nil { 754 ruser = ucache.Getn(re.CreatedBy) 755 } else if t.PostCount == 2 { 756 ruser = ucache.Getn(t.LastReplyBy) 757 } 758 } 759 760 hTbl := GetHookTable() 761 rf := func(r *ReplyUser) (err error) { 762 //log.Printf("before r: %+v\n", r) 763 postGroup, err := r.Init3(user, t) 764 if err != nil { 765 return err 766 } 767 //log.Printf("after r: %+v\n", r) 768 769 var parseSettings *ParseSettings 770 if (Config.NoEmbed || !postGroup.Perms.AutoEmbed) && (user.ParseSettings == nil || !user.ParseSettings.NoEmbed) { 771 parseSettings = DefaultParseSettings.CopyPtr() 772 parseSettings.NoEmbed = true 773 } else { 774 parseSettings = user.ParseSettings 775 } 776 /*if user.ParseSettings == nil { 777 parseSettings = DefaultParseSettings.CopyPtr() 778 parseSettings.NoEmbed = Config.NoEmbed || !postGroup.Perms.AutoEmbed 779 parseSettings.NoLink = !postGroup.Perms.AutoLink 780 } else { 781 parseSettings = user.ParseSettings 782 }*/ 783 784 var eh bool 785 r.ContentHtml, eh = ParseMessage2(r.Content, t.ParentID, "forums", parseSettings, user) 786 if eh { 787 externalHead = true 788 } 789 // TODO: Do this more efficiently by avoiding the allocations entirely in ParseMessage, if there's nothing to do. 790 if r.ContentHtml == r.Content { 791 r.ContentHtml = r.Content 792 } 793 r.Deletable = user.Perms.DeleteReply || r.CreatedBy == user.ID 794 795 // TODO: This doesn't work properly so pick the first one instead? 796 /*if r.ID == pFrag { 797 ogdesc = r.Content 798 if len(ogdesc) > 200 { 799 ogdesc = ogdesc[:197] + "..." 800 } 801 }*/ 802 803 return nil 804 } 805 806 rf3 := func(r *ReplyUser) error { 807 //log.Printf("before r: %+v\n", r) 808 postGroup, err := r.Init2() 809 if err != nil { 810 return err 811 } 812 813 var parseSettings *ParseSettings 814 if (Config.NoEmbed || !postGroup.Perms.AutoEmbed) && (user.ParseSettings == nil || !user.ParseSettings.NoEmbed) { 815 parseSettings = DefaultParseSettings.CopyPtr() 816 parseSettings.NoEmbed = true 817 } else { 818 parseSettings = user.ParseSettings 819 } 820 821 var eh bool 822 r.ContentHtml, eh = ParseMessage2(r.Content, t.ParentID, "forums", parseSettings, user) 823 if eh { 824 externalHead = true 825 } 826 // TODO: Do this more efficiently by avoiding the allocations entirely in ParseMessage, if there's nothing to do. 827 if r.ContentHtml == r.Content { 828 r.ContentHtml = r.Content 829 } 830 r.Deletable = user.Perms.DeleteReply || r.CreatedBy == user.ID 831 832 return nil 833 } 834 835 // TODO: Factor the user fields out and embed a user struct instead 836 if err == nil && ruser != nil { 837 //log.Print("reply cached serve") 838 r := &ReplyUser{ /*ClassName: "", */ Reply: *re, CreatedByName: ruser.Name, UserLink: ruser.Link, Avatar: ruser.Avatar, MicroAvatar: ruser.MicroAvatar /*URLPrefix: ruser.URLPrefix, URLName: ruser.URLName, */, Group: ruser.Group, Level: ruser.Level, Tag: ruser.Tag} 839 if err = rf3(r); err != nil { 840 return nil, externalHead, err 841 } 842 843 if r.LikeCount > 0 && user.Liked > 0 { 844 likedMap = map[int]int{r.ID: 0} 845 likedQueryList = []int{r.ID} 846 } 847 if user.Perms.EditReply && r.AttachCount > 0 { 848 if likedMap == nil { 849 attachMap = map[int]int{r.ID: 0} 850 attachQueryList = []int{r.ID} 851 } else { 852 attachMap = likedMap 853 attachQueryList = likedQueryList 854 } 855 } 856 857 H_topic_reply_row_assign_hook(hTbl, r) 858 // TODO: Use a pointer instead to make it easier to abstract this loop? What impact would this have on escape analysis? 859 rlist = []*ReplyUser{r} 860 //log.Printf("r: %d-%d", r.ID, len(rlist)-1) 861 } else { 862 //log.Print("reply query serve") 863 ap1 := func(r *ReplyUser) { 864 H_topic_reply_row_assign_hook(hTbl, r) 865 // TODO: Use a pointer instead to make it easier to abstract this loop? What impact would this have on escape analysis? 866 rlist = append(rlist, r) 867 //log.Printf("r: %d-%d", r.ID, len(rlist)-1) 868 } 869 rf2 := func(r *ReplyUser) { 870 if r.LikeCount > 0 && user.Liked > 0 { 871 if likedMap == nil { 872 likedMap = map[int]int{r.ID: len(rlist)} 873 likedQueryList = []int{r.ID} 874 } else { 875 likedMap[r.ID] = len(rlist) 876 likedQueryList = append(likedQueryList, r.ID) 877 } 878 } 879 if user.Perms.EditReply && r.AttachCount > 0 { 880 if attachMap == nil { 881 attachMap = map[int]int{r.ID: len(rlist)} 882 attachQueryList = []int{r.ID} 883 } else { 884 attachMap[r.ID] = len(rlist) 885 attachQueryList = append(attachQueryList, r.ID) 886 } 887 } 888 } 889 if !user.Perms.ViewIPs && ruser != nil { 890 rows, e := topicStmts.getReplies3.Query(t.ID, offset, Config.ItemsPerPage) 891 if err != nil { 892 return nil, externalHead, e 893 } 894 defer rows.Close() 895 for rows.Next() { 896 r := &ReplyUser{Avatar: ruser.Avatar, MicroAvatar: ruser.MicroAvatar, UserLink: ruser.Link, CreatedByName: ruser.Name, Group: ruser.Group, Level: ruser.Level} 897 e := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.LikeCount, &r.AttachCount, &r.ActionType) 898 if e != nil { 899 return nil, externalHead, e 900 } 901 if e = rf3(r); e != nil { 902 return nil, externalHead, e 903 } 904 rf2(r) 905 ap1(r) 906 } 907 if e = rows.Err(); e != nil { 908 return nil, externalHead, e 909 } 910 } else if user.Perms.ViewIPs { 911 rows, err := topicStmts.getReplies.Query(t.ID, offset, Config.ItemsPerPage) 912 if err != nil { 913 return nil, externalHead, err 914 } 915 defer rows.Close() 916 for rows.Next() { 917 r := &ReplyUser{} 918 err := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.Avatar, &r.CreatedByName, &r.Group /*&r.URLPrefix, &r.URLName,*/, &r.Level, &r.IP, &r.LikeCount, &r.AttachCount, &r.ActionType) 919 if err != nil { 920 return nil, externalHead, err 921 } 922 if err = rf(r); err != nil { 923 return nil, externalHead, err 924 } 925 rf2(r) 926 ap1(r) 927 } 928 if err = rows.Err(); err != nil { 929 return nil, externalHead, err 930 } 931 } else if t.PostCount >= 20 { 932 //log.Print("t.PostCount >= 20") 933 rows, err := topicStmts.getReplies3.Query(t.ID, offset, Config.ItemsPerPage) 934 if err != nil { 935 return nil, externalHead, err 936 } 937 defer rows.Close() 938 reqUserList := make(map[int]bool) 939 for rows.Next() { 940 r := &ReplyUser{} 941 err := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy /*&r.URLPrefix, &r.URLName,*/, &r.LikeCount, &r.AttachCount, &r.ActionType) 942 if err != nil { 943 return nil, externalHead, err 944 } 945 if r.CreatedBy != t.CreatedBy && r.CreatedBy != user.ID { 946 reqUserList[r.CreatedBy] = true 947 } 948 ap1(r) 949 } 950 if err = rows.Err(); err != nil { 951 return nil, externalHead, err 952 } 953 954 if len(reqUserList) == 1 { 955 //log.Print("len(reqUserList) == 1: ", len(reqUserList) == 1) 956 var uitem *User 957 for uid, _ := range reqUserList { 958 uitem, err = Users.Get(uid) 959 if err != nil { 960 return nil, externalHead, nil // TODO: Implement this! 961 } 962 } 963 for _, r := range rlist { 964 if r.CreatedBy == t.CreatedBy { 965 r.CreatedByName = t.CreatedByName 966 r.Avatar = t.Avatar 967 r.MicroAvatar = t.MicroAvatar 968 r.Group = t.Group 969 r.Level = t.Level 970 } else { 971 var u *User 972 if r.CreatedBy == user.ID { 973 u = user 974 } else { 975 u = uitem 976 } 977 r.CreatedByName = u.Name 978 r.Avatar = u.Avatar 979 r.MicroAvatar = u.MicroAvatar 980 r.Group = u.Group 981 r.Level = u.Level 982 } 983 if err = rf(r); err != nil { 984 return nil, externalHead, err 985 } 986 rf2(r) 987 } 988 } else { 989 //log.Print("len(reqUserList) != 1: ", len(reqUserList) != 1) 990 var userList map[int]*User 991 if len(reqUserList) > 0 { 992 //log.Print("len(reqUserList) > 0: ", len(reqUserList) > 0) 993 // Convert the user ID map to a slice, then bulk load the users 994 idSlice := make([]int, len(reqUserList)) 995 var i int 996 for userID := range reqUserList { 997 idSlice[i] = userID 998 i++ 999 } 1000 userList, err = Users.BulkGetMap(idSlice) 1001 if err != nil { 1002 return nil, externalHead, nil // TODO: Implement this! 1003 } 1004 } 1005 1006 for _, r := range rlist { 1007 if r.CreatedBy == t.CreatedBy { 1008 r.CreatedByName = t.CreatedByName 1009 r.Avatar = t.Avatar 1010 r.MicroAvatar = t.MicroAvatar 1011 r.Group = t.Group 1012 r.Level = t.Level 1013 } else { 1014 var u *User 1015 if r.CreatedBy == user.ID { 1016 u = user 1017 } else { 1018 u = userList[r.CreatedBy] 1019 } 1020 r.CreatedByName = u.Name 1021 r.Avatar = u.Avatar 1022 r.MicroAvatar = u.MicroAvatar 1023 r.Group = u.Group 1024 r.Level = u.Level 1025 } 1026 if err = rf(r); err != nil { 1027 return nil, externalHead, err 1028 } 1029 rf2(r) 1030 } 1031 } 1032 } else { 1033 //log.Print("reply fallback") 1034 rows, err := topicStmts.getReplies2.Query(t.ID, offset, Config.ItemsPerPage) 1035 if err != nil { 1036 return nil, externalHead, err 1037 } 1038 defer rows.Close() 1039 for rows.Next() { 1040 r := &ReplyUser{} 1041 err := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.Avatar, &r.CreatedByName, &r.Group /*&r.URLPrefix, &r.URLName,*/, &r.Level, &r.LikeCount, &r.AttachCount, &r.ActionType) 1042 if err != nil { 1043 return nil, externalHead, err 1044 } 1045 if err = rf(r); err != nil { 1046 return nil, externalHead, err 1047 } 1048 rf2(r) 1049 ap1(r) 1050 } 1051 if err = rows.Err(); err != nil { 1052 return nil, externalHead, err 1053 } 1054 } 1055 } 1056 1057 // TODO: Add a config setting to disable the liked query for a burst of extra speed 1058 if user.Liked > 0 && len(likedQueryList) > 0 /*&& user.LastLiked <= time.Now()*/ { 1059 e := Likes.BulkExistsFunc(likedQueryList, user.ID, "replies", func(eid int) error { 1060 rlist[likedMap[eid]].Liked = true 1061 return nil 1062 }) 1063 if e != nil { 1064 return nil, externalHead, e 1065 } 1066 } 1067 1068 if user.Perms.EditReply && len(attachQueryList) > 0 { 1069 //log.Printf("attachQueryList: %+v\n", attachQueryList) 1070 amap, err := Attachments.BulkMiniGetList("replies", attachQueryList) 1071 if err != nil && err != sql.ErrNoRows { 1072 return nil, externalHead, err 1073 } 1074 //log.Printf("amap: %+v\n", amap) 1075 //log.Printf("attachMap: %+v\n", attachMap) 1076 for id, attach := range amap { 1077 //log.Print("id:", id) 1078 rlist[attachMap[id]].Attachments = attach 1079 /*for _, a := range attach { 1080 log.Printf("a: %+v\n", a) 1081 }*/ 1082 } 1083 } 1084 1085 //hTbl.VhookNoRet("topic_reply_end", &rlist) 1086 1087 return rlist, externalHead, nil 1088 } 1089 1090 // TODO: Test this 1091 func (t *Topic) Author() (*User, error) { 1092 return Users.Get(t.CreatedBy) 1093 } 1094 1095 func (t *Topic) GetID() int { 1096 return t.ID 1097 } 1098 func (t *Topic) GetTable() string { 1099 return "topics" 1100 } 1101 1102 // Copy gives you a non-pointer concurrency safe copy of the topic 1103 func (t *Topic) Copy() Topic { 1104 return *t 1105 } 1106 1107 // TODO: Load LastReplyAt and LastReplyID? 1108 func TopicByReplyID(rid int) (*Topic, error) { 1109 t := Topic{ID: 0} 1110 err := topicStmts.getByReplyID.QueryRow(rid).Scan(&t.ID, &t.Title, &t.Content, &t.CreatedBy, &t.CreatedAt, &t.IsClosed, &t.Sticky, &t.ParentID, &t.IP, &t.ViewCount, &t.PostCount, &t.LikeCount, &t.Poll, &t.Data) 1111 t.Link = BuildTopicURL(NameToSlug(t.Title), t.ID) 1112 return &t, err 1113 } 1114 1115 // TODO: Refactor the caller to take a Topic and a User rather than a combined TopicUser 1116 // TODO: Load LastReplyAt everywhere in here? 1117 func GetTopicUser(user *User, tid int) (tu TopicUser, err error) { 1118 tcache := Topics.GetCache() 1119 ucache := Users.GetCache() 1120 if tcache != nil && ucache != nil { 1121 topic, err := tcache.Get(tid) 1122 if err == nil { 1123 if topic.CreatedBy != user.ID { 1124 user, err = Users.Get(topic.CreatedBy) 1125 if err != nil { 1126 return TopicUser{ID: tid}, err 1127 } 1128 } 1129 // We might be better off just passing separate topic and user structs to the caller? 1130 return copyTopicToTopicUser(topic, user), nil 1131 } else if ucache.Length() < ucache.GetCapacity() { 1132 topic, err = Topics.Get(tid) 1133 if err != nil { 1134 return TopicUser{ID: tid}, err 1135 } 1136 if topic.CreatedBy != user.ID { 1137 user, err = Users.Get(topic.CreatedBy) 1138 if err != nil { 1139 return TopicUser{ID: tid}, err 1140 } 1141 } 1142 return copyTopicToTopicUser(topic, user), nil 1143 } 1144 } 1145 1146 tu = TopicUser{ID: tid} 1147 // TODO: This misses some important bits... 1148 err = topicStmts.getTopicUser.QueryRow(tid).Scan(&tu.Title, &tu.Content, &tu.CreatedBy, &tu.CreatedAt, &tu.LastReplyAt, &tu.LastReplyBy, &tu.LastReplyID, &tu.IsClosed, &tu.Sticky, &tu.ParentID, &tu.IP, &tu.ViewCount, &tu.PostCount, &tu.LikeCount, &tu.AttachCount, &tu.Poll, &tu.CreatedByName, &tu.Avatar, &tu.Group, &tu.Level) 1149 tu.Avatar, tu.MicroAvatar = BuildAvatar(tu.CreatedBy, tu.Avatar) 1150 tu.Link = BuildTopicURL(NameToSlug(tu.Title), tu.ID) 1151 tu.UserLink = BuildProfileURL(NameToSlug(tu.CreatedByName), tu.CreatedBy) 1152 tu.Tag = Groups.DirtyGet(tu.Group).Tag 1153 1154 if tcache != nil { 1155 // TODO: weekly views 1156 theTopic := Topic{ID: tu.ID, Link: tu.Link, Title: tu.Title, Content: tu.Content, CreatedBy: tu.CreatedBy, IsClosed: tu.IsClosed, Sticky: tu.Sticky, CreatedAt: tu.CreatedAt, LastReplyAt: tu.LastReplyAt, LastReplyID: tu.LastReplyID, ParentID: tu.ParentID, IP: tu.IP, ViewCount: tu.ViewCount, PostCount: tu.PostCount, LikeCount: tu.LikeCount, AttachCount: tu.AttachCount, Poll: tu.Poll} 1157 //log.Printf("theTopic: %+v\n", theTopic) 1158 _ = tcache.Set(&theTopic) 1159 } 1160 return tu, err 1161 } 1162 1163 func copyTopicToTopicUser(t *Topic, u *User) (tu TopicUser) { 1164 tu.UserLink = u.Link 1165 tu.CreatedByName = u.Name 1166 tu.Group = u.Group 1167 tu.Avatar = u.Avatar 1168 tu.MicroAvatar = u.MicroAvatar 1169 //tu.URLPrefix = u.URLPrefix 1170 //tu.URLName = u.URLName 1171 tu.Level = u.Level 1172 1173 tu.ID = t.ID 1174 tu.Link = t.Link 1175 tu.Title = t.Title 1176 tu.Content = t.Content 1177 tu.CreatedBy = t.CreatedBy 1178 tu.IsClosed = t.IsClosed 1179 tu.Sticky = t.Sticky 1180 tu.CreatedAt = t.CreatedAt 1181 tu.LastReplyAt = t.LastReplyAt 1182 tu.LastReplyBy = t.LastReplyBy 1183 tu.ParentID = t.ParentID 1184 tu.IP = t.IP 1185 tu.ViewCount = t.ViewCount 1186 tu.PostCount = t.PostCount 1187 tu.LikeCount = t.LikeCount 1188 tu.AttachCount = t.AttachCount 1189 tu.Poll = t.Poll 1190 tu.Data = t.Data 1191 tu.Rids = t.Rids 1192 1193 return tu 1194 } 1195 1196 // For use in tests and for generating blank topics for forums which don't have a last poster 1197 func BlankTopic() *Topic { 1198 return new(Topic) 1199 } 1200 1201 func BuildTopicURL(slug string, tid int) string { 1202 if slug == "" || !Config.BuildSlugs { 1203 return "/topic/" + strconv.Itoa(tid) 1204 } 1205 return "/topic/" + slug + "." + strconv.Itoa(tid) 1206 } 1207 1208 func BuildTopicURLSb(sb *strings.Builder, slug string, tid int) { 1209 if slug == "" || !Config.BuildSlugs { 1210 sb.Grow(7 + 2) 1211 sb.WriteString("/topic/") 1212 sb.WriteString(strconv.Itoa(tid)) 1213 return 1214 } 1215 sb.Grow(7 + 3 + len(slug)) 1216 sb.WriteString("/topic/") 1217 sb.WriteString(slug) 1218 sb.WriteRune('.') 1219 sb.WriteString(strconv.Itoa(tid)) 1220 } 1221 1222 // I don't care if it isn't used,, it will likely be in the future. Nolint. 1223 // nolint 1224 func getTopicURLPrefix() string { 1225 return "/topic/" 1226 }