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.
 
 
 
 
 

1701 wiersze
45 KiB

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