tripoli.cloud/tripoli-api/src/files.rs

170 lines
4.9 KiB
Rust
Raw Normal View History

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