mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-14 18:58:30 +02:00
Merge commit 'efbd6dd' into claude/intelligent-wright-pr07e9
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
+304
-4
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user