package cache import ( "context" "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "os" "path/filepath" "sync" "time" "gitcitadel-online/internal/logger" ) // MediaCache handles caching of images and other media from events type MediaCache struct { cacheDir string activeEvents map[string]time.Time // eventID -> last seen time mu sync.RWMutex httpClient *http.Client } // NewMediaCache creates a new media cache func NewMediaCache(cacheDir string) (*MediaCache, error) { // Create cache directory if it doesn't exist if err := os.MkdirAll(cacheDir, 0755); err != nil { return nil, fmt.Errorf("failed to create media cache directory: %w", err) } mc := &MediaCache{ cacheDir: cacheDir, activeEvents: make(map[string]time.Time), httpClient: &http.Client{ Timeout: 30 * time.Second, }, } // Start cleanup goroutine go mc.cleanupLoop(context.Background()) return mc, nil } // CacheMedia downloads and caches a media file from a URL // Returns the local path to the cached file, or the original URL if caching fails func (mc *MediaCache) CacheMedia(ctx context.Context, url string, eventID string) (string, error) { if url == "" { return "", fmt.Errorf("empty URL") } // Mark event as active mc.mu.Lock() mc.activeEvents[eventID] = time.Now() mc.mu.Unlock() // Generate cache filename from URL hash hash := sha256.Sum256([]byte(url)) filename := hex.EncodeToString(hash[:]) + filepath.Ext(url) cachePath := filepath.Join(mc.cacheDir, filename) // Check if already cached if _, err := os.Stat(cachePath); err == nil { return "/cache/media/" + filename, nil } // Download the media req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return url, fmt.Errorf("failed to create request: %w", err) } // Set user agent req.Header.Set("User-Agent", "GitCitadel-Online/1.0") resp, err := mc.httpClient.Do(req) if err != nil { logger.WithFields(map[string]interface{}{ "url": url, "eventID": eventID, "error": err, }).Warn("Failed to download media") return url, fmt.Errorf("failed to download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return url, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } // Check content type - only cache images contentType := resp.Header.Get("Content-Type") if !isImageContentType(contentType) { logger.WithFields(map[string]interface{}{ "url": url, "contentType": contentType, }).Debug("Skipping non-image media") return url, nil } // Create cache file file, err := os.Create(cachePath) if err != nil { return url, fmt.Errorf("failed to create cache file: %w", err) } defer file.Close() // Copy response to file _, err = io.Copy(file, resp.Body) if err != nil { os.Remove(cachePath) // Clean up on error return url, fmt.Errorf("failed to write cache file: %w", err) } logger.WithFields(map[string]interface{}{ "url": url, "eventID": eventID, "cachePath": cachePath, }).Debug("Cached media file") return "/cache/media/" + filename, nil } // GetCacheDir returns the cache directory path func (mc *MediaCache) GetCacheDir() string { return mc.cacheDir } // isImageContentType checks if a content type is an image func isImageContentType(contentType string) bool { imageTypes := []string{ "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/bmp", "image/x-icon", } for _, imgType := range imageTypes { if contentType == imgType { return true } } return false } // MarkEventActive marks an event as currently active (displayed) func (mc *MediaCache) MarkEventActive(eventID string) { mc.mu.Lock() defer mc.mu.Unlock() mc.activeEvents[eventID] = time.Now() } // cleanupLoop periodically removes media for events that are no longer active func (mc *MediaCache) cleanupLoop(ctx context.Context) { ticker := time.NewTicker(1 * time.Hour) // Run cleanup every hour defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: mc.cleanup() } } } // cleanup removes media files for events that haven't been seen in 24 hours func (mc *MediaCache) cleanup() { mc.mu.Lock() defer mc.mu.Unlock() cutoff := time.Now().Add(-24 * time.Hour) var toRemove []string // Find events that are no longer active for eventID, lastSeen := range mc.activeEvents { if lastSeen.Before(cutoff) { toRemove = append(toRemove, eventID) } } // Remove inactive events from tracking for _, eventID := range toRemove { delete(mc.activeEvents, eventID) } // Note: We don't delete the actual files here because multiple events might use the same image // Instead, we rely on the fact that if an event is no longer displayed, its media won't be accessed // A more sophisticated cleanup would track which files are used by which events logger.WithField("removed_events", len(toRemove)).Debug("Cleaned up inactive events from media cache") }