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

     1  package sharing
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"net/http"
    10  	"net/url"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/cozy/cozy-stack/client/request"
    15  	"github.com/cozy/cozy-stack/model/bitwarden/settings"
    16  	"github.com/cozy/cozy-stack/model/instance"
    17  	"github.com/cozy/cozy-stack/model/job"
    18  	"github.com/cozy/cozy-stack/pkg/config/config"
    19  	"github.com/cozy/cozy-stack/pkg/consts"
    20  	"github.com/cozy/cozy-stack/pkg/couchdb"
    21  	"github.com/cozy/cozy-stack/pkg/couchdb/revision"
    22  	"github.com/cozy/cozy-stack/pkg/realtime"
    23  	"github.com/cozy/cozy-stack/pkg/safehttp"
    24  	"github.com/labstack/echo/v4"
    25  	"golang.org/x/sync/errgroup"
    26  )
    27  
    28  // MaxRetries is the maximal number of retries for a replicator
    29  const MaxRetries = 5
    30  
    31  // InitialBackoffPeriod is the initial duration to wait for the first retry
    32  // (each next retry will wait 4 times longer than its previous retry)
    33  const InitialBackoffPeriod = 1 * time.Minute
    34  
    35  // BatchSize is the maximal number of documents manipulated at once by the
    36  // replicator
    37  const BatchSize = 400
    38  
    39  // ReplicateMsg is used for jobs on the share-replicate worker.
    40  type ReplicateMsg struct {
    41  	SharingID string `json:"sharing_id"`
    42  	Errors    int    `json:"errors"`
    43  }
    44  
    45  // Replicate starts a replicator on this sharing.
    46  func (s *Sharing) Replicate(inst *instance.Instance, errors int) error {
    47  	mu := config.Lock().ReadWrite(inst, "sharings/"+s.SID)
    48  	if err := mu.Lock(); err != nil {
    49  		return err
    50  	}
    51  	defer mu.Unlock()
    52  
    53  	pending := false
    54  	var err error
    55  	if !s.Owner {
    56  		pending, err = s.ReplicateTo(inst, &s.Members[0], false)
    57  	} else {
    58  		g, _ := errgroup.WithContext(context.Background())
    59  		for i := range s.Members {
    60  			if i == 0 {
    61  				continue
    62  			}
    63  			m := &s.Members[i]
    64  			g.Go(func() error {
    65  				if m.Status == MemberStatusReady {
    66  					p, err := s.ReplicateTo(inst, m, false)
    67  					if err != nil {
    68  						return err
    69  					}
    70  					if p {
    71  						pending = true
    72  					}
    73  				}
    74  				return nil
    75  			})
    76  		}
    77  		err = g.Wait()
    78  	}
    79  	if err != nil {
    80  		s.retryWorker(inst, "share-replicate", errors)
    81  	} else if pending {
    82  		s.pushJob(inst, "share-replicate")
    83  	}
    84  	return err
    85  }
    86  
    87  // pushJob adds a new job to continue on the pending documents in the changes feed
    88  func (s *Sharing) pushJob(inst *instance.Instance, worker string) {
    89  	inst.Logger().WithNamespace("replicator").
    90  		Debugf("Push a new job for worker %s for sharing %s", worker, s.SID)
    91  	msg, err := job.NewMessage(&ReplicateMsg{
    92  		SharingID: s.SID,
    93  		Errors:    0,
    94  	})
    95  	if err != nil {
    96  		inst.Logger().WithNamespace("replicator").
    97  			Warnf("Error on push job to %s: %s", worker, err)
    98  		return
    99  	}
   100  	_, err = job.System().PushJob(inst, &job.JobRequest{
   101  		WorkerType: worker,
   102  		Message:    msg,
   103  	})
   104  	if err != nil {
   105  		inst.Logger().WithNamespace("replicator").
   106  			Warnf("Error on push job to %s: %s", worker, err)
   107  		return
   108  	}
   109  }
   110  
   111  // retryWorker will add a job to retry a failed replication or upload
   112  func (s *Sharing) retryWorker(inst *instance.Instance, worker string, errors int) {
   113  	inst.Logger().WithNamespace("replicator").
   114  		Debugf("Retry worker %s for sharing %s", worker, s.SID)
   115  	backoff := InitialBackoffPeriod << uint(errors*2)
   116  	errors++
   117  	if errors == MaxRetries {
   118  		inst.Logger().WithNamespace("replicator").Warnf("Max retries reached")
   119  		return
   120  	}
   121  	msg, err := job.NewMessage(&ReplicateMsg{
   122  		SharingID: s.SID,
   123  		Errors:    errors,
   124  	})
   125  	if err != nil {
   126  		inst.Logger().WithNamespace("replicator").
   127  			Warnf("Error on retry to %s: %s", worker, err)
   128  		return
   129  	}
   130  	t, err := job.NewTrigger(inst, job.TriggerInfos{
   131  		Type:       "@in",
   132  		WorkerType: worker,
   133  		Arguments:  backoff.String(),
   134  	}, msg)
   135  	if err != nil {
   136  		inst.Logger().WithNamespace("replicator").
   137  			Warnf("Error on retry to %s: %s", worker, err)
   138  		return
   139  	}
   140  	if err = job.System().AddTrigger(t); err != nil {
   141  		inst.Logger().WithNamespace("replicator").
   142  			Warnf("Error on retry to %s: %s", worker, err)
   143  	}
   144  }
   145  
   146  func (s *Sharing) InitialReplication(inst *instance.Instance, m *Member) error {
   147  	for i := 0; i < 1000; i++ {
   148  		pending, err := s.ReplicateTo(inst, m, true)
   149  		if err != nil {
   150  			return err
   151  		}
   152  		if !pending {
   153  			return nil
   154  		}
   155  	}
   156  	return ErrInternalServerError
   157  }
   158  
   159  // ReplicateTo starts a replicator on this sharing to the given member.
   160  // http://docs.couchdb.org/en/stable/replication/protocol.html
   161  // https://github.com/pouchdb/pouchdb/blob/master/packages/node_modules/pouchdb-replication/src/replicate.js
   162  func (s *Sharing) ReplicateTo(inst *instance.Instance, m *Member, initial bool) (bool, error) {
   163  	if m.Instance == "" {
   164  		return false, ErrInvalidURL
   165  	}
   166  	creds := s.FindCredentials(m)
   167  	if creds == nil {
   168  		return false, ErrInvalidSharing
   169  	}
   170  
   171  	lastSeq, err := s.getLastSeqNumber(inst, m, "replicator")
   172  	if err != nil {
   173  		return false, err
   174  	}
   175  	inst.Logger().WithNamespace("replicator").Debugf("lastSeq = %s", lastSeq)
   176  
   177  	feed, err := s.callChangesFeed(inst, lastSeq)
   178  	if err != nil {
   179  		if errors.Is(err, errRevokeSharing) {
   180  			if s.Owner {
   181  				return false, s.Revoke(inst)
   182  			} else {
   183  				return false, s.RevokeRecipientBySelf(inst, false)
   184  			}
   185  		}
   186  		return false, err
   187  	}
   188  	if feed.Seq == lastSeq {
   189  		return false, nil
   190  	}
   191  	inst.Logger().WithNamespace("replicator").Debugf("changes = %#v", feed.Changes)
   192  
   193  	changes := &feed.Changes
   194  	if len(changes.Changed) > 0 {
   195  		var missings *Missings
   196  		if initial || len(changes.Changed) == len(changes.Removed) {
   197  			missings = transformChangesInMissings(changes)
   198  		} else {
   199  			missings, err = s.callRevsDiff(inst, m, creds, changes)
   200  			if err != nil {
   201  				return false, err
   202  			}
   203  		}
   204  		inst.Logger().WithNamespace("replicator").Debugf("missings = %#v", missings)
   205  
   206  		docs, errb := s.getMissingDocs(inst, missings, changes)
   207  		if errb != nil {
   208  			return false, errb
   209  		}
   210  		inst.Logger().WithNamespace("replicator").Debugf("docs = %#v", docs)
   211  
   212  		err = s.sendBulkDocs(inst, m, creds, docs, feed.RuleIndexes)
   213  		if err != nil {
   214  			return false, err
   215  		}
   216  	}
   217  
   218  	err = s.UpdateLastSequenceNumber(inst, m, "replicator", feed.Seq)
   219  	return feed.Pending, err
   220  }
   221  
   222  // getLastSeqNumber returns the last sequence number of the previous
   223  // replication to this member
   224  func (s *Sharing) getLastSeqNumber(inst *instance.Instance, m *Member, worker string) (string, error) {
   225  	id, err := s.replicationID(m)
   226  	if err != nil {
   227  		return "", err
   228  	}
   229  	result, err := couchdb.GetLocal(inst, consts.Shared, id+"/"+worker)
   230  	if couchdb.IsNotFoundError(err) {
   231  		return "", nil
   232  	}
   233  	if err != nil {
   234  		return "", err
   235  	}
   236  	seq, _ := result["last_seq"].(string)
   237  	return seq, nil
   238  }
   239  
   240  // UpdateLastSequenceNumber updates the last sequence number for this
   241  // replication if it's superior to the number in CouchDB
   242  func (s *Sharing) UpdateLastSequenceNumber(inst *instance.Instance, m *Member, worker, seq string) error {
   243  	id, err := s.replicationID(m)
   244  	if err != nil {
   245  		return err
   246  	}
   247  	result, err := couchdb.GetLocal(inst, consts.Shared, id+"/"+worker)
   248  	if err != nil {
   249  		if !couchdb.IsNotFoundError(err) {
   250  			return err
   251  		}
   252  		result = make(map[string]interface{})
   253  	} else {
   254  		if prev, ok := result["last_seq"].(string); ok {
   255  			if revision.Generation(seq) <= revision.Generation(prev) {
   256  				return nil
   257  			}
   258  		}
   259  	}
   260  	result["last_seq"] = seq
   261  	return couchdb.PutLocal(inst, consts.Shared, id+"/"+worker, result)
   262  }
   263  
   264  // ClearLastSequenceNumbers removes the last sequence numbers for a member
   265  func (s *Sharing) ClearLastSequenceNumbers(inst *instance.Instance, m *Member) error {
   266  	errr := s.clearLastSequenceNumber(inst, m, "replicator")
   267  	erru := s.clearLastSequenceNumber(inst, m, "upload")
   268  	if errr != nil {
   269  		return errr
   270  	}
   271  	return erru
   272  }
   273  
   274  // clearLastSequenceNumber removes a last sequence number for a member on a given worker
   275  func (s *Sharing) clearLastSequenceNumber(inst *instance.Instance, m *Member, worker string) error {
   276  	id, err := s.replicationID(m)
   277  	if err != nil {
   278  		return err
   279  	}
   280  	err = couchdb.DeleteLocal(inst, consts.Shared, id+"/"+worker)
   281  	if couchdb.IsNotFoundError(err) {
   282  		return nil
   283  	}
   284  	return err
   285  }
   286  
   287  // replicationID gives an identifier for this replicator
   288  func (s *Sharing) replicationID(m *Member) (string, error) {
   289  	for i := range s.Members {
   290  		if &s.Members[i] == m {
   291  			id := fmt.Sprintf("sharing-%s-%d", s.SID, i)
   292  			return id, nil
   293  		}
   294  	}
   295  	return "", ErrMemberNotFound
   296  }
   297  
   298  // Changed is a map of "doctype/docid" -> [revisions]
   299  type Changed map[string][]string
   300  
   301  // Removed is a set of "doctype/docid"
   302  type Removed map[string]struct{}
   303  
   304  // Changes is a struct with informations from the changes feed of io.cozy.shared
   305  type Changes struct {
   306  	Changed Changed
   307  	Removed Removed
   308  }
   309  
   310  func extractLastRevision(doc couchdb.JSONDoc) string {
   311  	var rev string
   312  	subtree := doc.Get("revisions")
   313  	for {
   314  		m, ok := subtree.(map[string]interface{})
   315  		if !ok {
   316  			break
   317  		}
   318  		rev = m["rev"].(string)
   319  		branches, ok := m["branches"].([]interface{})
   320  		if !ok || len(branches) == 0 {
   321  			break
   322  		}
   323  		subtree = branches[0]
   324  	}
   325  	return rev
   326  }
   327  
   328  func extractRevisionsSlice(doc couchdb.JSONDoc) []string {
   329  	slice := []string{}
   330  	subtree := doc.Get("revisions")
   331  	for {
   332  		m, ok := subtree.(map[string]interface{})
   333  		if !ok {
   334  			break
   335  		}
   336  		rev := m["rev"].(string)
   337  		slice = append(slice, rev)
   338  		branches, ok := m["branches"].([]interface{})
   339  		if !ok || len(branches) == 0 {
   340  			break
   341  		}
   342  		subtree = branches[0]
   343  	}
   344  	return slice
   345  }
   346  
   347  // changesResponse contains the useful informations from a call to the changes
   348  // feed in the replicator context
   349  type changesResponse struct {
   350  	// Changes is the list of changed documents
   351  	Changes Changes
   352  	// RuleIndexes is a mapping between the "doctype/docid" -> rule index
   353  	RuleIndexes map[string]int
   354  	// Seq is the sequence number after these changes
   355  	Seq string
   356  	// Pending is true if there are some other changes in the feed after those
   357  	Pending bool
   358  }
   359  
   360  // errRevokeSharing is a sentinel value that can be returned by callChangesFeed.
   361  // It means that a document fetched from the changes that has been removed, and
   362  // the sharing rule for it has the revoke action. So, the caller should check
   363  // for this error, and it is the case, it should revoke the sharing.
   364  var errRevokeSharing = errors.New("Sharing must be revoked")
   365  
   366  // callChangesFeed fetches the last changes from the changes feed
   367  // http://docs.couchdb.org/en/stable/api/database/changes.html
   368  func (s *Sharing) callChangesFeed(inst *instance.Instance, since string) (*changesResponse, error) {
   369  	response, err := couchdb.GetChanges(inst, &couchdb.ChangesRequest{
   370  		DocType:     consts.Shared,
   371  		IncludeDocs: true,
   372  		Since:       since,
   373  		Limit:       BatchSize,
   374  	})
   375  	if err != nil {
   376  		return nil, err
   377  	}
   378  	res := changesResponse{
   379  		Changes: Changes{
   380  			Changed: make(Changed),
   381  			Removed: make(Removed),
   382  		},
   383  		RuleIndexes: make(map[string]int),
   384  		Seq:         response.LastSeq,
   385  		Pending:     response.Pending > 0,
   386  	}
   387  	for _, r := range response.Results {
   388  		infos, ok := r.Doc.Get("infos").(map[string]interface{})
   389  		if !ok {
   390  			continue
   391  		}
   392  		info, ok := infos[s.SID].(map[string]interface{})
   393  		if !ok {
   394  			continue
   395  		}
   396  		idx, ok := info["rule"].(float64)
   397  		if !ok {
   398  			continue
   399  		}
   400  		res.RuleIndexes[r.DocID] = int(idx)
   401  		if _, ok = info["removed"]; ok {
   402  			rule := s.Rules[int(idx)]
   403  			if rule.Remove == ActionRuleRevoke {
   404  				return nil, errRevokeSharing
   405  			}
   406  			res.Changes.Removed[r.DocID] = struct{}{}
   407  		}
   408  		if strings.HasPrefix(r.DocID, consts.Files+"/") {
   409  			if rev := extractLastRevision(r.Doc); rev != "" {
   410  				res.Changes.Changed[r.DocID] = []string{rev}
   411  			}
   412  		} else {
   413  			res.Changes.Changed[r.DocID] = extractRevisionsSlice(r.Doc)
   414  		}
   415  	}
   416  	return &res, nil
   417  }
   418  
   419  // Missings is a struct for the response of _revs_diff
   420  type Missings map[string]MissingEntry
   421  
   422  // MissingEntry is a struct with the missing revisions for an id
   423  type MissingEntry struct {
   424  	Missing []string `json:"missing"`
   425  }
   426  
   427  // transformChangesInMissings is used for the initial replication (revs_diff is
   428  // not called), to prepare the payload for the bulk_get calls
   429  func transformChangesInMissings(changes *Changes) *Missings {
   430  	missings := make(Missings, len(changes.Changed))
   431  	for key, revs := range changes.Changed {
   432  		missings[key] = MissingEntry{
   433  			Missing: []string{revs[len(revs)-1]},
   434  		}
   435  	}
   436  	return &missings
   437  }
   438  
   439  // callRevsDiff asks the other cozy to compute the _revs_diff.
   440  // This function does the ID transformation for files in both ways.
   441  // http://docs.couchdb.org/en/stable/api/database/misc.html#db-revs-diff
   442  func (s *Sharing) callRevsDiff(inst *instance.Instance, m *Member, creds *Credentials, changes *Changes) (*Missings, error) {
   443  	u, err := url.Parse(m.Instance)
   444  	if err != nil {
   445  		return nil, err
   446  	}
   447  	// "io.cozy.files/docid" for recipient -> "io.cozy.files/docid" for sender
   448  	xored := make(map[string]string)
   449  	// "doctype/docid" -> [leaf revisions]
   450  	leafRevs := make(Changed, len(changes.Changed))
   451  	for key, revs := range changes.Changed {
   452  		if len(creds.XorKey) > 0 {
   453  			old := key
   454  			parts := strings.SplitN(key, "/", 2)
   455  			parts[1] = XorID(parts[1], creds.XorKey)
   456  			key = parts[0] + "/" + parts[1]
   457  			xored[key] = old
   458  			leafRevs[key] = revs[len(revs)-1:]
   459  		} else {
   460  			leafRevs[key] = revs
   461  		}
   462  	}
   463  	body, err := json.Marshal(leafRevs)
   464  	if err != nil {
   465  		return nil, err
   466  	}
   467  	opts := &request.Options{
   468  		Method: http.MethodPost,
   469  		Scheme: u.Scheme,
   470  		Domain: u.Host,
   471  		Path:   "/sharings/" + s.SID + "/_revs_diff",
   472  		Headers: request.Headers{
   473  			echo.HeaderAccept:        echo.MIMEApplicationJSON,
   474  			echo.HeaderContentType:   echo.MIMEApplicationJSON,
   475  			echo.HeaderAuthorization: "Bearer " + creds.AccessToken.AccessToken,
   476  		},
   477  		Body:       bytes.NewReader(body),
   478  		ParseError: ParseRequestError,
   479  	}
   480  	var res *http.Response
   481  	res, err = request.Req(opts)
   482  	if res != nil && res.StatusCode/100 == 4 {
   483  		res, err = RefreshToken(inst, err, s, m, creds, opts, body)
   484  	}
   485  	if err != nil {
   486  		if res != nil && res.StatusCode/100 == 5 {
   487  			return nil, ErrInternalServerError
   488  		}
   489  		return nil, err
   490  	}
   491  	defer res.Body.Close()
   492  
   493  	missings := make(Missings)
   494  	if err = json.NewDecoder(res.Body).Decode(&missings); err != nil {
   495  		return nil, err
   496  	}
   497  	for k, v := range missings {
   498  		if old, ok := xored[k]; ok {
   499  			missings[old] = v
   500  			delete(missings, k)
   501  		}
   502  	}
   503  	return &missings, nil
   504  }
   505  
   506  // ComputeRevsDiff takes a map of id->[revisions] and returns the missing
   507  // revisions for those documents on the current instance.
   508  func (s *Sharing) ComputeRevsDiff(inst *instance.Instance, changed Changed) (*Missings, error) {
   509  	inst.Logger().WithNamespace("replicator").
   510  		Debugf("ComputeRevsDiff %#v", changed)
   511  	ids := make([]string, 0, len(changed))
   512  	for id := range changed {
   513  		ids = append(ids, id)
   514  	}
   515  	results := make([]SharedRef, 0, len(changed))
   516  	req := couchdb.AllDocsRequest{Keys: ids}
   517  	err := couchdb.GetAllDocs(inst, consts.Shared, &req, &results)
   518  	if err != nil {
   519  		return nil, err
   520  	}
   521  	missings := make(Missings)
   522  	for id, revs := range changed {
   523  		missings[id] = MissingEntry{Missing: revs}
   524  	}
   525  	for _, result := range results {
   526  		if _, ok := changed[result.SID]; !ok {
   527  			continue
   528  		}
   529  		if _, ok := result.Infos[s.SID]; !ok {
   530  			continue
   531  		}
   532  		notFounds := changed[result.SID][:0]
   533  		for _, r := range changed[result.SID] {
   534  			if sub, _ := result.Revisions.Find(r); sub == nil {
   535  				notFounds = append(notFounds, r)
   536  			}
   537  		}
   538  		if len(notFounds) == 0 {
   539  			delete(missings, result.SID)
   540  		} else {
   541  			missings[result.SID] = MissingEntry{
   542  				Missing: notFounds,
   543  			}
   544  		}
   545  	}
   546  	return &missings, nil
   547  }
   548  
   549  // DocsList is a slice of raw documents
   550  type DocsList []map[string]interface{}
   551  
   552  // DocsByDoctype is a map of doctype -> slice of documents of this doctype
   553  type DocsByDoctype map[string]DocsList
   554  
   555  // getMissingDocs fetches the documents in bulk, partitionned by their doctype.
   556  // https://github.com/apache/couchdb-documentation/pull/263/files
   557  func (s *Sharing) getMissingDocs(inst *instance.Instance, missings *Missings, changes *Changes) (*DocsByDoctype, error) {
   558  	docs := make(DocsByDoctype)
   559  	queries := make(map[string][]couchdb.IDRev) // doctype -> payload for _bulk_get
   560  	for key, missing := range *missings {
   561  		parts := strings.SplitN(key, "/", 2)
   562  		if len(parts) != 2 {
   563  			return nil, ErrInternalServerError
   564  		}
   565  		doctype := parts[0]
   566  		if _, ok := changes.Removed[key]; ok {
   567  			revisions := changes.Changed[key]
   568  			docs[doctype] = append(docs[doctype], map[string]interface{}{
   569  				"_id":        parts[1],
   570  				"_rev":       revisions[len(revisions)-1],
   571  				"_revisions": revsChainToStruct(revisions),
   572  				"_deleted":   true,
   573  			})
   574  			continue
   575  		}
   576  		for _, rev := range missing.Missing {
   577  			ir := couchdb.IDRev{ID: parts[1], Rev: rev}
   578  			queries[doctype] = append(queries[doctype], ir)
   579  		}
   580  	}
   581  
   582  	for doctype, query := range queries {
   583  		results, err := couchdb.BulkGetDocs(inst, doctype, query)
   584  		if err != nil {
   585  			return nil, err
   586  		}
   587  		docs[doctype] = append(docs[doctype], results...)
   588  	}
   589  	return &docs, nil
   590  }
   591  
   592  // sendBulkDocs takes a bulk of documents and send them to the other cozy.
   593  // This function does the id and dir_id transformation before sending files.
   594  // http://docs.couchdb.org/en/stable/api/database/bulk-api.html#db-bulk-docs
   595  // https://wiki.apache.org/couchdb/HTTP_Bulk_Document_API#Posting_Existing_Revisions
   596  // https://gist.github.com/nono/42aee18de6314a621f9126f284e303bb
   597  func (s *Sharing) sendBulkDocs(inst *instance.Instance, m *Member, creds *Credentials, docsByDoctype *DocsByDoctype, ruleIndexes map[string]int) error {
   598  	u, err := url.Parse(m.Instance)
   599  	if err != nil {
   600  		return err
   601  	}
   602  	for doctype, docs := range *docsByDoctype {
   603  		switch doctype {
   604  		case consts.Files:
   605  			s.SortFilesToSent(docs)
   606  			for i, file := range docs {
   607  				fileID := file["_id"].(string)
   608  				s.TransformFileToSent(file, creds.XorKey, ruleIndexes[fileID])
   609  				docs[i] = file
   610  			}
   611  		case consts.BitwardenCiphers:
   612  			for i, doc := range docs {
   613  				s.transformCipherToSent(doc, creds.XorKey)
   614  				docs[i] = doc
   615  			}
   616  		default:
   617  			for i, doc := range docs {
   618  				id := doc["_id"].(string)
   619  				doc["_id"] = XorID(id, creds.XorKey)
   620  				docs[i] = doc
   621  			}
   622  		}
   623  		(*docsByDoctype)[doctype] = docs
   624  	}
   625  	body, err := json.Marshal(docsByDoctype)
   626  	if err != nil {
   627  		return err
   628  	}
   629  	opts := &request.Options{
   630  		Method: http.MethodPost,
   631  		Scheme: u.Scheme,
   632  		Domain: u.Host,
   633  		Path:   "/sharings/" + s.SID + "/_bulk_docs",
   634  		Headers: request.Headers{
   635  			echo.HeaderAccept:        echo.MIMEApplicationJSON,
   636  			echo.HeaderContentType:   echo.MIMEApplicationJSON,
   637  			echo.HeaderAuthorization: "Bearer " + creds.AccessToken.AccessToken,
   638  		},
   639  		Body:       bytes.NewReader(body),
   640  		ParseError: ParseRequestError,
   641  		Client:     safehttp.ClientWithKeepAlive,
   642  	}
   643  	res, err := request.Req(opts)
   644  	if res != nil && res.StatusCode/100 == 4 {
   645  		res, err = RefreshToken(inst, err, s, m, creds, opts, body)
   646  	}
   647  	if err != nil {
   648  		if res != nil && res.StatusCode/100 == 5 {
   649  			return ErrInternalServerError
   650  		}
   651  		return err
   652  	}
   653  	res.Body.Close()
   654  	return nil
   655  }
   656  
   657  // ApplyBulkDocs is a multi-doctypes version of the POST _bulk_docs endpoint of CouchDB
   658  func (s *Sharing) ApplyBulkDocs(inst *instance.Instance, payload DocsByDoctype) error {
   659  	mu := config.Lock().ReadWrite(inst, "sharings/"+s.SID+"/_bulk_docs")
   660  	if err := mu.Lock(); err != nil {
   661  		return err
   662  	}
   663  	defer mu.Unlock()
   664  
   665  	var refs []*SharedRef
   666  
   667  	for doctype, docs := range payload {
   668  		inst.Logger().WithNamespace("replicator").
   669  			Debugf("Apply bulk docs %s: %#v", doctype, docs)
   670  		if doctype == consts.Files {
   671  			err := s.ApplyBulkFiles(inst, docs)
   672  			if err != nil {
   673  				return err
   674  			}
   675  			continue
   676  		}
   677  		var okDocs, docsToUpdate DocsList
   678  		var newRefs, existingRefs []*SharedRef
   679  		newDocs, existingDocs, err := partitionDocsPayload(inst, doctype, docs)
   680  		if err == nil {
   681  			okDocs, newRefs = s.filterDocsToAdd(inst, doctype, newDocs)
   682  			docsToUpdate, existingRefs, err = s.filterDocsToUpdate(inst, doctype, existingDocs)
   683  			if err != nil {
   684  				return err
   685  			}
   686  			okDocs = append(okDocs, docsToUpdate...)
   687  		} else {
   688  			okDocs, newRefs = s.filterDocsToAdd(inst, doctype, docs)
   689  			if len(okDocs) > 0 {
   690  				if err = couchdb.CreateDB(inst, doctype); err != nil {
   691  					return err
   692  				}
   693  			}
   694  		}
   695  		if len(okDocs) > 0 {
   696  			if err = couchdb.BulkForceUpdateDocs(inst, doctype, okDocs); err != nil {
   697  				return err
   698  			}
   699  			for _, doc := range okDocs {
   700  				d := couchdb.JSONDoc{M: doc, Type: doctype}
   701  				event := realtime.EventUpdate
   702  				if doc["_deleted"] != nil {
   703  					event = realtime.EventDelete
   704  				}
   705  				couchdb.RTEvent(inst, event, &d, nil)
   706  			}
   707  			refs = append(refs, newRefs...)
   708  			refs = append(refs, existingRefs...)
   709  		}
   710  
   711  		// XXX the bitwarden clients synchronize the ciphers only if the
   712  		// revision date from GET /bitwarden/api/accounts/revision-date has
   713  		// changed. So, we update it here!
   714  		if doctype == consts.BitwardenCiphers {
   715  			if setting, err := settings.Get(inst); err == nil {
   716  				_ = settings.UpdateRevisionDate(inst, setting)
   717  			}
   718  		}
   719  	}
   720  
   721  	refsToUpdate := make([]interface{}, len(refs))
   722  	for i, ref := range refs {
   723  		refsToUpdate[i] = ref
   724  	}
   725  	olds := make([]interface{}, len(refsToUpdate))
   726  	return couchdb.BulkUpdateDocs(inst, consts.Shared, refsToUpdate, olds)
   727  }
   728  
   729  // partitionDocsPayload returns two slices: the first with documents that are new,
   730  // the second with documents that already exist on this cozy and must be updated.
   731  func partitionDocsPayload(inst *instance.Instance, doctype string, docs DocsList) (news DocsList, existings DocsList, err error) {
   732  	ids := make([]string, len(docs))
   733  	for i, doc := range docs {
   734  		_, ok := doc["_rev"].(string)
   735  		if !ok {
   736  			return nil, nil, ErrMissingRev
   737  		}
   738  		ids[i], ok = doc["_id"].(string)
   739  		if !ok {
   740  			return nil, nil, ErrMissingID
   741  		}
   742  	}
   743  	results := make([]interface{}, 0, len(docs))
   744  	req := couchdb.AllDocsRequest{Keys: ids}
   745  	if err = couchdb.GetAllDocs(inst, doctype, &req, &results); err != nil {
   746  		return nil, nil, err
   747  	}
   748  	for i, doc := range docs {
   749  		if results[i] == nil {
   750  			news = append(news, doc)
   751  		} else {
   752  			existings = append(existings, doc)
   753  		}
   754  	}
   755  	return news, existings, nil
   756  }
   757  
   758  // filterDocsToAdd returns a subset of the docs slice with just the documents
   759  // that match a rule of the sharing. It also returns a reference documents to
   760  // put in the io.cozy.shared database.
   761  // https://github.com/golang/go/wiki/SliceTricks#filtering-without-allocating
   762  func (s *Sharing) filterDocsToAdd(inst *instance.Instance, doctype string, docs DocsList) (DocsList, []*SharedRef) {
   763  	filtered := docs[:0]
   764  	refs := make([]*SharedRef, 0, len(docs))
   765  	for _, doc := range docs {
   766  		if _, ok := doc["_deleted"]; ok {
   767  			continue
   768  		}
   769  		r := -1
   770  		for i, rule := range s.Rules {
   771  			if rule.Accept(doctype, doc) {
   772  				r = i
   773  				break
   774  			}
   775  		}
   776  		if r >= 0 {
   777  			ref := SharedRef{
   778  				SID:       doctype + "/" + doc["_id"].(string),
   779  				Revisions: &RevsTree{Rev: doc["_rev"].(string)},
   780  				Infos: map[string]SharedInfo{
   781  					s.SID: {Rule: r},
   782  				},
   783  			}
   784  			refs = append(refs, &ref)
   785  			filtered = append(filtered, doc)
   786  		}
   787  	}
   788  	return filtered, refs
   789  }
   790  
   791  // filterDocsToUpdate returns a subset of the docs slice with just the documents
   792  // that are referenced for this sharing in the io.cozy.shared database.
   793  func (s *Sharing) filterDocsToUpdate(inst *instance.Instance, doctype string, docs DocsList) (DocsList, []*SharedRef, error) {
   794  	ids := make([]string, len(docs))
   795  	for i, doc := range docs {
   796  		id, ok := doc["_id"].(string)
   797  		if !ok {
   798  			return nil, nil, ErrMissingID
   799  		}
   800  		ids[i] = doctype + "/" + id
   801  	}
   802  	refs, err := FindReferences(inst, ids)
   803  	if err != nil {
   804  		return nil, nil, err
   805  	}
   806  
   807  	filtered := docs[:0]
   808  	frefs := refs[:0]
   809  	for i, doc := range docs {
   810  		if refs[i] != nil {
   811  			infos, ok := refs[i].Infos[s.SID]
   812  			if ok && !infos.Removed {
   813  				rev := doc["_rev"].(string)
   814  				if sub, _ := refs[i].Revisions.Find(rev); sub == nil {
   815  					revs := revsMapToStruct(doc["_revisions"])
   816  					if revs != nil && len(revs.IDs) > 0 {
   817  						chain := revsStructToChain(*revs)
   818  						refs[i].Revisions.InsertChain(chain)
   819  					}
   820  				}
   821  				if _, ok := doc["_deleted"]; ok {
   822  					infos.Removed = true
   823  					refs[i].Infos[s.SID] = infos
   824  				}
   825  				frefs = append(frefs, refs[i])
   826  				filtered = append(filtered, doc)
   827  			}
   828  		}
   829  	}
   830  
   831  	return filtered, frefs, nil
   832  }
   833  
   834  func (s *Sharing) transformCipherToSent(doc map[string]interface{}, xorKey []byte) {
   835  	id := doc["_id"].(string)
   836  	doc["_id"] = XorID(id, xorKey)
   837  	if orgID, ok := doc["organization_id"].(string); ok {
   838  		doc["organization_id"] = XorID(orgID, xorKey)
   839  	}
   840  }