Frontend et backend entièrement réels : plus aucune donnée simulée
All checks were successful
CI / build-and-test (push) Successful in 2m14s
CI / deploy (push) Successful in 3m17s

À la demande explicite de l'utilisateur après avoir constaté que
tripoli.cloud affichait un 404 vide (le frontend n'avait jamais été
déployé) : déploiement du frontend, puis remplacement complet des
mocks par de vraies données, des deux côtés.

Backend (tripoli-api) :
- users.name, files.category (file/photo/backup), table notifications
  (migration 0002).
- POST /api/auth/guest : compte invité réel et réutilisé
  (invite@tripoli.local), pas une identité simulée côté frontend.
- GET /api/files?category=... , GET /api/notifications (+ read-all/
  {id}/read), GET /api/storage (used_bytes réel, available_bytes via
  statvfs - jamais un quota inventé).
- Notifications émises réellement sur upload/suppression.

Frontend (app/) :
- src/api/*.ts appellent réellement tripoli-api (plus de mockFetch).
- Upload réel (XHR, vraie progression), suppression avec bouton dédié,
  vraies miniatures photo (<img src="/api/files/:id">).
- Session restaurée au chargement (GET /api/auth/me), logout réel.
- Retiré plutôt que simulé : 2FA, clé de récupération, accès partagés,
  albums, dossiers, "4 appareils sauvegardés", chiffres marketing et
  claim AES-256 côté client sur la page de connexion (jamais vrai -
  remplacé par le vrai : HTTPS + Argon2).

Déploiement :
- Frontend construit et déployé sur le serveur (le build local échouait
  spécifiquement sur cette machine - rolldown/esbuild natif ne répondait
  plus, cause non résolue ; contourné en buildant sur le serveur, comme
  déjà fait pour les crates Rust).
- Caddy : /var/www/tripoli-cloud servi en statique pour tripoli.cloud,
  /api/*, /healthz, /metrics toujours proxyés vers tripoli-edge.
- Pipeline CI étendu : le job deploy construit et déploie maintenant
  aussi le frontend, via un script sudo dédié supplémentaire
  (tripoli-deploy-frontend.sh) sur le compte deploy sans privilège.
- Vérifié en navigateur réel (Chrome) : inscription, connexion, invité,
  upload avec progression, photos, sauvegardes, notifications,
  suppression, déconnexion - tous testés en conditions réelles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-17 18:19:51 +02:00
parent 5816c7eda6
commit a639b705a6
53 changed files with 1352 additions and 587 deletions

View file

@ -19,6 +19,7 @@ rand_core = { version = "0.6", features = ["std"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
futures-util = "0.3"
nix = { version = "0.31", features = ["fs"] }
time = "0.3"
tokio-util = { version = "0.7", features = ["io"] }
mime_guess = "2"

View file

@ -19,14 +19,19 @@ implémentations RustCrypto).
| Route | Auth | Rôle |
|---|---|---|
| `GET /healthz` | non | ping PostgreSQL |
| `POST /api/auth/register` | non | `{email, password}` -> crée le compte + session |
| `POST /api/auth/register` | non | `{name, email, password}` -> crée le compte + session |
| `POST /api/auth/login` | non | `{email, password}` -> session |
| `POST /api/auth/guest` | non | connecte (crée si besoin) le compte invité réel `invite@tripoli.local` |
| `POST /api/auth/logout` | oui | invalide la session |
| `GET /api/auth/me` | oui | utilisateur courant |
| `GET /api/files` | oui | fichiers de l'utilisateur courant |
| `POST /api/files` | oui | upload multipart, champ `file` |
| `GET /api/files` | oui | fichiers de l'utilisateur courant, `?category=file\|photo\|backup` |
| `POST /api/files` | oui | upload multipart, champs `file` et `category` (optionnel) |
| `GET /api/files/{id}` | oui | téléchargement (vérifie la propriété) |
| `DELETE /api/files/{id}` | oui | suppression (vérifie la propriété) |
| `GET /api/notifications` | oui | notifications de l'utilisateur (émises par upload/suppression) |
| `POST /api/notifications/read-all` | oui | marque tout comme lu |
| `POST /api/notifications/{id}/read` | oui | marque une notification comme lue |
| `GET /api/storage` | oui | `used_bytes` (somme réelle des fichiers), `available_bytes` (`statvfs`) |
## Stockage
@ -66,8 +71,18 @@ client - le corps de la requête part effectivement au fil de l'eau.
jamais affiché dans un terminal).
- tripoli-api tourne en systemd (`deploy/tripoli-api.service`) sous le même
utilisateur système sans privilège `tripoli` que tripoli-edge.
- Les migrations (`migrations/0001_init.sql`) s'appliquent automatiquement
au démarrage (`sqlx::migrate!`).
- Les migrations (`migrations/*.sql`) s'appliquent automatiquement au
démarrage (`sqlx::migrate!`) - `0002_full_features.sql` ajoute `name`
aux utilisateurs, `category` aux fichiers, et la table
`notifications`.
## Compte invité
"Visiter en tant qu'invité" se connecte à un compte réel
(`invite@tripoli.local`), créé une seule fois puis réutilisé - jamais
une identité simulée côté frontend. Son mot de passe est un UUID
aléatoire jamais communiqué : personne ne peut s'y connecter autrement
qu'en passant par `POST /api/auth/guest`.
## Vérifié en production

View file

@ -0,0 +1,15 @@
ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT '';
ALTER TABLE files ADD COLUMN category TEXT NOT NULL DEFAULT 'file';
CREATE TABLE notifications (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
read_at TIMESTAMPTZ
);
CREATE INDEX notifications_user_id_idx ON notifications(user_id);

View file

@ -5,7 +5,7 @@ use axum::http::request::Parts;
use axum::Json;
use axum_extra::extract::cookie::{Cookie, SameSite};
use axum_extra::extract::CookieJar;
use chrono::{Duration, Utc};
use chrono::{DateTime, Duration, Utc};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
@ -16,16 +16,32 @@ use crate::state::SharedState;
pub const SESSION_COOKIE: &str = "tripoli_session";
/// Adresse réservée au compte invité : "Visiter en tant qu'invité" se
/// connecte réellement à ce compte plutôt que de simuler une identité -
/// mêmes routes, mêmes sessions, même stockage. Pas de mot de passe
/// dessus : `guest()` crée ou réutilise ce compte directement.
const GUEST_EMAIL: &str = "invite@tripoli.local";
#[derive(Deserialize)]
pub struct Credentials {
pub email: String,
pub password: String,
}
#[derive(Deserialize)]
pub struct RegisterPayload {
pub name: String,
pub email: String,
pub password: String,
}
#[derive(Serialize)]
pub struct UserView {
pub id: Uuid,
pub name: String,
pub email: String,
pub is_guest: bool,
pub created_at: DateTime<Utc>,
}
fn hash_password(password: &str) -> Result<String, AppError> {
@ -67,42 +83,53 @@ fn session_cookie(session_id: Uuid, ttl_hours: i64) -> Cookie<'static> {
.build()
}
fn to_view(id: Uuid, name: String, email: String, created_at: DateTime<Utc>) -> UserView {
let is_guest = email == GUEST_EMAIL;
UserView {
id,
name,
email,
is_guest,
created_at,
}
}
pub async fn register(
State(state): State<SharedState>,
jar: CookieJar,
Json(body): Json<Credentials>,
Json(body): Json<RegisterPayload>,
) -> Result<(CookieJar, Json<UserView>), AppError> {
if body.email.trim().is_empty() || body.password.len() < 8 {
if body.name.trim().is_empty() || body.email.trim().is_empty() || body.password.len() < 8 {
return Err(AppError::BadRequest(
"email requis, mot de passe de 8 caractères minimum".to_string(),
"nom et email requis, mot de passe de 8 caractères minimum".to_string(),
));
}
let password_hash = hash_password(&body.password)?;
let user_id = Uuid::new_v4();
sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)")
.bind(user_id)
.bind(&body.email)
.bind(&password_hash)
.execute(&state.pool)
.await
.map_err(|e| match e {
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
AppError::EmailAlreadyTaken
}
other => AppError::from(other),
})?;
let created_at: DateTime<Utc> = sqlx::query_scalar(
"INSERT INTO users (id, name, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING created_at",
)
.bind(user_id)
.bind(&body.name)
.bind(&body.email)
.bind(&password_hash)
.fetch_one(&state.pool)
.await
.map_err(|e| match e {
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
AppError::EmailAlreadyTaken
}
other => AppError::from(other),
})?;
let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?;
let jar = jar.add(session_cookie(session_id, state.session_ttl_hours));
Ok((
jar,
Json(UserView {
id: user_id,
email: body.email,
}),
Json(to_view(user_id, body.name, body.email, created_at)),
))
}
@ -111,13 +138,14 @@ pub async fn login(
jar: CookieJar,
Json(body): Json<Credentials>,
) -> Result<(CookieJar, Json<UserView>), AppError> {
let row: Option<(Uuid, String, String)> =
sqlx::query_as("SELECT id, email, password_hash FROM users WHERE email = $1")
.bind(&body.email)
.fetch_optional(&state.pool)
.await?;
let row: Option<(Uuid, String, String, String, DateTime<Utc>)> = sqlx::query_as(
"SELECT id, name, email, password_hash, created_at FROM users WHERE email = $1",
)
.bind(&body.email)
.fetch_optional(&state.pool)
.await?;
let Some((user_id, email, password_hash)) = row else {
let Some((user_id, name, email, password_hash, created_at)) = row else {
return Err(AppError::InvalidCredentials);
};
@ -128,7 +156,44 @@ pub async fn login(
let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?;
let jar = jar.add(session_cookie(session_id, state.session_ttl_hours));
Ok((jar, Json(UserView { id: user_id, email })))
Ok((jar, Json(to_view(user_id, name, email, created_at))))
}
/// Compte réel, réutilisé à chaque appel (créé une seule fois, mot de
/// passe aléatoire jamais communiqué - personne ne peut se connecter
/// autrement qu'en repassant par cette route). "Invité" veut dire
/// "compte de démonstration sans inscription", pas "identité simulée".
pub async fn guest(
State(state): State<SharedState>,
jar: CookieJar,
) -> Result<(CookieJar, Json<UserView>), AppError> {
let existing: Option<(Uuid, String, String, DateTime<Utc>)> =
sqlx::query_as("SELECT id, name, email, created_at FROM users WHERE email = $1")
.bind(GUEST_EMAIL)
.fetch_optional(&state.pool)
.await?;
let (user_id, name, email, created_at) = if let Some(row) = existing {
row
} else {
let user_id = Uuid::new_v4();
let password_hash = hash_password(&Uuid::new_v4().to_string())?;
let created_at: DateTime<Utc> = sqlx::query_scalar(
"INSERT INTO users (id, name, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING created_at",
)
.bind(user_id)
.bind("Invité")
.bind(GUEST_EMAIL)
.bind(&password_hash)
.fetch_one(&state.pool)
.await?;
(user_id, "Invité".to_string(), GUEST_EMAIL.to_string(), created_at)
};
let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?;
let jar = jar.add(session_cookie(session_id, state.session_ttl_hours));
Ok((jar, Json(to_view(user_id, name, email, created_at))))
}
pub async fn logout(
@ -146,11 +211,12 @@ pub async fn logout(
Ok(jar.remove(Cookie::from(SESSION_COOKIE)))
}
pub async fn me(user: AuthUser) -> Json<UserView> {
Json(UserView {
id: user.id,
email: user.email,
})
pub async fn me(State(state): State<SharedState>, user: AuthUser) -> Result<Json<UserView>, AppError> {
let row: (DateTime<Utc>,) = sqlx::query_as("SELECT created_at FROM users WHERE id = $1")
.bind(user.id)
.fetch_one(&state.pool)
.await?;
Ok(Json(to_view(user.id, user.name, user.email, row.0)))
}
/// Extracteur de requête : présent sur toute route qui exige une session
@ -158,6 +224,7 @@ pub async fn me(user: AuthUser) -> Json<UserView> {
/// d'atteindre le handler - le handler n'a jamais à vérifier lui-même.
pub struct AuthUser {
pub id: Uuid,
pub name: String,
pub email: String,
}
@ -174,8 +241,8 @@ impl FromRequestParts<SharedState> for AuthUser {
.and_then(|c| Uuid::parse_str(c.value()).ok())
.ok_or(AppError::Unauthorized)?;
let row: Option<(Uuid, String)> = sqlx::query_as(
"SELECT users.id, users.email FROM sessions \
let row: Option<(Uuid, String, String)> = sqlx::query_as(
"SELECT users.id, users.name, users.email FROM sessions \
JOIN users ON users.id = sessions.user_id \
WHERE sessions.id = $1 AND sessions.expires_at > now()",
)
@ -184,7 +251,7 @@ impl FromRequestParts<SharedState> for AuthUser {
.await
.map_err(AppError::from)?;
let (id, email) = row.ok_or(AppError::Unauthorized)?;
Ok(AuthUser { id, email })
let (id, name, email) = row.ok_or(AppError::Unauthorized)?;
Ok(AuthUser { id, name, email })
}
}

View file

@ -1,9 +1,9 @@
use axum::body::Body;
use axum::extract::{Multipart, Path, State};
use axum::extract::{Multipart, Path, Query, State};
use axum::http::header;
use axum::response::Response;
use axum::Json;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use sqlx::Row;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
@ -12,36 +12,51 @@ use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::AppError;
use crate::notifications;
use crate::state::SharedState;
const CATEGORIES: [&str; 3] = ["file", "photo", "backup"];
#[derive(Serialize)]
pub struct FileView {
pub id: Uuid,
pub filename: String,
pub content_type: String,
pub category: String,
pub size_bytes: i64,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Deserialize)]
pub struct ListQuery {
category: Option<String>,
}
pub async fn list(
State(state): State<SharedState>,
user: AuthUser,
Query(query): Query<ListQuery>,
) -> 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",
let files = sqlx::query_as::<_, (Uuid, String, String, String, i64, chrono::DateTime<chrono::Utc>)>(
"SELECT id, filename, content_type, category, size_bytes, created_at \
FROM files WHERE owner_id = $1 AND ($2::text IS NULL OR category = $2) \
ORDER BY created_at DESC",
)
.bind(user.id)
.bind(query.category)
.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,
})
.map(
|(id, filename, content_type, category, size_bytes, created_at)| FileView {
id,
filename,
content_type,
category,
size_bytes,
created_at,
},
)
.collect();
Ok(Json(files))
@ -52,17 +67,43 @@ pub async fn list(
/// 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).
///
/// `category` est un champ multipart optionnel envoyé avant `file` par
/// le client (photo/backup/file) ; tout le reste du multipart est
/// traité comme avant.
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 mut category = "file".to_string();
// `Field` emprunte `multipart` : impossible de le faire sortir de la
// boucle sans conflit d'emprunt. On consomme donc les champs
// "category" un par un jusqu'à tomber sur le champ fichier, traité
// immédiatement dans cette même itération.
let mut field = loop {
let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?
else {
return Err(AppError::BadRequest("champ 'file' manquant".to_string()));
};
if field.name() == Some("category") {
let value = field
.text()
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?;
if !CATEGORIES.contains(&value.as_str()) {
return Err(AppError::BadRequest(format!("catégorie inconnue : {value}")));
}
category = value;
continue;
}
break field;
};
let filename = field
@ -93,22 +134,33 @@ pub async fn upload(
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",
"INSERT INTO files (id, owner_id, filename, content_type, category, size_bytes) \
VALUES ($1, $2, $3, $4, $5, $6) RETURNING created_at",
)
.bind(file_id)
.bind(user.id)
.bind(&filename)
.bind(&content_type)
.bind(&category)
.bind(size_bytes)
.fetch_one(&state.pool)
.await?
.get("created_at");
notifications::emit(
&state.pool,
user.id,
"upload",
"Transfert terminé",
&format!("{filename} a été déposé."),
)
.await;
Ok(Json(FileView {
id: file_id,
filename,
content_type,
category,
size_bytes,
created_at,
}))
@ -134,11 +186,19 @@ pub async fn download(
let file = File::open(&path).await.map_err(|_| AppError::NotFound)?;
let body = Body::from_stream(ReaderStream::new(file));
// "inline" pour les images : elles doivent s'afficher dans une
// balise <img>, pas déclencher un téléchargement.
let disposition_kind = if content_type.starts_with("image/") {
"inline"
} else {
"attachment"
};
Ok(Response::builder()
.header(header::CONTENT_TYPE, content_type)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
format!("{disposition_kind}; filename=\"{filename}\""),
)
.body(body)
.map_err(|e| AppError::Internal(e.to_string()))?)
@ -149,15 +209,16 @@ pub async fn delete(
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?;
let filename: Option<String> =
sqlx::query_scalar("DELETE FROM files WHERE id = $1 AND owner_id = $2 RETURNING filename")
.bind(file_id)
.bind(user.id)
.fetch_optional(&state.pool)
.await?;
if result.rows_affected() == 0 {
let Some(filename) = filename else {
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) :
@ -165,5 +226,14 @@ pub async fn delete(
// qui, du point de vue de l'utilisateur, a réussi.
let _ = tokio::fs::remove_file(&path).await;
notifications::emit(
&state.pool,
user.id,
"delete",
"Fichier supprimé",
&format!("{filename} a été retiré du coffre."),
)
.await;
Ok(())
}

View file

@ -2,7 +2,9 @@ mod auth;
mod config;
mod error;
mod files;
mod notifications;
mod state;
mod storage;
use std::sync::Arc;
@ -45,6 +47,7 @@ async fn main() {
.route("/healthz", get(healthz))
.route("/api/auth/register", post(auth::register))
.route("/api/auth/login", post(auth::login))
.route("/api/auth/guest", post(auth::guest))
.route("/api/auth/logout", post(auth::logout))
.route("/api/auth/me", get(auth::me))
.route("/api/files", get(files::list).post(files::upload))
@ -52,6 +55,16 @@ async fn main() {
"/api/files/{id}",
get(files::download).delete(files::delete),
)
.route("/api/notifications", get(notifications::list))
.route(
"/api/notifications/read-all",
post(notifications::mark_all_read),
)
.route(
"/api/notifications/{id}/read",
post(notifications::mark_read),
)
.route("/api/storage", get(storage::get))
.with_state(state);
tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré");

View file

@ -0,0 +1,95 @@
use axum::extract::{Path, State};
use axum::Json;
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::AppError;
use crate::state::SharedState;
#[derive(Serialize)]
pub struct NotificationView {
pub id: Uuid,
pub kind: String,
pub title: String,
pub body: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub unread: bool,
}
/// Émet une notification réelle liée à un événement qui vient de se
/// produire (upload, suppression, ...). Volontairement best-effort :
/// une notification qui échoue à s'insérer ne doit jamais faire échouer
/// l'action qui l'a déclenchée.
pub async fn emit(pool: &PgPool, user_id: Uuid, kind: &str, title: &str, body: &str) {
let result = sqlx::query(
"INSERT INTO notifications (id, user_id, kind, title, body) VALUES ($1, $2, $3, $4, $5)",
)
.bind(Uuid::new_v4())
.bind(user_id)
.bind(kind)
.bind(title)
.bind(body)
.execute(pool)
.await;
if let Err(err) = result {
tracing::warn!(error = %err, "échec de l'émission d'une notification");
}
}
pub async fn list(
State(state): State<SharedState>,
user: AuthUser,
) -> Result<Json<Vec<NotificationView>>, AppError> {
let rows = sqlx::query_as::<_, (Uuid, String, String, String, chrono::DateTime<chrono::Utc>, Option<chrono::DateTime<chrono::Utc>>)>(
"SELECT id, kind, title, body, created_at, read_at FROM notifications \
WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50",
)
.bind(user.id)
.fetch_all(&state.pool)
.await?
.into_iter()
.map(|(id, kind, title, body, created_at, read_at)| NotificationView {
id,
kind,
title,
body,
created_at,
unread: read_at.is_none(),
})
.collect();
Ok(Json(rows))
}
pub async fn mark_all_read(
State(state): State<SharedState>,
user: AuthUser,
) -> Result<(), AppError> {
sqlx::query("UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL")
.bind(user.id)
.execute(&state.pool)
.await?;
Ok(())
}
pub async fn mark_read(
State(state): State<SharedState>,
user: AuthUser,
Path(notification_id): Path<Uuid>,
) -> Result<(), AppError> {
let result = sqlx::query(
"UPDATE notifications SET read_at = now() WHERE id = $1 AND user_id = $2 AND read_at IS NULL",
)
.bind(notification_id)
.bind(user.id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound);
}
Ok(())
}

View file

@ -0,0 +1,47 @@
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use crate::auth::AuthUser;
use crate::error::AppError;
use crate::state::SharedState;
#[derive(Serialize)]
pub struct StorageView {
/// Somme réelle des tailles de fichiers de cet utilisateur.
pub used_bytes: i64,
/// Espace réellement disponible sur le disque du serveur qui
/// stocke les blobs (`statvfs`, pas un quota inventé).
pub available_bytes: u64,
}
/// Pas de quota fictif type "1 To" : l'espace "disponible" est celui
/// que `statvfs` rapporte vraiment sur le volume qui stocke
/// `blob_dir`, partagé avec le reste du serveur.
pub async fn get(
State(state): State<SharedState>,
user: AuthUser,
) -> Result<Json<StorageView>, AppError> {
// `SUM(bigint)` renvoie un NUMERIC côté Postgres (pour ne jamais
// silencieusement déborder) - cast explicite pour revenir à un i64.
let used_bytes: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(size_bytes), 0)::bigint FROM files WHERE owner_id = $1",
)
.bind(user.id)
.fetch_one(&state.pool)
.await?;
let blob_dir = state.blob_dir.clone();
let available_bytes = tokio::task::spawn_blocking(move || -> Result<u64, AppError> {
let stats = nix::sys::statvfs::statvfs(&blob_dir)
.map_err(|e| AppError::Internal(format!("statvfs : {e}")))?;
Ok(stats.blocks_available() as u64 * stats.fragment_size())
})
.await
.map_err(|e| AppError::Internal(e.to_string()))??;
Ok(Json(StorageView {
used_bytes,
available_bytes,
}))
}