fix(backend): harden security and speed up feeds and article API

Security:
- WebSocket events are routed to their owner only (no cross-user leak);
  hub close is idempotent (fixes double-close panic), adds ping/pong and
  write deadlines.
- Session tokens stored as SHA-256 (migration 008 keeps sessions valid);
  single-query auth middleware puts the user in the request context.
- Client IP only trusts X-Forwarded-For from TRUSTED_PROXIES; rate limiter
  map is bounded; per-user limit on AI summaries.
- Argon2id at OWASP minimum with a concurrency cap; constant-time login
  for unknown emails; atomic first-admin bootstrap; REGISTRATION_ENABLED.
- CSP/HSTS/COOP headers, same-origin guard on mutations, body size limits,
  wider SSRF denylist, bounded feed/page/AI response reads, generic errors.
- Upgrade chi, pgx, x/net, x/text, x/crypto (known CVEs); commit go.sum.

Performance:
- List endpoints return a plain-text excerpt and reading time instead of
  full HTML; content is sanitized once at ingest (legacy rows backfilled).
- Keyset pagination on (sort_at, id) with matching partial indexes;
  redundant indexes dropped (migration 007).
- Fetcher: bounded worker pool, conditional GET (ETag/Last-Modified),
  exponential backoff, dedupe before insert, column-safe truncation,
  retention-aware ingest, per-user refresh coalescing.
- Read/favorite/read-all are single ownership-scoped statements.
- gzip compression, immutable caching for hashed assets, path-safe SPA
  handler, server timeouts; expired sessions purged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antigravity AgentandClaude Opus 5.5 committed 2026-10-09 07:34:08 +02:00
