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.
 
 
 
 
 

1369 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. // 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. if app.cfg.App.Federation {
  768. for _, pRes := range *res {
  769. if pRes.Code != http.StatusOK {
  770. continue
  771. }
  772. go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
  773. }
  774. }
  775. return impart.WriteSuccess(w, res, http.StatusOK)
  776. }
  777. func dispersePost(app *app, w http.ResponseWriter, r *http.Request) error {
  778. var ownerID int64
  779. // Authenticate user
  780. at := r.Header.Get("Authorization")
  781. if at != "" {
  782. ownerID = app.db.GetUserID(at)
  783. if ownerID == -1 {
  784. return ErrBadAccessToken
  785. }
  786. } else {
  787. u := getUserSession(app, r)
  788. if u == nil {
  789. return ErrNotLoggedIn
  790. }
  791. ownerID = u.ID
  792. }
  793. // Parse posts in format:
  794. // ["..."]
  795. var postIDs []string
  796. decoder := json.NewDecoder(r.Body)
  797. err := decoder.Decode(&postIDs)
  798. if err != nil {
  799. return ErrBadJSONArray
  800. }
  801. // Update all given posts
  802. res, err := app.db.DispersePosts(ownerID, postIDs)
  803. if err != nil {
  804. return err
  805. }
  806. return impart.WriteSuccess(w, res, http.StatusOK)
  807. }
  808. type (
  809. PinPostResult struct {
  810. ID string `json:"id,omitempty"`
  811. Code int `json:"code,omitempty"`
  812. ErrorMessage string `json:"error_msg,omitempty"`
  813. }
  814. )
  815. // pinPost pins a post to a blog
  816. func pinPost(app *app, w http.ResponseWriter, r *http.Request) error {
  817. var userID int64
  818. // Authenticate user
  819. at := r.Header.Get("Authorization")
  820. if at != "" {
  821. userID = app.db.GetUserID(at)
  822. if userID == -1 {
  823. return ErrBadAccessToken
  824. }
  825. } else {
  826. u := getUserSession(app, r)
  827. if u == nil {
  828. return ErrNotLoggedIn
  829. }
  830. userID = u.ID
  831. }
  832. // Parse request
  833. var posts []struct {
  834. ID string `json:"id"`
  835. Position int64 `json:"position"`
  836. }
  837. decoder := json.NewDecoder(r.Body)
  838. err := decoder.Decode(&posts)
  839. if err != nil {
  840. return ErrBadJSONArray
  841. }
  842. // Validate data
  843. vars := mux.Vars(r)
  844. collAlias := vars["alias"]
  845. coll, err := app.db.GetCollection(collAlias)
  846. if err != nil {
  847. return err
  848. }
  849. if coll.OwnerID != userID {
  850. return ErrForbiddenCollection
  851. }
  852. // Do (un)pinning
  853. isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin"
  854. res := []PinPostResult{}
  855. for _, p := range posts {
  856. err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position)
  857. ppr := PinPostResult{ID: p.ID}
  858. if err != nil {
  859. ppr.Code = http.StatusInternalServerError
  860. // TODO: set error messsage
  861. } else {
  862. ppr.Code = http.StatusOK
  863. }
  864. res = append(res, ppr)
  865. }
  866. return impart.WriteSuccess(w, res, http.StatusOK)
  867. }
  868. func fetchPost(app *app, w http.ResponseWriter, r *http.Request) error {
  869. var collID int64
  870. var coll *Collection
  871. var err error
  872. vars := mux.Vars(r)
  873. if collAlias := vars["alias"]; collAlias != "" {
  874. // Fetch collection information, since an alias is provided
  875. coll, err = app.db.GetCollection(collAlias)
  876. if err != nil {
  877. return err
  878. }
  879. _, err = apiCheckCollectionPermissions(app, r, coll)
  880. if err != nil {
  881. return err
  882. }
  883. collID = coll.ID
  884. }
  885. p, err := app.db.GetPost(vars["post"], collID)
  886. if err != nil {
  887. return err
  888. }
  889. p.extractData()
  890. accept := r.Header.Get("Accept")
  891. if strings.Contains(accept, "application/activity+json") {
  892. // Fetch information about the collection this belongs to
  893. if coll == nil && p.CollectionID.Valid {
  894. coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
  895. if err != nil {
  896. return err
  897. }
  898. }
  899. if coll == nil {
  900. // This is a draft post; 404 for now
  901. // TODO: return ActivityObject
  902. return impart.HTTPError{http.StatusNotFound, ""}
  903. }
  904. p.Collection = &CollectionObj{Collection: *coll}
  905. po := p.ActivityObject()
  906. po.Context = []interface{}{activitystreams.Namespace}
  907. return impart.RenderActivityJSON(w, po, http.StatusOK)
  908. }
  909. return impart.WriteSuccess(w, p, http.StatusOK)
  910. }
  911. func fetchPostProperty(app *app, w http.ResponseWriter, r *http.Request) error {
  912. vars := mux.Vars(r)
  913. p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"])
  914. if err != nil {
  915. return err
  916. }
  917. return impart.WriteSuccess(w, p, http.StatusOK)
  918. }
  919. func (p *Post) processPost() PublicPost {
  920. res := &PublicPost{Post: p, Views: 0}
  921. res.Views = p.ViewCount
  922. // TODO: move to own function
  923. loc := monday.FuzzyLocale(p.Language.String)
  924. res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
  925. return *res
  926. }
  927. func (p *PublicPost) CanonicalURL() string {
  928. if p.Collection == nil || p.Collection.Alias == "" {
  929. return hostName + "/" + p.ID
  930. }
  931. return p.Collection.CanonicalURL() + p.Slug.String
  932. }
  933. func (p *PublicPost) ActivityObject() *activitystreams.Object {
  934. o := activitystreams.NewArticleObject()
  935. o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
  936. o.Published = p.Created
  937. o.URL = p.CanonicalURL()
  938. o.AttributedTo = p.Collection.FederatedAccount()
  939. o.CC = []string{
  940. p.Collection.FederatedAccount() + "/followers",
  941. }
  942. o.Name = p.DisplayTitle()
  943. if p.HTMLContent == template.HTML("") {
  944. p.formatContent(false)
  945. }
  946. o.Content = string(p.HTMLContent)
  947. if p.Language.Valid {
  948. o.ContentMap = map[string]string{
  949. p.Language.String: string(p.HTMLContent),
  950. }
  951. }
  952. if len(p.Tags) == 0 {
  953. o.Tag = []activitystreams.Tag{}
  954. } else {
  955. var tagBaseURL string
  956. if isSingleUser {
  957. tagBaseURL = p.Collection.CanonicalURL() + "tag:"
  958. } else {
  959. tagBaseURL = fmt.Sprintf("%s/%s/tag:", hostName, p.Collection.Alias)
  960. }
  961. for _, t := range p.Tags {
  962. o.Tag = append(o.Tag, activitystreams.Tag{
  963. Type: activitystreams.TagHashtag,
  964. HRef: tagBaseURL + t,
  965. Name: "#" + t,
  966. })
  967. }
  968. }
  969. return o
  970. }
  971. // TODO: merge this into getSlugFromPost or phase it out
  972. func getSlug(title, lang string) string {
  973. return getSlugFromPost("", title, lang)
  974. }
  975. func getSlugFromPost(title, body, lang string) string {
  976. if title == "" {
  977. title = postTitle(body, body)
  978. }
  979. title = parse.PostLede(title, false)
  980. // Truncate lede if needed
  981. title, _ = parse.TruncToWord(title, 80)
  982. if lang != "" && len(lang) == 2 {
  983. return slug.MakeLang(title, lang)
  984. }
  985. return slug.Make(title)
  986. }
  987. // isFontValid returns whether or not the submitted post's appearance is valid.
  988. func (p *SubmittedPost) isFontValid() bool {
  989. validFonts := map[string]bool{
  990. "norm": true,
  991. "sans": true,
  992. "mono": true,
  993. "wrap": true,
  994. "code": true,
  995. }
  996. _, valid := validFonts[p.Font]
  997. return valid
  998. }
  999. func getRawPost(app *app, friendlyID string) *RawPost {
  1000. var content, font, title string
  1001. var isRTL sql.NullBool
  1002. var lang sql.NullString
  1003. var ownerID sql.NullInt64
  1004. var created time.Time
  1005. 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)
  1006. switch {
  1007. case err == sql.ErrNoRows:
  1008. return &RawPost{Content: "", Found: false, Gone: false}
  1009. case err != nil:
  1010. return &RawPost{Content: "", Found: true, Gone: false}
  1011. }
  1012. return &RawPost{Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
  1013. }
  1014. // TODO; return a Post!
  1015. func getRawCollectionPost(app *app, slug, collAlias string) *RawPost {
  1016. var id, title, content, font string
  1017. var isRTL sql.NullBool
  1018. var lang sql.NullString
  1019. var created time.Time
  1020. var ownerID null.Int
  1021. var views int64
  1022. 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)
  1023. switch {
  1024. case err == sql.ErrNoRows:
  1025. return &RawPost{Content: "", Found: false, Gone: false}
  1026. case err != nil:
  1027. return &RawPost{Content: "", Found: true, Gone: false}
  1028. }
  1029. return &RawPost{
  1030. Id: id,
  1031. Slug: slug,
  1032. Title: title,
  1033. Content: content,
  1034. Font: font,
  1035. Created: created,
  1036. IsRTL: isRTL,
  1037. Language: lang,
  1038. OwnerID: ownerID.Int64,
  1039. Found: true,
  1040. Gone: content == "",
  1041. Views: views,
  1042. }
  1043. }
  1044. func viewCollectionPost(app *app, w http.ResponseWriter, r *http.Request) error {
  1045. vars := mux.Vars(r)
  1046. slug := vars["slug"]
  1047. isJSON := strings.HasSuffix(slug, ".json")
  1048. isXML := strings.HasSuffix(slug, ".xml")
  1049. isMarkdown := strings.HasSuffix(slug, ".md")
  1050. isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
  1051. if strings.Contains(r.URL.Path, ".") && !isRaw {
  1052. // Serve static file
  1053. shttp.ServeHTTP(w, r)
  1054. return nil
  1055. }
  1056. cr := &collectionReq{}
  1057. err := processCollectionRequest(cr, vars, w, r)
  1058. if err != nil {
  1059. return err
  1060. }
  1061. // Check for hellbanned users
  1062. u, err := checkUserForCollection(app, cr, r, true)
  1063. if err != nil {
  1064. return err
  1065. }
  1066. // Normalize the URL, redirecting user to consistent post URL
  1067. if slug != strings.ToLower(slug) {
  1068. loc := fmt.Sprintf("/%s", strings.ToLower(slug))
  1069. if !app.cfg.App.SingleUser {
  1070. loc = "/" + cr.alias + loc
  1071. }
  1072. return impart.HTTPError{http.StatusMovedPermanently, loc}
  1073. }
  1074. // Display collection if this is a collection
  1075. var c *Collection
  1076. if app.cfg.App.SingleUser {
  1077. c, err = app.db.GetCollectionByID(1)
  1078. } else {
  1079. c, err = app.db.GetCollection(cr.alias)
  1080. }
  1081. if err != nil {
  1082. if err, ok := err.(impart.HTTPError); ok {
  1083. if err.Status == http.StatusNotFound {
  1084. // Redirect if necessary
  1085. newAlias := app.db.GetCollectionRedirect(cr.alias)
  1086. if newAlias != "" {
  1087. return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug}
  1088. }
  1089. }
  1090. }
  1091. return err
  1092. }
  1093. // Check collection permissions
  1094. if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
  1095. return ErrPostNotFound
  1096. }
  1097. if c.IsProtected() && ((u == nil || u.ID != c.OwnerID) && !isAuthorizedForCollection(app, c.Alias, r)) {
  1098. return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
  1099. }
  1100. cr.isCollOwner = u != nil && c.OwnerID == u.ID
  1101. if isRaw {
  1102. slug = strings.Split(slug, ".")[0]
  1103. }
  1104. // Fetch extra data about the Collection
  1105. // TODO: refactor out this logic, shared in collection.go:fetchCollection()
  1106. coll := &CollectionObj{Collection: *c}
  1107. owner, err := app.db.GetUserByID(coll.OwnerID)
  1108. if err != nil {
  1109. // Log the error and just continue
  1110. log.Error("Error getting user for collection: %v", err)
  1111. } else {
  1112. coll.Owner = owner
  1113. }
  1114. p, err := app.db.GetPost(slug, coll.ID)
  1115. if err != nil {
  1116. if err == ErrCollectionPageNotFound && slug == "feed" {
  1117. // User tried to access blog feed without a trailing slash, and
  1118. // there's no post with a slug "feed"
  1119. return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/feed/"}
  1120. }
  1121. return err
  1122. }
  1123. p.IsOwner = owner != nil && p.OwnerID.Valid && owner.ID == p.OwnerID.Int64
  1124. p.Collection = coll
  1125. p.IsTopLevel = app.cfg.App.SingleUser
  1126. // Check if post has been unpublished
  1127. if p.Content == "" {
  1128. return impart.HTTPError{http.StatusGone, "Post was unpublished."}
  1129. }
  1130. // Serve collection post
  1131. if isRaw {
  1132. contentType := "text/plain"
  1133. if isJSON {
  1134. contentType = "application/json"
  1135. } else if isXML {
  1136. contentType = "application/xml"
  1137. } else if isMarkdown {
  1138. contentType = "text/markdown"
  1139. }
  1140. w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
  1141. if isMarkdown && p.Title.String != "" {
  1142. fmt.Fprintf(w, "# %s\n\n", p.Title.String)
  1143. }
  1144. fmt.Fprint(w, p.Content)
  1145. } else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
  1146. p.extractData()
  1147. ap := p.ActivityObject()
  1148. ap.Context = []interface{}{activitystreams.Namespace}
  1149. return impart.RenderActivityJSON(w, ap, http.StatusOK)
  1150. } else {
  1151. p.extractData()
  1152. p.Content = strings.Replace(p.Content, "<!--more-->", "", 1)
  1153. // TODO: move this to function
  1154. p.formatContent(cr.isCollOwner)
  1155. tp := struct {
  1156. *PublicPost
  1157. page.StaticPage
  1158. IsOwner bool
  1159. IsPinned bool
  1160. IsCustomDomain bool
  1161. PinnedPosts *[]PublicPost
  1162. }{
  1163. PublicPost: p,
  1164. StaticPage: pageForReq(app, r),
  1165. IsOwner: cr.isCollOwner,
  1166. IsCustomDomain: cr.isCustomDomain,
  1167. }
  1168. tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll)
  1169. tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
  1170. if err := templates["collection-post"].ExecuteTemplate(w, "post", tp); err != nil {
  1171. log.Error("Error in collection-post template: %v", err)
  1172. }
  1173. }
  1174. go func() {
  1175. if p.OwnerID.Valid {
  1176. // Post is owned by someone. Don't update stats if owner is viewing the post.
  1177. if u != nil && p.OwnerID.Int64 == u.ID {
  1178. return
  1179. }
  1180. }
  1181. // Update stats for non-raw post views
  1182. if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
  1183. _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID)
  1184. if err != nil {
  1185. log.Error("Unable to update posts count: %v", err)
  1186. }
  1187. }
  1188. }()
  1189. return nil
  1190. }
  1191. // TODO: move this to utils after making it more generic
  1192. func PostsContains(sl *[]PublicPost, s *PublicPost) bool {
  1193. for _, e := range *sl {
  1194. if e.ID == s.ID {
  1195. return true
  1196. }
  1197. }
  1198. return false
  1199. }
  1200. func (p *Post) extractData() {
  1201. p.Tags = tags.Extract(p.Content)
  1202. p.extractImages()
  1203. }
  1204. func (rp *RawPost) UserFacingCreated() string {
  1205. return rp.Created.Format(postMetaDateFormat)
  1206. }
  1207. func (rp *RawPost) Created8601() string {
  1208. return rp.Created.Format("2006-01-02T15:04:05Z")
  1209. }
  1210. var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg)$`)
  1211. func (p *Post) extractImages() {
  1212. matches := extract.ExtractUrls(p.Content)
  1213. urls := map[string]bool{}
  1214. for i := range matches {
  1215. u := matches[i].Text
  1216. if !imageURLRegex.MatchString(u) {
  1217. continue
  1218. }
  1219. urls[u] = true
  1220. }
  1221. resURLs := make([]string, 0)
  1222. for k := range urls {
  1223. resURLs = append(resURLs, k)
  1224. }
  1225. p.Images = resURLs
  1226. }