feat: agrégateur RSS /flux + gestion feeds dans /profile

This commit is contained in:
Cedric Abonnel
2026-05-12 23:59:09 +02:00
parent 03177dc732
commit 2e8302dad4
7 changed files with 432 additions and 7 deletions
+8
View File
@@ -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)
);
+3
View File
@@ -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]
+54
View File
@@ -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;
}
+86 -1
View File
@@ -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');
+185
View File
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
class FeedFetcher
{
private const MIN_TTL = 900; // 15 min
private const MAX_TTL = 86400; // 24 h
public function __construct(private string $cacheDir)
{
}
/**
* Retourne les items du feed (depuis le cache si valide, sinon refetch).
* @return array{items: array, feed_title: string, fetched_at: int, ttl: int}|null
*/
public function get(string $url): ?array
{
$cached = $this->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 (<ttl> 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)
);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php ob_start(); ?>
<div class="d-flex align-items-center gap-3 mb-5">
<h1 class="h4 mb-0">Flux agrégés</h1>
</div>
<?php if (empty($fluxItems)): ?>
<p class="text-muted">Aucun article disponible pour l'instant.</p>
<?php else: ?>
<div class="flux-list">
<?php foreach ($fluxItems as $_item):
$_date = $_item['date'] > 0 ? date('d/m/Y', $_item['date']) : '';
$_authorName = $_item['author_name'] ?? '';
$_authorSlug = $_item['author_slug'] ?? '';
?>
<article class="flux-item">
<div class="flux-item-meta">
<?php if ($_authorSlug !== ''): ?>
<a href="/profil/<?= rawurlencode($_authorSlug) ?>" class="flux-author"><?= htmlspecialchars($_authorName) ?></a>
<?php elseif ($_authorName !== ''): ?>
<span class="flux-author"><?= htmlspecialchars($_authorName) ?></span>
<?php endif; ?>
<?php if ($_item['feed_title'] !== ''): ?>
<span class="flux-feed-name"><?= htmlspecialchars($_item['feed_title']) ?></span>
<?php endif; ?>
<?php if ($_date !== ''): ?>
<span class="flux-date"><?= $_date ?></span>
<?php endif; ?>
</div>
<h2 class="flux-item-title">
<a href="<?= htmlspecialchars($_item['url']) ?>" target="_blank" rel="noopener">
<?= htmlspecialchars($_item['title']) ?> ↗
</a>
</h2>
<?php if ($_item['summary'] !== ''): ?>
<p class="flux-item-summary"><?= htmlspecialchars($_item['summary']) ?></p>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php
$content = ob_get_clean();
$title = 'Flux — ' . siteTitle();
$canonical = rtrim(APP_URL, '/') . '/flux';
$mainClass = 'container-fluid';
include __DIR__ . '/layout.php';
+48 -6
View File
@@ -1,7 +1,9 @@
<?php ob_start(); ?>
<div class="d-flex align-items-center gap-3 mb-4">
<form method="post" action="/profile">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<h1 class="h4 mb-0">Mon profil</h1>
<button type="submit" class="btn btn-primary btn-sm">Enregistrer</button>
</div>
<?php if ($profileSuccess): ?>
@@ -10,8 +12,6 @@
<?php if ($profileError !== ''): ?>
<div class="alert alert-danger py-2 small mb-3"><?= htmlspecialchars($profileError) ?></div>
<?php endif; ?>
<form method="post" action="/profile">
<div class="row g-4">
<!-- Colonne gauche : identité -->
@@ -36,9 +36,6 @@
<label class="form-label fw-semibold text-muted">Email</label>
<input type="text" class="form-control" value="<?= htmlspecialchars(currentUserEmail() ?? '') ?>" disabled>
</div>
<div class="mt-auto">
<button type="submit" class="btn btn-primary w-100">Enregistrer</button>
</div>
</div>
</div>
</div>
@@ -69,6 +66,51 @@
</div>
</form>
<!-- Flux RSS -->
<div class="mt-4" id="feeds">
<h2 class="h6 text-muted mb-3">Flux RSS</h2>
<div class="row g-3 align-items-start">
<div class="col-md-8">
<?php if (!empty($profileFeeds)): ?>
<div class="card mb-3">
<ul class="list-group list-group-flush">
<?php foreach ($profileFeeds as $_feed): ?>
<li class="list-group-item d-flex align-items-center gap-2 py-2">
<div class="flex-grow-1 min-w-0">
<div class="fw-semibold small text-truncate"><?= htmlspecialchars($_feed['label'] ?: $_feed['feed_url']) ?></div>
<div class="text-muted small text-truncate"><?= htmlspecialchars($_feed['feed_url']) ?></div>
</div>
<form method="post" action="/feed/delete" class="flex-shrink-0">
<input type="hidden" name="feed_id" value="<?= (int)$_feed['id'] ?>">
<button class="btn btn-sm btn-outline-danger py-0" data-confirm="Supprimer ce flux ?">✕</button>
</form>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header small fw-semibold">Ajouter un flux</div>
<div class="card-body">
<form method="post" action="/feed/add">
<div class="mb-2">
<input type="url" name="feed_url" class="form-control form-control-sm"
placeholder="https://example.com/feed.xml" required>
</div>
<div class="mb-3">
<input type="text" name="feed_label" class="form-control form-control-sm"
placeholder="Libellé (optionnel)" maxlength="100">
</div>
<button class="btn btn-primary btn-sm w-100">Ajouter</button>
</form>
</div>
</div>
</div>
</div>
</div>
<?php
$pdo = dbPdo();
$_profileRoles = [];