import fs from 'fs'; import path from 'path'; const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json'); // Interface for cache entries with TTL interface CacheEntry { value: string; expiresAt: number; // Timestamp when this entry expires } class SignedURLCache { private originalToSignedCache: Map; private signedToOriginalCache: Map; constructor() { this.originalToSignedCache = new Map(); this.signedToOriginalCache = new Map(); // Create cache directory if it doesn't exist const cacheDir = path.dirname(CACHE_FILE_PATH); if (!fs.existsSync(cacheDir)) { console.log('creating the directory') fs.mkdirSync(cacheDir, { recursive: true }); } else { console.log('the directory is already present') } } /** * Get a signed URL from the cache using an original URL as the key */ get(originalUrl: string): string | undefined { const entry = this.originalToSignedCache.get(originalUrl); // If no entry or entry has expired, return undefined if (!entry || Date.now() > entry.expiresAt) { if (entry) { // Remove expired entry this.originalToSignedCache.delete(originalUrl); // Also remove from reverse mapping if it exists this.signedToOriginalCache.delete(entry.value); } return undefined; } return entry.value; } /** * Get the original URL from the cache using a signed URL as the key */ getOriginalUrl(signedUrl: string): string | undefined { const entry = this.signedToOriginalCache.get(signedUrl); // If no entry or entry has expired, return undefined if (!entry || Date.now() > entry.expiresAt) { if (entry) { // Remove expired entry this.signedToOriginalCache.delete(signedUrl); // Also remove from primary mapping if it exists this.originalToSignedCache.delete(entry.value); } return undefined; } return entry.value; } /** * Set a value in the cache with a TTL (Time To Live) * @param originalUrl The original S3 URL * @param signedUrl The signed URL * @param ttlMs Time to live in milliseconds (default: 3 days) */ set(originalUrl: string, signedUrl: string, ttlMs: number = 259200000): void { const expiresAt = Date.now() + ttlMs; const entry: CacheEntry = { value: signedUrl, expiresAt }; const reverseEntry: CacheEntry = { value: originalUrl, expiresAt }; this.originalToSignedCache.set(originalUrl, entry); this.signedToOriginalCache.set(signedUrl, reverseEntry); } has(originalUrl: string): boolean { const entry = this.originalToSignedCache.get(originalUrl); // Entry exists and hasn't expired return !!entry && Date.now() <= entry.expiresAt; } hasSignedUrl(signedUrl: string): boolean { const entry = this.signedToOriginalCache.get(signedUrl); // Entry exists and hasn't expired return !!entry && Date.now() <= entry.expiresAt; } clear(): void { this.originalToSignedCache.clear(); this.signedToOriginalCache.clear(); this.saveToDisk(); } /** * Remove all expired entries from the cache * @returns The number of expired entries that were removed */ clearExpired(): number { const now = Date.now(); let removedCount = 0; // Clear expired entries from original to signed cache for (const [originalUrl, entry] of this.originalToSignedCache.entries()) { if (now > entry.expiresAt) { this.originalToSignedCache.delete(originalUrl); removedCount++; } } // Clear expired entries from signed to original cache for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) { if (now > entry.expiresAt) { this.signedToOriginalCache.delete(signedUrl); // No need to increment removedCount as we've already counted these in the first loop } } if (removedCount > 0) { console.log(`SignedURLCache: Cleared ${removedCount} expired entries`); } return removedCount; } /** * Save the cache to disk */ saveToDisk(): void { try { // Remove expired entries before saving const removedCount = this.clearExpired(); // Convert Maps to serializable objects const serializedOriginalToSigned: Record = {}; const serializedSignedToOriginal: Record = {}; for (const [originalUrl, entry] of this.originalToSignedCache.entries()) { serializedOriginalToSigned[originalUrl] = { value: entry.value, expiresAt: entry.expiresAt }; } for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) { serializedSignedToOriginal[signedUrl] = { value: entry.value, expiresAt: entry.expiresAt }; } const serializedCache = { originalToSigned: serializedOriginalToSigned, signedToOriginal: serializedSignedToOriginal }; // Write to file fs.writeFileSync( CACHE_FILE_PATH, JSON.stringify(serializedCache), 'utf8' ); console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`); } catch (error) { console.error('Error saving SignedURLCache to disk:', error); } } /** * Load the cache from disk */ loadFromDisk(): void { try { if (fs.existsSync(CACHE_FILE_PATH)) { // Read from file const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8'); // Parse the data const parsedData = JSON.parse(data) as { originalToSigned: Record, signedToOriginal: Record }; // Only load entries that haven't expired yet const now = Date.now(); let loadedCount = 0; let expiredCount = 0; // Load original to signed mappings if (parsedData.originalToSigned) { for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) { if (now <= entry.expiresAt) { this.originalToSignedCache.set(originalUrl, entry); loadedCount++; } else { expiredCount++; } } } // Load signed to original mappings if (parsedData.signedToOriginal) { for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) { if (now <= entry.expiresAt) { this.signedToOriginalCache.set(signedUrl, entry); // Don't increment loadedCount as these are pairs of what we already counted } else { // Don't increment expiredCount as these are pairs of what we already counted } } } console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`); } else { console.log('SignedURLCache: No cache file found, starting with empty cache'); } } catch (error) { console.error('Error loading SignedURLCache from disk:', error); // Start with empty caches if loading fails this.originalToSignedCache = new Map(); this.signedToOriginalCache = new Map(); } } } // Create a singleton instance to be used throughout the application const signedUrlCache = new SignedURLCache(); process.on('SIGINT', () => { console.log('SignedURLCache: Saving cache before shutdown...'); signedUrlCache.saveToDisk(); process.exit(0); }); process.on('SIGTERM', () => { console.log('SignedURLCache: Saving cache before shutdown...'); signedUrlCache.saveToDisk(); process.exit(0); }); export default signedUrlCache;