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{}