github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/apps/serve.go (about) 1 package apps 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "errors" 7 "html/template" 8 "io" 9 "net/http" 10 "net/url" 11 "os" 12 "path" 13 "strings" 14 15 "github.com/cozy/cozy-stack/model/app" 16 "github.com/cozy/cozy-stack/model/feature" 17 "github.com/cozy/cozy-stack/model/instance" 18 "github.com/cozy/cozy-stack/model/intent" 19 "github.com/cozy/cozy-stack/model/permission" 20 "github.com/cozy/cozy-stack/model/session" 21 csettings "github.com/cozy/cozy-stack/model/settings" 22 "github.com/cozy/cozy-stack/model/sharing" 23 "github.com/cozy/cozy-stack/pkg/appfs" 24 "github.com/cozy/cozy-stack/pkg/assets" 25 "github.com/cozy/cozy-stack/pkg/config/config" 26 "github.com/cozy/cozy-stack/pkg/consts" 27 "github.com/cozy/cozy-stack/pkg/couchdb" 28 "github.com/cozy/cozy-stack/pkg/jsonapi" 29 "github.com/cozy/cozy-stack/pkg/registry" 30 "github.com/cozy/cozy-stack/web/auth" 31 "github.com/cozy/cozy-stack/web/middlewares" 32 "github.com/cozy/cozy-stack/web/settings" 33 "github.com/cozy/cozy-stack/web/statik" 34 "github.com/labstack/echo/v4" 35 ) 36 37 // Serve is an handler for serving files from the VFS for a client-side app 38 func Serve(c echo.Context) error { 39 method := c.Request().Method 40 if method != "GET" && method != "HEAD" { 41 return echo.NewHTTPError(http.StatusMethodNotAllowed, "Method "+method+" not allowed") 42 } 43 44 i := middlewares.GetInstance(c) 45 slug := c.Get("slug").(string) 46 47 if !i.OnboardingFinished { 48 return c.Redirect(http.StatusFound, i.PageURL("/", nil)) 49 } 50 51 webapp, err := app.GetWebappBySlug(i, slug) 52 if err != nil { 53 if errors.Is(err, app.ErrNotFound) { 54 return handleAppNotFound(c, i, slug) 55 } 56 return err 57 } 58 59 route, file := webapp.FindRoute(path.Clean(c.Request().URL.Path)) 60 61 if webapp.FromAppsDir { 62 // Save permissions in couchdb before loading an index page 63 if file == "" && webapp.Permissions() != nil { 64 _ = permission.ForceWebapp(i, webapp.Slug(), webapp.Permissions()) 65 } 66 67 fs := app.FSForAppDir(slug) 68 return ServeAppFile(c, i, fs, webapp) 69 } 70 71 if file == "" || file == route.Index { 72 webapp = app.DoLazyUpdate(i, webapp, app.Copier(consts.WebappType, i), i.Registries()).(*app.WebappManifest) 73 } 74 75 switch webapp.State() { 76 case app.Installed: 77 // This legacy "installed" state is not used anymore with the addition 78 // of the registry. Change the webapp state to "ready" and serve the app 79 // file. 80 webapp.SetState(app.Ready) 81 if err := webapp.Update(i, nil); err != nil { 82 return err 83 } 84 fallthrough 85 case app.Ready: 86 return ServeAppFile(c, i, app.AppsFileServer(i), webapp) 87 default: 88 return echo.NewHTTPError(http.StatusServiceUnavailable, "Application is not ready") 89 } 90 } 91 92 // handleAppNotFound is used to render the error page when the user wants to 93 // access an app that is not yet installed 94 func handleAppNotFound(c echo.Context, i *instance.Instance, slug string) error { 95 // Used for the "collect" => "home" renaming 96 if slug == "collect" { 97 return c.Redirect(http.StatusMovedPermanently, i.DefaultRedirection().String()) 98 } 99 // Used for the deprecated "onboarding" app 100 if slug == "onboarding" { 101 return c.Redirect(http.StatusMovedPermanently, i.DefaultRedirection().String()) 102 } 103 i.Logger().WithNamespace("apps").Infof("App not found: %s", slug) 104 if _, err := registry.GetApplication(slug, i.Registries()); err != nil { 105 return app.ErrNotFound 106 } 107 if _, err := app.GetWebappBySlug(i, consts.StoreSlug); err != nil { 108 return app.ErrNotFound 109 } 110 u := i.SubDomain(consts.StoreSlug) 111 u.Fragment = "/discover/" + slug 112 return c.Redirect(http.StatusTemporaryRedirect, u.String()) 113 } 114 115 // handleIntent will allow iframes from another app if the current app is 116 // opened as an intent 117 func handleIntent(c echo.Context, i *instance.Instance, slug, intentID string) { 118 intent := &intent.Intent{} 119 if err := couchdb.GetDoc(i, consts.Intents, intentID, intent); err != nil { 120 return 121 } 122 allowed := false 123 for _, service := range intent.Services { 124 if slug == service.Slug { 125 allowed = true 126 } 127 } 128 if !allowed { 129 return 130 } 131 parts := strings.SplitN(intent.Client, "/", 2) 132 if len(parts) < 2 || parts[0] != consts.Apps { 133 return 134 } 135 from := i.SubDomain(parts[1]).String() 136 if !config.GetConfig().CSPDisabled { 137 middlewares.AppendCSPRule(c, "frame-ancestors", from) 138 } 139 } 140 141 // ServeAppFile will serve the requested file using the specified application 142 // manifest and appfs.FileServer context. 143 // 144 // It can be used to serve file application in another context than the VFS, 145 // for instance for tests or development purposes where we want to serve an 146 // application that is not installed on the user's instance. However this 147 // procedure should not be used for standard applications, use the Serve method 148 // for that. 149 func ServeAppFile(c echo.Context, i *instance.Instance, fs appfs.FileServer, webapp *app.WebappManifest) error { 150 slug := webapp.Slug() 151 route, file := webapp.FindRoute(path.Clean(c.Request().URL.Path)) 152 if route.NotFound() { 153 return echo.NewHTTPError(http.StatusNotFound, "Page not found") 154 } 155 if file == "" { 156 file = route.Index 157 } 158 159 sess, isLoggedIn := middlewares.GetSession(c) 160 if code := c.QueryParam("session_code"); code != "" { 161 // XXX we should always clear the session code to avoid it being 162 // reused, even if the user is already logged in and we don't want to 163 // create a new session 164 if checked := i.CheckAndClearSessionCode(code); checked && !isLoggedIn { 165 sessionID, err := auth.SetCookieForNewSession(c, session.NormalRun) 166 req := c.Request() 167 if err == nil { 168 if err = session.StoreNewLoginEntry(i, sessionID, "", req, "session_code", false); err != nil { 169 i.Logger().Errorf("Could not store session history %q: %s", sessionID, err) 170 } 171 } 172 redirect := req.URL 173 redirect.RawQuery = "" 174 return c.Redirect(http.StatusSeeOther, redirect.String()) 175 } 176 } 177 178 filepath := path.Join("/", route.Folder, file) 179 isRobotsTxt := filepath == "/robots.txt" 180 181 if !route.Public && !isLoggedIn { 182 if isRobotsTxt { 183 if f, ok := assets.Get("/robots.txt", i.ContextName); ok { 184 _, err := io.Copy(c.Response(), f.Reader()) 185 return err 186 } 187 } 188 if file != route.Index { 189 return echo.NewHTTPError(http.StatusUnauthorized, "You must be authenticated") 190 } 191 reqURL := c.Request().URL 192 subdomain := i.SubDomain(slug) 193 subdomain.Path = reqURL.Path 194 subdomain.RawQuery = reqURL.RawQuery 195 subdomain.Fragment = reqURL.Fragment 196 params := url.Values{ 197 "redirect": {subdomain.String()}, 198 } 199 if jwt := c.QueryParam("jwt"); jwt != "" { 200 params.Add("jwt", jwt) 201 } 202 return c.Redirect(http.StatusFound, i.PageURL("/auth/login", params)) 203 } 204 205 version := webapp.Version() 206 shasum := webapp.Checksum() 207 208 if file != route.Index { 209 // If file is not the index, it is considered an asset of the application 210 // (JS, image, ...). For theses assets we check if it contains an unique 211 // identifier to help caching. In such case, a long cache (1 year) is set. 212 // 213 // A unique identifier is matched when the file base contains a "long" 214 // hexadecimal subpart between '.', of at least 10 characters: for instance 215 // "app.badf00dbadf00d.js". 216 if _, id := statik.ExtractAssetID(file); id != "" { 217 c.Response().Header().Set("Cache-Control", "max-age=31536000, immutable") 218 } 219 220 err := fs.ServeFileContent(c.Response(), c.Request(), slug, version, shasum, filepath) 221 if os.IsNotExist(err) { 222 if isRobotsTxt { 223 if f, ok := assets.Get("/robots.txt", i.ContextName); ok { 224 _, err = io.Copy(c.Response(), f.Reader()) 225 return err 226 } 227 } 228 return echo.NewHTTPError(http.StatusNotFound, "Asset not found") 229 } 230 if err != nil { 231 return echo.NewHTTPError(http.StatusInternalServerError, err) 232 } 233 234 return nil 235 } 236 237 if !isLoggedIn { 238 doc, err := i.SettingsDocument() 239 if err == nil { 240 if to, ok := doc.M["moved_to"].(string); ok && to != "" { 241 subdomainType, _ := doc.M["moved_to_subdomain_type"].(string) 242 return renderMovedLink(c, i, to, subdomainType) 243 } 244 } 245 } 246 247 // For share by link, show the password page if it is password protected. 248 code := c.QueryParam("sharecode") 249 token, err := middlewares.TransformShortcodeToJWT(i, code) 250 if err == nil { 251 claims, err := middlewares.ExtractClaims(c, i, token) 252 if err == nil && claims.AudienceString() == consts.ShareAudience { 253 pdoc, err := permission.GetForShareCode(i, token) 254 if err == nil && pdoc.Password != nil && !middlewares.HasCookieForPassword(c, i, pdoc.ID()) { 255 return renderPasswordPage(c, i, pdoc.ID()) 256 } 257 } 258 } 259 260 if intentID := c.QueryParam("intent"); intentID != "" { 261 handleIntent(c, i, slug, intentID) 262 } 263 264 // For index file, we inject the locale, the stack domain, and a token if the 265 // user is connected 266 content, err := fs.Open(slug, version, shasum, filepath) 267 if err != nil { 268 return err 269 } 270 defer content.Close() 271 272 buf, err := io.ReadAll(content) 273 if err != nil { 274 return err 275 } 276 277 // XXX: Force include Warnings template in all app indexes 278 tmplText := string(buf) 279 if closeTagIdx := strings.Index(tmplText, "</head>"); closeTagIdx >= 0 { 280 tmplText = tmplText[:closeTagIdx] + "\n{{.Warnings}}\n" + tmplText[closeTagIdx:] 281 } else { 282 needsOpenTag := true 283 if openTagIdx := strings.Index(tmplText, "<head>"); openTagIdx >= 0 { 284 needsOpenTag = false 285 } 286 287 if bodyTagIdx := strings.Index(tmplText, "<body>"); bodyTagIdx >= 0 { 288 before := tmplText[:bodyTagIdx] 289 after := tmplText[bodyTagIdx:] 290 291 tmplText = before 292 293 if needsOpenTag { 294 tmplText += "\n<head>" 295 } 296 297 tmplText += "\n{{.Warnings}}\n</head>\n" + after 298 } 299 } 300 301 tmpl, err := template.New(file).Parse(tmplText) 302 if err != nil { 303 i.Logger().WithNamespace("apps").Warnf("%s cannot be parsed as a template: %s", file, err) 304 return fs.ServeFileContent(c.Response(), c.Request(), slug, version, shasum, filepath) 305 } 306 307 sessID := "" 308 if isLoggedIn { 309 sessID = sess.ID() 310 311 if file == "" || file == route.Index { 312 if !route.Public { 313 if handled, err := middlewares.CheckOAuthClientsLimitExceeded(c); handled { 314 return err 315 } 316 } 317 } 318 } 319 params := buildServeParams(c, i, webapp, isLoggedIn, sessID) 320 321 generated := &bytes.Buffer{} 322 if err := tmpl.Execute(generated, params); err != nil { 323 i.Logger().WithNamespace("apps").Warnf("%s cannot be interpreted as a template: %s", file, err) 324 return c.Render(http.StatusInternalServerError, "error.html", echo.Map{ 325 "Domain": i.ContextualDomain(), 326 "ContextName": i.ContextName, 327 "Locale": i.Locale, 328 "Title": i.TemplateTitle(), 329 "Favicon": middlewares.Favicon(i), 330 "Illustration": "/images/generic-error.svg", 331 "Error": "Error Application not supported Message", 332 "SupportEmail": i.SupportEmailAddress(), 333 }) 334 } 335 336 res := c.Response() 337 res.Header().Set(echo.HeaderContentType, echo.MIMETextHTMLCharsetUTF8) 338 res.Header().Set("Cache-Control", "private, no-store, must-revalidate") 339 res.WriteHeader(http.StatusOK) 340 _, err = io.Copy(res, generated) 341 return err 342 } 343 344 func buildServeParams( 345 c echo.Context, 346 inst *instance.Instance, 347 webapp *app.WebappManifest, 348 isLoggedIn bool, 349 sessID string, 350 ) serveParams { 351 token := getServeToken(c, inst, webapp, isLoggedIn, sessID) 352 tracking := false 353 settings, err := inst.SettingsDocument() 354 if err == nil { 355 if t, ok := settings.M["tracking"].(string); ok { 356 tracking = t == "true" 357 } 358 } 359 var subdomainsType string 360 switch config.GetConfig().Subdomains { 361 case config.FlatSubdomains: 362 subdomainsType = "flat" 363 case config.NestedSubdomains: 364 subdomainsType = "nested" 365 } 366 367 return serveParams{ 368 Token: token, 369 SubDomain: subdomainsType, 370 Tracking: tracking, 371 webapp: webapp, 372 instance: inst, 373 isLoggedIn: isLoggedIn, 374 } 375 } 376 377 func getServeToken( 378 c echo.Context, 379 inst *instance.Instance, 380 webapp *app.WebappManifest, 381 isLoggedIn bool, 382 sessID string, 383 ) string { 384 sharecode := c.QueryParam("sharecode") 385 if sharecode == "" { 386 if isLoggedIn { 387 return inst.BuildAppToken(webapp.Slug(), sessID) 388 } 389 return "" 390 } 391 392 // XXX The sharecode can be used for share by links, or for Cozy to Cozy 393 // sharings. When it is used for a Cozy to Cozy sharing, it can be for a 394 // preview token, and we need to upgrade it to an interact token if the 395 // member has a known Cozy URL. We do this upgrade when serving the preview 396 // page, not when sending the invitation link by mail, because we want the 397 // same link to work (and be upgraded) after the user has accepted the 398 // sharing. 399 token, pdoc, err := permission.GetTokenAndPermissionsFromShortcode(inst, sharecode) 400 if err != nil || pdoc.Type != permission.TypeSharePreview { 401 return sharecode 402 } 403 sharingID := strings.Split(pdoc.SourceID, "/") 404 sharingDoc, err := sharing.FindSharing(inst, sharingID[1]) 405 if err != nil || sharingDoc.ReadOnlyRules() { 406 return token 407 } 408 m, err := sharingDoc.FindMemberBySharecode(inst, token) 409 if err != nil { 410 return token 411 } 412 if m.Instance != "" && !m.ReadOnly && m.Status != sharing.MemberStatusRevoked { 413 memberIndex := 0 414 for i := range sharingDoc.Members { 415 if sharingDoc.Members[i].Instance == m.Instance { 416 memberIndex = i 417 } 418 } 419 interact, err := sharingDoc.GetInteractCode(inst, m, memberIndex) 420 if err == nil { 421 return interact 422 } 423 } 424 return token 425 } 426 427 func renderMovedLink(c echo.Context, i *instance.Instance, to, subdomainType string) error { 428 name, _ := csettings.PublicName(i) 429 link := *c.Request().URL 430 if u, err := url.Parse(to); err == nil { 431 parts := strings.SplitN(c.Request().Host, ".", 2) 432 app := parts[0] 433 if config.GetConfig().Subdomains == config.FlatSubdomains { 434 parts = strings.SplitN(app, "-", 2) 435 app = parts[len(parts)-1] 436 } 437 if subdomainType == "nested" { 438 link.Host = app + "." + u.Host 439 } else { 440 parts := strings.SplitN(u.Host, ".", 2) 441 link.Host = parts[0] + "-" + app + "." + parts[1] 442 } 443 link.Scheme = u.Scheme 444 } 445 446 return c.Render(http.StatusGone, "move_link.html", echo.Map{ 447 "Domain": i.ContextualDomain(), 448 "ContextName": i.ContextName, 449 "Locale": i.Locale, 450 "Title": i.Translate("Move Link Title", name), 451 "ThemeCSS": middlewares.ThemeCSS(i), 452 "Favicon": middlewares.Favicon(i), 453 "Link": link.String(), 454 }) 455 } 456 457 func renderPasswordPage(c echo.Context, inst *instance.Instance, permID string) error { 458 return c.Render(http.StatusUnauthorized, "share_by_link_password.html", echo.Map{ 459 "Action": inst.PageURL("/auth/share-by-link/password", nil), 460 "Domain": inst.ContextualDomain(), 461 "ContextName": inst.ContextName, 462 "Locale": inst.Locale, 463 "Title": inst.TemplateTitle(), 464 "ThemeCSS": middlewares.ThemeCSS(inst), 465 "Favicon": middlewares.Favicon(inst), 466 "PermID": permID, 467 }) 468 } 469 470 // serveParams is a struct used for rendering the index.html of webapps. A 471 // struct is used, and not a map, to have some methods declared on it. It 472 // allows to be lazy when constructing the paths of the assets: if an asset is 473 // not used in the template, the method won't be called and the stack can avoid 474 // checking if this asset is dynamically overridden in this instance context. 475 type serveParams struct { 476 Token string 477 SubDomain string 478 Tracking bool 479 webapp *app.WebappManifest 480 instance *instance.Instance 481 isLoggedIn bool 482 } 483 484 func (s serveParams) CozyData() (string, error) { 485 data := map[string]interface{}{ 486 "token": s.Token, 487 "domain": s.Domain(), 488 "subdomain": s.SubDomain, 489 "tracking": s.Tracking, 490 "locale": s.Locale(), 491 "app": map[string]interface{}{ 492 "editor": s.AppEditor(), 493 "name": s.AppName(), 494 "prefix": s.AppNamePrefix(), 495 "slug": s.AppSlug(), 496 "icon": s.IconPath(), 497 }, 498 "flags": s.GetFlags(), 499 "capabilities": s.GetCapabilities(), 500 } 501 bytes, err := json.Marshal(data) 502 503 if err != nil { 504 return "", err 505 } 506 507 return string(bytes), nil 508 } 509 510 func (s serveParams) Domain() string { 511 return s.instance.ContextualDomain() 512 } 513 514 func (s serveParams) ContextName() string { 515 return s.instance.ContextName 516 } 517 518 func (s serveParams) Locale() string { 519 return s.instance.Locale 520 } 521 522 func (s serveParams) AppSlug() string { 523 return s.webapp.Slug() 524 } 525 526 func (s serveParams) AppName() string { 527 return s.webapp.NameLocalized(s.instance.Locale) 528 } 529 530 func (s serveParams) AppEditor() string { 531 return s.webapp.Editor() 532 } 533 534 func (s serveParams) AppNamePrefix() string { 535 return s.webapp.NamePrefix() 536 } 537 538 func (s serveParams) IconPath() string { 539 return s.webapp.Icon() 540 } 541 542 func (s serveParams) Capabilities() (string, error) { 543 bytes, err := json.Marshal(s.GetCapabilities()) 544 if err != nil { 545 return "", err 546 } 547 return string(bytes), nil 548 } 549 550 func (s serveParams) GetCapabilities() jsonapi.Object { 551 capabilities := settings.NewCapabilities(s.instance) 552 capabilities.SetID("") 553 return capabilities 554 } 555 556 func (s serveParams) Flags() (string, error) { 557 flags, err := feature.GetFlags(s.instance) 558 if err != nil { 559 return "{}", err 560 } 561 bytes, err := json.Marshal(flags) 562 if err != nil { 563 return "", err 564 } 565 return string(bytes), nil 566 } 567 568 func (s serveParams) GetFlags() *feature.Flags { 569 flags, err := feature.GetFlags(s.instance) 570 if err != nil { 571 flags = &feature.Flags{ 572 M: map[string]interface{}{}, 573 } 574 } 575 return flags 576 } 577 578 func (s serveParams) CozyBar() (template.HTML, error) { 579 return cozybarHTML(s.instance, s.isLoggedIn) 580 } 581 582 func (s serveParams) CozyClientJS() (template.HTML, error) { 583 return cozyclientjsHTML(s.instance) 584 } 585 586 func (s serveParams) CozyFonts() template.HTML { 587 return middlewares.CozyFonts(s.instance) 588 } 589 590 func (s serveParams) ThemeCSS() template.HTML { 591 return middlewares.ThemeCSS(s.instance) 592 } 593 594 func (s serveParams) Favicon() template.HTML { 595 return middlewares.Favicon(s.instance) 596 } 597 598 func (s serveParams) DefaultWallpaper() string { 599 return statik.AssetPath( 600 s.instance.ContextualDomain(), 601 "/images/default-wallpaper.jpg", 602 s.instance.ContextName) 603 } 604 605 func (s serveParams) Warnings() (template.HTML, error) { 606 return warningsHTML(s.instance, s.isLoggedIn) 607 } 608 609 var clientTemplate *template.Template 610 var barTemplate *template.Template 611 var warningsTemplate *template.Template 612 613 // BuildTemplates ensure that cozy-client-js and the bar can be injected in templates 614 func BuildTemplates() { 615 clientTemplate = template.Must(template.New("cozy-client-js").Funcs(middlewares.FuncsMap).Parse(`` + 616 `<script src="{{asset .Domain "/js/cozy-client.min.js" .ContextName}}"></script>`, 617 )) 618 619 barTemplate = template.Must(template.New("cozy-bar").Funcs(middlewares.FuncsMap).Parse(` 620 <link rel="stylesheet" type="text/css" href="{{asset .Domain "/fonts/fonts.css" .ContextName}}"> 621 <link rel="stylesheet" type="text/css" href="{{asset .Domain "/css/cozy-bar.min.css" .ContextName}}"> 622 <script src="{{asset .Domain "/js/cozy-bar.min.js" .ContextName}}"></script>`, 623 )) 624 625 warningsTemplate = template.Must(template.New("warnings").Funcs(middlewares.FuncsMap).Parse(` 626 {{if .LoggedIn}} 627 {{range .Warnings}} 628 <meta name="user-action-required" data-title="{{ .Title }}" data-code="{{ .Code }}" data-detail="{{ .Detail }}" {{with .Links}}{{with .Self}}data-links="{{ . }}"{{end}}{{end}} /> 629 {{end}} 630 {{end}}`, 631 )) 632 } 633 634 func cozyclientjsHTML(i *instance.Instance) (template.HTML, error) { 635 buf := new(bytes.Buffer) 636 err := clientTemplate.Execute(buf, echo.Map{ 637 "Domain": i.ContextualDomain(), 638 "ContextName": i.ContextName, 639 }) 640 if err != nil { 641 return "", err 642 } 643 return template.HTML(buf.String()), nil 644 } 645 646 func cozybarHTML(i *instance.Instance, loggedIn bool) (template.HTML, error) { 647 buf := new(bytes.Buffer) 648 err := barTemplate.Execute(buf, echo.Map{ 649 "Domain": i.ContextualDomain(), 650 "Warnings": middlewares.ListWarnings(i), 651 "ContextName": i.ContextName, 652 "LoggedIn": loggedIn, 653 }) 654 if err != nil { 655 return "", err 656 } 657 return template.HTML(buf.String()), nil 658 } 659 660 func warningsHTML(i *instance.Instance, loggedIn bool) (template.HTML, error) { 661 buf := new(bytes.Buffer) 662 err := warningsTemplate.Execute(buf, echo.Map{ 663 "Warnings": middlewares.ListWarnings(i), 664 "LoggedIn": loggedIn, 665 }) 666 if err != nil { 667 return "", err 668 } 669 return template.HTML(buf.String()), nil 670 }