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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
2026-10-12 17:37:13 +00:00

1080 lines
36 KiB
Rust

//! API externe (`/api/v1`) utilisée par des outils tiers (n8n…), protégée par
//! la clé du header `X-API-Key`.
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<AppState> {
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<AppState> for ApiKey {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Response> {
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<AppError> for Failure {
fn from(error: AppError) -> Self {
Failure::Api(error)
}
}
impl From<anyhow::Error> for Failure {
fn from(error: anyhow::Error) -> Self {
Failure::Other(error)
}
}
impl From<sqlx::Error> 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<Response, Failure>;
// ----------------------------------------------------------------------------
// 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<Regex> = 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<String>,
page_ids: Vec<String>,
scheduled_at: Option<String>,
post_type: PostType,
user_id: Option<String>,
}
/// 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<PublishInput, Value> {
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<u8> = percent_encoding::percent_decode_str(data).collect();
let bytes = if is_base64 {
let cleaned: Vec<u8> = 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<DownloadedImage> {
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<DownloadedImage> {
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<crate::db::Media> {
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<AppState>, Body(body): Body<Value>) -> 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<AppState>) -> 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<Value> = 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<Vec<Value>, sqlx::Error> {
let links = state.db.get_post_media(post_id).await?;
let ids: Vec<String> = links.iter().map(|l| l.media_id.clone()).collect();
let by_id: HashMap<String, crate::db::Media> = 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<AppState>, 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<String> = 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<Option<Ts>, 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<String> = Vec::new();
let mut posts: HashMap<String, Value> = 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<Value> = 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<Ts> {
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::<Vec<_>>()
.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<Vec<Value>, 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<AppState>,
Path(id): Path<String>,
Body(body): Body<Value>,
) -> 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<AppState>,
Path(id): Path<String>,
) -> 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"));
}
}