diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 1273be4..ee4b08c 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -15,6 +15,9 @@ class StorageService { static const int _maxImageBytes = 500 * 1024; static const int _maxWidth = 1080; static const int _signedUrlExpirySeconds = 3600; + static const _signedUrlRefreshBuffer = Duration(minutes: 3); + static const _maxSignedUrlCacheEntries = 500; + static final Map _signedUrlCache = {}; /// Upload a car image for a specific hotwheels entry. /// Returns the storage path on success (e.g. `uid/123.jpg`). @@ -40,16 +43,32 @@ class StorageService { ), ); + _signedUrlCache.remove(path); + return path; } /// Generates a temporary signed URL for a private image path. static Future createSignedUrl(String? path) async { if (path == null || path.isEmpty) return null; + + final now = DateTime.now(); + final cached = _signedUrlCache[path]; + if (cached != null && + now.isBefore(cached.expiresAt.subtract(_signedUrlRefreshBuffer))) { + return cached.url; + } + try { - return await supabase.storage + final signed = await supabase.storage .from(_bucket) .createSignedUrl(path, _signedUrlExpirySeconds); + _signedUrlCache[path] = _SignedUrlCacheEntry( + url: signed, + expiresAt: now.add(const Duration(seconds: _signedUrlExpirySeconds)), + ); + _pruneSignedUrlCache(now); + return signed; } catch (e) { debugPrint('StorageService.createSignedUrl error: $e'); return null; @@ -59,6 +78,7 @@ class StorageService { /// Deletes an image using its storage path. static Future deleteCarImage(String? path) async { if (path == null || path.isEmpty) return; + _signedUrlCache.remove(path); try { await supabase.storage.from(_bucket).remove([path]); } catch (e) { @@ -66,6 +86,22 @@ class StorageService { } } + static void invalidateSignedUrl(String? path) { + if (path == null || path.isEmpty) return; + _signedUrlCache.remove(path); + } + + static void _pruneSignedUrlCache(DateTime now) { + _signedUrlCache.removeWhere((_, entry) => now.isAfter(entry.expiresAt)); + + if (_signedUrlCache.length <= _maxSignedUrlCacheEntries) return; + final overflow = _signedUrlCache.length - _maxSignedUrlCacheEntries; + final keys = _signedUrlCache.keys.take(overflow).toList(); + for (final key in keys) { + _signedUrlCache.remove(key); + } + } + static Future _compressImage(File source) async { final bytes = await source.readAsBytes(); final decoded = img.decodeImage(bytes); @@ -102,3 +138,10 @@ class StorageService { return out; } } + +class _SignedUrlCacheEntry { + final String url; + final DateTime expiresAt; + + const _SignedUrlCacheEntry({required this.url, required this.expiresAt}); +}