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}
+
+ ))}
+
+
+
+
+
+
+ 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 (
+
+ );
+}
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 (
+
+ );
+}
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))
+}