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.
 
 
 
 
 

270 lines
9.3 KiB

  1. /*
  2. * Copyright © 2018-2020 A Bunch Tell LLC.
  3. *
  4. * This file is part of WriteFreely.
  5. *
  6. * WriteFreely is free software: you can redistribute it and/or modify
  7. * it under the terms of the GNU Affero General Public License, included
  8. * in the LICENSE file in this source code package.
  9. */
  10. package writefreely
  11. import (
  12. "encoding/json"
  13. "fmt"
  14. "html"
  15. "html/template"
  16. "net/http"
  17. "regexp"
  18. "strings"
  19. "unicode"
  20. "unicode/utf8"
  21. "github.com/microcosm-cc/bluemonday"
  22. stripmd "github.com/writeas/go-strip-markdown"
  23. "github.com/writeas/impart"
  24. blackfriday "github.com/writeas/saturday"
  25. "github.com/writeas/web-core/log"
  26. "github.com/writeas/web-core/stringmanip"
  27. "github.com/writeas/writefreely/config"
  28. "github.com/writeas/writefreely/parse"
  29. )
  30. var (
  31. blockReg = regexp.MustCompile("<(ul|ol|blockquote)>\n")
  32. endBlockReg = regexp.MustCompile("</([a-z]+)>\n</(ul|ol|blockquote)>")
  33. youtubeReg = regexp.MustCompile("(https?://www.youtube.com/embed/[a-zA-Z0-9\\-_]+)(\\?[^\t\n\f\r \"']+)?")
  34. titleElementReg = regexp.MustCompile("</?h[1-6]>")
  35. hashtagReg = regexp.MustCompile(`{{\[\[\|\|([^|]+)\|\|\]\]}}`)
  36. markeddownReg = regexp.MustCompile("<p>(.+)</p>")
  37. mentionReg = regexp.MustCompile(`@([A-Za-z0-9._%+-]+)(@[A-Za-z0-9.-]+\.[A-Za-z]+)\b`)
  38. )
  39. func (p *Post) formatContent(cfg *config.Config, c *Collection, isOwner bool) {
  40. baseURL := c.CanonicalURL()
  41. // TODO: redundant
  42. if !isSingleUser {
  43. baseURL = "/" + c.Alias + "/"
  44. }
  45. p.HTMLTitle = template.HTML(applyBasicMarkdown([]byte(p.Title.String)))
  46. p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL, cfg))
  47. if exc := strings.Index(string(p.Content), "<!--more-->"); exc > -1 {
  48. p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(p.Content[:exc]), baseURL, cfg))
  49. }
  50. }
  51. func (p *PublicPost) formatContent(cfg *config.Config, isOwner bool) {
  52. p.Post.formatContent(cfg, &p.Collection.Collection, isOwner)
  53. }
  54. func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string {
  55. return applyMarkdownSpecial(data, false, baseURL, cfg)
  56. }
  57. func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *config.Config) string {
  58. mdExtensions := 0 |
  59. blackfriday.EXTENSION_TABLES |
  60. blackfriday.EXTENSION_FENCED_CODE |
  61. blackfriday.EXTENSION_AUTOLINK |
  62. blackfriday.EXTENSION_STRIKETHROUGH |
  63. blackfriday.EXTENSION_SPACE_HEADERS |
  64. blackfriday.EXTENSION_AUTO_HEADER_IDS
  65. htmlFlags := 0 |
  66. blackfriday.HTML_USE_SMARTYPANTS |
  67. blackfriday.HTML_SMARTYPANTS_DASHES
  68. if baseURL != "" {
  69. htmlFlags |= blackfriday.HTML_HASHTAGS
  70. }
  71. // Generate Markdown
  72. md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
  73. if baseURL != "" {
  74. // Replace special text generated by Markdown parser
  75. tagPrefix := baseURL + "tag:"
  76. if cfg.App.Chorus {
  77. tagPrefix = "/read/t/"
  78. }
  79. md = []byte(hashtagReg.ReplaceAll(md, []byte("<a href=\""+tagPrefix+"$1\" class=\"hashtag\"><span>#</span><span class=\"p-category\">$1</span></a>")))
  80. handlePrefix := cfg.App.Host + "/@/"
  81. md = []byte(mentionReg.ReplaceAll(md, []byte("<a href=\""+handlePrefix+"$1$2\" class=\"u-url mention\">@<span>$1$2</span></a>")))
  82. }
  83. // Strip out bad HTML
  84. policy := getSanitizationPolicy()
  85. policy.RequireNoFollowOnLinks(!skipNoFollow)
  86. outHTML := string(policy.SanitizeBytes(md))
  87. // Strip newlines on certain block elements that render with them
  88. outHTML = blockReg.ReplaceAllString(outHTML, "<$1>")
  89. outHTML = endBlockReg.ReplaceAllString(outHTML, "</$1></$2>")
  90. // Remove all query parameters on YouTube embed links
  91. // TODO: make this more specific. Taking the nuclear approach here to strip ?autoplay=1
  92. outHTML = youtubeReg.ReplaceAllString(outHTML, "$1")
  93. return outHTML
  94. }
  95. func applyBasicMarkdown(data []byte) string {
  96. mdExtensions := 0 |
  97. blackfriday.EXTENSION_STRIKETHROUGH |
  98. blackfriday.EXTENSION_SPACE_HEADERS |
  99. blackfriday.EXTENSION_HEADER_IDS
  100. htmlFlags := 0 |
  101. blackfriday.HTML_SKIP_HTML |
  102. blackfriday.HTML_USE_SMARTYPANTS |
  103. blackfriday.HTML_SMARTYPANTS_DASHES
  104. // Generate Markdown
  105. md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
  106. // Strip out bad HTML
  107. policy := bluemonday.UGCPolicy()
  108. policy.AllowAttrs("class", "id").Globally()
  109. outHTML := string(policy.SanitizeBytes(md))
  110. outHTML = markeddownReg.ReplaceAllString(outHTML, "$1")
  111. outHTML = strings.TrimRightFunc(outHTML, unicode.IsSpace)
  112. return outHTML
  113. }
  114. func postTitle(content, friendlyId string) string {
  115. const maxTitleLen = 80
  116. // Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML
  117. // entities added in by sanitizing the content.
  118. content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
  119. content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
  120. eol := strings.IndexRune(content, '\n')
  121. blankLine := strings.Index(content, "\n\n")
  122. if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen {
  123. return strings.TrimSpace(content[:blankLine])
  124. } else if utf8.RuneCountInString(content) <= maxTitleLen {
  125. return content
  126. }
  127. return friendlyId
  128. }
  129. // TODO: fix duplicated code from postTitle. postTitle is a widely used func we
  130. // don't have time to investigate right now.
  131. func friendlyPostTitle(content, friendlyId string) string {
  132. const maxTitleLen = 80
  133. // Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML
  134. // entities added in by sanitizing the content.
  135. content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
  136. content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
  137. eol := strings.IndexRune(content, '\n')
  138. blankLine := strings.Index(content, "\n\n")
  139. if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen {
  140. return strings.TrimSpace(content[:blankLine])
  141. } else if eol == -1 && utf8.RuneCountInString(content) <= maxTitleLen {
  142. return content
  143. }
  144. title, truncd := parse.TruncToWord(parse.PostLede(content, true), maxTitleLen)
  145. if truncd {
  146. title += "..."
  147. }
  148. return title
  149. }
  150. func getSanitizationPolicy() *bluemonday.Policy {
  151. policy := bluemonday.UGCPolicy()
  152. policy.AllowAttrs("src", "style").OnElements("iframe", "video", "audio")
  153. policy.AllowAttrs("src", "type").OnElements("source")
  154. policy.AllowAttrs("frameborder", "width", "height").Matching(bluemonday.Integer).OnElements("iframe")
  155. policy.AllowAttrs("allowfullscreen").OnElements("iframe")
  156. policy.AllowAttrs("controls", "loop", "muted", "autoplay").OnElements("video")
  157. policy.AllowAttrs("controls", "loop", "muted", "autoplay", "preload").OnElements("audio")
  158. policy.AllowAttrs("target").OnElements("a")
  159. policy.AllowAttrs("title").OnElements("abbr")
  160. policy.AllowAttrs("style", "class", "id").Globally()
  161. policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
  162. return policy
  163. }
  164. func sanitizePost(content string) string {
  165. return strings.Replace(content, "<", "&lt;", -1)
  166. }
  167. // postDescription generates a description based on the given post content,
  168. // title, and post ID. This doesn't consider a V2 post field, `title` when
  169. // choosing what to generate. In case a post has a title, this function will
  170. // fail, and logic should instead be implemented to skip this when there's no
  171. // title, like so:
  172. // var desc string
  173. // if title == "" {
  174. // desc = postDescription(content, title, friendlyId)
  175. // } else {
  176. // desc = shortPostDescription(content)
  177. // }
  178. func postDescription(content, title, friendlyId string) string {
  179. maxLen := 140
  180. if content == "" {
  181. content = "WriteFreely is a painless, simple, federated blogging platform."
  182. } else {
  183. fmtStr := "%s"
  184. truncation := 0
  185. if utf8.RuneCountInString(content) > maxLen {
  186. // Post is longer than the max description, so let's show a better description
  187. fmtStr = "%s..."
  188. truncation = 3
  189. }
  190. if title == friendlyId {
  191. // No specific title was found; simply truncate the post, starting at the beginning
  192. content = fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1))
  193. } else {
  194. // There was a title, so return a real description
  195. blankLine := strings.Index(content, "\n\n")
  196. if blankLine < 0 {
  197. blankLine = 0
  198. }
  199. truncd := stringmanip.Substring(content, blankLine, blankLine+maxLen-truncation)
  200. contentNoNL := strings.Replace(truncd, "\n", " ", -1)
  201. content = strings.TrimSpace(fmt.Sprintf(fmtStr, contentNoNL))
  202. }
  203. }
  204. return content
  205. }
  206. func shortPostDescription(content string) string {
  207. maxLen := 140
  208. fmtStr := "%s"
  209. truncation := 0
  210. if utf8.RuneCountInString(content) > maxLen {
  211. // Post is longer than the max description, so let's show a better description
  212. fmtStr = "%s..."
  213. truncation = 3
  214. }
  215. return strings.TrimSpace(fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1)))
  216. }
  217. func handleRenderMarkdown(app *App, w http.ResponseWriter, r *http.Request) error {
  218. if !IsJSON(r) {
  219. return impart.HTTPError{Status: http.StatusUnsupportedMediaType, Message: "Markdown API only supports JSON requests"}
  220. }
  221. in := struct {
  222. CollectionURL string `json:"collection_url"`
  223. RawBody string `json:"raw_body"`
  224. }{}
  225. decoder := json.NewDecoder(r.Body)
  226. err := decoder.Decode(&in)
  227. if err != nil {
  228. log.Error("Couldn't parse markdown JSON request: %v", err)
  229. return ErrBadJSON
  230. }
  231. out := struct {
  232. Body string `json:"body"`
  233. }{
  234. Body: applyMarkdown([]byte(in.RawBody), in.CollectionURL, app.cfg),
  235. }
  236. return impart.WriteSuccess(w, out, http.StatusOK)
  237. }