Vitrine publique (accueil, architecture, statut) + recherche plein texte
All checks were successful
CI / build-and-test (push) Successful in 2m16s
CI / deploy (push) Successful in 3m16s

- 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:
Adam Belkacemi 2026-09-24 13:03:52 +02:00
parent ddd0fbc0cb
commit 3172b3e256
15 changed files with 858 additions and 20 deletions

View 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);

View file

@ -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<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)]
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<chrono::Utc> = 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");

View file

@ -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é");

83
tripoli-api/src/search.rs Normal file
View 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))
}