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,357 @@
|
||||
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") }
|
||||
Reference in new issue
Block a user