264 lines
7.9 KiB
Go
264 lines
7.9 KiB
Go
// Package providers builds from the config the parts pixad serves images
|
|
// and logins with, and provides them to the fx graph:
|
|
// - the keys derived from the signing key
|
|
// - 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/imageproxy"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
"sneak.berlin/go/pixa/internal/logger"
|
|
"sneak.berlin/go/pixa/internal/seal"
|
|
"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 and the image service register their counters on.
|
|
func Constructors() []any {
|
|
return []any{
|
|
newKeys,
|
|
newCache,
|
|
newFetcherConfig,
|
|
httpfetcher.New,
|
|
newProcessor,
|
|
newImageService,
|
|
newSessionManager,
|
|
newEncryptedURLGenerator,
|
|
}
|
|
}
|
|
|
|
// newKeys derives the keys of seal.Keys from the signing key. fx calls it
|
|
// once, at startup.
|
|
func newKeys(cfg *config.Config) (seal.Keys, error) {
|
|
return seal.DeriveKeys(cfg.SigningKey)
|
|
}
|
|
|
|
// 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.Config{
|
|
StateDir: cfg.StateDir,
|
|
NegativeTTL: imgcache.DefaultNegativeTTL,
|
|
CacheTTL: imgcache.DefaultCacheTTL,
|
|
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 <state_dir>/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
|
|
MetricsRegistry prometheus.Registerer
|
|
}
|
|
|
|
// newImageService builds the image service from the cache, the fetcher
|
|
// and the processor, with the largest upstream response the config allows.
|
|
func newImageService(
|
|
_ fx.Lifecycle, params imageServiceParams,
|
|
) (*imageproxy.Proxy, error) {
|
|
return imageproxy.New(&imageproxy.Config{
|
|
Cache: params.Cache,
|
|
Fetcher: params.Fetcher,
|
|
Processor: params.Processor,
|
|
MaxResponseSize: params.Config.UpstreamMaxResponseSize,
|
|
Logger: params.Logger.Get(),
|
|
}, params.MetricsRegistry)
|
|
}
|
|
|
|
// sessionManagerParams defines dependencies for newSessionManager.
|
|
type sessionManagerParams struct {
|
|
fx.In
|
|
|
|
Keys seal.Keys
|
|
Database *database.Database
|
|
}
|
|
|
|
// newSessionManager builds the session manager with its two keys. 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.Keys.SessionHashKey, params.Keys.SessionBlockKey,
|
|
params.Database.DB())
|
|
}
|
|
|
|
// newEncryptedURLGenerator builds the encrypted URL generator with its key.
|
|
func newEncryptedURLGenerator(keys seal.Keys) *encurl.Generator {
|
|
return encurl.NewGenerator(keys.EncryptedURLKey)
|
|
}
|