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,139 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CacheDir(root string) string { return filepath.Join(root, "build", "upstream") }
|
||||
|
||||
func ReadLock(root string) (Lock, error) {
|
||||
var lock Lock
|
||||
b, err := os.ReadFile(filepath.Join(root, "gitea.lock.json"))
|
||||
if err != nil {
|
||||
return lock, err
|
||||
}
|
||||
if err = json.Unmarshal(b, &lock); err != nil {
|
||||
return lock, err
|
||||
}
|
||||
return lock, lock.validateIdentity()
|
||||
}
|
||||
|
||||
func LoadPinned(root string) (*Snapshot, error) {
|
||||
lock, err := ReadLock(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, err := Load(CacheDir(root))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !reflect.DeepEqual(lock, s.Lock) {
|
||||
return nil, fmt.Errorf("upstream cache differs from gitea.lock.json; run upstream prepare after reviewing the lock")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Prepare fetches immutable commit files only if the cache is absent. A corrupt
|
||||
// cache is an error, not permission to silently replace local evidence.
|
||||
func Prepare(ctx context.Context, root string) (*Snapshot, error) {
|
||||
return prepareFrom(ctx, root, "https://raw.githubusercontent.com/go-gitea/gitea/")
|
||||
}
|
||||
|
||||
func prepareFrom(ctx context.Context, root, rawBase string) (*Snapshot, error) {
|
||||
lock, err := ReadLock(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(CacheDir(root), "lock.json")); err == nil {
|
||||
return LoadPinned(root)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
s := &Snapshot{Lock: lock, Files: map[string][]byte{}}
|
||||
for name, hash := range lock.Files {
|
||||
if !SafePath(name) {
|
||||
return nil, fmt.Errorf("unsafe locked path %q", name)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawBase+lock.Commit+"/"+name, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "GiteaMailTemplates-locked-cache")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: HTTP %d", name, resp.StatusCode)
|
||||
}
|
||||
if Digest(b) != hash {
|
||||
return nil, fmt.Errorf("download checksum mismatch: %s", name)
|
||||
}
|
||||
s.Files[name] = b
|
||||
}
|
||||
if err = s.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := CacheDir(root)
|
||||
if err = os.MkdirAll(filepath.Dir(dir), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stage, err := os.MkdirTemp(filepath.Dir(dir), ".upstream-cache-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
for name, b := range s.Files {
|
||||
target := filepath.Join(stage, filepath.FromSlash(name))
|
||||
if err = os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = os.WriteFile(target, b, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
b, _ := json.MarshalIndent(lock, "", " ")
|
||||
if err = os.WriteFile(filepath.Join(stage, "lock.json"), append(b, '\n'), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = os.Stat(dir); err == nil {
|
||||
return nil, fmt.Errorf("incomplete upstream cache exists at %s; inspect it before removing it", dir)
|
||||
}
|
||||
if err = os.Rename(stage, dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Printf("[PASS] Downloaded locked Gitea %s (%s) to build cache\n", lock.Tag, lock.Commit)
|
||||
return LoadPinned(root)
|
||||
}
|
||||
|
||||
func SyncRoot(ctx context.Context, tag, root string) error {
|
||||
if err := os.MkdirAll(filepath.Join(root, "build"), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := Sync(ctx, tag, CacheDir(root)); err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := Load(CacheDir(root))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(s.Lock, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(root, "gitea.lock.json"), append(b, '\n'), 0644)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrepareCacheIsPinnedAndReusableOffline(t *testing.T) {
|
||||
s, err := LoadPinned(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
b, _ := json.Marshal(s.Lock)
|
||||
if err = os.WriteFile(filepath.Join(root, "gitea.lock.json"), b, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
prefix := "/" + s.Lock.Commit + "/"
|
||||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||
t.Errorf("unpinned request %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data, ok := s.Files[strings.TrimPrefix(r.URL.Path, prefix)]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Write(data)
|
||||
}))
|
||||
if _, err := prepareFrom(context.Background(), root, server.URL+"/"); err != nil {
|
||||
server.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.Close()
|
||||
if _, err := prepareFrom(context.Background(), root, server.URL+"/"); err != nil {
|
||||
t.Fatalf("offline cache: %v", err)
|
||||
}
|
||||
path := filepath.Join(CacheDir(root), "templates", "mail", "base", "head.tmpl")
|
||||
if err = os.WriteFile(path, []byte("corrupt"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := prepareFrom(context.Background(), root, server.URL+"/"); err == nil {
|
||||
t.Fatal("corrupt cache was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package upstream
|
||||
|
||||
// ReviewedFormattingIssue is deliberately matched by language, key and exact
|
||||
// upstream text. It cannot hide new defects after a snapshot update. Preview
|
||||
// preserves Gitea's output and advertises this upstream defect to the user.
|
||||
func ReviewedFormattingIssue(lang, key, value string) bool {
|
||||
return lang == "pl-PL" && key == "mail.team_invite.text_1" && value == "%[1]zaprasza Cię do dołączenia do zespołu %[2]s w organizacji %[3]s."
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"text/template"
|
||||
"text/template/parse"
|
||||
)
|
||||
|
||||
// ReferencedKeys walks Go's template AST, including nested pipelines and both
|
||||
// TrN keys. Dynamic translation keys require an explicit adapter review.
|
||||
func ReferencedKeys(s *Snapshot) ([]string, error) {
|
||||
fns := template.FuncMap{}
|
||||
for _, name := range []string{"AppUrl", "AppName", "AppDomain", "DotEscape", "QueryEscape", "ShortSha", "HTMLFormat", "PathEscapeSegments", "FormatByteSize", "Dict", "dict", "StringUtils", "SliceUtils", "JsonUtils", "QueryBuild", "PathEscape", "SanitizeHTML"} {
|
||||
fns[name] = func(...any) string { return "" }
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
var walk func(parse.Node) error
|
||||
walk = func(n parse.Node) error {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
switch n := n.(type) {
|
||||
case *parse.ListNode:
|
||||
if n != nil {
|
||||
for _, child := range n.Nodes {
|
||||
if err := walk(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
case *parse.ActionNode:
|
||||
return walk(n.Pipe)
|
||||
case *parse.PipeNode:
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
for _, cmd := range n.Cmds {
|
||||
if err := walk(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *parse.CommandNode:
|
||||
if len(n.Args) > 0 {
|
||||
var ident []string
|
||||
switch f := n.Args[0].(type) {
|
||||
case *parse.FieldNode:
|
||||
ident = f.Ident
|
||||
case *parse.VariableNode:
|
||||
ident = f.Ident
|
||||
}
|
||||
if len(ident) >= 2 && ident[len(ident)-2] == "locale" {
|
||||
var positions []int
|
||||
switch ident[len(ident)-1] {
|
||||
case "Tr", "TrString":
|
||||
positions = []int{1}
|
||||
case "TrN":
|
||||
positions = []int{2, 3}
|
||||
}
|
||||
for _, i := range positions {
|
||||
if i >= len(n.Args) {
|
||||
return fmt.Errorf("invalid translation call: %s", n)
|
||||
}
|
||||
key, ok := n.Args[i].(*parse.StringNode)
|
||||
if !ok {
|
||||
return fmt.Errorf("dynamic translation key needs review: %s", n)
|
||||
}
|
||||
keys[key.Text] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, arg := range n.Args {
|
||||
if err := walk(arg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *parse.IfNode:
|
||||
for _, child := range []parse.Node{n.Pipe, n.List, n.ElseList} {
|
||||
if err := walk(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *parse.RangeNode:
|
||||
for _, child := range []parse.Node{n.Pipe, n.List, n.ElseList} {
|
||||
if err := walk(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *parse.WithNode:
|
||||
for _, child := range []parse.Node{n.Pipe, n.List, n.ElseList} {
|
||||
if err := walk(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *parse.TemplateNode:
|
||||
return walk(n.Pipe)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for p, data := range s.Templates {
|
||||
t, err := template.New(p).Funcs(fns).Parse(string(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", p, err)
|
||||
}
|
||||
for _, t := range t.Templates() {
|
||||
if t.Tree != nil {
|
||||
if err := walk(t.Tree.Root); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var result []string
|
||||
for k := range keys {
|
||||
result = append(result, k)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ValidateKeys(s *Snapshot) error {
|
||||
keys, err := ReferencedKeys(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if s.Locales["en-US"][k] == "" {
|
||||
return fmt.Errorf("official English catalog is missing referenced key %q", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MissingKeys(s *Snapshot) (map[string][]string, error) {
|
||||
keys, err := ReferencedKeys(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
missing := map[string][]string{}
|
||||
for lang, catalog := range s.Locales {
|
||||
for _, k := range keys {
|
||||
if catalog[k] == "" {
|
||||
missing[lang] = append(missing[lang], k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// 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") }
|
||||
@@ -0,0 +1,86 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSnapshotAndKeys(t *testing.T) {
|
||||
s, err := Load(filepath.Join("..", "..", "build", "upstream"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Lock.Tag != "v28.0.0" || len(s.Templates) != 13 || len(s.Locales) != 28 {
|
||||
t.Fatalf("unexpected baseline %s", s.Lock.Tag)
|
||||
}
|
||||
keys, err := ReferencedKeys(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := map[string]bool{}
|
||||
for _, k := range keys {
|
||||
found[k] = true
|
||||
}
|
||||
for _, k := range []string{"mail.issue.action.push_1", "mail.issue.action.push_n", "actions.runs.attempt"} {
|
||||
if !found[k] {
|
||||
t.Fatalf("missing key %s", k)
|
||||
}
|
||||
}
|
||||
delete(s.Locales["en-US"], "actions.runs.attempt")
|
||||
if ValidateKeys(s) == nil {
|
||||
t.Fatal("missing English key accepted")
|
||||
}
|
||||
if !validTag("v28.0.0") || validTag("v1.27.3") || validTag("v28.1.0-rc1") {
|
||||
t.Fatal("tag policy")
|
||||
}
|
||||
for _, p := range []string{"../x", "/x", "C:/x", "mail\\x"} {
|
||||
if SafePath(p) {
|
||||
t.Fatal("unsafe path accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotRejectsChecksumAndAdapterDrift(t *testing.T) {
|
||||
s, err := Load(filepath.Join("..", "..", "build", "upstream"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
for p, b := range s.Files {
|
||||
target := filepath.Join(dir, filepath.FromSlash(p))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, b, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeLock := func() {
|
||||
b, _ := json.Marshal(s.Lock)
|
||||
if err := os.WriteFile(filepath.Join(dir, "lock.json"), b, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeLock()
|
||||
p := "templates/mail/base/head.tmpl"
|
||||
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), []byte("changed"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(dir); err == nil {
|
||||
t.Fatal("checksum mismatch accepted")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), s.Files[p], 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p = "modules/translation/translation.go"
|
||||
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), []byte("changed"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Lock.Files[p] = Digest([]byte("changed"))
|
||||
writeLock()
|
||||
if _, err := Load(dir); err == nil {
|
||||
t.Fatal("changed translation adapter accepted despite updated checksum")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var tagPattern = regexp.MustCompile(`^v([0-9]+)\.[0-9]+\.[0-9]+$`)
|
||||
|
||||
func validTag(tag string) bool {
|
||||
m := tagPattern.FindStringSubmatch(tag)
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
major, err := strconv.Atoi(m[1])
|
||||
return err == nil && major >= 28
|
||||
}
|
||||
|
||||
// Sync downloads by resolved commit, validates in memory, then swaps the whole
|
||||
// snapshot. Network/adapter failures leave the existing snapshot untouched.
|
||||
func Sync(ctx context.Context, tag, dir string) error {
|
||||
return syncFrom(ctx, tag, dir, "https://api.github.com/repos/go-gitea/gitea/", "https://raw.githubusercontent.com/go-gitea/gitea/")
|
||||
}
|
||||
|
||||
func syncFrom(ctx context.Context, tag, dir, api, raw string) error {
|
||||
if !validTag(tag) {
|
||||
return fmt.Errorf("expected a stable Gitea 28+ tag, e.g. v28.0.0")
|
||||
}
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
get := func(url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "GiteaMailTemplates-snapshot")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
return b, err
|
||||
}
|
||||
b, err := get(api + "commits/" + tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var commit struct {
|
||||
SHA string `json:"sha"`
|
||||
Commit struct {
|
||||
Tree struct {
|
||||
SHA string `json:"sha"`
|
||||
} `json:"tree"`
|
||||
} `json:"commit"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &commit); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err = get(api + "git/trees/" + commit.Commit.Tree.SHA + "?recursive=1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var tree struct {
|
||||
Truncated bool `json:"truncated"`
|
||||
Tree []struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
} `json:"tree"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &tree); err != nil {
|
||||
return err
|
||||
}
|
||||
if tree.Truncated {
|
||||
return fmt.Errorf("upstream tree is truncated")
|
||||
}
|
||||
s := &Snapshot{Lock: Lock{Schema: 1, Repository: "https://github.com/go-gitea/gitea", Tag: tag, Commit: commit.SHA, Files: map[string]string{}}, Files: map[string][]byte{}}
|
||||
for _, item := range tree.Tree {
|
||||
p := item.Path
|
||||
_, adapter := AdapterSources[p]
|
||||
selected := adapter || p == "LICENSE" || p == "public/assets/img/favicon.png" || (strings.HasPrefix(p, "templates/mail/") && strings.HasSuffix(p, ".tmpl")) || (strings.HasPrefix(p, "options/locale/locale_") && strings.HasSuffix(p, ".json"))
|
||||
if !selected || item.Type != "blob" {
|
||||
continue
|
||||
}
|
||||
b, err := get(raw + commit.SHA + "/" + p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Files[p] = b
|
||||
s.Lock.Files[p] = Digest(b)
|
||||
}
|
||||
if err := s.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Never replace a checkout or arbitrary non-snapshot directory.
|
||||
if filepath.Base(abs) != "upstream" {
|
||||
return fmt.Errorf("snapshot destination must be named upstream")
|
||||
}
|
||||
if entries, err := os.ReadDir(abs); err == nil && len(entries) > 0 {
|
||||
if _, err := loadFiles(abs); err != nil {
|
||||
return fmt.Errorf("refusing to replace invalid existing snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
stage, err := os.MkdirTemp(filepath.Dir(abs), ".upstream-stage-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
for p, data := range s.Files {
|
||||
filename := filepath.Join(stage, filepath.FromSlash(p))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filename, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b, err = json.MarshalIndent(s.Lock, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = append(b, '\n')
|
||||
if err := os.WriteFile(filepath.Join(stage, "lock.json"), b, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
backup, err := os.MkdirTemp(filepath.Dir(abs), ".upstream-backup-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(backup); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(abs, backup); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(stage, abs); err != nil {
|
||||
_ = os.Rename(backup, abs)
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(backup); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := os.Rename(stage, abs); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] Locked %s (%s): %d templates, %d languages\n", tag, commit.SHA, len(s.Templates), len(s.Locales))
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user