go.mway.dev/x@v0.0.0-20240520034138-950aede9a3fb/net/http/get_file.go (about)

     1  // Copyright (c) 2024 Matt Way
     2  //
     3  // Permission is hereby granted, free of charge, to any person obtaining a copy
     4  // of this software and associated documentation files (the "Software"), to
     5  // deal in the Software without restriction, including without limitation the
     6  // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
     7  // sell copies of the Software, and to permit persons to whom the Software is
     8  // furnished to do so, subject to the following conditions:
     9  //
    10  // The above copyright notice and this permission notice shall be included in
    11  // all copies or substantial portions of the Software.
    12  //
    13  // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    14  // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    15  // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    16  // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    17  // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    18  // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
    19  // IN THE THE SOFTWARE.
    20  
    21  // Package http provides http-related utilities and helpers.
    22  package http
    23  
    24  import (
    25  	"context"
    26  	"io/fs"
    27  	"net/http"
    28  	"os"
    29  	"path/filepath"
    30  
    31  	"go.mway.dev/errors"
    32  	xos "go.mway.dev/x/os"
    33  	"golang.org/x/sys/unix"
    34  )
    35  
    36  var (
    37  	// ErrDestNotWritable is returned when a given destination is a directory
    38  	// that is not writable.
    39  	ErrDestNotWritable = errors.New("destination is not writable")
    40  
    41  	_clientDo   = http.DefaultClient.Do
    42  	_osStat     = os.Stat
    43  	_osMkdirAll = os.MkdirAll
    44  	_unixAccess = unix.Access
    45  )
    46  
    47  // GetFile retrieves a file referenced by the given url and writes it to the
    48  // given destination, returning any errors in the process. The resulting file
    49  // may have been written despite returning a non-nil error. If dst does not
    50  // exist, GetFile will make its parent directory if needed before writing; if
    51  // dst exists and is a directory, GetFile will use the basename of the URL to
    52  // inform the resulting filename.
    53  func GetFile(url string, dst string) (err error) {
    54  	return GetFileContext(context.Background(), url, dst)
    55  }
    56  
    57  // GetFileContext retrieves a file referenced by the given url and writes it to
    58  // the given destination, returning any errors in the process. The resulting
    59  // file may have been written despite returning a non-nil error. If dst does
    60  // not exist, GetFile will make its parent directory if needed before writing;
    61  // if dst exists and is a directory, GetFile will use the basename of the URL
    62  // to inform the resulting filename.
    63  //
    64  //nolint:gocyclo
    65  func GetFileContext(ctx context.Context, url string, dst string) (err error) {
    66  	var (
    67  		req  *http.Request
    68  		resp *http.Response
    69  	)
    70  
    71  	req, err = http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    72  	if err != nil {
    73  		return err
    74  	}
    75  
    76  	resp, err = _clientDo(req)
    77  	if resp != nil && resp.Body != nil {
    78  		defer func() {
    79  			err = errors.Join(err, errors.Wrap(
    80  				resp.Body.Close(),
    81  				"failed to close response body",
    82  			))
    83  		}()
    84  	}
    85  	if err != nil {
    86  		return err
    87  	}
    88  
    89  	var dstInfo fs.FileInfo
    90  	if dstInfo, err = _osStat(dst); err != nil {
    91  		if errors.Is(err, fs.ErrNotExist) {
    92  			err = _osMkdirAll(filepath.Dir(dst), 0o755)
    93  		}
    94  	}
    95  
    96  	switch {
    97  	case err != nil:
    98  		return err
    99  	case dstInfo != nil && dstInfo.IsDir():
   100  		if base := filepath.Base(url); len(base) > 0 {
   101  			dst = filepath.Join(dst, base)
   102  		}
   103  	}
   104  
   105  	if parent := filepath.Dir(dst); !existsAndWritable(parent) {
   106  		return errors.Wrap(ErrDestNotWritable, parent)
   107  	}
   108  
   109  	_, err = xos.WriteReaderToFile(dst, resp.Body)
   110  	return err
   111  }
   112  
   113  func existsAndWritable(path string) bool {
   114  	return _unixAccess(path, unix.W_OK) == nil
   115  }