diff --git a/server/src/routes/external.rs b/server/src/routes/external.rs index 94c1729..b041676 100644 --- a/server/src/routes/external.rs +++ b/server/src/routes/external.rs @@ -1,9 +1,1079 @@ -//! STUB — routes à porter (voir `server/routes/external.ts`). +//! API externe (`/api/v1`) utilisée par des outils tiers (n8n…), protégée par +//! la clé du header `X-API-Key`. -use axum::Router; +use std::collections::HashMap; +use std::sync::LazyLock; +use std::time::Duration; +use axum::extract::{FromRequestParts, Path, State}; +use axum::http::request::Parts; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use base64::Engine; +use bytes::Bytes; +use regex::Regex; +use serde_json::{json, Map, Value}; + +use crate::db::{MediaType, NewMedia, NewPost, NewScheduledPost, Patch, PostStatus, PostType, Val}; +use crate::error::AppError; +use crate::extract::{Body, QueryMap}; +use crate::services::media_store::{self, MediaSource}; +use crate::services::tiktok::error_message; use crate::state::AppState; +use crate::time::Ts; pub fn router() -> Router { Router::new() + .route("/publish", post(publish)) + .route("/pages", get(list_pages)) + .route("/posts", get(list_posts)) + .route("/posts/{id}", delete(delete_post).patch(update_post)) +} + +// ---------------------------------------------------------------------------- +// Authentification par clé API +// ---------------------------------------------------------------------------- + +fn json_error(status: StatusCode, message: &str) -> Response { + (status, Json(json!({ "error": message }))).into_response() +} + +/// Comparaison à temps constant des clés. +fn keys_match(provided: &str, expected: &str) -> bool { + let (a, b) = (provided.as_bytes(), expected.as_bytes()); + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// Extracteur : la requête porte la clé de l'API externe (header `X-API-Key`), +/// comparée à `app_config.external_api_key`, à défaut à `EXTERNAL_API_KEY`. +/// Placé en premier paramètre des routes, il est vérifié avant tout le reste. +pub struct ApiKey; + +impl FromRequestParts for ApiKey { + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let provided = parts + .headers + .get("x-api-key") + .map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned()) + .filter(|v| !v.is_empty()); + + let Some(provided) = provided else { + return Err(json_error( + StatusCode::UNAUTHORIZED, + "Clé API manquante (header X-API-Key requis)", + )); + }; + + let config = match state.db.get_app_config().await { + Ok(config) => config, + Err(error) => { + tracing::error!("[apiKey middleware] Error: {error}"); + return Err(json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "Erreur interne lors de la vérification de la clé API", + )); + } + }; + + let key = config + .and_then(|c| c.external_api_key) + .filter(|k| !k.is_empty()) + .or_else(|| state.config.external_api_key.clone()); + + match key { + None => Err(json_error( + StatusCode::SERVICE_UNAVAILABLE, + "API externe non configurée", + )), + Some(key) if !keys_match(&provided, &key) => { + Err(json_error(StatusCode::UNAUTHORIZED, "Clé API invalide")) + } + Some(_) => Ok(ApiKey), + } + } +} + +// ---------------------------------------------------------------------------- +// Erreurs des routes +// ---------------------------------------------------------------------------- + +/// Échec d'une route : réponse déjà formée, ou erreur imprévue (→ 500). +enum Failure { + Api(AppError), + Other(anyhow::Error), +} + +impl From for Failure { + fn from(error: AppError) -> Self { + Failure::Api(error) + } +} + +impl From for Failure { + fn from(error: anyhow::Error) -> Self { + Failure::Other(error) + } +} + +impl From for Failure { + fn from(error: sqlx::Error) -> Self { + Failure::Other(error.into()) + } +} + +impl Failure { + /// 500 avec le message de la cause (`error.message`). + fn with_cause(self, log: &str) -> Response { + match self { + Failure::Api(error) => error.into_response(), + Failure::Other(error) => { + tracing::error!("{log} {error:#}"); + json_error(StatusCode::INTERNAL_SERVER_ERROR, &error_message(&error)) + } + } + } + + /// 500 avec un message générique. + fn generic(self, log: &str) -> Response { + match self { + Failure::Api(error) => error.into_response(), + Failure::Other(error) => { + tracing::error!("{log} {error:#}"); + json_error(StatusCode::INTERNAL_SERVER_ERROR, "Erreur interne") + } + } + } +} + +type Outcome = Result; + +// ---------------------------------------------------------------------------- +// Validation de POST /publish (schéma zod de l'ancien serveur) +// ---------------------------------------------------------------------------- + +const POST_TYPES: [&str; 3] = ["feed", "story", "both"]; + +/// `z.string().datetime({ offset: true })` (même expression que zod 3.24). +static DATETIME_RE: LazyLock = LazyLock::new(|| { + let date = "(([0-9][0-9][2468][048]|[0-9][0-9][13579][26]|[0-9][0-9]0[48]|[02468][048]00|[13579][26]00)-02-29|[0-9]{4}-((0[13578]|1[02])-(0[1-9]|[12][0-9]|3[01])|(0[469]|11)-(0[1-9]|[12][0-9]|30)|(02)-(0[1-9]|1[0-9]|2[0-8])))"; + let time = "([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?"; + Regex::new(&format!("^{date}T{time}(Z|([+-][0-9]{{2}}:?[0-9]{{2}}))$")) + .expect("expression de date valide") +}); + +#[derive(Debug, Clone, PartialEq)] +struct PublishInput { + content: String, + image_url: Option, + page_ids: Vec, + scheduled_at: Option, + post_type: PostType, + user_id: Option, +} + +/// Type d'une valeur JSON tel que le nomme zod. +fn zod_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn type_issue(expected: &str, value: Option<&Value>) -> String { + match value { + None => "Required".to_string(), + Some(value) => format!("Expected {expected}, received {}", zod_type(value)), + } +} + +/// Valide le corps comme `publishSchema.safeParse` ; en cas d'échec, renvoie +/// `error.flatten().fieldErrors`. +fn validate_publish(body: &Value) -> Result { + let mut errors: Vec<(&'static str, String)> = Vec::new(); + let Some(obj) = body.as_object() else { + // Erreur globale : aucun champ en cause + return Err(Value::Object(Map::new())); + }; + + // content : z.string().min(1) + let content = match obj.get("content") { + Some(Value::String(s)) => { + if s.is_empty() { + errors.push(("content", "Le contenu est requis".into())); + } + Some(s.clone()) + } + other => { + errors.push(("content", type_issue("string", other))); + None + } + }; + + // imageUrl : z.string().url().optional() + let image_url = match obj.get("imageUrl") { + None => None, + Some(Value::String(s)) => { + if url::Url::parse(s).is_err() { + errors.push(("imageUrl", "URL d'image invalide".into())); + } + Some(s.clone()) + } + Some(other) => { + errors.push(("imageUrl", type_issue("string", Some(other)))); + None + } + }; + + // pageIds : z.array(z.string()).min(1) + let page_ids = match obj.get("pageIds") { + Some(Value::Array(items)) => { + if items.is_empty() { + errors.push(("pageIds", "Au moins une page est requise".into())); + } + let mut ids = Vec::new(); + for item in items { + match item { + Value::String(s) => ids.push(s.clone()), + other => errors.push(("pageIds", type_issue("string", Some(other)))), + } + } + ids + } + other => { + errors.push(("pageIds", type_issue("array", other))); + Vec::new() + } + }; + + // scheduledAt : z.string().datetime({ offset: true }).optional() + let scheduled_at = match obj.get("scheduledAt") { + None => None, + Some(Value::String(s)) => { + if !DATETIME_RE.is_match(s) { + errors.push(("scheduledAt", "Invalid datetime".into())); + } + Some(s.clone()) + } + Some(other) => { + errors.push(("scheduledAt", type_issue("string", Some(other)))); + None + } + }; + + // postType : z.enum(["feed", "story", "both"]).default("feed") + let expected_types = "'feed' | 'story' | 'both'"; + let post_type = match obj.get("postType") { + None => PostType::Feed, + Some(Value::String(s)) if POST_TYPES.contains(&s.as_str()) => { + PostType::parse(s).unwrap_or(PostType::Feed) + } + Some(Value::String(s)) => { + errors.push(( + "postType", + format!("Invalid enum value. Expected {expected_types}, received '{s}'"), + )); + PostType::Feed + } + Some(other) => { + errors.push(("postType", type_issue(expected_types, Some(other)))); + PostType::Feed + } + }; + + // userId : z.string().optional() + let user_id = match obj.get("userId") { + None => None, + Some(Value::String(s)) => Some(s.clone()), + Some(other) => { + errors.push(("userId", type_issue("string", Some(other)))); + None + } + }; + + if !errors.is_empty() { + let mut details = Map::new(); + for (field, message) in errors { + details + .entry(field) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("tableau de messages") + .push(Value::String(message)); + } + return Err(Value::Object(details)); + } + + Ok(PublishInput { + content: content.unwrap_or_default(), + image_url, + page_ids, + scheduled_at, + post_type, + user_id, + }) +} + +// ---------------------------------------------------------------------------- +// Téléchargement d'une image distante +// ---------------------------------------------------------------------------- + +/// Taille maximale d'une image téléchargée : 10 Mo. +const MAX_IMAGE_SIZE: usize = 10 * 1024 * 1024; + +/// Délai d'obtention de la réponse (en-têtes). +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10); + +fn image_extension(mime_type: &str) -> Option<&'static str> { + match mime_type { + "image/jpeg" => Some(".jpg"), + "image/png" => Some(".png"), + "image/webp" => Some(".webp"), + "image/gif" => Some(".gif"), + _ => None, + } +} + +/// Type MIME d'un en-tête `Content-Type` (sans paramètres). +fn mime_of(content_type: &str) -> String { + content_type + .split(';') + .next() + .unwrap_or("") + .trim() + .to_string() +} + +struct DownloadedImage { + buffer: Bytes, + ext: &'static str, +} + +/// Contenu d'une URL `data:` (acceptée par `fetch` côté Node). +fn decode_data_url(url: &str) -> Option<(String, Bytes)> { + let rest = url.strip_prefix("data:")?; + let (meta, data) = rest.split_once(',')?; + let (mime, is_base64) = match meta.strip_suffix(";base64") { + Some(mime) => (mime, true), + None => (meta, false), + }; + let bytes: Vec = percent_encoding::percent_decode_str(data).collect(); + let bytes = if is_base64 { + let cleaned: Vec = bytes + .into_iter() + .filter(|b| !b.is_ascii_whitespace()) + .collect(); + base64::engine::general_purpose::STANDARD + .decode(&cleaned) + .ok()? + } else { + bytes + }; + let mime = if mime.is_empty() { + "text/plain;charset=US-ASCII".to_string() + } else { + mime.to_string() + }; + Some((mime, Bytes::from(bytes))) +} + +fn check_image(content_type: &str, buffer: Bytes) -> anyhow::Result { + let mime_type = mime_of(content_type); + let Some(ext) = image_extension(&mime_type) else { + anyhow::bail!( + "Type MIME non supporté: {mime_type}. Formats acceptés: JPEG, PNG, WebP, GIF" + ); + }; + if buffer.len() > MAX_IMAGE_SIZE { + anyhow::bail!( + "Image trop volumineuse ({:.1} MB, max 10 MB)", + buffer.len() as f64 / 1024.0 / 1024.0 + ); + } + Ok(DownloadedImage { buffer, ext }) +} + +async fn download_image(state: &AppState, url: &str) -> anyhow::Result { + let parsed = + url::Url::parse(url).map_err(|_| anyhow::anyhow!("Failed to parse URL from {url}"))?; + + if parsed.scheme() == "data" { + let (mime, buffer) = decode_data_url(url).ok_or_else(|| anyhow::anyhow!("fetch failed"))?; + return check_image(&mime, buffer); + } + + let response = match tokio::time::timeout(DOWNLOAD_TIMEOUT, state.http.get(parsed).send()).await + { + Err(_) => anyhow::bail!("This operation was aborted"), + Ok(Err(error)) => { + tracing::warn!("[external API] Téléchargement impossible de {url}: {error}"); + anyhow::bail!("fetch failed"); + } + Ok(Ok(response)) => response, + }; + + if !response.status().is_success() { + anyhow::bail!( + "Impossible de télécharger l'image (HTTP {})", + response.status().as_u16() + ); + } + + let content_type = response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + // Le type est contrôlé avant de lire le corps, comme l'ancien serveur + if image_extension(&mime_of(&content_type)).is_none() { + return check_image(&content_type, Bytes::new()); + } + let buffer = response.bytes().await.map_err(|error| { + tracing::warn!("[external API] Lecture impossible de {url}: {error}"); + anyhow::anyhow!("fetch failed") + })?; + check_image(&content_type, buffer) +} + +/// Télécharge une image et l'enregistre comme média du propriétaire. +async fn import_image( + state: &AppState, + url: &str, + owner_id: &str, + prefix: &str, +) -> anyhow::Result { + let image = download_image(state, url).await?; + let file_name = format!( + "{prefix}-{}{}", + chrono::Utc::now().timestamp_millis(), + image.ext + ); + let uploaded = + media_store::upload_media(state, MediaSource::Bytes(&image.buffer), &file_name).await?; + let media = state + .db + .create_media(NewMedia { + user_id: owner_id.to_string(), + media_type: Some(MediaType::Image), + cloudinary_public_id: Some(uploaded.public_id), + original_url: uploaded.original_url, + facebook_feed_url: uploaded.facebook_feed_url, + instagram_feed_url: uploaded.instagram_feed_url, + instagram_story_url: uploaded.instagram_story_url, + file_name, + file_size: image.buffer.len() as i32, + ..Default::default() + }) + .await?; + Ok(media) +} + +// ---------------------------------------------------------------------------- +// POST /api/v1/publish +// ---------------------------------------------------------------------------- + +/// Crée une publication (avec image optionnelle téléchargée depuis une URL) +/// et la programme pour une ou plusieurs pages. +async fn publish(_key: ApiKey, State(state): State, Body(body): Body) -> Response { + match publish_inner(&state, &body).await { + Ok(response) => response, + Err(failure) => failure.with_cause("[external API] Error:"), + } +} + +async fn publish_inner(state: &AppState, body: &Value) -> Outcome { + let input = match validate_publish(body) { + Ok(input) => input, + Err(details) => { + return Err(AppError::json( + StatusCode::BAD_REQUEST, + json!({ "error": "Données invalides", "details": details }), + ) + .into()) + } + }; + + // Résoudre l'utilisateur propriétaire + let owner_id = match input.user_id.as_deref().filter(|id| !id.is_empty()) { + None => { + let users = state.db.get_all_users().await?; + match users.into_iter().find(|u| u.is_admin()) { + Some(admin) => admin.id, + None => return Err(AppError::internal("Aucun utilisateur admin trouvé").into()), + } + } + Some(user_id) => { + if state.db.get_user(user_id).await?.is_none() { + return Err( + AppError::bad_request(format!("Utilisateur introuvable: {user_id}")).into(), + ); + } + user_id.to_string() + } + }; + + // Vérifier que toutes les pages existent + let mut resolved_pages: Vec<(String, String)> = Vec::new(); + for page_id in &input.page_ids { + match state.db.get_social_page(page_id).await? { + Some(page) => resolved_pages.push((page.id, page.page_name)), + None => { + return Err(AppError::bad_request(format!("Page introuvable: {page_id}")).into()) + } + } + } + + // Validation story → image obligatoire + let needs_image = matches!(input.post_type, PostType::Story | PostType::Both); + if needs_image && input.image_url.as_deref().is_none_or(str::is_empty) { + return Err( + AppError::bad_request("Les stories nécessitent une image (imageUrl requis)").into(), + ); + } + + // Télécharger l'image si fournie + let media = match input.image_url.as_deref().filter(|u| !u.is_empty()) { + Some(url) => Some(import_image(state, url, &owner_id, "external").await?), + None => None, + }; + + // Créer le post (heure de publication absente = immédiat) + let scheduled_at = match &input.scheduled_at { + Some(value) => Ts::parse(value) + .and_then(|ts| Ts::from_millis(ts.timestamp_millis())) + .ok_or_else(|| anyhow::anyhow!("Invalid time value"))?, + None => Ts::now(), + }; + + let mut new_post = NewPost::new(owner_id.clone(), input.content.clone()); + new_post.status = PostStatus::Scheduled; + new_post.scheduled_for = Some(scheduled_at); + let post = state.db.create_post(new_post).await?; + + // Lier le média si présent + if let Some(media) = &media { + state + .db + .set_post_media(&post.id, &[(media.id.clone(), 0)], false) + .await?; + } + + // Créer les scheduled_posts par page + let mut created = Vec::new(); + for (page_id, page_name) in &resolved_pages { + let types: &[PostType] = if input.post_type == PostType::Both { + &[PostType::Story, PostType::Feed] + } else { + std::slice::from_ref(&input.post_type) + }; + for post_type in types { + let scheduled = state + .db + .create_scheduled_post(NewScheduledPost::new( + post.id.clone(), + page_id.clone(), + *post_type, + scheduled_at, + )) + .await?; + created.push(json!({ + "pageId": page_id, + "pageName": page_name, + "scheduledPostId": scheduled.id, + })); + } + } + + Ok(( + StatusCode::CREATED, + Json(json!({ + "success": true, + "post": { + "id": post.id, + "content": post.content, + "status": post.status, + "scheduledAt": scheduled_at, + "postType": input.post_type, + "pages": created, + "media": media.map(|m| json!({ "id": m.id, "url": m.original_url })), + }, + })), + ) + .into_response()) +} + +// ---------------------------------------------------------------------------- +// GET /api/v1/pages +// ---------------------------------------------------------------------------- + +/// Liste les pages disponibles (id + nom + plateforme), pour connaître les +/// `pageIds` à passer à /publish. +async fn list_pages(_key: ApiKey, State(state): State) -> Response { + let result: Outcome = async { + let users = state.db.get_all_users().await?; + let Some(admin) = users.into_iter().find(|u| u.is_admin()) else { + return Err(AppError::internal("Aucun utilisateur admin trouvé").into()); + }; + let pages = state.db.get_social_pages(&admin.id).await?; + let list: Vec = pages + .iter() + .map(|p| { + json!({ + "id": p.id, + "pageName": p.page_name, + "platform": p.platform, + "pageId": p.page_id, + "tokenStatus": p.token_status, + }) + }) + .collect(); + Ok(Json(list).into_response()) + } + .await; + result.unwrap_or_else(|f| f.generic("[external API] Error listing pages:")) +} + +// ---------------------------------------------------------------------------- +// GET /api/v1/posts +// ---------------------------------------------------------------------------- + +/// Médias d'un post, dans l'ordre d'affichage : `{ id, url, type }`. +async fn post_media_items(state: &AppState, post_id: &str) -> Result, sqlx::Error> { + let links = state.db.get_post_media(post_id).await?; + let ids: Vec = links.iter().map(|l| l.media_id.clone()).collect(); + let by_id: HashMap = state + .db + .get_media_by_ids(&ids) + .await? + .into_iter() + .map(|m| (m.id.clone(), m)) + .collect(); + Ok(links + .iter() + .filter_map(|l| by_id.get(&l.media_id)) + .map(|m| json!({ "id": m.id, "url": m.original_url, "type": m.media_type })) + .collect()) +} + +/// Liste les publications planifiées (à venir). +/// +/// Paramètres : `pageId`, `from`, `to` (ISO 8601), `status` (défaut : scheduled, ou `all`). +async fn list_posts(_key: ApiKey, State(state): State, query: QueryMap) -> Response { + list_posts_inner(&state, &query) + .await + .unwrap_or_else(|f| f.generic("[external API] GET /posts error:")) +} + +async fn list_posts_inner(state: &AppState, query: &QueryMap) -> Outcome { + let post_status = query.non_empty("status").unwrap_or("scheduled"); + + // Toutes les pages, ou une seule si filtrée + let page_ids: Vec = match query.non_empty("pageId") { + Some(page_id) => { + if state.db.get_social_page(page_id).await?.is_none() { + return Err(AppError::not_found(format!("Page introuvable: {page_id}")).into()); + } + vec![page_id.to_string()] + } + None => state + .db + .get_all_social_pages() + .await? + .into_iter() + .map(|p| p.id) + .collect(), + }; + + if page_ids.is_empty() { + return Ok(Json(json!([])).into_response()); + } + + // Une date illisible faisait échouer la requête (`Invalid time value`) + let parse_bound = |key: &str| -> Result, Failure> { + match query.non_empty(key) { + None => Ok(None), + Some(value) => Ts::parse(value) + .map(Some) + .ok_or_else(|| anyhow::anyhow!("Invalid time value").into()), + } + }; + let start = parse_bound("from")?; + let end = parse_bound("to")?; + + let scheduled_posts = state + .db + .get_scheduled_posts_by_pages(&page_ids, start, end) + .await?; + + // Grouper par post, en ne gardant que les posts non publiés au bon statut + let mut order: Vec = Vec::new(); + let mut posts: HashMap = HashMap::new(); + + for sp in scheduled_posts { + if sp.scheduled.published_at.is_some() { + continue; + } + let post = &sp.post; + if post.status.as_str() != post_status && post_status != "all" { + continue; + } + + if !posts.contains_key(&post.id) { + let media = post_media_items(state, &post.id).await?; + order.push(post.id.clone()); + posts.insert( + post.id.clone(), + json!({ + "id": post.id, + "content": post.content, + "status": post.status, + "scheduledFor": post.scheduled_for, + "createdAt": post.created_at, + "media": media, + "schedules": [], + }), + ); + } + + let schedule = json!({ + "id": sp.scheduled.id, + "pageId": sp.scheduled.page_id, + "pageName": sp.page.as_ref().map(|p| p.page_name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Inconnu".into()), + "platform": sp.page.as_ref().map(|p| p.platform.as_str()).unwrap_or("facebook"), + "postType": sp.scheduled.post_type, + "scheduledAt": sp.scheduled.scheduled_at, + }); + if let Some(Value::Array(schedules)) = + posts.get_mut(&post.id).and_then(|p| p.get_mut("schedules")) + { + schedules.push(schedule); + } + } + + let list: Vec = order.iter().filter_map(|id| posts.remove(id)).collect(); + Ok(Json(list).into_response()) +} + +// ---------------------------------------------------------------------------- +// PATCH /api/v1/posts/:id +// ---------------------------------------------------------------------------- + +/// Valeur JSON écrite dans une colonne texte, comme le faisait node-postgres. +fn text_value(value: &Value) -> Val { + match value { + Value::Null => Val::Text(None), + Value::String(s) => Val::Text(Some(s.clone())), + Value::Number(n) => Val::Text(Some(n.to_string())), + Value::Bool(b) => Val::Text(Some(b.to_string())), + other => Val::Text(Some(other.to_string())), + } +} + +/// `new Date(value)` ; `None` pour une date invalide. +fn js_new_date(value: &Value) -> Option { + match value { + Value::Null => Ts::from_millis(0), + Value::Bool(b) => Ts::from_millis(i64::from(*b)), + Value::Number(n) => n.as_f64().and_then(|f| Ts::from_millis(f.trunc() as i64)), + Value::String(s) => Ts::parse(s).and_then(|ts| Ts::from_millis(ts.timestamp_millis())), + _ => None, + } +} + +/// Texte d'une valeur passée à `fetch()` (`String(value)`). +fn js_url_text(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Null => "null".into(), + Value::Object(_) => "[object Object]".into(), + Value::Array(items) => items + .iter() + .map(|v| match v { + Value::Null => String::new(), + Value::String(s) => s.clone(), + other => js_url_text(other), + }) + .collect::>() + .join(","), + other => other.to_string(), + } +} + +/// Planifications non publiées d'un post, avec la page. +async fn pending_schedules(state: &AppState, post_id: &str) -> Result, sqlx::Error> { + let mut schedules = Vec::new(); + for sp in state.db.get_scheduled_posts_by_post(post_id).await? { + if sp.published_at.is_some() { + continue; + } + let page = state.db.get_social_page(&sp.page_id).await?; + schedules.push(json!({ + "id": sp.id, + "pageId": sp.page_id, + "pageName": page.as_ref().map(|p| p.page_name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Inconnu".into()), + "platform": page.as_ref().map(|p| p.platform.as_str()).unwrap_or("facebook"), + "postType": sp.post_type, + "scheduledAt": sp.scheduled_at, + })); + } + Ok(schedules) +} + +/// Modifie une publication planifiée : `content`, `scheduledAt`, `imageUrl`. +async fn update_post( + _key: ApiKey, + State(state): State, + Path(id): Path, + Body(body): Body, +) -> Response { + update_post_inner(&state, &id, &body) + .await + .unwrap_or_else(|f| f.with_cause("[external API] PATCH /posts/:id error:")) +} + +async fn update_post_inner(state: &AppState, id: &str, body: &Value) -> Outcome { + // Vérifier que le post existe et est modifiable + let Some(post) = state.db.get_post(id).await? else { + return Err(AppError::not_found("Post introuvable").into()); + }; + if post.status == PostStatus::Published { + return Err(AppError::bad_request("Impossible de modifier un post déjà publié").into()); + } + + // Mettre à jour le contenu si fourni + if let Some(content) = body.get("content") { + state + .db + .update_post(id, Patch::new().set("content", text_value(content))) + .await?; + } + + // Mettre à jour la date de planification si fournie + if let Some(value) = body.get("scheduledAt") { + let new_date = js_new_date(value).ok_or_else(|| anyhow::anyhow!("Invalid time value"))?; + state + .db + .update_post(id, Patch::new().set("scheduled_for", new_date)) + .await?; + + // Propager à tous les scheduled_posts du post + for sp in state.db.get_scheduled_posts_by_post(id).await? { + if sp.published_at.is_none() { + state + .db + .update_scheduled_post(&sp.id, Patch::new().set("scheduled_at", new_date)) + .await?; + } + } + } + + // Remplacer le média si une nouvelle imageUrl est fournie + if let Some(image_url) = body.get("imageUrl") { + let media = import_image( + state, + &js_url_text(image_url), + &post.user_id, + "external-edit", + ) + .await?; + state.db.update_post_media(id, &[media.id]).await?; + } + + // Retourner le post mis à jour + let updated = state + .db + .get_post(id) + .await? + .ok_or_else(|| anyhow::anyhow!("Cannot read properties of undefined (reading 'id')"))?; + + let mut media_items = Vec::new(); + for link in state.db.get_post_media(id).await? { + if let Some(item) = state.db.get_media_by_id(&link.media_id).await? { + media_items + .push(json!({ "id": item.id, "url": item.original_url, "type": item.media_type })); + } + } + + let schedules = pending_schedules(state, id).await?; + + Ok(Json(json!({ + "success": true, + "post": { + "id": updated.id, + "content": updated.content, + "status": updated.status, + "scheduledFor": updated.scheduled_for, + "createdAt": updated.created_at, + "media": media_items, + "schedules": schedules, + }, + })) + .into_response()) +} + +// ---------------------------------------------------------------------------- +// DELETE /api/v1/posts/:id +// ---------------------------------------------------------------------------- + +/// Supprime un post planifié ; la cascade supprime ses planifications et liens médias. +async fn delete_post( + _key: ApiKey, + State(state): State, + Path(id): Path, +) -> Response { + let result: Outcome = async { + let Some(post) = state.db.get_post(&id).await? else { + return Err(AppError::not_found("Post introuvable").into()); + }; + if post.status == PostStatus::Published { + return Err( + AppError::bad_request("Impossible de supprimer un post déjà publié").into(), + ); + } + state.db.delete_post(&id).await?; + Ok(Json(json!({ "success": true, "deleted": id })).into_response()) + } + .await; + result.unwrap_or_else(|f| f.generic("[external API] DELETE /posts/:id error:")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn publish_validation_success_and_defaults() { + let input = validate_publish(&json!({ + "content": "Promo", + "pageIds": ["p1"], + "scheduledAt": "2026-05-01T10:30:00+02:00", + "extra": true, + })) + .unwrap(); + assert_eq!(input.post_type, PostType::Feed); + assert_eq!(input.page_ids, vec!["p1".to_string()]); + assert_eq!( + input.scheduled_at.as_deref(), + Some("2026-05-01T10:30:00+02:00") + ); + } + + #[test] + fn publish_validation_field_errors() { + let details = validate_publish(&json!({})).unwrap_err(); + assert_eq!( + details, + json!({ "content": ["Required"], "pageIds": ["Required"] }) + ); + + let details = validate_publish(&json!({ + "content": "", + "imageUrl": "pas une url", + "pageIds": [], + "scheduledAt": "2026-05-01 10:30", + "postType": "reel", + "userId": 4, + })) + .unwrap_err(); + assert_eq!( + details, + json!({ + "content": ["Le contenu est requis"], + "imageUrl": ["URL d'image invalide"], + "pageIds": ["Au moins une page est requise"], + "scheduledAt": ["Invalid datetime"], + "postType": ["Invalid enum value. Expected 'feed' | 'story' | 'both', received 'reel'"], + "userId": ["Expected string, received number"], + }) + ); + + let details = validate_publish(&json!({ + "content": 3, "pageIds": ["a", 1, null], "postType": 2, "imageUrl": null + })) + .unwrap_err(); + assert_eq!( + details, + json!({ + "content": ["Expected string, received number"], + "imageUrl": ["Expected string, received null"], + "pageIds": ["Expected string, received number", "Expected string, received null"], + "postType": ["Expected 'feed' | 'story' | 'both', received number"], + }) + ); + + assert_eq!(validate_publish(&json!([])).unwrap_err(), json!({})); + } + + #[test] + fn datetime_regex_matches_zod() { + for ok in [ + "2026-05-01T10:30:00Z", + "2026-05-01T10:30:00.123Z", + "2026-05-01T10:30:00+02:00", + "2026-05-01T10:30:00+0200", + "2024-02-29T00:00:00Z", + ] { + assert!(DATETIME_RE.is_match(ok), "{ok}"); + } + for bad in [ + "2026-05-01T10:30Z", + "2026-05-01T10:30:00", + "2023-02-29T00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-05-01 10:30:00Z", + "2026-05-01T10:30:00Z", + ] { + assert!(!DATETIME_RE.is_match(bad), "{bad}"); + } + } + + #[test] + fn image_checks() { + assert_eq!(mime_of("image/png; charset=binary"), "image/png"); + assert!(check_image("image/png", Bytes::from_static(b"x")).is_ok()); + assert_eq!( + check_image("text/html; charset=utf-8", Bytes::new()) + .err() + .unwrap() + .to_string(), + "Type MIME non supporté: text/html. Formats acceptés: JPEG, PNG, WebP, GIF" + ); + let big = Bytes::from(vec![0u8; MAX_IMAGE_SIZE + 1]); + assert_eq!( + check_image("image/jpeg", big).err().unwrap().to_string(), + "Image trop volumineuse (10.0 MB, max 10 MB)" + ); + let (mime, data) = decode_data_url("data:image/png;base64,aGVsbG8=").unwrap(); + assert_eq!(mime, "image/png"); + assert_eq!(&data[..], b"hello"); + } + + #[test] + fn js_conversions() { + assert_eq!( + js_new_date(&json!(null)).unwrap().to_iso(), + "1970-01-01T00:00:00.000Z" + ); + assert_eq!( + js_new_date(&json!("2026-05-01T10:30:00.123456+02:00")) + .unwrap() + .to_iso(), + "2026-05-01T08:30:00.123Z" + ); + assert!(js_new_date(&json!("demain")).is_none()); + assert!(js_new_date(&json!({})).is_none()); + assert_eq!(js_url_text(&json!(null)), "null"); + assert_eq!(js_url_text(&json!(12)), "12"); + assert!(keys_match("abc", "abc")); + assert!(!keys_match("abc", "abd")); + assert!(!keys_match("abc", "abcd")); + } } diff --git a/server/src/routes/legal.rs b/server/src/routes/legal.rs index 84d5731..a304356 100644 --- a/server/src/routes/legal.rs +++ b/server/src/routes/legal.rs @@ -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 { 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 = + 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###" + + + + + %TITLE% + + + +%BODY% + +"###; + +const FOOTER_TEMPLATE: &str = r###""###; + +const TERMS_TEMPLATE: &str = r###" +

