Phase 3 : tripoli-api (auth, fichiers, PostgreSQL)

- axum + sqlx (requêtes runtime, pas les macros query! à vérification
  compile-time - évite d'exiger une base joignable à chaque build).
- Auth par session opaque côté serveur (cookie HttpOnly/Secure/SameSite,
  pas de JWT - révocation = suppression de ligne). Mots de passe
  Argon2.
- Fichiers : upload multipart streamé chunk par chunk vers le disque
  (jamais accumulé en mémoire - condition réelle d'une progression
  d'upload non simulée), download streamé, blobs nommés par UUID
  (le nom d'origine ne vit qu'en métadonnée).
- Stockage disque pour cette v1 (pas Object Storage - pas de bénéfice
  réel pour un unique VPS avec 55 Go libres, ajouté sans complexité
  inutile).
- PostgreSQL dans un conteneur Docker dédié, séparé de celui
  d'iot-alarm-system (pas de couplage entre projets sans rapport).
- Déployé en prod sous systemd (même utilisateur système que
  tripoli-edge), branché sur son upstream par défaut. Vérifié
  bout-en-bout via https://tripoli.cloud/api/... : inscription,
  connexion, upload, download, suppression, et les cas d'erreur
  (401 sans session, 409 email déjà pris, 401 mauvais mot de passe,
  404 double suppression).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-16 22:43:02 +02:00
parent 8f70c2aae7
commit 546734ad77
13 changed files with 2693 additions and 8 deletions

190
tripoli-api/src/auth.rs Normal file
View file

@ -0,0 +1,190 @@
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use axum::extract::{FromRequestParts, State};
use axum::http::request::Parts;
use axum::Json;
use axum_extra::extract::cookie::{Cookie, SameSite};
use axum_extra::extract::CookieJar;
use chrono::{Duration, Utc};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::AppError;
use crate::state::SharedState;
pub const SESSION_COOKIE: &str = "tripoli_session";
#[derive(Deserialize)]
pub struct Credentials {
pub email: String,
pub password: String,
}
#[derive(Serialize)]
pub struct UserView {
pub id: Uuid,
pub email: String,
}
fn hash_password(password: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::Internal(format!("hachage du mot de passe : {e}")))
}
fn verify_password(password: &str, hash: &str) -> bool {
let Ok(parsed) = PasswordHash::new(hash) else {
return false;
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
async fn create_session(pool: &PgPool, user_id: Uuid, ttl_hours: i64) -> Result<Uuid, AppError> {
let session_id = Uuid::new_v4();
let expires_at = Utc::now() + Duration::hours(ttl_hours);
sqlx::query("INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, $3)")
.bind(session_id)
.bind(user_id)
.bind(expires_at)
.execute(pool)
.await?;
Ok(session_id)
}
fn session_cookie(session_id: Uuid, ttl_hours: i64) -> Cookie<'static> {
Cookie::build((SESSION_COOKIE, session_id.to_string()))
.path("/")
.http_only(true)
.secure(true)
.same_site(SameSite::Lax)
.max_age(time::Duration::hours(ttl_hours))
.build()
}
pub async fn register(
State(state): State<SharedState>,
jar: CookieJar,
Json(body): Json<Credentials>,
) -> Result<(CookieJar, Json<UserView>), AppError> {
if body.email.trim().is_empty() || body.password.len() < 8 {
return Err(AppError::BadRequest(
"email requis, mot de passe de 8 caractères minimum".to_string(),
));
}
let password_hash = hash_password(&body.password)?;
let user_id = Uuid::new_v4();
sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)")
.bind(user_id)
.bind(&body.email)
.bind(&password_hash)
.execute(&state.pool)
.await
.map_err(|e| match e {
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
AppError::EmailAlreadyTaken
}
other => AppError::from(other),
})?;
let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?;
let jar = jar.add(session_cookie(session_id, state.session_ttl_hours));
Ok((
jar,
Json(UserView {
id: user_id,
email: body.email,
}),
))
}
pub async fn login(
State(state): State<SharedState>,
jar: CookieJar,
Json(body): Json<Credentials>,
) -> Result<(CookieJar, Json<UserView>), AppError> {
let row: Option<(Uuid, String, String)> =
sqlx::query_as("SELECT id, email, password_hash FROM users WHERE email = $1")
.bind(&body.email)
.fetch_optional(&state.pool)
.await?;
let Some((user_id, email, password_hash)) = row else {
return Err(AppError::InvalidCredentials);
};
if !verify_password(&body.password, &password_hash) {
return Err(AppError::InvalidCredentials);
}
let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?;
let jar = jar.add(session_cookie(session_id, state.session_ttl_hours));
Ok((jar, Json(UserView { id: user_id, email })))
}
pub async fn logout(
State(state): State<SharedState>,
jar: CookieJar,
) -> Result<CookieJar, AppError> {
if let Some(cookie) = jar.get(SESSION_COOKIE) {
if let Ok(session_id) = Uuid::parse_str(cookie.value()) {
sqlx::query("DELETE FROM sessions WHERE id = $1")
.bind(session_id)
.execute(&state.pool)
.await?;
}
}
Ok(jar.remove(Cookie::from(SESSION_COOKIE)))
}
pub async fn me(user: AuthUser) -> Json<UserView> {
Json(UserView {
id: user.id,
email: user.email,
})
}
/// Extracteur de requête : présent sur toute route qui exige une session
/// valide. Une session expirée ou absente donne un 401 avant même
/// d'atteindre le handler - le handler n'a jamais à vérifier lui-même.
pub struct AuthUser {
pub id: Uuid,
pub email: String,
}
impl FromRequestParts<SharedState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &SharedState,
) -> Result<Self, Self::Rejection> {
let jar = CookieJar::from_headers(&parts.headers);
let session_id = jar
.get(SESSION_COOKIE)
.and_then(|c| Uuid::parse_str(c.value()).ok())
.ok_or(AppError::Unauthorized)?;
let row: Option<(Uuid, String)> = sqlx::query_as(
"SELECT users.id, users.email FROM sessions \
JOIN users ON users.id = sessions.user_id \
WHERE sessions.id = $1 AND sessions.expires_at > now()",
)
.bind(session_id)
.fetch_optional(&state.pool)
.await
.map_err(AppError::from)?;
let (id, email) = row.ok_or(AppError::Unauthorized)?;
Ok(AuthUser { id, email })
}
}

