Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
524 lines
17 KiB
Go
524 lines
17 KiB
Go
// 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)))
|
|
}
|