dataManager = $dataManager; $this->config = $dataManager->getAllConfig(); $this->baseUrl = $baseUrl ?: $this->config['rss_base_url'] ?? 'http://localhost/'; // Ensure base URL ends with slash $this->baseUrl = rtrim($this->baseUrl, '/').'/'; // Initialize caching $this->cacheDir = __DIR__.'/../data/cache'; $this->cacheFile = $this->cacheDir.'/rss_feed.xml'; $this->cacheLifetime = 300; // 5 minutes default cache lifetime // Ensure cache directory exists if (!is_dir($this->cacheDir)) { mkdir($this->cacheDir, 0755, true); } } /** * Generate complete RSS feed XML with caching support * @param bool $forceRegenerate Force regeneration ignoring cache * @return string RSS XML content */ public function generateFeed($forceRegenerate = false) { try { // Check if cached version exists and is still valid if (!$forceRegenerate && $this->isCacheValid()) { return file_get_contents($this->cacheFile); } $enabledAlbums = $this->getEnabledAlbums(); $rssItems = []; // Collect photos from all RSS-enabled albums foreach ($enabledAlbums as $album) { $photos = $this->dataManager->getPhotos($album['id']); foreach ($photos as $photo) { $rssItems[] = $this->formatPhotoForRSS($photo, $album); } } // Sort items by upload date (newest first) usort($rssItems, function ($a, $b) { return strtotime($b['pubDate']) - strtotime($a['pubDate']); }); // Limit items based on configuration $maxItems = $this->config['rss_max_items'] ?? 50; $rssItems = array_slice($rssItems, 0, $maxItems); // Generate RSS XML $xml = $this->generateRSSHeader(); foreach ($rssItems as $item) { $xml .= $this->generateRSSItem($item); } $xml .= $this->generateRSSFooter(); // Validate the generated XML if (!$this->validateRSSXML($xml)) { throw new Exception('Generated RSS XML is invalid'); } // Cache the generated feed $this->cacheRSSFeed($xml); return $xml; } catch (Exception $e) { throw new Exception('Failed to generate RSS feed: '.$e->getMessage()); } } /** * Get albums that are enabled for RSS * @return array Array of RSS-enabled albums */ public function getEnabledAlbums() { try { $allAlbums = $this->dataManager->getAlbums(true); return array_filter($allAlbums, function ($album) { return !empty($album['is_rss_enabled']) && $album['photo_count'] > 0; }); } catch (Exception $e) { throw new Exception('Failed to get enabled albums: '.$e->getMessage()); } } /** * Format photo data for RSS item * @param array $photo Photo data * @param array $album Album data * @return array RSS item data */ private function formatPhotoForRSS($photo, $album) { $photoUrl = $this->baseUrl."albums/{$album['id']}/{$photo['filename']}"; $albumUrl = $this->baseUrl."?album={$album['id']}"; // Create enhanced description with photo metadata and direct album link $description = "

Photo from album: {$album['title']}

"; if (!empty($album['description'])) { $description .= "

{$album['description']}

"; } $description .= "

\"Photo

"; // Add direct link to view full album $description .= "

📸 View Full Album

"; // Add photo metadata if (!empty($photo['width']) && !empty($photo['height'])) { $description .= "

📐 Dimensions: {$photo['width']} × {$photo['height']} pixels

"; } if (!empty($photo['file_size'])) { $fileSize = $this->formatBytes($photo['file_size']); $description .= "

💾 File size: {$fileSize}

"; } if (!empty($photo['uploaded_at'])) { $uploadDate = date('F j, Y g:i A', strtotime($photo['uploaded_at'])); $description .= "

📅 Uploaded: {$uploadDate}