1 parent d037e2be34
commit 03e57e4308
40 files changed
+2231 -1898

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-142
View File
@@ -1,142 +0,0 @@
import { useState } from 'react';
import { useSwipeable } from 'react-swipeable';
import { motion } from 'framer-motion';
import { ShareButton } from './ShareButton';
import { type Article, articlesApi } from '../api/articles';
interface MobileReaderViewProps {
article: Article;
onClose: () => void;
onToggleFavorite: (id: string) => void;
onNext: () => void;
onPrev: () => void;
}
export function MobileReaderView({ article, onClose, onToggleFavorite, onNext, onPrev }: MobileReaderViewProps) {
const [aiSummary, setAiSummary] = useState(article.ai_summary);
const [isSummarizing, setIsSummarizing] = useState(false);
let displayContent =
article.content || article.summary || '<p class="italic text-paper-muted">Aucun contenu disponible pour cet article.</p>';
if (article.image_url) displayContent = displayContent.replace(/<img[^>]*>/, '');
const handlers = useSwipeable({
onSwipedLeft: () => onNext(),
onSwipedRight: () => onPrev(),
preventScrollOnSwipe: false,
trackMouse: true,
});
const handleSummarize = async () => {
setIsSummarizing(true);
try {
const res = await articlesApi.summarize(article.id);
setAiSummary(res.summary);
} catch (err) {
console.error('Failed to summarize:', err);
} finally {
setIsSummarizing(false);
}
};
return (
<motion.div
className="fixed inset-0 z-50 bg-carbon flex justify-center items-start overflow-y-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={article.title}
>
<motion.div
{...handlers}
className="w-full min-h-screen bg-carbon-light relative pb-24"
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 30, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
onClick={(e) => e.stopPropagation()}
>
{article.image_url && (
<div className="w-full h-[38vh] relative overflow-hidden">
<img src={article.image_url} alt="" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-carbon-light via-carbon-light/20 to-transparent" />
</div>
)}
<div className={`px-6 ${article.image_url ? 'pt-6' : 'pt-14'}`}>
<header className="mb-8">
<div className="flex flex-wrap items-center gap-2 mb-4">
<span className="chip">{article.feed_title}</span>
<span className="text-paper-muted/40">·</span>
<span className="text-paper-muted text-xs">
{article.published_at
? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })
: "Aujourd'hui"}
</span>
</div>
<h1 className="text-3xl font-serif text-paper-white leading-tight tracking-tight text-balance">
{article.title}
</h1>
{aiSummary ? (
<div className="mt-6 bg-nature/5 border-l-4 border-nature p-4 rounded-r-2xl">
<h2 className="eyebrow mb-2 flex items-center gap-2"><span>✨</span> Résumé IA</h2>
<p className="text-paper-white/90 leading-relaxed font-reading italic">{aiSummary}</p>
</div>
) : (
<button onClick={handleSummarize} disabled={isSummarizing} className="btn-secondary mt-6">
<span className={isSummarizing ? 'animate-spin' : ''}>{isSummarizing ? '⏳' : '✨'}</span>
{isSummarizing ? 'Génération…' : 'Générer le résumé'}
</button>
)}
</header>
<div
className="magazine-content text-lg break-words mb-12"
dangerouslySetInnerHTML={{ __html: displayContent }}
/>
<div className="flex items-center justify-between py-5 border-y border-paper-muted/12 mb-8">
<ShareButton article={article} />
<button
onClick={() => onToggleFavorite(article.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-full border text-[11px] uppercase tracking-[0.18em] font-bold transition-all ${
article.is_favorite ? 'bg-earth text-white border-earth' : 'border-earth/30 text-earth'
}`}
aria-pressed={article.is_favorite}
>
<svg className="w-4 h-4" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
{article.is_favorite ? 'Favori' : 'Ajouter'}
</button>
{article.url && (
<a href={article.url} target="_blank" rel="noopener noreferrer" className="icon-btn" aria-label="Source d'origine">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</a>
)}
</div>
<footer className="text-center pb-10 opacity-60">
<p className="eyebrow text-paper-muted animate-pulse">Glissez pour lire la suite</p>
</footer>
</div>
</motion.div>
<button
onClick={(e) => { e.stopPropagation(); onClose(); }}
className="fixed top-5 right-5 z-50 w-10 h-10 bg-nature text-white rounded-full flex items-center justify-center active:scale-95 shadow-lg"
aria-label="Fermer"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</motion.div>
);
}
-162
View File
@@ -1,162 +0,0 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { ShareButton } from './ShareButton';
import { type Article, articlesApi } from '../api/articles';
interface ReaderViewProps {
article: Article;
onClose: () => void;
onToggleFavorite: (id: string) => void;
}
export function ReaderView({ article, onClose, onToggleFavorite }: ReaderViewProps) {
const [aiSummary, setAiSummary] = useState(article.ai_summary);
const [isSummarizing, setIsSummarizing] = useState(false);
let displayContent =
article.content || article.summary || '<p class="italic text-paper-muted">Aucun contenu disponible pour cet article.</p>';
if (article.image_url) displayContent = displayContent.replace(/<img[^>]*>/, '');
// Close on Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const handleSummarize = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsSummarizing(true);
try {
const res = await articlesApi.summarize(article.id);
setAiSummary(res.summary);
} catch (err) {
console.error('Failed to summarize:', err);
} finally {
setIsSummarizing(false);
}
};
return (
<motion.div
className="fixed inset-0 z-50 bg-carbon/90 backdrop-blur-md flex justify-center items-start overflow-y-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={article.title}
>
<motion.article
className="w-full max-w-3xl bg-carbon-light h-fit min-h-[60vh] my-0 md:my-12 relative md:rounded-3xl border border-paper-muted/12 overflow-hidden"
style={{ boxShadow: 'var(--shadow-float)' }}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
onClick={(e) => e.stopPropagation()}
>
{/* Desktop close */}
<button
onClick={onClose}
className="absolute top-6 right-6 z-50 hidden md:flex w-11 h-11 items-center justify-center rounded-full bg-carbon-light/80 backdrop-blur border border-nature/20 text-nature hover:bg-nature hover:text-white transition-all"
title="Fermer"
aria-label="Fermer"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Hero */}
{article.image_url && (
<div className="w-full h-[38vh] md:h-[44vh] relative overflow-hidden">
<img src={article.image_url} alt="" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-carbon-light via-carbon-light/20 to-transparent" />
</div>
)}
<div className={`px-6 md:px-16 ${article.image_url ? 'pt-8' : 'pt-16'} pb-16`}>
<header className="mb-10">
<div className="flex flex-wrap items-center gap-3 mb-6">
<span className="chip">{article.feed_title}</span>
<span className="text-paper-muted/40">·</span>
<span className="text-paper-muted text-xs font-medium">
{article.published_at
? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
: "Aujourd'hui"}
</span>
</div>
<h1 className="text-4xl md:text-5xl font-serif text-paper-white leading-[1.12] tracking-tight mb-8 text-balance">
{article.title}
</h1>
{/* Smart Digest */}
{aiSummary ? (
<div className="bg-nature/5 border-l-4 border-nature p-6 rounded-r-2xl">
<h2 className="eyebrow mb-3 flex items-center gap-2"><span>✨</span> Résumé IA</h2>
<p className="text-paper-white/90 text-lg leading-relaxed font-reading italic">{aiSummary}</p>
</div>
) : (
<button onClick={handleSummarize} disabled={isSummarizing} className="btn-secondary">
<span className={isSummarizing ? 'animate-spin' : ''}>{isSummarizing ? '⏳' : '✨'}</span>
{isSummarizing ? 'Génération…' : 'Générer le résumé'}
</button>
)}
</header>
<div
className="magazine-content drop-cap max-w-2xl mx-auto text-lg md:text-xl break-words mb-12"
dangerouslySetInnerHTML={{ __html: displayContent }}
/>
<div className="flex items-center justify-between py-6 border-y border-paper-muted/12">
<div className="flex items-center gap-3">
<ShareButton article={article} />
<button
onClick={() => onToggleFavorite(article.id)}
className={`flex items-center gap-2 px-5 py-2.5 rounded-full border text-[11px] uppercase tracking-[0.18em] font-bold transition-all ${
article.is_favorite ? 'bg-earth text-white border-earth' : 'border-earth/30 text-earth hover:bg-earth/10'
}`}
aria-pressed={article.is_favorite}
>
<svg className="w-4 h-4" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
{article.is_favorite ? 'Favori' : 'Ajouter'}
</button>
</div>
{article.url && (
<a href={article.url} target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-2 eyebrow text-paper-muted hover:text-nature transition-colors">
Source
<svg className="w-3 h-3 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</a>
)}
</div>
<footer className="text-center pt-12">
<div className="text-nature text-3xl font-serif italic select-none mb-3">F.</div>
<p className="eyebrow text-paper-muted/40">FlowReader · Édition 2026</p>
</footer>
</div>
</motion.article>
{/* Mobile close */}
<button
onClick={onClose}
className="fixed bottom-8 right-8 md:hidden w-14 h-14 bg-nature text-white rounded-full shadow-2xl flex items-center justify-center active:scale-90 transition-transform z-50"
aria-label="Fermer"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</motion.div>
);
}