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:
parent
8f70c2aae7
commit
546734ad77
13 changed files with 2693 additions and 8 deletions
1987
Cargo.lock
generated
1987
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,3 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["tripoli-edge"]
|
members = ["tripoli-edge", "tripoli-api"]
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ tenté, non simulé.
|
||||||
| Ansible (rôle de base) | Fait — `ansible/` |
|
| Ansible (rôle de base) | Fait — `ansible/` |
|
||||||
| OpenTofu (serveur + DNS) | Code écrit — `tofu/` ; serveur en donnée en lecture seule (jamais recréé/détruit), DNS géré via le provider OVH. `plan`/`apply` pas encore exécutés (nécessitent des identifiants OVH/Hetzner non fournis) |
|
| OpenTofu (serveur + DNS) | Code écrit — `tofu/` ; serveur en donnée en lecture seule (jamais recréé/détruit), DNS géré via le provider OVH. `plan`/`apply` pas encore exécutés (nécessitent des identifiants OVH/Hetzner non fournis) |
|
||||||
| `tripoli-edge` (Rust, Phase 2) | Fait et déployé en prod — `tripoli-edge/`, service systemd, derrière Caddy, vérifié via `https://tripoli.cloud/healthz` et `/metrics` |
|
| `tripoli-edge` (Rust, Phase 2) | Fait et déployé en prod — `tripoli-edge/`, service systemd, derrière Caddy, vérifié via `https://tripoli.cloud/healthz` et `/metrics` |
|
||||||
| `tripoli-api` (Rust, Phase 3) | Pas commencé |
|
| `tripoli-api` (Rust, Phase 3) | Fait et déployé en prod — `tripoli-api/`, auth par session (Argon2), upload/download, PostgreSQL dédié (Docker), vérifié via `https://tripoli.cloud/api/...` |
|
||||||
| `tripoli-netctl` (Rust, Phase 4) | Pas commencé |
|
| `tripoli-netctl` (Rust, Phase 4) | Pas commencé |
|
||||||
| Observabilité dédiée (Phase 5) | Pas commencé (une stack Prometheus/Grafana existe déjà sur le serveur, partagée avec les autres projets) |
|
| Observabilité dédiée (Phase 5) | Pas commencé (une stack Prometheus/Grafana existe déjà sur le serveur, partagée avec les autres projets) |
|
||||||
| Bird/BGP (Phase 6, bonus) | Pas commencé |
|
| Bird/BGP (Phase 6, bonus) | Pas commencé |
|
||||||
|
|
|
||||||
24
tripoli-api/Cargo.toml
Normal file
24
tripoli-api/Cargo.toml
Normal 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
83
tripoli-api/README.md
Normal 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).
|
||||||
25
tripoli-api/deploy/tripoli-api.service
Normal file
25
tripoli-api/deploy/tripoli-api.service
Normal 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
|
||||||
28
tripoli-api/migrations/0001_init.sql
Normal file
28
tripoli-api/migrations/0001_init.sql
Normal 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
190
tripoli-api/src/auth.rs
Normal 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
42
tripoli-api/src/config.rs
Normal 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
56
tripoli-api/src/error.rs
Normal 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
169
tripoli-api/src/files.rs
Normal 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
81
tripoli-api/src/main.rs
Normal 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
12
tripoli-api/src/state.rs
Normal 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>;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue