first commit
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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/<product>/<branch> (только .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
|
||||
}
|
||||
Reference in New Issue
Block a user