package lexicomai import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "sort" "strings" ) // repoFile — элемент листинга каталога SFTPGo web-client (/dirs). type repoFile struct { Name string `json:"name"` Type int `json:"type"` // 1 = папка, 2 = файл Size int64 `json:"size"` DirPath string `json:"dir_path"` } // repoClient — аутентифицированный клиент web-API репозитория. // // Доступ — только web-client (REST /api/v2 и SFTP снаружи закрыты): // // POST {base}/sftpgo/web/client/login (form) → Set-Cookie: jwt // // Креды — из конфига (${VAR}), никогда из кода. Отсутствие кред — fail-closed. func (m *Manager) repoClient(ctx context.Context, cfg *Config) (*http.Client, error) { base := strings.TrimRight(cfg.Repo.Base, "/") if base == "" || cfg.Repo.User == "" || cfg.Repo.Pass == "" { return nil, fmt.Errorf("repo not configured (repo.base/user/pass)") } jar, _ := cookiejar.New(nil) client := &http.Client{Jar: jar} loginBody := "username=" + url.QueryEscape(cfg.Repo.User) + "&password=" + url.QueryEscape(cfg.Repo.Pass) req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/sftpgo/web/client/login", strings.NewReader(loginBody)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("repo login: %w", err) } io.Copy(io.Discard, resp.Body) resp.Body.Close() return client, nil } // repoList возвращает имена файлов в /repos// (только .sh). func (m *Manager) repoList(ctx context.Context, client *http.Client, base, product, branch string) ([]string, error) { p := fmt.Sprintf("/repos/%s/%s", product, branch) listURL := base + "/sftpgo/web/client/dirs?path=" + url.QueryEscape(p) req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("list %s: http %d", p, resp.StatusCode) } var items []repoFile if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { return nil, fmt.Errorf("decode %s: %w", p, err) } var names []string for _, it := range items { if it.Type == 2 && strings.HasSuffix(it.Name, ".sh") { names = append(names, it.Name) } } return names, nil } // RepoCatalog возвращает версии продукта в ветках release/beta из репозитория. func (m *Manager) RepoCatalog(ctx context.Context, product string) (string, error) { cfg, err := m.resolve() if err != nil { return "", err } client, err := m.repoClient(ctx, cfg) if err != nil { return "", err } base := strings.TrimRight(cfg.Repo.Base, "/") var b strings.Builder for _, branch := range []string{"release", "beta"} { files, err := m.repoList(ctx, client, base, product, branch) if err != nil { fmt.Fprintf(&b, "%s: %v\n", branch, err) continue } sort.Strings(files) if len(files) == 0 { fmt.Fprintf(&b, "%s: (empty)\n", branch) continue } fmt.Fprintf(&b, "%s:\n", branch) for _, f := range files { b.WriteString(" " + f + "\n") } } return strings.TrimRight(b.String(), "\n"), nil } // RepoResolve находит имя файла дистрибутива для продукта/версии/ветки, // обращаясь к репозиторию. Пустая версия → последняя в ветке. func (m *Manager) RepoResolve(ctx context.Context, product, version, branch string) (string, error) { branch, err := normalizeBranch(branch) if err != nil { return "", err } cfg, err := m.resolve() if err != nil { return "", err } client, err := m.repoClient(ctx, cfg) if err != nil { return "", err } base := strings.TrimRight(cfg.Repo.Base, "/") names, err := m.repoList(ctx, client, base, product, branch) if err != nil { return "", err } return ResolveDistro(names, product, version, branch) } // RepoStream открывает поток скачивания дистрибутива (web-client download=1). // Вызывающий обязан закрыть поток. Стриминг — без буферизации целиком // (файлы могут быть до ~1.2 ГБ). func (m *Manager) RepoStream(ctx context.Context, product, branch, name string) (io.ReadCloser, error) { cfg, err := m.resolve() if err != nil { return nil, err } client, err := m.repoClient(ctx, cfg) if err != nil { return nil, err } base := strings.TrimRight(cfg.Repo.Base, "/") p := fmt.Sprintf("/repos/%s/%s/%s", product, branch, name) req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/sftpgo/web/client/files?path="+url.QueryEscape(p)+"&download=1", nil) if err != nil { return nil, err } resp, err := client.Do(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { io.Copy(io.Discard, resp.Body) resp.Body.Close() return nil, fmt.Errorf("download %s: http %d", p, resp.StatusCode) } return resp.Body, nil }