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
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(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue