package upstream import ( "context" "encoding/json" "fmt" "gitea-mail-templates/tools/logging" "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 } logging.Info("Upstream", "Locked %s (%s): %d templates, %d languages", tag, commit.SHA, len(s.Templates), len(s.Locales)) return nil }