goyave.dev/goyave/v5@v5.0.0-rc9.0.20240517145003-d3f977d0b9f3/util/fsutil/file.go (about)

     1  package fsutil
     2  
     3  import (
     4  	"encoding/json"
     5  	"io"
     6  	"mime/multipart"
     7  	"net/http"
     8  	"os"
     9  	"sync"
    10  
    11  	pathutil "path"
    12  
    13  	"github.com/google/uuid"
    14  	"goyave.dev/goyave/v5/util/errors"
    15  )
    16  
    17  // marshalCache temporarily stores files' `*multipart.FileHeader`. This type
    18  // cannot be marshaled, making the use of `fsutil.file` inconvenient with DTO conversion.
    19  // The key should a be unique ID. The key is removed from the map.
    20  // To avoid infinite growth of this cache, leading to potential memory problems, this map
    21  // is reset every time its length goes back to 0.
    22  var marshalCache = map[string]*multipart.FileHeader{}
    23  var cacheMu sync.RWMutex
    24  
    25  // File represents a file received from client.
    26  //
    27  // File implements `json.Marshaler` and `json.Unmarshaler` to be able
    28  // to be used in DTO conversion (`typeutil.Convert()`). This works with a global
    29  // concurrency-safe map that acts as a cache for the `*multipart.FileHeader`.
    30  // When marshaling, a UUID v1 is generated and used as a key. This UUID is the actual value
    31  // used when marhsaling the `Header` field. When unmarshaling, the `*multipart.FileHeader` is
    32  // retrieved then deleted from the cache. To avoid orphans clogging up the cache, you should
    33  // never JSON marshal this type outside of `typeutil.Convert()`: if a marshaled File never gets
    34  // unmarshaled, its UUID would remain in the cache forever.
    35  type File struct {
    36  	Header   *multipart.FileHeader
    37  	MIMEType string
    38  }
    39  
    40  type marshaledFile struct {
    41  	MIMEType string
    42  	Header   string
    43  }
    44  
    45  // MarshalJSON implementation of `json.Marhsaler`.
    46  func (file File) MarshalJSON() ([]byte, error) {
    47  	headerUID, err := uuid.NewUUID()
    48  	if err != nil {
    49  		return nil, err
    50  	}
    51  
    52  	uidStr := headerUID.String()
    53  	cacheMu.Lock()
    54  	marshalCache[uidStr] = file.Header
    55  	cacheMu.Unlock()
    56  
    57  	return json.Marshal(marshaledFile{
    58  		Header:   uidStr,
    59  		MIMEType: file.MIMEType,
    60  	})
    61  }
    62  
    63  // UnmarshalJSON implementation of `json.Unmarhsaler`.
    64  func (file *File) UnmarshalJSON(data []byte) error {
    65  	var v marshaledFile
    66  	if err := json.Unmarshal(data, &v); err != nil {
    67  		return errors.New(err)
    68  	}
    69  
    70  	file.MIMEType = v.MIMEType
    71  
    72  	cacheMu.RLock()
    73  	header, ok := marshalCache[v.Header]
    74  	cacheMu.RUnlock()
    75  	if !ok {
    76  		return errors.New("cannot unmarshal fsutil.File: multipart header not found in cache")
    77  	}
    78  
    79  	cacheMu.Lock()
    80  	delete(marshalCache, v.Header)
    81  	if len(marshalCache) == 0 {
    82  		// Maps never shrink, let's allocate a new empty map to reset the cache capacity
    83  		// and allow garbage collecting.
    84  		marshalCache = map[string]*multipart.FileHeader{}
    85  	}
    86  	cacheMu.Unlock()
    87  
    88  	file.Header = header
    89  	return nil
    90  }
    91  
    92  // Save writes the file's content to a new file in the given file system.
    93  // Appends a timestamp to the given file name to avoid duplicate file names.
    94  // The file is not readable anymore once saved as its FileReader has already been
    95  // closed.
    96  //
    97  // Creates directories if needed.
    98  //
    99  // Returns the actual file name.
   100  func (file *File) Save(fs WritableFS, path string, name string) (filename string, err error) {
   101  	filename = timestampFileName(name)
   102  
   103  	if mkdirFS, ok := fs.(MkdirFS); ok {
   104  		if err = mkdirFS.MkdirAll(path, os.ModePerm); err != nil {
   105  			err = errors.New(err)
   106  			return
   107  		}
   108  	}
   109  
   110  	var f multipart.File
   111  	f, err = file.Header.Open()
   112  	if err != nil {
   113  		err = errors.New(err)
   114  		return
   115  	}
   116  	defer func() {
   117  		closeError := f.Close()
   118  		if err == nil && closeError != nil {
   119  			err = errors.New(closeError)
   120  		}
   121  	}()
   122  
   123  	var writer io.ReadWriteCloser
   124  	writer, err = fs.OpenFile(pathutil.Join(path, filename), os.O_WRONLY|os.O_CREATE, 0660)
   125  	if err != nil {
   126  		err = errors.New(err)
   127  		return
   128  	}
   129  	defer func() {
   130  		closeError := writer.Close()
   131  		if err == nil && closeError != nil {
   132  			err = errors.New(closeError)
   133  		}
   134  	}()
   135  	_, err = io.Copy(writer, f)
   136  	if err != nil {
   137  		err = errors.New(err)
   138  	}
   139  	return
   140  }
   141  
   142  // ParseMultipartFiles parse a single file field in a request.
   143  func ParseMultipartFiles(headers []*multipart.FileHeader) ([]File, error) {
   144  	files := []File{}
   145  	for _, fh := range headers {
   146  
   147  		fileHeader := make([]byte, 512)
   148  
   149  		if fh.Size != 0 {
   150  			f, err := fh.Open()
   151  			if err != nil {
   152  				return nil, errors.New(err)
   153  			}
   154  			if _, err := f.Read(fileHeader); err != nil {
   155  				_ = f.Close()
   156  				return nil, errors.New(err)
   157  			}
   158  
   159  			if _, err := f.Seek(0, 0); err != nil {
   160  				_ = f.Close()
   161  				return nil, errors.New(err)
   162  			}
   163  			if err := f.Close(); err != nil {
   164  				return nil, errors.New(err)
   165  			}
   166  		}
   167  
   168  		file := File{
   169  			Header:   fh,
   170  			MIMEType: http.DetectContentType(fileHeader),
   171  		}
   172  		files = append(files, file)
   173  	}
   174  	return files, nil
   175  }