storage = StorageFactory::create(); $this->config = Config::getInstance(); $this->cache = new Cache(); $this->rateLimiter = new RateLimiter(); } /** * Génère le flux RSS avec rate limiting et cache * * @param string $type Type de flux: 'all', 'category', 'user', 'tag' * @param string|null $id ID de la catégorie, utilisateur ou tag (requis si type != 'all') * @param int|null $page Numéro de page pour la pagination (commence à 1) * @return string XML du flux RSS */ public function generateFeed(string $type = 'all', ?string $id = null, ?int $page = null, ?array $user = null): string { // Rate limiting pour éviter les abus $this->checkRateLimit(); // Validation du type if (!in_array($type, ['all', 'category', 'user', 'tag'])) { throw new \InvalidArgumentException("Invalid feed type: {$type}. Must be 'all', 'category', 'user', or 'tag'"); } // Validation de l'ID si requis if ($type !== 'all' && empty($id)) { throw new \InvalidArgumentException("ID is required for feed type: {$type}"); } $page = $page ?? 1; if ($page < 1) { $page = 1; } // La clé de cache inclut l'identifiant utilisateur pour isoler les feeds privés $userSegment = $user ? ('user:' . ($user['id'] ?? 'unknown')) : 'public'; $cacheKey = "rss:feed:{$type}:" . ($id ?? 'all') . ":page:{$page}:{$userSegment}"; return $this->cache->getOrSet($cacheKey, function() use ($type, $id, $page, $user) { return $this->buildFeed($type, $id, $page, $user); }, self::CACHE_TTL); } /** * Génère un flux Atom 1.0 complet * * @param string $type Type de flux: 'all', 'category', 'user', 'tag' * @param string|null $id ID de la catégorie, utilisateur ou tag (requis si type != 'all') * @param int|null $page Numéro de page pour la pagination (commence à 1) * @return string XML du flux Atom */ public function generateAtomFeed(string $type = 'all', ?string $id = null, ?int $page = null, ?array $user = null): string { // Rate limiting pour éviter les abus $this->checkRateLimit(); // Validation du type if (!in_array($type, ['all', 'category', 'user', 'tag'])) { throw new \InvalidArgumentException("Invalid feed type: {$type}. Must be 'all', 'category', 'user', or 'tag'"); } // Validation de l'ID si requis if ($type !== 'all' && empty($id)) { throw new \InvalidArgumentException("ID is required for feed type: {$type}"); } $page = $page ?? 1; if ($page < 1) { $page = 1; } // La clé de cache inclut l'identifiant utilisateur pour isoler les feeds privés $userSegment = $user ? ('user:' . ($user['id'] ?? 'unknown')) : 'public'; $cacheKey = "atom:feed:{$type}:" . ($id ?? 'all') . ":page:{$page}:{$userSegment}"; return $this->cache->getOrSet($cacheKey, function() use ($type, $id, $page, $user) { return $this->buildAtomFeed($type, $id, $page, $user); }, self::CACHE_TTL); } /** * Construit le contenu du flux RSS */ private function buildFeed(string $type, ?string $id, int $page, ?array $user = null): string { $baseUrl = $this->getBaseUrl(); $siteName = $this->config->get('forum.title', $this->config->get('site_name', 'Flatboard 5')); $language = $this->config->get('language', $this->config->get('default_language', 'fr')); $rssDescription = $this->getFeedDescription($type, $id); // Récupérer les discussions accessibles avec pagination $discussions = $this->getPublicDiscussions($type, $id, $page, $user); $hasMore = count($discussions) >= self::DEFAULT_ITEMS_LIMIT; // Charger les premiers posts en batch pour éviter N+1 $firstPosts = $this->getFirstPostsBatch($discussions); // Générer le XML avec XMLWriter pour sécurité et maintenabilité return $this->generateXml($baseUrl, $siteName, $rssDescription, $language, $discussions, $firstPosts, $type, $id, $page, $hasMore); } /** * Construit le contenu du flux Atom 1.0 */ private function buildAtomFeed(string $type, ?string $id, int $page, ?array $user = null): string { $baseUrl = $this->getBaseUrl(); $siteName = $this->config->get('forum.title', $this->config->get('site_name', 'Flatboard 5')); $language = $this->config->get('language', $this->config->get('default_language', 'fr')); $feedDescription = $this->getFeedDescription($type, $id); // Récupérer les discussions accessibles avec pagination $discussions = $this->getPublicDiscussions($type, $id, $page, $user); $hasMore = count($discussions) >= self::DEFAULT_ITEMS_LIMIT; // Charger les premiers posts en batch pour éviter N+1 $firstPosts = $this->getFirstPostsBatch($discussions); // Générer le XML Atom avec XMLWriter return $this->generateAtomXml($baseUrl, $siteName, $feedDescription, $language, $discussions, $firstPosts, $type, $id, $page, $hasMore); } /** * Obtient l'URL de base du site */ private function getBaseUrl(): string { $baseUrl = rtrim($this->config->get('site_url', 'http://localhost'), '/'); $basePath = UrlHelper::getBaseUrl(); if ($basePath && $basePath !== '') { $baseUrl .= '/' . ltrim($basePath, '/'); } return rtrim($baseUrl, '/'); } /** * Obtient la description du flux selon le type */ private function getFeedDescription(string $type, ?string $id): string { $siteName = $this->config->get('forum.title', $this->config->get('site_name', 'Flatboard 5')); $defaultDescription = 'Flux RSS de ' . $siteName; switch ($type) { case 'category': if ($id) { $category = Category::find($id); if ($category && !empty($category['name'])) { return 'Flux RSS de la catégorie ' . $category['name'] . ' - ' . $siteName; } } return $defaultDescription; case 'user': if ($id) { $user = User::find($id); if ($user && !empty($user['username'])) { return 'Flux RSS des discussions de ' . $user['username'] . ' - ' . $siteName; } } return $defaultDescription; case 'tag': if ($id) { $tag = Tag::find($id); if ($tag && !empty($tag['name'])) { return 'Flux RSS du tag ' . $tag['name'] . ' - ' . $siteName; } } return $defaultDescription; default: return $this->config->get('rss.description', $defaultDescription); } } /** * Vérifie le rate limiting pour les requêtes RSS */ private function checkRateLimit(): void { $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; if (!$this->rateLimiter->check('rss_feed', $ip, self::RATE_LIMIT_ATTEMPTS, self::RATE_LIMIT_WINDOW)) { Logger::warning('RSS feed rate limit exceeded', [ 'ip' => $ip, 'attempts' => self::RATE_LIMIT_ATTEMPTS, 'window' => self::RATE_LIMIT_WINDOW ]); http_response_code(429); header('Retry-After: ' . self::RATE_LIMIT_WINDOW); header('Content-Type: application/rss+xml; charset=UTF-8'); echo $this->generateErrorFeed('Rate limit exceeded. Please try again later.'); exit; } } /** * Récupère les discussions publiques filtrées selon le type */ private function getPublicDiscussions(string $type, ?string $id, int $page, ?array $user = null): array { $limit = $this->config->get('rss.items_per_feed', self::DEFAULT_ITEMS_LIMIT); $offset = ($page - 1) * $limit; $allDiscussions = []; switch ($type) { case 'category': if ($id) { $allDiscussions = $this->storage->getDiscussionsByCategory($id, $limit, $offset); } break; case 'user': if ($id) { // Filtrer manuellement par user_id car pas de méthode dédiée $allDiscussions = $this->storage->getAllDiscussions($limit * 2, $offset); $allDiscussions = array_filter($allDiscussions, function($discussion) use ($id) { return ($discussion['user_id'] ?? null) === $id; }); $allDiscussions = array_slice($allDiscussions, 0, $limit); } break; case 'tag': if ($id) { $allDiscussions = $this->storage->getDiscussionsByTag($id, $limit, $offset); } break; default: $allDiscussions = $this->storage->getAllDiscussions($limit, $offset); break; } return $this->filterAccessibleDiscussions($allDiscussions, $user); } /** * Filtre les discussions selon la visibilité pour l'utilisateur donné (null = invité). */ private function filterAccessibleDiscussions(array $discussions, ?array $user): array { return array_values(array_filter($discussions, function($discussion) use ($user) { return $this->isAccessibleDiscussion($discussion, $user); })); } /** * Vérifie si une discussion est accessible pour l'utilisateur donné. */ private function isAccessibleDiscussion(array $discussion, ?array $user): bool { $categoryId = $discussion['category_id'] ?? null; if (empty($categoryId)) { return false; } $category = Category::find($categoryId); if (!$category) { Logger::debug('RSS: Category not found for discussion', [ 'discussion_id' => $discussion['id'] ?? null, 'category_id' => $categoryId, ]); return false; } return $this->isAccessibleCategory($category, $user); } /** * Vérifie si une catégorie est accessible pour l'utilisateur donné (null = invité). * * - allowed_groups vide/null → publique, tout le monde y accède * - allowed_groups défini → privée, l'utilisateur doit appartenir à l'un des groupes */ private function isAccessibleCategory(array $category, ?array $user): bool { $allowedGroups = $category['allowed_groups'] ?? null; // Catégorie publique if ($allowedGroups === null || (is_array($allowedGroups) && empty($allowedGroups))) { return true; } // Format invalide → exclure par sécurité if (!is_array($allowedGroups)) { Logger::warning('RSS: Invalid allowed_groups format in category', [ 'category_id' => $category['id'] ?? null, 'allowed_groups' => $allowedGroups, ]); return false; } // Catégorie privée — l'utilisateur doit être authentifié if (!$user) { Logger::debug('RSS: Private category excluded (no user)', [ 'category_id' => $category['id'] ?? null, ]); return false; } $userId = $user['id'] ?? null; if (!$userId) { return false; } $userGroupIds = GroupHelper::getUserGroupIds($userId); if (empty($userGroupIds)) { return false; } $allowedGroupsStr = array_map('strval', $allowedGroups); $userGroupIdsStr = array_map('strval', $userGroupIds); foreach ($userGroupIdsStr as $groupId) { if (in_array($groupId, $allowedGroupsStr, true)) { return true; } } Logger::debug('RSS: Private category excluded (user not in allowed groups)', [ 'category_id' => $category['id'] ?? null, 'user_id' => $userId, ]); return false; } /** * Récupère les premiers posts de plusieurs discussions en batch pour éviter N+1 */ private function getFirstPostsBatch(array $discussions): array { $firstPosts = []; foreach ($discussions as $discussion) { $discussionId = $discussion['id'] ?? null; if (!$discussionId) { continue; } $firstPost = Post::getFirstPost($discussionId); if ($firstPost) { $firstPosts[$discussionId] = $firstPost; } } return $firstPosts; } /** * Génère le XML RSS avec XMLWriter (sécurisé et maintenable) */ private function generateXml( string $baseUrl, string $siteName, string $description, string $language, array $discussions, array $firstPosts, string $type, ?string $id, int $page, bool $hasMore ): string { $xml = new \XMLWriter(); $xml->openMemory(); $xml->startDocument('1.0', 'UTF-8'); $xml->setIndent(true); $xml->setIndentString(' '); // Élément racine RSS $xml->startElement('rss'); $xml->writeAttribute('version', '2.0'); $xml->writeAttribute('xmlns:atom', 'http://www.w3.org/2005/Atom'); $xml->writeAttribute('xmlns:dc', 'http://purl.org/dc/elements/1.1/'); // Channel $xml->startElement('channel'); // Éléments requis du channel $xml->writeElement('title', $siteName); $xml->writeElement('link', $baseUrl); $xml->writeElement('description', $description); // Éléments recommandés $xml->writeElement('language', $language); $latestDate = !empty($discussions) ? max(array_column($discussions, 'created_at')) : time(); $xml->writeElement('lastBuildDate', date('r', $latestDate)); $xml->writeElement('generator', 'Flatboard 5'); $xml->writeElement('ttl', '60'); // Cache pour 60 minutes // Lien Atom pour la découverte du flux $feedUrl = $this->getFeedUrl($baseUrl, $type, $id, $page); $xml->startElement('atom:link'); $xml->writeAttribute('href', $feedUrl); $xml->writeAttribute('rel', 'self'); $xml->writeAttribute('type', 'application/rss+xml'); $xml->endElement(); // Liens de pagination si nécessaire if ($page > 1) { $prevUrl = $this->getFeedUrl($baseUrl, $type, $id, $page - 1); $xml->startElement('atom:link'); $xml->writeAttribute('href', $prevUrl); $xml->writeAttribute('rel', 'prev'); $xml->writeAttribute('type', 'application/rss+xml'); $xml->endElement(); } if ($hasMore) { $nextUrl = $this->getFeedUrl($baseUrl, $type, $id, $page + 1); $xml->startElement('atom:link'); $xml->writeAttribute('href', $nextUrl); $xml->writeAttribute('rel', 'next'); $xml->writeAttribute('type', 'application/rss+xml'); $xml->endElement(); } // Items (discussions) foreach ($discussions as $discussion) { $this->writeDiscussionItem($xml, $discussion, $firstPosts[$discussion['id'] ?? null] ?? null, $baseUrl); } $xml->endElement(); // channel $xml->endElement(); // rss return $xml->outputMemory(); } /** * Génère le XML Atom 1.0 avec XMLWriter */ private function generateAtomXml( string $baseUrl, string $siteName, string $description, string $language, array $discussions, array $firstPosts, string $type, ?string $id, int $page, bool $hasMore ): string { $xml = new \XMLWriter(); $xml->openMemory(); $xml->startDocument('1.0', 'UTF-8'); $xml->setIndent(true); $xml->setIndentString(' '); // Élément racine feed $xml->startElement('feed'); $xml->writeAttribute('xmlns', 'http://www.w3.org/2005/Atom'); $xml->writeAttribute('xml:lang', $language); // Éléments requis du feed $xml->writeElement('title', $siteName); $xml->writeElement('subtitle', $description); // ID du feed (URL unique et permanente) $feedId = $this->getFeedUrl($baseUrl, $type, $id, 1); $xml->writeElement('id', $feedId); // Lien vers le site $xml->startElement('link'); $xml->writeAttribute('href', $baseUrl); $xml->writeAttribute('rel', 'alternate'); $xml->writeAttribute('type', 'text/html'); $xml->endElement(); // Lien self $feedUrl = $this->getFeedUrl($baseUrl, $type, $id, $page, 'atom'); $xml->startElement('link'); $xml->writeAttribute('href', $feedUrl); $xml->writeAttribute('rel', 'self'); $xml->writeAttribute('type', 'application/atom+xml'); $xml->endElement(); // Liens de pagination if ($page > 1) { $prevUrl = $this->getFeedUrl($baseUrl, $type, $id, $page - 1, 'atom'); $xml->startElement('link'); $xml->writeAttribute('href', $prevUrl); $xml->writeAttribute('rel', 'prev'); $xml->writeAttribute('type', 'application/atom+xml'); $xml->endElement(); } if ($hasMore) { $nextUrl = $this->getFeedUrl($baseUrl, $type, $id, $page + 1, 'atom'); $xml->startElement('link'); $xml->writeAttribute('href', $nextUrl); $xml->writeAttribute('rel', 'next'); $xml->writeAttribute('type', 'application/atom+xml'); $xml->endElement(); } // Updated (date de dernière mise à jour) $lastUpdated = !empty($discussions) ? max(array_column($discussions, 'created_at')) : time(); $xml->writeElement('updated', date('c', $lastUpdated)); // Author du feed $xml->startElement('author'); $xml->writeElement('name', $siteName); $xml->endElement(); // Entries (discussions) foreach ($discussions as $discussion) { $this->writeAtomEntry($xml, $discussion, $firstPosts[$discussion['id'] ?? null] ?? null, $baseUrl); } $xml->endElement(); // feed return $xml->outputMemory(); } /** * Obtient l'URL du flux selon le type et la page */ private function getFeedUrl(string $baseUrl, string $type, ?string $id, int $page, string $format = 'rss'): string { $extension = $format === 'atom' ? 'atom' : 'rss'; if ($type === 'all') { $url = $baseUrl . '/' . $extension; } else { $url = $baseUrl . '/' . $extension . '/' . $type . '/' . $id; } if ($page > 1) { $url .= '?page=' . $page; } return $url; } /** * Écrit un item RSS pour une discussion */ private function writeDiscussionItem(\XMLWriter $xml, array $discussion, ?array $firstPost, string $baseUrl): void { $discussionId = $discussion['id'] ?? ''; $discussionNumber = \App\Models\Discussion::getGlobalNumber($discussionId); $discussionSlug = \App\Core\Sanitizer::slug($discussion['title'] ?? 'discussion'); $discussionUrl = $baseUrl . '/d/' . $discussionNumber . '-' . $discussionSlug; // Récupérer le contenu $content = $this->extractContent($discussion, $firstPost); $content = $this->truncateContent($content, self::CONTENT_MAX_LENGTH); $xml->startElement('item'); // Éléments requis $xml->writeElement('title', $discussion['title'] ?? ''); $xml->writeElement('link', $discussionUrl); // Guid stable : numéro seul, sans slug (le slug peut changer si le titre est édité) $stableUrl = $baseUrl . '/d/' . $discussionNumber; $xml->startElement('guid'); $xml->writeAttribute('isPermaLink', 'true'); $xml->text($stableUrl); $xml->endElement(); $xml->writeElement('description', $content); $xml->writeElement('pubDate', date('r', $discussion['created_at'] ?? time())); // Informations auteur (Dublin Core) if (!empty($discussion['user_id'])) { $author = User::find($discussion['user_id']); if ($author && !empty($author['username'])) { $xml->writeElement('dc:creator', $author['username']); } } // Informations catégorie if (!empty($discussion['category_id'])) { $category = Category::find($discussion['category_id']); if ($category && !empty($category['name'])) { $xml->writeElement('category', $category['name']); } } // Tags si disponibles if (!empty($discussion['tags']) && is_array($discussion['tags'])) { foreach ($discussion['tags'] as $tag) { if (!empty($tag)) { $xml->writeElement('category', $tag); } } } // Enclosures pour les médias (images et attachments) $this->writeEnclosures($xml, $discussion, $firstPost, $baseUrl); $xml->endElement(); // item } /** * Écrit une entry Atom pour une discussion */ private function writeAtomEntry(\XMLWriter $xml, array $discussion, ?array $firstPost, string $baseUrl): void { $discussionId = $discussion['id'] ?? ''; $discussionNumber = \App\Models\Discussion::getGlobalNumber($discussionId); $discussionSlug = \App\Core\Sanitizer::slug($discussion['title'] ?? 'discussion'); $discussionUrl = $baseUrl . '/d/' . $discussionNumber . '-' . $discussionSlug; // Récupérer le contenu $content = $this->extractContent($discussion, $firstPost); $htmlContent = $this->extractHtmlContent($discussion, $firstPost); $xml->startElement('entry'); // Éléments requis $xml->writeElement('title', $discussion['title'] ?? ''); // ID stable : numéro seul, sans slug (le slug peut changer si le titre est édité) $xml->writeElement('id', $baseUrl . '/d/' . $discussionNumber); $xml->writeElement('updated', date('c', $discussion['updated_at'] ?? $discussion['created_at'] ?? time())); // Lien vers la discussion $xml->startElement('link'); $xml->writeAttribute('href', $discussionUrl); $xml->writeAttribute('rel', 'alternate'); $xml->writeAttribute('type', 'text/html'); $xml->endElement(); // Auteur if (!empty($discussion['user_id'])) { $author = User::find($discussion['user_id']); if ($author && !empty($author['username'])) { $xml->startElement('author'); $xml->writeElement('name', $author['username']); if (!empty($author['email'])) { $xml->writeElement('email', $author['email']); } $xml->endElement(); } } // Contenu $xml->startElement('content'); $xml->writeAttribute('type', 'html'); $xml->writeCData($htmlContent); $xml->endElement(); // Résumé $summary = $this->truncateContent($content, self::CONTENT_MAX_LENGTH); $xml->writeElement('summary', $summary); // Catégorie if (!empty($discussion['category_id'])) { $category = Category::find($discussion['category_id']); if ($category && !empty($category['name'])) { $xml->startElement('category'); $xml->writeAttribute('term', $category['name']); $xml->writeAttribute('label', $category['name']); $xml->endElement(); } } // Tags if (!empty($discussion['tags']) && is_array($discussion['tags'])) { foreach ($discussion['tags'] as $tag) { if (!empty($tag)) { $xml->startElement('category'); $xml->writeAttribute('term', $tag); $xml->writeAttribute('label', $tag); $xml->endElement(); } } } // Enclosures pour les médias $this->writeAtomEnclosures($xml, $discussion, $firstPost, $baseUrl); $xml->endElement(); // entry } /** * Écrit les enclosures RSS pour les médias (images et attachments) */ private function writeEnclosures(\XMLWriter $xml, array $discussion, ?array $firstPost, string $baseUrl): void { $mediaItems = $this->extractMedia($discussion, $firstPost, $baseUrl); foreach ($mediaItems as $media) { $xml->startElement('enclosure'); $xml->writeAttribute('url', $media['url']); $xml->writeAttribute('type', $media['type']); if (isset($media['length'])) { $xml->writeAttribute('length', $media['length']); } $xml->endElement(); } } /** * Écrit les enclosures Atom pour les médias */ private function writeAtomEnclosures(\XMLWriter $xml, array $discussion, ?array $firstPost, string $baseUrl): void { $mediaItems = $this->extractMedia($discussion, $firstPost, $baseUrl); foreach ($mediaItems as $media) { $xml->startElement('link'); $xml->writeAttribute('href', $media['url']); $xml->writeAttribute('rel', 'enclosure'); $xml->writeAttribute('type', $media['type']); if (isset($media['length'])) { $xml->writeAttribute('length', $media['length']); } $xml->endElement(); } } /** * Extrait les médias (images et attachments) d'une discussion */ private function extractMedia(array $discussion, ?array $firstPost, string $baseUrl): array { $mediaItems = []; // Extraire les images du contenu Markdown $content = ''; if ($firstPost && !empty($firstPost['content'])) { $content = $firstPost['content']; } elseif (!empty($discussion['content'])) { $content = $discussion['content']; } if (!empty($content)) { // Extraire les images Markdown: ![alt](url) preg_match_all('/!\[.*?\]\(([^)]+)\)/', $content, $imageMatches); $imageUrls = $imageMatches[1] ?? []; foreach ($imageUrls as $imageUrl) { // Nettoyer l'URL $imageUrl = trim($imageUrl); if (empty($imageUrl)) { continue; } // Convertir en URL absolue si nécessaire if (strpos($imageUrl, 'http') !== 0) { if (strpos($imageUrl, '/') === 0) { $imageUrl = $baseUrl . $imageUrl; } else { $imageUrl = $baseUrl . '/' . $imageUrl; } } // Déterminer le type MIME $mimeType = $this->getMimeType($imageUrl); $mediaItems[] = [ 'url' => $imageUrl, 'type' => $mimeType, ]; } } // Extraire les attachments du post if ($firstPost && !empty($firstPost['attachments'])) { $attachments = $firstPost['attachments']; // Si c'est une chaîne JSON, décoder if (is_string($attachments)) { $attachments = json_decode($attachments, true); } if (is_array($attachments)) { foreach ($attachments as $attachment) { $attachmentUrl = $attachment['url'] ?? ''; if (empty($attachmentUrl)) { continue; } // Convertir en URL absolue si nécessaire if (strpos($attachmentUrl, 'http') !== 0) { if (strpos($attachmentUrl, '/') === 0) { $attachmentUrl = $baseUrl . $attachmentUrl; } else { $attachmentUrl = $baseUrl . '/' . $attachmentUrl; } } // Déterminer le type MIME $mimeType = $this->getMimeType($attachmentUrl, $attachment['filename'] ?? ''); // Taille du fichier si disponible $length = null; if (!empty($attachment['size'])) { $length = (int)$attachment['size']; } $mediaItems[] = [ 'url' => $attachmentUrl, 'type' => $mimeType, 'length' => $length, ]; } } } return $mediaItems; } /** * Détermine le type MIME d'un fichier à partir de son URL ou nom */ private function getMimeType(string $url, string $filename = ''): string { // Essayer d'extraire l'extension $extension = ''; if (!empty($filename)) { $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); } if (empty($extension)) { $extension = strtolower(pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION)); } // Mapping des extensions vers les types MIME $mimeTypes = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml', 'pdf' => 'application/pdf', 'zip' => 'application/zip', 'rar' => 'application/x-rar-compressed', '7z' => 'application/x-7z-compressed', 'tar' => 'application/x-tar', 'gz' => 'application/gzip', ]; return $mimeTypes[$extension] ?? 'application/octet-stream'; } /** * Extrait le contenu d'une discussion (depuis le premier post ou la discussion) */ private function extractContent(array $discussion, ?array $firstPost): string { $content = ''; if ($firstPost && !empty($firstPost['content'])) { $content = $firstPost['content']; } elseif (!empty($discussion['content'])) { $content = $discussion['content']; } // Parser le Markdown et convertir en texte simple if (!empty($content)) { $html = \App\Helpers\MarkdownHelper::parse($content); $content = strip_tags($html); // Nettoyer les espaces multiples $content = preg_replace('/\s+/', ' ', $content); $content = trim($content); } return $content; } /** * Extrait le contenu HTML d'une discussion pour Atom */ private function extractHtmlContent(array $discussion, ?array $firstPost): string { $content = ''; if ($firstPost && !empty($firstPost['content'])) { $content = $firstPost['content']; } elseif (!empty($discussion['content'])) { $content = $discussion['content']; } // Parser le Markdown en HTML if (!empty($content)) { return \App\Helpers\MarkdownHelper::parse($content); } return ''; } /** * Tronque le contenu à la limite spécifiée en coupant aux mots */ private function truncateContent(string $content, int $maxLength): string { if (mb_strlen($content) <= $maxLength) { return $content; } // Couper à la limite $truncated = mb_substr($content, 0, $maxLength); // Chercher le dernier espace pour couper aux mots $lastSpace = mb_strrpos($truncated, ' '); if ($lastSpace !== false && $lastSpace > $maxLength * 0.8) { // Si on trouve un espace dans les 80% derniers caractères, couper là $truncated = mb_substr($truncated, 0, $lastSpace); } return $truncated . '...'; } /** * Génère un flux RSS d'erreur */ public function generateErrorFeed(string $errorMessage): string { $xml = new \XMLWriter(); $xml->openMemory(); $xml->startDocument('1.0', 'UTF-8'); $xml->setIndent(true); $xml->startElement('rss'); $xml->writeAttribute('version', '2.0'); $xml->startElement('channel'); $xml->writeElement('title', 'Error'); $xml->writeElement('description', htmlspecialchars($errorMessage, ENT_XML1 | ENT_QUOTES, 'UTF-8')); $xml->endElement(); // channel $xml->endElement(); // rss return $xml->outputMemory(); } }