358 lines
9.9 KiB
Go
358 lines
9.9 KiB
Go
package builder
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea-mail-templates/tools/upstream"
|
|
)
|
|
|
|
type Package struct {
|
|
Theme *Theme
|
|
Files map[string][]byte // only custom overrides required by this theme
|
|
}
|
|
|
|
func BuildAll(dir string, s *upstream.Snapshot, filter map[string]bool) (map[string]*Package, error) {
|
|
names, err := Discover(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
known := map[string]bool{}
|
|
for _, n := range names {
|
|
known[n] = true
|
|
}
|
|
for n := range filter {
|
|
if !known[n] {
|
|
return nil, fmt.Errorf("unknown theme %s", n)
|
|
}
|
|
}
|
|
result := map[string]*Package{}
|
|
for _, n := range names {
|
|
if len(filter) > 0 && !filter[n] {
|
|
continue
|
|
}
|
|
t, err := LoadTheme(filepath.Join(dir, n))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("theme %s: %w", n, err)
|
|
}
|
|
p, err := Build(s, t)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("theme %s: %w", n, err)
|
|
}
|
|
result[n] = p
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func injection(name, content string) string {
|
|
return "<!-- THEME:" + name + " -->" + content + "<!-- /THEME:" + name + " -->"
|
|
}
|
|
|
|
func Build(s *upstream.Snapshot, t *Theme) (*Package, error) {
|
|
if err := ValidateCSS(t.CSS); err != nil {
|
|
return nil, err
|
|
}
|
|
p := &Package{Theme: t, Files: map[string][]byte{}}
|
|
head := s.Templates["mail/base/head.tmpl"]
|
|
p.Files["mail/base/head.tmpl"] = append(bytes.Clone(head), []byte(injection("HEAD", "\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<style>\n"+t.CSS+"\n</style>\n"))...)
|
|
p.Files["mail/base/footer.tmpl"] = bytes.Clone(s.Templates["mail/base/footer.tmpl"])
|
|
if t.Mode == "framed" {
|
|
if err := ValidateDecoration(strings.ReplaceAll(t.Open, "__HEADER__", "") + strings.ReplaceAll(t.Footer, "__SIDEBAR__", "") + t.Close); err != nil {
|
|
return nil, err
|
|
}
|
|
for name, b := range t.Controls {
|
|
p.Files[name] = bytes.Clone(b)
|
|
}
|
|
for _, name := range s.Entrypoints() {
|
|
open := strings.ReplaceAll(t.Open, "__MAIL_TYPE__", upstream.TemplateID(name))
|
|
open = strings.ReplaceAll(open, "__HEADER__", fmt.Sprintf(`{{template "mail/base/header" (dict "Variant" %q)}}`, t.Layout))
|
|
middle := strings.ReplaceAll(t.Footer, "__SIDEBAR__", `{{template "mail/base/sidebar" .}}`)
|
|
adapted, err := AdaptOfficial(s, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
content, err := frameWithFooter(adapted, open, middle, t.Close)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
p.Files[name] = content
|
|
}
|
|
} else if t.Mode != "shared" {
|
|
return nil, fmt.Errorf("unsupported theme mode %s", t.Mode)
|
|
}
|
|
for name, b := range p.Files {
|
|
if _, ok := t.Controls[name]; ok && t.Mode == "framed" {
|
|
continue
|
|
}
|
|
stripped, err := StripInjections(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
baseline := s.Templates[name]
|
|
if t.Mode == "framed" && !strings.HasPrefix(name, "mail/base/") {
|
|
baseline, err = AdaptOfficial(s, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if !bytes.Equal(stripped, baseline) {
|
|
return nil, fmt.Errorf("generation changed official source: %s", name)
|
|
}
|
|
}
|
|
checked := *s
|
|
checked.Templates = EffectiveTemplates(s, p)
|
|
if err := upstream.ValidateKeys(&checked); err != nil {
|
|
return nil, fmt.Errorf("framework translations: %w", err)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// MaskActions keeps byte offsets stable and understands quotes, raw strings and
|
|
// Go template comments. HTML anchors inside actions must never be transformed.
|
|
func MaskActions(data []byte) ([]byte, error) {
|
|
masked, _, err := maskAndActions(data)
|
|
return masked, err
|
|
}
|
|
|
|
type actionSpan struct{ start, end int }
|
|
|
|
func maskAndActions(data []byte) ([]byte, []actionSpan, error) {
|
|
masked := bytes.Clone(data)
|
|
var actions []actionSpan
|
|
for i := 0; i < len(data)-1; i++ {
|
|
if data[i] != '{' || data[i+1] != '{' {
|
|
continue
|
|
}
|
|
start := i
|
|
i += 2
|
|
var quote byte
|
|
comment := false
|
|
closed := false
|
|
for i < len(data)-1 {
|
|
if comment {
|
|
if data[i] == '*' && data[i+1] == '/' {
|
|
comment = false
|
|
i += 2
|
|
} else {
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
if quote != 0 {
|
|
if data[i] == '\\' && quote != '`' {
|
|
i += 2
|
|
continue
|
|
}
|
|
if data[i] == quote {
|
|
quote = 0
|
|
}
|
|
i++
|
|
continue
|
|
}
|
|
if data[i] == '/' && data[i+1] == '*' {
|
|
comment = true
|
|
i += 2
|
|
continue
|
|
}
|
|
if data[i] == '"' || data[i] == '\'' || data[i] == '`' {
|
|
quote = data[i]
|
|
i++
|
|
continue
|
|
}
|
|
if data[i] == '}' && data[i+1] == '}' {
|
|
i += 2
|
|
closed = true
|
|
break
|
|
}
|
|
i++
|
|
}
|
|
if !closed {
|
|
return nil, nil, fmt.Errorf("unterminated Go template action")
|
|
}
|
|
for j := start; j < i; j++ {
|
|
if masked[j] != '\n' && masked[j] != '\r' {
|
|
masked[j] = ' '
|
|
}
|
|
}
|
|
actions = append(actions, actionSpan{start, i})
|
|
i--
|
|
}
|
|
return masked, actions, nil
|
|
}
|
|
|
|
func Frame(data []byte, open, close string) ([]byte, error) {
|
|
return frameWithFooter(data, open, "", close)
|
|
}
|
|
|
|
func frameWithFooter(data []byte, open, middle, close string) ([]byte, error) {
|
|
masked, actions, err := maskAndActions(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Tokenize only static HTML so fake anchors inside comments, attributes and
|
|
// style blocks do not become insertion points.
|
|
anchors, err := BodyAnchors(masked)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
start, end := anchors[0], anchors[1]
|
|
footer := []byte(`{{template "mail/base/footer"}}`)
|
|
footerAt, count := -1, 0
|
|
for _, a := range actions {
|
|
if bytes.Equal(data[a.start:a.end], footer) {
|
|
footerAt = a.start
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
return nil, fmt.Errorf("expected one reviewed official footer call")
|
|
}
|
|
if footerAt < start || footerAt > end {
|
|
return nil, fmt.Errorf("official footer is outside body")
|
|
}
|
|
result := append([]byte{}, data[:start]...)
|
|
result = append(result, []byte(injection("OPEN", open))...)
|
|
result = append(result, data[start:footerAt]...)
|
|
if middle != "" {
|
|
result = append(result, []byte(injection("FOOTER", middle))...)
|
|
}
|
|
result = append(result, data[footerAt:end]...)
|
|
result = append(result, []byte(injection("CLOSE", close))...)
|
|
result = append(result, data[end:]...)
|
|
return result, nil
|
|
}
|
|
|
|
var injectionPattern = regexp.MustCompile(`(?s)<!-- THEME:(HEAD|OPEN|FOOTER|CLOSE) -->.*?<!-- /THEME:(HEAD|OPEN|FOOTER|CLOSE) -->`)
|
|
|
|
func StripInjections(data []byte) ([]byte, error) {
|
|
for _, m := range injectionPattern.FindAllSubmatch(data, -1) {
|
|
if !bytes.Equal(m[1], m[2]) {
|
|
return nil, fmt.Errorf("mismatched injection markers")
|
|
}
|
|
}
|
|
result := injectionPattern.ReplaceAll(data, nil)
|
|
if bytes.Contains(result, []byte("<!-- THEME:")) || bytes.Contains(result, []byte("<!-- /THEME:")) {
|
|
return nil, fmt.Errorf("unpaired injection marker")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// EffectiveTemplates overlays the minimal install package on stock Gitea,
|
|
// matching the custom-before-builtin lookup used by Gitea's layered asset FS.
|
|
func EffectiveTemplates(s *upstream.Snapshot, p *Package) map[string][]byte {
|
|
result := map[string][]byte{}
|
|
for n, b := range s.Templates {
|
|
result[n] = b
|
|
}
|
|
if p != nil {
|
|
for n, b := range p.Files {
|
|
result[n] = b
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Write owns only generated files under build/themes. Updating files in place
|
|
// avoids directory-rename races with Windows antivirus and the dev watcher.
|
|
func Write(output string, s *upstream.Snapshot, packages map[string]*Package) error {
|
|
abs, err := filepath.Abs(output)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if filepath.Base(abs) != "themes" || filepath.Base(filepath.Dir(abs)) != "build" {
|
|
return fmt.Errorf("generated output must be build/themes")
|
|
}
|
|
if err := os.MkdirAll(abs, 0755); err != nil {
|
|
return err
|
|
}
|
|
var names []string
|
|
for n := range packages {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
if !ValidName(name) {
|
|
return fmt.Errorf("unsafe theme name")
|
|
}
|
|
dest := filepath.Join(abs, name)
|
|
if info, err := os.Lstat(dest); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("generated theme cannot be a symlink")
|
|
}
|
|
if _, err := os.Stat(dest); err == nil {
|
|
if _, err := os.Stat(filepath.Join(dest, "build.json")); err != nil {
|
|
return fmt.Errorf("refusing to replace non-generated directory %s", dest)
|
|
}
|
|
}
|
|
p := packages[name]
|
|
if err := os.MkdirAll(dest, 0755); err != nil {
|
|
return err
|
|
}
|
|
err := filepath.WalkDir(dest, func(filename string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("generated symlink %s", filename)
|
|
}
|
|
if !d.IsDir() && strings.HasSuffix(filename, ".tmpl") {
|
|
rel, _ := filepath.Rel(dest, filename)
|
|
rel = filepath.ToSlash(rel)
|
|
if !strings.HasPrefix(rel, "mail/") || !upstream.SafePath(rel) {
|
|
return fmt.Errorf("unexpected generated file %s", rel)
|
|
}
|
|
if _, ok := p.Files[rel]; !ok {
|
|
return os.Remove(filename)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hashes := map[string]string{}
|
|
for filename, b := range p.Files {
|
|
if !upstream.SafePath(filename) {
|
|
return fmt.Errorf("unsafe generated path")
|
|
}
|
|
target := filepath.Join(dest, filepath.FromSlash(filename))
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(target, b, 0644); err != nil {
|
|
return err
|
|
}
|
|
hashes[filename] = upstream.Digest(b)
|
|
}
|
|
meta, _ := json.MarshalIndent(map[string]any{"theme": name, "mode": p.Theme.Mode, "gitea_tag": s.Lock.Tag, "gitea_commit": s.Lock.Commit, "files": hashes, "sources": p.Theme.Sources}, "", " ")
|
|
if err := os.WriteFile(filepath.Join(dest, "build.json"), append(meta, '\n'), 0644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func Filter(names []string) (map[string]bool, error) {
|
|
if len(names) == 0 {
|
|
return nil, fmt.Errorf("specify themes or 'all'")
|
|
}
|
|
if len(names) == 1 && names[0] == "all" {
|
|
return nil, nil
|
|
}
|
|
f := map[string]bool{}
|
|
for _, n := range names {
|
|
if !ValidName(n) {
|
|
return nil, fmt.Errorf("invalid theme %q", n)
|
|
}
|
|
f[n] = true
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
func TemplateName(p string) string { return strings.TrimSuffix(p, ".tmpl") }
|