Conditions d'utilisation

+

Dernière mise à jour : 11 août 2026

+ +

+ 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. +

+ +

1. Accès au service

+

+ 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. +

+ +

2. Comptes de réseaux sociaux connectés

+

+ 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. +

+ +

3. Contenus publiés

+

+ 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. +

+ +

4. Disponibilité

+

+ 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. +

+ +

5. Responsabilité

+

+ 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. +

+ +

6. Modification des conditions

+

+ Ces conditions peuvent être modifiées. La date de dernière mise à jour figure en tête de page. +

+ +

7. Contact

+

Pour toute question relative à ces conditions : %EMAIL%

+ + %FOOTER%"###; + +const PRIVACY_TEMPLATE: &str = r###" +

Politique de confidentialité

+

Dernière mise à jour : 11 août 2026

+ +

+ 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%. +

+ +

1. Données de nos utilisateurs internes

+

+ 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. +

+ +

2. Données issues des comptes de réseaux sociaux connectés

+

+ 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 user.info.basic : +

+
    +
  • l'identifiant technique du compte (open_id) ;
  • +
  • le nom d'affichage du compte ;
  • +
  • l'image de profil du compte ;
  • +
  • un jeton d'accès et un jeton de renouvellement, qui nous permettent de publier en son nom.
  • +
