259 lines
7.9 KiB
Go
259 lines
7.9 KiB
Go
// Package builder generates Gitea overrides from immutable upstream templates.
|
|
package builder
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"gitea-mail-templates/tools/upstream"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
type Theme struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Mode string `json:"mode"`
|
|
Layout string `json:"layout,omitempty"`
|
|
CSS string `json:"-"`
|
|
Open string `json:"-"`
|
|
Close string `json:"-"`
|
|
Footer string `json:"-"`
|
|
Sources map[string]string `json:"-"`
|
|
Controls map[string][]byte `json:"-"`
|
|
}
|
|
|
|
var themeName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
|
var cssBlocks = regexp.MustCompile(`(?s)([^{}]+)\{([^{}]*)\}`)
|
|
var hiddenContent = regexp.MustCompile(`(?:display\s*:\s*none\b|visibility\s*:\s*hidden\b|font-size\s*:\s*0(?:px)?\s*(?:!important\s*)?(?:;|$))`)
|
|
|
|
func ValidName(name string) bool { return themeName.MatchString(name) }
|
|
|
|
func Discover(dir string) ([]string, error) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var names []string
|
|
for _, e := range entries {
|
|
if e.Type()&os.ModeSymlink != 0 {
|
|
return nil, fmt.Errorf("theme symlinks are unsupported: %s", e.Name())
|
|
}
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
if !ValidName(e.Name()) {
|
|
return nil, fmt.Errorf("invalid theme name %q", e.Name())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, e.Name(), "theme.json")); err != nil {
|
|
return nil, fmt.Errorf("theme %s: %w", e.Name(), err)
|
|
}
|
|
names = append(names, e.Name())
|
|
}
|
|
if len(names) == 0 {
|
|
return nil, fmt.Errorf("no theme manifests in %s", dir)
|
|
}
|
|
sort.Strings(names)
|
|
return names, nil
|
|
}
|
|
|
|
func LoadTheme(dir string) (*Theme, error) {
|
|
b, err := os.ReadFile(filepath.Join(dir, "theme.json"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t := &Theme{}
|
|
dec := json.NewDecoder(strings.NewReader(string(b)))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(t); err != nil {
|
|
return nil, err
|
|
}
|
|
if !ValidName(t.Name) || t.Name != filepath.Base(dir) || (t.Mode != "shared" && t.Mode != "framed") {
|
|
return nil, fmt.Errorf("invalid theme name or mode in %s", dir)
|
|
}
|
|
b, err = os.ReadFile(filepath.Join(dir, "theme.css"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.CSS = string(b)
|
|
if err := ValidateCSS(t.CSS); err != nil {
|
|
return nil, err
|
|
}
|
|
if t.Mode == "framed" {
|
|
if t.Layout == "" {
|
|
t.Layout = "standard"
|
|
}
|
|
if !ValidName(t.Layout) {
|
|
return nil, fmt.Errorf("invalid framework layout %q", t.Layout)
|
|
}
|
|
root := filepath.Dir(filepath.Dir(dir))
|
|
layoutDir := filepath.Join(root, "framework", "layouts", t.Layout)
|
|
b, err = os.ReadFile(filepath.Join(layoutDir, "frame-open.html"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Open = string(b)
|
|
b, err = os.ReadFile(filepath.Join(layoutDir, "frame-close.html"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Close = string(b)
|
|
b, err = os.ReadFile(filepath.Join(layoutDir, "frame-footer.html"))
|
|
if err == nil {
|
|
t.Footer = string(b)
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
if strings.Count(t.Open, "__HEADER__") != 1 {
|
|
return nil, fmt.Errorf("layout must contain one header slot")
|
|
}
|
|
if err := ValidateDecoration(strings.ReplaceAll(t.Open, "__HEADER__", "") + strings.ReplaceAll(t.Footer, "__SIDEBAR__", "") + t.Close); err != nil {
|
|
return nil, err
|
|
}
|
|
t.Controls = map[string][]byte{}
|
|
for _, name := range []string{"header", "footer", "action", "sidebar"} {
|
|
b, err := os.ReadFile(filepath.Join(root, "framework", "mail", "base", name+".tmpl"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Controls["mail/base/"+name+".tmpl"] = b
|
|
}
|
|
}
|
|
t.Sources = map[string]string{}
|
|
for _, name := range []string{"theme.json", "theme.css"} {
|
|
filename := filepath.Join(dir, name)
|
|
info, err := os.Lstat(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("theme resource must be a regular file: %s", filename)
|
|
}
|
|
data, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Sources[name] = upstream.Digest(data)
|
|
}
|
|
if t.Mode == "framed" {
|
|
root := filepath.Dir(filepath.Dir(dir))
|
|
for name, b := range t.Controls {
|
|
t.Sources["framework/"+name] = upstream.Digest(b)
|
|
}
|
|
for _, name := range []string{"frame-open.html", "frame-footer.html", "frame-close.html"} {
|
|
path := filepath.Join("framework", "layouts", t.Layout, name)
|
|
b, err := os.ReadFile(filepath.Join(root, path))
|
|
if os.IsNotExist(err) && name == "frame-footer.html" {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Sources[filepath.ToSlash(path)] = upstream.Digest(b)
|
|
}
|
|
for _, name := range []string{"build.go", "framework.go", "theme.go", "anchors.go"} {
|
|
path := filepath.Join("tools", "builder", name)
|
|
b, err := os.ReadFile(filepath.Join(root, path))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Sources[filepath.ToSlash(path)] = upstream.Digest(b)
|
|
}
|
|
}
|
|
if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(dir, path)
|
|
if rel != "theme.json" && rel != "theme.css" {
|
|
return fmt.Errorf("theme %s may contain only theme.json and theme.css, not %s", t.Name, rel)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func ValidateCSS(css string) error {
|
|
low := strings.ToLower(css)
|
|
for _, bad := range []string{"{{", "}}", "<", "url(", "@import", "expression(", "javascript:", "content:", "content :"} {
|
|
if strings.Contains(low, bad) {
|
|
return fmt.Errorf("theme CSS contains disallowed content %q", bad)
|
|
}
|
|
}
|
|
for _, block := range cssBlocks.FindAllStringSubmatch(low, -1) {
|
|
if hiddenContent.MatchString(block[2]) {
|
|
for _, selector := range strings.Split(block[1], ",") {
|
|
selector = strings.TrimSpace(selector)
|
|
if selector != ".resp-hide" && selector != ".email-sidebar" && selector != ".email-ornament" {
|
|
return fmt.Errorf("CSS may hide only decorative nodes, not %s", selector)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Decoration may contain structural presentation nodes but no text, business
|
|
// links, template actions, scripts, images, or external resources.
|
|
func ValidateDecoration(fragment string) error {
|
|
if strings.Contains(fragment, "{{") || strings.Contains(fragment, "}}") {
|
|
return fmt.Errorf("decoration must not contain Go template actions")
|
|
}
|
|
allowed := map[string]bool{"table": true, "tbody": true, "tr": true, "td": true, "div": true, "span": true}
|
|
attrs := map[string]bool{"class": true, "style": true, "role": true, "width": true, "height": true, "cellpadding": true, "cellspacing": true, "border": true, "align": true, "valign": true, "aria-hidden": true, "colspan": true}
|
|
z := html.NewTokenizer(strings.NewReader(fragment))
|
|
var stack []string
|
|
for {
|
|
switch z.Next() {
|
|
case html.ErrorToken:
|
|
if z.Err() != io.EOF {
|
|
return z.Err()
|
|
}
|
|
if len(stack) != 0 {
|
|
return fmt.Errorf("unbalanced decoration")
|
|
}
|
|
return nil
|
|
case html.TextToken:
|
|
if strings.TrimSpace(string(z.Text())) != "" {
|
|
return fmt.Errorf("decoration must not add visible text")
|
|
}
|
|
case html.StartTagToken, html.SelfClosingTagToken:
|
|
token := z.Token()
|
|
if !allowed[token.Data] {
|
|
return fmt.Errorf("disallowed decoration element %s", token.Data)
|
|
}
|
|
for _, a := range token.Attr {
|
|
if !attrs[a.Key] {
|
|
return fmt.Errorf("disallowed decoration attribute %s", a.Key)
|
|
}
|
|
if a.Key == "style" {
|
|
if err := ValidateCSS(a.Val); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if token.Type == html.StartTagToken {
|
|
stack = append(stack, token.Data)
|
|
}
|
|
case html.EndTagToken:
|
|
token := z.Token()
|
|
if len(stack) == 0 || stack[len(stack)-1] != token.Data {
|
|
return fmt.Errorf("unbalanced decoration end %s", token.Data)
|
|
}
|
|
stack = stack[:len(stack)-1]
|
|
default:
|
|
return fmt.Errorf("decoration accepts only static presentation markup")
|
|
}
|
|
}
|
|
}
|