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

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")
}