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

     1  package app
     2  
     3  import (
     4  	"encoding/json"
     5  	"io"
     6  	"net/url"
     7  	"time"
     8  
     9  	"github.com/cozy/cozy-stack/model/instance"
    10  	"github.com/cozy/cozy-stack/model/job"
    11  	"github.com/cozy/cozy-stack/model/permission"
    12  	"github.com/cozy/cozy-stack/pkg/appfs"
    13  	"github.com/cozy/cozy-stack/pkg/consts"
    14  	"github.com/cozy/cozy-stack/pkg/couchdb"
    15  	"github.com/cozy/cozy-stack/pkg/metadata"
    16  	"github.com/cozy/cozy-stack/pkg/prefixer"
    17  )
    18  
    19  // KonnManifest contains all the informations associated with an installed
    20  // konnector.
    21  type KonnManifest struct {
    22  	doc *couchdb.JSONDoc
    23  	err error
    24  
    25  	val struct {
    26  		// Fields that can be read and updated
    27  		Slug             string                 `json:"slug"`
    28  		Source           string                 `json:"source"`
    29  		State            State                  `json:"state"`
    30  		Version          string                 `json:"version"`
    31  		AvailableVersion string                 `json:"available_version"`
    32  		Checksum         string                 `json:"checksum"`
    33  		Parameters       map[string]interface{} `json:"parameters"`
    34  		CreatedAt        time.Time              `json:"created_at"`
    35  		UpdatedAt        time.Time              `json:"updated_at"`
    36  		Err              string                 `json:"error"`
    37  
    38  		// Just readers
    39  		Name            string `json:"name"`
    40  		Icon            string `json:"icon"`
    41  		Language        string `json:"language"`
    42  		ClientSide      bool   `json:"clientSide"`
    43  		OnDeleteAccount string `json:"on_delete_account"`
    44  
    45  		// Fields with complex types
    46  		Permissions   permission.Set `json:"permissions"`
    47  		Terms         Terms          `json:"terms"`
    48  		Notifications Notifications  `json:"notifications"`
    49  	}
    50  }
    51  
    52  // ID is part of the Manifest interface
    53  func (m *KonnManifest) ID() string { return m.doc.ID() }
    54  
    55  // Rev is part of the Manifest interface
    56  func (m *KonnManifest) Rev() string { return m.doc.Rev() }
    57  
    58  // DocType is part of the Manifest interface
    59  func (m *KonnManifest) DocType() string { return consts.Konnectors }
    60  
    61  // Clone is part of the Manifest interface
    62  func (m *KonnManifest) Clone() couchdb.Doc {
    63  	cloned := *m
    64  	cloned.doc = m.doc.Clone().(*couchdb.JSONDoc)
    65  	cloned.val.Permissions = make(permission.Set, len(m.val.Permissions))
    66  	copy(cloned.val.Permissions, m.val.Permissions)
    67  	return &cloned
    68  }
    69  
    70  // SetID is part of the Manifest interface
    71  func (m *KonnManifest) SetID(id string) { m.doc.SetID(id) }
    72  
    73  // SetRev is part of the Manifest interface
    74  func (m *KonnManifest) SetRev(rev string) { m.doc.SetRev(rev) }
    75  
    76  // SetSlug is part of the Manifest interface
    77  func (m *KonnManifest) SetSlug(slug string) { m.val.Slug = slug }
    78  
    79  // SetSource is part of the Manifest interface
    80  func (m *KonnManifest) SetSource(src *url.URL) { m.val.Source = src.String() }
    81  
    82  // Source is part of the Manifest interface
    83  func (m *KonnManifest) Source() string { return m.val.Source }
    84  
    85  // Version is part of the Manifest interface
    86  func (m *KonnManifest) Version() string { return m.val.Version }
    87  
    88  // AvailableVersion is part of the Manifest interface
    89  func (m *KonnManifest) AvailableVersion() string { return m.val.AvailableVersion }
    90  
    91  // Checksum is part of the Manifest interface
    92  func (m *KonnManifest) Checksum() string { return m.val.Checksum }
    93  
    94  // Slug is part of the Manifest interface
    95  func (m *KonnManifest) Slug() string { return m.val.Slug }
    96  
    97  // State is part of the Manifest interface
    98  func (m *KonnManifest) State() State { return m.val.State }
    99  
   100  // LastUpdate is part of the Manifest interface
   101  func (m *KonnManifest) LastUpdate() time.Time { return m.val.UpdatedAt }
   102  
   103  // SetState is part of the Manifest interface
   104  func (m *KonnManifest) SetState(state State) { m.val.State = state }
   105  
   106  // SetVersion is part of the Manifest interface
   107  func (m *KonnManifest) SetVersion(version string) { m.val.Version = version }
   108  
   109  // SetAvailableVersion is part of the Manifest interface
   110  func (m *KonnManifest) SetAvailableVersion(version string) { m.val.AvailableVersion = version }
   111  
   112  // SetChecksum is part of the Manifest interface
   113  func (m *KonnManifest) SetChecksum(shasum string) { m.val.Checksum = shasum }
   114  
   115  // AppType is part of the Manifest interface
   116  func (m *KonnManifest) AppType() consts.AppType { return consts.KonnectorType }
   117  
   118  // Terms is part of the Manifest interface
   119  func (m *KonnManifest) Terms() Terms { return m.val.Terms }
   120  
   121  // Permissions is part of the Manifest interface
   122  func (m *KonnManifest) Permissions() permission.Set { return m.val.Permissions }
   123  
   124  // SetError is part of the Manifest interface
   125  func (m *KonnManifest) SetError(err error) {
   126  	m.SetState(Errored)
   127  	m.val.Err = err.Error()
   128  	m.err = err
   129  }
   130  
   131  // Error is part of the Manifest interface
   132  func (m *KonnManifest) Error() error { return m.err }
   133  
   134  // Fetch is part of the Manifest interface
   135  func (m *KonnManifest) Fetch(field string) []string {
   136  	switch field {
   137  	case "slug":
   138  		return []string{m.val.Slug}
   139  	case "state":
   140  		return []string{string(m.val.State)}
   141  	}
   142  	return nil
   143  }
   144  
   145  // Notifications returns the notifications properties for this konnector.
   146  func (m *KonnManifest) Notifications() Notifications {
   147  	return m.val.Notifications
   148  }
   149  
   150  // Parameters returns the parameters for executing the konnector.
   151  func (m *KonnManifest) Parameters() map[string]interface{} {
   152  	return m.val.Parameters
   153  }
   154  
   155  // Name returns the konnector name.
   156  func (m *KonnManifest) Name() string { return m.val.Name }
   157  
   158  // Icon returns the konnector icon path.
   159  func (m *KonnManifest) Icon() string { return m.val.Icon }
   160  
   161  // Language returns the programming language used for executing the konnector
   162  // (only "node" for the moment).
   163  func (m *KonnManifest) Language() string { return m.val.Language }
   164  
   165  // ClientSide returns true for a konnector that runs on the client (flagship
   166  // app), and false for a konnector that runs on the server (nodejs executed by
   167  // the stack).
   168  func (m *KonnManifest) ClientSide() bool { return m.val.ClientSide }
   169  
   170  // OnDeleteAccount can be used to specify a file path which will be executed
   171  // when an account associated with the konnector is deleted.
   172  func (m *KonnManifest) OnDeleteAccount() string { return m.val.OnDeleteAccount }
   173  
   174  // VendorLink returns the vendor link.
   175  func (m *KonnManifest) VendorLink() interface{} {
   176  	return m.doc.M["vendor_link"]
   177  }
   178  
   179  func (m *KonnManifest) MarshalJSON() ([]byte, error) {
   180  	doc := m.doc.Clone().(*couchdb.JSONDoc)
   181  	doc.Type = consts.Konnectors
   182  	doc.M["slug"] = m.val.Slug
   183  	doc.M["source"] = m.val.Source
   184  	doc.M["state"] = m.val.State
   185  	doc.M["version"] = m.val.Version
   186  	if m.val.AvailableVersion == "" {
   187  		delete(doc.M, "available_version")
   188  	} else {
   189  		doc.M["available_version"] = m.val.AvailableVersion
   190  	}
   191  	doc.M["checksum"] = m.val.Checksum
   192  	if m.val.Parameters == nil {
   193  		delete(doc.M, "parameters")
   194  	} else {
   195  		doc.M["parameters"] = m.val.Parameters
   196  	}
   197  	doc.M["created_at"] = m.val.CreatedAt
   198  	doc.M["updated_at"] = m.val.UpdatedAt
   199  	if m.val.Err == "" {
   200  		delete(doc.M, "error")
   201  	} else {
   202  		doc.M["error"] = m.val.Err
   203  	}
   204  	// XXX: keep the weird UnmarshalJSON of permission.Set
   205  	perms, err := m.val.Permissions.MarshalJSON()
   206  	if err != nil {
   207  		return nil, err
   208  	}
   209  	doc.M["permissions"] = json.RawMessage(perms)
   210  	return json.Marshal(doc)
   211  }
   212  
   213  func (m *KonnManifest) UnmarshalJSON(j []byte) error {
   214  	if err := json.Unmarshal(j, &m.doc); err != nil {
   215  		return err
   216  	}
   217  	if err := json.Unmarshal(j, &m.val); err != nil {
   218  		return err
   219  	}
   220  	return nil
   221  }
   222  
   223  // ReadManifest is part of the Manifest interface
   224  func (m *KonnManifest) ReadManifest(r io.Reader, slug, sourceURL string) (Manifest, error) {
   225  	var newManifest KonnManifest
   226  	if err := json.NewDecoder(r).Decode(&newManifest); err != nil {
   227  		return nil, ErrBadManifest
   228  	}
   229  
   230  	newManifest.SetID(consts.Konnectors + "/" + slug)
   231  	newManifest.SetRev(m.Rev())
   232  	newManifest.SetState(m.State())
   233  	newManifest.val.CreatedAt = m.val.CreatedAt
   234  	newManifest.val.Slug = slug
   235  	newManifest.val.Source = sourceURL
   236  	if newManifest.val.Parameters == nil {
   237  		newManifest.val.Parameters = m.val.Parameters
   238  	}
   239  
   240  	return &newManifest, nil
   241  }
   242  
   243  // Create is part of the Manifest interface
   244  func (m *KonnManifest) Create(db prefixer.Prefixer) error {
   245  	m.SetID(consts.Konnectors + "/" + m.Slug())
   246  	m.val.CreatedAt = time.Now()
   247  	m.val.UpdatedAt = time.Now()
   248  	if err := couchdb.CreateNamedDocWithDB(db, m); err != nil {
   249  		return err
   250  	}
   251  
   252  	_, err := permission.CreateKonnectorSet(db, m.Slug(), m.Permissions(), m.Version())
   253  	return err
   254  }
   255  
   256  // Update is part of the Manifest interface
   257  func (m *KonnManifest) Update(db prefixer.Prefixer, extraPerms permission.Set) error {
   258  	m.val.UpdatedAt = time.Now()
   259  	err := couchdb.UpdateDoc(db, m)
   260  	if err != nil {
   261  		return err
   262  	}
   263  
   264  	perms := m.Permissions()
   265  
   266  	// Merging the potential extra permissions
   267  	if len(extraPerms) > 0 {
   268  		perms, err = permission.MergeExtraPermissions(perms, extraPerms)
   269  		if err != nil {
   270  			return err
   271  		}
   272  	}
   273  	_, err = permission.UpdateKonnectorSet(db, m.Slug(), perms)
   274  	return err
   275  }
   276  
   277  // Delete is part of the Manifest interface
   278  func (m *KonnManifest) Delete(db prefixer.Prefixer) error {
   279  	err := permission.DestroyKonnector(db, m.Slug())
   280  	if err != nil && !couchdb.IsNotFoundError(err) {
   281  		return err
   282  	}
   283  	return couchdb.DeleteDoc(db, m)
   284  }
   285  
   286  // BuildTrigger builds a @cron trigger with the parameter from the konnector
   287  // manifest (not yet persisted in CouchDB).
   288  func (m *KonnManifest) BuildTrigger(db prefixer.Prefixer, accountID, createdByApp string) (job.Trigger, error) {
   289  	var md *metadata.CozyMetadata
   290  	if createdByApp == "" {
   291  		md = metadata.New()
   292  	} else {
   293  		var err error
   294  		md, err = metadata.NewWithApp(createdByApp, "", job.DocTypeVersionTrigger)
   295  		if err != nil {
   296  			return nil, err
   297  		}
   298  	}
   299  	md.DocTypeVersion = "1"
   300  	data := map[string]interface{}{
   301  		"account":   accountID,
   302  		"konnector": m.Slug(),
   303  	}
   304  	if m.hasFolderPath() {
   305  		// XXX in theory, it is an ID, but we just put the yes string and let
   306  		// the worker change it to the folder ID on the first run.
   307  		data["folder_to_save"] = "yes"
   308  	}
   309  	msg, err := job.NewMessage(data)
   310  	if err != nil {
   311  		return nil, err
   312  	}
   313  	crontab := m.triggerCrontab()
   314  	return job.NewCronTrigger(&job.TriggerInfos{
   315  		Type:       "@cron",
   316  		WorkerType: "konnector",
   317  		Domain:     db.DomainName(),
   318  		Prefix:     db.DBPrefix(),
   319  		Arguments:  crontab,
   320  		Message:    msg,
   321  		Metadata:   md,
   322  	})
   323  }
   324  
   325  // CreateTrigger creates a @cron trigger with the parameter from the konnector
   326  // manifest (persisted in CouchDB).
   327  func (m *KonnManifest) CreateTrigger(db prefixer.Prefixer, accountID, createdByApp string) (job.Trigger, error) {
   328  	t, err := m.BuildTrigger(db, accountID, createdByApp)
   329  	if err != nil {
   330  		return nil, err
   331  	}
   332  	sched := job.System()
   333  	if err = sched.AddTrigger(t); err != nil {
   334  		return nil, err
   335  	}
   336  	return t, nil
   337  }
   338  
   339  func (m *KonnManifest) triggerCrontab() string {
   340  	spec := job.NewPeriodicSpec()
   341  
   342  	freq, _ := m.doc.M["frequency"].(string)
   343  	switch freq {
   344  	case "hourly":
   345  		spec.Frequency = job.HourlyKind
   346  	case "daily":
   347  		spec.Frequency = job.DailyKind
   348  	case "monthly":
   349  		spec.Frequency = job.MonthlyKind
   350  	default: // weekly
   351  		spec.Frequency = job.WeeklyKind
   352  	}
   353  
   354  	min, max := 0, 5 // By default konnectors are run at random hour between 12:00PM and 05:00AM
   355  	interval, ok := m.doc.M["time_interval"].([]int)
   356  	if ok && len(interval) == 2 {
   357  		min = interval[0]
   358  		if interval[1] > min {
   359  			max = interval[1]
   360  		}
   361  	}
   362  	spec.AfterHour = min
   363  	spec.BeforeHour = max
   364  
   365  	return spec.ToRandomCrontab(m.Slug())
   366  }
   367  
   368  // Cf https://github.com/cozy/cozy-libs/blob/55b5f23f0adbc308c3b70fa287c3938ee1b0a4cc/packages/cozy-harvest-lib/src/helpers/konnectors.js#L213-L225
   369  func (m *KonnManifest) hasFolderPath() bool {
   370  	if _, ok := m.doc.M["folders"].([]interface{}); ok {
   371  		return true
   372  	}
   373  	fields, ok := m.doc.M["fields"].(map[string]interface{})
   374  	if !ok {
   375  		return false
   376  	}
   377  	advanced, ok := fields["advanced_fields"].(map[string]interface{})
   378  	if !ok {
   379  		return false
   380  	}
   381  	return advanced["folderPath"] != nil
   382  }
   383  
   384  // GetKonnectorBySlug fetch the manifest of a konnector from the database given
   385  // a slug.
   386  func GetKonnectorBySlug(db prefixer.Prefixer, slug string) (*KonnManifest, error) {
   387  	if slug == "" || !slugReg.MatchString(slug) {
   388  		return nil, ErrInvalidSlugName
   389  	}
   390  	doc := &KonnManifest{}
   391  	err := couchdb.GetDoc(db, consts.Konnectors, consts.Konnectors+"/"+slug, doc)
   392  	if couchdb.IsNotFoundError(err) {
   393  		return nil, ErrNotFound
   394  	}
   395  	if err != nil {
   396  		return nil, err
   397  	}
   398  	return doc, nil
   399  }
   400  
   401  // GetKonnectorBySlugAndUpdate fetch the KonnManifest and perform an update of
   402  // the konnector if necessary and if the konnector was installed from the
   403  // registry.
   404  func GetKonnectorBySlugAndUpdate(in *instance.Instance, slug string, copier appfs.Copier, registries []*url.URL) (*KonnManifest, error) {
   405  	man, err := GetKonnectorBySlug(in, slug)
   406  	if err != nil {
   407  		return nil, err
   408  	}
   409  	return DoLazyUpdate(in, man, copier, registries).(*KonnManifest), nil
   410  }
   411  
   412  // ListKonnectorsWithPagination returns the list of installed konnectors with a
   413  // pagination
   414  func ListKonnectorsWithPagination(db prefixer.Prefixer, limit int, startKey string) ([]*KonnManifest, string, error) {
   415  	var docs []*KonnManifest
   416  
   417  	if limit == 0 {
   418  		limit = defaultAppListLimit
   419  	}
   420  
   421  	req := &couchdb.AllDocsRequest{
   422  		Limit:    limit + 1, // Also get the following document for the next key
   423  		StartKey: startKey,
   424  	}
   425  	err := couchdb.GetAllDocs(db, consts.Konnectors, req, &docs)
   426  	if err != nil {
   427  		return nil, "", err
   428  	}
   429  
   430  	nextID := ""
   431  	if len(docs) > 0 && len(docs) == limit+1 { // There are still documents to fetch
   432  		nextDoc := docs[len(docs)-1]
   433  		nextID = nextDoc.ID()
   434  		docs = docs[:len(docs)-1]
   435  	}
   436  
   437  	return docs, nextID, nil
   438  }
   439  
   440  var _ Manifest = &KonnManifest{}