+

+ 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. +

+ +

3. Finalités

+

+ 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é. +

+ +

4. Données que nous transmettons aux plateformes

+

+ 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. +

+ +

5. Conservation et sécurité

+
    +
  • Les jetons d'accès et de renouvellement sont chiffrés au repos (AES-256-GCM).
  • +
  • + Ils sont conservés tant que le compte reste connecté à l'application, et supprimés + immédiatement lorsque le compte en est retiré. +
  • +
  • Les médias sources sont supprimés automatiquement après leur publication et leur péremption.
  • +
  • L'accès à l'application est restreint aux personnes autorisées et protégé par mot de passe.
  • +
+ +

6. Partage avec des tiers

+

+ 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. +

+ +

7. Retirer une autorisation

+

+ Le titulaire d'un compte connecté peut retirer son autorisation à tout moment : +

+
    +
  • en demandant la déconnexion du compte depuis l'application, ce qui supprime les jetons ;
  • +
  • + ou directement dans TikTok, via Profil › Paramètres et confidentialité › + Sécurité et autorisations › Gérer les autorisations des applications. +
  • +
+

+ 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. +

+ +

8. Vos droits

+

+ 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%. +

+ +

9. Modification de cette politique

+

+ Cette politique peut être mise à jour. La date de dernière mise à jour figure en tête de page. +

