chore: migrate mail themes to shared framework and locked upstream inputs
This commit is contained in:
1 parent
5c0589f6f6
commit
fec3ace600
225 files changed
+4718
-15807
No files matched your search
@@ -0,0 +1,258 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user