// Package app wires the application together: configuration, store, auth, // HTTP, and the lifecycle that starts and stops them. // // Keeping the wiring in one place means main() is a dozen lines, the whole // object graph can be constructed in a test, and there is exactly one place to // look to answer "what actually runs in production". package app import ( "context" "errors" "fmt" "net" "net/http" "os" "path/filepath" "strings" "time" "github.com/urpi/go-bananas/internal/auth" "github.com/urpi/go-bananas/internal/config" "github.com/urpi/go-bananas/internal/domain" "github.com/urpi/go-bananas/internal/httpapi" "github.com/urpi/go-bananas/internal/logging" "github.com/urpi/go-bananas/internal/seed" "github.com/urpi/go-bananas/internal/store" ) // Build information, stamped at link time with -ldflags -X. // // Every value has a usable default so that `go run ./cmd/go-bananas` and a plain // `go build` both work without ceremony, and so that a binary built by hand still // reports something meaningful rather than an empty string in a log line. var ( // Version is the release version, e.g. 1.4.2. Version = "dev" // Commit is the source revision. Commit = "unknown" // BuildDate is the RFC 3339 build timestamp. BuildDate = "unknown" ) // App is the assembled application. type App struct { Config config.Config Log logging.Logger Store *store.Store Cache *httpapi.ThemeCache Auth *auth.Authenticator Router http.Handler Server *http.Server metrics *httpapi.Metrics // listener is bound before Serve so that the ready log line can report the // real address, which matters when the configured port is 0. listener net.Listener } // Options are the inputs needed to build an App. type Options struct { // Config is the resolved configuration. Config config.Config // Version is the build version. Version string // Logger overrides the constructed logger, for tests. Logger logging.Logger } // New builds the application without starting it. // // Construction validates the whole object graph and reconciles the built-in // themes, so a misconfigured deployment or a broken migration fails here rather // than on the first request. func New(ctx context.Context, opts Options) (*App, error) { cfg := opts.Config if cfg.StaticDir == "" && cfg.SDKDir == "" { return nil, errors.New("neither STATIC_DIR nor SDK_DIR is set") } log := opts.Logger if cfg.EphemeralSessionSecret() { // Worth a warning: an operator debugging a session that "keeps logging // me out" in local development needs to know why. if log == nil { log = logging.New(logging.Options{ Level: cfg.LogLevel, Format: cfg.LogFormat, Service: "Go Bananas!!", Environment: cfg.Environment, Version: opts.Version, }) } log.Warn("SESSION_SECRET was not set; an ephemeral one was generated. " + "Sessions will not survive a restart. Set SESSION_SECRET to at least 32 bytes.") } if log == nil { log = logging.New(logging.Options{ Level: cfg.LogLevel, Format: cfg.LogFormat, Service: "Go Bananas!!", Environment: cfg.Environment, Version: opts.Version, }) } st, err := store.Open(ctx, store.Options{ Path: cfg.DatabasePath, BusyTimeout: cfg.DatabaseBusyTimeout, MaxConns: cfg.DatabaseMaxConns, }) if err != nil { return nil, fmt.Errorf("open store: %w", err) } cache := httpapi.NewThemeCache() if cfg.SeedOnStart { inserted, refreshed, err := st.Seed(ctx, seed.Themes()) if err != nil { st.Close() return nil, fmt.Errorf("seed built-in themes: %w", err) } if inserted > 0 || refreshed > 0 { log.Info("built-in themes reconciled", "inserted", inserted, "refreshed", refreshed, "total", len(seed.Slugs())) } } authenticator, err := buildAuthenticator(ctx, cfg, st, log) if err != nil { st.Close() return nil, err } // Warming the render cache at start-up means the first request after a deploy // is already fast. Failure is not fatal: the cache is an optimisation. warmed := 0 if themes, err := st.ListRuntime(ctx); err == nil { for _, theme := range themes { warmed += cache.Prefetch(theme, nil) } } if warmed > 0 { log.Debug("render cache warmed", "artefacts", warmed) } metrics := httpapi.NewMetrics().WithCache(cache) metrics.RegisterCounter("bananas_theme_publishes_total", "Themes moved to the published status.") metrics.RegisterCounter("bananas_theme_updates_total", "Theme documents written.") metrics.RegisterCounter("bananas_auth_failures_total", "Requests rejected for want of a usable credential.") app := &App{ Config: cfg, Log: log, Store: st, Cache: cache, Auth: authenticator, metrics: metrics, } app.Router = httpapi.NewRouter(httpapi.Deps{ Config: cfg, Store: st, Cache: cache, Authenticator: authenticator, Log: log, Version: opts.Version, Metrics: metrics, StaticHandler: staticHandler(cfg.StaticDir), SDKHandler: sdkHandler(cfg.SDKDir), // Resolved per request rather than captured at start-up, so that changing the // default theme in the admin UI takes effect without a restart. // // A missing setting yields the supplied default, which is the normal case. // A database error is different: it would otherwise look like "no default // configured" and every runtime request would fall through to asking the // consumer for a theme. It is logged rather than propagated because this runs // inside a request handler whose job is to serve a theme, and failing the // whole request over a settings lookup would be a worse outcome than serving // the fallback. RuntimeDefaults: func() (string, domain.Appearance) { slug, err := st.Setting(ctx, domain.SettingDefaultTheme, "") if err != nil { log.Warn("could not read the default theme setting", "error", err) } appearance, err := st.Setting(ctx, domain.SettingDefaultAppearance, string(domain.AppearanceLight)) if err != nil { log.Warn("could not read the default appearance setting", "error", err) } return slug, domain.Appearance(appearance) }, }) app.Server = &http.Server{ Addr: cfg.HTTPAddr, Handler: app.Router, ReadTimeout: cfg.ReadTimeout, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout, MaxHeaderBytes: 1 << 20, ErrorLog: nil, BaseContext: func(_ net.Listener) context.Context { return context.WithoutCancel(ctx) }, } return app, nil } // buildAuthenticator constructs the auth stack for the configured mode. func buildAuthenticator(ctx context.Context, cfg config.Config, st *store.Store, log logging.Logger) (*auth.Authenticator, error) { switch cfg.AuthMode { case config.AuthModeOIDC: state := auth.NewStateStore( cfg.SessionSecret, "go-bananas-oidc-state", cfg.SessionSecure, sameSiteFrom(cfg.SessionSameSite), 10*time.Minute, ) provider, err := auth.NewOIDCProvider(ctx, cfg, state) if err != nil { return nil, fmt.Errorf("identity provider: %w", err) } log.Info("identity provider configured", "issuer", cfg.OIDCIssuer, "clientId", cfg.OIDCClientID, "redirectUrl", cfg.RedirectURL(), "adminGroups", cfg.OIDCAdminGroups, "editorGroups", cfg.OIDCEditorGroups, ) return auth.New(cfg, provider, st) case config.AuthModeDev: log.Warn("AUTH MODE IS DEV: every request is authenticated as a local administrator. " + "This mode is refused outside a development environment.") return auth.New(cfg, nil, st) case config.AuthModeNone: log.Warn("AUTH MODE IS NONE: every request is granted administrator access. " + "This mode is refused outside a development environment.") return auth.New(cfg, nil, st) default: return nil, fmt.Errorf("unknown AUTH_MODE %q", cfg.AuthMode) } } // sameSiteFrom maps a configuration value onto an http.SameSite. func sameSiteFrom(raw string) (result sameSite) { switch strings.ToLower(raw) { case "strict": return sameSiteStrict case "none": return sameSiteNone default: return sameSiteLax } } // Run starts the server and blocks until ctx is cancelled. // // The shutdown sequence is the part worth reading: on cancellation the listener // is closed first so no new connection is accepted, then in-flight requests get // ShutdownTimeout to finish, and only then is the store closed. Closing the store // while a request is still reading would surface as a confusing 500 to a user // who did nothing wrong. func (a *App) Run(ctx context.Context) error { listenCfg := net.ListenConfig{} listener, err := listenCfg.Listen(ctx, "tcp", a.Config.HTTPAddr) if err != nil { return fmt.Errorf("listen on %s: %w", a.Config.HTTPAddr, err) } a.listener = listener a.Log.Info("starting Go Bananas!!", "address", listener.Addr().String(), "publicUrl", a.Config.Origin(), "authMode", a.Config.AuthMode, "database", a.Config.DatabasePath, "environment", a.Config.Environment, "version", Version, "commit", Commit, "built", BuildDate, ) serverErrors := make(chan error, 1) go func() { err := a.Server.Serve(listener) if errors.Is(err, http.ErrServerClosed) { err = nil } serverErrors <- err }() select { case err := <-serverErrors: if err != nil { return fmt.Errorf("serve: %w", err) } return nil case <-ctx.Done(): a.Log.Info("shutdown signal received", "graceful", true) } shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), a.Config.ShutdownTimeout) defer cancel() if err := a.Server.Shutdown(shutdownCtx); err != nil { // A timeout here means a request outlived the grace period. Closing hard // is better than hanging, and the error is logged so it is visible. a.Log.Error("graceful shutdown timed out; closing connections", "error", err) if closeErr := a.Server.Close(); closeErr != nil { a.Log.Error("force close failed", "error", closeErr) } } if err := <-serverErrors; err != nil { return fmt.Errorf("serve: %w", err) } a.Log.Info("stopped cleanly") return nil } // Addr returns the bound address, useful when the configured port is 0. func (a *App) Addr() string { if a.listener == nil { return a.Config.HTTPAddr } return a.listener.Addr().String() } // Close releases the application's resources. func (a *App) Close() error { if a.Store == nil { return nil } return a.Store.Close() } // Run is the top-level entrypoint: load configuration, build, run, shut down. func Run(ctx context.Context) error { cfg, err := config.Load() if err != nil { // Configuration errors are reported plainly rather than through the logger, // because the logger's own configuration may be part of what is wrong. return fmt.Errorf("%w", err) } application, err := New(ctx, Options{Config: cfg, Version: Version}) if err != nil { return err } defer application.Close() return application.Run(ctx) } // staticHandler serves the built admin UI, or nil when no build is present. // // A missing UI is not fatal: the API is independently useful, and a deployment // that only serves the runtime endpoints has no reason to carry the SPA. func staticHandler(dir string) http.Handler { if dir == "" { return nil } index := filepath.Join(dir, "index.html") if _, err := os.Stat(index); err != nil { return nil } fileServer := spaHandler{root: dir, files: http.FileServer(http.Dir(dir))} return fileServer } // spaHandler serves the admin single-page application. // // Any GET that does not resolve to a real file falls through to index.html so // that client-side routes survive a page reload. An unknown /api or /rt path must // still 404 as JSON rather than returning HTML, which is handled by the router // before this handler is reached. type spaHandler struct { root string files http.Handler } // withinRoot reports whether path is root or lives beneath it. // // It compares path components rather than string prefixes, because a prefix test // cannot tell "/srv/web" from "/srv/web-secrets": the first contains the second as // a leading substring, and a request for the second would pass a HasPrefix check // against the first. func withinRoot(root, path string) bool { absRoot, err := filepath.Abs(root) if err != nil { return false } // The root is resolved when it exists. When it does not, Abs still gives an // absolute path to compare against, which is the best available answer. if resolvedRoot, resolveErr := filepath.EvalSymlinks(absRoot); resolveErr == nil { absRoot = resolvedRoot } absPath, err := filepath.Abs(path) if err != nil { return false } rel, err := filepath.Rel(absRoot, absPath) if err != nil { return false } // ".." is the only relative path that leaves the root. "." is the root itself, // which is allowed; a path equal to the root is not a file but reaching it is // not an escape. return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) } // ServeHTTP implements http.Handler. func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Anchoring the path with a leading slash and cleaning it removes every ".." // segment, so Join below cannot escape the root. The check after it is a // belt-and-braces assertion that the result really is inside, rather than a // prefix test: a plain HasPrefix against the root would accept a sibling // directory whose name merely starts with the root's. cleaned := filepath.Clean("/" + strings.TrimPrefix(r.URL.Path, "/")) candidate := filepath.Join(h.root, cleaned) if !withinRoot(h.root, candidate) { http.Error(w, "not found", http.StatusNotFound) return } // The static file server resolves symlinks, so a symlink inside the web // directory pointing at /etc would otherwise be served. Resolving the real // path and re-checking it is what actually closes that: the root itself is // resolved too, so a root reached through a symlink does not trip the check. resolved, err := filepath.EvalSymlinks(candidate) if err != nil { // A path that does not exist is not an error here: the SPA fallback below // serves the shell for it. Only a real failure is reported. resolved = candidate } if !withinRoot(h.root, resolved) { http.Error(w, "not found", http.StatusNotFound) return } //nolint:gosec // G703: as on the ServeFile call below; the taint source is // r.URL.Path and both paths it reaches are checked by withinRoot. if info, err := os.Stat(resolved); err == nil && !info.IsDir() { setStaticCacheHeaders(w, resolved) // ServeFile rather than the file server, because the request URL is the // uncleaned path and the file to serve is the resolved one. The directory // case cannot reach here, which is the only case in which ServeFile // redirects. // //nolint:gosec // G703: gosec's taint analysis cannot see withinRoot. The // containment predicate compares path components rather than prefixes, so a // sibling directory named "web-secrets" beside "web" is rejected, and // resolved above is the symlink-resolved path that was re-checked against it. // TestSPARejectsTraversal, TestSPARejectsSymlinkEscape and TestWithinRoot are // what hold this in place. http.ServeFile(w, r, resolved) return } // Serve the shell for a client-side route. The request path is rewritten to // the directory root rather than to index.html: http.FileServer answers a // request for /index.html with a 301 to /, which would turn every deep link // into a redirect loop against the SPA router. w.Header().Set("Cache-Control", "no-cache") h.files.ServeHTTP(w, indexRequest(r)) } // indexRequest rewrites a request onto the SPA entry point. func indexRequest(r *http.Request) *http.Request { clone := r.Clone(r.Context()) clone.URL.Path = "/" return clone } // staticAssetSuffixes are the file extensions that may be cached indefinitely, // because Vite emits content-hashed names for them. var staticAssetSuffixes = []string{".js", ".css", ".woff", ".woff2", ".svg", ".png", ".jpg", ".webp", ".avif"} // setStaticCacheHeaders chooses a cache policy for a static asset. func setStaticCacheHeaders(w http.ResponseWriter, path string) { for _, suffix := range staticAssetSuffixes { if strings.HasSuffix(path, suffix) { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") return } } w.Header().Set("Cache-Control", "public, max-age=300") } // sameSite aliases keep the http.SameSite constants out of this file's imports, // which keeps the mapping in one readable block. type sameSite = http.SameSite // Cookie SameSite modes, re-exported for the configuration mapping above. const ( sameSiteLax = http.SameSiteLaxMode sameSiteStrict = http.SameSiteStrictMode sameSiteNone = http.SameSiteNoneMode ) // sdkHandler serves the built runtime SDK bundles, or nil when no build is // present. // // The SDK is served from the same origin as the runtime API so that a consuming // application can load it with a plain URL and no build step, which is the whole // point of delivering themes at runtime rather than through a package registry. func sdkHandler(dir string) http.Handler { if dir == "" { return nil } if _, err := os.Stat(filepath.Join(dir, "runtime.js")); err != nil { return nil } return http.StripPrefix("/rt/v1/", http.FileServer(http.Dir(dir))) }