Serveur Rust : TikTok, planificateur, tâches périodiques, API externe et pages légales

- services/tiktok : OAuth, renouvellement des jetons, creator_info,
  publication par chunks (FILE_UPLOAD) et suivi asynchrone des statuts
- services/scheduler : publication des posts dus chaque minute
  (Facebook via facebook::publish_post, TikTok, rejet des anciens « both »)
- services/cron : suivi TikTok (*/2), jetons (0,12 h), statistiques
  (8,20 h), purge des médias (3 h) et purge horaire des sessions, en UTC
- services/media_purge : images > 30 jours, vidéos > 7 jours
- routes/tiktok (/api/tiktok), routes/external (/api/v1, clé X-API-Key,
  messages de validation identiques à zod), routes/legal (/terms, /privacy,
  HTML identique à l'octet près)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
This commit is contained in:
Claude committed 2026-10-12 17:37:13 +00:00
1 parent f9828d4887
commit efbd6dde39
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())
);
}
}