A web blog and personal homepage engine written in Go.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

547 lines
13 KiB

  1. package models
  2. import (
  3. "errors"
  4. "fmt"
  5. "html/template"
  6. "math"
  7. "regexp"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "git.kirsle.net/apps/gophertype/pkg/console"
  12. "git.kirsle.net/apps/gophertype/pkg/markdown"
  13. "git.kirsle.net/apps/gophertype/pkg/mogrify"
  14. "git.kirsle.net/apps/gophertype/pkg/rng"
  15. "github.com/albrow/forms"
  16. )
  17. type postMan struct{}
  18. // Posts is a singleton manager class for Post model access.
  19. var Posts = postMan{}
  20. // Post represents a single blog entry.
  21. type Post struct {
  22. BaseModel
  23. Title string
  24. Fragment string `gorm:"unique_index"`
  25. ContentType string `gorm:"default:'html'"`
  26. AuthorID int // foreign key to User.ID
  27. Thumbnail string // image thumbnail for the post
  28. Body string
  29. Privacy string
  30. Sticky bool
  31. EnableComments bool
  32. Tags []TaggedPost
  33. Author User `gorm:"foreign_key:UserID"`
  34. // Private fields not in DB.
  35. CommentCount int `gorm:"-"`
  36. }
  37. // PagedPosts holds a paginated response of multiple posts.
  38. type PagedPosts struct {
  39. Posts []Post
  40. Page int
  41. PerPage int
  42. Pages int
  43. Total int
  44. NextPage int
  45. PreviousPage int
  46. }
  47. // PostArchive holds the posts for a single year/month for the archive page.
  48. type PostArchive struct {
  49. Label string
  50. Date time.Time
  51. Posts []Post
  52. }
  53. // Regexp for matching a thumbnail image for a blog post.
  54. var ThumbnailImageRegexp = regexp.MustCompile(`['"(]([a-zA-Z0-9-_:/?.=&]+\.(?:jpe?g|png|gif))['")]`)
  55. // New creates a new Post model.
  56. func (m postMan) New() Post {
  57. return Post{
  58. ContentType: Markdown,
  59. Privacy: Public,
  60. EnableComments: true,
  61. }
  62. }
  63. // Load a post by ID.
  64. func (m postMan) Load(id int) (Post, error) {
  65. var post Post
  66. r := DB.Preload("Author").Preload("Tags").First(&post, id)
  67. return post, r.Error
  68. }
  69. // LoadFragment loads a blog post by its URL fragment.
  70. func (m postMan) LoadFragment(fragment string) (Post, error) {
  71. var post Post
  72. r := DB.Preload("Author").Preload("Tags").Where("fragment = ?", strings.Trim(fragment, "/")).First(&post)
  73. return post, r.Error
  74. }
  75. // LoadRandom gets a random post for a given privacy setting.
  76. func (m postMan) LoadRandom(privacy string) (Post, error) {
  77. // Find all the post IDs.
  78. var pp []Post
  79. r := DB.Select("id").Where("privacy = ?", privacy).Find(&pp)
  80. if r.Error != nil || len(pp) == 0 {
  81. return Post{}, r.Error
  82. }
  83. // Pick one at random.
  84. randPost := pp[rng.Intn(len(pp))]
  85. post, err := Posts.Load(randPost.ID)
  86. return post, err
  87. }
  88. // GetIndex returns the index page of blog posts.
  89. func (m postMan) GetIndexPosts(privacy string, page, perPage int) (PagedPosts, error) {
  90. var pp = PagedPosts{
  91. Page: page,
  92. PerPage: perPage,
  93. }
  94. if pp.Page < 1 {
  95. pp.Page = 1
  96. }
  97. if pp.PerPage <= 0 {
  98. pp.PerPage = 20
  99. }
  100. query := DB.Preload("Author").Preload("Tags").
  101. Where("privacy = ?", privacy).
  102. Order("sticky desc, created_at desc")
  103. // Count the total number of rows for paging purposes.
  104. query.Model(&Post{}).Count(&pp.Total)
  105. // Query the paginated slice of results.
  106. r := query.
  107. Offset((pp.Page - 1) * pp.PerPage).
  108. Limit(pp.PerPage).
  109. Find(&pp.Posts)
  110. pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage)))
  111. if pp.Page < pp.Pages {
  112. pp.NextPage = pp.Page + 1
  113. }
  114. if pp.Page > 1 {
  115. pp.PreviousPage = pp.Page - 1
  116. }
  117. if err := pp.CountComments(); err != nil {
  118. console.Error("PagedPosts.CountComments: %s", err)
  119. }
  120. return pp, r.Error
  121. }
  122. // GetPostsByTag gets posts by a certain tag.
  123. func (m postMan) GetPostsByTag(tag, privacy string, page, perPage int) (PagedPosts, error) {
  124. var pp = PagedPosts{
  125. Page: page,
  126. PerPage: perPage,
  127. }
  128. if pp.Page < 1 {
  129. pp.Page = 1
  130. }
  131. if pp.PerPage <= 0 {
  132. pp.PerPage = 20
  133. }
  134. // Multi-tag query.
  135. whitelist, blacklist, err := ParseMultitag(tag)
  136. if err != nil {
  137. return pp, err
  138. }
  139. // Query the whitelist of post IDs which match the whitelist tags.
  140. postIDs := getPostIDsByTag("tag IN (?)", whitelist)
  141. notPostIDs := getPostIDsByTag("tag IN (?)", blacklist)
  142. postIDs = narrowWhitelistByBlacklist(postIDs, notPostIDs)
  143. if len(postIDs) == 0 {
  144. return pp, errors.New("no posts found")
  145. }
  146. // Query this set of posts.
  147. query := DB.Preload("Author").Preload("Tags").
  148. Where("id IN (?) AND privacy = ?", postIDs, privacy).
  149. Order("sticky desc, created_at desc")
  150. // Count the total number of rows for paging purposes.
  151. query.Model(&Post{}).Count(&pp.Total)
  152. // Query the paginated slice of results.
  153. r := query.
  154. Offset((page - 1) * perPage).
  155. Limit(perPage).
  156. Find(&pp.Posts)
  157. pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage)))
  158. if pp.Page < pp.Pages {
  159. pp.NextPage = pp.Page + 1
  160. }
  161. if pp.Page > 1 {
  162. pp.PreviousPage = pp.Page - 1
  163. }
  164. if err := pp.CountComments(); err != nil {
  165. console.Error("PagedPosts.CountComments: %s", err)
  166. }
  167. return pp, r.Error
  168. }
  169. // getPostIDsByTag helper function returns the post IDs that either whitelist,
  170. // or blacklist, a set of tags.
  171. func getPostIDsByTag(query string, value []string) []int {
  172. var tags []TaggedPost
  173. var result []int
  174. if len(value) == 0 {
  175. return result
  176. }
  177. DB.Where(query, value).Find(&tags)
  178. for _, tag := range tags {
  179. result = append(result, tag.PostID)
  180. }
  181. return result
  182. }
  183. // narrowWhitelistByBlacklist removes IDs in whitelist that appear in blacklist.
  184. func narrowWhitelistByBlacklist(wl []int, bl []int) []int {
  185. // Map the blacklist into a hash map.
  186. var blacklist = map[int]interface{}{}
  187. for _, id := range bl {
  188. blacklist[id] = nil
  189. }
  190. // Limit the whitelist by the blacklist.
  191. var result []int
  192. for _, id := range wl {
  193. if _, ok := blacklist[id]; !ok {
  194. result = append(result, id)
  195. }
  196. }
  197. return result
  198. }
  199. // GetArchive queries the archive view of the blog.
  200. // Set private=true to return private posts, false returns public only.
  201. func (m postMan) GetArchive(private bool) ([]*PostArchive, error) {
  202. var result = []*PostArchive{}
  203. query := DB.Table("posts").
  204. Select("title, fragment, thumbnail, created_at, privacy")
  205. if !private {
  206. query = query.Where("privacy=?", Public)
  207. }
  208. rows, err := query.
  209. Order("created_at desc").
  210. Rows()
  211. if err != nil {
  212. return result, err
  213. }
  214. // Group the posts by their month/year.
  215. var months []string
  216. var byMonth = map[string]*PostArchive{}
  217. for rows.Next() {
  218. var row Post
  219. if err := rows.Scan(&row.Title, &row.Fragment, &row.Thumbnail, &row.CreatedAt, &row.Privacy); err != nil {
  220. return result, err
  221. }
  222. label := row.CreatedAt.Format("2006-01")
  223. if _, ok := byMonth[label]; !ok {
  224. months = append(months, label)
  225. byMonth[label] = &PostArchive{
  226. Label: label,
  227. Date: time.Date(
  228. row.CreatedAt.Year(), row.CreatedAt.Month(), 1,
  229. 0, 0, 0, 0, time.UTC,
  230. ),
  231. Posts: []Post{},
  232. }
  233. }
  234. byMonth[label].Posts = append(byMonth[label].Posts, row)
  235. }
  236. for _, month := range months {
  237. result = append(result, byMonth[month])
  238. }
  239. return result, nil
  240. }
  241. // CountComments gets comment counts for one or more posts.
  242. // Returns a map[uint]int mapping post ID to comment count.
  243. func (m postMan) CountComments(posts ...Post) (map[int]int, error) {
  244. var result = map[int]int{}
  245. // Create the comment thread IDs.
  246. var threadIDs = make([]string, len(posts))
  247. for i, post := range posts {
  248. threadIDs[i] = fmt.Sprintf("post-%d", post.ID)
  249. }
  250. // Query comment counts for each thread.
  251. if len(threadIDs) > 0 {
  252. rows, err := DB.Table("comments").
  253. Select("thread, count(*) as count").
  254. Group("thread").
  255. Rows()
  256. if err != nil {
  257. return result, err
  258. }
  259. for rows.Next() {
  260. var thread string
  261. var count int
  262. if err := rows.Scan(&thread, &count); err != nil {
  263. console.Error("CountComments: rows.Scan: %s", err)
  264. }
  265. postID, err := strconv.Atoi(strings.TrimPrefix(thread, "post-"))
  266. if err != nil {
  267. console.Warn("CountComments: strconv.Atoi(%s): %s", thread, err)
  268. }
  269. result[postID] = count
  270. }
  271. }
  272. return result, nil
  273. }
  274. // ParseMultitag parses a tag string to return arrays for IN and NOT IN queries from DB.
  275. //
  276. // Example input: "blog,updates,-photo,-ask"
  277. // Returns: ["blog", "updates"], ["photo", "ask"]
  278. func ParseMultitag(tagline string) (whitelist, blacklist []string, err error) {
  279. words := strings.Split(tagline, ",")
  280. for _, word := range words {
  281. word = strings.TrimSpace(word)
  282. if len(word) == 0 {
  283. continue
  284. }
  285. // Negation
  286. if strings.HasPrefix(word, "-") {
  287. blacklist = append(blacklist, strings.TrimPrefix(word, "-"))
  288. } else {
  289. whitelist = append(whitelist, word)
  290. }
  291. }
  292. if len(whitelist) == 0 && len(blacklist) == 0 {
  293. err = errors.New("parsing error")
  294. }
  295. return
  296. }
  297. // CountComments on the posts in a PagedPosts list.
  298. func (pp *PagedPosts) CountComments() error {
  299. counts, err := Posts.CountComments(pp.Posts...)
  300. if err != nil {
  301. return err
  302. }
  303. console.Info("counts: %+v", counts)
  304. for i, post := range pp.Posts {
  305. if count, ok := counts[post.ID]; ok {
  306. pp.Posts[i].CommentCount = count
  307. }
  308. }
  309. return nil
  310. }
  311. // PreviewHTML returns the post's body as rendered HTML code, but only above
  312. // the <snip> tag for index views.
  313. func (p Post) PreviewHTML() template.HTML {
  314. var (
  315. parts = strings.Split(p.Body, "<snip>")
  316. hasMore = len(parts) > 1
  317. body = strings.TrimSpace(parts[0])
  318. )
  319. if p.ContentType == Markdown {
  320. if hasMore {
  321. body += fmt.Sprintf("\n\n[Read more...](/%s)", p.Fragment)
  322. }
  323. body = markdown.RenderTrustedMarkdown(body)
  324. } else if hasMore {
  325. body += fmt.Sprintf(`<p><a href="/%s">Read more...</a></p>`, p.Fragment)
  326. }
  327. // Make all images lazy loaded. TODO: make this configurable behavior?
  328. body = mogrify.LazyLoadImages(body)
  329. return template.HTML(body)
  330. }
  331. // HTML returns the post's body as rendered HTML code.
  332. func (p Post) HTML() template.HTML {
  333. body := strings.ReplaceAll(p.Body, "<snip>", "")
  334. if p.ContentType == Markdown {
  335. body = markdown.RenderTrustedMarkdown(body)
  336. }
  337. // Make all images lazy loaded. TODO: make this configurable behavior?
  338. body = mogrify.LazyLoadImages(body)
  339. return template.HTML(body)
  340. }
  341. // Save a post.
  342. // This method also makes sure a unique Fragment is set and links the Tags correctly.
  343. func (p *Post) Save() error {
  344. // Generate the default fragment from the post title.
  345. if p.Fragment == "" {
  346. fragment := strings.ToLower(p.Title)
  347. fragment = regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(fragment, "-")
  348. fragment = strings.ReplaceAll(fragment, "--", "-")
  349. console.Error("frag: %s", fragment)
  350. p.Fragment = strings.Trim(fragment, "-")
  351. // If still no fragment, make one up from the current time.
  352. if p.Fragment == "" {
  353. p.Fragment = time.Now().Format("2006-01-02-150405")
  354. }
  355. }
  356. // Ensure the fragment is unique!
  357. {
  358. if exist, err := Posts.LoadFragment(p.Fragment); err != nil && exist.ID != p.ID {
  359. console.Debug("Post.Save: fragment %s is not unique, trying to resolve", p.Fragment)
  360. var resolved bool
  361. for i := 2; i <= 100; i++ {
  362. fragment := fmt.Sprintf("%s-%d", p.Fragment, i)
  363. console.Debug("Post.Save: try fragment '%s'", fragment)
  364. _, err = Posts.LoadFragment(fragment)
  365. if err == nil {
  366. continue
  367. }
  368. p.Fragment = fragment
  369. resolved = true
  370. break
  371. }
  372. if !resolved {
  373. return fmt.Errorf("failed to generate a unique URL fragment for '%s' after 100 attempts", p.Fragment)
  374. }
  375. }
  376. }
  377. // Cache the post thumbnail from the body.
  378. if thumbnail, ok := p.ExtractThumbnail(); ok {
  379. p.Thumbnail = thumbnail
  380. }
  381. // Empty tags list.
  382. if len(p.Tags) == 1 && p.Tags[0].Tag == "" {
  383. p.Tags = []TaggedPost{}
  384. }
  385. // TODO: tag relationships. For now just delete and re-add them all.
  386. if p.ID != 0 {
  387. DB.Where("post_id = ?", p.ID).Delete(TaggedPost{})
  388. }
  389. // Dedupe tags.
  390. p.fixTags()
  391. // Save the post.
  392. if DB.NewRecord(p) {
  393. return DB.Create(&p).Error
  394. }
  395. return DB.Save(&p).Error
  396. }
  397. // SetUpdated force sets the updated time of a post, i.e. to reset it to original.
  398. func (p Post) SetUpdated(dt time.Time) error {
  399. r := DB.Table("posts").Where("id = ?", p.ID).Updates(map[string]interface{}{
  400. "updated_at": dt,
  401. })
  402. return r.Error
  403. }
  404. // ParseForm populates a Post from an HTTP form.
  405. func (p *Post) ParseForm(form *forms.Data) {
  406. p.Title = form.Get("title")
  407. p.Fragment = form.Get("fragment")
  408. p.ContentType = form.Get("content-type")
  409. p.Body = form.Get("body")
  410. p.Privacy = form.Get("privacy")
  411. p.Sticky = form.GetBool("sticky")
  412. p.EnableComments = form.GetBool("enable-comments")
  413. // Parse the tags array. This replaces the post.Tags with an empty TaggedPost
  414. // list containing only the string Tag values. The IDs and DB side will be
  415. // patched up when the post gets saved.
  416. p.Tags = []TaggedPost{}
  417. tags := strings.Split(form.Get("tags"), ",")
  418. for _, tag := range tags {
  419. tag = strings.TrimSpace(tag)
  420. if len(tag) == 0 {
  421. continue
  422. }
  423. p.Tags = append(p.Tags, TaggedPost{
  424. Tag: tag,
  425. })
  426. }
  427. }
  428. // ExtractThumbnail searches and returns a thumbnail image to represent the post.
  429. // It will be the first image embedded in the post body, or nothing.
  430. func (p Post) ExtractThumbnail() (string, bool) {
  431. result := ThumbnailImageRegexp.FindStringSubmatch(p.Body)
  432. if len(result) < 2 {
  433. return "", false
  434. }
  435. return result[1], true
  436. }
  437. // TagsString turns the post tags into a comma separated string.
  438. func (p Post) TagsString() string {
  439. console.Error("TagsString: %+v", p.Tags)
  440. var tags = make([]string, len(p.Tags))
  441. for i, tag := range p.Tags {
  442. tags[i] = tag.Tag
  443. }
  444. return strings.Join(tags, ", ")
  445. }
  446. // fixTags is a pre-Save function to fix up the Tags relationships.
  447. // It checks that each tag has an ID, and if it doesn't have an ID yet, removes
  448. // it if a duplicate tag does exist that has an ID.
  449. func (p *Post) fixTags() {
  450. // De-duplicate tag values.
  451. var dedupe = map[string]interface{}{}
  452. var finalTags []TaggedPost
  453. for _, tag := range p.Tags {
  454. if _, ok := dedupe[tag.Tag]; !ok {
  455. finalTags = append(finalTags, tag)
  456. dedupe[tag.Tag] = nil
  457. }
  458. }
  459. p.Tags = finalTags
  460. }