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,42 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"golang.org/x/net/html"
|
||||
"io"
|
||||
)
|
||||
|
||||
func BodyAnchors(masked []byte) ([2]int, error) {
|
||||
var anchors [2]int
|
||||
opens, closes, offset := 0, 0, 0
|
||||
z := html.NewTokenizer(bytes.NewReader(masked))
|
||||
for {
|
||||
kind := z.Next()
|
||||
raw := z.Raw()
|
||||
start := offset
|
||||
offset += len(raw)
|
||||
switch kind {
|
||||
case html.ErrorToken:
|
||||
if z.Err() != io.EOF {
|
||||
return anchors, z.Err()
|
||||
}
|
||||
if opens != 1 || closes != 1 || anchors[0] >= anchors[1] {
|
||||
return anchors, fmt.Errorf("expected exactly one static body start and end")
|
||||
}
|
||||
return anchors, nil
|
||||
case html.StartTagToken:
|
||||
tag, _ := z.TagName()
|
||||
if bytes.Equal(tag, []byte("body")) {
|
||||
opens++
|
||||
anchors[0] = offset
|
||||
}
|
||||
case html.EndTagToken:
|
||||
tag, _ := z.TagName()
|
||||
if bytes.Equal(tag, []byte("body")) {
|
||||
closes++
|
||||
anchors[1] = start
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
@@ -0,0 +1,106 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerationPreservesSnapshot(t *testing.T) {
|
||||
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packages, err := BuildAll(filepath.Join("..", "..", "themes"), s, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, p := range packages {
|
||||
if len(p.Files) != len(s.Templates)+3 {
|
||||
t.Fatalf("%s missing generated files", name)
|
||||
}
|
||||
for filename, generated := range p.Files {
|
||||
if _, ok := p.Theme.Controls[filename]; ok {
|
||||
continue
|
||||
}
|
||||
original, err := StripInjections(generated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
baseline := s.Templates[filename]
|
||||
if !bytes.HasPrefix([]byte(filename), []byte("mail/base/")) {
|
||||
baseline, err = AdaptOfficial(s, filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(original, baseline) {
|
||||
t.Fatal("official source altered")
|
||||
}
|
||||
}
|
||||
again, err := Build(s, p.Theme)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for filename, b := range again.Files {
|
||||
if !bytes.Equal(b, p.Files[filename]) {
|
||||
t.Fatal("nondeterministic build")
|
||||
}
|
||||
}
|
||||
}
|
||||
shared, err := Build(s, &Theme{Name: "test", Mode: "shared", CSS: "a {color:#000}"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(shared.Files) != 2 {
|
||||
t.Fatal("shared theme overrides正文")
|
||||
}
|
||||
output := filepath.Join(t.TempDir(), "build", "themes")
|
||||
if err := Write(output, s, map[string]*Package{"test": packages["mono"]}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Write(output, s, map[string]*Package{"test": shared}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(output, "test", "mail", "repo", "release.tmpl")); !os.IsNotExist(err) {
|
||||
t.Fatal("stale framed override retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectUnsafeDecorationAndAnchors(t *testing.T) {
|
||||
for _, markup := range []string{"<div>Extra text</div>", "<a href='https://example.com'></a>", "<div onclick='bad()'></div>", "<div>{{.Subject}}</div>", "<table><tr></table>"} {
|
||||
if ValidateDecoration(markup) == nil {
|
||||
t.Fatalf("accepted %s", markup)
|
||||
}
|
||||
}
|
||||
for _, css := range []string{"a {background:url(https://example.com)}", "p {display:none}", "div:before {content:'Extra'}", "</style>"} {
|
||||
if ValidateCSS(css) == nil {
|
||||
t.Fatalf("accepted %s", css)
|
||||
}
|
||||
}
|
||||
if err := ValidateCSS("p {font-size:0.9em}"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ValidateCSS("p {font-size:0!important}") == nil {
|
||||
t.Fatal("accepted hidden text")
|
||||
}
|
||||
for _, source := range []string{"<html></html>", "<body><body>{{template \"mail/base/footer\"}}</body>", "<body></body>", "<body>{{template \"mail/base/footer\"}}</body></body>"} {
|
||||
if _, err := Frame([]byte(source), "<div>", "</div>"); err == nil {
|
||||
t.Fatalf("accepted changed anchor %s", source)
|
||||
}
|
||||
}
|
||||
good := []byte("<!-- <body> -->{{ $x := \"<body> }}\" }}<body>{{template \"mail/base/footer\"}}</body>")
|
||||
result, err := Frame(good, "<div>", "</div>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stripped, err := StripInjections(result)
|
||||
if err != nil || !bytes.Equal(stripped, good) {
|
||||
t.Fatal("action/comment anchors altered")
|
||||
}
|
||||
if _, err := MaskActions([]byte("{{ /* unclosed")); err == nil {
|
||||
t.Fatal("accepted invalid action")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type actionSpec struct {
|
||||
expression, href, text, key string
|
||||
args string
|
||||
}
|
||||
|
||||
// This is the single alignment layer between reviewed official mail contexts
|
||||
// and shared controls. Themes never own URLs, translation keys or mail logic.
|
||||
var actions = map[string]actionSpec{
|
||||
"activate": {expression: "$activate_url", href: "{{$activate_url}}", text: "{{$activate_url}}", key: "mail.activate_account"},
|
||||
"activate_email": {expression: "$activate_url", href: "{{$activate_url}}", text: "{{$activate_url}}", key: "mail.activate_email"},
|
||||
"reset_passwd": {expression: "$recover_url", href: "{{$recover_url}}", text: "{{$recover_url}}", key: "mail.reset_password"},
|
||||
"register_notify": {expression: `(printf "%suser/login" AppUrl)`, href: "{{AppUrl}}user/login", text: "{{AppUrl}}user/login", key: "mail.view_it_on", args: " AppName"},
|
||||
"team_invite": {expression: ".InviteURL", href: "{{.InviteURL}}", text: "{{.InviteURL}}", key: "mail.view_it_on", args: " AppName"},
|
||||
"collaborator": {expression: ".Link", key: "mail.view_it_on", args: " AppName"},
|
||||
"transfer": {expression: ".Link", key: "mail.view_it_on", args: " AppName"},
|
||||
"release": {expression: ".Release.HTMLURL", key: "mail.view_it_on", args: " AppName"},
|
||||
"workflow_run": {expression: ".Run.HTMLURL", key: "mail.view_it_on", args: " AppName"},
|
||||
"assigned": {expression: ".Link", key: "mail.view_it_on", args: " AppName"},
|
||||
"default": {expression: ".Link", key: "mail.view_it_on", args: " AppName"},
|
||||
}
|
||||
|
||||
type sourceEdit struct{ before, after string }
|
||||
|
||||
// AdaptOfficial leaves notification branches/data accesses upstream-owned;
|
||||
// only the presentation of the primary action and fallback is reorganized.
|
||||
// An edit ledger checks action changes before static presentation annotation.
|
||||
func AdaptOfficial(s *upstream.Snapshot, name string) ([]byte, error) {
|
||||
original := string(s.Templates[name])
|
||||
source := original
|
||||
spec, ok := actions[upstream.TemplateID(name)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("new mail type %s needs framework alignment", name)
|
||||
}
|
||||
if _, ok := s.Locales["en-US"][spec.key]; !ok {
|
||||
return nil, fmt.Errorf("missing framework translation key %s", spec.key)
|
||||
}
|
||||
control := fmt.Sprintf(`{{template "mail/base/action" (dict "URL" %s "Label" (.locale.Tr %q%s) "Hint" (.locale.Tr "mail.link_not_working_do_paste"))}}`, spec.expression, spec.key, spec.args)
|
||||
var edits []sourceEdit
|
||||
replace := func(before, after string) error {
|
||||
if strings.Count(source, before) != 1 {
|
||||
return fmt.Errorf("%s: expected one reviewed action anchor %q", name, before)
|
||||
}
|
||||
source = strings.Replace(source, before, after, 1)
|
||||
edits = append(edits, sourceEdit{before, after})
|
||||
return nil
|
||||
}
|
||||
if spec.href != "" {
|
||||
// Search only static markup/action boundaries reviewed for the primary URL.
|
||||
pattern := regexp.MustCompile(`<p(?: style="word-break: break-all;")?><a href="` + regexp.QuoteMeta(spec.href) + `">` + regexp.QuoteMeta(spec.text) + `</a></p>`)
|
||||
matches := pattern.FindAllString(source, -1)
|
||||
if len(matches) != 1 {
|
||||
return nil, fmt.Errorf("%s: primary URL anchor changed; review framework adapter", name)
|
||||
}
|
||||
if err := replace(matches[0], control); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hint := `<p>{{.locale.Tr "mail.link_not_working_do_paste"}}</p>`
|
||||
if strings.Contains(source, hint) {
|
||||
if err := replace(hint, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// These official bodies already contain their primary link in prose.
|
||||
// The extra button/fallback does not replace notification text or links.
|
||||
footer := `{{template "mail/base/footer"}}`
|
||||
if err := replace(footer, `<div class="framework-extra">`+control+`</div>`+"\n"+footer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Replay the recorded action edits and require an exact output match.
|
||||
replay := original
|
||||
for _, e := range edits {
|
||||
replay = strings.Replace(replay, e.before, e.after, 1)
|
||||
}
|
||||
if replay != source {
|
||||
return nil, fmt.Errorf("unrecorded framework change")
|
||||
}
|
||||
if !bytes.Equal([]byte(original), s.Templates[name]) {
|
||||
return nil, fmt.Errorf("upstream source mutated")
|
||||
}
|
||||
return paragraphRoles([]byte(source), upstream.TemplateID(name))
|
||||
}
|
||||
|
||||
// Use the established theme classes rather than duplicating their typography
|
||||
// in generic CSS. Only static paragraph tags outside Go actions are annotated.
|
||||
func paragraphRoles(data []byte, id string) ([]byte, error) {
|
||||
masked, err := MaskActions(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
anchors, err := BodyAnchors(masked)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags := regexp.MustCompile(`(?i)<p(?:\s[^<>]*?)?>`).FindAllIndex(masked[anchors[0]:anchors[1]], -1)
|
||||
type edit struct {
|
||||
at int
|
||||
class string
|
||||
}
|
||||
var edits []edit
|
||||
for i, loc := range tags {
|
||||
start, end := anchors[0]+loc[0], anchors[0]+loc[1]
|
||||
if bytes.Contains(masked[start:end], []byte("class=")) {
|
||||
continue
|
||||
}
|
||||
role := "email-text"
|
||||
if i == 0 && id != "activate" && id != "activate_email" {
|
||||
role = "email-heading"
|
||||
}
|
||||
stop := bytes.Index(masked[end:anchors[1]], []byte("</p>"))
|
||||
if stop < 0 {
|
||||
return nil, fmt.Errorf("unclosed official paragraph")
|
||||
}
|
||||
content := string(data[end : end+stop])
|
||||
for _, key := range []string{"mail.activate_account.text_2", "mail.register_notify.text_2", "mail.register_notify.text_3", "mail.team_invite.text_3"} {
|
||||
if strings.Contains(content, `"`+key+`"`) {
|
||||
role = "email-subtext"
|
||||
}
|
||||
}
|
||||
edits = append(edits, edit{start + 2, role})
|
||||
}
|
||||
result := bytes.Clone(data)
|
||||
for i := len(edits) - 1; i >= 0; i-- {
|
||||
e := edits[i]
|
||||
result = append(append(append([]byte{}, result[:e.at]...), []byte(` class="`+e.class+`"`)...), result[e.at:]...)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParagraphRolesPreserveOfficialActions(t *testing.T) {
|
||||
source := `<html><body><p>{{.locale.Tr "mail.hi_user_x" .Username}}</p><p>{{.locale.Tr "mail.register_notify.text_2"}}</p>{{printf "<p>not static</p>"}}<p class="existing">Kept</p></body></html>`
|
||||
result, err := paragraphRoles([]byte(source), "register_notify")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := strings.Replace(source, `<p>`, `<p class="email-heading">`, 1)
|
||||
want = strings.Replace(want, `<p>`, `<p class="email-subtext">`, 1)
|
||||
if string(result) != want {
|
||||
t.Fatalf("unexpected presentation annotation: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedAlignmentFailsOnDrift(t *testing.T) {
|
||||
s, err := upstream.LoadPinned(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "mail/user/auth/activate.tmpl"
|
||||
original := bytes.Clone(s.Templates[name])
|
||||
s.Templates[name] = bytes.Replace(original, []byte(`href="{{$activate_url}}"`), []byte(`href="changed"`), 1)
|
||||
if _, err := AdaptOfficial(s, name); err == nil {
|
||||
t.Fatal("unreviewed URL anchor accepted")
|
||||
}
|
||||
s.Templates[name] = original
|
||||
delete(s.Locales["en-US"], "mail.activate_account")
|
||||
if _, err := AdaptOfficial(s, name); err == nil {
|
||||
t.Fatal("missing button key accepted")
|
||||
}
|
||||
}
|
||||
@@ -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