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, } pub async fn list( State(state): State, user: AuthUser, ) -> Result>, AppError> { let files = sqlx::query_as::<_, (Uuid, String, String, i64, chrono::DateTime)>( "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, user: AuthUser, mut multipart: Multipart, ) -> Result, 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 = 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, user: AuthUser, Path(file_id): Path, ) -> Result { 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, user: AuthUser, Path(file_id): Path, ) -> 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(()) }