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.
 
 
 
 
 

282 lines
9.5 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 (p *Post) augmentContent(c *Collection) {
  55. // Add post signatures
  56. if c.Signature != "" {
  57. p.Content += "\n\n" + c.Signature
  58. }
  59. }
  60. func (p *PublicPost) augmentContent() {
  61. p.Post.augmentContent(&p.Collection.Collection)
  62. }
  63. func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string {
  64. return applyMarkdownSpecial(data, false, baseURL, cfg)
  65. }
  66. func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *config.Config) string {
  67. mdExtensions := 0 |
  68. blackfriday.EXTENSION_TABLES |
  69. blackfriday.EXTENSION_FENCED_CODE |
  70. blackfriday.EXTENSION_AUTOLINK |
  71. blackfriday.EXTENSION_STRIKETHROUGH |
  72. blackfriday.EXTENSION_SPACE_HEADERS |
  73. blackfriday.EXTENSION_AUTO_HEADER_IDS
  74. htmlFlags := 0 |
  75. blackfriday.HTML_USE_SMARTYPANTS |
  76. blackfriday.HTML_SMARTYPANTS_DASHES
  77. if baseURL != "" {
  78. htmlFlags |= blackfriday.HTML_HASHTAGS
  79. }
  80. // Generate Markdown
  81. md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
  82. if baseURL != "" {
  83. // Replace special text generated by Markdown parser
  84. tagPrefix := baseURL + "tag:"
  85. if cfg.App.Chorus {
  86. tagPrefix = "/read/t/"
  87. }
  88. md = []byte(hashtagReg.ReplaceAll(md, []byte("<a href=\""+tagPrefix+"$1\" class=\"hashtag\"><span>#</span><span class=\"p-category\">$1</span></a>")))
  89. handlePrefix := cfg.App.Host + "/@/"
  90. md = []byte(mentionReg.ReplaceAll(md, []byte("<a href=\""+handlePrefix+"$1$2\" class=\"u-url mention\">@<span>$1$2</span></a>")))
  91. }
  92. // Strip out bad HTML
  93. policy := getSanitizationPolicy()
  94. policy.RequireNoFollowOnLinks(!skipNoFollow)
  95. outHTML := string(policy.SanitizeBytes(md))
  96. // Strip newlines on certain block elements that render with them
  97. outHTML = blockReg.ReplaceAllString(outHTML, "<$1>")
  98. outHTML = endBlockReg.ReplaceAllString(outHTML, "</$1></$2>")
  99. // Remove all query parameters on YouTube embed links
  100. // TODO: make this more specific. Taking the nuclear approach here to strip ?autoplay=1
  101. outHTML = youtubeReg.ReplaceAllString(outHTML, "$1")
  102. return outHTML
  103. }
  104. func applyBasicMarkdown(data []byte) string {
  105. mdExtensions := 0 |
  106. blackfriday.EXTENSION_STRIKETHROUGH |
  107. blackfriday.EXTENSION_SPACE_HEADERS |
  108. blackfriday.EXTENSION_HEADER_IDS
  109. htmlFlags := 0 |
  110. blackfriday.HTML_SKIP_HTML |
  111. blackfriday.HTML_USE_SMARTYPANTS |
  112. blackfriday.HTML_SMARTYPANTS_DASHES
  113. // Generate Markdown
  114. md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
  115. // Strip out bad HTML
  116. policy := bluemonday.UGCPolicy()
  117. policy.AllowAttrs("class", "id").Globally()
  118. outHTML := string(policy.SanitizeBytes(md))
  119. outHTML = markeddownReg.ReplaceAllString(outHTML, "$1")
  120. outHTML = strings.TrimRightFunc(outHTML, unicode.IsSpace)
  121. return outHTML
  122. }
  123. func postTitle(content, friendlyId string) string {
  124. const maxTitleLen = 80
  125. // Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML
  126. // entities added in by sanitizing the content.
  127. content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
  128. content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
  129. eol := strings.IndexRune(content, '\n')
  130. blankLine := strings.Index(content, "\n\n")
  131. if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen {
  132. return strings.TrimSpace(content[:blankLine])
  133. } else if utf8.RuneCountInString(content) <= maxTitleLen {
  134. return content
  135. }
  136. return friendlyId
  137. }
  138. // TODO: fix duplicated code from postTitle. postTitle is a widely used func we
  139. // don't have time to investigate right now.
  140. func friendlyPostTitle(content, friendlyId string) string {
  141. const maxTitleLen = 80
  142. // Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML
  143. // entities added in by sanitizing the content.
  144. content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
  145. content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
  146. eol := strings.IndexRune(content, '\n')
  147. blankLine := strings.Index(content, "\n\n")
  148. if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen {
  149. return strings.TrimSpace(content[:blankLine])
  150. } else if eol == -1 && utf8.RuneCountInString(content) <= maxTitleLen {
  151. return content
  152. }
  153. title, truncd := parse.TruncToWord(parse.PostLede(content, true), maxTitleLen)
  154. if truncd {
  155. title += "..."
  156. }
  157. return title
  158. }
  159. func getSanitizationPolicy() *bluemonday.Policy {
  160. policy := bluemonday.UGCPolicy()
  161. policy.AllowAttrs("src", "style").OnElements("iframe", "video", "audio")
  162. policy.AllowAttrs("src", "type").OnElements("source")
  163. policy.AllowAttrs("frameborder", "width", "height").Matching(bluemonday.Integer).OnElements("iframe")
  164. policy.AllowAttrs("allowfullscreen").OnElements("iframe")
  165. policy.AllowAttrs("controls", "loop", "muted", "autoplay").OnElements("video")
  166. policy.AllowAttrs("controls", "loop", "muted", "autoplay", "preload").OnElements("audio")
  167. policy.AllowAttrs("target").OnElements("a")
  168. policy.AllowAttrs("title").OnElements("abbr")
  169. policy.AllowAttrs("style", "class", "id").Globally()
  170. policy.AllowElements("header", "footer")
  171. policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
  172. return policy
  173. }
  174. func sanitizePost(content string) string {
  175. return strings.Replace(content, "<", "&lt;", -1)
  176. }
  177. // postDescription generates a description based on the given post content,
  178. // title, and post ID. This doesn't consider a V2 post field, `title` when
  179. // choosing what to generate. In case a post has a title, this function will
  180. // fail, and logic should instead be implemented to skip this when there's no
  181. // title, like so:
  182. // var desc string
  183. // if title == "" {
  184. // desc = postDescription(content, title, friendlyId)
  185. // } else {
  186. // desc = shortPostDescription(content)
  187. // }
  188. func postDescription(content, title, friendlyId string) string {
  189. maxLen := 140
  190. if content == "" {
  191. content = "WriteFreely is a painless, simple, federated blogging platform."
  192. } else {
  193. fmtStr := "%s"
  194. truncation := 0
  195. if utf8.RuneCountInString(content) > maxLen {
  196. // Post is longer than the max description, so let's show a better description
  197. fmtStr = "%s..."
  198. truncation = 3
  199. }
  200. if title == friendlyId {
  201. // No specific title was found; simply truncate the post, starting at the beginning
  202. content = fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1))
  203. } else {
  204. // There was a title, so return a real description
  205. blankLine := strings.Index(content, "\n\n")
  206. if blankLine < 0 {
  207. blankLine = 0
  208. }
  209. truncd := stringmanip.Substring(content, blankLine, blankLine+maxLen-truncation)
  210. contentNoNL := strings.Replace(truncd, "\n", " ", -1)
  211. content = strings.TrimSpace(fmt.Sprintf(fmtStr, contentNoNL))
  212. }
  213. }
  214. return content
  215. }
  216. func shortPostDescription(content string) string {
  217. maxLen := 140
  218. fmtStr := "%s"
  219. truncation := 0
  220. if utf8.RuneCountInString(content) > maxLen {
  221. // Post is longer than the max description, so let's show a better description
  222. fmtStr = "%s..."
  223. truncation = 3
  224. }
  225. return strings.TrimSpace(fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1)))
  226. }
  227. func handleRenderMarkdown(app *App, w http.ResponseWriter, r *http.Request) error {
  228. if !IsJSON(r) {
  229. return impart.HTTPError{Status: http.StatusUnsupportedMediaType, Message: "Markdown API only supports JSON requests"}
  230. }
  231. in := struct {
  232. CollectionURL string `json:"collection_url"`
  233. RawBody string `json:"raw_body"`
  234. }{}
  235. decoder := json.NewDecoder(r.Body)
  236. err := decoder.Decode(&in)
  237. if err != nil {
  238. log.Error("Couldn't parse markdown JSON request: %v", err)
  239. return ErrBadJSON
  240. }
  241. out := struct {
  242. Body string `json:"body"`
  243. }{
  244. Body: applyMarkdown([]byte(in.RawBody), in.CollectionURL, app.cfg),
  245. }
  246. return impart.WriteSuccess(w, out, http.StatusOK)
  247. }