diff --git a/server/Cargo.lock b/server/Cargo.lock index b4c7eff..3feee40 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -2995,6 +2995,7 @@ dependencies = [ "bytes", "chrono", "futures", + "harfrust", "hex", "hmac", "image", @@ -3008,6 +3009,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "skrifa", "sqlx", "thiserror", "tokio", diff --git a/server/Cargo.toml b/server/Cargo.toml index 597248a..b8a02ef 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -18,6 +18,7 @@ bcrypt = "0.19.3" bytes = "1.12.1" chrono = { version = "0.4.45", features = ["serde"] } futures = "0.3.34" +harfrust = "0.12" hex = "0.4.3" hmac = "0.12" image = "0.25.10" @@ -31,6 +32,7 @@ scrypt = "0.11" serde = { version = "1.0.229", features = ["derive"] } serde_json = { version = "1.0.154", features = ["preserve_order"] } sha2 = "0.10" +skrifa = "0.44" 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"] } diff --git a/server/src/routes/analytics.rs b/server/src/routes/analytics.rs index f189c4f..12094d2 100644 --- a/server/src/routes/analytics.rs +++ b/server/src/routes/analytics.rs @@ -1,9 +1,109 @@ -//! STUB — routes à porter (voir `server/routes/analytics.ts`). +//! Statistiques des posts et des pages (ex-`routes/analytics.ts`). +//! +//! Routeur monté sous `/api/analytics` ; toutes les routes exigent une session. -use axum::Router; +use axum::extract::{Path, State}; +use axum::http::header::CONTENT_TYPE; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; +use crate::auth::AuthUser; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::services::{analytics, token_manager}; use crate::state::AppState; pub fn router() -> Router { Router::new() + .route("/posts/{post_id}", get(get_post_analytics)) + .route("/posts/{post_id}/refresh", post(refresh_post_analytics)) + .route("/pages/{page_id}/history", get(get_page_history)) + .route("/pages/{page_id}/refresh", post(refresh_page_analytics)) + .route("/tokens/check", post(check_tokens)) +} + +/// `res.json(undefined)` d'Express : 200 avec un corps vide. +fn json_or_empty(value: Option) -> Response { + match value { + Some(value) => Json(value).into_response(), + None => ([(CONTENT_TYPE, "application/json; charset=utf-8")], "").into_response(), + } +} + +async fn get_post_analytics( + State(state): State, + _user: AuthUser, + Path(post_id): Path, +) -> ApiResult> { + let analytics = analytics::get_post_analytics(&state, &post_id) + .await + .or_500("Failed to fetch post analytics")?; + Ok(Json(match analytics { + Some(row) => serde_json::to_value(row).or_500("Failed to fetch post analytics")?, + None => json!({}), + })) +} + +async fn refresh_post_analytics( + State(state): State, + _user: AuthUser, + Path(post_id): Path, +) -> ApiResult { + const MESSAGE: &str = "Failed to refresh post analytics"; + analytics::sync_post_analytics(&state, &post_id) + .await + .or_500(MESSAGE)?; + let updated = analytics::get_post_analytics(&state, &post_id) + .await + .or_500(MESSAGE)?; + let value = updated + .map(serde_json::to_value) + .transpose() + .or_500(MESSAGE)?; + Ok(json_or_empty(value)) +} + +async fn get_page_history( + State(state): State, + _user: AuthUser, + Path(page_id): Path, +) -> ApiResult> { + let history = analytics::get_page_history(&state, &page_id) + .await + .or_500("Failed to fetch page history")?; + Ok(Json( + serde_json::to_value(history).or_500("Failed to fetch page history")?, + )) +} + +async fn refresh_page_analytics( + State(state): State, + _user: AuthUser, + Path(page_id): Path, +) -> ApiResult { + let fail = |error: String| { + tracing::error!("Refresh error: {error}"); + AppError::internal(if error.is_empty() { + "Failed to refresh page analytics".to_string() + } else { + error + }) + }; + analytics::sync_page_analytics(&state, &page_id) + .await + .map_err(|e| fail(e.to_string()))?; + let page = state + .db + .get_social_page(&page_id) + .await + .map_err(|e| fail(e.to_string()))?; + // Page sans ses jetons (jamais envoyés au navigateur) + Ok(json_or_empty(page.map(|p| p.to_client()))) +} + +/// Contrôle manuel des jetons. +async fn check_tokens(State(state): State, _user: AuthUser) -> Json { + token_manager::check_and_refresh_tokens(&state).await; + Json(json!({ "success": true, "message": "Token check completed" })) } diff --git a/server/src/routes/facebook.rs b/server/src/routes/facebook.rs index f149911..ce5c9ee 100644 --- a/server/src/routes/facebook.rs +++ b/server/src/routes/facebook.rs @@ -1,9 +1,544 @@ -//! STUB — routes à porter (voir `server/routes/facebook.ts`). +//! Connexion et renouvellement des pages Facebook / Instagram (ex-`routes/facebook.ts`). +//! +//! Une page connectée ici embarque le jeton utilisateur longue durée qui a servi +//! à l'obtenir : c'est ce qui permet au gestionnaire de jetons de régénérer son +//! jeton de page tout seul, au lieu d'exiger un copier-coller depuis le Graph +//! Explorer tous les deux mois. +//! +//! Routeur monté sous `/api/facebook` ; toutes les routes exigent une session. -use axum::Router; +use axum::extract::{Path, State}; +use axum::http::header::LOCATION; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use rand::RngCore; +use serde_json::{json, Value}; +use crate::auth::AuthUser; +use crate::config::Config; +use crate::crypto::encrypt; +use crate::db::{NewSocialPage, Patch, Platform, TokenStatus}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{Body, QueryMap}; +use crate::services::facebook::oauth; +use crate::services::token_manager; use crate::state::AppState; +use crate::time::Ts; + +/// Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. +const RETURN_PATH: &str = "/pages"; + +/// Clé de session de l'état OAuth (protection CSRF). +const SESSION_STATE_KEY: &str = "facebookOAuthState"; + +const ADMIN_ONLY: &str = "Accès refusé. Réservé aux administrateurs."; pub fn router() -> Router { Router::new() + .route("/config", get(get_config).put(put_config)) + .route("/connect", get(connect)) + .route("/callback", get(callback)) + .route("/pages/{id}/refresh", post(refresh_page)) + .route("/tokens/check", post(check_tokens)) +} + +pub fn build_return_url(success: bool, message: &str) -> String { + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("facebook", if success { "success" } else { "error" }) + .append_pair("message", message) + .finish(); + format!("{RETURN_PATH}?{query}") +} + +/// Redirection 302, comme `res.redirect` d'Express. +fn redirect(location: &str) -> Response { + match location.parse::() { + Ok(value) => (StatusCode::FOUND, [(LOCATION, value)]).into_response(), + Err(_) => (StatusCode::FOUND, [(LOCATION, RETURN_PATH)]).into_response(), + } +} + +fn redirect_error(message: &str) -> Response { + redirect(&build_return_url(false, message)) +} + +/// État de la configuration Facebook. L'App Secret n'est jamais renvoyé. +async fn get_config( + State(state): State, + user: AuthUser, + headers: HeaderMap, +) -> ApiResult> { + let config = state + .db + .get_facebook_config() + .await + .or_500("Impossible de lire la configuration Facebook")?; + let configured = oauth::is_configured(&state).await; + + // Tout le monde a besoin de savoir si la connexion est possible ; seuls les + // admins voient les détails de l'application développeur. + if !user.is_admin() { + return Ok(Json(json!({ "configured": configured }))); + } + + let app_id = config + .as_ref() + .map(|c| c.app_id.clone()) + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| { + if Config::var("FACEBOOK_APP_ID").is_some() { + "(défini par variable d'environnement)".to_string() + } else { + String::new() + } + }); + let has_app_secret = config.as_ref().is_some_and(|c| !c.app_secret.is_empty()) + || Config::var("FACEBOOK_APP_SECRET").is_some(); + + Ok(Json(json!({ + "configured": configured, + "appId": app_id, + "hasAppSecret": has_app_secret, + "redirectUri": oauth::redirect_uri(&state, Some(&headers)), + "scopes": oauth::scopes().split(',').collect::>(), + }))) +} + +fn js_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// Champ texte validé comme `z.string().trim().min(1, message)` ; +/// `Ok(None)` = absent et facultatif. +fn required_string( + body: &Value, + key: &str, + empty_message: &str, + optional: bool, +) -> Result, String> { + match body.get(key) { + None if optional => Ok(None), + None => Err("Required".to_string()), + Some(Value::String(s)) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + Err(empty_message.to_string()) + } else { + Ok(Some(trimmed.to_string())) + } + } + Some(other) => Err(format!("Expected string, received {}", js_type(other))), + } +} + +/// Valide le corps de `PUT /config` ; renvoie (appId, appSecret facultatif). +pub fn validate_config_body( + body: &Value, + secret_optional: bool, +) -> Result<(String, Option), String> { + if !body.is_object() { + return Err(format!("Expected object, received {}", js_type(body))); + } + let app_id = required_string(body, "appId", "L'App ID ne peut pas être vide", false); + let app_secret = required_string( + body, + "appSecret", + "L'App Secret ne peut pas être vide", + secret_optional, + ); + // Première erreur dans l'ordre du schéma + let app_id = app_id?.unwrap_or_default(); + let app_secret = app_secret?; + Ok((app_id, app_secret)) +} + +/// Enregistre les identifiants de l'application Facebook (admin). +async fn put_config( + State(state): State, + user: AuthUser, + headers: HeaderMap, + Body(body): Body, +) -> ApiResult> { + if !user.is_admin() { + return Err(AppError::forbidden(ADMIN_ONLY)); + } + + let bad_request = |message: String| { + tracing::error!("❌ [Facebook] Erreur enregistrement configuration: {message}"); + AppError::bad_request(if message.is_empty() { + "Configuration Facebook invalide".to_string() + } else { + message + }) + }; + + let existing = state + .db + .get_facebook_config() + .await + .map_err(|e| bad_request(e.to_string()))?; + // Le secret peut être omis lors d'une mise à jour : on garde l'existant + let (app_id, app_secret) = + validate_config_body(&body, existing.is_some()).map_err(bad_request)?; + + let config = state + .db + .upsert_facebook_config(Some(&app_id), app_secret.as_deref()) + .await + .map_err(|e| bad_request(e.to_string()))?; + + Ok(Json(json!({ + "success": true, + "appId": config.app_id, + "redirectUri": oauth::redirect_uri(&state, Some(&headers)), + }))) +} + +/// Démarre la connexion des pages Facebook. À appeler par navigation +/// complète du navigateur (window.location), pas en fetch. +async fn connect(State(state): State, user: AuthUser, headers: HeaderMap) -> Response { + if !oauth::is_configured(&state).await { + return redirect_error( + "Facebook n'est pas configuré. Renseignez d'abord l'App ID et l'App Secret.", + ); + } + + // Protection CSRF : l'état est vérifié au retour + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + let oauth_state = hex::encode(bytes); + + let auth_url = match oauth::build_authorization_url(&state, &oauth_state, Some(&headers)).await + { + Ok(url) => url, + Err(error) => { + tracing::error!("❌ [Facebook] Erreur démarrage OAuth: {error:#}"); + return redirect_error(&error.to_string()); + } + }; + + // La session doit être écrite avant la redirection, sinon l'état est perdu + let mut session = user.session; + session.set(SESSION_STATE_KEY, Value::String(oauth_state)); + if let Err(error) = session.save(&state).await { + tracing::error!("❌ [Facebook] Impossible de sauvegarder la session OAuth: {error}"); + return redirect_error("Erreur de session, réessayez."); + } + + redirect(&auth_url) +} + +/// Résumé affiché après la connexion (« 2 pages connectées, 1 page mise à jour »). +pub fn connection_summary(created: usize, updated: usize) -> String { + let plural = |n: usize| if n > 1 { "s" } else { "" }; + let mut parts = Vec::new(); + if created > 0 { + parts.push(format!( + "{created} page{} connectée{}", + plural(created), + plural(created) + )); + } + if updated > 0 { + parts.push(format!( + "{updated} page{} mise{} à jour", + plural(updated), + plural(updated) + )); + } + parts.join(", ") +} + +/// Retour d'autorisation Facebook : échange le code contre un jeton utilisateur +/// longue durée, puis crée ou met à jour chaque page administrée. +async fn callback( + State(state): State, + user: AuthUser, + headers: HeaderMap, + query: QueryMap, +) -> Response { + if let Some(oauth_error) = query.non_empty("error") { + let description = query.non_empty("error_description").unwrap_or(oauth_error); + return redirect_error(&format!("Autorisation Facebook refusée : {description}")); + } + + let AuthUser { user, mut session } = user; + let expected_state = session.get_str(SESSION_STATE_KEY); + if session.remove(SESSION_STATE_KEY).is_some() { + if let Err(error) = session.save(&state).await { + tracing::warn!( + "⚠️ [Facebook] Impossible d'effacer l'état OAuth de la session: {error}" + ); + } + } + + let received_state = query.non_empty("state"); + let state_ok = matches!( + (received_state, expected_state.as_deref()), + (Some(received), Some(expected)) if !expected.is_empty() && received == expected + ); + if !state_ok { + return redirect_error("Requête de connexion Facebook invalide (état non reconnu)."); + } + + let Some(code) = query.non_empty("code") else { + return redirect_error("Aucun code d'autorisation reçu de Facebook."); + }; + + match connect_pages(&state, &user, code, &headers).await { + Ok(Some(summary)) => { + tracing::info!("✅ [Facebook] {summary} pour {}", user.username); + redirect(&build_return_url( + true, + &format!("{summary}. Les jetons se renouvellent désormais automatiquement."), + )) + } + Ok(None) => redirect_error( + "Aucune page Facebook administrée par ce compte. Connectez-vous avec un compte administrateur de la page.", + ), + Err(error) => { + tracing::error!("❌ [Facebook] Erreur callback OAuth: {error:#}"); + redirect_error(&error.to_string()) + } + } +} + +/// Crée ou met à jour les pages issues de l'autorisation ; `None` quand le +/// compte n'administre aucune page. +async fn connect_pages( + state: &AppState, + user: &crate::db::User, + code: &str, + headers: &HeaderMap, +) -> anyhow::Result> { + let user_token = oauth::exchange_code_for_user_token(state, code, Some(headers)).await?; + let targets = oauth::list_pages(state, &user_token.access_token).await?; + if targets.is_empty() { + return Ok(None); + } + + // Le jeton utilisateur est la clé du renouvellement automatique : il est + // conservé chiffré, sur chaque page issue de cette autorisation. + let encrypted_user_token = encrypt(&user_token.access_token); + let existing_pages = state.db.get_social_pages(&user.id).await?; + + let mut created = 0; + let mut updated = 0; + + for target in &targets { + let existing = existing_pages + .iter() + .find(|p| p.platform == Platform::Facebook && p.page_id == target.id); + + if let Some(existing) = existing { + state + .db + .update_social_page( + &existing.id, + Patch::new() + .set("page_name", target.name.clone()) + .set("access_token", target.access_token.clone()) + // Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire pas. + .set("token_expires_at", None::) + .set("user_access_token", encrypted_user_token.clone()) + .set("user_token_expires_at", user_token.expires_at) + .set("followers_count", target.followers_count) + .set("token_status", TokenStatus::Valid) + .set_null("token_error") + .set("last_token_check", Ts::now()) + .set("is_active", "true"), + ) + .await?; + updated += 1; + continue; + } + + let page = state + .db + .create_social_page(NewSocialPage { + user_id: user.id.clone(), + platform: Some(Platform::Facebook), + page_id: target.id.clone(), + page_name: target.name.clone(), + access_token: target.access_token.clone(), + token_expires_at: None, + user_access_token: Some(encrypted_user_token.clone()), + user_token_expires_at: user_token.expires_at, + followers_count: Some(target.followers_count), + is_active: Some("true".to_string()), + ..Default::default() + }) + .await?; + created += 1; + + // Les non-admins ne voient que les pages qui leur sont explicitement + // attribuées : sans cette permission, ils ne verraient pas leur propre page. + if !user.is_admin() { + if let Err(error) = state.db.create_page_permission(&user.id, &page.id).await { + tracing::error!("⚠️ [Facebook] Impossible de créer la permission de page: {error}"); + } + } + } + + // Un compte Instagram professionnel publie avec le jeton de la page Facebook + // à laquelle il est rattaché : on le rafraîchit dans la foulée, sans quoi il + // resterait sur un jeton saisi à la main, condamné à expirer. + for page in existing_pages + .iter() + .filter(|p| p.platform == Platform::Instagram) + { + let Some(linked) = targets + .iter() + .find(|t| t.instagram.as_ref().is_some_and(|ig| ig.id == page.page_id)) + else { + continue; + }; + state + .db + .update_social_page( + &page.id, + Patch::new() + .set("access_token", linked.access_token.clone()) + .set("token_expires_at", None::) + .set("user_access_token", encrypted_user_token.clone()) + .set("user_token_expires_at", user_token.expires_at) + .set("token_status", TokenStatus::Valid) + .set_null("token_error") + .set("last_token_check", Ts::now()), + ) + .await?; + updated += 1; + } + + Ok(Some(connection_summary(created, updated))) +} + +/// Contrôle (et renouvelle si besoin) le jeton d'une page à la demande. +async fn refresh_page( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult> { + let bad_gateway = |message: String| { + tracing::error!("❌ [Facebook] Erreur renouvellement du jeton: {message}"); + AppError::new( + StatusCode::BAD_GATEWAY, + if message.is_empty() { + "Impossible de contrôler le jeton".to_string() + } else { + message + }, + ) + }; + + let page = state + .db + .get_social_page(&id) + .await + .map_err(|e| bad_gateway(e.to_string()))? + .ok_or_else(|| AppError::not_found("Page non trouvée"))?; + + if !user.is_admin() && page.user_id != user.id { + let accessible = state + .db + .get_user_accessible_pages(&user.id) + .await + .map_err(|e| bad_gateway(e.to_string()))?; + if !accessible.iter().any(|p| p.id == page.id) { + return Err(AppError::forbidden("Accès refusé à cette page")); + } + } + + let result = token_manager::check_page(&state, &page.id) + .await + .map_err(|e| bad_gateway(e.to_string()))?; + Ok(Json(serde_json::to_value(result).or_500_cause()?)) +} + +/// Relance le contrôle de toutes les pages (admin). +async fn check_tokens(State(state): State, user: AuthUser) -> ApiResult> { + if !user.is_admin() { + return Err(AppError::forbidden(ADMIN_ONLY)); + } + + let results = token_manager::check_and_refresh_tokens(&state).await; + let renewed = results.iter().filter(|r| r.renewed).count(); + let failing = results + .iter() + .filter(|r| r.status == "expired" || r.status == "error") + .count(); + Ok(Json(json!({ + "checked": results.len(), + "renewed": renewed, + "failing": failing, + "results": results, + }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn return_url_is_encoded_like_url_search_params() { + assert_eq!( + build_return_url(false, "Erreur de session, réessayez."), + "/pages?facebook=error&message=Erreur+de+session%2C+r%C3%A9essayez." + ); + assert!(build_return_url(true, "ok").starts_with("/pages?facebook=success&message=ok")); + } + + #[test] + fn summary_wording() { + assert_eq!(connection_summary(1, 0), "1 page connectée"); + assert_eq!(connection_summary(2, 0), "2 pages connectées"); + assert_eq!(connection_summary(0, 1), "1 page mise à jour"); + assert_eq!( + connection_summary(3, 2), + "3 pages connectées, 2 pages mises à jour" + ); + assert_eq!(connection_summary(0, 0), ""); + } + + #[test] + fn config_validation_matches_zod_messages() { + let ok = + validate_config_body(&json!({ "appId": " 123 ", "appSecret": " s " }), false).unwrap(); + assert_eq!(ok, ("123".to_string(), Some("s".to_string()))); + + assert_eq!( + validate_config_body(&json!({}), false).unwrap_err(), + "Required" + ); + assert_eq!( + validate_config_body(&json!({ "appId": " " }), true).unwrap_err(), + "L'App ID ne peut pas être vide" + ); + assert_eq!( + validate_config_body(&json!({ "appId": "1" }), false).unwrap_err(), + "Required" + ); + assert_eq!( + validate_config_body(&json!({ "appId": "1", "appSecret": "" }), true).unwrap_err(), + "L'App Secret ne peut pas être vide" + ); + assert_eq!( + validate_config_body(&json!({ "appId": 12 }), true).unwrap_err(), + "Expected string, received number" + ); + // Secret facultatif lors d'une mise à jour + assert_eq!( + validate_config_body(&json!({ "appId": "1" }), true).unwrap(), + ("1".to_string(), None) + ); + } } diff --git a/server/src/services/analytics.rs b/server/src/services/analytics.rs index af1e589..ffa056e 100644 --- a/server/src/services/analytics.rs +++ b/server/src/services/analytics.rs @@ -1,11 +1,468 @@ -//! Statistiques des posts et des pages (API Graph). -//! -//! STUB — à porter depuis `server/services/analytics.ts`. -#![allow(unused_variables)] +//! Statistiques des posts et des pages via l'API Graph (ex-`analytics.ts`). +use chrono::{Duration, NaiveTime}; +use serde_json::{json, Value}; + +use crate::db::{PageAnalyticsHistory, Patch, Platform, PostAnalytics, TokenStatus}; +use crate::services::facebook::graph; use crate::state::AppState; +use crate::time::Ts; -/// Synchronise les statistiques de toutes les pages. -pub async fn sync_all_pages(state: &AppState) -> anyhow::Result<()> { +const POST_FIELDS: &str = "insights.metric(post_impressions,post_impressions_unique,post_engaged_users,post_clicks),shares,comments.summary(true),reactions.summary(true)"; + +/// Codes d'erreur Graph signalant un jeton invalide ou expiré. +const AUTH_ERROR_CODES: [i64; 3] = [190, 463, 467]; + +/// Métriques d'une publication. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct PostMetrics { + pub impressions: i64, + pub reach: i64, + pub engagement: i64, + pub clicks: i64, + pub reactions: i64, + pub comments: i64, + pub shares: i64, +} + +impl PostMetrics { + fn add(&mut self, other: &PostMetrics) { + self.impressions += other.impressions; + self.reach += other.reach; + self.engagement += other.engagement; + self.clicks += other.clicks; + self.reactions += other.reactions; + self.comments += other.comments; + self.shares += other.shares; + } +} + +fn number(value: Option<&Value>) -> i64 { + value.and_then(Value::as_f64).map(|n| n as i64).unwrap_or(0) +} + +/// Extrait les métriques de la réponse Graph d'un post. +pub fn parse_post_metrics(result: &Value) -> PostMetrics { + let insights = result + .get("insights") + .and_then(|i| i.get("data")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let metric = |name: &str| { + insights + .iter() + .find(|i| i.get("name").and_then(Value::as_str) == Some(name)) + .map(|m| { + number( + m.get("values") + .and_then(|v| v.get(0)) + .and_then(|v| v.get("value")), + ) + }) + .unwrap_or(0) + }; + let summary_count = |key: &str| { + number( + result + .get(key) + .and_then(|r| r.get("summary")) + .and_then(|s| s.get("total_count")), + ) + }; + + PostMetrics { + impressions: metric("post_impressions"), + reach: metric("post_impressions_unique"), + engagement: metric("post_engaged_users"), + clicks: metric("post_clicks"), + reactions: summary_count("reactions"), + comments: summary_count("comments"), + shares: number(result.get("shares").and_then(|s| s.get("count"))), + } +} + +/// Dernière valeur (la plus récente) d'une métrique de page. +pub fn last_insight_value(result: &Value) -> i64 { + result + .get("data") + .and_then(Value::as_array) + .and_then(|data| data.first()) + .and_then(|first| first.get("values")) + .and_then(Value::as_array) + .and_then(|values| values.last()) + .map(|v| number(v.get("value"))) + .unwrap_or(0) +} + +fn to_i32(value: i64) -> i32 { + value.clamp(i32::MIN as i64, i32::MAX as i64) as i32 +} + +/// Statistiques enregistrées d'un post. +pub async fn get_post_analytics( + state: &AppState, + post_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM post_analytics WHERE post_id = $1 LIMIT 1") + .bind(post_id) + .fetch_optional(&state.db.pool) + .await +} + +/// Historique d'une page : 30 derniers relevés, du plus récent au plus ancien. +pub async fn get_page_history( + state: &AppState, + page_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT * FROM page_analytics_history WHERE page_id = $1 ORDER BY date DESC LIMIT 30", + ) + .bind(page_id) + .fetch_all(&state.db.pool) + .await +} + +/// Récupère les métriques d'un post sur chaque page où il a été publié et +/// met à jour `post_analytics` (somme des publications). +pub async fn sync_post_analytics(state: &AppState, post_id: &str) -> anyhow::Result<()> { + tracing::info!("[AnalyticsService] Syncing post {post_id}"); + + let published: Vec<_> = state + .db + .get_scheduled_posts_by_post(post_id) + .await? + .into_iter() + .filter(|sp| sp.external_post_id.is_some()) + .collect(); + + if published.is_empty() { + tracing::info!("[AnalyticsService] No published external posts found for {post_id}"); + return Ok(()); + } + + let mut totals = PostMetrics::default(); + let mut raw_data = Vec::new(); + + for instance in &published { + let Some(external_id) = instance + .external_post_id + .as_deref() + .filter(|id| !id.is_empty()) + else { + continue; + }; + let Some(page) = state.db.get_social_page(&instance.page_id).await? else { + continue; + }; + // Les métriques TikTok ne sont pas exposées par la Graph API + if page.platform == Platform::Tiktok { + continue; + } + + match graph::get( + state, + external_id, + Some(&page.access_token), + &[("fields", POST_FIELDS)], + ) + .await + { + Ok(result) => { + totals.add(&parse_post_metrics(&result)); + raw_data.push(json!({ "id": external_id, "data": result })); + } + Err(error) => { + tracing::error!( + "[AnalyticsService] Failed to fetch for instance {}: {error}", + instance.id + ); + } + } + } + + let raw_data = Value::Array(raw_data); + let existing = get_post_analytics(state, post_id).await?; + if let Some(existing) = existing { + sqlx::query( + r#"UPDATE post_analytics SET fetched_at = $2, impressions = $3, reach = $4, engagement = $5, + reactions = $6, comments = $7, shares = $8, clicks = $9, raw_data = $10 WHERE id = $1"#, + ) + .bind(&existing.id) + .bind(Ts::now()) + .bind(to_i32(totals.impressions)) + .bind(to_i32(totals.reach)) + .bind(to_i32(totals.engagement)) + .bind(to_i32(totals.reactions)) + .bind(to_i32(totals.comments)) + .bind(to_i32(totals.shares)) + .bind(to_i32(totals.clicks)) + .bind(&raw_data) + .execute(&state.db.pool) + .await?; + } else { + sqlx::query( + r#"INSERT INTO post_analytics (post_id, fetched_at, impressions, reach, engagement, + reactions, comments, shares, clicks, raw_data) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"#, + ) + .bind(post_id) + .bind(Ts::now()) + .bind(to_i32(totals.impressions)) + .bind(to_i32(totals.reach)) + .bind(to_i32(totals.engagement)) + .bind(to_i32(totals.reactions)) + .bind(to_i32(totals.comments)) + .bind(to_i32(totals.shares)) + .bind(to_i32(totals.clicks)) + .bind(&raw_data) + .execute(&state.db.pool) + .await?; + } Ok(()) } + +/// Début du jour (UTC, l'heure du serveur) et lendemain. +pub fn day_bounds(now: Ts) -> (Ts, Ts) { + let start = Ts(now.0.date().and_time(NaiveTime::MIN)); + (start, start.add(Duration::days(1))) +} + +/// Synchronise les statistiques d'une page (abonnés, portée, engagement, +/// vues). Chaque métrique est récupérée séparément pour plus de résilience. +pub async fn sync_page_analytics(state: &AppState, page_id: &str) -> anyhow::Result<()> { + tracing::info!("[AnalyticsService] Syncing page {page_id}"); + + let Some(page) = state.db.get_social_page(page_id).await? else { + return Ok(()); + }; + let access_token = page.access_token.as_str(); + + // 1. Infos de base (abonnés) — indispensables : un échec signale + // généralement un jeton invalide, on s'arrête là. + let mut followers = page.followers_count.unwrap_or(0) as i64; + match graph::get( + state, + &page.page_id, + Some(access_token), + &[("fields", "fan_count")], + ) + .await + { + Ok(info) => { + if let Some(fan_count) = info.get("fan_count").and_then(Value::as_f64) { + followers = fan_count as i64; + } + } + Err(error) => { + tracing::error!("[AnalyticsService] Failed to fetch basic info for {page_id}: {error}"); + let preview: String = access_token.chars().take(10).collect(); + tracing::info!("[AnalyticsService] DEBUG: Token used: {preview}..."); + + // Erreur d'authentification : jeton marqué expiré, on s'arrête + if graph::error_code(&error).is_some_and(|code| AUTH_ERROR_CODES.contains(&code)) { + tracing::info!("[AnalyticsService] Marking token as EXPIRED for page {page_id}"); + state + .db + .update_social_page( + page_id, + Patch::new() + .set("token_status", TokenStatus::Expired) + .set("last_token_check", Ts::now()), + ) + .await?; + return Ok(()); + } + + anyhow::bail!("Failed to fetch Basic Info: {error}"); + } + } + + // 2. Chaque métrique de page, indépendamment + let page_reach = fetch_insight( + state, + &page.page_id, + page_id, + access_token, + "page_impressions_unique", + ) + .await; + let page_engagement = fetch_insight( + state, + &page.page_id, + page_id, + access_token, + "page_post_engagements", + ) + .await; + let page_views = fetch_insight( + state, + &page.page_id, + page_id, + access_token, + "page_views_total", + ) + .await; + + tracing::info!( + "[AnalyticsService] Metrics: followers={followers}, reach={page_reach}, engagement={page_engagement}, views={page_views}" + ); + + // 3. Mise à jour de la page — le statut du jeton se rétablit tout seul + state + .db + .update_social_page( + page_id, + Patch::new() + .set("followers_count", to_i32(followers)) + .set("token_status", TokenStatus::Valid) + .set("last_token_check", Ts::now()), + ) + .await?; + + // 4. Relevé historique : un seul par jour et par page + let (today, tomorrow) = day_bounds(Ts::now()); + let existing: Option<(String,)> = sqlx::query_as( + "SELECT id FROM page_analytics_history WHERE page_id = $1 AND date >= $2 AND date < $3 LIMIT 1", + ) + .bind(page_id) + .bind(today) + .bind(tomorrow) + .fetch_optional(&state.db.pool) + .await?; + + if let Some((entry_id,)) = existing { + sqlx::query( + r#"UPDATE page_analytics_history SET followers_count = $2, page_reach = $3, page_views = $4, + page_engagement = $5, date = $6 WHERE id = $1"#, + ) + .bind(entry_id) + .bind(to_i32(followers)) + .bind(to_i32(page_reach)) + .bind(to_i32(page_views)) + .bind(to_i32(page_engagement)) + .bind(Ts::now()) + .execute(&state.db.pool) + .await?; + } else { + sqlx::query( + r#"INSERT INTO page_analytics_history (page_id, date, followers_count, page_reach, page_views, page_engagement) + VALUES ($1, $2, $3, $4, $5, $6)"#, + ) + .bind(page_id) + .bind(Ts::now()) + .bind(to_i32(followers)) + .bind(to_i32(page_reach)) + .bind(to_i32(page_views)) + .bind(to_i32(page_engagement)) + .execute(&state.db.pool) + .await?; + } + + tracing::info!( + "[AnalyticsService] Synced page {page_id}: {followers} followers, {page_reach} reach, {page_engagement} engagement, {page_views} views" + ); + Ok(()) +} + +/// Une métrique de page (`period=day`) ; 0 si elle est indisponible. +async fn fetch_insight( + state: &AppState, + graph_page_id: &str, + page_id: &str, + access_token: &str, + metric: &str, +) -> i64 { + let endpoint = format!("{graph_page_id}/insights/{metric}"); + match graph::get(state, &endpoint, Some(access_token), &[("period", "day")]).await { + Ok(result) => last_insight_value(&result), + Err(error) => { + if graph::error_code(&error) == Some(100) { + tracing::warn!("[AnalyticsService] Metric \"{metric}\" unavailable for {page_id} (Code 100). Skipping."); + } else { + tracing::warn!( + "[AnalyticsService] Failed to fetch \"{metric}\" for {page_id}: {error}" + ); + } + 0 + } + } +} + +/// Synchronise les statistiques de toutes les pages (tâche planifiée). +/// Les statistiques passent par la Graph API : les comptes TikTok en sont exclus. +pub async fn sync_all_pages(state: &AppState) -> anyhow::Result<()> { + tracing::info!("[AnalyticsService] Starting sync for all pages..."); + let pages: Vec<_> = state + .db + .get_all_social_pages() + .await? + .into_iter() + .filter(|p| p.platform != Platform::Tiktok) + .collect(); + + for page in &pages { + if let Err(error) = sync_page_analytics(state, &page.id).await { + tracing::error!( + "[AnalyticsService] Failed to sync page {}: {error}", + page.id + ); + } + } + + tracing::info!( + "[AnalyticsService] Sync complete for {} pages.", + pages.len() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn post_metrics_are_extracted() { + let result = json!({ + "insights": { "data": [ + { "name": "post_impressions", "values": [{ "value": 120 }] }, + { "name": "post_impressions_unique", "values": [{ "value": 80 }] }, + { "name": "post_engaged_users", "values": [] }, + { "name": "post_clicks", "values": [{ "value": { "link clicks": 3 } }] } + ]}, + "shares": { "count": 4 }, + "comments": { "summary": { "total_count": 5 } }, + "reactions": { "summary": { "total_count": 9 } } + }); + assert_eq!( + parse_post_metrics(&result), + PostMetrics { + impressions: 120, + reach: 80, + engagement: 0, + clicks: 0, + reactions: 9, + comments: 5, + shares: 4 + } + ); + assert_eq!(parse_post_metrics(&json!({})), PostMetrics::default()); + } + + #[test] + fn last_value_of_page_insight() { + let result = json!({ "data": [{ "values": [{ "value": 1 }, { "value": 7 }] }] }); + assert_eq!(last_insight_value(&result), 7); + assert_eq!(last_insight_value(&json!({ "data": [] })), 0); + assert_eq!( + last_insight_value(&json!({ "data": [{ "values": [] }] })), + 0 + ); + } + + #[test] + fn day_bounds_are_utc_midnight() { + let (start, end) = day_bounds(Ts::parse("2026-10-12T17:45:12Z").unwrap()); + assert_eq!(start.to_iso(), "2026-10-12T00:00:00.000Z"); + assert_eq!(end.to_iso(), "2026-10-13T00:00:00.000Z"); + } +} diff --git a/server/src/services/facebook/emoji.rs b/server/src/services/facebook/emoji.rs new file mode 100644 index 0000000..bfe62cc --- /dev/null +++ b/server/src/services/facebook/emoji.rs @@ -0,0 +1,387 @@ +//! Découpage d'un texte en segments texte / emoji (ex-`twemoji-parser`). +//! +//! La table des caractères emoji est celle reconnue par twemoji-parser 14, +//! élargie au bloc « Symbols and Pictographs Extended-A » (emoji récents). + +/// Segment d'une ligne : texte brut ou emoji (avec son URL Twemoji). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Segment { + Text(String), + Emoji { text: String, url: String }, +} + +/// Images Twemoji (fork maintenu, comme côté client). +const TWEMOJI_BASE: &str = "https://cdn.jsdelivr.net/gh/jdecked/twemoji@latest/assets/svg/"; + +const ZWJ: char = '\u{200D}'; +const VS16: char = '\u{FE0F}'; +const KEYCAP: char = '\u{20E3}'; + +/// Caractères reconnus seuls comme emoji (sans sélecteur de variante). +const EMOJI_RANGES: &[(u32, u32)] = &[ + (0x203c, 0x203c), + (0x2049, 0x2049), + (0x2139, 0x2139), + (0x2194, 0x2199), + (0x21a9, 0x21aa), + (0x231a, 0x231b), + (0x2328, 0x2328), + (0x23cf, 0x23cf), + (0x23e9, 0x23f3), + (0x23f8, 0x23fa), + (0x24c2, 0x24c2), + (0x25aa, 0x25ab), + (0x25b6, 0x25b6), + (0x25c0, 0x25c0), + (0x25fb, 0x25fe), + (0x2600, 0x2604), + (0x260e, 0x260e), + (0x2611, 0x2611), + (0x2614, 0x2615), + (0x2618, 0x2618), + (0x261d, 0x261d), + (0x2620, 0x2620), + (0x2622, 0x2623), + (0x2626, 0x2626), + (0x262a, 0x262a), + (0x262e, 0x262f), + (0x2638, 0x263a), + (0x2640, 0x2640), + (0x2642, 0x2642), + (0x2648, 0x2653), + (0x2660, 0x2660), + (0x2663, 0x2663), + (0x2665, 0x2666), + (0x2668, 0x2668), + (0x267b, 0x267b), + (0x267e, 0x267f), + (0x2692, 0x2697), + (0x2699, 0x2699), + (0x269b, 0x269c), + (0x26a0, 0x26a1), + (0x26a7, 0x26a7), + (0x26aa, 0x26ab), + (0x26b0, 0x26b1), + (0x26bd, 0x26be), + (0x26c4, 0x26c5), + (0x26c8, 0x26c8), + (0x26ce, 0x26cf), + (0x26d1, 0x26d1), + (0x26d3, 0x26d4), + (0x26e9, 0x26ea), + (0x26f0, 0x26f5), + (0x26f7, 0x26fa), + (0x26fd, 0x26fd), + (0x2702, 0x2702), + (0x2705, 0x2705), + (0x2708, 0x270d), + (0x270f, 0x270f), + (0x2712, 0x2712), + (0x2714, 0x2714), + (0x2716, 0x2716), + (0x271d, 0x271d), + (0x2721, 0x2721), + (0x2728, 0x2728), + (0x2733, 0x2734), + (0x2744, 0x2744), + (0x2747, 0x2747), + (0x274c, 0x274c), + (0x274e, 0x274e), + (0x2753, 0x2755), + (0x2757, 0x2757), + (0x2763, 0x2764), + (0x2795, 0x2797), + (0x27a1, 0x27a1), + (0x27b0, 0x27b0), + (0x27bf, 0x27bf), + (0x2934, 0x2935), + (0x2b05, 0x2b07), + (0x2b1b, 0x2b1c), + (0x2b50, 0x2b50), + (0x2b55, 0x2b55), + (0x3030, 0x3030), + (0x303d, 0x303d), + (0x3297, 0x3297), + (0x3299, 0x3299), + (0x1f004, 0x1f004), + (0x1f0cf, 0x1f0cf), + (0x1f170, 0x1f171), + (0x1f17e, 0x1f17f), + (0x1f18e, 0x1f18e), + (0x1f191, 0x1f19a), + (0x1f1e6, 0x1f1ff), + (0x1f201, 0x1f202), + (0x1f21a, 0x1f21a), + (0x1f22f, 0x1f22f), + (0x1f232, 0x1f23a), + (0x1f250, 0x1f251), + (0x1f300, 0x1f321), + (0x1f324, 0x1f393), + (0x1f396, 0x1f397), + (0x1f399, 0x1f39b), + (0x1f39e, 0x1f3f0), + (0x1f3f3, 0x1f3f5), + (0x1f3f7, 0x1f4fd), + (0x1f4ff, 0x1f53d), + (0x1f549, 0x1f54e), + (0x1f550, 0x1f567), + (0x1f56f, 0x1f570), + (0x1f573, 0x1f57a), + (0x1f587, 0x1f587), + (0x1f58a, 0x1f58d), + (0x1f590, 0x1f590), + (0x1f595, 0x1f596), + (0x1f5a4, 0x1f5a5), + (0x1f5a8, 0x1f5a8), + (0x1f5b1, 0x1f5b2), + (0x1f5bc, 0x1f5bc), + (0x1f5c2, 0x1f5c4), + (0x1f5d1, 0x1f5d3), + (0x1f5dc, 0x1f5de), + (0x1f5e1, 0x1f5e1), + (0x1f5e3, 0x1f5e3), + (0x1f5e8, 0x1f5e8), + (0x1f5ef, 0x1f5ef), + (0x1f5f3, 0x1f5f3), + (0x1f5fa, 0x1f64f), + (0x1f680, 0x1f6c5), + (0x1f6cb, 0x1f6d2), + (0x1f6d5, 0x1f6d7), + (0x1f6dc, 0x1f6e5), + (0x1f6e9, 0x1f6e9), + (0x1f6eb, 0x1f6ec), + (0x1f6f0, 0x1f6f0), + (0x1f6f3, 0x1f6fc), + (0x1f7e0, 0x1f7eb), + (0x1f7f0, 0x1f7f0), + (0x1f90c, 0x1f93a), + (0x1f93c, 0x1f945), + (0x1f947, 0x1f9ff), + (0x1fa70, 0x1faff), +]; + +/// Caractères qui ne sont des emoji que suivis de U+FE0F (©, ®, ™, ♟). +const VS16_ONLY: &[u32] = &[0xa9, 0xae, 0x2122, 0x265f]; + +fn in_table(c: char) -> bool { + let cp = c as u32; + EMOJI_RANGES + .binary_search_by(|&(start, end)| { + if cp < start { + std::cmp::Ordering::Greater + } else if cp > end { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }) + .is_ok() +} + +fn is_regional_indicator(c: char) -> bool { + ('\u{1F1E6}'..='\u{1F1FF}').contains(&c) +} + +fn is_skin_tone(c: char) -> bool { + ('\u{1F3FB}'..='\u{1F3FF}').contains(&c) +} + +fn is_tag(c: char) -> bool { + ('\u{E0020}'..='\u{E007E}').contains(&c) +} + +fn is_keycap_base(c: char) -> bool { + c.is_ascii_digit() || c == '#' || c == '*' +} + +/// Longueur (en caractères) d'un élément emoji commençant à `i`, sans les +/// jonctions ZWJ : base, sélecteur, teinte de peau, drapeau, séquence de tags. +fn element_len(chars: &[char], i: usize) -> usize { + let c = chars[i]; + let next = |k: usize| chars.get(i + k).copied(); + + // Keycaps : 1⃣, 1️⃣, #️⃣… + if is_keycap_base(c) { + return match (next(1), next(2)) { + (Some(KEYCAP), _) => 2, + (Some(VS16), Some(KEYCAP)) => 3, + _ => 0, + }; + } + if VS16_ONLY.contains(&(c as u32)) { + return if next(1) == Some(VS16) { 2 } else { 0 }; + } + if !in_table(c) { + return 0; + } + // Drapeaux : deux indicateurs régionaux + if is_regional_indicator(c) { + return if next(1).is_some_and(is_regional_indicator) { + 2 + } else { + 1 + }; + } + + let mut len = 1; + if next(len) == Some(VS16) { + len += 1; + } + if next(len).is_some_and(is_skin_tone) { + len += 1; + } + // Drapeaux de subdivisions (🏴 + tags + U+E007F) + if c == '\u{1F3F4}' && next(len).is_some_and(is_tag) { + let mut k = len; + while next(k).is_some_and(is_tag) { + k += 1; + } + if next(k) == Some('\u{E007F}') { + len = k + 1; + } + } + len +} + +/// Longueur d'une séquence emoji complète (éléments reliés par ZWJ). +fn emoji_len(chars: &[char], start: usize) -> usize { + let mut len = element_len(chars, start); + if len == 0 { + return 0; + } + while chars.get(start + len) == Some(&ZWJ) && start + len + 1 < chars.len() { + let next = element_len(chars, start + len + 1); + if next == 0 { + break; + } + len += 1 + next; + } + len +} + +/// Nom du fichier Twemoji : points de code en hexadécimal séparés par « - », +/// sans U+FE0F sauf dans les séquences ZWJ (comme twemoji-parser). +pub fn codepoints(emoji: &str) -> String { + let keep_vs16 = emoji.contains(ZWJ); + emoji + .chars() + .filter(|c| keep_vs16 || *c != VS16) + .map(|c| format!("{:x}", c as u32)) + .collect::>() + .join("-") +} + +pub fn twemoji_url(emoji: &str) -> String { + format!("{TWEMOJI_BASE}{}.svg", codepoints(emoji)) +} + +/// Découpe le texte en segments texte / emoji. +pub fn parse(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let mut segments = Vec::new(); + let mut current = String::new(); + let mut i = 0; + + while i < chars.len() { + let len = emoji_len(&chars, i); + if len > 0 { + if !current.is_empty() { + segments.push(Segment::Text(std::mem::take(&mut current))); + } + let emoji: String = chars[i..i + len].iter().collect(); + segments.push(Segment::Emoji { + url: twemoji_url(&emoji), + text: emoji, + }); + i += len; + } else { + // Un U+FE0F isolé n'a pas d'image : on l'ignore + if chars[i] != VS16 { + current.push(chars[i]); + } + i += 1; + } + } + if !current.is_empty() { + segments.push(Segment::Text(current)); + } + segments +} + +#[cfg(test)] +mod tests { + use super::*; + + fn emojis(text: &str) -> Vec { + parse(text) + .into_iter() + .filter_map(|s| match s { + Segment::Emoji { url, .. } => { + Some(url.trim_start_matches(TWEMOJI_BASE).to_string()) + } + Segment::Text(_) => None, + }) + .collect() + } + + #[test] + fn plain_text_is_one_segment() { + assert_eq!( + parse("Bonjour à tous"), + vec![Segment::Text("Bonjour à tous".into())] + ); + assert!(parse("").is_empty()); + // © ® ™ sans sélecteur, chiffres seuls : pas des emoji + assert!(emojis("© ® ™ 123 #1 *").is_empty()); + } + + #[test] + fn segments_keep_surrounding_text() { + assert_eq!( + parse("Promo 🔥 ici"), + vec![ + Segment::Text("Promo ".into()), + Segment::Emoji { + text: "🔥".into(), + url: format!("{TWEMOJI_BASE}1f525.svg") + }, + Segment::Text(" ici".into()), + ] + ); + } + + // Valeurs attendues relevées avec twemoji-parser 14 + #[test] + fn matches_twemoji_parser() { + assert_eq!( + emojis("©️ ❤ ❤️ ☀"), + vec!["a9.svg", "2764.svg", "2764.svg", "2600.svg"] + ); + assert_eq!( + emojis("1️⃣ 1⃣ #️⃣ *️⃣"), + vec!["31-20e3.svg", "31-20e3.svg", "23-20e3.svg", "2a-20e3.svg"] + ); + assert_eq!(emojis("🇫🇷 🇫"), vec!["1f1eb-1f1f7.svg", "1f1eb.svg"]); + assert_eq!(emojis("👍🏽"), vec!["1f44d-1f3fd.svg"]); + assert_eq!(emojis("👨‍👩‍👧"), vec!["1f468-200d-1f469-200d-1f467.svg"]); + assert_eq!(emojis("🏳️‍🌈"), vec!["1f3f3-fe0f-200d-1f308.svg"]); + assert_eq!(emojis("❤️‍🔥"), vec!["2764-fe0f-200d-1f525.svg"]); + assert_eq!( + emojis("👩🏻‍❤️‍👨🏼"), + vec!["1f469-1f3fb-200d-2764-fe0f-200d-1f468-1f3fc.svg"] + ); + assert_eq!( + emojis("🏴󠁧󠁢󠁳󠁣󠁴󠁿"), + vec!["1f3f4-e0067-e0062-e0073-e0063-e0074-e007f.svg"] + ); + assert_eq!( + emojis("😀🎉✨⭐✅"), + vec!["1f600.svg", "1f389.svg", "2728.svg", "2b50.svg", "2705.svg"] + ); + } + + #[test] + fn stray_variation_selector_is_dropped() { + assert_eq!(parse("a\u{FE0F}b"), vec![Segment::Text("ab".into())]); + } +} diff --git a/server/src/services/facebook/graph.rs b/server/src/services/facebook/graph.rs new file mode 100644 index 0000000..4c8db05 --- /dev/null +++ b/server/src/services/facebook/graph.rs @@ -0,0 +1,159 @@ +//! Client minimal de l'API Graph (ex-`utils/graph_client.ts`), utilisé par le +//! contrôle des jetons et les statistiques. + +use serde_json::Value; + +use crate::state::AppState; + +const GRAPH_API_VERSION: &str = "v19.0"; + +/// Erreur renvoyée par l'API Graph (`{ error: { message, code, fbtrace_id } }`). +#[derive(Debug, Clone, thiserror::Error)] +#[error("{message}")] +pub struct FacebookApiError { + pub message: String, + pub status: u16, + pub code: i64, + pub fb_trace_id: String, +} + +/// Code d'erreur Graph d'une erreur `anyhow`, s'il s'agit d'une [`FacebookApiError`]. +pub fn error_code(error: &anyhow::Error) -> Option { + error.downcast_ref::().map(|e| e.code) +} + +/// URL complète d'un appel : `endpoint` avec ou sans « / » initial, jeton puis +/// paramètres en query string. +pub fn build_url( + endpoint: &str, + access_token: Option<&str>, + params: &[(&str, &str)], +) -> anyhow::Result { + let path = if endpoint.starts_with('/') { + endpoint.to_string() + } else { + format!("/{endpoint}") + }; + let mut url = url::Url::parse(&format!( + "https://graph.facebook.com/{GRAPH_API_VERSION}{path}" + ))?; + { + let mut query = url.query_pairs_mut(); + if let Some(token) = access_token.filter(|t| !t.is_empty()) { + query.append_pair("access_token", token); + } + for (key, value) in params { + query.append_pair(key, value); + } + } + Ok(url) +} + +/// `GET` sur l'API Graph. +pub async fn get( + state: &AppState, + endpoint: &str, + access_token: Option<&str>, + params: &[(&str, &str)], +) -> anyhow::Result { + let url = build_url(endpoint, access_token, params)?; + tracing::info!("[GraphAPI] GET {endpoint}"); + let response = state.http.get(url).send().await?; + handle_response(response).await +} + +/// `POST` d'un corps JSON sur l'API Graph. +pub async fn post( + state: &AppState, + endpoint: &str, + body: &Value, + access_token: Option<&str>, + params: &[(&str, &str)], +) -> anyhow::Result { + let url = build_url(endpoint, access_token, params)?; + tracing::info!("[GraphAPI] POST {endpoint}"); + let response = state.http.post(url).json(body).send().await?; + handle_response(response).await +} + +async fn handle_response(response: reqwest::Response) -> anyhow::Result { + let status = response.status(); + let data: Value = response.json().await?; + if !status.is_success() { + return Err(api_error(status.as_u16(), &data).into()); + } + Ok(data) +} + +fn api_error(status: u16, data: &Value) -> FacebookApiError { + let error = data.get("error"); + let field = |key: &str| error.and_then(|e| e.get(key)); + FacebookApiError { + message: field("message") + .and_then(Value::as_str) + .filter(|m| !m.is_empty()) + .unwrap_or("Unknown Facebook API Error") + .to_string(), + status, + code: field("code").and_then(Value::as_i64).unwrap_or(0), + fb_trace_id: field("fbtrace_id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn url_with_and_without_leading_slash() { + let a = build_url("me", Some("TOKEN"), &[]).unwrap(); + assert_eq!( + a.as_str(), + "https://graph.facebook.com/v19.0/me?access_token=TOKEN" + ); + let b = build_url( + "/123/insights/page_views_total", + Some("T"), + &[("period", "day")], + ) + .unwrap(); + assert_eq!( + b.as_str(), + "https://graph.facebook.com/v19.0/123/insights/page_views_total?access_token=T&period=day" + ); + } + + #[test] + fn params_are_encoded() { + let url = build_url( + "1_2", + Some("a b"), + &[("fields", "insights.metric(post_clicks),shares")], + ) + .unwrap(); + assert_eq!( + url.query(), + Some("access_token=a+b&fields=insights.metric%28post_clicks%29%2Cshares") + ); + } + + #[test] + fn error_structure_is_parsed() { + let err = api_error( + 400, + &json!({ "error": { "message": "Bad token", "code": 190, "fbtrace_id": "X" } }), + ); + assert_eq!(err.message, "Bad token"); + assert_eq!(err.code, 190); + assert_eq!(err.fb_trace_id, "X"); + let unknown = api_error(500, &json!({})); + assert_eq!(unknown.message, "Unknown Facebook API Error"); + assert_eq!(unknown.code, 0); + let any: anyhow::Error = err.into(); + assert_eq!(error_code(&any), Some(190)); + } +} diff --git a/server/src/services/facebook/mod.rs b/server/src/services/facebook/mod.rs index a493604..a94dac4 100644 --- a/server/src/services/facebook/mod.rs +++ b/server/src/services/facebook/mod.rs @@ -1,15 +1,158 @@ -//! Publication Facebook / Instagram (API Graph). +//! Publication Facebook (API Graph), portage de `server/services/facebook.ts`. //! -//! STUB — à porter depuis `server/services/facebook.ts` (+ `imageProcessor.ts`, -//! `emojiRenderer.ts`, `facebook_oauth.ts`, `utils/graph_client.ts`). -#![allow(unused_variables)] +//! Sous-modules : client Graph générique (`graph`), connexion OAuth et +//! renouvellement des jetons (`oauth`), image de story avec texte incrusté +//! (`story_image`, `emoji`). +pub mod emoji; +pub mod graph; +pub mod oauth; +pub mod story_image; + +use std::time::Duration; + +use anyhow::bail; use bytes::Bytes; +use once_cell::sync::Lazy; +use regex::Regex; +use reqwest::multipart::{Form, Part}; +use serde_json::Value; -use crate::db::{Media, Post, PostType, SocialPage}; +use crate::db::{Media, MediaType, Platform, Post, PostType, SocialPage}; +use crate::services::media_store; use crate::state::AppState; -/// Publie un post (feed, story…) sur une page Facebook ; renvoie l'identifiant externe. +const BASE_URL: &str = "https://graph.facebook.com/v23.0"; + +/// Suivi du traitement d'une story vidéo après la phase FINISH. +const STORY_STATUS_ATTEMPTS: u32 = 12; +const STORY_STATUS_INTERVAL: Duration = Duration::from_secs(5); + +// ---------------------------------------------------------------------- +// Utilitaires +// ---------------------------------------------------------------------- + +/// URL `/{node}/{edge}` de l'API Graph avec ses paramètres en query string. +fn graph_url(node: &str, edge: &str, params: &[(&str, &str)]) -> anyhow::Result { + let mut url = url::Url::parse(&format!("{BASE_URL}/{node}/{edge}"))?; + if !params.is_empty() { + url.query_pairs_mut().extend_pairs(params); + } + Ok(url) +} + +/// Valeur rendue comme dans un gabarit JavaScript (`${valeur}`). +fn js(value: Option<&Value>) -> String { + match value { + None => "undefined".to_string(), + Some(Value::Null) => "null".to_string(), + Some(Value::String(s)) => s.clone(), + Some(other) => other.to_string(), + } +} + +/// Texte non vide d'un champ (identifiants renvoyés en chaîne ou en nombre). +fn field(data: &Value, key: &str) -> Option { + match data.get(key) { + Some(Value::String(s)) if !s.is_empty() => Some(s.clone()), + Some(Value::Number(n)) => Some(n.to_string()), + _ => None, + } +} + +/// Message d'une erreur Graph : `{prefix}: {message} (code: {code})`. +fn format_api_error(prefix: &str, status: u16, body: &str) -> String { + match serde_json::from_str::(body) { + Ok(data) if data.get("error").is_some_and(Value::is_object) => { + let error = &data["error"]; + format!( + "{prefix}: {} (code: {})", + js(error.get("message")), + js(error.get("code")) + ) + } + _ => { + let excerpt: String = body.chars().take(500).collect(); + format!("{prefix}: HTTP {status} {excerpt}") + .trim_end() + .to_string() + } + } +} + +async fn api_error(prefix: &str, response: reqwest::Response) -> anyhow::Error { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + anyhow::anyhow!(format_api_error(prefix, status, &body)) +} + +/// `POST` sans corps (paramètres en query string), réponse JSON exigée. +async fn post_query(state: &AppState, url: url::Url, error_prefix: &str) -> anyhow::Result { + let response = state.http.post(url).send().await?; + if !response.status().is_success() { + return Err(api_error(error_prefix, response).await); + } + Ok(response.json().await?) +} + +/// `POST` multipart, réponse JSON exigée. +async fn post_form( + state: &AppState, + url: url::Url, + form: Form, + error_prefix: &str, +) -> anyhow::Result { + let response = state.http.post(url).multipart(form).send().await?; + if !response.status().is_success() { + return Err(api_error(error_prefix, response).await); + } + Ok(response.json().await?) +} + +/// Contenu d'un média : fichier local `/uploads/...` (à défaut, son URL +/// publique) ou URL distante. +async fn get_media_buffer(state: &AppState, url: &str) -> anyhow::Result { + if url.starts_with("http") { + let response = state.http.get(url).send().await?; + if !response.status().is_success() { + bail!("Failed to fetch media from {url}"); + } + return Ok(response.bytes().await?); + } + media_store::read_media_buffer(state, url).await +} + +fn basename(url: &str) -> String { + url.rsplit('/').next().unwrap_or(url).to_string() +} + +fn image_mime(file_name: &str) -> &'static str { + if file_name.to_lowercase().ends_with(".png") { + "image/png" + } else { + "image/jpeg" + } +} + +fn file_part(data: Bytes, file_name: String, mime: &str) -> anyhow::Result { + Ok(Part::stream(reqwest::Body::from(data)) + .file_name(file_name) + .mime_str(mime)?) +} + +fn require_token(page: &SocialPage) -> anyhow::Result<&str> { + if page.access_token.is_empty() { + bail!("No access token found for this page"); + } + Ok(&page.access_token) +} + +// ---------------------------------------------------------------------- +// Publication +// ---------------------------------------------------------------------- + +/// Publie un post (feed, story, reel) sur une page Facebook ; renvoie +/// l'identifiant externe (plusieurs stories : identifiants séparés par « , »). pub async fn publish_post( state: &AppState, post: &Post, @@ -17,24 +160,793 @@ pub async fn publish_post( post_type: PostType, media: &[Media], ) -> anyhow::Result { - anyhow::bail!("publication Facebook non implémentée") + if page.platform != Platform::Facebook { + bail!("This service only supports Facebook pages"); + } + require_token(page)?; + + match post_type { + // Les nouveaux posts « both » sont scindés à la création : seul un + // ancien enregistrement peut arriver ici. + PostType::Both => bail!( + "postType \"both\" is not supported. Posts should be split into separate feed and story entries at creation." + ), + PostType::Story => { + if media.is_empty() { + bail!("Stories require media (photo or video)"); + } + // Une story par média + let mut story_ids = Vec::with_capacity(media.len()); + for item in media { + story_ids.push(publish_story(state, post, page, item).await?); + } + Ok(story_ids.join(",")) + } + PostType::Reel => { + if media.is_empty() { + bail!("Reels require a video"); + } + let Some(video) = media.iter().find(|m| m.media_type == MediaType::Video) else { + bail!("Reels require a video media"); + }; + // Lecture locale d'abord, URL publique en secours + let buffer = get_media_buffer(state, &video.original_url).await?; + publish_video_from_buffer(state, page, buffer, &post.content).await + } + PostType::Feed => { + let images: Vec<&Media> = media.iter().filter(|m| m.media_type == MediaType::Image).collect(); + let videos: Vec<&Media> = media.iter().filter(|m| m.media_type == MediaType::Video).collect(); + + if images.len() > 1 { + publish_multi_photo_post(state, post, page, media).await + } else if let Some(image) = images.first() { + // Une image (prioritaire sur d'éventuelles vidéos) + publish_photo_post(state, post, page, image).await + } else if let Some(video) = videos.first() { + publish_video_post(state, post, page, video).await + } else { + publish_text_post(state, post, page).await + } + } + } } -/// Publie une vidéo (Reel) envoyée en binaire ; renvoie l'identifiant de la vidéo. +async fn publish_text_post( + state: &AppState, + post: &Post, + page: &SocialPage, +) -> anyhow::Result { + let url = graph_url( + &page.page_id, + "feed", + &[ + ("access_token", &page.access_token), + ("message", &post.content), + ], + )?; + let data = post_query(state, url, "Facebook API error").await?; + Ok(field(&data, "id").unwrap_or_default()) +} + +async fn publish_photo_post( + state: &AppState, + post: &Post, + page: &SocialPage, + media: &Media, +) -> anyhow::Result { + let buffer = get_media_buffer(state, &media.original_url).await?; + let mut form = Form::new().text("access_token", page.access_token.clone()); + if !post.content.is_empty() { + form = form.text("message", post.content.clone()); + } + let form = form.part( + "source", + file_part( + buffer, + basename(&media.original_url), + image_mime(&media.original_url), + )?, + ); + + let url = graph_url(&page.page_id, "photos", &[])?; + let data = post_form(state, url, form, "Facebook API error").await?; + // post_id est l'identifiant complet du post + Ok(field(&data, "post_id") + .or_else(|| field(&data, "id")) + .unwrap_or_default()) +} + +/// URL Cloudinary transcodée pour Facebook (H.264, AAC, MP4) ; les autres +/// URL sont renvoyées telles quelles. +pub fn facebook_compatible_video_url(original_url: &str) -> String { + static CLOUDINARY: Lazy = Lazy::new(|| { + Regex::new(r"https://res\.cloudinary\.com/([^/]+)/video/upload/").expect("regex valide") + }); + + let Some(captures) = CLOUDINARY.captures(original_url) else { + tracing::warn!( + "Video URL is not from Cloudinary, cannot apply transformations: {original_url}" + ); + return original_url.to_string(); + }; + let full_match = captures.get(0).map(|m| m.as_str()).unwrap_or_default(); + let cloud_name = &captures[1]; + let video_path = original_url.replacen(full_match, "", 1); + + // vc_h264 : vidéo H.264 ; ac_aac : audio AAC ; f_mp4 : conteneur MP4 ; q_auto : qualité auto + let transformed = + format!("https://res.cloudinary.com/{cloud_name}/video/upload/vc_h264,ac_aac,f_mp4,q_auto/{video_path}"); + tracing::info!( + "🎬 Transforming video for Facebook compatibility: {original_url} → {transformed}" + ); + transformed +} + +async fn publish_video_post( + state: &AppState, + post: &Post, + page: &SocialPage, + media: &Media, +) -> anyhow::Result { + let video_url = + facebook_compatible_video_url(&media_store::resolve_public_url(state, &media.original_url)); + let url = graph_url( + &page.page_id, + "videos", + &[ + ("access_token", &page.access_token), + ("description", &post.content), + ("file_url", &video_url), + ], + )?; + let data = post_query(state, url, "Facebook API error publishing video").await?; + Ok(field(&data, "post_id") + .or_else(|| field(&data, "id")) + .unwrap_or_default()) +} + +async fn publish_multi_photo_post( + state: &AppState, + post: &Post, + page: &SocialPage, + media: &[Media], +) -> anyhow::Result { + let images: Vec<&Media> = media + .iter() + .filter(|m| m.media_type == MediaType::Image) + .collect(); + if images.is_empty() { + bail!("Multi-photo posts require at least one image"); + } + if images.len() != media.len() { + tracing::warn!( + "Filtered out {} non-image media from multi-photo post", + media.len() - images.len() + ); + } + + // 1. Envoi de chaque photo non publiée + let mut photo_ids = Vec::new(); + for image in images { + if image.original_url.is_empty() { + tracing::warn!("Skipping media {} - no valid URL found", image.id); + continue; + } + let buffer = get_media_buffer(state, &image.original_url).await?; + let form = Form::new() + .text("access_token", page.access_token.clone()) + .text("published", "false") + .part( + "source", + file_part( + buffer, + basename(&image.original_url), + image_mime(&image.original_url), + )?, + ); + let url = graph_url(&page.page_id, "photos", &[])?; + let data = post_form(state, url, form, "Facebook API error uploading photo").await?; + photo_ids.push(field(&data, "id").unwrap_or_default()); + } + + if photo_ids.is_empty() { + bail!("No photos were successfully uploaded"); + } + + // 2. Post du fil avec toutes les photos jointes + let attached: Vec<(String, String)> = photo_ids + .iter() + .enumerate() + .map(|(index, id)| { + ( + format!("attached_media[{index}]"), + serde_json::json!({ "media_fbid": id }).to_string(), + ) + }) + .collect(); + let mut params: Vec<(&str, &str)> = vec![ + ("access_token", &page.access_token), + ("message", &post.content), + ]; + params.extend(attached.iter().map(|(k, v)| (k.as_str(), v.as_str()))); + + let url = graph_url(&page.page_id, "feed", ¶ms)?; + let data = post_query(state, url, "Facebook API error creating feed post").await?; + Ok(field(&data, "id").unwrap_or_default()) +} + +async fn publish_story( + state: &AppState, + post: &Post, + page: &SocialPage, + media: &Media, +) -> anyhow::Result { + if media.media_type == MediaType::Video { + // L'ancien serveur passait l'URL publique de la vidéo (en-tête `file_url`) + // et Facebook devait venir la télécharger : sans APP_URL joignable depuis + // Internet (ex. http://localhost:5555), la story échouait alors que le + // Reel, envoyé en binaire, passait. On envoie donc aussi la vidéo en binaire. + let video = media_store::read_media_buffer(state, &media.original_url).await?; + return publish_video_story_from_buffer(state, page, video).await; + } + + let mut photo = get_media_buffer(state, &media.original_url).await?; + let mut file_name = basename(&media.original_url); + + if !post.content.trim().is_empty() { + let story_name = format!("story-{}.png", media.id); + match story_image::render_story_image(state, photo.clone(), &post.content).await { + Ok(with_text) => { + if let Err(error) = + media_store::upload_story_image(state, &with_text, &story_name).await + { + tracing::warn!("Impossible d'archiver l'image de story : {error:#}"); + } + photo = Bytes::from(with_text); + file_name = story_name; + tracing::info!("Story image with text generated locally"); + } + Err(error) => { + tracing::error!( + "Error generating story image with text, using original: {error:#}" + ); + } + } + } + + // 1. Photo envoyée non publiée (obligatoire pour une story) + let mime = image_mime(&file_name); + let form = Form::new() + .text("access_token", page.access_token.clone()) + .text("published", "false") + .part("source", file_part(photo, file_name, mime)?); + let url = graph_url(&page.page_id, "photos", &[])?; + let upload = post_form( + state, + url, + form, + "Facebook API error uploading photo for story", + ) + .await?; + let photo_id = field(&upload, "id").unwrap_or_default(); + + // 2. Publication de la photo en story + let url = graph_url( + &page.page_id, + "photo_stories", + &[ + ("access_token", &page.access_token), + ("photo_id", &photo_id), + ], + )?; + let story = post_query(state, url, "Facebook API error publishing story").await?; + Ok(field(&story, "post_id").unwrap_or(photo_id)) +} + +/// Démarre un envoi en plusieurs phases (`/video_stories` ou `/video_reels`) ; +/// renvoie `(video_id, upload_url)`. +fn parse_start(data: &Value) -> Option<(String, String)> { + Some((field(data, "video_id")?, field(data, "upload_url")?)) +} + +/// Envoie la vidéo en binaire vers l'`upload_url` (rupload.facebook.com). +async fn upload_binary( + state: &AppState, + upload_url: &str, + access_token: &str, + video: Bytes, +) -> Result<(), String> { + let file_size = video.len(); + let response = state + .http + .post(upload_url) + .header("Authorization", format!("OAuth {access_token}")) + .header("Content-Type", "application/octet-stream") + .header("offset", "0") + .header("file_size", file_size.to_string()) + .body(video) + .send() + .await + .map_err(|e| e.to_string())?; + let ok = response.status().is_success(); + let text = response.text().await.unwrap_or_default(); + // rupload répond `{ "success": true }` + let refused = serde_json::from_str::(&text) + .ok() + .is_some_and(|v| v.get("success") == Some(&Value::Bool(false)) || v.get("error").is_some()); + if !ok || refused { + return Err(text); + } + Ok(()) +} + +/// Publie une vidéo en story Facebook en envoyant directement les données +/// binaires (start → upload → finish sur /video_stories) ; renvoie +/// l'identifiant de la story. +pub async fn publish_video_story_from_buffer( + state: &AppState, + page: &SocialPage, + video: Bytes, +) -> anyhow::Result { + let access_token = require_token(page)?.to_string(); + + // Phase 1 : START + let url = graph_url( + &page.page_id, + "video_stories", + &[("access_token", &access_token), ("upload_phase", "start")], + )?; + let start = post_query(state, url, "Facebook Story API error (START phase)").await?; + let Some((video_id, upload_url)) = parse_start(&start) else { + bail!("Facebook Story API error (START phase): réponse inattendue {start}"); + }; + + // Phase 2 : UPLOAD + if let Err(text) = upload_binary(state, &upload_url, &access_token, video).await { + bail!("Facebook Story API error (UPLOAD phase): {text}"); + } + + // Phase 3 : FINISH + let url = graph_url( + &page.page_id, + "video_stories", + &[ + ("access_token", &access_token), + ("upload_phase", "finish"), + ("video_id", &video_id), + ], + )?; + let response = state.http.post(url).send().await?; + let status = response.status(); + let raw = response.text().await.unwrap_or_default(); + let finish: Value = serde_json::from_str(&raw).unwrap_or(Value::Null); + if !status.is_success() || finish.get("error").is_some() { + bail!(format_api_error( + "Facebook Story API error (FINISH phase)", + status.as_u16(), + &raw + )); + } + if finish.get("success") == Some(&Value::Bool(false)) { + bail!("Facebook Story API error (FINISH phase): {raw}"); + } + + // La story n'apparaît qu'une fois la vidéo traitée : on suit son statut + // pour remonter une éventuelle erreur de traitement à l'utilisateur. + wait_for_video_story(state, &video_id, &access_token).await?; + + tracing::info!("✅ Story vidéo publiée sur {}", page.page_name); + Ok(field(&finish, "post_id").unwrap_or(video_id)) +} + +/// État du traitement d'une vidéo (`GET /{video-id}?fields=status`). +#[derive(Debug, PartialEq, Eq)] +enum VideoProcessing { + Done, + Pending, + Failed(String), +} + +fn video_processing_state(data: &Value) -> VideoProcessing { + let status = &data["status"]; + let phase_errors = |name: &str| -> Option { + let phase = &status[name]; + if phase["status"].as_str() != Some("error") { + return None; + } + let messages: Vec = phase["errors"] + .as_array() + .map(|errors| { + errors + .iter() + .filter_map(|e| e["message"].as_str()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + Some(if messages.is_empty() { + format!("échec de la phase {name}") + } else { + messages.join(" ; ") + }) + }; + + for phase in ["uploading_phase", "processing_phase", "publishing_phase"] { + if let Some(message) = phase_errors(phase) { + return VideoProcessing::Failed(message); + } + } + match status["video_status"].as_str() { + Some("error") => VideoProcessing::Failed("traitement de la vidéo en erreur".to_string()), + Some("ready") => VideoProcessing::Done, + _ if status["publishing_phase"]["status"].as_str() == Some("complete") => { + VideoProcessing::Done + } + _ => VideoProcessing::Pending, + } +} + +/// Attend la fin du traitement d'une story vidéo (au plus une minute). Une +/// erreur signalée par Facebook fait échouer la publication ; un statut +/// illisible ou un traitement encore en cours ne bloque pas. +async fn wait_for_video_story( + state: &AppState, + video_id: &str, + access_token: &str, +) -> anyhow::Result<()> { + for attempt in 0..STORY_STATUS_ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(STORY_STATUS_INTERVAL).await; + } + let mut url = url::Url::parse(&format!("{BASE_URL}/{video_id}"))?; + url.query_pairs_mut() + .append_pair("fields", "status") + .append_pair("access_token", access_token); + let data: Value = match state.http.get(url).send().await { + Ok(response) => match response.json().await { + Ok(data) => data, + Err(error) => { + tracing::warn!("Statut de la story vidéo {video_id} illisible : {error}"); + return Ok(()); + } + }, + Err(error) => { + tracing::warn!("Statut de la story vidéo {video_id} indisponible : {error}"); + return Ok(()); + } + }; + if data.get("error").is_some() { + tracing::warn!("Statut de la story vidéo {video_id} indisponible : {data}"); + return Ok(()); + } + match video_processing_state(&data) { + VideoProcessing::Done => return Ok(()), + VideoProcessing::Failed(message) => { + bail!("Facebook Story API error (traitement de la vidéo) : {message}") + } + VideoProcessing::Pending => {} + } + } + tracing::warn!("Story vidéo {video_id} toujours en traitement chez Facebook, publication supposée en cours"); + Ok(()) +} + +/// Publie une vidéo comme Reel Facebook via l'API Reels (start → upload → +/// finish sur /video_reels) ; renvoie l'identifiant du Reel. +/// +/// Spécifications vidéo requises pour les Reels : MP4 H.264, 9:16, 3 à 90 s, +/// 1080×1920 recommandé. pub async fn publish_video_from_buffer( state: &AppState, page: &SocialPage, video: Bytes, description: &str, ) -> anyhow::Result { - anyhow::bail!("publication de vidéo Facebook non implémentée") + let access_token = require_token(page)?.to_string(); + let file_size = video.len(); + + tracing::info!( + "🎬 Publishing video as Facebook Reel via /video_reels endpoint: page {} ({}), {:.2} MB", + page.page_id, + page.page_name, + file_size as f64 / 1024.0 / 1024.0 + ); + + // Phase 1 : START + let url = graph_url( + &page.page_id, + "video_reels", + &[("access_token", &access_token), ("upload_phase", "start")], + )?; + let response = state.http.post(url).send().await?; + let ok = response.status().is_success(); + let start_raw = response.text().await.unwrap_or_default(); + tracing::info!("📤 Facebook Reel START response: {start_raw}"); + let start: Value = serde_json::from_str(&start_raw).unwrap_or(Value::Null); + + if !ok || start.get("error").is_some() { + let error = start.get("error"); + let subcode = error.and_then(|e| e.get("error_subcode")); + let code = error.and_then(|e| e.get("code")); + if subcode.and_then(Value::as_i64) == Some(1_363_042) { + bail!( + "Le token Facebook de la page \"{}\" n'a pas la permission \"publish_video\". \ + Régénérez le token dans Graph API Explorer en ajoutant les permissions : \ + pages_manage_posts, pages_read_engagement, publish_video. (code: {}, subcode: {})", + page.page_name, + js(code), + js(subcode) + ); + } + let message = error + .and_then(|e| e.get("message")) + .filter(|m| !m.is_null()) + .map(|m| js(Some(m))) + .unwrap_or_else(|| start_raw.clone()); + bail!( + "Facebook Reel START error: {message} (code: {}, subcode: {})", + js(code), + js(subcode) + ); + } + + let Some((video_id, upload_url)) = parse_start(&start) else { + bail!("Facebook Reel START error: {start_raw} (code: undefined, subcode: undefined)"); + }; + tracing::info!("📤 Reel upload initialized: video_id={video_id}, upload_url={upload_url}"); + + // Phase 2 : UPLOAD + if let Err(text) = upload_binary(state, &upload_url, &access_token, video).await { + bail!("Facebook Reel UPLOAD error: {text}"); + } + tracing::info!("📤 Reel video data uploaded successfully ({file_size} bytes)"); + + // Phase 3 : FINISH + let mut params: Vec<(&str, &str)> = vec![ + ("access_token", &access_token), + ("upload_phase", "finish"), + ("video_id", &video_id), + ("video_state", "PUBLISHED"), + ]; + if !description.is_empty() { + params.push(("description", description)); + } + let url = graph_url(&page.page_id, "video_reels", ¶ms)?; + let response = state.http.post(url).send().await?; + let ok = response.status().is_success(); + let finish_raw = response.text().await.unwrap_or_default(); + tracing::info!("📤 Facebook Reel FINISH response: {finish_raw}"); + let finish: Value = serde_json::from_str(&finish_raw).unwrap_or(Value::Null); + + if !ok || finish.get("error").is_some() { + let error = finish.get("error"); + let message = error + .and_then(|e| e.get("message")) + .filter(|m| !m.is_null()) + .map(|m| js(Some(m))) + .unwrap_or_else(|| finish_raw.clone()); + bail!( + "Facebook Reel FINISH error: {message} (code: {})", + js(error.and_then(|e| e.get("code"))) + ); + } + + let post_id = field(&finish, "post_id").unwrap_or(video_id); + tracing::info!("✅ Reel published successfully! ID: {post_id}"); + Ok(post_id) } -/// 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 { - anyhow::bail!("story vidéo Facebook non implémentée") +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn graph_urls_are_built_like_url_search_params() { + let url = graph_url( + "123", + "feed", + &[("access_token", "T"), ("message", "Été & promo !")], + ) + .unwrap(); + assert_eq!( + url.as_str(), + "https://graph.facebook.com/v23.0/123/feed?access_token=T&message=%C3%89t%C3%A9+%26+promo+%21" + ); + let attached = graph_url( + "1", + "feed", + &[("attached_media[0]", r#"{"media_fbid":"9"}"#)], + ) + .unwrap(); + assert_eq!( + attached.query(), + Some("attached_media%5B0%5D=%7B%22media_fbid%22%3A%229%22%7D") + ); + } + + #[test] + fn api_errors_are_formatted_like_the_old_server() { + let body = r#"{"error":{"message":"Invalid OAuth access token.","type":"OAuthException","code":190}}"#; + assert_eq!( + format_api_error("Facebook API error", 400, body), + "Facebook API error: Invalid OAuth access token. (code: 190)" + ); + assert_eq!( + format_api_error("Facebook API error", 400, r#"{"error":{"message":"x"}}"#), + "Facebook API error: x (code: undefined)" + ); + assert_eq!( + format_api_error("Facebook API error", 502, "Bad gateway"), + "Facebook API error: HTTP 502 Bad gateway" + ); + } + + #[test] + fn cloudinary_videos_are_transcoded() { + assert_eq!( + facebook_compatible_video_url( + "https://res.cloudinary.com/demo/video/upload/v1/clip.mov" + ), + "https://res.cloudinary.com/demo/video/upload/vc_h264,ac_aac,f_mp4,q_auto/v1/clip.mov" + ); + assert_eq!( + facebook_compatible_video_url("https://app.fr/uploads/media/a.mp4"), + "https://app.fr/uploads/media/a.mp4" + ); + } + + #[test] + fn helpers() { + assert_eq!(basename("/uploads/media/123-photo.PNG"), "123-photo.PNG"); + assert_eq!(image_mime("photo.PNG"), "image/png"); + assert_eq!(image_mime("photo.jpg"), "image/jpeg"); + assert_eq!(field(&json!({ "id": 12 }), "id").as_deref(), Some("12")); + assert_eq!(field(&json!({ "post_id": "" }), "post_id"), None); + assert_eq!(js(None), "undefined"); + assert_eq!(js(Some(&json!(190))), "190"); + } + + #[test] + fn video_story_processing_states() { + let ready = json!({ "status": { "video_status": "ready" } }); + assert_eq!(video_processing_state(&ready), VideoProcessing::Done); + + let published = json!({ "status": { "video_status": "processing", + "publishing_phase": { "status": "complete" } } }); + assert_eq!(video_processing_state(&published), VideoProcessing::Done); + + let pending = json!({ "status": { "video_status": "processing", + "processing_phase": { "status": "in_progress" } } }); + assert_eq!(video_processing_state(&pending), VideoProcessing::Pending); + + let failed = json!({ "status": { "video_status": "error", + "processing_phase": { "status": "error", "errors": [{ "code": 1, "message": "Format non pris en charge" }] } } }); + assert_eq!( + video_processing_state(&failed), + VideoProcessing::Failed("Format non pris en charge".to_string()) + ); + + let bare_error = json!({ "status": { "video_status": "error" } }); + assert!(matches!( + video_processing_state(&bare_error), + VideoProcessing::Failed(_) + )); + } + + /// Appels réels à l'API Graph avec un jeton bidon : vérifie les messages + /// d'erreur remontés à l'utilisateur. + /// `cargo test graph_errors_with_fake_token -- --ignored` + #[tokio::test] + #[ignore] + async fn graph_errors_with_fake_token() { + let config = crate::config::Config { + port: 0, + database_url: String::new(), + session_secret: "x".into(), + production: false, + app_url: None, + internal_app_url: None, + static_dir: ".".into(), + public_root_dir: ".".into(), + uploads_dir: std::env::temp_dir(), + audio_upload_dir: std::env::temp_dir(), + temp_upload_dir: std::env::temp_dir(), + fonts_dir: ".".into(), + gemini_api_key: None, + external_api_key: None, + reel_renderer: "ffmpeg".into(), + remotion_url: None, + ffmpeg_api_url: None, + ffmpeg_api_key: None, + }; + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/none") + .unwrap(); + let state = AppState::new(config, pool).unwrap(); + let page = SocialPage { + id: "p".into(), + user_id: "u".into(), + platform: Platform::Facebook, + page_id: "1234567890".into(), + page_name: "Test".into(), + access_token: "fake-token".into(), + token_expires_at: None, + followers_count: None, + is_active: "true".into(), + token_status: None, + last_token_check: None, + user_access_token: None, + user_token_expires_at: None, + token_error: None, + refresh_token: None, + refresh_token_expires_at: None, + scopes: None, + avatar_url: None, + created_at: None, + }; + let post = Post { + id: "post".into(), + user_id: "u".into(), + content: "Bonjour".into(), + ai_generated: "false".into(), + product_info: None, + status: crate::db::PostStatus::Scheduled, + scheduled_for: None, + generation_status: None, + generation_progress: None, + generation_error: None, + created_at: None, + updated_at: None, + }; + + let text = publish_post(&state, &post, &page, PostType::Feed, &[]) + .await + .unwrap_err(); + println!("texte : {text}"); + assert!(text.to_string().starts_with("Facebook API error: ")); + assert!(text.to_string().contains("(code: 190)")); + + let story = publish_video_story_from_buffer(&state, &page, Bytes::from_static(b"x")) + .await + .unwrap_err(); + println!("story vidéo : {story}"); + assert!(story + .to_string() + .starts_with("Facebook Story API error (START phase): ")); + + let reel = publish_video_from_buffer(&state, &page, Bytes::from_static(b"x"), "d") + .await + .unwrap_err(); + println!("reel : {reel}"); + assert!(reel.to_string().starts_with("Facebook Reel START error: ")); + + // Story photo : image locale, texte incrusté puis envoi refusé + let photo = image::RgbImage::from_pixel(800, 600, image::Rgb([200, 80, 40])); + let name = format!("story-test-{}.jpg", std::process::id()); + photo.save(std::env::temp_dir().join(&name)).unwrap(); + let media = Media { + id: "m1".into(), + user_id: "u".into(), + media_type: MediaType::Image, + cloudinary_public_id: None, + original_url: format!("/uploads/{name}"), + facebook_landscape_url: None, + facebook_feed_url: None, + instagram_feed_url: None, + instagram_story_url: None, + thumbnail_url: None, + file_name: name.clone(), + file_size: 0, + created_at: None, + }; + let story = publish_post(&state, &post, &page, PostType::Story, &[media]) + .await + .unwrap_err(); + println!("story photo : {story}"); + assert!(story + .to_string() + .starts_with("Facebook API error uploading photo for story: ")); + let _ = std::fs::remove_file(std::env::temp_dir().join(&name)); + } } diff --git a/server/src/services/facebook/oauth.rs b/server/src/services/facebook/oauth.rs new file mode 100644 index 0000000..b628571 --- /dev/null +++ b/server/src/services/facebook/oauth.rs @@ -0,0 +1,456 @@ +//! Renouvellement des tokens Facebook (ex-`facebook_oauth.ts`). +//! +//! Le problème que ce module résout : un token de page saisi à la main finit +//! toujours par mourir (60 jours pour un token issu du Graph Explorer, ou du +//! jour au lendemain si le mot de passe change, si l'utilisateur perd son rôle +//! d'admin, ou si Meta révoque l'autorisation) — et on ne l'apprend qu'au +//! moment où une publication échoue. +//! +//! La parade est celle recommandée par Meta : +//! 1. l'utilisateur autorise l'application (OAuth) ; +//! 2. le code est échangé contre un token utilisateur court ; +//! 3. ce token court est échangé contre un token utilisateur longue durée +//! (~60 jours) — c'est lui que l'on conserve, chiffré ; +//! 4. les tokens de page dérivés d'un token utilisateur longue durée +//! n'expirent pas. +//! +//! Conserver le token utilisateur permet de régénérer un token de page sans +//! intervention humaine, et de prolonger le token utilisateur lui-même avant +//! son échéance : tant que l'utilisateur ne retire pas son autorisation, la +//! chaîne se renouvelle toute seule. + +use axum::http::HeaderMap; +use chrono::Duration; +use serde_json::Value; + +use crate::config::Config; +use crate::extract::is_truthy; +use crate::public_url::resolve_public_base_url; +use crate::state::AppState; +use crate::time::Ts; + +/// Permissions nécessaires pour publier et lire les statistiques d'une page. +const DEFAULT_SCOPES: &str = "pages_show_list,pages_manage_posts,pages_read_engagement,pages_manage_engagement,read_insights,business_management,instagram_basic,instagram_content_publish"; + +fn graph_version() -> String { + Config::var("FACEBOOK_GRAPH_VERSION").unwrap_or_else(|| "v19.0".to_string()) +} + +/// Erreur d'autorisation Facebook ; son message est montré tel quel à l'utilisateur. +#[derive(Debug, Clone, thiserror::Error)] +#[error("{0}")] +pub struct FacebookAuthError(pub String); + +#[derive(Debug, Clone)] +pub struct FacebookLongLivedToken { + pub access_token: String, + /// Nul quand Facebook ne renvoie pas d'échéance (jeton sans expiration). + pub expires_at: Option, +} + +#[derive(Debug, Clone)] +pub struct InstagramTarget { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone)] +pub struct FacebookPageTarget { + pub id: String, + pub name: String, + pub access_token: String, + pub followers_count: i32, + /// Compte Instagram professionnel rattaché à la page, s'il existe. + pub instagram: Option, +} + +/// Résultat de /debug_token : ce que Meta sait vraiment du jeton. +#[derive(Debug, Clone)] +pub struct FacebookTokenInfo { + pub is_valid: bool, + /// Nul = n'expire pas (cas normal d'un token de page). + pub expires_at: Option, + pub scopes: Vec, + pub error: Option, +} + +/// Identifiants de l'application Facebook (globaux à l'instance). +/// La base prime sur les variables d'environnement. +async fn credentials(state: &AppState) -> anyhow::Result<(String, String)> { + let config = state.db.get_facebook_config().await?; + let app_id = config + .as_ref() + .map(|c| c.app_id.clone()) + .filter(|s| !s.is_empty()) + .or_else(|| Config::var("FACEBOOK_APP_ID")); + let app_secret = config + .as_ref() + .map(|c| c.app_secret.clone()) + .filter(|s| !s.is_empty()) + .or_else(|| Config::var("FACEBOOK_APP_SECRET")); + + match (app_id, app_secret) { + (Some(id), Some(secret)) => Ok((id, secret)), + _ => Err(FacebookAuthError( + "Facebook n'est pas configuré. Renseignez l'App ID et l'App Secret de \ + votre application Facebook dans les paramètres." + .to_string(), + ) + .into()), + } +} + +pub async fn is_configured(state: &AppState) -> bool { + credentials(state).await.is_ok() +} + +/// URI de redirection OAuth. Les en-têtes permettent de la déduire du domaine +/// réellement utilisé quand `APP_URL` n'est pas défini : sans eux on renverrait +/// `http://localhost:5555`, que Facebook rejette. +pub fn redirect_uri(state: &AppState, headers: Option<&HeaderMap>) -> String { + if let Some(uri) = Config::var("FACEBOOK_REDIRECT_URI") { + return uri; + } + format!( + "{}/api/facebook/callback", + resolve_public_base_url(state, headers) + ) +} + +pub fn scopes() -> String { + Config::var("FACEBOOK_SCOPES").unwrap_or_else(|| DEFAULT_SCOPES.to_string()) +} + +/// URL de la boîte de dialogue OAuth. +pub fn authorization_url( + version: &str, + app_id: &str, + redirect_uri: &str, + oauth_state: &str, + scopes: &str, +) -> String { + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("client_id", app_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("state", oauth_state) + .append_pair("scope", scopes) + .append_pair("response_type", "code") + .finish(); + format!("https://www.facebook.com/{version}/dialog/oauth?{query}") +} + +/// URL d'autorisation à ouvrir pour connecter les pages d'un compte Facebook. +pub async fn build_authorization_url( + state: &AppState, + oauth_state: &str, + headers: Option<&HeaderMap>, +) -> anyhow::Result { + let (app_id, _) = credentials(state).await?; + Ok(authorization_url( + &graph_version(), + &app_id, + &redirect_uri(state, headers), + oauth_state, + &scopes(), + )) +} + +/// Échange le code d'autorisation contre un token utilisateur **longue durée**. +/// L'étape intermédiaire (token court) n'est jamais conservée : seul le token +/// longue durée rend les tokens de page permanents. +pub async fn exchange_code_for_user_token( + state: &AppState, + code: &str, + headers: Option<&HeaderMap>, +) -> anyhow::Result { + let (app_id, app_secret) = credentials(state).await?; + let redirect = redirect_uri(state, headers); + + let short_lived = graph_get( + state, + "/oauth/access_token", + &[ + ("client_id", &app_id), + ("client_secret", &app_secret), + ("redirect_uri", &redirect), + ("code", code), + ], + "échange du code d'autorisation", + ) + .await?; + + let Some(token) = non_empty_str(&short_lived, "access_token") else { + return Err(FacebookAuthError( + "Facebook n'a pas renvoyé de jeton pour ce code d'autorisation.".to_string(), + ) + .into()); + }; + + extend_user_token(state, &token).await +} + +/// Prolonge un token utilisateur. Appelé au retour d'OAuth (token court → +/// ~60 jours) mais aussi périodiquement : ré-échanger un token longue durée +/// encore valide repart pour 60 jours, ce qui évite la reconnexion manuelle. +pub async fn extend_user_token( + state: &AppState, + user_token: &str, +) -> anyhow::Result { + let (app_id, app_secret) = credentials(state).await?; + + let long_lived = graph_get( + state, + "/oauth/access_token", + &[ + ("grant_type", "fb_exchange_token"), + ("client_id", &app_id), + ("client_secret", &app_secret), + ("fb_exchange_token", user_token), + ], + "prolongation du jeton utilisateur", + ) + .await?; + + let Some(access_token) = non_empty_str(&long_lived, "access_token") else { + return Err(FacebookAuthError( + "Facebook n'a pas renvoyé de jeton longue durée.".to_string(), + ) + .into()); + }; + + // expires_in absent ou nul = jeton sans expiration connue. + let expires_at = long_lived + .get("expires_in") + .and_then(Value::as_f64) + .filter(|s| *s != 0.0 && !s.is_nan()) + .map(|seconds| Ts::now().add(Duration::milliseconds((seconds * 1000.0) as i64))); + + Ok(FacebookLongLivedToken { + access_token, + expires_at, + }) +} + +/// Pages administrées par le compte, avec leur token de page. +/// Ces tokens sont permanents lorsqu'ils dérivent d'un token utilisateur +/// longue durée : c'est le cœur du renouvellement automatique. +pub async fn list_pages( + state: &AppState, + user_token: &str, +) -> anyhow::Result> { + let response = graph_get( + state, + "/me/accounts", + &[ + ( + "fields", + "id,name,access_token,fan_count,followers_count,instagram_business_account{id,name,username}", + ), + ("limit", "200"), + ("access_token", user_token), + ], + "récupération des pages", + ) + .await?; + Ok(parse_pages(&response)) +} + +pub fn parse_pages(response: &Value) -> Vec { + let Some(pages) = response.get("data").and_then(Value::as_array) else { + return Vec::new(); + }; + pages + .iter() + .map(|page| { + let text = |key: &str| page.get(key).map(value_text).unwrap_or_default(); + let name = text("name"); + let count = |key: &str| page.get(key).and_then(Value::as_i64); + let followers = count("followers_count") + .or_else(|| count("fan_count")) + .unwrap_or(0); + let instagram = page + .get("instagram_business_account") + .filter(|v| v.is_object()) + .map(|ig| InstagramTarget { + id: ig.get("id").map(value_text).unwrap_or_default(), + name: non_empty_str(ig, "username") + .or_else(|| non_empty_str(ig, "name")) + .unwrap_or_else(|| name.clone()), + }); + FacebookPageTarget { + id: text("id"), + access_token: text("access_token"), + followers_count: followers as i32, + instagram, + name, + } + }) + .collect() +} + +/// Interroge Meta sur l'état réel d'un jeton. Un simple appel /me ne dit pas +/// quand le jeton expire : /debug_token le dit, ce qui permet de renouveler +/// *avant* la panne plutôt qu'après. +pub async fn inspect_token(state: &AppState, token: &str) -> anyhow::Result { + let (app_id, app_secret) = credentials(state).await?; + // Jeton d'application : `app_id|app_secret`, accepté par /debug_token. + let app_token = format!("{app_id}|{app_secret}"); + let response = graph_get( + state, + "/debug_token", + &[("input_token", token), ("access_token", &app_token)], + "inspection du jeton", + ) + .await?; + Ok(parse_token_info(&response)) +} + +pub fn parse_token_info(response: &Value) -> FacebookTokenInfo { + let data = response.get("data").cloned().unwrap_or(Value::Null); + FacebookTokenInfo { + is_valid: data.get("is_valid") == Some(&Value::Bool(true)), + // `expires_at: 0` signifie « n'expire pas » chez Meta. + expires_at: data + .get("expires_at") + .and_then(Value::as_f64) + .filter(|s| *s != 0.0) + .and_then(|s| Ts::from_millis((s * 1000.0) as i64)), + scopes: data + .get("scopes") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + error: data + .get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .map(str::to_string), + } +} + +async fn graph_get( + state: &AppState, + endpoint: &str, + params: &[(&str, &str)], + context: &str, +) -> anyhow::Result { + let mut url = url::Url::parse(&format!( + "https://graph.facebook.com/{}{endpoint}", + graph_version() + ))?; + url.query_pairs_mut().extend_pairs(params); + + let response = state.http.get(url).send().await.map_err(|error| { + FacebookAuthError(format!( + "Facebook injoignable ({context}) : {}", + reqwest_message(&error) + )) + })?; + + let status = response.status(); + let payload: Value = response + .json() + .await + .unwrap_or_else(|_| Value::Object(Default::default())); + + if !status.is_success() || is_truthy(payload.get("error")) { + let message = payload + .get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .filter(|m| !m.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + return Err(FacebookAuthError(format!("Facebook ({context}) : {message}")).into()); + } + + Ok(payload) +} + +fn reqwest_message(error: &reqwest::Error) -> String { + let text = error.to_string(); + if text.is_empty() { + "erreur réseau".to_string() + } else { + text + } +} + +/// Chaîne (ou nombre) d'un champ JSON, non vide. +fn non_empty_str(value: &Value, key: &str) -> Option { + value.get(key).map(value_text).filter(|s| !s.is_empty()) +} + +fn value_text(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn authorization_url_is_encoded() { + let url = authorization_url( + "v19.0", + "123", + "https://app.fr/api/facebook/callback", + "abc", + "a,b", + ); + assert_eq!( + url, + "https://www.facebook.com/v19.0/dialog/oauth?client_id=123&redirect_uri=https%3A%2F%2Fapp.fr%2Fapi%2Ffacebook%2Fcallback&state=abc&scope=a%2Cb&response_type=code" + ); + } + + #[test] + fn pages_are_mapped() { + let response = json!({ "data": [ + { "id": "1", "name": "Boutique", "access_token": "T1", "fan_count": 10, "followers_count": 12, + "instagram_business_account": { "id": "ig1", "username": "boutique.ig" } }, + { "id": "2", "name": "Autre", "access_token": "T2", "fan_count": 5, + "instagram_business_account": { "id": "ig2" } }, + { "id": "3", "name": "Sans IG", "access_token": "T3" } + ]}); + let pages = parse_pages(&response); + assert_eq!(pages.len(), 3); + assert_eq!(pages[0].followers_count, 12); + assert_eq!(pages[0].instagram.as_ref().unwrap().name, "boutique.ig"); + assert_eq!(pages[1].followers_count, 5); + assert_eq!(pages[1].instagram.as_ref().unwrap().name, "Autre"); + assert_eq!(pages[2].followers_count, 0); + assert!(pages[2].instagram.is_none()); + assert!(parse_pages(&json!({})).is_empty()); + } + + #[test] + fn token_info_is_parsed() { + let info = parse_token_info( + &json!({ "data": { "is_valid": true, "expires_at": 0, "scopes": ["a"] } }), + ); + assert!(info.is_valid); + assert!(info.expires_at.is_none()); + assert_eq!(info.scopes, vec!["a".to_string()]); + + let info = parse_token_info(&json!({ "data": { + "is_valid": false, "expires_at": 1_700_000_000, "error": { "message": "Session expired" } + }})); + assert!(!info.is_valid); + assert_eq!( + info.expires_at.unwrap().timestamp_millis(), + 1_700_000_000_000 + ); + assert_eq!(info.error.as_deref(), Some("Session expired")); + } +} diff --git a/server/src/services/facebook/story_image.rs b/server/src/services/facebook/story_image.rs new file mode 100644 index 0000000..0232c10 --- /dev/null +++ b/server/src/services/facebook/story_image.rs @@ -0,0 +1,685 @@ +//! Image de story avec le texte du post incrusté (ex-`imageProcessor.addTextToStoryImage` +//! et `emojiRenderer.ts`). +//! +//! L'image est recadrée en 1080×1920 (« cover »), un bandeau noir translucide +//! occupe le quart inférieur et le texte (sans hashtags) y est centré, en gras, +//! avec une taille de police dégressive de 72 px à 12 px pour tenir dans le +//! bandeau. Les emoji sont dessinés avec les images Twemoji. +//! +//! Rendu : composition de l'image avec `image`, bandeau avec `tiny-skia`, +//! texte et emoji via un SVG rendu par `resvg` (police DejaVu Sans). Les +//! largeurs sont mesurées avec `harfrust` (mise en forme avec crénage, comme +//! le `measureText` de node-canvas). + +use std::collections::HashMap; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use anyhow::{anyhow, Context}; +use base64::Engine; +use bytes::Bytes; +use image::{ImageDecoder, ImageReader, Rgba, RgbaImage}; +use once_cell::sync::Lazy; +use regex::Regex; +use resvg::{tiny_skia, usvg}; + +use super::emoji::{self, Segment}; +use crate::state::AppState; + +const STORY_WIDTH: u32 = 1080; +const STORY_HEIGHT: u32 = 1920; +const TEXT_BOX_HEIGHT_RATIO: f32 = 0.25; +const MIN_FONT_SIZE: f32 = 12.0; +const MAX_FONT_SIZE: f32 = 72.0; +const PADDING: f32 = 40.0; +const LINE_HEIGHT_MULTIPLIER: f32 = 1.3; +const FONT_FAMILY: &str = "DejaVu Sans"; + +/// Retire les hashtags en gardant les emoji (ex-`removeHashtags` de `shared/emoji.ts`). +pub fn remove_hashtags(text: &str) -> String { + static HASHTAG: Lazy = + Lazy::new(|| Regex::new(r"#[A-Za-z0-9_\x{00C0}-\x{017F}]+").expect("regex valide")); + static SPACES: Lazy = Lazy::new(|| Regex::new(r"\s+").expect("regex valide")); + let without = HASHTAG.replace_all(text, ""); + SPACES.replace_all(&without, " ").trim().to_string() +} + +// ---------------------------------------------------------------------- +// Découpage en lignes (logique pure, indépendante de la police) +// ---------------------------------------------------------------------- + +/// Découpe un mot trop long en morceaux tenant dans `max_width`. +fn break_long_word( + word: &str, + max_width: f32, + measure: &mut impl FnMut(&str) -> f32, +) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + for ch in word.chars() { + let mut test = current.clone(); + test.push(ch); + if measure(&test) > max_width && !current.is_empty() { + chunks.push(std::mem::replace(&mut current, ch.to_string())); + } else { + current = test; + } + } + if !current.is_empty() { + chunks.push(current); + } + chunks +} + +/// Répartit le texte en lignes de largeur maximale `max_width` (mots séparés +/// par des espaces ; un mot trop long est coupé). +pub fn wrap_text(text: &str, max_width: f32, measure: &mut impl FnMut(&str) -> f32) -> Vec { + let mut lines = Vec::new(); + let mut current = String::new(); + + for word in text.split(' ') { + let test = if current.is_empty() { + word.to_string() + } else { + format!("{current} {word}") + }; + let width = measure(&test); + + if width > max_width && !current.is_empty() { + // La ligne déborderait : on la pousse et on repart de ce mot + lines.push(std::mem::replace(&mut current, word.to_string())); + if measure(word) > max_width { + let mut chunks = break_long_word(word, max_width, measure); + current = chunks.pop().unwrap_or_default(); + lines.extend(chunks); + } + } else if width > max_width { + // Premier mot déjà trop long + let mut chunks = break_long_word(word, max_width, measure); + current = chunks.pop().unwrap_or_default(); + lines.extend(chunks); + } else { + current = test; + } + } + + if !current.is_empty() { + lines.push(current); + } + lines +} + +/// Taille de police et lignes retenues pour tenir dans `max_height`. +/// `measure(texte, taille)` donne la largeur d'une ligne. +pub fn fit_text( + text: &str, + max_width: f32, + max_height: f32, + measure: &mut impl FnMut(&str, f32) -> f32, +) -> (f32, Vec, f32) { + let mut font_size = MAX_FONT_SIZE; + let mut lines = Vec::new(); + let mut total_height = 0.0; + + while font_size >= MIN_FONT_SIZE { + lines = wrap_text(text, max_width, &mut |t| measure(t, font_size)); + total_height = lines.len() as f32 * font_size * LINE_HEIGHT_MULTIPLIER; + if total_height <= max_height { + break; + } + font_size -= 2.0; + } + + if font_size < MIN_FONT_SIZE { + font_size = MIN_FONT_SIZE; + lines = wrap_text(text, max_width, &mut |t| measure(t, font_size)); + let line_height = font_size * LINE_HEIGHT_MULTIPLIER; + total_height = lines.len() as f32 * line_height; + + let max_lines = (max_height / line_height).floor() as usize; + if lines.len() > max_lines { + lines.truncate(max_lines); + if let Some(last) = lines.last_mut().filter(|l| !l.is_empty()) { + let keep = last.chars().count().saturating_sub(3); + *last = format!("{}...", last.chars().take(keep).collect::()); + } + total_height = max_lines as f32 * line_height; + } + } + + (font_size, lines, total_height) +} + +/// Placement « cover » de l'image : (largeur, hauteur, x, y) dans la story. +pub fn cover_rect(width: u32, height: u32) -> (f32, f32, f32, f32) { + let (sw, sh) = (STORY_WIDTH as f32, STORY_HEIGHT as f32); + let (w, h) = (width as f32, height as f32); + if w / h > sw / sh { + let draw_width = w * (sh / h); + (draw_width, sh, (sw - draw_width) / 2.0, 0.0) + } else { + let draw_height = h * (sw / w); + (sw, draw_height, 0.0, (sh - draw_height) / 2.0) + } +} + +// ---------------------------------------------------------------------- +// Police +// ---------------------------------------------------------------------- + +struct StoryFont { + data: Arc>, + fontdb: Arc, + units_per_em: f32, + ascent: f32, + descent: f32, + /// Pas de DejaVu Sans Bold disponible : gras simulé par un contour. + faux_bold: bool, +} + +const SYSTEM_FONT_DIRS: &[&str] = &[ + "/usr/share/fonts/truetype/dejavu", + "/usr/share/fonts/TTF", + "/usr/share/fonts/dejavu", +]; + +fn find_font(fonts_dir: &Path, file: &str) -> Option { + std::iter::once(fonts_dir.to_path_buf()) + .chain(SYSTEM_FONT_DIRS.iter().map(PathBuf::from)) + .map(|dir| dir.join(file)) + .find(|p| p.is_file()) +} + +impl StoryFont { + fn load(fonts_dir: &Path) -> anyhow::Result { + // Le texte est en gras : node-canvas prenait DejaVu Sans Bold sur le + // système (paquet fonts-dejavu-core), à défaut la police normale. + let (path, faux_bold) = match find_font(fonts_dir, "DejaVuSans-Bold.ttf") { + Some(path) => (path, false), + None => ( + find_font(fonts_dir, "DejaVuSans.ttf").ok_or_else(|| { + anyhow!( + "Police DejaVuSans.ttf introuvable dans {}", + fonts_dir.display() + ) + })?, + true, + ), + }; + let data = + std::fs::read(&path).with_context(|| format!("lecture de {}", path.display()))?; + + let (units_per_em, ascent, descent) = { + use skrifa::instance::{LocationRef, Size}; + use skrifa::MetadataProvider; + let font = skrifa::FontRef::new(&data).map_err(|e| anyhow!("police invalide : {e}"))?; + let metrics = font.metrics(Size::unscaled(), LocationRef::default()); + ( + metrics.units_per_em as f32, + metrics.ascent, + -metrics.descent, + ) + }; + + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(data.clone()); + tracing::info!("✓ Police {} chargée pour les stories", path.display()); + + Ok(Self { + data: Arc::new(data), + fontdb: Arc::new(db), + units_per_em, + ascent, + descent, + faux_bold, + }) + } +} + +fn story_font(fonts_dir: &Path) -> anyhow::Result<&'static StoryFont> { + static FONT: OnceLock> = OnceLock::new(); + FONT.get_or_init(|| StoryFont::load(fonts_dir).map_err(|e| format!("{e:#}"))) + .as_ref() + .map_err(|e| anyhow!("{e}")) +} + +/// Mesure des textes : largeur en unités de police, mise en cache. +struct TextMeasurer<'a> { + font: harfrust::FontRef<'a>, + shaper_data: harfrust::ShaperData, + units_per_em: f32, + cache: HashMap, +} + +impl<'a> TextMeasurer<'a> { + fn new(font: &'a StoryFont) -> anyhow::Result { + let font_ref = + harfrust::FontRef::new(&font.data).map_err(|e| anyhow!("police invalide : {e}"))?; + let shaper_data = harfrust::ShaperData::new(&font_ref); + Ok(Self { + font: font_ref, + shaper_data, + units_per_em: font.units_per_em, + cache: HashMap::new(), + }) + } + + /// Largeur d'un texte sans emoji, en pixels. + fn text_width(&mut self, text: &str, font_size: f32) -> f32 { + if text.is_empty() { + return 0.0; + } + let units = match self.cache.get(text) { + Some(units) => *units, + None => { + let shaper = self.shaper_data.shaper(&self.font).build(); + let mut buffer = harfrust::UnicodeBuffer::new(); + buffer.push_str(text); + buffer.guess_segment_properties(); + let output = shaper.shape(buffer, harfrust::ShapeOptions::new()); + let units: i32 = output.glyph_positions().iter().map(|p| p.x_advance).sum(); + self.cache.insert(text.to_string(), units as f32); + units as f32 + } + }; + units * font_size / self.units_per_em + } + + /// Largeur d'une ligne : un emoji compte pour la taille de police. + fn line_width(&mut self, line: &str, font_size: f32) -> f32 { + emoji::parse(line) + .iter() + .map(|segment| match segment { + Segment::Text(text) => self.text_width(text, font_size), + Segment::Emoji { .. } => font_size, + }) + .sum() + } +} + +// ---------------------------------------------------------------------- +// Emoji (Twemoji) +// ---------------------------------------------------------------------- + +static EMOJI_CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); + +/// Télécharge (et met en cache) l'image SVG d'un emoji. +async fn emoji_image(state: &AppState, url: &str) -> anyhow::Result { + if let Some(data) = EMOJI_CACHE.lock().ok().and_then(|c| c.get(url).cloned()) { + return Ok(data); + } + let response = state + .http + .get(url) + .timeout(Duration::from_secs(15)) + .send() + .await?; + if !response.status().is_success() { + anyhow::bail!("Failed to fetch emoji: {}", response.status()); + } + let data = response.bytes().await?; + if let Ok(mut cache) = EMOJI_CACHE.lock() { + cache.insert(url.to_string(), data.clone()); + } + Ok(data) +} + +/// Vide le cache des emoji. +pub fn clear_emoji_cache() { + if let Ok(mut cache) = EMOJI_CACHE.lock() { + cache.clear(); + } +} + +// ---------------------------------------------------------------------- +// Rendu +// ---------------------------------------------------------------------- + +/// Télécharge l'image puis incruste le texte ; renvoie un PNG. +pub async fn add_text_to_story_image( + state: &AppState, + image_url: &str, + text: &str, +) -> anyhow::Result> { + let response = state.http.get(image_url).send().await?; + if !response.status().is_success() { + anyhow::bail!("Failed to fetch image from URL: {image_url}"); + } + let image = response.bytes().await?; + render_story_image(state, image, text).await +} + +/// Incruste le texte dans l'image fournie ; renvoie un PNG 1080×1920. +pub async fn render_story_image( + state: &AppState, + image: Bytes, + text: &str, +) -> anyhow::Result> { + let clean_text = remove_hashtags(text); + + // Les emoji sont téléchargés avant le rendu (qui est synchrone) + let mut emoji_images: HashMap = HashMap::new(); + for segment in emoji::parse(&clean_text) { + if let Segment::Emoji { url, .. } = segment { + if emoji_images.contains_key(&url) { + continue; + } + match emoji_image(state, &url).await { + Ok(data) => { + emoji_images.insert(url, data); + } + Err(error) => tracing::error!("Error downloading emoji from {url}: {error}"), + } + } + } + + let fonts_dir = state.config.fonts_dir.clone(); + tokio::task::spawn_blocking(move || { + let font = story_font(&fonts_dir)?; + compose(&image, &clean_text, font, &emoji_images) + }) + .await? +} + +fn decode_oriented(data: &[u8]) -> anyhow::Result { + let reader = ImageReader::new(Cursor::new(data)).with_guessed_format()?; + let mut decoder = reader.into_decoder()?; + // Rotation EXIF appliquée, comme `sharp().rotate()` + let orientation = decoder.orientation()?; + let mut image = image::DynamicImage::from_decoder(decoder)?; + image.apply_orientation(orientation); + Ok(image) +} + +fn compose( + image_data: &[u8], + text: &str, + font: &StoryFont, + emoji_images: &HashMap, +) -> anyhow::Result> { + let image = decode_oriented(image_data)?; + if image.width() == 0 || image.height() == 0 { + anyhow::bail!("image vide"); + } + + // 1. Fond noir + image en « cover » + let (draw_width, draw_height, offset_x, offset_y) = cover_rect(image.width(), image.height()); + let resized = image + .resize_exact( + draw_width.round().max(1.0) as u32, + draw_height.round().max(1.0) as u32, + image::imageops::FilterType::CatmullRom, + ) + .to_rgba8(); + let mut canvas = RgbaImage::from_pixel(STORY_WIDTH, STORY_HEIGHT, Rgba([0, 0, 0, 255])); + image::imageops::overlay( + &mut canvas, + &resized, + offset_x.round() as i64, + offset_y.round() as i64, + ); + + // Fond opaque : les pixels sont déjà « prémultipliés » + let size = tiny_skia::IntSize::from_wh(STORY_WIDTH, STORY_HEIGHT) + .ok_or_else(|| anyhow!("taille invalide"))?; + let mut pixmap = tiny_skia::Pixmap::from_vec(canvas.into_raw(), size) + .ok_or_else(|| anyhow!("pixmap invalide"))?; + + // 2. Bandeau translucide + let text_box_height = STORY_HEIGHT as f32 * TEXT_BOX_HEIGHT_RATIO; + let text_box_y = STORY_HEIGHT as f32 - text_box_height; + let mut paint = tiny_skia::Paint::default(); + paint.set_color_rgba8(0, 0, 0, 153); + if let Some(rect) = + tiny_skia::Rect::from_xywh(0.0, text_box_y, STORY_WIDTH as f32, text_box_height) + { + pixmap.fill_rect(rect, &paint, tiny_skia::Transform::identity(), None); + } + + // 3. Texte + let max_text_width = STORY_WIDTH as f32 - PADDING * 2.0; + let max_text_height = text_box_height - PADDING * 2.0; + let mut measurer = TextMeasurer::new(font)?; + let (font_size, lines, total_height) = + fit_text(text, max_text_width, max_text_height, &mut |t, size| { + measurer.line_width(t, size) + }); + + // Emoji utilisables (SVG valide) + let valid_emoji: HashMap<&str, String> = emoji_images + .iter() + .filter(|(_, data)| usvg::Tree::from_data(data, &usvg::Options::default()).is_ok()) + .map(|(url, data)| { + let uri = format!( + "data:image/svg+xml;base64,{}", + base64::engine::general_purpose::STANDARD.encode(data) + ); + (url.as_str(), uri) + }) + .collect(); + + let line_height = font_size * LINE_HEIGHT_MULTIPLIER; + let start_y = text_box_y + (text_box_height - total_height) / 2.0 + line_height / 2.0; + // `textBaseline = 'middle'` : milieu entre le haut et le bas de la police + let baseline_shift = (font.ascent - font.descent) / 2.0 / font.units_per_em * font_size; + + let mut svg = format!( + r#""# + ); + let text_attrs = if font.faux_bold { + format!( + r#" stroke="white" stroke-width="{:.2}" stroke-linejoin="round""#, + font_size / 24.0 + ) + } else { + String::new() + }; + + for (index, line) in lines.iter().enumerate() { + let y = start_y + index as f32 * line_height; + let line_width = measurer.line_width(line, font_size); + let mut x = (STORY_WIDTH as f32 - line_width) / 2.0; + + for segment in emoji::parse(line) { + let fallback_text = match &segment { + Segment::Text(text) => Some(text.clone()), + Segment::Emoji { text, url } => match valid_emoji.get(url.as_str()) { + Some(uri) => { + // Emoji légèrement au-dessus de la ligne de base + svg.push_str(&format!( + r#""#, + y - font_size * 0.8 + )); + x += font_size; + None + } + // Image indisponible : on écrit le caractère + None => Some(text.clone()), + }, + }; + if let Some(text) = fallback_text { + svg.push_str(&format!( + r#"{}"#, + y + baseline_shift, + xml_escape(&text) + )); + x += measurer.text_width(&text, font_size); + } + } + } + svg.push_str(""); + + let options = usvg::Options { + font_family: FONT_FAMILY.to_string(), + fontdb: font.fontdb.clone(), + ..usvg::Options::default() + }; + let tree = + usvg::Tree::from_str(&svg, &options).map_err(|e| anyhow!("SVG du texte invalide : {e}"))?; + resvg::render( + &tree, + tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); + + pixmap + .encode_png() + .map_err(|e| anyhow!("encodage PNG : {e}")) +} + +fn xml_escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + // Caractères de contrôle interdits en XML + c if (c as u32) < 0x20 && !matches!(c, '\t' | '\n' | '\r') => {} + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mesure factice : 10 px par caractère. + fn fixed(text: &str) -> f32 { + text.chars().count() as f32 * 10.0 + } + + #[test] + fn hashtags_are_removed() { + assert_eq!( + remove_hashtags("Promo #soldes été #Été2024 ! 🔥"), + "Promo été ! 🔥" + ); + assert_eq!(remove_hashtags(" a\n\n b\tc "), "a b c"); + assert_eq!(remove_hashtags("#seul"), ""); + } + + #[test] + fn wrap_keeps_words_together() { + let lines = wrap_text("aaa bbb ccc dd", 70.0, &mut fixed); + assert_eq!(lines, vec!["aaa bbb", "ccc dd"]); + } + + #[test] + fn wrap_breaks_long_words() { + // Premier mot trop long + assert_eq!( + wrap_text("abcdefghij", 40.0, &mut fixed), + vec!["abcd", "efgh", "ij"] + ); + // Mot trop long après une ligne existante + assert_eq!( + wrap_text("ab abcdefghij x", 40.0, &mut fixed), + vec!["ab", "abcd", "efgh", "ij x"] + ); + } + + #[test] + fn wrap_empty_text() { + assert!(wrap_text("", 100.0, &mut fixed).is_empty()); + } + + #[test] + fn font_shrinks_until_text_fits() { + // 1000 px de large, 400 px de haut ; 0,6 × taille par caractère + let mut measure = |t: &str, size: f32| t.chars().count() as f32 * size * 0.6; + let (size, lines, height) = fit_text("Bonjour", 1000.0, 400.0, &mut measure); + assert_eq!(size, 72.0); + assert_eq!(lines, vec!["Bonjour"]); + assert!((height - 93.6).abs() < 0.01); + + let long = "mot ".repeat(200); + let (size, lines, height) = fit_text(long.trim(), 1000.0, 400.0, &mut measure); + assert!((12.0..72.0).contains(&size)); + assert!(height <= 400.0); + assert!(!lines.is_empty()); + } + + #[test] + fn text_is_truncated_at_minimum_size() { + let mut measure = |t: &str, size: f32| t.chars().count() as f32 * size * 0.6; + let huge = "abcdefgh ".repeat(2000); + let (size, lines, height) = fit_text(huge.trim(), 1000.0, 400.0, &mut measure); + assert_eq!(size, 12.0); + // floor(400 / 15.6) = 25 lignes + assert_eq!(lines.len(), 25); + assert!(lines.last().unwrap().ends_with("...")); + assert!((height - 25.0 * 15.6).abs() < 0.01); + } + + #[test] + fn cover_fills_the_story() { + // Paysage : hauteur pleine, centré horizontalement + let (w, h, x, y) = cover_rect(1920, 1080); + assert_eq!(h, 1920.0); + assert!((w - 3413.33).abs() < 0.01); + assert!((x - (1080.0 - w) / 2.0).abs() < 0.01); + assert_eq!(y, 0.0); + // Carré : plus large que 9:16, hauteur pleine + let (w, h, x, y) = cover_rect(1000, 1000); + assert_eq!((w, h, x, y), (1920.0, 1920.0, -420.0, 0.0)); + // Très haut : largeur pleine, centré verticalement + let (w, h, x, y) = cover_rect(500, 2000); + assert_eq!((w, h, x, y), (1080.0, 4320.0, 0.0, -1200.0)); + } + + /// Rendu d'exemple à inspecter : `STORY_OUT=/tmp/story.png cargo test render_sample -- --ignored` + /// (télécharge les emoji depuis le CDN Twemoji). + #[tokio::test] + #[ignore] + async fn render_sample() { + let out = std::env::var("STORY_OUT").unwrap_or_else(|_| "story-sample.png".into()); + let text = std::env::var("STORY_TEXT").unwrap_or_else(|_| { + "🎉 Grande promo d'automne chez nous ! -30 % sur toute la collection 🍂🔥 \ + Venez découvrir nos nouveautés en magasin dès samedi 👉 #promo #automne #soldes" + .into() + }); + + // Image de test : dégradé paysage + let photo = RgbaImage::from_fn(1600, 900, |x, y| { + Rgba([(x / 7) as u8, (y / 4) as u8, 160, 255]) + }); + let mut jpeg = Vec::new(); + image::DynamicImage::ImageRgba8(photo) + .to_rgb8() + .write_to(&mut Cursor::new(&mut jpeg), image::ImageFormat::Jpeg) + .unwrap(); + + let clean = remove_hashtags(&text); + let http = reqwest::Client::new(); + let mut emoji_images = HashMap::new(); + for segment in emoji::parse(&clean) { + if let Segment::Emoji { url, .. } = segment { + match http.get(&url).send().await { + Ok(r) if r.status().is_success() => { + emoji_images.insert(url, r.bytes().await.unwrap()); + } + other => { + eprintln!("emoji {url} indisponible : {:?}", other.map(|r| r.status())) + } + } + } + } + + let fonts = Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts"); + let font = StoryFont::load(&fonts).unwrap(); + let png = compose(&jpeg, &clean, &font, &emoji_images).unwrap(); + std::fs::write(&out, png).unwrap(); + } + + #[test] + fn xml_is_escaped() { + assert_eq!(xml_escape("a, } -/// Contrôle toutes les pages et renouvelle les jetons qui doivent l'être. +impl TokenCheckResult { + fn new(page: &SocialPage, status: TokenStatus, renewed: bool, error: Option) -> Self { + Self { + page_id: page.id.clone(), + page_name: page.page_name.clone(), + status: status.as_str().to_string(), + renewed, + error, + } + } +} + +/// Contrôle toutes les pages actives et renouvelle les jetons qui doivent l'être. pub async fn check_and_refresh_tokens(state: &AppState) -> Vec { - Vec::new() + tracing::info!("[TokenManager] Starting daily token check..."); + let mut results = Vec::new(); + + let pages = match state.db.get_all_social_pages().await { + Ok(pages) => pages, + Err(error) => { + tracing::error!("[TokenManager] Critical error in checkAndRefreshTokens: {error}"); + return results; + } + }; + + for page in pages.iter().filter(|p| p.is_active == "true") { + match process_page_token(state, page).await { + Ok(result) => results.push(result), + Err(error) => { + let message = error.to_string(); + tracing::error!( + "[TokenManager] Error processing page {} ({}): {error:#}", + page.page_name, + page.id + ); + if let Err(db_error) = persist_status( + state, + &page.id, + TokenStatus::Error, + Some(message.clone()), + None, + ) + .await + { + tracing::error!( + "[TokenManager] Impossible d'enregistrer le statut de {}: {db_error}", + page.page_name + ); + } + results.push(TokenCheckResult::new( + page, + TokenStatus::Error, + false, + Some(message), + )); + } + } + } + + tracing::info!("[TokenManager] Finished token check."); + results +} + +/// Contrôle (et renouvellement) d'une seule page, déclenché depuis l'UI. +pub async fn check_page(state: &AppState, page_id: &str) -> anyhow::Result { + let Some(page) = state.db.get_social_page(page_id).await? else { + anyhow::bail!("Page introuvable"); + }; + process_page_token(state, &page).await +} + +/// Échéance trop proche (ou dépassée) au regard de la fenêtre donnée. +pub fn expires_within(expires_at: Option, now: Ts, days: i64) -> bool { + expires_at.is_some_and(|at| { + at.timestamp_millis() - now.timestamp_millis() < Duration::days(days).num_milliseconds() + }) +} + +/// Date au format `toLocaleDateString('fr-FR')` (heure du serveur : UTC). +pub fn french_date(ts: Ts) -> String { + ts.0.format("%d/%m/%Y").to_string() +} + +async fn process_page_token( + state: &AppState, + page: &SocialPage, +) -> anyhow::Result { + // Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure + // que 24h et se renouvelle avec le refresh token, pas via la Graph API. + if page.platform == Platform::Tiktok { + return process_tiktok_token(state, page).await; + } + + tracing::info!("[TokenManager] Checking page: {}", page.page_name); + + // Jeton déjà déchiffré par la couche d'accès aux données + let access_token = page.access_token.clone(); + + // Sans application Facebook configurée, on ne peut ni interroger + // /debug_token ni régénérer quoi que ce soit : on se rabat sur le + // contrôle historique (le jeton répond-il encore ?). + if !oauth::is_configured(state).await { + return legacy_check(state, page, &access_token).await; + } + + let info = match oauth::inspect_token(state, &access_token).await { + Ok(info) => info, + Err(error) => { + // Facebook injoignable : on ne dégrade pas le statut sur un incident + // réseau, sinon toute l'UI passerait au rouge le temps d'une panne. + tracing::warn!( + "[TokenManager] Inspection impossible pour {}: {error}", + page.page_name + ); + return legacy_check(state, page, &access_token).await; + } + }; + + let expires_at = info.expires_at; + let expiring_soon = expires_within(expires_at, Ts::now(), RENEWAL_WINDOW_DAYS); + + if info.is_valid && !expiring_soon { + // Le jeton de page tient : on en profite pour prolonger le jeton + // utilisateur s'il approche de son échéance, car c'est lui qui + // permettra le prochain renouvellement. + extend_user_token_if_needed(state, page).await; + persist_status(state, &page.id, TokenStatus::Valid, None, Some(expires_at)).await?; + return Ok(TokenCheckResult::new(page, TokenStatus::Valid, false, None)); + } + + let reason = if info.is_valid { + format!( + "Le jeton expire le {}", + expires_at + .map(french_date) + .unwrap_or_else(|| "undefined".to_string()) + ) + } else { + info.error + .clone() + .filter(|e| !e.is_empty()) + .unwrap_or_else(|| "Le jeton a été invalidé par Facebook".to_string()) + }; + tracing::warn!( + "[TokenManager] {}: {reason} — tentative de renouvellement", + page.page_name + ); + + let failing_status = if info.is_valid { + TokenStatus::Expiring + } else { + TokenStatus::Expired + }; + + // Renouvellement automatique : c'est tout l'intérêt d'avoir conservé le + // jeton utilisateur longue durée au moment de la connexion OAuth. + match renew_from_user_token(state, page).await { + Ok(true) => { + tracing::info!( + "[TokenManager] Jeton renouvelé automatiquement pour {}", + page.page_name + ); + return Ok(TokenCheckResult::new(page, TokenStatus::Valid, true, None)); + } + Ok(false) => {} + Err(error) => { + let message = error.to_string(); + tracing::error!( + "[TokenManager] Renouvellement impossible pour {}: {message}", + page.page_name + ); + persist_status( + state, + &page.id, + failing_status, + Some(message.clone()), + Some(expires_at), + ) + .await?; + return Ok(TokenCheckResult::new( + page, + failing_status, + false, + Some(message), + )); + } + } + + // Pas de jeton utilisateur en base : la page a été ajoutée à la main, + // seule une reconnexion peut la remettre d'aplomb. + let manual = format!( + "{reason}. Reconnectez la page via « Connecter des pages Facebook » \ + pour activer le renouvellement automatique." + ); + persist_status( + state, + &page.id, + failing_status, + Some(manual.clone()), + Some(expires_at), + ) + .await?; + Ok(TokenCheckResult::new( + page, + failing_status, + false, + Some(manual), + )) +} + +/// Jeton utilisateur conservé (chiffré) sur la page, s'il existe. +fn stored_user_token(page: &SocialPage) -> Option { + page.user_access_token + .as_deref() + .filter(|t| !t.is_empty()) + .map(decrypt) +} + +/// Régénère le jeton de page à partir du jeton utilisateur longue durée. +/// Renvoie `false` quand la page n'a pas de jeton utilisateur enregistré. +async fn renew_from_user_token(state: &AppState, page: &SocialPage) -> anyhow::Result { + let Some(stored) = stored_user_token(page) else { + return Ok(false); + }; + + // On prolonge d'abord le jeton utilisateur : le ré-échanger repart pour + // ~60 jours, ce qui repousse d'autant la prochaine reconnexion manuelle. + // Un échec ici n'est pas bloquant tant que le jeton actuel fonctionne. + let mut user_token = stored.clone(); + let mut user_token_expires_at = page.user_token_expires_at; + match oauth::extend_user_token(state, &stored).await { + Ok(extended) => { + user_token = extended.access_token; + user_token_expires_at = extended.expires_at; + } + Err(error) => tracing::warn!( + "[TokenManager] Prolongation du jeton utilisateur impossible pour {}: {error}", + page.page_name + ), + } + + let targets = oauth::list_pages(state, &user_token).await?; + // Une page Instagram est publiée avec le jeton de la page Facebook à + // laquelle son compte professionnel est rattaché. + let Some(target) = targets.iter().find(|t| { + t.id == page.page_id || t.instagram.as_ref().is_some_and(|ig| ig.id == page.page_id) + }) else { + return Err(oauth::FacebookAuthError(format!( + "La page « {} » n'est plus administrée par le compte Facebook connecté. \ + Reconnectez-la avec un compte administrateur de cette page.", + page.page_name + )) + .into()); + }; + + // Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire + // pas : l'échéance reste donc généralement nulle. L'inspection est un + // confort : le jeton fraîchement émis est bon. + let page_token_expires_at = oauth::inspect_token(state, &target.access_token) + .await + .ok() + .and_then(|info| info.expires_at); + + let followers = if target.followers_count != 0 { + Some(target.followers_count) + } else { + page.followers_count + }; + + state + .db + .update_social_page( + &page.id, + Patch::new() + .set("access_token", target.access_token.clone()) + .set("token_expires_at", page_token_expires_at) + .set("user_access_token", encrypt(&user_token)) + .set("user_token_expires_at", user_token_expires_at) + .set("followers_count", followers) + .set("token_status", TokenStatus::Valid) + .set_null("token_error") + .set("last_token_check", Ts::now()), + ) + .await?; + + Ok(true) +} + +/// Prolonge le jeton utilisateur quand son échéance approche, même si le +/// jeton de page se porte bien : c'est ce qui évite de perdre la capacité de +/// renouveler le jour où le jeton de page est révoqué. +async fn extend_user_token_if_needed(state: &AppState, page: &SocialPage) { + let Some(stored) = stored_user_token(page) else { + return; + }; + if let Some(expires_at) = page.user_token_expires_at { + if !expires_within(Some(expires_at), Ts::now(), RENEWAL_WINDOW_DAYS) { + return; + } + } + + let result = async { + let extended = oauth::extend_user_token(state, &stored).await?; + state + .db + .update_social_page( + &page.id, + Patch::new() + .set("user_access_token", encrypt(&extended.access_token)) + .set("user_token_expires_at", extended.expires_at), + ) + .await?; + anyhow::Ok(()) + } + .await; + + match result { + Ok(()) => tracing::info!( + "[TokenManager] Jeton utilisateur prolongé pour {}", + page.page_name + ), + Err(error) => tracing::warn!( + "[TokenManager] Impossible de prolonger le jeton utilisateur de {}: {error}", + page.page_name + ), + } +} + +/// Contrôle historique, utilisé quand l'application Facebook n'est pas +/// configurée : on sait seulement si le jeton répond encore. +async fn legacy_check( + state: &AppState, + page: &SocialPage, + access_token: &str, +) -> anyhow::Result { + if !verify_token(state, access_token).await { + let error = "Le jeton n'est plus accepté par Facebook. Configurez l'application Facebook \ + dans les paramètres pour activer le renouvellement automatique, ou saisissez un nouveau jeton." + .to_string(); + persist_status( + state, + &page.id, + TokenStatus::Expired, + Some(error.clone()), + None, + ) + .await?; + return Ok(TokenCheckResult::new( + page, + TokenStatus::Expired, + false, + Some(error), + )); + } + + // Le jeton répond : reste l'échéance déclarée à l'ajout de la page. + let status = if expires_within(page.token_expires_at, Ts::now(), WARNING_WINDOW_DAYS) { + TokenStatus::Expiring + } else { + TokenStatus::Valid + }; + persist_status(state, &page.id, status, None, None).await?; + Ok(TokenCheckResult::new(page, status, false, None)) +} + +/// Statut d'un compte TikTok d'après l'échéance de son refresh token. +pub fn tiktok_status( + refresh_token: Option<&str>, + refresh_expires_at: Option, + now: Ts, +) -> TokenStatus { + // Échéance absente = 0, comme l'ancien serveur + let expires_ms = refresh_expires_at + .map(|t| t.timestamp_millis()) + .unwrap_or(0); + let now_ms = now.timestamp_millis(); + let has_refresh = refresh_token.is_some_and(|t| !t.is_empty()); + + if !has_refresh || (expires_ms != 0 && expires_ms <= now_ms) { + TokenStatus::Expired + } else if expires_ms != 0 + && expires_ms - now_ms < Duration::days(TIKTOK_WARNING_DAYS).num_milliseconds() + { + TokenStatus::Expiring + } else { + TokenStatus::Valid + } +} + +/// Vérifie qu'un compte TikTok est toujours utilisable. +/// On ne teste pas l'access token (il est renouvelé à la demande avant chaque +/// publication) mais le refresh token, dont l'expiration déconnecterait le compte. +async fn process_tiktok_token( + state: &AppState, + page: &SocialPage, +) -> anyhow::Result { + tracing::info!("[TokenManager] Checking TikTok account: {}", page.page_name); + + let status = tiktok_status( + page.refresh_token.as_deref(), + page.refresh_token_expires_at, + Ts::now(), + ); + let error = (status == TokenStatus::Expired).then(|| { + tracing::warn!( + "[TokenManager] TikTok account {} must be reconnected", + page.page_name + ); + "L'autorisation TikTok a expiré : reconnectez le compte.".to_string() + }); + + persist_status(state, &page.id, status, error.clone(), None).await?; + Ok(TokenCheckResult::new(page, status, false, error)) +} + +/// Enregistre le résultat du contrôle ; `token_expires_at` n'est modifié que +/// s'il est fourni (`Some(None)` l'efface). +async fn persist_status( + state: &AppState, + page_id: &str, + status: TokenStatus, + error: Option, + token_expires_at: Option>, +) -> anyhow::Result<()> { + let mut patch = Patch::new() + .set("token_status", status) + .set("token_error", error) + .set("last_token_check", Ts::now()); + if let Some(expires_at) = token_expires_at { + patch = patch.set("token_expires_at", expires_at); + } + state.db.update_social_page(page_id, patch).await?; + Ok(()) +} + +/// Le jeton répond-il encore (`GET /me`) ? +async fn verify_token(state: &AppState, token: &str) -> bool { + match graph::get(state, "me", Some(token), &[]).await { + Ok(_) => true, + Err(error) => { + tracing::error!("[TokenManager] Token verification failed: {error}"); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(s: &str) -> Ts { + Ts::parse(s).unwrap() + } + + #[test] + fn expiry_window() { + let now = ts("2026-01-01T00:00:00Z"); + assert!(!expires_within(None, now, 15)); + assert!(expires_within(Some(ts("2026-01-10T00:00:00Z")), now, 15)); + assert!(expires_within(Some(ts("2025-12-01T00:00:00Z")), now, 15)); + assert!(!expires_within(Some(ts("2026-01-20T00:00:00Z")), now, 15)); + assert!(expires_within(Some(ts("2026-01-07T23:00:00Z")), now, 7)); + } + + #[test] + fn french_date_format() { + assert_eq!(french_date(ts("2026-03-05T10:00:00Z")), "05/03/2026"); + } + + #[test] + fn tiktok_statuses() { + let now = ts("2026-01-01T00:00:00Z"); + assert_eq!(tiktok_status(None, None, now), TokenStatus::Expired); + assert_eq!(tiktok_status(Some(""), None, now), TokenStatus::Expired); + assert_eq!(tiktok_status(Some("r"), None, now), TokenStatus::Valid); + assert_eq!( + tiktok_status(Some("r"), Some(ts("2025-12-31T00:00:00Z")), now), + TokenStatus::Expired + ); + assert_eq!( + tiktok_status(Some("r"), Some(ts("2026-01-15T00:00:00Z")), now), + TokenStatus::Expiring + ); + assert_eq!( + tiktok_status(Some("r"), Some(ts("2026-06-01T00:00:00Z")), now), + TokenStatus::Valid + ); + } }