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

24
tripoli-api/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "tripoli-api"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "signal", "io-util"] }
axum = { version = "0.8", features = ["multipart"] }
axum-extra = { version = "0.10", features = ["cookie"] }
tower-http = { version = "0.6", features = ["trace"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
argon2 = "0.5"
password-hash = "0.5"
rand_core = { version = "0.6", features = ["std"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
futures-util = "0.3"
time = "0.3"
tokio-util = { version = "0.7", features = ["io"] }
mime_guess = "2"

83
tripoli-api/README.md Normal file
View file

@ -0,0 +1,83 @@
# tripoli-api
Auth, fichiers, métadonnées PostgreSQL. Axum + sqlx (requêtes vérifiées
à l'exécution, pas les macros `query!` à vérification compile-time :
elles auraient exigé une base joignable à chaque `cargo build`, y
compris depuis un poste qui n'a pas le tunnel WireGuard monté - plus de
complexité que ce que ça apporte pour un projet de cette taille).
## Auth
Sessions opaques côté serveur (table `sessions`, cookie `tripoli_session`
`HttpOnly` + `Secure` + `SameSite=Lax`), pas de JWT : révoquer une session
est une suppression de ligne, pas une histoire de clé de signature à
faire tourner. Mots de passe hachés avec Argon2 (`argon2` + `password-hash`,
implémentations RustCrypto).
## Endpoints
| Route | Auth | Rôle |
|---|---|---|
| `GET /healthz` | non | ping PostgreSQL |
| `POST /api/auth/register` | non | `{email, password}` -> crée le compte + session |
| `POST /api/auth/login` | non | `{email, password}` -> session |
| `POST /api/auth/logout` | oui | invalide la session |
| `GET /api/auth/me` | oui | utilisateur courant |
| `GET /api/files` | oui | fichiers de l'utilisateur courant |
| `POST /api/files` | oui | upload multipart, champ `file` |
| `GET /api/files/{id}` | oui | téléchargement (vérifie la propriété) |
| `DELETE /api/files/{id}` | oui | suppression (vérifie la propriété) |
## Stockage
Chaque fichier est un blob sur disque nommé par son UUID
(`TRIPOLI_BLOB_DIR`, `/var/lib/tripoli-api/blobs` par défaut) - jamais
par le nom d'origine, qui ne vit qu'en métadonnée PostgreSQL. Ça évite
toute question de traversée de chemin ou de caractères spéciaux dans un
nom de fichier envoyé par un client.
Stockage disque, pas Hetzner Object Storage pour cette v1 : le brief
laissait le choix, et Object Storage aurait ajouté un client S3 et des
identifiants supplémentaires sans bénéfice pour un unique VPS avec 55 Go
disponibles. À revisiter si l'espace disque devient une contrainte
réelle.
L'upload multipart est écrit sur disque chunk par chunk
(`field.chunk().await` -> `AsyncWriteExt::write_all`), jamais 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 effectivement au fil de l'eau.
## Configuration
| Variable | Défaut | Rôle |
|---|---|---|
| `TRIPOLI_API_LISTEN` | `127.0.0.1:8081` | Adresse d'écoute (jamais publique - tripoli-edge est le seul appelant) |
| `DATABASE_URL` | — (requis) | `postgres://...` |
| `TRIPOLI_BLOB_DIR` | `/var/lib/tripoli-api/blobs` | Stockage des blobs |
| `TRIPOLI_SESSION_TTL_HOURS` | `720` (30 jours) | Durée de vie d'une session |
## Déploiement (fait manuellement pour cette phase, même remarque que tripoli-edge)
- PostgreSQL dans un conteneur Docker dédié (`/opt/tripoli-api/docker-compose.yml`),
**séparé de celui d'iot-alarm-system** : pas de couplage entre projets
qui n'ont rien à voir. Exposé uniquement sur `127.0.0.1:5433` - tripoli-api
tourne nativement sur l'hôte, pas besoin de l'exposer sur un réseau Docker.
Mot de passe généré côté serveur (`/opt/tripoli-api/.env`, jamais commité,
jamais affiché dans un terminal).
- tripoli-api tourne en systemd (`deploy/tripoli-api.service`) sous le même
utilisateur système sans privilège `tripoli` que tripoli-edge.
- Les migrations (`migrations/0001_init.sql`) s'appliquent automatiquement
au démarrage (`sqlx::migrate!`).
## Vérifié en production
```
curl https://tripoli.cloud/healthz -> {"status":"ok"}
curl -X POST https://tripoli.cloud/api/auth/register -> compte + session
curl https://tripoli.cloud/api/auth/me (avec cookie) -> utilisateur courant
curl -F file=@... https://tripoli.cloud/api/files -> upload
curl https://tripoli.cloud/api/files/{id} -> download
```
Testé aussi : accès sans cookie (401), email déjà pris (409), mauvais
mot de passe (401), double suppression (404 la seconde fois).

View file

@ -0,0 +1,25 @@
[Unit]
Description=tripoli-api - auth, fichiers, métadonnées PostgreSQL
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
User=tripoli
Group=tripoli
ExecStart=/usr/local/bin/tripoli-api
Restart=on-failure
RestartSec=2
EnvironmentFile=/opt/tripoli-api/.env
Environment=RUST_LOG=info
Environment=TRIPOLI_API_LISTEN=127.0.0.1:8081
Environment=TRIPOLI_BLOB_DIR=/var/lib/tripoli-api/blobs
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/tripoli-api
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,28 @@
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Sessions opaques côté serveur plutôt que des JWT : révocation = une
-- ligne supprimée, pas de clé de signature à faire tourner ni à protéger.
CREATE TABLE sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX sessions_user_id_idx ON sessions(user_id);
CREATE TABLE files (
id UUID PRIMARY KEY,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX files_owner_id_idx ON files(owner_id);

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