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.
 
 
 
 
 

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