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.
 
 
 
 
 

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