github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/move/doc.go (about) 1 package move 2 3 import ( 4 "bytes" 5 "encoding/base64" 6 "encoding/json" 7 "fmt" 8 "math" 9 "net/http" 10 "time" 11 12 "github.com/cozy/cozy-stack/model/bitwarden/settings" 13 "github.com/cozy/cozy-stack/model/instance" 14 "github.com/cozy/cozy-stack/model/job" 15 csettings "github.com/cozy/cozy-stack/model/settings" 16 "github.com/cozy/cozy-stack/pkg/consts" 17 "github.com/cozy/cozy-stack/pkg/couchdb" 18 "github.com/cozy/cozy-stack/pkg/couchdb/mango" 19 "github.com/cozy/cozy-stack/pkg/crypto" 20 "github.com/cozy/cozy-stack/pkg/jsonapi" 21 "github.com/cozy/cozy-stack/pkg/mail" 22 "github.com/cozy/cozy-stack/pkg/prefixer" 23 "github.com/cozy/cozy-stack/pkg/realtime" 24 "github.com/cozy/cozy-stack/pkg/safehttp" 25 "github.com/labstack/echo/v4" 26 ) 27 28 const ( 29 // ExportStateExporting is the state used when the export document is being 30 // created. 31 ExportStateExporting = "exporting" 32 // ExportStateDone is used when the export document is finished, without 33 // error. 34 ExportStateDone = "done" 35 // ExportStateError is used when the export document is finshed with error. 36 ExportStateError = "error" 37 ) 38 39 // ExportDoc is a documents storing the metadata of an export. 40 type ExportDoc struct { 41 DocID string `json:"_id,omitempty"` 42 DocRev string `json:"_rev,omitempty"` 43 Domain string `json:"domain"` 44 PartsSize int64 `json:"parts_size,omitempty"` 45 46 PartsCursors []string `json:"parts_cursors"` 47 WithDoctypes []string `json:"with_doctypes,omitempty"` 48 State string `json:"state"` 49 CreatedAt time.Time `json:"created_at"` 50 ExpiresAt time.Time `json:"expires_at"` 51 TotalSize int64 `json:"total_size,omitempty"` 52 CreationDuration time.Duration `json:"creation_duration,omitempty"` 53 Error string `json:"error,omitempty"` 54 } 55 56 // DocType implements the couchdb.Doc interface 57 func (e *ExportDoc) DocType() string { return consts.Exports } 58 59 // ID implements the couchdb.Doc interface 60 func (e *ExportDoc) ID() string { return e.DocID } 61 62 // Rev implements the couchdb.Doc interface 63 func (e *ExportDoc) Rev() string { return e.DocRev } 64 65 // SetID implements the couchdb.Doc interface 66 func (e *ExportDoc) SetID(id string) { e.DocID = id } 67 68 // SetRev implements the couchdb.Doc interface 69 func (e *ExportDoc) SetRev(rev string) { e.DocRev = rev } 70 71 // Clone implements the couchdb.Doc interface 72 func (e *ExportDoc) Clone() couchdb.Doc { 73 clone := *e 74 75 clone.PartsCursors = make([]string, len(e.PartsCursors)) 76 copy(clone.PartsCursors, e.PartsCursors) 77 78 clone.WithDoctypes = make([]string, len(e.WithDoctypes)) 79 copy(clone.WithDoctypes, e.WithDoctypes) 80 81 return &clone 82 } 83 84 // Links implements the jsonapi.Object interface 85 func (e *ExportDoc) Links() *jsonapi.LinksList { return nil } 86 87 // Relationships implements the jsonapi.Object interface 88 func (e *ExportDoc) Relationships() jsonapi.RelationshipMap { return nil } 89 90 // Included implements the jsonapi.Object interface 91 func (e *ExportDoc) Included() []jsonapi.Object { return nil } 92 93 // HasExpired returns whether or not the export document has expired. 94 func (e *ExportDoc) HasExpired() bool { 95 return time.Until(e.ExpiresAt) <= 0 96 } 97 98 var _ jsonapi.Object = &ExportDoc{} 99 100 // AcceptDoctype returns true if the documents of the given doctype must be 101 // exported. 102 func (e *ExportDoc) AcceptDoctype(doctype string) bool { 103 if len(e.WithDoctypes) == 0 { 104 return true 105 } 106 for _, typ := range e.WithDoctypes { 107 if typ == doctype { 108 return true 109 } 110 } 111 return false 112 } 113 114 // MarksAsFinished saves the document when the export is done. 115 func (e *ExportDoc) MarksAsFinished(i *instance.Instance, size int64, err error) error { 116 e.CreationDuration = time.Since(e.CreatedAt) 117 if err == nil { 118 e.State = ExportStateDone 119 e.TotalSize = size 120 } else { 121 e.State = ExportStateError 122 e.Error = err.Error() 123 } 124 return couchdb.UpdateDoc(prefixer.GlobalPrefixer, e) 125 } 126 127 // SendExportMail sends a mail to the user with a link where they can download 128 // the export tarballs. 129 func (e *ExportDoc) SendExportMail(inst *instance.Instance) error { 130 link := e.GenerateLink(inst) 131 publicName, _ := csettings.PublicName(inst) 132 mail := mail.Options{ 133 Mode: mail.ModeFromStack, 134 TemplateName: "archiver", 135 TemplateValues: map[string]interface{}{ 136 "ArchiveLink": link, 137 "PublicName": publicName, 138 }, 139 } 140 141 msg, err := job.NewMessage(&mail) 142 if err != nil { 143 return err 144 } 145 146 _, err = job.System().PushJob(inst, &job.JobRequest{ 147 WorkerType: "sendmail", 148 Message: msg, 149 }) 150 return err 151 } 152 153 // NotifyTarget sends an HTTP request to the target so that it can start 154 // importing the tarballs. 155 func (e *ExportDoc) NotifyTarget(inst *instance.Instance, to *MoveToOptions, token string, ignoreVault bool) error { 156 link := e.GenerateLink(inst) 157 u := to.ImportsURL() 158 vault := false 159 if !ignoreVault { 160 vault = settings.HasVault(inst) 161 } 162 payload, err := json.Marshal(map[string]interface{}{ 163 "data": map[string]interface{}{ 164 "attributes": map[string]interface{}{ 165 "url": link, 166 "vault": vault, 167 "move_from": map[string]interface{}{ 168 "url": inst.PageURL("/", nil), 169 "token": token, 170 }, 171 }, 172 }, 173 }) 174 if err != nil { 175 return err 176 } 177 req, err := http.NewRequest("POST", u, bytes.NewReader(payload)) 178 if err != nil { 179 return err 180 } 181 req.Header.Add(echo.HeaderContentType, jsonapi.ContentType) 182 req.Header.Add(echo.HeaderAuthorization, "Bearer "+to.Token) 183 res, err := safehttp.ClientWithKeepAlive.Do(req) 184 if err != nil { 185 return err 186 } 187 defer res.Body.Close() 188 if res.StatusCode != 200 { 189 return fmt.Errorf("Cannot notify target: %d", res.StatusCode) 190 } 191 return nil 192 } 193 194 func (e *ExportDoc) NotifyRealtime() { 195 realtime.GetHub().Publish(prefixer.GlobalPrefixer, realtime.EventCreate, e.Clone(), nil) 196 } 197 198 // GenerateLink generates a link to download the export with a MAC. 199 func (e *ExportDoc) GenerateLink(i *instance.Instance) string { 200 mac, err := crypto.EncodeAuthMessage(archiveMACConfig, i.SessionSecret(), []byte(e.ID()), nil) 201 if err != nil { 202 panic(fmt.Errorf("could not generate archive auth message: %s", err)) 203 } 204 encoded := base64.URLEncoding.EncodeToString(mac) 205 link := i.SubDomain(consts.SettingsSlug) 206 link.Fragment = fmt.Sprintf("/exports/%s", encoded) 207 return link.String() 208 } 209 210 // CleanPreviousExports ensures that we have no old exports (or clean them). 211 func (e *ExportDoc) CleanPreviousExports(archiver Archiver) error { 212 exportedDocs, err := GetExports(e.Domain) 213 if err != nil { 214 return err 215 } 216 notRemovedDocs := exportedDocs[:0] 217 for _, e := range exportedDocs { 218 if e.State == ExportStateExporting && time.Since(e.CreatedAt) < 24*time.Hour { 219 return ErrExportConflict 220 } 221 notRemovedDocs = append(notRemovedDocs, e) 222 } 223 if len(notRemovedDocs) > 0 { 224 _ = archiver.RemoveArchives(notRemovedDocs) 225 } 226 return nil 227 } 228 229 func prepareExportDoc(i *instance.Instance, opts ExportOptions) *ExportDoc { 230 createdAt := time.Now() 231 232 // The size of the buckets can be specified by the options. If it is not 233 // the case, it is computed from the disk usage. An instance with 4x more 234 // bytes than another instance will have 2x more buckets and the buckets 235 // will be 2x larger. 236 bucketSize := opts.PartsSize 237 if bucketSize < minimalPartsSize { 238 bucketSize = minimalPartsSize 239 if usage, err := i.VFS().DiskUsage(); err == nil && usage > bucketSize { 240 factor := math.Sqrt(float64(usage) / float64(minimalPartsSize)) 241 bucketSize = int64(factor * float64(bucketSize)) 242 } 243 } 244 245 maxAge := opts.MaxAge 246 if maxAge == 0 || maxAge > archiveMaxAge { 247 maxAge = archiveMaxAge 248 } 249 250 return &ExportDoc{ 251 Domain: i.Domain, 252 State: ExportStateExporting, 253 CreatedAt: createdAt, 254 ExpiresAt: createdAt.Add(maxAge), 255 WithDoctypes: opts.WithDoctypes, 256 TotalSize: -1, 257 PartsSize: bucketSize, 258 } 259 } 260 261 // verifyAuthMessage verifies the given MAC to authenticate and grant the 262 // access to the export data. 263 func verifyAuthMessage(i *instance.Instance, mac []byte) (string, bool) { 264 exportID, err := crypto.DecodeAuthMessage(archiveMACConfig, i.SessionSecret(), mac, nil) 265 return string(exportID), err == nil 266 } 267 268 // GetExport returns an Export document associated with the given instance and 269 // with the given MAC message. 270 func GetExport(inst *instance.Instance, mac []byte) (*ExportDoc, error) { 271 exportID, ok := verifyAuthMessage(inst, mac) 272 if !ok { 273 return nil, ErrMACInvalid 274 } 275 var exportDoc ExportDoc 276 if err := couchdb.GetDoc(prefixer.GlobalPrefixer, consts.Exports, exportID, &exportDoc); err != nil { 277 if couchdb.IsNotFoundError(err) || couchdb.IsNoDatabaseError(err) { 278 return nil, ErrExportNotFound 279 } 280 return nil, err 281 } 282 if exportDoc.HasExpired() { 283 return nil, ErrExportExpired 284 } 285 return &exportDoc, nil 286 } 287 288 // GetExports returns the list of exported documents. 289 func GetExports(domain string) ([]*ExportDoc, error) { 290 var docs []*ExportDoc 291 req := &couchdb.FindRequest{ 292 UseIndex: "by-domain", 293 Selector: mango.Equal("domain", domain), 294 Sort: mango.SortBy{ 295 {Field: "domain", Direction: mango.Desc}, 296 {Field: "created_at", Direction: mango.Desc}, 297 }, 298 Limit: 256, 299 } 300 err := couchdb.FindDocs(prefixer.GlobalPrefixer, consts.Exports, req, &docs) 301 if err != nil && !couchdb.IsNoDatabaseError(err) { 302 return nil, err 303 } 304 return docs, nil 305 }