package managementasset import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "sync" "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" log "github.com/sirupsen/logrus" ) const ( managementReleaseURL = "https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest" managementAssetName = "management.html" httpUserAgent = "CLIProxyAPI-management-updater" updateCheckInterval = 3 * time.Hour ) // ManagementFileName exposes the control panel asset filename. const ManagementFileName = managementAssetName var ( lastUpdateCheckMu sync.Mutex lastUpdateCheckTime time.Time ) func newHTTPClient(proxyURL string) *http.Client { client := &http.Client{Timeout: 15 * time.Second} sdkCfg := &sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(proxyURL)} util.SetProxy(sdkCfg, client) return client } type releaseAsset struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` Digest string `json:"digest"` } type releaseResponse struct { Assets []releaseAsset `json:"assets"` } // StaticDir resolves the directory that stores the management control panel asset. func StaticDir(configFilePath string) string { configFilePath = strings.TrimSpace(configFilePath) if configFilePath == "" { return "" } base := filepath.Dir(configFilePath) fileInfo, err := os.Stat(configFilePath) if err == nil { if fileInfo.IsDir() { base = configFilePath } } return filepath.Join(base, "static") } // FilePath resolves the absolute path to the management control panel asset. func FilePath(configFilePath string) string { dir := StaticDir(configFilePath) if dir == "" { return "" } return filepath.Join(dir, ManagementFileName) } // EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed. // The function is designed to run in a background goroutine and will never panic. // It enforces a 3-hour rate limit to avoid frequent checks on config/auth file changes. func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string) { if ctx == nil { ctx = context.Background() } staticDir = strings.TrimSpace(staticDir) if staticDir == "" { log.Debug("management asset sync skipped: empty static directory") return } // Rate limiting: check only once every 3 hours lastUpdateCheckMu.Lock() now := time.Now() timeSinceLastCheck := now.Sub(lastUpdateCheckTime) if timeSinceLastCheck < updateCheckInterval { lastUpdateCheckMu.Unlock() log.Debugf("management asset update check skipped: last check was %v ago (interval: %v)", timeSinceLastCheck.Round(time.Second), updateCheckInterval) return } lastUpdateCheckTime = now lastUpdateCheckMu.Unlock() if err := os.MkdirAll(staticDir, 0o755); err != nil { log.WithError(err).Warn("failed to prepare static directory for management asset") return } client := newHTTPClient(proxyURL) localPath := filepath.Join(staticDir, managementAssetName) localHash, err := fileSHA256(localPath) if err != nil { if !errors.Is(err, os.ErrNotExist) { log.WithError(err).Debug("failed to read local management asset hash") } localHash = "" } asset, remoteHash, err := fetchLatestAsset(ctx, client) if err != nil { log.WithError(err).Warn("failed to fetch latest management release information") return } if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) { log.Debug("management asset is already up to date") return } data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL) if err != nil { log.WithError(err).Warn("failed to download management asset") return } if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) { log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash) } if err = atomicWriteFile(localPath, data); err != nil { log.WithError(err).Warn("failed to update management asset on disk") return } log.Infof("management asset updated successfully (hash=%s)", downloadedHash) } func fetchLatestAsset(ctx context.Context, client *http.Client) (*releaseAsset, string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, managementReleaseURL, nil) if err != nil { return nil, "", fmt.Errorf("create release request: %w", err) } req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", httpUserAgent) resp, err := client.Do(req) if err != nil { return nil, "", fmt.Errorf("execute release request: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return nil, "", fmt.Errorf("unexpected release status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var release releaseResponse if err = json.NewDecoder(resp.Body).Decode(&release); err != nil { return nil, "", fmt.Errorf("decode release response: %w", err) } for i := range release.Assets { asset := &release.Assets[i] if strings.EqualFold(asset.Name, managementAssetName) { remoteHash := parseDigest(asset.Digest) return asset, remoteHash, nil } } return nil, "", fmt.Errorf("management asset %s not found in latest release", managementAssetName) } func downloadAsset(ctx context.Context, client *http.Client, downloadURL string) ([]byte, string, error) { if strings.TrimSpace(downloadURL) == "" { return nil, "", fmt.Errorf("empty download url") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) if err != nil { return nil, "", fmt.Errorf("create download request: %w", err) } req.Header.Set("User-Agent", httpUserAgent) resp, err := client.Do(req) if err != nil { return nil, "", fmt.Errorf("execute download request: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, "", fmt.Errorf("read download body: %w", err) } sum := sha256.Sum256(data) return data, hex.EncodeToString(sum[:]), nil } func fileSHA256(path string) (string, error) { file, err := os.Open(path) if err != nil { return "", err } defer func() { _ = file.Close() }() h := sha256.New() if _, err = io.Copy(h, file); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } func atomicWriteFile(path string, data []byte) error { tmpFile, err := os.CreateTemp(filepath.Dir(path), "management-*.html") if err != nil { return err } tmpName := tmpFile.Name() defer func() { _ = tmpFile.Close() _ = os.Remove(tmpName) }() if _, err = tmpFile.Write(data); err != nil { return err } if err = tmpFile.Chmod(0o644); err != nil { return err } if err = tmpFile.Close(); err != nil { return err } if err = os.Rename(tmpName, path); err != nil { return err } return nil } func parseDigest(digest string) string { digest = strings.TrimSpace(digest) if digest == "" { return "" } if idx := strings.Index(digest, ":"); idx >= 0 { digest = digest[idx+1:] } return strings.ToLower(strings.TrimSpace(digest)) }