mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-19 05:07:08 +02:00
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:
8 files changed
+3749
-26
No files matched your search
+1072
-2
File diff suppressed because it is too large.
Load diff
+367
-1
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
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% – enseigne %TRADE_NAME%<br>
|
||||
%LEGAL_FORM%<br>
|
||||
%ADDRESS%<br>
|
||||
%RCS%<br>
|
||||
Contact : %EMAIL%
|
||||
</p>
|
||||
<p><a href="/terms">Conditions d'utilisation</a> · <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 › Paramètres et confidentialité ›
|
||||
Sécurité et autorisations › 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 & <b> "c""
|
||||
);
|
||||
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
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user