A clean, Markdown-based publishing platform made for writers. Write together, and build a community. https://writefreely.org
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

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