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.
 
 
 
 
 

364 rivejä
12 KiB

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