A clean, Markdown-based publishing platform made for writers. Write together, and build a community. https://writefreely.org
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

309 lines
10 KiB

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