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.
 
 
 
 
 

1640 lines
42 KiB

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