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.
 
 
 
 
 

1533 lines
40 KiB

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