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

     1  package sharing
     2  
     3  import (
     4  	"encoding/json"
     5  	"strings"
     6  
     7  	"github.com/cozy/cozy-stack/model/instance"
     8  	"github.com/cozy/cozy-stack/model/permission"
     9  	"github.com/cozy/cozy-stack/model/vfs"
    10  	"github.com/cozy/cozy-stack/pkg/config/config"
    11  	"github.com/cozy/cozy-stack/pkg/consts"
    12  	"github.com/cozy/cozy-stack/pkg/couchdb"
    13  	"github.com/cozy/cozy-stack/pkg/prefixer"
    14  )
    15  
    16  // TrackMessage is used for jobs on the share-track worker.
    17  // It's the same for all the jobs of a trigger.
    18  type TrackMessage struct {
    19  	SharingID string `json:"sharing_id"`
    20  	RuleIndex int    `json:"rule_index"`
    21  	DocType   string `json:"doctype"`
    22  }
    23  
    24  // TrackEvent is used for jobs on the share-track worker.
    25  // It's unique per job.
    26  type TrackEvent struct {
    27  	Verb   string           `json:"verb"`
    28  	Doc    couchdb.JSONDoc  `json:"doc"`
    29  	OldDoc *couchdb.JSONDoc `json:"old,omitempty"`
    30  }
    31  
    32  // SharedInfo gives informations about how to apply the sharing to the shared
    33  // document
    34  type SharedInfo struct {
    35  	// Rule is the index of the rule inside the sharing rules
    36  	Rule int `json:"rule"`
    37  
    38  	// Removed is true for a deleted document, a trashed file, or if the
    39  	// document does no longer match the sharing rule
    40  	Removed bool `json:"removed,omitempty"`
    41  
    42  	// Binary is a boolean flag that is true only for files (and not even
    43  	// folders) with `removed: false`
    44  	Binary bool `json:"binary,omitempty"`
    45  
    46  	// Dissociated is a boolean flag that can be true only for files and
    47  	// folders when they have been removed from the sharing but can be put
    48  	// again (only on the Cozy instance of the owner)
    49  	Dissociated bool `json:"dissociated,omitempty"`
    50  }
    51  
    52  // SharedRef is the struct for the documents in io.cozy.shared.
    53  // They are used to track which documents is in which sharings.
    54  type SharedRef struct {
    55  	// SID is the identifier, it is doctype + / + id of the referenced doc
    56  	SID  string `json:"_id,omitempty"`
    57  	SRev string `json:"_rev,omitempty"`
    58  
    59  	// Revisions is a tree with the last known _rev of the shared object.
    60  	Revisions *RevsTree `json:"revisions"`
    61  
    62  	// Infos is a map of sharing ids -> informations
    63  	Infos map[string]SharedInfo `json:"infos"`
    64  }
    65  
    66  // ID returns the sharing qualified identifier
    67  func (s *SharedRef) ID() string { return s.SID }
    68  
    69  // Rev returns the sharing revision
    70  func (s *SharedRef) Rev() string { return s.SRev }
    71  
    72  // DocType returns the sharing document type
    73  func (s *SharedRef) DocType() string { return consts.Shared }
    74  
    75  // SetID changes the sharing qualified identifier
    76  func (s *SharedRef) SetID(id string) { s.SID = id }
    77  
    78  // SetRev changes the sharing revision
    79  func (s *SharedRef) SetRev(rev string) { s.SRev = rev }
    80  
    81  // Clone implements couchdb.Doc
    82  func (s *SharedRef) Clone() couchdb.Doc {
    83  	cloned := *s
    84  	revs := s.Revisions.Clone()
    85  	cloned.Revisions = &revs
    86  	cloned.Infos = make(map[string]SharedInfo, len(s.Infos))
    87  	for k, v := range s.Infos {
    88  		cloned.Infos[k] = v
    89  	}
    90  	return &cloned
    91  }
    92  
    93  // Fetch implements the permission.Fetcher interface
    94  func (s *SharedRef) Fetch(field string) []string {
    95  	switch field {
    96  	case "sharing":
    97  		var keys []string
    98  		for key := range s.Infos {
    99  			keys = append(keys, key)
   100  		}
   101  		return keys
   102  	default:
   103  		return nil
   104  	}
   105  }
   106  
   107  // FindReferences returns the io.cozy.shared references to the given identifiers
   108  func FindReferences(inst *instance.Instance, ids []string) ([]*SharedRef, error) {
   109  	var refs []*SharedRef
   110  	req := &couchdb.AllDocsRequest{Keys: ids}
   111  	if err := couchdb.GetAllDocs(inst, consts.Shared, req, &refs); err != nil {
   112  		return nil, err
   113  	}
   114  	return refs, nil
   115  }
   116  
   117  // extractReferencedBy extracts the referenced_by slice from the given doc
   118  // and cast it to the right type
   119  func extractReferencedBy(doc *couchdb.JSONDoc) []couchdb.DocReference {
   120  	slice, _ := doc.Get(couchdb.SelectorReferencedBy).([]interface{})
   121  	refs := make([]couchdb.DocReference, len(slice))
   122  	for i, ref := range slice {
   123  		switch r := ref.(type) {
   124  		case couchdb.DocReference:
   125  			refs[i] = r
   126  		case map[string]interface{}:
   127  			id, _ := r["id"].(string)
   128  			typ, _ := r["type"].(string)
   129  			refs[i] = couchdb.DocReference{ID: id, Type: typ}
   130  		}
   131  	}
   132  	return refs
   133  }
   134  
   135  // isNoLongerShared returns true for a document/file/folder that has matched a
   136  // rule of a sharing, but no longer does.
   137  func isNoLongerShared(inst *instance.Instance, msg TrackMessage, evt TrackEvent) (bool, error) {
   138  	switch msg.DocType {
   139  	case consts.Files:
   140  		return isFileNoLongerShared(inst, msg, evt)
   141  	case consts.BitwardenCiphers:
   142  		return isCipherNoLongerShared(inst, msg, evt)
   143  	default:
   144  		return false, nil
   145  	}
   146  }
   147  
   148  func isCipherNoLongerShared(inst *instance.Instance, msg TrackMessage, evt TrackEvent) (bool, error) {
   149  	if evt.OldDoc == nil {
   150  		return false, nil
   151  	}
   152  	oldOrg := evt.OldDoc.Get("organization_id")
   153  	newOrg := evt.Doc.Get("organization_id")
   154  	if oldOrg != newOrg {
   155  		s, err := FindSharing(inst, msg.SharingID)
   156  		if err != nil {
   157  			return false, err
   158  		}
   159  		rule := s.Rules[msg.RuleIndex]
   160  		if rule.Selector == "organization_id" {
   161  			for _, val := range rule.Values {
   162  				if val == newOrg {
   163  					return false, nil
   164  				}
   165  			}
   166  			return true, nil
   167  		}
   168  	}
   169  	return false, nil
   170  }
   171  
   172  func isFileNoLongerShared(inst *instance.Instance, msg TrackMessage, evt TrackEvent) (bool, error) {
   173  	// Optim: if dir_id and referenced_by have not changed, the file can't have
   174  	// been removed from the sharing. Same if it has no old doc.
   175  	if evt.OldDoc == nil {
   176  		return false, nil
   177  	}
   178  	if evt.Doc.Get("type") == consts.FileType {
   179  		if evt.OldDoc.Get("dir_id") == evt.Doc.Get("dir_id") {
   180  			olds := extractReferencedBy(evt.OldDoc)
   181  			news := extractReferencedBy(&evt.Doc)
   182  			if vfs.SameReferences(olds, news) {
   183  				return false, nil
   184  			}
   185  		}
   186  	} else {
   187  		// For a directory, we have to check the path, as it can be a subfolder
   188  		// of a folder moved from inside the sharing to outside, and we will
   189  		// have an event for that (path is updated by the VFS).
   190  		if evt.OldDoc.Get("path") == evt.Doc.Get("path") {
   191  			olds := extractReferencedBy(evt.OldDoc)
   192  			news := extractReferencedBy(&evt.Doc)
   193  			if vfs.SameReferences(olds, news) {
   194  				return false, nil
   195  			}
   196  		}
   197  	}
   198  
   199  	s, err := FindSharing(inst, msg.SharingID)
   200  	if err != nil {
   201  		return false, err
   202  	}
   203  	rule := s.Rules[msg.RuleIndex]
   204  	if rule.Selector == couchdb.SelectorReferencedBy {
   205  		refs := extractReferencedBy(&evt.Doc)
   206  		for _, ref := range refs {
   207  			if rule.hasReferencedBy(ref) {
   208  				return false, nil
   209  			}
   210  		}
   211  		return true, nil
   212  	}
   213  	if rule.Selector == "" || rule.Selector == "id" {
   214  		docID := evt.Doc.ID()
   215  		for _, id := range rule.Values {
   216  			if id == docID {
   217  				return false, nil
   218  			}
   219  		}
   220  	}
   221  
   222  	var docPath string
   223  	if evt.Doc.Get("type") == consts.FileType {
   224  		dirID, ok := evt.Doc.Get("dir_id").(string)
   225  		if !ok {
   226  			return false, ErrInternalServerError
   227  		}
   228  		var parent *vfs.DirDoc
   229  		parent, err = inst.VFS().DirByID(dirID)
   230  		if err != nil {
   231  			return false, err
   232  		}
   233  		docPath = parent.Fullpath
   234  	} else {
   235  		p, ok := evt.Doc.Get("path").(string)
   236  		if !ok {
   237  			return false, ErrInternalServerError
   238  		}
   239  		docPath = p
   240  	}
   241  	sharingDir, err := inst.VFS().DirByID(rule.Values[0])
   242  	if err != nil {
   243  		return false, err
   244  	}
   245  	return !strings.HasPrefix(docPath+"/", sharingDir.Fullpath+"/"), nil
   246  }
   247  
   248  // isTheSharingDirectory returns true if the event was for the directory that
   249  // is the root of the sharing: we don't want to track it in io.cozy.shared.
   250  func isTheSharingDirectory(inst *instance.Instance, msg TrackMessage, evt TrackEvent) (bool, error) {
   251  	if evt.Doc.Type != consts.Files || evt.Doc.Get("type") != consts.DirType {
   252  		return false, nil
   253  	}
   254  	s, err := FindSharing(inst, msg.SharingID)
   255  	if err != nil {
   256  		return false, err
   257  	}
   258  	rule := s.Rules[msg.RuleIndex]
   259  	if rule.Selector == couchdb.SelectorReferencedBy {
   260  		return false, nil
   261  	}
   262  	id := evt.Doc.ID()
   263  	for _, val := range rule.Values {
   264  		if val == id {
   265  			return true, nil
   266  		}
   267  	}
   268  	return false, nil
   269  }
   270  
   271  // updateRemovedForFiles updates the removed flag for files inside a directory
   272  // that was moved.
   273  func updateRemovedForFiles(inst *instance.Instance, sharingID, dirID string, rule int, removed bool) error {
   274  	dir := &vfs.DirDoc{DocID: dirID}
   275  	cursor := couchdb.NewSkipCursor(100, 0)
   276  	var docs []interface{}
   277  	for cursor.HasMore() {
   278  		children, err := inst.VFS().DirBatch(dir, cursor)
   279  		if err != nil {
   280  			return err
   281  		}
   282  		for _, child := range children {
   283  			_, file := child.Refine()
   284  			if file == nil {
   285  				continue
   286  			}
   287  			sid := consts.Files + "/" + file.ID()
   288  			var ref SharedRef
   289  			if err := couchdb.GetDoc(inst, consts.Shared, sid, &ref); err != nil {
   290  				if !couchdb.IsNotFoundError(err) {
   291  					return err
   292  				}
   293  				ref.SID = sid
   294  				ref.Infos = make(map[string]SharedInfo)
   295  			}
   296  			ref.Infos[sharingID] = SharedInfo{
   297  				Rule:    rule,
   298  				Removed: removed,
   299  				Binary:  !removed,
   300  			}
   301  			rev := file.Rev()
   302  			if ref.Rev() == "" {
   303  				ref.Revisions = &RevsTree{Rev: rev}
   304  			} else if !removed {
   305  				chain, err := addMissingRevsToChain(inst, &ref, []string{rev})
   306  				if err == nil {
   307  					ref.Revisions.InsertChain(chain)
   308  				}
   309  			}
   310  			docs = append(docs, ref)
   311  		}
   312  	}
   313  	if len(docs) == 0 {
   314  		return nil
   315  	}
   316  	olds := make([]interface{}, len(docs))
   317  	return couchdb.BulkUpdateDocs(inst, consts.Shared, docs, olds)
   318  }
   319  
   320  // UpdateShared updates the io.cozy.shared database when a document is
   321  // created/update/removed
   322  func UpdateShared(inst *instance.Instance, msg TrackMessage, evt TrackEvent) error {
   323  	evt.Doc.Type = msg.DocType
   324  	sid := evt.Doc.Type + "/" + evt.Doc.ID()
   325  
   326  	mu := config.Lock().ReadWrite(inst, "shared/"+sid)
   327  	if err := mu.Lock(); err != nil {
   328  		return err
   329  	}
   330  	defer mu.Unlock()
   331  
   332  	var ref SharedRef
   333  	if err := couchdb.GetDoc(inst, consts.Shared, sid, &ref); err != nil {
   334  		if !couchdb.IsNotFoundError(err) {
   335  			return err
   336  		}
   337  		ref.SID = sid
   338  		ref.Infos = make(map[string]SharedInfo)
   339  	}
   340  
   341  	rev := evt.Doc.Rev()
   342  	_, wasTracked := ref.Infos[msg.SharingID]
   343  
   344  	// If a document was in a sharing, was removed of the sharing, and comes
   345  	// back inside it, we need to clear the Removed flag.
   346  	needToUpdateFiles := false
   347  	removed := false
   348  	wasRemoved := true
   349  	ruleIndex := msg.RuleIndex
   350  	if rule, ok := ref.Infos[msg.SharingID]; ok {
   351  		wasRemoved = rule.Removed
   352  		ruleIndex = ref.Infos[msg.SharingID].Rule
   353  	}
   354  	ref.Infos[msg.SharingID] = SharedInfo{
   355  		Rule:    ruleIndex,
   356  		Binary:  evt.Doc.Type == consts.Files && evt.Doc.Get("type") == consts.FileType,
   357  		Removed: false,
   358  	}
   359  
   360  	if evt.Verb == "DELETED" || isTrashed(evt.Doc) {
   361  		// Do not create a shared doc for a deleted document: it's useless and
   362  		// it can have some side effects!
   363  		if ref.Rev() == "" {
   364  			return nil
   365  		}
   366  		if wasRemoved {
   367  			return nil
   368  		}
   369  		ref.Infos[msg.SharingID] = SharedInfo{
   370  			Rule:    ruleIndex,
   371  			Removed: true,
   372  			Binary:  false,
   373  		}
   374  	} else {
   375  		if skip, err := isTheSharingDirectory(inst, msg, evt); err != nil || skip {
   376  			return err
   377  		}
   378  		var err error
   379  		removed, err = isNoLongerShared(inst, msg, evt)
   380  		if err != nil {
   381  			return err
   382  		}
   383  		if removed {
   384  			if ref.Rev() == "" {
   385  				return nil
   386  			}
   387  			ref.Infos[msg.SharingID] = SharedInfo{
   388  				Rule:    ruleIndex,
   389  				Removed: true,
   390  				Binary:  false,
   391  			}
   392  		}
   393  		if evt.Doc.Type == consts.Files && evt.Doc.Get("type") == consts.DirType {
   394  			needToUpdateFiles = removed != wasRemoved
   395  		}
   396  	}
   397  
   398  	// XXX this optimization only works for files
   399  	if wasTracked && msg.DocType == consts.Files && !removed {
   400  		if sub, _ := ref.Revisions.Find(rev); sub != nil {
   401  			return nil
   402  		}
   403  	}
   404  
   405  	if ref.Rev() == "" {
   406  		ref.Revisions = &RevsTree{Rev: rev}
   407  		if err := couchdb.CreateNamedDoc(inst, &ref); err != nil {
   408  			return err
   409  		}
   410  	} else {
   411  		if evt.OldDoc == nil {
   412  			inst.Logger().WithNamespace("sharing").
   413  				Infof("Updating an io.cozy.shared with no previous revision: %v %v", evt, ref)
   414  			if subtree, _ := ref.Revisions.Find(rev); subtree == nil {
   415  				ref.Revisions.Add(rev)
   416  			}
   417  		} else {
   418  			chain, err := addMissingRevsToChain(inst, &ref, []string{rev})
   419  			if err != nil {
   420  				return err
   421  			}
   422  			ref.Revisions.InsertChain(chain)
   423  		}
   424  		if err := couchdb.UpdateDoc(inst, &ref); err != nil {
   425  			return err
   426  		}
   427  	}
   428  
   429  	// For a directory, we have to update the Removed flag for the files inside
   430  	// it, as we won't have any events for them.
   431  	if needToUpdateFiles {
   432  		err := updateRemovedForFiles(inst, msg.SharingID, evt.Doc.ID(), ruleIndex, removed)
   433  		if err != nil {
   434  			inst.Logger().WithNamespace("sharing").
   435  				Warnf("Error on updateRemovedForFiles for %v: %s", evt, err)
   436  		}
   437  	}
   438  
   439  	return nil
   440  }
   441  
   442  // UpdateFileShared creates or updates the io.cozy.shared for a file with
   443  // possibly multiple revisions.
   444  func UpdateFileShared(db prefixer.Prefixer, ref *SharedRef, revs RevsStruct) error {
   445  	chain := revsStructToChain(revs)
   446  	if ref.Rev() == "" {
   447  		ref.Revisions = &RevsTree{Rev: chain[0]}
   448  		ref.Revisions.InsertChain(chain)
   449  		return couchdb.CreateNamedDoc(db, ref)
   450  	}
   451  	chain, err := addMissingRevsToChain(db, ref, chain)
   452  	if err != nil {
   453  		return err
   454  	}
   455  	ref.Revisions.InsertChain(chain)
   456  	return couchdb.UpdateDoc(db, ref)
   457  }
   458  
   459  // RemoveSharedRefs deletes the references containing the sharingid
   460  func RemoveSharedRefs(inst *instance.Instance, sharingID string) error {
   461  	// We can have CouchDB conflicts if another instance is synchronizing files
   462  	// to this instance
   463  	maxRetries := 5
   464  	var err error
   465  	for i := 0; i < maxRetries; i++ {
   466  		err = doRemoveSharedRefs(inst, sharingID)
   467  		if !couchdb.IsConflictError(err) {
   468  			return err
   469  		}
   470  	}
   471  	return err
   472  }
   473  
   474  func doRemoveSharedRefs(inst *instance.Instance, sharingID string) error {
   475  	req := &couchdb.ViewRequest{
   476  		Key:         sharingID,
   477  		IncludeDocs: true,
   478  	}
   479  	var res couchdb.ViewResponse
   480  	err := couchdb.ExecView(inst, couchdb.SharedDocsBySharingID, req, &res)
   481  	if err != nil {
   482  		return err
   483  	}
   484  
   485  	for _, row := range res.Rows {
   486  		var doc SharedRef
   487  		if err = json.Unmarshal(row.Doc, &doc); err != nil {
   488  			return err
   489  		}
   490  		// Remove the ref if there are others sharings; remove the doc otherwise
   491  		if len(doc.Infos) > 1 {
   492  			delete(doc.Infos, sharingID)
   493  			if err = couchdb.UpdateDoc(inst, &doc); err != nil {
   494  				return err
   495  			}
   496  		} else {
   497  			if err = couchdb.DeleteDoc(inst, &doc); err != nil {
   498  				return err
   499  			}
   500  		}
   501  	}
   502  	return nil
   503  }
   504  
   505  // GetSharedDocsBySharingIDs returns a map associating each given sharingID
   506  // to a list of DocReference, which are the shared documents
   507  func GetSharedDocsBySharingIDs(inst *instance.Instance, sharingIDs []string) (map[string][]couchdb.DocReference, error) {
   508  	keys := make([]interface{}, len(sharingIDs))
   509  	for i, id := range sharingIDs {
   510  		keys[i] = id
   511  	}
   512  	req := &couchdb.ViewRequest{
   513  		Keys:        keys,
   514  		IncludeDocs: true,
   515  	}
   516  	var res couchdb.ViewResponse
   517  
   518  	err := couchdb.ExecView(inst, couchdb.SharedDocsBySharingID, req, &res)
   519  	if err != nil {
   520  		return nil, err
   521  	}
   522  	result := make(map[string][]couchdb.DocReference, len(res.Rows))
   523  
   524  	for _, row := range res.Rows {
   525  		var doc SharedRef
   526  		err := json.Unmarshal(row.Doc, &doc)
   527  		if err != nil {
   528  			return nil, err
   529  		}
   530  		sID := row.Key.(string)
   531  		// Filter out the removed docs
   532  		if !doc.Infos[sID].Removed {
   533  			docRef := extractDocReferenceFromID(doc.ID())
   534  			if docRef != nil {
   535  				result[sID] = append(result[sID], *docRef)
   536  			}
   537  		}
   538  	}
   539  	return result, nil
   540  }
   541  
   542  // extractDocReferenceFromID takes a string formatted as doctype/docid and
   543  // returns a DocReference
   544  func extractDocReferenceFromID(id string) *couchdb.DocReference {
   545  	var ref couchdb.DocReference
   546  	slice := strings.SplitN(id, "/", 2)
   547  	if len(slice) != 2 {
   548  		return nil
   549  	}
   550  	ref.ID = slice[1]
   551  	ref.Type = slice[0]
   552  	return &ref
   553  }
   554  
   555  func (s *Sharing) fixMissingShared(inst *instance.Instance, fileDoc *vfs.FileDoc) (SharedRef, error) {
   556  	var ref SharedRef
   557  	ref.SID = consts.Files + "/" + fileDoc.ID()
   558  	ref.Revisions = &RevsTree{Rev: fileDoc.Rev()}
   559  
   560  	rule, ruleIndex := s.findRuleForNewFile(fileDoc)
   561  	if rule == nil {
   562  		return ref, ErrSafety
   563  	}
   564  
   565  	sharingDir, err := s.GetSharingDir(inst)
   566  	if err != nil {
   567  		return ref, err
   568  	}
   569  	fs := inst.VFS()
   570  	pth, err := fileDoc.Path(fs)
   571  	if err != nil || !strings.HasPrefix(pth, sharingDir.Fullpath+"/") {
   572  		return ref, ErrSafety
   573  	}
   574  
   575  	ref.Infos = map[string]SharedInfo{
   576  		s.SID: {
   577  			Rule:    ruleIndex,
   578  			Binary:  true,
   579  			Removed: false,
   580  		},
   581  	}
   582  
   583  	err = couchdb.CreateNamedDoc(inst, &ref)
   584  	return ref, err
   585  }
   586  
   587  // CheckShared will scan all the io.cozy.shared documents and check their
   588  // revision tree for inconsistencies.
   589  func CheckShared(inst *instance.Instance) ([]*CheckSharedError, error) {
   590  	checks := []*CheckSharedError{}
   591  	err := couchdb.ForeachDocs(inst, consts.Shared, func(id string, data json.RawMessage) error {
   592  		s := &SharedRef{}
   593  		if err := json.Unmarshal(data, s); err != nil {
   594  			checks = append(checks, &CheckSharedError{Type: "invalid_json", ID: id})
   595  			return nil
   596  		}
   597  		if check := s.Revisions.check(); check != nil {
   598  			check.ID = s.SID
   599  			checks = append(checks, check)
   600  		}
   601  		return nil
   602  	})
   603  	return checks, err
   604  }
   605  
   606  var _ couchdb.Doc = &SharedRef{}
   607  var _ permission.Fetcher = &SharedRef{}