+ + %FOOTER%"###; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escaping_and_single_pass_fill() { + assert_eq!( + escape_html(r#"A & "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("\n")); + assert!(page.contains("Conditions d'utilisation")); + assert!(page.contains("enseigne LA FOIR'FOUILLE
")); + assert!(page.ends_with("\n\n")); + assert!(!page.contains('%')); + let page = render("Politique de confidentialité", PRIVACY_TEMPLATE); + assert!(page.contains("

Politique de confidentialité

")); + assert!(!page.contains("%NAME%")); + } } diff --git a/server/src/routes/tiktok.rs b/server/src/routes/tiktok.rs index 9da1d31..3553d16 100644 --- a/server/src/routes/tiktok.rs +++ b/server/src/routes/tiktok.rs @@ -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 { 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, + headers: HeaderMap, + user: AuthUser, +) -> ApiResult> { + 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 { + 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> { + 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, + headers: HeaderMap, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + 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, 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, + 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 { + 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, + user: AuthUser, + Path(id): Path, +) -> ApiResult> { + 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()) + ); + } } diff --git a/server/src/services/cron.rs b/server/src/services/cron.rs index e891369..24ac516 100644 --- a/server/src/services/cron.rs +++ b/server/src/services/cron.rs @@ -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 { + let mut mask = 0u64; + for item in field.split(',') { + let (range, step) = match item.split_once('/') { + Some((range, step)) => (range, step.parse::().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 { + 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) -> bool { + self.matches(time.minute(), time.hour()) + } +} + +/// Début de la minute suivant `now`. +fn next_minute(now: DateTime) -> DateTime { + 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 { + 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); + +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(running: &Arc, name: &'static str, task: F) +where + F: Future + 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 + Send>>; + +struct Job { + name: &'static str, + spec: CronSpec, + run: JobFn, + running: Arc, +} + +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)); + } +} diff --git a/server/src/services/media_purge.rs b/server/src/services/media_purge.rs index 71714ec..2d14cb5 100644 --- a/server/src/services/media_purge.rs +++ b/server/src/services/media_purge.rs @@ -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, 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 = + 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(); + } +} diff --git a/server/src/services/scheduler.rs b/server/src/services/scheduler.rs index 4b5829e..5fd8470 100644 --- a/server/src/services/scheduler.rs +++ b/server/src/services/scheduler.rs @@ -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 = 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 { + 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 +} diff --git a/server/src/services/tiktok/mod.rs b/server/src/services/tiktok/mod.rs index a395933..1be9995 100644 --- a/server/src/services/tiktok/mod.rs +++ b/server/src/services/tiktok/mod.rs @@ -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::() { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, Option) { + 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, + pub display_name: String, + pub avatar_url: Option, +} + +pub async fn get_user_info(state: &AppState, access_token: &str) -> anyhow::Result { + 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 { + 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 { + 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 { - 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 { + 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 { + 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 { + let (status, ok, raw) = read_response(response).await?; + parse_api_payload(status, ok, &raw, context) +} diff --git a/server/src/services/tiktok/payload.rs b/server/src/services/tiktok/payload.rs new file mode 100644 index 0000000..39a3ce4 --- /dev/null +++ b/server/src/services/tiktok/payload.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub creator_username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub creator_nickname: Option, + pub privacy_level_options: Vec, + 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, +} + +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, + 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, + pub fail_reason: Option, + pub post_id: Option, +} + +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, + pub open_id: Option, + pub refresh_token: Option, + pub refresh_expires_in: Option, + pub scope: Option, +} + +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 { + 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::>() + .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 { + 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 { + 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 { + 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 { + 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 { + 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, "", "ctx") + .unwrap_err() + .to_string(), + "Réponse TikTok illisible lors de la ctx (502): " + ); + 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()); + } +}