A clean, Markdown-based publishing platform made for writers. Write together, and build a community. https://writefreely.org
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 
 

1373 řádky
36 KiB

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