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