diff --git a/database/migration_005_rss_feeds.sql b/database/migration_005_rss_feeds.sql new file mode 100644 index 0000000..c07adaa --- /dev/null +++ b/database/migration_005_rss_feeds.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS rss_feeds ( + id SERIAL PRIMARY KEY, + user_email TEXT NOT NULL, + feed_url TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT now(), + UNIQUE (user_email, feed_url) +); diff --git a/public/.htaccess b/public/.htaccess index 2a62dab..de04719 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -40,6 +40,9 @@ RewriteRule ^admin/?$ /index.php?action=admin [L,QSA] RewriteRule ^categories/?$ /index.php?action=categories [L,QSA] RewriteRule ^profile/?$ /index.php?action=profile [L,QSA] RewriteRule ^search/?$ /index.php?action=search [L,QSA] +RewriteRule ^flux/?$ /index.php?action=flux [L,QSA] +RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA] +RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA] # Profil public auteur RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=author&slug=$1 [L,QSA] diff --git a/public/assets/css/style.css b/public/assets/css/style.css index d6c446b..e861d2a 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -1283,3 +1283,57 @@ footer.mt-5 { margin-top: 0 !important; } color: var(--vl-muted); line-height: 1.7; } + +/* ─── Agrégateur de flux ─────────────────── */ + +.flux-list { + display: flex; + flex-direction: column; + gap: 1.5rem; + max-width: 52rem; +} + +.flux-item { + border-left: 3px solid var(--vl-border); + padding-left: 1rem; +} + +.flux-item-meta { + display: flex; + align-items: center; + gap: .5rem; + flex-wrap: wrap; + font-size: .8rem; + color: var(--vl-muted); + margin-bottom: .25rem; +} + +.flux-author { + font-weight: 600; + color: var(--vl-accent); + text-decoration: none; +} +.flux-author:hover { text-decoration: underline; } + +.flux-feed-name::before { content: '·'; margin-right: .5rem; } + +.flux-date::before { content: '·'; margin-right: .5rem; } + +.flux-item-title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 .25rem; +} + +.flux-item-title a { + color: var(--vl-text); + text-decoration: none; +} +.flux-item-title a:hover { color: var(--vl-accent); } + +.flux-item-summary { + font-size: .875rem; + color: var(--vl-muted); + margin: 0; + line-height: 1.6; +} diff --git a/public/index.php b/public/index.php index cb0b85d..439d92b 100644 --- a/public/index.php +++ b/public/index.php @@ -22,7 +22,7 @@ $action = $_GET['action'] ?? 'list'; $uuid = $_GET['uuid'] ?? ''; $slug = $_GET['slug'] ?? ''; -$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found']; +$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed']; $metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null; unset($_noindexActions); @@ -881,6 +881,44 @@ switch ($action) { include BASE_PATH . '/templates/author_profile.php'; break; + case 'flux': + require_once BASE_PATH . '/src/FeedFetcher.php'; + $fetcher = new FeedFetcher(BASE_PATH . '/data/_cache/feeds'); + $fluxItems = []; + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->query( + 'SELECT f.user_email, f.feed_url, f.label, + p.display_name, p.profile_slug + FROM rss_feeds f + LEFT JOIN user_profiles p ON p.email = f.user_email + ORDER BY f.created_at' + ); + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $_row) { + $data = $fetcher->get($_row['feed_url']); + if (!$data) { + continue; + } + $feedTitle = $_row['label'] !== '' ? $_row['label'] : $data['feed_title']; + $authorName = $_row['display_name'] ?? ''; + $authorSlug = $_row['profile_slug'] ?? ''; + foreach ($data['items'] as $_item) { + $fluxItems[] = array_merge($_item, [ + 'feed_title' => $feedTitle, + 'feed_url' => $_row['feed_url'], + 'author_name' => $authorName, + 'author_slug' => $authorSlug, + ]); + } + } + } catch (\Throwable) { + } + } + usort($fluxItems, static fn ($a, $b) => $b['date'] <=> $a['date']); + include BASE_PATH . '/templates/flux.php'; + break; + case 'about': include BASE_PATH . '/templates/about.php'; break; @@ -1840,9 +1878,56 @@ switch ($action) { if ($profileCurrentUrl === '' && $profileCurrentSlug !== '') { $profileCurrentUrl = rtrim(APP_URL, '/') . '/profil/' . rawurlencode($profileCurrentSlug); } + // Feeds RSS de l'utilisateur + $profileFeeds = []; + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare('SELECT id, feed_url, label FROM rss_feeds WHERE user_email = :e ORDER BY created_at'); + $st->execute([':e' => currentUserEmail()]); + $profileFeeds = $st->fetchAll(PDO::FETCH_ASSOC); + } catch (\Throwable) { + } + } include BASE_PATH . '/templates/profile.php'; break; + case 'add_feed': + requireAuth(); + $feedUrl = filter_var(trim($_POST['feed_url'] ?? ''), FILTER_VALIDATE_URL) ?: ''; + $feedLabel = trim($_POST['feed_label'] ?? ''); + if ($feedUrl !== '') { + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare( + 'INSERT INTO rss_feeds (user_email, feed_url, label) VALUES (:e, :u, :l) + ON CONFLICT (user_email, feed_url) DO UPDATE SET label = :l' + ); + $st->execute([':e' => currentUserEmail(), ':u' => $feedUrl, ':l' => $feedLabel]); + } catch (\Throwable) { + } + } + } + header('Location: /profile#feeds'); + exit; + + case 'delete_feed': + requireAuth(); + $feedId = (int)($_POST['feed_id'] ?? 0); + if ($feedId > 0) { + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare('DELETE FROM rss_feeds WHERE id = :id AND user_email = :e'); + $st->execute([':id' => $feedId, ':e' => currentUserEmail()]); + } catch (\Throwable) { + } + } + } + header('Location: /profile#feeds'); + exit; + case 'search_files': requireAuth(); header('Content-Type: application/json'); diff --git a/src/FeedFetcher.php b/src/FeedFetcher.php new file mode 100644 index 0000000..7fd00da --- /dev/null +++ b/src/FeedFetcher.php @@ -0,0 +1,185 @@ +cacheRead($url); + if ($cached !== null && time() < (int)$cached['fetched_at'] + (int)$cached['ttl']) { + return $cached; + } + return $this->fetch($url); + } + + /** Force le refetch et met le cache à jour. */ + public function fetch(string $url): ?array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => 10, + CURLOPT_USERAGENT => 'varlog/1.0 FeedFetcher (+' . (defined('APP_URL') ? APP_URL : '') . ')', + CURLOPT_HEADER => true, + ]); + $raw = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $hSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + curl_close($ch); + + if ($raw === false || !is_int($httpCode) || $httpCode < 200 || $httpCode >= 400) { + return null; + } + + $headers = substr((string)$raw, 0, $hSize); + $body = substr((string)$raw, $hSize); + + libxml_use_internal_errors(true); + $xml = simplexml_load_string($body); + libxml_clear_errors(); + if ($xml === false) { + return null; + } + + $isAtom = ($xml->getName() === 'feed'); + $items = $isAtom ? $this->parseAtom($xml) : $this->parseRss($xml); + $feedTitle = $isAtom + ? (string)($xml->title ?? '') + : (string)($xml->channel->title ?? ''); + + $ttl = $this->resolveTtl($xml, $isAtom, $headers); + + $data = [ + 'feed_title' => $feedTitle, + 'fetched_at' => time(), + 'ttl' => $ttl, + 'items' => $items, + ]; + $this->cacheWrite($url, $data); + return $data; + } + + // ------------------------------------------------------------------ // + + private function parseRss(\SimpleXMLElement $xml): array + { + $items = []; + foreach ($xml->channel->item ?? [] as $item) { + $date = (string)($item->pubDate ?? ''); + $items[] = [ + 'title' => trim((string)($item->title ?? '')), + 'url' => trim((string)($item->link ?? '')), + 'summary' => $this->cleanSummary((string)($item->description ?? '')), + 'date' => $date !== '' ? (int)strtotime($date) : 0, + 'author' => trim((string)($item->author ?? '')), + ]; + } + return $this->sortItems($items); + } + + private function parseAtom(\SimpleXMLElement $xml): array + { + $ns = $xml->getNamespaces(true); + $items = []; + foreach ($xml->entry ?? [] as $entry) { + $url = ''; + foreach ($entry->link ?? [] as $link) { + $rel = (string)($link['rel'] ?? 'alternate'); + if ($rel === 'alternate' || $rel === '') { + $url = (string)($link['href'] ?? ''); + break; + } + } + $date = (string)($entry->published ?? $entry->updated ?? ''); + $author = (string)($entry->author->name ?? ''); + $summary = (string)($entry->summary ?? $entry->content ?? ''); + $items[] = [ + 'title' => trim((string)($entry->title ?? '')), + 'url' => trim($url), + 'summary' => $this->cleanSummary($summary), + 'date' => $date !== '' ? (int)strtotime($date) : 0, + 'author' => trim($author), + ]; + } + return $this->sortItems($items); + } + + private function cleanSummary(string $html): string + { + $text = strip_tags($html); + $text = preg_replace('/\s+/', ' ', $text) ?? $text; + return mb_strimwidth(trim($text), 0, 200, '…'); + } + + private function sortItems(array $items): array + { + usort($items, static fn ($a, $b) => $b['date'] <=> $a['date']); + return $items; + } + + private function resolveTtl(\SimpleXMLElement $xml, bool $isAtom, string $headers): int + { + // 1. TTL déclaré dans le flux RSS ( en minutes) + if (!$isAtom) { + $rssttl = (int)($xml->channel->ttl ?? 0); + if ($rssttl > 0) { + return $this->clampTtl($rssttl * 60); + } + } + + // 2. Cache-Control: max-age depuis les headers HTTP + if (preg_match('/max-age=(\d+)/i', $headers, $m)) { + return $this->clampTtl((int)$m[1]); + } + + // 3. Valeur par défaut : 1 heure + return 3600; + } + + private function clampTtl(int $seconds): int + { + return max(self::MIN_TTL, min(self::MAX_TTL, $seconds)); + } + + // ------------------------------------------------------------------ // + + private function cachePath(string $url): string + { + return $this->cacheDir . '/' . md5($url) . '.json'; + } + + private function cacheRead(string $url): ?array + { + $path = $this->cachePath($url); + if (!file_exists($path)) { + return null; + } + $data = json_decode((string)file_get_contents($path), true); + return is_array($data) ? $data : null; + } + + private function cacheWrite(string $url, array $data): void + { + if (!is_dir($this->cacheDir)) { + mkdir($this->cacheDir, 0755, true); + } + file_put_contents( + $this->cachePath($url), + json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } +} diff --git a/templates/flux.php b/templates/flux.php new file mode 100644 index 0000000..401e912 --- /dev/null +++ b/templates/flux.php @@ -0,0 +1,48 @@ + + +
+

Flux agrégés

+
+ + +

Aucun article disponible pour l'instant.

+ +
+ 0 ? date('d/m/Y', $_item['date']) : ''; + $_authorName = $_item['author_name'] ?? ''; + $_authorSlug = $_item['author_slug'] ?? ''; + ?> +
+
+ + + + + + + + + + + +
+

+ + ↗ + +

+ +

+ +
+ +
+ + + -
+
+

Mon profil

+
@@ -10,8 +12,6 @@
- -
@@ -36,9 +36,6 @@
-
- -
@@ -69,6 +66,51 @@ + +
+

Flux RSS

+
+
+ +
+
    + +
  • +
    +
    +
    +
    +
    + + +
    +
  • + +
+
+ +
+
+
+
Ajouter un flux
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+