// Package providers builds from the config the parts pixad serves images // and logins with, and provides them to the fx graph: // - the image cache, with its default limit // - the fetcher // - the processor // - the image service // - the session manager // - the encrypted URL generator package providers import ( "context" "database/sql" "errors" "fmt" "log/slog" "os" "path/filepath" "syscall" "github.com/prometheus/client_golang/prometheus" "go.uber.org/fx" "sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/logger" "sneak.berlin/go/pixa/internal/session" ) // Constructors returns the constructors of the parts this package builds, // for fx.Provide. The fx graph must also provide the prometheus.Registerer // the cache registers its counters on. func Constructors() []any { return []any{ newCache, newFetcherConfig, httpfetcher.New, newProcessor, newImageService, newSessionManager, newEncryptedURLGenerator, } } // cacheParams defines dependencies for newCache. type cacheParams struct { fx.In Config *config.Config Database *database.Database Logger *logger.Logger MetricsRegistry prometheus.Registerer } // newCache builds the image cache with the disk cache limit cacheMaxBytes // returns. Its start hook starts the cache's background goroutines. Its // stop hook stops them, which writes the pending access times. The // database is open for both: it opens before the cache is built and // closes after this stop hook. func newCache(lc fx.Lifecycle, params cacheParams) (*imgcache.Cache, error) { cfg := params.Config db := params.Database.DB() maxBytes, err := cacheMaxBytes( context.Background(), cfg, db, params.Logger.Get(), defaultFreeSpaceProbe) if err != nil { return nil, err } cache, err := imgcache.NewCache(db, imgcache.CacheConfig{ StateDir: cfg.StateDir, NegativeTTL: imgcache.DefaultNegativeTTL, MaxBytes: maxBytes, Logger: params.Logger.Get(), }, params.MetricsRegistry) if err != nil { return nil, err } // The start hook takes no context: the goroutines it starts outlive it, // and the stop hook stops them. lc.Append(fx.StartStopHook( func() { // Background eviction: startup reconciliation, then periodic // and write-pressure passes, and the removal of expired // negative cache rows, which is all it does when the disk // cache is off. cache.StartEviction(imgcache.DefaultEvictionInterval) // Writes the access times that requests hand over, so that no // request waits for the database to record them cache.StartPendingAccessTimeWrites() }, func(ctx context.Context) error { return errors.Join( cache.StopEviction(ctx), cache.StopPendingAccessTimeWrites(ctx), ) }, )) return cache, nil } // freeSpaceProbeFunc reports the number of free bytes available on the // filesystem containing path. It is a function type so tests can pass a // fake probe instead of depending on the host disk. type freeSpaceProbeFunc func(path string) (uint64, error) var errNegativeBlockSize = errors.New("statfs reported negative block size") // defaultFreeSpaceProbe reports free filesystem bytes via statfs on the // given path, as available to unprivileged processes. func defaultFreeSpaceProbe(path string) (uint64, error) { var stat syscall.Statfs_t err := syscall.Statfs(path, &stat) if err != nil { return 0, err } if stat.Bsize < 0 { return 0, fmt.Errorf("%w %d for %q", errNegativeBlockSize, stat.Bsize, path) } blockSize := uint64(stat.Bsize) return stat.Bavail * blockSize, nil } // cacheMaxBytes returns cache_max_bytes as set. When the key is omitted, // it returns config.DefaultCacheMaxBytes of the free bytes probe reports // for /cache/ and the bytes the cache holds, as db records // them. It creates that directory first, so the probe measures the // filesystem that holds the cache. func cacheMaxBytes( ctx context.Context, cfg *config.Config, db *sql.DB, log *slog.Logger, probe freeSpaceProbeFunc, ) (int64, error) { if cfg.CacheMaxBytesExplicit { return cfg.CacheMaxBytes, nil } cacheDir := filepath.Join(cfg.StateDir, "cache") err := os.MkdirAll(cacheDir, imgcache.StorageDirPerm) if err != nil { return 0, fmt.Errorf("failed to create cache directory: %w", err) } freeBytes, err := probe(cacheDir) if err != nil { return 0, fmt.Errorf( "default cache_max_bytes: cannot determine free space for %q: %w", cacheDir, err) } heldBytes, err := imgcache.UsageBytes(ctx, db) if err != nil { return 0, err } limit := config.DefaultCacheMaxBytes(freeBytes, heldBytes) log.Info("computed default cache size limit from free space and cache contents", "cache_max_bytes", limit, "cache_dir", cacheDir, ) return limit, nil } // newFetcherConfig builds the upstream fetcher's settings from cfg, and // httpfetcher.New builds the fetcher from them. The settings are a value // of their own in the fx graph, so that a test can replace the dial // function alone. func newFetcherConfig(cfg *config.Config) *httpfetcher.Config { return &httpfetcher.Config{ Timeout: cfg.UpstreamFetchTimeout, MaxResponseSize: cfg.UpstreamMaxResponseSize, AllowHTTP: cfg.AllowHTTP, MaxConnectionsPerHost: cfg.UpstreamConnectionsPerHost, MaxConnections: cfg.UpstreamConnections, BlockedNetworks: cfg.BlockedNetworks, } } // newProcessor builds the image processor from cfg. Its input limit is // the largest upstream response the fetcher accepts. func newProcessor(cfg *config.Config) *imageprocessor.ImageProcessor { return imageprocessor.New(imageprocessor.Params{ MaxInputBytes: cfg.UpstreamMaxResponseSize, MaxConcurrentProcessing: cfg.MaxConcurrentProcessing, }) } // imageServiceParams defines dependencies for newImageService. type imageServiceParams struct { fx.In Config *config.Config Cache *imgcache.Cache Fetcher *httpfetcher.HTTPFetcher Processor *imageprocessor.ImageProcessor Logger *logger.Logger } // newImageService builds the image service from the cache, the fetcher // and the processor, with the signing key, the allowlist and the upstream // settings of the config. func newImageService( _ fx.Lifecycle, params imageServiceParams, ) (*imgcache.Service, error) { cfg := params.Config return imgcache.NewService(&imgcache.ServiceConfig{ Cache: params.Cache, Fetcher: params.Fetcher, Processor: params.Processor, SigningKey: cfg.SigningKey, Allowlist: cfg.AllowlistHosts, AllowHTTP: cfg.AllowHTTP, MaxResponseSize: cfg.UpstreamMaxResponseSize, Logger: params.Logger.Get(), }) } // sessionManagerParams defines dependencies for newSessionManager. type sessionManagerParams struct { fx.In Config *config.Config Database *database.Database } // newSessionManager builds the session manager. Session cookies are // always Secure, HttpOnly and SameSite=Strict. The random value that each // logout replaces is saved in the database, so it outlasts a restart. func newSessionManager( _ fx.Lifecycle, params sessionManagerParams, ) (*session.Manager, error) { return session.NewManagerWithDatabase( context.Background(), params.Config.SigningKey, params.Database.DB()) } // newEncryptedURLGenerator builds the encrypted URL generator, with a key // derived from the signing key. func newEncryptedURLGenerator(cfg *config.Config) (*encurl.Generator, error) { return encurl.NewGenerator(cfg.SigningKey) }