github_release_service.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. package repository
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "log/slog"
  8. "net/http"
  9. "net/url"
  10. "os"
  11. "strings"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
  14. "github.com/Wei-Shaw/sub2api/internal/service"
  15. )
  16. type githubReleaseClient struct {
  17. httpClient *http.Client
  18. downloadHTTPClient *http.Client
  19. updateGitHubToken string
  20. }
  21. type githubReleaseClientError struct {
  22. err error
  23. }
  24. // NewGitHubReleaseClient 创建 GitHub Release 客户端
  25. // proxyURL 为空时直连 GitHub,支持 http/https/socks5/socks5h 协议
  26. // 代理配置失败时行为由 allowDirectOnProxyError 控制:
  27. // - false(默认):返回错误占位客户端,禁止回退到直连
  28. // - true:回退到直连(仅限管理员显式开启)
  29. func NewGitHubReleaseClient(proxyURL string, allowDirectOnProxyError bool) service.GitHubReleaseClient {
  30. // 安全说明:httpclient.GetClient 的错误链(url.Parse / proxyutil)不含明文代理凭据,
  31. // 但仍通过 slog 仅在服务端日志记录,不会暴露给 HTTP 响应。
  32. sharedClient, err := httpclient.GetClient(httpclient.Options{
  33. Timeout: 30 * time.Second,
  34. ProxyURL: proxyURL,
  35. })
  36. if err != nil {
  37. if strings.TrimSpace(proxyURL) != "" && !allowDirectOnProxyError {
  38. slog.Warn("proxy client init failed, all requests will fail", "service", "github_release", "error", err)
  39. return &githubReleaseClientError{err: fmt.Errorf("proxy client init failed and direct fallback is disabled; set security.proxy_fallback.allow_direct_on_error=true to allow fallback: %w", err)}
  40. }
  41. sharedClient = &http.Client{Timeout: 30 * time.Second}
  42. }
  43. apiClient := cloneHTTPClient(sharedClient)
  44. apiClient.CheckRedirect = githubAPICheckRedirect(apiClient.CheckRedirect)
  45. // 下载客户端需要更长的超时时间
  46. downloadClient, err := httpclient.GetClient(httpclient.Options{
  47. Timeout: 10 * time.Minute,
  48. ProxyURL: proxyURL,
  49. })
  50. if err != nil {
  51. if strings.TrimSpace(proxyURL) != "" && !allowDirectOnProxyError {
  52. slog.Warn("proxy download client init failed, all requests will fail", "service", "github_release", "error", err)
  53. return &githubReleaseClientError{err: fmt.Errorf("proxy client init failed and direct fallback is disabled; set security.proxy_fallback.allow_direct_on_error=true to allow fallback: %w", err)}
  54. }
  55. downloadClient = &http.Client{Timeout: 10 * time.Minute}
  56. }
  57. downloadClient = cloneHTTPClient(downloadClient)
  58. return &githubReleaseClient{
  59. httpClient: apiClient,
  60. downloadHTTPClient: downloadClient,
  61. updateGitHubToken: os.Getenv("UPDATE_GITHUB_TOKEN"),
  62. }
  63. }
  64. func cloneHTTPClient(client *http.Client) *http.Client {
  65. cloned := *client
  66. return &cloned
  67. }
  68. func isGitHubAPIURL(url *url.URL) bool {
  69. return url != nil && strings.EqualFold(url.Scheme, "https") && url.User == nil &&
  70. strings.EqualFold(url.Host, "api.github.com")
  71. }
  72. func githubAPICheckRedirect(previous func(*http.Request, []*http.Request) error) func(*http.Request, []*http.Request) error {
  73. return func(req *http.Request, via []*http.Request) error {
  74. if !isGitHubAPIURL(req.URL) {
  75. req.Header.Del("Authorization")
  76. }
  77. if previous != nil {
  78. return previous(req, via)
  79. }
  80. return nil
  81. }
  82. }
  83. func (c *githubReleaseClient) newAPIRequest(ctx context.Context, url string) (*http.Request, error) {
  84. req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
  85. if err != nil {
  86. return nil, err
  87. }
  88. req.Header.Set("Accept", "application/vnd.github.v3+json")
  89. req.Header.Set("User-Agent", "Sub2API-Updater")
  90. if c.updateGitHubToken != "" && isGitHubAPIURL(req.URL) {
  91. req.Header.Set("Authorization", "Bearer "+c.updateGitHubToken)
  92. }
  93. return req, nil
  94. }
  95. func (c *githubReleaseClientError) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) {
  96. return nil, c.err
  97. }
  98. func (c *githubReleaseClientError) FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*service.GitHubRelease, error) {
  99. return nil, c.err
  100. }
  101. func (c *githubReleaseClientError) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error {
  102. return c.err
  103. }
  104. func (c *githubReleaseClientError) FetchChecksumFile(ctx context.Context, url string) ([]byte, error) {
  105. return nil, c.err
  106. }
  107. func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) {
  108. url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
  109. req, err := c.newAPIRequest(ctx, url)
  110. if err != nil {
  111. return nil, err
  112. }
  113. resp, err := c.httpClient.Do(req)
  114. if err != nil {
  115. return nil, err
  116. }
  117. defer func() { _ = resp.Body.Close() }()
  118. if resp.StatusCode != http.StatusOK {
  119. return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
  120. }
  121. var release service.GitHubRelease
  122. if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
  123. return nil, err
  124. }
  125. return &release, nil
  126. }
  127. func (c *githubReleaseClient) FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*service.GitHubRelease, error) {
  128. if perPage <= 0 {
  129. perPage = 10
  130. }
  131. if perPage > 100 {
  132. perPage = 100 // GitHub API hard limit
  133. }
  134. url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d", repo, perPage)
  135. req, err := c.newAPIRequest(ctx, url)
  136. if err != nil {
  137. return nil, err
  138. }
  139. resp, err := c.httpClient.Do(req)
  140. if err != nil {
  141. return nil, err
  142. }
  143. defer func() { _ = resp.Body.Close() }()
  144. if resp.StatusCode != http.StatusOK {
  145. return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
  146. }
  147. var releases []*service.GitHubRelease
  148. if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
  149. return nil, err
  150. }
  151. return releases, nil
  152. }
  153. func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error {
  154. req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
  155. if err != nil {
  156. return err
  157. }
  158. // 使用预配置的下载客户端(已包含代理配置)
  159. resp, err := c.downloadHTTPClient.Do(req)
  160. if err != nil {
  161. return err
  162. }
  163. defer func() { _ = resp.Body.Close() }()
  164. if resp.StatusCode != http.StatusOK {
  165. return fmt.Errorf("download returned %d", resp.StatusCode)
  166. }
  167. // SECURITY: Check Content-Length if available
  168. if resp.ContentLength > maxSize {
  169. return fmt.Errorf("file too large: %d bytes (max %d)", resp.ContentLength, maxSize)
  170. }
  171. out, err := os.Create(dest)
  172. if err != nil {
  173. return err
  174. }
  175. // SECURITY: Use LimitReader to enforce max download size even if Content-Length is missing/wrong
  176. limited := io.LimitReader(resp.Body, maxSize+1)
  177. written, err := io.Copy(out, limited)
  178. // Close file before attempting to remove (required on Windows)
  179. _ = out.Close()
  180. if err != nil {
  181. _ = os.Remove(dest) // Clean up partial file (best-effort)
  182. return err
  183. }
  184. // Check if we hit the limit (downloaded more than maxSize)
  185. if written > maxSize {
  186. _ = os.Remove(dest) // Clean up partial file (best-effort)
  187. return fmt.Errorf("download exceeded maximum size of %d bytes", maxSize)
  188. }
  189. return nil
  190. }
  191. func (c *githubReleaseClient) FetchChecksumFile(ctx context.Context, url string) ([]byte, error) {
  192. req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
  193. if err != nil {
  194. return nil, err
  195. }
  196. resp, err := c.httpClient.Do(req)
  197. if err != nil {
  198. return nil, err
  199. }
  200. defer func() { _ = resp.Body.Close() }()
  201. if resp.StatusCode != http.StatusOK {
  202. return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
  203. }
  204. return io.ReadAll(resp.Body)
  205. }