package apps import ( "context" "fmt" "io" "log/slog" "net/http" "sync" "time" ) // Fetcher pulls URL sources once, caches the body forever, and serves // from cache on subsequent calls. Path sources don't go through here — // the handler reads the file directly. // // Concurrent calls for the same URL dedupe via singleflight. There is no // background refresh, no conditional GET, no SHA-256 verification. type Fetcher struct { Cache *Cache Client *http.Client Logger *slog.Logger sf singleflightGroup embeddedFails sync.Map // url → struct{} (rate-limit "fell back to embedded" warnings) } // NewFetcher returns a Fetcher with sensible defaults: 10s timeout, no // redirects (ops must point at the final URL). func NewFetcher(cache *Cache, logger *slog.Logger) *Fetcher { if logger == nil { logger = slog.Default() } return &Fetcher{ Cache: cache, Logger: logger, Client: &http.Client{ Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }, } } // Fetch returns the body for url. If the cache already has it, returns // the cached bytes immediately. Otherwise fetches, caches, and returns. // All concurrent requests for the same URL share one outbound fetch. func (f *Fetcher) Fetch(ctx context.Context, urlStr string) ([]byte, error) { if f.Cache != nil { if body, err := f.Cache.Read(urlStr); err == nil { return body, nil } } val, err := f.sf.Do(urlStr, func() (any, error) { return f.fetchOnce(ctx, urlStr) }) if err != nil { return nil, err } return val.([]byte), nil } func (f *Fetcher) fetchOnce(ctx context.Context, urlStr string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) if err != nil { return nil, err } resp, err := f.Client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("upstream %s returned HTTP %d", urlStr, resp.StatusCode) } const maxBytes = 25 * 1024 * 1024 body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) if err != nil { return nil, err } if int64(len(body)) > maxBytes { return nil, fmt.Errorf("response from %s exceeds %d bytes", urlStr, maxBytes) } if f.Cache != nil { if err := f.Cache.Write(urlStr, body); err != nil { f.Logger.Warn("cache write failed; serving from response anyway", "url", urlStr, "err", err) } } return body, nil } // LogEmbeddedFallback emits a one-time warning when the embedded fallback // is used for a particular source URL. Rate-limited per URL. func (f *Fetcher) LogEmbeddedFallback(app, urlStr string, reason error) { if _, loaded := f.embeddedFails.LoadOrStore(urlStr, struct{}{}); loaded { return } f.Logger.Warn("serving embedded fallback for app HTML", "app", app, "url", urlStr, "reason", reason) }