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.
 
 
 
 
 

1587 lines
41 KiB

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