github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/client/instances.go (about) 1 package client 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "io" 7 "mime" 8 "net/http" 9 "net/url" 10 "os" 11 "path" 12 "strconv" 13 "strings" 14 "time" 15 16 "github.com/cozy/cozy-stack/client/request" 17 "github.com/cozy/cozy-stack/model/job" 18 "github.com/cozy/cozy-stack/model/move" 19 "github.com/cozy/cozy-stack/pkg/consts" 20 "github.com/cozy/cozy-stack/pkg/realtime" 21 "github.com/labstack/echo/v4" 22 ) 23 24 // Instance is a struct holding the representation of an instance on the API. 25 type Instance struct { 26 ID string `json:"id"` 27 Meta struct { 28 Rev string `json:"rev"` 29 } `json:"meta"` 30 Attrs struct { 31 Domain string `json:"domain"` 32 DomainAliases []string `json:"domain_aliases,omitempty"` 33 Prefix string `json:"prefix,omitempty"` 34 Locale string `json:"locale"` 35 UUID string `json:"uuid,omitempty"` 36 OIDCID string `json:"oidc_id,omitempty"` 37 ContextName string `json:"context,omitempty"` 38 Sponsorships []string `json:"sponsorships,omitempty"` 39 FeatureSets []string `json:"feature_sets,omitempty"` 40 TOSSigned string `json:"tos,omitempty"` 41 TOSLatest string `json:"tos_latest,omitempty"` 42 AuthMode int `json:"auth_mode,omitempty"` 43 NoAutoUpdate bool `json:"no_auto_update,omitempty"` 44 Blocked bool `json:"blocked,omitempty"` 45 OnboardingFinished bool `json:"onboarding_finished"` 46 PasswordDefined *bool `json:"password_defined"` 47 MagicLink bool `json:"magic_link,omitempty"` 48 BytesDiskQuota int64 `json:"disk_quota,string,omitempty"` 49 IndexViewsVersion int `json:"indexes_version"` 50 CouchCluster int `json:"couch_cluster,omitempty"` 51 SwiftLayout int `json:"swift_cluster,omitempty"` 52 PassphraseResetToken []byte `json:"passphrase_reset_token"` 53 PassphraseResetTime time.Time `json:"passphrase_reset_time"` 54 RegisterToken []byte `json:"register_token,omitempty"` 55 } `json:"attributes"` 56 } 57 58 // InstanceOptions contains the options passed on instance creation. 59 type InstanceOptions struct { 60 Domain string 61 DomainAliases []string 62 Locale string 63 UUID string 64 OIDCID string 65 FranceConnectID string 66 TOSSigned string 67 TOSLatest string 68 Timezone string 69 ContextName string 70 Sponsorships []string 71 Email string 72 PublicName string 73 Settings string 74 BlockingReason string 75 SwiftLayout int 76 CouchCluster int 77 DiskQuota int64 78 Apps []string 79 Passphrase string 80 KdfIterations int 81 MagicLink *bool 82 Debug *bool 83 Blocked *bool 84 Deleting *bool 85 OnboardingFinished *bool 86 Trace *bool 87 } 88 89 // TokenOptions is a struct holding all the options to generate a token. 90 type TokenOptions struct { 91 Domain string 92 Subject string 93 Audience string 94 Scope []string 95 Expire *time.Duration 96 } 97 98 // OAuthClientOptions is a struct holding all the options to generate an OAuth 99 // client associated to an instance. 100 type OAuthClientOptions struct { 101 Domain string 102 RedirectURI string 103 ClientName string 104 SoftwareID string 105 AllowLoginScope bool 106 OnboardingSecret string 107 OnboardingApp string 108 OnboardingPermissions string 109 OnboardingState string 110 } 111 112 type ExportOptions struct { 113 Domain string 114 LocalPath string 115 } 116 117 // ImportOptions is a struct with the options for importing a tarball. 118 type ImportOptions struct { 119 ManifestURL string 120 } 121 122 // DBPrefix returns the database prefix for the instance 123 func (i *Instance) DBPrefix() string { 124 if i.Attrs.Prefix != "" { 125 return i.Attrs.Prefix 126 } 127 return i.Attrs.Domain 128 } 129 130 // GetInstance returns the instance associated with the specified domain. 131 func (ac *AdminClient) GetInstance(domain string) (*Instance, error) { 132 res, err := ac.Req(&request.Options{ 133 Method: "GET", 134 Path: "/instances/" + domain, 135 }) 136 if err != nil { 137 return nil, err 138 } 139 return readInstance(res) 140 } 141 142 // CreateInstance is used to create a new cozy instance of the specified domain 143 // and locale. 144 func (ac *AdminClient) CreateInstance(opts *InstanceOptions) (*Instance, error) { 145 if !validDomain(opts.Domain) { 146 return nil, fmt.Errorf("Invalid domain: %s", opts.Domain) 147 } 148 q := url.Values{ 149 "Domain": {opts.Domain}, 150 "Locale": {opts.Locale}, 151 "UUID": {opts.UUID}, 152 "OIDCID": {opts.OIDCID}, 153 "FranceConnectID": {opts.FranceConnectID}, 154 "TOSSigned": {opts.TOSSigned}, 155 "Timezone": {opts.Timezone}, 156 "ContextName": {opts.ContextName}, 157 "Email": {opts.Email}, 158 "PublicName": {opts.PublicName}, 159 "Settings": {opts.Settings}, 160 "SwiftLayout": {strconv.Itoa(opts.SwiftLayout)}, 161 "CouchCluster": {strconv.Itoa(opts.CouchCluster)}, 162 "DiskQuota": {strconv.FormatInt(opts.DiskQuota, 10)}, 163 "Apps": {strings.Join(opts.Apps, ",")}, 164 "Passphrase": {opts.Passphrase}, 165 "KdfIterations": {strconv.Itoa(opts.KdfIterations)}, 166 } 167 if opts.DomainAliases != nil { 168 q.Add("DomainAliases", strings.Join(opts.DomainAliases, ",")) 169 } 170 if opts.Sponsorships != nil { 171 q.Add("Sponsorships", strings.Join(opts.Sponsorships, ",")) 172 } 173 if opts.MagicLink != nil && *opts.MagicLink { 174 q.Add("MagicLink", "true") 175 } 176 if opts.Trace != nil && *opts.Trace { 177 q.Add("Trace", "true") 178 } 179 res, err := ac.Req(&request.Options{ 180 Method: "POST", 181 Path: "/instances", 182 Queries: q, 183 }) 184 if err != nil { 185 return nil, err 186 } 187 return readInstance(res) 188 } 189 190 // CountInstances returns the number of instances. 191 func (ac *AdminClient) CountInstances() (int, error) { 192 res, err := ac.Req(&request.Options{ 193 Method: "GET", 194 Path: "/instances/count", 195 }) 196 if err != nil { 197 return 0, err 198 } 199 defer res.Body.Close() 200 var data map[string]int 201 if err = json.NewDecoder(res.Body).Decode(&data); err != nil { 202 return 0, err 203 } 204 return data["count"], nil 205 } 206 207 // ListInstances returns the list of instances recorded on the stack. 208 func (ac *AdminClient) ListInstances() ([]*Instance, error) { 209 res, err := ac.Req(&request.Options{ 210 Method: "GET", 211 Path: "/instances", 212 }) 213 if err != nil { 214 return nil, err 215 } 216 var list []*Instance 217 if err = readJSONAPI(res.Body, &list); err != nil { 218 return nil, err 219 } 220 return list, nil 221 } 222 223 // ModifyInstance is used to update an instance. 224 func (ac *AdminClient) ModifyInstance(opts *InstanceOptions) (*Instance, error) { 225 domain := opts.Domain 226 if !validDomain(domain) { 227 return nil, fmt.Errorf("Invalid domain: %s", domain) 228 } 229 q := url.Values{ 230 "Locale": {opts.Locale}, 231 "UUID": {opts.UUID}, 232 "OIDCID": {opts.OIDCID}, 233 "FranceConnectID": {opts.FranceConnectID}, 234 "TOSSigned": {opts.TOSSigned}, 235 "TOSLatest": {opts.TOSLatest}, 236 "Timezone": {opts.Timezone}, 237 "ContextName": {opts.ContextName}, 238 "Email": {opts.Email}, 239 "PublicName": {opts.PublicName}, 240 "Settings": {opts.Settings}, 241 "DiskQuota": {strconv.FormatInt(opts.DiskQuota, 10)}, 242 } 243 if opts.DomainAliases != nil { 244 q.Add("DomainAliases", strings.Join(opts.DomainAliases, ",")) 245 } 246 if opts.Sponsorships != nil { 247 q.Add("Sponsorships", strings.Join(opts.Sponsorships, ",")) 248 } 249 if opts.MagicLink != nil { 250 q.Add("MagicLink", strconv.FormatBool(*opts.MagicLink)) 251 } 252 if opts.Debug != nil { 253 q.Add("Debug", strconv.FormatBool(*opts.Debug)) 254 } 255 if opts.Blocked != nil { 256 q.Add("Blocked", strconv.FormatBool(*opts.Blocked)) 257 q.Add("BlockingReason", opts.BlockingReason) 258 } 259 if opts.Deleting != nil { 260 q.Add("Deleting", strconv.FormatBool(*opts.Deleting)) 261 } 262 if opts.OnboardingFinished != nil { 263 q.Add("OnboardingFinished", strconv.FormatBool(*opts.OnboardingFinished)) 264 } 265 res, err := ac.Req(&request.Options{ 266 Method: "PATCH", 267 Path: "/instances/" + domain, 268 Queries: q, 269 }) 270 if err != nil { 271 return nil, err 272 } 273 return readInstance(res) 274 } 275 276 // DestroyInstance is used to delete an instance and all its data. 277 func (ac *AdminClient) DestroyInstance(domain string) error { 278 if !validDomain(domain) { 279 return fmt.Errorf("Invalid domain: %s", domain) 280 } 281 _, err := ac.Req(&request.Options{ 282 Method: "DELETE", 283 Path: "/instances/" + domain, 284 NoResponse: true, 285 }) 286 return err 287 } 288 289 // GetDebug is used to known if an instance has its logger in debug mode. 290 func (ac *AdminClient) GetDebug(domain string) (bool, error) { 291 if !validDomain(domain) { 292 return false, fmt.Errorf("Invalid domain: %s", domain) 293 } 294 _, err := ac.Req(&request.Options{ 295 Method: "GET", 296 Path: "/instances/" + domain + "/debug", 297 NoResponse: true, 298 }) 299 if err != nil { 300 if e, ok := err.(*request.Error); ok { 301 if e.Title == http.StatusText(http.StatusNotFound) { 302 return false, nil 303 } 304 } 305 return false, err 306 } 307 return true, nil 308 } 309 310 // EnableDebug sets the logger of an instance in debug mode. 311 func (ac *AdminClient) EnableDebug(domain string, ttl time.Duration) error { 312 if !validDomain(domain) { 313 return fmt.Errorf("Invalid domain: %s", domain) 314 } 315 _, err := ac.Req(&request.Options{ 316 Method: "POST", 317 Path: "/instances/" + domain + "/debug", 318 NoResponse: true, 319 Queries: url.Values{ 320 "TTL": {ttl.String()}, 321 }, 322 }) 323 return err 324 } 325 326 // CleanSessions delete the databases for io.cozy.sessions and io.cozy.sessions.logins 327 func (ac *AdminClient) CleanSessions(domain string) error { 328 if !validDomain(domain) { 329 return fmt.Errorf("Invalid domain: %s", domain) 330 } 331 _, err := ac.Req(&request.Options{ 332 Method: "DELETE", 333 Path: "/instances/" + domain + "/sessions", 334 NoResponse: true, 335 }) 336 return err 337 } 338 339 // DisableDebug disables the debug mode for the logger of an instance. 340 func (ac *AdminClient) DisableDebug(domain string) error { 341 if !validDomain(domain) { 342 return fmt.Errorf("Invalid domain: %s", domain) 343 } 344 _, err := ac.Req(&request.Options{ 345 Method: "DELETE", 346 Path: "/instances/" + domain + "/debug", 347 NoResponse: true, 348 }) 349 return err 350 } 351 352 // GetToken is used to generate a token with the specified options. 353 func (ac *AdminClient) GetToken(opts *TokenOptions) (string, error) { 354 q := url.Values{ 355 "Domain": {opts.Domain}, 356 "Subject": {opts.Subject}, 357 "Audience": {opts.Audience}, 358 "Scope": {strings.Join(opts.Scope, " ")}, 359 } 360 if opts.Expire != nil { 361 q.Add("Expire", opts.Expire.String()) 362 } 363 res, err := ac.Req(&request.Options{ 364 Method: "POST", 365 Path: "/instances/token", 366 Queries: q, 367 }) 368 if err != nil { 369 return "", err 370 } 371 defer res.Body.Close() 372 b, err := io.ReadAll(res.Body) 373 if err != nil { 374 return "", err 375 } 376 return string(b), nil 377 } 378 379 // RegisterOAuthClient register a new OAuth client associated to the specified 380 // instance. 381 func (ac *AdminClient) RegisterOAuthClient(opts *OAuthClientOptions) (map[string]interface{}, error) { 382 q := url.Values{ 383 "Domain": {opts.Domain}, 384 "RedirectURI": {opts.RedirectURI}, 385 "ClientName": {opts.ClientName}, 386 "SoftwareID": {opts.SoftwareID}, 387 "AllowLoginScope": {strconv.FormatBool(opts.AllowLoginScope)}, 388 "OnboardingSecret": {opts.OnboardingSecret}, 389 "OnboardingApp": {opts.OnboardingApp}, 390 "OnboardingPermissions": {opts.OnboardingPermissions}, 391 "OnboardingState": {opts.OnboardingState}, 392 } 393 res, err := ac.Req(&request.Options{ 394 Method: "POST", 395 Path: "/instances/oauth_client", 396 Queries: q, 397 }) 398 if err != nil { 399 return nil, err 400 } 401 defer res.Body.Close() 402 var client map[string]interface{} 403 if err = json.NewDecoder(res.Body).Decode(&client); err != nil { 404 return nil, err 405 } 406 return client, nil 407 } 408 409 // Export launch the creation of a tarball to export data from an instance. 410 func (ac *AdminClient) Export(opts *ExportOptions) error { 411 if !validDomain(opts.Domain) { 412 return fmt.Errorf("Invalid domain: %s", opts.Domain) 413 } 414 415 downloadArchives := opts.LocalPath != "" 416 417 res, err := ac.Req(&request.Options{ 418 Method: "POST", 419 Path: "/instances/" + url.PathEscape(opts.Domain) + "/export", 420 Queries: url.Values{ 421 "admin-req": []string{strconv.FormatBool(downloadArchives)}, 422 }, 423 }) 424 if err != nil { 425 return err 426 } 427 defer res.Body.Close() 428 429 if downloadArchives { 430 channel, err := ac.RealtimeClient(RealtimeOptions{ 431 DocTypes: []string{consts.Exports}, 432 }) 433 if err != nil { 434 return err 435 } 436 defer channel.Close() 437 438 var j job.Job 439 if err = json.NewDecoder(res.Body).Decode(&j); err != nil { 440 return err 441 } 442 443 for evt := range channel.Channel() { 444 if evt.Event == "error" { 445 return fmt.Errorf("realtime: %s", evt.Payload.Title) 446 } 447 if evt.Event == realtime.EventUpdate && evt.Payload.Type == consts.Exports { 448 var exportDoc move.ExportDoc 449 err := json.Unmarshal(evt.Payload.Doc, &exportDoc) 450 if err != nil { 451 return err 452 } 453 454 if exportDoc.Domain != opts.Domain { 455 continue 456 } 457 if exportDoc.State == move.ExportStateError { 458 return fmt.Errorf("Failed to export instance: %s", exportDoc.Error) 459 } 460 if exportDoc.State != move.ExportStateDone { 461 continue 462 } 463 464 cursors := append([]string{""}, exportDoc.PartsCursors...) 465 partsCount := len(cursors) 466 for i, pc := range cursors { 467 res, err := ac.Req(&request.Options{ 468 Method: "GET", 469 Path: "/instances/" + url.PathEscape(exportDoc.Domain) + "/exports/" + exportDoc.ID() + "/data", 470 Queries: url.Values{ 471 "cursor": {pc}, 472 }, 473 }) 474 if err != nil { 475 return err 476 } 477 defer res.Body.Close() 478 479 filename := fmt.Sprintf("%s - part%03d.zip", opts.Domain, i) 480 if _, params, err := mime.ParseMediaType(res.Header.Get(echo.HeaderContentDisposition)); err != nil && params["filename"] != "" { 481 filename = params["filename"] 482 } 483 484 fmt.Fprintf(os.Stdout, "Exporting archive %d/%d (%s)... ", i+1, partsCount, filename) 485 486 filepath := path.Join(opts.LocalPath, filename) 487 f, err := os.OpenFile(filepath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) 488 if err != nil { 489 if !os.IsExist(err) { 490 return err 491 } 492 if err := os.Remove(filepath); err != nil { 493 return err 494 } 495 f, err = os.OpenFile(filepath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) 496 if err != nil { 497 return err 498 } 499 } 500 defer f.Close() 501 502 if _, err := io.Copy(f, res.Body); err != nil { 503 return err 504 } 505 506 fmt.Println("✅") 507 } 508 509 return nil 510 } 511 } 512 } 513 514 return nil 515 } 516 517 // Import launch the import of a tarball with data to put in an instance. 518 func (ac *AdminClient) Import(domain string, opts *ImportOptions) error { 519 if !validDomain(domain) { 520 return fmt.Errorf("Invalid domain: %s", domain) 521 } 522 q := url.Values{ 523 "manifest_url": {opts.ManifestURL}, 524 } 525 _, err := ac.Req(&request.Options{ 526 Method: "POST", 527 Path: "/instances/" + url.PathEscape(domain) + "/import", 528 Queries: q, 529 NoResponse: true, 530 }) 531 return err 532 } 533 534 // RebuildRedis puts the triggers in redis. 535 func (ac *AdminClient) RebuildRedis() error { 536 _, err := ac.Req(&request.Options{ 537 Method: "POST", 538 Path: "/instances/redis", 539 NoResponse: true, 540 }) 541 return err 542 } 543 544 // DiskUsage returns the information about disk usage and quota 545 func (ac *AdminClient) DiskUsage(domain string, includeTrash bool) (map[string]interface{}, error) { 546 var q map[string][]string 547 if includeTrash { 548 q = url.Values{ 549 "include": {"trash"}, 550 } 551 } 552 553 res, err := ac.Req(&request.Options{ 554 Method: "GET", 555 Path: "/instances/" + url.PathEscape(domain) + "/disk-usage", 556 Queries: q, 557 }) 558 if err != nil { 559 return nil, err 560 } 561 var info map[string]interface{} 562 if err = json.NewDecoder(res.Body).Decode(&info); err != nil { 563 return nil, err 564 } 565 return info, nil 566 } 567 568 func readInstance(res *http.Response) (*Instance, error) { 569 in := &Instance{} 570 if err := readJSONAPI(res.Body, &in); err != nil { 571 return nil, err 572 } 573 return in, nil 574 } 575 576 func validDomain(domain string) bool { 577 return !strings.ContainsAny(domain, " /?#@\t\r\n") 578 }