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

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

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

No files matched your search

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