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.
 
 
 
 
 

1387 lines
36 KiB

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