A clean, Markdown-based publishing platform made for writers. Write together, and build a community. https://writefreely.org
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 
 
 

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