- 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>
169 lines
4.9 KiB
Rust
169 lines
4.9 KiB
Rust
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(())
|
|
}
|