github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/client/files.go (about)

     1  package client
     2  
     3  import (
     4  	"encoding/base64"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"net/url"
    10  	"path"
    11  	"path/filepath"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/cozy/cozy-stack/client/request"
    16  )
    17  
    18  const (
    19  	// DirType is the directory type name
    20  	DirType = "directory"
    21  	// FileType is the file type name
    22  	FileType = "file"
    23  )
    24  
    25  // Upload is a struct containing the options of an upload
    26  type Upload struct {
    27  	Name          string
    28  	DirID         string
    29  	FileID        string
    30  	FileRev       string
    31  	ContentMD5    []byte
    32  	Contents      io.Reader
    33  	ContentType   string
    34  	ContentLength int64
    35  	Overwrite     bool
    36  }
    37  
    38  // File is the JSON-API file structure
    39  type File struct {
    40  	ID    string `json:"id"`
    41  	Rev   string `json:"rev"`
    42  	Attrs struct {
    43  		Type       string                 `json:"type"`
    44  		Name       string                 `json:"name"`
    45  		DirID      string                 `json:"dir_id"`
    46  		CreatedAt  time.Time              `json:"created_at"`
    47  		UpdatedAt  time.Time              `json:"updated_at"`
    48  		Size       int64                  `json:"size,string"`
    49  		MD5Sum     []byte                 `json:"md5sum"`
    50  		Mime       string                 `json:"mime"`
    51  		Class      string                 `json:"class"`
    52  		Executable bool                   `json:"executable"`
    53  		Encrypted  bool                   `json:"encrypted"`
    54  		Tags       []string               `json:"tags"`
    55  		Metadata   map[string]interface{} `json:"metadata"`
    56  	} `json:"attributes"`
    57  }
    58  
    59  // Dir is the JSON-API directory structure
    60  type Dir struct {
    61  	ID    string `json:"id"`
    62  	Rev   string `json:"rev"`
    63  	Attrs struct {
    64  		Type      string                 `json:"type"`
    65  		Name      string                 `json:"name"`
    66  		DirID     string                 `json:"dir_id"`
    67  		Fullpath  string                 `json:"path"`
    68  		CreatedAt time.Time              `json:"created_at"`
    69  		UpdatedAt time.Time              `json:"updated_at"`
    70  		Tags      []string               `json:"tags"`
    71  		Metadata  map[string]interface{} `json:"metadata"`
    72  	} `json:"attributes"`
    73  }
    74  
    75  // DirOrFile is the JSON-API file structure used to encapsulate a file or
    76  // directory
    77  type DirOrFile File
    78  
    79  // FilePatchAttrs is the attributes in the JSON-API structure for modifying the
    80  // metadata of a file or directory
    81  type FilePatchAttrs struct {
    82  	Name       string    `json:"name,omitempty"`
    83  	DirID      string    `json:"dir_id,omitempty"`
    84  	Tags       []string  `json:"tags,omitempty"`
    85  	UpdatedAt  time.Time `json:"updated_at,omitempty"`
    86  	Executable bool      `json:"executable,omitempty"`
    87  	Class      string    `json:"class,omitempty"`
    88  }
    89  
    90  // FilePatch is the structure used to modify file or directory metadata
    91  type FilePatch struct {
    92  	Rev   string         `json:"-"`
    93  	Attrs FilePatchAttrs `json:"attributes"`
    94  }
    95  
    96  // GetFileByID returns a File given the specified ID
    97  func (c *Client) GetFileByID(id string) (*File, error) {
    98  	res, err := c.Req(&request.Options{
    99  		Method: "GET",
   100  		Path:   "/files/" + url.PathEscape(id),
   101  	})
   102  	if err != nil {
   103  		return nil, err
   104  	}
   105  	return readFile(res)
   106  }
   107  
   108  // GetFileByPath returns a File given the specified path
   109  func (c *Client) GetFileByPath(name string) (*File, error) {
   110  	res, err := c.Req(&request.Options{
   111  		Method:  "GET",
   112  		Path:    "/files/metadata",
   113  		Queries: url.Values{"Path": {name}},
   114  	})
   115  	if err != nil {
   116  		return nil, err
   117  	}
   118  	return readFile(res)
   119  }
   120  
   121  // GetDirByID returns a Dir given the specified ID
   122  func (c *Client) GetDirByID(id string) (*Dir, error) {
   123  	res, err := c.Req(&request.Options{
   124  		Method: "GET",
   125  		Path:   "/files/" + url.PathEscape(id),
   126  	})
   127  	if err != nil {
   128  		return nil, err
   129  	}
   130  	return readDir(res)
   131  }
   132  
   133  // GetDirByPath returns a Dir given the specified path
   134  func (c *Client) GetDirByPath(name string) (*Dir, error) {
   135  	res, err := c.Req(&request.Options{
   136  		Method:  "GET",
   137  		Path:    "/files/metadata",
   138  		Queries: url.Values{"Path": {name}},
   139  	})
   140  	if err != nil {
   141  		return nil, err
   142  	}
   143  	return readDir(res)
   144  }
   145  
   146  // GetDirOrFileByPath returns a DirOrFile given the specified path
   147  func (c *Client) GetDirOrFileByPath(name string) (*DirOrFile, error) {
   148  	res, err := c.Req(&request.Options{
   149  		Method:  "GET",
   150  		Path:    "/files/metadata",
   151  		Queries: url.Values{"Path": {name}},
   152  	})
   153  	if err != nil {
   154  		return nil, err
   155  	}
   156  	return readDirOrFile(res)
   157  }
   158  
   159  // Mkdir creates a directory with the specified path. If the directory's parent
   160  // does not exist, an error is returned.
   161  func (c *Client) Mkdir(name string) (*Dir, error) {
   162  	return c.mkdir(name, "")
   163  }
   164  
   165  // Mkdirall creates a directory with the specified path. If the directory's
   166  // parent does not exist, all intermediary parents are created.
   167  func (c *Client) Mkdirall(name string) (*Dir, error) {
   168  	return c.mkdir(name, "true")
   169  }
   170  
   171  func (c *Client) mkdir(name string, recur string) (*Dir, error) {
   172  	res, err := c.Req(&request.Options{
   173  		Method: "POST",
   174  		Path:   "/files/",
   175  		Queries: url.Values{
   176  			"Path":      {name},
   177  			"Type":      {"directory"},
   178  			"Recursive": {recur},
   179  		},
   180  	})
   181  	if err != nil {
   182  		return nil, err
   183  	}
   184  	return readDir(res)
   185  }
   186  
   187  // DownloadByID is used to download a file's content given its ID. It returns
   188  // a io.ReadCloser that you can read from.
   189  func (c *Client) DownloadByID(id string) (io.ReadCloser, error) {
   190  	res, err := c.Req(&request.Options{
   191  		Method: "GET",
   192  		Path:   "/files/download/" + url.PathEscape(id),
   193  	})
   194  	if err != nil {
   195  		return nil, err
   196  	}
   197  	return res.Body, nil
   198  }
   199  
   200  // DownloadByPath is used to download a file's content given its path. It
   201  // returns a io.ReadCloser that you can read from.
   202  func (c *Client) DownloadByPath(name string) (io.ReadCloser, error) {
   203  	res, err := c.Req(&request.Options{
   204  		Method:  "GET",
   205  		Path:    "/files/download",
   206  		Queries: url.Values{"Path": {name}},
   207  	})
   208  	if err != nil {
   209  		return nil, err
   210  	}
   211  	return res.Body, nil
   212  }
   213  
   214  // Upload is used to upload a new file from an using a Upload instance. If the
   215  // ContentMD5 field is not nil, the file integrity is checked.
   216  func (c *Client) Upload(u *Upload) (*File, error) {
   217  	headers := make(request.Headers)
   218  	if u.ContentMD5 != nil {
   219  		headers["Content-MD5"] = base64.StdEncoding.EncodeToString(u.ContentMD5)
   220  	}
   221  	if u.ContentType != "" {
   222  		headers["Content-Type"] = u.ContentType
   223  	}
   224  	headers["Expect"] = "100-continue"
   225  
   226  	opts := &request.Options{
   227  		Body:    u.Contents,
   228  		Headers: headers,
   229  	}
   230  	if u.ContentLength > 0 {
   231  		opts.ContentLength = u.ContentLength
   232  	}
   233  
   234  	if u.Overwrite {
   235  		opts.Method = "PUT"
   236  		opts.Path = "/files/" + url.PathEscape(u.FileID)
   237  		if u.FileRev != "" {
   238  			headers["If-Match"] = u.FileRev
   239  		}
   240  	} else {
   241  		opts.Method = "POST"
   242  		opts.Path = "/files/" + url.PathEscape(u.DirID)
   243  		opts.Queries = url.Values{
   244  			"Type": {"file"},
   245  			"Name": {u.Name},
   246  		}
   247  	}
   248  	res, err := c.Req(opts)
   249  	if err != nil {
   250  		return nil, err
   251  	}
   252  	return readFile(res)
   253  }
   254  
   255  // UpdateAttrsByID is used to update the attributes of a file or directory
   256  // of the specified ID
   257  func (c *Client) UpdateAttrsByID(id string, patch *FilePatch) (*DirOrFile, error) {
   258  	body, err := writeJSONAPI(patch)
   259  	if err != nil {
   260  		return nil, err
   261  	}
   262  	headers := make(request.Headers)
   263  	if patch.Rev != "" {
   264  		headers["If-Match"] = patch.Rev
   265  	}
   266  	res, err := c.Req(&request.Options{
   267  		Method:  "PATCH",
   268  		Path:    "/files/" + id,
   269  		Body:    body,
   270  		Headers: headers,
   271  	})
   272  	if err != nil {
   273  		return nil, err
   274  	}
   275  	return readDirOrFile(res)
   276  }
   277  
   278  // UpdateAttrsByPath is used to update the attributes of a file or directory
   279  // of the specified path
   280  func (c *Client) UpdateAttrsByPath(name string, patch *FilePatch) (*DirOrFile, error) {
   281  	body, err := writeJSONAPI(patch)
   282  	if err != nil {
   283  		return nil, err
   284  	}
   285  	headers := make(request.Headers)
   286  	if patch.Rev != "" {
   287  		headers["If-Match"] = patch.Rev
   288  	}
   289  	res, err := c.Req(&request.Options{
   290  		Method:  "PATCH",
   291  		Path:    "/files/metadata",
   292  		Headers: headers,
   293  		Body:    body,
   294  		Queries: url.Values{"Path": {name}},
   295  	})
   296  	if err != nil {
   297  		return nil, err
   298  	}
   299  	return readDirOrFile(res)
   300  }
   301  
   302  // Move is used to move a file or directory from a given path to the other
   303  // given path
   304  func (c *Client) Move(from, to string) error {
   305  	doc, err := c.GetDirByPath(path.Dir(to))
   306  	if err != nil {
   307  		return err
   308  	}
   309  	_, err = c.UpdateAttrsByPath(from, &FilePatch{
   310  		Attrs: FilePatchAttrs{
   311  			DirID:     doc.ID,
   312  			Name:      path.Base(to),
   313  			UpdatedAt: time.Now(),
   314  		},
   315  	})
   316  	return err
   317  }
   318  
   319  // TrashByID is used to move a file or directory specified by its ID to the
   320  // trash
   321  func (c *Client) TrashByID(id string) error {
   322  	_, err := c.Req(&request.Options{
   323  		Method:     "DELETE",
   324  		Path:       "/files/" + url.PathEscape(id),
   325  		NoResponse: true,
   326  	})
   327  	return err
   328  }
   329  
   330  // TrashByPath is used to move a file or directory specified by its path to the
   331  // trash
   332  func (c *Client) TrashByPath(name string) error {
   333  	doc, err := c.GetDirOrFileByPath(name)
   334  	if err != nil {
   335  		return err
   336  	}
   337  	return c.TrashByID(doc.ID)
   338  }
   339  
   340  // RestoreByID is used to restore a file or directory from the trash given its
   341  // ID
   342  func (c *Client) RestoreByID(id string) error {
   343  	_, err := c.Req(&request.Options{
   344  		Method:     "POST",
   345  		Path:       "/files/trash/" + url.PathEscape(id),
   346  		NoResponse: true,
   347  	})
   348  	return err
   349  }
   350  
   351  // RestoreByPath is used to restore a file or directory from the trash given its
   352  // path
   353  func (c *Client) RestoreByPath(name string) error {
   354  	doc, err := c.GetDirOrFileByPath(name)
   355  	if err != nil {
   356  		return err
   357  	}
   358  	return c.RestoreByID(doc.ID)
   359  }
   360  
   361  // PermanentDeleteByID is used to delete a file or directory specified by its
   362  // ID, not just putting it in the trash
   363  func (c *Client) PermanentDeleteByID(id string) error {
   364  	_, err := c.Req(&request.Options{
   365  		Method:     "PATCH",
   366  		Path:       "/files/" + url.PathEscape(id),
   367  		Body:       strings.NewReader(`{"data": {"attributes": {"permanent_delete": true}}}`),
   368  		NoResponse: true,
   369  	})
   370  	return err
   371  }
   372  
   373  // PermanentDeleteByPath is used to delete a file or directory specified by its
   374  // path, not just putting it in the trash
   375  func (c *Client) PermanentDeleteByPath(name string) error {
   376  	doc, err := c.GetDirOrFileByPath(name)
   377  	if err != nil {
   378  		return err
   379  	}
   380  	return c.PermanentDeleteByID(doc.ID)
   381  }
   382  
   383  // WalkFn is the function type used by the walk function.
   384  type WalkFn func(name string, doc *DirOrFile, err error) error
   385  
   386  // WalkByPath is used to walk along the filesystem tree originated at the
   387  // specified root path.
   388  func (c *Client) WalkByPath(root string, walkFn WalkFn) error {
   389  	doc, err := c.GetDirOrFileByPath(path.Clean(root))
   390  	root = path.Clean(root)
   391  	if err != nil {
   392  		return walkFn(root, doc, err)
   393  	}
   394  	return walk(c, root, doc, walkFn)
   395  }
   396  
   397  func walk(c *Client, name string, doc *DirOrFile, walkFn WalkFn) error {
   398  	isDir := doc.Attrs.Type == DirType
   399  
   400  	err := walkFn(name, doc, nil)
   401  	if err != nil {
   402  		if isDir && errors.Is(err, filepath.SkipDir) {
   403  			return nil
   404  		}
   405  		return err
   406  	}
   407  
   408  	if !isDir {
   409  		return nil
   410  	}
   411  
   412  	reqPath := "/files/" + url.PathEscape(doc.ID)
   413  	reqQuery := url.Values{"page[limit]": {"100"}}
   414  	for {
   415  		res, err := c.Req(&request.Options{
   416  			Method:  "GET",
   417  			Path:    reqPath,
   418  			Queries: reqQuery,
   419  		})
   420  		if err != nil {
   421  			return walkFn(name, doc, err)
   422  		}
   423  
   424  		var included []*DirOrFile
   425  		var links struct {
   426  			Next string
   427  		}
   428  		if err = readJSONAPILinks(res.Body, &included, &links); err != nil {
   429  			return walkFn(name, doc, err)
   430  		}
   431  
   432  		for _, d := range included {
   433  			fullpath := path.Join(name, d.Attrs.Name)
   434  			err = walk(c, fullpath, d, walkFn)
   435  			if err != nil && !errors.Is(err, filepath.SkipDir) {
   436  				return err
   437  			}
   438  		}
   439  
   440  		if links.Next == "" {
   441  			break
   442  		}
   443  		u, err := url.Parse(links.Next)
   444  		if err != nil {
   445  			return err
   446  		}
   447  		reqPath = u.Path
   448  		reqQuery = u.Query()
   449  	}
   450  
   451  	return nil
   452  }
   453  
   454  func readDirOrFile(res *http.Response) (*DirOrFile, error) {
   455  	dirOrFile := &DirOrFile{}
   456  	if err := readJSONAPI(res.Body, &dirOrFile); err != nil {
   457  		return nil, err
   458  	}
   459  	return dirOrFile, nil
   460  }
   461  
   462  func readFile(res *http.Response) (*File, error) {
   463  	file := &File{}
   464  	if err := readJSONAPI(res.Body, &file); err != nil {
   465  		return nil, err
   466  	}
   467  	if file.Attrs.Type != FileType {
   468  		return nil, fmt.Errorf("Not a file")
   469  	}
   470  	return file, nil
   471  }
   472  
   473  func readDir(res *http.Response) (*Dir, error) {
   474  	dir := &Dir{}
   475  	if err := readJSONAPI(res.Body, &dir); err != nil {
   476  		return nil, err
   477  	}
   478  	if dir.Attrs.Type != DirType {
   479  		return nil, fmt.Errorf("Not a directory")
   480  	}
   481  	return dir, nil
   482  }