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

This commit is contained in:
Claude committed 2026-10-12 17:37:38 +00:00
commit 66bcbe5edd
8 files changed
+3749 -26

No files matched your search

File diff suppressed because it is too large. Load diff
+367 -1
View File
@@ -1,9 +1,375 @@
//! STUB — routes à porter (voir `server/routes/legal.ts`).
//! Pages légales publiques (conditions d'utilisation et politique de confidentialité).
//!
//! TikTok exige une URL publique pour chacune lors de la création de l'application
//! développeur, et les relecteurs les ouvrent réellement pendant l'audit. Elles sont
//! servies en HTML depuis le serveur, sans authentification, pour rester lisibles
//! même par un robot qui n'exécute pas le JavaScript du client.
//!
//! Les mentions de l'exploitant sont préremplies avec l'identité de la société
//! exploitante et restent surchargeables par variables d'environnement :
//! LEGAL_COMPANY_NAME, LEGAL_TRADE_NAME, LEGAL_COMPANY_LEGAL_FORM,
//! LEGAL_COMPANY_RCS, LEGAL_COMPANY_ADDRESS, LEGAL_CONTACT_EMAIL
use std::sync::LazyLock;
use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use regex::{Captures, Regex};
use crate::config::Config;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/terms", get(terms))
.route("/privacy", get(privacy))
}
struct CompanyInfo {
name: String,
trade_name: String,
legal_form: String,
rcs: String,
address: String,
email: String,
}
fn company_info() -> CompanyInfo {
let var = |name: &str, default: &str| Config::var(name).unwrap_or_else(|| default.to_string());
CompanyInfo {
name: var("LEGAL_COMPANY_NAME", "FROUARD DISTRIBUTION"),
trade_name: var("LEGAL_TRADE_NAME", "LA FOIR'FOUILLE"),
legal_form: var(
"LEGAL_COMPANY_LEGAL_FORM",
"Société par actions simplifiée au capital de 50 000 €",
),
rcs: var("LEGAL_COMPANY_RCS", "478 693 518 R.C.S. Nancy"),
address: var(
"LEGAL_COMPANY_ADDRESS",
"13 rue du Saule Gaillard, 54390 Frouard",
),
// Aucune adresse de contact ne figure au registre : elle doit être fournie,
// le RGPD imposant un point de contact pour l'exercice des droits.
email: var(
"LEGAL_CONTACT_EMAIL",
"[Adresse e-mail de contact à renseigner]",
),
}
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
static PLACEHOLDER: LazyLock<Regex> =
LazyLock::new(|| Regex::new("%([A-Z_]+)%").expect("expression valide"));
/// Remplit les marqueurs `%NOM%` d'un gabarit en une seule passe (les valeurs
/// insérées ne sont jamais réinterprétées).
fn fill(template: &str, values: &[(&str, &str)]) -> String {
PLACEHOLDER
.replace_all(template, |caps: &Captures| {
let key = &caps[1];
values
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| v.to_string())
.unwrap_or_else(|| caps[0].to_string())
})
.into_owned()
}
/// Mentions de l'exploitant, échappées, pour les gabarits.
fn company_values(company: &CompanyInfo) -> Vec<(&'static str, String)> {
vec![
("NAME", escape_html(&company.name)),
("TRADE_NAME", escape_html(&company.trade_name)),
("LEGAL_FORM", escape_html(&company.legal_form)),
("ADDRESS", escape_html(&company.address)),
("RCS", escape_html(&company.rcs)),
("EMAIL", escape_html(&company.email)),
]
}
fn render(title: &str, body_template: &str) -> String {
let company = company_info();
let mut values = company_values(&company);
let footer = {
let refs: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect();
fill(FOOTER_TEMPLATE, &refs)
};
values.push(("FOOTER", footer));
let body = {
let refs: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect();
fill(body_template, &refs)
};
fill(
PAGE_TEMPLATE,
&[("TITLE", &escape_html(title)), ("BODY", &body)],
)
}
fn html(content: String) -> Response {
([(CONTENT_TYPE, "text/html; charset=utf-8")], content).into_response()
}
async fn terms() -> Response {
html(render("Conditions d'utilisation", TERMS_TEMPLATE))
}
async fn privacy() -> Response {
html(render("Politique de confidentialité", PRIVACY_TEMPLATE))
}
// ----------------------------------------------------------------------------
// Gabarits (repris à l'identique de l'ancien serveur)
// ----------------------------------------------------------------------------
const PAGE_TEMPLATE: &str = r###"<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%TITLE%</title>
<style>
:root { color-scheme: light dark; }
body {
max-width: 46rem;
margin: 0 auto;
padding: 2.5rem 1.25rem 4rem;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.65;
color: #1f2328;
background: #ffffff;
}
h1 { font-size: 1.75rem; margin-bottom: 0.25rem; }
h2 { font-size: 1.15rem; margin-top: 2.25rem; }
.updated { color: #6b7280; font-size: 0.875rem; margin-top: 0; }
ul { padding-left: 1.25rem; }
li { margin-bottom: 0.35rem; }
code { background: rgba(127, 127, 127, 0.15); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.9em; }
footer { margin-top: 3rem; padding-top: 1.25rem; border-top: 1px solid rgba(127,127,127,0.3); color: #6b7280; font-size: 0.875rem; }
a { color: #2563eb; }
@media (prefers-color-scheme: dark) {
body { color: #e6e6e6; background: #0f1115; }
.updated, footer { color: #9ca3af; }
a { color: #7aa7ff; }
}
</style>
</head>
<body>
%BODY%
</body>
</html>"###;
const FOOTER_TEMPLATE: &str = r###"<footer>
<p>
%NAME% &ndash; enseigne %TRADE_NAME%<br>
%LEGAL_FORM%<br>
%ADDRESS%<br>
%RCS%<br>
Contact : %EMAIL%
</p>
<p><a href="/terms">Conditions d'utilisation</a> &middot; <a href="/privacy">Politique de confidentialité</a></p>
</footer>"###;
const TERMS_TEMPLATE: &str = r###"
<h1>Conditions d'utilisation</h1>
<p class="updated">Dernière mise à jour : 11 août 2026</p>
<p>
Cette application est un outil interne de gestion des réseaux sociaux exploité par
%NAME%, %LEGAL_FORM%, dont le siège est situé
%ADDRESS%, immatriculée au %RCS%, qui exploite le
magasin sous l'enseigne %TRADE_NAME% (« nous »). Elle permet à nos équipes
de créer des vidéos et des publications promotionnelles, puis de les diffuser sur les comptes
et pages officiels de nos magasins.
</p>
<h2>1. Accès au service</h2>
<p>
L'accès est réservé aux personnes que nous avons expressément autorisées, au moyen d'un compte
nominatif. Chaque utilisateur est responsable de la confidentialité de ses identifiants et des
actions effectuées depuis son compte. Nous pouvons suspendre ou retirer un accès à tout moment.
</p>
<h2>2. Comptes de réseaux sociaux connectés</h2>
<p>
L'application publie sur des comptes Facebook et TikTok qui nous appartiennent ou que nous
sommes habilités à administrer. En connectant un compte, l'utilisateur déclare disposer des
droits nécessaires pour le faire. Un compte peut être déconnecté à tout moment depuis
l'application, ou par le retrait de l'autorisation directement dans les réglages de la
plateforme concernée.
</p>
<h2>3. Contenus publiés</h2>
<p>
Les utilisateurs restent responsables des contenus qu'ils créent et diffusent. Ces contenus
doivent respecter la loi applicable ainsi que les règles des plateformes de destination,
notamment les Conditions d'utilisation et les Règles communautaires de TikTok et de Meta.
Sont notamment proscrits les contenus dont les utilisateurs ne détiennent pas les droits,
y compris pour les images et les musiques utilisées.
</p>
<h2>4. Disponibilité</h2>
<p>
Le service est fourni « en l'état ». Il dépend de services tiers (plateformes de publication,
hébergement, traitement vidéo) dont les interruptions ou modifications peuvent affecter son
fonctionnement. Nous ne garantissons ni une disponibilité continue, ni la publication effective
d'un contenu par une plateforme tierce, celle-ci pouvant la refuser selon ses propres règles.
</p>
<h2>5. Responsabilité</h2>
<p>
Dans les limites permises par la loi, notre responsabilité ne saurait être engagée au titre des
dommages indirects résultant de l'utilisation du service, notamment une perte de contenu, une
publication manquée ou la suspension d'un compte par une plateforme tierce.
</p>
<h2>6. Modification des conditions</h2>
<p>
Ces conditions peuvent être modifiées. La date de dernière mise à jour figure en tête de page.
</p>
<h2>7. Contact</h2>
<p>Pour toute question relative à ces conditions : %EMAIL%</p>
%FOOTER%"###;
const PRIVACY_TEMPLATE: &str = r###"
<h1>Politique de confidentialité</h1>
<p class="updated">Dernière mise à jour : 11 août 2026</p>
<p>
Cette politique décrit les données traitées par notre outil interne de gestion des réseaux
sociaux. Le responsable de traitement est %NAME%,
%LEGAL_FORM%, dont le siège est situé %ADDRESS%,
immatriculée au %RCS%, exploitant le magasin sous l'enseigne
%TRADE_NAME%.
</p>
<h2>1. Données de nos utilisateurs internes</h2>
<p>
Pour chaque personne autorisée à utiliser l'outil, nous conservons un identifiant de connexion,
un mot de passe stocké sous forme chiffrée, un rôle et la liste des comptes auxquels elle a
accès. Ces données servent uniquement à l'authentification et à la gestion des droits.
</p>
<h2>2. Données issues des comptes de réseaux sociaux connectés</h2>
<p>
Lorsqu'un compte TikTok est connecté, nous recevons de TikTok, avec l'autorisation explicite du
titulaire du compte et dans le cadre du périmètre <code>user.info.basic</code> :
</p>
<ul>
<li>l'identifiant technique du compte (<code>open_id</code>) ;</li>
<li>le nom d'affichage du compte ;</li>
<li>l'image de profil du compte ;</li>
<li>un jeton d'accès et un jeton de renouvellement, qui nous permettent de publier en son nom.</li>
</ul>
<p>
Nous procédons de manière équivalente pour les pages Facebook connectées. Nous ne collectons ni
la liste des abonnés, ni les messages privés, ni les données de navigation des visiteurs de ces
comptes.
</p>
<h2>3. Finalités</h2>
<p>
Ces données sont utilisées exclusivement pour identifier le compte de destination dans
l'interface et y publier les vidéos et publications créées par nos équipes, immédiatement ou à
l'heure programmée. Elles ne servent à aucune autre finalité.
</p>
<h2>4. Données que nous transmettons aux plateformes</h2>
<p>
Lors d'une publication, nous transmettons à la plateforme concernée le fichier vidéo ou image,
le texte de la publication, ainsi que les paramètres de diffusion associés (niveau de
confidentialité, autorisation des commentaires). Ces contenus sont ensuite régis par la
politique de confidentialité de la plateforme concernée.
</p>
<h2>5. Conservation et sécurité</h2>
<ul>
<li>Les jetons d'accès et de renouvellement sont chiffrés au repos (AES-256-GCM).</li>
<li>
Ils sont conservés tant que le compte reste connecté à l'application, et supprimés
immédiatement lorsque le compte en est retiré.
</li>
<li>Les médias sources sont supprimés automatiquement après leur publication et leur péremption.</li>
<li>L'accès à l'application est restreint aux personnes autorisées et protégé par mot de passe.</li>
</ul>
<h2>6. Partage avec des tiers</h2>
<p>
Nous ne vendons ni ne louons ces données, et ne les transmettons à aucun tiers à des fins
publicitaires ou de profilage. Elles ne sont communiquées qu'aux plateformes de destination
(TikTok, Meta) dans la stricte mesure nécessaire à la publication demandée, ainsi qu'à nos
prestataires techniques d'hébergement et de traitement vidéo agissant sur nos instructions.
</p>
<h2>7. Retirer une autorisation</h2>
<p>
Le titulaire d'un compte connecté peut retirer son autorisation à tout moment :
</p>
<ul>
<li>en demandant la déconnexion du compte depuis l'application, ce qui supprime les jetons ;</li>
<li>
ou directement dans TikTok, via <em>Profil &rsaquo; Paramètres et confidentialité &rsaquo;
Sécurité et autorisations &rsaquo; Gérer les autorisations des applications</em>.
</li>
</ul>
<p>
Le retrait de l'autorisation empêche toute publication ultérieure. Il n'entraîne pas la
suppression des contenus déjà publiés sur la plateforme, qui doivent être supprimés depuis
celle-ci.
</p>
<h2>8. Vos droits</h2>
<p>
Conformément au Règlement général sur la protection des données, vous disposez d'un droit
d'accès, de rectification, d'effacement, de limitation et de portabilité des données vous
concernant, ainsi que du droit d'introduire une réclamation auprès de la CNIL. Pour exercer ces
droits, écrivez à %EMAIL%.
</p>
<h2>9. Modification de cette politique</h2>
<p>
Cette politique peut être mise à jour. La date de dernière mise à jour figure en tête de page.
</p>
%FOOTER%"###;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escaping_and_single_pass_fill() {
assert_eq!(
escape_html(r#"A & <b> "c""#),
"A &amp; &lt;b&gt; &quot;c&quot;"
);
assert_eq!(
fill("%A%-%B%-%C%", &[("A", "%B%"), ("B", "x")]),
"%B%-x-%C%"
);
}
#[test]
fn pages_are_complete() {
let page = render("Conditions d'utilisation", TERMS_TEMPLATE);
assert!(page.starts_with("<!doctype html>\n<html lang=\"fr\">"));
assert!(page.contains("<title>Conditions d'utilisation</title>"));
assert!(page.contains("enseigne LA FOIR'FOUILLE<br>"));
assert!(page.ends_with("</footer>\n</body>\n</html>"));
assert!(!page.contains('%'));
let page = render("Politique de confidentialité", PRIVACY_TEMPLATE);
assert!(page.contains("<h1>Politique de confidentialité</h1>"));
assert!(!page.contains("%NAME%"));
}
}
+442 -2
View File
@@ -1,9 +1,449 @@
//! STUB — routes à porter (voir `server/routes/tiktok.ts`).
//! Routes de gestion des comptes TikTok (montées sous `/api/tiktok`, connexion requise).
//!
//! Un compte TikTok de magasin se connecte en OAuth (TikTok n'autorise pas la
//! saisie manuelle d'un token), puis vit dans `social_pages` comme une page
//! Facebook : mêmes permissions, même planification, même calendrier.
use axum::Router;
use axum::extract::{Path, State};
use axum::http::header::{CONTENT_TYPE, LOCATION};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use rand::RngCore;
use serde_json::{json, Value};
use crate::auth::AuthUser;
use crate::config::Config;
use crate::db::{NewSocialPage, Patch, Platform};
use crate::error::{ApiResult, AppError};
use crate::extract::{Body, QueryMap};
use crate::services::tiktok;
use crate::state::AppState;
/// 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 OAUTH_STATE_KEY: &str = "tiktokOAuthState";
pub fn router() -> Router<AppState> {
Router::new()
.route("/config", get(get_config).put(put_config))
.route("/connect", get(connect))
.route("/callback", get(callback))
.route("/accounts/{id}/creator-info", get(creator_info))
}
fn build_return_url(success: bool, message: &str) -> String {
let query = url::form_urlencoded::Serializer::new(String::new())
.append_pair("tiktok", 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 {
(
StatusCode::FOUND,
[
(LOCATION, location.to_string()),
(CONTENT_TYPE, "text/plain; charset=utf-8".to_string()),
],
format!("Found. Redirecting to {location}"),
)
.into_response()
}
fn redirect_error(message: &str) -> Response {
redirect(&build_return_url(false, message))
}
/// État de la configuration TikTok.
/// Le client secret n'est jamais renvoyé au client.
async fn get_config(
State(state): State<AppState>,
headers: HeaderMap,
user: AuthUser,
) -> ApiResult<Json<Value>> {
let config = state.db.get_tiktok_config().await.map_err(|e| {
tracing::error!("❌ [TikTok] Erreur lecture configuration: {e}");
AppError::internal("Impossible de lire la configuration TikTok")
})?;
let configured = tiktok::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 client_key = config
.as_ref()
.map(|c| c.client_key.clone())
.filter(|k| !k.is_empty())
.unwrap_or_else(|| {
if Config::var("TIKTOK_CLIENT_KEY").is_some() {
"(défini par variable d'environnement)".to_string()
} else {
String::new()
}
});
let has_client_secret = config.as_ref().is_some_and(|c| !c.client_secret.is_empty())
|| Config::var("TIKTOK_CLIENT_SECRET").is_some();
Ok(Json(json!({
"configured": configured,
"clientKey": client_key,
"hasClientSecret": has_client_secret,
"redirectUri": tiktok::redirect_uri(&state, Some(&headers)),
})))
}
/// Type d'une valeur JSON tel que le nomme zod (`Expected string, received …`).
fn zod_type(value: Option<&Value>) -> &'static str {
match value {
None => "undefined",
Some(Value::Null) => "null",
Some(Value::Bool(_)) => "boolean",
Some(Value::Number(_)) => "number",
Some(Value::String(_)) => "string",
Some(Value::Array(_)) => "array",
Some(Value::Object(_)) => "object",
}
}
/// `z.string().trim().min(1, message)` : valeur rognée, ou message d'erreur zod.
fn required_trimmed(value: Option<&Value>, empty_message: &str) -> Result<String, String> {
match value {
None => Err("Required".to_string()),
Some(Value::String(s)) => {
let trimmed = s.trim();
if trimmed.is_empty() {
Err(empty_message.to_string())
} else {
Ok(trimmed.to_string())
}
}
Some(other) => Err(format!(
"Expected string, received {}",
zod_type(Some(other))
)),
}
}
/// Validation du corps de `PUT /config` (schémas `insertTiktokConfigSchema` /
/// `updateTiktokConfigSchema`) : renvoie le premier message d'erreur, comme la route.
fn validate_config(
body: &Value,
secret_required: bool,
) -> Result<(String, Option<String>), String> {
if !body.is_object() {
return Err(format!(
"Expected object, received {}",
zod_type(Some(body))
));
}
let client_key =
required_trimmed(body.get("clientKey"), "Le client key ne peut pas être vide")?;
let secret_message = "Le client secret ne peut pas être vide";
let client_secret = match body.get("clientSecret") {
// Le secret peut être omis lors d'une mise à jour : on garde l'existant
None if !secret_required => None,
value => Some(required_trimmed(value, secret_message)?),
};
Ok((client_key, client_secret))
}
/// Enregistre les identifiants de l'application développeur TikTok (admin).
async fn put_config(
State(state): State<AppState>,
headers: HeaderMap,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
if !user.is_admin() {
return Err(AppError::forbidden(
"Accès refusé. Réservé aux administrateurs.",
));
}
let invalid = |message: String| {
tracing::error!("❌ [TikTok] Erreur enregistrement configuration: {message}");
AppError::bad_request(if message.is_empty() {
"Configuration TikTok invalide".to_string()
} else {
message
})
};
let existing = state
.db
.get_tiktok_config()
.await
.map_err(|e| invalid(tiktok::error_message(&e.into())))?;
let (client_key, client_secret) =
validate_config(&body, existing.is_none()).map_err(invalid)?;
let config = state
.db
.upsert_tiktok_config(Some(&client_key), client_secret.as_deref())
.await
.map_err(|e| invalid(tiktok::error_message(&e)))?;
Ok(Json(json!({
"success": true,
"clientKey": config.client_key,
"redirectUri": tiktok::redirect_uri(&state, Some(&headers)),
})))
}
/// Démarre la connexion d'un compte TikTok.
/// À appeler par navigation complète du navigateur (window.location), pas en fetch.
async fn connect(State(state): State<AppState>, headers: HeaderMap, user: AuthUser) -> Response {
if !tiktok::is_configured(&state).await {
return redirect_error(
"TikTok n'est pas configuré. Renseignez d'abord le client key et le client 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 mut session = user.session;
session.set(OAUTH_STATE_KEY, Value::String(oauth_state.clone()));
let auth_url = match tiktok::build_authorization_url(&state, &oauth_state, Some(&headers)).await
{
Ok(url) => url,
Err(error) => {
tracing::error!("❌ [TikTok] Erreur démarrage OAuth: {error:#}");
return redirect_error(&tiktok::error_message(&error));
}
};
// La session doit être écrite avant la redirection, sinon l'état est perdu
if let Err(error) = session.save(&state).await {
tracing::error!("❌ [TikTok] Impossible de sauvegarder la session OAuth: {error}");
return redirect_error("Erreur de session, réessayez.");
}
redirect(&auth_url)
}
/// Retour d'autorisation TikTok : échange le code, puis crée ou met à jour le
/// compte dans social_pages.
async fn callback(
State(state): State<AppState>,
headers: HeaderMap,
user: AuthUser,
query: QueryMap,
) -> Response {
match handle_callback(&state, &headers, user, &query).await {
Ok(response) => response,
Err(error) => {
tracing::error!("❌ [TikTok] Erreur callback OAuth: {error:#}");
redirect_error(&tiktok::error_message(&error))
}
}
}
async fn handle_callback(
state: &AppState,
headers: &HeaderMap,
user: AuthUser,
query: &QueryMap,
) -> anyhow::Result<Response> {
if let Some(oauth_error) = query.non_empty("error") {
let description = query.non_empty("error_description").unwrap_or(oauth_error);
return Ok(redirect_error(&format!(
"Autorisation TikTok refusée : {description}"
)));
}
let AuthUser { user, mut session } = user;
let expected_state = session.get_str(OAUTH_STATE_KEY);
if session.remove(OAUTH_STATE_KEY).is_some() {
if let Err(error) = session.save(state).await {
tracing::warn!("⚠️ [TikTok] Session non enregistrée après le retour OAuth: {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 Ok(redirect_error(
"Requête de connexion TikTok invalide (état non reconnu).",
));
}
let Some(code) = query.non_empty("code") else {
return Ok(redirect_error("Aucun code d'autorisation reçu de TikTok."));
};
let tokens = tiktok::exchange_code_for_token(state, code, Some(headers)).await?;
let profile = tiktok::get_user_info(state, &tokens.access_token).await?;
let open_id = profile
.open_id
.clone()
.or_else(|| tokens.open_id.clone().filter(|id| !id.is_empty()))
.ok_or_else(|| {
anyhow::anyhow!("Réponse TikTok inattendue : identifiant du compte absent")
})?;
// Un compte déjà connecté est mis à jour plutôt que dupliqué
let existing_pages = state.db.get_social_pages(&user.id).await?;
let existing = existing_pages
.iter()
.find(|p| p.platform == Platform::Tiktok && p.page_id == open_id);
if let Some(existing) = existing {
state
.db
.update_social_page(
&existing.id,
Patch::new()
.set("page_name", profile.display_name.clone())
.set("avatar_url", profile.avatar_url.clone())
.set("is_active", "true"),
)
.await?;
tiktok::persist_tokens(state, &existing.id, &tokens).await?;
tracing::info!("🔁 [TikTok] Compte reconnecté: {}", profile.display_name);
return Ok(redirect(&build_return_url(
true,
&format!("Compte TikTok \"{}\" reconnecté.", profile.display_name),
)));
}
let (token_expires_at, refresh_token_expires_at) = tiktok::token_expirations(&tokens);
let page = state
.db
.create_social_page(NewSocialPage {
user_id: user.id.clone(),
platform: Some(Platform::Tiktok),
page_id: open_id.clone(),
page_name: profile.display_name.clone(),
access_token: tokens.access_token.clone(),
token_expires_at,
refresh_token: tokens.refresh_token.clone(),
refresh_token_expires_at,
scopes: tokens.scope.clone(),
avatar_url: profile.avatar_url.clone(),
is_active: Some("true".to_string()),
..Default::default()
})
.await?;
// Les non-admins ne voient que les pages qui leur sont explicitement
// attribuées : sans cette permission, ils ne verraient pas leur propre compte.
if !user.is_admin() {
if let Err(error) = state.db.create_page_permission(&user.id, &page.id).await {
tracing::error!("⚠️ [TikTok] Impossible de créer la permission de page: {error}");
}
}
tracing::info!(
"✅ [TikTok] Nouveau compte connecté: {} ({open_id})",
profile.display_name
);
Ok(redirect(&build_return_url(
true,
&format!("Compte TikTok \"{}\" connecté.", profile.display_name),
)))
}
/// Options réellement autorisées sur un compte (confidentialité, commentaires,
/// duo, stitch, durée max). TikTok impose de les afficher avant publication.
async fn creator_info(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<String>,
) -> ApiResult<Json<Value>> {
let bad_gateway = |error: anyhow::Error| {
tracing::error!("❌ [TikTok] Erreur récupération creator info: {error:#}");
AppError::json(
StatusCode::BAD_GATEWAY,
json!({ "error": tiktok::error_message(&error) }),
)
};
let page = state
.db
.get_social_page(&id)
.await
.map_err(|e| bad_gateway(e.into()))?;
let Some(page) = page.filter(|p| p.platform == Platform::Tiktok) else {
return Err(AppError::not_found("Compte TikTok non trouvé"));
};
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.into()))?;
if !accessible.iter().any(|p| p.id == page.id) {
return Err(AppError::forbidden("Accès refusé à ce compte"));
}
}
let info = tiktok::get_creator_info(&state, &page)
.await
.map_err(bad_gateway)?;
Ok(Json(serde_json::to_value(info).unwrap_or(Value::Null)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn return_url_is_url_encoded() {
assert_eq!(
build_return_url(true, "Compte TikTok \"Nancy\" connecté."),
"/pages?tiktok=success&message=Compte+TikTok+%22Nancy%22+connect%C3%A9."
);
}
#[test]
fn config_validation_matches_zod_messages() {
assert_eq!(
validate_config(&json!({ "clientKey": " k ", "clientSecret": " s " }), true),
Ok(("k".to_string(), Some("s".to_string())))
);
assert_eq!(
validate_config(&json!({ "clientKey": "k" }), true),
Err("Required".to_string())
);
assert_eq!(
validate_config(&json!({ "clientKey": "k" }), false),
Ok(("k".to_string(), None))
);
assert_eq!(
validate_config(&json!({ "clientKey": " " }), false),
Err("Le client key ne peut pas être vide".to_string())
);
assert_eq!(
validate_config(&json!({ "clientKey": "k", "clientSecret": "" }), false),
Err("Le client secret ne peut pas être vide".to_string())
);
assert_eq!(
validate_config(&json!({ "clientKey": 3 }), false),
Err("Expected string, received number".to_string())
);
assert_eq!(
validate_config(&json!({ "clientKey": "k", "clientSecret": null }), false),
Err("Expected string, received null".to_string())
);
assert_eq!(
validate_config(&json!([]), false),
Err("Expected object, received array".to_string())
);
}
}
+304 -4
View File
@@ -1,8 +1,308 @@
//! Tâches périodiques (suivi TikTok, jetons, statistiques, purge des médias).
//! Tâches périodiques (suivi TikTok, jetons, statistiques, purge des médias,
//! sessions expirées).
//!
//! STUB — à porter depuis `server/cron.ts`.
#![allow(unused_variables)]
//! Les horaires sont ceux de l'ancien `cron.ts` (node-cron), évalués en UTC,
//! l'heure du conteneur. Une boucle se réveille au début de chaque minute et
//! lance les tâches dont l'expression correspond.
use std::future::Future;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use chrono::{DateTime, Duration, Timelike, Utc};
use crate::services::{analytics, media_purge, tiktok, token_manager};
use crate::state::AppState;
pub fn start(state: AppState) {}
/// Expression cron à cinq champs ; seuls les minutes et les heures peuvent
/// être restreintes (jour, mois et jour de semaine valent `*`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CronSpec {
minutes: u64,
hours: u32,
}
/// Champ cron (`*`, `*/n`, `a`, `a-b`, `a-b/n`, listes séparées par des virgules)
/// en masque de bits sur `0..=max`.
fn parse_field(field: &str, max: u32) -> Option<u64> {
let mut mask = 0u64;
for item in field.split(',') {
let (range, step) = match item.split_once('/') {
Some((range, step)) => (range, step.parse::<u32>().ok().filter(|s| *s > 0)?),
None => (item, 1),
};
let (start, end) = if range == "*" {
(0, max)
} else if let Some((a, b)) = range.split_once('-') {
(a.parse().ok()?, b.parse().ok()?)
} else {
let value: u32 = range.parse().ok()?;
// `5/10` : de 5 jusqu'au maximum, comme cron
(value, if item.contains('/') { max } else { value })
};
if start > end || end > max {
return None;
}
let mut value = start;
while value <= end {
mask |= 1 << value;
value += step;
}
}
Some(mask)
}
impl CronSpec {
pub fn parse(expression: &str) -> Option<Self> {
let fields: Vec<&str> = expression.split_whitespace().collect();
let [minute, hour, day, month, weekday] = fields.as_slice() else {
return None;
};
if [day, month, weekday].iter().any(|f| **f != "*") {
return None;
}
Some(Self {
minutes: parse_field(minute, 59)?,
hours: u32::try_from(parse_field(hour, 23)?).ok()?,
})
}
pub fn matches(&self, minute: u32, hour: u32) -> bool {
minute < 60
&& hour < 24
&& self.minutes & (1 << minute) != 0
&& self.hours & (1 << hour) != 0
}
pub fn matches_time(&self, time: DateTime<Utc>) -> bool {
self.matches(time.minute(), time.hour())
}
}
/// Début de la minute suivant `now`.
fn next_minute(now: DateTime<Utc>) -> DateTime<Utc> {
let truncated = now
.with_second(0)
.and_then(|t| t.with_nanosecond(0))
.unwrap_or(now);
truncated + Duration::minutes(1)
}
/// Attend le début de la minute suivante et renvoie l'instant visé.
pub async fn sleep_until_next_minute() -> DateTime<Utc> {
let now = Utc::now();
let next = next_minute(now);
if let Ok(wait) = (next - now).to_std() {
tokio::time::sleep(wait).await;
}
next
}
/// Libère le verrou d'une tâche, même si elle panique.
struct RunningGuard(Arc<AtomicBool>);
impl Drop for RunningGuard {
fn drop(&mut self) {
self.0.store(false, Ordering::SeqCst);
}
}
/// Lance une exécution de tâche, sauf si la précédente n'est pas terminée
/// (évite par exemple de publier deux fois le même post).
pub fn spawn_guarded<F>(running: &Arc<AtomicBool>, name: &'static str, task: F)
where
F: Future<Output = ()> + Send + 'static,
{
if running.swap(true, Ordering::SeqCst) {
tracing::warn!("[Cron] {name} : exécution précédente encore en cours, passage ignoré.");
return;
}
let guard = RunningGuard(running.clone());
tokio::spawn(async move {
let _guard = guard;
task.await;
});
}
type JobFn = fn(AppState) -> std::pin::Pin<Box<dyn Future<Output = ()> + Send>>;
struct Job {
name: &'static str,
spec: CronSpec,
run: JobFn,
running: Arc<AtomicBool>,
}
impl Job {
fn new(name: &'static str, expression: &str, run: JobFn) -> Self {
Self {
name,
spec: CronSpec::parse(expression).expect("expression cron valide"),
run,
running: Arc::new(AtomicBool::new(false)),
}
}
}
async fn tiktok_status_sync(state: AppState) {
if let Err(error) = tiktok::sync_pending_publications(&state).await {
tracing::error!("[Cron] TikTok publish status sync failed: {error:#}");
}
}
async fn token_check(state: AppState) {
tracing::info!("[Cron] Running token check...");
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();
tracing::info!(
"[Cron] Token check done: {} contrôlés, {renewed} renouvelés, {failing} en échec.",
results.len()
);
}
async fn analytics_sync(state: AppState) {
tracing::info!("[Cron] Running analytics sync for all pages...");
if let Err(error) = analytics::sync_all_pages(&state).await {
tracing::error!("[Cron] Analytics sync failed: {error:#}");
}
}
async fn media_purge_job(state: AppState) {
tracing::info!("[Cron] Running daily media purge check...");
if let Err(error) = media_purge::purge_old_media(&state).await {
tracing::error!("[Cron] Media purge check failed: {error:#}");
}
}
async fn session_prune(state: AppState) {
crate::auth::prune_sessions(&state).await;
}
pub fn start(state: AppState) {
let jobs = vec![
// Toutes les 2 minutes — suivi des publications TikTok (API asynchrone)
Job::new("TikTok publish status", "*/2 * * * *", |s| {
Box::pin(tiktok_status_sync(s))
}),
// Deux fois par jour (00:00 et 12:00) — contrôle et renouvellement des jetons.
// Un jeton révoqué par Meta ne prévient pas : passer deux fois par jour
// réduit d'autant la fenêtre pendant laquelle une publication échouerait.
Job::new("Token refresh", "0 0,12 * * *", |s| {
Box::pin(token_check(s))
}),
// Deux fois par jour (08:00 et 20:00) — statistiques de toutes les pages
Job::new("Analytics sync", "0 8,20 * * *", |s| {
Box::pin(analytics_sync(s))
}),
// Tous les jours à 03:00 — purge des anciens médias
Job::new("Media purge", "0 3 * * *", |s| Box::pin(media_purge_job(s))),
// Toutes les heures — suppression des sessions expirées
Job::new("Session cleanup", "0 * * * *", |s| {
Box::pin(session_prune(s))
}),
];
tracing::info!("[Cron] TikTok publish status job scheduled (*/2 * * * *).");
tracing::info!("[Cron] Token refresh job scheduled (0 0,12 * * *).");
tracing::info!("[Cron] Analytics sync job scheduled (0 8,20 * * *).");
tracing::info!("[Cron] Media purge job scheduled (0 3 * * *).");
tracing::info!("[Cron] Session cleanup job scheduled (0 * * * *).");
tokio::spawn(async move {
loop {
let tick = sleep_until_next_minute().await;
for job in &jobs {
if job.spec.matches_time(tick) {
spawn_guarded(&job.running, job.name, (job.run)(state.clone()));
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn every_two_minutes() {
let spec = CronSpec::parse("*/2 * * * *").unwrap();
assert!(spec.matches(0, 0));
assert!(spec.matches(58, 13));
assert!(!spec.matches(1, 0));
assert!(!spec.matches(59, 23));
}
#[test]
fn fixed_hours() {
let spec = CronSpec::parse("0 0,12 * * *").unwrap();
assert!(spec.matches(0, 0));
assert!(spec.matches(0, 12));
assert!(!spec.matches(1, 12));
assert!(!spec.matches(0, 8));
let spec = CronSpec::parse("0 8,20 * * *").unwrap();
assert!(spec.matches(0, 20) && spec.matches(0, 8) && !spec.matches(0, 9));
let spec = CronSpec::parse("0 3 * * *").unwrap();
assert!(spec.matches(0, 3) && !spec.matches(30, 3) && !spec.matches(0, 15));
let spec = CronSpec::parse("0 * * * *").unwrap();
assert!((0..24).all(|h| spec.matches(0, h)) && !spec.matches(1, 5));
let spec = CronSpec::parse("* * * * *").unwrap();
assert!(spec.matches(17, 9));
}
#[test]
fn ranges_and_invalid_expressions() {
let spec = CronSpec::parse("10-20/5 1-3 * * *").unwrap();
assert!(
spec.matches(15, 2)
&& spec.matches(20, 3)
&& !spec.matches(16, 2)
&& !spec.matches(15, 4)
);
assert!(CronSpec::parse("0 0 1 * *").is_none());
assert!(CronSpec::parse("60 * * * *").is_none());
assert!(CronSpec::parse("*/0 * * * *").is_none());
assert!(CronSpec::parse("* * * *").is_none());
}
#[test]
fn next_minute_is_aligned() {
let now = Utc.with_ymd_and_hms(2026, 10, 12, 11, 59, 42).unwrap();
assert_eq!(
next_minute(now),
Utc.with_ymd_and_hms(2026, 10, 12, 12, 0, 0).unwrap()
);
let exact = Utc.with_ymd_and_hms(2026, 10, 12, 12, 0, 0).unwrap();
assert_eq!(
next_minute(exact),
Utc.with_ymd_and_hms(2026, 10, 12, 12, 1, 0).unwrap()
);
}
#[tokio::test]
async fn guard_skips_overlapping_runs() {
let running = Arc::new(AtomicBool::new(false));
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
spawn_guarded(&running, "test", async move {
let _ = rx.await;
});
assert!(running.load(Ordering::SeqCst));
// Deuxième lancement ignoré tant que le premier tourne
spawn_guarded(&running, "test", async {
panic!("ne doit pas s'exécuter")
});
tx.send(()).unwrap();
for _ in 0..100 {
if !running.load(Ordering::SeqCst) {
break;
}
tokio::task::yield_now().await;
}
assert!(!running.load(Ordering::SeqCst));
}
}
+152 -5
View File
@@ -1,10 +1,157 @@
//! Purge quotidienne des anciens médias.
//!
//! STUB — à porter depuis `server/services/media-purge.ts`.
#![allow(unused_variables)]
//! Purge quotidienne des anciens médias locaux, pour économiser l'espace disque :
//! - images de plus de 30 jours ;
//! - vidéos de plus de 7 jours.
use chrono::Duration;
use crate::db::{Media, MediaType};
use crate::services::media_store;
use crate::state::AppState;
use crate::time::Ts;
pub async fn purge_old_media(state: &AppState) -> anyhow::Result<()> {
/// Ancienneté au-delà de laquelle une image est purgée.
const IMAGE_RETENTION_DAYS: i64 = 30;
/// Ancienneté au-delà de laquelle une vidéo est purgée.
const VIDEO_RETENTION_DAYS: i64 = 7;
/// Médias d'un type créés avant `before`.
async fn media_older_than(
state: &AppState,
media_type: MediaType,
before: Ts,
) -> Result<Vec<Media>, sqlx::Error> {
sqlx::query_as("SELECT * FROM media WHERE type = $1 AND created_at < $2")
.bind(media_type)
.bind(before)
.fetch_all(&state.db.pool)
.await
}
/// Supprime le fichier, la vignette et la ligne d'un média.
async fn purge_one(state: &AppState, item: &Media) -> anyhow::Result<()> {
if let Some(public_id) = item
.cloudinary_public_id
.as_deref()
.filter(|p| !p.is_empty())
{
media_store::delete_media(state, public_id).await?;
}
// La vignette n'a plus de raison d'être une fois la ligne supprimée
if let Some(thumbnail) = item
.thumbnail_url
.as_deref()
.and_then(|t| t.strip_prefix("/uploads/"))
{
if let Err(error) = media_store::delete_media(state, thumbnail).await {
tracing::warn!(
"[Media Purge] Vignette non supprimée pour {}: {error}",
item.id
);
}
}
state.db.delete_media(&item.id).await?;
Ok(())
}
async fn run(state: &AppState) -> anyhow::Result<()> {
let now = Ts::now();
let thirty_days_ago = now.add(Duration::days(-IMAGE_RETENTION_DAYS));
let seven_days_ago = now.add(Duration::days(-VIDEO_RETENTION_DAYS));
let old_images = media_older_than(state, MediaType::Image, thirty_days_ago).await?;
let old_videos = media_older_than(state, MediaType::Video, seven_days_ago).await?;
if old_images.is_empty() && old_videos.is_empty() {
tracing::info!("[Media Purge] No old media found to purge.");
return Ok(());
}
tracing::info!(
"[Media Purge] Found {} old images (>30 days) and {} old videos (>7 days) to purge.",
old_images.len(),
old_videos.len()
);
let mut success_count = 0;
let mut error_count = 0;
for item in old_images.iter().chain(old_videos.iter()) {
match purge_one(state, item).await {
Ok(()) => success_count += 1,
Err(error) => {
tracing::error!(
"[Media Purge] Failed to delete media {}: {error:#}",
item.id
);
error_count += 1;
}
}
}
tracing::info!(
"[Media Purge] Purge completed. Deleted: {success_count}, Failed: {error_count}."
);
Ok(())
}
/// Purge les anciens médias. Ne renvoie jamais d'erreur : un échec global est
/// journalisé, comme dans l'ancien serveur.
pub async fn purge_old_media(state: &AppState) -> anyhow::Result<()> {
tracing::info!("[Media Purge] Starting automatic purge of old media...");
if let Err(error) = run(state).await {
tracing::error!("[Media Purge] Fatal error during media purge process: {error:#}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// Test sur une vraie base : `DATABASE_URL=… cargo test -- --ignored purge`.
#[tokio::test]
#[ignore]
async fn purge_against_database() {
let config = crate::config::Config::from_env().expect("DATABASE_URL requis");
crate::crypto::init(&config);
let pool = crate::db::connect(&config.database_url).await.unwrap();
let state = AppState::new(config, pool).unwrap();
let user: String = sqlx::query_scalar("SELECT id FROM users LIMIT 1")
.fetch_one(&state.db.pool)
.await
.unwrap();
let rows = [
("purge_old_img", "image", 31),
("purge_new_img", "image", 29),
("purge_old_vid", "video", 8),
("purge_new_vid", "video", 6),
];
for (id, kind, days) in rows {
sqlx::query(
"INSERT INTO media (id, user_id, type, cloudinary_public_id, original_url, file_name, file_size, created_at)
VALUES ($1, $2, $3::media_type, $4, '/uploads/x', 'x', 1, now() - make_interval(days => $5))",
)
.bind(id)
.bind(&user)
.bind(kind)
.bind(format!("media/{id}.bin"))
.bind(days)
.execute(&state.db.pool)
.await
.unwrap();
}
purge_old_media(&state).await.unwrap();
let left: Vec<String> =
sqlx::query_scalar("SELECT id FROM media WHERE id LIKE 'purge_%' ORDER BY id")
.fetch_all(&state.db.pool)
.await
.unwrap();
assert_eq!(left, vec!["purge_new_img", "purge_new_vid"]);
sqlx::query("DELETE FROM media WHERE id LIKE 'purge_%'")
.execute(&state.db.pool)
.await
.unwrap();
}
}
+253 -5
View File
@@ -1,8 +1,256 @@
//! Publication des posts programmés (toutes les minutes).
//!
//! STUB — à porter depuis `server/services/scheduler.ts`.
#![allow(unused_variables)]
//! Publication des posts programmés : toutes les minutes, les publications
//! dues sont envoyées sur Facebook ou TikTok.
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use crate::db::{
Media, MediaType, Patch, Platform, Post, PostStatus, PostType, ScheduledPost, SocialPage,
};
use crate::services::cron::{sleep_until_next_minute, spawn_guarded};
use crate::services::tiktok::error_message;
use crate::services::{facebook, media_store, tiktok};
use crate::state::AppState;
use crate::time::Ts;
pub fn start(state: AppState) {}
/// Démarre la boucle de publication (au début de chaque minute).
pub fn start(state: AppState) {
let running = Arc::new(AtomicBool::new(false));
tokio::spawn(async move {
loop {
sleep_until_next_minute().await;
let state = state.clone();
spawn_guarded(&running, "Scheduler", async move {
process_pending_posts(&state).await;
});
}
});
tracing::info!("Scheduler service started");
}
/// Traite les publications dues (une erreur n'interrompt pas les suivantes).
pub async fn process_pending_posts(state: &AppState) {
let pending = match state.db.get_pending_scheduled_posts().await {
Ok(pending) => pending,
Err(error) => {
tracing::error!("Error processing pending posts: {error}");
return;
}
};
for scheduled_post in pending {
if let Err(error) = process_one(state, &scheduled_post).await {
tracing::error!("Error publishing post {}: {error:#}", scheduled_post.id);
let recorded = state
.db
.update_scheduled_post(
&scheduled_post.id,
Patch::new().set("error", error_message(&error)),
)
.await;
if let Err(error) = recorded {
tracing::error!("Error processing pending posts: {error}");
return;
}
}
}
}
async fn process_one(state: &AppState, scheduled_post: &ScheduledPost) -> anyhow::Result<()> {
// Les anciens posts « both » doivent avoir été scindés à la création
// (les nouveaux le sont automatiquement par /api/posts)
if scheduled_post.post_type == PostType::Both {
let error_msg = "Legacy postType \"both\" is no longer supported. Deleting this scheduled entry. Please recreate this post - it will automatically be split into separate feed and story posts.";
tracing::error!(
"Rejecting and deleting legacy \"both\" post {}: {error_msg}",
scheduled_post.id
);
// Suppression plutôt que « publié », qui serait trompeur
state.db.delete_scheduled_post(&scheduled_post.id).await?;
// Le post repasse en brouillon pour être reprogrammé
state
.db
.update_post(
&scheduled_post.post_id,
Patch::new().set("status", PostStatus::Draft),
)
.await?;
return Ok(());
}
publish_post(state, scheduled_post).await
}
async fn publish_post(state: &AppState, scheduled_post: &ScheduledPost) -> anyhow::Result<()> {
let post = state.db.get_post(&scheduled_post.post_id).await?;
let page = state.db.get_social_page(&scheduled_post.page_id).await?;
let (Some(post), Some(page)) = (post, page) else {
anyhow::bail!("Post or page not found");
};
tracing::info!(
"Publishing post {} to {} page {}",
post.id,
page.platform,
page.page_name
);
// Tous les médias du post (carrousels multi-photos)
let mut media_list: Vec<Media> = Vec::new();
for link in state.db.get_post_media(&post.id).await? {
if let Some(media) = state.db.get_media_by_id(&link.media_id).await? {
media_list.push(media);
}
}
// Une story sans média ne peut pas être publiée
if scheduled_post.post_type == PostType::Story && media_list.is_empty() {
let error_msg = "Les stories nécessitent un média. Ce post ne peut pas être publié.";
tracing::error!(
"Deleting invalid story post {} without media: {error_msg}",
scheduled_post.id
);
state.db.delete_scheduled_post(&scheduled_post.id).await?;
state
.db
.update_post(&post.id, Patch::new().set("status", PostStatus::Draft))
.await?;
return Ok(());
}
match page.platform {
Platform::Facebook => {
let external_post_id =
facebook::publish_post(state, &post, &page, scheduled_post.post_type, &media_list)
.await?;
state
.db
.update_scheduled_post(
&scheduled_post.id,
Patch::new()
.set("published_at", Ts::now())
.set("external_post_id", external_post_id)
.set_null("error"),
)
.await?;
}
Platform::Tiktok => {
let publish_id = publish_to_tiktok(state, &post, &page, &media_list).await?;
// TikTok publie de façon asynchrone : on marque l'envoi comme effectué pour
// ne pas republier à la minute suivante, et le poller de statut renseignera
// l'identifiant définitif du post.
state
.db
.update_scheduled_post(
&scheduled_post.id,
Patch::new()
.set("published_at", Ts::now())
.set("publish_id", publish_id)
.set("publish_status", "PROCESSING_UPLOAD")
.set_null("error"),
)
.await?;
}
Platform::Instagram => {
// La publication Instagram n'est pas encore implémentée
tracing::warn!(
"Instagram publishing not yet implemented for post {}",
post.id
);
state
.db
.update_scheduled_post(
&scheduled_post.id,
Patch::new().set("error", "Instagram publishing not yet implemented"),
)
.await?;
state
.db
.update_post(&post.id, Patch::new().set("status", PostStatus::Failed))
.await?;
return Ok(());
}
}
state
.db
.update_post(&post.id, Patch::new().set("status", PostStatus::Published))
.await?;
// Les vidéos locales ne sont supprimées que si plus aucune publication
// planifiée non envoyée ne les utilise
for media in &media_list {
let Some(public_id) = media
.cloudinary_public_id
.as_deref()
.filter(|p| media.media_type == MediaType::Video && !p.is_empty())
else {
continue;
};
if let Err(error) = delete_video_if_unused(state, &post, media, public_id).await {
tracing::warn!(
"[Scheduler] Failed to delete local video file for media {}: {error:#}",
media.id
);
}
}
tracing::info!(
"Successfully published post {} to {} page {}",
post.id,
page.platform,
page.page_name
);
Ok(())
}
async fn delete_video_if_unused(
state: &AppState,
post: &Post,
media: &Media,
public_id: &str,
) -> anyhow::Result<()> {
let remaining = state.db.get_scheduled_posts_by_post(&post.id).await?;
let has_pending = remaining.iter().any(|sp| sp.published_at.is_none());
if has_pending {
tracing::info!(
"[Scheduler] Kept local video file for media {} (pending scheduled posts still exist)",
media.id
);
} else {
media_store::delete_media(state, public_id).await?;
tracing::info!(
"[Scheduler] Deleted local video file for media {}",
media.id
);
}
Ok(())
}
/// Envoie la vidéo d'un post planifié sur un compte TikTok.
/// TikTok ne sait pas programmer une publication : c'est notre planificateur qui
/// déclenche l'envoi à l'heure voulue. Renvoie le `publish_id` à suivre.
async fn publish_to_tiktok(
state: &AppState,
post: &Post,
page: &SocialPage,
media_list: &[Media],
) -> anyhow::Result<String> {
let Some(video) = media_list.iter().find(|m| m.media_type == MediaType::Video) else {
anyhow::bail!(
"Le compte TikTok \"{}\" ne peut recevoir que des vidéos : ce post n'en contient pas.",
page.page_name
);
};
let buffer = media_store::read_media_buffer(state, &video.original_url).await?;
tiktok::publish_video_from_buffer(state, page, buffer, &post.content).await
}
+623 -7
View File
@@ -1,24 +1,640 @@
//! Comptes TikTok : OAuth, renouvellement des jetons, publication de vidéos.
//! Service de publication TikTok (Content Posting API v2).
//!
//! STUB — à porter depuis `server/services/tiktok.ts`.
#![allow(unused_variables)]
//! Différences structurantes avec Facebook, qui expliquent la forme de ce service :
//! - Pas de token collable à la main : chaque compte magasin passe par OAuth.
//! L'access token ne vit que 24h et doit être renouvelé avec le refresh token
//! (valable 1 an) avant chaque publication.
//! - La publication est asynchrone : l'upload rend un `publish_id`, et l'identifiant
//! définitif du post n'est connu qu'en interrogeant /status/fetch/ ensuite.
//! - `creator_info/query` doit être appelé avant chaque publication pour connaître
//! les niveaux de confidentialité réellement autorisés sur le compte.
//!
//! Note : tant que l'application n'a pas passé l'audit TikTok, le seul niveau de
//! confidentialité disponible est SELF_ONLY (vidéo visible du seul propriétaire du
//! compte). Le code s'adapte automatiquement à ce que le compte autorise.
mod payload;
use axum::http::header::{CACHE_CONTROL, CONTENT_RANGE, CONTENT_TYPE};
use axum::http::HeaderMap;
use bytes::Bytes;
use chrono::Duration;
use serde_json::{json, Value};
use crate::db::SocialPage;
use payload::{
build_init_body, chunk_range, compute_chunking, decode_uri_component, js_slice, js_string,
parse_api_payload, parse_token_payload, resolve_privacy_level,
};
pub use payload::{CreatorInfo, PublishOptions, PublishStatus, TokenResponse};
use crate::config::Config;
use crate::db::{Patch, Platform, SocialPage, TokenStatus};
use crate::public_url::resolve_public_base_url;
use crate::state::AppState;
use crate::time::Ts;
/// Envoie une vidéo à TikTok ; renvoie le `publish_id` (publication asynchrone).
const AUTH_BASE_URL: &str = "https://www.tiktok.com/v2/auth/authorize/";
const API_BASE_URL: &str = "https://open.tiktokapis.com/v2";
/// Scopes demandés à l'autorisation. `video.publish` nécessite l'audit TikTok.
const DEFAULT_SCOPES: &str = "user.info.basic,video.publish";
/// Renouvelle le token si sa fin de validité est à moins de 5 minutes.
const TOKEN_REFRESH_MARGIN_MS: i64 = 5 * 60 * 1000;
/// Abandon du suivi d'une publication au-delà de 24h.
const STATUS_TRACKING_LIMIT_MS: i64 = 24 * 60 * 60 * 1000;
/// Message d'une erreur tel que l'exposait l'ancien serveur : pour une erreur
/// PostgreSQL, le seul message du serveur (comme `error.message` de node-postgres).
pub fn error_message(error: &anyhow::Error) -> String {
if let Some(sqlx::Error::Database(db)) = error.downcast_ref::<sqlx::Error>() {
return db.message().to_string();
}
error.to_string()
}
/// Envoie une requête ; une erreur réseau donne « fetch failed », comme `fetch`
/// côté Node (la cause est journalisée).
pub async fn send(request: reqwest::RequestBuilder) -> anyhow::Result<reqwest::Response> {
request.send().await.map_err(|e| {
tracing::warn!("⚠️ Requête HTTP en échec : {e}");
anyhow::anyhow!("fetch failed")
})
}
/// Statut HTTP, succès et corps texte d'une réponse.
async fn read_response(response: reqwest::Response) -> anyhow::Result<(u16, bool, String)> {
let status = response.status();
let raw = response.text().await.map_err(|e| {
tracing::warn!("⚠️ Lecture de la réponse TikTok impossible : {e}");
anyhow::anyhow!("fetch failed")
})?;
Ok((status.as_u16(), status.is_success(), raw))
}
/// Identifiants de l'application développeur TikTok (globaux à l'instance).
/// La base prime sur les variables d'environnement.
async fn get_credentials(state: &AppState) -> anyhow::Result<(String, String)> {
let config = state.db.get_tiktok_config().await?;
let client_key = config
.as_ref()
.map(|c| c.client_key.clone())
.filter(|k| !k.is_empty())
.or_else(|| Config::var("TIKTOK_CLIENT_KEY"));
let client_secret = config
.as_ref()
.map(|c| c.client_secret.clone())
.filter(|s| !s.is_empty())
.or_else(|| Config::var("TIKTOK_CLIENT_SECRET"));
match (client_key, client_secret) {
(Some(key), Some(secret)) => Ok((key, secret)),
_ => anyhow::bail!(
"TikTok n'est pas configuré. Renseignez le client key et le client secret \
de votre application TikTok dans les paramètres."
),
}
}
pub async fn is_configured(state: &AppState) -> bool {
get_credentials(state).await.is_ok()
}
/// URI de redirection OAuth. Les en-têtes servent de repli quand `APP_URL`
/// n'est pas défini : le domaine public est alors lu sur la requête plutôt que
/// remplacé par `http://localhost:5555`.
pub fn redirect_uri(state: &AppState, headers: Option<&HeaderMap>) -> String {
if let Some(uri) = Config::var("TIKTOK_REDIRECT_URI") {
return uri;
}
format!(
"{}/api/tiktok/callback",
resolve_public_base_url(state, headers)
)
}
/// URL d'autorisation à ouvrir pour connecter le compte TikTok d'un magasin.
/// Chaque magasin répète ce flux : un compte = une ligne dans social_pages.
pub async fn build_authorization_url(
state: &AppState,
oauth_state: &str,
headers: Option<&HeaderMap>,
) -> anyhow::Result<String> {
let (client_key, _) = get_credentials(state).await?;
let scopes = Config::var("TIKTOK_SCOPES").unwrap_or_else(|| DEFAULT_SCOPES.to_string());
let query = url::form_urlencoded::Serializer::new(String::new())
.append_pair("client_key", &client_key)
.append_pair("scope", &scopes)
.append_pair("response_type", "code")
.append_pair("redirect_uri", &redirect_uri(state, headers))
.append_pair("state", oauth_state)
.finish();
Ok(format!("{AUTH_BASE_URL}?{query}"))
}
/// Échange le code d'autorisation contre un couple access/refresh token.
pub async fn exchange_code_for_token(
state: &AppState,
code: &str,
headers: Option<&HeaderMap>,
) -> anyhow::Result<TokenResponse> {
let (client_key, client_secret) = get_credentials(state).await?;
// TikTok renvoie le code URL-encodé dans la query string
let code = decode_uri_component(code)?;
let redirect = redirect_uri(state, headers);
request_token(
state,
&[
("client_key", client_key.as_str()),
("client_secret", client_secret.as_str()),
("code", code.as_str()),
("grant_type", "authorization_code"),
("redirect_uri", redirect.as_str()),
],
)
.await
}
/// Renouvelle un access token expiré. TikTok rend aussi un nouveau refresh token.
pub async fn refresh_access_token(
state: &AppState,
refresh_token: &str,
) -> anyhow::Result<TokenResponse> {
let (client_key, client_secret) = get_credentials(state).await?;
request_token(
state,
&[
("client_key", client_key.as_str()),
("client_secret", client_secret.as_str()),
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
],
)
.await
}
async fn request_token(state: &AppState, form: &[(&str, &str)]) -> anyhow::Result<TokenResponse> {
let body = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(form)
.finish();
let response = send(
state
.http
.post(format!("{API_BASE_URL}/oauth/token/"))
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(CACHE_CONTROL, "no-cache")
.body(body),
)
.await?;
let (status, ok, raw) = read_response(response).await?;
parse_token_payload(status, ok, &raw)
}
/// Récupère un access token valide pour une page, en le renouvelant si besoin.
/// Le nouveau couple de tokens est persisté immédiatement : le refresh token
/// TikTok est à usage unique, le perdre déconnecterait le compte.
pub async fn get_valid_access_token(state: &AppState, page: &SocialPage) -> anyhow::Result<String> {
if page.platform != Platform::Tiktok {
anyhow::bail!("Ce service ne gère que les comptes TikTok");
}
let expires_at = page
.token_expires_at
.map(|t| t.timestamp_millis())
.unwrap_or(0);
let still_valid = expires_at - TOKEN_REFRESH_MARGIN_MS > Ts::now().timestamp_millis();
if still_valid && !page.access_token.is_empty() {
return Ok(page.access_token.clone());
}
let Some(refresh_token) = page.refresh_token.as_deref().filter(|t| !t.is_empty()) else {
anyhow::bail!(
"Le compte TikTok \"{}\" doit être reconnecté (aucun refresh token enregistré).",
page.page_name
);
};
tracing::info!(
"🔄 [TikTok] Renouvellement du token pour {}",
page.page_name
);
let tokens = match refresh_access_token(state, refresh_token).await {
Ok(tokens) => tokens,
Err(error) => {
state
.db
.update_social_page(
&page.id,
Patch::new()
.set("token_status", TokenStatus::Expired)
.set("last_token_check", Ts::now()),
)
.await?;
anyhow::bail!(
"Impossible de renouveler le token TikTok de \"{}\". \
Le compte doit être reconnecté. ({})",
page.page_name,
error_message(&error)
);
}
};
persist_tokens(state, &page.id, &tokens).await?;
Ok(tokens.access_token)
}
/// Échéance « maintenant + n secondes ».
fn expires_in(now: Ts, seconds: f64) -> Ts {
now.add(Duration::milliseconds((seconds * 1000.0) as i64))
}
/// Enregistre un couple de tokens fraîchement obtenu sur une page.
pub async fn persist_tokens(
state: &AppState,
page_id: &str,
tokens: &TokenResponse,
) -> anyhow::Result<()> {
let now = Ts::now();
// Un champ absent de la réponse laisse la colonne inchangée
let mut patch = Patch::new().set("access_token", tokens.access_token.clone());
if let Some(seconds) = tokens.expires_in {
patch = patch.set("token_expires_at", expires_in(now, seconds));
}
if let Some(refresh_token) = &tokens.refresh_token {
patch = patch.set("refresh_token", refresh_token.clone());
}
if let Some(seconds) = tokens.refresh_expires_in {
patch = patch.set("refresh_token_expires_at", expires_in(now, seconds));
}
if let Some(scope) = &tokens.scope {
patch = patch.set("scopes", scope.clone());
}
let patch = patch
.set("token_status", TokenStatus::Valid)
.set("last_token_check", now);
state.db.update_social_page(page_id, patch).await?;
Ok(())
}
/// Échéances d'un couple de tokens, pour la création d'une page.
pub fn token_expirations(tokens: &TokenResponse) -> (Option<Ts>, Option<Ts>) {
let now = Ts::now();
(
tokens.expires_in.map(|s| expires_in(now, s)),
tokens.refresh_expires_in.map(|s| expires_in(now, s)),
)
}
/// Profil public du compte, utilisé pour nommer la page à la connexion.
#[derive(Debug, Clone)]
pub struct UserProfile {
pub open_id: Option<String>,
pub display_name: String,
pub avatar_url: Option<String>,
}
pub async fn get_user_info(state: &AppState, access_token: &str) -> anyhow::Result<UserProfile> {
let response = send(
state
.http
.get(format!(
"{API_BASE_URL}/user/info/?fields=open_id%2Cdisplay_name%2Cavatar_url"
))
.bearer_auth(access_token),
)
.await?;
let data = parse_api_response(response, "récupération du profil").await?;
let user = data.get("user").cloned().unwrap_or(Value::Null);
let text = |key: &str| user.get(key).and_then(js_string).filter(|s| !s.is_empty());
Ok(UserProfile {
open_id: text("open_id"),
display_name: text("display_name").unwrap_or_else(|| "Compte TikTok".to_string()),
avatar_url: text("avatar_url"),
})
}
/// Options réellement autorisées sur le compte (confidentialité, commentaires,
/// duo, stitch, durée max). TikTok impose cet appel avant chaque publication.
pub async fn get_creator_info(state: &AppState, page: &SocialPage) -> anyhow::Result<CreatorInfo> {
let token = get_valid_access_token(state, page).await?;
query_creator_info(state, &token).await
}
/// Variante prenant un token déjà résolu : le refresh token TikTok tourne à chaque
/// renouvellement, donc une même opération ne doit déclencher qu'un seul refresh.
async fn query_creator_info(state: &AppState, access_token: &str) -> anyhow::Result<CreatorInfo> {
let response = send(
state
.http
.post(format!("{API_BASE_URL}/post/publish/creator_info/query/"))
.bearer_auth(access_token)
.header(CONTENT_TYPE, "application/json; charset=UTF-8"),
)
.await?;
let data = parse_api_response(response, "récupération des options du compte").await?;
Ok(CreatorInfo::from_data(&data))
}
/// Publie une vidéo avec les options par défaut ; renvoie le `publish_id`
/// (publication asynchrone, suivie par [`sync_pending_publications`]).
pub async fn publish_video_from_buffer(
state: &AppState,
page: &SocialPage,
video: Bytes,
description: &str,
) -> anyhow::Result<String> {
anyhow::bail!("publication TikTok non implémentée")
publish_video_with_options(state, page, video, description, &PublishOptions::default()).await
}
/// Interroge le statut des envois TikTok en attente et met à jour les publications.
/// Publie une vidéo sur un compte TikTok en trois temps :
/// 1. init — déclare la taille et le découpage, récupère publish_id + upload_url
/// 2. upload — envoie les données binaires par chunks
/// 3. (plus tard) status/fetch — le poller récupère l'identifiant du post
///
/// On envoie le fichier (FILE_UPLOAD) plutôt qu'une URL (PULL_FROM_URL) : cette
/// seconde option exigerait de faire vérifier le domaine de l'instance chez TikTok
/// et que les médias soient publiquement accessibles.
pub async fn publish_video_with_options(
state: &AppState,
page: &SocialPage,
video: Bytes,
caption: &str,
options: &PublishOptions,
) -> anyhow::Result<String> {
if page.platform != Platform::Tiktok {
anyhow::bail!("Ce service ne gère que les comptes TikTok");
}
// Un seul renouvellement de token pour toute la publication
let access_token = get_valid_access_token(state, page).await?;
let creator_info = query_creator_info(state, &access_token).await?;
let privacy_level = resolve_privacy_level(
&creator_info,
options.privacy_level.as_deref(),
&page.page_name,
)?;
let video_size = video.len() as i64;
let (chunk_size, total_chunk_count) = compute_chunking(video_size);
tracing::info!(
"🎬 [TikTok] Publication en cours: compte={}, taille={:.2} Mo, chunks={}, confidentialite={}",
page.page_name,
video_size as f64 / 1024.0 / 1024.0,
total_chunk_count,
privacy_level
);
// ── Étape 1 : init ────────────────────────────────────────────────────────
let init_body = build_init_body(
caption,
&privacy_level,
&creator_info,
options,
video_size,
chunk_size,
total_chunk_count,
);
let init_response = send(
state
.http
.post(format!("{API_BASE_URL}/post/publish/video/init/"))
.bearer_auth(&access_token)
.header(CONTENT_TYPE, "application/json; charset=UTF-8")
.body(init_body.to_string()),
)
.await?;
let init_data = parse_api_response(init_response, "initialisation de la publication").await?;
let publish_id = init_data
.get("publish_id")
.and_then(js_string)
.filter(|s| !s.is_empty());
let upload_url = init_data
.get("upload_url")
.and_then(js_string)
.filter(|s| !s.is_empty());
let (Some(publish_id), Some(upload_url)) = (publish_id, upload_url) else {
anyhow::bail!("TikTok n'a pas renvoyé d'identifiant de publication");
};
tracing::info!("📤 [TikTok] Upload initialisé: publish_id={publish_id}");
// ── Étape 2 : upload des chunks ───────────────────────────────────────────
for index in 0..total_chunk_count {
let (start, end) = chunk_range(index, chunk_size, total_chunk_count, video_size);
let chunk = video.slice(start as usize..(end + 1).max(start) as usize);
let upload_response = send(
state
.http
.put(&upload_url)
.header(CONTENT_TYPE, "video/mp4")
.header(CONTENT_RANGE, format!("bytes {start}-{end}/{video_size}"))
.body(chunk),
)
.await?;
if !upload_response.status().is_success() {
let status = upload_response.status().as_u16();
let error_text = upload_response.text().await.unwrap_or_default();
anyhow::bail!(
"Échec de l'envoi du chunk {}/{} vers TikTok ({}): {}",
index + 1,
total_chunk_count,
status,
js_slice(&error_text, 200)
);
}
tracing::info!(
"📤 [TikTok] Chunk {}/{} envoyé",
index + 1,
total_chunk_count
);
}
tracing::info!(
"✅ [TikTok] Vidéo transmise pour {}, traitement en cours côté TikTok",
page.page_name
);
Ok(publish_id)
}
/// Interroge TikTok sur l'avancement d'une publication.
pub async fn get_publish_status(
state: &AppState,
page: &SocialPage,
publish_id: &str,
) -> anyhow::Result<PublishStatus> {
let access_token = get_valid_access_token(state, page).await?;
let response = send(
state
.http
.post(format!("{API_BASE_URL}/post/publish/status/fetch/"))
.bearer_auth(&access_token)
.header(CONTENT_TYPE, "application/json; charset=UTF-8")
.body(json!({ "publish_id": publish_id }).to_string()),
)
.await?;
let data = parse_api_response(response, "suivi de la publication").await?;
Ok(PublishStatus::from_data(&data))
}
/// Met à jour les publications TikTok encore en cours de traitement.
/// Appelé périodiquement : c'est ce qui renseigne l'identifiant définitif du post
/// (ou l'erreur) une fois que TikTok a fini d'encoder la vidéo.
pub async fn sync_pending_publications(state: &AppState) -> anyhow::Result<()> {
let pending = state
.db
.get_scheduled_posts_awaiting_publish_status()
.await?;
if pending.is_empty() {
return Ok(());
}
tracing::info!(
"🔎 [TikTok] Suivi de {} publication(s) en cours...",
pending.len()
);
for scheduled_post in pending {
if let Err(error) = sync_one(state, &scheduled_post).await {
tracing::error!(
"⚠️ [TikTok] Impossible de récupérer le statut de la publication {}: {}",
scheduled_post.id,
error_message(&error)
);
}
}
Ok(())
}
async fn sync_one(
state: &AppState,
scheduled_post: &crate::db::ScheduledPost,
) -> anyhow::Result<()> {
let page = state.db.get_social_page(&scheduled_post.page_id).await?;
let (Some(page), Some(publish_id)) = (page, scheduled_post.publish_id.as_deref()) else {
return Ok(());
};
if page.platform != Platform::Tiktok || publish_id.is_empty() {
return Ok(());
}
let status = get_publish_status(state, &page, publish_id).await?;
let current = status.status.as_deref();
if current == Some("PUBLISH_COMPLETE") {
state
.db
.update_scheduled_post(
&scheduled_post.id,
Patch::new()
.set("publish_status", "PUBLISH_COMPLETE")
// À défaut d'ID public (compte privé), on garde le publish_id comme référence
.set(
"external_post_id",
status
.post_id
.clone()
.filter(|id| !id.is_empty())
.unwrap_or_else(|| publish_id.to_string()),
)
.set_null("error"),
)
.await?;
tracing::info!("✅ [TikTok] Publication terminée sur {}", page.page_name);
return Ok(());
}
if current == Some("FAILED") {
let reason = status
.fail_reason
.clone()
.filter(|r| !r.is_empty())
.unwrap_or_else(|| "raison inconnue".to_string());
state
.db
.update_scheduled_post(
&scheduled_post.id,
Patch::new()
.set("publish_status", "FAILED")
.set("error", format!("Publication TikTok échouée : {reason}")),
)
.await?;
tracing::error!(
"❌ [TikTok] Publication échouée sur {}: {}",
page.page_name,
status.fail_reason.as_deref().unwrap_or("undefined")
);
return Ok(());
}
// Filet de sécurité : au-delà de 24h, on cesse d'interroger l'API
let sent_at = scheduled_post
.published_at
.map(|t| t.timestamp_millis())
.unwrap_or(0);
if sent_at != 0 && Ts::now().timestamp_millis() - sent_at > STATUS_TRACKING_LIMIT_MS {
let shown = current.unwrap_or("undefined");
state
.db
.update_scheduled_post(
&scheduled_post.id,
Patch::new().set("publish_status", "FAILED").set(
"error",
format!("Statut TikTok toujours \"{shown}\" après 24h, suivi abandonné."),
),
)
.await?;
tracing::error!(
"❌ [TikTok] Suivi abandonné pour {} (statut {shown})",
page.page_name
);
return Ok(());
}
match current {
Some(current) => {
state
.db
.update_scheduled_post(
&scheduled_post.id,
Patch::new().set("publish_status", current),
)
.await?;
}
// Statut absent de la réponse : rien à enregistrer, on réessaiera
None => anyhow::bail!("No values to set"),
}
Ok(())
}
async fn parse_api_response(response: reqwest::Response, context: &str) -> anyhow::Result<Value> {
let (status, ok, raw) = read_response(response).await?;
parse_api_payload(status, ok, &raw, context)
}
+536
View File
@@ -0,0 +1,536 @@
//! Logique pure du service TikTok : découpage en chunks, choix de la
//! confidentialité, corps de l'initialisation et lecture des réponses de l'API.
use serde::Serialize;
use serde_json::{json, Map, Value};
use crate::extract::is_truthy;
/// TikTok impose des chunks entre 5 Mo et 64 Mo.
pub const MAX_SINGLE_CHUNK_SIZE: i64 = 64 * 1024 * 1024;
pub const CHUNK_SIZE: i64 = 32 * 1024 * 1024;
/// Longueur maximale de la légende d'une vidéo TikTok.
pub const MAX_TITLE_LENGTH: usize = 2200;
/// Options réellement autorisées sur un compte, renvoyées telles quelles au client.
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatorInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub creator_avatar_url: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub creator_username: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub creator_nickname: Option<Value>,
pub privacy_level_options: Vec<String>,
pub comment_disabled: bool,
pub duet_disabled: bool,
pub stitch_disabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_video_post_duration_sec: Option<Value>,
}
impl CreatorInfo {
/// Lecture du champ `data` de `creator_info/query`.
pub fn from_data(data: &Value) -> Self {
// Un champ absent reste absent (comme `undefined` côté JavaScript)
let field = |key: &str| data.get(key).cloned();
let privacy_level_options = data
.get("privacy_level_options")
.and_then(Value::as_array)
.map(|options| {
options
.iter()
.filter_map(|o| o.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
Self {
creator_avatar_url: field("creator_avatar_url"),
creator_username: field("creator_username"),
creator_nickname: field("creator_nickname"),
privacy_level_options,
comment_disabled: data.truthy_field("comment_disabled"),
duet_disabled: data.truthy_field("duet_disabled"),
stitch_disabled: data.truthy_field("stitch_disabled"),
max_video_post_duration_sec: field("max_video_post_duration_sec"),
}
}
}
/// Préférences de publication (toutes facultatives).
#[derive(Debug, Clone, Default)]
pub struct PublishOptions {
pub privacy_level: Option<String>,
pub disable_comment: bool,
pub disable_duet: bool,
pub disable_stitch: bool,
}
/// État d'une publication renvoyé par `/post/publish/status/fetch/`.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PublishStatus {
pub status: Option<String>,
pub fail_reason: Option<String>,
pub post_id: Option<String>,
}
impl PublishStatus {
pub fn from_data(data: &Value) -> Self {
// Le nom du champ comporte bien une faute de frappe côté API TikTok
let ids = [
data.get("publicaly_available_post_id"),
data.get("publicly_available_post_id"),
]
.into_iter()
.flatten()
.find(|v| is_truthy(Some(v)));
let post_id = ids
.and_then(Value::as_array)
.and_then(|ids| ids.first())
.and_then(js_string);
Self {
status: data.get("status").and_then(js_string),
fail_reason: data.get("fail_reason").and_then(js_string),
post_id,
}
}
}
/// Jetons renvoyés par `/oauth/token/`.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TokenResponse {
pub access_token: String,
pub expires_in: Option<f64>,
pub open_id: Option<String>,
pub refresh_token: Option<String>,
pub refresh_expires_in: Option<f64>,
pub scope: Option<String>,
}
impl TokenResponse {
pub fn from_value(data: &Value) -> Self {
Self {
access_token: data
.get("access_token")
.and_then(js_string)
.unwrap_or_default(),
expires_in: data.get("expires_in").and_then(Value::as_f64),
open_id: data.get("open_id").and_then(js_string),
refresh_token: data.get("refresh_token").and_then(js_string),
refresh_expires_in: data.get("refresh_expires_in").and_then(Value::as_f64),
scope: data.get("scope").and_then(js_string),
}
}
}
trait TruthyField {
fn truthy_field(&self, key: &str) -> bool;
}
impl TruthyField for Value {
fn truthy_field(&self, key: &str) -> bool {
is_truthy(self.get(key))
}
}
/// Valeur scalaire convertie en texte (`String(x)`), `None` pour null / objet.
pub fn js_string(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
/// Rendu d'une valeur dans un gabarit JavaScript (`${x}`).
pub fn js_display(value: &Value) -> String {
match value {
Value::Null => "null".into(),
Value::Object(_) => "[object Object]".into(),
Value::Array(items) => items
.iter()
.map(|v| match v {
Value::Null => String::new(),
other => js_display(other),
})
.collect::<Vec<_>>()
.join(","),
other => js_string(other).unwrap_or_default(),
}
}
/// Première valeur « truthy » d'une chaîne de `a || b || …`, rendue en texte.
pub fn first_truthy(values: &[Option<&Value>]) -> Option<String> {
values
.iter()
.find(|v| is_truthy(**v))
.and_then(|v| v.map(js_display))
}
/// `s.slice(0, n)` : longueur comptée en unités UTF-16, comme en JavaScript
/// (sans couper un caractère en deux).
pub fn js_slice(s: &str, max: usize) -> String {
let mut used = 0;
let mut out = String::new();
for c in s.chars() {
used += c.len_utf16();
if used > max {
break;
}
out.push(c);
}
out
}
/// Découpage du fichier : un seul chunk jusqu'à 64 Mo, sinon des chunks de 32 Mo,
/// le dernier absorbant le reliquat (règle imposée par TikTok).
pub fn compute_chunking(video_size: i64) -> (i64, i64) {
if video_size <= MAX_SINGLE_CHUNK_SIZE {
return (video_size, 1);
}
(CHUNK_SIZE, video_size / CHUNK_SIZE)
}
/// Bornes (incluses) du chunk `index`.
pub fn chunk_range(index: i64, chunk_size: i64, total: i64, video_size: i64) -> (i64, i64) {
let start = index * chunk_size;
// Le dernier chunk absorbe le reliquat
let end = if index == total - 1 {
video_size - 1
} else {
start + chunk_size - 1
};
(start, end)
}
/// Choisit un niveau de confidentialité réellement autorisé sur le compte.
/// Sans audit TikTok validé, seul SELF_ONLY est proposé : on s'y rabat plutôt
/// que d'échouer, en le signalant dans les logs.
pub fn resolve_privacy_level(
info: &CreatorInfo,
requested: Option<&str>,
page_name: &str,
) -> anyhow::Result<String> {
let options = &info.privacy_level_options;
if options.is_empty() {
anyhow::bail!(
"Le compte TikTok \"{page_name}\" ne propose aucun niveau de confidentialité. \
Vérifiez que le compte est bien autorisé et que l'application dispose du scope video.publish."
);
}
let wanted = requested
.filter(|r| !r.is_empty())
.unwrap_or("PUBLIC_TO_EVERYONE");
if options.iter().any(|o| o == wanted) {
return Ok(wanted.to_string());
}
let fallback = if options.iter().any(|o| o == "SELF_ONLY") {
"SELF_ONLY".to_string()
} else {
options[0].clone()
};
tracing::warn!(
"⚠️ [TikTok] \"{wanted}\" indisponible sur {page_name} (options: {}). \
Publication en {fallback}. Tant que l'application TikTok n'est pas auditée, \
les vidéos restent privées.",
options.join(", ")
);
Ok(fallback)
}
/// Corps de `/post/publish/video/init/`.
pub fn build_init_body(
caption: &str,
privacy_level: &str,
info: &CreatorInfo,
options: &PublishOptions,
video_size: i64,
chunk_size: i64,
total_chunk_count: i64,
) -> Value {
json!({
"post_info": {
"title": js_slice(caption, MAX_TITLE_LENGTH),
"privacy_level": privacy_level,
// Une option désactivée côté compte doit obligatoirement être déclarée désactivée
"disable_comment": info.comment_disabled || options.disable_comment,
"disable_duet": info.duet_disabled || options.disable_duet,
"disable_stitch": info.stitch_disabled || options.disable_stitch,
},
"source_info": {
"source": "FILE_UPLOAD",
"video_size": video_size,
"chunk_size": chunk_size,
"total_chunk_count": total_chunk_count,
},
})
}
/// Les endpoints v2 répondent HTTP 200 avec `error.code === 'ok'` en cas de succès :
/// il ne suffit pas de regarder le statut HTTP. Renvoie le champ `data` (ou `{}`).
pub fn parse_api_payload(status: u16, ok: bool, raw: &str, context: &str) -> anyhow::Result<Value> {
let payload: Value = serde_json::from_str(raw).map_err(|_| {
anyhow::anyhow!(
"Réponse TikTok illisible lors de la {context} ({status}): {}",
js_slice(raw, 200)
)
})?;
let error_code = payload.get("error").and_then(|e| e.get("code"));
let has_error_code = is_truthy(error_code) && error_code != Some(&Value::from("ok"));
if !ok || has_error_code {
let message = first_truthy(&[payload.get("error").and_then(|e| e.get("message"))])
.unwrap_or_else(|| js_slice(raw, 200));
let code = first_truthy(&[error_code]).unwrap_or_else(|| status.to_string());
anyhow::bail!("Erreur TikTok lors de la {context}: {message} (code: {code})");
}
Ok(match payload.get("data") {
Some(data) if is_truthy(Some(data)) => data.clone(),
_ => Value::Object(Map::new()),
})
}
/// Lecture de la réponse de `/oauth/token/` (erreurs signalées par `error` +
/// `error_description`).
pub fn parse_token_payload(status: u16, ok: bool, raw: &str) -> anyhow::Result<TokenResponse> {
let data: Value = serde_json::from_str(raw).map_err(|_| {
anyhow::anyhow!(
"Réponse TikTok illisible ({status}): {}",
js_slice(raw, 200)
)
})?;
if !ok || is_truthy(data.get("error")) {
let description = first_truthy(&[data.get("error_description"), data.get("error")])
.unwrap_or_else(|| js_slice(raw, 200));
anyhow::bail!("Erreur OAuth TikTok: {description}");
}
if !is_truthy(data.get("access_token")) {
anyhow::bail!("Réponse TikTok inattendue : aucun access token reçu");
}
Ok(TokenResponse::from_value(&data))
}
/// `decodeURIComponent` : échoue (« URI malformed ») sur une séquence `%` invalide.
pub fn decode_uri_component(value: &str) -> anyhow::Result<String> {
let bytes = value.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let valid = i + 2 < bytes.len()
&& bytes[i + 1].is_ascii_hexdigit()
&& bytes[i + 2].is_ascii_hexdigit();
if !valid {
anyhow::bail!("URI malformed");
}
i += 3;
} else {
i += 1;
}
}
percent_encoding::percent_decode_str(value)
.decode_utf8()
.map(|s| s.into_owned())
.map_err(|_| anyhow::anyhow!("URI malformed"))
}
#[cfg(test)]
mod tests {
use super::*;
fn info(options: &[&str]) -> CreatorInfo {
CreatorInfo {
privacy_level_options: options.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
#[test]
fn chunking_follows_tiktok_rules() {
assert_eq!(compute_chunking(10), (10, 1));
assert_eq!(
compute_chunking(MAX_SINGLE_CHUNK_SIZE),
(MAX_SINGLE_CHUNK_SIZE, 1)
);
let size = 100 * 1024 * 1024;
assert_eq!(compute_chunking(size), (CHUNK_SIZE, 3));
// Le dernier chunk absorbe le reliquat
assert_eq!(
chunk_range(2, CHUNK_SIZE, 3, size),
(2 * CHUNK_SIZE, size - 1)
);
assert_eq!(chunk_range(0, CHUNK_SIZE, 3, size), (0, CHUNK_SIZE - 1));
assert_eq!(chunk_range(0, 10, 1, 10), (0, 9));
}
#[test]
fn privacy_level_falls_back_to_self_only() {
let all = info(&["PUBLIC_TO_EVERYONE", "SELF_ONLY"]);
assert_eq!(
resolve_privacy_level(&all, None, "p").unwrap(),
"PUBLIC_TO_EVERYONE"
);
assert_eq!(
resolve_privacy_level(&all, Some("SELF_ONLY"), "p").unwrap(),
"SELF_ONLY"
);
let private = info(&["MUTUAL_FOLLOW_FRIENDS", "SELF_ONLY"]);
assert_eq!(
resolve_privacy_level(&private, None, "p").unwrap(),
"SELF_ONLY"
);
let other = info(&["FOLLOWER_OF_CREATOR"]);
assert_eq!(
resolve_privacy_level(&other, None, "p").unwrap(),
"FOLLOWER_OF_CREATOR"
);
let err = resolve_privacy_level(&info(&[]), None, "Magasin").unwrap_err();
assert!(err
.to_string()
.starts_with("Le compte TikTok \"Magasin\" ne propose aucun niveau"));
}
#[test]
fn init_body_forces_account_restrictions() {
let mut creator = info(&["SELF_ONLY"]);
creator.duet_disabled = true;
let options = PublishOptions {
disable_comment: true,
..Default::default()
};
let body = build_init_body("Bonjour", "SELF_ONLY", &creator, &options, 10, 10, 1);
assert_eq!(
body,
json!({
"post_info": {
"title": "Bonjour",
"privacy_level": "SELF_ONLY",
"disable_comment": true,
"disable_duet": true,
"disable_stitch": false,
},
"source_info": {
"source": "FILE_UPLOAD",
"video_size": 10,
"chunk_size": 10,
"total_chunk_count": 1,
},
})
);
let long = "é".repeat(3000);
let body = build_init_body(&long, "SELF_ONLY", &creator, &options, 1, 1, 1);
assert_eq!(
body["post_info"]["title"].as_str().unwrap().chars().count(),
2200
);
// Un emoji compte pour deux unités UTF-16
assert_eq!(js_slice("😀😀", 3), "😀");
}
#[test]
fn api_payload_errors() {
let ok = r#"{"data":{"publish_id":"p1"},"error":{"code":"ok","message":""}}"#;
assert_eq!(
parse_api_payload(200, true, ok, "x").unwrap(),
json!({"publish_id":"p1"})
);
let failed =
r#"{"data":{},"error":{"code":"access_token_invalid","message":"Token invalide"}}"#;
assert_eq!(
parse_api_payload(401, false, failed, "suivi de la publication")
.unwrap_err()
.to_string(),
"Erreur TikTok lors de la suivi de la publication: Token invalide (code: access_token_invalid)"
);
assert_eq!(
parse_api_payload(500, false, "{}", "ctx")
.unwrap_err()
.to_string(),
"Erreur TikTok lors de la ctx: {} (code: 500)"
);
assert_eq!(
parse_api_payload(502, false, "<html>", "ctx")
.unwrap_err()
.to_string(),
"Réponse TikTok illisible lors de la ctx (502): <html>"
);
assert_eq!(
parse_api_payload(200, true, r#"{"error":{"code":"ok"}}"#, "x").unwrap(),
json!({})
);
}
#[test]
fn token_payload() {
let raw = r#"{"access_token":"a","expires_in":86400,"open_id":"o","refresh_token":"r","refresh_expires_in":31536000,"scope":"user.info.basic","token_type":"Bearer"}"#;
let tokens = parse_token_payload(200, true, raw).unwrap();
assert_eq!(tokens.access_token, "a");
assert_eq!(tokens.expires_in, Some(86400.0));
assert_eq!(tokens.open_id.as_deref(), Some("o"));
let err = r#"{"error":"invalid_grant","error_description":"Code expiré"}"#;
assert_eq!(
parse_token_payload(400, false, err)
.unwrap_err()
.to_string(),
"Erreur OAuth TikTok: Code expiré"
);
assert_eq!(
parse_token_payload(200, true, "{}")
.unwrap_err()
.to_string(),
"Réponse TikTok inattendue : aucun access token reçu"
);
}
#[test]
fn publish_status_reads_misspelled_field() {
let status = PublishStatus::from_data(&json!({
"status": "PUBLISH_COMPLETE",
"publicaly_available_post_id": [7301234567890123456u64]
}));
assert_eq!(status.status.as_deref(), Some("PUBLISH_COMPLETE"));
assert_eq!(status.post_id.as_deref(), Some("7301234567890123456"));
let status = PublishStatus::from_data(&json!({
"status": "FAILED", "fail_reason": "spam", "publicly_available_post_id": []
}));
assert_eq!(status.fail_reason.as_deref(), Some("spam"));
assert_eq!(status.post_id, None);
}
#[test]
fn creator_info_serialization() {
let info = CreatorInfo::from_data(&json!({
"creator_username": "magasin",
"privacy_level_options": ["SELF_ONLY"],
"comment_disabled": true,
}));
assert_eq!(
serde_json::to_value(&info).unwrap(),
json!({
"creatorUsername": "magasin",
"privacyLevelOptions": ["SELF_ONLY"],
"commentDisabled": true,
"duetDisabled": false,
"stitchDisabled": false,
})
);
}
#[test]
fn uri_component_decoding() {
assert_eq!(decode_uri_component("a%2Ab%20c").unwrap(), "a*b c");
assert_eq!(decode_uri_component("plain").unwrap(), "plain");
assert!(decode_uri_component("bad%zz").is_err());
assert!(decode_uri_component("bad%2").is_err());
assert!(decode_uri_component("%FF").is_err());
}
}