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  }