github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/vfs/version.go (about)

     1  package vfs
     2  
     3  import (
     4  	"sort"
     5  	"time"
     6  
     7  	"github.com/cozy/cozy-stack/pkg/config/config"
     8  	"github.com/cozy/cozy-stack/pkg/consts"
     9  	"github.com/cozy/cozy-stack/pkg/couchdb"
    10  	"github.com/cozy/cozy-stack/pkg/jsonapi"
    11  	"github.com/cozy/cozy-stack/pkg/prefixer"
    12  )
    13  
    14  // Version is used for storing the metadata about previous versions of file
    15  // contents. The content its-self is stored on the local file system or in
    16  // Swift.
    17  type Version struct {
    18  	DocID        string            `json:"_id,omitempty"`
    19  	DocRev       string            `json:"_rev,omitempty"`
    20  	UpdatedAt    time.Time         `json:"updated_at"`
    21  	ByteSize     int64             `json:"size,string"`
    22  	MD5Sum       []byte            `json:"md5sum"`
    23  	Tags         []string          `json:"tags"`
    24  	Metadata     Metadata          `json:"metadata,omitempty"`
    25  	CozyMetadata FilesCozyMetadata `json:"cozyMetadata,omitempty"`
    26  	Rels         struct {
    27  		File struct {
    28  			Data struct {
    29  				ID   string `json:"_id"`
    30  				Type string `json:"_type"`
    31  			} `json:"data"`
    32  		} `json:"file"`
    33  	} `json:"relationships"`
    34  }
    35  
    36  // ID returns the version identifier
    37  func (v *Version) ID() string { return v.DocID }
    38  
    39  // Rev returns the version revision
    40  func (v *Version) Rev() string { return v.DocRev }
    41  
    42  // DocType returns the version document type
    43  func (v *Version) DocType() string { return consts.FilesVersions }
    44  
    45  // Clone implements couchdb.Doc
    46  func (v *Version) Clone() couchdb.Doc {
    47  	cloned := *v
    48  	cloned.MD5Sum = make([]byte, len(v.MD5Sum))
    49  	copy(cloned.MD5Sum, v.MD5Sum)
    50  	cloned.Tags = make([]string, len(v.Tags))
    51  	copy(cloned.Tags, v.Tags)
    52  	cloned.Metadata = make(Metadata, len(v.Metadata))
    53  	for k, val := range v.Metadata {
    54  		cloned.Metadata[k] = val
    55  	}
    56  	meta := v.CozyMetadata.Clone()
    57  	cloned.CozyMetadata = *meta
    58  	return &cloned
    59  }
    60  
    61  // SetID changes the version qualified identifier
    62  func (v *Version) SetID(id string) { v.DocID = id }
    63  
    64  // SetRev changes the version revision
    65  func (v *Version) SetRev(rev string) { v.DocRev = rev }
    66  
    67  // Included is part of jsonapi.Object interface
    68  func (v *Version) Included() []jsonapi.Object { return nil }
    69  
    70  // Relationships is part of jsonapi.Object interface
    71  func (v *Version) Relationships() jsonapi.RelationshipMap { return nil }
    72  
    73  // Links is part of jsonapi.Object interface
    74  func (v *Version) Links() *jsonapi.LinksList { return nil }
    75  
    76  // NewVersion returns a version from a given FileDoc. It is often used just
    77  // before modifying the content of this file.
    78  // Note that the _id is precomputed as it can be useful to use it for a storage
    79  // location before the version is saved in CouchDB.
    80  func NewVersion(file *FileDoc) *Version {
    81  	var instanceURL string
    82  	if file.CozyMetadata != nil {
    83  		instanceURL = file.CozyMetadata.UploadedOn
    84  	}
    85  	fcm := NewCozyMetadata(instanceURL)
    86  	id := file.InternalID
    87  	if id == "" {
    88  		id = file.Rev()
    89  	}
    90  	v := &Version{
    91  		DocID:        file.ID() + "/" + id,
    92  		UpdatedAt:    file.UpdatedAt,
    93  		ByteSize:     file.ByteSize,
    94  		MD5Sum:       file.MD5Sum,
    95  		Tags:         file.Tags,
    96  		Metadata:     file.Metadata,
    97  		CozyMetadata: *fcm,
    98  	}
    99  	v.Rels.File.Data.ID = file.ID()
   100  	v.Rels.File.Data.Type = consts.Files
   101  	v.CozyMetadata.UploadedOn = instanceURL
   102  	at := file.UpdatedAt
   103  	if file.CozyMetadata != nil && file.CozyMetadata.UploadedAt != nil {
   104  		at = *file.CozyMetadata.UploadedAt
   105  	}
   106  	v.CozyMetadata.UploadedAt = &at
   107  	if file.CozyMetadata != nil && file.CozyMetadata.UploadedBy != nil {
   108  		by := *file.CozyMetadata.UploadedBy
   109  		v.CozyMetadata.UploadedBy = &by
   110  	}
   111  	return v
   112  }
   113  
   114  // SetMetaFromVersion takes the metadata from the version and copies them to
   115  // the file document.
   116  func SetMetaFromVersion(file *FileDoc, version *Version) {
   117  	file.UpdatedAt = version.UpdatedAt
   118  	file.ByteSize = version.ByteSize
   119  	file.MD5Sum = version.MD5Sum
   120  	file.Tags = version.Tags
   121  	file.Metadata = version.Metadata
   122  	if file.CozyMetadata == nil {
   123  		file.CozyMetadata = NewCozyMetadata("")
   124  		file.CozyMetadata.CreatedAt = file.CreatedAt
   125  	}
   126  	file.CozyMetadata.UploadedOn = version.CozyMetadata.UploadedOn
   127  	at := *version.CozyMetadata.UploadedAt
   128  	file.CozyMetadata.UploadedAt = &at
   129  	if version.CozyMetadata.UploadedBy != nil {
   130  		by := *version.CozyMetadata.UploadedBy
   131  		file.CozyMetadata.UploadedBy = &by
   132  	}
   133  }
   134  
   135  // FindVersion returns the version for the given id
   136  func FindVersion(db prefixer.Prefixer, id string) (*Version, error) {
   137  	doc := &Version{}
   138  	if err := couchdb.GetDoc(db, consts.FilesVersions, id, doc); err != nil {
   139  		return nil, err
   140  	}
   141  	return doc, nil
   142  }
   143  
   144  // VersionsFor returns the list of the versions for a given file identifier.
   145  func VersionsFor(db prefixer.Prefixer, fileID string) ([]*Version, error) {
   146  	var versions []*Version
   147  	req := &couchdb.AllDocsRequest{
   148  		StartKey: fileID + "/",
   149  		EndKey:   fileID + "0", // 0 is the next character after / in ascii
   150  	}
   151  	if err := couchdb.GetAllDocs(db, consts.FilesVersions, req, &versions); err != nil {
   152  		return nil, err
   153  	}
   154  	return versions, nil
   155  }
   156  
   157  type ActionForCandidateVersion int
   158  
   159  const (
   160  	DoNothingForCandidateVersion ActionForCandidateVersion = iota
   161  	KeepCandidateVersion
   162  	CleanCandidateVersion
   163  )
   164  
   165  // FindVersionsToClean returns a bool to say if the candidate version must be
   166  // cleaned, a list of old versions to clean, and an error. The rules to know
   167  // the versions to clean or keep are:
   168  // - the tagged versions are kept
   169  // - two versions must not be too close in time
   170  // - there is a maximal number of versions.
   171  func FindVersionsToClean(db Prefixer, fileID string, candidate *Version) (ActionForCandidateVersion, []*Version, error) {
   172  	olds, err := VersionsFor(db, fileID)
   173  	if err != nil {
   174  		return DoNothingForCandidateVersion, nil, err
   175  	}
   176  	maxNumber, minDelay := getVersioningConfig(db.GetContextName())
   177  	action, toClean := detectVersionsToClean(candidate, olds, maxNumber, minDelay)
   178  	return action, toClean, nil
   179  }
   180  
   181  func getVersioningConfig(contextName string) (int, time.Duration) {
   182  	cfg := config.GetConfig()
   183  	maxNumber := cfg.Fs.Versioning.MaxNumberToKeep
   184  	minDelay := cfg.Fs.Versioning.MinDelayBetweenTwoVersions
   185  
   186  	context, _ := cfg.Fs.Contexts[contextName].(map[string]interface{})
   187  	if number, ok := context["max_number_of_versions_to_keep"].(int); ok {
   188  		maxNumber = number
   189  	}
   190  	if delay, ok := context["min_delay_between_two_versions"].(string); ok {
   191  		if min, err := time.ParseDuration(delay); err == nil {
   192  			minDelay = min
   193  		}
   194  	}
   195  
   196  	return maxNumber, minDelay
   197  }
   198  
   199  func detectVersionsToClean(candidate *Version, olds []*Version, maxNumber int, minDelay time.Duration) (ActionForCandidateVersion, []*Version) {
   200  	if maxNumber == 0 {
   201  		return CleanCandidateVersion, olds
   202  	}
   203  
   204  	if len(olds) == 0 {
   205  		return KeepCandidateVersion, nil
   206  	}
   207  
   208  	// When there are 2 concurrent uploads for the same file, we may have a
   209  	// race condition on the candidate version, and we can avoid it by checking
   210  	// the ID.
   211  	for _, old := range olds {
   212  		if old.DocID == candidate.DocID {
   213  			return DoNothingForCandidateVersion, nil
   214  		}
   215  	}
   216  
   217  	sort.Slice(olds, func(i, j int) bool {
   218  		return olds[i].CozyMetadata.CreatedAt.Before(olds[j].CozyMetadata.CreatedAt)
   219  	})
   220  
   221  	// We will keep the candidate version if it has no tags and is not too
   222  	// close to the previous version.
   223  	action := KeepCandidateVersion
   224  	if candidate != nil && len(candidate.Tags) == 0 {
   225  		candidateTime := candidate.CozyMetadata.CreatedAt
   226  		previousTime := olds[len(olds)-1].CozyMetadata.CreatedAt
   227  		if previousTime.Add(minDelay).After(candidateTime) {
   228  			action = CleanCandidateVersion
   229  		}
   230  	}
   231  
   232  	// Find the number of old versions to clean
   233  	nb := len(olds) + 1 - maxNumber // +1 is for the current version
   234  	if candidate != nil && action == KeepCandidateVersion {
   235  		nb++ // +1 for the candidate version (if it is kept)
   236  	}
   237  	if nb <= 0 {
   238  		return action, nil
   239  	}
   240  
   241  	var toClean []*Version
   242  	for _, v := range olds {
   243  		if len(v.Tags) > 0 {
   244  			continue
   245  		}
   246  		toClean = append(toClean, v)
   247  		nb--
   248  		if nb <= 0 {
   249  			break
   250  		}
   251  	}
   252  	return action, toClean
   253  }
   254  
   255  var _ jsonapi.Object = &Version{}