github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/vfs/file.go (about) 1 package vfs 2 3 import ( 4 "encoding/base64" 5 "fmt" 6 "mime" 7 "net/http" 8 "os" 9 "path" 10 "strings" 11 "time" 12 13 "github.com/cozy/cozy-stack/pkg/consts" 14 "github.com/cozy/cozy-stack/pkg/couchdb" 15 "github.com/labstack/echo/v4" 16 ) 17 18 // FileDoc is a struct containing all the informations about a file. 19 // It implements the couchdb.Doc and jsonapi.Object interfaces. 20 type FileDoc struct { 21 // Type of document. Useful to (de)serialize and filter the data 22 // from couch. 23 Type string `json:"type"` 24 // Qualified file identifier 25 DocID string `json:"_id,omitempty"` 26 // File revision 27 DocRev string `json:"_rev,omitempty"` 28 // File name 29 DocName string `json:"name,omitempty"` 30 // Parent directory identifier 31 DirID string `json:"dir_id,omitempty"` 32 RestorePath string `json:"restore_path,omitempty"` 33 34 CreatedAt time.Time `json:"created_at"` 35 UpdatedAt time.Time `json:"updated_at"` 36 37 ByteSize int64 `json:"size,string"` // Serialized in JSON as a string, because JS has some issues with big numbers 38 MD5Sum []byte `json:"md5sum,omitempty"` 39 Mime string `json:"mime,omitempty"` 40 Class string `json:"class,omitempty"` 41 Executable bool `json:"executable"` 42 Trashed bool `json:"trashed"` 43 Encrypted bool `json:"encrypted"` 44 Tags []string `json:"tags,omitempty"` 45 46 Metadata Metadata `json:"metadata,omitempty"` 47 ReferencedBy []couchdb.DocReference `json:"referenced_by,omitempty"` 48 49 CozyMetadata *FilesCozyMetadata `json:"cozyMetadata,omitempty"` 50 51 // InternalID is an identifier that can be used by the VFS, but must no be 52 // used by clients. For example, it can be used to know the location in 53 // Swift of a file. 54 InternalID string `json:"internal_vfs_id,omitempty"` 55 56 // Cache of the fullpath of the file. Should not have to be invalidated 57 // since we use FileDoc as immutable data-structures. 58 fullpath string 59 60 // NOTE: Do not forget to propagate changes made to this structure to the 61 // structure DirOrFileDoc in model/vfs/vfs.go and client/files.go. 62 } 63 64 // ID returns the file qualified identifier 65 func (f *FileDoc) ID() string { return f.DocID } 66 67 // Rev returns the file revision 68 func (f *FileDoc) Rev() string { return f.DocRev } 69 70 // DocType returns the file document type 71 func (f *FileDoc) DocType() string { return consts.Files } 72 73 // Clone implements couchdb.Doc 74 func (f *FileDoc) Clone() couchdb.Doc { 75 cloned := *f 76 cloned.MD5Sum = make([]byte, len(f.MD5Sum)) 77 copy(cloned.MD5Sum, f.MD5Sum) 78 cloned.Tags = make([]string, len(f.Tags)) 79 copy(cloned.Tags, f.Tags) 80 cloned.ReferencedBy = make([]couchdb.DocReference, len(f.ReferencedBy)) 81 copy(cloned.ReferencedBy, f.ReferencedBy) 82 cloned.Metadata = make(Metadata, len(f.Metadata)) 83 for k, v := range f.Metadata { 84 cloned.Metadata[k] = v 85 } 86 if f.CozyMetadata != nil { 87 cloned.CozyMetadata = f.CozyMetadata.Clone() 88 } 89 return &cloned 90 } 91 92 // SetID changes the file qualified identifier 93 func (f *FileDoc) SetID(id string) { f.DocID = id } 94 95 // SetRev changes the file revision 96 func (f *FileDoc) SetRev(rev string) { f.DocRev = rev } 97 98 // Path is used to generate the file path 99 func (f *FileDoc) Path(fp FilePather) (string, error) { 100 if f.fullpath != "" { 101 return f.fullpath, nil 102 } 103 var err error 104 f.fullpath, err = fp.FilePath(f) 105 return f.fullpath, err 106 } 107 108 // ResetFullpath clears the fullpath, so it can be recomputed with Path() 109 func (f *FileDoc) ResetFullpath() { 110 f.fullpath = "" 111 } 112 113 // Parent returns the parent directory document 114 func (f *FileDoc) Parent(fs VFS) (*DirDoc, error) { 115 parent, err := fs.DirByID(f.DirID) 116 if os.IsNotExist(err) { 117 err = ErrParentDoesNotExist 118 } 119 return parent, err 120 } 121 122 // Name returns base name of the file 123 func (f *FileDoc) Name() string { return f.DocName } 124 125 // Size returns the length in bytes for regular files; system-dependent for others 126 func (f *FileDoc) Size() int64 { return f.ByteSize } 127 128 // Mode returns the file mode bits 129 func (f *FileDoc) Mode() os.FileMode { return getFileMode(f.Executable) } 130 131 // ModTime returns the modification time 132 func (f *FileDoc) ModTime() time.Time { return f.UpdatedAt } 133 134 // IsDir returns the abbreviation for Mode().IsDir() 135 func (f *FileDoc) IsDir() bool { return false } 136 137 // Sys returns the underlying data source (can return nil) 138 func (f *FileDoc) Sys() interface{} { return nil } 139 140 // AddReferencedBy adds referenced_by to the file 141 func (f *FileDoc) AddReferencedBy(ri ...couchdb.DocReference) { 142 f.ReferencedBy = append(f.ReferencedBy, ri...) 143 } 144 145 // SameReferences returns true if the two sets reference the same documents. 146 func SameReferences(a, b []couchdb.DocReference) bool { 147 if len(a) != len(b) { 148 return false 149 } 150 for _, ref := range a { 151 if !containsDocReference(b, ref) { 152 return false 153 } 154 } 155 return true 156 } 157 158 func containsDocReference(haystack []couchdb.DocReference, needle couchdb.DocReference) bool { 159 for _, ref := range haystack { 160 if ref.ID == needle.ID && ref.Type == needle.Type { 161 return true 162 } 163 } 164 return false 165 } 166 167 // RemoveReferencedBy removes one or several referenced_by to the file 168 func (f *FileDoc) RemoveReferencedBy(ri ...couchdb.DocReference) { 169 // https://github.com/golang/go/wiki/SliceTricks#filtering-without-allocating 170 referenced := f.ReferencedBy[:0] 171 for _, ref := range f.ReferencedBy { 172 if !containsDocReference(ri, ref) { 173 referenced = append(referenced, ref) 174 } 175 } 176 f.ReferencedBy = referenced 177 } 178 179 // NewFileDoc is the FileDoc constructor. The given name is validated. 180 func NewFileDoc(name, dirID string, size int64, md5Sum []byte, mimeType, class string, cdate time.Time, executable, trashed, encrypted bool, tags []string) (*FileDoc, error) { 181 if err := checkFileName(name); err != nil { 182 return nil, err 183 } 184 185 if _, _, err := mime.ParseMediaType(mimeType); err != nil { 186 return nil, ErrIllegalMime 187 } 188 189 if dirID == "" { 190 dirID = consts.RootDirID 191 } 192 193 tags = uniqueTags(tags) 194 195 doc := &FileDoc{ 196 Type: consts.FileType, 197 DocName: name, 198 DirID: dirID, 199 200 CreatedAt: cdate, 201 UpdatedAt: cdate, 202 ByteSize: size, 203 MD5Sum: md5Sum, 204 Mime: mimeType, 205 Class: class, 206 Executable: executable, 207 Trashed: trashed, 208 Encrypted: encrypted, 209 Tags: tags, 210 } 211 212 return doc, nil 213 } 214 215 // ServeFileContent replies to a http request using the content of a 216 // file given its FileDoc. 217 // 218 // It uses internally http.ServeContent and benefits from it by 219 // offering support to Range, If-Modified-Since and If-None-Match 220 // requests. It uses the revision of the file as the Etag value for 221 // non-ranged requests 222 // 223 // The content disposition is inlined. 224 func ServeFileContent(fs VFS, doc *FileDoc, version *Version, filename, disposition string, req *http.Request, w http.ResponseWriter) error { 225 if filename == "" { 226 filename = doc.DocName 227 } 228 header := w.Header() 229 header.Set(echo.HeaderContentType, doc.Mime) 230 if disposition != "" { 231 header.Set(echo.HeaderContentDisposition, ContentDisposition(disposition, filename)) 232 } 233 234 if header.Get("Range") == "" { 235 eTag := base64.StdEncoding.EncodeToString(doc.MD5Sum) 236 header.Set("Etag", fmt.Sprintf(`"%s"`, eTag)) 237 } 238 239 var content File 240 var err error 241 if version == nil { 242 content, err = fs.OpenFile(doc) 243 } else { 244 content, err = fs.OpenFileVersion(doc, version) 245 } 246 if err != nil { 247 return err 248 } 249 defer content.Close() 250 251 http.ServeContent(w, req, filename, doc.UpdatedAt, content) 252 return nil 253 } 254 255 // ModifyFileMetadata modify the metadata associated to a file. It can 256 // be used to rename or move the file in the VFS. 257 func ModifyFileMetadata(fs VFS, olddoc *FileDoc, patch *DocPatch) (*FileDoc, error) { 258 var err error 259 rename := patch.Name != nil 260 cdate := olddoc.CreatedAt 261 oname := olddoc.DocName 262 trashed := olddoc.Trashed 263 if patch.RestorePath != nil { 264 trashed = *patch.RestorePath != "" 265 } 266 var oldFavorite *bool 267 if olddoc.CozyMetadata != nil { 268 oldFavorite = &olddoc.CozyMetadata.Favorite 269 } 270 patch, err = normalizeDocPatch(&DocPatch{ 271 Name: &oname, 272 DirID: &olddoc.DirID, 273 RestorePath: &olddoc.RestorePath, 274 Tags: &olddoc.Tags, 275 UpdatedAt: &olddoc.UpdatedAt, 276 Executable: &olddoc.Executable, 277 Encrypted: &olddoc.Encrypted, 278 CozyMetadata: CozyMetadataPatch{Favorite: oldFavorite}, 279 }, patch, cdate) 280 if err != nil { 281 return nil, err 282 } 283 284 // in case of a renaming of the file, if the extension of the file has 285 // changed, we consider recalculating the mime and class attributes, using 286 // the new extension. 287 newname := *patch.Name 288 oldname := olddoc.DocName 289 var mime, class string 290 if patch.Class != nil || (rename && path.Ext(newname) != path.Ext(oldname)) { 291 mime, class = ExtractMimeAndClassFromFilename(newname) 292 } else { 293 mime, class = olddoc.Mime, olddoc.Class 294 } 295 296 if trashed && olddoc.DirID != *patch.DirID { 297 return nil, ErrFileInTrash 298 } 299 300 newdoc, err := NewFileDoc( 301 newname, 302 *patch.DirID, 303 olddoc.Size(), 304 olddoc.MD5Sum, 305 mime, 306 class, 307 cdate, 308 *patch.Executable, 309 trashed, 310 *patch.Encrypted, 311 *patch.Tags, 312 ) 313 if err != nil { 314 return nil, err 315 } 316 317 newdoc.RestorePath = *patch.RestorePath 318 newdoc.UpdatedAt = *patch.UpdatedAt 319 newdoc.Metadata = olddoc.Metadata 320 newdoc.ReferencedBy = olddoc.ReferencedBy 321 newdoc.CozyMetadata = olddoc.CozyMetadata 322 newdoc.InternalID = olddoc.InternalID 323 if newdoc.CozyMetadata != nil && patch.CozyMetadata.Favorite != nil { 324 newdoc.CozyMetadata.Favorite = *patch.CozyMetadata.Favorite 325 } 326 327 if err = fs.UpdateFileDoc(olddoc, newdoc); err != nil { 328 return nil, err 329 } 330 return newdoc, nil 331 } 332 333 // TrashFile is used to delete a file given its document 334 func TrashFile(fs VFS, olddoc *FileDoc) (*FileDoc, error) { 335 oldpath, err := olddoc.Path(fs) 336 if err != nil { 337 return nil, err 338 } 339 340 // If there is only the trashed attribute or the parent in trash, but not 341 // both, we can try again to move the file to the trash to fix the 342 // inconsistency. 343 if olddoc.Trashed && strings.HasPrefix(oldpath, TrashDirName) { 344 return nil, ErrFileInTrash 345 } 346 347 var newdoc *FileDoc 348 restorePath := path.Dir(oldpath) 349 err = tryOrUseSuffix(olddoc.DocName, conflictFormat, func(name string) error { 350 newdoc = olddoc.Clone().(*FileDoc) 351 newdoc.DirID = consts.TrashDirID 352 newdoc.RestorePath = restorePath 353 newdoc.DocName = name 354 newdoc.Trashed = true 355 newdoc.fullpath = path.Join(TrashDirName, name) 356 newdoc.CozyMetadata = olddoc.CozyMetadata 357 return fs.UpdateFileDoc(olddoc, newdoc) 358 }) 359 360 return newdoc, err 361 } 362 363 // RestoreFile is used to restore a trashed file given its document 364 func RestoreFile(fs VFS, olddoc *FileDoc) (*FileDoc, error) { 365 oldpath, err := olddoc.Path(fs) 366 if err != nil { 367 return nil, err 368 } 369 370 restoreDir, err := getRestoreDir(fs, oldpath, olddoc.RestorePath) 371 if err != nil { 372 return nil, err 373 } 374 375 name := stripConflictSuffix(olddoc.DocName) 376 377 var newdoc *FileDoc 378 err = tryOrUseSuffix(name, conflictFormat, func(name string) error { 379 newdoc = olddoc.Clone().(*FileDoc) 380 newdoc.DirID = restoreDir.DocID 381 newdoc.RestorePath = "" 382 newdoc.DocName = name 383 newdoc.Trashed = false 384 newdoc.fullpath = path.Join(restoreDir.Fullpath, name) 385 newdoc.CozyMetadata = olddoc.CozyMetadata 386 return fs.UpdateFileDoc(olddoc, newdoc) 387 }) 388 389 return newdoc, err 390 } 391 392 func getFileMode(executable bool) os.FileMode { 393 if executable { 394 return 0755 // -rwxr-xr-x 395 } 396 return 0644 // -rw-r--r-- 397 } 398 399 var ( 400 _ couchdb.Doc = &FileDoc{} 401 _ os.FileInfo = &FileDoc{} 402 )