Merge commit 'f45e13d' into claude/intelligent-wright-pr07e9

This commit is contained in:
Claude committed 2026-10-12 17:38:03 +00:00
commit 9f72c626a2
11 files changed
+4228 -31

No files matched your search

+102 -2
View File
@@ -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<AppState> {
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<Value>) -> 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<AppState>,
_user: AuthUser,
Path(post_id): Path<String>,
) -> ApiResult<Json<Value>> {
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<AppState>,
_user: AuthUser,
Path(post_id): Path<String>,
) -> 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<AppState>,
_user: AuthUser,
Path(page_id): Path<String>,
) -> ApiResult<Json<Value>> {
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<AppState>,
_user: AuthUser,
Path(page_id): Path<String>,
) -> 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<AppState>, _user: AuthUser) -> Json<Value> {
token_manager::check_and_refresh_tokens(&state).await;
Json(json!({ "success": true, "message": "Token check completed" }))
}
+537 -2
View File
@@ -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<AppState> {
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::<axum::http::HeaderValue>() {
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<AppState>,
user: AuthUser,
headers: HeaderMap,
) -> ApiResult<Json<Value>> {
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::<Vec<_>>(),
})))
}
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<Option<String>, 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>), 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<AppState>,
user: AuthUser,
headers: HeaderMap,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<AppState>, 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<AppState>,
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<Option<String>> {
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::<Ts>)
.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::<Ts>)
.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<AppState>,
user: AuthUser,
Path(id): Path<String>,
) -> ApiResult<Json<Value>> {
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<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
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)
);
}
}
+463 -6
View File
@@ -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<Option<PostAnalytics>, 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<Vec<PageAnalyticsHistory>, 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");
}
}
+387
View File
@@ -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::<Vec<_>>()
.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<Segment> {
let chars: Vec<char> = 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<String> {
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())]);
}
}
+159
View File
@@ -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<i64> {
error.downcast_ref::<FacebookApiError>().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<url::Url> {
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<Value> {
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<Value> {
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<Value> {
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));
}
}
+928 -16
View File
@@ -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<url::Url> {
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<String> {
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::<Value>(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<Value> {
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<Value> {
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<Bytes> {
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<Part> {
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<String> {
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<String> {
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<String> {
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<Regex> = 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<String> {
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<String> {
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", &params)?;
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<String> {
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::<Value>(&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<String> {
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<String> {
let phase = &status[name];
if phase["status"].as_str() != Some("error") {
return None;
}
let messages: Vec<String> = 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<String> {
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", &params)?;
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<String> {
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, "<html>Bad gateway</html>"),
"Facebook API error: HTTP 502 <html>Bad gateway</html>"
);
}
#[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));
}
}
+456
View File
@@ -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<Ts>,
}
#[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<InstagramTarget>,
}
/// 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<Ts>,
pub scopes: Vec<String>,
pub error: Option<String>,
}
/// 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<String> {
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<FacebookLongLivedToken> {
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<FacebookLongLivedToken> {
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<Vec<FacebookPageTarget>> {
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<FacebookPageTarget> {
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<FacebookTokenInfo> {
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<Value> {
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<String> {
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"));
}
}
+685
View File
@@ -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<Regex> =
Lazy::new(|| Regex::new(r"#[A-Za-z0-9_\x{00C0}-\x{017F}]+").expect("regex valide"));
static SPACES: Lazy<Regex> = 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<String> {
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<String> {
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<String>, 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::<String>());
}
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<Vec<u8>>,
fontdb: Arc<usvg::fontdb::Database>,
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<PathBuf> {
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<Self> {
// 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<Result<StoryFont, String>> = 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<String, f32>,
}
impl<'a> TextMeasurer<'a> {
fn new(font: &'a StoryFont) -> anyhow::Result<Self> {
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<Mutex<HashMap<String, Bytes>>> = 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<Bytes> {
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<Vec<u8>> {
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<Vec<u8>> {
let clean_text = remove_hashtags(text);
// Les emoji sont téléchargés avant le rendu (qui est synchrone)
let mut emoji_images: HashMap<String, Bytes> = 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<image::DynamicImage> {
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<String, Bytes>,
) -> anyhow::Result<Vec<u8>> {
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#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{STORY_WIDTH}" height="{STORY_HEIGHT}" viewBox="0 0 {STORY_WIDTH} {STORY_HEIGHT}">"#
);
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#"<image x="{x:.2}" y="{:.2}" width="{font_size}" height="{font_size}" xlink:href="{uri}"/>"#,
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#"<text x="{x:.2}" y="{:.2}" font-family="{FONT_FAMILY}" font-weight="bold" font-size="{font_size}" fill="white"{text_attrs} xml:space="preserve">{}</text>"#,
y + baseline_shift,
xml_escape(&text)
));
x += measurer.text_width(&text, font_size);
}
}
}
svg.push_str("</svg>");
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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
// 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<b & \"c\"\u{1}"), "a&lt;b &amp; &quot;c&quot;");
}
}
+507 -5
View File
@@ -1,11 +1,28 @@
//! Contrôle et renouvellement des jetons de page.
//! Contrôle et renouvellement des jetons de page (ex-`token_manager.ts`).
//!
//! STUB — à porter depuis `server/services/token_manager.ts`.
#![allow(unused_variables)]
//! Les jetons Facebook sont inspectés via /debug_token et régénérés à partir
//! du jeton utilisateur longue durée conservé lors de la connexion OAuth ;
//! les comptes TikTok sont contrôlés sur l'échéance de leur refresh token.
use chrono::Duration;
use serde::Serialize;
use crate::crypto::{decrypt, encrypt};
use crate::db::{Patch, Platform, SocialPage, TokenStatus};
use crate::services::facebook::{graph, oauth};
use crate::state::AppState;
use crate::time::Ts;
/// Un jeton dont l'échéance est plus proche que cette fenêtre est renouvelé
/// sans attendre : il reste largement le temps de réessayer les jours suivants
/// si Facebook est indisponible au moment du contrôle.
const RENEWAL_WINDOW_DAYS: i64 = 15;
/// En deçà de cette marge, un jeton non renouvelable est signalé « expiring ».
const WARNING_WINDOW_DAYS: i64 = 7;
/// En deçà de cette marge, l'autorisation TikTok est signalée « expiring ».
const TIKTOK_WARNING_DAYS: i64 = 30;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -20,7 +37,492 @@ pub struct TokenCheckResult {
pub error: Option<String>,
}
/// 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<String>) -> 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<TokenCheckResult> {
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<TokenCheckResult> {
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<Ts>, 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<TokenCheckResult> {
// 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<String> {
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<bool> {
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<TokenCheckResult> {
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<Ts>,
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<TokenCheckResult> {
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<String>,
token_expires_at: Option<Option<Ts>>,
) -> 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
);
}
}