"; } // Create enhanced title with album name $title = $photo['original_filename'] ?? $photo['filename']; if (!empty($album['title'])) { $title .= " | {$album['title']}"; } return [ 'title' => $this->escapeXml($title), 'link' => $this->escapeXml($albumUrl), 'description' => $this->escapeXml($description), 'pubDate' => $this->formatRSSDate($photo['uploaded_at']), 'guid' => $this->escapeXml($photoUrl), 'enclosure' => [ 'url' => $this->escapeXml($photoUrl), 'length' => $photo['file_size'] ?? 0, 'type' => 'image/webp' ] ]; } /** * Generate RSS header * @return string RSS header XML */ private function generateRSSHeader() { $title = $this->escapeXml($this->config['rss_title'] ?? 'Photo Gallery RSS Feed'); $description = $this->escapeXml($this->config['rss_description'] ?? 'Latest photos from selected albums'); $link = $this->escapeXml($this->baseUrl); $buildDate = $this->formatRSSDate(date('c')); return ''."\n". ''."\n". ''."\n". "{$title}\n". "{$link}\n". "{$description}\n". "en-us\n". "{$buildDate}\n". "Photo Gallery Backend\n". "\n"; } /** * Generate RSS footer * @return string RSS footer XML */ private function generateRSSFooter() { return "\n\n"; } /** * Generate single RSS item * @param array $item RSS item data * @return string RSS item XML */ private function generateRSSItem($item) { $xml = "\n"; $xml .= "{$item['title']}\n"; $xml .= "{$item['link']}\n"; $xml .= "\n"; $xml .= "{$item['pubDate']}\n"; $xml .= "{$item['guid']}\n"; if (!empty($item['enclosure'])) { $xml .= "\n"; } $xml .= "\n"; return $xml; } /** * Format date for RSS (RFC 2822 format) * @param string $date ISO date string * @return string RFC 2822 formatted date */ private function formatRSSDate($date) { if (empty($date)) { return date('r'); } return date('r', strtotime($date)); } /** * Escape XML special characters * @param string $text Text to escape * @return string Escaped text */ private function escapeXml($text) { return htmlspecialchars($text, ENT_XML1 | ENT_COMPAT, 'UTF-8'); } /** * Format bytes to human readable format * @param int $bytes Number of bytes * @return string Formatted string */ private function formatBytes($bytes) { if ($bytes == 0) { return '0 B'; } $units = ['B', 'KB', 'MB', 'GB']; $factor = floor(log($bytes, 1024)); return sprintf('%.1f %s', $bytes / pow(1024, $factor), $units[$factor]); } /** * Check if cached RSS feed is still valid * @return bool True if cache is valid */ private function isCacheValid() { if (!file_exists($this->cacheFile)) { return false; } $cacheTime = filemtime($this->cacheFile); $currentTime = time(); // Check if cache has expired if (($currentTime - $cacheTime) > $this->cacheLifetime) { return false; } // Check if any RSS-enabled albums have been modified since cache was created $enabledAlbums = $this->getEnabledAlbums(); foreach ($enabledAlbums as $album) { if (strtotime($album['updated_at']) > $cacheTime) { return false; } } return true; } /** * Cache the RSS feed content * @param string $xml RSS XML content * @return bool Success status */ private function cacheRSSFeed($xml) { try { return file_put_contents($this->cacheFile, $xml) !== false; } catch (Exception $e) { error_log('Failed to cache RSS feed: '.$e->getMessage()); return false; } } /** * Validate RSS XML format * @param string $xml RSS XML content * @return bool True if valid */ private function validateRSSXML($xml) { try { // Enable user error handling libxml_use_internal_errors(true); libxml_clear_errors(); // Create DOMDocument and load XML $dom = new DOMDocument(); $dom->loadXML($xml); // Check for XML errors $errors = libxml_get_errors(); if (!empty($errors)) { error_log('RSS XML validation errors: '.print_r($errors, true)); return false; } // Validate RSS structure $rssElements = $dom->getElementsByTagName('rss'); if ($rssElements->length !== 1) { error_log('RSS XML validation error: Missing or multiple RSS elements'); return false; } $channelElements = $dom->getElementsByTagName('channel'); if ($channelElements->length !== 1) { error_log('RSS XML validation error: Missing or multiple channel elements'); return false; } // Check required channel elements $requiredElements = ['title', 'link', 'description']; $channel = $channelElements->item(0); foreach ($requiredElements as $element) { $elements = $channel->getElementsByTagName($element); if ($elements->length === 0) { error_log("RSS XML validation error: Missing required element: {$element}"); return false; } } return true; } catch (Exception $e) { error_log('RSS XML validation exception: '.$e->getMessage()); return false; } finally { // Restore error handling libxml_use_internal_errors(false); } } /** * Invalidate RSS cache (force regeneration on next request) * @return bool Success status */ public function invalidateCache() { if (file_exists($this->cacheFile)) { return unlink($this->cacheFile); } return true; } /** * Trigger RSS feed regeneration (auto-update mechanism) * Called when photos are added or albums are modified * @return bool Success status */ public function triggerAutoUpdate() { try { // Invalidate cache first $this->invalidateCache(); // Generate new feed (this will create new cache) $this->generateFeed(true); return true; } catch (Exception $e) { error_log('RSS auto-update failed: '.$e->getMessage()); return false; } } /** * Get RSS feed discovery meta tags for HTML head * @return string HTML meta tags */ public function getRSSDiscoveryTags() { $rssUrl = $this->baseUrl.'api.php?action=getRSSFeed'; $title = $this->config['rss_title'] ?? 'Photo Gallery RSS Feed'; return ''; } /** * Get cache statistics * @return array Cache information */ public function getCacheStats() { $stats = [ 'cache_exists' => file_exists($this->cacheFile), 'cache_valid' => false, 'cache_size' => 0, 'cache_age' => 0, 'cache_lifetime' => $this->cacheLifetime ]; if ($stats['cache_exists']) { $stats['cache_size'] = filesize($this->cacheFile); $stats['cache_age'] = time() - filemtime($this->cacheFile); $stats['cache_valid'] = $this->isCacheValid(); } return $stats; } }