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