Files
KenanZhu fec3ace600
Release / Validate Templates (push) Canceled after 0s
Release / Package & Release (push) Canceled after 0s
Release / Update Latest Release Documentation (push) Canceled after 0s
chore: migrate mail themes to shared framework and locked upstream inputs
2026-10-09 18:34:36 +08:00

212 lines
6.1 KiB
Go

// Package upstream loads and explicitly synchronizes the reviewed Gitea snapshot.
package upstream
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
type Lock struct {
Schema int `json:"schema"`
Repository string `json:"repository"`
Tag string `json:"tag"`
Commit string `json:"commit"`
Files map[string]string `json:"files"`
}
type Snapshot struct {
Lock Lock
Files map[string][]byte
Templates map[string][]byte // mail/... paths, including shared partials
Locales map[string]map[string]string
}
// AdapterSources also binds the preview's formatting and rendering semantics to
// the upstream implementation reviewed during this migration. A changed source
// requires reviewing the adapter before accepting another snapshot.
var AdapterSources = map[string]string{
"modules/translation/translation.go": "4217cbd9beb79c0e1be52dd9a7d7705b9ff4becc2d9515d49236db126da0f7ee",
"modules/translation/i18n/localestore.go": "e55a7832d01753622a7d47e904de95e5aed7ad3535c59bf6ac3fec4346e40e9d",
"modules/translation/i18n/format.go": "f440236eaf1f73fe0a9aa36a2c036a4b34c6ab28c9e4cf971bef7fceee701cb4",
"modules/templates/mail.go": "fb8204b79f700f88c7c3f0da16e50c3d678a2216e62e98f712805ed1d42ab3a9",
}
func Digest(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
func SafePath(p string) bool {
return p != "." && fs.ValidPath(p) && !strings.ContainsAny(p, "\\:")
}
func Load(dir string) (*Snapshot, error) {
s, err := loadFiles(dir)
if err != nil {
return nil, err
}
return s, s.validate()
}
// Ownership/checksum validation is separate so explicitly syncing after an
// adapter review can replace an older, still intact snapshot.
func loadFiles(dir string) (*Snapshot, error) {
data, err := os.ReadFile(filepath.Join(dir, "lock.json"))
if err != nil {
return nil, fmt.Errorf("snapshot lock: %w (run upstream sync explicitly)", err)
}
s := &Snapshot{Files: map[string][]byte{}}
if err := json.Unmarshal(data, &s.Lock); err != nil {
return nil, err
}
if err := s.Lock.validateIdentity(); err != nil {
return nil, err
}
for p, expected := range s.Lock.Files {
if !SafePath(p) {
return nil, fmt.Errorf("unsafe snapshot path %q", p)
}
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
if err != nil {
return nil, err
}
if Digest(b) != expected {
return nil, fmt.Errorf("snapshot checksum mismatch: %s", p)
}
s.Files[p] = b
}
// Unlocked files must not silently become input to a build.
err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("snapshot symlinks are unsupported: %s", p)
}
if d.IsDir() {
return nil
}
rel, _ := filepath.Rel(dir, p)
rel = filepath.ToSlash(rel)
if rel != "lock.json" {
if _, ok := s.Files[rel]; !ok {
return fmt.Errorf("unlocked snapshot file: %s", rel)
}
}
return nil
})
if err != nil {
return nil, err
}
return s, nil
}
func (lock Lock) validateIdentity() error {
if lock.Schema != 1 || lock.Repository != "https://github.com/go-gitea/gitea" || !validTag(lock.Tag) || len(lock.Commit) != 40 || len(lock.Files) == 0 {
return fmt.Errorf("invalid snapshot identity or unsupported version")
}
if _, err := hex.DecodeString(lock.Commit); err != nil {
return fmt.Errorf("invalid snapshot commit: %w", err)
}
return nil
}
func (s *Snapshot) validate() error {
if err := s.Lock.validateIdentity(); err != nil {
return err
}
for p, hash := range AdapterSources {
if Digest(s.Files[p]) != hash {
return fmt.Errorf("upstream adapter changed: %s; review preview semantics before syncing", p)
}
}
if len(s.Files["LICENSE"]) == 0 {
return fmt.Errorf("missing upstream license")
}
s.Templates = map[string][]byte{}
s.Locales = map[string]map[string]string{}
for p, b := range s.Files {
switch {
case p == "public/assets/img/favicon.png":
if len(b) == 0 {
return fmt.Errorf("missing preview favicon")
}
case strings.HasPrefix(p, "templates/mail/") && strings.HasSuffix(p, ".tmpl"):
s.Templates[strings.TrimPrefix(p, "templates/")] = b
case strings.HasPrefix(p, "options/locale/locale_") && strings.HasSuffix(p, ".json"):
locale := strings.TrimSuffix(strings.TrimPrefix(p, "options/locale/locale_"), ".json")
values, err := DecodeLocale(b)
if err != nil {
return fmt.Errorf("%s: %w", p, err)
}
s.Locales[locale] = values
default:
if p != "LICENSE" {
if _, ok := AdapterSources[p]; !ok {
return fmt.Errorf("unsupported snapshot file: %s", p)
}
}
}
}
if len(s.Entrypoints()) == 0 || len(s.Templates["mail/base/head.tmpl"]) == 0 || len(s.Templates["mail/base/footer.tmpl"]) == 0 || len(s.Locales["en-US"]) == 0 {
return fmt.Errorf("snapshot is missing entrypoints, shared partials, or English translations")
}
return ValidateKeys(s)
}
// DecodeLocale mirrors Gitea's flat or one-level nested JSON catalog layout.
func DecodeLocale(data []byte) (map[string]string, error) {
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
result := map[string]string{}
for k, v := range raw {
switch v := v.(type) {
case string:
result[k] = v
case map[string]any:
for inner, value := range v {
str, ok := value.(string)
if !ok {
return nil, fmt.Errorf("unsupported translation value: %s.%s", k, inner)
}
result[k+"."+inner] = str
}
default:
return nil, fmt.Errorf("unsupported translation value: %s", k)
}
}
return result, nil
}
func (s *Snapshot) Entrypoints() []string {
var result []string
for p := range s.Templates {
if !strings.HasPrefix(p, "mail/base/") {
result = append(result, p)
}
}
sort.Strings(result)
return result
}
func (s *Snapshot) Languages() []string {
var langs []string
for l := range s.Locales {
langs = append(langs, l)
}
sort.Strings(langs)
return langs
}
func TemplateID(p string) string { return strings.TrimSuffix(path.Base(p), ".tmpl") }