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.
 
 
 
 
 

1364 line
35 KiB

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