diff --git a/app/src/App.tsx b/app/src/App.tsx index 265eab2..77583af 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -4,8 +4,12 @@ import { BackupsView } from './components/backups/BackupsView'; import { DashboardView } from './components/dashboard/DashboardView'; import { PhotosView } from './components/photos/PhotosView'; 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 { SessionProvider, useSession } from './contexts/SessionContext'; +import { useRoute } from './hooks/useRoute'; /** Content router — a plain state switch (NavigationContext.nav), no react-router. */ 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 ; + if (path === '/statut' || path === '/status') return ; + if (path === '/connexion' || path === '/auth') return ; + return ; +} + function Root() { const { view, restoring } = useSession(); if (restoring) return null; - if (view === 'auth') return ; - return ( - - - - - - ); + if (view === 'app') { + return ( + + + + + + ); + } + + return ; } export default function App() { diff --git a/app/src/api/search.ts b/app/src/api/search.ts new file mode 100644 index 0000000..a3c1f53 --- /dev/null +++ b/app/src/api/search.ts @@ -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 { + const q = query.trim(); + if (!q) return []; + return apiFetch(`/api/search?q=${encodeURIComponent(q)}`); +} diff --git a/app/src/components/auth/AuthLayout.tsx b/app/src/components/auth/AuthLayout.tsx index 3dbb021..e83124b 100644 --- a/app/src/components/auth/AuthLayout.tsx +++ b/app/src/components/auth/AuthLayout.tsx @@ -1,5 +1,6 @@ import { AuthBackground } from './AuthBackground'; import { AuthPanel } from './AuthPanel'; +import { RouteLink } from '../marketing/RouteLink'; import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo'; export function AuthLayout() { @@ -8,10 +9,10 @@ export function AuthLayout() {
-
+ -
+
diff --git a/app/src/components/layout/TopBar.tsx b/app/src/components/layout/TopBar.tsx index 82f685c..81a3e3a 100644 --- a/app/src/components/layout/TopBar.tsx +++ b/app/src/components/layout/TopBar.tsx @@ -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 { useIsMobile } from '../../hooks/useIsMobile'; 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 … ; 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() { const { openDrawer } = useNavigation(); const isMobile = useIsMobile(); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const boxRef = useRef(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 (
{isMobile && ( @@ -20,13 +65,55 @@ export function TopBar() { )} -
- - +
+
+ {loading ? ( + + ) : ( + + )} + { + 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" + /> +
+ + {showPanel && ( +
+ {loading && results.length === 0 &&

Recherche…

} + {!loading && results.length === 0 && ( +

+ Aucun résultat pour « {query.trim()} », ni dans les noms ni dans le contenu indexé. +

+ )} + {results.map((r) => ( + +
+ {r.filename} + {formatBytes(r.size_bytes)} +
+ {r.snippet && ( +

+ {stripHeadlineTags(r.snippet)} +

+ )} +
+ ))} +
+ )}
diff --git a/app/src/components/marketing/ArchitecturePage.tsx b/app/src/components/marketing/ArchitecturePage.tsx new file mode 100644 index 0000000..bb2eeb6 --- /dev/null +++ b/app/src/components/marketing/ArchitecturePage.tsx @@ -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: , + 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: , + 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: , + 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 ( +
+ + +
+ + + Un seul serveur, pas un cluster + + +

+ Ce qui tourne réellement. +

+

+ 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. +

+ +
+

+ Le chemin d'une requête +

+
+ {FLOW.map((step, i) => ( +
+ + {i + 1} + +
+

{step.title}

+

{step.detail}

+
+
+ ))} +
+
+ +
+

+ Hors du chemin de trafic +

+

+ Labos pédagogiques — jamais branchés sur le trafic de production. +

+
+ {LABS.map((lab) => ( +
+
{lab.icon}
+

{lab.title}

+

{lab.detail}

+
+ ))} +
+
+ +
+

État réel

+
+ {STATE.map(([name, status], i) => ( +
+ {name} + {status} +
+ ))} +
+
+ +
+
+ +

Limites assumées

+
+

+ 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. +

+
+ +
+ + Code et historique de commits versionnés sur Forgejo. +
+
+ + +
+ ); +} diff --git a/app/src/components/marketing/LandingPage.tsx b/app/src/components/marketing/LandingPage.tsx new file mode 100644 index 0000000..0d060a0 --- /dev/null +++ b/app/src/components/marketing/LandingPage.tsx @@ -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: , + 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: , + title: 'Photos', + detail: "Vos photos, catégorisées et servies directement depuis votre coffre — aucune n'est envoyée à un service tiers.", + }, + { + icon: , + 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: , + 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 ( +
+ + +
+ + + Auto-hébergé sur un vrai serveur + + +

+ Votre cloud, +
+ sur votre serveur. +

+ +

+ 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. +

+ +
+ + Se connecter + + + + Voir l'architecture + +
+ +
+ {FEATURES.map((f) => ( +
+
+ {f.icon} +
+

{f.title}

+

{f.detail}

+
+ ))} +
+ +
+
+

+ Envie de voir sous le capot ? +

+

+ WireGuard, Rust, PostgreSQL, Prometheus/Grafana, CI/CD — détaillé sans filtre marketing. +

+
+
+ + Architecture + + + Statut en direct + +
+
+
+ + +
+ ); +} diff --git a/app/src/components/marketing/MarketingFooter.tsx b/app/src/components/marketing/MarketingFooter.tsx new file mode 100644 index 0000000..12c54ae --- /dev/null +++ b/app/src/components/marketing/MarketingFooter.tsx @@ -0,0 +1,11 @@ +export function MarketingFooter() { + return ( +
+
+ Tripoli Cloud — 2026 + Hébergé sur votre propre serveur + Sans publicité, sans traçage +
+
+ ); +} diff --git a/app/src/components/marketing/MarketingHeader.tsx b/app/src/components/marketing/MarketingHeader.tsx new file mode 100644 index 0000000..42355f5 --- /dev/null +++ b/app/src/components/marketing/MarketingHeader.tsx @@ -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 ( +
+
+ + + + + + + + + Se connecter + +
+
+ ); +} diff --git a/app/src/components/marketing/RouteLink.tsx b/app/src/components/marketing/RouteLink.tsx new file mode 100644 index 0000000..2553ad9 --- /dev/null +++ b/app/src/components/marketing/RouteLink.tsx @@ -0,0 +1,19 @@ +import type { AnchorHTMLAttributes, MouseEvent } from 'react'; +import { navigate } from '../../hooks/useRoute'; + +interface RouteLinkProps extends AnchorHTMLAttributes { + to: string; +} + +/** Vrai `` (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) => { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; + e.preventDefault(); + navigate(to); + onClick?.(e); + }; + + return ; +} diff --git a/app/src/components/marketing/StatusPage.tsx b/app/src/components/marketing/StatusPage.tsx new file mode 100644 index 0000000..6e481bb --- /dev/null +++ b/app/src/components/marketing/StatusPage.tsx @@ -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(null); + const [metrics, setMetrics] = useState(null); + const [checkedAt, setCheckedAt] = useState(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 ( +
+ + +
+ + + Télémétrie en direct + + +

+ Statut du service. +

+

+ Ces chiffres sont les compteurs réels de{' '} + tripoli-edge, le + reverse-proxy Rust qui reçoit chaque requête — lus en direct sur{' '} + + /metrics + {' '} + et{' '} + + /healthz + + . Rien n'est simulé ; un redémarrage du service remet ces compteurs à zéro. +

+ +
+ {healthy === null ? ( + + ) : healthy ? ( + + ) : ( + + )} +
+

+ {healthy === null ? 'Vérification…' : healthy ? 'Opérationnel' : 'Dégradé'} +

+

+ {checkedAt + ? `Dernière vérification à ${checkedAt.toLocaleTimeString('fr-FR')}` + : 'Interrogation de tripoli-edge…'} +

+
+ +
+ +
+ } + /> + } + /> + } + /> +
+ +
+

Répartition des réponses HTTP

+ {totalResponses === 0 ? ( +

+ Pas encore de trafic enregistré depuis le dernier redémarrage. +

+ ) : ( + <> +
+ {bars.map((bar) => + bar.value > 0 ? ( +
+ ) : null, + )} +
+
+ {bars.map((bar) => ( + + + {bar.label} — {bar.value.toLocaleString('fr-FR')} + + ))} +
+ + )} +
+
+ + +
+ ); +} + +function StatBox({ label, value, icon }: { label: string; value: string; icon: ReactNode }) { + return ( +
+
+ {icon} + {label} +
+

{value}

+
+ ); +} diff --git a/app/src/hooks/useRoute.ts b/app/src/hooks/useRoute.ts new file mode 100644 index 0000000..00e366e --- /dev/null +++ b/app/src/hooks/useRoute.ts @@ -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 }; +} diff --git a/tripoli-api/migrations/0003_search.sql b/tripoli-api/migrations/0003_search.sql new file mode 100644 index 0000000..70bfc3d --- /dev/null +++ b/tripoli-api/migrations/0003_search.sql @@ -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); diff --git a/tripoli-api/src/files.rs b/tripoli-api/src/files.rs index dff7529..12ed8d4 100644 --- a/tripoli-api/src/files.rs +++ b/tripoli-api/src/files.rs @@ -17,6 +17,58 @@ use crate::state::SharedState; 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 { + 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)] pub struct FileView { pub id: Uuid, @@ -133,9 +185,11 @@ pub async fn upload( out.flush().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 = sqlx::query( - "INSERT INTO files (id, owner_id, filename, content_type, category, size_bytes) \ - VALUES ($1, $2, $3, $4, $5, $6) RETURNING created_at", + "INSERT INTO files (id, owner_id, filename, content_type, category, size_bytes, content_text) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING created_at", ) .bind(file_id) .bind(user.id) @@ -143,6 +197,7 @@ pub async fn upload( .bind(&content_type) .bind(&category) .bind(size_bytes) + .bind(&content_text) .fetch_one(&state.pool) .await? .get("created_at"); diff --git a/tripoli-api/src/main.rs b/tripoli-api/src/main.rs index 3923cf0..5a806b4 100644 --- a/tripoli-api/src/main.rs +++ b/tripoli-api/src/main.rs @@ -3,6 +3,7 @@ mod config; mod error; mod files; mod notifications; +mod search; mod state; mod storage; @@ -65,6 +66,7 @@ async fn main() { post(notifications::mark_read), ) .route("/api/storage", get(storage::get)) + .route("/api/search", get(search::search)) .with_state(state); tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré"); diff --git a/tripoli-api/src/search.rs b/tripoli-api/src/search.rs new file mode 100644 index 0000000..6938fa4 --- /dev/null +++ b/tripoli-api/src/search.rs @@ -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, + /// 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, +} + +/// 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, + user: AuthUser, + Query(query): Query, +) -> Result>, 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, + Option, + ), + >( + "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)) +}