# Thumbnail Caching System - Developer Documentation ## Overview The RSX thumbnail caching system provides a two-tier architecture designed to prevent cache pollution while maintaining flexibility. This document explains the implementation details for framework developers. ## Architecture ### Two-Tier System **Preset Thumbnails** (`storage/rsx-thumbnails/preset/`) - Developer-defined named sizes in config - Quota enforced via scheduled task (`rsx:thumbnails:clean --preset`) - Used for regular application features - Protected from abuse/spam **Dynamic Thumbnails** (`storage/rsx-thumbnails/dynamic/`) - Ad-hoc sizes via URL parameters - Quota enforced synchronously after each generation - Used for edge cases and development - LRU eviction prevents unbounded growth ### Design Philosophy **Problem**: Traditional on-demand thumbnail generation allows users to request arbitrary sizes (e.g., by manipulating URL parameters), potentially generating thousands of cached thumbnails and consuming disk space. **Solution**: 1. Define all application thumbnail sizes as named presets 2. Reference presets by name (`get_thumbnail_url_preset('profile')`) 3. Dynamic thumbnails still available but discouraged and quota-limited **Benefits**: - Prevents cache pollution from URL manipulation - Centralizes thumbnail size definitions - Enables cache warming via `rsx:thumbnails:generate` - Predictable cache size and cleanup ## Storage Structure ### Filename Format **Preset**: `{preset_name}_{storage_hash}_{extension}.webp` - Example: `profile_abc123def456_jpg.webp` **Dynamic**: `{type}_{width}x{height}_{storage_hash}_{extension}.webp` - Example: `cover_200x200_abc123def456_jpg.webp` ### Why Include Extension? Edge case: Identical file content uploaded with different extensions (e.g., `document.zip` and `document.docx`) should render different icon-based thumbnails. Including the extension in the filename ensures separate caches. ## Caching Flow ### Request Processing **Route**: `/_thumbnail/:key/:type/:width/:height` (dynamic) or `/_thumbnail/:key/preset/:preset_name` (preset) **Common Flow** (via `generate_and_serve_thumbnail()`): 1. Validate authorization (`file.thumbnail.authorize` event) 2. Generate cache filename 3. Check if cached file exists 4. If exists: - Attempt to open (handles race condition) - If successful: touch mtime (if enabled), serve from disk - If failed: fall through to regeneration 5. If not exists: - Generate thumbnail (Imagick for images, icon-based for others) - Save to cache - Enforce quota (dynamic only) - Serve from memory (no re-read) ### Race Condition Handling **Scenario**: Between `file_exists()` check and `fopen()`, file might be deleted by quota enforcement in another request. **Solution**: `_serve_cached_thumbnail()` returns `null` if file cannot be opened, triggering immediate regeneration. User never sees an error. ```php if (file_exists($cache_path)) { $response = static::_serve_cached_thumbnail($cache_path); if ($response !== null) { return $response; // Success } // File deleted - fall through to regeneration } // Generate and serve $thumbnail_data = static::__generate_thumbnail(...); ``` ### LRU Tracking **mtime Touching**: - On cache hit, touch file's mtime if older than `touch_interval` (default 10 minutes) - Prevents excessive filesystem writes while maintaining LRU accuracy - Used by quota enforcement to delete oldest (least recently used) files first **Configuration**: ```php 'touch_on_read' => true, // Enable/disable 'touch_interval' => 600, // Seconds (10 minutes) ``` ## Quota Enforcement ### Dynamic Thumbnails (Synchronous) Called after each new dynamic thumbnail generation: ```php protected static function _enforce_dynamic_quota() { $max_bytes = config('rsx.thumbnails.quotas.dynamic_max_bytes'); $dir = storage_path('rsx-thumbnails/dynamic/'); // Scan directory $total_size = 0; $files = []; foreach (glob($dir . '*.webp') as $file) { $size = filesize($file); $mtime = filemtime($file); $total_size += $size; $files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime]; } // Over quota? if ($total_size > $max_bytes) { // Sort by mtime (oldest first) usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']); // Delete oldest until under quota foreach ($files as $file) { unlink($file['path']); $total_size -= $file['size']; if ($total_size <= $max_bytes) break; } } } ``` **Performance**: Glob + stat operations are fast for directories with <10,000 files. Dynamic quota prevents unbounded growth. ### Preset Thumbnails (Scheduled) Not enforced synchronously. Run via scheduled task: ```bash php artisan rsx:thumbnails:clean --preset ``` Same LRU algorithm as dynamic, but triggered externally. ## Helper Methods ### Internal Helpers (Underscore-Prefixed) These are `public static` for artisan command access but prefixed with `_` to indicate they're internal: **`_get_cache_filename_preset($preset_name, $hash, $extension)`** - Generates: `{preset_name}_{hash}_{ext}.webp` **`_get_cache_filename_dynamic($type, $width, $height, $hash, $extension)`** - Generates: `{type}_{w}x{h}_{hash}_{ext}.webp` **`_get_cache_path($cache_type, $filename)`** - Returns: `storage_path("rsx-thumbnails/{$cache_type}/{$filename}")` **`_save_thumbnail_to_cache($cache_path, $thumbnail_data)`** - Writes WebP data to cache - Creates directory if needed **`_serve_cached_thumbnail($cache_path)`** - Attempts to open file (race condition safe) - Touches mtime if configured - Returns Response or null **`_enforce_dynamic_quota()`** - Scans dynamic directory - Deletes oldest files until under quota ### Why Public? Artisan commands (`Thumbnails_Generate_Command`, etc.) need access to these helpers. Making them public with `_` prefix indicates "internal use only - don't call from application code." ## DRY Implementation **Single Generation Function**: ```php protected static function generate_and_serve_thumbnail( $attachment, $type, $width, $height, $cache_type, $cache_filename ) { // Common logic for both preset and dynamic // Check cache -> Generate if needed -> Save -> Enforce quota -> Serve } ``` **Route Methods Are Thin Wrappers**: ```php public static function thumbnail_preset(Request $request, array $params = []) { // Parse preset params // Generate cache filename // Call common function return static::generate_and_serve_thumbnail(...); } public static function thumbnail(Request $request, array $params = []) { // Parse dynamic params // Generate cache filename // Call common function return static::generate_and_serve_thumbnail(...); } ``` This eliminates duplication while keeping route-specific validation separate. ## Artisan Commands ### Thumbnails_Clean_Command Enforces quotas for preset and/or dynamic thumbnails. **Implementation**: - Scans directory, builds array of [path, size, mtime] - Sorts by mtime ascending (oldest first) - Deletes files until under quota - Reports statistics **Flags**: `--preset`, `--dynamic`, `--all` (default) ### Thumbnails_Generate_Command Pre-generates preset thumbnails for attachments. **Implementation**: - Queries File_Attachment_Model (optionally filtered by key) - For each attachment/preset combination: - Generate cache filename - Skip if already cached - Generate thumbnail - Save to cache - No quota enforcement (manual command) **Use Cases**: - Cache warming after deployment - Background generation for new uploads - Regenerating after changing preset sizes ### Thumbnails_Stats_Command Displays cache usage statistics. **Implementation**: - Scans both directories - Calculates: file count, total size, quota percentage, oldest/newest mtime - For presets: breaks down by preset name (extracted from filename) ## WebP Output All thumbnails are WebP format for optimal size/quality: ```php $image->setImageFormat('webp'); $image->setImageCompressionQuality(85); $thumbnail_data = $image->getImageBlob(); ``` **Why WebP?** - Superior compression vs JPEG/PNG - Supports transparency (for 'fit' thumbnails) - Universal browser support (96%+ as of 2024) ## Non-Image Files Icon-based thumbnails for PDFs, documents, etc.: ```php if ($attachment->is_image()) { $thumbnail_data = static::__generate_thumbnail(...); } else { $thumbnail_data = File_Attachment_Icons::render_icon_as_thumbnail( $attachment->file_extension, $width, $height ); } ``` Icons are rendered as WebP images for consistency. ## Security **Authorization**: All thumbnail endpoints check `file.thumbnail.authorize` event gate. Implement handlers in `/rsx/handlers/` to control access. **Dimension Limits**: Dynamic thumbnails enforce 10-256 pixel limits to prevent abuse. Preset thumbnails have no limits (developer-controlled). **Extension Validation**: File extensions are normalized (lowercase, jpeg→jpg) before cache filename generation. ## Configuration All configuration in `/system/config/rsx.php` under `thumbnails` key: ```php 'thumbnails' => [ 'presets' => [ 'profile' => ['type' => 'cover', 'width' => 200, 'height' => 200], // ... ], 'quotas' => [ 'preset_max_bytes' => 100 * 1024 * 1024, // 100MB 'dynamic_max_bytes' => 50 * 1024 * 1024, // 50MB ], 'touch_on_read' => env('THUMBNAILS_TOUCH_ON_READ', true), 'touch_interval' => env('THUMBNAILS_TOUCH_INTERVAL', 600), ], ``` ## File_Thumbnail_Model Removal **Previous Design**: Database table tracking thumbnails with source/thumbnail storage relationships. **Why Removed**: - Filesystem is sufficient for caching - mtime provides LRU info - No need for DB overhead - Simpler, faster, less to maintain **Migration**: `2025_11_16_093331_drop_file_thumbnails_table.php` drops the table. ## Testing Checklist - [ ] Preset thumbnail generation and caching - [ ] Dynamic thumbnail generation and caching - [ ] Cache hits serve from disk - [ ] Cache misses generate and cache - [ ] Race condition handling (file deleted between check and open) - [ ] Dynamic quota enforcement deletes oldest first - [ ] mtime touching respects config and interval - [ ] Preset/dynamic survive `rsx:clean` - [ ] Same hash + different extension creates separate thumbnails - [ ] Invalid preset name throws exception - [ ] All generated thumbnails are WebP - [ ] `rsx:thumbnails:clean` enforces quotas - [ ] `rsx:thumbnails:generate` pre-generates correctly - [ ] `rsx:thumbnails:stats` shows accurate data - [ ] Icon-based thumbnails for non-images work - [ ] Browser cache headers correct (1 year) ## Future Enhancements Not in current implementation but planned: - **Animated thumbnails**: Extract first frame from GIF/WebP/video - **Video thumbnails**: Frame extraction via FFmpeg - **PDF thumbnails**: First page via Imagick - **CDN integration**: Upload thumbnails to CDN storage - **Per-preset quotas**: Different limits for different presets - **Background generation**: Queue-based thumbnail generation - **Analytics**: DB-backed usage tracking (optional)