package gitea import ( "context" "errors" "io" "net/http" "net/url" "strconv" "strings" ) type RemoteAsset struct { ID int64 Filename string Size int64 DownloadURL string } type assetResponse struct { ID int64 `json:"id"` Name string `json:"name"` Size int64 `json:"size"` BrowserDownloadURL string `json:"browser_download_url"` } func (c Client) listAssets(ctx context.Context, client *http.Client, token string, releaseID int64, apiRoot string) ([]RemoteAsset, error) { if releaseID <= 0 { return nil, failure(FailureInvalid, "list release assets", 0, errors.New("release ID must be positive")) } assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets" var listed []assetResponse if err := c.getJSON(ctx, client, token, "list release assets", assetsURL, &listed); err != nil { return nil, err } if listed == nil { return nil, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array")) } assets := make([]RemoteAsset, 0, len(listed)) for _, item := range listed { if item.ID <= 0 || item.Name == "" || item.Size < 0 { return nil, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size")) } assets = append(assets, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL}) } return assets, nil } func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (io.ReadCloser, error) { if strings.TrimSpace(rawURL) == "" { return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL")) } assetURL, err := base.Parse(rawURL) if err != nil || assetURL.User != nil { return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("invalid attachment URL")) } assetURL.Fragment = "" visited := make(map[string]struct{}) for { key := assetURL.String() if _, exists := visited[key]; exists { return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop")) } visited[key] = struct{}{} request, err := http.NewRequestWithContext(ctx, http.MethodGet, key, nil) if err != nil { return nil, failure(FailureUnverifiable, "download release asset", 0, err) } if sameOrigin(base, assetURL) { request.Header.Set("Authorization", "token "+token) } request.Header.Set("Accept", "application/octet-stream") response, err := client.Do(request) if err != nil { return nil, failure(FailureUnverifiable, "download release asset", 0, err) } if isRedirect(response.StatusCode) { location, locationErr := response.Location() _ = response.Body.Close() if locationErr != nil || location.User != nil { return nil, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect")) } location.Fragment = "" assetURL = location continue } if response.StatusCode < 200 || response.StatusCode >= 300 { _ = response.Body.Close() return nil, responseFailure("download release asset", response.StatusCode) } return response.Body, nil } } func sameOrigin(base, target *url.URL) bool { if target == nil || target.User != nil || target.Host == "" { return false } return strings.EqualFold(base.Scheme, target.Scheme) && strings.EqualFold(base.Hostname(), target.Hostname()) && effectivePort(base) == effectivePort(target) } func effectivePort(parsed *url.URL) string { if port := parsed.Port(); port != "" { return port } if strings.EqualFold(parsed.Scheme, "https") { return "443" } return "80" } func isRedirect(status int) bool { switch status { case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect: return true default: return false } }