check / check (push) Failing after 3s
The default limit was 75% of the space free at startup. The cache's own files are not free space, so a fuller cache got a smaller limit after a restart and eviction then deleted most of it. The default is now 75% of the sum of the free space and what the cache already holds by its own size accounting, at least 500 MiB. The cache works it out when it opens, after the database is open, so the computation and its tests moved from internal/config to internal/imgcache; the config only records whether cache_max_bytes was set, and the handlers turn the disk cache off only for an explicit 0. Model: opus-5-5
177 lines
4.8 KiB
Go
177 lines
4.8 KiB
Go
// Package handlers provides HTTP request handlers.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"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/healthcheck"
|
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
"sneak.berlin/go/pixa/internal/logger"
|
|
"sneak.berlin/go/pixa/internal/session"
|
|
)
|
|
|
|
// Params defines dependencies for Handlers.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Healthcheck *healthcheck.Healthcheck
|
|
Database *database.Database
|
|
Config *config.Config
|
|
}
|
|
|
|
// Handlers provides HTTP request handlers.
|
|
type Handlers struct {
|
|
log *slog.Logger
|
|
hc *healthcheck.Healthcheck
|
|
db *database.Database
|
|
config *config.Config
|
|
imgSvc *imgcache.Service
|
|
imgCache *imgcache.Cache
|
|
sessMgr *session.Manager
|
|
encGen *encurl.Generator
|
|
csrfProtect func(http.Handler) http.Handler
|
|
}
|
|
|
|
// New creates a new Handlers instance.
|
|
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
|
csrfProtect, err := newCSRFProtect(params.Config.SigningKey, params.Config.Debug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s := &Handlers{
|
|
log: params.Logger.Get(),
|
|
hc: params.Healthcheck,
|
|
db: params.Database,
|
|
config: params.Config,
|
|
csrfProtect: csrfProtect,
|
|
}
|
|
|
|
lc.Append(fx.Hook{
|
|
//nolint:contextcheck // the eviction loop outlives OnStart; OnStop cancels it
|
|
OnStart: func(_ context.Context) error {
|
|
return s.initImageService()
|
|
},
|
|
OnStop: func(ctx context.Context) error {
|
|
if s.imgCache == nil {
|
|
return nil
|
|
}
|
|
|
|
return s.imgCache.StopEviction(ctx)
|
|
},
|
|
})
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// WaitForProcessing waits until no image is being processed, or until ctx
|
|
// ends, and returns how many images were still being processed then.
|
|
func (s *Handlers) WaitForProcessing(ctx context.Context) int {
|
|
return s.imgSvc.WaitForProcessing(ctx)
|
|
}
|
|
|
|
// initImageService initializes the image cache and service.
|
|
func (s *Handlers) initImageService() error {
|
|
// Create the cache. cache_max_bytes: 0 disables the disk cache
|
|
// entirely; any other value is the eviction limit in bytes; when
|
|
// it is omitted, the cache works out the default limit itself.
|
|
cache, err := imgcache.NewCache(s.db.DB(), imgcache.CacheConfig{
|
|
StateDir: s.config.StateDir,
|
|
CacheTTL: imgcache.DefaultCacheTTL,
|
|
NegativeTTL: imgcache.DefaultNegativeTTL,
|
|
MaxBytes: s.config.CacheMaxBytes,
|
|
UseDefaultMaxBytes: !s.config.CacheMaxBytesExplicit,
|
|
DisableDiskCache: s.config.CacheMaxBytesExplicit && s.config.CacheMaxBytes == 0,
|
|
Logger: s.log,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.imgCache = cache
|
|
|
|
// Background eviction: startup reconciliation, then periodic and
|
|
// write-pressure passes. No-op when the disk cache is disabled.
|
|
cache.StartEviction(imgcache.DefaultEvictionInterval)
|
|
|
|
// Create the fetcher config
|
|
fetcherCfg := httpfetcher.DefaultConfig()
|
|
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
|
fetcherCfg.Timeout = s.config.UpstreamFetchTimeout
|
|
fetcherCfg.MaxResponseSize = s.config.UpstreamMaxResponseSize
|
|
|
|
if s.config.UpstreamConnectionsPerHost > 0 {
|
|
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
|
}
|
|
|
|
fetcherCfg.MaxConnections = s.config.UpstreamConnections
|
|
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
|
|
|
// Create the service
|
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
|
Cache: cache,
|
|
FetcherConfig: fetcherCfg,
|
|
SigningKey: s.config.SigningKey,
|
|
Allowlist: s.config.AllowlistHosts,
|
|
MaxConcurrentProcessing: s.config.MaxConcurrentProcessing,
|
|
Logger: s.log,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.imgSvc = svc
|
|
s.log.Info("image service initialized")
|
|
|
|
// Initialize session manager (signing key is validated at config load
|
|
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
|
|
sessMgr, err := session.NewManager(s.config.SigningKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.sessMgr = sessMgr
|
|
|
|
// Initialize encrypted URL generator
|
|
encGen, err := encurl.NewGenerator(s.config.SigningKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.encGen = encGen
|
|
|
|
s.log.Info("session manager and URL generator initialized")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Handlers) respondJSON(w http.ResponseWriter, data any, status int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
|
|
if data != nil {
|
|
err := json.NewEncoder(w).Encode(data)
|
|
if err != nil {
|
|
s.log.Error("json encode error", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Handlers) respondError(w http.ResponseWriter, message string, status int) {
|
|
s.respondJSON(w, map[string]any{
|
|
"error": message,
|
|
"status": status,
|
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
}, status)
|
|
}
|