check / check (push) Canceled after 0s
The image request now holds only its source and its transform, exactly what the variant key covers. The /v1/image/ handler checks the allowlist and verifies the signature itself with Signer.Verify(req, sig, expires). Both image handlers keep the URL's expiry as a local value for Cache-Control. The image service holds no signer, allowlist or AllowHTTP, and ValidateRequest is gone. The fetcher takes the source and builds its URL with Source.URL from its own AllowHTTP, so no URL string is built and parsed again. chooseAutoFormat returns the format it chooses instead of writing into the request. The signed URL format and every route's answers are unchanged. Model: opus-5-5
251 lines
7.5 KiB
Go
251 lines
7.5 KiB
Go
// 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 <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
|
|
}
|
|
|
|
// 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,
|
|
) (*imgcache.Service, error) {
|
|
return imgcache.NewService(&imgcache.ServiceConfig{
|
|
Cache: params.Cache,
|
|
Fetcher: params.Fetcher,
|
|
Processor: params.Processor,
|
|
MaxResponseSize: params.Config.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)
|
|
}
|