Files
KenanZhu fec3ace600
Release / Validate Templates (push) Canceled after 0s
Release / Package & Release (push) Canceled after 0s
Release / Update Latest Release Documentation (push) Canceled after 0s
chore: migrate mail themes to shared framework and locked upstream inputs
2026-10-09 18:34:36 +08:00

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")
}
}
}