mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-14 18:58:30 +02:00
Merge commit 'f45e13d' into claude/intelligent-wright-pr07e9
This commit is contained in:
11 files changed
+4228
-31
No files matched your search
@@ -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" }))
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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())]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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", ¶ms)?;
|
||||
let data = post_query(state, url, "Facebook API error creating feed post").await?;
|
||||
Ok(field(&data, "id").unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn publish_story(
|
||||
state: &AppState,
|
||||
post: &Post,
|
||||
page: &SocialPage,
|
||||
media: &Media,
|
||||
) -> anyhow::Result<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", ¶ms)?;
|
||||
let response = state.http.post(url).send().await?;
|
||||
let ok = response.status().is_success();
|
||||
let finish_raw = response.text().await.unwrap_or_default();
|
||||
tracing::info!("📤 Facebook Reel FINISH response: {finish_raw}");
|
||||
let finish: Value = serde_json::from_str(&finish_raw).unwrap_or(Value::Null);
|
||||
|
||||
if !ok || finish.get("error").is_some() {
|
||||
let error = finish.get("error");
|
||||
let message = error
|
||||
.and_then(|e| e.get("message"))
|
||||
.filter(|m| !m.is_null())
|
||||
.map(|m| js(Some(m)))
|
||||
.unwrap_or_else(|| finish_raw.clone());
|
||||
bail!(
|
||||
"Facebook Reel FINISH error: {message} (code: {})",
|
||||
js(error.and_then(|e| e.get("code")))
|
||||
);
|
||||
}
|
||||
|
||||
let post_id = field(&finish, "post_id").unwrap_or(video_id);
|
||||
tracing::info!("✅ Reel published successfully! ID: {post_id}");
|
||||
Ok(post_id)
|
||||
}
|
||||
|
||||
/// Publie une vidéo en story Facebook ; renvoie l'identifiant de la story.
|
||||
pub async fn publish_video_story_from_buffer(
|
||||
state: &AppState,
|
||||
page: &SocialPage,
|
||||
video: Bytes,
|
||||
) -> anyhow::Result<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));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
// Caractères de contrôle interdits en XML
|
||||
c if (c as u32) < 0x20 && !matches!(c, '\t' | '\n' | '\r') => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Mesure factice : 10 px par caractère.
|
||||
fn fixed(text: &str) -> f32 {
|
||||
text.chars().count() as f32 * 10.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashtags_are_removed() {
|
||||
assert_eq!(
|
||||
remove_hashtags("Promo #soldes été #Été2024 ! 🔥"),
|
||||
"Promo été ! 🔥"
|
||||
);
|
||||
assert_eq!(remove_hashtags(" a\n\n b\tc "), "a b c");
|
||||
assert_eq!(remove_hashtags("#seul"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_keeps_words_together() {
|
||||
let lines = wrap_text("aaa bbb ccc dd", 70.0, &mut fixed);
|
||||
assert_eq!(lines, vec!["aaa bbb", "ccc dd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_breaks_long_words() {
|
||||
// Premier mot trop long
|
||||
assert_eq!(
|
||||
wrap_text("abcdefghij", 40.0, &mut fixed),
|
||||
vec!["abcd", "efgh", "ij"]
|
||||
);
|
||||
// Mot trop long après une ligne existante
|
||||
assert_eq!(
|
||||
wrap_text("ab abcdefghij x", 40.0, &mut fixed),
|
||||
vec!["ab", "abcd", "efgh", "ij x"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_empty_text() {
|
||||
assert!(wrap_text("", 100.0, &mut fixed).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_shrinks_until_text_fits() {
|
||||
// 1000 px de large, 400 px de haut ; 0,6 × taille par caractère
|
||||
let mut measure = |t: &str, size: f32| t.chars().count() as f32 * size * 0.6;
|
||||
let (size, lines, height) = fit_text("Bonjour", 1000.0, 400.0, &mut measure);
|
||||
assert_eq!(size, 72.0);
|
||||
assert_eq!(lines, vec!["Bonjour"]);
|
||||
assert!((height - 93.6).abs() < 0.01);
|
||||
|
||||
let long = "mot ".repeat(200);
|
||||
let (size, lines, height) = fit_text(long.trim(), 1000.0, 400.0, &mut measure);
|
||||
assert!((12.0..72.0).contains(&size));
|
||||
assert!(height <= 400.0);
|
||||
assert!(!lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_truncated_at_minimum_size() {
|
||||
let mut measure = |t: &str, size: f32| t.chars().count() as f32 * size * 0.6;
|
||||
let huge = "abcdefgh ".repeat(2000);
|
||||
let (size, lines, height) = fit_text(huge.trim(), 1000.0, 400.0, &mut measure);
|
||||
assert_eq!(size, 12.0);
|
||||
// floor(400 / 15.6) = 25 lignes
|
||||
assert_eq!(lines.len(), 25);
|
||||
assert!(lines.last().unwrap().ends_with("..."));
|
||||
assert!((height - 25.0 * 15.6).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_fills_the_story() {
|
||||
// Paysage : hauteur pleine, centré horizontalement
|
||||
let (w, h, x, y) = cover_rect(1920, 1080);
|
||||
assert_eq!(h, 1920.0);
|
||||
assert!((w - 3413.33).abs() < 0.01);
|
||||
assert!((x - (1080.0 - w) / 2.0).abs() < 0.01);
|
||||
assert_eq!(y, 0.0);
|
||||
// Carré : plus large que 9:16, hauteur pleine
|
||||
let (w, h, x, y) = cover_rect(1000, 1000);
|
||||
assert_eq!((w, h, x, y), (1920.0, 1920.0, -420.0, 0.0));
|
||||
// Très haut : largeur pleine, centré verticalement
|
||||
let (w, h, x, y) = cover_rect(500, 2000);
|
||||
assert_eq!((w, h, x, y), (1080.0, 4320.0, 0.0, -1200.0));
|
||||
}
|
||||
|
||||
/// Rendu d'exemple à inspecter : `STORY_OUT=/tmp/story.png cargo test render_sample -- --ignored`
|
||||
/// (télécharge les emoji depuis le CDN Twemoji).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn render_sample() {
|
||||
let out = std::env::var("STORY_OUT").unwrap_or_else(|_| "story-sample.png".into());
|
||||
let text = std::env::var("STORY_TEXT").unwrap_or_else(|_| {
|
||||
"🎉 Grande promo d'automne chez nous ! -30 % sur toute la collection 🍂🔥 \
|
||||
Venez découvrir nos nouveautés en magasin dès samedi 👉 #promo #automne #soldes"
|
||||
.into()
|
||||
});
|
||||
|
||||
// Image de test : dégradé paysage
|
||||
let photo = RgbaImage::from_fn(1600, 900, |x, y| {
|
||||
Rgba([(x / 7) as u8, (y / 4) as u8, 160, 255])
|
||||
});
|
||||
let mut jpeg = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(photo)
|
||||
.to_rgb8()
|
||||
.write_to(&mut Cursor::new(&mut jpeg), image::ImageFormat::Jpeg)
|
||||
.unwrap();
|
||||
|
||||
let clean = remove_hashtags(&text);
|
||||
let http = reqwest::Client::new();
|
||||
let mut emoji_images = HashMap::new();
|
||||
for segment in emoji::parse(&clean) {
|
||||
if let Segment::Emoji { url, .. } = segment {
|
||||
match http.get(&url).send().await {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
emoji_images.insert(url, r.bytes().await.unwrap());
|
||||
}
|
||||
other => {
|
||||
eprintln!("emoji {url} indisponible : {:?}", other.map(|r| r.status()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fonts = Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts");
|
||||
let font = StoryFont::load(&fonts).unwrap();
|
||||
let png = compose(&jpeg, &clean, &font, &emoji_images).unwrap();
|
||||
std::fs::write(&out, png).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_is_escaped() {
|
||||
assert_eq!(xml_escape("a<b & \"c\"\u{1}"), "a<b & "c"");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user