Vitrine publique (accueil, architecture, statut) + recherche plein texte
- Nouvelles pages publiques sans routeur externe (History API minimale, useRoute/RouteLink) : accueil, /architecture (état réel du projet, honnête sur les limites), /statut (télémétrie live de tripoli-edge lue directement sur /healthz et /metrics, aucune donnée simulée). - Recherche façon Spotlight dans TopBar : nouvel endpoint GET /api/search côté tripoli-api, recherche plein texte Postgres (tsvector généré + index GIN) sur le nom ET le contenu des fichiers texte extrait à l'upload (formats texte/code, borné à 5 Mio) ; extraits de résultat via ts_headline. - AuthLayout : logo cliquable vers l'accueil. Vérifié : tsc --noEmit propre, npm run build propre, cargo build -p tripoli-api propre. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
ddd0fbc0cb
commit
3172b3e256
15 changed files with 858 additions and 20 deletions
|
|
@ -4,8 +4,12 @@ import { BackupsView } from './components/backups/BackupsView';
|
||||||
import { DashboardView } from './components/dashboard/DashboardView';
|
import { DashboardView } from './components/dashboard/DashboardView';
|
||||||
import { PhotosView } from './components/photos/PhotosView';
|
import { PhotosView } from './components/photos/PhotosView';
|
||||||
import { SettingsView } from './components/settings/SettingsView';
|
import { SettingsView } from './components/settings/SettingsView';
|
||||||
|
import { LandingPage } from './components/marketing/LandingPage';
|
||||||
|
import { ArchitecturePage } from './components/marketing/ArchitecturePage';
|
||||||
|
import { StatusPage } from './components/marketing/StatusPage';
|
||||||
import { NavigationProvider, useNavigation } from './contexts/NavigationContext';
|
import { NavigationProvider, useNavigation } from './contexts/NavigationContext';
|
||||||
import { SessionProvider, useSession } from './contexts/SessionContext';
|
import { SessionProvider, useSession } from './contexts/SessionContext';
|
||||||
|
import { useRoute } from './hooks/useRoute';
|
||||||
|
|
||||||
/** Content router — a plain state switch (NavigationContext.nav), no react-router. */
|
/** Content router — a plain state switch (NavigationContext.nav), no react-router. */
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
|
|
@ -23,19 +27,34 @@ function AppContent() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Site public (sans session) : vitrine, architecture, statut, connexion —
|
||||||
|
* routé par chemin (voir useRoute), Caddy sert index.html sur tout chemin
|
||||||
|
* inconnu (try_files) donc un rechargement direct fonctionne aussi. */
|
||||||
|
function PublicSite() {
|
||||||
|
const { path } = useRoute();
|
||||||
|
|
||||||
|
if (path === '/architecture') return <ArchitecturePage />;
|
||||||
|
if (path === '/statut' || path === '/status') return <StatusPage />;
|
||||||
|
if (path === '/connexion' || path === '/auth') return <AuthLayout />;
|
||||||
|
return <LandingPage />;
|
||||||
|
}
|
||||||
|
|
||||||
function Root() {
|
function Root() {
|
||||||
const { view, restoring } = useSession();
|
const { view, restoring } = useSession();
|
||||||
|
|
||||||
if (restoring) return null;
|
if (restoring) return null;
|
||||||
if (view === 'auth') return <AuthLayout />;
|
|
||||||
|
|
||||||
return (
|
if (view === 'app') {
|
||||||
<NavigationProvider>
|
return (
|
||||||
<AppShell>
|
<NavigationProvider>
|
||||||
<AppContent />
|
<AppShell>
|
||||||
</AppShell>
|
<AppContent />
|
||||||
</NavigationProvider>
|
</AppShell>
|
||||||
);
|
</NavigationProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PublicSite />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
|
||||||
18
app/src/api/search.ts
Normal file
18
app/src/api/search.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { apiFetch } from './client';
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
content_type: string;
|
||||||
|
category: string;
|
||||||
|
size_bytes: number;
|
||||||
|
created_at: string;
|
||||||
|
/** Extrait du contenu autour des termes trouvés, ou `null` quand seul le nom a matché. */
|
||||||
|
snippet: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchFiles(query: string): Promise<SearchResult[]> {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return [];
|
||||||
|
return apiFetch<SearchResult[]>(`/api/search?q=${encodeURIComponent(q)}`);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { AuthBackground } from './AuthBackground';
|
import { AuthBackground } from './AuthBackground';
|
||||||
import { AuthPanel } from './AuthPanel';
|
import { AuthPanel } from './AuthPanel';
|
||||||
|
import { RouteLink } from '../marketing/RouteLink';
|
||||||
import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo';
|
import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo';
|
||||||
|
|
||||||
export function AuthLayout() {
|
export function AuthLayout() {
|
||||||
|
|
@ -8,10 +9,10 @@ export function AuthLayout() {
|
||||||
<AuthBackground />
|
<AuthBackground />
|
||||||
|
|
||||||
<div className="relative flex min-h-screen flex-col justify-between gap-11 p-6 sm:p-10 lg:p-[60px]">
|
<div className="relative flex min-h-screen flex-col justify-between gap-11 p-6 sm:p-10 lg:p-[60px]">
|
||||||
<div className="flex items-center gap-2.5 text-white">
|
<RouteLink to="/" className="flex items-center gap-2.5 text-white">
|
||||||
<TripoliLogo size={30} variant="hero" />
|
<TripoliLogo size={30} variant="hero" />
|
||||||
<TripoliWordmark className="text-base" />
|
<TripoliWordmark className="text-base" />
|
||||||
</div>
|
</RouteLink>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-8 sm:gap-14 lg:gap-[72px]">
|
<div className="flex flex-wrap items-center justify-between gap-8 sm:gap-14 lg:gap-[72px]">
|
||||||
<div className="min-w-0 max-w-[560px] flex-1 basis-[340px] text-white">
|
<div className="min-w-0 max-w-[560px] flex-1 basis-[340px] text-white">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,57 @@
|
||||||
import { Menu, Search } from 'lucide-react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Loader2, Menu, Search as SearchIcon } from 'lucide-react';
|
||||||
import { useNavigation } from '../../contexts/NavigationContext';
|
import { useNavigation } from '../../contexts/NavigationContext';
|
||||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
import { NotificationCenter } from '../notifications/NotificationCenter';
|
import { NotificationCenter } from '../notifications/NotificationCenter';
|
||||||
|
import { searchFiles, type SearchResult } from '../../api/search';
|
||||||
|
import { downloadUrl } from '../../api/files';
|
||||||
|
import { formatBytes } from '../../lib/format';
|
||||||
|
|
||||||
|
/** ts_headline (Postgres) entoure les termes trouvés de <b>…</b> ; on ne
|
||||||
|
* rend jamais ce HTML tel quel (le contenu vient d'un fichier uploadé par
|
||||||
|
* l'utilisateur, donc potentiellement pas fiable) — juste le texte, sans
|
||||||
|
* les marqueurs de surlignage. */
|
||||||
|
function stripHeadlineTags(snippet: string): string {
|
||||||
|
return snippet.replace(/<\/?b>/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const { openDrawer } = useNavigation();
|
const { openDrawer } = useNavigation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setResults([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
searchFiles(trimmed)
|
||||||
|
.then(setResults)
|
||||||
|
.catch(() => setResults([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, 300);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onClickOutside = (e: MouseEvent) => {
|
||||||
|
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', onClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showPanel = open && query.trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-[60] flex items-center gap-3 border-b border-line bg-panel p-3.5 backdrop-blur-xl sm:px-8">
|
<header className="sticky top-0 z-[60] flex items-center gap-3 border-b border-line bg-panel p-3.5 backdrop-blur-xl sm:px-8">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
|
|
@ -20,13 +65,55 @@ export function TopBar() {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex max-w-[440px] flex-1 items-center gap-2.5 rounded-xl border border-line bg-field px-3.5 py-2.5">
|
<div ref={boxRef} className="relative max-w-[440px] flex-1">
|
||||||
<Search size={16} className="flex-none opacity-50" strokeWidth={1.8} />
|
<div className="flex items-center gap-2.5 rounded-xl border border-line bg-field px-3.5 py-2.5">
|
||||||
<input
|
{loading ? (
|
||||||
aria-label="Rechercher un fichier"
|
<Loader2 size={16} className="flex-none animate-spin opacity-50" strokeWidth={1.8} />
|
||||||
placeholder="Rechercher un document, un dossier, une référence…"
|
) : (
|
||||||
className="w-full min-w-0 flex-1 bg-transparent text-[13.5px] text-ink outline-none"
|
<SearchIcon size={16} className="flex-none opacity-50" strokeWidth={1.8} />
|
||||||
/>
|
)}
|
||||||
|
<input
|
||||||
|
aria-label="Rechercher dans le contenu des fichiers"
|
||||||
|
placeholder="Rechercher un document, un dossier, un mot dans le contenu…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
className="w-full min-w-0 flex-1 bg-transparent text-[13.5px] text-ink outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPanel && (
|
||||||
|
<div className="absolute left-0 right-0 top-[calc(100%+8px)] max-h-[420px] overflow-y-auto rounded-xl border border-line bg-panel shadow-tc">
|
||||||
|
{loading && results.length === 0 && <p className="p-4 text-[13px] text-ink-soft">Recherche…</p>}
|
||||||
|
{!loading && results.length === 0 && (
|
||||||
|
<p className="p-4 text-[13px] text-ink-soft">
|
||||||
|
Aucun résultat pour « {query.trim()} », ni dans les noms ni dans le contenu indexé.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{results.map((r) => (
|
||||||
|
<a
|
||||||
|
key={r.id}
|
||||||
|
href={downloadUrl(r.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block border-b border-line/70 px-4 py-3 last:border-b-0 hover:bg-field"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate text-[13.5px] font-semibold text-ink">{r.filename}</span>
|
||||||
|
<span className="flex-none text-[11px] text-ink-soft">{formatBytes(r.size_bytes)}</span>
|
||||||
|
</div>
|
||||||
|
{r.snippet && (
|
||||||
|
<p className="mt-1 line-clamp-2 text-[12.5px] leading-snug text-ink-soft">
|
||||||
|
{stripHeadlineTags(r.snippet)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
|
|
||||||
157
app/src/components/marketing/ArchitecturePage.tsx
Normal file
157
app/src/components/marketing/ArchitecturePage.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Cpu, GitBranch, Network, Radio, Server, ShieldCheck } from 'lucide-react';
|
||||||
|
import { MarketingHeader } from './MarketingHeader';
|
||||||
|
import { MarketingFooter } from './MarketingFooter';
|
||||||
|
|
||||||
|
const FLOW = [
|
||||||
|
{
|
||||||
|
title: 'Caddy (edge partagé)',
|
||||||
|
detail:
|
||||||
|
"TLS Let's Encrypt automatique, routage par nom d'hôte. Ce Caddy sert aussi six autres domaines sur ce même serveur — tripoli-edge n'a jamais eu les ports 80/443 pour lui seul.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'tripoli-edge — Rust (hyper/tokio)',
|
||||||
|
detail:
|
||||||
|
'Reverse-proxy interne (172.17.0.1:9000, passerelle docker0, jamais exposé directement). Expose /healthz et /metrics en clair, sans framework de métriques : cinq compteurs atomiques suffisaient.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'tripoli-api — Rust (axum/sqlx)',
|
||||||
|
detail:
|
||||||
|
"Authentification par session opaque (Argon2, pas de JWT — révoquer une session, c'est supprimer une ligne). Upload en streaming vers disque, recherche plein texte Postgres.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'PostgreSQL + blobs',
|
||||||
|
detail:
|
||||||
|
"Conteneur Docker dédié à Tripoli Cloud, séparé de celui d'un autre projet sur ce même serveur. Les fichiers sont nommés par UUID sur disque — le nom d'origine ne vit qu'en base.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LABS: { icon: ReactNode; title: string; detail: string }[] = [
|
||||||
|
{
|
||||||
|
icon: <Network size={18} />,
|
||||||
|
title: 'Tunnel WireGuard',
|
||||||
|
detail:
|
||||||
|
"10.100.0.0/24 entre le Mac d'administration et le serveur — canal SSH/Ansible, jamais dans le chemin de trafic applicatif.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Server size={18} />,
|
||||||
|
title: 'tripoli-netctl',
|
||||||
|
detail:
|
||||||
|
'Netns + veth créés à la demande via rtnetlink (Rust). Testé (création, ping traversant, destruction propre) — volontairement jamais branché sur le trafic de prod.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Radio size={18} />,
|
||||||
|
title: 'bird-lab — BGP + BFD',
|
||||||
|
detail:
|
||||||
|
'Deux namespaces en session BGP via une racine en transit (AS 65000/65010/65011). Sessions Established, BFD Up et détection de panne vérifiées, puis démonté proprement.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATE: [string, string][] = [
|
||||||
|
['Frontend (app/)', 'Fait — branché sur les vraies données de tripoli-api, aucun mock.'],
|
||||||
|
['WireGuard', 'Fait — 10.100.0.0/24.'],
|
||||||
|
['tripoli-edge', 'Fait, en prod — /healthz, /metrics vérifiés.'],
|
||||||
|
['tripoli-api', 'Fait, en prod — auth, fichiers, notifications, recherche.'],
|
||||||
|
['tripoli-netctl', 'Fait — labo, jamais branché sur le trafic de prod.'],
|
||||||
|
['Observabilité', 'Fait — scrape Prometheus/Grafana partagé, alertes versionnées.'],
|
||||||
|
['bird-lab (BGP/BFD)', 'Fait, bonus — démonté après vérification.'],
|
||||||
|
['CI/CD', 'Fait — Forgejo Actions, compte deploy sans privilège.'],
|
||||||
|
['OpenTofu', 'Code écrit, non appliqué — identifiants OVH/Hetzner non fournis.'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ArchitecturePage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg text-ink">
|
||||||
|
<MarketingHeader />
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-[880px] px-6 py-16 sm:px-10 sm:py-24">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-line bg-field px-3.5 py-1.5 text-xs font-semibold tracking-wide text-ink-soft">
|
||||||
|
<Cpu size={13} />
|
||||||
|
Un seul serveur, pas un cluster
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="m-0 my-5 font-display text-[36px] font-semibold leading-[1.08] tracking-tight sm:text-[48px]">
|
||||||
|
Ce qui tourne <span className="italic">réellement</span>.
|
||||||
|
</h1>
|
||||||
|
<p className="m-0 max-w-[62ch] text-[15.5px] leading-relaxed text-ink-soft">
|
||||||
|
Un VPS Hetzner CPX22 (2 vCPU, 3,7 Go de RAM), partagé avec six autres projets personnels.
|
||||||
|
Ce qui suit est honnête sur ce qui est en place — pas de cluster simulé, pas de haute
|
||||||
|
disponibilité qui n'existe pas.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="mt-14">
|
||||||
|
<h2 className="m-0 mb-6 font-display text-[22px] font-semibold text-ink">
|
||||||
|
Le chemin d'une requête
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{FLOW.map((step, i) => (
|
||||||
|
<div key={step.title} className="flex gap-4 rounded-2xl border border-line bg-panel p-5 shadow-tc">
|
||||||
|
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-field text-[13px] font-bold text-ink">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="m-0 mb-1 text-[14.5px] font-bold text-ink">{step.title}</p>
|
||||||
|
<p className="m-0 text-[13.5px] leading-relaxed text-ink-soft">{step.detail}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-14">
|
||||||
|
<h2 className="m-0 mb-2 font-display text-[22px] font-semibold text-ink">
|
||||||
|
Hors du chemin de trafic
|
||||||
|
</h2>
|
||||||
|
<p className="m-0 mb-6 text-[13.5px] text-ink-soft">
|
||||||
|
Labos pédagogiques — jamais branchés sur le trafic de production.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
{LABS.map((lab) => (
|
||||||
|
<div key={lab.title} className="rounded-2xl border border-line bg-panel p-5 shadow-tc">
|
||||||
|
<div className="mb-2 text-ink-soft">{lab.icon}</div>
|
||||||
|
<p className="m-0 mb-1 text-[13.5px] font-bold text-ink">{lab.title}</p>
|
||||||
|
<p className="m-0 text-[12.5px] leading-relaxed text-ink-soft">{lab.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-14">
|
||||||
|
<h2 className="m-0 mb-6 font-display text-[22px] font-semibold text-ink">État réel</h2>
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-line bg-panel shadow-tc">
|
||||||
|
{STATE.map(([name, status], i) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className={`flex items-center justify-between gap-4 px-5 py-3.5 text-[13px] ${
|
||||||
|
i !== STATE.length - 1 ? 'border-b border-line/70' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-ink">{name}</span>
|
||||||
|
<span className="text-right text-ink-soft">{status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-14 rounded-2xl border border-line bg-field p-6">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-ink">
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
<p className="m-0 text-[13.5px] font-bold">Limites assumées</p>
|
||||||
|
</div>
|
||||||
|
<p className="m-0 text-[13px] leading-relaxed text-ink-soft">
|
||||||
|
SSH public encore ouvert (choix explicite, pas encore restreint au VPN). Pas de
|
||||||
|
virtualisation imbriquée sur ce VPS. Alertes Prometheus qui se déclenchent mais ne sont
|
||||||
|
pas encore routées par notification. Détail complet dans le README du dépôt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="mt-10 flex items-center gap-2 text-[13px] text-ink-soft">
|
||||||
|
<GitBranch size={15} />
|
||||||
|
Code et historique de commits versionnés sur Forgejo.
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<MarketingFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
app/src/components/marketing/LandingPage.tsx
Normal file
111
app/src/components/marketing/LandingPage.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { ArrowRight, HardDrive, Image as ImagesIcon, Lock, Share2 } from 'lucide-react';
|
||||||
|
import { MarketingHeader } from './MarketingHeader';
|
||||||
|
import { MarketingFooter } from './MarketingFooter';
|
||||||
|
import { RouteLink } from './RouteLink';
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{
|
||||||
|
icon: <HardDrive size={20} />,
|
||||||
|
title: 'Sauvegardes réelles',
|
||||||
|
detail:
|
||||||
|
"Upload en streaming vers disque avec progression réelle, pas une barre simulée — les gros fichiers partent au fil de l'eau, pas d'un coup une fois tout reçu.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ImagesIcon size={20} />,
|
||||||
|
title: 'Photos',
|
||||||
|
detail: "Vos photos, catégorisées et servies directement depuis votre coffre — aucune n'est envoyée à un service tiers.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Share2 size={20} />,
|
||||||
|
title: 'Fichiers, à plat',
|
||||||
|
detail:
|
||||||
|
"Recherche par nom et par contenu du fichier. Pas de hiérarchie de dossiers à gérer pour l'instant — assumé, pas simulé.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Lock size={20} />,
|
||||||
|
title: 'Chiffré, hashé',
|
||||||
|
detail:
|
||||||
|
'HTTPS de bout en bout, mots de passe hachés en Argon2, sessions opaques révocables — jamais de mot de passe en clair, jamais de JWT à faire tourner.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LandingPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg text-ink">
|
||||||
|
<MarketingHeader />
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-[1160px] px-6 pb-24 pt-16 sm:px-10 sm:pt-24">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-line bg-field px-3.5 py-1.5 text-xs font-semibold tracking-wide text-ink-soft">
|
||||||
|
<span className="h-1.5 w-1.5 animate-tc-pulse rounded-full bg-success" />
|
||||||
|
Auto-hébergé sur un vrai serveur
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="m-0 my-6 max-w-[16ch] font-display text-[42px] font-semibold leading-[1.05] tracking-tight text-balance sm:text-[64px]">
|
||||||
|
Votre cloud,
|
||||||
|
<br />
|
||||||
|
<span className="italic">sur votre serveur.</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="m-0 max-w-[58ch] text-[16px] leading-relaxed text-ink-soft sm:text-[18px]">
|
||||||
|
Photos, sauvegardes et partage de fichiers — sans dépendre d'un hébergeur tiers pour vos
|
||||||
|
données. Construit et exploité comme un vrai service en production, pas une démo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||||
|
<RouteLink
|
||||||
|
to="/connexion"
|
||||||
|
className="flex items-center gap-2 rounded-xl bg-ink px-5 py-3 text-[14px] font-bold text-bg transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
Se connecter
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</RouteLink>
|
||||||
|
<RouteLink
|
||||||
|
to="/architecture"
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-line bg-panel px-5 py-3 text-[14px] font-bold text-ink transition-colors hover:bg-field"
|
||||||
|
>
|
||||||
|
Voir l'architecture
|
||||||
|
</RouteLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-24 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{FEATURES.map((f) => (
|
||||||
|
<div key={f.title} className="rounded-2xl border border-line bg-panel p-6 shadow-tc">
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-xl bg-field text-ink">
|
||||||
|
{f.icon}
|
||||||
|
</div>
|
||||||
|
<p className="m-0 mb-2 text-[15px] font-bold text-ink">{f.title}</p>
|
||||||
|
<p className="m-0 text-[13.5px] leading-relaxed text-ink-soft">{f.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-24 flex flex-col items-start justify-between gap-6 rounded-2xl border border-line bg-field p-8 sm:flex-row sm:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="m-0 mb-1 font-display text-[22px] font-semibold text-ink">
|
||||||
|
Envie de voir sous le capot ?
|
||||||
|
</p>
|
||||||
|
<p className="m-0 text-[13.5px] text-ink-soft">
|
||||||
|
WireGuard, Rust, PostgreSQL, Prometheus/Grafana, CI/CD — détaillé sans filtre marketing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-none gap-3">
|
||||||
|
<RouteLink
|
||||||
|
to="/architecture"
|
||||||
|
className="rounded-xl border border-line bg-panel px-4 py-2.5 text-[13.5px] font-bold text-ink hover:bg-bg"
|
||||||
|
>
|
||||||
|
Architecture
|
||||||
|
</RouteLink>
|
||||||
|
<RouteLink
|
||||||
|
to="/statut"
|
||||||
|
className="rounded-xl border border-line bg-panel px-4 py-2.5 text-[13.5px] font-bold text-ink hover:bg-bg"
|
||||||
|
>
|
||||||
|
Statut en direct
|
||||||
|
</RouteLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<MarketingFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/src/components/marketing/MarketingFooter.tsx
Normal file
11
app/src/components/marketing/MarketingFooter.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export function MarketingFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-line px-6 py-8 text-xs text-ink-soft sm:px-10">
|
||||||
|
<div className="mx-auto flex max-w-[1160px] flex-wrap gap-5">
|
||||||
|
<span>Tripoli Cloud — 2026</span>
|
||||||
|
<span>Hébergé sur votre propre serveur</span>
|
||||||
|
<span>Sans publicité, sans traçage</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
app/src/components/marketing/MarketingHeader.tsx
Normal file
45
app/src/components/marketing/MarketingHeader.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo';
|
||||||
|
import { RouteLink } from './RouteLink';
|
||||||
|
import { useRoute } from '../../hooks/useRoute';
|
||||||
|
|
||||||
|
const LINKS = [
|
||||||
|
{ to: '/', label: 'Accueil' },
|
||||||
|
{ to: '/architecture', label: 'Architecture' },
|
||||||
|
{ to: '/statut', label: 'Statut' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MarketingHeader() {
|
||||||
|
const { path } = useRoute();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 border-b border-line bg-bg/85 backdrop-blur-xl">
|
||||||
|
<div className="mx-auto flex max-w-[1160px] items-center gap-8 px-6 py-4 sm:px-10">
|
||||||
|
<RouteLink to="/" className="flex items-center gap-2.5 text-ink">
|
||||||
|
<TripoliLogo size={26} />
|
||||||
|
<TripoliWordmark />
|
||||||
|
</RouteLink>
|
||||||
|
|
||||||
|
<nav className="ml-2 hidden items-center gap-1 sm:flex">
|
||||||
|
{LINKS.map((link) => (
|
||||||
|
<RouteLink
|
||||||
|
key={link.to}
|
||||||
|
to={link.to}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-[13.5px] font-semibold transition-colors ${
|
||||||
|
path === link.to ? 'bg-field text-ink' : 'text-ink-soft hover:text-ink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</RouteLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<RouteLink
|
||||||
|
to="/connexion"
|
||||||
|
className="ml-auto flex items-center gap-2 rounded-xl bg-ink px-4 py-2 text-[13px] font-bold text-bg transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
Se connecter
|
||||||
|
</RouteLink>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
app/src/components/marketing/RouteLink.tsx
Normal file
19
app/src/components/marketing/RouteLink.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { AnchorHTMLAttributes, MouseEvent } from 'react';
|
||||||
|
import { navigate } from '../../hooks/useRoute';
|
||||||
|
|
||||||
|
interface RouteLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrai `<a href>` (accessibilité, ouverture dans un nouvel onglet) dont
|
||||||
|
* le clic simple est intercepté pour naviguer côté client. */
|
||||||
|
export function RouteLink({ to, onClick, ...rest }: RouteLinkProps) {
|
||||||
|
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(to);
|
||||||
|
onClick?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <a href={to} onClick={handleClick} {...rest} />;
|
||||||
|
}
|
||||||
190
app/src/components/marketing/StatusPage.tsx
Normal file
190
app/src/components/marketing/StatusPage.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { type ReactNode, useEffect, useState } from 'react';
|
||||||
|
import { Activity, CheckCircle2, Gauge, Radio, XCircle } from 'lucide-react';
|
||||||
|
import { MarketingHeader } from './MarketingHeader';
|
||||||
|
import { MarketingFooter } from './MarketingFooter';
|
||||||
|
|
||||||
|
interface EdgeMetrics {
|
||||||
|
requestsTotal: number;
|
||||||
|
responses2xx: number;
|
||||||
|
responses3xx: number;
|
||||||
|
responses4xx: number;
|
||||||
|
responses5xx: number;
|
||||||
|
inFlight: number;
|
||||||
|
avgLatencyMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetrics(text: string): EdgeMetrics {
|
||||||
|
const num = (pattern: RegExp): number => {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
return match ? Number.parseFloat(match[1]!) : 0;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
requestsTotal: num(/tripoli_edge_requests_total (\d+)/),
|
||||||
|
responses2xx: num(/responses_total\{class="2xx"\} (\d+)/),
|
||||||
|
responses3xx: num(/responses_total\{class="3xx"\} (\d+)/),
|
||||||
|
responses4xx: num(/responses_total\{class="4xx"\} (\d+)/),
|
||||||
|
responses5xx: num(/responses_total\{class="5xx"\} (\d+)/),
|
||||||
|
inFlight: num(/in_flight_requests (-?\d+)/),
|
||||||
|
avgLatencyMs: num(/request_duration_ms_avg ([\d.]+)/),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_MS = 12_000;
|
||||||
|
|
||||||
|
export function StatusPage() {
|
||||||
|
const [healthy, setHealthy] = useState<boolean | null>(null);
|
||||||
|
const [metrics, setMetrics] = useState<EdgeMetrics | null>(null);
|
||||||
|
const [checkedAt, setCheckedAt] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const [healthRes, metricsRes] = await Promise.all([fetch('/healthz'), fetch('/metrics')]);
|
||||||
|
const healthText = await healthRes.text();
|
||||||
|
const metricsText = await metricsRes.text();
|
||||||
|
if (cancelled) return;
|
||||||
|
setHealthy(healthRes.ok && healthText.trim() === 'ok');
|
||||||
|
setMetrics(parseMetrics(metricsText));
|
||||||
|
setCheckedAt(new Date());
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setHealthy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void poll();
|
||||||
|
const id = window.setInterval(() => void poll(), POLL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalResponses = metrics
|
||||||
|
? metrics.responses2xx + metrics.responses3xx + metrics.responses4xx + metrics.responses5xx
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const bars = metrics
|
||||||
|
? [
|
||||||
|
{ label: '2xx', value: metrics.responses2xx, className: 'bg-success' },
|
||||||
|
{ label: '3xx', value: metrics.responses3xx, className: 'bg-brand' },
|
||||||
|
{ label: '4xx', value: metrics.responses4xx, className: 'bg-accent' },
|
||||||
|
{ label: '5xx', value: metrics.responses5xx, className: 'bg-danger' },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg text-ink">
|
||||||
|
<MarketingHeader />
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-[880px] px-6 py-16 sm:px-10 sm:py-24">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-line bg-field px-3.5 py-1.5 text-xs font-semibold tracking-wide text-ink-soft">
|
||||||
|
<Radio size={13} />
|
||||||
|
Télémétrie en direct
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="m-0 my-5 font-display text-[36px] font-semibold leading-[1.08] tracking-tight sm:text-[48px]">
|
||||||
|
Statut du service.
|
||||||
|
</h1>
|
||||||
|
<p className="m-0 max-w-[56ch] text-[15.5px] leading-relaxed text-ink-soft">
|
||||||
|
Ces chiffres sont les compteurs réels de{' '}
|
||||||
|
<code className="rounded bg-field px-1.5 py-0.5 text-[13px]">tripoli-edge</code>, le
|
||||||
|
reverse-proxy Rust qui reçoit chaque requête — lus en direct sur{' '}
|
||||||
|
<a href="/metrics" className="underline decoration-ink/30 underline-offset-2 hover:decoration-ink">
|
||||||
|
/metrics
|
||||||
|
</a>{' '}
|
||||||
|
et{' '}
|
||||||
|
<a href="/healthz" className="underline decoration-ink/30 underline-offset-2 hover:decoration-ink">
|
||||||
|
/healthz
|
||||||
|
</a>
|
||||||
|
. Rien n'est simulé ; un redémarrage du service remet ces compteurs à zéro.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 flex items-center gap-3 rounded-2xl border border-line bg-panel p-5 shadow-tc">
|
||||||
|
{healthy === null ? (
|
||||||
|
<Activity size={22} className="flex-none animate-pulse text-ink-soft" />
|
||||||
|
) : healthy ? (
|
||||||
|
<CheckCircle2 size={22} className="flex-none text-success" />
|
||||||
|
) : (
|
||||||
|
<XCircle size={22} className="flex-none text-danger" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="m-0 text-[14.5px] font-bold text-ink">
|
||||||
|
{healthy === null ? 'Vérification…' : healthy ? 'Opérationnel' : 'Dégradé'}
|
||||||
|
</p>
|
||||||
|
<p className="m-0 text-[12.5px] text-ink-soft">
|
||||||
|
{checkedAt
|
||||||
|
? `Dernière vérification à ${checkedAt.toLocaleTimeString('fr-FR')}`
|
||||||
|
: 'Interrogation de tripoli-edge…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto h-2 w-2 flex-none animate-tc-pulse rounded-full bg-success" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
|
<StatBox
|
||||||
|
label="Requêtes totales"
|
||||||
|
value={metrics ? metrics.requestsTotal.toLocaleString('fr-FR') : '—'}
|
||||||
|
icon={<Gauge size={16} />}
|
||||||
|
/>
|
||||||
|
<StatBox
|
||||||
|
label="En cours"
|
||||||
|
value={metrics ? String(metrics.inFlight) : '—'}
|
||||||
|
icon={<Activity size={16} />}
|
||||||
|
/>
|
||||||
|
<StatBox
|
||||||
|
label="Latence moyenne"
|
||||||
|
value={metrics ? `${metrics.avgLatencyMs.toFixed(1)} ms` : '—'}
|
||||||
|
icon={<Radio size={16} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-2xl border border-line bg-panel p-5 shadow-tc">
|
||||||
|
<p className="m-0 mb-3 text-[13px] font-semibold text-ink-soft">Répartition des réponses HTTP</p>
|
||||||
|
{totalResponses === 0 ? (
|
||||||
|
<p className="m-0 text-[13px] text-ink-soft">
|
||||||
|
Pas encore de trafic enregistré depuis le dernier redémarrage.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex h-2.5 overflow-hidden rounded-full bg-field">
|
||||||
|
{bars.map((bar) =>
|
||||||
|
bar.value > 0 ? (
|
||||||
|
<div
|
||||||
|
key={bar.label}
|
||||||
|
className={bar.className}
|
||||||
|
style={{ width: `${(bar.value / totalResponses) * 100}%` }}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1.5 text-[12.5px] text-ink-soft">
|
||||||
|
{bars.map((bar) => (
|
||||||
|
<span key={bar.label} className="flex items-center gap-1.5">
|
||||||
|
<span className={`h-2 w-2 rounded-full ${bar.className}`} />
|
||||||
|
{bar.label} — {bar.value.toLocaleString('fr-FR')}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<MarketingFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatBox({ label, value, icon }: { label: string; value: string; icon: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-line bg-panel p-5 shadow-tc">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-ink-soft">
|
||||||
|
{icon}
|
||||||
|
<span className="text-[12px] font-semibold uppercase tracking-wide">{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="m-0 font-display text-[26px] font-semibold text-ink">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
app/src/hooks/useRoute.ts
Normal file
27
app/src/hooks/useRoute.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routing minimal par History API — pas de react-router pour trois
|
||||||
|
* pages publiques. `navigate` et `useRoute` communiquent par un simple
|
||||||
|
* événement `popstate` synthétique, sans Context : n'importe quel
|
||||||
|
* composant peut naviguer sans passer par un Provider dédié.
|
||||||
|
*/
|
||||||
|
export function navigate(path: string): void {
|
||||||
|
if (window.location.pathname !== path) {
|
||||||
|
window.history.pushState({}, '', path);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoute(): { path: string } {
|
||||||
|
const [path, setPath] = useState(() => window.location.pathname);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setPath(window.location.pathname);
|
||||||
|
window.addEventListener('popstate', onChange);
|
||||||
|
return () => window.removeEventListener('popstate', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { path };
|
||||||
|
}
|
||||||
13
tripoli-api/migrations/0003_search.sql
Normal file
13
tripoli-api/migrations/0003_search.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Recherche plein texte façon Spotlight : sur le nom du fichier *et* sur
|
||||||
|
-- son contenu extrait à l'upload (voir files::upload). Colonne générée
|
||||||
|
-- STORED plutôt que calculée à la volée dans chaque requête : l'index
|
||||||
|
-- GIN a besoin d'une colonne matérialisée pour être utilisable.
|
||||||
|
ALTER TABLE files ADD COLUMN content_text TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE files ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
setweight(to_tsvector('french', coalesce(filename, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('french', coalesce(content_text, '')), 'B')
|
||||||
|
) STORED;
|
||||||
|
|
||||||
|
CREATE INDEX files_search_vector_idx ON files USING GIN (search_vector);
|
||||||
|
|
@ -17,6 +17,58 @@ use crate::state::SharedState;
|
||||||
|
|
||||||
const CATEGORIES: [&str; 3] = ["file", "photo", "backup"];
|
const CATEGORIES: [&str; 3] = ["file", "photo", "backup"];
|
||||||
|
|
||||||
|
/// Extensions traitées comme du texte indexable quand le type MIME seul
|
||||||
|
/// ne suffit pas à trancher (navigateurs envoyant souvent
|
||||||
|
/// "application/octet-stream" pour du code ou du markdown).
|
||||||
|
const TEXT_EXTENSIONS: [&str; 19] = [
|
||||||
|
"txt", "md", "markdown", "csv", "json", "yaml", "yml", "toml", "log", "rs", "ts", "tsx", "js",
|
||||||
|
"jsx", "py", "html", "css", "sql", "sh",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Bornes volontairement basses : ce serveur partage 2 vCPU / 3,7 Go de
|
||||||
|
/// RAM avec six autres projets. Indexer un fichier de plusieurs centaines
|
||||||
|
/// de Mo en mémoire pour en tirer du texte serait un coût disproportionné
|
||||||
|
/// pour une recherche "façon Spotlight" sur un coffre personnel.
|
||||||
|
const MAX_INDEXABLE_BYTES: i64 = 5 * 1024 * 1024;
|
||||||
|
const MAX_INDEXED_CHARS: usize = 200_000;
|
||||||
|
|
||||||
|
fn looks_like_text(content_type: &str, filename: &str) -> bool {
|
||||||
|
if content_type.starts_with("text/") || content_type == "application/json" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
filename
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.map(|ext| TEXT_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extraction de texte volontairement minimale : formats texte bruts
|
||||||
|
/// uniquement (pas de PDF/DOCX — demanderait une dépendance de parsing
|
||||||
|
/// binaire absente de ce projet). Un fichier binaire reste cherchable
|
||||||
|
/// par son nom seul, jamais indexé à moitié ou de façon trompeuse.
|
||||||
|
async fn extract_indexable_text(
|
||||||
|
path: &std::path::Path,
|
||||||
|
content_type: &str,
|
||||||
|
filename: &str,
|
||||||
|
size_bytes: i64,
|
||||||
|
) -> Option<String> {
|
||||||
|
if !looks_like_text(content_type, filename) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if size_bytes <= 0 || size_bytes > MAX_INDEXABLE_BYTES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bytes = tokio::fs::read(path).await.ok()?;
|
||||||
|
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||||
|
let truncated: String = text.chars().take(MAX_INDEXED_CHARS).collect();
|
||||||
|
if truncated.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(truncated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct FileView {
|
pub struct FileView {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|
@ -133,9 +185,11 @@ pub async fn upload(
|
||||||
out.flush().await?;
|
out.flush().await?;
|
||||||
tokio::fs::rename(&tmp_path, &final_path).await?;
|
tokio::fs::rename(&tmp_path, &final_path).await?;
|
||||||
|
|
||||||
|
let content_text = extract_indexable_text(&final_path, &content_type, &filename, size_bytes).await;
|
||||||
|
|
||||||
let created_at: chrono::DateTime<chrono::Utc> = sqlx::query(
|
let created_at: chrono::DateTime<chrono::Utc> = sqlx::query(
|
||||||
"INSERT INTO files (id, owner_id, filename, content_type, category, size_bytes) \
|
"INSERT INTO files (id, owner_id, filename, content_type, category, size_bytes, content_text) \
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING created_at",
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING created_at",
|
||||||
)
|
)
|
||||||
.bind(file_id)
|
.bind(file_id)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
|
|
@ -143,6 +197,7 @@ pub async fn upload(
|
||||||
.bind(&content_type)
|
.bind(&content_type)
|
||||||
.bind(&category)
|
.bind(&category)
|
||||||
.bind(size_bytes)
|
.bind(size_bytes)
|
||||||
|
.bind(&content_text)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.get("created_at");
|
.get("created_at");
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ mod config;
|
||||||
mod error;
|
mod error;
|
||||||
mod files;
|
mod files;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
|
mod search;
|
||||||
mod state;
|
mod state;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
|
|
@ -65,6 +66,7 @@ async fn main() {
|
||||||
post(notifications::mark_read),
|
post(notifications::mark_read),
|
||||||
)
|
)
|
||||||
.route("/api/storage", get(storage::get))
|
.route("/api/storage", get(storage::get))
|
||||||
|
.route("/api/search", get(search::search))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré");
|
tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré");
|
||||||
|
|
|
||||||
83
tripoli-api/src/search.rs
Normal file
83
tripoli-api/src/search.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
use axum::Json;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::AuthUser;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::state::SharedState;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SearchQuery {
|
||||||
|
q: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SearchResult {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub filename: String,
|
||||||
|
pub content_type: String,
|
||||||
|
pub category: String,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
/// Extrait du contenu autour des termes trouvés (`ts_headline`).
|
||||||
|
/// `None` quand le résultat provient uniquement du nom de fichier
|
||||||
|
/// (pas de texte indexé, ex. une image ou un binaire).
|
||||||
|
pub snippet: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche façon Spotlight : porte sur le nom de fichier *et* le
|
||||||
|
/// contenu textuel extrait à l'upload (`files.content_text`), pas
|
||||||
|
/// seulement le nom. `websearch_to_tsquery` accepte une saisie libre
|
||||||
|
/// (mots, guillemets) sans jamais lever d'erreur de syntaxe, contrairement
|
||||||
|
/// à `to_tsquery` — c'est ce qu'attend une barre de recherche.
|
||||||
|
pub async fn search(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Query(query): Query<SearchQuery>,
|
||||||
|
) -> Result<Json<Vec<SearchResult>>, AppError> {
|
||||||
|
let q = query.q.trim();
|
||||||
|
if q.is_empty() {
|
||||||
|
return Ok(Json(Vec::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<
|
||||||
|
_,
|
||||||
|
(
|
||||||
|
Uuid,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
i64,
|
||||||
|
chrono::DateTime<chrono::Utc>,
|
||||||
|
Option<String>,
|
||||||
|
),
|
||||||
|
>(
|
||||||
|
"SELECT id, filename, content_type, category, size_bytes, created_at, \
|
||||||
|
NULLIF(ts_headline('french', coalesce(content_text, ''), websearch_to_tsquery('french', $2), \
|
||||||
|
'MaxFragments=1, MaxWords=28, MinWords=10, ShortWord=3'), '') AS snippet \
|
||||||
|
FROM files \
|
||||||
|
WHERE owner_id = $1 AND search_vector @@ websearch_to_tsquery('french', $2) \
|
||||||
|
ORDER BY ts_rank(search_vector, websearch_to_tsquery('french', $2)) DESC \
|
||||||
|
LIMIT 20",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(q)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(
|
||||||
|
|(id, filename, content_type, category, size_bytes, created_at, snippet)| SearchResult {
|
||||||
|
id,
|
||||||
|
filename,
|
||||||
|
content_type,
|
||||||
|
category,
|
||||||
|
size_bytes,
|
||||||
|
created_at,
|
||||||
|
snippet,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue