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.
 
 
 
 
 

1520 lines
39 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. suspended, err := app.db.IsUserSuspended(ownerID.Int64)
  343. if err != nil {
  344. log.Error("view post: get collection owner: %v", err)
  345. return ErrInternalGeneral
  346. }
  347. if suspended {
  348. return ErrPostNotFound
  349. }
  350. // Check if post has been unpublished
  351. if content == "" {
  352. gone = true
  353. if isJSON {
  354. content = "{\"error\": \"Post was unpublished.\"}"
  355. } else if isCSS {
  356. content = ""
  357. } else if isRaw {
  358. content = "Post was unpublished."
  359. } else {
  360. return ErrPostUnpublished
  361. }
  362. }
  363. var u = &User{}
  364. if isRaw {
  365. contentType := "text/plain"
  366. if isJSON {
  367. contentType = "application/json"
  368. } else if isCSS {
  369. contentType = "text/css"
  370. } else if isXML {
  371. contentType = "application/xml"
  372. } else if isMarkdown {
  373. contentType = "text/markdown"
  374. }
  375. w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
  376. if isMarkdown && post.Title != "" {
  377. fmt.Fprintf(w, "%s\n", post.Title)
  378. for i := 1; i <= len(post.Title); i++ {
  379. fmt.Fprintf(w, "=")
  380. }
  381. fmt.Fprintf(w, "\n\n")
  382. }
  383. fmt.Fprint(w, content)
  384. if !found {
  385. return ErrPostNotFound
  386. } else if gone {
  387. return ErrPostUnpublished
  388. }
  389. } else {
  390. var err error
  391. page := struct {
  392. *AnonymousPost
  393. page.StaticPage
  394. Username string
  395. IsOwner bool
  396. SiteURL string
  397. }{
  398. AnonymousPost: post,
  399. StaticPage: pageForReq(app, r),
  400. SiteURL: app.cfg.App.Host,
  401. }
  402. if u = getUserSession(app, r); u != nil {
  403. page.Username = u.Username
  404. page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
  405. }
  406. err = templates["post"].ExecuteTemplate(w, "post", page)
  407. if err != nil {
  408. log.Error("Post template execute error: %v", err)
  409. }
  410. }
  411. go func() {
  412. if u != nil && ownerID.Valid && ownerID.Int64 == u.ID {
  413. // Post is owned by someone; skip view increment since that person is viewing this post.
  414. return
  415. }
  416. // Update stats for non-raw post views
  417. if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
  418. _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID)
  419. if err != nil {
  420. log.Error("Unable to update posts count: %v", err)
  421. }
  422. }
  423. }()
  424. return nil
  425. }
  426. // API v2 funcs
  427. // newPost creates a new post with or without an owning Collection.
  428. //
  429. // Endpoints:
  430. // /posts
  431. // /posts?collection={alias}
  432. // ? /collections/{alias}/posts
  433. func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
  434. reqJSON := IsJSON(r.Header.Get("Content-Type"))
  435. vars := mux.Vars(r)
  436. collAlias := vars["alias"]
  437. if collAlias == "" {
  438. collAlias = r.FormValue("collection")
  439. }
  440. accessToken := r.Header.Get("Authorization")
  441. if accessToken == "" {
  442. // TODO: remove this
  443. accessToken = r.FormValue("access_token")
  444. }
  445. // FIXME: determine web submission with Content-Type header
  446. var u *User
  447. var userID int64 = -1
  448. var username string
  449. if accessToken == "" {
  450. u = getUserSession(app, r)
  451. if u != nil {
  452. userID = u.ID
  453. username = u.Username
  454. }
  455. } else {
  456. userID = app.db.GetUserID(accessToken)
  457. }
  458. suspended, err := app.db.IsUserSuspended(userID)
  459. if err != nil {
  460. log.Error("new post: get user: %v", err)
  461. return ErrInternalGeneral
  462. }
  463. if suspended {
  464. return ErrUserSuspended
  465. }
  466. if userID == -1 {
  467. return ErrNotLoggedIn
  468. }
  469. if accessToken == "" && u == nil && collAlias != "" {
  470. return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."}
  471. }
  472. // Get post data
  473. var p *SubmittedPost
  474. if reqJSON {
  475. decoder := json.NewDecoder(r.Body)
  476. err = decoder.Decode(&p)
  477. if err != nil {
  478. log.Error("Couldn't parse new post JSON request: %v\n", err)
  479. return ErrBadJSON
  480. }
  481. if p.Title == nil {
  482. t := ""
  483. p.Title = &t
  484. }
  485. if strings.TrimSpace(*(p.Content)) == "" {
  486. return ErrNoPublishableContent
  487. }
  488. } else {
  489. post := r.FormValue("body")
  490. appearance := r.FormValue("font")
  491. title := r.FormValue("title")
  492. rtlValue := r.FormValue("rtl")
  493. langValue := r.FormValue("lang")
  494. if strings.TrimSpace(post) == "" {
  495. return ErrNoPublishableContent
  496. }
  497. var isRTL, rtlValid bool
  498. if rtlValue == "auto" && langValue != "" {
  499. isRTL = i18n.LangIsRTL(langValue)
  500. rtlValid = true
  501. } else {
  502. isRTL = rtlValue == "true"
  503. rtlValid = rtlValue != "" && langValue != ""
  504. }
  505. // Create a new post
  506. p = &SubmittedPost{
  507. Title: &title,
  508. Content: &post,
  509. Font: appearance,
  510. IsRTL: converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}},
  511. Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}},
  512. }
  513. }
  514. if !p.isFontValid() {
  515. p.Font = "norm"
  516. }
  517. var newPost *PublicPost = &PublicPost{}
  518. var coll *Collection
  519. if accessToken != "" {
  520. newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host)
  521. } else {
  522. //return ErrNotLoggedIn
  523. // TODO: verify user is logged in
  524. var collID int64
  525. if collAlias != "" {
  526. coll, err = app.db.GetCollection(collAlias)
  527. if err != nil {
  528. return err
  529. }
  530. coll.hostName = app.cfg.App.Host
  531. if coll.OwnerID != u.ID {
  532. return ErrForbiddenCollection
  533. }
  534. collID = coll.ID
  535. }
  536. // TODO: return PublicPost from createPost
  537. newPost.Post, err = app.db.CreatePost(userID, collID, p)
  538. }
  539. if err != nil {
  540. return err
  541. }
  542. if coll != nil {
  543. coll.ForPublic()
  544. newPost.Collection = &CollectionObj{Collection: *coll}
  545. }
  546. newPost.extractData()
  547. newPost.OwnerName = username
  548. // Write success now
  549. response := impart.WriteSuccess(w, newPost, http.StatusCreated)
  550. if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
  551. go federatePost(app, newPost, newPost.Collection.ID, false)
  552. }
  553. return response
  554. }
  555. func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
  556. reqJSON := IsJSON(r.Header.Get("Content-Type"))
  557. vars := mux.Vars(r)
  558. postID := vars["post"]
  559. p := AuthenticatedPost{ID: postID}
  560. var err error
  561. if reqJSON {
  562. // Decode JSON request
  563. decoder := json.NewDecoder(r.Body)
  564. err = decoder.Decode(&p)
  565. if err != nil {
  566. log.Error("Couldn't parse post update JSON request: %v\n", err)
  567. return ErrBadJSON
  568. }
  569. } else {
  570. err = r.ParseForm()
  571. if err != nil {
  572. log.Error("Couldn't parse post update form request: %v\n", err)
  573. return ErrBadFormData
  574. }
  575. // Can't decode to a nil SubmittedPost property, so create instance now
  576. p.SubmittedPost = &SubmittedPost{}
  577. err = app.formDecoder.Decode(&p, r.PostForm)
  578. if err != nil {
  579. log.Error("Couldn't decode post update form request: %v\n", err)
  580. return ErrBadFormData
  581. }
  582. }
  583. if p.Web {
  584. p.IsRTL.Valid = true
  585. }
  586. if p.SubmittedPost == nil {
  587. return ErrPostNoUpdatableVals
  588. }
  589. // Ensure an access token was given
  590. accessToken := r.Header.Get("Authorization")
  591. // Get user's cookie session if there's no token
  592. var u *User
  593. //var username string
  594. if accessToken == "" {
  595. u = getUserSession(app, r)
  596. if u != nil {
  597. //username = u.Username
  598. }
  599. }
  600. if u == nil && accessToken == "" {
  601. return ErrNoAccessToken
  602. }
  603. // Get user ID from current session or given access token, if one was given.
  604. var userID int64
  605. if u != nil {
  606. userID = u.ID
  607. } else if accessToken != "" {
  608. userID, err = AuthenticateUser(app.db, accessToken)
  609. if err != nil {
  610. return err
  611. }
  612. }
  613. suspended, err := app.db.IsUserSuspended(userID)
  614. if err != nil {
  615. log.Error("existing post: get user: %v", err)
  616. return ErrInternalGeneral
  617. }
  618. if suspended {
  619. return ErrUserSuspended
  620. }
  621. // Modify post struct
  622. p.ID = postID
  623. err = app.db.UpdateOwnedPost(&p, userID)
  624. if err != nil {
  625. if reqJSON {
  626. return err
  627. }
  628. if err, ok := err.(impart.HTTPError); ok {
  629. addSessionFlash(app, w, r, err.Message, nil)
  630. } else {
  631. addSessionFlash(app, w, r, err.Error(), nil)
  632. }
  633. }
  634. var pRes *PublicPost
  635. pRes, err = app.db.GetPost(p.ID, 0)
  636. if reqJSON {
  637. if err != nil {
  638. return err
  639. }
  640. pRes.extractData()
  641. }
  642. if pRes.CollectionID.Valid {
  643. coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64)
  644. if err == nil && !app.cfg.App.Private && app.cfg.App.Federation {
  645. coll.hostName = app.cfg.App.Host
  646. pRes.Collection = &CollectionObj{Collection: *coll}
  647. go federatePost(app, pRes, pRes.Collection.ID, true)
  648. }
  649. }
  650. // Write success now
  651. if reqJSON {
  652. return impart.WriteSuccess(w, pRes, http.StatusOK)
  653. }
  654. addSessionFlash(app, w, r, "Changes saved.", nil)
  655. collectionAlias := vars["alias"]
  656. redirect := "/" + postID + "/meta"
  657. if collectionAlias != "" {
  658. collPre := "/" + collectionAlias
  659. if app.cfg.App.SingleUser {
  660. collPre = ""
  661. }
  662. redirect = collPre + "/" + pRes.Slug.String + "/edit/meta"
  663. } else {
  664. if app.cfg.App.SingleUser {
  665. redirect = "/d" + redirect
  666. }
  667. }
  668. w.Header().Set("Location", redirect)
  669. w.WriteHeader(http.StatusFound)
  670. return nil
  671. }
  672. func deletePost(app *App, w http.ResponseWriter, r *http.Request) error {
  673. vars := mux.Vars(r)
  674. friendlyID := vars["post"]
  675. editToken := r.FormValue("token")
  676. var ownerID int64
  677. var u *User
  678. accessToken := r.Header.Get("Authorization")
  679. if accessToken == "" && editToken == "" {
  680. u = getUserSession(app, r)
  681. if u == nil {
  682. return ErrNoAccessToken
  683. }
  684. }
  685. var res sql.Result
  686. var t *sql.Tx
  687. var err error
  688. var collID sql.NullInt64
  689. var coll *Collection
  690. var pp *PublicPost
  691. if editToken != "" {
  692. // TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries
  693. var dummy int64
  694. err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy)
  695. switch {
  696. case err == sql.ErrNoRows:
  697. return impart.HTTPError{http.StatusNotFound, "Post not found."}
  698. }
  699. err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy)
  700. switch {
  701. case err == sql.ErrNoRows:
  702. // Post already has an owner. This could provide a bad experience
  703. // for the user, but it's more important to ensure data isn't lost
  704. // unexpectedly. So prevent deletion via token.
  705. return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."}
  706. }
  707. res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken)
  708. } else if accessToken != "" || u != nil {
  709. // Caller provided some way to authenticate; assume caller expects the
  710. // post to be deleted based on a specific post owner, thus we should
  711. // return corresponding errors.
  712. if accessToken != "" {
  713. ownerID = app.db.GetUserID(accessToken)
  714. if ownerID == -1 {
  715. return ErrBadAccessToken
  716. }
  717. } else {
  718. ownerID = u.ID
  719. }
  720. // TODO: don't make two queries
  721. var realOwnerID sql.NullInt64
  722. err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID)
  723. if err != nil {
  724. return err
  725. }
  726. if !collID.Valid {
  727. // There's no collection; simply delete the post
  728. res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
  729. } else {
  730. // Post belongs to a collection; do any additional clean up
  731. coll, err = app.db.GetCollectionBy("id = ?", collID.Int64)
  732. if err != nil {
  733. log.Error("Unable to get collection: %v", err)
  734. return err
  735. }
  736. if app.cfg.App.Federation {
  737. // First fetch full post for federation
  738. pp, err = app.db.GetOwnedPost(friendlyID, ownerID)
  739. if err != nil {
  740. log.Error("Unable to get owned post: %v", err)
  741. return err
  742. }
  743. collObj := &CollectionObj{Collection: *coll}
  744. pp.Collection = collObj
  745. }
  746. t, err = app.db.Begin()
  747. if err != nil {
  748. log.Error("No begin: %v", err)
  749. return err
  750. }
  751. res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
  752. }
  753. } else {
  754. return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."}
  755. }
  756. if err != nil {
  757. return err
  758. }
  759. affected, err := res.RowsAffected()
  760. if err != nil {
  761. if t != nil {
  762. t.Rollback()
  763. log.Error("Rows affected err! Rolling back")
  764. }
  765. return err
  766. } else if affected == 0 {
  767. if t != nil {
  768. t.Rollback()
  769. log.Error("No rows affected! Rolling back")
  770. }
  771. return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."}
  772. }
  773. if t != nil {
  774. t.Commit()
  775. }
  776. if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation {
  777. go deleteFederatedPost(app, pp, collID.Int64)
  778. }
  779. return impart.HTTPError{Status: http.StatusNoContent}
  780. }
  781. // addPost associates a post with the authenticated user.
  782. func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
  783. var ownerID int64
  784. // Authenticate user
  785. at := r.Header.Get("Authorization")
  786. if at != "" {
  787. ownerID = app.db.GetUserID(at)
  788. if ownerID == -1 {
  789. return ErrBadAccessToken
  790. }
  791. } else {
  792. u := getUserSession(app, r)
  793. if u == nil {
  794. return ErrNotLoggedIn
  795. }
  796. ownerID = u.ID
  797. }
  798. suspended, err := app.db.IsUserSuspended(ownerID)
  799. if err != nil {
  800. log.Error("add post: get user: %v", err)
  801. return ErrInternalGeneral
  802. }
  803. if suspended {
  804. return ErrUserSuspended
  805. }
  806. // Parse claimed posts in format:
  807. // [{"id": "...", "token": "..."}]
  808. var claims *[]ClaimPostRequest
  809. decoder := json.NewDecoder(r.Body)
  810. err = decoder.Decode(&claims)
  811. if err != nil {
  812. return ErrBadJSONArray
  813. }
  814. vars := mux.Vars(r)
  815. collAlias := vars["alias"]
  816. // Update all given posts
  817. res, err := app.db.ClaimPosts(app.cfg, ownerID, collAlias, claims)
  818. if err != nil {
  819. return err
  820. }
  821. if !app.cfg.App.Private && app.cfg.App.Federation {
  822. for _, pRes := range *res {
  823. if pRes.Code != http.StatusOK {
  824. continue
  825. }
  826. if !pRes.Post.Created.After(time.Now()) {
  827. pRes.Post.Collection.hostName = app.cfg.App.Host
  828. go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
  829. }
  830. }
  831. }
  832. return impart.WriteSuccess(w, res, http.StatusOK)
  833. }
  834. func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error {
  835. var ownerID int64
  836. // Authenticate user
  837. at := r.Header.Get("Authorization")
  838. if at != "" {
  839. ownerID = app.db.GetUserID(at)
  840. if ownerID == -1 {
  841. return ErrBadAccessToken
  842. }
  843. } else {
  844. u := getUserSession(app, r)
  845. if u == nil {
  846. return ErrNotLoggedIn
  847. }
  848. ownerID = u.ID
  849. }
  850. // Parse posts in format:
  851. // ["..."]
  852. var postIDs []string
  853. decoder := json.NewDecoder(r.Body)
  854. err := decoder.Decode(&postIDs)
  855. if err != nil {
  856. return ErrBadJSONArray
  857. }
  858. // Update all given posts
  859. res, err := app.db.DispersePosts(ownerID, postIDs)
  860. if err != nil {
  861. return err
  862. }
  863. return impart.WriteSuccess(w, res, http.StatusOK)
  864. }
  865. type (
  866. PinPostResult struct {
  867. ID string `json:"id,omitempty"`
  868. Code int `json:"code,omitempty"`
  869. ErrorMessage string `json:"error_msg,omitempty"`
  870. }
  871. )
  872. // pinPost pins a post to a blog
  873. func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
  874. var userID int64
  875. // Authenticate user
  876. at := r.Header.Get("Authorization")
  877. if at != "" {
  878. userID = app.db.GetUserID(at)
  879. if userID == -1 {
  880. return ErrBadAccessToken
  881. }
  882. } else {
  883. u := getUserSession(app, r)
  884. if u == nil {
  885. return ErrNotLoggedIn
  886. }
  887. userID = u.ID
  888. }
  889. suspended, err := app.db.IsUserSuspended(userID)
  890. if err != nil {
  891. log.Error("pin post: get user: %v", err)
  892. return ErrInternalGeneral
  893. }
  894. if suspended {
  895. return ErrUserSuspended
  896. }
  897. // Parse request
  898. var posts []struct {
  899. ID string `json:"id"`
  900. Position int64 `json:"position"`
  901. }
  902. decoder := json.NewDecoder(r.Body)
  903. err = decoder.Decode(&posts)
  904. if err != nil {
  905. return ErrBadJSONArray
  906. }
  907. // Validate data
  908. vars := mux.Vars(r)
  909. collAlias := vars["alias"]
  910. coll, err := app.db.GetCollection(collAlias)
  911. if err != nil {
  912. return err
  913. }
  914. if coll.OwnerID != userID {
  915. return ErrForbiddenCollection
  916. }
  917. // Do (un)pinning
  918. isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin"
  919. res := []PinPostResult{}
  920. for _, p := range posts {
  921. err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position)
  922. ppr := PinPostResult{ID: p.ID}
  923. if err != nil {
  924. ppr.Code = http.StatusInternalServerError
  925. // TODO: set error messsage
  926. } else {
  927. ppr.Code = http.StatusOK
  928. }
  929. res = append(res, ppr)
  930. }
  931. return impart.WriteSuccess(w, res, http.StatusOK)
  932. }
  933. func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
  934. var collID int64
  935. var ownerID int64
  936. var coll *Collection
  937. var err error
  938. vars := mux.Vars(r)
  939. if collAlias := vars["alias"]; collAlias != "" {
  940. // Fetch collection information, since an alias is provided
  941. coll, err = app.db.GetCollection(collAlias)
  942. if err != nil {
  943. return err
  944. }
  945. coll.hostName = app.cfg.App.Host
  946. _, err = apiCheckCollectionPermissions(app, r, coll)
  947. if err != nil {
  948. return err
  949. }
  950. collID = coll.ID
  951. ownerID = coll.OwnerID
  952. }
  953. p, err := app.db.GetPost(vars["post"], collID)
  954. if err != nil {
  955. return err
  956. }
  957. suspended, err := app.db.IsUserSuspended(ownerID)
  958. if err != nil {
  959. log.Error("fetch post: get owner: %v", err)
  960. return ErrInternalGeneral
  961. }
  962. if suspended {
  963. return ErrPostNotFound
  964. }
  965. p.extractData()
  966. accept := r.Header.Get("Accept")
  967. if strings.Contains(accept, "application/activity+json") {
  968. // Fetch information about the collection this belongs to
  969. if coll == nil && p.CollectionID.Valid {
  970. coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
  971. if err != nil {
  972. return err
  973. }
  974. }
  975. if coll == nil {
  976. // This is a draft post; 404 for now
  977. // TODO: return ActivityObject
  978. return impart.HTTPError{http.StatusNotFound, ""}
  979. }
  980. p.Collection = &CollectionObj{Collection: *coll}
  981. po := p.ActivityObject()
  982. po.Context = []interface{}{activitystreams.Namespace}
  983. return impart.RenderActivityJSON(w, po, http.StatusOK)
  984. }
  985. return impart.WriteSuccess(w, p, http.StatusOK)
  986. }
  987. func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
  988. vars := mux.Vars(r)
  989. p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"])
  990. if err != nil {
  991. return err
  992. }
  993. return impart.WriteSuccess(w, p, http.StatusOK)
  994. }
  995. func (p *Post) processPost() PublicPost {
  996. res := &PublicPost{Post: p, Views: 0}
  997. res.Views = p.ViewCount
  998. // TODO: move to own function
  999. loc := monday.FuzzyLocale(p.Language.String)
  1000. res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
  1001. return *res
  1002. }
  1003. func (p *PublicPost) CanonicalURL() string {
  1004. if p.Collection == nil || p.Collection.Alias == "" {
  1005. return p.Collection.hostName + "/" + p.ID
  1006. }
  1007. return p.Collection.CanonicalURL() + p.Slug.String
  1008. }
  1009. func (p *PublicPost) ActivityObject() *activitystreams.Object {
  1010. o := activitystreams.NewArticleObject()
  1011. o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
  1012. o.Published = p.Created
  1013. o.URL = p.CanonicalURL()
  1014. o.AttributedTo = p.Collection.FederatedAccount()
  1015. o.CC = []string{
  1016. p.Collection.FederatedAccount() + "/followers",
  1017. }
  1018. o.Name = p.DisplayTitle()
  1019. if p.HTMLContent == template.HTML("") {
  1020. p.formatContent(false)
  1021. }
  1022. o.Content = string(p.HTMLContent)
  1023. if p.Language.Valid {
  1024. o.ContentMap = map[string]string{
  1025. p.Language.String: string(p.HTMLContent),
  1026. }
  1027. }
  1028. if len(p.Tags) == 0 {
  1029. o.Tag = []activitystreams.Tag{}
  1030. } else {
  1031. var tagBaseURL string
  1032. if isSingleUser {
  1033. tagBaseURL = p.Collection.CanonicalURL() + "tag:"
  1034. } else {
  1035. tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias)
  1036. }
  1037. for _, t := range p.Tags {
  1038. o.Tag = append(o.Tag, activitystreams.Tag{
  1039. Type: activitystreams.TagHashtag,
  1040. HRef: tagBaseURL + t,
  1041. Name: "#" + t,
  1042. })
  1043. }
  1044. }
  1045. return o
  1046. }
  1047. // TODO: merge this into getSlugFromPost or phase it out
  1048. func getSlug(title, lang string) string {
  1049. return getSlugFromPost("", title, lang)
  1050. }
  1051. func getSlugFromPost(title, body, lang string) string {
  1052. if title == "" {
  1053. title = postTitle(body, body)
  1054. }
  1055. title = parse.PostLede(title, false)
  1056. // Truncate lede if needed
  1057. title, _ = parse.TruncToWord(title, 80)
  1058. var s string
  1059. if lang != "" && len(lang) == 2 {
  1060. s = slug.MakeLang(title, lang)
  1061. } else {
  1062. s = slug.Make(title)
  1063. }
  1064. // Transliteration may cause the slug to expand past the limit, so truncate again
  1065. s, _ = parse.TruncToWord(s, 80)
  1066. return strings.TrimFunc(s, func(r rune) bool {
  1067. // TruncToWord doesn't respect words in a slug, since spaces are replaced
  1068. // with hyphens. So remove any trailing hyphens.
  1069. return r == '-'
  1070. })
  1071. }
  1072. // isFontValid returns whether or not the submitted post's appearance is valid.
  1073. func (p *SubmittedPost) isFontValid() bool {
  1074. validFonts := map[string]bool{
  1075. "norm": true,
  1076. "sans": true,
  1077. "mono": true,
  1078. "wrap": true,
  1079. "code": true,
  1080. }
  1081. _, valid := validFonts[p.Font]
  1082. return valid
  1083. }
  1084. func getRawPost(app *App, friendlyID string) *RawPost {
  1085. var content, font, title string
  1086. var isRTL sql.NullBool
  1087. var lang sql.NullString
  1088. var ownerID sql.NullInt64
  1089. var created time.Time
  1090. 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)
  1091. switch {
  1092. case err == sql.ErrNoRows:
  1093. return &RawPost{Content: "", Found: false, Gone: false}
  1094. case err != nil:
  1095. return &RawPost{Content: "", Found: true, Gone: false}
  1096. }
  1097. return &RawPost{Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
  1098. }
  1099. // TODO; return a Post!
  1100. func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
  1101. var id, title, content, font string
  1102. var isRTL sql.NullBool
  1103. var lang sql.NullString
  1104. var created time.Time
  1105. var ownerID null.Int
  1106. var views int64
  1107. var err error
  1108. if app.cfg.App.SingleUser {
  1109. 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)
  1110. } else {
  1111. 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)
  1112. }
  1113. switch {
  1114. case err == sql.ErrNoRows:
  1115. return &RawPost{Content: "", Found: false, Gone: false}
  1116. case err != nil:
  1117. return &RawPost{Content: "", Found: true, Gone: false}
  1118. }
  1119. return &RawPost{
  1120. Id: id,
  1121. Slug: slug,
  1122. Title: title,
  1123. Content: content,
  1124. Font: font,
  1125. Created: created,
  1126. IsRTL: isRTL,
  1127. Language: lang,
  1128. OwnerID: ownerID.Int64,
  1129. Found: true,
  1130. Gone: content == "",
  1131. Views: views,
  1132. }
  1133. }
  1134. func isRaw(r *http.Request) bool {
  1135. vars := mux.Vars(r)
  1136. slug := vars["slug"]
  1137. // NOTE: until this is done better, be sure to keep this in parity with
  1138. // isRaw in viewCollectionPost() and handleViewPost()
  1139. isJSON := strings.HasSuffix(slug, ".json")
  1140. isXML := strings.HasSuffix(slug, ".xml")
  1141. isMarkdown := strings.HasSuffix(slug, ".md")
  1142. return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
  1143. }
  1144. func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error {
  1145. vars := mux.Vars(r)
  1146. slug := vars["slug"]
  1147. // NOTE: until this is done better, be sure to keep this in parity with
  1148. // isRaw() and handleViewPost()
  1149. isJSON := strings.HasSuffix(slug, ".json")
  1150. isXML := strings.HasSuffix(slug, ".xml")
  1151. isMarkdown := strings.HasSuffix(slug, ".md")
  1152. isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
  1153. cr := &collectionReq{}
  1154. err := processCollectionRequest(cr, vars, w, r)
  1155. if err != nil {
  1156. return err
  1157. }
  1158. // Check for hellbanned users
  1159. u, err := checkUserForCollection(app, cr, r, true)
  1160. if err != nil {
  1161. return err
  1162. }
  1163. // Normalize the URL, redirecting user to consistent post URL
  1164. if slug != strings.ToLower(slug) {
  1165. loc := fmt.Sprintf("/%s", strings.ToLower(slug))
  1166. if !app.cfg.App.SingleUser {
  1167. loc = "/" + cr.alias + loc
  1168. }
  1169. return impart.HTTPError{http.StatusMovedPermanently, loc}
  1170. }
  1171. // Display collection if this is a collection
  1172. var c *Collection
  1173. if app.cfg.App.SingleUser {
  1174. c, err = app.db.GetCollectionByID(1)
  1175. } else {
  1176. c, err = app.db.GetCollection(cr.alias)
  1177. }
  1178. if err != nil {
  1179. if err, ok := err.(impart.HTTPError); ok {
  1180. if err.Status == http.StatusNotFound {
  1181. // Redirect if necessary
  1182. newAlias := app.db.GetCollectionRedirect(cr.alias)
  1183. if newAlias != "" {
  1184. return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug}
  1185. }
  1186. }
  1187. }
  1188. return err
  1189. }
  1190. c.hostName = app.cfg.App.Host
  1191. suspended, err := app.db.IsUserSuspended(c.OwnerID)
  1192. if err != nil {
  1193. log.Error("view collection post: get owner: %v", err)
  1194. return ErrInternalGeneral
  1195. }
  1196. if suspended {
  1197. return ErrPostNotFound
  1198. }
  1199. // Check collection permissions
  1200. if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
  1201. return ErrPostNotFound
  1202. }
  1203. if c.IsProtected() && ((u == nil || u.ID != c.OwnerID) && !isAuthorizedForCollection(app, c.Alias, r)) {
  1204. return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
  1205. }
  1206. cr.isCollOwner = u != nil && c.OwnerID == u.ID
  1207. if isRaw {
  1208. slug = strings.Split(slug, ".")[0]
  1209. }
  1210. // Fetch extra data about the Collection
  1211. // TODO: refactor out this logic, shared in collection.go:fetchCollection()
  1212. coll := &CollectionObj{Collection: *c}
  1213. owner, err := app.db.GetUserByID(coll.OwnerID)
  1214. if err != nil {
  1215. // Log the error and just continue
  1216. log.Error("Error getting user for collection: %v", err)
  1217. } else {
  1218. coll.Owner = owner
  1219. }
  1220. postFound := true
  1221. p, err := app.db.GetPost(slug, coll.ID)
  1222. if err != nil {
  1223. if err == ErrCollectionPageNotFound {
  1224. postFound = false
  1225. if slug == "feed" {
  1226. // User tried to access blog feed without a trailing slash, and
  1227. // there's no post with a slug "feed"
  1228. return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/feed/"}
  1229. }
  1230. po := &Post{
  1231. Slug: null.NewString(slug, true),
  1232. Font: "norm",
  1233. Language: zero.NewString("en", true),
  1234. RTL: zero.NewBool(false, true),
  1235. Content: `<p class="msg">This page is missing.</p>
  1236. Are you sure it was ever here?`,
  1237. }
  1238. pp := po.processPost()
  1239. p = &pp
  1240. } else {
  1241. return err
  1242. }
  1243. }
  1244. p.IsOwner = owner != nil && p.OwnerID.Valid && owner.ID == p.OwnerID.Int64
  1245. p.Collection = coll
  1246. p.IsTopLevel = app.cfg.App.SingleUser
  1247. // Check if post has been unpublished
  1248. if p.Content == "" && p.Title.String == "" {
  1249. return impart.HTTPError{http.StatusGone, "Post was unpublished."}
  1250. }
  1251. // Serve collection post
  1252. if isRaw {
  1253. contentType := "text/plain"
  1254. if isJSON {
  1255. contentType = "application/json"
  1256. } else if isXML {
  1257. contentType = "application/xml"
  1258. } else if isMarkdown {
  1259. contentType = "text/markdown"
  1260. }
  1261. w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
  1262. if !postFound {
  1263. w.WriteHeader(http.StatusNotFound)
  1264. fmt.Fprintf(w, "Post not found.")
  1265. // TODO: return error instead, so status is correctly reflected in logs
  1266. return nil
  1267. }
  1268. if isMarkdown && p.Title.String != "" {
  1269. fmt.Fprintf(w, "# %s\n\n", p.Title.String)
  1270. }
  1271. fmt.Fprint(w, p.Content)
  1272. } else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
  1273. if !postFound {
  1274. return ErrCollectionPageNotFound
  1275. }
  1276. p.extractData()
  1277. ap := p.ActivityObject()
  1278. ap.Context = []interface{}{activitystreams.Namespace}
  1279. return impart.RenderActivityJSON(w, ap, http.StatusOK)
  1280. } else {
  1281. p.extractData()
  1282. p.Content = strings.Replace(p.Content, "<!--more-->", "", 1)
  1283. // TODO: move this to function
  1284. p.formatContent(cr.isCollOwner)
  1285. tp := struct {
  1286. *PublicPost
  1287. page.StaticPage
  1288. IsOwner bool
  1289. IsPinned bool
  1290. IsCustomDomain bool
  1291. PinnedPosts *[]PublicPost
  1292. IsFound bool
  1293. }{
  1294. PublicPost: p,
  1295. StaticPage: pageForReq(app, r),
  1296. IsOwner: cr.isCollOwner,
  1297. IsCustomDomain: cr.isCustomDomain,
  1298. IsFound: postFound,
  1299. }
  1300. tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll)
  1301. tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
  1302. if !postFound {
  1303. w.WriteHeader(http.StatusNotFound)
  1304. }
  1305. if err := templates["collection-post"].ExecuteTemplate(w, "post", tp); err != nil {
  1306. log.Error("Error in collection-post template: %v", err)
  1307. }
  1308. }
  1309. go func() {
  1310. if p.OwnerID.Valid {
  1311. // Post is owned by someone. Don't update stats if owner is viewing the post.
  1312. if u != nil && p.OwnerID.Int64 == u.ID {
  1313. return
  1314. }
  1315. }
  1316. // Update stats for non-raw post views
  1317. if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
  1318. _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID)
  1319. if err != nil {
  1320. log.Error("Unable to update posts count: %v", err)
  1321. }
  1322. }
  1323. }()
  1324. return nil
  1325. }
  1326. // TODO: move this to utils after making it more generic
  1327. func PostsContains(sl *[]PublicPost, s *PublicPost) bool {
  1328. for _, e := range *sl {
  1329. if e.ID == s.ID {
  1330. return true
  1331. }
  1332. }
  1333. return false
  1334. }
  1335. func (p *Post) extractData() {
  1336. p.Tags = tags.Extract(p.Content)
  1337. p.extractImages()
  1338. }
  1339. func (rp *RawPost) UserFacingCreated() string {
  1340. return rp.Created.Format(postMetaDateFormat)
  1341. }
  1342. func (rp *RawPost) Created8601() string {
  1343. return rp.Created.Format("2006-01-02T15:04:05Z")
  1344. }
  1345. var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg|image)$`)
  1346. func (p *Post) extractImages() {
  1347. matches := extract.ExtractUrls(p.Content)
  1348. urls := map[string]bool{}
  1349. for i := range matches {
  1350. u := matches[i].Text
  1351. if !imageURLRegex.MatchString(u) {
  1352. continue
  1353. }
  1354. urls[u] = true
  1355. }
  1356. resURLs := make([]string, 0)
  1357. for k := range urls {
  1358. resURLs = append(resURLs, k)
  1359. }
  1360. p.Images = resURLs
  1361. }