initial commit
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

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

524
internal/app/app.go Normal file
View file

@ -0,0 +1,524 @@
// 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)))
}