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  }