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

     1  package app
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"net/url"
     9  	"os"
    10  	"path"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/cozy/cozy-stack/model/instance"
    15  	"github.com/cozy/cozy-stack/model/job"
    16  	"github.com/cozy/cozy-stack/model/notification"
    17  	"github.com/cozy/cozy-stack/model/permission"
    18  	"github.com/cozy/cozy-stack/pkg/appfs"
    19  	"github.com/cozy/cozy-stack/pkg/consts"
    20  	"github.com/cozy/cozy-stack/pkg/couchdb"
    21  	"github.com/cozy/cozy-stack/pkg/metadata"
    22  	"github.com/cozy/cozy-stack/pkg/prefixer"
    23  	"github.com/spf13/afero"
    24  )
    25  
    26  // defaultAppListLimit is the default limit for returned documents
    27  const defaultAppListLimit = 100
    28  
    29  // Route is a struct to serve a folder inside an app
    30  type Route struct {
    31  	Folder string `json:"folder"`
    32  	Index  string `json:"index"`
    33  	Public bool   `json:"public"`
    34  }
    35  
    36  // NotFound returns true for a blank route (ie not found by FindRoute)
    37  func (c *Route) NotFound() bool { return c.Folder == "" }
    38  
    39  // Routes is a map for routing inside an application.
    40  type Routes map[string]Route
    41  
    42  // Service is a struct to define a service executed by the stack.
    43  type Service struct {
    44  	name string
    45  
    46  	Type           string `json:"type"`
    47  	File           string `json:"file"`
    48  	Debounce       string `json:"debounce"`
    49  	TriggerOptions string `json:"trigger"`
    50  	TriggerID      string `json:"trigger_id"`
    51  }
    52  
    53  // Services is a map to define services assciated with an application.
    54  type Services map[string]*Service
    55  
    56  // Notifications is a map to define the notifications properties used by the
    57  // application.
    58  type Notifications map[string]notification.Properties
    59  
    60  // Intent is a declaration of a service for other client-side apps
    61  type Intent struct {
    62  	Action string   `json:"action"`
    63  	Types  []string `json:"type"`
    64  	Href   string   `json:"href"`
    65  }
    66  
    67  // Terms of an application/webapp
    68  type Terms struct {
    69  	URL     string `json:"url"`
    70  	Version string `json:"version"`
    71  }
    72  
    73  // Locales is used for the translations of the application name.
    74  // "fr" -> "name" -> "Cozy Drive"
    75  type Locales map[string]map[string]interface{}
    76  
    77  // WebappManifest contains all the informations associated with an installed web
    78  // application.
    79  type WebappManifest struct {
    80  	doc *couchdb.JSONDoc
    81  	err error
    82  
    83  	val struct {
    84  		// Fields that can be read and updated
    85  		Slug             string    `json:"slug"`
    86  		Source           string    `json:"source"`
    87  		State            State     `json:"state"`
    88  		Version          string    `json:"version"`
    89  		AvailableVersion string    `json:"available_version"`
    90  		Checksum         string    `json:"checksum"`
    91  		CreatedAt        time.Time `json:"created_at"`
    92  		UpdatedAt        time.Time `json:"updated_at"`
    93  		Err              string    `json:"error"`
    94  
    95  		// Just readers
    96  		Name       string `json:"name"`
    97  		NamePrefix string `json:"name_prefix"`
    98  		Icon       string `json:"icon"`
    99  		Editor     string `json:"editor"`
   100  
   101  		// Fields with complex types
   102  		Permissions   permission.Set `json:"permissions"`
   103  		Terms         Terms          `json:"terms"`
   104  		Intents       []Intent       `json:"intents"`
   105  		Routes        Routes         `json:"routes"`
   106  		Services      Services       `json:"services"`
   107  		Locales       Locales        `json:"locales"`
   108  		Notifications Notifications  `json:"notifications"`
   109  	}
   110  
   111  	FromAppsDir bool        `json:"-"` // Used in development
   112  	Instance    SubDomainer `json:"-"` // Used for JSON-API links
   113  
   114  	oldServices Services // Used to diff against when updating the app
   115  }
   116  
   117  // ID is part of the Manifest interface
   118  func (m *WebappManifest) ID() string { return m.doc.ID() }
   119  
   120  // Rev is part of the Manifest interface
   121  func (m *WebappManifest) Rev() string { return m.doc.Rev() }
   122  
   123  // DocType is part of the Manifest interface
   124  func (m *WebappManifest) DocType() string { return consts.Apps }
   125  
   126  // Clone implements couchdb.Doc
   127  func (m *WebappManifest) Clone() couchdb.Doc {
   128  	cloned := *m
   129  	cloned.doc = m.doc.Clone().(*couchdb.JSONDoc)
   130  	cloned.val.Permissions = make(permission.Set, len(m.val.Permissions))
   131  	copy(cloned.val.Permissions, m.val.Permissions)
   132  	return &cloned
   133  }
   134  
   135  // SetID is part of the Manifest interface
   136  func (m *WebappManifest) SetID(id string) { m.doc.SetID(id) }
   137  
   138  // SetRev is part of the Manifest interface
   139  func (m *WebappManifest) SetRev(rev string) { m.doc.SetRev(rev) }
   140  
   141  // SetSource is part of the Manifest interface
   142  func (m *WebappManifest) SetSource(src *url.URL) { m.val.Source = src.String() }
   143  
   144  // Source is part of the Manifest interface
   145  func (m *WebappManifest) Source() string { return m.val.Source }
   146  
   147  // Version is part of the Manifest interface
   148  func (m *WebappManifest) Version() string { return m.val.Version }
   149  
   150  // AvailableVersion is part of the Manifest interface
   151  func (m *WebappManifest) AvailableVersion() string { return m.val.AvailableVersion }
   152  
   153  // Checksum is part of the Manifest interface
   154  func (m *WebappManifest) Checksum() string { return m.val.Checksum }
   155  
   156  // Slug is part of the Manifest interface
   157  func (m *WebappManifest) Slug() string { return m.val.Slug }
   158  
   159  // State is part of the Manifest interface
   160  func (m *WebappManifest) State() State { return m.val.State }
   161  
   162  // LastUpdate is part of the Manifest interface
   163  func (m *WebappManifest) LastUpdate() time.Time { return m.val.UpdatedAt }
   164  
   165  // SetSlug is part of the Manifest interface
   166  func (m *WebappManifest) SetSlug(slug string) { m.val.Slug = slug }
   167  
   168  // SetState is part of the Manifest interface
   169  func (m *WebappManifest) SetState(state State) { m.val.State = state }
   170  
   171  // SetVersion is part of the Manifest interface
   172  func (m *WebappManifest) SetVersion(version string) { m.val.Version = version }
   173  
   174  // SetAvailableVersion is part of the Manifest interface
   175  func (m *WebappManifest) SetAvailableVersion(version string) { m.val.AvailableVersion = version }
   176  
   177  // SetChecksum is part of the Manifest interface
   178  func (m *WebappManifest) SetChecksum(shasum string) { m.val.Checksum = shasum }
   179  
   180  // AppType is part of the Manifest interface
   181  func (m *WebappManifest) AppType() consts.AppType { return consts.WebappType }
   182  
   183  // Terms is part of the Manifest interface
   184  func (m *WebappManifest) Terms() Terms { return m.val.Terms }
   185  
   186  // Permissions is part of the Manifest interface
   187  func (m *WebappManifest) Permissions() permission.Set { return m.val.Permissions }
   188  
   189  // Name returns the webapp name.
   190  func (m *WebappManifest) Name() string { return m.val.Name }
   191  
   192  // Icon returns the webapp icon path.
   193  func (m *WebappManifest) Icon() string { return m.val.Icon }
   194  
   195  // Editor returns the webapp editor.
   196  func (m *WebappManifest) Editor() string { return m.val.Editor }
   197  
   198  // NamePrefix returns the webapp name prefix.
   199  func (m *WebappManifest) NamePrefix() string { return m.val.NamePrefix }
   200  
   201  // Notifications returns the notifications properties for this webapp.
   202  func (m *WebappManifest) Notifications() Notifications {
   203  	return m.val.Notifications
   204  }
   205  
   206  func (m *WebappManifest) Services() Services {
   207  	return m.val.Services
   208  }
   209  
   210  // SetError is part of the Manifest interface
   211  func (m *WebappManifest) SetError(err error) {
   212  	m.SetState(Errored)
   213  	m.val.Err = err.Error()
   214  	m.err = err
   215  }
   216  
   217  // Error is part of the Manifest interface
   218  func (m *WebappManifest) Error() error { return m.err }
   219  
   220  // Fetch is part of the Manifest interface
   221  func (m *WebappManifest) Fetch(field string) []string {
   222  	switch field {
   223  	case "slug":
   224  		return []string{m.val.Slug}
   225  	case "state":
   226  		return []string{string(m.val.State)}
   227  	}
   228  	return nil
   229  }
   230  
   231  // NameLocalized returns the name of the app in the given locale
   232  func (m *WebappManifest) NameLocalized(locale string) string {
   233  	if m.val.Locales != nil && locale != "" {
   234  		if dict, ok := m.val.Locales[locale]; ok {
   235  			if v, ok := dict["name"].(string); ok && v != "" {
   236  				return v
   237  			}
   238  		}
   239  	}
   240  	return m.val.Name
   241  }
   242  
   243  func (m *WebappManifest) MarshalJSON() ([]byte, error) {
   244  	doc := m.doc.Clone().(*couchdb.JSONDoc)
   245  	doc.Type = consts.Apps
   246  	doc.M["slug"] = m.val.Slug
   247  	doc.M["source"] = m.val.Source
   248  	doc.M["state"] = m.val.State
   249  	doc.M["version"] = m.val.Version
   250  	if m.val.AvailableVersion == "" {
   251  		delete(doc.M, "available_version")
   252  	} else {
   253  		doc.M["available_version"] = m.val.AvailableVersion
   254  	}
   255  	doc.M["checksum"] = m.val.Checksum
   256  	doc.M["created_at"] = m.val.CreatedAt
   257  	doc.M["updated_at"] = m.val.UpdatedAt
   258  	if m.val.Err == "" {
   259  		delete(doc.M, "error")
   260  	} else {
   261  		doc.M["error"] = m.val.Err
   262  	}
   263  	// XXX: keep the weird UnmarshalJSON of permission.Set
   264  	perms, err := m.val.Permissions.MarshalJSON()
   265  	if err != nil {
   266  		return nil, err
   267  	}
   268  	doc.M["permissions"] = json.RawMessage(perms)
   269  	doc.M["terms"] = m.val.Terms
   270  	doc.M["intents"] = m.val.Intents
   271  	doc.M["routes"] = m.val.Routes
   272  	doc.M["services"] = m.val.Services
   273  	doc.M["locales"] = m.val.Locales
   274  	doc.M["notifications"] = m.val.Notifications
   275  	return json.Marshal(doc)
   276  }
   277  
   278  func (m *WebappManifest) UnmarshalJSON(j []byte) error {
   279  	if err := json.Unmarshal(j, &m.doc); err != nil {
   280  		return err
   281  	}
   282  	if err := json.Unmarshal(j, &m.val); err != nil {
   283  		return err
   284  	}
   285  	return nil
   286  }
   287  
   288  // ReadManifest is part of the Manifest interface
   289  func (m *WebappManifest) ReadManifest(r io.Reader, slug, sourceURL string) (Manifest, error) {
   290  	var newManifest WebappManifest
   291  	if err := json.NewDecoder(r).Decode(&newManifest); err != nil {
   292  		return nil, ErrBadManifest
   293  	}
   294  
   295  	newManifest.SetID(consts.Apps + "/" + slug)
   296  	newManifest.SetRev(m.Rev())
   297  	newManifest.SetState(m.State())
   298  	newManifest.val.CreatedAt = m.val.CreatedAt
   299  	newManifest.val.Slug = slug
   300  	newManifest.val.Source = sourceURL
   301  	newManifest.Instance = m.Instance
   302  	newManifest.oldServices = m.val.Services
   303  	if newManifest.val.Routes == nil {
   304  		newManifest.val.Routes = make(Routes)
   305  		newManifest.val.Routes["/"] = Route{
   306  			Folder: "/",
   307  			Index:  "index.html",
   308  			Public: false,
   309  		}
   310  	}
   311  
   312  	return &newManifest, nil
   313  }
   314  
   315  // Create is part of the Manifest interface
   316  func (m *WebappManifest) Create(db prefixer.Prefixer) error {
   317  	m.SetID(consts.Apps + "/" + m.val.Slug)
   318  	m.val.CreatedAt = time.Now()
   319  	m.val.UpdatedAt = time.Now()
   320  	if err := couchdb.CreateNamedDocWithDB(db, m); err != nil {
   321  		return err
   322  	}
   323  
   324  	if len(m.val.Services) > 0 {
   325  		if err := diffServices(db, m.Slug(), nil, m.val.Services); err != nil {
   326  			return err
   327  		}
   328  		_ = couchdb.UpdateDoc(db, m)
   329  	}
   330  
   331  	_, err := permission.CreateWebappSet(db, m.Slug(), m.Permissions(), m.Version())
   332  	return err
   333  }
   334  
   335  // Update is part of the Manifest interface
   336  func (m *WebappManifest) Update(db prefixer.Prefixer, extraPerms permission.Set) error {
   337  	if err := diffServices(db, m.Slug(), m.oldServices, m.val.Services); err != nil {
   338  		return err
   339  	}
   340  	m.val.UpdatedAt = time.Now()
   341  	if err := couchdb.UpdateDoc(db, m); err != nil {
   342  		return err
   343  	}
   344  
   345  	var err error
   346  	perms := m.Permissions()
   347  
   348  	// Merging the potential extra permissions
   349  	if len(extraPerms) > 0 {
   350  		perms, err = permission.MergeExtraPermissions(perms, extraPerms)
   351  		if err != nil {
   352  			return err
   353  		}
   354  	}
   355  
   356  	_, err = permission.UpdateWebappSet(db, m.Slug(), perms)
   357  	return err
   358  }
   359  
   360  // Delete is part of the Manifest interface
   361  func (m *WebappManifest) Delete(db prefixer.Prefixer) error {
   362  	err := diffServices(db, m.Slug(), m.val.Services, nil)
   363  	if err != nil {
   364  		return err
   365  	}
   366  	err = permission.DestroyWebapp(db, m.Slug())
   367  	if err != nil && !couchdb.IsNotFoundError(err) {
   368  		return err
   369  	}
   370  	return couchdb.DeleteDoc(db, m)
   371  }
   372  
   373  func diffServices(db prefixer.Prefixer, slug string, oldServices, newServices Services) error {
   374  	if oldServices == nil {
   375  		oldServices = make(Services)
   376  	}
   377  	if newServices == nil {
   378  		newServices = make(Services)
   379  	}
   380  
   381  	var deleted []*Service
   382  	var created []*Service
   383  
   384  	clone := make(Services)
   385  	for newName, newService := range newServices {
   386  		clone[newName] = newService
   387  		newService.name = newName
   388  	}
   389  
   390  	for name, oldService := range oldServices {
   391  		oldService.name = name
   392  		newService, ok := newServices[name]
   393  		if !ok {
   394  			deleted = append(deleted, oldService)
   395  			continue
   396  		}
   397  		delete(clone, name)
   398  		if newService.File != oldService.File ||
   399  			newService.Type != oldService.Type ||
   400  			newService.TriggerOptions != oldService.TriggerOptions ||
   401  			newService.Debounce != oldService.Debounce {
   402  			deleted = append(deleted, oldService)
   403  			created = append(created, newService)
   404  		} else {
   405  			*newService = *oldService
   406  		}
   407  		newService.name = name
   408  	}
   409  	for _, newService := range clone {
   410  		created = append(created, newService)
   411  	}
   412  
   413  	sched := job.System()
   414  	for _, service := range deleted {
   415  		if service.TriggerID != "" {
   416  			if err := sched.DeleteTrigger(db, service.TriggerID); err != nil && !errors.Is(err, job.ErrNotFoundTrigger) {
   417  				return err
   418  			}
   419  		}
   420  	}
   421  
   422  	for _, service := range created {
   423  		triggerID, err := CreateServiceTrigger(db, slug, service.name, service)
   424  		if err != nil {
   425  			return err
   426  		}
   427  		if triggerID != "" {
   428  			service.TriggerID = triggerID
   429  		}
   430  	}
   431  
   432  	return nil
   433  }
   434  
   435  // CreateServiceTrigger creates a trigger for the given service. It returns the
   436  // id of the created trigger or an error.
   437  func CreateServiceTrigger(db prefixer.Prefixer, slug, serviceName string, service *Service) (string, error) {
   438  	var triggerType string
   439  	var triggerArgs string
   440  	triggerOpts := strings.SplitN(service.TriggerOptions, " ", 2)
   441  	if len(triggerOpts) > 0 {
   442  		triggerType = strings.TrimSpace(triggerOpts[0])
   443  	}
   444  	if len(triggerOpts) > 1 {
   445  		triggerArgs = strings.TrimSpace(triggerOpts[1])
   446  	}
   447  
   448  	// Do not create triggers for services called programmatically
   449  	if triggerType == "" || service.TriggerOptions == "@at 2000-01-01T00:00:00.000Z" {
   450  		return "", nil
   451  	}
   452  
   453  	// Add metadata
   454  	md, err := metadata.NewWithApp(slug, "", job.DocTypeVersionTrigger)
   455  	if err != nil {
   456  		return "", err
   457  	}
   458  	msg := map[string]string{
   459  		"slug": slug,
   460  		"name": serviceName,
   461  	}
   462  	trigger, err := job.NewTrigger(db, job.TriggerInfos{
   463  		Type:       triggerType,
   464  		WorkerType: "service",
   465  		Debounce:   service.Debounce,
   466  		Arguments:  triggerArgs,
   467  		Metadata:   md,
   468  	}, msg)
   469  	if err != nil {
   470  		return "", err
   471  	}
   472  	sched := job.System()
   473  	if err = sched.AddTrigger(trigger); err != nil {
   474  		return "", err
   475  	}
   476  	return trigger.ID(), nil
   477  }
   478  
   479  // FindRoute takes a path, returns the route which matches the best,
   480  // and the part that remains unmatched
   481  func (m *WebappManifest) FindRoute(vpath string) (Route, string) {
   482  	parts := strings.Split(vpath, "/")
   483  	lenParts := len(parts)
   484  
   485  	var best Route
   486  	rest := ""
   487  	specificity := 0
   488  	for key, ctx := range m.val.Routes {
   489  		var keys []string
   490  		if key == "/" {
   491  			keys = []string{""}
   492  		} else {
   493  			keys = strings.Split(key, "/")
   494  		}
   495  		count := len(keys)
   496  		if count > lenParts || count < specificity {
   497  			continue
   498  		}
   499  		if routeMatches(parts, keys) {
   500  			specificity = count
   501  			best = ctx
   502  			rest = path.Join(parts[count:]...)
   503  		}
   504  	}
   505  
   506  	return best, rest
   507  }
   508  
   509  // FindIntent returns an intent for the given action and type if the manifest has one
   510  func (m *WebappManifest) FindIntent(action, typ string) *Intent {
   511  	for _, intent := range m.val.Intents {
   512  		if !strings.EqualFold(action, intent.Action) {
   513  			continue
   514  		}
   515  		for _, t := range intent.Types {
   516  			if t == typ {
   517  				return &intent
   518  			}
   519  			// Allow a joker for mime-types like image/*
   520  			if strings.HasSuffix(t, "/*") {
   521  				if strings.SplitN(t, "/", 2)[0] == strings.SplitN(typ, "/", 2)[0] {
   522  					return &intent
   523  				}
   524  			}
   525  		}
   526  	}
   527  	return nil
   528  }
   529  
   530  // appsdir is a map of slug -> directory used in development for webapps that
   531  // are not installed in the Cozy but serve directly from a directory.
   532  var appsdir map[string]string
   533  
   534  // SetupAppsDir allow to load some webapps from directories for development.
   535  func SetupAppsDir(apps map[string]string) {
   536  	if appsdir == nil {
   537  		appsdir = make(map[string]string)
   538  	}
   539  	for app, dir := range apps {
   540  		appsdir[app] = dir
   541  	}
   542  }
   543  
   544  // FSForAppDir returns a FS for the webapp in development.
   545  func FSForAppDir(slug string) appfs.FileServer {
   546  	base := baseFSForAppDir(slug)
   547  	return appfs.NewAferoFileServer(base, func(_, _, _, file string) string {
   548  		return path.Join("/", file)
   549  	})
   550  }
   551  
   552  func baseFSForAppDir(slug string) afero.Fs {
   553  	return afero.NewBasePathFs(afero.NewOsFs(), appsdir[slug])
   554  }
   555  
   556  // loadManifestFromDir returns a manifest for a webapp in development.
   557  func loadManifestFromDir(slug string) (*WebappManifest, error) {
   558  	dir, ok := appsdir[slug]
   559  	if !ok {
   560  		return nil, ErrNotFound
   561  	}
   562  	fs := baseFSForAppDir(slug)
   563  	manFile, err := fs.Open(WebappManifestName)
   564  	if err != nil {
   565  		if os.IsNotExist(err) {
   566  			return nil, fmt.Errorf("Could not find the manifest in your app directory %s", dir)
   567  		}
   568  		return nil, err
   569  	}
   570  	app := &WebappManifest{
   571  		doc: &couchdb.JSONDoc{},
   572  	}
   573  	man, err := app.ReadManifest(manFile, slug, "file://localhost"+dir)
   574  	if err != nil {
   575  		return nil, fmt.Errorf("Could not parse the manifest: %s", err.Error())
   576  	}
   577  	app = man.(*WebappManifest)
   578  	app.FromAppsDir = true
   579  	app.val.State = Ready
   580  	return app, nil
   581  }
   582  
   583  // GetWebappBySlug fetch the WebappManifest from the database given a slug.
   584  func GetWebappBySlug(db prefixer.Prefixer, slug string) (*WebappManifest, error) {
   585  	if slug == "" || !slugReg.MatchString(slug) {
   586  		return nil, ErrInvalidSlugName
   587  	}
   588  	for app := range appsdir {
   589  		if app == slug {
   590  			return loadManifestFromDir(slug)
   591  		}
   592  	}
   593  	man := &WebappManifest{}
   594  	err := couchdb.GetDoc(db, consts.Apps, consts.Apps+"/"+slug, man)
   595  	if couchdb.IsNotFoundError(err) {
   596  		return nil, ErrNotFound
   597  	}
   598  	if err != nil {
   599  		return nil, err
   600  	}
   601  	return man, nil
   602  }
   603  
   604  // GetWebappBySlugAndUpdate fetch the WebappManifest and perform an update of
   605  // the application if necessary and if the application was installed from the
   606  // registry.
   607  func GetWebappBySlugAndUpdate(in *instance.Instance, slug string, copier appfs.Copier, registries []*url.URL) (*WebappManifest, error) {
   608  	man, err := GetWebappBySlug(in, slug)
   609  	if err != nil {
   610  		return nil, err
   611  	}
   612  	return DoLazyUpdate(in, man, copier, registries).(*WebappManifest), nil
   613  }
   614  
   615  // ListWebappsWithPagination returns the list of installed web applications with
   616  // a pagination
   617  func ListWebappsWithPagination(db prefixer.Prefixer, limit int, startKey string) ([]*WebappManifest, string, error) {
   618  	var docs []*WebappManifest
   619  
   620  	if limit == 0 {
   621  		limit = defaultAppListLimit
   622  	}
   623  
   624  	req := &couchdb.AllDocsRequest{
   625  		Limit:    limit + 1, // Also get the following document for the next key
   626  		StartKey: startKey,
   627  	}
   628  	err := couchdb.GetAllDocs(db, consts.Apps, req, &docs)
   629  	if err != nil {
   630  		return nil, "", err
   631  	}
   632  
   633  	nextID := ""
   634  	if len(docs) > 0 && len(docs) == limit+1 { // There are still documents to fetch
   635  		nextDoc := docs[len(docs)-1]
   636  		nextID = nextDoc.ID()
   637  		docs = docs[:len(docs)-1]
   638  		return docs, nextID, nil
   639  	}
   640  
   641  	// If we get here, either :
   642  	// - There are no more docs in couchDB
   643  	// - There are no docs at all
   644  	// We can load extra apps and append them safely to the list
   645  	for slug := range appsdir {
   646  		if man, err := loadManifestFromDir(slug); err == nil {
   647  			docs = append(docs, man)
   648  		}
   649  	}
   650  
   651  	return docs, nextID, nil
   652  }
   653  
   654  var _ Manifest = &WebappManifest{}