Wire up image handler endpoint with service orchestration
- Add image proxy config options (signing_key, whitelist_hosts, allow_http) - Create Service to orchestrate cache, fetcher, and processor - Initialize image service in handlers OnStart hook - Implement HandleImage with URL parsing, signature validation, cache - Implement HandleRobotsTxt for search engine prevention - Parse query params for signature, quality, and fit mode
This commit is contained in:
252
internal/imgcache/service.go
Normal file
252
internal/imgcache/service.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
|
||||
type Service struct {
|
||||
cache *Cache
|
||||
fetcher *HTTPFetcher
|
||||
processor Processor
|
||||
signer *Signer
|
||||
whitelist *HostWhitelist
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// ServiceConfig holds configuration for the image service.
|
||||
type ServiceConfig struct {
|
||||
// Cache is the cache instance
|
||||
Cache *Cache
|
||||
// FetcherConfig configures the upstream fetcher
|
||||
FetcherConfig *FetcherConfig
|
||||
// SigningKey is the HMAC signing key (empty disables signing)
|
||||
SigningKey string
|
||||
// Whitelist is the list of hosts that don't require signatures
|
||||
Whitelist []string
|
||||
// Logger for logging
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewService creates a new image service.
|
||||
func NewService(cfg *ServiceConfig) (*Service, error) {
|
||||
if cfg.Cache == nil {
|
||||
return nil, errors.New("cache is required")
|
||||
}
|
||||
|
||||
fetcherCfg := cfg.FetcherConfig
|
||||
if fetcherCfg == nil {
|
||||
fetcherCfg = DefaultFetcherConfig()
|
||||
}
|
||||
|
||||
var signer *Signer
|
||||
if cfg.SigningKey != "" {
|
||||
signer = NewSigner(cfg.SigningKey)
|
||||
}
|
||||
|
||||
log := cfg.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
|
||||
return &Service{
|
||||
cache: cfg.Cache,
|
||||
fetcher: NewHTTPFetcher(fetcherCfg),
|
||||
processor: NewImageProcessor(),
|
||||
signer: signer,
|
||||
whitelist: NewHostWhitelist(cfg.Whitelist),
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get retrieves a processed image, fetching and processing if necessary.
|
||||
func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, error) {
|
||||
// Check cache first
|
||||
result, err := s.cache.Lookup(ctx, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNegativeCache) {
|
||||
return nil, fmt.Errorf("upstream returned error (cached)")
|
||||
}
|
||||
|
||||
s.log.Warn("cache lookup failed", "error", err)
|
||||
}
|
||||
|
||||
// Cache hit - serve from cache
|
||||
if result != nil && result.Hit {
|
||||
s.cache.IncrementStats(ctx, true, 0)
|
||||
|
||||
reader, err := s.cache.GetOutput(result.OutputHash)
|
||||
if err != nil {
|
||||
s.log.Error("failed to get cached output", "hash", result.OutputHash, "error", err)
|
||||
// Fall through to re-fetch
|
||||
} else {
|
||||
return &ImageResponse{
|
||||
Content: reader,
|
||||
ContentLength: -1, // Unknown until read
|
||||
ContentType: result.ContentType,
|
||||
CacheStatus: CacheHit,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss - need to fetch, process, and cache
|
||||
s.cache.IncrementStats(ctx, false, 0)
|
||||
|
||||
response, err := s.fetchAndProcess(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response.CacheStatus = CacheMiss
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// fetchAndProcess fetches from upstream, processes, and caches the result.
|
||||
func (s *Service) fetchAndProcess(ctx context.Context, req *ImageRequest) (*ImageResponse, error) {
|
||||
// Fetch from upstream
|
||||
sourceURL := req.SourceURL()
|
||||
|
||||
s.log.Debug("fetching from upstream", "url", sourceURL)
|
||||
|
||||
fetchResult, err := s.fetcher.Fetch(ctx, sourceURL)
|
||||
if err != nil {
|
||||
// Store negative cache for certain errors
|
||||
if isNegativeCacheable(err) {
|
||||
statusCode := extractStatusCode(err)
|
||||
_ = s.cache.StoreNegative(ctx, req, statusCode, err.Error())
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("upstream fetch failed: %w", err)
|
||||
}
|
||||
defer func() { _ = fetchResult.Content.Close() }()
|
||||
|
||||
// Read and validate the source content
|
||||
sourceData, err := io.ReadAll(fetchResult.Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
||||
}
|
||||
|
||||
// Validate magic bytes match content type
|
||||
if err := ValidateMagicBytes(sourceData, fetchResult.ContentType); err != nil {
|
||||
return nil, fmt.Errorf("content validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Store source content
|
||||
_, err = s.cache.StoreSource(ctx, req, bytes.NewReader(sourceData), fetchResult)
|
||||
if err != nil {
|
||||
s.log.Warn("failed to store source content", "error", err)
|
||||
// Continue even if caching fails
|
||||
}
|
||||
|
||||
// Process the image
|
||||
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("image processing failed: %w", err)
|
||||
}
|
||||
|
||||
// Read processed data to cache it
|
||||
processedData, err := io.ReadAll(processResult.Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read processed image: %w", err)
|
||||
}
|
||||
_ = processResult.Content.Close()
|
||||
|
||||
// Store output content
|
||||
metaID, err := s.cache.GetSourceMetadataID(ctx, req)
|
||||
if err == nil {
|
||||
err = s.cache.StoreOutput(ctx, req, metaID, bytes.NewReader(processedData), processResult.ContentType)
|
||||
if err != nil {
|
||||
s.log.Warn("failed to store output content", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &ImageResponse{
|
||||
Content: io.NopCloser(bytes.NewReader(processedData)),
|
||||
ContentLength: int64(len(processedData)),
|
||||
ContentType: processResult.ContentType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Warm pre-fetches and caches an image without returning it.
|
||||
func (s *Service) Warm(ctx context.Context, req *ImageRequest) error {
|
||||
_, err := s.Get(ctx, req)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Purge removes a cached image.
|
||||
func (s *Service) Purge(_ context.Context, _ *ImageRequest) error {
|
||||
// TODO: Implement purge
|
||||
return errors.New("purge not implemented")
|
||||
}
|
||||
|
||||
// Stats returns cache statistics.
|
||||
func (s *Service) Stats(ctx context.Context) (*CacheStats, error) {
|
||||
return s.cache.Stats(ctx)
|
||||
}
|
||||
|
||||
// ValidateRequest validates the request signature if required.
|
||||
func (s *Service) ValidateRequest(req *ImageRequest) error {
|
||||
// Check if host is whitelisted (no signature required)
|
||||
sourceURL := req.SourceURL()
|
||||
|
||||
parsedURL, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid source URL: %w", err)
|
||||
}
|
||||
|
||||
if s.whitelist.IsWhitelisted(parsedURL) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Signature required
|
||||
if s.signer == nil {
|
||||
return errors.New("signing key not configured but host not whitelisted")
|
||||
}
|
||||
|
||||
return s.signer.Verify(req)
|
||||
}
|
||||
|
||||
// GenerateSignedURL generates a signed URL for the given request.
|
||||
func (s *Service) GenerateSignedURL(
|
||||
baseURL string,
|
||||
req *ImageRequest,
|
||||
ttl time.Duration,
|
||||
) (string, error) {
|
||||
if s.signer == nil {
|
||||
return "", errors.New("signing key not configured")
|
||||
}
|
||||
|
||||
path, sig, exp := s.signer.GenerateSignedURL(req, ttl)
|
||||
|
||||
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
|
||||
}
|
||||
|
||||
// HTTP status codes for error responses.
|
||||
const (
|
||||
httpStatusBadGateway = 502
|
||||
httpStatusInternalError = 500
|
||||
)
|
||||
|
||||
// isNegativeCacheable returns true if the error should be cached.
|
||||
func isNegativeCacheable(err error) bool {
|
||||
return errors.Is(err, ErrUpstreamError)
|
||||
}
|
||||
|
||||
// extractStatusCode extracts HTTP status code from error message.
|
||||
func extractStatusCode(err error) int {
|
||||
// Default to 502 Bad Gateway for upstream errors
|
||||
if errors.Is(err, ErrUpstreamError) {
|
||||
return httpStatusBadGateway
|
||||
}
|
||||
|
||||
return httpStatusInternalError
|
||||
}
|
||||
Reference in New Issue
Block a user