A clean, Markdown-based publishing platform made for writers. Write together, and build a community. https://writefreely.org
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.
 
 
 
 
 

1431 lines
38 KiB

  1. /*
  2. * Copyright © 2018-2019 A Bunch Tell LLC.
  3. *
  4. * This file is part of WriteFreely.
  5. *
  6. * WriteFreely is free software: you can redistribute it and/or modify
  7. * it under the terms of the GNU Affero General Public License, included
  8. * in the LICENSE file in this source code package.
  9. */
  10. package writefreely
  11. import (
  12. "database/sql"
  13. "encoding/json"
  14. "fmt"
  15. "html/template"
  16. "net/http"
  17. "regexp"
  18. "strings"
  19. "time"
  20. "github.com/gorilla/mux"
  21. "github.com/guregu/null"
  22. "github.com/guregu/null/zero"
  23. "github.com/kylemcc/twitter-text-go/extract"
  24. "github.com/microcosm-cc/bluemonday"
  25. stripmd "github.com/writeas/go-strip-markdown"
  26. "github.com/writeas/impart"
  27. "github.com/writeas/monday"
  28. "github.com/writeas/slug"
  29. "github.com/writeas/web-core/activitystreams"
  30. "github.com/writeas/web-core/bots"
  31. "github.com/writeas/web-core/converter"
  32. "github.com/writeas/web-core/i18n"
  33. "github.com/writeas/web-core/log"
  34. "github.com/writeas/web-core/tags"
  35. "github.com/writeas/writefreely/page"
  36. "github.com/writeas/writefreely/parse"
  37. )
  38. const (
  39. // Post ID length bounds
  40. minIDLen = 10
  41. maxIDLen = 10
  42. userPostIDLen = 10
  43. postIDLen = 10
  44. postMetaDateFormat = "2006-01-02 15:04:05"
  45. )
  46. type (
  47. AnonymousPost struct {
  48. ID string
  49. Content string
  50. HTMLContent template.HTML
  51. Font string
  52. Language string
  53. Direction string
  54. Title string
  55. GenTitle string
  56. Description string
  57. Author string
  58. Views int64
  59. IsPlainText bool
  60. IsCode bool
  61. IsLinkable bool
  62. }
  63. AuthenticatedPost struct {
  64. ID string `json:"id" schema:"id"`
  65. Web bool `json:"web" schema:"web"`
  66. *SubmittedPost
  67. }
  68. // SubmittedPost represents a post supplied by a client for publishing or
  69. // updating. Since Title and Content can be updated to "", they are
  70. // pointers that can be easily tested to detect changes.
  71. SubmittedPost struct {
  72. Slug *string `json:"slug" schema:"slug"`
  73. Title *string `json:"title" schema:"title"`
  74. Content *string `json:"body" schema:"body"`
  75. Font string `json:"font" schema:"font"`
  76. IsRTL converter.NullJSONBool `json:"rtl" schema:"rtl"`
  77. Language converter.NullJSONString `json:"lang" schema:"lang"`
  78. Created *string `json:"created" schema:"created"`
  79. }
  80. // Post represents a post as found in the database.
  81. Post struct {
  82. ID string `db:"id" json:"id"`
  83. Slug null.String `db:"slug" json:"slug,omitempty"`
  84. Font string `db:"text_appearance" json:"appearance"`
  85. Language zero.String `db:"language" json:"language"`
  86. RTL zero.Bool `db:"rtl" json:"rtl"`
  87. Privacy int64 `db:"privacy" json:"-"`
  88. OwnerID null.Int `db:"owner_id" json:"-"`
  89. CollectionID null.Int `db:"collection_id" json:"-"`
  90. PinnedPosition null.Int `db:"pinned_position" json:"-"`
  91. Created time.Time `db:"created" json:"created"`
  92. Updated time.Time `db:"updated" json:"updated"`
  93. ViewCount int64 `db:"view_count" json:"-"`
  94. Title zero.String `db:"title" json:"title"`
  95. HTMLTitle template.HTML `db:"title" json:"-"`
  96. Content string `db:"content" json:"body"`
  97. HTMLContent template.HTML `db:"content" json:"-"`
  98. HTMLExcerpt template.HTML `db:"content" json:"-"`
  99. Tags []string `json:"tags"`
  100. Images []string `json:"images,omitempty"`
  101. OwnerName string `json:"owner,omitempty"`
  102. }
  103. // PublicPost holds properties for a publicly returned post, i.e. a post in
  104. // a context where the viewer may not be the owner. As such, sensitive
  105. // metadata for the post is hidden and properties supporting the display of
  106. // the post are added.
  107. PublicPost struct {
  108. *Post
  109. IsSubdomain bool `json:"-"`
  110. IsTopLevel bool `json:"-"`
  111. DisplayDate string `json:"-"`
  112. Views int64 `json:"views"`
  113. Owner *PublicUser `json:"-"`
  114. IsOwner bool `json:"-"`
  115. Collection *CollectionObj `json:"collection,omitempty"`
  116. }
  117. RawPost struct {
  118. Id, Slug string
  119. Title string
  120. Content string
  121. Views int64
  122. Font string
  123. Created time.Time
  124. IsRTL sql.NullBool
  125. Language sql.NullString
  126. OwnerID int64
  127. CollectionID sql.NullInt64
  128. Found bool
  129. Gone bool
  130. }
  131. AnonymousAuthPost struct {
  132. ID string `json:"id"`
  133. Token string `json:"token"`
  134. }
  135. ClaimPostRequest struct {
  136. *AnonymousAuthPost
  137. CollectionAlias string `json:"collection"`
  138. CreateCollection bool `json:"create_collection"`
  139. // Generated properties
  140. Slug string `json:"-"`
  141. }
  142. ClaimPostResult struct {
  143. ID string `json:"id,omitempty"`
  144. Code int `json:"code,omitempty"`
  145. ErrorMessage string `json:"error_msg,omitempty"`
  146. Post *PublicPost `json:"post,omitempty"`
  147. }
  148. )
  149. func (p *Post) Direction() string {
  150. if p.RTL.Valid {
  151. if p.RTL.Bool {
  152. return "rtl"
  153. }
  154. return "ltr"
  155. }
  156. return "auto"
  157. }
  158. // DisplayTitle dynamically generates a title from the Post's contents if it
  159. // doesn't already have an explicit title.
  160. func (p *Post) DisplayTitle() string {
  161. if p.Title.String != "" {
  162. return p.Title.String
  163. }
  164. t := friendlyPostTitle(p.Content, p.ID)
  165. return t
  166. }
  167. // PlainDisplayTitle dynamically generates a title from the Post's contents if it
  168. // doesn't already have an explicit title.
  169. func (p *Post) PlainDisplayTitle() string {
  170. if t := stripmd.Strip(p.DisplayTitle()); t != "" {
  171. return t
  172. }
  173. return p.ID
  174. }
  175. // FormattedDisplayTitle dynamically generates a title from the Post's contents if it
  176. // doesn't already have an explicit title.
  177. func (p *Post) FormattedDisplayTitle() template.HTML {
  178. if p.HTMLTitle != "" {
  179. return p.HTMLTitle
  180. }
  181. return template.HTML(p.DisplayTitle())
  182. }
  183. // Summary gives a shortened summary of the post based on the post's title,
  184. // especially for display in a longer list of posts. It extracts a summary for
  185. // posts in the Title\n\nBody format, returning nothing if the entire was short
  186. // enough that the extracted title == extracted summary.
  187. func (p Post) Summary() string {
  188. if p.Content == "" {
  189. return ""
  190. }
  191. // Strip out HTML
  192. p.Content = bluemonday.StrictPolicy().Sanitize(p.Content)
  193. // and Markdown
  194. p.Content = stripmd.Strip(p.Content)
  195. title := p.Title.String
  196. var desc string
  197. if title == "" {
  198. // No title, so generate one
  199. title = friendlyPostTitle(p.Content, p.ID)
  200. desc = postDescription(p.Content, title, p.ID)
  201. if desc == title {
  202. return ""
  203. }
  204. return desc
  205. }
  206. return shortPostDescription(p.Content)
  207. }
  208. // Excerpt shows any text that comes before a (more) tag.
  209. // TODO: use HTMLExcerpt in templates instead of this method
  210. func (p *Post) Excerpt() template.HTML {
  211. return p.HTMLExcerpt
  212. }
  213. func (p *Post) CreatedDate() string {
  214. return p.Created.Format("2006-01-02")
  215. }
  216. func (p *Post) Created8601() string {
  217. return p.Created.Format("2006-01-02T15:04:05Z")
  218. }
  219. func (p *Post) IsScheduled() bool {
  220. return p.Created.After(time.Now())
  221. }
  222. func (p *Post) HasTag(tag string) bool {
  223. // Regexp looks for tag and has a non-capturing group at the end looking
  224. // for the end of the word.
  225. // Assisted by: https://stackoverflow.com/a/35192941/1549194
  226. hasTag, _ := regexp.MatchString("#"+tag+`(?:[[:punct:]]|\s|\z)`, p.Content)
  227. return hasTag
  228. }
  229. func (p *Post) HasTitleLink() bool {
  230. if p.Title.String == "" {
  231. return false
  232. }
  233. hasLink, _ := regexp.MatchString(`([^!]+|^)\[.+\]\(.+\)`, p.Title.String)
  234. return hasLink
  235. }
  236. func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
  237. vars := mux.Vars(r)
  238. friendlyID := vars["post"]
  239. // NOTE: until this is done better, be sure to keep this in parity with
  240. // isRaw() and viewCollectionPost()
  241. isJSON := strings.HasSuffix(friendlyID, ".json")
  242. isXML := strings.HasSuffix(friendlyID, ".xml")
  243. isCSS := strings.HasSuffix(friendlyID, ".css")
  244. isMarkdown := strings.HasSuffix(friendlyID, ".md")
  245. isRaw := strings.HasSuffix(friendlyID, ".txt") || isJSON || isXML || isCSS || isMarkdown
  246. // Display reserved page if that is requested resource
  247. if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok {
  248. return handleTemplatedPage(app, w, r, t)
  249. } else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" {
  250. // Serve static file
  251. app.shttp.ServeHTTP(w, r)
  252. return nil
  253. }
  254. // Display collection if this is a collection
  255. c, _ := app.db.GetCollection(friendlyID)
  256. if c != nil {
  257. return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", friendlyID)}
  258. }
  259. // Normalize the URL, redirecting user to consistent post URL
  260. if friendlyID != strings.ToLower(friendlyID) {
  261. return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s", strings.ToLower(friendlyID))}
  262. }
  263. ext := ""
  264. if isRaw {
  265. parts := strings.Split(friendlyID, ".")
  266. friendlyID = parts[0]
  267. if len(parts) > 1 {
  268. ext = "." + parts[1]
  269. }
  270. }
  271. var ownerID sql.NullInt64
  272. var title string
  273. var content string
  274. var font string
  275. var language []byte
  276. var rtl []byte
  277. var views int64
  278. var post *AnonymousPost
  279. var found bool
  280. var gone bool
  281. fixedID := slug.Make(friendlyID)
  282. if fixedID != friendlyID {
  283. return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)}
  284. }
  285. err := app.db.QueryRow(fmt.Sprintf("SELECT owner_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?"), friendlyID).Scan(&ownerID, &title, &content, &font, &views, &language, &rtl)
  286. switch {
  287. case err == sql.ErrNoRows:
  288. found = false
  289. // Output the error in the correct format
  290. if isJSON {
  291. content = "{\"error\": \"Post not found.\"}"
  292. } else if isRaw {
  293. content = "Post not found."
  294. } else {
  295. return ErrPostNotFound
  296. }
  297. case err != nil:
  298. found = false
  299. log.Error("Post loading err: %s\n", err)
  300. return ErrInternalGeneral
  301. default:
  302. found = true
  303. var d string
  304. if len(rtl) == 0 {
  305. d = "auto"
  306. } else if rtl[0] == 49 {
  307. // TODO: find a cleaner way to get this (possibly NULL) value
  308. d = "rtl"
  309. } else {
  310. d = "ltr"
  311. }
  312. generatedTitle := friendlyPostTitle(content, friendlyID)
  313. sanitizedContent := content
  314. if font != "code" {
  315. sanitizedContent = template.HTMLEscapeString(content)
  316. }
  317. var desc string
  318. if title == "" {
  319. desc = postDescription(content, title, friendlyID)
  320. } else {
  321. desc = shortPostDescription(content)
  322. }
  323. post = &AnonymousPost{
  324. ID: friendlyID,
  325. Content: sanitizedContent,
  326. Title: title,
  327. GenTitle: generatedTitle,
  328. Description: desc,
  329. Author: "",
  330. Font: font,
  331. IsPlainText: isRaw,
  332. IsCode: font == "code",
  333. IsLinkable: font != "code",
  334. Views: views,
  335. Language: string(language),
  336. Direction: d,
  337. }
  338. if !isRaw {
  339. post.HTMLContent = template.HTML(applyMarkdown([]byte(content), ""))
  340. }
  341. }
  342. // Check if post has been unpublished
  343. if content == "" {
  344. gone = true
  345. if isJSON {
  346. content = "{\"error\": \"Post was unpublished.\"}"
  347. } else if isCSS {
  348. content = ""
  349. } else if isRaw {
  350. content = "Post was unpublished."
  351. } else {
  352. return ErrPostUnpublished
  353. }
  354. }
  355. var u = &User{}
  356. if isRaw {
  357. contentType := "text/plain"
  358. if isJSON {
  359. contentType = "application/json"
  360. } else if isCSS {
  361. contentType = "text/css"
  362. } else if isXML {
  363. contentType = "application/xml"
  364. } else if isMarkdown {
  365. contentType = "text/markdown"
  366. }
  367. w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
  368. if isMarkdown && post.Title != "" {
  369. fmt.Fprintf(w, "%s\n", post.Title)
  370. for i := 1; i <= len(post.Title); i++ {
  371. fmt.Fprintf(w, "=")
  372. }
  373. fmt.Fprintf(w, "\n\n")
  374. }
  375. fmt.Fprint(w, content)
  376. if !found {
  377. return ErrPostNotFound
  378. } else if gone {
  379. return ErrPostUnpublished
  380. }
  381. } else {
  382. var err error
  383. page := struct {
  384. *AnonymousPost
  385. page.StaticPage
  386. Username string
  387. IsOwner bool
  388. SiteURL string
  389. }{
  390. AnonymousPost: post,
  391. StaticPage: pageForReq(app, r),
  392. SiteURL: app.cfg.App.Host,
  393. }
  394. if u = getUserSession(app, r); u != nil {
  395. page.Username = u.Username
  396. page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
  397. }
  398. err = templates["post"].ExecuteTemplate(w, "post", page)
  399. if err != nil {
  400. log.Error("Post template execute error: %v", err)
  401. }
  402. }
  403. go func() {
  404. if u != nil && ownerID.Valid && ownerID.Int64 == u.ID {
  405. // Post is owned by someone; skip view increment since that person is viewing this post.
  406. return
  407. }
  408. // Update stats for non-raw post views
  409. if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
  410. _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID)
  411. if err != nil {
  412. log.Error("Unable to update posts count: %v", err)
  413. }
  414. }
  415. }()
  416. return nil
  417. }
  418. // API v2 funcs
  419. // newPost creates a new post with or without an owning Collection.
  420. //
  421. // Endpoints:
  422. // /posts
  423. // /posts?collection={alias}
  424. // ? /collections/{alias}/posts
  425. func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
  426. reqJSON := IsJSON(r.Header.Get("Content-Type"))
  427. vars := mux.Vars(r)
  428. collAlias := vars["alias"]
  429. if collAlias == "" {
  430. collAlias = r.FormValue("collection")
  431. }
  432. accessToken := r.Header.Get("Authorization")
  433. if accessToken == "" {
  434. // TODO: remove this
  435. accessToken = r.FormValue("access_token")
  436. }
  437. // FIXME: determine web submission with Content-Type header
  438. var u *User
  439. var userID int64 = -1
  440. var username string
  441. if accessToken == "" {
  442. u = getUserSession(app, r)
  443. if u != nil {
  444. userID = u.ID
  445. username = u.Username
  446. }
  447. } else {
  448. userID = app.db.GetUserID(accessToken)
  449. }
  450. if userID == -1 {
  451. return ErrNotLoggedIn
  452. }
  453. if accessToken == "" && u == nil && collAlias != "" {
  454. return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."}
  455. }
  456. // Get post data
  457. var p *SubmittedPost
  458. if reqJSON {
  459. decoder := json.NewDecoder(r.Body)
  460. err := decoder.Decode(&p)
  461. if err != nil {
  462. log.Error("Couldn't parse new post JSON request: %v\n", err)
  463. return ErrBadJSON
  464. }
  465. if p.Title == nil {
  466. t := ""
  467. p.Title = &t
  468. }
  469. if strings.TrimSpace(*(p.Content)) == "" {
  470. return ErrNoPublishableContent
  471. }
  472. } else {
  473. post := r.FormValue("body")
  474. appearance := r.FormValue("font")
  475. title := r.FormValue("title")
  476. rtlValue := r.FormValue("rtl")
  477. langValue := r.FormValue("lang")
  478. if strings.TrimSpace(post) == "" {
  479. return ErrNoPublishableContent
  480. }
  481. var isRTL, rtlValid bool
  482. if rtlValue == "auto" && langValue != "" {
  483. isRTL = i18n.LangIsRTL(langValue)
  484. rtlValid = true
  485. } else {
  486. isRTL = rtlValue == "true"
  487. rtlValid = rtlValue != "" && langValue != ""
  488. }
  489. // Create a new post
  490. p = &SubmittedPost{
  491. Title: &title,
  492. Content: &post,
  493. Font: appearance,
  494. IsRTL: converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}},
  495. Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}},
  496. }
  497. }
  498. if !p.isFontValid() {
  499. p.Font = "norm"
  500. }
  501. var newPost *PublicPost = &PublicPost{}
  502. var coll *Collection
  503. var err error
  504. if accessToken != "" {
  505. newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias)
  506. } else {
  507. //return ErrNotLoggedIn
  508. // TODO: verify user is logged in
  509. var collID int64
  510. if collAlias != "" {
  511. coll, err = app.db.GetCollection(collAlias)
  512. if err != nil {
  513. return err
  514. }
  515. coll.hostName = app.cfg.App.Host
  516. if coll.OwnerID != u.ID {
  517. return ErrForbiddenCollection
  518. }
  519. collID = coll.ID
  520. }
  521. // TODO: return PublicPost from createPost
  522. newPost.Post, err = app.db.CreatePost(userID, collID, p)
  523. }
  524. if err != nil {
  525. return err
  526. }
  527. if coll != nil {
  528. coll.ForPublic()
  529. newPost.Collection = &CollectionObj{Collection: *coll}
  530. }
  531. newPost.extractData()
  532. newPost.OwnerName = username
  533. // Write success now
  534. response := impart.WriteSuccess(w, newPost, http.StatusCreated)
  535. if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
  536. go federatePost(app, newPost, newPost.Collection.ID, false)
  537. }
  538. return response
  539. }
  540. func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
  541. reqJSON := IsJSON(r.Header.Get("Content-Type"))
  542. vars := mux.Vars(r)
  543. postID := vars["post"]
  544. p := AuthenticatedPost{ID: postID}
  545. var err error
  546. if reqJSON {
  547. // Decode JSON request
  548. decoder := json.NewDecoder(r.Body)
  549. err = decoder.Decode(&p)
  550. if err != nil {
  551. log.Error("Couldn't parse post update JSON request: %v\n", err)
  552. return ErrBadJSON
  553. }
  554. } else {
  555. err = r.ParseForm()
  556. if err != nil {
  557. log.Error("Couldn't parse post update form request: %v\n", err)
  558. return ErrBadFormData
  559. }
  560. // Can't decode to a nil SubmittedPost property, so create instance now
  561. p.SubmittedPost = &SubmittedPost{}
  562. err = app.formDecoder.Decode(&p, r.PostForm)
  563. if err != nil {
  564. log.Error("Couldn't decode post update form request: %v\n", err)
  565. return ErrBadFormData
  566. }
  567. }
  568. if p.Web {
  569. p.IsRTL.Valid = true
  570. }
  571. if p.SubmittedPost == nil {
  572. return ErrPostNoUpdatableVals
  573. }
  574. // Ensure an access token was given
  575. accessToken := r.Header.Get("Authorization")
  576. // Get user's cookie session if there's no token
  577. var u *User
  578. //var username string
  579. if accessToken == "" {
  580. u = getUserSession(app, r)
  581. if u != nil {
  582. //username = u.Username
  583. }
  584. }
  585. if u == nil && accessToken == "" {
  586. return ErrNoAccessToken
  587. }
  588. // Get user ID from current session or given access token, if one was given.
  589. var userID int64
  590. if u != nil {
  591. userID = u.ID
  592. } else if accessToken != "" {
  593. userID, err = AuthenticateUser(app.db, accessToken)
  594. if err != nil {
  595. return err
  596. }
  597. }
  598. // Modify post struct
  599. p.ID = postID
  600. err = app.db.UpdateOwnedPost(&p, userID)
  601. if err != nil {
  602. if reqJSON {
  603. return err
  604. }
  605. if err, ok := err.(impart.HTTPError); ok {
  606. addSessionFlash(app, w, r, err.Message, nil)
  607. } else {
  608. addSessionFlash(app, w, r, err.Error(), nil)
  609. }
  610. }
  611. var pRes *PublicPost
  612. pRes, err = app.db.GetPost(p.ID, 0)
  613. if reqJSON {
  614. if err != nil {
  615. return err
  616. }
  617. pRes.extractData()
  618. }
  619. if pRes.CollectionID.Valid {
  620. coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64)
  621. if err == nil && !app.cfg.App.Private && app.cfg.App.Federation {
  622. coll.hostName = app.cfg.App.Host
  623. pRes.Collection = &CollectionObj{Collection: *coll}
  624. go federatePost(app, pRes, pRes.Collection.ID, true)
  625. }
  626. }
  627. // Write success now
  628. if reqJSON {
  629. return impart.WriteSuccess(w, pRes, http.StatusOK)
  630. }
  631. addSessionFlash(app, w, r, "Changes saved.", nil)
  632. collectionAlias := vars["alias"]
  633. redirect := "/" + postID + "/meta"
  634. if collectionAlias != "" {
  635. collPre := "/" + collectionAlias
  636. if app.cfg.App.SingleUser {
  637. collPre = ""
  638. }
  639. redirect = collPre + "/" + pRes.Slug.String + "/edit/meta"
  640. } else {
  641. if app.cfg.App.SingleUser {
  642. redirect = "/d" + redirect
  643. }
  644. }
  645. w.Header().Set("Location", redirect)
  646. w.WriteHeader(http.StatusFound)
  647. return nil
  648. }
  649. func deletePost(app *App, w http.ResponseWriter, r *http.Request) error {
  650. vars := mux.Vars(r)
  651. friendlyID := vars["post"]
  652. editToken := r.FormValue("token")
  653. var ownerID int64
  654. var u *User
  655. accessToken := r.Header.Get("Authorization")
  656. if accessToken == "" && editToken == "" {
  657. u = getUserSession(app, r)
  658. if u == nil {
  659. return ErrNoAccessToken
  660. }
  661. }
  662. var res sql.Result
  663. var t *sql.Tx
  664. var err error
  665. var collID sql.NullInt64
  666. var coll *Collection
  667. var pp *PublicPost
  668. if editToken != "" {
  669. // TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries
  670. var dummy int64
  671. err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy)
  672. switch {
  673. case err == sql.ErrNoRows:
  674. return impart.HTTPError{http.StatusNotFound, "Post not found."}
  675. }
  676. err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy)
  677. switch {
  678. case err == sql.ErrNoRows:
  679. // Post already has an owner. This could provide a bad experience
  680. // for the user, but it's more important to ensure data isn't lost
  681. // unexpectedly. So prevent deletion via token.
  682. return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."}
  683. }
  684. res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken)
  685. } else if accessToken != "" || u != nil {
  686. // Caller provided some way to authenticate; assume caller expects the
  687. // post to be deleted based on a specific post owner, thus we should
  688. // return corresponding errors.
  689. if accessToken != "" {
  690. ownerID = app.db.GetUserID(accessToken)
  691. if ownerID == -1 {
  692. return ErrBadAccessToken
  693. }
  694. } else {
  695. ownerID = u.ID
  696. }
  697. // TODO: don't make two queries
  698. var realOwnerID sql.NullInt64
  699. err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID)
  700. if err != nil {
  701. return err
  702. }
  703. if !collID.Valid {
  704. // There's no collection; simply delete the post
  705. res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
  706. } else {
  707. // Post belongs to a collection; do any additional clean up
  708. coll, err = app.db.GetCollectionBy("id = ?", collID.Int64)
  709. if err != nil {
  710. log.Error("Unable to get collection: %v", err)
  711. return err
  712. }
  713. if app.cfg.App.Federation {
  714. // First fetch full post for federation
  715. pp, err = app.db.GetOwnedPost(friendlyID, ownerID)
  716. if err != nil {
  717. log.Error("Unable to get owned post: %v", err)
  718. return err
  719. }
  720. collObj := &CollectionObj{Collection: *coll}
  721. pp.Collection = collObj
  722. }
  723. t, err = app.db.Begin()
  724. if err != nil {
  725. log.Error("No begin: %v", err)
  726. return err
  727. }
  728. res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
  729. }
  730. } else {
  731. return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."}
  732. }
  733. if err != nil {
  734. return err
  735. }
  736. affected, err := res.RowsAffected()
  737. if err != nil {
  738. if t != nil {
  739. t.Rollback()
  740. log.Error("Rows affected err! Rolling back")
  741. }
  742. return err
  743. } else if affected == 0 {
  744. if t != nil {
  745. t.Rollback()
  746. log.Error("No rows affected! Rolling back")
  747. }
  748. return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."}
  749. }
  750. if t != nil {
  751. t.Commit()
  752. }
  753. if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation {
  754. go deleteFederatedPost(app, pp, collID.Int64)
  755. }
  756. return impart.HTTPError{Status: http.StatusNoContent}
  757. }
  758. // addPost associates a post with the authenticated user.
  759. func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
  760. var ownerID int64
  761. // Authenticate user
  762. at := r.Header.Get("Authorization")
  763. if at != "" {
  764. ownerID = app.db.GetUserID(at)
  765. if ownerID == -1 {
  766. return ErrBadAccessToken
  767. }
  768. } else {
  769. u := getUserSession(app, r)
  770. if u == nil {
  771. return ErrNotLoggedIn
  772. }
  773. ownerID = u.ID
  774. }
  775. // Parse claimed posts in format:
  776. // [{"id": "...", "token": "..."}]
  777. var claims *[]ClaimPostRequest
  778. decoder := json.NewDecoder(r.Body)
  779. err := decoder.Decode(&claims)
  780. if err != nil {
  781. return ErrBadJSONArray
  782. }
  783. vars := mux.Vars(r)
  784. collAlias := vars["alias"]
  785. // Update all given posts
  786. res, err := app.db.ClaimPosts(ownerID, collAlias, claims)
  787. if err != nil {
  788. return err
  789. }
  790. if !app.cfg.App.Private && app.cfg.App.Federation {
  791. for _, pRes := range *res {
  792. if pRes.Code != http.StatusOK {
  793. continue
  794. }
  795. if !pRes.Post.Created.After(time.Now()) {
  796. pRes.Post.Collection.hostName = app.cfg.App.Host
  797. go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
  798. }
  799. }
  800. }
  801. return impart.WriteSuccess(w, res, http.StatusOK)
  802. }
  803. func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error {
  804. var ownerID int64
  805. // Authenticate user
  806. at := r.Header.Get("Authorization")
  807. if at != "" {
  808. ownerID = app.db.GetUserID(at)
  809. if ownerID == -1 {
  810. return ErrBadAccessToken
  811. }
  812. } else {
  813. u := getUserSession(app, r)
  814. if u == nil {
  815. return ErrNotLoggedIn
  816. }
  817. ownerID = u.ID
  818. }
  819. // Parse posts in format:
  820. // ["..."]
  821. var postIDs []string
  822. decoder := json.NewDecoder(r.Body)
  823. err := decoder.Decode(&postIDs)
  824. if err != nil {
  825. return ErrBadJSONArray
  826. }
  827. // Update all given posts
  828. res, err := app.db.DispersePosts(ownerID, postIDs)
  829. if err != nil {
  830. return err
  831. }
  832. return impart.WriteSuccess(w, res, http.StatusOK)
  833. }
  834. type (
  835. PinPostResult struct {
  836. ID string `json:"id,omitempty"`
  837. Code int `json:"code,omitempty"`
  838. ErrorMessage string `json:"error_msg,omitempty"`
  839. }
  840. )
  841. // pinPost pins a post to a blog
  842. func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
  843. var userID int64
  844. // Authenticate user
  845. at := r.Header.Get("Authorization")
  846. if at != "" {
  847. userID = app.db.GetUserID(at)
  848. if userID == -1 {
  849. return ErrBadAccessToken
  850. }
  851. } else {
  852. u := getUserSession(app, r)
  853. if u == nil {
  854. return ErrNotLoggedIn
  855. }
  856. userID = u.ID
  857. }
  858. // Parse request
  859. var posts []struct {
  860. ID string `json:"id"`
  861. Position int64 `json:"position"`
  862. }
  863. decoder := json.NewDecoder(r.Body)
  864. err := decoder.Decode(&posts)
  865. if err != nil {
  866. return ErrBadJSONArray
  867. }
  868. // Validate data
  869. vars := mux.Vars(r)
  870. collAlias := vars["alias"]
  871. coll, err := app.db.GetCollection(collAlias)
  872. if err != nil {
  873. return err
  874. }
  875. if coll.OwnerID != userID {
  876. return ErrForbiddenCollection
  877. }
  878. // Do (un)pinning
  879. isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin"
  880. res := []PinPostResult{}
  881. for _, p := range posts {
  882. err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position)
  883. ppr := PinPostResult{ID: p.ID}
  884. if err != nil {
  885. ppr.Code = http.StatusInternalServerError
  886. // TODO: set error messsage
  887. } else {
  888. ppr.Code = http.StatusOK
  889. }
  890. res = append(res, ppr)
  891. }
  892. return impart.WriteSuccess(w, res, http.StatusOK)
  893. }
  894. func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
  895. var collID int64
  896. var coll *Collection
  897. var err error
  898. vars := mux.Vars(r)
  899. if collAlias := vars["alias"]; collAlias != "" {
  900. // Fetch collection information, since an alias is provided
  901. coll, err = app.db.GetCollection(collAlias)
  902. if err != nil {
  903. return err
  904. }
  905. coll.hostName = app.cfg.App.Host
  906. _, err = apiCheckCollectionPermissions(app, r, coll)
  907. if err != nil {
  908. return err
  909. }
  910. collID = coll.ID
  911. }
  912. p, err := app.db.GetPost(vars["post"], collID)
  913. if err != nil {
  914. return err
  915. }
  916. p.extractData()
  917. accept := r.Header.Get("Accept")
  918. if strings.Contains(accept, "application/activity+json") {
  919. // Fetch information about the collection this belongs to
  920. if coll == nil && p.CollectionID.Valid {
  921. coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
  922. if err != nil {
  923. return err
  924. }
  925. }
  926. if coll == nil {
  927. // This is a draft post; 404 for now
  928. // TODO: return ActivityObject
  929. return impart.HTTPError{http.StatusNotFound, ""}
  930. }
  931. p.Collection = &CollectionObj{Collection: *coll}
  932. po := p.ActivityObject()
  933. po.Context = []interface{}{activitystreams.Namespace}
  934. return impart.RenderActivityJSON(w, po, http.StatusOK)
  935. }
  936. return impart.WriteSuccess(w, p, http.StatusOK)
  937. }
  938. func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
  939. vars := mux.Vars(r)
  940. p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"])
  941. if err != nil {
  942. return err
  943. }
  944. return impart.WriteSuccess(w, p, http.StatusOK)
  945. }
  946. func (p *Post) processPost() PublicPost {
  947. res := &PublicPost{Post: p, Views: 0}
  948. res.Views = p.ViewCount
  949. // TODO: move to own function
  950. loc := monday.FuzzyLocale(p.Language.String)
  951. res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
  952. return *res
  953. }
  954. func (p *PublicPost) CanonicalURL() string {
  955. if p.Collection == nil || p.Collection.Alias == "" {
  956. return p.Collection.hostName + "/" + p.ID
  957. }
  958. return p.Collection.CanonicalURL() + p.Slug.String
  959. }
  960. func (p *PublicPost) ActivityObject() *activitystreams.Object {
  961. o := activitystreams.NewArticleObject()
  962. o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
  963. o.Published = p.Created
  964. o.URL = p.CanonicalURL()
  965. o.AttributedTo = p.Collection.FederatedAccount()
  966. o.CC = []string{
  967. p.Collection.FederatedAccount() + "/followers",
  968. }
  969. o.Name = p.DisplayTitle()
  970. if p.HTMLContent == template.HTML("") {
  971. p.formatContent(false)
  972. }
  973. o.Content = string(p.HTMLContent)
  974. if p.Language.Valid {
  975. o.ContentMap = map[string]string{
  976. p.Language.String: string(p.HTMLContent),
  977. }
  978. }
  979. if len(p.Tags) == 0 {
  980. o.Tag = []activitystreams.Tag{}
  981. } else {
  982. var tagBaseURL string
  983. if isSingleUser {
  984. tagBaseURL = p.Collection.CanonicalURL() + "tag:"
  985. } else {
  986. tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias)
  987. }
  988. for _, t := range p.Tags {
  989. o.Tag = append(o.Tag, activitystreams.Tag{
  990. Type: activitystreams.TagHashtag,
  991. HRef: tagBaseURL + t,
  992. Name: "#" + t,
  993. })
  994. }
  995. }
  996. return o
  997. }
  998. // TODO: merge this into getSlugFromPost or phase it out
  999. func getSlug(title, lang string) string {
  1000. return getSlugFromPost("", title, lang)
  1001. }
  1002. func getSlugFromPost(title, body, lang string) string {
  1003. if title == "" {
  1004. title = postTitle(body, body)
  1005. }
  1006. title = parse.PostLede(title, false)
  1007. // Truncate lede if needed
  1008. title, _ = parse.TruncToWord(title, 80)
  1009. var s string
  1010. if lang != "" && len(lang) == 2 {
  1011. s = slug.MakeLang(title, lang)
  1012. } else {
  1013. s = slug.Make(title)
  1014. }
  1015. // Transliteration may cause the slug to expand past the limit, so truncate again
  1016. s, _ = parse.TruncToWord(s, 80)
  1017. return strings.TrimFunc(s, func(r rune) bool {
  1018. // TruncToWord doesn't respect words in a slug, since spaces are replaced
  1019. // with hyphens. So remove any trailing hyphens.
  1020. return r == '-'
  1021. })
  1022. }
  1023. // isFontValid returns whether or not the submitted post's appearance is valid.
  1024. func (p *SubmittedPost) isFontValid() bool {
  1025. validFonts := map[string]bool{
  1026. "norm": true,
  1027. "sans": true,
  1028. "mono": true,
  1029. "wrap": true,
  1030. "code": true,
  1031. }
  1032. _, valid := validFonts[p.Font]
  1033. return valid
  1034. }
  1035. func getRawPost(app *App, friendlyID string) *RawPost {
  1036. var content, font, title string
  1037. var isRTL sql.NullBool
  1038. var lang sql.NullString
  1039. var ownerID sql.NullInt64
  1040. var created time.Time
  1041. err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &ownerID)
  1042. switch {
  1043. case err == sql.ErrNoRows:
  1044. return &RawPost{Content: "", Found: false, Gone: false}
  1045. case err != nil:
  1046. return &RawPost{Content: "", Found: true, Gone: false}
  1047. }
  1048. return &RawPost{Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
  1049. }
  1050. // TODO; return a Post!
  1051. func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
  1052. var id, title, content, font string
  1053. var isRTL sql.NullBool
  1054. var lang sql.NullString
  1055. var created time.Time
  1056. var ownerID null.Int
  1057. var views int64
  1058. var err error
  1059. if app.cfg.App.SingleUser {
  1060. err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
  1061. } else {
  1062. err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
  1063. }
  1064. switch {
  1065. case err == sql.ErrNoRows:
  1066. return &RawPost{Content: "", Found: false, Gone: false}
  1067. case err != nil:
  1068. return &RawPost{Content: "", Found: true, Gone: false}
  1069. }
  1070. return &RawPost{
  1071. Id: id,
  1072. Slug: slug,
  1073. Title: title,
  1074. Content: content,
  1075. Font: font,
  1076. Created: created,
  1077. IsRTL: isRTL,
  1078. Language: lang,
  1079. OwnerID: ownerID.Int64,
  1080. Found: true,
  1081. Gone: content == "",
  1082. Views: views,
  1083. }
  1084. }
  1085. func isRaw(r *http.Request) bool {
  1086. vars := mux.Vars(r)
  1087. slug := vars["slug"]
  1088. // NOTE: until this is done better, be sure to keep this in parity with
  1089. // isRaw in viewCollectionPost() and handleViewPost()
  1090. isJSON := strings.HasSuffix(slug, ".json")
  1091. isXML := strings.HasSuffix(slug, ".xml")
  1092. isMarkdown := strings.HasSuffix(slug, ".md")
  1093. return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
  1094. }
  1095. func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error {
  1096. vars := mux.Vars(r)
  1097. slug := vars["slug"]
  1098. // NOTE: until this is done better, be sure to keep this in parity with
  1099. // isRaw() and handleViewPost()
  1100. isJSON := strings.HasSuffix(slug, ".json")
  1101. isXML := strings.HasSuffix(slug, ".xml")
  1102. isMarkdown := strings.HasSuffix(slug, ".md")
  1103. isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
  1104. cr := &collectionReq{}
  1105. err := processCollectionRequest(cr, vars, w, r)
  1106. if err != nil {
  1107. return err
  1108. }
  1109. // Check for hellbanned users
  1110. u, err := checkUserForCollection(app, cr, r, true)
  1111. if err != nil {
  1112. return err
  1113. }
  1114. // Normalize the URL, redirecting user to consistent post URL
  1115. if slug != strings.ToLower(slug) {
  1116. loc := fmt.Sprintf("/%s", strings.ToLower(slug))
  1117. if !app.cfg.App.SingleUser {
  1118. loc = "/" + cr.alias + loc
  1119. }
  1120. return impart.HTTPError{http.StatusMovedPermanently, loc}
  1121. }
  1122. // Display collection if this is a collection
  1123. var c *Collection
  1124. if app.cfg.App.SingleUser {
  1125. c, err = app.db.GetCollectionByID(1)
  1126. } else {
  1127. c, err = app.db.GetCollection(cr.alias)
  1128. }
  1129. if err != nil {
  1130. if err, ok := err.(impart.HTTPError); ok {
  1131. if err.Status == http.StatusNotFound {
  1132. // Redirect if necessary
  1133. newAlias := app.db.GetCollectionRedirect(cr.alias)
  1134. if newAlias != "" {
  1135. return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug}
  1136. }
  1137. }
  1138. }
  1139. return err
  1140. }
  1141. c.hostName = app.cfg.App.Host
  1142. // Check collection permissions
  1143. if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
  1144. return ErrPostNotFound
  1145. }
  1146. if c.IsProtected() && ((u == nil || u.ID != c.OwnerID) && !isAuthorizedForCollection(app, c.Alias, r)) {
  1147. return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
  1148. }
  1149. cr.isCollOwner = u != nil && c.OwnerID == u.ID
  1150. if isRaw {
  1151. slug = strings.Split(slug, ".")[0]
  1152. }
  1153. // Fetch extra data about the Collection
  1154. // TODO: refactor out this logic, shared in collection.go:fetchCollection()
  1155. coll := &CollectionObj{Collection: *c}
  1156. owner, err := app.db.GetUserByID(coll.OwnerID)
  1157. if err != nil {
  1158. // Log the error and just continue
  1159. log.Error("Error getting user for collection: %v", err)
  1160. } else {
  1161. coll.Owner = owner
  1162. }
  1163. p, err := app.db.GetPost(slug, coll.ID)
  1164. if err != nil {
  1165. if err == ErrCollectionPageNotFound && slug == "feed" {
  1166. // User tried to access blog feed without a trailing slash, and
  1167. // there's no post with a slug "feed"
  1168. return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/feed/"}
  1169. }
  1170. return err
  1171. }
  1172. p.IsOwner = owner != nil && p.OwnerID.Valid && owner.ID == p.OwnerID.Int64
  1173. p.Collection = coll
  1174. p.IsTopLevel = app.cfg.App.SingleUser
  1175. // Check if post has been unpublished
  1176. if p.Content == "" && p.Title.String == "" {
  1177. return impart.HTTPError{http.StatusGone, "Post was unpublished."}
  1178. }
  1179. // Serve collection post
  1180. if isRaw {
  1181. contentType := "text/plain"
  1182. if isJSON {
  1183. contentType = "application/json"
  1184. } else if isXML {
  1185. contentType = "application/xml"
  1186. } else if isMarkdown {
  1187. contentType = "text/markdown"
  1188. }
  1189. w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
  1190. if isMarkdown && p.Title.String != "" {
  1191. fmt.Fprintf(w, "# %s\n\n", p.Title.String)
  1192. }
  1193. fmt.Fprint(w, p.Content)
  1194. } else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
  1195. p.extractData()
  1196. ap := p.ActivityObject()
  1197. ap.Context = []interface{}{activitystreams.Namespace}
  1198. return impart.RenderActivityJSON(w, ap, http.StatusOK)
  1199. } else {
  1200. p.extractData()
  1201. p.Content = strings.Replace(p.Content, "<!--more-->", "", 1)
  1202. // TODO: move this to function
  1203. p.formatContent(cr.isCollOwner)
  1204. tp := struct {
  1205. *PublicPost
  1206. page.StaticPage
  1207. IsOwner bool
  1208. IsPinned bool
  1209. IsCustomDomain bool
  1210. PinnedPosts *[]PublicPost
  1211. IsAdmin bool
  1212. CanInvite bool
  1213. }{
  1214. PublicPost: p,
  1215. StaticPage: pageForReq(app, r),
  1216. IsOwner: cr.isCollOwner,
  1217. IsCustomDomain: cr.isCustomDomain,
  1218. }
  1219. tp.IsAdmin = u != nil && u.IsAdmin()
  1220. tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
  1221. tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll)
  1222. tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
  1223. postTmpl := "collection-post"
  1224. if app.cfg.App.Chorus {
  1225. postTmpl = "chorus-collection-post"
  1226. }
  1227. if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil {
  1228. log.Error("Error in collection-post template: %v", err)
  1229. }
  1230. }
  1231. go func() {
  1232. if p.OwnerID.Valid {
  1233. // Post is owned by someone. Don't update stats if owner is viewing the post.
  1234. if u != nil && p.OwnerID.Int64 == u.ID {
  1235. return
  1236. }
  1237. }
  1238. // Update stats for non-raw post views
  1239. if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
  1240. _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID)
  1241. if err != nil {
  1242. log.Error("Unable to update posts count: %v", err)
  1243. }
  1244. }
  1245. }()
  1246. return nil
  1247. }
  1248. // TODO: move this to utils after making it more generic
  1249. func PostsContains(sl *[]PublicPost, s *PublicPost) bool {
  1250. for _, e := range *sl {
  1251. if e.ID == s.ID {
  1252. return true
  1253. }
  1254. }
  1255. return false
  1256. }
  1257. func (p *Post) extractData() {
  1258. p.Tags = tags.Extract(p.Content)
  1259. p.extractImages()
  1260. }
  1261. func (rp *RawPost) UserFacingCreated() string {
  1262. return rp.Created.Format(postMetaDateFormat)
  1263. }
  1264. func (rp *RawPost) Created8601() string {
  1265. return rp.Created.Format("2006-01-02T15:04:05Z")
  1266. }
  1267. var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg|image)$`)
  1268. func (p *Post) extractImages() {
  1269. matches := extract.ExtractUrls(p.Content)
  1270. urls := map[string]bool{}
  1271. for i := range matches {
  1272. u := matches[i].Text
  1273. if !imageURLRegex.MatchString(u) {
  1274. continue
  1275. }
  1276. urls[u] = true
  1277. }
  1278. resURLs := make([]string, 0)
  1279. for k := range urls {
  1280. resURLs = append(resURLs, k)
  1281. }
  1282. p.Images = resURLs
  1283. }