perf: cache signed storage URLs

This commit is contained in:
Lukas Müllner 2026-03-05 08:35:30 +01:00
parent 83344a06d5
commit 93cbf4b568

View file

@ -15,6 +15,9 @@ class StorageService {
static const int _maxImageBytes = 500 * 1024; static const int _maxImageBytes = 500 * 1024;
static const int _maxWidth = 1080; static const int _maxWidth = 1080;
static const int _signedUrlExpirySeconds = 3600; static const int _signedUrlExpirySeconds = 3600;
static const _signedUrlRefreshBuffer = Duration(minutes: 3);
static const _maxSignedUrlCacheEntries = 500;
static final Map<String, _SignedUrlCacheEntry> _signedUrlCache = {};
/// Upload a car image for a specific hotwheels entry. /// Upload a car image for a specific hotwheels entry.
/// Returns the storage path on success (e.g. `uid/123.jpg`). /// Returns the storage path on success (e.g. `uid/123.jpg`).
@ -40,16 +43,32 @@ class StorageService {
), ),
); );
_signedUrlCache.remove(path);
return path; return path;
} }
/// Generates a temporary signed URL for a private image path. /// Generates a temporary signed URL for a private image path.
static Future<String?> createSignedUrl(String? path) async { static Future<String?> createSignedUrl(String? path) async {
if (path == null || path.isEmpty) return null; 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 { try {
return await supabase.storage final signed = await supabase.storage
.from(_bucket) .from(_bucket)
.createSignedUrl(path, _signedUrlExpirySeconds); .createSignedUrl(path, _signedUrlExpirySeconds);
_signedUrlCache[path] = _SignedUrlCacheEntry(
url: signed,
expiresAt: now.add(const Duration(seconds: _signedUrlExpirySeconds)),
);
_pruneSignedUrlCache(now);
return signed;
} catch (e) { } catch (e) {
debugPrint('StorageService.createSignedUrl error: $e'); debugPrint('StorageService.createSignedUrl error: $e');
return null; return null;
@ -59,6 +78,7 @@ class StorageService {
/// Deletes an image using its storage path. /// Deletes an image using its storage path.
static Future<void> deleteCarImage(String? path) async { static Future<void> deleteCarImage(String? path) async {
if (path == null || path.isEmpty) return; if (path == null || path.isEmpty) return;
_signedUrlCache.remove(path);
try { try {
await supabase.storage.from(_bucket).remove([path]); await supabase.storage.from(_bucket).remove([path]);
} catch (e) { } 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<Uint8List> _compressImage(File source) async { static Future<Uint8List> _compressImage(File source) async {
final bytes = await source.readAsBytes(); final bytes = await source.readAsBytes();
final decoded = img.decodeImage(bytes); final decoded = img.decodeImage(bytes);
@ -102,3 +138,10 @@ class StorageService {
return out; return out;
} }
} }
class _SignedUrlCacheEntry {
final String url;
final DateTime expiresAt;
const _SignedUrlCacheEntry({required this.url, required this.expiresAt});
}