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