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.
 
 
 
 
 

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