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.
 
 
 
 
 

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