From bdbd6675846b97fae7fa3cdea7442569dd81480e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 11 May 2026 04:19:41 +0200 Subject: [PATCH] feat(discord): self-host rich presence posters --- lib/services/discord_rpc_service.dart | 87 ++++-- server/main.go | 396 ++++++++++++++++++++++++-- server/main_test.go | 169 ++++++++++- 3 files changed, 596 insertions(+), 56 deletions(-) diff --git a/lib/services/discord_rpc_service.dart b/lib/services/discord_rpc_service.dart index fa2e5d67..83955a69 100644 --- a/lib/services/discord_rpc_service.dart +++ b/lib/services/discord_rpc_service.dart @@ -1,18 +1,17 @@ import 'dart:async'; import 'package:dart_discord_presence/dart_discord_presence.dart'; -import 'package:http/http.dart' as http; import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../utils/app_logger.dart'; -import '../utils/future_extensions.dart'; +import '../utils/media_image_helper.dart'; import '../utils/platform_detector.dart'; import '../utils/media_server_http_client.dart'; import 'settings_service.dart'; -/// Cached Litterbox URL with expiry timestamp +/// Cached poster URL with expiry timestamp. class _CachedUrl { final String url; final DateTime expiresAt; @@ -28,12 +27,14 @@ class _CachedUrl { /// when video is playing. Gracefully handles Discord not running. class DiscordRPCService { static const String _applicationId = '1453773470306402439'; - static const String _litterboxUrl = 'https://litterbox.catbox.moe/resources/internals/api.php'; + static const String _posterUploadUrl = 'https://ice.plezy.app/posters'; + static const Duration _posterCacheTtl = Duration(hours: 3); + static const int _maxPosterUploadBytes = 5 * 1024 * 1024; - /// Cache of thumbnail paths to Litterbox URLs with expiry (1 hour). Keyed - /// by `:` so the same path on different backends - /// doesn't collide. - static final Map _litterboxCache = {}; + /// Cache of thumbnail paths to hosted poster URLs. Keyed by + /// `:` so the same path on different backends doesn't + /// collide. + static final Map _posterUrlCache = {}; static DiscordRPCService? _instance; static DiscordRPCService get instance { @@ -292,17 +293,13 @@ class DiscordRPCService { // Check cache first (with expiry check). Key by backend so the same // path on Plex and Jellyfin doesn't collide. final cacheKey = '${client.backend.id}:$thumbPath'; - final cached = _litterboxCache[cacheKey]; + final cached = _posterUrlCache[cacheKey]; if (cached != null && !cached.isExpired) { - appLogger.d('Using cached Litterbox URL for: $cacheKey'); + appLogger.d('Using cached poster URL for: $cacheKey'); return cached.url; } - // Build the image URL. Both backends embed auth in the URL — Plex via - // `?X-Plex-Token=...`, Jellyfin via `?api_key=...` — so no extra - // headers are needed for the fetch below. We still pass [streamHeaders] - // for Plex installs that prefer header-based auth. - final imageUrl = client.thumbnailUrl(thumbPath); + final imageUrl = _buildTranscodedThumbnailUrl(metadata, client, thumbPath); if (imageUrl.isEmpty) return null; final imageBytes = await httpClient.getBytes( @@ -311,29 +308,59 @@ class DiscordRPCService { timeout: const Duration(seconds: 10), ); if (imageBytes.isEmpty) return null; + if (imageBytes.length > _maxPosterUploadBytes) { + appLogger.d('Discord poster upload skipped: transcoded image is ${imageBytes.length} bytes'); + return null; + } - final uploadRequest = http.MultipartRequest('POST', Uri.parse(_litterboxUrl)) - ..fields['reqtype'] = 'fileupload' - ..fields['time'] = '1h' - ..files.add(http.MultipartFile.fromBytes('fileToUpload', imageBytes, filename: 'thumbnail.jpg')); + final uploadResponse = await httpClient.post( + _posterUploadUrl, + body: imageBytes, + headers: {'Content-Type': 'application/octet-stream'}, + timeout: const Duration(seconds: 15), + ); - final uploadStreamed = await httpClient.inner - .send(uploadRequest) - .namedTimeout(const Duration(seconds: 15), operation: 'Litterbox upload'); - final uploadedUrl = (await uploadStreamed.stream.bytesToString()).trim(); - - if (uploadedUrl.startsWith('http')) { - // Cache the URL with 1 hour expiry (matching Litterbox) - _litterboxCache[cacheKey] = _CachedUrl(uploadedUrl, DateTime.now().add(const Duration(hours: 1))); - appLogger.d('Uploaded and cached thumbnail: $uploadedUrl'); - return uploadedUrl; + final uploadedUrl = switch (uploadResponse.data) { + {'url': final String url} when uploadResponse.statusCode >= 200 && uploadResponse.statusCode < 300 => url, + _ => null, + }; + final hostedUrl = _absolutePosterUrl(uploadedUrl); + if (hostedUrl != null) { + _posterUrlCache[cacheKey] = _CachedUrl(hostedUrl, DateTime.now().add(_posterCacheTtl)); + appLogger.d('Uploaded and cached thumbnail: $hostedUrl'); + return hostedUrl; } } catch (e) { - appLogger.d('Failed to upload thumbnail to Litterbox', error: e); + appLogger.d('Failed to upload thumbnail to Plezy poster host', error: e); } return null; } + String _buildTranscodedThumbnailUrl(MediaItem metadata, MediaServerClient client, String thumbPath) { + final useEpisodeThumb = metadata.kind == MediaKind.episode && metadata.grandparentThumbPath == null; + return MediaImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: thumbPath, + maxWidth: useEpisodeThumb ? 960 : 512, + maxHeight: useEpisodeThumb ? 540 : 768, + devicePixelRatio: 1, + imageType: useEpisodeThumb ? ImageType.thumb : ImageType.poster, + ); + } + + String? _absolutePosterUrl(String? url) { + if (url == null || url.isEmpty) return null; + final uri = Uri.tryParse(url); + if (uri == null) return null; + if (uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https')) { + return url; + } + if (uri.hasScheme || uri.hasAuthority || !url.startsWith('/posters/')) { + return null; + } + return Uri.parse(_posterUploadUrl).resolve(url).toString(); + } + Future _updatePresence() async { if (_rpc == null || !_isConnected || _currentMetadata == null) return; diff --git a/server/main.go b/server/main.go index fdde307e..487f4775 100644 --- a/server/main.go +++ b/server/main.go @@ -40,6 +40,10 @@ const ( logIDLength = 5 logRateInterval = 1 * time.Minute maxLogEntries = 500 + maxPosterSize = 5 * 1024 * 1024 // 5MB + maxPosterStoreSize = int64(1 * 1024 * 1024 * 1024) + posterMaxAge = 3 * time.Hour + posterIDLength = 16 maxConnsPerIP = 5 maxGlobalConns = 100 maxRoomsPerIP = 3 @@ -61,19 +65,19 @@ var upgrader = websocket.Upgrader{ // --- Rate limiter (token bucket) --- type rateLimiter struct { - tokens float64 - maxTokens float64 + tokens float64 + maxTokens float64 refillRate float64 - lastTime time.Time - mu sync.Mutex + lastTime time.Time + mu sync.Mutex } func newRateLimiter(burst, sustained int) *rateLimiter { return &rateLimiter{ - tokens: float64(burst), - maxTokens: float64(burst), + tokens: float64(burst), + maxTokens: float64(burst), refillRate: float64(sustained), - lastTime: time.Now(), + lastTime: time.Now(), } } @@ -373,17 +377,21 @@ func (ls *logStore) filePath(id string) string { return filepath.Join(ls.dir, id+".log") } -const logIDChars = "abcdefghijklmnopqrstuvwxyz0123456789" +const idChars = "abcdefghijklmnopqrstuvwxyz0123456789" -func generateLogID() string { - b := make([]byte, logIDLength) +func generateID(length int) string { + b := make([]byte, length) for i := range b { - n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(logIDChars)))) - b[i] = logIDChars[n.Int64()] + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(idChars)))) + b[i] = idChars[n.Int64()] } return string(b) } +func generateLogID() string { + return generateID(logIDLength) +} + func (ls *logStore) cleanup() { ls.mu.Lock() defer ls.mu.Unlock() @@ -401,6 +409,269 @@ func (ls *logStore) cleanup() { } } +// --- Poster store --- + +type posterEntry struct { + Filename string + Size int64 + ContentType string + CreatedAt time.Time + ExpiresAt time.Time +} + +type posterStore struct { + entries map[string]posterEntry + dir string + maxBytes int64 + maxAge time.Duration + totalBytes int64 + mu sync.RWMutex +} + +func newPosterStore(dir string, maxBytes int64, maxAge time.Duration) *posterStore { + if err := os.MkdirAll(dir, 0755); err != nil { + log.Fatalf("failed to create poster dir %s: %v", dir, err) + } + ps := &posterStore{ + entries: make(map[string]posterEntry), + dir: dir, + maxBytes: maxBytes, + maxAge: maxAge, + } + ps.loadExisting(time.Now()) + return ps +} + +func (ps *posterStore) filePath(filename string) string { + return filepath.Join(ps.dir, filename) +} + +func generatePosterID() string { + return generateID(posterIDLength) +} + +func posterExtForContentType(contentType string) (string, bool) { + switch strings.ToLower(strings.SplitN(contentType, ";", 2)[0]) { + case "image/jpeg": + return ".jpg", true + case "image/png": + return ".png", true + case "image/gif": + return ".gif", true + case "image/webp": + return ".webp", true + default: + return "", false + } +} + +func posterContentTypeForExt(ext string) (string, bool) { + switch strings.ToLower(ext) { + case ".jpg", ".jpeg": + return "image/jpeg", true + case ".png": + return "image/png", true + case ".gif": + return "image/gif", true + case ".webp": + return "image/webp", true + default: + return "", false + } +} + +func validID(id string, length int) bool { + if len(id) != length { + return false + } + for _, ch := range id { + if !strings.ContainsRune(idChars, ch) { + return false + } + } + return true +} + +func posterIDFromFilename(filename string) (string, bool) { + if filename == "" || strings.ContainsAny(filename, `/\\`) { + return "", false + } + ext := filepath.Ext(filename) + if _, ok := posterContentTypeForExt(ext); !ok { + return "", false + } + id := strings.TrimSuffix(filename, ext) + if !validID(id, posterIDLength) { + return "", false + } + return id, true +} + +func (ps *posterStore) loadExisting(now time.Time) { + ps.mu.Lock() + defer ps.mu.Unlock() + + files, err := os.ReadDir(ps.dir) + if err != nil { + log.Printf("posters: failed to read dir %s: %v", ps.dir, err) + return + } + for _, f := range files { + filename := f.Name() + path := ps.filePath(filename) + if f.IsDir() || strings.HasSuffix(filename, ".tmp") { + os.RemoveAll(path) + continue + } + id, ok := posterIDFromFilename(filename) + if !ok { + os.Remove(path) + continue + } + info, err := f.Info() + if err != nil { + os.Remove(path) + continue + } + createdAt := info.ModTime() + expiresAt := createdAt.Add(ps.maxAge) + if !now.Before(expiresAt) { + os.Remove(path) + continue + } + contentType, _ := posterContentTypeForExt(filepath.Ext(filename)) + entry := posterEntry{ + Filename: filename, + Size: info.Size(), + ContentType: contentType, + CreatedAt: createdAt, + ExpiresAt: expiresAt, + } + ps.entries[id] = entry + ps.totalBytes += entry.Size + } + ps.evictOldestLocked(0) +} + +func (ps *posterStore) store(data []byte, contentType string, now time.Time) (string, posterEntry, error) { + entrySize := int64(len(data)) + if entrySize <= 0 { + return "", posterEntry{}, errors.New("empty poster") + } + if entrySize > ps.maxBytes { + return "", posterEntry{}, errors.New("poster exceeds store size") + } + ext, ok := posterExtForContentType(contentType) + if !ok { + return "", posterEntry{}, errors.New("unsupported poster type") + } + + ps.mu.Lock() + defer ps.mu.Unlock() + + ps.cleanupExpiredLocked(now) + ps.evictOldestLocked(entrySize) + if ps.totalBytes+entrySize > ps.maxBytes { + return "", posterEntry{}, errors.New("poster store full") + } + + id := generatePosterID() + for { + if _, exists := ps.entries[id]; !exists { + if _, err := os.Stat(ps.filePath(id + ext)); errors.Is(err, fs.ErrNotExist) { + break + } + } + id = generatePosterID() + } + + filename := id + ext + path := ps.filePath(filename) + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + os.Remove(tmpPath) + return "", posterEntry{}, err + } + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return "", posterEntry{}, err + } + _ = os.Chtimes(path, now, now) + + entry := posterEntry{ + Filename: filename, + Size: entrySize, + ContentType: strings.ToLower(strings.SplitN(contentType, ";", 2)[0]), + CreatedAt: now, + ExpiresAt: now.Add(ps.maxAge), + } + ps.entries[id] = entry + ps.totalBytes += entry.Size + return id, entry, nil +} + +func (ps *posterStore) lookup(filename string, now time.Time) (posterEntry, bool) { + id, ok := posterIDFromFilename(filename) + if !ok { + return posterEntry{}, false + } + + ps.mu.Lock() + defer ps.mu.Unlock() + entry, ok := ps.entries[id] + if !ok || entry.Filename != filename { + return posterEntry{}, false + } + if !now.Before(entry.ExpiresAt) { + ps.deleteEntryLocked(id, entry) + return posterEntry{}, false + } + return entry, true +} + +func (ps *posterStore) cleanup(now time.Time) { + ps.mu.Lock() + defer ps.mu.Unlock() + ps.cleanupExpiredLocked(now) + ps.evictOldestLocked(0) +} + +func (ps *posterStore) cleanupExpiredLocked(now time.Time) { + for id, entry := range ps.entries { + if !now.Before(entry.ExpiresAt) { + ps.deleteEntryLocked(id, entry) + } + } +} + +func (ps *posterStore) evictOldestLocked(extraBytes int64) { + for ps.totalBytes+extraBytes > ps.maxBytes && len(ps.entries) > 0 { + var oldestID string + var oldest posterEntry + first := true + for id, entry := range ps.entries { + if first || entry.CreatedAt.Before(oldest.CreatedAt) { + oldestID = id + oldest = entry + first = false + } + } + if oldestID == "" { + return + } + ps.deleteEntryLocked(oldestID, oldest) + } +} + +func (ps *posterStore) deleteEntryLocked(id string, entry posterEntry) { + os.Remove(ps.filePath(entry.Filename)) + delete(ps.entries, id) + ps.totalBytes -= entry.Size + if ps.totalBytes < 0 { + ps.totalBytes = 0 + } +} + // --- Snapshotter (single-writer, debounced, atomic disk persistence) --- type snapshotter struct { @@ -551,16 +822,22 @@ func (sn *snapshotter) logWriteErr(err error) { // --- Server --- type Server struct { - rooms map[string]*Room - logs *logStore - conns *connTracker - snap *snapshotter - oauth *oauthProxy // nil when OAUTH_BASE_URL is unset - mu sync.RWMutex + rooms map[string]*Room + logs *logStore + posters *posterStore + conns *connTracker + snap *snapshotter + oauth *oauthProxy // nil when OAUTH_BASE_URL is unset + mu sync.RWMutex } -func newServer(logDir, stateFile string) *Server { - s := &Server{rooms: make(map[string]*Room), logs: newLogStore(logDir), conns: newConnTracker()} +func newServer(logDir, stateFile, posterDir string) *Server { + s := &Server{ + rooms: make(map[string]*Room), + logs: newLogStore(logDir), + posters: newPosterStore(posterDir, maxPosterStoreSize, posterMaxAge), + conns: newConnTracker(), + } if p, ok := oauthConfigFromEnv(); ok { s.oauth = p log.Printf("oauth: proxy enabled (base=%s, services=%d)", p.baseURL, len(p.services)) @@ -681,6 +958,7 @@ func (s *Server) runCleanupStep(now time.Time) { s.snap.schedule() } s.logs.cleanup() + s.posters.cleanup(now) s.conns.cleanup() if s.oauth != nil { s.oauth.cleanup() @@ -803,6 +1081,79 @@ func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) { w.Write(data) } +func (s *Server) handlePostPosters(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, maxPosterSize+1)) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + if len(body) > maxPosterSize { + http.Error(w, "Poster too large (max 5MB)", http.StatusRequestEntityTooLarge) + return + } + if len(body) == 0 { + http.Error(w, "Empty body", http.StatusBadRequest) + return + } + + contentType := http.DetectContentType(body) + if _, ok := posterExtForContentType(contentType); !ok { + http.Error(w, "Unsupported media type", http.StatusUnsupportedMediaType) + return + } + + id, entry, err := s.posters.store(body, contentType, time.Now()) + if err != nil { + log.Printf("posters: failed to store from %s: %v", clientIP(r), err) + http.Error(w, "Failed to store poster", http.StatusInternalServerError) + return + } + + url := "/posters/" + entry.Filename + log.Printf("posters: stored %s (%d bytes) from %s", id, entry.Size, clientIP(r)) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": id, + "url": url, + "expiresIn": int(s.posters.maxAge.Seconds()), + }) +} + +func (s *Server) handleGetPosters(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + filename := strings.TrimPrefix(r.URL.Path, "/posters/") + entry, ok := s.posters.lookup(filename, time.Now()) + if !ok { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + f, err := os.Open(s.posters.filePath(entry.Filename)) + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + defer f.Close() + + remaining := int(time.Until(entry.ExpiresAt).Seconds()) + if remaining < 0 { + remaining = 0 + } + w.Header().Set("Cache-Control", "public, max-age="+strconv.Itoa(remaining)) + w.Header().Set("Content-Type", entry.ContentType) + w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10)) + http.ServeContent(w, r, entry.Filename, entry.CreatedAt, f) +} func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) @@ -999,10 +1350,11 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { func main() { addr := flag.String("addr", ":8080", "Listen address") logDir := flag.String("log-dir", "/data/logs", "Directory for log file storage") + posterDir := flag.String("poster-dir", "/data/posters", "Directory for Discord poster storage") stateFile := flag.String("state-file", "/data/rooms.json", "Path to room snapshot file") flag.Parse() - srv := newServer(*logDir, *stateFile) + srv := newServer(*logDir, *stateFile, *posterDir) mux := http.NewServeMux() mux.HandleFunc("/relay", srv.handleWS) @@ -1012,6 +1364,8 @@ func main() { }) mux.HandleFunc("/logs", srv.handlePostLogs) mux.HandleFunc("/logs/", srv.handleGetLogs) + mux.HandleFunc("/posters", srv.handlePostPosters) + mux.HandleFunc("/posters/", srv.handleGetPosters) registerOAuthRoutes(mux, srv.oauth) httpSrv := &http.Server{Addr: *addr, Handler: mux} diff --git a/server/main_test.go b/server/main_test.go index 73f717be..cb14ad97 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -27,9 +27,10 @@ import ( func newTestServer(t *testing.T, stateFile string) *Server { t.Helper() s := &Server{ - rooms: make(map[string]*Room), - logs: newLogStore(t.TempDir()), - conns: newConnTracker(), + rooms: make(map[string]*Room), + logs: newLogStore(t.TempDir()), + posters: newPosterStore(t.TempDir(), maxPosterStoreSize, posterMaxAge), + conns: newConnTracker(), } s.snap = newSnapshotter(stateFile, s.buildSnapshot) return s @@ -323,7 +324,7 @@ func newRelayHarness(t *testing.T) *relayHarness { // can share a snapshot across a simulated restart. func newRelayHarnessAt(t *testing.T, logDir, stateFile string) *relayHarness { t.Helper() - srv := newServer(logDir, stateFile) + srv := newServer(logDir, stateFile, filepath.Join(t.TempDir(), "posters")) mux := http.NewServeMux() mux.HandleFunc("/relay", srv.handleWS) @@ -333,6 +334,8 @@ func newRelayHarnessAt(t *testing.T, logDir, stateFile string) *relayHarness { }) mux.HandleFunc("/logs", srv.handlePostLogs) mux.HandleFunc("/logs/", srv.handleGetLogs) + mux.HandleFunc("/posters", srv.handlePostPosters) + mux.HandleFunc("/posters/", srv.handleGetPosters) httpSrv := httptest.NewServer(mux) t.Cleanup(func() { @@ -673,7 +676,7 @@ func TestGenerateLogIDShape(t *testing.T) { t.Fatalf("len=%d want %d (id=%q)", len(id), logIDLength, id) } for _, c := range id { - if !strings.ContainsRune(logIDChars, c) { + if !strings.ContainsRune(idChars, c) { t.Fatalf("id %q has unexpected char %q", id, c) } } @@ -1065,6 +1068,48 @@ func postLogAndGetID(t *testing.T, baseURL, ip string, body []byte) string { return out.ID } +type posterUploadResponse struct { + ID string `json:"id"` + URL string `json:"url"` + ExpiresIn int `json:"expiresIn"` +} + +func postPoster(t *testing.T, baseURL, ip string, body []byte) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, baseURL+"/posters", bytes.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + if ip != "" { + req.Header.Set("X-Forwarded-For", ip) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post: %v", err) + } + return resp +} + +func postPosterAndDecode(t *testing.T, baseURL, ip string, body []byte) posterUploadResponse { + t.Helper() + resp := postPoster(t, baseURL, ip, body) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("post status=%d", resp.StatusCode) + } + var out posterUploadResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out.ID) != posterIDLength { + t.Fatalf("id=%q len=%d want %d", out.ID, len(out.ID), posterIDLength) + } + if out.URL == "" { + t.Fatal("empty poster url") + } + return out +} + func TestLogsRoundTrip(t *testing.T) { h := newRelayHarness(t) payload := []byte("hello log world") @@ -1197,6 +1242,120 @@ func TestLogsMethodNotAllowed(t *testing.T) { } } +// ====================================================================== +// Poster endpoints +// ====================================================================== + +func TestPostersRoundTrip(t *testing.T) { + h := newRelayHarness(t) + payload := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03} + out := postPosterAndDecode(t, h.baseURL, "9.0.0.1", payload) + + if out.ExpiresIn != int(posterMaxAge.Seconds()) { + t.Fatalf("expiresIn=%d want %d", out.ExpiresIn, int(posterMaxAge.Seconds())) + } + if !strings.HasPrefix(out.URL, "/posters/") || !strings.HasSuffix(out.URL, ".png") { + t.Fatalf("url=%q should be a relative png poster path", out.URL) + } + if strings.Contains(out.URL, "://") { + t.Fatalf("url=%q should be relative", out.URL) + } + + getResp, err := http.Get(h.baseURL + out.URL) + if err != nil { + t.Fatalf("get: %v", err) + } + defer getResp.Body.Close() + if getResp.StatusCode != http.StatusOK { + t.Fatalf("get status=%d", getResp.StatusCode) + } + got, _ := io.ReadAll(getResp.Body) + if !bytes.Equal(got, payload) { + t.Fatalf("round-tripped bytes mismatch: got %v want %v", got, payload) + } + if ct := getResp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/png") { + t.Errorf("Content-Type=%q", ct) + } +} + +func TestPostersRejectInvalidAndOversizedUploads(t *testing.T) { + h := newRelayHarness(t) + + invalid := postPoster(t, h.baseURL, "9.0.0.2", []byte("not an image")) + invalid.Body.Close() + if invalid.StatusCode != http.StatusUnsupportedMediaType { + t.Fatalf("invalid status=%d want 415", invalid.StatusCode) + } + + oversized := postPoster(t, h.baseURL, "9.0.0.3", make([]byte, maxPosterSize+1)) + oversized.Body.Close() + if oversized.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized status=%d want 413", oversized.StatusCode) + } +} + +func TestPosterStoreEvictsOldestOverQuota(t *testing.T) { + ps := newPosterStore(t.TempDir(), 12, time.Hour) + now := time.Now() + payload := []byte{1, 2, 3, 4, 5, 6, 7} + + id1, entry1, err := ps.store(payload, "image/png", now) + if err != nil { + t.Fatalf("store first: %v", err) + } + id2, entry2, err := ps.store(payload, "image/png", now.Add(time.Minute)) + if err != nil { + t.Fatalf("store second: %v", err) + } + + ps.mu.RLock() + _, hasFirst := ps.entries[id1] + _, hasSecond := ps.entries[id2] + total := ps.totalBytes + ps.mu.RUnlock() + + if hasFirst { + t.Fatal("oldest poster should have been evicted") + } + if !hasSecond { + t.Fatal("newest poster should remain") + } + if total != int64(len(payload)) { + t.Fatalf("totalBytes=%d want %d", total, len(payload)) + } + if _, err := os.Stat(ps.filePath(entry1.Filename)); !os.IsNotExist(err) { + t.Fatalf("oldest file still exists or stat failed unexpectedly: %v", err) + } + if _, err := os.Stat(ps.filePath(entry2.Filename)); err != nil { + t.Fatalf("newest file missing: %v", err) + } +} + +func TestPosterStoreCleanupExpiresOldPosters(t *testing.T) { + ps := newPosterStore(t.TempDir(), 1024, time.Hour) + now := time.Now() + id, entry, err := ps.store([]byte{1, 2, 3}, "image/png", now.Add(-2*time.Hour)) + if err != nil { + t.Fatalf("store: %v", err) + } + + ps.cleanup(now) + + ps.mu.RLock() + _, exists := ps.entries[id] + total := ps.totalBytes + ps.mu.RUnlock() + if exists { + t.Fatal("expired poster should have been removed") + } + if total != 0 { + t.Fatalf("totalBytes=%d want 0", total) + } + if _, err := os.Stat(ps.filePath(entry.Filename)); !os.IsNotExist(err) { + t.Fatalf("expired file still exists or stat failed unexpectedly: %v", err) + } +} + func TestHealthEndpointReturnsOK(t *testing.T) { h := newRelayHarness(t) resp, err := http.Get(h.baseURL + "/health")