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  )