aboutsummaryrefslogtreecommitdiff
path: root/plugins/viewhtml
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/viewhtml')
-rw-r--r--plugins/viewhtml/plugin.go10
-rw-r--r--plugins/viewhtml/public/assets/script.js18
-rw-r--r--plugins/viewhtml/public/assets/style.css5
-rw-r--r--plugins/viewhtml/sanitize.go193
-rw-r--r--plugins/viewhtml/viewer.go57
5 files changed, 283 insertions, 0 deletions
diff --git a/plugins/viewhtml/plugin.go b/plugins/viewhtml/plugin.go
new file mode 100644
index 0000000..f22364e
--- /dev/null
+++ b/plugins/viewhtml/plugin.go
@@ -0,0 +1,10 @@
+package koushinviewhtml
+
+import (
+ "git.sr.ht/~emersion/koushin"
+)
+
+func init() {
+ p := koushin.GoPlugin{Name: "viewhtml"}
+ koushin.RegisterPluginLoader(p.Loader())
+}
diff --git a/plugins/viewhtml/public/assets/script.js b/plugins/viewhtml/public/assets/script.js
new file mode 100644
index 0000000..fd7a1d5
--- /dev/null
+++ b/plugins/viewhtml/public/assets/script.js
@@ -0,0 +1,18 @@
+var emailFrame = document.getElementById("email-frame");
+if (emailFrame) {
+ // Resize the frame with its content
+ var resizeFrame = function() {
+ emailFrame.style.height = emailFrame.contentWindow.document.documentElement.scrollHeight + "px";
+ };
+ emailFrame.addEventListener("load", resizeFrame);
+ emailFrame.contentWindow.addEventListener("resize", resizeFrame);
+
+ // Polyfill in case the srcdoc attribute isn't supported
+ if (!emailFrame.srcdoc) {
+ var srcdoc = emailFrame.getAttribute("srcdoc");
+ var doc = emailFrame.contentWindow.document;
+ doc.open();
+ doc.write(srcdoc);
+ doc.close();
+ }
+}
diff --git a/plugins/viewhtml/public/assets/style.css b/plugins/viewhtml/public/assets/style.css
new file mode 100644
index 0000000..4f91f63
--- /dev/null
+++ b/plugins/viewhtml/public/assets/style.css
@@ -0,0 +1,5 @@
+iframe {
+ width: 100%;
+ height: 400px;
+ border: 0;
+}
diff --git a/plugins/viewhtml/sanitize.go b/plugins/viewhtml/sanitize.go
new file mode 100644
index 0000000..8cee481
--- /dev/null
+++ b/plugins/viewhtml/sanitize.go
@@ -0,0 +1,193 @@
+package koushinviewhtml
+
+import (
+ "bytes"
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/aymerick/douceur/css"
+ cssparser "github.com/chris-ramon/douceur/parser"
+ "github.com/microcosm-cc/bluemonday"
+ "golang.org/x/net/html"
+)
+
+// TODO: this doesn't accomodate for quoting
+var (
+ cssURLRegexp = regexp.MustCompile(`url\([^)]*\)`)
+ cssExprRegexp = regexp.MustCompile(`expression\([^)]*\)`)
+)
+
+var allowedStyles = map[string]bool{
+ "direction": true,
+ "font": true,
+ "font-family": true,
+ "font-style": true,
+ "font-variant": true,
+ "font-size": true,
+ "font-weight": true,
+ "letter-spacing": true,
+ "line-height": true,
+ "text-align": true,
+ "text-decoration": true,
+ "text-indent": true,
+ "text-overflow": true,
+ "text-shadow": true,
+ "text-transform": true,
+ "white-space": true,
+ "word-spacing": true,
+ "word-wrap": true,
+ "vertical-align": true,
+
+ "color": true,
+ "background": true,
+ "background-color": true,
+ "background-image": true,
+ "background-repeat": true,
+
+ "border": true,
+ "border-color": true,
+ "border-radius": true,
+ "height": true,
+ "margin": true,
+ "padding": true,
+ "width": true,
+ "max-width": true,
+ "min-width": true,
+
+ "clear": true,
+ "float": true,
+
+ "border-collapse": true,
+ "border-spacing": true,
+ "caption-side": true,
+ "empty-cells": true,
+ "table-layout": true,
+
+ "list-style-type": true,
+ "list-style-position": true,
+}
+
+func sanitizeCSSDecls(decls []*css.Declaration) []*css.Declaration {
+ sanitized := make([]*css.Declaration, 0, len(decls))
+ for _, decl := range decls {
+ if !allowedStyles[decl.Property] {
+ continue
+ }
+ if cssExprRegexp.FindStringIndex(decl.Value) != nil {
+ continue
+ }
+
+ // TODO: more robust CSS declaration parsing
+ decl.Value = cssURLRegexp.ReplaceAllString(decl.Value, "url(about:blank)")
+
+ sanitized = append(sanitized, decl)
+ }
+ return sanitized
+}
+
+func sanitizeCSSRule(rule *css.Rule) {
+ // Disallow @import
+ if rule.Kind == css.AtRule && strings.EqualFold(rule.Name, "@import") {
+ rule.Prelude = "url(about:blank)"
+ }
+
+ rule.Declarations = sanitizeCSSDecls(rule.Declarations)
+
+ for _, child := range rule.Rules {
+ sanitizeCSSRule(child)
+ }
+}
+
+func sanitizeNode(n *html.Node) {
+ if n.Type == html.ElementNode {
+ if strings.EqualFold(n.Data, "img") {
+ for i := range n.Attr {
+ attr := &n.Attr[i]
+ if strings.EqualFold(attr.Key, "src") {
+ attr.Val = "about:blank"
+ }
+ }
+ } else if strings.EqualFold(n.Data, "style") {
+ var s string
+ c := n.FirstChild
+ for c != nil {
+ if c.Type == html.TextNode {
+ s += c.Data
+ }
+
+ next := c.NextSibling
+ n.RemoveChild(c)
+ c = next
+ }
+
+ stylesheet, err := cssparser.Parse(s)
+ if err != nil {
+ s = ""
+ } else {
+ for _, rule := range stylesheet.Rules {
+ sanitizeCSSRule(rule)
+ }
+
+ s = stylesheet.String()
+ }
+
+ n.AppendChild(&html.Node{
+ Type: html.TextNode,
+ Data: s,
+ })
+ }
+
+ for i := range n.Attr {
+ // Don't use `i, attr := range n.Attr` since `attr` would be a copy
+ attr := &n.Attr[i]
+
+ if strings.EqualFold(attr.Key, "style") {
+ decls, err := cssparser.ParseDeclarations(attr.Val)
+ if err != nil {
+ attr.Val = ""
+ continue
+ }
+
+ decls = sanitizeCSSDecls(decls)
+
+ attr.Val = ""
+ for _, d := range decls {
+ attr.Val += d.String()
+ }
+ }
+ }
+ }
+
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ sanitizeNode(c)
+ }
+}
+
+func sanitizeHTML(b []byte) ([]byte, error) {
+ doc, err := html.Parse(bytes.NewReader(b))
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse HTML: %v", err)
+ }
+
+ sanitizeNode(doc)
+
+ var buf bytes.Buffer
+ if err := html.Render(&buf, doc); err != nil {
+ return nil, fmt.Errorf("failed to render HTML: %v", err)
+ }
+ b = buf.Bytes()
+
+ // bluemonday must always be run last
+ p := bluemonday.UGCPolicy()
+
+ // TODO: use bluemonday's AllowStyles once it's released and
+ // supports <style>
+ p.AllowElements("style")
+ p.AllowAttrs("style").Globally()
+
+ p.AddTargetBlankToFullyQualifiedLinks(true)
+ p.RequireNoFollowOnLinks(true)
+
+ return p.SanitizeBytes(b), nil
+}
diff --git a/plugins/viewhtml/viewer.go b/plugins/viewhtml/viewer.go
new file mode 100644
index 0000000..9734c37
--- /dev/null
+++ b/plugins/viewhtml/viewer.go
@@ -0,0 +1,57 @@
+package koushinviewhtml
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "io/ioutil"
+ "strings"
+
+ "git.sr.ht/~emersion/koushin"
+ koushinbase "git.sr.ht/~emersion/koushin/plugins/base"
+ "github.com/emersion/go-message"
+)
+
+const tpl = `
+<!-- allow-same-origin is required to resize the frame with its content -->
+<!-- allow-popups is required for target="_blank" links -->
+<iframe id="email-frame" srcdoc="{{.}}" sandbox="allow-same-origin allow-popups"></iframe>
+<script src="/plugins/viewhtml/assets/script.js"></script>
+<link rel="stylesheet" href="/plugins/viewhtml/assets/style.css">
+`
+
+type viewer struct{}
+
+func (viewer) ViewMessagePart(ctx *koushin.Context, msg *koushinbase.IMAPMessage, part *message.Entity) (interface{}, error) {
+ mimeType, _, err := part.Header.ContentType()
+ if err != nil {
+ return nil, err
+ }
+ if !strings.EqualFold(mimeType, "text/html") {
+ return nil, koushinbase.ErrViewUnsupported
+ }
+
+ body, err := ioutil.ReadAll(part.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read part body: %v", err)
+ }
+
+ body, err = sanitizeHTML(body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to sanitize HTML part: %v", err)
+ }
+
+ t := template.Must(template.New("view-html.html").Parse(tpl))
+
+ var buf bytes.Buffer
+ err = t.Execute(&buf, string(body))
+ if err != nil {
+ return nil, err
+ }
+
+ return template.HTML(buf.String()), nil
+}
+
+func init() {
+ koushinbase.RegisterViewer(viewer{})
+}