mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
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:
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 +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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user