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{}