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

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(())
}