Frontend et backend entièrement réels : plus aucune donnée simulée
À 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:
parent
5816c7eda6
commit
a639b705a6
53 changed files with 1352 additions and 587 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
15
tripoli-api/migrations/0002_full_features.sql
Normal file
15
tripoli-api/migrations/0002_full_features.sql
Normal 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);
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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é");
|
||||
|
|
|
|||
95
tripoli-api/src/notifications.rs
Normal file
95
tripoli-api/src/notifications.rs
Normal 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(())
|
||||
}
|
||||
47
tripoli-api/src/storage.rs
Normal file
47
tripoli-api/src/storage.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue