mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-14 18:58:30 +02:00
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:
40 files changed
+8647
-1
No files matched your search
+3
-1
@@ -32,4 +32,6 @@ npm-debug.log*
|
||||
*~
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
# Rust
|
||||
server/target
|
||||
Generated
+4312
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "socialflow"
|
||||
version = "2.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "socialflow"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
aes-gcm = "0.10"
|
||||
anyhow = "1.0.104"
|
||||
axum = { version = "0.8.9", features = ["multipart", "macros"] }
|
||||
axum-extra = { version = "0.12.6", features = ["cookie", "typed-header"] }
|
||||
base64 = "0.22"
|
||||
bcrypt = "0.19.3"
|
||||
bytes = "1.12.1"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
futures = "0.3.34"
|
||||
hex = "0.4.3"
|
||||
hmac = "0.12"
|
||||
image = "0.25.10"
|
||||
once_cell = "1.21.4"
|
||||
percent-encoding = "2.3.2"
|
||||
rand = "0.8"
|
||||
regex = "1.13.1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "stream", "rustls-tls-native-roots", "gzip", "http2"] }
|
||||
resvg = "0.48.1"
|
||||
scrypt = "0.11"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = { version = "1.0.154", features = ["preserve_order"] }
|
||||
sha2 = "0.10"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres", "chrono", "json", "uuid", "macros", "migrate"] }
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.2", features = ["full"] }
|
||||
tokio-util = { version = "0.7.20", features = ["io"] }
|
||||
tower = "0.5.3"
|
||||
tower-http = { version = "0.7.1", features = ["fs", "trace", "set-header", "cors", "limit"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
url = "2.5.8"
|
||||
uuid = { version = "1.28.0", features = ["v4", "serde"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 4
|
||||
strip = true
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Authentification par session.
|
||||
//!
|
||||
//! Compatible avec l'ancien couple express-session + connect-pg-simple :
|
||||
//! même cookie (`connect.sid`, signé HMAC-SHA256 avec `SESSION_SECRET`),
|
||||
//! même table `session`, même structure JSON (`passport.user`). Les sessions
|
||||
//! ouvertes avant la mise à jour restent donc valides.
|
||||
|
||||
use std::ops::Deref;
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::header::{COOKIE, SET_COOKIE};
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use base64::Engine;
|
||||
use chrono::Duration;
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
use serde_json::{json, Map, Value};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::db::{User, UserRole};
|
||||
use crate::error::AppError;
|
||||
use crate::public_url::is_https;
|
||||
use crate::state::AppState;
|
||||
use crate::time::Ts;
|
||||
|
||||
pub const COOKIE_NAME: &str = "connect.sid";
|
||||
/// Durée de vie d'une session : 7 jours.
|
||||
pub const SESSION_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const COOKIE_ENCODE: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'_')
|
||||
.remove(b'.')
|
||||
.remove(b'!')
|
||||
.remove(b'~')
|
||||
.remove(b'*')
|
||||
.remove(b'\'')
|
||||
.remove(b'(')
|
||||
.remove(b')');
|
||||
|
||||
/// Session chargée depuis la base ; `data` est l'objet JSON stocké (`sess`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub sid: String,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn user_id(&self) -> Option<&str> {
|
||||
self.data.get("passport")?.get("user")?.as_str()
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&Value> {
|
||||
self.data.get(key)
|
||||
}
|
||||
|
||||
pub fn get_str(&self, key: &str) -> Option<String> {
|
||||
self.data.get(key).and_then(Value::as_str).map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn set(&mut self, key: &str, value: Value) {
|
||||
if let Value::Object(map) = &mut self.data {
|
||||
map.insert(key.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &str) -> Option<Value> {
|
||||
match &mut self.data {
|
||||
Value::Object(map) => map.remove(key),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enregistre les données modifiées (et prolonge la session).
|
||||
pub async fn save(&self, state: &AppState) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE session SET sess = $2, expire = $3 WHERE sid = $1")
|
||||
.bind(&self.sid)
|
||||
.bind(&self.data)
|
||||
.bind(Ts::now().add(Duration::milliseconds(SESSION_TTL_MS)))
|
||||
.execute(&state.db.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn sign(secret: &str, value: &str) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepte toute clé");
|
||||
mac.update(value.as_bytes());
|
||||
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
format!("{value}.{}", sig.trim_end_matches('='))
|
||||
}
|
||||
|
||||
/// `s:<sid>.<signature>` → sid, si la signature est valide.
|
||||
fn unsign(secret: &str, signed: &str) -> Option<String> {
|
||||
let raw = signed.strip_prefix("s:")?;
|
||||
let (value, _) = raw.rsplit_once('.')?;
|
||||
let expected = sign(secret, value);
|
||||
// Comparaison à temps constant
|
||||
let a = expected.as_bytes();
|
||||
let b = raw.as_bytes();
|
||||
if a.len() != b.len() {
|
||||
return None;
|
||||
}
|
||||
let diff = a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y));
|
||||
(diff == 0).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn session_cookie_value(headers: &HeaderMap) -> Option<String> {
|
||||
for header in headers.get_all(COOKIE) {
|
||||
let Ok(text) = header.to_str() else { continue };
|
||||
for pair in text.split(';') {
|
||||
let pair = pair.trim();
|
||||
if let Some(value) = pair.strip_prefix(&format!("{COOKIE_NAME}=")) {
|
||||
return percent_encoding::percent_decode_str(value)
|
||||
.decode_utf8()
|
||||
.ok()
|
||||
.map(|v| v.into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Identifiant de session (comme `uid-safe(24)` : 32 caractères base64url).
|
||||
fn new_sid() -> String {
|
||||
let mut bytes = [0u8; 24];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn cookie_meta(secure: bool, expires: Ts) -> Value {
|
||||
json!({
|
||||
"originalMaxAge": SESSION_TTL_MS,
|
||||
"expires": expires.to_iso(),
|
||||
"secure": secure,
|
||||
"httpOnly": true,
|
||||
"path": "/",
|
||||
"sameSite": "lax",
|
||||
})
|
||||
}
|
||||
|
||||
/// En-tête `Set-Cookie` de la session.
|
||||
pub fn session_cookie(state: &AppState, headers: &HeaderMap, sid: &str, expires: Ts) -> HeaderValue {
|
||||
let signed = sign(&state.config.session_secret, sid);
|
||||
let encoded = percent_encoding::utf8_percent_encode(&format!("s:{signed}"), COOKIE_ENCODE).to_string();
|
||||
let mut cookie = format!(
|
||||
"{COOKIE_NAME}={encoded}; Path=/; Expires={}; HttpOnly; SameSite=Lax",
|
||||
expires.utc().format("%a, %d %b %Y %H:%M:%S GMT")
|
||||
);
|
||||
if is_https(state, headers) {
|
||||
cookie.push_str("; Secure");
|
||||
}
|
||||
HeaderValue::from_str(&cookie).expect("cookie ASCII")
|
||||
}
|
||||
|
||||
/// En-tête `Set-Cookie` qui efface la session côté navigateur.
|
||||
pub fn clear_session_cookie() -> HeaderValue {
|
||||
HeaderValue::from_static("connect.sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax")
|
||||
}
|
||||
|
||||
/// Charge la session de la requête (cookie valide et session non expirée).
|
||||
pub async fn load_session(state: &AppState, headers: &HeaderMap) -> Option<Session> {
|
||||
let cookie = session_cookie_value(headers)?;
|
||||
let sid = unsign(&state.config.session_secret, &cookie)?;
|
||||
let row: Option<(Value,)> = sqlx::query_as("SELECT sess FROM session WHERE sid = $1 AND expire > $2")
|
||||
.bind(&sid)
|
||||
.bind(Ts::now())
|
||||
.fetch_optional(&state.db.pool)
|
||||
.await
|
||||
.ok()?;
|
||||
row.map(|(data,)| Session { sid, data })
|
||||
}
|
||||
|
||||
/// Ouvre une nouvelle session pour l'utilisateur (l'ancienne est détruite,
|
||||
/// comme le faisait passport lors de la connexion).
|
||||
pub async fn start_session(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
previous: Option<&Session>,
|
||||
user_id: &str,
|
||||
) -> Result<(Session, HeaderValue), sqlx::Error> {
|
||||
if let Some(previous) = previous {
|
||||
destroy_session(state, &previous.sid).await?;
|
||||
}
|
||||
let sid = new_sid();
|
||||
let expires = Ts::now().add(Duration::milliseconds(SESSION_TTL_MS));
|
||||
let data = json!({
|
||||
"cookie": cookie_meta(is_https(state, headers), expires),
|
||||
"passport": { "user": user_id },
|
||||
});
|
||||
sqlx::query("INSERT INTO session (sid, sess, expire) VALUES ($1, $2, $3)")
|
||||
.bind(&sid)
|
||||
.bind(&data)
|
||||
.bind(expires)
|
||||
.execute(&state.db.pool)
|
||||
.await?;
|
||||
let cookie = session_cookie(state, headers, &sid, expires);
|
||||
Ok((Session { sid, data }, cookie))
|
||||
}
|
||||
|
||||
pub async fn destroy_session(state: &AppState, sid: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("DELETE FROM session WHERE sid = $1").bind(sid).execute(&state.db.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prolonge la session en base (équivalent du `touch` de connect-pg-simple).
|
||||
fn touch_session(state: &AppState, sid: String) {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let expire = Ts::now().add(Duration::milliseconds(SESSION_TTL_MS));
|
||||
let _ = sqlx::query("UPDATE session SET expire = $2 WHERE sid = $1")
|
||||
.bind(sid)
|
||||
.bind(expire)
|
||||
.execute(&state.db.pool)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Purge des sessions expirées (appelée périodiquement).
|
||||
pub async fn prune_sessions(state: &AppState) {
|
||||
let _ = sqlx::query("DELETE FROM session WHERE expire < $1")
|
||||
.bind(Ts::now())
|
||||
.execute(&state.db.pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Utilisateur connecté et sa session.
|
||||
pub async fn current_user(state: &AppState, headers: &HeaderMap) -> Option<(User, Session)> {
|
||||
let session = load_session(state, headers).await?;
|
||||
let user_id = session.user_id()?.to_string();
|
||||
let user = state.db.get_user(&user_id).await.ok()??;
|
||||
touch_session(state, session.sid.clone());
|
||||
Some((user, session))
|
||||
}
|
||||
|
||||
/// Extracteur : utilisateur authentifié, sinon `401 { error: "Non authentifié" }`.
|
||||
pub struct AuthUser {
|
||||
pub user: User,
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
impl Deref for AuthUser {
|
||||
type Target = User;
|
||||
|
||||
fn deref(&self) -> &User {
|
||||
&self.user
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
match current_user(state, &parts.headers).await {
|
||||
Some((user, session)) => Ok(AuthUser { user, session }),
|
||||
None => Err(AppError::unauthorized("Non authentifié")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracteur : administrateur, sinon 401 / 403.
|
||||
pub struct AdminUser {
|
||||
pub user: User,
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
impl Deref for AdminUser {
|
||||
type Target = User;
|
||||
|
||||
fn deref(&self) -> &User {
|
||||
&self.user
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for AdminUser {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
let AuthUser { user, session } = AuthUser::from_request_parts(parts, state).await?;
|
||||
if user.role != UserRole::Admin {
|
||||
return Err(AppError::forbidden("Accès refusé. Réservé aux administrateurs."));
|
||||
}
|
||||
Ok(AdminUser { user, session })
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracteur optionnel.
|
||||
pub struct MaybeUser(pub Option<(User, Session)>);
|
||||
|
||||
impl FromRequestParts<AppState> for MaybeUser {
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
Ok(MaybeUser(current_user(state, &parts.headers).await))
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse JSON accompagnée d'un `Set-Cookie`.
|
||||
pub fn with_cookie(body: Value, cookie: HeaderValue) -> Response {
|
||||
let mut response = axum::Json(body).into_response();
|
||||
response.headers_mut().append(SET_COOKIE, cookie);
|
||||
response
|
||||
}
|
||||
|
||||
pub fn user_json(user: &User) -> Value {
|
||||
let mut map = Map::new();
|
||||
map.insert("id".into(), Value::String(user.id.clone()));
|
||||
map.insert("username".into(), Value::String(user.username.clone()));
|
||||
map.insert("role".into(), Value::String(user.role.as_str().into()));
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
pub async fn hash_password(password: &str) -> anyhow::Result<String> {
|
||||
let password = password.to_string();
|
||||
Ok(tokio::task::spawn_blocking(move || bcrypt::hash(password, 10)).await??)
|
||||
}
|
||||
|
||||
pub async fn verify_password(password: &str, hash: &str) -> bool {
|
||||
let password = password.to_string();
|
||||
let hash = hash.to_string();
|
||||
tokio::task::spawn_blocking(move || bcrypt::verify(password, &hash).unwrap_or(false))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Crée l'utilisateur `admin` / `admin` au premier démarrage.
|
||||
pub async fn ensure_admin_user_exists(state: &AppState) -> anyhow::Result<()> {
|
||||
if state.db.get_user_by_username("admin").await?.is_some() {
|
||||
tracing::info!("✓ Utilisateur admin existe déjà");
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!("🔧 Création de l'utilisateur admin par défaut...");
|
||||
let hash = hash_password("admin").await?;
|
||||
state.db.create_user("admin", &hash, UserRole::Admin).await?;
|
||||
tracing::info!("✅ Utilisateur admin créé avec succès (admin / admin)");
|
||||
tracing::warn!("⚠️ IMPORTANT: Changez ce mot de passe immédiatement !");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signature_matches_cookie_signature_package() {
|
||||
// Valeur produite par `cookie-signature` (Node) : sign('abc', 'secret')
|
||||
assert_eq!(sign("secret", "abc"), "abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4");
|
||||
assert_eq!(unsign("secret", "s:abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4").as_deref(), Some("abc"));
|
||||
assert!(unsign("secret", "s:abc.invalid").is_none());
|
||||
assert!(unsign("other", "s:abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Configuration lue dans l'environnement (mêmes variables que l'ancien serveur).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::bail;
|
||||
use rand::RngCore;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub port: u16,
|
||||
pub database_url: String,
|
||||
pub session_secret: String,
|
||||
pub production: bool,
|
||||
/// URL publique (ex. https://socialflow.exemple.fr), sans « / » final.
|
||||
pub app_url: Option<String>,
|
||||
/// URL interne Docker par laquelle les autres services joignent l'app.
|
||||
pub internal_app_url: Option<String>,
|
||||
/// Frontend compilé (Vite) : `dist/public`.
|
||||
pub static_dir: PathBuf,
|
||||
/// Fichiers servis à la racine du domaine (vérifications de domaine).
|
||||
pub public_root_dir: PathBuf,
|
||||
/// Racine des fichiers servis sous /uploads.
|
||||
pub uploads_dir: PathBuf,
|
||||
pub audio_upload_dir: PathBuf,
|
||||
pub temp_upload_dir: PathBuf,
|
||||
/// Polices (DejaVuSans.ttf) pour le texte incrusté dans les images.
|
||||
pub fonts_dir: PathBuf,
|
||||
pub gemini_api_key: Option<String>,
|
||||
pub external_api_key: Option<String>,
|
||||
/// « remotion » (défaut) ou « ffmpeg ».
|
||||
pub reel_renderer: String,
|
||||
/// Service de rendu Remotion (conteneur Node dédié).
|
||||
pub remotion_url: Option<String>,
|
||||
pub ffmpeg_api_url: Option<String>,
|
||||
pub ffmpeg_api_key: Option<String>,
|
||||
}
|
||||
|
||||
fn env(name: &str) -> Option<String> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
let production = matches!(
|
||||
env("NODE_ENV").or_else(|| env("APP_ENV")).as_deref(),
|
||||
Some("production")
|
||||
);
|
||||
|
||||
let session_secret = match env("SESSION_SECRET") {
|
||||
Some(secret) => secret,
|
||||
None if production => {
|
||||
bail!("SESSION_SECRET non défini en production. Arrêt du serveur.")
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"⚠️ SESSION_SECRET non défini. Utilisation d'une clé aléatoire pour le développement."
|
||||
);
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
};
|
||||
|
||||
let database_url = match env("DATABASE_URL") {
|
||||
Some(url) => url,
|
||||
None => bail!("DATABASE_URL must be set. Did you forget to provision a database?"),
|
||||
};
|
||||
|
||||
let uploads_dir = cwd.join("uploads");
|
||||
let audio_upload_dir = env("AUDIO_UPLOAD_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| uploads_dir.join("audio"));
|
||||
let temp_upload_dir = env("TEMP_UPLOAD_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| uploads_dir.join("temp"));
|
||||
|
||||
let static_dir = env("STATIC_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| cwd.join("dist").join("public"));
|
||||
|
||||
let fonts_dir = env("FONTS_DIR").map(PathBuf::from).unwrap_or_else(|| {
|
||||
let local = cwd.join("server").join("fonts");
|
||||
if local.exists() {
|
||||
local
|
||||
} else {
|
||||
cwd.join("fonts")
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
port: env("PORT").and_then(|p| p.parse().ok()).unwrap_or(5000),
|
||||
database_url,
|
||||
session_secret,
|
||||
production,
|
||||
app_url: env("APP_URL").map(|u| u.trim_end_matches('/').to_string()),
|
||||
internal_app_url: env("INTERNAL_APP_URL").map(|u| u.trim_end_matches('/').to_string()),
|
||||
static_dir,
|
||||
public_root_dir: env("PUBLIC_ROOT_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| cwd.join("public-root")),
|
||||
uploads_dir,
|
||||
audio_upload_dir,
|
||||
temp_upload_dir,
|
||||
fonts_dir,
|
||||
gemini_api_key: env("GEMINI_API_KEY"),
|
||||
external_api_key: env("EXTERNAL_API_KEY"),
|
||||
reel_renderer: if env("REEL_RENDERER").as_deref() == Some("ffmpeg") {
|
||||
"ffmpeg".into()
|
||||
} else {
|
||||
"remotion".into()
|
||||
},
|
||||
remotion_url: env("REMOTION_RENDERER_URL").map(|u| u.trim_end_matches('/').to_string()),
|
||||
ffmpeg_api_url: env("FFMPEG_API_URL"),
|
||||
ffmpeg_api_key: env("FFMPEG_API_KEY"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Lecture directe d'une variable optionnelle (services peu configurés).
|
||||
pub fn var(name: &str) -> Option<String> {
|
||||
env(name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Chiffrement des jetons au repos (AES-256-GCM), compatible avec les valeurs
|
||||
//! déjà écrites par l'ancien serveur : clé dérivée par scrypt du secret
|
||||
//! (`ENCRYPTION_KEY`, sinon fichier persistant), IV de 16 octets, format
|
||||
//! `iv:authTag:chiffré` en hexadécimal.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use aes_gcm::aead::consts::U16;
|
||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||
use aes_gcm::aes::Aes256;
|
||||
use aes_gcm::{AesGcm, Nonce};
|
||||
use once_cell::sync::OnceCell;
|
||||
use rand::RngCore;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
type Cipher = AesGcm<Aes256, U16>;
|
||||
|
||||
static KEY: OnceCell<[u8; 32]> = OnceCell::new();
|
||||
|
||||
const SALT: &[u8] = b"socialflow-salt";
|
||||
const DOCKER_KEY_DIR: &str = "/app/.encryption-key-data";
|
||||
|
||||
fn derive(secret: &str) -> [u8; 32] {
|
||||
// Paramètres par défaut de crypto.scryptSync : N=16384, r=8, p=1
|
||||
let params = scrypt::Params::new(14, 8, 1, 32).expect("paramètres scrypt valides");
|
||||
let mut out = [0u8; 32];
|
||||
scrypt::scrypt(secret.as_bytes(), SALT, ¶ms, &mut out).expect("dérivation scrypt");
|
||||
out
|
||||
}
|
||||
|
||||
fn key_file_path() -> PathBuf {
|
||||
let dir = if Path::new(DOCKER_KEY_DIR).exists() {
|
||||
PathBuf::from(DOCKER_KEY_DIR)
|
||||
} else {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
};
|
||||
dir.join(".encryption-key")
|
||||
}
|
||||
|
||||
/// Charge (ou crée) la clé de chiffrement. Appelé une fois au démarrage.
|
||||
pub fn init(_config: &Config) {
|
||||
KEY.get_or_init(load_key);
|
||||
}
|
||||
|
||||
fn load_key() -> [u8; 32] {
|
||||
if let Some(secret) = Config::var("ENCRYPTION_KEY") {
|
||||
return derive(&secret);
|
||||
}
|
||||
|
||||
let path = key_file_path();
|
||||
if let Ok(saved) = std::fs::read_to_string(&path) {
|
||||
let saved = saved.trim();
|
||||
if !saved.is_empty() {
|
||||
tracing::info!("🔐 Clé de chiffrement chargée depuis le fichier persistant");
|
||||
return derive(saved);
|
||||
}
|
||||
}
|
||||
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
let secret = hex::encode(bytes);
|
||||
match write_private(&path, &secret) {
|
||||
Ok(()) => tracing::info!("🔐 Nouvelle clé de chiffrement générée et sauvegardée automatiquement"),
|
||||
Err(_) => tracing::warn!(
|
||||
"⚠️ Impossible de sauvegarder la clé de chiffrement dans un fichier. Elle sera régénérée au prochain redémarrage."
|
||||
),
|
||||
}
|
||||
derive(&secret)
|
||||
}
|
||||
|
||||
fn write_private(path: &Path, content: &str) -> std::io::Result<()> {
|
||||
std::fs::write(path, content)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn key() -> &'static [u8; 32] {
|
||||
KEY.get_or_init(load_key)
|
||||
}
|
||||
|
||||
pub fn encrypt(text: &str) -> String {
|
||||
let cipher = Cipher::new_from_slice(key()).expect("clé de 32 octets");
|
||||
let mut iv = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut iv);
|
||||
let sealed = cipher
|
||||
.encrypt(Nonce::<U16>::from_slice(&iv), Payload { msg: text.as_bytes(), aad: &[] })
|
||||
.expect("chiffrement AES-GCM");
|
||||
// `sealed` = chiffré || tag (16 octets)
|
||||
let (ciphertext, tag) = sealed.split_at(sealed.len() - 16);
|
||||
format!("{}:{}:{}", hex::encode(iv), hex::encode(tag), hex::encode(ciphertext))
|
||||
}
|
||||
|
||||
/// Déchiffre ; une valeur qui n'a pas le format attendu (jeton enregistré en
|
||||
/// clair avant le chiffrement) est rendue telle quelle.
|
||||
pub fn decrypt(value: &str) -> String {
|
||||
if !value.contains(':') {
|
||||
return value.to_string();
|
||||
}
|
||||
let parts: Vec<&str> = value.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return value.to_string();
|
||||
}
|
||||
let attempt = || -> Option<String> {
|
||||
let iv = hex::decode(parts[0]).ok()?;
|
||||
let tag = hex::decode(parts[1]).ok()?;
|
||||
let mut data = hex::decode(parts[2]).ok()?;
|
||||
if iv.len() != 16 || tag.len() != 16 {
|
||||
return None;
|
||||
}
|
||||
data.extend_from_slice(&tag);
|
||||
let cipher = Cipher::new_from_slice(key()).ok()?;
|
||||
let plain = cipher
|
||||
.decrypt(Nonce::<U16>::from_slice(&iv), Payload { msg: &data, aad: &[] })
|
||||
.ok()?;
|
||||
String::from_utf8(plain).ok()
|
||||
};
|
||||
match attempt() {
|
||||
Some(plain) => plain,
|
||||
None => {
|
||||
tracing::warn!("⚠️ Échec du déchiffrement, token probablement non chiffré");
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_encrypted(text: &str) -> bool {
|
||||
let parts: Vec<&str> = text.split(':').collect();
|
||||
parts.len() == 3
|
||||
&& parts
|
||||
.iter()
|
||||
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_hexdigit()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let _ = KEY.set(derive("test-secret"));
|
||||
let sealed = encrypt("EAAB-token");
|
||||
assert!(is_encrypted(&sealed));
|
||||
assert_eq!(decrypt(&sealed), "EAAB-token");
|
||||
assert_eq!(decrypt("plain-token"), "plain-token");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,371 @@
|
||||
//! Lignes de la base, sérialisées en camelCase comme les objets Drizzle que
|
||||
//! renvoyait l'ancien serveur.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::FromRow;
|
||||
|
||||
use crate::time::Ts;
|
||||
|
||||
macro_rules! pg_enum {
|
||||
($name:ident, $pg:literal, { $($variant:ident => $text:literal),+ $(,)? }) => {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = $pg)]
|
||||
pub enum $name {
|
||||
$(
|
||||
#[serde(rename = $text)]
|
||||
#[sqlx(rename = $text)]
|
||||
$variant,
|
||||
)+
|
||||
}
|
||||
|
||||
impl $name {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
$( $name::$variant => $text, )+
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
$( $text => Some($name::$variant), )+
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Nom du type énuméré PostgreSQL (pour les conversions `$1::type`).
|
||||
#[allow(dead_code)]
|
||||
pub const PG_TYPE: &'static str = $pg;
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pg_enum!(Platform, "platform", { Facebook => "facebook", Instagram => "instagram", Tiktok => "tiktok" });
|
||||
pg_enum!(PostType, "post_type", { Feed => "feed", Story => "story", Both => "both", Reel => "reel" });
|
||||
pg_enum!(PostStatus, "post_status", { Draft => "draft", Scheduled => "scheduled", Published => "published", Failed => "failed" });
|
||||
pg_enum!(MediaType, "media_type", { Image => "image", Video => "video" });
|
||||
pg_enum!(UserRole, "user_role", { Admin => "admin", User => "user" });
|
||||
pg_enum!(TokenStatus, "token_status", { Valid => "valid", Expiring => "expiring", Expired => "expired", Error => "error" });
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password: String,
|
||||
pub role: UserRole,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.role == UserRole::Admin
|
||||
}
|
||||
}
|
||||
|
||||
/// Page ou compte connecté. Les jetons sont **déchiffrés** par la couche
|
||||
/// d'accès aux données ; ils ne sont jamais sérialisés vers le navigateur.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SocialPage {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub platform: Platform,
|
||||
pub page_id: String,
|
||||
pub page_name: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub access_token: String,
|
||||
pub token_expires_at: Option<Ts>,
|
||||
pub followers_count: Option<i32>,
|
||||
pub is_active: String,
|
||||
pub token_status: Option<TokenStatus>,
|
||||
pub last_token_check: Option<Ts>,
|
||||
#[serde(skip_serializing)]
|
||||
pub user_access_token: Option<String>,
|
||||
pub user_token_expires_at: Option<Ts>,
|
||||
pub token_error: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
pub refresh_token: Option<String>,
|
||||
pub refresh_token_expires_at: Option<Ts>,
|
||||
pub scopes: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub created_at: Option<Ts>,
|
||||
}
|
||||
|
||||
impl SocialPage {
|
||||
/// Vue client : sans jetons, avec l'indicateur de renouvellement automatique.
|
||||
pub fn to_client(&self) -> Value {
|
||||
let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
|
||||
let auto_renew = match self.platform {
|
||||
Platform::Tiktok => self.refresh_token.as_deref().is_some_and(|t| !t.is_empty()),
|
||||
_ => self.user_access_token.as_deref().is_some_and(|t| !t.is_empty()),
|
||||
};
|
||||
if let Value::Object(map) = &mut value {
|
||||
map.insert("autoRenew".into(), Value::Bool(auto_renew));
|
||||
}
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Media {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
#[serde(rename = "type")]
|
||||
#[sqlx(rename = "type")]
|
||||
pub media_type: MediaType,
|
||||
pub cloudinary_public_id: Option<String>,
|
||||
pub original_url: String,
|
||||
pub facebook_landscape_url: Option<String>,
|
||||
pub facebook_feed_url: Option<String>,
|
||||
pub instagram_feed_url: Option<String>,
|
||||
pub instagram_story_url: Option<String>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub file_name: String,
|
||||
pub file_size: i32,
|
||||
pub created_at: Option<Ts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Post {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub content: String,
|
||||
pub ai_generated: String,
|
||||
pub product_info: Option<Value>,
|
||||
pub status: PostStatus,
|
||||
pub scheduled_for: Option<Ts>,
|
||||
pub generation_status: Option<String>,
|
||||
pub generation_progress: Option<i32>,
|
||||
pub generation_error: Option<String>,
|
||||
pub created_at: Option<Ts>,
|
||||
pub updated_at: Option<Ts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PostMedia {
|
||||
pub id: String,
|
||||
pub post_id: String,
|
||||
pub media_id: String,
|
||||
pub display_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScheduledPost {
|
||||
pub id: String,
|
||||
pub post_id: String,
|
||||
pub page_id: String,
|
||||
pub post_type: PostType,
|
||||
pub scheduled_at: Ts,
|
||||
pub published_at: Option<Ts>,
|
||||
pub external_post_id: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub publish_id: Option<String>,
|
||||
pub publish_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiGeneration {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub product_info: Value,
|
||||
pub generated_texts: Value,
|
||||
pub selected_text: Option<String>,
|
||||
pub created_at: Option<Ts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloudinaryConfig {
|
||||
pub id: String,
|
||||
pub cloud_name: String,
|
||||
pub api_key: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub api_secret: String,
|
||||
pub logo_public_id: Option<String>,
|
||||
pub endpoint_url: Option<String>,
|
||||
pub public_url: Option<String>,
|
||||
pub created_at: Option<Ts>,
|
||||
pub updated_at: Option<Ts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenrouterConfig {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
pub system_prompt: String,
|
||||
pub created_at: Option<Ts>,
|
||||
pub updated_at: Option<Ts>,
|
||||
}
|
||||
|
||||
/// Application développeur Facebook (secret déchiffré).
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FacebookConfig {
|
||||
pub id: String,
|
||||
pub app_id: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub app_secret: String,
|
||||
pub created_at: Option<Ts>,
|
||||
pub updated_at: Option<Ts>,
|
||||
}
|
||||
|
||||
/// Application développeur TikTok (secret déchiffré).
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiktokConfig {
|
||||
pub id: String,
|
||||
pub client_key: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub client_secret: String,
|
||||
pub created_at: Option<Ts>,
|
||||
pub updated_at: Option<Ts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MusicFavorite {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub track_id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album_name: Option<String>,
|
||||
pub duration: i32,
|
||||
pub preview_url: String,
|
||||
pub download_url: String,
|
||||
pub image_url: Option<String>,
|
||||
pub license: Option<String>,
|
||||
pub created_at: Option<Ts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppConfig {
|
||||
pub id: String,
|
||||
pub external_api_key: Option<String>,
|
||||
pub gemini_api_key: Option<String>,
|
||||
pub qwen_tts_url: Option<String>,
|
||||
pub qwen_tts_api_key: Option<String>,
|
||||
pub created_at: Option<Ts>,
|
||||
pub updated_at: Option<Ts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AudioTrack {
|
||||
pub id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub title: String,
|
||||
pub file_name: String,
|
||||
pub url: String,
|
||||
pub duration: Option<i32>,
|
||||
pub created_at: Option<Ts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserPagePermission {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub page_id: String,
|
||||
pub created_at: Option<Ts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReelJob {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub post_id: Option<String>,
|
||||
pub kind: String,
|
||||
pub params: Value,
|
||||
pub status: String,
|
||||
pub step: Option<String>,
|
||||
pub progress: i32,
|
||||
pub attempts: i32,
|
||||
pub result: Option<Value>,
|
||||
pub error: Option<String>,
|
||||
pub heartbeat_at: Option<Ts>,
|
||||
pub created_at: Ts,
|
||||
pub updated_at: Ts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PostAnalytics {
|
||||
pub id: String,
|
||||
pub post_id: String,
|
||||
pub fetched_at: Option<Ts>,
|
||||
pub impressions: Option<i32>,
|
||||
pub reach: Option<i32>,
|
||||
pub engagement: Option<i32>,
|
||||
pub reactions: Option<i32>,
|
||||
pub comments: Option<i32>,
|
||||
pub shares: Option<i32>,
|
||||
pub clicks: Option<i32>,
|
||||
pub raw_data: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageAnalyticsHistory {
|
||||
pub id: String,
|
||||
pub page_id: String,
|
||||
pub date: Ts,
|
||||
pub followers_count: Option<i32>,
|
||||
pub page_views: Option<i32>,
|
||||
pub page_reach: Option<i32>,
|
||||
pub page_engagement: Option<i32>,
|
||||
pub created_at: Option<Ts>,
|
||||
}
|
||||
|
||||
/// Publication planifiée accompagnée du post et de la page (sans jetons),
|
||||
/// telle que la renvoyaient les routes du calendrier et de l'historique.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScheduledPostWithDetails {
|
||||
#[serde(flatten)]
|
||||
pub scheduled: ScheduledPost,
|
||||
pub post: ScheduledPostPost,
|
||||
pub page: Option<ScheduledPostPage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScheduledPostPost {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub content: String,
|
||||
pub status: PostStatus,
|
||||
pub scheduled_for: Option<Ts>,
|
||||
pub ai_generated: String,
|
||||
pub generation_status: Option<String>,
|
||||
pub generation_progress: Option<i32>,
|
||||
pub created_at: Option<Ts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScheduledPostPage {
|
||||
pub id: String,
|
||||
pub page_id: String,
|
||||
pub page_name: String,
|
||||
pub platform: Platform,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Erreurs HTTP au format attendu par le client : `{ "error": "message" }`.
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppError {
|
||||
pub status: StatusCode,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
pub type ApiResult<T = Response> = Result<T, AppError>;
|
||||
|
||||
impl AppError {
|
||||
/// Réponse `{ error: message }` avec le statut donné.
|
||||
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
|
||||
Self { status, body: json!({ "error": message.into() }) }
|
||||
}
|
||||
|
||||
/// Corps JSON libre (ex. `{ success: false, error }`).
|
||||
pub fn json(status: StatusCode, body: Value) -> Self {
|
||||
Self { status, body }
|
||||
}
|
||||
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, message)
|
||||
}
|
||||
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::UNAUTHORIZED, message)
|
||||
}
|
||||
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::FORBIDDEN, message)
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::NOT_FOUND, message)
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::CONFLICT, message)
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
|
||||
}
|
||||
|
||||
/// 500 avec un message fixe ; la cause est journalisée.
|
||||
pub fn internal_logged(message: impl Into<String>, cause: impl std::fmt::Display) -> Self {
|
||||
let message = message.into();
|
||||
tracing::error!("{message}: {cause}");
|
||||
Self::internal(message)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, Json(self.body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for AppError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
AppError::internal_logged("Erreur de base de données", error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(error: anyhow::Error) -> Self {
|
||||
tracing::error!("{error:#}");
|
||||
AppError::internal(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Raccourcis pour convertir une erreur en réponse 500 au message donné,
|
||||
/// comme les `catch` des anciennes routes (`res.status(500).json({ error })`).
|
||||
pub trait OrApiError<T> {
|
||||
/// Journalise la cause et renvoie `500 { error: message }`.
|
||||
fn or_500(self, message: &str) -> Result<T, AppError>;
|
||||
/// Renvoie `500 { error: <message de la cause> }`.
|
||||
fn or_500_cause(self) -> Result<T, AppError>;
|
||||
}
|
||||
|
||||
impl<T, E: std::fmt::Display> OrApiError<T> for Result<T, E> {
|
||||
fn or_500(self, message: &str) -> Result<T, AppError> {
|
||||
self.map_err(|e| AppError::internal_logged(message, e))
|
||||
}
|
||||
|
||||
fn or_500_cause(self) -> Result<T, AppError> {
|
||||
self.map_err(|e| {
|
||||
let message = e.to_string();
|
||||
tracing::error!("{message}");
|
||||
AppError::internal(message)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Extracteurs de requête.
|
||||
//!
|
||||
//! `Body<T>` reproduit `express.json()` + `express.urlencoded()` : un corps
|
||||
//! JSON est lu tel quel, un formulaire est converti en objet, et une requête
|
||||
//! sans corps donne `{}`. Les erreurs sortent au format `{ error }`.
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{FromRequest, FromRequestParts, Request};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::request::Parts;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Taille maximale d'un corps JSON ou formulaire.
|
||||
pub const MAX_JSON_BODY: usize = 25 * 1024 * 1024;
|
||||
|
||||
pub struct Body<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequest<S> for Body<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let content_type = req
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
|
||||
let bytes = Bytes::from_request(req, state)
|
||||
.await
|
||||
.map_err(|e| AppError::new(e.status(), e.body_text()))?;
|
||||
|
||||
let value = if bytes.iter().all(|b| b.is_ascii_whitespace()) {
|
||||
Value::Object(Map::new())
|
||||
} else if content_type.contains("json") {
|
||||
serde_json::from_slice::<Value>(&bytes)
|
||||
.map_err(|e| AppError::bad_request(format!("JSON invalide : {e}")))?
|
||||
} else if content_type.starts_with("application/x-www-form-urlencoded") {
|
||||
let pairs: Vec<(String, String)> = serde_urlencoded_pairs(&bytes);
|
||||
let mut map = Map::new();
|
||||
for (k, v) in pairs {
|
||||
map.insert(k, Value::String(v));
|
||||
}
|
||||
Value::Object(map)
|
||||
} else {
|
||||
Value::Object(Map::new())
|
||||
};
|
||||
|
||||
let parsed = serde_json::from_value::<T>(value)
|
||||
.map_err(|e| AppError::bad_request(format!("Données invalides : {e}")))?;
|
||||
Ok(Body(parsed))
|
||||
}
|
||||
}
|
||||
|
||||
fn serde_urlencoded_pairs(bytes: &[u8]) -> Vec<(String, String)> {
|
||||
url::form_urlencoded::parse(bytes)
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Paramètres de requête bruts (`?a=1&b=2`), comme `req.query` (valeurs texte).
|
||||
pub struct QueryMap(pub std::collections::HashMap<String, String>);
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for QueryMap {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
if let Some(q) = parts.uri.query() {
|
||||
for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
|
||||
// Premier gagnant, comme une valeur simple d'Express
|
||||
map.entry(k.into_owned()).or_insert_with(|| v.into_owned());
|
||||
}
|
||||
}
|
||||
Ok(QueryMap(map))
|
||||
}
|
||||
}
|
||||
|
||||
impl QueryMap {
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.0.get(key).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Valeur non vide.
|
||||
pub fn non_empty(&self, key: &str) -> Option<&str> {
|
||||
self.get(key).filter(|v| !v.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Petits accesseurs pour lire un corps JSON dynamique comme en JavaScript.
|
||||
pub trait JsonExt {
|
||||
/// Chaîne (non vide ou non) si la valeur est une chaîne.
|
||||
fn str_field(&self, key: &str) -> Option<&str>;
|
||||
/// Chaîne non vide après `trim()`.
|
||||
fn trimmed(&self, key: &str) -> Option<String>;
|
||||
/// Valeur « truthy » au sens JavaScript.
|
||||
fn truthy(&self, key: &str) -> bool;
|
||||
}
|
||||
|
||||
impl JsonExt for Value {
|
||||
fn str_field(&self, key: &str) -> Option<&str> {
|
||||
self.get(key).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
fn trimmed(&self, key: &str) -> Option<String> {
|
||||
self.str_field(key)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn truthy(&self, key: &str) -> bool {
|
||||
is_truthy(self.get(key))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_truthy(value: Option<&Value>) -> bool {
|
||||
match value {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false),
|
||||
Some(Value::String(s)) => !s.is_empty(),
|
||||
Some(_) => true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Intergiciels HTTP : en-têtes de sécurité (équivalent de helmet), limitation
|
||||
//! de débit par IP et journal des appels `/api`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::extract::{ConnectInfo, Request, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Fenêtre fixe par IP, comme express-rate-limit.
|
||||
pub struct RateLimiter {
|
||||
max: u32,
|
||||
window: Duration,
|
||||
hits: Mutex<HashMap<IpAddr, (Instant, u32)>>,
|
||||
}
|
||||
|
||||
pub struct RateDecision {
|
||||
pub allowed: bool,
|
||||
pub limit: u32,
|
||||
pub remaining: u32,
|
||||
pub reset_secs: u64,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(max: u32, window: Duration) -> Self {
|
||||
Self { max, window, hits: Mutex::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
pub fn check(&self, ip: IpAddr) -> RateDecision {
|
||||
let now = Instant::now();
|
||||
let mut hits = self.hits.lock().expect("verrou du limiteur");
|
||||
if hits.len() > 10_000 {
|
||||
let window = self.window;
|
||||
hits.retain(|_, (start, _)| now.duration_since(*start) < window);
|
||||
}
|
||||
let entry = hits.entry(ip).or_insert((now, 0));
|
||||
if now.duration_since(entry.0) >= self.window {
|
||||
*entry = (now, 0);
|
||||
}
|
||||
entry.1 += 1;
|
||||
let reset = self.window.saturating_sub(now.duration_since(entry.0));
|
||||
RateDecision {
|
||||
allowed: entry.1 <= self.max,
|
||||
limit: self.max,
|
||||
remaining: self.max.saturating_sub(entry.1),
|
||||
reset_secs: reset.as_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IP du client : dernier saut de `X-Forwarded-For` (posé par Nginx, `trust proxy 1`),
|
||||
/// sinon l'adresse de la connexion.
|
||||
pub fn client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> IpAddr {
|
||||
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
|
||||
if let Some(ip) = xff.split(',').map(str::trim).filter(|s| !s.is_empty()).last() {
|
||||
if let Ok(ip) = ip.parse() {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
peer.map(|p| p.ip()).unwrap_or(IpAddr::from([127, 0, 0, 1]))
|
||||
}
|
||||
|
||||
pub async fn rate_limit(State(state): State<AppState>, request: Request, next: Next) -> Response {
|
||||
let path = request.uri().path();
|
||||
if !path.starts_with("/api/") {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let peer = request.extensions().get::<ConnectInfo<SocketAddr>>().map(|c| c.0);
|
||||
let ip = client_ip(request.headers(), peer);
|
||||
|
||||
let decision = state.global_limiter.check(ip);
|
||||
if !decision.allowed {
|
||||
return too_many(&decision, "Trop de requêtes, réessayez plus tard");
|
||||
}
|
||||
if path == "/api/auth/login" {
|
||||
let auth = state.auth_limiter.check(ip);
|
||||
if !auth.allowed {
|
||||
return too_many(&auth, "Trop de tentatives de connexion, réessayez dans 15 minutes");
|
||||
}
|
||||
}
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
let headers = response.headers_mut();
|
||||
headers.insert("ratelimit-limit", HeaderValue::from(decision.limit));
|
||||
headers.insert("ratelimit-remaining", HeaderValue::from(decision.remaining));
|
||||
headers.insert("ratelimit-reset", HeaderValue::from(decision.reset_secs));
|
||||
response
|
||||
}
|
||||
|
||||
fn too_many(decision: &RateDecision, message: &str) -> Response {
|
||||
let mut response = (StatusCode::TOO_MANY_REQUESTS, axum::Json(json!({ "error": message }))).into_response();
|
||||
let headers = response.headers_mut();
|
||||
headers.insert("ratelimit-limit", HeaderValue::from(decision.limit));
|
||||
headers.insert("ratelimit-remaining", HeaderValue::from(0u32));
|
||||
headers.insert("ratelimit-reset", HeaderValue::from(decision.reset_secs));
|
||||
headers.insert("retry-after", HeaderValue::from(decision.reset_secs));
|
||||
response
|
||||
}
|
||||
|
||||
const CSP: &str = "default-src 'self';\
|
||||
base-uri 'self';\
|
||||
font-src 'self' https://fonts.gstatic.com;\
|
||||
form-action 'self';\
|
||||
frame-ancestors 'self';\
|
||||
img-src 'self' data: https: blob:;\
|
||||
object-src 'none';\
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval';\
|
||||
script-src-attr 'none';\
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;\
|
||||
connect-src 'self' https://graph.facebook.com https://openrouter.ai https://res.cloudinary.com wss: ws:;\
|
||||
media-src 'self' https: data:;\
|
||||
upgrade-insecure-requests";
|
||||
|
||||
/// En-têtes posés par helmet dans l'ancien serveur.
|
||||
pub async fn security_headers(request: Request, next: Next) -> Response {
|
||||
let is_upload = request.uri().path().starts_with("/uploads/");
|
||||
let mut response = next.run(request).await;
|
||||
let headers = response.headers_mut();
|
||||
let mut set = |name: &'static str, value: &'static str| {
|
||||
headers
|
||||
.entry(HeaderName::from_static(name))
|
||||
.or_insert(HeaderValue::from_static(value));
|
||||
};
|
||||
set("content-security-policy", CSP);
|
||||
set("cross-origin-opener-policy", "same-origin");
|
||||
// Les fichiers téléversés sont lus par d'autres origines (rendu des Reels)
|
||||
set("cross-origin-resource-policy", if is_upload { "cross-origin" } else { "same-origin" });
|
||||
set("origin-agent-cluster", "?1");
|
||||
set("referrer-policy", "no-referrer");
|
||||
set("strict-transport-security", "max-age=31536000; includeSubDomains");
|
||||
set("x-content-type-options", "nosniff");
|
||||
set("x-dns-prefetch-control", "off");
|
||||
set("x-download-options", "noopen");
|
||||
set("x-frame-options", "SAMEORIGIN");
|
||||
set("x-permitted-cross-domain-policies", "none");
|
||||
set("x-xss-protection", "0");
|
||||
response
|
||||
}
|
||||
|
||||
/// Journal des appels `/api` (méthode, chemin, statut, durée).
|
||||
pub async fn log_requests(request: Request, next: Next) -> Response {
|
||||
let path = request.uri().path().to_string();
|
||||
if !path.starts_with("/api") {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let method = request.method().clone();
|
||||
let started = Instant::now();
|
||||
let response = next.run(request).await;
|
||||
let status = response.status();
|
||||
// Les 401 de /api/auth/session sont la vérification normale de session
|
||||
if !(path == "/api/auth/session" && status == StatusCode::UNAUTHORIZED) {
|
||||
tracing::info!("{method} {path} {} in {}ms", status.as_u16(), started.elapsed().as_millis());
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#![allow(dead_code)]
|
||||
//! SocialFlow — serveur HTTP en Rust.
|
||||
//!
|
||||
//! Remplace l'ancien serveur Node/Express : mêmes routes `/api`, même base
|
||||
//! PostgreSQL (le schéma existant est repris tel quel puis complété au
|
||||
//! démarrage), mêmes sessions (cookie `connect.sid` compatible) et même
|
||||
//! chiffrement des jetons. Le frontend React compilé est servi depuis
|
||||
//! `dist/public`.
|
||||
|
||||
mod auth;
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod db;
|
||||
mod error;
|
||||
mod extract;
|
||||
mod http;
|
||||
mod migrate;
|
||||
mod public_url;
|
||||
mod routes;
|
||||
mod services;
|
||||
mod state;
|
||||
mod time;
|
||||
mod upload;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Context;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,tower_http=warn,hyper=warn")),
|
||||
)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
crypto::init(&config);
|
||||
|
||||
let pool = db::connect(&config.database_url)
|
||||
.await
|
||||
.context("connexion à PostgreSQL impossible")?;
|
||||
|
||||
migrate::run(&pool).await;
|
||||
|
||||
let state = AppState::new(config, pool)?;
|
||||
|
||||
auth::ensure_admin_user_exists(&state).await?;
|
||||
services::audio::fix_audio_track_encoding(&state).await;
|
||||
|
||||
let app = routes::router(state.clone());
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.with_context(|| format!("impossible d'écouter sur {addr}"))?;
|
||||
tracing::info!("serving on port {}", state.config.port);
|
||||
|
||||
// Tâches de fond : publications programmées, crons (jetons, statistiques,
|
||||
// purge, suivi TikTok) et file des rendus de Reels. La file démarre une
|
||||
// fois le serveur à l'écoute : les rendus lisent des fichiers servis ici.
|
||||
services::scheduler::start(state.clone());
|
||||
services::cron::start(state.clone());
|
||||
services::reels::start(state.clone());
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
if let Ok(mut sig) =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
{
|
||||
sig.recv().await;
|
||||
}
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
tracing::info!("arrêt demandé, fermeture du serveur");
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Migration du schéma au démarrage, idempotente.
|
||||
//!
|
||||
//! Sur une base existante (créée par l'ancien serveur Node/Drizzle), toutes
|
||||
//! les instructions sont sans effet ou complètent ce qui manque ; sur une base
|
||||
//! vide, elles créent l'intégralité du schéma. Chaque instruction est exécutée
|
||||
//! isolément : un échec est journalisé sans empêcher les suivantes, ni le
|
||||
//! démarrage de l'application.
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
const ENUMS: &[&str] = &[
|
||||
r#"DO $$ BEGIN CREATE TYPE "platform" AS ENUM('facebook', 'instagram', 'tiktok'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#,
|
||||
r#"DO $$ BEGIN CREATE TYPE "post_type" AS ENUM('feed', 'story', 'both', 'reel'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#,
|
||||
r#"DO $$ BEGIN CREATE TYPE "post_status" AS ENUM('draft', 'scheduled', 'published', 'failed'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#,
|
||||
r#"DO $$ BEGIN CREATE TYPE "media_type" AS ENUM('image', 'video'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#,
|
||||
r#"DO $$ BEGIN CREATE TYPE "user_role" AS ENUM('admin', 'user'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#,
|
||||
r#"DO $$ BEGIN CREATE TYPE "token_status" AS ENUM('valid', 'expiring', 'expired', 'error'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#,
|
||||
r#"ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';"#,
|
||||
r#"ALTER TYPE "platform" ADD VALUE IF NOT EXISTS 'tiktok';"#,
|
||||
];
|
||||
|
||||
const TABLES: &[&str] = &[
|
||||
r#"CREATE TABLE IF NOT EXISTS "users" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"password" text NOT NULL,
|
||||
"role" "user_role" DEFAULT 'user' NOT NULL,
|
||||
CONSTRAINT "users_username_unique" UNIQUE ("username")
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "social_pages" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"platform" "platform" NOT NULL,
|
||||
"page_id" text NOT NULL,
|
||||
"page_name" text NOT NULL,
|
||||
"access_token" text NOT NULL,
|
||||
"token_expires_at" timestamp,
|
||||
"followers_count" integer DEFAULT 0,
|
||||
"is_active" text DEFAULT 'true' NOT NULL,
|
||||
"token_status" "token_status" DEFAULT 'valid',
|
||||
"last_token_check" timestamp,
|
||||
"user_access_token" text,
|
||||
"user_token_expires_at" timestamp,
|
||||
"token_error" text,
|
||||
"refresh_token" text,
|
||||
"refresh_token_expires_at" timestamp,
|
||||
"scopes" text,
|
||||
"avatar_url" text,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "media" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"type" "media_type" NOT NULL,
|
||||
"cloudinary_public_id" text,
|
||||
"original_url" text NOT NULL,
|
||||
"facebook_landscape_url" text,
|
||||
"facebook_feed_url" text,
|
||||
"instagram_feed_url" text,
|
||||
"instagram_story_url" text,
|
||||
"thumbnail_url" text,
|
||||
"file_name" text NOT NULL,
|
||||
"file_size" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "posts" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"content" text NOT NULL,
|
||||
"ai_generated" text DEFAULT 'false' NOT NULL,
|
||||
"product_info" jsonb,
|
||||
"status" "post_status" DEFAULT 'draft' NOT NULL,
|
||||
"scheduled_for" timestamp,
|
||||
"generation_status" text,
|
||||
"generation_progress" integer DEFAULT 0,
|
||||
"generation_error" text,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "post_media" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"post_id" varchar NOT NULL REFERENCES "posts"("id") ON DELETE cascade,
|
||||
"media_id" varchar NOT NULL REFERENCES "media"("id") ON DELETE cascade,
|
||||
"display_order" integer DEFAULT 0 NOT NULL
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "scheduled_posts" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"post_id" varchar NOT NULL REFERENCES "posts"("id") ON DELETE cascade,
|
||||
"page_id" varchar NOT NULL REFERENCES "social_pages"("id") ON DELETE cascade,
|
||||
"post_type" "post_type" NOT NULL,
|
||||
"scheduled_at" timestamp NOT NULL,
|
||||
"published_at" timestamp,
|
||||
"external_post_id" text,
|
||||
"error" text,
|
||||
"publish_id" text,
|
||||
"publish_status" text
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "ai_generations" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"product_info" jsonb NOT NULL,
|
||||
"generated_texts" jsonb NOT NULL,
|
||||
"selected_text" text,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "cloudinary_config" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"cloud_name" text NOT NULL,
|
||||
"api_key" text NOT NULL,
|
||||
"api_secret" text NOT NULL,
|
||||
"logo_public_id" text,
|
||||
"endpoint_url" text,
|
||||
"public_url" text,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "openrouter_config" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"api_key" text NOT NULL,
|
||||
"model" text DEFAULT 'anthropic/claude-sonnet-4.5' NOT NULL,
|
||||
"system_prompt" text DEFAULT 'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "openrouter_config_user_id_unique" UNIQUE ("user_id")
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "user_page_permissions" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"page_id" varchar NOT NULL REFERENCES "social_pages"("id") ON DELETE cascade,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "post_analytics" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"post_id" varchar NOT NULL REFERENCES "posts"("id") ON DELETE cascade,
|
||||
"fetched_at" timestamp DEFAULT now(),
|
||||
"impressions" integer DEFAULT 0,
|
||||
"reach" integer DEFAULT 0,
|
||||
"engagement" integer DEFAULT 0,
|
||||
"reactions" integer DEFAULT 0,
|
||||
"comments" integer DEFAULT 0,
|
||||
"shares" integer DEFAULT 0,
|
||||
"clicks" integer DEFAULT 0,
|
||||
"raw_data" jsonb
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "page_analytics_history" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"page_id" varchar NOT NULL REFERENCES "social_pages"("id") ON DELETE cascade,
|
||||
"date" timestamp NOT NULL,
|
||||
"followers_count" integer DEFAULT 0,
|
||||
"page_views" integer DEFAULT 0,
|
||||
"page_reach" integer DEFAULT 0,
|
||||
"page_engagement" integer DEFAULT 0,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "music_favorites" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"track_id" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"artist" text NOT NULL,
|
||||
"album_name" text,
|
||||
"duration" integer NOT NULL,
|
||||
"preview_url" text NOT NULL,
|
||||
"download_url" text NOT NULL,
|
||||
"image_url" text,
|
||||
"license" text,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "audio_tracks" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar REFERENCES "users"("id") ON DELETE cascade,
|
||||
"title" text NOT NULL,
|
||||
"file_name" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"duration" integer DEFAULT 0,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "app_config" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"external_api_key" text,
|
||||
"gemini_api_key" text,
|
||||
"qwen_tts_url" text,
|
||||
"qwen_tts_api_key" text,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "tiktok_config" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"client_key" text NOT NULL,
|
||||
"client_secret" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "facebook_config" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"app_id" text NOT NULL,
|
||||
"app_secret" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
)"#,
|
||||
r#"CREATE TABLE IF NOT EXISTS "reel_jobs" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"post_id" varchar REFERENCES "posts"("id") ON DELETE cascade,
|
||||
"kind" text NOT NULL,
|
||||
"params" jsonb NOT NULL,
|
||||
"status" text NOT NULL DEFAULT 'pending',
|
||||
"step" text,
|
||||
"progress" integer NOT NULL DEFAULT 0,
|
||||
"attempts" integer NOT NULL DEFAULT 0,
|
||||
"result" jsonb,
|
||||
"error" text,
|
||||
"heartbeat_at" timestamp,
|
||||
"created_at" timestamp NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamp NOT NULL DEFAULT now()
|
||||
)"#,
|
||||
// Sessions : même table que connect-pg-simple, les utilisateurs déjà
|
||||
// connectés le restent après la mise à jour.
|
||||
r#"CREATE TABLE IF NOT EXISTS "session" (
|
||||
"sid" varchar NOT NULL COLLATE "default" PRIMARY KEY,
|
||||
"sess" json NOT NULL,
|
||||
"expire" timestamp(6) NOT NULL
|
||||
)"#,
|
||||
];
|
||||
|
||||
/// Colonnes ajoutées au fil des versions : complètent les bases anciennes.
|
||||
const COLUMNS: &[&str] = &[
|
||||
r#"ALTER TABLE "social_pages" ADD COLUMN IF NOT EXISTS "token_status" "token_status" DEFAULT 'valid'"#,
|
||||
r#"ALTER TABLE "social_pages" ADD COLUMN IF NOT EXISTS "last_token_check" timestamp"#,
|
||||
r#"ALTER TABLE "social_pages"
|
||||
ADD COLUMN IF NOT EXISTS "refresh_token" text,
|
||||
ADD COLUMN IF NOT EXISTS "refresh_token_expires_at" timestamp,
|
||||
ADD COLUMN IF NOT EXISTS "scopes" text,
|
||||
ADD COLUMN IF NOT EXISTS "avatar_url" text"#,
|
||||
r#"ALTER TABLE "social_pages"
|
||||
ADD COLUMN IF NOT EXISTS "user_access_token" text,
|
||||
ADD COLUMN IF NOT EXISTS "user_token_expires_at" timestamp,
|
||||
ADD COLUMN IF NOT EXISTS "token_error" text"#,
|
||||
r#"ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "generation_status" text"#,
|
||||
r#"ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "generation_progress" integer DEFAULT 0"#,
|
||||
r#"ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "generation_error" text"#,
|
||||
r#"ALTER TABLE "cloudinary_config" ADD COLUMN IF NOT EXISTS "endpoint_url" text"#,
|
||||
r#"ALTER TABLE "cloudinary_config" ADD COLUMN IF NOT EXISTS "public_url" text"#,
|
||||
r#"ALTER TABLE "page_analytics_history" ADD COLUMN IF NOT EXISTS "page_engagement" integer DEFAULT 0"#,
|
||||
r#"ALTER TABLE "app_config" ADD COLUMN IF NOT EXISTS "gemini_api_key" text"#,
|
||||
r#"ALTER TABLE "app_config"
|
||||
ADD COLUMN IF NOT EXISTS "qwen_tts_url" text,
|
||||
ADD COLUMN IF NOT EXISTS "qwen_tts_api_key" text"#,
|
||||
r#"ALTER TABLE "media" ADD COLUMN IF NOT EXISTS "thumbnail_url" text"#,
|
||||
r#"ALTER TABLE "scheduled_posts"
|
||||
ADD COLUMN IF NOT EXISTS "publish_id" text,
|
||||
ADD COLUMN IF NOT EXISTS "publish_status" text"#,
|
||||
];
|
||||
|
||||
/// Index de performance (Postgres n'indexe pas les clés étrangères seul).
|
||||
const INDEXES: &[(&str, &str)] = &[
|
||||
("idx_posts_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_posts_user_id" ON "posts" ("user_id")"#),
|
||||
("idx_media_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_media_user_id" ON "media" ("user_id")"#),
|
||||
("idx_social_pages_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_social_pages_user_id" ON "social_pages" ("user_id")"#),
|
||||
("idx_ai_generations_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_ai_generations_user_id" ON "ai_generations" ("user_id")"#),
|
||||
("idx_scheduled_posts_post_id", r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_post_id" ON "scheduled_posts" ("post_id")"#),
|
||||
("idx_post_media_post_id", r#"CREATE INDEX IF NOT EXISTS "idx_post_media_post_id" ON "post_media" ("post_id")"#),
|
||||
("idx_post_media_media_id", r#"CREATE INDEX IF NOT EXISTS "idx_post_media_media_id" ON "post_media" ("media_id")"#),
|
||||
("idx_post_analytics_post_id", r#"CREATE INDEX IF NOT EXISTS "idx_post_analytics_post_id" ON "post_analytics" ("post_id")"#),
|
||||
("idx_page_analytics_history_page_date", r#"CREATE INDEX IF NOT EXISTS "idx_page_analytics_history_page_date" ON "page_analytics_history" ("page_id", "date")"#),
|
||||
("idx_user_page_permissions_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_user_id" ON "user_page_permissions" ("user_id")"#),
|
||||
("idx_user_page_permissions_page_id", r#"CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_page_id" ON "user_page_permissions" ("page_id")"#),
|
||||
("idx_scheduled_posts_page_scheduled_at", r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_page_scheduled_at" ON "scheduled_posts" ("page_id", "scheduled_at")"#),
|
||||
("idx_scheduled_posts_pending", r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_pending" ON "scheduled_posts" ("scheduled_at") WHERE "published_at" IS NULL"#),
|
||||
("idx_scheduled_posts_publish_id", r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_publish_id" ON "scheduled_posts" ("publish_id") WHERE "publish_id" IS NOT NULL"#),
|
||||
("idx_reel_jobs_queue", r#"CREATE INDEX IF NOT EXISTS "idx_reel_jobs_queue" ON "reel_jobs" ("created_at") WHERE "status" IN ('pending', 'processing')"#),
|
||||
("idx_posts_generation_status", r#"CREATE INDEX IF NOT EXISTS "idx_posts_generation_status" ON "posts" ("generation_status") WHERE "generation_status" IS NOT NULL"#),
|
||||
("IDX_session_expire", r#"CREATE INDEX IF NOT EXISTS "IDX_session_expire" ON "session" ("expire")"#),
|
||||
];
|
||||
|
||||
pub async fn run(pool: &PgPool) {
|
||||
tracing::info!("[Migration] Vérification du schéma…");
|
||||
let mut failures = 0;
|
||||
|
||||
for statement in ENUMS.iter().chain(TABLES).chain(COLUMNS) {
|
||||
if let Err(error) = sqlx::raw_sql(statement).execute(pool).await {
|
||||
failures += 1;
|
||||
let head: String = statement.split_whitespace().take(6).collect::<Vec<_>>().join(" ");
|
||||
tracing::warn!("[Migration] Échec de « {head} … » : {error}");
|
||||
}
|
||||
}
|
||||
|
||||
for (name, statement) in INDEXES {
|
||||
if let Err(error) = sqlx::raw_sql(statement).execute(pool).await {
|
||||
failures += 1;
|
||||
tracing::warn!("[Migration] Index {name} non créé : {error}");
|
||||
}
|
||||
}
|
||||
|
||||
if failures == 0 {
|
||||
tracing::info!("[Migration] Schéma à jour.");
|
||||
} else {
|
||||
tracing::warn!("[Migration] Terminée avec {failures} avertissement(s).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! URL publique de l'application.
|
||||
//!
|
||||
//! Les flux OAuth (Facebook, TikTok) exigent une URI de redirection exacte.
|
||||
//! `APP_URL` prime ; à défaut, l'URL est reconstruite depuis la requête
|
||||
//! (en-têtes `X-Forwarded-Proto` / `X-Forwarded-Host` posés par Nginx).
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
const DEV_FALLBACK: &str = "http://localhost:5555";
|
||||
|
||||
fn first_header_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Base publique sans « / » final (« https://exemple.fr »).
|
||||
pub fn resolve_public_base_url(state: &AppState, headers: Option<&HeaderMap>) -> String {
|
||||
if let Some(url) = &state.config.app_url {
|
||||
return url.clone();
|
||||
}
|
||||
if let Some(headers) = headers {
|
||||
let host = first_header_value(headers, "x-forwarded-host").or_else(|| first_header_value(headers, "host"));
|
||||
if let Some(host) = host {
|
||||
let protocol = first_header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".into());
|
||||
return format!("{protocol}://{host}");
|
||||
}
|
||||
}
|
||||
DEV_FALLBACK.to_string()
|
||||
}
|
||||
|
||||
/// La requête est-elle arrivée en HTTPS (directement ou derrière le proxy) ?
|
||||
pub fn is_https(state: &AppState, headers: &HeaderMap) -> bool {
|
||||
if let Some(url) = &state.config.app_url {
|
||||
if url.starts_with("https://") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
first_header_value(headers, "x-forwarded-proto").as_deref() == Some("https")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Statistiques des posts et des pages (API Graph).
|
||||
//!
|
||||
//! STUB — à porter depuis `server/services/analytics.ts`.
|
||||
#![allow(unused_variables)]
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Synchronise les statistiques de toutes les pages.
|
||||
pub async fn sync_all_pages(state: &AppState) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Bibliothèque audio : réparation des titres mal encodés et durée des pistes.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Texte UTF-8 qui a été lu comme du latin1 (« é » au lieu de « é ») ?
|
||||
fn looks_mis_decoded(text: &str) -> bool {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
chars
|
||||
.windows(2)
|
||||
.any(|w| ('\u{C0}'..='\u{C3}').contains(&w[0]) && ('\u{80}'..='\u{BF}').contains(&w[1]))
|
||||
}
|
||||
|
||||
/// Réencode chaque caractère en un octet latin1 puis relit en UTF-8
|
||||
/// (`Buffer.from(text, 'latin1').toString('utf8')`).
|
||||
fn latin1_to_utf8(text: &str) -> String {
|
||||
let bytes: Vec<u8> = text.chars().map(|c| (c as u32 & 0xFF) as u8).collect();
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
/// Corrige les titres corrompus des pistes audio ; renvoie le nombre corrigé.
|
||||
pub async fn fix_audio_track_encoding(state: &AppState) -> usize {
|
||||
let tracks = match state.db.get_audio_tracks().await {
|
||||
Ok(tracks) => tracks,
|
||||
Err(error) => {
|
||||
tracing::error!("Error fixing audio track encoding: {error}");
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
let mut fixed = 0;
|
||||
for track in tracks {
|
||||
if !looks_mis_decoded(&track.title) {
|
||||
continue;
|
||||
}
|
||||
let title = latin1_to_utf8(&track.title);
|
||||
let file_name = latin1_to_utf8(&track.file_name);
|
||||
if sqlx::query("UPDATE audio_tracks SET title = $1, file_name = $2 WHERE id = $3")
|
||||
.bind(&title)
|
||||
.bind(&file_name)
|
||||
.bind(&track.id)
|
||||
.execute(&state.db.pool)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
fixed += 1;
|
||||
}
|
||||
}
|
||||
if fixed > 0 {
|
||||
tracing::info!("🎵 Fixed encoding for {fixed} audio track(s).");
|
||||
}
|
||||
fixed
|
||||
}
|
||||
|
||||
/// Durée d'un fichier audio en secondes (arrondie), via ffprobe ; 0 si inconnue.
|
||||
pub async fn probe_duration_secs(path: &Path) -> i32 {
|
||||
let output = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.await;
|
||||
match output {
|
||||
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.map(|d| d.round() as i32)
|
||||
.unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repairs_latin1_mojibake() {
|
||||
assert!(looks_mis_decoded("Café"));
|
||||
assert_eq!(latin1_to_utf8("Café"), "Café");
|
||||
assert!(!looks_mis_decoded("Café"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Tâches périodiques (suivi TikTok, jetons, statistiques, purge des médias).
|
||||
//!
|
||||
//! STUB — à porter depuis `server/cron.ts`.
|
||||
#![allow(unused_variables)]
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn start(state: AppState) {}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Publication Facebook / Instagram (API Graph).
|
||||
//!
|
||||
//! STUB — à porter depuis `server/services/facebook.ts` (+ `imageProcessor.ts`,
|
||||
//! `emojiRenderer.ts`, `facebook_oauth.ts`, `utils/graph_client.ts`).
|
||||
#![allow(unused_variables)]
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use crate::db::{Media, Post, PostType, SocialPage};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Publie un post (feed, story…) sur une page Facebook ; renvoie l'identifiant externe.
|
||||
pub async fn publish_post(
|
||||
state: &AppState,
|
||||
post: &Post,
|
||||
page: &SocialPage,
|
||||
post_type: PostType,
|
||||
media: &[Media],
|
||||
) -> anyhow::Result<String> {
|
||||
anyhow::bail!("publication Facebook non implémentée")
|
||||
}
|
||||
|
||||
/// Publie une vidéo (Reel) envoyée en binaire ; renvoie l'identifiant de la vidéo.
|
||||
pub async fn publish_video_from_buffer(
|
||||
state: &AppState,
|
||||
page: &SocialPage,
|
||||
video: Bytes,
|
||||
description: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
anyhow::bail!("publication de vidéo Facebook non implémentée")
|
||||
}
|
||||
|
||||
/// Publie une vidéo en story Facebook ; renvoie l'identifiant de la story.
|
||||
pub async fn publish_video_story_from_buffer(
|
||||
state: &AppState,
|
||||
page: &SocialPage,
|
||||
video: Bytes,
|
||||
) -> anyhow::Result<String> {
|
||||
anyhow::bail!("story vidéo Facebook non implémentée")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Client du service FFmpeg (conteneur Python `ffmpeg-service`).
|
||||
//!
|
||||
//! STUB — à porter depuis `server/services/ffmpeg.ts`.
|
||||
#![allow(unused_variables)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Service Qwen TTS (local ou serveur GPU) réglé dans les paramètres.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QwenTarget {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Catalogue des voix renvoyé par le service (noms de champs du service Python).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct VoiceCatalog {
|
||||
#[serde(default)]
|
||||
pub qwen: Vec<Value>,
|
||||
#[serde(default)]
|
||||
pub qwen_available: bool,
|
||||
/// Modèle et carte graphique du service Qwen, null s'il ne répond pas.
|
||||
#[serde(default)]
|
||||
pub qwen_status: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub gemini: Vec<Value>,
|
||||
#[serde(default)]
|
||||
pub edge: Vec<Value>,
|
||||
}
|
||||
|
||||
/// Le service répond-il ?
|
||||
pub async fn health_check(state: &AppState) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Voix disponibles ; `qwen` remplace la cible Qwen configurée côté service.
|
||||
pub async fn list_voices(state: &AppState, qwen: Option<QwenTarget>) -> anyhow::Result<VoiceCatalog> {
|
||||
anyhow::bail!("service FFmpeg non implémenté")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Purge quotidienne des anciens médias.
|
||||
//!
|
||||
//! STUB — à porter depuis `server/services/media-purge.ts`.
|
||||
#![allow(unused_variables)]
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn purge_old_media(state: &AppState) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Stockage local des fichiers (ex-`minio.ts`) : tout vit sous `uploads/`
|
||||
//! et est servi en relatif (`/uploads/<clé>`), quel que soit le domaine.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// URL d'un fichier stocké : relative, pour fonctionner sur tout domaine.
|
||||
pub fn build_url(object_key: &str) -> String {
|
||||
format!("/uploads/{object_key}")
|
||||
}
|
||||
|
||||
/// URL absolue pour les services externes (API Facebook…) : préfixée par `APP_URL`.
|
||||
pub fn resolve_public_url(state: &AppState, url: &str) -> String {
|
||||
if url.is_empty() || !url.starts_with('/') {
|
||||
return url.to_string();
|
||||
}
|
||||
let base = state.config.app_url.as_deref().unwrap_or("http://localhost:5555");
|
||||
format!("{}{url}", base.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
/// URL absolue pour les services Docker internes (FFmpeg…) : `INTERNAL_APP_URL`.
|
||||
pub fn resolve_internal_url(state: &AppState, url: &str) -> String {
|
||||
if url.is_empty() || !url.starts_with('/') {
|
||||
return url.to_string();
|
||||
}
|
||||
let base = state
|
||||
.config
|
||||
.internal_app_url
|
||||
.as_deref()
|
||||
.or(state.config.app_url.as_deref())
|
||||
.unwrap_or("http://localhost:5555");
|
||||
format!("{}{url}", base.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
/// Chemin disque d'une URL `/uploads/...`, si elle reste dans le dossier des
|
||||
/// uploads (refuse toute remontée `..`).
|
||||
pub fn local_path(state: &AppState, url: &str) -> Option<PathBuf> {
|
||||
let rest = url.strip_prefix("/uploads/")?;
|
||||
let rest = rest.split(['?', '#']).next().unwrap_or(rest);
|
||||
let decoded = percent_encoding::percent_decode_str(rest).decode_utf8().ok()?;
|
||||
if decoded.split('/').any(|part| part == ".." || part.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let (root, relative) = if let Some(r) = decoded.strip_prefix("audio/") {
|
||||
(state.config.audio_upload_dir.clone(), r.to_string())
|
||||
} else if let Some(r) = decoded.strip_prefix("temp/") {
|
||||
(state.config.temp_upload_dir.clone(), r.to_string())
|
||||
} else {
|
||||
(state.config.uploads_dir.clone(), decoded.to_string())
|
||||
};
|
||||
Some(root.join(relative))
|
||||
}
|
||||
|
||||
/// Contenu d'un média, local (`/uploads/...`) ou distant.
|
||||
pub async fn read_media_buffer(state: &AppState, url: &str) -> anyhow::Result<Bytes> {
|
||||
if let Some(path) = local_path(state, url) {
|
||||
if let Ok(data) = tokio::fs::read(&path).await {
|
||||
return Ok(Bytes::from(data));
|
||||
}
|
||||
}
|
||||
let absolute = resolve_public_url(state, url);
|
||||
let response = state.http.get(&absolute).send().await?;
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"Impossible de récupérer le média depuis {absolute} ({})",
|
||||
response.status().as_u16()
|
||||
);
|
||||
}
|
||||
Ok(response.bytes().await?)
|
||||
}
|
||||
|
||||
fn safe_name(file_name: &str) -> String {
|
||||
let base = Path::new(file_name)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("file");
|
||||
base.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '-' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
}
|
||||
|
||||
async fn write(state: &AppState, object_key: &str, data: &[u8]) -> anyhow::Result<()> {
|
||||
let path = state.config.uploads_dir.join(object_key);
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::write(&path, data).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Résultat d'un enregistrement de média : toutes les déclinaisons pointent sur le même fichier.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredMedia {
|
||||
pub public_id: String,
|
||||
pub original_url: String,
|
||||
pub facebook_feed_url: Option<String>,
|
||||
pub instagram_feed_url: Option<String>,
|
||||
pub instagram_story_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Source d'un média à ranger : fichier sur disque ou contenu en mémoire.
|
||||
pub enum MediaSource<'a> {
|
||||
Path(&'a Path),
|
||||
Bytes(&'a [u8]),
|
||||
}
|
||||
|
||||
pub async fn upload_media(state: &AppState, source: MediaSource<'_>, file_name: &str) -> anyhow::Result<StoredMedia> {
|
||||
let object_key = format!("media/{}-{}", now_ms(), safe_name(file_name));
|
||||
let target = state.config.uploads_dir.join(&object_key);
|
||||
if let Some(parent) = target.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
match source {
|
||||
MediaSource::Path(path) => {
|
||||
// Copie plutôt que renommage : le fichier temporaire peut être sur un autre volume
|
||||
tokio::fs::copy(path, &target).await?;
|
||||
}
|
||||
MediaSource::Bytes(data) => tokio::fs::write(&target, data).await?,
|
||||
}
|
||||
let url = build_url(&object_key);
|
||||
Ok(StoredMedia {
|
||||
public_id: object_key,
|
||||
original_url: url.clone(),
|
||||
facebook_feed_url: Some(url.clone()),
|
||||
instagram_feed_url: Some(url.clone()),
|
||||
instagram_story_url: Some(url),
|
||||
})
|
||||
}
|
||||
|
||||
/// Supprime un fichier rangé par `upload_media` (clé relative à `uploads/`).
|
||||
pub async fn delete_media(state: &AppState, public_id: &str) -> anyhow::Result<()> {
|
||||
if public_id.split('/').any(|p| p == "..") {
|
||||
anyhow::bail!("clé de média invalide : {public_id}");
|
||||
}
|
||||
let path = state.config.uploads_dir.join(public_id);
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Image de story avec texte incrusté ; renvoie son URL.
|
||||
pub async fn upload_story_image(state: &AppState, data: &[u8], original_file_name: &str) -> anyhow::Result<String> {
|
||||
let object_key = format!("stories/story-{}-{}", now_ms(), safe_name(original_file_name));
|
||||
write(state, &object_key, data).await?;
|
||||
Ok(build_url(&object_key))
|
||||
}
|
||||
|
||||
/// Vignette vidéo (conservée après la suppression de la vidéo source).
|
||||
pub async fn upload_thumbnail(state: &AppState, data: &[u8], file_name: &str) -> anyhow::Result<(String, String)> {
|
||||
let object_key = format!("thumbnails/{}", safe_name(file_name));
|
||||
write(state, &object_key, data).await?;
|
||||
let url = build_url(&object_key);
|
||||
Ok((object_key, url))
|
||||
}
|
||||
|
||||
/// Logo de l'entreprise ; renvoie (clé, URL).
|
||||
pub async fn upload_logo(state: &AppState, data: &[u8], file_name: &str) -> anyhow::Result<(String, String)> {
|
||||
let ext = Path::new(file_name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| format!(".{e}"))
|
||||
.unwrap_or_else(|| ".png".into());
|
||||
let object_key = format!("logos/logo-{}{ext}", now_ms());
|
||||
write(state, &object_key, data).await?;
|
||||
let url = build_url(&object_key);
|
||||
Ok((object_key, url))
|
||||
}
|
||||
|
||||
pub async fn delete_logo(state: &AppState, public_id: &str) -> anyhow::Result<()> {
|
||||
delete_media(state, public_id).await
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Services métier (intégrations externes, traitements de fond).
|
||||
|
||||
pub mod analytics;
|
||||
pub mod audio;
|
||||
pub mod cron;
|
||||
pub mod facebook;
|
||||
pub mod ffmpeg;
|
||||
pub mod media_purge;
|
||||
pub mod media_store;
|
||||
pub mod openrouter;
|
||||
pub mod reels;
|
||||
pub mod scheduler;
|
||||
pub mod thumbnail;
|
||||
pub mod tiktok;
|
||||
pub mod token_manager;
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Génération de textes par IA (OpenRouter).
|
||||
//!
|
||||
//! STUB — à porter depuis `server/services/openrouter.ts`.
|
||||
#![allow(unused_variables)]
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Génère les variantes de texte (`{ variant, text, characterCount }`).
|
||||
pub async fn generate_post_text(
|
||||
state: &AppState,
|
||||
product_info: &Value,
|
||||
user_id: &str,
|
||||
model_override: Option<&str>,
|
||||
) -> anyhow::Result<Vec<Value>> {
|
||||
anyhow::bail!("Configuration OpenRouter non trouvée")
|
||||
}
|
||||
|
||||
/// Statut HTTP et message à renvoyer pour une erreur de génération.
|
||||
pub fn describe_generation_error(error: &anyhow::Error) -> (StatusCode, String) {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Reels : file de rendus persistante, montage, publication.
|
||||
//!
|
||||
//! STUB — à porter depuis `server/services/reels/*`.
|
||||
#![allow(unused_variables)]
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Démarre le worker de la file `reel_jobs`.
|
||||
pub fn start(state: AppState) {}
|
||||
|
||||
/// Adresse http(s) du service Qwen saisie dans les paramètres, sans « / » final ; None si invalide.
|
||||
pub fn normalize_qwen_url(value: Option<&Value>) -> Option<String> {
|
||||
let text = value?.as_str()?.trim();
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let url = url::Url::parse(text).ok()?;
|
||||
if url.scheme() != "http" && url.scheme() != "https" {
|
||||
return None;
|
||||
}
|
||||
Some(url.to_string().trim_end_matches('/').to_string())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Publication des posts programmés (toutes les minutes).
|
||||
//!
|
||||
//! STUB — à porter depuis `server/services/scheduler.ts`.
|
||||
#![allow(unused_variables)]
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn start(state: AppState) {}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Vignettes vidéo, extraites avec FFmpeg au moment de la création du média :
|
||||
//! elles survivent à la suppression de la vidéo (historique illustré).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rand::Rng;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::services::media_store;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Source de la vidéo : fichier sur disque ou contenu en mémoire.
|
||||
pub enum ThumbnailSource<'a> {
|
||||
Path(&'a Path),
|
||||
Bytes(&'a [u8]),
|
||||
}
|
||||
|
||||
/// Extrait une image à `seek_time` secondes ; vrai si l'image a été écrite.
|
||||
pub async fn generate_video_thumbnail(video: &Path, output: &Path, seek_time: f64) -> bool {
|
||||
// Arguments séparés : aucun passage par le shell
|
||||
let status = Command::new("ffmpeg")
|
||||
.args(["-y", "-ss", &seek_time.to_string(), "-i"])
|
||||
.arg(video)
|
||||
.args(["-vframes", "1", "-q:v", "2"])
|
||||
.arg(output)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await;
|
||||
match status {
|
||||
Ok(s) if s.success() => tokio::fs::metadata(output).await.is_ok(),
|
||||
Ok(_) => false,
|
||||
Err(error) => {
|
||||
tracing::warn!("⚠️ Failed to generate video thumbnail: {error}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Produit la vignette et la range dans le stockage public. Tolérante à
|
||||
/// l'échec : renvoie `None` sans jamais faire échouer l'appelant.
|
||||
pub async fn create_video_thumbnail(state: &AppState, source: ThumbnailSource<'_>) -> Option<String> {
|
||||
let temp_dir = state.config.uploads_dir.join("temp");
|
||||
if tokio::fs::create_dir_all(&temp_dir).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
let stamp = format!(
|
||||
"{}-{}",
|
||||
chrono::Utc::now().timestamp_millis(),
|
||||
rand::thread_rng().gen_range(0..1_000_000_000u32)
|
||||
);
|
||||
let output = temp_dir.join(format!("thumb-{stamp}.jpg"));
|
||||
|
||||
let mut temp_video: Option<PathBuf> = None;
|
||||
let video_path: PathBuf = match source {
|
||||
ThumbnailSource::Path(path) => path.to_path_buf(),
|
||||
ThumbnailSource::Bytes(data) => {
|
||||
let path = temp_dir.join(format!("thumb-src-{stamp}.mp4"));
|
||||
if tokio::fs::write(&path, data).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
temp_video = Some(path.clone());
|
||||
path
|
||||
}
|
||||
};
|
||||
|
||||
// Une vidéo de moins d'une seconde n'a pas d'image à 1 s : repli sur la première
|
||||
let mut ok = generate_video_thumbnail(&video_path, &output, 1.0).await;
|
||||
if !ok {
|
||||
ok = generate_video_thumbnail(&video_path, &output, 0.0).await;
|
||||
}
|
||||
|
||||
let result = if ok {
|
||||
match tokio::fs::read(&output).await {
|
||||
Ok(data) => match media_store::upload_thumbnail(state, &data, &format!("thumb-{stamp}.jpg")).await {
|
||||
Ok((_, url)) => Some(url),
|
||||
Err(error) => {
|
||||
tracing::warn!("⚠️ Vignette non enregistrée : {error}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let _ = tokio::fs::remove_file(&output).await;
|
||||
if let Some(path) = temp_video {
|
||||
let _ = tokio::fs::remove_file(path).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Comptes TikTok : OAuth, renouvellement des jetons, publication de vidéos.
|
||||
//!
|
||||
//! STUB — à porter depuis `server/services/tiktok.ts`.
|
||||
#![allow(unused_variables)]
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use crate::db::SocialPage;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Envoie une vidéo à TikTok ; renvoie le `publish_id` (publication asynchrone).
|
||||
pub async fn publish_video_from_buffer(
|
||||
state: &AppState,
|
||||
page: &SocialPage,
|
||||
video: Bytes,
|
||||
description: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
anyhow::bail!("publication TikTok non implémentée")
|
||||
}
|
||||
|
||||
/// Interroge le statut des envois TikTok en attente et met à jour les publications.
|
||||
pub async fn sync_pending_publications(state: &AppState) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Contrôle et renouvellement des jetons de page.
|
||||
//!
|
||||
//! STUB — à porter depuis `server/services/token_manager.ts`.
|
||||
#![allow(unused_variables)]
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TokenCheckResult {
|
||||
pub page_id: String,
|
||||
pub page_name: String,
|
||||
/// 'valid' | 'expiring' | 'expired' | 'error'
|
||||
pub status: String,
|
||||
/// Vrai quand ce passage a effectivement réécrit un nouveau jeton.
|
||||
pub renewed: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Contrôle toutes les pages et renouvelle les jetons qui doivent l'être.
|
||||
pub async fn check_and_refresh_tokens(state: &AppState) -> Vec<TokenCheckResult> {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! État partagé par les routes et les tâches de fond.
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::{Notify, RwLock};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::http::RateLimiter;
|
||||
|
||||
/// Adresse et clé du service FFmpeg ; modifiables depuis les paramètres.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FfmpegSettings {
|
||||
pub api_url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Inner {
|
||||
pub config: Config,
|
||||
pub db: Db,
|
||||
/// Client HTTP commun (APIs Meta, TikTok, OpenRouter, services internes).
|
||||
pub http: reqwest::Client,
|
||||
pub ffmpeg: RwLock<FfmpegSettings>,
|
||||
/// Réveille la file des rendus de Reels quand un job est ajouté.
|
||||
pub reel_queue: Notify,
|
||||
pub global_limiter: RateLimiter,
|
||||
pub auth_limiter: RateLimiter,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState(Arc<Inner>);
|
||||
|
||||
impl Deref for AppState {
|
||||
type Target = Inner;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: Config, pool: PgPool) -> anyhow::Result<Self> {
|
||||
let http = reqwest::Client::builder()
|
||||
.user_agent("SocialFlow/2.0")
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
let ffmpeg = FfmpegSettings {
|
||||
api_url: config.ffmpeg_api_url.as_ref().map(|u| u.trim_end_matches('/').to_string()),
|
||||
api_key: config.ffmpeg_api_key.clone(),
|
||||
};
|
||||
if ffmpeg.api_url.is_none() || ffmpeg.api_key.is_none() {
|
||||
tracing::warn!("⚠️ FFmpeg API not configured - video processing will be skipped");
|
||||
} else if let Some(url) = &ffmpeg.api_url {
|
||||
tracing::info!("🎬 FFmpeg Service configured: {url}");
|
||||
}
|
||||
|
||||
Ok(Self(Arc::new(Inner {
|
||||
db: Db::new(pool),
|
||||
http,
|
||||
ffmpeg: RwLock::new(ffmpeg),
|
||||
reel_queue: Notify::new(),
|
||||
// 1000 requêtes / 15 min par IP sur /api, 30 tentatives de connexion
|
||||
global_limiter: RateLimiter::new(1000, Duration::from_secs(15 * 60)),
|
||||
auth_limiter: RateLimiter::new(30, Duration::from_secs(15 * 60)),
|
||||
config,
|
||||
})))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Horodatages.
|
||||
//!
|
||||
//! Les colonnes sont des `timestamp` sans fuseau qui contiennent de l'UTC
|
||||
//! (l'ancien serveur tournait en UTC dans Docker). On les expose au navigateur
|
||||
//! comme le faisait `JSON.stringify(new Date())` : `2024-05-01T08:30:00.000Z`.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, Utc};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::Type)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct Ts(pub NaiveDateTime);
|
||||
|
||||
impl Ts {
|
||||
pub fn now() -> Self {
|
||||
// Précision milliseconde, comme une Date JavaScript
|
||||
let now = Utc::now().naive_utc();
|
||||
Ts(now - Duration::nanoseconds(now.and_utc().timestamp_subsec_nanos() as i64 % 1_000_000))
|
||||
}
|
||||
|
||||
pub fn from_utc(dt: DateTime<Utc>) -> Self {
|
||||
Ts(dt.naive_utc())
|
||||
}
|
||||
|
||||
pub fn utc(&self) -> DateTime<Utc> {
|
||||
self.0.and_utc()
|
||||
}
|
||||
|
||||
pub fn to_iso(&self) -> String {
|
||||
self.0.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
|
||||
}
|
||||
|
||||
pub fn timestamp_millis(&self) -> i64 {
|
||||
self.0.and_utc().timestamp_millis()
|
||||
}
|
||||
|
||||
pub fn from_millis(ms: i64) -> Option<Self> {
|
||||
DateTime::<Utc>::from_timestamp_millis(ms).map(Ts::from_utc)
|
||||
}
|
||||
|
||||
pub fn add(&self, d: Duration) -> Self {
|
||||
Ts(self.0 + d)
|
||||
}
|
||||
|
||||
/// Équivalent de `new Date(value)` pour les formats envoyés par le client :
|
||||
/// ISO 8601 avec ou sans fuseau (sans fuseau = UTC, l'heure du conteneur),
|
||||
/// date seule, ou nombre de millisecondes.
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
parse_js_date(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Ts {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.to_iso())
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Ts {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_iso())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Ts {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
match &value {
|
||||
serde_json::Value::String(s) => {
|
||||
parse_js_date(s).ok_or_else(|| serde::de::Error::custom(format!("date invalide : {s}")))
|
||||
}
|
||||
serde_json::Value::Number(n) => n
|
||||
.as_i64()
|
||||
.or_else(|| n.as_f64().map(|f| f as i64))
|
||||
.and_then(Ts::from_millis)
|
||||
.ok_or_else(|| serde::de::Error::custom("date invalide")),
|
||||
_ => Err(serde::de::Error::custom("date invalide")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NaiveDateTime> for Ts {
|
||||
fn from(value: NaiveDateTime) -> Self {
|
||||
Ts(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DateTime<Utc>> for Ts {
|
||||
fn from(value: DateTime<Utc>) -> Self {
|
||||
Ts::from_utc(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_js_date(value: &str) -> Option<Ts> {
|
||||
let s = value.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
|
||||
return Some(Ts::from_utc(dt.with_timezone(&Utc)));
|
||||
}
|
||||
// Variantes ISO tolérées par Date.parse : décalage sans « : », espace au lieu de « T »
|
||||
for fmt in ["%Y-%m-%dT%H:%M:%S%.f%z", "%Y-%m-%d %H:%M:%S%.f%z", "%Y-%m-%dT%H:%M%z"] {
|
||||
if let Ok(dt) = DateTime::parse_from_str(s, fmt) {
|
||||
return Some(Ts::from_utc(dt.with_timezone(&Utc)));
|
||||
}
|
||||
}
|
||||
for fmt in [
|
||||
"%Y-%m-%dT%H:%M:%S%.f",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M",
|
||||
"%Y-%m-%d %H:%M:%S%.f",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%d %H:%M",
|
||||
] {
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
|
||||
return Some(Ts(dt));
|
||||
}
|
||||
}
|
||||
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
|
||||
return d.and_hms_opt(0, 0, 0).map(Ts);
|
||||
}
|
||||
if let Ok(dt) = DateTime::parse_from_rfc2822(s) {
|
||||
return Some(Ts::from_utc(dt.with_timezone(&Utc)));
|
||||
}
|
||||
if let Ok(ms) = s.parse::<i64>() {
|
||||
return Ts::from_millis(ms);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn iso_round_trip() {
|
||||
let ts = parse_js_date("2024-05-01T08:30:00.123Z").unwrap();
|
||||
assert_eq!(ts.to_iso(), "2024-05-01T08:30:00.123Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_are_converted_to_utc() {
|
||||
let ts = parse_js_date("2024-05-01T10:30:00+02:00").unwrap();
|
||||
assert_eq!(ts.to_iso(), "2024-05-01T08:30:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naive_and_date_only() {
|
||||
assert_eq!(parse_js_date("2024-05-01T08:30").unwrap().to_iso(), "2024-05-01T08:30:00.000Z");
|
||||
assert_eq!(parse_js_date("2024-05-01").unwrap().to_iso(), "2024-05-01T00:00:00.000Z");
|
||||
assert!(parse_js_date("pas une date").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Réception de fichiers `multipart/form-data` (équivalent de multer avec
|
||||
//! stockage disque) : chaque fichier est écrit en flux sur le disque.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use axum::extract::Multipart;
|
||||
use rand::Rng;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadedFile {
|
||||
pub field: String,
|
||||
/// Nom d'origine envoyé par le navigateur.
|
||||
pub original_name: String,
|
||||
/// Nom sur le disque (`<horodatage>-<aléa>-<nom nettoyé>`).
|
||||
pub file_name: String,
|
||||
pub path: PathBuf,
|
||||
pub size: u64,
|
||||
pub mime: String,
|
||||
}
|
||||
|
||||
pub struct UploadRules<'a> {
|
||||
/// Champs de fichier acceptés (`file`, `logo`, `files`…).
|
||||
pub fields: &'a [&'a str],
|
||||
pub dir: &'a Path,
|
||||
pub max_file_size: u64,
|
||||
pub max_files: usize,
|
||||
/// Filtre (type MIME, nom d'origine) → message d'erreur si refusé.
|
||||
pub filter: &'a (dyn Fn(&str, &str) -> Result<(), String> + Send + Sync),
|
||||
}
|
||||
|
||||
/// Nom de fichier multer : caractères hors `[a-zA-Z0-9.]` remplacés par `_`.
|
||||
pub fn disk_name(original: &str) -> String {
|
||||
let safe: String = original
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '.' { c } else { '_' })
|
||||
.collect();
|
||||
let millis = chrono::Utc::now().timestamp_millis();
|
||||
let random: u32 = rand::thread_rng().gen_range(0..1_000_000_000);
|
||||
format!("{millis}-{random}-{safe}")
|
||||
}
|
||||
|
||||
/// Lit le formulaire ; les champs texte sont renvoyés à part.
|
||||
pub async fn receive_files(
|
||||
mut multipart: Multipart,
|
||||
rules: UploadRules<'_>,
|
||||
) -> Result<(Vec<UploadedFile>, Vec<(String, String)>), AppError> {
|
||||
tokio::fs::create_dir_all(rules.dir)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_logged("Dossier d'upload inaccessible", e))?;
|
||||
|
||||
let mut files: Vec<UploadedFile> = Vec::new();
|
||||
let mut fields: Vec<(String, String)> = Vec::new();
|
||||
|
||||
let result: Result<(), AppError> = async {
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("Formulaire invalide : {e}")))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
let Some(original_name) = field.file_name().map(str::to_string) else {
|
||||
let text = field.text().await.unwrap_or_default();
|
||||
fields.push((name, text));
|
||||
continue;
|
||||
};
|
||||
if !rules.fields.contains(&name.as_str()) {
|
||||
return Err(AppError::internal(format!("Unexpected field: {name}")));
|
||||
}
|
||||
if files.len() >= rules.max_files {
|
||||
return Err(AppError::internal("Too many files"));
|
||||
}
|
||||
let mime = field
|
||||
.content_type()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "application/octet-stream".into());
|
||||
if let Err(message) = (rules.filter)(&mime, &original_name) {
|
||||
return Err(AppError::internal(message));
|
||||
}
|
||||
|
||||
let file_name = disk_name(&original_name);
|
||||
let path = rules.dir.join(&file_name);
|
||||
let mut out = tokio::fs::File::create(&path)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_logged("Écriture du fichier impossible", e))?;
|
||||
let mut size: u64 = 0;
|
||||
let mut uploaded = UploadedFile {
|
||||
field: name,
|
||||
original_name,
|
||||
file_name,
|
||||
path: path.clone(),
|
||||
size: 0,
|
||||
mime,
|
||||
};
|
||||
// Ajouté tout de suite : nettoyé en cas d'erreur plus bas
|
||||
files.push(uploaded.clone());
|
||||
while let Some(chunk) = field
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("Réception interrompue : {e}")))?
|
||||
{
|
||||
size += chunk.len() as u64;
|
||||
if size > rules.max_file_size {
|
||||
return Err(AppError::new(axum::http::StatusCode::PAYLOAD_TOO_LARGE, "File too large"));
|
||||
}
|
||||
out.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_logged("Écriture du fichier impossible", e))?;
|
||||
}
|
||||
out.flush().await.ok();
|
||||
uploaded.size = size;
|
||||
if let Some(last) = files.last_mut() {
|
||||
*last = uploaded;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(error) = result {
|
||||
for file in &files {
|
||||
let _ = tokio::fs::remove_file(&file.path).await;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok((files, fields))
|
||||
}
|
||||
|
||||
/// Supprime des fichiers temporaires sans se soucier des erreurs.
|
||||
pub async fn cleanup(files: &[UploadedFile]) {
|
||||
for file in files {
|
||||
let _ = tokio::fs::remove_file(&file.path).await;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user