You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
198 lines
4.9 KiB
198 lines
4.9 KiB
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") |
|
}
|
|
|