2018-12-24 17:45:15 +00:00
|
|
|
/*
|
2020-01-05 16:22:22 +00:00
|
|
|
* Copyright © 2018-2020 A Bunch Tell LLC.
|
2018-12-24 17:45:15 +00:00
|
|
|
*
|
|
|
|
* This file is part of WriteFreely.
|
|
|
|
*
|
|
|
|
* WriteFreely is free software: you can redistribute it and/or modify
|
|
|
|
* it under the terms of the GNU Affero General Public License, included
|
|
|
|
* in the LICENSE file in this source code package.
|
|
|
|
*/
|
2018-12-31 06:05:26 +00:00
|
|
|
|
2018-10-17 02:31:27 +00:00
|
|
|
package writefreely
|
|
|
|
|
|
|
|
import (
|
2019-10-15 22:03:45 +00:00
|
|
|
"encoding/json"
|
2018-11-08 04:43:11 +00:00
|
|
|
"fmt"
|
2018-10-17 02:31:27 +00:00
|
|
|
"html"
|
|
|
|
"html/template"
|
2019-10-15 22:03:45 +00:00
|
|
|
"net/http"
|
2020-08-07 06:05:43 +00:00
|
|
|
"net/url"
|
2018-10-17 02:31:27 +00:00
|
|
|
"regexp"
|
|
|
|
"strings"
|
|
|
|
"unicode"
|
|
|
|
"unicode/utf8"
|
2019-08-12 19:35:17 +00:00
|
|
|
|
|
|
|
"github.com/microcosm-cc/bluemonday"
|
|
|
|
stripmd "github.com/writeas/go-strip-markdown"
|
2019-12-17 20:27:34 +00:00
|
|
|
"github.com/writeas/impart"
|
2019-08-12 19:35:17 +00:00
|
|
|
blackfriday "github.com/writeas/saturday"
|
2019-12-17 20:27:34 +00:00
|
|
|
"github.com/writeas/web-core/log"
|
2019-08-12 19:35:17 +00:00
|
|
|
"github.com/writeas/web-core/stringmanip"
|
2019-09-09 15:26:40 +00:00
|
|
|
"github.com/writeas/writefreely/config"
|
2019-08-12 19:35:17 +00:00
|
|
|
"github.com/writeas/writefreely/parse"
|
2018-10-17 02:31:27 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
blockReg = regexp.MustCompile("<(ul|ol|blockquote)>\n")
|
|
|
|
endBlockReg = regexp.MustCompile("</([a-z]+)>\n</(ul|ol|blockquote)>")
|
|
|
|
youtubeReg = regexp.MustCompile("(https?://www.youtube.com/embed/[a-zA-Z0-9\\-_]+)(\\?[^\t\n\f\r \"']+)?")
|
|
|
|
titleElementReg = regexp.MustCompile("</?h[1-6]>")
|
2019-02-04 16:50:37 +00:00
|
|
|
hashtagReg = regexp.MustCompile(`{{\[\[\|\|([^|]+)\|\|\]\]}}`)
|
2018-10-17 02:31:27 +00:00
|
|
|
markeddownReg = regexp.MustCompile("<p>(.+)</p>")
|
2020-02-08 17:57:49 +00:00
|
|
|
mentionReg = regexp.MustCompile(`@([A-Za-z0-9._%+-]+)(@[A-Za-z0-9.-]+\.[A-Za-z]+)\b`)
|
2018-10-17 02:31:27 +00:00
|
|
|
)
|
|
|
|
|
2019-08-07 13:26:07 +00:00
|
|
|
func (p *Post) formatContent(cfg *config.Config, c *Collection, isOwner bool) {
|
2018-10-17 02:31:27 +00:00
|
|
|
baseURL := c.CanonicalURL()
|
2019-08-12 19:35:17 +00:00
|
|
|
// TODO: redundant
|
2018-11-10 03:10:46 +00:00
|
|
|
if !isSingleUser {
|
2018-10-17 02:31:27 +00:00
|
|
|
baseURL = "/" + c.Alias + "/"
|
|
|
|
}
|
|
|
|
p.HTMLTitle = template.HTML(applyBasicMarkdown([]byte(p.Title.String)))
|
2019-08-07 13:26:07 +00:00
|
|
|
p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL, cfg))
|
2019-02-04 16:50:37 +00:00
|
|
|
if exc := strings.Index(string(p.Content), "<!--more-->"); exc > -1 {
|
2019-08-07 13:26:07 +00:00
|
|
|
p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(p.Content[:exc]), baseURL, cfg))
|
2018-10-17 02:31:27 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-08-07 13:26:07 +00:00
|
|
|
func (p *PublicPost) formatContent(cfg *config.Config, isOwner bool) {
|
|
|
|
p.Post.formatContent(cfg, &p.Collection.Collection, isOwner)
|
2018-10-17 02:31:27 +00:00
|
|
|
}
|
|
|
|
|
2020-06-23 20:24:45 +00:00
|
|
|
func (p *Post) augmentContent(c *Collection) {
|
2021-02-23 22:36:35 +00:00
|
|
|
if p.PinnedPosition.Valid {
|
|
|
|
// Don't augment posts that are pinned
|
|
|
|
return
|
|
|
|
}
|
2021-02-24 17:49:28 +00:00
|
|
|
if strings.Index(p.Content, "<!--nosig-->") > -1 {
|
|
|
|
// Don't augment posts with the special "nosig" shortcode
|
|
|
|
return
|
|
|
|
}
|
2020-06-23 20:24:45 +00:00
|
|
|
// Add post signatures
|
|
|
|
if c.Signature != "" {
|
|
|
|
p.Content += "\n\n" + c.Signature
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *PublicPost) augmentContent() {
|
|
|
|
p.Post.augmentContent(&p.Collection.Collection)
|
|
|
|
}
|
|
|
|
|
2019-08-07 13:26:07 +00:00
|
|
|
func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string {
|
|
|
|
return applyMarkdownSpecial(data, false, baseURL, cfg)
|
2018-10-17 02:31:27 +00:00
|
|
|
}
|
|
|
|
|
2020-08-07 06:05:43 +00:00
|
|
|
func disableYoutubeAutoplay(outHTML string) string {
|
|
|
|
for _, match := range youtubeReg.FindAllString(outHTML, -1) {
|
|
|
|
u, err := url.Parse(match)
|
|
|
|
if err != nil {
|
2020-09-09 05:59:56 +00:00
|
|
|
continue
|
2020-08-07 06:05:43 +00:00
|
|
|
}
|
|
|
|
u.RawQuery = html.UnescapeString(u.RawQuery)
|
|
|
|
q := u.Query()
|
|
|
|
// Set Youtube autoplay url parameter, if any, to 0
|
|
|
|
if len(q["autoplay"]) == 1 {
|
|
|
|
q.Set("autoplay", "0")
|
|
|
|
}
|
|
|
|
u.RawQuery = q.Encode()
|
2020-09-09 06:01:32 +00:00
|
|
|
cleanURL := u.String()
|
|
|
|
outHTML = strings.Replace(outHTML, match, cleanURL, 1)
|
2020-08-07 06:05:43 +00:00
|
|
|
}
|
|
|
|
return outHTML
|
|
|
|
}
|
|
|
|
|
2019-08-07 13:26:07 +00:00
|
|
|
func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *config.Config) string {
|
2018-10-17 02:31:27 +00:00
|
|
|
mdExtensions := 0 |
|
|
|
|
blackfriday.EXTENSION_TABLES |
|
|
|
|
blackfriday.EXTENSION_FENCED_CODE |
|
|
|
|
blackfriday.EXTENSION_AUTOLINK |
|
|
|
|
blackfriday.EXTENSION_STRIKETHROUGH |
|
|
|
|
blackfriday.EXTENSION_SPACE_HEADERS |
|
|
|
|
blackfriday.EXTENSION_AUTO_HEADER_IDS
|
|
|
|
htmlFlags := 0 |
|
|
|
|
blackfriday.HTML_USE_SMARTYPANTS |
|
|
|
|
blackfriday.HTML_SMARTYPANTS_DASHES
|
|
|
|
|
2019-02-04 16:50:37 +00:00
|
|
|
if baseURL != "" {
|
|
|
|
htmlFlags |= blackfriday.HTML_HASHTAGS
|
|
|
|
}
|
|
|
|
|
2018-10-17 02:31:27 +00:00
|
|
|
// Generate Markdown
|
|
|
|
md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
|
2019-02-04 16:50:37 +00:00
|
|
|
if baseURL != "" {
|
|
|
|
// Replace special text generated by Markdown parser
|
2019-08-07 13:26:07 +00:00
|
|
|
tagPrefix := baseURL + "tag:"
|
|
|
|
if cfg.App.Chorus {
|
|
|
|
tagPrefix = "/read/t/"
|
|
|
|
}
|
|
|
|
md = []byte(hashtagReg.ReplaceAll(md, []byte("<a href=\""+tagPrefix+"$1\" class=\"hashtag\"><span>#</span><span class=\"p-category\">$1</span></a>")))
|
2020-02-08 17:57:49 +00:00
|
|
|
handlePrefix := cfg.App.Host + "/@/"
|
2020-02-08 18:04:23 +00:00
|
|
|
md = []byte(mentionReg.ReplaceAll(md, []byte("<a href=\""+handlePrefix+"$1$2\" class=\"u-url mention\">@<span>$1$2</span></a>")))
|
2019-02-04 16:50:37 +00:00
|
|
|
}
|
2018-10-17 02:31:27 +00:00
|
|
|
// Strip out bad HTML
|
|
|
|
policy := getSanitizationPolicy()
|
|
|
|
policy.RequireNoFollowOnLinks(!skipNoFollow)
|
|
|
|
outHTML := string(policy.SanitizeBytes(md))
|
|
|
|
// Strip newlines on certain block elements that render with them
|
|
|
|
outHTML = blockReg.ReplaceAllString(outHTML, "<$1>")
|
|
|
|
outHTML = endBlockReg.ReplaceAllString(outHTML, "</$1></$2>")
|
2020-08-07 06:05:43 +00:00
|
|
|
outHTML = disableYoutubeAutoplay(outHTML)
|
2018-10-17 02:31:27 +00:00
|
|
|
return outHTML
|
|
|
|
}
|
|
|
|
|
|
|
|
func applyBasicMarkdown(data []byte) string {
|
|
|
|
mdExtensions := 0 |
|
|
|
|
blackfriday.EXTENSION_STRIKETHROUGH |
|
|
|
|
blackfriday.EXTENSION_SPACE_HEADERS |
|
|
|
|
blackfriday.EXTENSION_HEADER_IDS
|
|
|
|
htmlFlags := 0 |
|
|
|
|
blackfriday.HTML_SKIP_HTML |
|
|
|
|
blackfriday.HTML_USE_SMARTYPANTS |
|
|
|
|
blackfriday.HTML_SMARTYPANTS_DASHES
|
|
|
|
|
|
|
|
// Generate Markdown
|
|
|
|
md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
|
|
|
|
// Strip out bad HTML
|
|
|
|
policy := bluemonday.UGCPolicy()
|
|
|
|
policy.AllowAttrs("class", "id").Globally()
|
|
|
|
outHTML := string(policy.SanitizeBytes(md))
|
|
|
|
outHTML = markeddownReg.ReplaceAllString(outHTML, "$1")
|
|
|
|
outHTML = strings.TrimRightFunc(outHTML, unicode.IsSpace)
|
|
|
|
|
|
|
|
return outHTML
|
|
|
|
}
|
|
|
|
|
|
|
|
func postTitle(content, friendlyId string) string {
|
|
|
|
const maxTitleLen = 80
|
|
|
|
|
2020-09-04 23:58:45 +00:00
|
|
|
content = stripHTMLWithoutEscaping(content)
|
2018-10-17 02:31:27 +00:00
|
|
|
|
|
|
|
content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
|
|
|
|
eol := strings.IndexRune(content, '\n')
|
|
|
|
blankLine := strings.Index(content, "\n\n")
|
|
|
|
if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen {
|
|
|
|
return strings.TrimSpace(content[:blankLine])
|
|
|
|
} else if utf8.RuneCountInString(content) <= maxTitleLen {
|
|
|
|
return content
|
|
|
|
}
|
|
|
|
return friendlyId
|
|
|
|
}
|
|
|
|
|
2018-11-08 04:43:11 +00:00
|
|
|
// TODO: fix duplicated code from postTitle. postTitle is a widely used func we
|
|
|
|
// don't have time to investigate right now.
|
|
|
|
func friendlyPostTitle(content, friendlyId string) string {
|
|
|
|
const maxTitleLen = 80
|
|
|
|
|
2020-09-04 23:58:45 +00:00
|
|
|
content = stripHTMLWithoutEscaping(content)
|
2018-11-08 04:43:11 +00:00
|
|
|
|
|
|
|
content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
|
|
|
|
eol := strings.IndexRune(content, '\n')
|
|
|
|
blankLine := strings.Index(content, "\n\n")
|
|
|
|
if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen {
|
|
|
|
return strings.TrimSpace(content[:blankLine])
|
|
|
|
} else if eol == -1 && utf8.RuneCountInString(content) <= maxTitleLen {
|
|
|
|
return content
|
|
|
|
}
|
|
|
|
title, truncd := parse.TruncToWord(parse.PostLede(content, true), maxTitleLen)
|
|
|
|
if truncd {
|
|
|
|
title += "..."
|
|
|
|
}
|
|
|
|
return title
|
|
|
|
}
|
|
|
|
|
2020-09-04 23:58:45 +00:00
|
|
|
// Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML
|
|
|
|
// entities added in by sanitizing the content.
|
|
|
|
func stripHTMLWithoutEscaping(content string) string {
|
|
|
|
return html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
|
|
|
|
}
|
|
|
|
|
2018-10-17 02:31:27 +00:00
|
|
|
func getSanitizationPolicy() *bluemonday.Policy {
|
|
|
|
policy := bluemonday.UGCPolicy()
|
2019-07-26 02:54:11 +00:00
|
|
|
policy.AllowAttrs("src", "style").OnElements("iframe", "video", "audio")
|
|
|
|
policy.AllowAttrs("src", "type").OnElements("source")
|
2018-10-17 02:31:27 +00:00
|
|
|
policy.AllowAttrs("frameborder", "width", "height").Matching(bluemonday.Integer).OnElements("iframe")
|
|
|
|
policy.AllowAttrs("allowfullscreen").OnElements("iframe")
|
|
|
|
policy.AllowAttrs("controls", "loop", "muted", "autoplay").OnElements("video")
|
2019-07-26 02:54:11 +00:00
|
|
|
policy.AllowAttrs("controls", "loop", "muted", "autoplay", "preload").OnElements("audio")
|
2018-10-17 02:31:27 +00:00
|
|
|
policy.AllowAttrs("target").OnElements("a")
|
2019-09-18 15:21:33 +00:00
|
|
|
policy.AllowAttrs("title").OnElements("abbr")
|
2018-10-17 02:31:27 +00:00
|
|
|
policy.AllowAttrs("style", "class", "id").Globally()
|
2020-06-22 20:33:52 +00:00
|
|
|
policy.AllowElements("header", "footer")
|
2018-10-17 02:31:27 +00:00
|
|
|
policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
|
|
|
|
return policy
|
|
|
|
}
|
2018-11-08 04:43:11 +00:00
|
|
|
|
|
|
|
func sanitizePost(content string) string {
|
|
|
|
return strings.Replace(content, "<", "<", -1)
|
|
|
|
}
|
|
|
|
|
|
|
|
// postDescription generates a description based on the given post content,
|
|
|
|
// title, and post ID. This doesn't consider a V2 post field, `title` when
|
|
|
|
// choosing what to generate. In case a post has a title, this function will
|
|
|
|
// fail, and logic should instead be implemented to skip this when there's no
|
|
|
|
// title, like so:
|
|
|
|
// var desc string
|
|
|
|
// if title == "" {
|
|
|
|
// desc = postDescription(content, title, friendlyId)
|
|
|
|
// } else {
|
|
|
|
// desc = shortPostDescription(content)
|
|
|
|
// }
|
|
|
|
func postDescription(content, title, friendlyId string) string {
|
|
|
|
maxLen := 140
|
|
|
|
|
|
|
|
if content == "" {
|
2019-04-12 01:33:33 +00:00
|
|
|
content = "WriteFreely is a painless, simple, federated blogging platform."
|
2018-11-08 04:43:11 +00:00
|
|
|
} else {
|
|
|
|
fmtStr := "%s"
|
|
|
|
truncation := 0
|
|
|
|
if utf8.RuneCountInString(content) > maxLen {
|
|
|
|
// Post is longer than the max description, so let's show a better description
|
|
|
|
fmtStr = "%s..."
|
|
|
|
truncation = 3
|
|
|
|
}
|
|
|
|
|
|
|
|
if title == friendlyId {
|
|
|
|
// No specific title was found; simply truncate the post, starting at the beginning
|
|
|
|
content = fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1))
|
|
|
|
} else {
|
|
|
|
// There was a title, so return a real description
|
|
|
|
blankLine := strings.Index(content, "\n\n")
|
|
|
|
if blankLine < 0 {
|
|
|
|
blankLine = 0
|
|
|
|
}
|
|
|
|
truncd := stringmanip.Substring(content, blankLine, blankLine+maxLen-truncation)
|
|
|
|
contentNoNL := strings.Replace(truncd, "\n", " ", -1)
|
|
|
|
content = strings.TrimSpace(fmt.Sprintf(fmtStr, contentNoNL))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return content
|
|
|
|
}
|
|
|
|
|
|
|
|
func shortPostDescription(content string) string {
|
|
|
|
maxLen := 140
|
|
|
|
fmtStr := "%s"
|
|
|
|
truncation := 0
|
|
|
|
if utf8.RuneCountInString(content) > maxLen {
|
|
|
|
// Post is longer than the max description, so let's show a better description
|
|
|
|
fmtStr = "%s..."
|
|
|
|
truncation = 3
|
|
|
|
}
|
|
|
|
return strings.TrimSpace(fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1)))
|
|
|
|
}
|
2019-10-15 22:03:45 +00:00
|
|
|
|
|
|
|
func handleRenderMarkdown(app *App, w http.ResponseWriter, r *http.Request) error {
|
2019-11-29 13:12:54 +00:00
|
|
|
if !IsJSON(r) {
|
2019-12-17 20:27:34 +00:00
|
|
|
return impart.HTTPError{Status: http.StatusUnsupportedMediaType, Message: "Markdown API only supports JSON requests"}
|
2019-10-15 22:03:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
in := struct {
|
2020-01-05 16:22:22 +00:00
|
|
|
CollectionURL string `json:"collection_url"`
|
|
|
|
RawBody string `json:"raw_body"`
|
|
|
|
}{}
|
2019-10-15 22:03:45 +00:00
|
|
|
|
2019-12-17 20:27:34 +00:00
|
|
|
decoder := json.NewDecoder(r.Body)
|
|
|
|
err := decoder.Decode(&in)
|
2019-10-15 22:03:45 +00:00
|
|
|
if err != nil {
|
2019-12-17 20:27:34 +00:00
|
|
|
log.Error("Couldn't parse markdown JSON request: %v", err)
|
|
|
|
return ErrBadJSON
|
2019-10-15 22:03:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
out := struct {
|
|
|
|
Body string `json:"body"`
|
|
|
|
}{
|
2020-01-05 16:22:22 +00:00
|
|
|
Body: applyMarkdown([]byte(in.RawBody), in.CollectionURL, app.cfg),
|
2019-10-15 22:03:45 +00:00
|
|
|
}
|
|
|
|
|
2019-12-19 16:28:06 +00:00
|
|
|
return impart.WriteSuccess(w, out, http.StatusOK)
|
2019-10-15 22:03:45 +00:00
|
|
|
}
|