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 }