Serveur Rust : socle (config, base, sessions, chiffrement, migrations)

Squelette du nouveau serveur axum/sqlx : accès aux données porté de
storage.ts, sessions compatibles connect-pg-simple, chiffrement des
jetons compatible, migrations idempotentes, en-têtes de sécurité,
limitation de débit et service des fichiers statiques.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
This commit is contained in:
Claude committed 2026-10-12 17:14:58 +00:00
1 parent 51f4406173
commit f9828d4887
40 files changed
+8647 -1

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
//! STUB — routes à porter (voir `server/routes/analytics.ts`).
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
}
+86
View File
@@ -0,0 +1,86 @@
//! Connexion, déconnexion, session courante.
use axum::http::header::SET_COOKIE;
use axum::http::HeaderMap;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{json, Value};
use crate::auth::{self, MaybeUser};
use crate::error::{ApiResult, AppError, OrApiError};
use crate::extract::Body;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/auth/login", post(login))
.route("/api/auth/logout", post(logout))
.route("/api/auth/session", get(session))
.route("/api/auth/default-password-status", get(default_password_status))
}
async fn login(
axum::extract::State(state): axum::extract::State<AppState>,
headers: HeaderMap,
Body(body): Body<Value>,
) -> ApiResult {
const BAD_CREDENTIALS: &str = "Nom d'utilisateur ou mot de passe incorrect";
// passport-local : champs absents ou vides → « Missing credentials »
let username = body.get("username").and_then(Value::as_str).filter(|s| !s.is_empty());
let password = body.get("password").and_then(Value::as_str).filter(|s| !s.is_empty());
let (Some(username), Some(password)) = (username, password) else {
return Err(AppError::unauthorized("Missing credentials"));
};
let user = state
.db
.get_user_by_username(username)
.await
.or_500("Erreur lors de la connexion")?
.ok_or_else(|| AppError::unauthorized(BAD_CREDENTIALS))?;
if !auth::verify_password(password, &user.password).await {
return Err(AppError::unauthorized(BAD_CREDENTIALS));
}
let previous = auth::load_session(&state, &headers).await;
let (_, cookie) = auth::start_session(&state, &headers, previous.as_ref(), &user.id)
.await
.or_500("Erreur lors de la connexion")?;
Ok(auth::with_cookie(auth::user_json(&user), cookie))
}
async fn logout(axum::extract::State(state): axum::extract::State<AppState>, headers: HeaderMap) -> ApiResult {
if let Some(session) = auth::load_session(&state, &headers).await {
auth::destroy_session(&state, &session.sid)
.await
.map_err(|_| AppError::internal("Erreur lors de la déconnexion"))?;
}
let mut response = Json(json!({ "message": "Déconnecté avec succès" })).into_response();
response.headers_mut().append(SET_COOKIE, auth::clear_session_cookie());
Ok(response)
}
async fn session(MaybeUser(user): MaybeUser) -> Response {
match user {
Some((user, _)) => Json(auth::user_json(&user)).into_response(),
None => AppError::unauthorized("Non authentifié").into_response(),
}
}
/// Le mot de passe de `admin` est-il toujours « admin » ?
async fn default_password_status(axum::extract::State(state): axum::extract::State<AppState>) -> ApiResult<Json<Value>> {
let admin = state
.db
.get_user_by_username("admin")
.await
.or_500("Erreur lors de la vérification du statut du mot de passe")?;
let is_default = match admin {
Some(admin) => auth::verify_password("admin", &admin.password).await,
None => false,
};
Ok(Json(json!({ "isDefault": is_default })))
}
+9
View File
@@ -0,0 +1,9 @@
//! STUB — routes principales à porter depuis `server/routes.ts`.
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
}
+9
View File
@@ -0,0 +1,9 @@
//! STUB — routes à porter (voir `server/routes/external.ts`).
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
}
+9
View File
@@ -0,0 +1,9 @@
//! STUB — routes à porter (voir `server/routes/facebook.ts`).
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
}
+9
View File
@@ -0,0 +1,9 @@
//! STUB — routes à porter (voir `server/routes/legal.ts`).
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
}
+96
View File
@@ -0,0 +1,96 @@
//! Assemblage des routes HTTP.
mod auth;
mod core;
mod analytics;
mod external;
mod facebook;
mod legal;
mod reels;
mod tiktok;
use axum::extract::{DefaultBodyLimit, Request, State};
use axum::http::{HeaderMap, StatusCode};
use axum::middleware;
use axum::response::{IntoResponse, Response};
use axum::Router;
use serde_json::json;
use tower::ServiceExt;
use tower_http::services::{ServeDir, ServeFile};
use crate::extract::MAX_JSON_BODY;
use crate::http;
use crate::state::AppState;
pub fn router(state: AppState) -> Router {
let api = Router::new()
.merge(auth::router())
.merge(core::router())
.nest("/api/analytics", analytics::router())
.nest("/api/facebook", facebook::router())
.nest("/api/tiktok", tiktok::router())
.nest("/api/v1", external::router())
.merge(reels::router())
.merge(legal::router());
let config = &state.config;
for dir in [&config.uploads_dir, &config.audio_upload_dir, &config.temp_upload_dir] {
let _ = std::fs::create_dir_all(dir);
}
for sub in ["media", "logos", "stories"] {
let _ = std::fs::create_dir_all(config.uploads_dir.join(sub));
}
// Fichiers téléversés (médias, logos, stories, musique, fichiers temporaires des rendus)
let uploads = Router::new()
.nest_service("/uploads/audio", ServeDir::new(&config.audio_upload_dir))
.nest_service("/uploads/temp", ServeDir::new(&config.temp_upload_dir))
.nest_service("/uploads", ServeDir::new(&config.uploads_dir));
api.merge(uploads)
.fallback(fallback)
.layer(DefaultBodyLimit::max(MAX_JSON_BODY))
.layer(middleware::from_fn_with_state(state.clone(), http::rate_limit))
.layer(middleware::from_fn(http::log_requests))
.layer(middleware::from_fn(http::security_headers))
.with_state(state)
}
/// Route inconnue : JSON pour `/api`, sinon fichiers publics puis l'application React.
async fn fallback(State(state): State<AppState>, headers: HeaderMap, request: Request) -> Response {
let path = request.uri().path().to_string();
if path == "/api" || path.starts_with("/api/") {
return if crate::auth::current_user(&state, &headers).await.is_none() {
(StatusCode::UNAUTHORIZED, axum::Json(json!({ "error": "Non authentifié" }))).into_response()
} else {
(StatusCode::NOT_FOUND, axum::Json(json!({ "error": "Route introuvable" }))).into_response()
};
}
// Vérifications de domaine (TikTok, Google, Meta…), sans les fichiers cachés
let is_hidden = path.split('/').any(|part| part.starts_with('.'));
if !is_hidden && path != "/" {
let candidate = state.config.public_root_dir.join(path.trim_start_matches('/'));
if candidate.is_file() {
return serve(ServeFile::new(candidate), request).await;
}
}
let index = state.config.static_dir.join("index.html");
let spa = ServeDir::new(&state.config.static_dir)
.append_index_html_on_directories(true)
.fallback(ServeFile::new(index));
serve(spa, request).await
}
async fn serve<S>(service: S, request: Request) -> Response
where
S: tower::Service<Request, Error = std::convert::Infallible> + Send,
S::Response: IntoResponse,
S::Future: Send,
{
match service.oneshot(request).await {
Ok(response) => response.into_response(),
Err(never) => match never {},
}
}
+9
View File
@@ -0,0 +1,9 @@
//! STUB — routes à porter (voir `server/routes/reels.ts`).
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
}
+9
View File
@@ -0,0 +1,9 @@
//! STUB — routes à porter (voir `server/routes/tiktok.ts`).
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
}