github.com/google/go-github/v74@v74.0.0/github/repos_contents.go (about) 1 // Copyright 2013 The go-github AUTHORS. All rights reserved. 2 // 3 // Use of this source code is governed by a BSD-style 4 // license that can be found in the LICENSE file. 5 6 // Repository contents API methods. 7 // GitHub API docs: https://docs.github.com/rest/repos/contents/ 8 9 package github 10 11 import ( 12 "context" 13 "encoding/base64" 14 "encoding/json" 15 "errors" 16 "fmt" 17 "io" 18 "net/http" 19 "net/url" 20 "path" 21 "strings" 22 ) 23 24 var ErrPathForbidden = errors.New("path must not contain '..' due to auth vulnerability issue") 25 26 // RepositoryContent represents a file or directory in a github repository. 27 type RepositoryContent struct { 28 Type *string `json:"type,omitempty"` 29 // Target is only set if the type is "symlink" and the target is not a normal file. 30 // If Target is set, Path will be the symlink path. 31 Target *string `json:"target,omitempty"` 32 Encoding *string `json:"encoding,omitempty"` 33 Size *int `json:"size,omitempty"` 34 Name *string `json:"name,omitempty"` 35 Path *string `json:"path,omitempty"` 36 // Content contains the actual file content, which may be encoded. 37 // Callers should call GetContent which will decode the content if 38 // necessary. 39 Content *string `json:"content,omitempty"` 40 SHA *string `json:"sha,omitempty"` 41 URL *string `json:"url,omitempty"` 42 GitURL *string `json:"git_url,omitempty"` 43 HTMLURL *string `json:"html_url,omitempty"` 44 DownloadURL *string `json:"download_url,omitempty"` 45 SubmoduleGitURL *string `json:"submodule_git_url,omitempty"` 46 } 47 48 // RepositoryContentResponse holds the parsed response from CreateFile, UpdateFile, and DeleteFile. 49 type RepositoryContentResponse struct { 50 Content *RepositoryContent `json:"content,omitempty"` 51 Commit `json:"commit,omitempty"` 52 } 53 54 // RepositoryContentFileOptions specifies optional parameters for CreateFile, UpdateFile, and DeleteFile. 55 type RepositoryContentFileOptions struct { 56 Message *string `json:"message,omitempty"` 57 Content []byte `json:"content"` // unencoded 58 SHA *string `json:"sha,omitempty"` 59 Branch *string `json:"branch,omitempty"` 60 Author *CommitAuthor `json:"author,omitempty"` 61 Committer *CommitAuthor `json:"committer,omitempty"` 62 } 63 64 // RepositoryContentGetOptions represents an optional ref parameter, which can be a SHA, 65 // branch, or tag. E.g., `6540c41b`, `heads/main`, `tags/v1.0`. 66 type RepositoryContentGetOptions struct { 67 Ref string `url:"ref,omitempty"` 68 } 69 70 // String converts RepositoryContent to a string. It's primarily for testing. 71 func (r RepositoryContent) String() string { 72 return Stringify(r) 73 } 74 75 // GetContent returns the content of r, decoding it if necessary. 76 func (r *RepositoryContent) GetContent() (string, error) { 77 var encoding string 78 if r.Encoding != nil { 79 encoding = *r.Encoding 80 } 81 82 switch encoding { 83 case "base64": 84 if r.Content == nil { 85 return "", errors.New("malformed response: base64 encoding of null content") 86 } 87 c, err := base64.StdEncoding.DecodeString(*r.Content) 88 return string(c), err 89 case "": 90 if r.Content == nil { 91 return "", nil 92 } 93 return *r.Content, nil 94 case "none": 95 return "", errors.New("unsupported content encoding: none, this may occur when file size > 1 MB, if that is the case consider using DownloadContents") 96 default: 97 return "", fmt.Errorf("unsupported content encoding: %v", encoding) 98 } 99 } 100 101 // GetReadme gets the Readme file for the repository. 102 // 103 // GitHub API docs: https://docs.github.com/rest/repos/contents#get-a-repository-readme 104 // 105 //meta:operation GET /repos/{owner}/{repo}/readme 106 func (s *RepositoriesService) GetReadme(ctx context.Context, owner, repo string, opts *RepositoryContentGetOptions) (*RepositoryContent, *Response, error) { 107 u := fmt.Sprintf("repos/%v/%v/readme", owner, repo) 108 u, err := addOptions(u, opts) 109 if err != nil { 110 return nil, nil, err 111 } 112 113 req, err := s.client.NewRequest("GET", u, nil) 114 if err != nil { 115 return nil, nil, err 116 } 117 118 readme := new(RepositoryContent) 119 resp, err := s.client.Do(ctx, req, readme) 120 if err != nil { 121 return nil, resp, err 122 } 123 124 return readme, resp, nil 125 } 126 127 // DownloadContents returns an io.ReadCloser that reads the contents of the 128 // specified file. This function will work with files of any size, as opposed 129 // to GetContents which is limited to 1 Mb files. It is the caller's 130 // responsibility to close the ReadCloser. 131 // 132 // It is possible for the download to result in a failed response when the 133 // returned error is nil. Callers should check the returned Response status 134 // code to verify the content is from a successful response. 135 // 136 // GitHub API docs: https://docs.github.com/rest/repos/contents#get-repository-content 137 // 138 //meta:operation GET /repos/{owner}/{repo}/contents/{path} 139 func (s *RepositoriesService) DownloadContents(ctx context.Context, owner, repo, filepath string, opts *RepositoryContentGetOptions) (io.ReadCloser, *Response, error) { 140 dir := path.Dir(filepath) 141 filename := path.Base(filepath) 142 fileContent, _, resp, err := s.GetContents(ctx, owner, repo, filepath, opts) 143 if err == nil && fileContent != nil { 144 content, err := fileContent.GetContent() 145 if err == nil && content != "" { 146 return io.NopCloser(strings.NewReader(content)), resp, nil 147 } 148 } 149 150 _, dirContents, resp, err := s.GetContents(ctx, owner, repo, dir, opts) 151 if err != nil { 152 return nil, resp, err 153 } 154 155 for _, contents := range dirContents { 156 if contents.GetName() == filename { 157 if contents.GetDownloadURL() == "" { 158 return nil, resp, fmt.Errorf("no download link found for %s", filepath) 159 } 160 dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, *contents.DownloadURL, nil) 161 if err != nil { 162 return nil, resp, err 163 } 164 dlResp, err := s.client.client.Do(dlReq) 165 if err != nil { 166 return nil, &Response{Response: dlResp}, err 167 } 168 169 return dlResp.Body, &Response{Response: dlResp}, nil 170 } 171 } 172 173 return nil, resp, fmt.Errorf("no file named %s found in %s", filename, dir) 174 } 175 176 // DownloadContentsWithMeta is identical to DownloadContents but additionally 177 // returns the RepositoryContent of the requested file. This additional data 178 // is useful for future operations involving the requested file. For merely 179 // reading the content of a file, DownloadContents is perfectly adequate. 180 // 181 // It is possible for the download to result in a failed response when the 182 // returned error is nil. Callers should check the returned Response status 183 // code to verify the content is from a successful response. 184 // 185 // GitHub API docs: https://docs.github.com/rest/repos/contents#get-repository-content 186 // 187 //meta:operation GET /repos/{owner}/{repo}/contents/{path} 188 func (s *RepositoriesService) DownloadContentsWithMeta(ctx context.Context, owner, repo, filepath string, opts *RepositoryContentGetOptions) (io.ReadCloser, *RepositoryContent, *Response, error) { 189 dir := path.Dir(filepath) 190 filename := path.Base(filepath) 191 fileContent, _, resp, err := s.GetContents(ctx, owner, repo, filepath, opts) 192 if err == nil && fileContent != nil { 193 content, err := fileContent.GetContent() 194 if err == nil && content != "" { 195 return io.NopCloser(strings.NewReader(content)), fileContent, resp, nil 196 } 197 } 198 199 _, dirContents, resp, err := s.GetContents(ctx, owner, repo, dir, opts) 200 if err != nil { 201 return nil, nil, resp, err 202 } 203 204 for _, contents := range dirContents { 205 if contents.GetName() == filename { 206 if contents.GetDownloadURL() == "" { 207 return nil, contents, resp, fmt.Errorf("no download link found for %s", filepath) 208 } 209 dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, *contents.DownloadURL, nil) 210 if err != nil { 211 return nil, contents, resp, err 212 } 213 dlResp, err := s.client.client.Do(dlReq) 214 if err != nil { 215 return nil, contents, &Response{Response: dlResp}, err 216 } 217 218 return dlResp.Body, contents, &Response{Response: dlResp}, nil 219 } 220 } 221 222 return nil, nil, resp, fmt.Errorf("no file named %s found in %s", filename, dir) 223 } 224 225 // GetContents can return either the metadata and content of a single file 226 // (when path references a file) or the metadata of all the files and/or 227 // subdirectories of a directory (when path references a directory). To make it 228 // easy to distinguish between both result types and to mimic the API as much 229 // as possible, both result types will be returned but only one will contain a 230 // value and the other will be nil. 231 // 232 // Due to an auth vulnerability issue in the GitHub v3 API, ".." is not allowed 233 // to appear anywhere in the "path" or this method will return an error. 234 // 235 // GitHub API docs: https://docs.github.com/rest/repos/contents#get-repository-content 236 // 237 //meta:operation GET /repos/{owner}/{repo}/contents/{path} 238 func (s *RepositoriesService) GetContents(ctx context.Context, owner, repo, path string, opts *RepositoryContentGetOptions) (fileContent *RepositoryContent, directoryContent []*RepositoryContent, resp *Response, err error) { 239 if strings.Contains(path, "..") { 240 return nil, nil, nil, ErrPathForbidden 241 } 242 243 escapedPath := (&url.URL{Path: strings.TrimSuffix(path, "/")}).String() 244 u := fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, escapedPath) 245 u, err = addOptions(u, opts) 246 if err != nil { 247 return nil, nil, nil, err 248 } 249 250 req, err := s.client.NewRequest("GET", u, nil) 251 if err != nil { 252 return nil, nil, nil, err 253 } 254 255 var rawJSON json.RawMessage 256 resp, err = s.client.Do(ctx, req, &rawJSON) 257 if err != nil { 258 return nil, nil, resp, err 259 } 260 261 fileUnmarshalError := json.Unmarshal(rawJSON, &fileContent) 262 if fileUnmarshalError == nil { 263 return fileContent, nil, resp, nil 264 } 265 266 directoryUnmarshalError := json.Unmarshal(rawJSON, &directoryContent) 267 if directoryUnmarshalError == nil { 268 return nil, directoryContent, resp, nil 269 } 270 271 return nil, nil, resp, fmt.Errorf("unmarshaling failed for both file and directory content: %s and %s", fileUnmarshalError, directoryUnmarshalError) 272 } 273 274 // CreateFile creates a new file in a repository at the given path and returns 275 // the commit and file metadata. 276 // 277 // GitHub API docs: https://docs.github.com/rest/repos/contents#create-or-update-file-contents 278 // 279 //meta:operation PUT /repos/{owner}/{repo}/contents/{path} 280 func (s *RepositoriesService) CreateFile(ctx context.Context, owner, repo, path string, opts *RepositoryContentFileOptions) (*RepositoryContentResponse, *Response, error) { 281 u := fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, path) 282 req, err := s.client.NewRequest("PUT", u, opts) 283 if err != nil { 284 return nil, nil, err 285 } 286 287 createResponse := new(RepositoryContentResponse) 288 resp, err := s.client.Do(ctx, req, createResponse) 289 if err != nil { 290 return nil, resp, err 291 } 292 293 return createResponse, resp, nil 294 } 295 296 // UpdateFile updates a file in a repository at the given path and returns the 297 // commit and file metadata. Requires the blob SHA of the file being updated. 298 // 299 // GitHub API docs: https://docs.github.com/rest/repos/contents#create-or-update-file-contents 300 // 301 //meta:operation PUT /repos/{owner}/{repo}/contents/{path} 302 func (s *RepositoriesService) UpdateFile(ctx context.Context, owner, repo, path string, opts *RepositoryContentFileOptions) (*RepositoryContentResponse, *Response, error) { 303 u := fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, path) 304 req, err := s.client.NewRequest("PUT", u, opts) 305 if err != nil { 306 return nil, nil, err 307 } 308 309 updateResponse := new(RepositoryContentResponse) 310 resp, err := s.client.Do(ctx, req, updateResponse) 311 if err != nil { 312 return nil, resp, err 313 } 314 315 return updateResponse, resp, nil 316 } 317 318 // DeleteFile deletes a file from a repository and returns the commit. 319 // Requires the blob SHA of the file to be deleted. 320 // 321 // GitHub API docs: https://docs.github.com/rest/repos/contents#delete-a-file 322 // 323 //meta:operation DELETE /repos/{owner}/{repo}/contents/{path} 324 func (s *RepositoriesService) DeleteFile(ctx context.Context, owner, repo, path string, opts *RepositoryContentFileOptions) (*RepositoryContentResponse, *Response, error) { 325 u := fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, path) 326 req, err := s.client.NewRequest("DELETE", u, opts) 327 if err != nil { 328 return nil, nil, err 329 } 330 331 deleteResponse := new(RepositoryContentResponse) 332 resp, err := s.client.Do(ctx, req, deleteResponse) 333 if err != nil { 334 return nil, resp, err 335 } 336 337 return deleteResponse, resp, nil 338 } 339 340 // ArchiveFormat is used to define the archive type when calling GetArchiveLink. 341 type ArchiveFormat string 342 343 const ( 344 // Tarball specifies an archive in gzipped tar format. 345 Tarball ArchiveFormat = "tarball" 346 347 // Zipball specifies an archive in zip format. 348 Zipball ArchiveFormat = "zipball" 349 ) 350 351 // GetArchiveLink returns an URL to download a tarball or zipball archive for a 352 // repository. The archiveFormat can be specified by either the github.Tarball 353 // or github.Zipball constant. 354 // 355 // GitHub API docs: https://docs.github.com/rest/repos/contents#download-a-repository-archive-tar 356 // GitHub API docs: https://docs.github.com/rest/repos/contents#download-a-repository-archive-zip 357 // 358 //meta:operation GET /repos/{owner}/{repo}/tarball/{ref} 359 //meta:operation GET /repos/{owner}/{repo}/zipball/{ref} 360 func (s *RepositoriesService) GetArchiveLink(ctx context.Context, owner, repo string, archiveformat ArchiveFormat, opts *RepositoryContentGetOptions, maxRedirects int) (*url.URL, *Response, error) { 361 u := fmt.Sprintf("repos/%s/%s/%s", owner, repo, archiveformat) 362 if opts != nil && opts.Ref != "" { 363 u += fmt.Sprintf("/%s", opts.Ref) 364 } 365 366 if s.client.RateLimitRedirectionalEndpoints { 367 return s.getArchiveLinkWithRateLimit(ctx, u, maxRedirects) 368 } 369 370 return s.getArchiveLinkWithoutRateLimit(ctx, u, maxRedirects) 371 } 372 373 func (s *RepositoriesService) getArchiveLinkWithoutRateLimit(ctx context.Context, u string, maxRedirects int) (*url.URL, *Response, error) { 374 resp, err := s.client.roundTripWithOptionalFollowRedirect(ctx, u, maxRedirects) 375 if err != nil { 376 return nil, nil, err 377 } 378 defer resp.Body.Close() 379 380 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusFound { 381 return nil, newResponse(resp), fmt.Errorf("unexpected status code: %v", resp.Status) 382 } 383 384 parsedURL, err := url.Parse(resp.Header.Get("Location")) 385 if err != nil { 386 return nil, newResponse(resp), err 387 } 388 389 return parsedURL, newResponse(resp), nil 390 } 391 392 func (s *RepositoriesService) getArchiveLinkWithRateLimit(ctx context.Context, u string, maxRedirects int) (*url.URL, *Response, error) { 393 req, err := s.client.NewRequest("GET", u, nil) 394 if err != nil { 395 return nil, nil, err 396 } 397 398 url, resp, err := s.client.bareDoUntilFound(ctx, req, maxRedirects) 399 if err != nil { 400 return nil, resp, err 401 } 402 defer resp.Body.Close() 403 404 // If we didn't receive a valid Location in a 302 response 405 if url == nil { 406 return nil, resp, fmt.Errorf("unexpected status code: %v", resp.Status) 407 } 408 409 return url, resp, nil 410 }