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.
 
 
 
 
 

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