42
tripoli-api/src/config.rs Normal file
View file

@ -0,0 +1,42 @@
use std::net::SocketAddr;
use std::path::PathBuf;
pub struct Config {
pub listen_addr: SocketAddr,
pub database_url: String,
/// Dossier de stockage des fichiers. Chaque blob est nommé par son
/// UUID en base - le nom d'origine ne vit qu'en métadonnée, jamais
/// sur le disque, ce qui évite toute question de traversée de
/// chemin ou de caractères spéciaux dans un nom de fichier.
pub blob_dir: PathBuf,
/// Durée de vie d'une session, en heures.
pub session_ttl_hours: i64,
}
impl Config {
pub fn from_env() -> Self {
let listen_addr = std::env::var("TRIPOLI_API_LISTEN")
.unwrap_or_else(|_| "127.0.0.1:8081".to_string())
.parse()
.expect("TRIPOLI_API_LISTEN doit être une adresse host:port valide");
let database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL doit être définie");
let blob_dir = std::env::var("TRIPOLI_BLOB_DIR")
.unwrap_or_else(|_| "/var/lib/tripoli-api/blobs".to_string())
.into();
let session_ttl_hours = std::env::var("TRIPOLI_SESSION_TTL_HOURS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(24 * 30);
Self {
listen_addr,
database_url,
blob_dir,
session_ttl_hours,
}
}
}

56
tripoli-api/src/error.rs Normal file
View file

@ -0,0 +1,56 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
/// Une seule erreur applicative, mappée explicitement vers un statut
/// HTTP - pas de `anyhow`/`Box<dyn Error>` générique : chaque variante
/// dit exactement ce qui a été refusé et pourquoi, ce qui reste
/// défendable ligne par ligne.
pub enum AppError {
InvalidCredentials,
EmailAlreadyTaken,
Unauthorized,
NotFound,
BadRequest(String),
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::InvalidCredentials => {
(StatusCode::UNAUTHORIZED, "identifiants invalides".to_string())
}
AppError::EmailAlreadyTaken => {
(StatusCode::CONFLICT, "cet email est déjà utilisé".to_string())
}
AppError::Unauthorized => {
(StatusCode::UNAUTHORIZED, "authentification requise".to_string())
}
AppError::NotFound => (StatusCode::NOT_FOUND, "introuvable".to_string()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::Internal(msg) => {
tracing::error!(error = %msg, "erreur interne");
(
StatusCode::INTERNAL_SERVER_ERROR,
"erreur interne".to_string(),
)
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
AppError::Internal(err.to_string())
}
}
impl From<std::io::Error> for AppError {
fn from(err: std::io::Error) -> Self {
AppError::Internal(err.to_string())
}
}

169
tripoli-api/src/files.rs Normal file
View file

@ -0,0 +1,169 @@
use axum::body::Body;
use axum::extract::{Multipart, Path, State};
use axum::http::header;
use axum::response::Response;
use axum::Json;
use serde::Serialize;
use sqlx::Row;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use tokio_util::io::ReaderStream;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::AppError;
use crate::state::SharedState;
#[derive(Serialize)]
pub struct FileView {
pub id: Uuid,
pub filename: String,
pub content_type: String,
pub size_bytes: i64,
pub created_at: chrono::DateTime<chrono::Utc>,
}
pub async fn list(
State(state): State<SharedState>,
user: AuthUser,
) -> Result<Json<Vec<FileView>>, AppError> {
let files = sqlx::query_as::<_, (Uuid, String, String, i64, chrono::DateTime<chrono::Utc>)>(
"SELECT id, filename, content_type, size_bytes, created_at \
FROM files WHERE owner_id = $1 ORDER BY created_at DESC",
)
.bind(user.id)
.fetch_all(&state.pool)
.await?
.into_iter()
.map(|(id, filename, content_type, size_bytes, created_at)| FileView {
id,
filename,
content_type,
size_bytes,
created_at,
})
.collect();
Ok(Json(files))
}
/// Le champ multipart est écrit sur disque au fur et à mesure des
/// chunks reçus (`field.chunk().await`) plutôt que d'être accumulé en
/// mémoire : c'est ce qui rend la progression d'upload réelle côté
/// client (le corps de la requête part réellement au fil de l'eau, pas
/// d'un coup une fois tout reçu par le serveur).
pub async fn upload(
State(state): State<SharedState>,
user: AuthUser,
mut multipart: Multipart,
) -> Result<Json<FileView>, AppError> {
let Some(mut field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?
else {
return Err(AppError::BadRequest("champ 'file' manquant".to_string()));
};
let filename = field
.file_name()
.map(|s| s.to_string())
.unwrap_or_else(|| "fichier".to_string());
let content_type = field
.content_type()
.map(|s| s.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
tokio::fs::create_dir_all(&state.blob_dir).await?;
let file_id = Uuid::new_v4();
let final_path = state.blob_dir.join(file_id.to_string());
let tmp_path = state.blob_dir.join(format!("{file_id}.part"));
let mut out = File::create(&tmp_path).await?;
let mut size_bytes: i64 = 0;
while let Some(chunk) = field
.chunk()
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?
{
out.write_all(&chunk).await?;
size_bytes += chunk.len() as i64;
}
out.flush().await?;
tokio::fs::rename(&tmp_path, &final_path).await?;
let created_at: chrono::DateTime<chrono::Utc> = sqlx::query(
"INSERT INTO files (id, owner_id, filename, content_type, size_bytes) \
VALUES ($1, $2, $3, $4, $5) RETURNING created_at",
)
.bind(file_id)
.bind(user.id)
.bind(&filename)
.bind(&content_type)
.bind(size_bytes)
.fetch_one(&state.pool)
.await?
.get("created_at");
Ok(Json(FileView {
id: file_id,
filename,
content_type,
size_bytes,
created_at,
}))
}
pub async fn download(
State(state): State<SharedState>,
user: AuthUser,
Path(file_id): Path<Uuid>,
) -> Result<Response, AppError> {
let row: Option<(String, String)> =
sqlx::query_as("SELECT filename, content_type FROM files WHERE id = $1 AND owner_id = $2")
.bind(file_id)
.bind(user.id)
.fetch_optional(&state.pool)
.await?;
let Some((filename, content_type)) = row else {
return Err(AppError::NotFound);
};
let path = state.blob_dir.join(file_id.to_string());
let file = File::open(&path).await.map_err(|_| AppError::NotFound)?;
let body = Body::from_stream(ReaderStream::new(file));
Ok(Response::builder()
.header(header::CONTENT_TYPE, content_type)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
)
.body(body)
.map_err(|e| AppError::Internal(e.to_string()))?)
}
pub async fn delete(
State(state): State<SharedState>,
user: AuthUser,
Path(file_id): Path<Uuid>,
) -> Result<(), AppError> {
let result = sqlx::query("DELETE FROM files WHERE id = $1 AND owner_id = $2")
.bind(file_id)
.bind(user.id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound);
}
let path = state.blob_dir.join(file_id.to_string());
// Le blob peut déjà être absent (incohérence disque/DB préexistante) :
// ce n'est pas une raison de renvoyer une erreur pour une suppression
// qui, du point de vue de l'utilisateur, a réussi.
let _ = tokio::fs::remove_file(&path).await;
Ok(())
}

81
tripoli-api/src/main.rs Normal file
View file

@ -0,0 +1,81 @@
mod auth;
mod config;
mod error;
mod files;
mod state;
use std::sync::Arc;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::json;
use sqlx::postgres::PgPoolOptions;
use config::Config;
use state::AppState;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.json()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let config = Config::from_env();
let pool = PgPoolOptions::new()
.max_connections(5) // serveur à 2 vCPU / 3.7 Go partagé avec 6 autres projets
.connect(&config.database_url)
.await
.expect("connexion à PostgreSQL impossible");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations impossibles à appliquer");
let listen_addr = config.listen_addr;
let state = Arc::new(AppState {
pool,
blob_dir: config.blob_dir,
session_ttl_hours: config.session_ttl_hours,
});
let app = Router::new()
.route("/healthz", get(healthz))
.route("/api/auth/register", post(auth::register))
.route("/api/auth/login", post(auth::login))
.route("/api/auth/logout", post(auth::logout))
.route("/api/auth/me", get(auth::me))
.route("/api/files", get(files::list).post(files::upload))
.route(
"/api/files/{id}",
get(files::download).delete(files::delete),
)
.with_state(state);
tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré");
let listener = tokio::net::TcpListener::bind(listen_addr)
.await
.unwrap_or_else(|err| panic!("impossible d'écouter sur {listen_addr}: {err}"));
axum::serve(listener, app)
.with_graceful_shutdown(async {
tokio::signal::ctrl_c()
.await
.expect("impossible d'installer le gestionnaire de signal");
tracing::info!("signal d'arrêt reçu, extinction de tripoli-api");
})
.await
.expect("le serveur s'est arrêté en erreur");
}
async fn healthz(
axum::extract::State(state): axum::extract::State<state::SharedState>,
) -> Json<serde_json::Value> {
match sqlx::query("SELECT 1").execute(&state.pool).await {
Ok(_) => Json(json!({ "status": "ok" })),
Err(err) => Json(json!({ "status": "degraded", "error": err.to_string() })),
}
}

12
tripoli-api/src/state.rs Normal file
View file

@ -0,0 +1,12 @@
use std::path::PathBuf;
use std::sync::Arc;
use sqlx::PgPool;
pub struct AppState {
pub pool: PgPool,
pub blob_dir: PathBuf,
pub session_ttl_hours: i64,
}
pub type SharedState = Arc<AppState>;