diff --git a/server/src/auth.rs b/server/src/auth.rs index fa95301..1441544 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -57,7 +57,10 @@ impl Session { } pub fn get_str(&self, key: &str) -> Option { - self.data.get(key).and_then(Value::as_str).map(str::to_string) + self.data + .get(key) + .and_then(Value::as_str) + .map(str::to_string) } pub fn set(&mut self, key: &str, value: Value) { @@ -86,7 +89,8 @@ impl Session { } fn sign(secret: &str, value: &str) -> String { - let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("HMAC accepte toute clé"); + let mut mac = + Hmac::::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('=')) @@ -142,9 +146,15 @@ fn cookie_meta(secure: bool, expires: Ts) -> Value { } /// En-tête `Set-Cookie` de la session. -pub fn session_cookie(state: &AppState, headers: &HeaderMap, sid: &str, expires: Ts) -> HeaderValue { +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 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") @@ -157,19 +167,22 @@ pub fn session_cookie(state: &AppState, headers: &HeaderMap, sid: &str, expires: /// 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") + 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 { 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()?; + 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 }) } @@ -201,7 +214,10 @@ pub async fn start_session( } 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?; + sqlx::query("DELETE FROM session WHERE sid = $1") + .bind(sid) + .execute(&state.db.pool) + .await?; Ok(()) } @@ -252,7 +268,10 @@ impl Deref for AuthUser { impl FromRequestParts for AuthUser { type Rejection = AppError; - async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { match current_user(state, &parts.headers).await { Some((user, session)) => Ok(AuthUser { user, session }), None => Err(AppError::unauthorized("Non authentifié")), @@ -277,10 +296,15 @@ impl Deref for AdminUser { impl FromRequestParts for AdminUser { type Rejection = AppError; - async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { 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.")); + return Err(AppError::forbidden( + "Accès refusé. Réservé aux administrateurs.", + )); } Ok(AdminUser { user, session }) } @@ -292,7 +316,10 @@ pub struct MaybeUser(pub Option<(User, Session)>); impl FromRequestParts for MaybeUser { type Rejection = std::convert::Infallible; - async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { Ok(MaybeUser(current_user(state, &parts.headers).await)) } } @@ -333,7 +360,10 @@ pub async fn ensure_admin_user_exists(state: &AppState) -> anyhow::Result<()> { } 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?; + 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(()) @@ -346,8 +376,18 @@ mod tests { #[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_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()); } diff --git a/server/src/crypto.rs b/server/src/crypto.rs index 3b03df3..5bbbcbd 100644 --- a/server/src/crypto.rs +++ b/server/src/crypto.rs @@ -88,11 +88,22 @@ pub fn encrypt(text: &str) -> String { let mut iv = [0u8; 16]; rand::thread_rng().fill_bytes(&mut iv); let sealed = cipher - .encrypt(Nonce::::from_slice(&iv), Payload { msg: text.as_bytes(), aad: &[] }) + .encrypt( + Nonce::::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)) + format!( + "{}:{}:{}", + hex::encode(iv), + hex::encode(tag), + hex::encode(ciphertext) + ) } /// Déchiffre ; une valeur qui n'a pas le format attendu (jeton enregistré en @@ -115,7 +126,13 @@ pub fn decrypt(value: &str) -> String { data.extend_from_slice(&tag); let cipher = Cipher::new_from_slice(key()).ok()?; let plain = cipher - .decrypt(Nonce::::from_slice(&iv), Payload { msg: &data, aad: &[] }) + .decrypt( + Nonce::::from_slice(&iv), + Payload { + msg: &data, + aad: &[], + }, + ) .ok()?; String::from_utf8(plain).ok() }; diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs index b40a61a..1d1da85 100644 --- a/server/src/db/mod.rs +++ b/server/src/db/mod.rs @@ -18,7 +18,8 @@ use crate::crypto::{decrypt, encrypt}; use crate::time::Ts; /// Statuts de publication TikTok définitifs : inutile d'interroger l'API au-delà. -pub const TERMINAL_PUBLISH_STATUSES: [&str; 3] = ["PUBLISH_COMPLETE", "FAILED", "SEND_TO_USER_INBOX"]; +pub const TERMINAL_PUBLISH_STATUSES: [&str; 3] = + ["PUBLISH_COMPLETE", "FAILED", "SEND_TO_USER_INBOX"]; pub async fn connect(url: &str) -> Result { let options: PgConnectOptions = url.parse()?; @@ -193,13 +194,21 @@ impl Patch { } /// Exécute `UPDATE table SET … WHERE id = $id RETURNING *`. -async fn update_returning(pool: &PgPool, table: &str, id: &str, patch: &Patch) -> Result, sqlx::Error> +async fn update_returning( + pool: &PgPool, + table: &str, + id: &str, + patch: &Patch, +) -> Result, sqlx::Error> where T: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin, { if patch.is_empty() { let sql = format!("SELECT * FROM \"{table}\" WHERE id = $1"); - return sqlx::query_as::<_, T>(&sql).bind(id).fetch_optional(pool).await; + return sqlx::query_as::<_, T>(&sql) + .bind(id) + .fetch_optional(pool) + .await; } let mut qb = QueryBuilder::::new(format!("UPDATE \"{table}\" SET ")); patch.push_into(&mut qb); @@ -289,7 +298,12 @@ pub struct NewScheduledPost { } impl NewScheduledPost { - pub fn new(post_id: impl Into, page_id: impl Into, post_type: PostType, scheduled_at: Ts) -> Self { + pub fn new( + post_id: impl Into, + page_id: impl Into, + post_type: PostType, + scheduled_at: Ts, + ) -> Self { Self { post_id: post_id.into(), page_id: page_id.into(), @@ -351,7 +365,10 @@ impl Db { // ------------------------------------------------------------------ pub async fn get_user(&self, id: &str) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM users WHERE id = $1").bind(id).fetch_optional(&self.pool).await + sqlx::query_as("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_optional(&self.pool) + .await } pub async fn get_user_by_username(&self, username: &str) -> Result, sqlx::Error> { @@ -362,17 +379,26 @@ impl Db { } pub async fn get_all_users(&self) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM users").fetch_all(&self.pool).await + sqlx::query_as("SELECT * FROM users") + .fetch_all(&self.pool) + .await } /// `password` doit déjà être haché. - pub async fn create_user(&self, username: &str, password_hash: &str, role: UserRole) -> Result { - sqlx::query_as("INSERT INTO users (username, password, role) VALUES ($1, $2, $3) RETURNING *") - .bind(username) - .bind(password_hash) - .bind(role) - .fetch_one(&self.pool) - .await + pub async fn create_user( + &self, + username: &str, + password_hash: &str, + role: UserRole, + ) -> Result { + sqlx::query_as( + "INSERT INTO users (username, password, role) VALUES ($1, $2, $3) RETURNING *", + ) + .bind(username) + .bind(password_hash) + .bind(role) + .fetch_one(&self.pool) + .await } pub async fn update_user(&self, id: &str, patch: Patch) -> Result, sqlx::Error> { @@ -380,7 +406,10 @@ impl Db { } pub async fn delete_user(&self, id: &str) -> Result<(), sqlx::Error> { - sqlx::query("DELETE FROM users WHERE id = $1").bind(id).execute(&self.pool).await?; + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; Ok(()) } @@ -421,15 +450,18 @@ impl Db { // ------------------------------------------------------------------ pub async fn get_social_pages(&self, user_id: &str) -> Result, sqlx::Error> { - let pages: Vec = sqlx::query_as("SELECT * FROM social_pages WHERE user_id = $1") - .bind(user_id) - .fetch_all(&self.pool) - .await?; + let pages: Vec = + sqlx::query_as("SELECT * FROM social_pages WHERE user_id = $1") + .bind(user_id) + .fetch_all(&self.pool) + .await?; Ok(pages.into_iter().map(decrypt_page).collect()) } pub async fn get_all_social_pages(&self) -> Result, sqlx::Error> { - let pages: Vec = sqlx::query_as("SELECT * FROM social_pages").fetch_all(&self.pool).await?; + let pages: Vec = sqlx::query_as("SELECT * FROM social_pages") + .fetch_all(&self.pool) + .await?; Ok(pages.into_iter().map(decrypt_page).collect()) } @@ -471,16 +503,36 @@ impl Db { /// Mise à jour partielle ; `access_token` et `refresh_token` sont chiffrés /// ici (une valeur vide n'est pas chiffrée, comme avant). - pub async fn update_social_page(&self, id: &str, patch: Patch) -> Result, sqlx::Error> { + pub async fn update_social_page( + &self, + id: &str, + patch: Patch, + ) -> Result, sqlx::Error> { let patch = patch - .map_text("access_token", |t| if t.is_empty() { t.to_string() } else { encrypt(t) }) - .map_text("refresh_token", |t| if t.is_empty() { t.to_string() } else { encrypt(t) }); - let updated: Option = update_returning(&self.pool, "social_pages", id, &patch).await?; + .map_text("access_token", |t| { + if t.is_empty() { + t.to_string() + } else { + encrypt(t) + } + }) + .map_text("refresh_token", |t| { + if t.is_empty() { + t.to_string() + } else { + encrypt(t) + } + }); + let updated: Option = + update_returning(&self.pool, "social_pages", id, &patch).await?; Ok(updated.map(decrypt_page)) } pub async fn delete_social_page(&self, id: &str) -> Result<(), sqlx::Error> { - sqlx::query("DELETE FROM social_pages WHERE id = $1").bind(id).execute(&self.pool).await?; + sqlx::query("DELETE FROM social_pages WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; Ok(()) } @@ -488,7 +540,12 @@ impl Db { // Médias // ------------------------------------------------------------------ - pub async fn get_media(&self, user_id: &str, limit: Option, offset: Option) -> Result, sqlx::Error> { + pub async fn get_media( + &self, + user_id: &str, + limit: Option, + offset: Option, + ) -> Result, sqlx::Error> { sqlx::query_as( "SELECT * FROM media WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", ) @@ -503,7 +560,10 @@ impl Db { if ids.is_empty() { return Ok(Vec::new()); } - sqlx::query_as("SELECT * FROM media WHERE id = ANY($1)").bind(ids).fetch_all(&self.pool).await + sqlx::query_as("SELECT * FROM media WHERE id = ANY($1)") + .bind(ids) + .fetch_all(&self.pool) + .await } pub async fn get_media_count(&self, user_id: &str) -> Result { @@ -514,7 +574,10 @@ impl Db { } pub async fn get_media_by_id(&self, id: &str) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM media WHERE id = $1").bind(id).fetch_optional(&self.pool).await + sqlx::query_as("SELECT * FROM media WHERE id = $1") + .bind(id) + .fetch_optional(&self.pool) + .await } pub async fn create_media(&self, media: NewMedia) -> Result { @@ -545,7 +608,10 @@ impl Db { } pub async fn delete_media(&self, id: &str) -> Result<(), sqlx::Error> { - sqlx::query("DELETE FROM media WHERE id = $1").bind(id).execute(&self.pool).await?; + sqlx::query("DELETE FROM media WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; Ok(()) } @@ -568,19 +634,32 @@ impl Db { } pub async fn get_post(&self, id: &str) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM posts WHERE id = $1").bind(id).fetch_optional(&self.pool).await + sqlx::query_as("SELECT * FROM posts WHERE id = $1") + .bind(id) + .fetch_optional(&self.pool) + .await } /// Post et médias, dans l'ordre d'affichage. - pub async fn get_post_with_media(&self, id: &str) -> Result)>, sqlx::Error> { + pub async fn get_post_with_media( + &self, + id: &str, + ) -> Result)>, sqlx::Error> { let Some(post) = self.get_post(id).await? else { return Ok(None); }; let links = self.get_post_media(id).await?; let ids: Vec = links.iter().map(|l| l.media_id.clone()).collect(); - let by_id: HashMap = - self.get_media_by_ids(&ids).await?.into_iter().map(|m| (m.id.clone(), m)).collect(); - let media = links.iter().filter_map(|l| by_id.get(&l.media_id).cloned()).collect(); + let by_id: HashMap = self + .get_media_by_ids(&ids) + .await? + .into_iter() + .map(|m| (m.id.clone(), m)) + .collect(); + let media = links + .iter() + .filter_map(|l| by_id.get(&l.media_id).cloned()) + .collect(); Ok(Some((post, media))) } @@ -617,7 +696,9 @@ impl Db { progress: i32, error: Option<&str>, ) -> Result, sqlx::Error> { - let mut patch = Patch::new().set("generation_status", status).set("generation_progress", progress); + let mut patch = Patch::new() + .set("generation_status", status) + .set("generation_progress", progress); if let Some(error) = error { patch = patch.set("generation_error", error); } @@ -625,31 +706,51 @@ impl Db { } pub async fn delete_post(&self, id: &str) -> Result<(), sqlx::Error> { - sqlx::query("DELETE FROM posts WHERE id = $1").bind(id).execute(&self.pool).await?; + sqlx::query("DELETE FROM posts WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; Ok(()) } /// Remplace les médias d'un post (ordre = position dans la liste). - pub async fn update_post_media(&self, post_id: &str, media_ids: &[String]) -> Result<(), sqlx::Error> { - let items: Vec<(String, i32)> = - media_ids.iter().enumerate().map(|(i, id)| (id.clone(), i as i32)).collect(); + pub async fn update_post_media( + &self, + post_id: &str, + media_ids: &[String], + ) -> Result<(), sqlx::Error> { + let items: Vec<(String, i32)> = media_ids + .iter() + .enumerate() + .map(|(i, id)| (id.clone(), i as i32)) + .collect(); self.set_post_media(post_id, &items, true).await } /// Lie des médias à un post avec un ordre explicite ; `replace` efface d'abord les liens existants. - pub async fn set_post_media(&self, post_id: &str, items: &[(String, i32)], replace: bool) -> Result<(), sqlx::Error> { + pub async fn set_post_media( + &self, + post_id: &str, + items: &[(String, i32)], + replace: bool, + ) -> Result<(), sqlx::Error> { let mut tx = self.pool.begin().await?; if replace { - sqlx::query("DELETE FROM post_media WHERE post_id = $1").bind(post_id).execute(&mut *tx).await?; - } - for (media_id, order) in items { - sqlx::query("INSERT INTO post_media (post_id, media_id, display_order) VALUES ($1, $2, $3)") + sqlx::query("DELETE FROM post_media WHERE post_id = $1") .bind(post_id) - .bind(media_id) - .bind(order) .execute(&mut *tx) .await?; } + for (media_id, order) in items { + sqlx::query( + "INSERT INTO post_media (post_id, media_id, display_order) VALUES ($1, $2, $3)", + ) + .bind(post_id) + .bind(media_id) + .bind(order) + .execute(&mut *tx) + .await?; + } tx.commit().await } @@ -663,7 +764,13 @@ impl Db { start: Option, end: Option, ) -> Result, sqlx::Error> { - self.query_scheduled_posts("p.user_id = $1", ScopeBind::Text(user_id.to_string()), start, end).await + self.query_scheduled_posts( + "p.user_id = $1", + ScopeBind::Text(user_id.to_string()), + start, + end, + ) + .await } pub async fn get_scheduled_posts_by_pages( @@ -675,7 +782,13 @@ impl Db { if page_ids.is_empty() { return Ok(Vec::new()); } - self.query_scheduled_posts("sp.page_id = ANY($1)", ScopeBind::List(page_ids.to_vec()), start, end).await + self.query_scheduled_posts( + "sp.page_id = ANY($1)", + ScopeBind::List(page_ids.to_vec()), + start, + end, + ) + .await } async fn query_scheduled_posts( @@ -733,23 +846,36 @@ impl Db { }), None => None, }; - out.push(ScheduledPostWithDetails { scheduled, post, page }); + out.push(ScheduledPostWithDetails { + scheduled, + post, + page, + }); } Ok(out) } pub async fn get_scheduled_post(&self, id: &str) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM scheduled_posts WHERE id = $1").bind(id).fetch_optional(&self.pool).await + sqlx::query_as("SELECT * FROM scheduled_posts WHERE id = $1") + .bind(id) + .fetch_optional(&self.pool) + .await } - pub async fn get_scheduled_posts_by_post(&self, post_id: &str) -> Result, sqlx::Error> { + pub async fn get_scheduled_posts_by_post( + &self, + post_id: &str, + ) -> Result, sqlx::Error> { sqlx::query_as("SELECT * FROM scheduled_posts WHERE post_id = $1") .bind(post_id) .fetch_all(&self.pool) .await } - pub async fn create_scheduled_post(&self, sp: NewScheduledPost) -> Result { + pub async fn create_scheduled_post( + &self, + sp: NewScheduledPost, + ) -> Result { sqlx::query_as( r#"INSERT INTO scheduled_posts (post_id, page_id, post_type, scheduled_at, publish_id, publish_status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *"#, @@ -764,25 +890,36 @@ impl Db { .await } - pub async fn update_scheduled_post(&self, id: &str, patch: Patch) -> Result, sqlx::Error> { + pub async fn update_scheduled_post( + &self, + id: &str, + patch: Patch, + ) -> Result, sqlx::Error> { update_returning(&self.pool, "scheduled_posts", id, &patch).await } pub async fn delete_scheduled_post(&self, id: &str) -> Result<(), sqlx::Error> { - sqlx::query("DELETE FROM scheduled_posts WHERE id = $1").bind(id).execute(&self.pool).await?; + sqlx::query("DELETE FROM scheduled_posts WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; Ok(()) } /// Publications dues et pas encore traitées. pub async fn get_pending_scheduled_posts(&self) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM scheduled_posts WHERE published_at IS NULL AND scheduled_at <= $1") - .bind(Ts::now()) - .fetch_all(&self.pool) - .await + sqlx::query_as( + "SELECT * FROM scheduled_posts WHERE published_at IS NULL AND scheduled_at <= $1", + ) + .bind(Ts::now()) + .fetch_all(&self.pool) + .await } /// Envois TikTok dont le statut final n'est pas encore connu. - pub async fn get_scheduled_posts_awaiting_publish_status(&self) -> Result, sqlx::Error> { + pub async fn get_scheduled_posts_awaiting_publish_status( + &self, + ) -> Result, sqlx::Error> { // `NOT IN` ignore les statuts NULL, comme `notInArray` de Drizzle sqlx::query_as( "SELECT * FROM scheduled_posts WHERE publish_id IS NOT NULL AND publish_status NOT IN ($1, $2, $3)", @@ -817,15 +954,20 @@ impl Db { .bind(&ids) .fetch_all(&self.pool) .await?; - let step_by_post: HashMap> = - steps.into_iter().filter_map(|(post_id, step)| post_id.map(|p| (p, step))).collect(); + let step_by_post: HashMap> = steps + .into_iter() + .filter_map(|(post_id, step)| post_id.map(|p| (p, step))) + .collect(); Ok(posts .into_iter() .map(|post| { let step = step_by_post.get(&post.id).cloned().flatten(); let mut value = serde_json::to_value(&post).unwrap_or(Value::Null); if let Value::Object(map) = &mut value { - map.insert("generationStep".into(), step.map(Value::String).unwrap_or(Value::Null)); + map.insert( + "generationStep".into(), + step.map(Value::String).unwrap_or(Value::Null), + ); } value }) @@ -847,7 +989,10 @@ impl Db { // Générations IA // ------------------------------------------------------------------ - pub async fn get_ai_generations(&self, user_id: &str) -> Result, sqlx::Error> { + pub async fn get_ai_generations( + &self, + user_id: &str, + ) -> Result, sqlx::Error> { sqlx::query_as("SELECT * FROM ai_generations WHERE user_id = $1 ORDER BY created_at DESC") .bind(user_id) .fetch_all(&self.pool) @@ -875,7 +1020,9 @@ impl Db { // ------------------------------------------------------------------ pub async fn get_cloudinary_config(&self) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM cloudinary_config LIMIT 1").fetch_optional(&self.pool).await + sqlx::query_as("SELECT * FROM cloudinary_config LIMIT 1") + .fetch_optional(&self.pool) + .await } pub async fn create_cloudinary_config( @@ -908,9 +1055,14 @@ impl Db { .await? .ok_or_else(|| anyhow::anyhow!("Cloudinary config not initialized"))?; let patch = patch.set("updated_at", Ts::now()); - let updated = update_returning::(&self.pool, "cloudinary_config", &existing.id, &patch) - .await? - .ok_or_else(|| anyhow::anyhow!("Cloudinary config not initialized"))?; + let updated = update_returning::( + &self.pool, + "cloudinary_config", + &existing.id, + &patch, + ) + .await? + .ok_or_else(|| anyhow::anyhow!("Cloudinary config not initialized"))?; Ok(updated) } @@ -918,7 +1070,10 @@ impl Db { // OpenRouter // ------------------------------------------------------------------ - pub async fn get_openrouter_config(&self, user_id: &str) -> Result, sqlx::Error> { + pub async fn get_openrouter_config( + &self, + user_id: &str, + ) -> Result, sqlx::Error> { sqlx::query_as("SELECT * FROM openrouter_config WHERE user_id = $1") .bind(user_id) .fetch_optional(&self.pool) @@ -926,9 +1081,11 @@ impl Db { } pub async fn get_any_openrouter_config(&self) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM openrouter_config ORDER BY updated_at DESC NULLS LAST LIMIT 1") - .fetch_optional(&self.pool) - .await + sqlx::query_as( + "SELECT * FROM openrouter_config ORDER BY updated_at DESC NULLS LAST LIMIT 1", + ) + .fetch_optional(&self.pool) + .await } /// `model` / `system_prompt` absents : valeurs par défaut de la table. @@ -954,7 +1111,11 @@ impl Db { .await } - pub async fn update_openrouter_config(&self, user_id: &str, patch: Patch) -> Result, sqlx::Error> { + pub async fn update_openrouter_config( + &self, + user_id: &str, + patch: Patch, + ) -> Result, sqlx::Error> { let patch = patch.set("updated_at", Ts::now()); let mut qb = QueryBuilder::::new("UPDATE openrouter_config SET "); patch.push_into(&mut qb); @@ -968,29 +1129,45 @@ impl Db { // Permissions des utilisateurs sur les pages // ------------------------------------------------------------------ - pub async fn get_user_page_permissions(&self, user_id: &str) -> Result, sqlx::Error> { + pub async fn get_user_page_permissions( + &self, + user_id: &str, + ) -> Result, sqlx::Error> { sqlx::query_as("SELECT * FROM user_page_permissions WHERE user_id = $1") .bind(user_id) .fetch_all(&self.pool) .await } - pub async fn get_page_permissions(&self, page_id: &str) -> Result, sqlx::Error> { + pub async fn get_page_permissions( + &self, + page_id: &str, + ) -> Result, sqlx::Error> { sqlx::query_as("SELECT * FROM user_page_permissions WHERE page_id = $1") .bind(page_id) .fetch_all(&self.pool) .await } - pub async fn create_page_permission(&self, user_id: &str, page_id: &str) -> Result { - sqlx::query_as("INSERT INTO user_page_permissions (user_id, page_id) VALUES ($1, $2) RETURNING *") - .bind(user_id) - .bind(page_id) - .fetch_one(&self.pool) - .await + pub async fn create_page_permission( + &self, + user_id: &str, + page_id: &str, + ) -> Result { + sqlx::query_as( + "INSERT INTO user_page_permissions (user_id, page_id) VALUES ($1, $2) RETURNING *", + ) + .bind(user_id) + .bind(page_id) + .fetch_one(&self.pool) + .await } - pub async fn delete_page_permission(&self, user_id: &str, page_id: &str) -> Result<(), sqlx::Error> { + pub async fn delete_page_permission( + &self, + user_id: &str, + page_id: &str, + ) -> Result<(), sqlx::Error> { sqlx::query("DELETE FROM user_page_permissions WHERE user_id = $1 AND page_id = $2") .bind(user_id) .bind(page_id) @@ -1008,7 +1185,10 @@ impl Db { } /// Pages attribuées à un utilisateur (jetons déchiffrés). - pub async fn get_user_accessible_pages(&self, user_id: &str) -> Result, sqlx::Error> { + pub async fn get_user_accessible_pages( + &self, + user_id: &str, + ) -> Result, sqlx::Error> { let pages: Vec = sqlx::query_as( r#"SELECT sp.* FROM user_page_permissions upp INNER JOIN social_pages sp ON upp.page_id = sp.id @@ -1033,14 +1213,20 @@ impl Db { // Musique // ------------------------------------------------------------------ - pub async fn get_music_favorites(&self, user_id: &str) -> Result, sqlx::Error> { + pub async fn get_music_favorites( + &self, + user_id: &str, + ) -> Result, sqlx::Error> { sqlx::query_as("SELECT * FROM music_favorites WHERE user_id = $1 ORDER BY created_at DESC") .bind(user_id) .fetch_all(&self.pool) .await } - pub async fn add_music_favorite(&self, fav: NewMusicFavorite) -> Result { + pub async fn add_music_favorite( + &self, + fav: NewMusicFavorite, + ) -> Result { sqlx::query_as( r#"INSERT INTO music_favorites (user_id, track_id, title, artist, album_name, duration, preview_url, download_url, image_url, license) @@ -1060,7 +1246,11 @@ impl Db { .await } - pub async fn remove_music_favorite(&self, user_id: &str, track_id: &str) -> Result<(), sqlx::Error> { + pub async fn remove_music_favorite( + &self, + user_id: &str, + track_id: &str, + ) -> Result<(), sqlx::Error> { sqlx::query("DELETE FROM music_favorites WHERE user_id = $1 AND track_id = $2") .bind(user_id) .bind(track_id) @@ -1069,13 +1259,18 @@ impl Db { Ok(()) } - pub async fn is_music_favorite(&self, user_id: &str, track_id: &str) -> Result { - let found: Option = - sqlx::query_scalar("SELECT id FROM music_favorites WHERE user_id = $1 AND track_id = $2 LIMIT 1") - .bind(user_id) - .bind(track_id) - .fetch_optional(&self.pool) - .await?; + pub async fn is_music_favorite( + &self, + user_id: &str, + track_id: &str, + ) -> Result { + let found: Option = sqlx::query_scalar( + "SELECT id FROM music_favorites WHERE user_id = $1 AND track_id = $2 LIMIT 1", + ) + .bind(user_id) + .bind(track_id) + .fetch_optional(&self.pool) + .await?; Ok(found.is_some()) } @@ -1084,11 +1279,16 @@ impl Db { // ------------------------------------------------------------------ pub async fn get_audio_tracks(&self) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM audio_tracks ORDER BY created_at DESC").fetch_all(&self.pool).await + sqlx::query_as("SELECT * FROM audio_tracks ORDER BY created_at DESC") + .fetch_all(&self.pool) + .await } pub async fn get_audio_track(&self, id: &str) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM audio_tracks WHERE id = $1").bind(id).fetch_optional(&self.pool).await + sqlx::query_as("SELECT * FROM audio_tracks WHERE id = $1") + .bind(id) + .fetch_optional(&self.pool) + .await } pub async fn create_audio_track( @@ -1112,7 +1312,10 @@ impl Db { } pub async fn delete_audio_track(&self, id: &str) -> Result<(), sqlx::Error> { - sqlx::query("DELETE FROM audio_tracks WHERE id = $1").bind(id).execute(&self.pool).await?; + sqlx::query("DELETE FROM audio_tracks WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; Ok(()) } @@ -1121,14 +1324,18 @@ impl Db { // ------------------------------------------------------------------ pub async fn get_app_config(&self) -> Result, sqlx::Error> { - sqlx::query_as("SELECT * FROM app_config LIMIT 1").fetch_optional(&self.pool).await + sqlx::query_as("SELECT * FROM app_config LIMIT 1") + .fetch_optional(&self.pool) + .await } /// Colonnes possibles : external_api_key, gemini_api_key, qwen_tts_url, qwen_tts_api_key. pub async fn upsert_app_config(&self, patch: Patch) -> Result { if let Some(existing) = self.get_app_config().await? { let patch = patch.clone().set("updated_at", Ts::now()); - if let Some(updated) = update_returning(&self.pool, "app_config", &existing.id, &patch).await? { + if let Some(updated) = + update_returning(&self.pool, "app_config", &existing.id, &patch).await? + { return Ok(updated); } } @@ -1147,7 +1354,9 @@ impl Db { pub async fn get_facebook_config(&self) -> Result, sqlx::Error> { let config: Option = - sqlx::query_as("SELECT * FROM facebook_config LIMIT 1").fetch_optional(&self.pool).await?; + sqlx::query_as("SELECT * FROM facebook_config LIMIT 1") + .fetch_optional(&self.pool) + .await?; Ok(config.map(|mut c| { c.app_secret = decrypt(&c.app_secret); c @@ -1155,9 +1364,15 @@ impl Db { } /// Crée ou met à jour ; `app_secret` absent = secret inchangé. - pub async fn upsert_facebook_config(&self, app_id: Option<&str>, app_secret: Option<&str>) -> anyhow::Result { + pub async fn upsert_facebook_config( + &self, + app_id: Option<&str>, + app_secret: Option<&str>, + ) -> anyhow::Result { let existing: Option = - sqlx::query_as("SELECT * FROM facebook_config LIMIT 1").fetch_optional(&self.pool).await?; + sqlx::query_as("SELECT * FROM facebook_config LIMIT 1") + .fetch_optional(&self.pool) + .await?; let secret = app_secret.filter(|s| !s.is_empty()).map(encrypt); let mut row: FacebookConfig = match existing { Some(existing) => { @@ -1174,14 +1389,17 @@ impl Db { .ok_or_else(|| anyhow::anyhow!("configuration Facebook introuvable"))? } None => { - let (Some(app_id), Some(secret)) = (app_id.filter(|s| !s.is_empty()), secret) else { + let (Some(app_id), Some(secret)) = (app_id.filter(|s| !s.is_empty()), secret) + else { anyhow::bail!("L'App ID et l'App Secret Facebook sont requis"); }; - sqlx::query_as("INSERT INTO facebook_config (app_id, app_secret) VALUES ($1, $2) RETURNING *") - .bind(app_id) - .bind(secret) - .fetch_one(&self.pool) - .await? + sqlx::query_as( + "INSERT INTO facebook_config (app_id, app_secret) VALUES ($1, $2) RETURNING *", + ) + .bind(app_id) + .bind(secret) + .fetch_one(&self.pool) + .await? } }; row.app_secret = decrypt(&row.app_secret); @@ -1189,8 +1407,9 @@ impl Db { } pub async fn get_tiktok_config(&self) -> Result, sqlx::Error> { - let config: Option = - sqlx::query_as("SELECT * FROM tiktok_config LIMIT 1").fetch_optional(&self.pool).await?; + let config: Option = sqlx::query_as("SELECT * FROM tiktok_config LIMIT 1") + .fetch_optional(&self.pool) + .await?; Ok(config.map(|mut c| { c.client_secret = decrypt(&c.client_secret); c @@ -1198,9 +1417,14 @@ impl Db { } /// Crée ou met à jour ; `client_secret` absent = secret inchangé. - pub async fn upsert_tiktok_config(&self, client_key: Option<&str>, client_secret: Option<&str>) -> anyhow::Result { - let existing: Option = - sqlx::query_as("SELECT * FROM tiktok_config LIMIT 1").fetch_optional(&self.pool).await?; + pub async fn upsert_tiktok_config( + &self, + client_key: Option<&str>, + client_secret: Option<&str>, + ) -> anyhow::Result { + let existing: Option = sqlx::query_as("SELECT * FROM tiktok_config LIMIT 1") + .fetch_optional(&self.pool) + .await?; let secret = client_secret.filter(|s| !s.is_empty()).map(encrypt); let mut row: TiktokConfig = match existing { Some(existing) => { @@ -1217,7 +1441,8 @@ impl Db { .ok_or_else(|| anyhow::anyhow!("configuration TikTok introuvable"))? } None => { - let (Some(key), Some(secret)) = (client_key.filter(|s| !s.is_empty()), secret) else { + let (Some(key), Some(secret)) = (client_key.filter(|s| !s.is_empty()), secret) + else { anyhow::bail!("Le client key et le client secret TikTok sont requis"); }; sqlx::query_as("INSERT INTO tiktok_config (client_key, client_secret) VALUES ($1, $2) RETURNING *") diff --git a/server/src/db/models.rs b/server/src/db/models.rs index 1a687ff..e4a491b 100644 --- a/server/src/db/models.rs +++ b/server/src/db/models.rs @@ -104,7 +104,10 @@ impl SocialPage { 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()), + _ => 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)); diff --git a/server/src/error.rs b/server/src/error.rs index 1f5a5c4..539b667 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -16,7 +16,10 @@ pub type ApiResult = Result; impl AppError { /// Réponse `{ error: message }` avec le statut donné. pub fn new(status: StatusCode, message: impl Into) -> Self { - Self { status, body: json!({ "error": message.into() }) } + Self { + status, + body: json!({ "error": message.into() }), + } } /// Corps JSON libre (ex. `{ success: false, error }`). diff --git a/server/src/http.rs b/server/src/http.rs index c01c61e..d2333fc 100644 --- a/server/src/http.rs +++ b/server/src/http.rs @@ -30,7 +30,11 @@ pub struct RateDecision { impl RateLimiter { pub fn new(max: u32, window: Duration) -> Self { - Self { max, window, hits: Mutex::new(HashMap::new()) } + Self { + max, + window, + hits: Mutex::new(HashMap::new()), + } } pub fn check(&self, ip: IpAddr) -> RateDecision { @@ -59,7 +63,7 @@ impl RateLimiter { /// sinon l'adresse de la connexion. pub fn client_ip(headers: &HeaderMap, peer: Option) -> 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 Some(ip) = xff.split(',').map(str::trim).rfind(|s| !s.is_empty()) { if let Ok(ip) = ip.parse() { return ip; } @@ -73,7 +77,10 @@ pub async fn rate_limit(State(state): State, request: Request, next: N if !path.starts_with("/api/") { return next.run(request).await; } - let peer = request.extensions().get::>().map(|c| c.0); + let peer = request + .extensions() + .get::>() + .map(|c| c.0); let ip = client_ip(request.headers(), peer); let decision = state.global_limiter.check(ip); @@ -83,7 +90,10 @@ pub async fn rate_limit(State(state): State, request: Request, next: N 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"); + return too_many( + &auth, + "Trop de tentatives de connexion, réessayez dans 15 minutes", + ); } } @@ -96,7 +106,11 @@ pub async fn rate_limit(State(state): State, request: Request, next: N } fn too_many(decision: &RateDecision, message: &str) -> Response { - let mut response = (StatusCode::TOO_MANY_REQUESTS, axum::Json(json!({ "error": message }))).into_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)); @@ -132,10 +146,20 @@ pub async fn security_headers(request: Request, next: Next) -> Response { 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( + "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( + "strict-transport-security", + "max-age=31536000; includeSubDomains", + ); set("x-content-type-options", "nosniff"); set("x-dns-prefetch-control", "off"); set("x-download-options", "noopen"); @@ -157,7 +181,11 @@ pub async fn log_requests(request: Request, next: Next) -> Response { 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()); + tracing::info!( + "{method} {path} {} in {}ms", + status.as_u16(), + started.elapsed().as_millis() + ); } response } diff --git a/server/src/migrate.rs b/server/src/migrate.rs index ac128b9..21de9c9 100644 --- a/server/src/migrate.rs +++ b/server/src/migrate.rs @@ -255,23 +255,74 @@ const COLUMNS: &[&str] = &[ /// 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")"#), + ( + "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) { @@ -281,7 +332,11 @@ pub async fn run(pool: &PgPool) { 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::>().join(" "); + let head: String = statement + .split_whitespace() + .take(6) + .collect::>() + .join(" "); tracing::warn!("[Migration] Échec de « {head} … » : {error}"); } } diff --git a/server/src/public_url.rs b/server/src/public_url.rs index a073959..727147b 100644 --- a/server/src/public_url.rs +++ b/server/src/public_url.rs @@ -25,9 +25,11 @@ pub fn resolve_public_base_url(state: &AppState, headers: Option<&HeaderMap>) -> return url.clone(); } if let Some(headers) = headers { - let host = first_header_value(headers, "x-forwarded-host").or_else(|| first_header_value(headers, "host")); + 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()); + let protocol = + first_header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".into()); return format!("{protocol}://{host}"); } } diff --git a/server/src/routes/auth.rs b/server/src/routes/auth.rs index d67b887..77df863 100644 --- a/server/src/routes/auth.rs +++ b/server/src/routes/auth.rs @@ -17,7 +17,10 @@ pub fn router() -> Router { .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)) + .route( + "/api/auth/default-password-status", + get(default_password_status), + ) } async fn login( @@ -28,8 +31,14 @@ async fn login( 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 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")); }; @@ -53,14 +62,19 @@ async fn login( Ok(auth::with_cookie(auth::user_json(&user), cookie)) } -async fn logout(axum::extract::State(state): axum::extract::State, headers: HeaderMap) -> ApiResult { +async fn logout( + axum::extract::State(state): axum::extract::State, + 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()); + response + .headers_mut() + .append(SET_COOKIE, auth::clear_session_cookie()); Ok(response) } @@ -72,7 +86,9 @@ async fn session(MaybeUser(user): MaybeUser) -> Response { } /// Le mot de passe de `admin` est-il toujours « admin » ? -async fn default_password_status(axum::extract::State(state): axum::extract::State) -> ApiResult> { +async fn default_password_status( + axum::extract::State(state): axum::extract::State, +) -> ApiResult> { let admin = state .db .get_user_by_username("admin") diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 584bdea..6d718a4 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,8 +1,8 @@ //! Assemblage des routes HTTP. +mod analytics; mod auth; mod core; -mod analytics; mod external; mod facebook; mod legal; @@ -35,7 +35,11 @@ pub fn router(state: AppState) -> Router { .merge(legal::router()); let config = &state.config; - for dir in [&config.uploads_dir, &config.audio_upload_dir, &config.temp_upload_dir] { + 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"] { @@ -51,7 +55,10 @@ pub fn router(state: AppState) -> Router { 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_with_state( + state.clone(), + http::rate_limit, + )) .layer(middleware::from_fn(http::log_requests)) .layer(middleware::from_fn(http::security_headers)) .with_state(state) @@ -59,7 +66,10 @@ pub fn router(state: AppState) -> Router { /// Sonde de santé (Docker) : le serveur répond et la base est joignable. async fn healthz(State(state): State) -> Response { - match sqlx::query_scalar::<_, i32>("SELECT 1").fetch_one(&state.db.pool).await { + match sqlx::query_scalar::<_, i32>("SELECT 1") + .fetch_one(&state.db.pool) + .await + { Ok(_) => (StatusCode::OK, "ok").into_response(), Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "database unavailable").into_response(), } @@ -70,16 +80,27 @@ async fn fallback(State(state): State, headers: HeaderMap, request: Re 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() + ( + StatusCode::UNAUTHORIZED, + axum::Json(json!({ "error": "Non authentifié" })), + ) + .into_response() } else { - (StatusCode::NOT_FOUND, axum::Json(json!({ "error": "Route introuvable" }))).into_response() + ( + 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('/')); + let candidate = state + .config + .public_root_dir + .join(path.trim_start_matches('/')); if candidate.is_file() { return serve(ServeFile::new(candidate), request).await; } diff --git a/server/src/services/audio.rs b/server/src/services/audio.rs index 7bb21c8..2ace75b 100644 --- a/server/src/services/audio.rs +++ b/server/src/services/audio.rs @@ -57,7 +57,14 @@ pub async fn fix_audio_track_encoding(state: &AppState) -> usize { /// 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"]) + .args([ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + ]) .arg(path) .output() .await; diff --git a/server/src/services/ffmpeg.rs b/server/src/services/ffmpeg.rs index b771b70..0fb6133 100644 --- a/server/src/services/ffmpeg.rs +++ b/server/src/services/ffmpeg.rs @@ -39,6 +39,9 @@ pub async fn health_check(state: &AppState) -> bool { } /// Voix disponibles ; `qwen` remplace la cible Qwen configurée côté service. -pub async fn list_voices(state: &AppState, qwen: Option) -> anyhow::Result { +pub async fn list_voices( + state: &AppState, + qwen: Option, +) -> anyhow::Result { anyhow::bail!("service FFmpeg non implémenté") } diff --git a/server/src/services/media_store.rs b/server/src/services/media_store.rs index 4bfb7f2..8eb38bc 100644 --- a/server/src/services/media_store.rs +++ b/server/src/services/media_store.rs @@ -17,7 +17,11 @@ 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"); + let base = state + .config + .app_url + .as_deref() + .unwrap_or("http://localhost:5555"); format!("{}{url}", base.trim_end_matches('/')) } @@ -40,8 +44,13 @@ pub fn resolve_internal_url(state: &AppState, url: &str) -> String { pub fn local_path(state: &AppState, url: &str) -> Option { 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()) { + 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/") { @@ -78,7 +87,13 @@ fn safe_name(file_name: &str) -> String { .and_then(|n| n.to_str()) .unwrap_or("file"); base.chars() - .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '-' }) + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '-' + } + }) .collect() } @@ -111,7 +126,11 @@ pub enum MediaSource<'a> { Bytes(&'a [u8]), } -pub async fn upload_media(state: &AppState, source: MediaSource<'_>, file_name: &str) -> anyhow::Result { +pub async fn upload_media( + state: &AppState, + source: MediaSource<'_>, + file_name: &str, +) -> anyhow::Result { 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() { @@ -148,14 +167,26 @@ pub async fn delete_media(state: &AppState, public_id: &str) -> anyhow::Result<( } /// 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 { - let object_key = format!("stories/story-{}-{}", now_ms(), safe_name(original_file_name)); +pub async fn upload_story_image( + state: &AppState, + data: &[u8], + original_file_name: &str, +) -> anyhow::Result { + 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)> { +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); @@ -163,7 +194,11 @@ pub async fn upload_thumbnail(state: &AppState, data: &[u8], file_name: &str) -> } /// Logo de l'entreprise ; renvoie (clé, URL). -pub async fn upload_logo(state: &AppState, data: &[u8], file_name: &str) -> anyhow::Result<(String, String)> { +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()) diff --git a/server/src/services/thumbnail.rs b/server/src/services/thumbnail.rs index e6ab732..59382c8 100644 --- a/server/src/services/thumbnail.rs +++ b/server/src/services/thumbnail.rs @@ -39,7 +39,10 @@ pub async fn generate_video_thumbnail(video: &Path, output: &Path, seek_time: f6 /// 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 { +pub async fn create_video_thumbnail( + state: &AppState, + source: ThumbnailSource<'_>, +) -> Option { let temp_dir = state.config.uploads_dir.join("temp"); if tokio::fs::create_dir_all(&temp_dir).await.is_err() { return None; @@ -72,13 +75,17 @@ pub async fn create_video_thumbnail(state: &AppState, source: ThumbnailSource<'_ 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 + 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 { diff --git a/server/src/state.rs b/server/src/state.rs index aafd0f3..11d2e49 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -49,7 +49,10 @@ impl AppState { .build()?; let ffmpeg = FfmpegSettings { - api_url: config.ffmpeg_api_url.as_ref().map(|u| u.trim_end_matches('/').to_string()), + 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() { diff --git a/server/src/time.rs b/server/src/time.rs index bbb67c6..43498e9 100644 --- a/server/src/time.rs +++ b/server/src/time.rs @@ -24,15 +24,15 @@ impl Ts { Ts(dt.naive_utc()) } - pub fn utc(&self) -> DateTime { + pub fn utc(self) -> DateTime { self.0.and_utc() } - pub fn to_iso(&self) -> String { + pub fn to_iso(self) -> String { self.0.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() } - pub fn timestamp_millis(&self) -> i64 { + pub fn timestamp_millis(self) -> i64 { self.0.and_utc().timestamp_millis() } @@ -40,7 +40,7 @@ impl Ts { DateTime::::from_timestamp_millis(ms).map(Ts::from_utc) } - pub fn add(&self, d: Duration) -> Self { + pub fn add(self, d: Duration) -> Self { Ts(self.0 + d) } @@ -68,9 +68,8 @@ impl<'de> Deserialize<'de> for Ts { fn deserialize>(deserializer: D) -> Result { 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::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)) @@ -102,7 +101,11 @@ pub fn parse_js_date(value: &str) -> Option { 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"] { + 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))); } @@ -149,8 +152,14 @@ mod tests { #[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_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()); } } diff --git a/server/src/upload.rs b/server/src/upload.rs index dd86dc3..1ec696f 100644 --- a/server/src/upload.rs +++ b/server/src/upload.rs @@ -35,7 +35,13 @@ pub struct UploadRules<'a> { pub fn disk_name(original: &str) -> String { let safe: String = original .chars() - .map(|c| if c.is_ascii_alphanumeric() || c == '.' { c } else { '_' }) + .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); @@ -103,7 +109,10 @@ pub async fn receive_files( { size += chunk.len() as u64; if size > rules.max_file_size { - return Err(AppError::new(axum::http::StatusCode::PAYLOAD_TOO_LARGE, "File too large")); + return Err(AppError::new( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + "File too large", + )); } out.write_all(&chunk) .await