A clean, Markdown-based publishing platform made for writers. Write together, and build a community. https://writefreely.org
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 
 

1536 wiersze
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: %v", err)
  346. return ErrInternalGeneral
  347. }
  348. // Check if post has been unpublished
  349. if content == "" {
  350. gone = true
  351. if isJSON {
  352. content = "{\"error\": \"Post was unpublished.\"}"
  353. } else if isCSS {
  354. content = ""
  355. } else if isRaw {
  356. content = "Post was unpublished."
  357. } else {
  358. return ErrPostUnpublished
  359. }
  360. }
  361. var u = &User{}
  362. if isRaw {
  363. contentType := "text/plain"
  364. if isJSON {
  365. contentType = "application/json"
  366. } else if isCSS {
  367. contentType = "text/css"
  368. } else if isXML {
  369. contentType = "application/xml"
  370. } else if isMarkdown {
  371. contentType = "text/markdown"
  372. }
  373. w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
  374. if isMarkdown && post.Title != "" {
  375. fmt.Fprintf(w, "%s\n", post.Title)
  376. for i := 1; i <= len(post.Title); i++ {
  377. fmt.Fprintf(w, "=")
  378. }
  379. fmt.Fprintf(w, "\n\n")
  380. }
  381. fmt.Fprint(w, content)
  382. if !found {
  383. return ErrPostNotFound
  384. } else if gone {
  385. return ErrPostUnpublished
  386. }
  387. } else {
  388. var err error
  389. page := struct {
  390. *AnonymousPost
  391. page.StaticPage
  392. Username string
  393. IsOwner bool
  394. SiteURL string
  395. Suspended bool
  396. }{
  397. AnonymousPost: post,
  398. StaticPage: pageForReq(app, r),
  399. SiteURL: app.cfg.App.Host,
  400. }
  401. if u = getUserSession(app, r); u != nil {
  402. page.Username = u.Username
  403. page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
  404. }
  405. if !page.IsOwner && suspended {
  406. return ErrPostNotFound
  407. }
  408. page.Suspended = suspended
  409. err = templates["post"].ExecuteTemplate(w, "post", page)
  410. if err != nil {
  411. log.Error("Post template execute error: %v", err)
  412. }
  413. }
  414. go func() {
  415. if u != nil && ownerID.Valid && ownerID.Int64 == u.ID {
  416. // Post is owned by someone; skip view increment since that person is viewing this post.
  417. return
  418. }
  419. // Update stats for non-raw post views
  420. if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
  421. _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID)
  422. if err != nil {
  423. log.Error("Unable to update posts count: %v", err)
  424. }
  425. }
  426. }()
  427. return nil
  428. }
  429. // API v2 funcs
  430. // newPost creates a new post with or without an owning Collection.
  431. //
  432. // Endpoints:
  433. // /posts
  434. // /posts?collection={alias}
  435. // ? /collections/{alias}/posts
  436. func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
  437. reqJSON := IsJSON(r)
  438. vars := mux.Vars(r)
  439. collAlias := vars["alias"]
  440. if collAlias == "" {
  441. collAlias = r.FormValue("collection")
  442. }
  443. accessToken := r.Header.Get("Authorization")
  444. if accessToken == "" {
  445. // TODO: remove this
  446. accessToken = r.FormValue("access_token")
  447. }
  448. // FIXME: determine web submission with Content-Type header
  449. var u *User
  450. var userID int64 = -1
  451. var username string
  452. if accessToken == "" {
  453. u = getUserSession(app, r)
  454. if u != nil {
  455. userID = u.ID
  456. username = u.Username
  457. }
  458. } else {
  459. userID = app.db.GetUserID(accessToken)
  460. }
  461. suspended, err := app.db.IsUserSuspended(userID)
  462. if err != nil {
  463. log.Error("new post: %v", err)
  464. return ErrInternalGeneral
  465. }
  466. if suspended {
  467. return ErrUserSuspended
  468. }
  469. if userID == -1 {
  470. return ErrNotLoggedIn
  471. }
  472. if accessToken == "" && u == nil && collAlias != "" {
  473. return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."}
  474. }
  475. // Get post data
  476. var p *SubmittedPost
  477. if reqJSON {
  478. decoder := json.NewDecoder(r.Body)
  479. err = decoder.Decode(&p)
  480. if err != nil {
  481. log.Error("Couldn't parse new post JSON request: %v\n", err)
  482. return ErrBadJSON
  483. }
  484. if p.Title == nil {
  485. t := ""
  486. p.Title = &t
  487. }
  488. if strings.TrimSpace(*(p.Content)) == "" {
  489. return ErrNoPublishableContent
  490. }
  491. } else {
  492. post := r.FormValue("body")
  493. appearance := r.FormValue("font")
  494. title := r.FormValue("title")
  495. rtlValue := r.FormValue("rtl")
  496. langValue := r.FormValue("lang")
  497. if strings.TrimSpace(post) == "" {
  498. return ErrNoPublishableContent
  499. }
  500. var isRTL, rtlValid bool
  501. if rtlValue == "auto" && langValue != "" {
  502. isRTL = i18n.LangIsRTL(langValue)
  503. rtlValid = true
  504. } else {
  505. isRTL = rtlValue == "true"
  506. rtlValid = rtlValue != "" && langValue != ""
  507. }
  508. // Create a new post
  509. p = &SubmittedPost{
  510. Title: &title,
  511. Content: &post,
  512. Font: appearance,
  513. IsRTL: converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}},
  514. Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}},
  515. }
  516. }
  517. if !p.isFontValid() {
  518. p.Font = "norm"
  519. }
  520. var newPost *PublicPost = &PublicPost{}
  521. var coll *Collection
  522. if accessToken != "" {
  523. newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host)
  524. } else {
  525. //return ErrNotLoggedIn
  526. // TODO: verify user is logged in
  527. var collID int64
  528. if collAlias != "" {
  529. coll, err = app.db.GetCollection(collAlias)
  530. if err != nil {
  531. return err
  532. }
  533. coll.hostName = app.cfg.App.Host
  534. if coll.OwnerID != u.ID {
  535. return ErrForbiddenCollection
  536. }
  537. collID = coll.ID
  538. }
  539. // TODO: return PublicPost from createPost
  540. newPost.Post, err = app.db.CreatePost(userID, collID, p)
  541. }
  542. if err != nil {
  543. return err
  544. }
  545. if coll != nil {
  546. coll.ForPublic()
  547. newPost.Collection = &CollectionObj{Collection: *coll}
  548. }
  549. newPost.extractData()
  550. newPost.OwnerName = username
  551. // Write success now
  552. response := impart.WriteSuccess(w, newPost, http.StatusCreated)
  553. if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
  554. go federatePost(app, newPost, newPost.Collection.ID, false)
  555. }
  556. return response
  557. }
  558. func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
  559. reqJSON := IsJSON(r)
  560. vars := mux.Vars(r)
  561. postID := vars["post"]
  562. p := AuthenticatedPost{ID: postID}
  563. var err error
  564. if reqJSON {
  565. // Decode JSON request
  566. decoder := json.NewDecoder(r.Body)
  567. err = decoder.Decode(&p)
  568. if err != nil {
  569. log.Error("Couldn't parse post update JSON request: %v\n", err)
  570. return ErrBadJSON
  571. }
  572. } else {
  573. err = r.ParseForm()
  574. if err != nil {
  575. log.Error("Couldn't parse post update form request: %v\n", err)
  576. return ErrBadFormData
  577. }
  578. // Can't decode to a nil SubmittedPost property, so create instance now
  579. p.SubmittedPost = &SubmittedPost{}
  580. err = app.formDecoder.Decode(&p, r.PostForm)
  581. if err != nil {
  582. log.Error("Couldn't decode post update form request: %v\n", err)
  583. return ErrBadFormData
  584. }
  585. }
  586. if p.Web {
  587. p.IsRTL.Valid = true
  588. }
  589. if p.SubmittedPost == nil {
  590. return ErrPostNoUpdatableVals
  591. }
  592. // Ensure an access token was given
  593. accessToken := r.Header.Get("Authorization")
  594. // Get user's cookie session if there's no token
  595. var u *User
  596. //var username string
  597. if accessToken == "" {
  598. u = getUserSession(app, r)
  599. if u != nil {
  600. //username = u.Username
  601. }
  602. }
  603. if u == nil && accessToken == "" {
  604. return ErrNoAccessToken
  605. }
  606. // Get user ID from current session or given access token, if one was given.
  607. var userID int64
  608. if u != nil {
  609. userID = u.ID
  610. } else if accessToken != "" {
  611. userID, err = AuthenticateUser(app.db, accessToken)
  612. if err != nil {
  613. return err
  614. }
  615. }
  616. suspended, err := app.db.IsUserSuspended(userID)
  617. if err != nil {
  618. log.Error("existing post: %v", err)
  619. return ErrInternalGeneral
  620. }
  621. if suspended {
  622. return ErrUserSuspended
  623. }
  624. // Modify post struct
  625. p.ID = postID
  626. err = app.db.UpdateOwnedPost(&p, userID)
  627. if err != nil {
  628. if reqJSON {
  629. return err
  630. }
  631. if err, ok := err.(impart.HTTPError); ok {
  632. addSessionFlash(app, w, r, err.Message, nil)
  633. } else {
  634. addSessionFlash(app, w, r, err.Error(), nil)
  635. }
  636. }
  637. var pRes *PublicPost
  638. pRes, err = app.db.GetPost(p.ID, 0)
  639. if reqJSON {
  640. if err != nil {
  641. return err
  642. }
  643. pRes.extractData()
  644. }
  645. if pRes.CollectionID.Valid {
  646. coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64)
  647. if err == nil && !app.cfg.App.Private && app.cfg.App.Federation {
  648. coll.hostName = app.cfg.App.Host
  649. pRes.Collection = &CollectionObj{Collection: *coll}
  650. go federatePost(app, pRes, pRes.Collection.ID, true)
  651. }
  652. }
  653. // Write success now
  654. if reqJSON {
  655. return impart.WriteSuccess(w, pRes, http.StatusOK)
  656. }
  657. addSessionFlash(app, w, r, "Changes saved.", nil)
  658. collectionAlias := vars["alias"]
  659. redirect := "/" + postID + "/meta"
  660. if collectionAlias != "" {
  661. collPre := "/" + collectionAlias
  662. if app.cfg.App.SingleUser {
  663. collPre = ""
  664. }
  665. redirect = collPre + "/" + pRes.Slug.String + "/edit/meta"
  666. } else {
  667. if app.cfg.App.SingleUser {
  668. redirect = "/d" + redirect
  669. }
  670. }
  671. w.Header().Set("Location", redirect)
  672. w.WriteHeader(http.StatusFound)
  673. return nil
  674. }
  675. func deletePost(app *App, w http.ResponseWriter, r *http.Request) error {
  676. vars := mux.Vars(r)
  677. friendlyID := vars["post"]
  678. editToken := r.FormValue("token")
  679. var ownerID int64
  680. var u *User
  681. accessToken := r.Header.Get("Authorization")
  682. if accessToken == "" && editToken == "" {
  683. u = getUserSession(app, r)
  684. if u == nil {
  685. return ErrNoAccessToken
  686. }
  687. }
  688. var res sql.Result
  689. var t *sql.Tx
  690. var err error
  691. var collID sql.NullInt64
  692. var coll *Collection
  693. var pp *PublicPost
  694. if editToken != "" {
  695. // TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries
  696. var dummy int64
  697. err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy)
  698. switch {
  699. case err == sql.ErrNoRows:
  700. return impart.HTTPError{http.StatusNotFound, "Post not found."}
  701. }
  702. err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy)
  703. switch {
  704. case err == sql.ErrNoRows:
  705. // Post already has an owner. This could provide a bad experience
  706. // for the user, but it's more important to ensure data isn't lost
  707. // unexpectedly. So prevent deletion via token.
  708. return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."}
  709. }
  710. res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken)
  711. } else if accessToken != "" || u != nil {
  712. // Caller provided some way to authenticate; assume caller expects the
  713. // post to be deleted based on a specific post owner, thus we should
  714. // return corresponding errors.
  715. if accessToken != "" {
  716. ownerID = app.db.GetUserID(accessToken)
  717. if ownerID == -1 {
  718. return ErrBadAccessToken
  719. }
  720. } else {
  721. ownerID = u.ID
  722. }
  723. // TODO: don't make two queries
  724. var realOwnerID sql.NullInt64
  725. err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID)
  726. if err != nil {
  727. return err
  728. }
  729. if !collID.Valid {
  730. // There's no collection; simply delete the post
  731. res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
  732. } else {
  733. // Post belongs to a collection; do any additional clean up
  734. coll, err = app.db.GetCollectionBy("id = ?", collID.Int64)
  735. if err != nil {
  736. log.Error("Unable to get collection: %v", err)
  737. return err
  738. }
  739. if app.cfg.App.Federation {
  740. // First fetch full post for federation
  741. pp, err = app.db.GetOwnedPost(friendlyID, ownerID)
  742. if err != nil {
  743. log.Error("Unable to get owned post: %v", err)
  744. return err
  745. }
  746. collObj := &CollectionObj{Collection: *coll}
  747. pp.Collection = collObj
  748. }
  749. t, err = app.db.Begin()
  750. if err != nil {
  751. log.Error("No begin: %v", err)
  752. return err
  753. }
  754. res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
  755. }
  756. } else {
  757. return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."}
  758. }
  759. if err != nil {
  760. return err
  761. }
  762. affected, err := res.RowsAffected()
  763. if err != nil {
  764. if t != nil {
  765. t.Rollback()
  766. log.Error("Rows affected err! Rolling back")
  767. }
  768. return err
  769. } else if affected == 0 {
  770. if t != nil {
  771. t.Rollback()
  772. log.Error("No rows affected! Rolling back")
  773. }
  774. return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."}
  775. }
  776. if t != nil {
  777. t.Commit()
  778. }
  779. if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation {
  780. go deleteFederatedPost(app, pp, collID.Int64)
  781. }
  782. return impart.HTTPError{Status: http.StatusNoContent}
  783. }
  784. // addPost associates a post with the authenticated user.
  785. func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
  786. var ownerID int64
  787. // Authenticate user
  788. at := r.Header.Get("Authorization")
  789. if at != "" {
  790. ownerID = app.db.GetUserID(at)
  791. if ownerID == -1 {
  792. return ErrBadAccessToken
  793. }
  794. } else {
  795. u := getUserSession(app, r)
  796. if u == nil {
  797. return ErrNotLoggedIn
  798. }
  799. ownerID = u.ID
  800. }
  801. suspended, err := app.db.IsUserSuspended(ownerID)
  802. if err != nil {
  803. log.Error("add post: %v", err)
  804. return ErrInternalGeneral
  805. }
  806. if suspended {
  807. return ErrUserSuspended
  808. }
  809. // Parse claimed posts in format:
  810. // [{"id": "...", "token": "..."}]
  811. var claims *[]ClaimPostRequest
  812. decoder := json.NewDecoder(r.Body)
  813. err = decoder.Decode(&claims)
  814. if err != nil {
  815. return ErrBadJSONArray
  816. }
  817. vars := mux.Vars(r)
  818. collAlias := vars["alias"]
  819. // Update all given posts
  820. res, err := app.db.ClaimPosts(app.cfg, ownerID, collAlias, claims)
  821. if err != nil {
  822. return err
  823. }
  824. if !app.cfg.App.Private && app.cfg.App.Federation {
  825. for _, pRes := range *res {
  826. if pRes.Code != http.StatusOK {
  827. continue
  828. }
  829. if !pRes.Post.Created.After(time.Now()) {
  830. pRes.Post.Collection.hostName = app.cfg.App.Host
  831. go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
  832. }
  833. }
  834. }
  835. return impart.WriteSuccess(w, res, http.StatusOK)
  836. }
  837. func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error {
  838. var ownerID int64
  839. // Authenticate user
  840. at := r.Header.Get("Authorization")
  841. if at != "" {
  842. ownerID = app.db.GetUserID(at)
  843. if ownerID == -1 {
  844. return ErrBadAccessToken
  845. }
  846. } else {
  847. u := getUserSession(app, r)
  848. if u == nil {
  849. return ErrNotLoggedIn
  850. }
  851. ownerID = u.ID
  852. }
  853. // Parse posts in format:
  854. // ["..."]
  855. var postIDs []string
  856. decoder := json.NewDecoder(r.Body)
  857. err := decoder.Decode(&postIDs)
  858. if err != nil {
  859. return ErrBadJSONArray
  860. }
  861. // Update all given posts
  862. res, err := app.db.DispersePosts(ownerID, postIDs)
  863. if err != nil {
  864. return err
  865. }
  866. return impart.WriteSuccess(w, res, http.StatusOK)
  867. }
  868. type (
  869. PinPostResult struct {
  870. ID string `json:"id,omitempty"`
  871. Code int `json:"code,omitempty"`
  872. ErrorMessage string `json:"error_msg,omitempty"`
  873. }
  874. )
  875. // pinPost pins a post to a blog
  876. func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
  877. var userID int64
  878. // Authenticate user
  879. at := r.Header.Get("Authorization")
  880. if at != "" {
  881. userID = app.db.GetUserID(at)
  882. if userID == -1 {
  883. return ErrBadAccessToken
  884. }
  885. } else {
  886. u := getUserSession(app, r)
  887. if u == nil {
  888. return ErrNotLoggedIn
  889. }
  890. userID = u.ID
  891. }
  892. suspended, err := app.db.IsUserSuspended(userID)
  893. if err != nil {
  894. log.Error("pin post: %v", err)
  895. return ErrInternalGeneral
  896. }
  897. if suspended {
  898. return ErrUserSuspended
  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 ownerID int64
  939. var coll *Collection
  940. var err error
  941. vars := mux.Vars(r)
  942. if collAlias := vars["alias"]; collAlias != "" {
  943. // Fetch collection information, since an alias is provided
  944. coll, err = app.db.GetCollection(collAlias)
  945. if err != nil {
  946. return err
  947. }
  948. coll.hostName = app.cfg.App.Host
  949. _, err = apiCheckCollectionPermissions(app, r, coll)
  950. if err != nil {
  951. return err
  952. }
  953. collID = coll.ID
  954. ownerID = coll.OwnerID
  955. }
  956. p, err := app.db.GetPost(vars["post"], collID)
  957. if err != nil {
  958. return err
  959. }
  960. suspended, err := app.db.IsUserSuspended(ownerID)
  961. if err != nil {
  962. log.Error("fetch post: %v", err)
  963. return ErrInternalGeneral
  964. }
  965. if suspended {
  966. return ErrPostNotFound
  967. }
  968. p.extractData()
  969. accept := r.Header.Get("Accept")
  970. if strings.Contains(accept, "application/activity+json") {
  971. // Fetch information about the collection this belongs to
  972. if coll == nil && p.CollectionID.Valid {
  973. coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
  974. if err != nil {
  975. return err
  976. }
  977. }
  978. if coll == nil {
  979. // This is a draft post; 404 for now
  980. // TODO: return ActivityObject
  981. return impart.HTTPError{http.StatusNotFound, ""}
  982. }
  983. p.Collection = &CollectionObj{Collection: *coll}
  984. po := p.ActivityObject(app.cfg)
  985. po.Context = []interface{}{activitystreams.Namespace}
  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(cfg *config.Config) *activitystreams.Object {
  1013. o := activitystreams.NewArticleObject()
  1014. o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
  1015. o.Published = p.Created
  1016. o.URL = p.CanonicalURL(cfg.App.Host)
  1017. o.AttributedTo = p.Collection.FederatedAccount()
  1018. o.CC = []string{
  1019. p.Collection.FederatedAccount() + "/followers",
  1020. }
  1021. o.Name = p.DisplayTitle()
  1022. if p.HTMLContent == template.HTML("") {
  1023. p.formatContent(cfg, false)
  1024. }
  1025. o.Content = string(p.HTMLContent)
  1026. if p.Language.Valid {
  1027. o.ContentMap = map[string]string{
  1028. p.Language.String: string(p.HTMLContent),
  1029. }
  1030. }
  1031. if len(p.Tags) == 0 {
  1032. o.Tag = []activitystreams.Tag{}
  1033. } else {
  1034. var tagBaseURL string
  1035. if isSingleUser {
  1036. tagBaseURL = p.Collection.CanonicalURL() + "tag:"
  1037. } else {
  1038. if cfg.App.Chorus {
  1039. tagBaseURL = fmt.Sprintf("%s/read/t/", p.Collection.hostName)
  1040. } else {
  1041. tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias)
  1042. }
  1043. }
  1044. for _, t := range p.Tags {
  1045. o.Tag = append(o.Tag, activitystreams.Tag{
  1046. Type: activitystreams.TagHashtag,
  1047. HRef: tagBaseURL + t,
  1048. Name: "#" + t,
  1049. })
  1050. }
  1051. }
  1052. return o
  1053. }
  1054. // TODO: merge this into getSlugFromPost or phase it out
  1055. func getSlug(title, lang string) string {
  1056. return getSlugFromPost("", title, lang)
  1057. }
  1058. func getSlugFromPost(title, body, lang string) string {
  1059. if title == "" {
  1060. title = postTitle(body, body)
  1061. }
  1062. title = parse.PostLede(title, false)
  1063. // Truncate lede if needed
  1064. title, _ = parse.TruncToWord(title, 80)
  1065. var s string
  1066. if lang != "" && len(lang) == 2 {
  1067. s = slug.MakeLang(title, lang)
  1068. } else {
  1069. s = slug.Make(title)
  1070. }
  1071. // Transliteration may cause the slug to expand past the limit, so truncate again
  1072. s, _ = parse.TruncToWord(s, 80)
  1073. return strings.TrimFunc(s, func(r rune) bool {
  1074. // TruncToWord doesn't respect words in a slug, since spaces are replaced
  1075. // with hyphens. So remove any trailing hyphens.
  1076. return r == '-'
  1077. })
  1078. }
  1079. // isFontValid returns whether or not the submitted post's appearance is valid.
  1080. func (p *SubmittedPost) isFontValid() bool {
  1081. validFonts := map[string]bool{
  1082. "norm": true,
  1083. "sans": true,
  1084. "mono": true,
  1085. "wrap": true,
  1086. "code": true,
  1087. }
  1088. _, valid := validFonts[p.Font]
  1089. return valid
  1090. }
  1091. func getRawPost(app *App, friendlyID string) *RawPost {
  1092. var content, font, title string
  1093. var isRTL sql.NullBool
  1094. var lang sql.NullString
  1095. var ownerID sql.NullInt64
  1096. var created time.Time
  1097. 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)
  1098. switch {
  1099. case err == sql.ErrNoRows:
  1100. return &RawPost{Content: "", Found: false, Gone: false}
  1101. case err != nil:
  1102. return &RawPost{Content: "", Found: true, Gone: false}
  1103. }
  1104. return &RawPost{Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
  1105. }
  1106. // TODO; return a Post!
  1107. func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
  1108. var id, title, content, font string
  1109. var isRTL sql.NullBool
  1110. var lang sql.NullString
  1111. var created time.Time
  1112. var ownerID null.Int
  1113. var views int64
  1114. var err error
  1115. if app.cfg.App.SingleUser {
  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 = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
  1117. } else {
  1118. 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)
  1119. }
  1120. switch {
  1121. case err == sql.ErrNoRows:
  1122. return &RawPost{Content: "", Found: false, Gone: false}
  1123. case err != nil:
  1124. return &RawPost{Content: "", Found: true, Gone: false}
  1125. }
  1126. return &RawPost{
  1127. Id: id,
  1128. Slug: slug,
  1129. Title: title,
  1130. Content: content,
  1131. Font: font,
  1132. Created: created,
  1133. IsRTL: isRTL,
  1134. Language: lang,
  1135. OwnerID: ownerID.Int64,
  1136. Found: true,
  1137. Gone: content == "",
  1138. Views: views,
  1139. }
  1140. }
  1141. func isRaw(r *http.Request) bool {
  1142. vars := mux.Vars(r)
  1143. slug := vars["slug"]
  1144. // NOTE: until this is done better, be sure to keep this in parity with
  1145. // isRaw in viewCollectionPost() and handleViewPost()
  1146. isJSON := strings.HasSuffix(slug, ".json")
  1147. isXML := strings.HasSuffix(slug, ".xml")
  1148. isMarkdown := strings.HasSuffix(slug, ".md")
  1149. return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
  1150. }
  1151. func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error {
  1152. vars := mux.Vars(r)
  1153. slug := vars["slug"]
  1154. // NOTE: until this is done better, be sure to keep this in parity with
  1155. // isRaw() and handleViewPost()
  1156. isJSON := strings.HasSuffix(slug, ".json")
  1157. isXML := strings.HasSuffix(slug, ".xml")
  1158. isMarkdown := strings.HasSuffix(slug, ".md")
  1159. isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
  1160. cr := &collectionReq{}
  1161. err := processCollectionRequest(cr, vars, w, r)
  1162. if err != nil {
  1163. return err
  1164. }
  1165. // Check for hellbanned users
  1166. u, err := checkUserForCollection(app, cr, r, true)
  1167. if err != nil {
  1168. return err
  1169. }
  1170. // Normalize the URL, redirecting user to consistent post URL
  1171. if slug != strings.ToLower(slug) {
  1172. loc := fmt.Sprintf("/%s", strings.ToLower(slug))
  1173. if !app.cfg.App.SingleUser {
  1174. loc = "/" + cr.alias + loc
  1175. }
  1176. return impart.HTTPError{http.StatusMovedPermanently, loc}
  1177. }
  1178. // Display collection if this is a collection
  1179. var c *Collection
  1180. if app.cfg.App.SingleUser {
  1181. c, err = app.db.GetCollectionByID(1)
  1182. } else {
  1183. c, err = app.db.GetCollection(cr.alias)
  1184. }
  1185. if err != nil {
  1186. if err, ok := err.(impart.HTTPError); ok {
  1187. if err.Status == http.StatusNotFound {
  1188. // Redirect if necessary
  1189. newAlias := app.db.GetCollectionRedirect(cr.alias)
  1190. if newAlias != "" {
  1191. return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug}
  1192. }
  1193. }
  1194. }
  1195. return err
  1196. }
  1197. c.hostName = app.cfg.App.Host
  1198. suspended, err := app.db.IsUserSuspended(c.OwnerID)
  1199. if err != nil {
  1200. log.Error("view collection post: %v", err)
  1201. return ErrInternalGeneral
  1202. }
  1203. // Check collection permissions
  1204. if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
  1205. return ErrPostNotFound
  1206. }
  1207. if c.IsProtected() && ((u == nil || u.ID != c.OwnerID) && !isAuthorizedForCollection(app, c.Alias, r)) {
  1208. return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
  1209. }
  1210. cr.isCollOwner = u != nil && c.OwnerID == u.ID
  1211. if isRaw {
  1212. slug = strings.Split(slug, ".")[0]
  1213. }
  1214. // Fetch extra data about the Collection
  1215. // TODO: refactor out this logic, shared in collection.go:fetchCollection()
  1216. coll := &CollectionObj{Collection: *c}
  1217. owner, err := app.db.GetUserByID(coll.OwnerID)
  1218. if err != nil {
  1219. // Log the error and just continue
  1220. log.Error("Error getting user for collection: %v", err)
  1221. } else {
  1222. coll.Owner = owner
  1223. }
  1224. postFound := true
  1225. p, err := app.db.GetPost(slug, coll.ID)
  1226. if err != nil {
  1227. if err == ErrCollectionPageNotFound {
  1228. postFound = false
  1229. if slug == "feed" {
  1230. // User tried to access blog feed without a trailing slash, and
  1231. // there's no post with a slug "feed"
  1232. return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/feed/"}
  1233. }
  1234. po := &Post{
  1235. Slug: null.NewString(slug, true),
  1236. Font: "norm",
  1237. Language: zero.NewString("en", true),
  1238. RTL: zero.NewBool(false, true),
  1239. Content: `<p class="msg">This page is missing.</p>
  1240. Are you sure it was ever here?`,
  1241. }
  1242. pp := po.processPost()
  1243. p = &pp
  1244. } else {
  1245. return err
  1246. }
  1247. }
  1248. p.IsOwner = owner != nil && p.OwnerID.Valid && u.ID == p.OwnerID.Int64
  1249. p.Collection = coll
  1250. p.IsTopLevel = app.cfg.App.SingleUser
  1251. if !p.IsOwner && suspended {
  1252. return ErrPostNotFound
  1253. }
  1254. // Check if post has been unpublished
  1255. if p.Content == "" && p.Title.String == "" {
  1256. return impart.HTTPError{http.StatusGone, "Post was unpublished."}
  1257. }
  1258. // Serve collection post
  1259. if isRaw {
  1260. contentType := "text/plain"
  1261. if isJSON {
  1262. contentType = "application/json"
  1263. } else if isXML {
  1264. contentType = "application/xml"
  1265. } else if isMarkdown {
  1266. contentType = "text/markdown"
  1267. }
  1268. w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
  1269. if !postFound {
  1270. w.WriteHeader(http.StatusNotFound)
  1271. fmt.Fprintf(w, "Post not found.")
  1272. // TODO: return error instead, so status is correctly reflected in logs
  1273. return nil
  1274. }
  1275. if isMarkdown && p.Title.String != "" {
  1276. fmt.Fprintf(w, "# %s\n\n", p.Title.String)
  1277. }
  1278. fmt.Fprint(w, p.Content)
  1279. } else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
  1280. if !postFound {
  1281. return ErrCollectionPageNotFound
  1282. }
  1283. p.extractData()
  1284. ap := p.ActivityObject(app.cfg)
  1285. ap.Context = []interface{}{activitystreams.Namespace}
  1286. return impart.RenderActivityJSON(w, ap, http.StatusOK)
  1287. } else {
  1288. p.extractData()
  1289. p.Content = strings.Replace(p.Content, "<!--more-->", "", 1)
  1290. // TODO: move this to function
  1291. p.formatContent(app.cfg, cr.isCollOwner)
  1292. tp := struct {
  1293. *PublicPost
  1294. page.StaticPage
  1295. IsOwner bool
  1296. IsPinned bool
  1297. IsCustomDomain bool
  1298. PinnedPosts *[]PublicPost
  1299. IsFound bool
  1300. IsAdmin bool
  1301. CanInvite bool
  1302. Suspended bool
  1303. }{
  1304. PublicPost: p,
  1305. StaticPage: pageForReq(app, r),
  1306. IsOwner: cr.isCollOwner,
  1307. IsCustomDomain: cr.isCustomDomain,
  1308. IsFound: postFound,
  1309. Suspended: suspended,
  1310. }
  1311. tp.IsAdmin = u != nil && u.IsAdmin()
  1312. tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
  1313. tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner)
  1314. tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
  1315. if !postFound {
  1316. w.WriteHeader(http.StatusNotFound)
  1317. }
  1318. postTmpl := "collection-post"
  1319. if app.cfg.App.Chorus {
  1320. postTmpl = "chorus-collection-post"
  1321. }
  1322. if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil {
  1323. log.Error("Error in collection-post template: %v", err)
  1324. }
  1325. }
  1326. go func() {
  1327. if p.OwnerID.Valid {
  1328. // Post is owned by someone. Don't update stats if owner is viewing the post.
  1329. if u != nil && p.OwnerID.Int64 == u.ID {
  1330. return
  1331. }
  1332. }
  1333. // Update stats for non-raw post views
  1334. if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
  1335. _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID)
  1336. if err != nil {
  1337. log.Error("Unable to update posts count: %v", err)
  1338. }
  1339. }
  1340. }()
  1341. return nil
  1342. }
  1343. // TODO: move this to utils after making it more generic
  1344. func PostsContains(sl *[]PublicPost, s *PublicPost) bool {
  1345. for _, e := range *sl {
  1346. if e.ID == s.ID {
  1347. return true
  1348. }
  1349. }
  1350. return false
  1351. }
  1352. func (p *Post) extractData() {
  1353. p.Tags = tags.Extract(p.Content)
  1354. p.extractImages()
  1355. }
  1356. func (rp *RawPost) UserFacingCreated() string {
  1357. return rp.Created.Format(postMetaDateFormat)
  1358. }
  1359. func (rp *RawPost) Created8601() string {
  1360. return rp.Created.Format("2006-01-02T15:04:05Z")
  1361. }
  1362. var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg|image)$`)
  1363. func (p *Post) extractImages() {
  1364. matches := extract.ExtractUrls(p.Content)
  1365. urls := map[string]bool{}
  1366. for i := range matches {
  1367. u := matches[i].Text
  1368. if !imageURLRegex.MatchString(u) {
  1369. continue
  1370. }
  1371. urls[u] = true
  1372. }
  1373. resURLs := make([]string, 0)
  1374. for k := range urls {
  1375. resURLs = append(resURLs, k)
  1376. }
  1377. p.Images = resURLs
  1378. }