package apps import ( "bytes" "encoding/json" "errors" "html/template" "io" "net/http" "net/url" "os" "path" "strings" "github.com/cozy/cozy-stack/model/app" "github.com/cozy/cozy-stack/model/feature" "github.com/cozy/cozy-stack/model/instance" "github.com/cozy/cozy-stack/model/intent" "github.com/cozy/cozy-stack/model/permission" "github.com/cozy/cozy-stack/model/publiclink" "github.com/cozy/cozy-stack/model/session" csettings "github.com/cozy/cozy-stack/model/settings" "github.com/cozy/cozy-stack/model/sharing" "github.com/cozy/cozy-stack/pkg/appfs" "github.com/cozy/cozy-stack/pkg/assets" "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/consts" "github.com/cozy/cozy-stack/pkg/couchdb" "github.com/cozy/cozy-stack/pkg/jsonapi" "github.com/cozy/cozy-stack/pkg/registry" "github.com/cozy/cozy-stack/web/auth" "github.com/cozy/cozy-stack/web/intents" "github.com/cozy/cozy-stack/web/middlewares" "github.com/cozy/cozy-stack/web/publiclinkevents" "github.com/cozy/cozy-stack/web/settings" "github.com/cozy/cozy-stack/web/statik" "github.com/labstack/echo/v4" ) // Serve is an handler for serving files from the VFS for a client-side app func Serve(c echo.Context) error { method := c.Request().Method if method != "GET" && method != "HEAD" { return echo.NewHTTPError(http.StatusMethodNotAllowed, "Method "+method+" not allowed") } i := middlewares.GetInstance(c) slug := c.Get("slug").(string) if !i.OnboardingFinished { return c.Redirect(http.StatusFound, i.PageURL("/", nil)) } webapp, err := app.GetWebappBySlug(i, slug) if err != nil { if errors.Is(err, app.ErrNotFound) { return handleAppNotFound(c, i, slug) } return err } route, file := webapp.FindRoute(path.Clean(c.Request().URL.Path)) if webapp.FromAppsDir { // Save permissions in couchdb before loading an index page if file == "" && webapp.Permissions() != nil { _ = permission.ForceWebapp(i, webapp.Slug(), webapp.Permissions()) } fs := app.FSForAppDir(slug) return ServeAppFile(c, i, fs, webapp) } if file == "" || file == route.Index { webapp = app.DoLazyUpdate(i, webapp, app.Copier(consts.WebappType, i), i.Registries()).(*app.WebappManifest) } switch webapp.State() { case app.Installed: // This legacy "installed" state is not used anymore with the addition // of the registry. Change the webapp state to "ready" and serve the app // file. webapp.SetState(app.Ready) if err := webapp.Update(i, nil); err != nil { return err } fallthrough case app.Ready: return ServeAppFile(c, i, app.AppsFileServer(i), webapp) default: return echo.NewHTTPError(http.StatusServiceUnavailable, "Application is not ready") } } // handleAppNotFound is used to render the error page when the user wants to // access an app that is not yet installed func handleAppNotFound(c echo.Context, i *instance.Instance, slug string) error { // Used for the "collect" => "home" renaming if slug == "collect" { return c.Redirect(http.StatusMovedPermanently, i.DefaultRedirection().String()) } // Used for the deprecated "onboarding" app if slug == "onboarding" { return c.Redirect(http.StatusMovedPermanently, i.DefaultRedirection().String()) } i.Logger().WithNamespace("apps").Infof("App not found: %s", slug) if _, err := registry.GetApplication(slug, i.Registries()); err != nil { return app.ErrNotFound } if _, err := app.GetWebappBySlug(i, consts.StoreSlug); err != nil { return app.ErrNotFound } u := i.SubDomain(consts.StoreSlug) u.Fragment = "/discover/" + slug return c.Redirect(http.StatusTemporaryRedirect, u.String()) } // handleIntent will allow iframes from another app if the current app is // opened as an intent. It returns the loaded intent so the caller can inline // it into the served page. A nil return means the intent could not be used // in that case the caller simply skips inlining, preserving the previous behavior. func handleIntent(c echo.Context, i *instance.Instance, slug, intentID string) *intent.Intent { intent := &intent.Intent{} if err := couchdb.GetDoc(i, consts.Intents, intentID, intent); err != nil { return nil } allowed := false for _, service := range intent.Services { if slug == service.Slug { allowed = true } } if !allowed { return nil } parts := strings.SplitN(intent.Client, "/", 2) if len(parts) < 2 || parts[0] != consts.Apps { return nil } from := app.ResolveClientURL(i, parts[1]) if !config.GetConfig().CSPDisabled { middlewares.AppendCSPRule(c, "frame-ancestors", from) // When the client app is hosted on a non-cozy domain (via its // client_url_flag), it can itself be framed by its cozy subdomain // parent. frame-ancestors must list every ancestor in the iframe // chain, so also allow the cozy subdomain. if defaultFrom := app.DefaultClientURL(i, parts[1]); defaultFrom != from { middlewares.AppendCSPRule(c, "frame-ancestors", defaultFrom) } } return intent } // allowContainerAppsFrame adds to frame-ancestors the cozy subdomain and the // client URL (via its client_url_flag feature flag) of every installed app // that declares one. Dataproxy can be nested several iframes deep in an // intent flow (e.g. a container app framing its external client, which frames // the intent client, which frames dataproxy), and frame-ancestors must list // every ancestor in the chain. func allowContainerAppsFrame(c echo.Context, i *instance.Instance) { if config.GetConfig().CSPDisabled { return } manifests, _, err := app.ListWebappsWithPagination(i, 0, "") if err != nil { return } for _, manifest := range manifests { if manifest.ClientURLFlag() == "" { continue } from := app.ResolveClientURL(i, manifest.Slug()) defaultFrom := app.DefaultClientURL(i, manifest.Slug()) middlewares.AppendCSPRule(c, "frame-ancestors", from) if defaultFrom != from { middlewares.AppendCSPRule(c, "frame-ancestors", defaultFrom) } } } // allowExternalIntentServicesFrame adds to frame-src the origin of every // installed app whose intent services are hosted outside the cozy domain (via // its service_url_flag), so that any app can open their intents in an iframe. func allowExternalIntentServicesFrame(c echo.Context, i *instance.Instance) { if config.GetConfig().CSPDisabled { return } bases, err := app.ExternalServiceURLs(i) if err != nil { return } for _, base := range bases { middlewares.AppendCSPRule(c, "frame-src", base.Scheme+"://"+base.Host) } } // ServeAppFile will serve the requested file using the specified application // manifest and appfs.FileServer context. // // It can be used to serve file application in another context than the VFS, // for instance for tests or development purposes where we want to serve an // application that is not installed on the user's instance. However this // procedure should not be used for standard applications, use the Serve method // for that. func ServeAppFile(c echo.Context, i *instance.Instance, fs appfs.FileServer, webapp *app.WebappManifest) (serveErr error) { slug := webapp.Slug() route, file := webapp.FindRoute(path.Clean(c.Request().URL.Path)) if route.NotFound() { return echo.NewHTTPError(http.StatusNotFound, "Page not found") } if file == "" { file = route.Index } sess, isLoggedIn := middlewares.GetSession(c) if code := c.QueryParam("session_code"); code != "" { // XXX we should always clear the session code to avoid it being // reused, even if the user is already logged in and we don't want to // create a new session if checked := i.CheckAndClearSessionCode(code); checked && !isLoggedIn { sessionID, err := auth.SetCookieForNewSession(c, session.NormalRun, "") req := c.Request() if err == nil { if err = session.StoreNewLoginEntry(i, sessionID, "", req, "session_code"); err != nil { i.Logger().Errorf("Could not store session history %q: %s", sessionID, err) } } redirect := req.URL redirect.RawQuery = sessionCodeRedirectQuery(redirect.Query()).Encode() return c.Redirect(http.StatusSeeOther, redirect.String()) } } filepath := path.Join("/", route.Folder, file) isRobotsTxt := filepath == "/robots.txt" if !route.Public && !isLoggedIn { if isRobotsTxt { if f, ok := assets.Get("/robots.txt", i.ContextName); ok { _, err := io.Copy(c.Response(), f.Reader()) return err } } if file != route.Index { return echo.NewHTTPError(http.StatusUnauthorized, "You must be authenticated") } reqURL := c.Request().URL subdomain := i.SubDomain(slug) subdomain.Path = reqURL.Path subdomain.RawQuery = reqURL.RawQuery subdomain.Fragment = reqURL.Fragment params := url.Values{ "redirect": {subdomain.String()}, } if jwt := c.QueryParam("jwt"); jwt != "" { params.Add("jwt", jwt) } return c.Redirect(http.StatusFound, i.PageURL("/auth/login", params)) } version := webapp.Version() shasum := webapp.Checksum() if file != route.Index { // If file is not the index, it is considered an asset of the application // (JS, image, ...). For theses assets we check if it contains an unique // identifier to help caching. In such case, a long cache (1 year) is set. // // A unique identifier is matched when the file base contains a "long" // hexadecimal subpart between '.', of at least 10 characters: for instance // "app.badf00dbadf00d.js". if _, id := statik.ExtractAssetID(file); id != "" { c.Response().Header().Set("Cache-Control", "max-age=31536000, immutable") } err := fs.ServeFileContent(c.Response(), c.Request(), slug, version, shasum, filepath) if os.IsNotExist(err) { if isRobotsTxt { if f, ok := assets.Get("/robots.txt", i.ContextName); ok { _, err = io.Copy(c.Response(), f.Reader()) return err } } return echo.NewHTTPError(http.StatusNotFound, "Asset not found") } if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err) } return nil } if !isLoggedIn { doc, err := i.SettingsDocument() if err == nil { if to, ok := doc.M["moved_to"].(string); ok && to != "" { subdomainType, _ := doc.M["moved_to_subdomain_type"].(string) return renderMovedLink(c, i, to, subdomainType) } } } // For share by link, show the password page if it is password protected. var og *openGraph var publicLink *permission.Permission code := c.QueryParam("sharecode") token, err := middlewares.TransformShortcodeToJWT(i, code) if err == nil { claims, err := middlewares.ExtractClaims(c, i, token) if err == nil && claims.AudienceString() == consts.ShareAudience { pdoc, err := permission.GetForShareCode(i, token) if err == nil && route.OpenGraph { og = newOpenGraph(i, webapp, pdoc) } if err == nil && pdoc.Password != nil && !middlewares.HasCookieForPassword(c, i, pdoc.ID()) { return renderPasswordPage(c, i, pdoc.ID()) } if err == nil { publicLink = pdoc } } } defer func() { if serveErr == nil && c.Request().Method == http.MethodGet && c.Response().Committed && c.Response().Status == http.StatusOK { publiclinkevents.Record(c, i, publicLink, publiclink.Opened) } }() allowExternalIntentServicesFrame(c, i) var intentDoc *intent.Intent if intentID := c.QueryParam("intent"); intentID != "" { intentDoc = handleIntent(c, i, slug, intentID) if intentDoc != nil && slug == consts.DataProxySlug { allowContainerAppsFrame(c, i) } } if route.Public && slug == consts.DataProxySlug { // Allow to dataproxy to be embedded in a iframe from the login page of the // stack for cleaning. middlewares.AppendCSPRule(c, "frame-ancestors", i.PageURL("/", nil)) } // For index file, we inject the locale, the stack domain, and a token if the // user is connected content, err := fs.Open(slug, version, shasum, filepath) if err != nil { return err } defer content.Close() buf, err := io.ReadAll(content) if err != nil { return err } // XXX: Force include Warnings template in all app indexes tmplText := string(buf) if closeTagIdx := strings.Index(tmplText, ""); closeTagIdx >= 0 { tmplText = tmplText[:closeTagIdx] + "\n{{.Warnings}}\n{{.IntentData}}\n{{.OpenGraph}}\n" + tmplText[closeTagIdx:] } else { needsOpenTag := true if openTagIdx := strings.Index(tmplText, ""); openTagIdx >= 0 { needsOpenTag = false } if bodyTagIdx := strings.Index(tmplText, ""); bodyTagIdx >= 0 { before := tmplText[:bodyTagIdx] after := tmplText[bodyTagIdx:] tmplText = before if needsOpenTag { tmplText += "\n" } tmplText += "\n{{.Warnings}}\n{{.IntentData}}\n{{.OpenGraph}}\n\n" + after } } tmpl, err := template.New(file).Parse(tmplText) if err != nil { i.Logger().WithNamespace("apps").Warnf("%s cannot be parsed as a template: %s", file, err) return fs.ServeFileContent(c.Response(), c.Request(), slug, version, shasum, filepath) } sessID := "" if isLoggedIn { sessID = sess.ID() if file == "" || file == route.Index { if !route.Public { if handled, err := middlewares.CheckOAuthClientsLimitExceeded(c); handled { return err } } } } params := buildServeParams(c, i, webapp, intentDoc, isLoggedIn, sessID) params.openGraph = og generated := &bytes.Buffer{} if err := tmpl.Execute(generated, params); err != nil { i.Logger().WithNamespace("apps").Warnf("%s cannot be interpreted as a template: %s", file, err) return c.Render(http.StatusInternalServerError, "error.html", echo.Map{ "Domain": i.ContextualDomain(), "ContextName": i.ContextName, "Locale": i.Locale, "Title": i.TemplateTitle(), "Favicon": middlewares.Favicon(i), "Illustration": "/images/generic-error.svg", "Error": "Error Application not supported Message", "SupportEmail": i.SupportEmailAddress(), "SupportPageURL": i.SupportPageURL(), }) } res := c.Response() res.Header().Set(echo.HeaderContentType, echo.MIMETextHTMLCharsetUTF8) res.Header().Set("Cache-Control", "private, no-store, must-revalidate") res.WriteHeader(http.StatusOK) _, err = io.Copy(res, generated) return err } func sessionCodeRedirectQuery(query url.Values) url.Values { redirectQuery := url.Values{} if intentID := query.Get("intent"); intentID != "" { redirectQuery.Set("intent", intentID) } return redirectQuery } func buildServeParams( c echo.Context, inst *instance.Instance, webapp *app.WebappManifest, intentDoc *intent.Intent, isLoggedIn bool, sessID string, ) serveParams { token := getServeToken(c, inst, webapp, isLoggedIn, sessID) tracking := false settings, err := inst.SettingsDocument() if err == nil { if t, ok := settings.M["tracking"].(string); ok { tracking = t == "true" } } var subdomainsType string switch config.GetConfig().Subdomains { case config.FlatSubdomains: subdomainsType = "flat" case config.NestedSubdomains: subdomainsType = "nested" } return serveParams{ Token: token, SubDomain: subdomainsType, Tracking: tracking, intent: intentDoc, webapp: webapp, instance: inst, isLoggedIn: isLoggedIn, } } func getServeToken( c echo.Context, inst *instance.Instance, webapp *app.WebappManifest, isLoggedIn bool, sessID string, ) string { sharecode := c.QueryParam("sharecode") if sharecode == "" { if isLoggedIn { return inst.BuildAppToken(webapp.Slug(), sessID) } return "" } // XXX The sharecode can be used for share by links, or for Cozy to Cozy // sharings. When it is used for a Cozy to Cozy sharing, it can be for a // preview token, and we need to upgrade it to an interact token if the // member has a known Cozy URL. We do this upgrade when serving the preview // page, not when sending the invitation link by mail, because we want the // same link to work (and be upgraded) after the user has accepted the // sharing. token, pdoc, err := permission.GetTokenAndPermissionsFromShortcode(inst, sharecode) if err != nil || pdoc.Type != permission.TypeSharePreview { return sharecode } sharingID := strings.Split(pdoc.SourceID, "/") sharingDoc, err := sharing.FindSharing(inst, sharingID[1]) if err != nil || sharingDoc.ReadOnlyRules() { return token } m, err := sharingDoc.FindMemberBySharecode(inst, token) if err != nil { return token } if m.Instance != "" && !m.ReadOnly && m.Status != sharing.MemberStatusRevoked { memberIndex := 0 for i := range sharingDoc.Members { if sharingDoc.Members[i].Instance == m.Instance { memberIndex = i } } interact, err := sharingDoc.GetInteractCode(inst, m, memberIndex) if err == nil { return interact } } return token } func renderMovedLink(c echo.Context, i *instance.Instance, to, subdomainType string) error { name, _ := csettings.PublicName(i) link := *c.Request().URL if u, err := url.Parse(to); err == nil { parts := strings.SplitN(c.Request().Host, ".", 2) app := parts[0] if config.GetConfig().Subdomains == config.FlatSubdomains { parts = strings.SplitN(app, "-", 2) app = parts[len(parts)-1] } if subdomainType == "nested" { link.Host = app + "." + u.Host } else { parts := strings.SplitN(u.Host, ".", 2) link.Host = parts[0] + "-" + app + "." + parts[1] } link.Scheme = u.Scheme } return c.Render(http.StatusGone, "move_link.html", echo.Map{ "Domain": i.ContextualDomain(), "ContextName": i.ContextName, "Locale": i.Locale, "Title": i.Translate("Move Link Title", name), "ThemeCSS": middlewares.ThemeCSS(i), "Favicon": middlewares.Favicon(i), "Link": link.String(), }) } func renderPasswordPage(c echo.Context, inst *instance.Instance, permID string) error { return c.Render(http.StatusUnauthorized, "share_by_link_password.html", echo.Map{ "Action": inst.PageURL("/auth/share-by-link/password", nil), "Domain": inst.ContextualDomain(), "ContextName": inst.ContextName, "Locale": inst.Locale, "Title": inst.TemplateTitle(), "ThemeCSS": middlewares.ThemeCSS(inst), "Favicon": middlewares.Favicon(inst), "PermID": permID, }) } // serveParams is a struct used for rendering the index.html of webapps. A // struct is used, and not a map, to have some methods declared on it. It // allows to be lazy when constructing the paths of the assets: if an asset is // not used in the template, the method won't be called and the stack can avoid // checking if this asset is dynamically overridden in this instance context. type serveParams struct { Token string SubDomain string Tracking bool intent *intent.Intent webapp *app.WebappManifest instance *instance.Instance isLoggedIn bool openGraph *openGraph } func (s serveParams) CozyData() (string, error) { data := map[string]interface{}{ "token": s.Token, "domain": s.Domain(), "subdomain": s.SubDomain, "tracking": s.Tracking, "locale": s.Locale(), "isLoggedIn": s.isLoggedIn, "app": map[string]interface{}{ "editor": s.AppEditor(), "name": s.AppName(), "prefix": s.AppNamePrefix(), "slug": s.AppSlug(), "icon": s.IconPath(), }, "flags": s.GetFlags(), "capabilities": s.GetCapabilities(), } bytes, err := json.Marshal(data) if err != nil { return "", err } return string(bytes), nil } func (s serveParams) Domain() string { return s.instance.ContextualDomain() } func (s serveParams) ContextName() string { return s.instance.ContextName } func (s serveParams) Locale() string { return s.instance.Locale } func (s serveParams) AppSlug() string { return s.webapp.Slug() } func (s serveParams) AppName() string { return s.webapp.NameLocalized(s.instance.Locale) } func (s serveParams) AppEditor() string { return s.webapp.Editor() } func (s serveParams) AppNamePrefix() string { return s.webapp.NamePrefix() } func (s serveParams) IconPath() string { return s.webapp.Icon() } func (s serveParams) Capabilities() (string, error) { bytes, err := json.Marshal(s.GetCapabilities()) if err != nil { return "", err } return string(bytes), nil } func (s serveParams) GetCapabilities() jsonapi.Object { capabilities := settings.NewCapabilities(s.instance) capabilities.SetID("") return capabilities } func (s serveParams) Flags() (string, error) { flags, err := feature.GetFlags(s.instance) if err != nil { return "{}", err } bytes, err := json.Marshal(flags) if err != nil { return "", err } return string(bytes), nil } func (s serveParams) GetFlags() *feature.Flags { flags, err := feature.GetFlags(s.instance) if err != nil { flags = &feature.Flags{ M: map[string]interface{}{}, } } return flags } func (s serveParams) CozyBar() (template.HTML, error) { return cozybarHTML(s.instance, s.isLoggedIn) } func (s serveParams) CozyClientJS() (template.HTML, error) { return cozyclientjsHTML(s.instance) } func (s serveParams) CozyFonts() template.HTML { return middlewares.CozyFonts(s.instance) } func (s serveParams) ThemeCSS() template.HTML { return middlewares.ThemeCSS(s.instance) } func (s serveParams) Favicon() template.HTML { return middlewares.Favicon(s.instance) } func (s serveParams) DefaultWallpaper() string { return statik.AssetPath( s.instance.ContextualDomain(), "/images/default-wallpaper.jpg", s.instance.ContextName) } func (s serveParams) Warnings() (template.HTML, error) { return warningsHTML(s.instance, s.isLoggedIn) } func (s serveParams) OpenGraph() template.HTML { return s.openGraph.HTML() } func (s serveParams) IntentData() (template.HTML, error) { if s.intent == nil { return "", nil } api := intents.NewAPIIntent(s.intent, s.instance) raw, err := jsonapi.MarshalObject(api) if err != nil { return "", err } // jsonapi.MarshalObject uses json.Marshal that escapes <, >, & to \u003c, \u003e, \u0026, so the JSON is // safe to embed in a breakout). return template.HTML(``), nil } var clientTemplate *template.Template var barTemplate *template.Template var warningsTemplate *template.Template // BuildTemplates ensure that cozy-client-js and the bar can be injected in templates func BuildTemplates() { clientTemplate = template.Must(template.New("cozy-client-js").Funcs(middlewares.FuncsMap).Parse(`` + ``, )) barTemplate = template.Must(template.New("cozy-bar").Funcs(middlewares.FuncsMap).Parse(` `, )) warningsTemplate = template.Must(template.New("warnings").Funcs(middlewares.FuncsMap).Parse(` {{if .LoggedIn}} {{range .Warnings}} {{end}} {{end}}`, )) } func cozyclientjsHTML(i *instance.Instance) (template.HTML, error) { buf := new(bytes.Buffer) err := clientTemplate.Execute(buf, echo.Map{ "Domain": i.ContextualDomain(), "ContextName": i.ContextName, }) if err != nil { return "", err } return template.HTML(buf.String()), nil } func cozybarHTML(i *instance.Instance, loggedIn bool) (template.HTML, error) { buf := new(bytes.Buffer) err := barTemplate.Execute(buf, echo.Map{ "Domain": i.ContextualDomain(), "Warnings": middlewares.ListWarnings(i), "ContextName": i.ContextName, "LoggedIn": loggedIn, }) if err != nil { return "", err } return template.HTML(buf.String()), nil } func warningsHTML(i *instance.Instance, loggedIn bool) (template.HTML, error) { buf := new(bytes.Buffer) err := warningsTemplate.Execute(buf, echo.Map{ "Warnings": middlewares.ListWarnings(i), "LoggedIn": loggedIn, }) if err != nil { return "", err } return template.HTML(buf.String()), nil }