Merge pull request #127 from R0m1k3/claude/intelligent-wright-pr07e9

Serveur réécrit en Rust (axum + sqlx)
This commit is contained in:
LogiFlow authored and GitHub committed 2026-10-12 20:09:37 +02:00
commit 7f21e23a91
136 files changed
+28672 -21276

No files matched your search

+6
View File
@@ -1,5 +1,6 @@
# Dépendances
node_modules
**/node_modules
npm-debug.log
# Fichiers de build
@@ -38,3 +39,8 @@ logs
# Uploads (seront montés comme volume)
uploads/*
# Compilation Rust
server/target
**/target
.claude
+20 -4
View File
@@ -6,8 +6,8 @@ on:
pull_request:
jobs:
app:
name: Application (TypeScript)
frontend:
name: Frontend (TypeScript)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -15,13 +15,29 @@ jobs:
with:
node-version: 20
cache: npm
# Les dépendances natives (canvas, sharp) et le navigateur Remotion ne
# servent pas aux vérifications : on saute leurs scripts d'installation.
- run: npm ci --ignore-scripts
- run: npm run check
- run: npm test
- run: npm run build
server:
name: Serveur (Rust)
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: server
- run: cargo fmt --check
- run: cargo clippy --locked --all-targets -- -D warnings
- run: cargo test --locked
ffmpeg-service:
name: Service vidéo (Python)
runs-on: ubuntu-latest
+4 -1
View File
@@ -32,4 +32,7 @@ npm-debug.log*
*~
# OS
Thumbs.db
Thumbs.db
# Rust
server/target
.claude/worktrees
+30 -14
View File
@@ -4,7 +4,8 @@
L'application utilise Docker Compose pour orchestrer :
- **PostgreSQL 16** : Base de données sur réseau interne
- **Social Flow App** : Application Node.js exposée sur le port **4523**
- **Social Flow App** : serveur Rust (API + frontend compilé), port interne **5555**
- **ffmpeg-api** / **remotion-renderer** : services de traitement vidéo des Reels
## 🚀 Démarrage rapide
@@ -203,7 +204,8 @@ La stack se construit depuis les sources : `app` et `ffmpeg-api` ont une section
## 📊 Healthchecks
- **PostgreSQL** : Vérifie que la base est prête avant de démarrer l'app
- **Migrations** : Exécutées automatiquement au démarrage (`drizzle-kit push --force`)
- **Migrations** : exécutées automatiquement par le serveur à chaque démarrage (idempotentes)
- **Application** : `GET /healthz` répond `ok` quand le serveur et la base sont joignables (healthcheck de l'image)
## 🐛 Dépannage
@@ -232,23 +234,37 @@ Vérifiez que `DATABASE_URL` dans `.env` correspond aux variables `PGUSER`, `PGP
## 📦 Volumes Docker
- `postgres_data` : Données persistantes de PostgreSQL
- Mappages locaux :
- `./attached_assets` → `/app/attached_assets` (médias uploadés)
- `./migrations` → `/app/migrations` (migrations DB)
- `postgres_data` : données persistantes de PostgreSQL
- `encryption_key` : clé de chiffrement des jetons (`/app/.encryption-key-data`)
- `audio_uploads`, `media_uploads`, `logos_uploads`, `stories_uploads` : fichiers téléversés (`/app/uploads/...`)
## 🔄 Mise à jour de l'application
L'image `socialflow-app` est construite à partir des sources
(`pull_policy: build`) : le prochain démarrage de la stack après un `git pull`
applique la nouvelle version.
```bash
# 1. Pull les dernières modifications
# 1. Récupérer les dernières modifications
git pull
# 2. Reconstruire l'image
docker-compose build
# 2. Reconstruire et redémarrer (Portainer : « Update the stack »)
docker compose up -d --build
# 3. Redémarrer
docker-compose up -d
# 4. Vérifier les logs
docker-compose logs -f app
# 3. Vérifier les logs
docker compose logs -f app
```
Au démarrage, le serveur complète le schéma de la base (tables, colonnes,
index) : aucune commande de migration n'est nécessaire. Les données, les
fichiers téléversés, la clé de chiffrement et les sessions ouvertes sont
conservés.
### Passage au serveur Rust
Depuis la version 2, le serveur est écrit en Rust (dossier `server/`) et
remplace l'ancien serveur Node/Express. La bascule ne demande rien de plus
qu'une reconstruction : mêmes variables d'environnement, même base, mêmes
volumes, et les utilisateurs déjà connectés le restent (cookie `connect.sid`
et table `session` repris tels quels). Le rendu Remotion des Reels tourne
désormais dans son propre conteneur (`remotion-renderer`).
+60 -76
View File
@@ -1,84 +1,68 @@
# Debian slim — glibc requis par le Chrome headless shell de Remotion (posix_fallocate64, etc.)
FROM node:20-slim
# SocialFlow — image de l'application : serveur Rust + frontend React compilé.
#
# Trois étapes : le frontend est compilé par Vite, le serveur par Cargo, et
# l'image finale ne contient que le binaire, les fichiers statiques et FFmpeg
# (vignettes vidéo, durée des pistes audio). Pas de Node à l'exécution.
# ---------------------------------------------------------------------------
# 1. Frontend (Vite)
# ---------------------------------------------------------------------------
FROM node:20-slim AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
# Aucune dépendance native n'est utile au build du frontend
RUN npm ci --ignore-scripts --no-audit --no-fund
COPY client ./client
COPY shared ./shared
COPY attached_assets ./attached_assets
COPY vite.config.ts tsconfig.json tailwind.config.ts postcss.config.js components.json ./
RUN npx vite build
# ---------------------------------------------------------------------------
# 2. Serveur (Rust)
# ---------------------------------------------------------------------------
FROM rust:1-slim-bookworm AS backend
WORKDIR /app/server
# Dépendances compilées dans une couche à part : elles ne sont recompilées que
# si Cargo.toml / Cargo.lock changent.
COPY server/Cargo.toml server/Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs \
&& cargo build --release --locked \
&& rm -rf src target/release/socialflow target/release/deps/socialflow-* target/release/.fingerprint/socialflow-*
COPY server/src ./src
RUN cargo build --release --locked
# ---------------------------------------------------------------------------
# 3. Image d'exécution
# ---------------------------------------------------------------------------
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
ffmpeg \
fonts-dejavu-core \
fontconfig \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Augmenter la mémoire AVANT npm install + build (évite OOM sur les grosses dépendances)
ENV NODE_OPTIONS="--max-old-space-size=4096"
COPY --from=backend /app/server/target/release/socialflow /usr/local/bin/socialflow
COPY --from=frontend /app/dist/public ./dist/public
COPY public-root ./public-root
COPY server/fonts ./server/fonts
# Dépendances système : build tools, canvas/sharp, fonts, ffmpeg + dépendances Chrome
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
libcairo2-dev libjpeg-dev libpango1.0-dev libgif-dev libpixman-1-dev \
libvips-dev pkg-config \
fonts-dejavu-core fontconfig \
curl tar bzip2 \
ffmpeg \
libcap2-bin \
ca-certificates \
# Dépendances Chrome headless
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxshmfence1 \
libxtst6 \
&& rm -rf /var/lib/apt/lists/*
# Copier les fichiers de configuration
COPY package*.json ./
# Installer les dépendances
RUN npm install --legacy-peer-deps --no-audit --no-fund
# Copier tout le code source
COPY . .
# Télécharger et installer la police DejaVu Sans
RUN mkdir -p server/fonts && \
cd server/fonts && \
curl -L -f --retry 3 --retry-delay 2 -o dejavu.tar.bz2 "https://sourceforge.net/projects/dejavu/files/dejavu/2.37/dejavu-fonts-ttf-2.37.tar.bz2/download" && \
tar -xjf dejavu.tar.bz2 && \
cp dejavu-fonts-ttf-2.37/ttf/DejaVuSans.ttf . && \
rm -rf dejavu.tar.bz2 dejavu-fonts-ttf-2.37 && \
echo "✓ Police DejaVu Sans installée"
# Build du frontend et backend
RUN npm run build
# Nettoyer les fichiers inutiles
RUN rm -rf client/node_modules \
client/.vite \
node_modules/.cache \
/tmp/remotion-*
# Pré-bundler la composition Remotion
RUN node scripts/prebundle-remotion.mjs || true
# Télécharger le Chrome headless shell de Remotion (binaire glibc natif sur Debian)
RUN npx remotion browser ensure && echo "✓ Remotion browser prêt"
# Exposer le port
EXPOSE 5555
RUN mkdir -p uploads/media uploads/logos uploads/stories uploads/audio uploads/temp
ENV NODE_ENV=production
ENV PORT=5555
EXPOSE 5555
CMD ["npm", "run", "start"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS "http://localhost:${PORT}/healthz" > /dev/null || exit 1
CMD ["socialflow"]
+45 -34
View File
@@ -100,9 +100,9 @@ Architecture Production-Ready avec sécurité renforcée.
| Fonctionnalité | Détail |
| ------------------------------- | ---------------------------------------------------- |
| **Authentification** | Passport.js + bcrypt, sessions PostgreSQL |
| **Rate Limiting** | 100 req/15min global, 5 tentatives login/15min |
| **Headers de sécurité** | Helmet (CSP, HSTS, XSS protection) |
| **Authentification** | bcrypt, sessions PostgreSQL (cookie signé) |
| **Rate Limiting** | 1000 req/15min global, 30 tentatives login/15min |
| **Headers de sécurité** | CSP, HSTS, X-Frame-Options, nosniff… |
| **Chiffrement des tokens** | Tokens Facebook/Instagram chiffrés (AES-256) |
| **Gestion multi-utilisateurs** | Rôles Admin / User, permissions par page |
| **Console SQL** | Requêtes directes en base (admin uniquement) |
@@ -117,43 +117,53 @@ Architecture Production-Ready avec sécurité renforcée.
socialflow/
├── client/ # Frontend React (Vite + TailwindCSS)
│ └── src/
│ ├── pages/ # 17 pages desktop + 13 pages mobile
│ ├── components/ # 47 composants UI (Radix/shadcn)
│ └── hooks/ # Hooks React personnalisés
├── server/ # Backend Express.js (TypeScript)
│ ├── routes.ts # 45+ endpoints REST API
│ ├── routes/ # Routes modulaires (analytics, reels)
│ ├── services/ # 12 services métier
│ │ ├── analytics.ts # Sync Facebook Graph API
│ │ ├── facebook.ts # Publication Facebook/Instagram
│ │ ├── openrouter.ts # Génération texte IA
│ │ ├── cloudinary.ts # Upload cloud médias
│ │ ├── ffmpeg.ts # Encodage vidéo (microservice)
│ │ ├── freesound.ts # Musique libre de droits
│ │ ├── jamendo.ts # Musique libre de droits
│ │ ├── imageProcessor.ts # Traitement images (Sharp)
│ │ ├── scheduler.ts # Publications programmées
│ │ └── token_manager.ts # Gestion tokens OAuth
│ └── cron.ts # Tâches planifiées
├── shared/ # Schéma DB partagé (Drizzle ORM)
│ ├── pages/ # Pages desktop + mobile
│ ├── components/ # Composants UI (Radix/shadcn)
│ └── remotion/ # Compositions vidéo des Reels (aperçu + rendu)
├── server/ # Backend Rust (axum + sqlx)
│ ├── Cargo.toml
│ └── src/
│ ├── main.rs # Démarrage : migrations, routes, tâches de fond
│ ├── migrate.rs # Schéma PostgreSQL, mis à jour au démarrage
│ ├── auth.rs # Sessions (cookie connect.sid) et rôles
│ ├── db/ # Accès aux données
│ ├── routes/ # API REST (/api, /api/v1 externe, pages légales)
│ └── services/ # Facebook, TikTok, OpenRouter, Reels, scheduler, crons
├── shared/ # Types et logique partagés par le frontend
├── remotion-service/ # Rendu des Reels avec Remotion (Node + Chrome)
├── ffmpeg-service/ # Microservice FFmpeg (Python/FastAPI)
├── docker-compose.yml # Stack Docker (3 services)
└── Dockerfile # Image production Node.js
├── qwen-tts/ # Voix Qwen3-TTS (optionnel)
├── docker-compose.yml # Stack Docker
└── Dockerfile # Image de l'application (Rust + frontend compilé)
```
### Stack Technique
| Couche | Technologies |
| ---------- | --------------------------------------------------------------------------- |
| **Frontend** | React 18, Vite, TailwindCSS, Radix UI, Recharts, Framer Motion, Wouter |
| **Backend** | Express.js, TypeScript, Passport.js, Helmet, node-cron |
| **BDD** | PostgreSQL 16, Drizzle ORM, connect-pg-simple (sessions) |
| **Frontend** | React 18, Vite, TailwindCSS, Radix UI, Recharts, Wouter |
| **Backend** | Rust : axum, tokio, sqlx, reqwest |
| **BDD** | PostgreSQL 16 (schéma créé et migré automatiquement au démarrage) |
| **IA** | OpenRouter (multi-modèle : GPT-4, Claude, Gemini, etc.) |
| **Médias** | Cloudinary (upload), Sharp (images), FFmpeg microservice (vidéo) |
| **Musique** | FreeSound API, Jamendo API |
| **Réseaux** | Facebook Graph API v19 (Pages, Instagram, Insights) |
| **Médias** | Stockage local, FFmpeg (vignettes, microservice vidéo), Remotion (Reels) |
| **Réseaux** | Facebook Graph API (Pages, Instagram, Insights), TikTok Content Posting |
| **Infra** | Docker Compose, Nginx reverse proxy |
### Mise à jour
L'image de l'application est construite à partir des sources (`pull_policy: build`).
Après un `git pull`, le prochain démarrage de la stack reconstruit l'image et
applique la nouvelle version :
```bash
git pull
docker compose up -d --build
```
Au démarrage, le serveur met le schéma de la base à jour tout seul (aucune
commande de migration à lancer). Les données, les médias, la clé de chiffrement
et les sessions ouvertes sont conservés.
---
## 🚀 Installation Rapide
@@ -161,7 +171,7 @@ socialflow/
### Prérequis
- [Docker](https://www.docker.com/) & Docker Compose
- [Node.js](https://nodejs.org/) v18+ (pour le développement local)
- Pour le développement local : [Rust](https://rustup.rs/) (stable), [Node.js](https://nodejs.org/) 20+ et PostgreSQL
### 1. Récupérer l'application
@@ -199,11 +209,12 @@ FACEBOOK_APP_SECRET=...
```bash
# Production (Docker)
docker-compose up -d
docker compose up -d --build
# Développement local
# Développement local (deux terminaux)
npm install
npm run dev
DATABASE_URL=postgresql://... npm run dev:server # API Rust sur le port 5000
npm run dev # Frontend Vite, relaie /api vers le port 5000
```
🚀 Rendez-vous sur **`http://localhost:5555`** pour commencer !
+23 -3
View File
@@ -55,8 +55,8 @@ services:
# Rendu des Reels vidéo : remotion (sous-titres animés, identique à
# l'aperçu) ou ffmpeg (plus rapide, sous-titres classiques)
REEL_RENDERER: ${REEL_RENDERER:-remotion}
# Onglets Chromium en parallèle pour Remotion (défaut : moitié des cœurs)
RENDER_CONCURRENCY: ${RENDER_CONCURRENCY:-}
# Service de rendu Remotion (conteneur Node dédié, même clé que FFmpeg)
REMOTION_RENDERER_URL: http://remotion-renderer:8100
networks:
- internal
- nginx_default
@@ -69,7 +69,27 @@ services:
- media_uploads:/app/uploads/media
- logos_uploads:/app/uploads/logos
- stories_uploads:/app/uploads/stories
command: npm run start
# Rendu Remotion des Reels (sous-titres animés, logo, effet de fin)
remotion-renderer:
build:
context: .
dockerfile: remotion-service/Dockerfile
image: socialflow-remotion-renderer:latest
pull_policy: build
container_name: socialflow-remotion
restart: unless-stopped
environment:
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
PORT: 8100
# Onglets Chromium par rendu (défaut : moitié des cœurs)
RENDER_CONCURRENCY: ${RENDER_CONCURRENCY:-}
MAX_PARALLEL_RENDERS: 1
shm_size: 1gb
networks:
- internal
expose:
- "8100"
# Service FFmpeg dédié
ffmpeg-api:
-14
View File
@@ -1,14 +0,0 @@
import { defineConfig } from "drizzle-kit";
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL, ensure the database is provisioned");
}
export default defineConfig({
out: "./migrations",
schema: "./shared/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
},
});
-57
View File
@@ -1,57 +0,0 @@
-- Script d'initialisation de la base de données Social Flow
-- Ce fichier contient les commandes SQL pour créer les tables et l'utilisateur admin par défaut
-- IMPORTANT: Pour les serveurs privés existants, ajouter la valeur 'both' à l'enum post_type
-- Commande à exécuter manuellement sur votre serveur privé :
-- ALTER TYPE post_type ADD VALUE IF NOT EXISTS 'both';
-- Créer l'enum pour les rôles utilisateur (si pas déjà créé par drizzle-kit push)
DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('admin', 'user');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Créer l'utilisateur admin par défaut (username: admin, password: admin)
-- Note: Le hash bcrypt pour "admin" est généré avec un salt de 10
INSERT INTO users (id, username, password, role)
VALUES (
'admin-user-id',
'admin',
'$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G',
'admin'
) ON CONFLICT (username) DO NOTHING;
-- Créer un utilisateur demo pour les tests
INSERT INTO users (id, username, password, role)
VALUES (
'demo-user',
'demo',
'$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G',
'user'
) ON CONFLICT (username) DO NOTHING;
-- Créer la table user_page_permissions (permissions d'accès aux pages par utilisateur)
CREATE TABLE IF NOT EXISTS user_page_permissions (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
page_id VARCHAR NOT NULL REFERENCES social_pages(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, page_id)
);
-- Index pour optimiser les requêtes de permissions
CREATE INDEX IF NOT EXISTS idx_user_page_permissions_user_id ON user_page_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_page_permissions_page_id ON user_page_permissions(page_id);
-- Liste des tables créées automatiquement par Drizzle ORM :
-- - users (utilisateurs avec rôles)
-- - social_pages (pages Facebook/Instagram connectées)
-- - cloudinary_config (configuration Cloudinary par utilisateur)
-- - openrouter_config (configuration OpenRouter par utilisateur)
-- - media (fichiers médias stockés dans Cloudinary)
-- - posts (publications créées)
-- - scheduled_posts (publications programmées)
-- - ai_generations (historique des générations IA)
-- - post_media (relation many-to-many entre posts et media)
-- - user_page_permissions (permissions d'accès aux pages par utilisateur)
-233
View File
@@ -1,233 +0,0 @@
-- ============================================================
-- MIGRATION SQL POUR SERVEUR DOCKER EXISTANT
-- Script de mise à jour pour Social Flow
-- ============================================================
-- Ce fichier contient toutes les commandes SQL pour mettre à jour
-- une base de données existante vers la dernière version du schéma
-- ============================================================
-- ÉTAPE 1: Créer les ENUMs s'ils n'existent pas déjà
-- ============================================================
DO $$ BEGIN
CREATE TYPE platform AS ENUM ('facebook', 'instagram');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE post_type AS ENUM ('feed', 'story', 'both');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE post_status AS ENUM ('draft', 'scheduled', 'published', 'failed');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE media_type AS ENUM ('image', 'video');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('admin', 'user');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- IMPORTANT: Ajouter 'both' à post_type si serveur existant
-- Cette valeur a été ajoutée récemment
DO $$ BEGIN
ALTER TYPE post_type ADD VALUE IF NOT EXISTS 'both';
EXCEPTION
WHEN duplicate_object THEN null;
WHEN others THEN null;
END $$;
-- ÉTAPE 2: Créer les tables principales
-- ============================================================
-- Table users
CREATE TABLE IF NOT EXISTS users (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
role user_role NOT NULL DEFAULT 'user'
);
-- Table social_pages
CREATE TABLE IF NOT EXISTS social_pages (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
platform platform NOT NULL,
page_id TEXT NOT NULL,
page_name TEXT NOT NULL,
access_token TEXT NOT NULL,
followers_count INTEGER DEFAULT 0,
is_active TEXT NOT NULL DEFAULT 'true',
created_at TIMESTAMP DEFAULT NOW()
);
-- Table cloudinary_config
CREATE TABLE IF NOT EXISTS cloudinary_config (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
cloud_name TEXT NOT NULL,
api_key TEXT NOT NULL,
api_secret TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Table openrouter_config
CREATE TABLE IF NOT EXISTS openrouter_config (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
api_key TEXT NOT NULL,
model TEXT NOT NULL DEFAULT 'anthropic/claude-sonnet-4.5',
system_prompt TEXT NOT NULL DEFAULT 'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Table media
CREATE TABLE IF NOT EXISTS media (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type media_type NOT NULL,
cloudinary_public_id TEXT,
original_url TEXT NOT NULL,
facebook_landscape_url TEXT,
facebook_feed_url TEXT,
instagram_feed_url TEXT,
instagram_story_url TEXT,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Table posts
CREATE TABLE IF NOT EXISTS posts (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
ai_generated TEXT NOT NULL DEFAULT 'false',
product_info JSONB,
status post_status NOT NULL DEFAULT 'draft',
scheduled_for TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Table post_media
CREATE TABLE IF NOT EXISTS post_media (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
post_id VARCHAR NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
media_id VARCHAR NOT NULL REFERENCES media(id) ON DELETE CASCADE,
display_order INTEGER NOT NULL DEFAULT 0
);
-- Table scheduled_posts
CREATE TABLE IF NOT EXISTS scheduled_posts (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
post_id VARCHAR NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
page_id VARCHAR NOT NULL REFERENCES social_pages(id) ON DELETE CASCADE,
post_type post_type NOT NULL,
scheduled_at TIMESTAMP NOT NULL,
published_at TIMESTAMP,
external_post_id TEXT,
error TEXT
);
-- Table ai_generations
CREATE TABLE IF NOT EXISTS ai_generations (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
product_info JSONB NOT NULL,
generated_texts JSONB NOT NULL,
selected_text TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Table user_page_permissions
CREATE TABLE IF NOT EXISTS user_page_permissions (
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
page_id VARCHAR NOT NULL REFERENCES social_pages(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, page_id)
);
-- ÉTAPE 3: Créer les index pour optimiser les performances
-- ============================================================
-- Index pour user_page_permissions
CREATE INDEX IF NOT EXISTS idx_user_page_permissions_user_id ON user_page_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_page_permissions_page_id ON user_page_permissions(page_id);
-- Index pour scheduled_posts
CREATE INDEX IF NOT EXISTS idx_scheduled_posts_scheduled_at ON scheduled_posts(scheduled_at);
CREATE INDEX IF NOT EXISTS idx_scheduled_posts_post_id ON scheduled_posts(post_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_posts_page_id ON scheduled_posts(page_id);
-- Index pour posts
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id);
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
CREATE INDEX IF NOT EXISTS idx_posts_scheduled_for ON posts(scheduled_for);
-- Index pour media
CREATE INDEX IF NOT EXISTS idx_media_user_id ON media(user_id);
-- ÉTAPE 4: Créer les utilisateurs par défaut
-- ============================================================
-- Créer l'utilisateur admin (username: admin, password: admin)
INSERT INTO users (id, username, password, role)
VALUES (
'admin-user-id',
'admin',
'$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G',
'admin'
) ON CONFLICT (username) DO NOTHING;
-- Créer un utilisateur demo (username: demo, password: admin)
INSERT INTO users (id, username, password, role)
VALUES (
'demo-user',
'demo',
'$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G',
'user'
) ON CONFLICT (username) DO NOTHING;
-- ÉTAPE 5: Mise à jour des posts programmés existants (si migration)
-- ============================================================
-- Mettre à jour le statut des posts programmés qui étaient en "draft"
UPDATE posts
SET status = 'scheduled'
WHERE scheduled_for IS NOT NULL
AND status = 'draft';
-- ============================================================
-- FIN DE LA MIGRATION
-- ============================================================
--
-- INSTRUCTIONS D'UTILISATION:
--
-- 1. Pour un NOUVEAU serveur Docker:
-- - Rien à faire ! Le docker-compose.yml exécute automatiquement
-- "drizzle-kit push --force" qui crée toutes les tables
--
-- 2. Pour un serveur Docker EXISTANT avec données:
-- - Connectez-vous à PostgreSQL:
-- docker exec -it socialflow-db psql -U socialflow -d socialflow
-- - Exécutez ce fichier:
-- \i /chemin/vers/migration-docker.sql
--
-- 3. Alternative avec commande directe:
-- docker exec -i socialflow-db psql -U socialflow -d socialflow < migration-docker.sql
--
-- ============================================================
-151
View File
@@ -1,151 +0,0 @@
CREATE TYPE "public"."media_type" AS ENUM('image', 'video');--> statement-breakpoint
CREATE TYPE "public"."platform" AS ENUM('facebook', 'instagram');--> statement-breakpoint
CREATE TYPE "public"."post_status" AS ENUM('draft', 'scheduled', 'published', 'failed');--> statement-breakpoint
CREATE TYPE "public"."post_type" AS ENUM('feed', 'story', 'both');--> statement-breakpoint
CREATE TYPE "public"."token_status" AS ENUM('valid', 'expiring', 'expired', 'error');--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('admin', 'user');--> statement-breakpoint
CREATE TABLE "ai_generations" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL,
"product_info" jsonb NOT NULL,
"generated_texts" jsonb NOT NULL,
"selected_text" text,
"created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "cloudinary_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL,
"cloud_name" text NOT NULL,
"api_key" text NOT NULL,
"api_secret" text NOT NULL,
"logo_public_id" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "cloudinary_config_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
CREATE TABLE "media" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL,
"type" "media_type" NOT NULL,
"cloudinary_public_id" text,
"original_url" text NOT NULL,
"facebook_landscape_url" text,
"facebook_feed_url" text,
"instagram_feed_url" text,
"instagram_story_url" text,
"file_name" text NOT NULL,
"file_size" integer NOT NULL,
"created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "openrouter_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL,
"api_key" text NOT NULL,
"model" text DEFAULT 'anthropic/claude-3.5-sonnet' NOT NULL,
"system_prompt" text DEFAULT 'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.' NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "openrouter_config_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
CREATE TABLE "page_analytics_history" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"page_id" varchar NOT NULL,
"date" timestamp NOT NULL,
"followers_count" integer DEFAULT 0,
"page_views" integer DEFAULT 0,
"page_reach" integer DEFAULT 0,
"created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "post_analytics" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"post_id" varchar NOT NULL,
"fetched_at" timestamp DEFAULT now(),
"impressions" integer DEFAULT 0,
"reach" integer DEFAULT 0,
"engagement" integer DEFAULT 0,
"reactions" integer DEFAULT 0,
"comments" integer DEFAULT 0,
"shares" integer DEFAULT 0,
"clicks" integer DEFAULT 0,
"raw_data" jsonb
);
--> statement-breakpoint
CREATE TABLE "post_media" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"post_id" varchar NOT NULL,
"media_id" varchar NOT NULL,
"display_order" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "posts" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL,
"content" text NOT NULL,
"ai_generated" text DEFAULT 'false' NOT NULL,
"product_info" jsonb,
"status" "post_status" DEFAULT 'draft' NOT NULL,
"scheduled_for" timestamp,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "scheduled_posts" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"post_id" varchar NOT NULL,
"page_id" varchar NOT NULL,
"post_type" "post_type" NOT NULL,
"scheduled_at" timestamp NOT NULL,
"published_at" timestamp,
"external_post_id" text,
"error" text
);
--> statement-breakpoint
CREATE TABLE "social_pages" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL,
"platform" "platform" NOT NULL,
"page_id" text NOT NULL,
"page_name" text NOT NULL,
"access_token" text NOT NULL,
"token_expires_at" timestamp,
"followers_count" integer DEFAULT 0,
"is_active" text DEFAULT 'true' NOT NULL,
"token_status" "token_status" DEFAULT 'valid',
"last_token_check" timestamp,
"created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "user_page_permissions" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL,
"page_id" varchar NOT NULL,
"created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"username" text NOT NULL,
"password" text NOT NULL,
"role" "user_role" DEFAULT 'user' NOT NULL,
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
ALTER TABLE "ai_generations" ADD CONSTRAINT "ai_generations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cloudinary_config" ADD CONSTRAINT "cloudinary_config_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "media" ADD CONSTRAINT "media_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "openrouter_config" ADD CONSTRAINT "openrouter_config_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "page_analytics_history" ADD CONSTRAINT "page_analytics_history_page_id_social_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."social_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "post_analytics" ADD CONSTRAINT "post_analytics_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "post_media" ADD CONSTRAINT "post_media_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "post_media" ADD CONSTRAINT "post_media_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "posts" ADD CONSTRAINT "posts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "scheduled_posts" ADD CONSTRAINT "scheduled_posts_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "scheduled_posts" ADD CONSTRAINT "scheduled_posts_page_id_social_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."social_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "social_pages" ADD CONSTRAINT "social_pages_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_page_permissions" ADD CONSTRAINT "user_page_permissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_page_permissions" ADD CONSTRAINT "user_page_permissions_page_id_social_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."social_pages"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large. Load diff
-13
View File
@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1768914136854,
"tag": "0000_spicy_cargill",
"breakpoints": true
}
]
}
+89 -8090
View File
File diff suppressed because it is too large. Load diff
+4 -48
View File
@@ -4,22 +4,19 @@
"type": "module",
"license": "MIT",
"scripts": {
"dev": "NODE_ENV=development tsx server/index.ts",
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"start": "NODE_ENV=production node dist/index.js",
"dev": "vite",
"dev:server": "cargo run --manifest-path server/Cargo.toml",
"build": "vite build",
"start": "cargo run --release --manifest-path server/Cargo.toml",
"check": "tsc",
"db:push": "drizzle-kit push",
"test": "vitest run"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1016.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/montserrat": "^5.3.0",
"@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-aspect-ratio": "^1.1.3",
@@ -47,46 +44,20 @@
"@radix-ui/react-toggle": "^1.1.3",
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@remotion/bundler": "^4.0.438",
"@remotion/cli": "^4.0.438",
"@remotion/player": "^4.0.438",
"@remotion/renderer": "^4.0.438",
"@tanstack/react-query": "^5.60.5",
"@twemoji/api": "^17.0.2",
"@types/bcrypt": "^6.0.0",
"@types/multer": "^2.0.0",
"@types/node-cron": "^3.0.11",
"@types/node-fetch": "^2.6.13",
"@types/pg": "^8.15.5",
"bcrypt": "^6.0.0",
"canvas": "^2.11.2",
"class-variance-authority": "^0.7.1",
"cloudinary": "^2.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"connect-pg-simple": "^10.0.0",
"date-fns": "^3.6.0",
"drizzle-orm": "^0.39.1",
"drizzle-zod": "^0.7.0",
"embla-carousel-react": "^8.6.0",
"emoji-regex": "^10.6.0",
"express": "^4.21.2",
"express-rate-limit": "^7.5.1",
"express-session": "^1.18.1",
"framer-motion": "^11.13.1",
"helmet": "^8.1.0",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
"multer": "^2.0.2",
"music-metadata": "^11.12.3",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"pg": "^8.16.3",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
@@ -97,15 +68,12 @@
"react-webcam": "^7.2.0",
"recharts": "^2.15.2",
"remotion": "^4.0.438",
"sharp": "^0.34.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.2.5",
"twemoji": "^14.0.2",
"twemoji-parser": "^14.0.0",
"vaul": "^1.1.2",
"wouter": "^3.3.5",
"ws": "^8.18.0",
"zod": "^3.24.2",
"zod-validation-error": "^3.4.0"
},
@@ -115,27 +83,15 @@
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3",
"@types/connect-pg-simple": "^7.0.3",
"@types/express": "4.17.21",
"@types/express-session": "^1.18.0",
"@types/node": "20.16.11",
"@types/passport": "^1.0.16",
"@types/passport-local": "^1.0.38",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@types/ws": "^8.5.13",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.31.4",
"esbuild": "^0.25.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.17",
"tsx": "^4.20.5",
"typescript": "5.6.3",
"vite": "^5.4.20",
"vitest": "^3.2.7"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
}
}
+3
View File
@@ -0,0 +1,3 @@
node_modules
# Bundle construit par `node server.mjs --prepare`
bundle
+66
View File
@@ -0,0 +1,66 @@
# Service de rendu Remotion des Reels (sous-titres animés, logo, effet de fin).
# Contexte de build : la racine du dépôt (les compositions vivent dans
# client/src/remotion et importent shared/captions.ts).
# docker build -f remotion-service/Dockerfile .
# Debian slim — glibc requis par le Chrome headless shell de Remotion
FROM node:20-slim
# Dépendances du Chrome headless shell et polices de secours
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
fonts-dejavu-core \
fontconfig \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxshmfence1 \
libxtst6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Dépendances (versions épinglées, identiques au package.json racine)
COPY remotion-service/package.json remotion-service/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
# Compositions et module partagé qu'elles importent (@shared/captions).
# La police Montserrat vient du paquet @fontsource/montserrat.
COPY client/src/remotion ./client/src/remotion
COPY shared/captions.ts ./shared/captions.ts
COPY remotion-service/server.mjs ./server.mjs
# Pré-bundle des compositions et téléchargement du Chrome headless shell
# (équivalent de `npx remotion browser ensure`) : aucun accès réseau au
# premier rendu.
RUN node server.mjs --prepare && rm -rf /tmp/remotion-*
ENV NODE_ENV=production \
PORT=8100 \
OUTPUT_DIR=/tmp/remotion-renders
# Utilisateur non privilégié ; seuls /tmp et le cache Chrome sont utilisés
RUN chown -R node:node /app
USER node
EXPOSE 8100
CMD ["node", "server.mjs"]
+2931
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "socialflow-remotion-service",
"version": "1.0.0",
"private": true,
"description": "Service de rendu Remotion des Reels SocialFlow (sous-titres animés, logo, effet de fin)",
"type": "module",
"license": "MIT",
"main": "server.mjs",
"scripts": {
"start": "node server.mjs",
"prepare-render": "node server.mjs --prepare"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@fontsource/montserrat": "5.3.0",
"@remotion/bundler": "4.0.438",
"@remotion/renderer": "4.0.438",
"react": "18.3.1",
"react-dom": "18.3.1",
"remotion": "4.0.438"
}
}
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env node
/**
* Service de rendu Remotion des Reels SocialFlow.
*
* Le serveur principal (Rust) ne peut pas exécuter Remotion (React +
* Chromium) : il lui confie la composition « ReelVideo » (sous-titres animés,
* logo, effet de fin) avec la vidéo et le son déjà préparés par le service
* FFmpeg, puis récupère le MP4.
*
* API (en-tête X-API-Key obligatoire, variable API_KEY) :
* POST /renders { compositionId, inputProps } → { id }
* GET /renders/:id → { status, progress (0..1), error? }
* GET /renders/:id/file → le MP4
* DELETE /renders/:id → annule le rendu et supprime le fichier
* GET /health → { status: "ok" }
*
* `node server.mjs --prepare` construit le bundle et télécharge le Chrome
* headless shell (étape de build Docker), puis s'arrête.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { bundle } from "@remotion/bundler";
import { ensureBrowser, makeCancelSignal, renderMedia, selectComposition } from "@remotion/renderer";
const HERE = path.dirname(fileURLToPath(import.meta.url));
/** Racine contenant client/src/remotion et shared/ : l'image Docker, sinon le dépôt. */
const SOURCE_ROOT =
process.env.REMOTION_SOURCE_ROOT ||
(fs.existsSync(path.join(HERE, "client/src/remotion")) ? HERE : path.resolve(HERE, ".."));
const ENTRY_POINT = path.join(SOURCE_ROOT, "client/src/remotion/index.ts");
const BUNDLE_DIR = process.env.BUNDLE_DIR || path.join(HERE, "bundle");
const OUTPUT_DIR = process.env.OUTPUT_DIR || path.join(os.tmpdir(), "remotion-renders");
const PORT = Number(process.env.PORT) || 8100;
const API_KEY = process.env.API_KEY || "";
/** Rendus simultanés (chacun occupe déjà plusieurs onglets Chromium). */
const MAX_PARALLEL_RENDERS = Math.max(1, Number(process.env.MAX_PARALLEL_RENDERS) || 1);
/** Les props contiennent le logo en data URL. */
const MAX_BODY_BYTES = 32 * 1024 * 1024;
/** Rendus terminés conservés au plus une heure s'ils ne sont pas supprimés. */
const RESULT_TTL_MS = 60 * 60_000;
const log = (...args) => console.log(new Date().toISOString(), ...args);
const warn = (...args) => console.warn(new Date().toISOString(), ...args);
// ---------------------------------------------------------------------------
// Bundle des compositions
// ---------------------------------------------------------------------------
function bundleOptions(outDir) {
return {
entryPoint: ENTRY_POINT,
outDir,
// Mêmes alias que Vite : les compositions importent @shared/*
webpackOverride: (config) => ({
...config,
resolve: {
...config.resolve,
alias: {
...(config.resolve?.alias ?? {}),
"@shared": path.join(SOURCE_ROOT, "shared"),
},
// Dépendances (react, remotion, polices) installées à côté de ce service
modules: [...(config.resolve?.modules ?? ["node_modules"]), path.join(HERE, "node_modules")],
},
}),
};
}
let bundlePromise = null;
/** Bundle construit au build Docker, sinon construit une fois par processus. */
function getBundle() {
if (fs.existsSync(path.join(BUNDLE_DIR, "index.html"))) return Promise.resolve(BUNDLE_DIR);
if (!bundlePromise) {
log("📦 [Remotion] Bundle de", ENTRY_POINT);
bundlePromise = bundle(bundleOptions(null)).catch((error) => {
bundlePromise = null; // réessayer au prochain rendu
throw error;
});
}
return bundlePromise;
}
/** Onglets Chromium en parallèle : par défaut la moitié des cœurs, 1 au minimum. */
function renderConcurrency() {
const configured = Number(process.env.RENDER_CONCURRENCY);
if (Number.isFinite(configured) && configured > 0) return Math.floor(configured);
return Math.max(1, Math.floor(os.cpus().length / 2));
}
// ---------------------------------------------------------------------------
// Rendus
// ---------------------------------------------------------------------------
/** @type {Map<string, {id: string, compositionId: string, inputProps: object, status: string, progress: number, error?: string, output: string, finishedAt?: number, cancel?: () => void}>} */
const renders = new Map();
const waiting = [];
let running = 0;
function enqueue(compositionId, inputProps) {
const id = crypto.randomUUID();
const render = {
id,
compositionId,
inputProps,
status: "pending",
progress: 0,
output: path.join(OUTPUT_DIR, `${id}.mp4`),
};
renders.set(id, render);
waiting.push(render);
pump();
return render;
}
function pump() {
while (running < MAX_PARALLEL_RENDERS && waiting.length) {
const render = waiting.shift();
if (!renders.has(render.id)) continue; // supprimé avant son tour
running++;
run(render).finally(() => {
running--;
pump();
});
}
}
async function run(render) {
render.status = "rendering";
const startedAt = Date.now();
const { cancelSignal, cancel } = makeCancelSignal();
render.cancel = cancel;
try {
const serveUrl = await getBundle();
const composition = await selectComposition({
serveUrl,
id: render.compositionId,
inputProps: render.inputProps,
});
await renderMedia({
composition,
serveUrl,
inputProps: render.inputProps,
outputLocation: render.output,
codec: "h264",
// Qualité visuelle élevée, conforme aux recommandations Instagram/TikTok
crf: 18,
x264Preset: "medium",
pixelFormat: "yuv420p",
colorSpace: "bt709",
audioCodec: "aac",
audioBitrate: "192k",
concurrency: renderConcurrency(),
cancelSignal,
onProgress: ({ progress }) => {
render.progress = progress;
},
// Une image lente (décodage vidéo sur une petite machine) ne doit pas faire échouer le rendu
timeoutInMilliseconds: 60_000,
onBrowserLog: (entry) => {
if (entry.type === "error") warn("⚠️ [Remotion] Navigateur :", entry.text.slice(0, 300));
},
});
render.status = "done";
render.progress = 1;
log(`🎞️ [Remotion] ${render.compositionId} ${render.id} rendu en ${((Date.now() - startedAt) / 1000).toFixed(1)} s`);
} catch (error) {
render.status = "failed";
render.error = error instanceof Error ? error.message : String(error);
warn(`❌ [Remotion] Rendu ${render.id} en échec :`, render.error);
fs.promises.rm(render.output, { force: true }).catch(() => {});
} finally {
render.cancel = undefined;
render.finishedAt = Date.now();
// Les props (logo en data URL) ne servent plus
render.inputProps = null;
}
}
function remove(id) {
const render = renders.get(id);
if (!render) return;
renders.delete(id);
render.cancel?.();
fs.promises.rm(render.output, { force: true }).catch(() => {});
}
// Purge des rendus oubliés
setInterval(() => {
const now = Date.now();
for (const render of renders.values()) {
if (render.finishedAt && now - render.finishedAt > RESULT_TTL_MS) remove(render.id);
}
}, 10 * 60_000).unref();
// ---------------------------------------------------------------------------
// HTTP
// ---------------------------------------------------------------------------
function send(res, status, body) {
const data = JSON.stringify(body);
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(data) });
res.end(data);
}
function authorized(req) {
const given = Buffer.from(String(req.headers["x-api-key"] ?? ""));
const expected = Buffer.from(API_KEY);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
function readJson(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on("data", (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
reject(Object.assign(new Error("Corps de requête trop volumineux"), { status: 413 }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {});
} catch {
reject(Object.assign(new Error("JSON invalide"), { status: 400 }));
}
});
req.on("error", reject);
});
}
async function handle(req, res) {
const url = new URL(req.url ?? "/", "http://localhost");
const parts = url.pathname.split("/").filter(Boolean);
if (!authorized(req)) return send(res, 401, { error: "Invalid API Key" });
if (req.method === "GET" && url.pathname === "/health") {
return send(res, 200, { status: "ok", renders: renders.size, running });
}
if (parts[0] !== "renders") return send(res, 404, { error: "Route introuvable" });
if (parts.length === 1 && req.method === "POST") {
const body = await readJson(req);
if (typeof body?.compositionId !== "string" || !body.compositionId) {
return send(res, 400, { error: "compositionId requis" });
}
if (body.inputProps == null || typeof body.inputProps !== "object" || Array.isArray(body.inputProps)) {
return send(res, 400, { error: "inputProps doit être un objet" });
}
const render = enqueue(body.compositionId, body.inputProps);
log(`🎬 [Remotion] Rendu ${render.id} (${render.compositionId}) en file`);
return send(res, 202, { id: render.id });
}
const render = parts[1] ? renders.get(parts[1]) : undefined;
if (parts.length === 2 && req.method === "DELETE") {
remove(parts[1]);
return send(res, 200, { success: true });
}
if (!render) return send(res, 404, { error: "Rendu introuvable ou expiré" });
if (parts.length === 2 && req.method === "GET") {
return send(res, 200, {
status: render.status,
progress: render.progress,
...(render.error ? { error: render.error } : {}),
});
}
if (parts.length === 3 && parts[2] === "file" && req.method === "GET") {
if (render.status !== "done") return send(res, 409, { error: "Rendu non terminé" });
let stat;
try {
stat = await fs.promises.stat(render.output);
} catch {
return send(res, 404, { error: "Fichier introuvable ou expiré" });
}
res.writeHead(200, { "Content-Type": "video/mp4", "Content-Length": stat.size });
fs.createReadStream(render.output)
.on("error", () => res.destroy())
.pipe(res);
return;
}
return send(res, 405, { error: "Méthode non autorisée" });
}
// ---------------------------------------------------------------------------
// Démarrage
// ---------------------------------------------------------------------------
async function prepare() {
log("📦 [Remotion] Pré-bundle de", ENTRY_POINT, "→", BUNDLE_DIR);
await fs.promises.rm(BUNDLE_DIR, { recursive: true, force: true });
await bundle(bundleOptions(BUNDLE_DIR));
log("🌐 [Remotion] Téléchargement du Chrome headless shell…");
await ensureBrowser();
log("✅ [Remotion] Bundle et navigateur prêts");
}
if (process.argv.includes("--prepare")) {
prepare().catch((error) => {
console.error("❌ [Remotion] Préparation impossible :", error);
process.exit(1);
});
} else {
if (!API_KEY) {
console.error("API_KEY doit être défini : le service refuse de démarrer sans clé.");
process.exit(1);
}
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const server = http.createServer((req, res) => {
handle(req, res).catch((error) => {
if (res.headersSent) return res.destroy();
send(res, error?.status ?? 500, { error: error instanceof Error ? error.message : String(error) });
});
});
server.listen(PORT, "0.0.0.0", () => {
log(`🎬 [Remotion] Service de rendu à l'écoute sur le port ${PORT} (rendus simultanés : ${MAX_PARALLEL_RENDERS})`);
});
// Bundle et navigateur prêts avant le premier rendu (sans bloquer le démarrage)
getBundle().catch((error) => warn("⚠️ [Remotion] Bundle impossible :", error.message));
ensureBrowser().catch((error) => warn("⚠️ [Remotion] Navigateur indisponible :", error.message));
const shutdown = () => {
log("Arrêt du service de rendu");
for (const render of renders.values()) render.cancel?.();
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 5_000).unref();
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
+1 -1
View File
@@ -32,7 +32,7 @@ Preferred communication style: Simple, everyday language.
The frontend is built with React and TypeScript, leveraging Vite for bundling and Wouter for routing. State management is primarily handled by TanStack Query. UI components are constructed using Radix UI primitives and shadcn/ui, styled with Tailwind CSS, emphasizing a component-based, responsive, mobile-first design with real-time data synchronization. Dark mode is the default theme.
### Backend Architecture
The backend is developed in Node.js with Express.js, entirely in TypeScript. It adheres to a RESTful API design with dedicated endpoints for authentication, user management, AI content generation, media handling, social page management, and post scheduling. Multer is utilized for file uploads, integrated with Cloudinary. Node-cron manages background processes for scheduled post publication. The architecture incorporates a service layer, storage abstraction, and a middleware-based request processing pipeline. Session management is handled by `express-session` with `connect-pg-simple`.
The backend is written in Rust (axum, tokio, sqlx) in the `server/` crate. It exposes the same RESTful API as the former Node/Express server (authentication, user management, AI content generation, media handling, social page management, post scheduling, external API). Uploads are stored locally under `uploads/`. Background tasks (scheduled publications, token checks, analytics, media purge, Reel render queue) run as tokio tasks. Sessions are stored in the PostgreSQL `session` table with a signed `connect.sid` cookie, compatible with the former server. The database schema is created and migrated automatically at startup (`server/src/migrate.rs`). Remotion Reel rendering runs in a separate Node sidecar (`remotion-service/`).
### Database Architecture
PostgreSQL serves as the database, interfaced using Drizzle ORM for type-safe operations and Neon serverless driver for WebSocket support. The schema includes tables for `users`, `cloudinary_config`, `social_pages`, `media`, `posts`, `post_media`, `scheduled_posts`, and `ai_generations`. Drizzle Kit is used for schema migrations.
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env node
// Pré-bundle des compositions Remotion pendant le build Docker : le cache
// Webpack est chaud, le premier rendu d'un utilisateur démarre plus vite.
// Même configuration que server/services/reels/remotionRenderer.ts.
import path from "node:path";
import { fileURLToPath } from "node:url";
import { bundle } from "@remotion/bundler";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
bundle({
entryPoint: path.join(root, "client/src/remotion/index.ts"),
webpackOverride: (config) => ({
...config,
resolve: {
...config.resolve,
alias: { ...(config.resolve?.alias ?? {}), "@shared": path.join(root, "shared") },
},
}),
})
.then((location) => console.log("✅ Remotion pre-bundle OK:", location))
.catch((error) => {
console.warn("⚠️ Remotion pre-bundle skipped:", error.message);
process.exit(0);
});
+4314
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
[package]
name = "socialflow"
version = "2.0.0"
edition = "2021"
publish = false
[[bin]]
name = "socialflow"
path = "src/main.rs"
[dependencies]
aes-gcm = "0.10"
anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["multipart", "macros"] }
axum-extra = { version = "0.12.6", features = ["cookie", "typed-header"] }
base64 = "0.22"
bcrypt = "0.19.3"
bytes = "1.12.1"
chrono = { version = "0.4.45", features = ["serde"] }
futures = "0.3.34"
harfrust = "0.12"
hex = "0.4.3"
hmac = "0.12"
image = "0.25.10"
once_cell = "1.21.4"
percent-encoding = "2.3.2"
rand = "0.8"
regex = "1.13.1"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "stream", "rustls-tls-native-roots", "gzip", "http2"] }
resvg = "0.48.1"
scrypt = "0.11"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = { version = "1.0.154", features = ["preserve_order"] }
sha2 = "0.10"
skrifa = "0.44"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres", "chrono", "json", "uuid", "macros", "migrate"] }
thiserror = "2.0.21"
tokio = { version = "1.53.2", features = ["full"] }
tokio-util = { version = "0.7.20", features = ["io"] }
tower = "0.5.3"
tower-http = { version = "0.7.1", features = ["fs", "trace", "set-header", "cors", "limit"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
url = "2.5.8"
uuid = { version = "1.28.0", features = ["v4", "serde"] }
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 4
strip = true
-45
View File
@@ -1,45 +0,0 @@
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import bcrypt from "bcrypt";
import { storage } from "./storage";
import type { User } from "@shared/schema";
// Configuration de la stratégie locale
passport.use(
new LocalStrategy(async (username, password, done) => {
try {
const user = await storage.getUserByUsername(username);
if (!user) {
return done(null, false, { message: "Nom d'utilisateur ou mot de passe incorrect" });
}
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return done(null, false, { message: "Nom d'utilisateur ou mot de passe incorrect" });
}
return done(null, user);
} catch (error) {
return done(error);
}
})
);
// Sérialisation de l'utilisateur pour la session
passport.serializeUser((user: Express.User, done) => {
done(null, (user as User).id);
});
// Désérialisation de l'utilisateur depuis la session
passport.deserializeUser(async (id: string, done) => {
try {
const user = await storage.getUser(id);
done(null, user);
} catch (error) {
done(error);
}
});
export default passport;
-51
View File
@@ -1,51 +0,0 @@
import cron from 'node-cron';
import { TokenManager } from './services/token_manager';
import { AnalyticsService } from './services/analytics';
import { purgeOldMedia } from './services/media-purge';
import { tiktokService } from './services/tiktok';
export function startTokenCron() {
// Toutes les 2 minutes — suivi des publications TikTok (API asynchrone)
cron.schedule('*/2 * * * *', async () => {
try {
await tiktokService.syncPendingPublications();
} catch (error) {
console.error('[Cron] TikTok publish status sync failed:', error);
}
});
console.log('[Cron] TikTok publish status job scheduled (*/2 * * * *).');
// 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.
cron.schedule('0 0,12 * * *', async () => {
console.log('[Cron] Running token check...');
const results = await TokenManager.checkAndRefreshTokens();
const renewed = results.filter(r => r.renewed).length;
const failing = results.filter(r => r.status === 'expired' || r.status === 'error').length;
console.log(`[Cron] Token check done: ${results.length} contrôlés, ${renewed} renouvelés, ${failing} en échec.`);
});
console.log('[Cron] Token refresh job scheduled (0 0,12 * * *).');
// Run twice daily (08:00 and 20:00) — Analytics sync for all pages
cron.schedule('0 8,20 * * *', async () => {
console.log('[Cron] Running analytics sync for all pages...');
try {
await AnalyticsService.syncAllPages();
} catch (error) {
console.error('[Cron] Analytics sync failed:', error);
}
});
console.log('[Cron] Analytics sync job scheduled (0 8,20 * * *).');
// Run every day at 03:00 AM — Cleanup old media from Cloudinary and DB
cron.schedule('0 3 * * *', async () => {
console.log('[Cron] Running daily media purge check...');
try {
await purgeOldMedia();
} catch (error) {
console.error('[Cron] Media purge check failed:', error);
}
});
console.log('[Cron] Media purge job scheduled (0 3 * * *).');
}
-12
View File
@@ -1,12 +0,0 @@
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from "@shared/schema";
if (!process.env.DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set. Did you forget to provision a database?",
);
}
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
-241
View File
@@ -1,241 +0,0 @@
import express, { type Request, Response, NextFunction } from "express";
import path from "path";
import fs from "fs";
import session from "express-session";
import connectPgSimple from "connect-pg-simple";
import pg from "pg";
import crypto from "crypto";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import passport from "./auth";
import { registerRoutes } from "./routes";
import { setupVite, serveStatic, log } from "./vite";
import { schedulerService } from "./services/scheduler";
import { ensureAdminUserExists } from "./init-admin";
import { startTokenCron } from "./cron";
import { migrate } from "./migrate";
import { startReelProcessing } from "./services/reels";
import { jamendoService } from "./services/jamendo";
import { ffmpegService } from "./services/ffmpeg";
const app = express();
const PgSession = connectPgSimple(session);
// Faire confiance au reverse proxy (nginx) pour X-Forwarded-For
// Nécessaire pour que express-rate-limit identifie correctement les IPs
app.set('trust proxy', 1);
// Headers de sécurité HTTP avec helmet
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "https:", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
connectSrc: ["'self'", "https://graph.facebook.com", "https://openrouter.ai", "https://res.cloudinary.com", "wss:", "ws:"],
mediaSrc: ["'self'", "https:", "data:"],
}
},
crossOriginEmbedderPolicy: false,
}));
// Rate limiting global - 1000 requêtes par 15 minutes par IP
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 1000,
message: { error: 'Trop de requêtes, réessayez plus tard' },
standardHeaders: true,
legacyHeaders: false,
});
// Rate limiting pour l'authentification - 30 tentatives par 15 minutes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
message: { error: 'Trop de tentatives de connexion, réessayez dans 15 minutes' },
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/', globalLimiter);
app.use('/api/auth/login', authLimiter);
// Serve locally stored audio files
const AUDIO_UPLOAD_DIR = process.env.AUDIO_UPLOAD_DIR || '/app/uploads/audio';
app.use('/uploads/audio', express.static(AUDIO_UPLOAD_DIR));
// Serve locally stored media files (images, videos, logos, stories)
const MEDIA_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'media');
const LOGOS_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'logos');
const STORIES_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'stories');
try { fs.mkdirSync(MEDIA_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ }
try { fs.mkdirSync(LOGOS_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ }
try { fs.mkdirSync(STORIES_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ }
app.use('/uploads/media', express.static(MEDIA_UPLOAD_DIR));
app.use('/uploads/logos', express.static(LOGOS_UPLOAD_DIR));
app.use('/uploads/stories', express.static(STORIES_UPLOAD_DIR));
// Serve temp uploads for Remotion
const TEMP_UPLOAD_DIR = process.env.TEMP_UPLOAD_DIR || path.join(process.cwd(), 'uploads', 'temp');
try { fs.mkdirSync(TEMP_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ }
app.use('/uploads/temp', express.static(TEMP_UPLOAD_DIR));
declare module 'http' {
interface IncomingMessage {
rawBody: unknown
}
}
app.use(express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
}
}));
app.use(express.urlencoded({ extended: false }));
// Validation renforcée du SESSION_SECRET
if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'production') {
console.error('❌ SESSION_SECRET non défini en production. Arrêt du serveur.');
process.exit(1);
}
if (!process.env.SESSION_SECRET) {
console.warn('⚠️ SESSION_SECRET non défini. Utilisation d\'une clé aléatoire pour le développement.');
}
// Générer un secret aléatoire pour le dev si non défini
const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
// HTTPS déduit d'APP_URL ; à défaut, 'auto' laisse express-session décider par
// requête à partir de X-Forwarded-Proto (cf. `trust proxy` plus haut), ce qui
// évite un cookie non sécurisé derrière un reverse proxy HTTPS sans APP_URL.
const isHttps: boolean | 'auto' = process.env.APP_URL?.startsWith('https://') || 'auto';
// Configuration du store de session pour production
const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL
? new PgSession({
pool: new pg.Pool({
connectionString: process.env.DATABASE_URL,
}),
tableName: 'session',
createTableIfMissing: true,
})
: undefined; // MemoryStore par défaut en dev
app.use(session({
store: sessionStore,
secret: sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
secure: isHttps,
httpOnly: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
}
}));
// Initialisation de Passport
app.use(passport.initialize());
app.use(passport.session());
app.use((req, res, next) => {
const start = Date.now();
const path = req.path;
let capturedJsonResponse: Record<string, any> | undefined = undefined;
const originalResJson = res.json;
res.json = function (bodyJson, ...args) {
capturedJsonResponse = bodyJson;
return originalResJson.apply(res, [bodyJson, ...args]);
};
res.on("finish", () => {
const duration = Date.now() - start;
if (path.startsWith("/api")) {
// Ne pas logger les 401 sur /api/auth/session (vérification normale de session)
if (path === "/api/auth/session" && res.statusCode === 401) {
return;
}
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
if (capturedJsonResponse) {
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
}
if (logLine.length > 80) {
logLine = logLine.slice(0, 79) + "…";
}
log(logLine);
}
});
next();
});
(async () => {
// Run database migration first
await migrate();
const server = await registerRoutes(app);
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
const status = err.status || err.statusCode || 500;
const message = err.message || "Internal Server Error";
res.status(status).json({ message });
throw err;
});
// importantly only setup vite in development and after
// setting up all the other routes so the catch-all route
// doesn't interfere with the other routes
if (app.get("env") === "development") {
await setupVite(app, server);
} else {
serveStatic(app);
}
// ALWAYS serve the app on the port specified in the environment variable PORT
// Other ports are firewalled. Default to 5000 if not specified.
// this serves both the API and the client.
// It is the only port that is not firewalled.
const port = parseInt(process.env.PORT || '5000', 10);
// Initialiser l'utilisateur admin par défaut avant de démarrer le serveur
await ensureAdminUserExists();
// Configurer le service Jamendo pour les Reels
const jamendoClientId = process.env.JAMENDO_CLIENT_ID || '5b6bba49';
jamendoService.configure(jamendoClientId);
// Configurer le service FFmpeg pour le traitement vidéo (si configuré)
if (process.env.FFMPEG_API_URL && process.env.FFMPEG_API_KEY) {
ffmpegService.configure(process.env.FFMPEG_API_URL, process.env.FFMPEG_API_KEY);
} else {
log('⚠️ FFmpeg API not configured - video processing will be skipped');
}
server.listen({
port,
host: "0.0.0.0",
reusePort: true,
}, () => {
log(`serving on port ${port}`);
// Démarrer le scheduler pour les publications programmées
schedulerService.start();
// Start Token Refresh Cron
startTokenCron();
// File des rendus de Reels : démarrée une fois le serveur à l'écoute, car
// le rendu d'images lit la voix et la musique via http://localhost.
startReelProcessing().catch((error) => {
console.error("❌ Démarrage de la file des Reels impossible :", error);
});
});
})();
-30
View File
@@ -1,30 +0,0 @@
import { storage } from "./storage";
import bcrypt from "bcrypt";
export async function ensureAdminUserExists() {
try {
const adminUser = await storage.getUserByUsername("admin");
if (!adminUser) {
console.log("🔧 Création de l'utilisateur admin par défaut...");
const hashedPassword = await bcrypt.hash("admin", 10);
await storage.createUser({
username: "admin",
password: hashedPassword,
role: "admin",
});
console.log("✅ Utilisateur admin créé avec succès");
console.log(" Username: admin");
console.log(" Password: admin");
console.log(" ⚠️ IMPORTANT: Changez ce mot de passe immédiatement !");
} else {
console.log("✓ Utilisateur admin existe déjà");
}
} catch (error) {
console.error("❌ Erreur lors de la vérification/création de l'utilisateur admin:", error);
throw error;
}
}
-28
View File
@@ -1,28 +0,0 @@
import type { Request, Response, NextFunction } from "express";
import { storage } from "../storage";
export async function requireApiKey(req: Request, res: Response, next: NextFunction) {
const provided = req.headers["x-api-key"];
if (!provided) {
return res.status(401).json({ error: "Clé API manquante (header X-API-Key requis)" });
}
try {
const config = await storage.getAppConfig();
const key = config?.externalApiKey || process.env.EXTERNAL_API_KEY;
if (!key) {
return res.status(503).json({ error: "API externe non configurée" });
}
if (provided !== key) {
return res.status(401).json({ error: "Clé API invalide" });
}
next();
} catch (error) {
console.error("[apiKey middleware] Error:", error);
return res.status(500).json({ error: "Erreur interne lors de la vérification de la clé API" });
}
}
-299
View File
@@ -1,299 +0,0 @@
import { pool } from "./db";
export async function migrate() {
const client = await pool.connect();
try {
console.log("[Migration] Starting safe migration Check...");
// 1. Create Enums if not exist and Update Enums
await client.query(`
DO $$ BEGIN
CREATE TYPE "token_status" AS ENUM('valid', 'expiring', 'expired', 'error');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`);
// Add 'reel' to post_type enum if not exists
await client.query(`ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';`);
// Add 'tiktok' to platform enum if not exists
await client.query(`ALTER TYPE "platform" ADD VALUE IF NOT EXISTS 'tiktok';`);
// 2. Create Tables if not exist
// post_analytics
await client.query(`
CREATE TABLE IF NOT EXISTS "post_analytics" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"post_id" varchar NOT NULL REFERENCES "posts"("id") ON DELETE cascade,
"fetched_at" timestamp DEFAULT now(),
"impressions" integer DEFAULT 0,
"reach" integer DEFAULT 0,
"engagement" integer DEFAULT 0,
"reactions" integer DEFAULT 0,
"comments" integer DEFAULT 0,
"shares" integer DEFAULT 0,
"clicks" integer DEFAULT 0,
"raw_data" jsonb
);
`);
// page_analytics_history
await client.query(`
CREATE TABLE IF NOT EXISTS "page_analytics_history" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"page_id" varchar NOT NULL REFERENCES "social_pages"("id") ON DELETE cascade,
"date" timestamp NOT NULL,
"followers_count" integer DEFAULT 0,
"page_views" integer DEFAULT 0,
"page_reach" integer DEFAULT 0,
"created_at" timestamp DEFAULT now()
);
`);
// 3. Add Columns to existing tables if not exist
// social_pages.token_status
await client.query(`
ALTER TABLE "social_pages"
ADD COLUMN IF NOT EXISTS "token_status" "token_status" DEFAULT 'valid';
`);
// social_pages.last_token_check
await client.query(`
ALTER TABLE "social_pages"
ADD COLUMN IF NOT EXISTS "last_token_check" timestamp;
`);
// music_favorites
await client.query(`
CREATE TABLE IF NOT EXISTS "music_favorites" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"track_id" text NOT NULL,
"title" text NOT NULL,
"artist" text NOT NULL,
"album_name" text,
"duration" integer NOT NULL,
"preview_url" text NOT NULL,
"download_url" text NOT NULL,
"image_url" text,
"license" text,
"created_at" timestamp DEFAULT now()
);
`);
// freesound_config
await client.query(`
CREATE TABLE IF NOT EXISTS "freesound_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"client_id" text NOT NULL,
"client_secret" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT freesound_config_user_id_unique UNIQUE ("user_id")
);
`);
// posts.generation_status (Reel progress tracking)
await client.query(`
ALTER TABLE "posts"
ADD COLUMN IF NOT EXISTS "generation_status" text;
`);
// posts.generation_progress
await client.query(`
ALTER TABLE "posts"
ADD COLUMN IF NOT EXISTS "generation_progress" integer DEFAULT 0;
`);
// posts.generation_error
await client.query(`
ALTER TABLE "posts"
ADD COLUMN IF NOT EXISTS "generation_error" text;
`);
// cloudinary_config.endpoint_url (MinIO internal endpoint URL)
await client.query(`
ALTER TABLE "cloudinary_config"
ADD COLUMN IF NOT EXISTS "endpoint_url" text;
`);
// cloudinary_config.public_url (MinIO public-facing base URL)
await client.query(`
ALTER TABLE "cloudinary_config"
ADD COLUMN IF NOT EXISTS "public_url" text;
`);
// page_analytics_history.page_engagement
await client.query(`
ALTER TABLE "page_analytics_history"
ADD COLUMN IF NOT EXISTS "page_engagement" integer DEFAULT 0;
`);
// audio_tracks (bibliothèque audio interne)
await client.query(`
CREATE TABLE IF NOT EXISTS "audio_tracks" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar REFERENCES "users"("id") ON DELETE cascade,
"title" text NOT NULL,
"file_name" text NOT NULL,
"url" text NOT NULL,
"duration" integer DEFAULT 0,
"created_at" timestamp DEFAULT now()
);
`);
// app_config (global application settings)
await client.query(`
CREATE TABLE IF NOT EXISTS "app_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"external_api_key" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
`);
// app_config.gemini_api_key
await client.query(`
ALTER TABLE "app_config"
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
`);
// app_config.qwen_tts_url / qwen_tts_api_key (service Qwen3-TTS)
await client.query(`
ALTER TABLE "app_config"
ADD COLUMN IF NOT EXISTS "qwen_tts_url" text,
ADD COLUMN IF NOT EXISTS "qwen_tts_api_key" text;
`);
// media.thumbnail_url (vignette conservée après suppression de la vidéo)
await client.query(`
ALTER TABLE "media"
ADD COLUMN IF NOT EXISTS "thumbnail_url" text;
`);
// tiktok_config (application développeur TikTok, globale)
await client.query(`
CREATE TABLE IF NOT EXISTS "tiktok_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_key" text NOT NULL,
"client_secret" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
`);
// social_pages : champs OAuth TikTok
await client.query(`
ALTER TABLE "social_pages"
ADD COLUMN IF NOT EXISTS "refresh_token" text,
ADD COLUMN IF NOT EXISTS "refresh_token_expires_at" timestamp,
ADD COLUMN IF NOT EXISTS "scopes" text,
ADD COLUMN IF NOT EXISTS "avatar_url" text;
`);
// facebook_config (application développeur Facebook, globale)
await client.query(`
CREATE TABLE IF NOT EXISTS "facebook_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"app_id" text NOT NULL,
"app_secret" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
`);
// social_pages : renouvellement automatique des tokens Facebook
await client.query(`
ALTER TABLE "social_pages"
ADD COLUMN IF NOT EXISTS "user_access_token" text,
ADD COLUMN IF NOT EXISTS "user_token_expires_at" timestamp,
ADD COLUMN IF NOT EXISTS "token_error" text;
`);
// scheduled_posts : suivi de la publication asynchrone TikTok
await client.query(`
ALTER TABLE "scheduled_posts"
ADD COLUMN IF NOT EXISTS "publish_id" text,
ADD COLUMN IF NOT EXISTS "publish_status" text;
`);
// reel_jobs : file d'attente persistante des rendus de Reels
await client.query(`
CREATE TABLE IF NOT EXISTS "reel_jobs" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"post_id" varchar REFERENCES "posts"("id") ON DELETE cascade,
"kind" text NOT NULL,
"params" jsonb NOT NULL,
"status" text NOT NULL DEFAULT 'pending',
"step" text,
"progress" integer NOT NULL DEFAULT 0,
"attempts" integer NOT NULL DEFAULT 0,
"result" jsonb,
"error" text,
"heartbeat_at" timestamp,
"created_at" timestamp NOT NULL DEFAULT now(),
"updated_at" timestamp NOT NULL DEFAULT now()
);
`);
// 4. Index de performance
//
// Postgres n'indexe pas les clés étrangères tout seul : sans ces index, le
// calendrier, l'historique et le scheduler faisaient des parcours complets
// de table à chaque appel. Ils existaient dans migration-docker.sql, qui
// n'est monté nulle part — ce fichier-ci est le seul exécuté au démarrage.
//
// Chaque index est créé isolément : une table absente sur une vieille
// installation ne doit pas empêcher la création des suivants.
const indexes: [string, string][] = [
// Filtres par propriétaire
["idx_posts_user_id", `CREATE INDEX IF NOT EXISTS "idx_posts_user_id" ON "posts" ("user_id")`],
["idx_media_user_id", `CREATE INDEX IF NOT EXISTS "idx_media_user_id" ON "media" ("user_id")`],
["idx_social_pages_user_id", `CREATE INDEX IF NOT EXISTS "idx_social_pages_user_id" ON "social_pages" ("user_id")`],
["idx_ai_generations_user_id", `CREATE INDEX IF NOT EXISTS "idx_ai_generations_user_id" ON "ai_generations" ("user_id")`],
// Jointures
["idx_scheduled_posts_post_id", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_post_id" ON "scheduled_posts" ("post_id")`],
["idx_post_media_post_id", `CREATE INDEX IF NOT EXISTS "idx_post_media_post_id" ON "post_media" ("post_id")`],
["idx_post_media_media_id", `CREATE INDEX IF NOT EXISTS "idx_post_media_media_id" ON "post_media" ("media_id")`],
["idx_post_analytics_post_id", `CREATE INDEX IF NOT EXISTS "idx_post_analytics_post_id" ON "post_analytics" ("post_id")`],
["idx_page_analytics_history_page_date", `CREATE INDEX IF NOT EXISTS "idx_page_analytics_history_page_date" ON "page_analytics_history" ("page_id", "date")`],
["idx_user_page_permissions_user_id", `CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_user_id" ON "user_page_permissions" ("user_id")`],
["idx_user_page_permissions_page_id", `CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_page_id" ON "user_page_permissions" ("page_id")`],
// Calendrier et historique : « les publications de ces pages, sur cette période »
["idx_scheduled_posts_page_scheduled_at", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_page_scheduled_at" ON "scheduled_posts" ("page_id", "scheduled_at")`],
// Scheduler (toutes les minutes) : seules les publications non encore
// traitées comptent, d'où un index partiel qui reste minuscule.
["idx_scheduled_posts_pending", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_pending" ON "scheduled_posts" ("scheduled_at") WHERE "published_at" IS NULL`],
// Suivi asynchrone TikTok (toutes les deux minutes)
["idx_scheduled_posts_publish_id", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_publish_id" ON "scheduled_posts" ("publish_id") WHERE "publish_id" IS NOT NULL`],
// Reels en cours de génération
["idx_reel_jobs_queue", `CREATE INDEX IF NOT EXISTS "idx_reel_jobs_queue" ON "reel_jobs" ("created_at") WHERE "status" IN ('pending', 'processing')`],
["idx_posts_generation_status", `CREATE INDEX IF NOT EXISTS "idx_posts_generation_status" ON "posts" ("generation_status") WHERE "generation_status" IS NOT NULL`],
];
for (const [name, statement] of indexes) {
try {
await client.query(statement);
} catch (error) {
console.warn(`[Migration] Index ${name} non créé:`, error instanceof Error ? error.message : error);
}
}
console.log("[Migration] Safe migration completed.");
} catch (error) {
console.error("[Migration] Error during migration:", error);
// Don't kill the process, let the app try to start even if migration failed
// (It might have failed because tables already existed in a different state)
} finally {
client.release();
}
}
-32
View File
@@ -1,32 +0,0 @@
import { emojiRenderer } from "./services/emojiRenderer";
import { createCanvas } from "canvas";
import fs from "fs";
import path from "path";
async function run() {
const width = 500;
const height = 200;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "black";
ctx.font = "30px Arial";
const text = "Hello 🌍 World! 😂";
console.log(`Rendering text: ${text}`);
try {
await emojiRenderer.drawText(text, ctx, 50, 100, 30);
const buffer = canvas.toBuffer("image/png");
fs.writeFileSync("emoji_test.png", buffer);
console.log("Image saved to emoji_test.png");
} catch (error) {
console.error("Error rendering emoji:", error);
}
}
run();
-2120
View File
File diff suppressed because it is too large. Load diff
-79
View File
@@ -1,79 +0,0 @@
import { Router } from 'express';
import { db } from '../db';
import { AnalyticsService } from '../services/analytics';
import { TokenManager } from '../services/token_manager';
import { postAnalytics, pageAnalyticsHistory, socialPages } from '../../shared/schema';
import { eq, desc } from 'drizzle-orm';
const router = Router();
// Get Post Analytics
router.get('/posts/:postId', async (req, res) => {
try {
const { postId } = req.params;
const analytics = await db.query.postAnalytics.findFirst({
where: eq(postAnalytics.postId, postId)
});
res.json(analytics || {});
} catch (error) {
res.status(500).json({ error: 'Failed to fetch post analytics' });
}
});
// Refresh Post Analytics
router.post('/posts/:postId/refresh', async (req, res) => {
try {
const { postId } = req.params;
await AnalyticsService.syncPostAnalytics(postId);
const updated = await db.query.postAnalytics.findFirst({
where: eq(postAnalytics.postId, postId)
});
res.json(updated);
} catch (error) {
res.status(500).json({ error: 'Failed to refresh post analytics' });
}
});
// Get Page Analytics History
router.get('/pages/:pageId/history', async (req, res) => {
try {
const { pageId } = req.params;
const history = await db.query.pageAnalyticsHistory.findMany({
where: eq(pageAnalyticsHistory.pageId, pageId),
orderBy: [desc(pageAnalyticsHistory.date)],
limit: 30 // Last 30 entries
});
res.json(history);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch page history' });
}
});
// Refresh Page Analytics
router.post('/pages/:pageId/refresh', async (req, res) => {
try {
const { pageId } = req.params;
await AnalyticsService.syncPageAnalytics(pageId);
const page = await db.query.socialPages.findFirst({
where: eq(socialPages.id, pageId)
});
res.json(page);
} catch (error: any) {
console.error('Refresh error:', error);
res.status(500).json({ error: error.message || 'Failed to refresh page analytics' });
}
});
// Manual Token Check Trigger
router.post('/tokens/check', async (req, res) => {
try {
// This runs in background, but we can await it for manual trigger
await TokenManager.checkAndRefreshTokens();
res.json({ success: true, message: 'Token check completed' });
} catch (error) {
res.status(500).json({ error: 'Token check failed' });
}
});
export const analyticsRouter = router;
-497
View File
@@ -1,497 +0,0 @@
import { Router } from "express";
import { z } from "zod";
import path from "path";
import { storage } from "../storage";
import { db } from "../db";
import { postMedia } from "@shared/schema";
import { minioService } from "../services/minio";
import { requireApiKey } from "../middleware/apiKey";
const router = Router();
router.use(requireApiKey);
const publishSchema = z.object({
content: z.string().min(1, "Le contenu est requis"),
imageUrl: z.string().url("URL d'image invalide").optional(),
pageIds: z.array(z.string()).min(1, "Au moins une page est requise"),
scheduledAt: z.string().datetime({ offset: true }).optional(),
postType: z.enum(["feed", "story", "both"]).default("feed"),
userId: z.string().optional(),
});
const IMAGE_MIME_TYPES: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
};
async function downloadImage(url: string): Promise<{ buffer: Buffer; ext: string; mimeType: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
let response: Response;
try {
response = await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
throw new Error(`Impossible de télécharger l'image (HTTP ${response.status})`);
}
const contentType = response.headers.get("content-type")?.split(";")[0].trim() ?? "";
const ext = IMAGE_MIME_TYPES[contentType];
if (!ext) {
throw new Error(`Type MIME non supporté: ${contentType}. Formats acceptés: JPEG, PNG, WebP, GIF`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
if (buffer.length > MAX_SIZE) {
throw new Error(`Image trop volumineuse (${(buffer.length / 1024 / 1024).toFixed(1)} MB, max 10 MB)`);
}
return { buffer, ext, mimeType: contentType };
}
/**
* 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 Facebook/Instagram.
*
* Headers:
* X-API-Key: <EXTERNAL_API_KEY>
*
* Body:
* content string Texte de la publication
* imageUrl string? URL publique de l'image à télécharger
* pageIds string[] IDs des pages cibles (social_pages.id)
* scheduledAt ISO8601? Date/heure de publication (absent = immédiat)
* postType feed|story|both Type de publication (défaut: feed)
* userId string? ID utilisateur propriétaire (défaut: premier admin)
*/
router.post("/publish", async (req, res) => {
try {
const parsed = publishSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: "Données invalides",
details: parsed.error.flatten().fieldErrors,
});
}
const { content, imageUrl, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data;
// Résoudre l'utilisateur propriétaire
let ownerId = bodyUserId;
if (!ownerId) {
const users = await storage.getAllUsers();
const admin = users.find((u) => u.role === "admin");
if (!admin) {
return res.status(500).json({ error: "Aucun utilisateur admin trouvé" });
}
ownerId = admin.id;
} else {
const user = await storage.getUser(ownerId);
if (!user) {
return res.status(400).json({ error: `Utilisateur introuvable: ${ownerId}` });
}
}
// Vérifier que toutes les pages existent
const resolvedPages: Array<{ id: string; pageName: string }> = [];
for (const pageId of pageIds) {
const page = await storage.getSocialPage(pageId);
if (!page) {
return res.status(400).json({ error: `Page introuvable: ${pageId}` });
}
resolvedPages.push({ id: page.id, pageName: page.pageName });
}
// Validation story → image obligatoire
if ((postType === "story" || postType === "both") && !imageUrl) {
return res.status(400).json({ error: "Les stories nécessitent une image (imageUrl requis)" });
}
// Télécharger l'image si fournie
let mediaRecord: { id: string; originalUrl: string } | null = null;
if (imageUrl) {
const { buffer, ext, mimeType } = await downloadImage(imageUrl);
const fileName = `external-${Date.now()}${ext}`;
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType);
const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image";
mediaRecord = await storage.createMedia({
userId: ownerId,
type: mediaType,
cloudinaryPublicId: uploaded.publicId,
originalUrl: uploaded.originalUrl,
facebookFeedUrl: uploaded.facebookFeedUrl,
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
fileName,
fileSize: buffer.length,
});
}
// Créer le post
const scheduledFor = scheduledAt ? new Date(scheduledAt) : null;
const post = await storage.createPost({
userId: ownerId,
content,
status: scheduledFor ? "scheduled" : "scheduled",
scheduledFor: scheduledFor ?? new Date(),
aiGenerated: "false",
});
// Lier le média si présent
if (mediaRecord) {
await db.insert(postMedia).values({
postId: post.id,
mediaId: mediaRecord.id,
displayOrder: 0,
});
}
// Créer les scheduled_posts par page
const scheduledAt_ = scheduledFor ?? new Date();
const createdSchedules: Array<{ pageId: string; pageName: string; scheduledPostId: string }> = [];
for (const page of resolvedPages) {
if (postType === "both") {
const story = await storage.createScheduledPost({
postId: post.id,
pageId: page.id,
postType: "story",
scheduledAt: scheduledAt_,
});
const feed = await storage.createScheduledPost({
postId: post.id,
pageId: page.id,
postType: "feed",
scheduledAt: scheduledAt_,
});
createdSchedules.push(
{ pageId: page.id, pageName: page.pageName, scheduledPostId: story.id },
{ pageId: page.id, pageName: page.pageName, scheduledPostId: feed.id }
);
} else {
const sp = await storage.createScheduledPost({
postId: post.id,
pageId: page.id,
postType: postType,
scheduledAt: scheduledAt_,
});
createdSchedules.push({ pageId: page.id, pageName: page.pageName, scheduledPostId: sp.id });
}
}
return res.status(201).json({
success: true,
post: {
id: post.id,
content: post.content,
status: post.status,
scheduledAt: scheduledAt_,
postType,
pages: createdSchedules,
media: mediaRecord
? { id: mediaRecord.id, url: mediaRecord.originalUrl }
: null,
},
});
} catch (error) {
console.error("[external API] Error:", error);
const message = error instanceof Error ? error.message : "Erreur interne";
return res.status(500).json({ error: message });
}
});
/**
* GET /api/v1/pages
*
* Liste toutes les pages disponibles (id + nom + plateforme).
* Utile pour connaître les pageIds à passer à /publish.
*/
router.get("/pages", async (_req, res) => {
try {
const users = await storage.getAllUsers();
const admin = users.find((u) => u.role === "admin");
if (!admin) {
return res.status(500).json({ error: "Aucun utilisateur admin trouvé" });
}
const pages = await storage.getSocialPages(admin.id);
return res.json(
pages.map((p) => ({
id: p.id,
pageName: p.pageName,
platform: p.platform,
pageId: p.pageId,
tokenStatus: p.tokenStatus,
}))
);
} catch (error) {
console.error("[external API] Error listing pages:", error);
return res.status(500).json({ error: "Erreur interne" });
}
});
/**
* GET /api/v1/posts
*
* Liste les publications planifiées (à venir).
*
* Query params:
* pageId string? Filtrer par page spécifique
* from ISO8601? Date de début (scheduledAt >= from)
* to ISO8601? Date de fin (scheduledAt <= to)
* status string? Statut du post (défaut: scheduled)
*/
router.get("/posts", async (req, res) => {
try {
const { pageId, from, to, status } = req.query as Record<string, string | undefined>;
const postStatus = status || "scheduled";
// Récupérer toutes les pages (ou une seule si filtrée)
let pageIds: string[];
if (pageId) {
const page = await storage.getSocialPage(pageId);
if (!page) {
return res.status(404).json({ error: `Page introuvable: ${pageId}` });
}
pageIds = [pageId];
} else {
pageIds = (await storage.getAllSocialPages()).map(p => p.id);
}
if (pageIds.length === 0) {
return res.json([]);
}
const startDate = from ? new Date(from) : undefined;
const endDate = to ? new Date(to) : undefined;
const scheduledPosts = await storage.getScheduledPostsByPages(pageIds, startDate, endDate);
// Grouper par postId, filtrer par statut et posts non publiés
const postMap = new Map<string, {
id: string;
content: string;
status: string;
scheduledFor: Date | null;
createdAt: Date | null;
media: Array<{ id: string; url: string; type: string }>;
schedules: Array<{
id: string;
pageId: string;
pageName: string;
platform: string;
postType: string;
scheduledAt: Date;
}>;
}>();
for (const sp of scheduledPosts) {
// sp = { ...scheduled_posts, post: Post, page: SocialPage }
const post = sp.post;
if (!post) continue;
// Ne montrer que les posts non publiés avec le bon statut
if (sp.publishedAt) continue;
if (post.status !== postStatus && postStatus !== "all") continue;
if (!postMap.has(post.id)) {
// Récupérer les médias du post, en une requête plutôt qu'une par média
const postMediaLinks = await storage.getPostMedia(post.id);
const byId = new Map(
(await storage.getMediaByIds(postMediaLinks.map(link => link.mediaId)))
.map(item => [item.id, item])
);
const mediaItems = postMediaLinks
.map(link => byId.get(link.mediaId))
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.map(item => ({
id: item.id,
url: item.originalUrl,
type: item.type,
}));
postMap.set(post.id, {
id: post.id,
content: post.content,
status: post.status,
scheduledFor: post.scheduledFor,
createdAt: post.createdAt,
media: mediaItems,
schedules: [],
});
}
const page = sp.page;
postMap.get(post.id)!.schedules.push({
id: sp.id,
pageId: sp.pageId,
pageName: page?.pageName || "Inconnu",
platform: page?.platform || "facebook",
postType: sp.postType,
scheduledAt: sp.scheduledAt,
});
}
return res.json(Array.from(postMap.values()));
} catch (error) {
console.error("[external API] GET /posts error:", error);
return res.status(500).json({ error: "Erreur interne" });
}
});
/**
* PATCH /api/v1/posts/:id
*
* Modifier une publication planifiée.
*
* Body:
* content string? Nouveau texte
* scheduledAt ISO8601? Nouvelle date/heure de publication
* imageUrl string? URL d'une nouvelle image (remplace l'existante)
*/
router.patch("/posts/:id", async (req, res) => {
try {
const { id } = req.params;
const { content, scheduledAt, imageUrl } = req.body;
// Vérifier que le post existe et est modifiable
const post = await storage.getPost(id);
if (!post) {
return res.status(404).json({ error: "Post introuvable" });
}
if (post.status === "published") {
return res.status(400).json({ error: "Impossible de modifier un post déjà publié" });
}
// Mettre à jour le contenu si fourni
if (content !== undefined) {
await storage.updatePost(id, { content });
}
// Mettre à jour la date de planification si fournie
if (scheduledAt !== undefined) {
const newDate = new Date(scheduledAt);
await storage.updatePost(id, { scheduledFor: newDate });
// Propager à tous les scheduled_posts du post
const scheduledPosts = await storage.getScheduledPostsByPost(id);
for (const sp of scheduledPosts) {
if (!sp.publishedAt) {
await storage.updateScheduledPost(sp.id, { scheduledAt: newDate });
}
}
}
// Remplacer le média si une nouvelle imageUrl est fournie
if (imageUrl !== undefined) {
// Résoudre le userId pour le stockage du média
const ownerId = post.userId;
const { buffer, ext, mimeType } = await downloadImage(imageUrl);
const fileName = `external-edit-${Date.now()}${ext}`;
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType);
const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image";
const mediaRecord = await storage.createMedia({
userId: ownerId,
type: mediaType,
cloudinaryPublicId: uploaded.publicId,
originalUrl: uploaded.originalUrl,
facebookFeedUrl: uploaded.facebookFeedUrl,
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
fileName,
fileSize: buffer.length,
});
// Remplacer les médias existants par le nouveau
await storage.updatePostMedia(id, [mediaRecord.id]);
}
// Retourner le post mis à jour
const updatedPost = await storage.getPost(id);
const postMediaLinks = await storage.getPostMedia(id);
const mediaItems: Array<{ id: string; url: string; type: string }> = [];
for (const link of postMediaLinks) {
const mediaItem = await storage.getMediaById(link.mediaId);
if (mediaItem) {
mediaItems.push({
id: mediaItem.id,
url: mediaItem.originalUrl,
type: mediaItem.type,
});
}
}
const scheduledPosts = await storage.getScheduledPostsByPost(id);
return res.json({
success: true,
post: {
id: updatedPost!.id,
content: updatedPost!.content,
status: updatedPost!.status,
scheduledFor: updatedPost!.scheduledFor,
createdAt: updatedPost!.createdAt,
media: mediaItems,
schedules: await Promise.all(scheduledPosts.filter(sp => !sp.publishedAt).map(async sp => {
const page = await storage.getSocialPage(sp.pageId);
return {
id: sp.id,
pageId: sp.pageId,
pageName: page?.pageName || "Inconnu",
platform: page?.platform || "facebook",
postType: sp.postType,
scheduledAt: sp.scheduledAt,
};
})),
},
});
} catch (error) {
console.error("[external API] PATCH /posts/:id error:", error);
const message = error instanceof Error ? error.message : "Erreur interne";
return res.status(500).json({ error: message });
}
});
/**
* DELETE /api/v1/posts/:id
*
* Supprimer un post planifié et toutes ses planifications associées.
* La suppression en cascade gère les scheduled_posts et post_media.
*/
router.delete("/posts/:id", async (req, res) => {
try {
const { id } = req.params;
const post = await storage.getPost(id);
if (!post) {
return res.status(404).json({ error: "Post introuvable" });
}
if (post.status === "published") {
return res.status(400).json({ error: "Impossible de supprimer un post déjà publié" });
}
await storage.deletePost(id);
return res.json({ success: true, deleted: id });
} catch (error) {
console.error("[external API] DELETE /posts/:id error:", error);
return res.status(500).json({ error: "Erreur interne" });
}
});
export { router as externalRouter };
-300
View File
@@ -1,300 +0,0 @@
/**
* Connexion et renouvellement des pages Facebook / Instagram.
*
* Une page connectée ici embarque le jeton utilisateur longue durée qui a servi
* à l'obtenir : c'est ce qui permet au `TokenManager` de régénérer son jeton de
* page tout seul, au lieu d'exiger un copier-coller depuis le Graph Explorer
* tous les deux mois.
*/
import crypto from 'crypto';
import { Router, Request, Response } from 'express';
import type { User } from '@shared/schema';
import { storage } from '../storage';
import { facebookOAuthService } from '../services/facebook_oauth';
import { TokenManager } from '../services/token_manager';
import { encrypt } from '../utils/encryption';
import { insertFacebookConfigSchema, updateFacebookConfigSchema } from '@shared/schema';
export const facebookRouter = Router();
/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */
const RETURN_PATH = '/pages';
function isAdmin(req: Request): boolean {
const user = req.user as User | undefined;
return user?.role === 'admin';
}
function buildReturnUrl(status: 'success' | 'error', message: string): string {
const params = new URLSearchParams({ facebook: status, message });
return `${RETURN_PATH}?${params.toString()}`;
}
/**
* État de la configuration Facebook.
* L'App Secret n'est jamais renvoyé au client.
*/
facebookRouter.get('/config', async (req: Request, res: Response) => {
try {
const config = await storage.getFacebookConfig();
const configured = await facebookOAuthService.isConfigured();
// 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 (!isAdmin(req)) {
return res.json({ configured });
}
res.json({
configured,
appId: config?.appId || (process.env.FACEBOOK_APP_ID ? "(défini par variable d'environnement)" : ''),
hasAppSecret: !!(config?.appSecret || process.env.FACEBOOK_APP_SECRET),
redirectUri: facebookOAuthService.getRedirectUri(req),
scopes: facebookOAuthService.getScopes().split(','),
});
} catch (error) {
console.error('❌ [Facebook] Erreur lecture configuration:', error);
res.status(500).json({ error: 'Impossible de lire la configuration Facebook' });
}
});
/** Enregistre les identifiants de l'application Facebook (admin). */
facebookRouter.put('/config', async (req: Request, res: Response) => {
try {
if (!isAdmin(req)) {
return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' });
}
const existing = await storage.getFacebookConfig();
// Le secret peut être omis lors d'une mise à jour : on garde l'existant
const schema = existing ? updateFacebookConfigSchema : insertFacebookConfigSchema;
const data = schema.parse(req.body);
const config = await storage.upsertFacebookConfig(data);
res.json({
success: true,
appId: config.appId,
redirectUri: facebookOAuthService.getRedirectUri(req),
});
} catch (error: any) {
console.error('❌ [Facebook] Erreur enregistrement configuration:', error);
res.status(400).json({
error: error?.errors?.[0]?.message || error?.message || 'Configuration Facebook invalide',
});
}
});
/**
* Démarre la connexion des pages Facebook.
* À appeler par navigation complète du navigateur (window.location), pas en fetch.
*/
facebookRouter.get('/connect', async (req: Request, res: Response) => {
try {
if (!(await facebookOAuthService.isConfigured())) {
return res.redirect(
buildReturnUrl('error', "Facebook n'est pas configuré. Renseignez d'abord l'App ID et l'App Secret.")
);
}
// Protection CSRF : l'état est vérifié au retour
const state = crypto.randomBytes(16).toString('hex');
(req.session as any).facebookOAuthState = state;
const authUrl = await facebookOAuthService.buildAuthorizationUrl(state, req);
// La session doit être écrite avant la redirection, sinon l'état est perdu
req.session.save((err) => {
if (err) {
console.error('❌ [Facebook] Impossible de sauvegarder la session OAuth:', err);
return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.'));
}
res.redirect(authUrl);
});
} catch (error) {
console.error('❌ [Facebook] Erreur démarrage OAuth:', error);
res.redirect(
buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion Facebook')
);
}
});
/**
* Retour d'autorisation Facebook : échange le code contre un jeton utilisateur
* longue durée, puis crée ou met à jour chaque page administrée.
*/
facebookRouter.get('/callback', async (req: Request, res: Response) => {
const user = req.user as User;
const { code, state, error: oauthError, error_description: oauthErrorDescription } = req.query;
try {
if (oauthError) {
const description = (oauthErrorDescription as string) || (oauthError as string);
return res.redirect(buildReturnUrl('error', `Autorisation Facebook refusée : ${description}`));
}
const expectedState = (req.session as any)?.facebookOAuthState;
delete (req.session as any)?.facebookOAuthState;
if (!state || !expectedState || state !== expectedState) {
return res.redirect(buildReturnUrl('error', 'Requête de connexion Facebook invalide (état non reconnu).'));
}
if (!code || typeof code !== 'string') {
return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de Facebook."));
}
const userToken = await facebookOAuthService.exchangeCodeForUserToken(code, req);
const targets = await facebookOAuthService.listPages(userToken.accessToken);
if (targets.length === 0) {
return res.redirect(
buildReturnUrl(
'error',
"Aucune page Facebook administrée par ce compte. Connectez-vous avec un compte administrateur de la page."
)
);
}
// Le jeton utilisateur est la clé du renouvellement automatique : il est
// conservé chiffré, sur chaque page issue de cette autorisation.
const encryptedUserToken = encrypt(userToken.accessToken);
const existingPages = await storage.getSocialPages(user.id);
let created = 0;
let updated = 0;
for (const target of targets) {
const existing = existingPages.find(
(page) => page.platform === 'facebook' && page.pageId === target.id
);
if (existing) {
await storage.updateSocialPage(existing.id, {
pageName: target.name,
accessToken: target.accessToken,
// Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire pas.
tokenExpiresAt: null,
userAccessToken: encryptedUserToken,
userTokenExpiresAt: userToken.expiresAt,
followersCount: target.followersCount,
tokenStatus: 'valid',
tokenError: null,
lastTokenCheck: new Date(),
isActive: 'true',
});
updated++;
continue;
}
const page = await storage.createSocialPage({
userId: user.id,
platform: 'facebook',
pageId: target.id,
pageName: target.name,
accessToken: target.accessToken,
tokenExpiresAt: null,
userAccessToken: encryptedUserToken,
userTokenExpiresAt: userToken.expiresAt,
followersCount: target.followersCount,
isActive: 'true',
});
created++;
// Les non-admins ne voient que les pages qui leur sont explicitement
// attribuées : sans cette permission, ils ne verraient pas leur propre page.
if (user.role !== 'admin') {
try {
await storage.createPagePermission({ userId: user.id, pageId: page.id });
} catch (permissionError) {
console.error('⚠️ [Facebook] Impossible de créer la permission de page:', permissionError);
}
}
}
// Un compte Instagram professionnel publie avec le jeton de la page Facebook
// à laquelle il est rattaché : on le rafraîchit dans la foulée, sans quoi il
// resterait sur un jeton saisi à la main, condamné à expirer.
for (const page of existingPages) {
if (page.platform !== 'instagram') continue;
const linked = targets.find((target) => target.instagram?.id === page.pageId);
if (!linked) continue;
await storage.updateSocialPage(page.id, {
accessToken: linked.accessToken,
tokenExpiresAt: null,
userAccessToken: encryptedUserToken,
userTokenExpiresAt: userToken.expiresAt,
tokenStatus: 'valid',
tokenError: null,
lastTokenCheck: new Date(),
});
updated++;
}
const summary = [
created > 0 ? `${created} page${created > 1 ? 's' : ''} connectée${created > 1 ? 's' : ''}` : null,
updated > 0 ? `${updated} page${updated > 1 ? 's' : ''} mise${updated > 1 ? 's' : ''} à jour` : null,
]
.filter(Boolean)
.join(', ');
console.log(`✅ [Facebook] ${summary} pour ${user.username}`);
res.redirect(
buildReturnUrl('success', `${summary}. Les jetons se renouvellent désormais automatiquement.`)
);
} catch (error) {
console.error('❌ [Facebook] Erreur callback OAuth:', error);
res.redirect(
buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion Facebook impossible')
);
}
});
/** Contrôle (et renouvelle si besoin) le jeton d'une page à la demande. */
facebookRouter.post('/pages/:id/refresh', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const page = await storage.getSocialPage(req.params.id);
if (!page) {
return res.status(404).json({ error: 'Page non trouvée' });
}
if (user.role !== 'admin' && page.userId !== user.id) {
const accessible = await storage.getUserAccessiblePages(user.id);
if (!accessible.some((p) => p.id === page.id)) {
return res.status(403).json({ error: 'Accès refusé à cette page' });
}
}
const result = await TokenManager.checkPage(page.id);
res.json(result);
} catch (error) {
console.error('❌ [Facebook] Erreur renouvellement du jeton:', error);
res.status(502).json({
error: error instanceof Error ? error.message : 'Impossible de contrôler le jeton',
});
}
});
/** Relance le contrôle de toutes les pages (admin). */
facebookRouter.post('/tokens/check', async (req: Request, res: Response) => {
try {
if (!isAdmin(req)) {
return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' });
}
const results = await TokenManager.checkAndRefreshTokens();
res.json({
checked: results.length,
renewed: results.filter((r) => r.renewed).length,
failing: results.filter((r) => r.status === 'expired' || r.status === 'error').length,
results,
});
} catch (error) {
console.error('❌ [Facebook] Erreur contrôle global des jetons:', error);
res.status(500).json({ error: 'Contrôle des jetons impossible' });
}
});
-470
View File
@@ -1,470 +0,0 @@
/**
* Routes API pour les Reels Facebook et la musique Jamendo
*/
import { Router, Request, Response } from 'express';
import type { User } from '@shared/schema';
import { storage } from '../storage';
import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg';
import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
import { srtText } from '@shared/srt';
import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue';
import {
qwenTargetFromConfig,
resolveGeminiApiKey,
resolveLogoPath,
resolveQwenTarget,
resolveStoreName,
} from '../services/reels/assets';
import { configuredRenderer } from '../services/reels/videoPipeline';
import { openRouterService, describeGenerationError } from '../services/openrouter';
import { estimateVoiceTiming } from '../services/ttsSync';
/** Piste musicale telle qu'attendue par le client. */
interface MusicTrack {
id: string;
title: string;
artist: string;
albumName: string;
duration: number;
previewUrl: string;
downloadUrl: string;
imageUrl: string;
license: string;
}
export const reelsRouter = Router();
// ============================================
// ROUTES MUSIQUE JAMENDO
// ============================================
/**
* Recherche de musiques par durée
* GET /api/music/search?minDuration=10&maxDuration=60&genre=pop&limit=10
*/
reelsRouter.get('/music/search', async (req: Request, res: Response) => {
try {
const {
minDuration = '10',
maxDuration = '120',
genre,
limit = '10',
offset = '0',
search,
} = req.query;
res.json({
tracks: [],
pagination: {
offset: parseInt(offset as string),
limit: parseInt(limit as string),
hasMore: false,
},
});
} catch (error) {
console.error('❌ Error searching music:', error);
res.status(500).json({ error: 'Erreur lors de la recherche de musique' });
}
});
/**
* Voir plus de musiques (pagination)
* GET /api/music/more?offset=10&minDuration=10&maxDuration=60
*/
reelsRouter.get('/music/more', async (req: Request, res: Response) => {
try {
const {
minDuration = '10',
maxDuration = '120',
genre,
limit = '10',
offset = '0',
search,
} = req.query;
res.json({
tracks: [],
pagination: {
offset: parseInt(offset as string),
limit: parseInt(limit as string),
hasMore: false,
},
});
} catch (error) {
console.error('❌ Error loading more music:', error);
res.status(500).json({ error: 'Erreur lors du chargement des musiques' });
}
});
/**
* Obtenir les tracks populaires
* GET /api/music/popular
*/
reelsRouter.get('/music/popular', async (req: Request, res: Response) => {
try {
res.json({ tracks: [] });
} catch (error) {
console.error('❌ Error fetching popular music:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des musiques populaires' });
}
});
// ============================================
// ROUTES FAVORIS MUSIQUE
// ============================================
/**
* Obtenir les favoris de l'utilisateur
* GET /api/music/favorites
*/
reelsRouter.get('/music/favorites', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const favorites = await storage.getMusicFavorites(user.id);
// Convertir en format MusicTrack pour le client
const tracks: MusicTrack[] = favorites.map(f => ({
id: f.trackId,
title: f.title,
artist: f.artist,
albumName: f.albumName || '',
duration: f.duration,
previewUrl: f.previewUrl,
downloadUrl: f.downloadUrl,
imageUrl: f.imageUrl || '',
license: f.license || '',
}));
res.json({ tracks, isFavorites: true });
} catch (error) {
console.error('❌ Error fetching music favorites:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des favoris' });
}
});
/**
* Vérifier si un track est en favori
* GET /api/music/favorites/check/:trackId
*/
reelsRouter.get('/music/favorites/check/:trackId', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { trackId } = req.params;
const isFavorite = await storage.isMusicFavorite(user.id, trackId);
res.json({ isFavorite });
} catch (error) {
console.error('❌ Error checking music favorite:', error);
res.status(500).json({ error: 'Erreur lors de la vérification du favori' });
}
});
/**
* Ajouter un favori
* POST /api/music/favorites
*/
reelsRouter.post('/music/favorites', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { trackId, title, artist, albumName, duration, previewUrl, downloadUrl, imageUrl, license } = req.body;
if (!trackId || !title) {
return res.status(400).json({ error: 'trackId et title requis' });
}
// Vérifier si déjà en favori
const alreadyFavorite = await storage.isMusicFavorite(user.id, trackId);
if (alreadyFavorite) {
return res.json({ success: true, message: 'Déjà en favori' });
}
await storage.addMusicFavorite({
userId: user.id,
trackId,
title,
artist: artist || '',
albumName: albumName || null,
duration: duration || 0,
previewUrl: previewUrl || '',
downloadUrl: downloadUrl || '',
imageUrl: imageUrl || null,
license: license || null,
});
res.json({ success: true });
} catch (error) {
console.error('❌ Error adding music favorite:', error);
res.status(500).json({ error: 'Erreur lors de l\'ajout du favori' });
}
});
/**
* Supprimer un favori
* DELETE /api/music/favorites/:trackId
*/
reelsRouter.delete('/music/favorites/:trackId', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { trackId } = req.params;
await storage.removeMusicFavorite(user.id, trackId);
res.json({ success: true });
} catch (error) {
console.error('❌ Error removing music favorite:', error);
res.status(500).json({ error: 'Erreur lors de la suppression du favori' });
}
});
// ============================================
// ROUTE DÉTAILS TRACK (après les routes /music/favorites et /music/popular)
// ============================================
/**
* Détails d'un track
* GET /api/music/:trackId
*/
reelsRouter.get('/music/:trackId', async (req: Request, res: Response) => {
try {
const { trackId } = req.params;
return res.status(404).json({ error: 'Musique non trouvée' });
} catch (error) {
console.error('❌ Error fetching music details:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des détails' });
}
});
// ============================================
// ROUTES REELS
// ============================================
/**
* Générer des textes IA pour un Reel (utilise le même service que les posts)
* POST /api/reels/generate-text
*/
reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { productInfo, model } = req.body;
if (!productInfo) {
return res.status(400).json({ error: 'Informations produit requises' });
}
// Convertir la chaîne de texte simple en objet ProductInfo
// Si l'utilisateur envoie une chaîne, on l'utilise comme description du produit
const productInfoObject = typeof productInfo === 'string'
? {
name: productInfo.trim(), // Utiliser le texte comme nom
description: productInfo.trim(), // Et comme description
}
: productInfo;
const generatedTexts = await openRouterService.generatePostText(productInfoObject, user.id, model);
// Sauvegarder la génération
await storage.createAiGeneration({
userId: user.id,
productInfo: productInfoObject,
generatedTexts,
});
res.json({ variants: generatedTexts });
} catch (error) {
console.error('❌ Error generating Reel text:', error);
const { status, message } = describeGenerationError(error);
res.status(status).json({ error: message });
}
});
/**
* Voix disponibles (dont les voix clonées Qwen) et disponibilité du moteur local
* GET /api/reels/voices
*/
reelsRouter.get('/reels/voices', async (_req: Request, res: Response) => {
try {
const catalog = await ffmpegService.listVoices(qwenTargetFromConfig(await storage.getAppConfig()));
res.json({ qwenAvailable: catalog.qwen_available, qwen: catalog.qwen, qwenStatus: catalog.qwen_status });
} catch (error) {
console.error('❌ Error listing voices:', error);
res.json({ qwenAvailable: false, qwen: [], qwenStatus: null });
}
});
/**
* Prévisualiser la voix TTS
* POST /api/reels/tts-preview
*/
reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
const parsed = ttsPreviewSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Paramètres invalides' });
}
const { text, ttsVoice, ttsEngine, ttsStyle } = parsed.data;
try {
const preview = await ffmpegService.previewVoice(text, {
voice: ttsVoice,
engine: ttsEngine,
style: ttsStyle,
geminiApiKey: await resolveGeminiApiKey(ttsEngine),
qwen: await resolveQwenTarget(ttsEngine),
});
res.json({
success: true,
audioBase64: preview.audio.toString('base64'),
duration: preview.duration,
words: preview.words,
engine: preview.engine,
voice: preview.voice,
warnings: preview.warnings,
});
} catch (error) {
console.error('❌ Error generating TTS preview:', error);
const message = error instanceof Error ? error.message : 'Erreur lors de la génération de la voix';
res.status(error instanceof FFmpegServiceError && error.status === 400 ? 400 : 502).json({ error: message });
}
});
/**
* Calculer la synchronisation texte/voix TTS
* POST /api/reels/sync-info
*/
reelsRouter.post('/reels/sync-info', async (req: Request, res: Response) => {
try {
const { text } = req.body;
if (!text) {
return res.status(400).json({ error: 'Texte requis' });
}
// Estimation locale : ne déclenche aucune synthèse (payante avec Gemini)
res.json(estimateVoiceTiming(text));
} catch (error) {
console.error('❌ Error calculating sync:', error);
res.status(500).json({ error: 'Erreur de calcul de synchronisation' });
}
});
/**
* Créer et publier un Reel : le rendu part dans la file persistante.
* POST /api/reels
*/
reelsRouter.post('/reels', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const parsed = videoReelParamsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Paramètres invalides' });
}
const { srtCues } = parsed.data;
const params: VideoReelParams = {
...parsed.data,
// Avec un SRT, le texte libre n'est plus utilisé : la voix lit les sous-titres
overlayText: srtCues ? undefined : parsed.data.overlayText,
description: parsed.data.description || (srtCues ? srtText(srtCues) : undefined),
storeName: await resolveStoreName(user.id, parsed.data.pageIds[0]),
};
const waiting = await countActiveReelJobs();
const post = await storage.createPost({
userId: user.id,
content: params.description || params.overlayText || '',
aiGenerated: 'false',
status: params.scheduledFor ? 'scheduled' : 'draft',
scheduledFor: params.scheduledFor ? new Date(params.scheduledFor) : undefined,
generationStatus: 'pending',
generationProgress: 0,
});
await enqueueReelJob({ kind: 'video', userId: user.id, postId: post.id, params });
console.log(`✨ Reel en file. Post ${post.id}, ${waiting} job(s) avant lui.`);
res.json({
success: true,
postId: post.id,
queued: waiting > 0,
message: waiting > 0
? "File d'attente occupée. Votre vidéo sera traitée dès que possible."
: 'Traitement démarré en arrière-plan.',
results: [],
videoUrl: '',
});
} catch (error) {
console.error('❌ Error initiating Reel:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Erreur lors de l\'initialisation du Reel',
});
}
});
/**
* Obtenir la configuration des services Reels
* GET /api/reels/config
*/
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
try {
const [ffmpegConfigured, logoUrl] = await Promise.all([
ffmpegService.healthCheck().catch(() => false),
resolveLogoPath(),
]);
res.json({
jamendo: { configured: false },
freesound: { configured: false },
ffmpeg: { configured: ffmpegConfigured },
// Aperçu : logo affiché par le lecteur, moteur de rendu utilisé
logoUrl: logoUrl ?? null,
renderer: configuredRenderer(),
});
} catch (error) {
console.error('❌ Error fetching Reels config:', error);
res.status(500).json({ error: 'Erreur lors de la récupération de la configuration' });
}
});
/**
* Reels en cours de génération
* GET /api/reels/ongoing
*/
reelsRouter.get('/reels/ongoing', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const ongoing = await storage.getOngoingReelPosts(user.id);
res.json(ongoing);
} catch (error) {
console.error('❌ Error fetching ongoing reels:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des reels en cours' });
}
});
/**
* Supprimer un Reel
* DELETE /api/reels/:id
*/
reelsRouter.delete('/reels/:id', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { id } = req.params;
// Vérifier si le post existe
const post = await storage.getPost(id);
if (!post) {
return res.status(404).json({ error: 'Reel non trouvé' });
}
// Vérifier les permissions (admin ou propriétaire)
if (user.role !== 'admin' && post.userId !== user.id) {
return res.status(403).json({ error: 'Non autorisé à supprimer ce Reel' });
}
// Supprimer le post (cascade supprimera scheduled_posts et liens media)
await storage.deletePost(id);
res.json({ success: true, message: 'Reel supprimé avec succès' });
} catch (error) {
console.error('❌ Error deleting Reel:', error);
res.status(500).json({ error: 'Erreur lors de la suppression du Reel' });
}
});
-226
View File
@@ -1,226 +0,0 @@
/**
* Routes de gestion des comptes TikTok.
*
* 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.
*/
import crypto from 'crypto';
import { Router, Request, Response } from 'express';
import type { User } from '@shared/schema';
import { storage } from '../storage';
import { tiktokService } from '../services/tiktok';
import { insertTiktokConfigSchema, updateTiktokConfigSchema } from '@shared/schema';
export const tiktokRouter = Router();
/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */
const RETURN_PATH = '/pages';
function isAdmin(req: Request): boolean {
const user = req.user as User | undefined;
return user?.role === 'admin';
}
function buildReturnUrl(status: 'success' | 'error', message: string): string {
const params = new URLSearchParams({ tiktok: status, message });
return `${RETURN_PATH}?${params.toString()}`;
}
/**
* État de la configuration TikTok.
* Le client secret n'est jamais renvoyé au client.
*/
tiktokRouter.get('/config', async (req: Request, res: Response) => {
try {
const config = await storage.getTiktokConfig();
const configured = await tiktokService.isConfigured();
// 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 (!isAdmin(req)) {
return res.json({ configured });
}
res.json({
configured,
clientKey: config?.clientKey || (process.env.TIKTOK_CLIENT_KEY ? '(défini par variable d\'environnement)' : ''),
hasClientSecret: !!(config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET),
redirectUri: tiktokService.getRedirectUri(req),
});
} catch (error) {
console.error('❌ [TikTok] Erreur lecture configuration:', error);
res.status(500).json({ error: 'Impossible de lire la configuration TikTok' });
}
});
/** Enregistre les identifiants de l'application développeur TikTok (admin). */
tiktokRouter.put('/config', async (req: Request, res: Response) => {
try {
if (!isAdmin(req)) {
return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' });
}
const existing = await storage.getTiktokConfig();
// Le secret peut être omis lors d'une mise à jour : on garde l'existant
const schema = existing ? updateTiktokConfigSchema : insertTiktokConfigSchema;
const data = schema.parse(req.body);
const config = await storage.upsertTiktokConfig(data);
res.json({
success: true,
clientKey: config.clientKey,
redirectUri: tiktokService.getRedirectUri(req),
});
} catch (error: any) {
console.error('❌ [TikTok] Erreur enregistrement configuration:', error);
res.status(400).json({
error: error?.errors?.[0]?.message || error?.message || 'Configuration TikTok invalide',
});
}
});
/**
* Démarre la connexion d'un compte TikTok.
* À appeler par navigation complète du navigateur (window.location), pas en fetch.
*/
tiktokRouter.get('/connect', async (req: Request, res: Response) => {
try {
if (!(await tiktokService.isConfigured())) {
return res.redirect(
buildReturnUrl('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
const state = crypto.randomBytes(16).toString('hex');
(req.session as any).tiktokOAuthState = state;
const authUrl = await tiktokService.buildAuthorizationUrl(state, req);
// La session doit être écrite avant la redirection, sinon l'état est perdu
req.session.save((err) => {
if (err) {
console.error('❌ [TikTok] Impossible de sauvegarder la session OAuth:', err);
return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.'));
}
res.redirect(authUrl);
});
} catch (error) {
console.error('❌ [TikTok] Erreur démarrage OAuth:', error);
res.redirect(
buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion TikTok')
);
}
});
/**
* Retour d'autorisation TikTok : échange le code, puis crée ou met à jour le
* compte dans social_pages.
*/
tiktokRouter.get('/callback', async (req: Request, res: Response) => {
const user = req.user as User;
const { code, state, error: oauthError, error_description: oauthErrorDescription } = req.query;
try {
if (oauthError) {
const description = (oauthErrorDescription as string) || (oauthError as string);
return res.redirect(buildReturnUrl('error', `Autorisation TikTok refusée : ${description}`));
}
const expectedState = (req.session as any)?.tiktokOAuthState;
delete (req.session as any)?.tiktokOAuthState;
if (!state || !expectedState || state !== expectedState) {
return res.redirect(buildReturnUrl('error', 'Requête de connexion TikTok invalide (état non reconnu).'));
}
if (!code || typeof code !== 'string') {
return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de TikTok."));
}
const tokens = await tiktokService.exchangeCodeForToken(code, req);
const profile = await tiktokService.getUserInfo(tokens.access_token);
const openId = profile.openId || tokens.open_id;
// Un compte déjà connecté est mis à jour plutôt que dupliqué
const existingPages = await storage.getSocialPages(user.id);
const existing = existingPages.find(p => p.platform === 'tiktok' && p.pageId === openId);
if (existing) {
await storage.updateSocialPage(existing.id, {
pageName: profile.displayName,
avatarUrl: profile.avatarUrl || null,
isActive: 'true',
});
await tiktokService.persistTokens(existing.id, tokens);
console.log(`🔁 [TikTok] Compte reconnecté: ${profile.displayName}`);
return res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" reconnecté.`));
}
const page = await storage.createSocialPage({
userId: user.id,
platform: 'tiktok',
pageId: openId,
pageName: profile.displayName,
accessToken: tokens.access_token,
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
refreshToken: tokens.refresh_token,
refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000),
scopes: tokens.scope,
avatarUrl: profile.avatarUrl || null,
isActive: 'true',
});
// 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.role !== 'admin') {
try {
await storage.createPagePermission({ userId: user.id, pageId: page.id });
} catch (permissionError) {
console.error('⚠️ [TikTok] Impossible de créer la permission de page:', permissionError);
}
}
console.log(`✅ [TikTok] Nouveau compte connecté: ${profile.displayName} (${openId})`);
res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" connecté.`));
} catch (error) {
console.error('❌ [TikTok] Erreur callback OAuth:', error);
res.redirect(
buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion TikTok impossible')
);
}
});
/**
* Options réellement autorisées sur un compte (confidentialité, commentaires,
* duo, stitch, durée max). TikTok impose de les afficher avant publication.
*/
tiktokRouter.get('/accounts/:id/creator-info', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const page = await storage.getSocialPage(req.params.id);
if (!page || page.platform !== 'tiktok') {
return res.status(404).json({ error: 'Compte TikTok non trouvé' });
}
if (user.role !== 'admin' && page.userId !== user.id) {
const accessible = await storage.getUserAccessiblePages(user.id);
if (!accessible.some(p => p.id === page.id)) {
return res.status(403).json({ error: 'Accès refusé à ce compte' });
}
}
const creatorInfo = await tiktokService.getCreatorInfo(page);
res.json(creatorInfo);
} catch (error) {
console.error('❌ [TikTok] Erreur récupération creator info:', error);
res.status(502).json({
error: error instanceof Error ? error.message : 'Impossible de contacter TikTok',
});
}
});
-297
View File
@@ -1,297 +0,0 @@
import { db } from '../db';
import {
posts,
scheduledPosts,
postAnalytics,
pageAnalyticsHistory,
socialPages,
tokenStatusEnum
} from '../../shared/schema';
import { eq, and, isNotNull, desc, gte, lt } from 'drizzle-orm';
import { GraphAPIClient } from '../utils/graph_client';
import { TokenManager } from './token_manager';
export class AnalyticsService {
/**
* Fetches latest metrics for a specific post from Facebook/Instagram
* and updates the local postAnalytics table.
* Handles aggregation if a single Post content was published to multiple destinations.
*/
static async syncPostAnalytics(postId: string) {
console.log(`[AnalyticsService] Syncing post ${postId}`);
// Find all published instances of this post with an external ID
const publishedInstances = await db.query.scheduledPosts.findMany({
where: and(
eq(scheduledPosts.postId, postId),
isNotNull(scheduledPosts.externalPostId)
),
with: {
page: true
}
});
if (publishedInstances.length === 0) {
console.log(`[AnalyticsService] No published external posts found for ${postId}`);
return;
}
let totalImpressions = 0;
let totalReach = 0;
let totalEngagement = 0;
let totalReactions = 0;
let totalComments = 0;
let totalShares = 0;
let totalClicks = 0;
const rawDataList: any[] = [];
for (const instance of publishedInstances) {
if (!instance.externalPostId || !instance.page) continue;
// Les métriques TikTok ne sont pas exposées par la Graph API
if (instance.page.platform === 'tiktok') continue;
try {
// We need the page access token
let accessToken = instance.page.accessToken;
// Decrypt if needed
if (accessToken.includes(':')) {
try { accessToken = TokenManager.decrypt(accessToken); } catch { }
}
// Determine fields based on platform (assuming Facbeook for now as per context)
// providing a generic list of fields
const fields = 'insights.metric(post_impressions,post_impressions_unique,post_engaged_users,post_clicks),shares,comments.summary(true),reactions.summary(true)';
const result = await GraphAPIClient.get<any>(instance.externalPostId, {
accessToken,
params: { fields }
});
rawDataList.push({ id: instance.externalPostId, data: result });
// Parse Metrics
// Note: Graph API structure for insights is complex (data array).
// For simplicity in this implementation, we will try to extract what we can.
// This is a simplified mapper.
const insights = result.insights?.data || [];
const findMetric = (name: string) => {
const m = insights.find((i: any) => i.name === name);
return m ? (m.values[0]?.value || 0) : 0;
};
const impressions = findMetric('post_impressions');
const reach = findMetric('post_impressions_unique');
const engagement = findMetric('post_engaged_users');
const clicks = findMetric('post_clicks'); // might be 'post_clicks_by_type'
const reactionCount = result.reactions?.summary?.total_count || 0;
const commentCount = result.comments?.summary?.total_count || 0;
const shareCount = result.shares?.count || 0;
totalImpressions += impressions;
totalReach += reach;
totalEngagement += engagement;
totalClicks += clicks; // Aggregation might be complex for unique values, but sum is okay for now
totalReactions += reactionCount;
totalComments += commentCount;
totalShares += shareCount;
} catch (error) {
console.error(`[AnalyticsService] Failed to fetch for instance ${instance.id}:`, error);
}
}
// Update DB
// Check if exists
const existing = await db.query.postAnalytics.findFirst({
where: eq(postAnalytics.postId, postId)
});
if (existing) {
await db.update(postAnalytics)
.set({
fetchedAt: new Date(),
impressions: totalImpressions,
reach: totalReach,
engagement: totalEngagement,
reactions: totalReactions,
comments: totalComments,
shares: totalShares,
clicks: totalClicks,
rawData: rawDataList
})
.where(eq(postAnalytics.id, existing.id));
} else {
await db.insert(postAnalytics).values({
postId,
fetchedAt: new Date(),
impressions: totalImpressions,
reach: totalReach,
engagement: totalEngagement,
reactions: totalReactions,
comments: totalComments,
shares: totalShares,
clicks: totalClicks,
rawData: rawDataList
});
}
}
/**
* Syncs page-level analytics (Followers, Reach, Engagement, Views)
* Each metric is fetched independently for maximum resilience.
*/
static async syncPageAnalytics(pageId: string) {
console.log(`[AnalyticsService] Syncing page ${pageId}`);
const page = await db.query.socialPages.findFirst({
where: eq(socialPages.id, pageId)
});
if (!page) return;
let accessToken = page.accessToken;
if (accessToken.includes(':')) {
try { accessToken = TokenManager.decrypt(accessToken); } catch { }
}
// 1. Fetch Basic Page Info (Followers/Fans) — Essential
// If this fails, the token is likely invalid → abort
let followers = page.followersCount || 0;
try {
const basicInfo = await GraphAPIClient.get<any>(page.pageId, {
accessToken,
params: { fields: 'fan_count' }
});
if (basicInfo.fan_count !== undefined) {
followers = basicInfo.fan_count;
}
} catch (error: any) {
console.error(`[AnalyticsService] Failed to fetch basic info for ${pageId}:`, error);
const tokenPreview = accessToken ? `${accessToken.substring(0, 10)}...` : 'undefined';
console.log(`[AnalyticsService] DEBUG: Token used: ${tokenPreview}`);
// Auth errors → mark token as expired and abort
if (error.code === 190 || error.code === 463 || error.code === 467) {
console.log(`[AnalyticsService] Marking token as EXPIRED for page ${pageId}`);
await db.update(socialPages)
.set({
tokenStatus: "expired",
lastTokenCheck: new Date()
})
.where(eq(socialPages.id, pageId));
return;
}
throw new Error(`Failed to fetch Basic Info: ${error.message || error}`);
}
// Helper: fetch a single page insight metric safely
const fetchInsight = async (metricName: string): Promise<number> => {
try {
const result = await GraphAPIClient.get<any>(
`${page.pageId}/insights/${metricName}`,
{
accessToken,
params: { period: 'day' }
}
);
const data = result.data || [];
if (data.length > 0) {
const values = data[0].values || [];
// Take the most recent value (last entry = yesterday)
return values.length > 0
? (values[values.length - 1]?.value || 0)
: 0;
}
return 0;
} catch (error: any) {
if (error.code === 100) {
console.warn(`[AnalyticsService] Metric "${metricName}" unavailable for ${pageId} (Code 100). Skipping.`);
} else {
console.warn(`[AnalyticsService] Failed to fetch "${metricName}" for ${pageId}:`, error.message || error);
}
return 0;
}
};
// 2. Fetch each insight independently (resilient)
const pageReach = await fetchInsight('page_impressions_unique');
const pageEngagement = await fetchInsight('page_post_engagements');
const pageViews = await fetchInsight('page_views_total');
console.log(`[AnalyticsService] Metrics: followers=${followers}, reach=${pageReach}, engagement=${pageEngagement}, views=${pageViews}`);
// 3. Update Page — auto-heal token status
await db.update(socialPages)
.set({
followersCount: followers,
tokenStatus: 'valid',
lastTokenCheck: new Date()
})
.where(eq(socialPages.id, pageId));
// 4. Upsert History snapshot — one entry per day per page
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const existingEntry = await db.query.pageAnalyticsHistory.findFirst({
where: and(
eq(pageAnalyticsHistory.pageId, pageId),
gte(pageAnalyticsHistory.date, today),
lt(pageAnalyticsHistory.date, tomorrow)
)
});
if (existingEntry) {
// Update today's existing entry with latest values
await db.update(pageAnalyticsHistory)
.set({
followersCount: followers,
pageReach,
pageViews,
pageEngagement,
date: new Date(),
})
.where(eq(pageAnalyticsHistory.id, existingEntry.id));
} else {
// Insert new entry for today
await db.insert(pageAnalyticsHistory).values({
pageId,
date: new Date(),
followersCount: followers,
pageReach,
pageViews,
pageEngagement,
});
}
console.log(`[AnalyticsService] Synced page ${pageId}: ${followers} followers, ${pageReach} reach, ${pageEngagement} engagement, ${pageViews} views`);
}
/**
* Syncs analytics for ALL active pages.
* Called by the CRON job.
*/
static async syncAllPages(): Promise<void> {
console.log('[AnalyticsService] Starting sync for all pages...');
// Les statistiques passent par la Graph API : les comptes TikTok en sont exclus
const allPages = (await db.query.socialPages.findMany())
.filter(page => page.platform !== 'tiktok');
for (const page of allPages) {
try {
await this.syncPageAnalytics(page.id);
} catch (error: any) {
console.error(`[AnalyticsService] Failed to sync page ${page.id}:`, error.message);
}
}
console.log(`[AnalyticsService] Sync complete for ${allPages.length} pages.`);
}
}
-204
View File
@@ -1,204 +0,0 @@
import { v2 as cloudinary } from 'cloudinary';
import { storage } from '../storage';
class CloudinaryService {
async uploadMedia(
file: Buffer | string,
fileName: string,
userId: string,
mimeType: string
): Promise<{
publicId: string;
originalUrl: string;
facebookFeedUrl: string | null;
instagramFeedUrl: string | null;
instagramStoryUrl: string | null;
}> {
// Get any available Cloudinary config (shared across all users)
const config = await storage.getCloudinaryConfig();
if (!config) {
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
}
// Configure Cloudinary with user's credentials
cloudinary.config({
cloud_name: config.cloudName,
api_key: config.apiKey,
api_secret: config.apiSecret,
secure: true,
});
const isVideo = mimeType.startsWith('video/');
// Define eager transformations for images (generated during upload)
// Note: Eager transformations are not applied to videos in this flow to avoid timeouts or huge processing
const eagerTransformations = !isVideo ? [
{ width: 1080, height: 1080, crop: 'fill', gravity: 'center' }, // Facebook Feed (square, no borders)
{ width: 1080, height: 1080, crop: 'pad', gravity: 'center', background: 'auto' }, // Instagram Feed
{ width: 1080, height: 1920, crop: 'pad', gravity: 'center', background: 'auto' } // Instagram Story
] : undefined;
const uploadOptions = {
folder: 'social-flow',
public_id: `${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`,
resource_type: isVideo ? 'video' : 'image',
eager: eagerTransformations,
eager_async: false, // Generate transformations immediately
};
let uploadResult;
if (typeof file === 'string') {
// Upload from file path (streamed by SDK)
uploadResult = await cloudinary.uploader.upload(file, {
...uploadOptions,
resource_type: isVideo ? 'video' : 'image', // Type assertion for SDK
} as any);
} else {
// Upload from buffer (memory)
uploadResult = await new Promise<any>((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
uploadOptions as any,
(error, result) => {
if (error) reject(error);
else resolve(result);
}
);
uploadStream.end(file);
});
}
const publicId = uploadResult.public_id;
const originalUrl = uploadResult.secure_url;
// Extract eager transformation URLs (generated by Cloudinary during upload)
let facebookFeedUrl = null;
let instagramFeedUrl = null;
let instagramStoryUrl = null;
if (!isVideo && uploadResult.eager && uploadResult.eager.length === 3) {
facebookFeedUrl = uploadResult.eager[0].secure_url; // Facebook Feed (1200x630)
instagramFeedUrl = uploadResult.eager[1].secure_url; // Instagram Feed (1080x1080)
instagramStoryUrl = uploadResult.eager[2].secure_url; // Instagram Story (1080x1920)
}
return {
publicId,
originalUrl,
facebookFeedUrl,
instagramFeedUrl,
instagramStoryUrl,
};
}
async deleteMedia(publicId: string, userId: string, mediaType: 'image' | 'video'): Promise<void> {
// Get any available Cloudinary config (shared across all users)
const config = await storage.getCloudinaryConfig();
if (!config) {
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
}
cloudinary.config({
cloud_name: config.cloudName,
api_key: config.apiKey,
api_secret: config.apiSecret,
secure: true,
});
await cloudinary.uploader.destroy(publicId, {
resource_type: mediaType,
});
}
async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise<string> {
const config = await storage.getCloudinaryConfig();
if (!config) {
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
}
cloudinary.config({
cloud_name: config.cloudName,
api_key: config.apiKey,
api_secret: config.apiSecret,
secure: true,
});
const uploadResult = await new Promise<any>((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
{
folder: 'social-flow/stories',
public_id: `story-${Date.now()}-${originalFileName.replace(/\.[^/.]+$/, '')}`,
resource_type: 'image',
},
(error, result) => {
if (error) reject(error);
else resolve(result);
}
);
uploadStream.end(buffer);
});
return uploadResult.secure_url;
}
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const config = await storage.getCloudinaryConfig();
if (!config) {
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
}
cloudinary.config({
cloud_name: config.cloudName,
api_key: config.apiKey,
api_secret: config.apiSecret,
secure: true,
});
const uploadResult = await new Promise<any>((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
{
folder: 'social-flow/logos',
public_id: `logo-${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`,
resource_type: 'image',
},
(error, result) => {
if (error) reject(error);
else resolve(result);
}
);
uploadStream.end(buffer);
});
return {
publicId: uploadResult.public_id,
url: uploadResult.secure_url,
};
}
async deleteLogo(publicId: string): Promise<void> {
const config = await storage.getCloudinaryConfig();
if (!config) {
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
}
cloudinary.config({
cloud_name: config.cloudName,
api_key: config.apiKey,
api_secret: config.apiSecret,
secure: true,
});
await cloudinary.uploader.destroy(publicId, {
resource_type: 'image',
});
}
}
export const cloudinaryService = new CloudinaryService();
-238
View File
@@ -1,238 +0,0 @@
import { parse } from 'twemoji-parser';
import { createCanvas, loadImage, CanvasRenderingContext2D } from 'canvas';
import sharp from 'sharp';
interface EmojiSegment {
type: 'text' | 'emoji';
content: string;
url?: string;
}
/**
* Service to render text with emojis on canvas using Twemoji
* Converts emojis to images and renders them alongside text
*/
export class EmojiRenderer {
private emojiCache = new Map<string, Buffer>();
/**
* Parse text into segments of text and emojis
*/
private parseTextWithEmojis(text: string): EmojiSegment[] {
const emojis = parse(text);
if (emojis.length === 0) {
return [{ type: 'text', content: text }];
}
const segments: EmojiSegment[] = [];
let lastIndex = 0;
for (const emoji of emojis) {
// Add text before emoji
if (emoji.indices[0] > lastIndex) {
const textContent = text.substring(lastIndex, emoji.indices[0]);
if (textContent) {
segments.push({ type: 'text', content: textContent });
}
}
// Add emoji
segments.push({
type: 'emoji',
content: emoji.text,
url: emoji.url
});
lastIndex = emoji.indices[1];
}
// Add remaining text
if (lastIndex < text.length) {
const textContent = text.substring(lastIndex);
if (textContent) {
segments.push({ type: 'text', content: textContent });
}
}
return segments;
}
/**
* Download and cache emoji image
*/
async getEmojiImage(url: string): Promise<Buffer> {
if (this.emojiCache.has(url)) {
return this.emojiCache.get(url)!;
}
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch emoji: ${response.status} ${response.statusText}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
// Convert SVG to PNG using sharp because node-canvas struggles with SVGs lacking dimensions
const pngBuffer = await sharp(buffer).png().toBuffer();
this.emojiCache.set(url, pngBuffer);
return pngBuffer;
} catch (error) {
console.error(`Error downloading emoji from ${url}:`, error);
throw error;
}
}
/**
* Measure the width of text with emojis
*/
async measureText(text: string, ctx: CanvasRenderingContext2D, fontSize: number): Promise<number> {
const segments = this.parseTextWithEmojis(text);
let totalWidth = 0;
for (const segment of segments) {
if (segment.type === 'text') {
totalWidth += ctx.measureText(segment.content).width;
} else {
// Emoji width is approximately equal to font size
totalWidth += fontSize;
}
}
return totalWidth;
}
/**
* Draw text with emojis on canvas
*/
async drawText(
text: string,
ctx: CanvasRenderingContext2D,
x: number,
y: number,
fontSize: number
): Promise<void> {
const segments = this.parseTextWithEmojis(text);
let currentX = x;
for (const segment of segments) {
if (segment.type === 'text') {
ctx.fillText(segment.content, currentX, y);
currentX += ctx.measureText(segment.content).width;
} else if (segment.url) {
try {
const emojiBuffer = await this.getEmojiImage(segment.url);
const emojiImage = await loadImage(emojiBuffer);
// Draw emoji slightly above baseline to align with text
const emojiSize = fontSize;
const emojiY = y - fontSize * 0.8; // Adjust vertical position
ctx.drawImage(emojiImage, currentX, emojiY, emojiSize, emojiSize);
currentX += emojiSize;
} catch (error) {
console.error('Error loading emoji:', error);
// Fallback: draw text representation
ctx.fillText(segment.content, currentX, y);
currentX += ctx.measureText(segment.content).width;
}
}
}
}
/**
* Break a long word that exceeds maxWidth into chunks
*/
private async breakLongWord(
word: string,
ctx: CanvasRenderingContext2D,
maxWidth: number,
fontSize: number
): Promise<string[]> {
const chunks: string[] = [];
let currentChunk = '';
// Split word into individual characters (graphemes for emoji support)
const chars = Array.from(word);
for (const char of chars) {
const testChunk = currentChunk + char;
const width = await this.measureText(testChunk, ctx, fontSize);
if (width > maxWidth && currentChunk) {
chunks.push(currentChunk);
currentChunk = char;
} else {
currentChunk = testChunk;
}
}
if (currentChunk) {
chunks.push(currentChunk);
}
return chunks;
}
/**
* Wrap text with emojis into multiple lines
* Handles long words/emoji sequences by breaking them when needed
*/
async wrapText(
text: string,
ctx: CanvasRenderingContext2D,
maxWidth: number,
fontSize: number
): Promise<string[]> {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
const testWidth = await this.measureText(testLine, ctx, fontSize);
if (testWidth > maxWidth && currentLine) {
// Current line would exceed width, push it and start new line
lines.push(currentLine);
currentLine = word;
// Check if the word itself is too long for a single line
const wordWidth = await this.measureText(word, ctx, fontSize);
if (wordWidth > maxWidth) {
// Word is too long, need to break it into chunks
const chunks = await this.breakLongWord(word, ctx, maxWidth, fontSize);
for (let i = 0; i < chunks.length - 1; i++) {
lines.push(chunks[i]);
}
currentLine = chunks[chunks.length - 1];
}
} else if (testWidth > maxWidth && !currentLine) {
// First word is too long, need to break it
const chunks = await this.breakLongWord(word, ctx, maxWidth, fontSize);
for (let i = 0; i < chunks.length - 1; i++) {
lines.push(chunks[i]);
}
currentLine = chunks[chunks.length - 1];
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
/**
* Clear emoji cache
*/
clearCache(): void {
this.emojiCache.clear();
}
}
// Singleton instance
export const emojiRenderer = new EmojiRenderer();
-840
View File
@@ -1,840 +0,0 @@
import fs from 'fs';
import path from 'path';
import { storage } from '../storage';
import type { Post, SocialPage, Media } from '@shared/schema';
import { imageProcessor } from './imageProcessor';
import { minioService, resolvePublicUrl } from './minio';
interface FacebookPhotoResponse {
id: string;
post_id: string;
}
interface FacebookFeedResponse {
id: string;
}
interface FacebookVideoResponse {
id: string;
post_id?: string;
}
interface FacebookError {
error: {
message: string;
type: string;
code: number;
};
}
export class FacebookService {
private baseUrl = 'https://graph.facebook.com/v23.0';
async publishPost(post: Post, page: SocialPage, postType: string, mediaList: Media[] = []): Promise<string> {
if (page.platform !== 'facebook') {
throw new Error('This service only supports Facebook pages');
}
if (!page.accessToken) {
throw new Error('No access token found for this page');
}
// Handle different post types
// Note: "both" type should never reach here - new posts are split at creation
// If it does reach here (legacy data), throw an error
if (postType === 'both') {
throw new Error('postType "both" is not supported. Posts should be split into separate feed and story entries at creation.');
}
if (postType === 'story') {
// Publish as story - one story per media
if (mediaList.length === 0) {
throw new Error('Stories require media (photo or video)');
}
// Publish each media as a separate story
const storyIds: string[] = [];
for (const media of mediaList) {
const storyId = await this.publishStory(post, page, media);
storyIds.push(storyId);
}
// Return all story IDs joined together
return storyIds.join(',');
} else if (postType === 'reel') {
// Publish as Reel
if (mediaList.length === 0) {
throw new Error('Reels require a video');
}
const videoMedia = mediaList.find(m => m.type === 'video');
if (!videoMedia) {
throw new Error('Reels require a video media');
}
// Use public URL so getMediaBuffer can fetch via HTTP if local file is missing
const publicUrl = resolvePublicUrl(videoMedia.originalUrl);
const nodeBuffer = await this.getMediaBuffer(publicUrl);
return await this.publishVideoFromBuffer(page, nodeBuffer, post.content);
} else {
// Default to feed
const imageMedia = mediaList.filter(m => m.type === 'image');
const videoMedia = mediaList.filter(m => m.type === 'video');
if (imageMedia.length > 1) {
// Multi-photo carousel (images only)
return await this.publishMultiPhotoPost(post, page, mediaList);
} else if (imageMedia.length === 1) {
// Single image (even if mixed with videos, prioritize the image)
return await this.publishPhotoPost(post, page, imageMedia[0]);
} else if (videoMedia.length >= 1) {
// Video post - use video endpoint
return await this.publishVideoPost(post, page, videoMedia[0]);
} else {
// Text only
return await this.publishTextPost(post, page);
}
}
}
private async publishTextPost(post: Post, page: SocialPage): Promise<string> {
const params = new URLSearchParams({
access_token: page.accessToken!,
message: post.content,
});
const url = `${this.baseUrl}/${page.pageId}/feed?${params.toString()}`;
const response = await fetch(url, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json() as FacebookError;
throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`);
}
const data = await response.json() as FacebookFeedResponse;
return data.id;
}
private async getMediaBuffer(originalUrl: string): Promise<Buffer> {
const localPath = originalUrl.startsWith('/')
? path.join(process.cwd(), originalUrl)
: originalUrl;
if (localPath.startsWith('http')) {
const response = await fetch(localPath);
if (!response.ok) throw new Error(`Failed to fetch media from ${localPath}`);
return Buffer.from(await response.arrayBuffer());
}
return fs.promises.readFile(localPath);
}
private async publishPhotoPost(post: Post, page: SocialPage, media: Media): Promise<string> {
const buffer = await this.getMediaBuffer(media.originalUrl);
const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const blob = new Blob([buffer], { type: mimeType });
const formData = new FormData();
formData.append('access_token', page.accessToken!);
if (post.content) {
formData.append('message', post.content);
}
formData.append('source', blob, path.basename(media.originalUrl));
const url = `${this.baseUrl}/${page.pageId}/photos`;
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json() as FacebookError;
throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`);
}
const data = await response.json() as FacebookPhotoResponse;
// Return the post_id which is the full post identifier
return data.post_id || data.id;
}
/**
* Generates a Facebook-compatible video URL with Cloudinary transformations
* Facebook requires: H.264 video codec, AAC audio codec, MP4 container
*/
private getFacebookCompatibleVideoUrl(originalUrl: string): string {
// Check if this is a Cloudinary URL
const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/video\/upload\//;
const match = originalUrl.match(cloudinaryPattern);
if (!match) {
// Not a Cloudinary URL, return as-is (may fail on Facebook)
console.warn('Video URL is not from Cloudinary, cannot apply transformations:', originalUrl);
return originalUrl;
}
const [fullMatch, cloudName] = match;
const videoPath = originalUrl.replace(fullMatch, '');
// Apply transformations for Facebook compatibility:
// - vc_h264: Video codec H.264
// - ac_aac: Audio codec AAC
// - f_mp4: Force MP4 format
// - q_auto: Automatic quality optimization
const transformations = 'vc_h264,ac_aac,f_mp4,q_auto';
const transformedUrl = `https://res.cloudinary.com/${cloudName}/video/upload/${transformations}/${videoPath}`;
console.log('🎬 Transforming video for Facebook compatibility:');
console.log(' Original:', originalUrl);
console.log(' Transformed:', transformedUrl);
return transformedUrl;
}
private async publishVideoPost(post: Post, page: SocialPage, media: Media): Promise<string> {
// Transform video URL to ensure Facebook compatibility (H.264/AAC/MP4)
const videoUrl = this.getFacebookCompatibleVideoUrl(resolvePublicUrl(media.originalUrl));
const params = new URLSearchParams({
access_token: page.accessToken!,
description: post.content,
file_url: videoUrl,
});
const url = `${this.baseUrl}/${page.pageId}/videos?${params.toString()}`;
const response = await fetch(url, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json() as FacebookError;
throw new Error(`Facebook API error publishing video: ${error.error.message} (code: ${error.error.code})`);
}
const data = await response.json() as FacebookVideoResponse;
// Return the post_id or id
return data.post_id || data.id;
}
private async publishMultiPhotoPost(post: Post, page: SocialPage, mediaList: Media[]): Promise<string> {
// Validate that all media are images
const imageMedia = mediaList.filter(m => m.type === 'image');
if (imageMedia.length === 0) {
throw new Error('Multi-photo posts require at least one image');
}
if (imageMedia.length !== mediaList.length) {
console.warn(`Filtered out ${mediaList.length - imageMedia.length} non-image media from multi-photo post`);
}
// Step 1: Upload all photos as unpublished and collect photo IDs
const photoIds: string[] = [];
for (const media of imageMedia) {
if (!media.originalUrl) {
console.warn(`Skipping media ${media.id} - no valid URL found`);
continue;
}
const buffer = await this.getMediaBuffer(media.originalUrl);
const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const blob = new Blob([buffer], { type: mimeType });
const formData = new FormData();
formData.append('access_token', page.accessToken!);
formData.append('published', 'false');
formData.append('source', blob, path.basename(media.originalUrl));
const uploadUrl = `${this.baseUrl}/${page.pageId}/photos`;
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
const error = await uploadResponse.json() as FacebookError;
throw new Error(`Facebook API error uploading photo: ${error.error.message} (code: ${error.error.code})`);
}
const uploadData = await uploadResponse.json() as FacebookPhotoResponse;
photoIds.push(uploadData.id);
}
if (photoIds.length === 0) {
throw new Error('No photos were successfully uploaded');
}
// Step 2: Create feed post with all attached photos
const feedParams = new URLSearchParams({
access_token: page.accessToken!,
message: post.content,
});
// Add each photo as attached_media
photoIds.forEach((photoId, index) => {
feedParams.append(`attached_media[${index}]`, JSON.stringify({ media_fbid: photoId }));
});
const feedUrl = `${this.baseUrl}/${page.pageId}/feed?${feedParams.toString()}`;
const feedResponse = await fetch(feedUrl, {
method: 'POST',
});
if (!feedResponse.ok) {
const error = await feedResponse.json() as FacebookError;
throw new Error(`Facebook API error creating feed post: ${error.error.message} (code: ${error.error.code})`);
}
const feedData = await feedResponse.json() as FacebookFeedResponse;
return feedData.id;
}
async schedulePost(post: Post, page: SocialPage, scheduledTime: Date, media?: Media): Promise<string> {
if (page.platform !== 'facebook') {
throw new Error('This service only supports Facebook pages');
}
if (!page.accessToken) {
throw new Error('No access token found for this page');
}
// Facebook requires Unix timestamp for scheduled posts
const scheduledTimestamp = Math.floor(scheduledTime.getTime() / 1000);
const now = Math.floor(Date.now() / 1000);
// Facebook requires scheduled time to be at least 10 minutes in the future
if (scheduledTimestamp < now + 600) {
throw new Error('Scheduled time must be at least 10 minutes in the future');
}
// Maximum 6 months in the future
const sixMonthsFromNow = now + (6 * 30 * 24 * 60 * 60);
if (scheduledTimestamp > sixMonthsFromNow) {
throw new Error('Scheduled time cannot be more than 6 months in the future');
}
// For scheduled posts with photos
if (media) {
return await this.schedulePhotoPost(post, page, scheduledTimestamp, media);
} else {
return await this.scheduleTextPost(post, page, scheduledTimestamp);
}
}
private async scheduleTextPost(post: Post, page: SocialPage, scheduledTimestamp: number): Promise<string> {
const params = new URLSearchParams({
access_token: page.accessToken!,
message: post.content,
published: '0',
scheduled_publish_time: scheduledTimestamp.toString(),
});
const url = `${this.baseUrl}/${page.pageId}/feed?${params.toString()}`;
const response = await fetch(url, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json() as FacebookError;
throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`);
}
const data = await response.json() as FacebookFeedResponse;
return data.id;
}
private async schedulePhotoPost(post: Post, page: SocialPage, scheduledTimestamp: number, media: Media): Promise<string> {
const buffer = await this.getMediaBuffer(media.originalUrl);
const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const blob = new Blob([buffer], { type: mimeType });
const formData = new FormData();
formData.append('access_token', page.accessToken!);
if (post.content) {
formData.append('message', post.content);
}
formData.append('published', '0');
formData.append('scheduled_publish_time', scheduledTimestamp.toString());
formData.append('source', blob, path.basename(media.originalUrl));
const url = `${this.baseUrl}/${page.pageId}/photos`;
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json() as FacebookError;
throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`);
}
const data = await response.json() as FacebookPhotoResponse;
return data.post_id || data.id;
}
private async publishStory(post: Post, page: SocialPage, media: Media): Promise<string> {
if (media.type === 'video') {
return this.publishVideoStory(post, page, media);
}
let photoBuffer = await this.getMediaBuffer(media.originalUrl);
let originalFilename = path.basename(media.originalUrl);
if (post.content && post.content.trim().length > 0) {
try {
const imageWithText = await imageProcessor.addTextToStoryImage(resolvePublicUrl(media.originalUrl), post.content);
await minioService.uploadStoryImageWithText(imageWithText, `story-${media.id}.png`);
photoBuffer = imageWithText;
originalFilename = `story-${media.id}.png`;
console.log('Story image with text generated locally');
} catch (error) {
console.error('Error generating story image with text, using original:', error);
}
}
const mimeType = originalFilename.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const blob = new Blob([photoBuffer], { type: mimeType });
const formData = new FormData();
formData.append('access_token', page.accessToken!);
formData.append('published', 'false'); // Important: must be unpublished first
formData.append('source', blob, originalFilename);
const uploadUrl = `${this.baseUrl}/${page.pageId}/photos`;
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
const error = await uploadResponse.json() as FacebookError;
throw new Error(`Facebook API error uploading photo for story: ${error.error.message} (code: ${error.error.code})`);
}
const uploadData = await uploadResponse.json() as FacebookPhotoResponse;
const photoId = uploadData.id;
// Step 2: Publish the photo as a story
const storyParams = new URLSearchParams({
access_token: page.accessToken!,
photo_id: photoId,
});
const storyUrl = `${this.baseUrl}/${page.pageId}/photo_stories?${storyParams.toString()}`;
const storyResponse = await fetch(storyUrl, {
method: 'POST',
});
if (!storyResponse.ok) {
const error = await storyResponse.json() as FacebookError;
throw new Error(`Facebook API error publishing story: ${error.error.message} (code: ${error.error.code})`);
}
const storyData = await storyResponse.json() as { success: boolean; post_id: string };
return storyData.post_id;
}
private async publishVideoStory(post: Post, page: SocialPage, media: Media): Promise<string> {
// Video stories require a 3-phase upload process:
// 1. START phase - Initialize the upload and get upload_url
// 2. UPLOAD phase - Upload the video to upload_url using file_url header
// 3. FINISH phase - Finalize and publish the story
const videoUrl = resolvePublicUrl(media.originalUrl);
const accessToken = page.accessToken!;
// Phase 1: START - Initialize the upload
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startUrl = `${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`;
const startResponse = await fetch(startUrl, {
method: 'POST',
});
if (!startResponse.ok) {
const error = await startResponse.json() as FacebookError;
throw new Error(`Facebook API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const startData = await startResponse.json() as { video_id: string; upload_url: string };
const videoId = startData.video_id;
const uploadUrl = startData.upload_url;
// Phase 2: UPLOAD - Upload the video using the upload_url
// IMPORTANT: file_url must be passed as a header, not a query parameter
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'file_url': videoUrl,
},
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`Facebook API error (UPLOAD phase): ${errorText}`);
}
// Phase 3: FINISH - Finalize and publish the story
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
});
const finishUrl = `${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`;
const finishResponse = await fetch(finishUrl, {
method: 'POST',
});
if (!finishResponse.ok) {
const error = await finishResponse.json() as FacebookError;
throw new Error(`Facebook API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishResponse.json() as { success: boolean; post_id?: string };
// Return the post_id from the finish response
return finishData.post_id || videoId;
}
/**
* Publie une vidéo en story Facebook en envoyant directement les données
* binaires (start → upload → finish sur /video_stories). Sert à doubler un
* Reel d'une story : la vidéo rendue est déjà en mémoire.
*/
async publishVideoStoryFromBuffer(page: SocialPage, videoBuffer: Buffer): Promise<string> {
if (!page.accessToken) throw new Error('No access token found for this page');
const accessToken = page.accessToken;
const fileSize = videoBuffer.length;
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`, {
method: 'POST',
});
if (!startRes.ok) {
const error = await startRes.json() as FacebookError;
throw new Error(`Facebook Story API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const { video_id: videoId, upload_url: uploadUrl } = await startRes.json() as { video_id: string; upload_url: string };
const uploadRes = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'Content-Type': 'application/octet-stream',
'offset': '0',
'file_size': fileSize.toString(),
},
body: new Blob([videoBuffer], { type: 'video/mp4' }),
});
if (!uploadRes.ok) {
throw new Error(`Facebook Story API error (UPLOAD phase): ${await uploadRes.text()}`);
}
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
});
const finishRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`, {
method: 'POST',
});
if (!finishRes.ok) {
const error = await finishRes.json() as FacebookError;
throw new Error(`Facebook Story API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishRes.json() as { success: boolean; post_id?: string };
console.log(`✅ Story vidéo publiée sur ${page.pageName}`);
return finishData.post_id || videoId;
}
/**
* Publie une vidéo comme Reel Facebook
*
* Utilise l'endpoint /{page-id}/video_reels avec un processus en 3 phases :
* 1. START - Initialise l'upload et récupère l'upload_url
* 2. UPLOAD - Upload la vidéo vers rupload.facebook.com
* 3. FINISH - Finalise et publie le Reel
*
* Spécifications vidéo requises :
* - Format : MP4, H.264
* - Ratio : 9:16 (vertical)
* - Durée : 3-90 secondes
* - Résolution recommandée : 1080x1920
*
* @param page - Page Facebook cible
* @param videoUrl - URL de la vidéo à publier
* @param description - Description du Reel (optionnel)
* @returns ID du Reel publié
*/
async publishReel(
page: SocialPage,
videoUrl: string,
description?: string
): Promise<string> {
if (page.platform !== 'facebook') {
throw new Error('This method only supports Facebook pages');
}
if (!page.accessToken) {
throw new Error('No access token found for this page');
}
console.log('🎬 Publishing Reel to Facebook Feed & Reels via /videos endpoint:', {
pageId: page.pageId,
pageName: page.pageName,
hasDescription: !!description,
});
// Use the /videos endpoint instead of /video_reels
// This ensures the video appears on the Page Timeline (Feed) AND in Reels (if 9:16)
const params = new URLSearchParams({
access_token: page.accessToken,
description: description || '',
file_url: videoUrl,
});
const url = `${this.baseUrl}/${page.pageId}/videos?${params.toString()}`;
const response = await fetch(url, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json() as FacebookError;
throw new Error(`Facebook API error publishing video/reel: ${error.error.message} (code: ${error.error.code})`);
}
const data = await response.json() as FacebookVideoResponse;
const postId = data.post_id || data.id;
console.log('✅ Reel published successfully to Feed & Reels! ID:', postId);
return postId;
}
/**
* Publie une vidéo comme Reel Facebook via l'API Reels (start → upload → finish).
* Utilise l'endpoint /video_reels pour que la vidéo soit reconnue comme un Reel.
*
* Spécifications vidéo requises pour les Reels :
* - Format : MP4, H.264
* - Ratio : 9:16 (vertical)
* - Durée : 3-90 secondes
* - Résolution recommandée : 1080x1920
*/
async publishVideoFromBuffer(
page: SocialPage,
videoBuffer: Buffer | ArrayBuffer,
description?: string
): Promise<string> {
if (!page.accessToken) throw new Error('No access token found for this page');
const buf = Buffer.isBuffer(videoBuffer) ? videoBuffer : Buffer.from(videoBuffer);
const fileSize = buf.length;
const accessToken = page.accessToken;
console.log('🎬 Publishing video as Facebook Reel via /video_reels endpoint:', {
pageId: page.pageId,
pageName: page.pageName,
size: `${(fileSize / 1024 / 1024).toFixed(2)} MB`,
});
// ── Phase 1: START - Initialize the Reel upload ──────────────────────────
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`;
const startRes = await fetch(startUrl, { method: 'POST' });
const startRaw = await startRes.text();
console.log('📤 Facebook Reel START response:', startRaw);
const startData = JSON.parse(startRaw) as any;
if (!startRes.ok || startData.error) {
const subcode = startData.error?.error_subcode;
if (subcode === 1363042) {
throw new Error(
`Le token Facebook de la page "${page.pageName}" n'a pas la permission "publish_video". ` +
`Régénérez le token dans Graph API Explorer en ajoutant les permissions : ` +
`pages_manage_posts, pages_read_engagement, publish_video. ` +
`(code: ${startData.error?.code}, subcode: ${subcode})`
);
}
throw new Error(`Facebook Reel START error: ${startData.error?.message ?? startRaw} (code: ${startData.error?.code}, subcode: ${subcode})`);
}
const videoId = startData.video_id;
const uploadUrl = startData.upload_url;
console.log(`📤 Reel upload initialized: video_id=${videoId}, upload_url=${uploadUrl}`);
// ── Phase 2: UPLOAD - Upload binary data to the upload_url ───────────────
const videoBlob = new Blob([buf], { type: 'video/mp4' });
const uploadRes = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'Content-Type': 'application/octet-stream',
'offset': '0',
'file_size': fileSize.toString(),
},
body: videoBlob,
});
if (!uploadRes.ok) {
const errorText = await uploadRes.text();
throw new Error(`Facebook Reel UPLOAD error: ${errorText}`);
}
console.log(`📤 Reel video data uploaded successfully (${fileSize} bytes)`);
// ── Phase 3: FINISH - Finalize and publish the Reel ──────────────────────
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
video_state: 'PUBLISHED',
});
if (description) {
finishParams.append('description', description);
}
const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`;
const finishRes = await fetch(finishUrl, { method: 'POST' });
const finishRaw = await finishRes.text();
console.log('📤 Facebook Reel FINISH response:', finishRaw);
const finishData = JSON.parse(finishRaw) as any;
if (!finishRes.ok || finishData.error) {
throw new Error(`Facebook Reel FINISH error: ${finishData.error?.message ?? finishRaw} (code: ${finishData.error?.code})`);
}
const postId = finishData.post_id || videoId;
console.log('✅ Reel published successfully! ID:', postId);
return postId;
}
/**
* Publie une vidéo comme Reel Facebook en uploadant directement les données binaires
* Utile quand on a la vidéo en base64 (après traitement FFmpeg)
*
* @param page - Page Facebook cible
* @param videoBuffer - Buffer de la vidéo
* @param description - Description du Reel (optionnel)
* @returns ID du Reel publié
*/
async publishReelFromBuffer(
page: SocialPage,
videoBuffer: ArrayBuffer,
description?: string
): Promise<string> {
if (page.platform !== 'facebook') {
throw new Error('This method only supports Facebook pages');
}
if (!page.accessToken) {
throw new Error('No access token found for this page');
}
const accessToken = page.accessToken;
const fileSize = videoBuffer.byteLength;
console.log('🎬 Publishing Reel from buffer:', {
pageId: page.pageId,
fileSize: `${(fileSize / 1024 / 1024).toFixed(2)} MB`,
});
// Phase 1: START
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`;
const startResponse = await fetch(startUrl, {
method: 'POST',
});
if (!startResponse.ok) {
const error = await startResponse.json() as FacebookError;
throw new Error(`Facebook Reel API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const startData = await startResponse.json() as { video_id: string; upload_url: string };
const videoId = startData.video_id;
const uploadUrl = startData.upload_url;
// Phase 2: UPLOAD - Upload binary data directly
const videoBlob = new Blob([videoBuffer], { type: 'video/mp4' });
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'Content-Type': 'application/octet-stream',
'offset': '0',
'file_size': fileSize.toString(),
},
body: videoBlob,
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`Facebook Reel API error (UPLOAD phase): ${errorText}`);
}
// Phase 3: FINISH
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
video_state: 'PUBLISHED',
});
if (description) {
finishParams.append('description', description);
}
const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`;
const finishResponse = await fetch(finishUrl, {
method: 'POST',
});
if (!finishResponse.ok) {
const error = await finishResponse.json() as FacebookError;
throw new Error(`Facebook Reel API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishResponse.json() as { success: boolean; post_id?: string };
return finishData.post_id || videoId;
}
}
export const facebookService = new FacebookService();
-295
View File
@@ -1,295 +0,0 @@
/**
* Renouvellement des tokens Facebook.
*
* Le problème que ce service résout : un token de page saisi à la main finit
* toujours par mourir (60 jours pour un token issu du Graph Explorer, ou du
* jour au lendemain si le mot de passe change, si l'utilisateur perd son rôle
* d'admin, ou si Meta révoque l'autorisation) — et on ne l'apprend qu'au
* moment où une publication échoue.
*
* La parade est celle recommandée par Meta :
* 1. l'utilisateur autorise l'application (OAuth) ;
* 2. le code est échangé contre un token utilisateur court ;
* 3. ce token court est échangé contre un token utilisateur longue durée
* (~60 jours) — c'est lui que l'on conserve, chiffré ;
* 4. les tokens de page dérivés d'un token utilisateur longue durée
* n'expirent pas.
*
* Conserver le token utilisateur permet de régénérer un token de page sans
* intervention humaine, et de prolonger le token utilisateur lui-même avant
* son échéance : tant que l'utilisateur ne retire pas son autorisation, la
* chaîne se renouvelle toute seule.
*/
import type { Request } from 'express';
import { storage } from '../storage';
import { resolvePublicBaseUrl } from '../utils/public_url';
const GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || 'v19.0';
const GRAPH_URL = `https://graph.facebook.com/${GRAPH_VERSION}`;
/** Permissions nécessaires pour publier et lire les statistiques d'une page. */
const DEFAULT_SCOPES = [
'pages_show_list',
'pages_manage_posts',
'pages_read_engagement',
'pages_manage_engagement',
'read_insights',
'business_management',
'instagram_basic',
'instagram_content_publish',
].join(',');
export interface FacebookLongLivedToken {
accessToken: string;
/** Nul quand Facebook ne renvoie pas d'échéance (jeton sans expiration). */
expiresAt: Date | null;
}
export interface FacebookPageTarget {
id: string;
name: string;
accessToken: string;
followersCount: number;
/** Compte Instagram professionnel rattaché à la page, s'il existe. */
instagram: { id: string; name: string } | null;
}
/** Résultat de /debug_token : ce que Meta sait vraiment du jeton. */
export interface FacebookTokenInfo {
isValid: boolean;
/** Nul = n'expire pas (cas normal d'un token de page). */
expiresAt: Date | null;
scopes: string[];
error: string | null;
}
export class FacebookAuthError extends Error {
constructor(message: string) {
super(message);
this.name = 'FacebookAuthError';
}
}
export class FacebookOAuthService {
/**
* Identifiants de l'application Facebook (globaux à l'instance).
* La base prime sur les variables d'environnement.
*/
private async getCredentials(): Promise<{ appId: string; appSecret: string }> {
const config = await storage.getFacebookConfig();
const appId = config?.appId || process.env.FACEBOOK_APP_ID;
const appSecret = config?.appSecret || process.env.FACEBOOK_APP_SECRET;
if (!appId || !appSecret) {
throw new FacebookAuthError(
"Facebook n'est pas configuré. Renseignez l'App ID et l'App Secret de " +
'votre application Facebook dans les paramètres.'
);
}
return { appId, appSecret };
}
async isConfigured(): Promise<boolean> {
try {
await this.getCredentials();
return true;
} catch {
return false;
}
}
/**
* URI de redirection OAuth. `req` permet de la déduire du domaine réellement
* utilisé quand `APP_URL` n'est pas défini : sans lui on renverrait
* `http://localhost:5555`, que Facebook rejette.
*/
getRedirectUri(req?: Request): string {
if (process.env.FACEBOOK_REDIRECT_URI) {
return process.env.FACEBOOK_REDIRECT_URI;
}
return `${resolvePublicBaseUrl(req)}/api/facebook/callback`;
}
getScopes(): string {
return process.env.FACEBOOK_SCOPES || DEFAULT_SCOPES;
}
/** URL d'autorisation à ouvrir pour connecter les pages d'un compte Facebook. */
async buildAuthorizationUrl(state: string, req?: Request): Promise<string> {
const { appId } = await this.getCredentials();
const params = new URLSearchParams({
client_id: appId,
redirect_uri: this.getRedirectUri(req),
state,
scope: this.getScopes(),
response_type: 'code',
});
return `https://www.facebook.com/${GRAPH_VERSION}/dialog/oauth?${params.toString()}`;
}
/**
* Échange le code d'autorisation contre un token utilisateur **longue durée**.
* L'étape intermédiaire (token court) n'est jamais conservée : seul le token
* longue durée rend les tokens de page permanents.
*/
async exchangeCodeForUserToken(code: string, req?: Request): Promise<FacebookLongLivedToken> {
const { appId, appSecret } = await this.getCredentials();
const shortLived = await this.graphGet<{ access_token?: string }>('/oauth/access_token', {
client_id: appId,
client_secret: appSecret,
redirect_uri: this.getRedirectUri(req),
code,
}, "échange du code d'autorisation");
if (!shortLived.access_token) {
throw new FacebookAuthError('Facebook n\'a pas renvoyé de jeton pour ce code d\'autorisation.');
}
return this.extendUserToken(shortLived.access_token);
}
/**
* Prolonge un token utilisateur. Appelé au retour d'OAuth (token court →
* ~60 jours) mais aussi périodiquement : ré-échanger un token longue durée
* encore valide repart pour 60 jours, ce qui évite la reconnexion manuelle.
*/
async extendUserToken(userToken: string): Promise<FacebookLongLivedToken> {
const { appId, appSecret } = await this.getCredentials();
const longLived = await this.graphGet<{ access_token?: string; expires_in?: number }>(
'/oauth/access_token',
{
grant_type: 'fb_exchange_token',
client_id: appId,
client_secret: appSecret,
fb_exchange_token: userToken,
},
'prolongation du jeton utilisateur'
);
if (!longLived.access_token) {
throw new FacebookAuthError("Facebook n'a pas renvoyé de jeton longue durée.");
}
return {
accessToken: longLived.access_token,
// expires_in absent ou nul = jeton sans expiration connue.
expiresAt: longLived.expires_in
? new Date(Date.now() + longLived.expires_in * 1000)
: null,
};
}
/**
* Pages administrées par le compte, avec leur token de page.
* Ces tokens sont permanents lorsqu'ils dérivent d'un token utilisateur
* longue durée : c'est le cœur du renouvellement automatique.
*/
async listPages(userToken: string): Promise<FacebookPageTarget[]> {
const response = await this.graphGet<{
data?: {
id: string;
name: string;
access_token: string;
fan_count?: number;
followers_count?: number;
instagram_business_account?: { id: string; name?: string; username?: string };
}[];
}>(
'/me/accounts',
{
fields: 'id,name,access_token,fan_count,followers_count,instagram_business_account{id,name,username}',
limit: '200',
access_token: userToken,
},
'récupération des pages'
);
return (response.data ?? []).map((page) => ({
id: page.id,
name: page.name,
accessToken: page.access_token,
followersCount: page.followers_count ?? page.fan_count ?? 0,
instagram: page.instagram_business_account
? {
id: page.instagram_business_account.id,
name:
page.instagram_business_account.username ||
page.instagram_business_account.name ||
page.name,
}
: null,
}));
}
/**
* Interroge Meta sur l'état réel d'un jeton. Un simple appel /me ne dit pas
* quand le jeton expire : /debug_token le dit, ce qui permet de renouveler
* *avant* la panne plutôt qu'après.
*/
async inspectToken(token: string): Promise<FacebookTokenInfo> {
const { appId, appSecret } = await this.getCredentials();
const response = await this.graphGet<{
data?: {
is_valid?: boolean;
expires_at?: number;
scopes?: string[];
error?: { message?: string };
};
}>(
'/debug_token',
{
input_token: token,
// Jeton d'application : `app_id|app_secret`, accepté par /debug_token.
access_token: `${appId}|${appSecret}`,
},
'inspection du jeton'
);
const data = response.data ?? {};
return {
isValid: data.is_valid === true,
// `expires_at: 0` signifie « n'expire pas » chez Meta.
expiresAt: data.expires_at ? new Date(data.expires_at * 1000) : null,
scopes: data.scopes ?? [],
error: data.error?.message ?? null,
};
}
private async graphGet<T>(
endpoint: string,
params: Record<string, string>,
context: string
): Promise<T> {
const url = new URL(`${GRAPH_URL}${endpoint}`);
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
let response: Response;
try {
response = await fetch(url.toString());
} catch (error) {
throw new FacebookAuthError(
`Facebook injoignable (${context}) : ${error instanceof Error ? error.message : 'erreur réseau'}`
);
}
const payload: any = await response.json().catch(() => ({}));
if (!response.ok || payload?.error) {
const message = payload?.error?.message || `HTTP ${response.status}`;
throw new FacebookAuthError(`Facebook (${context}) : ${message}`);
}
return payload as T;
}
}
export const facebookOAuthService = new FacebookOAuthService();
-356
View File
@@ -1,356 +0,0 @@
/**
* Client du service FFmpeg (conteneur Python `ffmpeg-service`).
*
* Le service rend le Reel puis expose le MP4 en téléchargement
* (GET /files/{job}/output.mp4) : la vidéo ne transite plus en base64 dans du
* JSON, qui gonflait sa taille d'un tiers et la gardait entière en mémoire.
*/
import fs from 'fs';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import type { ReadableStream as WebReadableStream } from 'stream/web';
import type { SrtCue } from '@shared/srt';
import type { TtsEngine, TtsStyle, VoiceOption } from '@shared/voices';
/** Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. */
const PROCESS_TIMEOUT_MS = 20 * 60_000;
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
// Qwen (local, CPU) met plusieurs fois la durée de la voix à la générer
const TTS_TIMEOUT_MS = 15 * 60_000;
const HEALTH_TIMEOUT_MS = 5_000;
export interface ReelRenderOptions {
text?: string;
/** Sous-titres SRT : remplacent `text`, la voix lit chacun à son instant. */
srtCues?: SrtCue[];
musicUrl?: string;
ttsEnabled?: boolean;
ttsVoice?: string;
ttsEngine?: TtsEngine;
ttsStyle?: TtsStyle;
geminiApiKey?: string;
qwen?: QwenTarget;
fontSize?: number;
musicVolume?: number;
drawText?: boolean;
stabilize?: boolean;
watermarkUrl?: string;
storeName?: string;
enableEndingEffect?: boolean;
/** Petit logo pendant la vidéo (le grand logo de fin dépend de enableEndingEffect). */
showLogo?: boolean;
}
export interface ReelRenderResult {
video: Buffer;
duration: number;
ttsEngine?: string | null;
ttsVoice?: string | null;
warnings: string[];
}
/** Rendu préparé pour Remotion : fichiers déjà écrits sur le disque. */
export interface PreparedReel {
videoPath: string;
audioPath: string | null;
totalDuration: number;
videoDuration: number;
logoStart: number | null;
/** Fondu final, calculé par le service pour ne jamais rogner la voix. */
fadeStart: number | null;
fadeDuration: number | null;
words: TimedWord[];
ttsEngine?: string | null;
ttsVoice?: string | null;
warnings: string[];
}
export interface TimedWord {
text: string;
start: number;
end: number;
}
/** Service Qwen TTS (local ou serveur GPU) réglé dans les paramètres. */
export interface QwenTarget {
url: string;
apiKey?: string;
}
export interface VoiceCatalog {
qwen: VoiceOption[];
qwen_available: boolean;
/** Modèle et carte graphique du service Qwen, null s'il ne répond pas. */
qwen_status: { model?: string; device?: string; busy?: boolean } | null;
gemini: VoiceOption[];
edge: VoiceOption[];
}
export interface VoicePreview {
audio: Buffer;
duration: number;
words: TimedWord[];
engine: string;
voice: string;
warnings: string[];
}
interface FFmpegConfig {
apiUrl: string;
apiKey: string;
}
/** Erreur renvoyée par le service, avec son message lisible. */
export class FFmpegServiceError extends Error {
constructor(message: string, readonly status?: number) {
super(message);
this.name = 'FFmpegServiceError';
}
}
export class FFmpegService {
private config: FFmpegConfig | null = null;
configure(apiUrl: string, apiKey: string): void {
this.config = { apiUrl: apiUrl.replace(/\/$/, ''), apiKey };
console.log('🎬 FFmpeg Service configured:', this.config.apiUrl);
}
private ensureConfigured(): FFmpegConfig {
if (!this.config) {
throw new FFmpegServiceError("Le service FFmpeg n'est pas configuré (FFMPEG_API_URL / FFMPEG_API_KEY).");
}
return this.config;
}
private async call(path: string, init: RequestInit & { timeoutMs: number }): Promise<Response> {
const config = this.ensureConfigured();
const { timeoutMs, headers, ...rest } = init;
const response = await fetch(`${config.apiUrl}${path}`, {
...rest,
headers: { 'X-API-Key': config.apiKey, ...headers },
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
const body = await response.text();
let detail = body;
try {
detail = JSON.parse(body).detail ?? body;
} catch { /* corps non JSON */ }
throw new FFmpegServiceError(String(detail).slice(0, 2000), response.status);
}
return response;
}
/**
* Rend un Reel à partir de l'URL d'une vidéo et renvoie le MP4 produit.
* Lève FFmpegServiceError en cas d'échec (voix comprise).
*/
async renderReel(videoUrl: string, options: ReelRenderOptions = {}): Promise<ReelRenderResult> {
const body = {
video_url: videoUrl,
text: options.text,
srt_cues: options.srtCues,
music_url: options.musicUrl,
tts_enabled: options.ttsEnabled ?? false,
tts_voice: options.ttsVoice,
tts_engine: options.ttsEngine,
tts_style: options.ttsStyle,
gemini_api_key: options.geminiApiKey,
qwen_tts_url: options.qwen?.url,
qwen_tts_api_key: options.qwen?.apiKey,
font_size: options.fontSize ?? 64,
music_volume: options.musicVolume ?? 0.25,
draw_text: options.drawText ?? true,
stabilize: options.stabilize ?? false,
watermark_url: options.watermarkUrl,
store_name: options.storeName,
enable_ending_effect: options.enableEndingEffect ?? true,
show_watermark: options.showLogo ?? true,
};
console.log('🎬 Rendu du Reel :', {
videoUrl,
textLength: options.text?.length ?? 0,
music: !!options.musicUrl,
tts: options.ttsEnabled ? `${options.ttsEngine}/${options.ttsVoice}/${options.ttsStyle ?? 'neutral'}` : false,
});
const response = await this.call('/process-reel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
timeoutMs: PROCESS_TIMEOUT_MS,
});
const data = await response.json() as {
job_id: string;
output_path: string;
duration: number;
tts_engine?: string | null;
tts_voice?: string | null;
warnings?: string[];
};
try {
const file = await this.call(data.output_path, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS });
const video = Buffer.from(await file.arrayBuffer());
for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`);
return {
video,
duration: data.duration,
ttsEngine: data.tts_engine,
ttsVoice: data.tts_voice,
warnings: data.warnings ?? [],
};
} finally {
// Le fichier n'est plus utile au service une fois récupéré
this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS })
.catch(() => { /* purgé plus tard par le service */ });
}
}
/**
* Prépare un rendu Remotion : image recadrée à la durée finale et piste son
* finale, écrites dans `targetDir`, plus le minutage des mots.
*/
async prepareReel(
videoUrl: string,
options: ReelRenderOptions & { hasLogo: boolean },
targetDir: string,
): Promise<PreparedReel> {
const response = await this.call('/prepare-reel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
video_url: videoUrl,
text: options.text,
srt_cues: options.srtCues,
music_url: options.musicUrl,
tts_enabled: options.ttsEnabled ?? false,
tts_voice: options.ttsVoice,
tts_engine: options.ttsEngine,
tts_style: options.ttsStyle,
gemini_api_key: options.geminiApiKey,
qwen_tts_url: options.qwen?.url,
qwen_tts_api_key: options.qwen?.apiKey,
music_volume: options.musicVolume ?? 0.25,
draw_text: options.drawText ?? true,
stabilize: options.stabilize ?? false,
store_name: options.storeName,
enable_ending_effect: options.enableEndingEffect ?? true,
has_logo: options.hasLogo,
}),
timeoutMs: PROCESS_TIMEOUT_MS,
});
const data = await response.json() as {
job_id: string;
video_path: string;
audio_path: string | null;
total_duration: number;
video_duration: number;
logo_start: number | null;
fade_start?: number | null;
fade_duration?: number | null;
words: TimedWord[];
tts_engine?: string | null;
tts_voice?: string | null;
warnings?: string[];
};
try {
await fs.promises.mkdir(targetDir, { recursive: true });
const videoPath = `${targetDir}/video.mp4`;
await this.downloadTo(data.video_path, videoPath);
let audioPath: string | null = null;
if (data.audio_path) {
audioPath = `${targetDir}/audio.wav`;
await this.downloadTo(data.audio_path, audioPath);
}
for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`);
return {
videoPath,
audioPath,
totalDuration: data.total_duration,
videoDuration: data.video_duration,
logoStart: data.logo_start,
fadeStart: data.fade_start ?? null,
fadeDuration: data.fade_duration ?? null,
words: data.words ?? [],
ttsEngine: data.tts_engine,
ttsVoice: data.tts_voice,
warnings: data.warnings ?? [],
};
} finally {
this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS })
.catch(() => { /* purgé plus tard par le service */ });
}
}
/** Télécharge un fichier du service en flux, sans le charger en mémoire. */
private async downloadTo(servicePath: string, target: string): Promise<void> {
const response = await this.call(servicePath, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS });
if (!response.body) throw new FFmpegServiceError(`Réponse vide pour ${servicePath}`);
await pipeline(
Readable.fromWeb(response.body as unknown as WebReadableStream<Uint8Array>),
fs.createWriteStream(target),
);
}
async healthCheck(): Promise<boolean> {
try {
await this.call('/health', { method: 'GET', timeoutMs: HEALTH_TIMEOUT_MS });
return true;
} catch {
return false;
}
}
/** Voix proposées par moteur ; `qwen_available` indique si le service local répond. */
async listVoices(qwen?: QwenTarget): Promise<VoiceCatalog> {
const headers: Record<string, string> = {};
if (qwen?.url) headers['X-Qwen-Url'] = qwen.url;
if (qwen?.apiKey) headers['X-Qwen-Key'] = qwen.apiKey;
const response = await this.call('/voices', { method: 'GET', headers, timeoutMs: 30_000 });
return await response.json() as VoiceCatalog;
}
/** Génère la voix seule (aperçu), avec le minutage de chaque mot. */
async previewVoice(
text: string,
options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string; qwen?: QwenTarget } = {},
): Promise<VoicePreview> {
const response = await this.call('/preview-tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
tts_voice: options.voice,
tts_engine: options.engine,
tts_style: options.style,
gemini_api_key: options.geminiApiKey,
qwen_tts_url: options.qwen?.url,
qwen_tts_api_key: options.qwen?.apiKey,
}),
timeoutMs: TTS_TIMEOUT_MS,
});
const data = await response.json() as {
audio_base64: string;
duration: number;
words: TimedWord[];
engine: string;
voice: string;
warnings?: string[];
};
return {
audio: Buffer.from(data.audio_base64, 'base64'),
duration: data.duration,
words: data.words ?? [],
engine: data.engine,
voice: data.voice,
warnings: data.warnings ?? [],
};
}
}
export const ffmpegService = new FFmpegService();
-251
View File
@@ -1,251 +0,0 @@
/**
* FreeSound Music API Service
*
* Intégration avec l'API FreeSound pour rechercher et récupérer des sons
* libres de droits pour les Reels.
*
* Documentation: https://freesound.org/docs/api/
*/
interface FreeSoundResult {
id: number;
name: string;
duration: number;
username: string;
tags: string[];
license: string;
previews: {
'preview-hq-mp3': string;
'preview-lq-mp3': string;
'preview-hq-ogg': string;
'preview-lq-ogg': string;
};
images: {
waveform_m: string;
waveform_l: string;
spectral_m: string;
spectral_l: string;
};
download?: string;
}
interface FreeSoundSearchResponse {
count: number;
next: string | null;
previous: string | null;
results: FreeSoundResult[];
}
export interface MusicTrack {
id: string;
title: string;
artist: string;
albumName: string;
duration: number; // en secondes
previewUrl: string; // URL audio streaming
downloadUrl: string; // URL téléchargement
imageUrl: string; // Waveform image
license: string;
}
interface MusicSearchParams {
minDuration?: number; // Durée min en secondes
maxDuration?: number; // Durée max en secondes
genre?: string; // Genre musical / tag
limit?: number; // Nombre de résultats (default: 10)
offset?: number; // Offset pour pagination
search?: string; // Recherche texte libre
}
interface FreeSoundConfig {
clientId: string;
clientSecret: string;
}
const FREESOUND_API_BASE = 'https://freesound.org/apiv2';
export class FreeSoundService {
private config: FreeSoundConfig | null = null;
/**
* Configure le service avec les credentials FreeSound
*/
configure(clientId: string, clientSecret: string): void {
this.config = { clientId, clientSecret };
console.log('🎵 FreeSound Service configured');
}
/**
* Vérifie que le service est configuré
*/
private ensureConfigured(): FreeSoundConfig {
if (!this.config) {
throw new Error('FreeSound Service not configured. Call configure() first.');
}
return this.config;
}
/**
* Convertit un résultat FreeSound en format MusicTrack unifié
*/
private mapResult(result: FreeSoundResult): MusicTrack {
return {
id: result.id.toString(),
title: result.name,
artist: result.username,
albumName: 'FreeSound',
duration: Math.round(result.duration),
previewUrl: result.previews['preview-hq-mp3'] || result.previews['preview-lq-mp3'],
downloadUrl: result.previews['preview-hq-mp3'], // On utilise le preview HQ
imageUrl: result.images?.waveform_m || '',
license: result.license,
};
}
/**
* Recherche des musiques par durée
*
* @param params - Paramètres de recherche
* @returns Liste de tracks correspondants
*/
async searchMusicByDuration(params: MusicSearchParams = {}): Promise<MusicTrack[]> {
const config = this.ensureConfigured();
const {
minDuration = 10,
maxDuration = 120,
genre,
limit = 10,
offset = 0,
search,
} = params;
// Construction du filtre de durée
const durationFilter = `duration:[${minDuration} TO ${maxDuration}]`;
// Recherche de musiques/sons musicaux
let query = search || 'music';
if (genre) {
query = `${query} ${genre}`;
}
const queryParams = new URLSearchParams({
token: config.clientSecret, // FreeSound utilise le token dans l'URL pour les requêtes simples
query: query,
filter: durationFilter,
page_size: limit.toString(),
page: Math.floor(offset / limit + 1).toString(),
fields: 'id,name,duration,username,tags,license,previews,images',
sort: 'rating_desc',
});
const url = `${FREESOUND_API_BASE}/search/text/?${queryParams.toString()}`;
console.log('🎵 Searching FreeSound:', {
query,
duration: `${minDuration}-${maxDuration}s`,
limit,
offset,
});
try {
const response = await fetch(url);
if (!response.ok) {
const errorText = await response.text();
console.error('FreeSound API error:', response.status, errorText);
throw new Error(`FreeSound API error: ${response.status}`);
}
const data = await response.json() as FreeSoundSearchResponse;
const tracks = data.results.map(result => this.mapResult(result));
console.log(`✅ Found ${tracks.length} tracks on FreeSound`);
return tracks;
} catch (error) {
console.error('❌ FreeSound search error:', error);
throw error;
}
}
/**
* Récupère les détails d'un son par son ID
*/
async getMusicDetails(trackId: string): Promise<MusicTrack | null> {
const config = this.ensureConfigured();
const url = `${FREESOUND_API_BASE}/sounds/${trackId}/?token=${config.clientSecret}&fields=id,name,duration,username,tags,license,previews,images`;
try {
const response = await fetch(url);
if (!response.ok) {
console.error('FreeSound API error:', response.status);
return null;
}
const result = await response.json() as FreeSoundResult;
return this.mapResult(result);
} catch (error) {
console.error('❌ FreeSound getMusicDetails error:', error);
return null;
}
}
/**
* Récupère des sons populaires (utilisé comme fallback ou découverte)
*/
async getPopularTracks(limit: number = 10): Promise<MusicTrack[]> {
const config = this.ensureConfigured();
const queryParams = new URLSearchParams({
token: config.clientSecret,
query: 'music loop beat',
filter: 'duration:[15 TO 90]',
page_size: limit.toString(),
fields: 'id,name,duration,username,tags,license,previews,images',
sort: 'downloads_desc',
});
const url = `${FREESOUND_API_BASE}/search/text/?${queryParams.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`FreeSound API error: ${response.status}`);
}
const data = await response.json() as FreeSoundSearchResponse;
return data.results.map(result => this.mapResult(result));
} catch (error) {
console.error('❌ FreeSound getPopularTracks error:', error);
return [];
}
}
/**
* Retourne l'URL de téléchargement/preview
*/
getDownloadUrl(track: MusicTrack): string {
return track.downloadUrl;
}
/**
* Vérifie la validité de la configuration
*/
async testConnection(): Promise<boolean> {
try {
const tracks = await this.getPopularTracks(1);
return tracks.length > 0;
} catch {
return false;
}
}
}
export const freeSoundService = new FreeSoundService();
-301
View File
@@ -1,301 +0,0 @@
import sharp from "sharp";
import { randomUUID } from "crypto";
import path from "path";
import fs from "fs/promises";
import { createCanvas, loadImage, registerFont } from "canvas";
import { removeHashtags } from "@shared/emoji";
import { emojiRenderer } from "./emojiRenderer";
interface CropDimensions {
width: number;
height: number;
}
interface ProcessedImages {
facebookFeed: string;
instagramFeed: string;
instagramStory: string;
}
const CROP_FORMATS = {
facebookFeed: { width: 1200, height: 630 } as CropDimensions,
instagramFeed: { width: 1080, height: 1080 } as CropDimensions,
instagramStory: { width: 1080, height: 1920 } as CropDimensions,
};
// Register DejaVu Sans font for proper character rendering
try {
const fontPath = path.join(process.cwd(), "server", "fonts", "DejaVuSans.ttf");
registerFont(fontPath, { family: 'DejaVu Sans' });
console.log('✓ Font DejaVu Sans enregistrée avec succès');
} catch (error) {
console.error('Erreur lors de l\'enregistrement de la police:', error);
}
export class ImageProcessor {
private uploadsDir: string;
constructor() {
this.uploadsDir = path.join(process.cwd(), "uploads");
this.ensureUploadsDirExists();
}
private async ensureUploadsDirExists() {
try {
await fs.access(this.uploadsDir);
} catch {
await fs.mkdir(this.uploadsDir, { recursive: true });
}
}
async processImage(buffer: Buffer, originalFilename: string): Promise<ProcessedImages> {
const fileExt = path.extname(originalFilename);
const baseId = randomUUID();
const processedImages: ProcessedImages = {
facebookFeed: "",
instagramFeed: "",
instagramStory: "",
};
// Process Facebook Feed (1200x630)
const fbFeedFilename = `${baseId}-fb-feed${fileExt}`;
const fbFeedPath = path.join(this.uploadsDir, fbFeedFilename);
await sharp(buffer)
.rotate() // Auto-rotate based on EXIF orientation
.resize(CROP_FORMATS.facebookFeed.width, CROP_FORMATS.facebookFeed.height, {
fit: "cover",
position: "center",
})
.toFile(fbFeedPath);
processedImages.facebookFeed = `/uploads/${fbFeedFilename}`;
// Process Instagram Feed (1080x1080)
const igFeedFilename = `${baseId}-ig-feed${fileExt}`;
const igFeedPath = path.join(this.uploadsDir, igFeedFilename);
await sharp(buffer)
.rotate() // Auto-rotate based on EXIF orientation
.resize(CROP_FORMATS.instagramFeed.width, CROP_FORMATS.instagramFeed.height, {
fit: "cover",
position: "center",
})
.toFile(igFeedPath);
processedImages.instagramFeed = `/uploads/${igFeedFilename}`;
// Process Instagram Story (1080x1920)
const igStoryFilename = `${baseId}-ig-story${fileExt}`;
const igStoryPath = path.join(this.uploadsDir, igStoryFilename);
await sharp(buffer)
.rotate() // Auto-rotate based on EXIF orientation
.resize(CROP_FORMATS.instagramStory.width, CROP_FORMATS.instagramStory.height, {
fit: "cover",
position: "center",
})
.toFile(igStoryPath);
processedImages.instagramStory = `/uploads/${igStoryFilename}`;
return processedImages;
}
async saveOriginalFile(buffer: Buffer, filename: string): Promise<string> {
const uniqueFilename = `${randomUUID()}-${filename}`;
const filePath = path.join(this.uploadsDir, uniqueFilename);
await fs.writeFile(filePath, buffer);
return `/uploads/${uniqueFilename}`;
}
async deleteFile(filePath: string): Promise<void> {
try {
const fullPath = path.join(process.cwd(), filePath);
await fs.unlink(fullPath);
} catch (error) {
console.error("Error deleting file:", error);
}
}
async addTextToStoryImage(imageUrl: string, text: string): Promise<Buffer> {
const STORY_WIDTH = 1080;
const STORY_HEIGHT = 1920;
const TEXT_BOX_HEIGHT_RATIO = 0.25;
const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 72;
const PADDING = 40;
const LINE_HEIGHT_MULTIPLIER = 1.3;
// Remove hashtags from text while keeping emojis
const cleanText = removeHashtags(text);
// Download image from URL first (Sharp can't read URLs directly)
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
throw new Error(`Failed to fetch image from URL: ${imageUrl}`);
}
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
// Pre-process image with sharp to apply rotation
const rotationBuffer = await sharp(imageBuffer)
.rotate() // Apply EXIF rotation
.toBuffer();
const canvas = createCanvas(STORY_WIDTH, STORY_HEIGHT);
const ctx = canvas.getContext('2d');
const image = await loadImage(rotationBuffer);
const imgWidth = image.width;
const imgHeight = image.height;
const targetRatio = STORY_WIDTH / STORY_HEIGHT;
const imgRatio = imgWidth / imgHeight;
let drawWidth, drawHeight, offsetX, offsetY;
if (imgRatio > targetRatio) {
drawHeight = STORY_HEIGHT;
drawWidth = imgWidth * (STORY_HEIGHT / imgHeight);
offsetX = (STORY_WIDTH - drawWidth) / 2;
offsetY = 0;
} else {
drawWidth = STORY_WIDTH;
drawHeight = imgHeight * (STORY_WIDTH / imgWidth);
offsetX = 0;
offsetY = (STORY_HEIGHT - drawHeight) / 2;
}
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, STORY_WIDTH, STORY_HEIGHT);
ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
const textBoxHeight = STORY_HEIGHT * TEXT_BOX_HEIGHT_RATIO;
const textBoxY = STORY_HEIGHT - textBoxHeight;
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(0, textBoxY, STORY_WIDTH, textBoxHeight);
const maxTextWidth = STORY_WIDTH - (PADDING * 2);
const maxTextHeight = textBoxHeight - (PADDING * 2);
let fontSize = MAX_FONT_SIZE;
let lines: string[] = [];
let totalTextHeight = 0;
while (fontSize >= MIN_FONT_SIZE) {
ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`;
lines = await emojiRenderer.wrapText(cleanText, ctx, maxTextWidth, fontSize);
const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER;
totalTextHeight = lines.length * lineHeight;
if (totalTextHeight <= maxTextHeight) {
break;
}
fontSize -= 2;
}
if (fontSize < MIN_FONT_SIZE) {
fontSize = MIN_FONT_SIZE;
ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`;
lines = await emojiRenderer.wrapText(cleanText, ctx, maxTextWidth, fontSize);
const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER;
totalTextHeight = lines.length * lineHeight;
const maxLines = Math.floor(maxTextHeight / lineHeight);
if (lines.length > maxLines) {
lines = lines.slice(0, maxLines);
if (lines[maxLines - 1]) {
lines[maxLines - 1] = lines[maxLines - 1].substring(0, lines[maxLines - 1].length - 3) + '...';
}
totalTextHeight = maxLines * lineHeight;
}
}
ctx.fillStyle = 'white';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER;
const startY = textBoxY + (textBoxHeight - totalTextHeight) / 2 + lineHeight / 2;
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
const y = startY + (index * lineHeight);
const lineWidth = await emojiRenderer.measureText(line, ctx, fontSize);
const x = (STORY_WIDTH - lineWidth) / 2; // Center the line
await emojiRenderer.drawText(line, ctx, x, y, fontSize);
}
return canvas.toBuffer('image/png');
}
private wrapText(ctx: any, text: string, maxWidth: number): string[] {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
// Current line would exceed width, push it and start new line
lines.push(currentLine);
currentLine = word;
// Check if the word itself is too long for a single line
const wordMetrics = ctx.measureText(word);
if (wordMetrics.width > maxWidth) {
// Word is too long, need to break it into chunks
const chunks = this.breakLongWord(ctx, word, maxWidth);
for (let i = 0; i < chunks.length - 1; i++) {
lines.push(chunks[i]);
}
currentLine = chunks[chunks.length - 1];
}
} else if (metrics.width > maxWidth && !currentLine) {
// First word is too long, need to break it
const chunks = this.breakLongWord(ctx, word, maxWidth);
for (let i = 0; i < chunks.length - 1; i++) {
lines.push(chunks[i]);
}
currentLine = chunks[chunks.length - 1];
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
/**
* Breaks a long word into chunks that fit within maxWidth
*/
private breakLongWord(ctx: any, word: string, maxWidth: number): string[] {
const chunks: string[] = [];
let currentChunk = '';
for (let i = 0; i < word.length; i++) {
const char = word[i];
const testChunk = currentChunk + char;
const metrics = ctx.measureText(testChunk);
if (metrics.width > maxWidth && currentChunk) {
chunks.push(currentChunk);
currentChunk = char;
} else {
currentChunk = testChunk;
}
}
if (currentChunk) {
chunks.push(currentChunk);
}
return chunks.length > 0 ? chunks : [word];
}
}
export const imageProcessor = new ImageProcessor();
-296
View File
@@ -1,296 +0,0 @@
/**
* Jamendo Music API Service
*
* Intégration avec l'API Jamendo pour rechercher et récupérer des musiques
* libres de droits pour les Reels.
*
* Documentation: https://developer.jamendo.com/v3.0
*/
interface JamendoTrack {
id: string;
name: string;
duration: number; // en secondes
artist_id: string;
artist_name: string;
album_name: string;
album_id: string;
license_ccurl: string;
position: number;
releasedate: string;
album_image: string;
audio: string; // URL du fichier audio complet
audiodownload: string; // URL de téléchargement
prourl: string;
shorturl: string;
shareurl: string;
waveform: string;
image: string;
audiodownload_allowed: boolean;
}
interface JamendoResponse {
headers: {
status: string;
code: number;
error_message: string;
warnings: string;
results_count: number;
};
results: JamendoTrack[];
}
export interface MusicTrack {
id: string;
title: string;
artist: string;
albumName: string;
duration: number; // en secondes
previewUrl: string; // URL audio streaming
downloadUrl: string; // URL téléchargement
imageUrl: string; // Pochette album
license: string;
}
interface MusicSearchParams {
minDuration?: number; // Durée min en secondes
maxDuration?: number; // Durée max en secondes
genre?: string; // Genre musical (ex: "pop", "rock", "electronic")
mood?: string; // Ambiance (ex: "happy", "sad", "energetic")
limit?: number; // Nombre de résultats (default: 10)
offset?: number; // Offset pour pagination
search?: string; // Recherche texte libre
}
interface JamendoConfig {
clientId: string;
}
const JAMENDO_API_BASE = 'https://api.jamendo.com/v3.0';
// Mapping des genres Jamendo
const GENRE_MAPPING: Record<string, string> = {
'pop': 'pop',
'rock': 'rock',
'electronic': 'electronic',
'hiphop': 'hiphop',
'jazz': 'jazz',
'classical': 'classical',
'ambient': 'ambient',
'chill': 'chillout',
'dance': 'dance',
'indie': 'indie',
};
export class JamendoService {
private config: JamendoConfig | null = null;
/**
* Configure le service avec le Client ID Jamendo
*/
configure(clientId: string): void {
this.config = { clientId };
console.log('🎵 Jamendo Service configured');
}
/**
* Vérifie que le service est configuré
*/
private ensureConfigured(): JamendoConfig {
if (!this.config) {
throw new Error('Jamendo Service not configured. Call configure() first.');
}
return this.config;
}
/**
* Convertit un track Jamendo en format MusicTrack unifié
*/
private mapTrack(track: JamendoTrack): MusicTrack {
return {
id: track.id,
title: track.name,
artist: track.artist_name,
albumName: track.album_name,
duration: track.duration,
previewUrl: track.audio,
downloadUrl: track.audiodownload,
imageUrl: track.album_image || track.image,
license: track.license_ccurl,
};
}
/**
* Recherche des musiques par durée
*
* @param params - Paramètres de recherche
* @returns Liste de tracks correspondants
*/
async searchMusicByDuration(params: MusicSearchParams = {}): Promise<MusicTrack[]> {
const config = this.ensureConfigured();
const {
minDuration = 10,
maxDuration = 120,
genre,
limit = 10,
offset = 0,
search,
} = params;
// Construction de l'URL avec les paramètres
const queryParams = new URLSearchParams({
client_id: config.clientId,
format: 'json',
limit: limit.toString(),
offset: offset.toString(),
include: 'musicinfo',
audioformat: 'mp32', // MP3 320kbps
durationbetween: `${minDuration}_${maxDuration}`,
order: 'popularity_total',
});
// Ajouter le genre si spécifié
if (genre && GENRE_MAPPING[genre.toLowerCase()]) {
queryParams.append('tags', GENRE_MAPPING[genre.toLowerCase()]);
} else if (genre) {
queryParams.append('tags', genre.toLowerCase());
}
// Ajouter la recherche texte si spécifiée
if (search) {
queryParams.append('namesearch', search);
}
const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`;
console.log('🎵 Searching Jamendo:', {
duration: `${minDuration}-${maxDuration}s`,
genre,
limit,
offset,
});
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Jamendo API error: ${response.status}`);
}
const data = await response.json() as JamendoResponse;
if (data.headers.code !== 0) {
throw new Error(`Jamendo API error: ${data.headers.error_message}`);
}
const tracks = data.results.map(track => this.mapTrack(track));
console.log(`✅ Found ${tracks.length} tracks on Jamendo`);
return tracks;
} catch (error) {
console.error('❌ Jamendo search error:', error);
throw error;
}
}
/**
* Récupère les détails d'un track par son ID
*/
async getMusicDetails(trackId: string): Promise<MusicTrack | null> {
const config = this.ensureConfigured();
const queryParams = new URLSearchParams({
client_id: config.clientId,
format: 'json',
id: trackId,
include: 'musicinfo',
audioformat: 'mp32',
});
const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Jamendo API error: ${response.status}`);
}
const data = await response.json() as JamendoResponse;
if (data.headers.code !== 0 || data.results.length === 0) {
return null;
}
return this.mapTrack(data.results[0]);
} catch (error) {
console.error('❌ Jamendo getMusicDetails error:', error);
return null;
}
}
/**
* Récupère des tracks populaires (utilisé comme fallback ou découverte)
*/
async getPopularTracks(limit: number = 10): Promise<MusicTrack[]> {
const config = this.ensureConfigured();
const queryParams = new URLSearchParams({
client_id: config.clientId,
format: 'json',
limit: limit.toString(),
include: 'musicinfo',
audioformat: 'mp32',
order: 'popularity_week',
durationbetween: '15_90', // Durée idéale pour Reels
});
const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Jamendo API error: ${response.status}`);
}
const data = await response.json() as JamendoResponse;
if (data.headers.code !== 0) {
throw new Error(`Jamendo API error: ${data.headers.error_message}`);
}
return data.results.map(track => this.mapTrack(track));
} catch (error) {
console.error('❌ Jamendo getPopularTracks error:', error);
return [];
}
}
/**
* Télécharge l'URL du fichier audio (avec redirection si nécessaire)
* Note: Jamendo fournit directement les URLs de téléchargement
*/
getDownloadUrl(track: MusicTrack): string {
// L'URL de téléchargement Jamendo est directement utilisable
return track.downloadUrl;
}
/**
* Vérifie la validité de la configuration
*/
async testConnection(): Promise<boolean> {
try {
const tracks = await this.getPopularTracks(1);
return tracks.length > 0;
} catch {
return false;
}
}
}
export const jamendoService = new JamendoService();
-64
View File
@@ -1,64 +0,0 @@
import { db } from '../db';
import { media } from '@shared/schema';
import { eq, lt, and } from 'drizzle-orm';
import { minioService as storageService } from './minio';
/**
* Purge old local media to save disk space:
* - Images older than 30 days
* - Videos older than 7 days
*/
export async function purgeOldMedia() {
console.log('[Media Purge] Starting automatic purge of old media...');
try {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const oldImages = await db.query.media.findMany({
where: (m, { and, lt, eq }) => and(eq(m.type, 'image'), lt(m.createdAt, thirtyDaysAgo))
});
const oldVideos = await db.query.media.findMany({
where: (m, { and, lt, eq }) => and(eq(m.type, 'video'), lt(m.createdAt, sevenDaysAgo))
});
const toDelete = [...oldImages, ...oldVideos];
if (toDelete.length === 0) {
console.log('[Media Purge] No old media found to purge.');
return;
}
console.log(`[Media Purge] Found ${oldImages.length} old images (>30 days) and ${oldVideos.length} old videos (>7 days) to purge.`);
let successCount = 0;
let errorCount = 0;
for (const item of toDelete) {
try {
if (item.cloudinaryPublicId) {
await storageService.deleteMedia(item.cloudinaryPublicId, item.userId, item.type === 'video' ? 'video' : 'image');
}
// La vignette n'a plus de raison d'être une fois la ligne supprimée
if (item.thumbnailUrl?.startsWith('/uploads/')) {
await storageService
.deleteMedia(item.thumbnailUrl.replace('/uploads/', ''), item.userId, 'image')
.catch(err => console.warn(`[Media Purge] Vignette non supprimée pour ${item.id}:`, err));
}
await db.delete(media).where(eq(media.id, item.id));
successCount++;
} catch (err) {
console.error(`[Media Purge] Failed to delete media ${item.id}:`, err);
errorCount++;
}
}
console.log(`[Media Purge] Purge completed. Deleted: ${successCount}, Failed: ${errorCount}.`);
} catch (error) {
console.error('[Media Purge] Fatal error during media purge process:', error);
}
}
-143
View File
@@ -1,143 +0,0 @@
import fs from 'fs';
import path from 'path';
const UPLOADS_BASE = path.resolve(process.cwd(), 'uploads');
function ensureDir(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Builds the public URL for a locally stored file.
* Returns a relative path so it works on any domain/protocol.
* objectKey is the relative path from uploads/ (e.g. "media/123-file.jpg").
*/
export function buildMinioUrl(_bucketName: string, objectKey: string, _publicUrlOverride?: string | null): string {
return `/uploads/${objectKey}`;
}
/**
* Resolves a possibly-relative URL to an absolute URL for use by external
* services (Facebook API, Instagram API) that must download the file.
* Uses APP_URL env var (public-facing domain).
*/
export function resolvePublicUrl(url: string): string {
if (!url || !url.startsWith('/')) return url;
const base = (process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, '');
return `${base}${url}`;
}
/**
* Resolves a possibly-relative URL to an absolute URL for use by internal
* Docker services (FFmpeg, etc.) that communicate via internal Docker network.
* Uses INTERNAL_APP_URL env var.
*/
export function resolveInternalUrl(url: string): string {
if (!url || !url.startsWith('/')) return url;
const base = (process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, '');
return `${base}${url}`;
}
/**
* Charge un média en mémoire, que son URL soit locale (/uploads/...) ou distante.
* Nécessaire pour les APIs qui veulent recevoir le fichier plutôt qu'une URL
* (upload TikTok, Reels Facebook).
*/
export async function readMediaBuffer(url: string): Promise<Buffer> {
if (url.startsWith('/')) {
const localPath = path.join(process.cwd(), url);
if (fs.existsSync(localPath)) {
return await fs.promises.readFile(localPath);
}
}
const absoluteUrl = resolvePublicUrl(url);
const response = await fetch(absoluteUrl);
if (!response.ok) {
throw new Error(`Impossible de récupérer le média depuis ${absoluteUrl} (${response.status})`);
}
return Buffer.from(await response.arrayBuffer());
}
class LocalStorageService {
async uploadMedia(
file: Buffer | string,
fileName: string,
_userId: string,
_mimeType: string
): Promise<{
publicId: string;
originalUrl: string;
facebookFeedUrl: string | null;
instagramFeedUrl: string | null;
instagramStoryUrl: string | null;
}> {
const dir = path.join(UPLOADS_BASE, 'media');
ensureDir(dir);
const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '-');
const objectKey = `media/${Date.now()}-${safeName}`;
const filePath = path.join(UPLOADS_BASE, objectKey);
const body: Buffer = typeof file === 'string' ? fs.readFileSync(file) : file;
fs.writeFileSync(filePath, body);
const url = buildMinioUrl('', objectKey);
return { publicId: objectKey, originalUrl: url, facebookFeedUrl: url, instagramFeedUrl: url, instagramStoryUrl: url };
}
async deleteMedia(publicId: string, _userId: string, _mediaType: 'image' | 'video'): Promise<void> {
const filePath = path.join(UPLOADS_BASE, publicId);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise<string> {
const dir = path.join(UPLOADS_BASE, 'stories');
ensureDir(dir);
const safeName = path.basename(originalFileName).replace(/[^a-zA-Z0-9._-]/g, '-');
const objectKey = `stories/story-${Date.now()}-${safeName}`;
fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer);
return buildMinioUrl('', objectKey);
}
/**
* Range une vignette vidéo. Elle survit volontairement à la suppression de la
* vidéo source, pour que l'historique reste illustré.
*/
async uploadThumbnail(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const dir = path.join(UPLOADS_BASE, 'thumbnails');
ensureDir(dir);
const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '-');
const objectKey = `thumbnails/${safeName}`;
fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer);
return { publicId: objectKey, url: buildMinioUrl('', objectKey) };
}
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const dir = path.join(UPLOADS_BASE, 'logos');
ensureDir(dir);
const ext = path.extname(fileName) || '.png';
const objectKey = `logos/logo-${Date.now()}${ext}`;
fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer);
return { publicId: objectKey, url: buildMinioUrl('', objectKey) };
}
async deleteLogo(publicId: string): Promise<void> {
const filePath = path.join(UPLOADS_BASE, publicId);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
}
export const minioService = new LocalStorageService();
-289
View File
@@ -1,289 +0,0 @@
interface ProductInfo {
name: string;
description?: string;
price?: string;
features?: string[];
[key: string]: any;
}
interface GeneratedText {
variant: string;
text: string;
characterCount: number;
}
import crypto from 'crypto';
import { storage } from '../storage';
/**
* Extrait le message d'erreur d'une réponse OpenRouter, qu'elle soit au format
* JSON ({ error: { message } }) ou en texte brut. Tronqué pour éviter de
* déverser une page HTML entière dans l'interface.
*/
function extractUpstreamMessage(body: string): string {
const trimmed = (body || '').trim();
if (!trimmed) return 'aucun détail fourni';
try {
const parsed = JSON.parse(trimmed);
const message = parsed?.error?.message || parsed?.message || parsed?.error;
if (typeof message === 'string' && message.trim()) {
return message.trim().slice(0, 300);
}
} catch {
// Réponse non-JSON (page d'erreur HTML d'un proxy, par exemple)
}
return trimmed.replace(/\s+/g, ' ').slice(0, 300);
}
/**
* Traduit une erreur de génération en réponse HTTP exploitable côté interface.
* Sans cela, l'utilisateur ne voit qu'un 500 générique alors que la cause
* (configuration absente, clé invalide, crédits épuisés) est connue du serveur.
*/
export function describeGenerationError(error: unknown): { status: number; message: string } {
const message = error instanceof Error ? error.message : 'Erreur inconnue lors de la génération';
// Problème de configuration : l'administrateur peut le corriger lui-même
if (/Configuration OpenRouter non trouvée|Clé API OpenRouter manquante/.test(message)) {
return { status: 400, message };
}
// Refus du service tiers
if (/OpenRouter a refusé la requête|Réponse invalide de l'API OpenRouter/.test(message)) {
return { status: 502, message };
}
return { status: 500, message };
}
export class OpenRouterService {
private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
async generatePostText(productInfo: ProductInfo, userId: string, modelOverride?: string): Promise<GeneratedText[]> {
// Prefer the requesting user's own configuration; fall back to the most
// recently saved shared configuration if this user hasn't set one up.
const config = (await storage.getOpenrouterConfig(userId)) || (await storage.getAnyOpenrouterConfig());
if (!config) {
throw new Error('Configuration OpenRouter non trouvée. Veuillez demander à un administrateur de configurer OpenRouter dans les Paramètres.');
}
if (!config.apiKey || !config.apiKey.trim()) {
throw new Error('Clé API OpenRouter manquante ou vide en base de données. Veuillez la ressaisir dans les Paramètres.');
}
const prompt = this.buildPrompt(productInfo, config.systemPrompt);
// Use provided model or fall back to config model
const modelToUse = modelOverride || config.model;
const keyFingerprint = crypto.createHash('sha256').update(config.apiKey).digest('hex').slice(0, 8);
console.log(`[OpenRouter] Generating with model="${modelToUse}" keyFingerprint=${keyFingerprint} keyLength=${config.apiKey.length} configUserId=${config.userId}`);
try {
const response = await fetch(this.baseUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${config.apiKey.trim()}`,
"Content-Type": "application/json",
"HTTP-Referer": process.env.APP_URL || "http://localhost:5555",
"X-Title": "Social Flow"
},
body: JSON.stringify({
model: modelToUse,
messages: [
{
role: "user",
content: prompt
}
],
temperature: 0.8,
max_tokens: 1000
})
});
if (!response.ok) {
const errorText = await response.text();
console.error('OpenRouter API Error Response:', errorText);
// Le motif renvoyé par OpenRouter (clé invalide, crédits épuisés, modèle
// inconnu…) est la seule information exploitable : on le fait remonter
// jusqu'à l'utilisateur, qui n'a pas accès aux logs du serveur.
throw new Error(
`OpenRouter a refusé la requête (${response.status} ${response.statusText}) ` +
`avec le modèle "${modelToUse}" : ${extractUpstreamMessage(errorText)}`
);
}
const data = await response.json();
if (!data.choices || !data.choices[0]) {
console.error('Invalid OpenRouter response - no choices:', { hasChoices: !!data.choices, model: modelToUse });
throw new Error('Réponse invalide de l\'API OpenRouter. Vérifiez votre clé API et votre modèle.');
}
const content = data.choices[0]?.message?.content || "";
return this.parseGeneratedText(content);
} catch (error) {
console.error("Error generating text with OpenRouter:", error);
throw error;
}
}
private buildPrompt(productInfo: ProductInfo, systemPrompt: string): string {
const features = productInfo.features?.join(", ") || "";
const productDetails = `Nom: ${productInfo.name}
${productInfo.description ? `Description: ${productInfo.description}` : ''}
${productInfo.price ? `Prix: ${productInfo.price}` : ''}
${features ? `Caractéristiques: ${features}` : ''}`;
return `${systemPrompt}
Informations produit:
${productDetails}
Format de réponse:
VERSION 1 - DYNAMIQUE:
[texte]
VERSION 2 - INFORMATIVE:
[texte]
VERSION 3 - ÉMOTIONNELLE:
[texte]`;
}
private parseGeneratedText(content: string): GeneratedText[] {
// Normaliser les retours à la ligne
const normalizedContent = content.replace(/\r\n/g, '\n').trim();
console.log('🔍 Parsing AI response (length:', normalizedContent.length, ')');
// Stratégie 1: Détection des headers VERSION avec parsing intelligent
const variants = this.tryMultipleParsingStrategies(normalizedContent);
if (variants.length > 0) {
console.log('✅ Parsed successfully:', variants.length, 'variants');
return variants;
}
// Fallback: retourner le texte complet
console.log('⚠️ Parsing failed, using fallback');
return [{
variant: "Généré par IA",
text: normalizedContent,
characterCount: normalizedContent.length
}];
}
private tryMultipleParsingStrategies(content: string): GeneratedText[] {
// Stratégie 1: Split par headers de VERSION (le plus flexible)
const strategy1 = this.parseByVersionHeaders(content);
if (strategy1.length >= 3) return strategy1;
// Stratégie 2: Regex avec markdown **VERSION N:**
const strategy2 = this.parseWithMarkdownHeaders(content);
if (strategy2.length >= 3) return strategy2;
// Stratégie 3: Regex simple sans markdown
const strategy3 = this.parseWithSimpleHeaders(content);
if (strategy3.length >= 3) return strategy3;
// Retourner le meilleur résultat obtenu
return strategy1.length > 0 ? strategy1 : (strategy2.length > 0 ? strategy2 : strategy3);
}
private parseByVersionHeaders(content: string): GeneratedText[] {
const variants: GeneratedText[] = [];
// Chercher tous les patterns VERSION N (très flexible)
const versionPattern = /(?:\*{2})?VERSION\s+(\d+)\s*[-–—]\s*([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ]+)(?:\*{2})?:?/gi;
const matches = Array.from(content.matchAll(versionPattern));
if (matches.length < 3) return variants;
// Extraire le texte entre chaque header
for (let i = 0; i < matches.length; i++) {
const match = matches[i];
const versionNum = match[1];
const style = match[2];
const startPos = match.index! + match[0].length;
const endPos = i < matches.length - 1 ? matches[i + 1].index! : content.length;
let text = content.substring(startPos, endPos).trim();
// Nettoyer le texte (enlever les séparateurs, notes entre parenthèses au début, etc.)
text = text.replace(/^[-*]{3,}\s*/gm, ''); // Enlever les séparateurs
text = text.replace(/^\([^)]+\)\s*/gm, ''); // Enlever les notes entre parenthèses
text = text.trim();
if (text.length > 0) {
variants.push({
variant: `Version ${versionNum} - ${this.capitalizeFirst(style.toLowerCase())}`,
text,
characterCount: text.length
});
}
}
return variants;
}
private parseWithMarkdownHeaders(content: string): GeneratedText[] {
const variants: GeneratedText[] = [];
const v1 = content.match(/\*{2}VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\*{2}\s*\n([\s\S]*?)(?=\*{2}VERSION\s+2|$)/i);
const v2 = content.match(/\*{2}VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\*{2}\s*\n([\s\S]*?)(?=\*{2}VERSION\s+3|$)/i);
const v3 = content.match(/\*{2}VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\*{2}\s*\n([\s\S]*?)$/i);
if (v1) variants.push({ variant: "Version 1 - Dynamique", text: v1[1].trim(), characterCount: v1[1].trim().length });
if (v2) variants.push({ variant: "Version 2 - Informative", text: v2[1].trim(), characterCount: v2[1].trim().length });
if (v3) variants.push({ variant: "Version 3 - Émotionnelle", text: v3[1].trim(), characterCount: v3[1].trim().length });
return variants;
}
private parseWithSimpleHeaders(content: string): GeneratedText[] {
const variants: GeneratedText[] = [];
const v1 = content.match(/VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\s*\n([\s\S]*?)(?=VERSION\s+2|$)/i);
const v2 = content.match(/VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\s*\n([\s\S]*?)(?=VERSION\s+3|$)/i);
const v3 = content.match(/VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\s*\n([\s\S]*?)$/i);
if (v1) variants.push({ variant: "Version 1 - Dynamique", text: v1[1].trim(), characterCount: v1[1].trim().length });
if (v2) variants.push({ variant: "Version 2 - Informative", text: v2[1].trim(), characterCount: v2[1].trim().length });
if (v3) variants.push({ variant: "Version 3 - Émotionnelle", text: v3[1].trim(), characterCount: v3[1].trim().length });
return variants;
}
private capitalizeFirst(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
}
async getAvailableModels(): Promise<any[]> {
try {
const response = await fetch("https://openrouter.ai/api/v1/models", {
method: "GET",
headers: {
"Content-Type": "application/json",
}
});
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data.data || [];
} catch (error) {
console.error("Error fetching OpenRouter models:", error);
throw error;
}
}
}
export const openRouterService = new OpenRouterService();
-94
View File
@@ -1,94 +0,0 @@
/**
* Résolution des ressources d'un Reel (musique, logo, clé Gemini, nom du
* magasin), jusqu'ici recopiée dans chaque route.
*/
import type { QwenTarget } from "../ffmpeg";
import { storage } from "../../storage";
import { buildMinioUrl, resolveInternalUrl } from "../minio";
/**
* URL de la musique téléchargeable par le service FFmpeg (réseau Docker
* interne : l'URL publique HTTPS n'y est pas joignable).
*/
export async function resolveMusicUrl(
musicTrackId?: string,
musicUrl?: string,
): Promise<string | undefined> {
if (musicUrl) return resolveInternalUrl(musicUrl);
if (!musicTrackId?.startsWith("internal_")) return undefined;
try {
const track = await storage.getAudioTrack(musicTrackId.slice("internal_".length));
return track ? resolveInternalUrl(track.url) : undefined;
} catch (error) {
console.error("⚠️ [Reels] Piste audio introuvable :", error);
return undefined;
}
}
/** Chemin relatif (/uploads/...) du logo configuré, s'il existe. */
export async function resolveLogoPath(): Promise<string | undefined> {
try {
const config = await storage.getCloudinaryConfig();
if (config?.logoPublicId) {
return buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl);
}
} catch (error) {
console.error("⚠️ [Reels] Configuration du logo illisible :", error);
}
return undefined;
}
/**
* Clé Gemini : configuration de l'application, sinon variable d'environnement.
* Aussi transmise avec Qwen, qui se replie sur Gemini si le service local échoue.
*/
export async function resolveGeminiApiKey(ttsEngine?: string): Promise<string | undefined> {
if (ttsEngine !== "gemini" && ttsEngine !== "qwen") return undefined;
const appConfig = await storage.getAppConfig();
return appConfig?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
}
/** Service Qwen TTS réglé dans les paramètres (sinon ffmpeg-api prend QWEN_TTS_URL). */
export async function resolveQwenTarget(ttsEngine?: string): Promise<QwenTarget | undefined> {
if (ttsEngine !== "qwen") return undefined;
return qwenTargetFromConfig(await storage.getAppConfig());
}
/** Adresse http(s) du service Qwen saisie dans les paramètres, sans « / » final ; null si invalide. */
export function normalizeQwenUrl(value: unknown): string | null {
if (typeof value !== "string" || !value.trim()) return null;
try {
const url = new URL(value.trim());
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.toString().replace(/\/+$/, "");
} catch {
return null;
}
}
export function qwenTargetFromConfig(
appConfig: { qwenTtsUrl?: string | null; qwenTtsApiKey?: string | null } | undefined,
): QwenTarget | undefined {
if (!appConfig?.qwenTtsUrl) return undefined;
return { url: appConfig.qwenTtsUrl, apiKey: appConfig.qwenTtsApiKey ?? undefined };
}
/** Nom affiché dans l'outro : celui de la page choisie, sinon de la première page de l'utilisateur. */
export async function resolveStoreName(
userId: string,
preferredPageId?: string,
): Promise<string | undefined> {
try {
if (preferredPageId) {
const page = await storage.getSocialPage(preferredPageId);
if (page) return page.pageName;
}
const pages = await storage.getSocialPages(userId);
return pages[0]?.pageName;
} catch (error) {
console.error("⚠️ [Reels] Nom du magasin introuvable :", error);
return undefined;
}
}
-12
View File
@@ -1,12 +0,0 @@
/**
* Point d'entrée des Reels : enregistre les traitements de la file et démarre
* le worker.
*/
import { registerReelJobHandler, startReelWorker } from "./queue";
import { runVideoReelJob } from "./videoPipeline";
export function startReelProcessing(): Promise<void> {
registerReelJobHandler("video", runVideoReelJob);
return startReelWorker();
}
-189
View File
@@ -1,189 +0,0 @@
/**
* Enregistrement et publication d'un Reel rendu, commun aux Reels vidéo et
* images.
*
* La vidéo est toujours envoyée en binaire : les fichiers vivent sous
* /uploads, une URL relative que Facebook ne peut pas télécharger (c'était le
* cas de l'ancien `publishReel(originalUrl)`).
*/
import type { Media, SocialPage } from "@shared/schema";
import { storage } from "../../storage";
import { minioService } from "../minio";
import { createVideoThumbnail } from "../thumbnail";
import { facebookService } from "../facebook";
import { tiktokService } from "../tiktok";
export interface PagePublishResult {
pageId: string;
success: boolean;
reelId?: string;
error?: string;
/** Échec de la story associée : n'empêche pas le Reel d'être publié. */
storyError?: string;
}
/** Range la vidéo rendue dans la médiathèque, avec sa vignette. */
export async function storeRenderedVideo(
userId: string,
videoBuffer: Buffer,
fileName: string,
): Promise<Media> {
const uploaded = await minioService.uploadMedia(videoBuffer, fileName, userId, "video/mp4");
// Vignette extraite maintenant : la vidéo sera supprimée du disque après
// publication, mais l'historique doit rester illustré.
const thumbnailUrl = await createVideoThumbnail(videoBuffer);
return storage.createMedia({
userId,
type: "video",
cloudinaryPublicId: uploaded.publicId,
originalUrl: uploaded.originalUrl,
facebookFeedUrl: uploaded.facebookFeedUrl,
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
thumbnailUrl,
fileName,
fileSize: videoBuffer.length,
});
}
/**
* Publie (ou planifie) la vidéo sur chaque page, crée les entrées
* `scheduled_posts` et met à jour le statut du post.
*
* Avec `alsoStory`, chaque page Facebook reçoit aussi la vidéo en story (TikTok
* n'a pas de stories).
*/
export async function publishReelToPages(options: {
postId: string;
pageIds: string[];
videoBuffer: Buffer;
description: string;
scheduledFor?: string;
alsoStory?: boolean;
}): Promise<PagePublishResult[]> {
const { postId, pageIds, videoBuffer, description, scheduledFor, alsoStory } = options;
const results: PagePublishResult[] = [];
for (const pageId of pageIds) {
try {
const page = await storage.getSocialPage(pageId);
if (!page) {
results.push({ pageId, success: false, error: "Page non trouvée" });
continue;
}
if (page.platform !== "facebook" && page.platform !== "tiktok") {
results.push({
pageId,
success: false,
error: `Plateforme non supportée pour les reels : ${page.platform}`,
});
continue;
}
const scheduledPost = await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "reel",
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
});
const withStory = alsoStory && page.platform === "facebook";
if (scheduledFor) {
// Le planificateur publiera à l'heure dite
if (withStory) {
await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "story",
scheduledAt: new Date(scheduledFor),
});
}
results.push({ pageId, success: true, reelId: "scheduled" });
continue;
}
console.log(`🚀 [Reels] Publication sur ${page.platform} ${page.pageName}...`);
if (page.platform === "facebook") {
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description);
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId: reelId,
});
const storyError = withStory ? await publishStory(postId, page, videoBuffer) : undefined;
results.push({ pageId, success: true, reelId, storyError });
} else {
// TikTok finalise la publication de son côté : l'envoi est marqué comme
// effectué pour ne pas republier, et le poller de statut renseignera
// l'identifiant définitif du post.
const publishId = await tiktokService.publishVideoFromBuffer(page, videoBuffer, description);
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: "PROCESSING_UPLOAD",
});
results.push({ pageId, success: true, reelId: publishId });
}
} catch (error) {
console.error(`❌ [Reels] Publication sur la page ${pageId} impossible :`, error);
results.push({
pageId,
success: false,
error: error instanceof Error ? error.message : "Erreur inconnue",
});
}
}
const succeeded = results.filter((r) => r.success).length;
const status = scheduledFor && succeeded > 0 ? "scheduled" : succeeded > 0 ? "published" : "failed";
await storage.updatePost(postId, { status });
return results;
}
/**
* Publie la story d'une page juste après son Reel : un échec est consigné sur
* son entrée planifiée (visible dans l'historique) et renvoyé, sans faire
* échouer le Reel.
*/
async function publishStory(
postId: string,
page: SocialPage,
videoBuffer: Buffer,
): Promise<string | undefined> {
// Créée seulement maintenant : en attente pendant l'envoi du Reel, le
// planificateur aurait pu la publier une seconde fois.
let scheduledStoryId: string | undefined;
try {
({ id: scheduledStoryId } = await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "story",
scheduledAt: new Date(),
}));
const storyId = await facebookService.publishVideoStoryFromBuffer(page, videoBuffer);
await storage.updateScheduledPost(scheduledStoryId, {
publishedAt: new Date(),
externalPostId: storyId,
});
return undefined;
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur inconnue";
console.error(`❌ [Reels] Story sur ${page.pageName} impossible :`, error);
if (scheduledStoryId) {
// Marquée comme traitée pour que le planificateur ne la republie pas en boucle
await storage.updateScheduledPost(scheduledStoryId, {
publishedAt: new Date(),
error: message,
}).catch((e) => console.error(`⚠️ [Reels] Échec de la story non enregistré :`, e));
}
return message;
}
}
/** Message d'erreur lisible lorsque toutes les publications ont échoué. */
export function describePublishFailure(results: PagePublishResult[]): string {
const details = results.map((r) => r.error).filter(Boolean).join(" ; ");
return `Publication impossible sur toutes les pages${details ? ` : ${details}` : ""}`;
}
-278
View File
@@ -1,278 +0,0 @@
/**
* File d'attente persistante des rendus de Reels.
*
* L'ancienne file comptait les posts « processing » pour décider de lancer un
* rendu : un redémarrage pendant un job laissait ce compteur à 1 pour toujours
* et bloquait tous les Reels suivants. Ici, chaque job est une ligne de
* `reel_jobs` :
* - le worker la réserve atomiquement (`FOR UPDATE SKIP LOCKED`), ce qui exclut
* tout double traitement, même avec plusieurs instances ;
* - un battement de cœur est écrit pendant le rendu ; au démarrage, les jobs
* dont le cœur ne bat plus sont remis en file (ou échouent après
* MAX_ATTEMPTS tentatives).
*/
import { and, eq, sql } from "drizzle-orm";
import { db } from "../../db";
import { posts, reelJobs, type ReelJob } from "@shared/schema";
import type { ReelJobKind, ReelJobStatus } from "@shared/reel";
const HEARTBEAT_INTERVAL_MS = 15_000;
/** Au-delà, un job « processing » est considéré comme orphelin. */
const STALE_AFTER_MS = 2 * 60_000;
const POLL_INTERVAL_MS = 10_000;
const MAX_ATTEMPTS = 2;
/** Outils fournis à un handler pour rendre compte de son avancement. */
export interface JobContext {
job: ReelJob;
progress(progress: number, step?: string): Promise<void>;
}
export type JobHandler = (ctx: JobContext) => Promise<unknown>;
const handlers = new Map<ReelJobKind, JobHandler>();
let running = false;
let started = false;
let pollTimer: NodeJS.Timeout | null = null;
export function registerReelJobHandler(kind: ReelJobKind, handler: JobHandler): void {
handlers.set(kind, handler);
}
/** Ajoute un job à la file et réveille le worker. */
export async function enqueueReelJob(input: {
kind: ReelJobKind;
userId: string;
postId?: string;
params: unknown;
}): Promise<ReelJob> {
const [job] = await db
.insert(reelJobs)
.values({
kind: input.kind,
userId: input.userId,
postId: input.postId,
params: input.params,
})
.returning();
if (job.postId) {
await syncPost(job.postId, "pending", 0);
}
kick();
return job;
}
export async function getReelJob(id: string): Promise<ReelJob | undefined> {
const [job] = await db.select().from(reelJobs).where(eq(reelJobs.id, id));
return job;
}
/** Nombre de jobs qui passeront avant un nouveau job (utile pour informer l'utilisateur). */
export async function countActiveReelJobs(): Promise<number> {
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(reelJobs)
.where(sql`${reelJobs.status} IN ('pending', 'processing')`);
return row?.count ?? 0;
}
/**
* Démarre le worker : récupère les jobs orphelins puis traite la file.
* Idempotent.
*/
export async function startReelWorker(): Promise<void> {
if (started) return;
started = true;
try {
await recoverStaleJobs();
await failLegacyStuckPosts();
} catch (error) {
console.error("❌ [ReelQueue] Récupération des jobs orphelins impossible :", error);
}
console.log(`🎬 [ReelQueue] Worker démarré (traitements : ${Array.from(handlers.keys()).join(", ")})`);
pollTimer = setInterval(kick, POLL_INTERVAL_MS);
pollTimer.unref();
kick();
}
/** Relance la boucle si elle est à l'arrêt. */
function kick(): void {
if (!started || running) return;
running = true;
drain()
.catch((error) => console.error("❌ [ReelQueue] Erreur de la boucle :", error))
.finally(() => {
running = false;
});
}
async function drain(): Promise<void> {
for (;;) {
const job = await claimNextJob();
if (!job) return;
await runJob(job);
}
}
async function claimNextJob(): Promise<ReelJob | undefined> {
const result = await db.execute<{ id: string }>(sql`
UPDATE reel_jobs
SET status = 'processing',
attempts = attempts + 1,
heartbeat_at = now(),
updated_at = now(),
error = NULL
WHERE id = (
SELECT id FROM reel_jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id
`);
const id = result.rows[0]?.id;
return id ? getReelJob(id) : undefined;
}
async function runJob(job: ReelJob): Promise<void> {
const handler = handlers.get(job.kind as ReelJobKind);
console.log(`🎬 [ReelQueue] Job ${job.id} (${job.kind}), tentative ${job.attempts}`);
const heartbeat = setInterval(() => {
db.update(reelJobs)
.set({ heartbeatAt: new Date() })
.where(eq(reelJobs.id, job.id))
.catch((error) => console.warn(`⚠️ [ReelQueue] Heartbeat ${job.id} :`, error));
}, HEARTBEAT_INTERVAL_MS);
const ctx: JobContext = {
job,
async progress(progress, step) {
await updateJob(job, { progress, step });
},
};
try {
if (!handler) {
throw new Error(`Aucun traitement enregistré pour les jobs « ${job.kind} »`);
}
if (job.postId) {
await syncPost(job.postId, "processing", 0);
}
const result = await handler(ctx);
await updateJob(job, { status: "completed", progress: 100, step: "done", result: result ?? null });
console.log(`✅ [ReelQueue] Job ${job.id} terminé`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`❌ [ReelQueue] Job ${job.id} en échec :`, error);
await updateJob(job, { status: "failed", error: message }).catch((e) =>
console.error(`⚠️ [ReelQueue] Statut d'échec non enregistré pour ${job.id} :`, e),
);
} finally {
clearInterval(heartbeat);
}
}
async function updateJob(
job: ReelJob,
patch: {
status?: ReelJobStatus;
progress?: number;
step?: string;
result?: unknown;
error?: string;
},
): Promise<void> {
await db
.update(reelJobs)
.set({ ...patch, heartbeatAt: new Date(), updatedAt: new Date() })
.where(eq(reelJobs.id, job.id));
if (job.postId) {
const status = patch.status ?? "processing";
const progress = patch.status === "failed" ? 0 : patch.progress;
await syncPost(job.postId, status, progress, patch.error);
}
}
/**
* Reflète l'état du job sur le post : l'interface (`/reels/ongoing`) lit les
* colonnes `generation_*` du post.
*/
async function syncPost(
postId: string,
status: ReelJobStatus,
progress?: number,
error?: string,
): Promise<void> {
const patch: Partial<typeof posts.$inferInsert> = {
generationStatus: status,
updatedAt: new Date(),
};
if (progress !== undefined) patch.generationProgress = progress;
if (error !== undefined) patch.generationError = error;
if (status === "failed") patch.status = "failed";
await db.update(posts).set(patch).where(eq(posts.id, postId));
}
/**
* Remet en file les jobs interrompus (serveur arrêté pendant un rendu), ou les
* fait échouer s'ils ont déjà épuisé leurs tentatives : un job qui fait tomber
* le serveur ne doit pas le faire tomber en boucle.
*/
async function recoverStaleJobs(): Promise<void> {
const staleBefore = new Date(Date.now() - STALE_AFTER_MS);
const stale = await db
.select()
.from(reelJobs)
.where(
and(
eq(reelJobs.status, "processing"),
sql`(${reelJobs.heartbeatAt} IS NULL OR ${reelJobs.heartbeatAt} < ${staleBefore})`,
),
);
for (const job of stale) {
if (job.attempts >= MAX_ATTEMPTS) {
console.warn(`⚠️ [ReelQueue] Job ${job.id} abandonné après ${job.attempts} tentatives`);
await updateJob(job, {
status: "failed",
error: "Le rendu a été interrompu à plusieurs reprises (redémarrage du serveur).",
});
} else {
console.warn(`♻️ [ReelQueue] Job ${job.id} interrompu, remis en file`);
await db
.update(reelJobs)
.set({ status: "pending", step: null, progress: 0, updatedAt: new Date() })
.where(eq(reelJobs.id, job.id));
if (job.postId) await syncPost(job.postId, "pending", 0);
}
}
}
/**
* Posts laissés « processing »/« pending » par l'ancienne file (avant
* reel_jobs) : ils n'ont aucun job pour les reprendre et bloquaient l'affichage
* des Reels en cours. On les marque en échec pour que l'utilisateur relance.
*/
async function failLegacyStuckPosts(): Promise<void> {
const result = await db.execute(sql`
UPDATE posts
SET generation_status = 'failed',
generation_progress = 0,
generation_error = 'Génération interrompue, veuillez relancer le Reel.',
status = 'failed',
updated_at = now()
WHERE generation_status IN ('processing', 'pending')
AND NOT EXISTS (SELECT 1 FROM reel_jobs j WHERE j.post_id = posts.id)
`);
if (result.rowCount) {
console.warn(`⚠️ [ReelQueue] ${result.rowCount} Reel(s) de l'ancienne file marqués en échec`);
}
}
@@ -1,12 +0,0 @@
import { describe, expect, it } from "vitest";
import { toDataUrl } from "./remotionAssets";
describe("toDataUrl", () => {
it("refuse un chemin qui sort du dossier uploads", async () => {
await expect(toDataUrl("/uploads/../../etc/passwd")).rejects.toThrow(/refusé/);
});
it("laisse les URL externes telles quelles", async () => {
await expect(toDataUrl("https://exemple.fr/logo.png")).resolves.toBe("https://exemple.fr/logo.png");
});
});
-58
View File
@@ -1,58 +0,0 @@
/**
* Ressources fournies aux compositions Remotion pendant un rendu serveur.
*/
import fs from "fs";
import path from "path";
/** Dossier temporaire servi sous /uploads/temp (voir server/index.ts). */
export const REMOTION_TEMP_DIR =
process.env.TEMP_UPLOAD_DIR || path.join(process.cwd(), "uploads", "temp");
/**
* URL HTTP locale d'un fichier servi par l'application. Vidéo et audio sont
* lus côté Node par le moteur de rendu : l'adresse locale suffit.
*/
export function localHttpUrl(url: string): string {
if (!url.startsWith("/")) return url;
const port = process.env.PORT || "5555";
return `http://localhost:${port}${url}`;
}
/** URL servie d'un fichier du dossier temporaire. */
export function tempFileUrl(absolutePath: string): string {
const relative = path.relative(REMOTION_TEMP_DIR, absolutePath).split(path.sep).join("/");
return localHttpUrl(`/uploads/temp/${relative}`);
}
const MIME_BY_EXT: Record<string, string> = {
png: "image/png",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
};
/**
* Convertit un chemin local /uploads/... en data URL : les images sont
* affichées par Chromium, qui ne lit pas http://localhost de façon fiable
* dans Docker. Refuse tout chemin sortant du dossier uploads.
*/
export async function toDataUrl(relativeUrl: string): Promise<string> {
if (!relativeUrl.startsWith("/uploads/")) return relativeUrl;
const uploadsRoot = path.join(process.cwd(), "uploads") + path.sep;
const tempRoot = path.resolve(REMOTION_TEMP_DIR) + path.sep;
const filePath = relativeUrl.startsWith("/uploads/temp/")
? path.resolve(REMOTION_TEMP_DIR, "." + relativeUrl.slice("/uploads/temp".length))
: path.resolve(process.cwd(), "." + relativeUrl);
if (!filePath.startsWith(uploadsRoot) && !filePath.startsWith(tempRoot)) {
throw new Error(`Chemin d'image refusé : ${relativeUrl}`);
}
try {
const buffer = await fs.promises.readFile(filePath);
const ext = path.extname(filePath).slice(1).toLowerCase();
return `data:${MIME_BY_EXT[ext] ?? "image/jpeg"};base64,${buffer.toString("base64")}`;
} catch {
console.warn(`⚠️ [Remotion] Image introuvable : ${filePath}`);
return relativeUrl;
}
}
-85
View File
@@ -1,85 +0,0 @@
/**
* Rendu Remotion côté serveur, commun aux Reels vidéo et images : bundle mis
* en cache, réglages d'encodage des réseaux sociaux, progression.
*/
import os from "os";
import path from "path";
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
let bundleCache: Promise<string> | null = null;
/** Bundle des compositions, construit une fois par processus. */
export function getRemotionBundle(): Promise<string> {
if (!bundleCache) {
// process.cwd() = /app dans Docker, racine du projet en dev
const entryPoint = path.resolve(process.cwd(), "client/src/remotion/index.ts");
console.log("📦 [Remotion] Bundle de", entryPoint);
bundleCache = bundle({
entryPoint,
// Mêmes alias que Vite : les compositions importent @shared/*
webpackOverride: (config) => ({
...config,
resolve: {
...config.resolve,
alias: {
...(config.resolve?.alias ?? {}),
"@shared": path.resolve(process.cwd(), "shared"),
},
},
}),
}).catch((error) => {
bundleCache = null; // réessayer au prochain rendu
throw error;
});
}
return bundleCache;
}
/** Rendus en parallèle dans Chromium : par défaut la moitié des cœurs, 1 au minimum. */
function renderConcurrency(): number {
const configured = Number(process.env.RENDER_CONCURRENCY);
if (Number.isFinite(configured) && configured > 0) return Math.floor(configured);
return Math.max(1, Math.floor(os.cpus().length / 2));
}
export async function renderReelComposition(options: {
compositionId: "ReelVideo";
inputProps: Record<string, unknown>;
outputLocation: string;
onProgress?: (ratio: number) => void;
}): Promise<void> {
const serveUrl = await getRemotionBundle();
const composition = await selectComposition({
serveUrl,
id: options.compositionId,
inputProps: options.inputProps,
});
const startedAt = Date.now();
await renderMedia({
composition,
serveUrl,
inputProps: options.inputProps,
outputLocation: options.outputLocation,
codec: "h264",
// Qualité visuelle élevée, conforme aux recommandations Instagram/TikTok
crf: 18,
x264Preset: "medium",
pixelFormat: "yuv420p",
colorSpace: "bt709",
audioCodec: "aac",
audioBitrate: "192k",
concurrency: renderConcurrency(),
onProgress: ({ progress }) => options.onProgress?.(progress),
// Une image lente (décodage vidéo sur une petite machine) ne doit pas faire échouer le rendu
timeoutInMilliseconds: 60_000,
onBrowserLog: (log) => {
if (log.type === "error") console.warn("⚠️ [Remotion] Navigateur :", log.text.slice(0, 300));
},
});
console.log(
`🎞️ [Remotion] ${options.compositionId} rendu en ${((Date.now() - startedAt) / 1000).toFixed(1)} s`,
);
}
-160
View File
@@ -1,160 +0,0 @@
/**
* Rendu d'un Reel à partir d'une vidéo, puis médiathèque et publication.
* Exécuté par la file `reel_jobs` (voir queue.ts).
*
* Deux moteurs :
* - « remotion » (défaut) : le service Python prépare l'image (recadrage, HDR,
* stabilisation) et la piste son finale, Remotion compose les sous-titres
* animés, le logo et l'effet de fin — le même rendu que l'aperçu ;
* - « ffmpeg » : tout est fait par le service Python (sous-titres ASS),
* plus rapide, conservé en secours (REEL_RENDERER=ffmpeg).
*/
import fs from "fs";
import path from "path";
import { videoReelParamsSchema, type VideoReelParams } from "@shared/reel";
import { storage } from "../../storage";
import { ffmpegService, type ReelRenderOptions } from "../ffmpeg";
import { resolveInternalUrl } from "../minio";
import type { JobContext } from "./queue";
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl, resolveQwenTarget } from "./assets";
import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish";
import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets";
import { renderReelComposition } from "./remotionRenderer";
export type ReelRenderer = "remotion" | "ffmpeg";
export function configuredRenderer(): ReelRenderer {
return process.env.REEL_RENDERER === "ffmpeg" ? "ffmpeg" : "remotion";
}
export async function runVideoReelJob({ job, progress }: JobContext) {
if (!job.postId) throw new Error("Job vidéo sans post associé");
const postId = job.postId;
const params = videoReelParamsSchema.parse(job.params);
await progress(5, "prepare");
const media = await storage.getMediaById(params.videoMediaId);
if (!media || media.type !== "video") {
throw new Error("Vidéo source introuvable ou invalide");
}
const [musicUrl, logoPath, geminiApiKey, qwen] = await Promise.all([
resolveMusicUrl(params.musicTrackId, params.musicUrl),
resolveLogoPath(),
resolveGeminiApiKey(params.ttsEngine),
resolveQwenTarget(params.ttsEngine),
]);
const options: ReelRenderOptions = {
text: params.srtCues ? undefined : params.overlayText,
srtCues: params.srtCues,
musicUrl,
ttsEnabled: params.ttsEnabled,
ttsVoice: params.ttsVoice,
ttsEngine: params.ttsEngine,
ttsStyle: params.ttsStyle,
geminiApiKey,
qwen,
fontSize: params.fontSize,
musicVolume: params.musicVolume,
drawText: params.drawText,
stabilize: params.stabilize,
storeName: params.storeName,
enableEndingEffect: params.enableEndingEffect,
showLogo: params.showLogo,
};
const renderer = configuredRenderer();
const startedAt = Date.now();
const videoBuffer =
renderer === "remotion"
? await renderWithRemotion(job.id, resolveInternalUrl(media.originalUrl), options, params, logoPath, progress)
: await renderWithFfmpeg(resolveInternalUrl(media.originalUrl), options, logoPath, progress);
console.log(`⏱️ [Reels] Rendu ${renderer} : ${((Date.now() - startedAt) / 1000).toFixed(1)} s`);
await progress(88, "store");
const processedMedia = await storeRenderedVideo(job.userId, videoBuffer, `reel-${Date.now()}.mp4`);
await storage.updatePostMedia(postId, [processedMedia.id]);
await progress(93, "publish");
const results = await publishReelToPages({
postId,
pageIds: params.pageIds,
videoBuffer,
description: params.description || params.overlayText || "",
scheduledFor: params.scheduledFor,
alsoStory: params.alsoStory,
});
if (!results.some((r) => r.success)) {
throw new Error(describePublishFailure(results));
}
return { mediaId: processedMedia.id, renderer, results };
}
async function renderWithFfmpeg(
videoUrl: string,
options: ReelRenderOptions,
logoPath: string | undefined,
progress: JobContext["progress"],
): Promise<Buffer> {
await progress(15, "render");
// Le logo sert au coin de la vidéo et/ou à l'effet de fin : inutile de l'envoyer si aucun des deux
const logoUsed = Boolean(logoPath) && (options.showLogo !== false || options.enableEndingEffect !== false);
const rendered = await ffmpegService.renderReel(videoUrl, {
...options,
watermarkUrl: logoUsed && logoPath ? resolveInternalUrl(logoPath) : undefined,
});
return rendered.video;
}
async function renderWithRemotion(
jobId: string,
videoUrl: string,
options: ReelRenderOptions,
params: VideoReelParams,
logoPath: string | undefined,
progress: JobContext["progress"],
): Promise<Buffer> {
const workdir = path.join(REMOTION_TEMP_DIR, `reel-${jobId}`);
try {
// 1. Image recadrée à la durée finale, piste son finale, mots minutés
await progress(10, "voice");
const prepared = await ffmpegService.prepareReel(videoUrl, { ...options, hasLogo: Boolean(logoPath) }, workdir);
// 2. Composition : sous-titres animés, logo, effet de fin
await progress(40, "render");
const outputLocation = path.join(workdir, "reel.mp4");
let lastReported = 40;
await renderReelComposition({
compositionId: "ReelVideo",
outputLocation,
inputProps: {
videoUrl: tempFileUrl(prepared.videoPath),
videoDuration: prepared.totalDuration,
totalDuration: prepared.totalDuration,
words: prepared.words,
captionStyle: params.captionStyle,
logoUrl: logoPath ? await toDataUrl(logoPath) : undefined,
showWatermark: params.showLogo,
storeName: params.enableEndingEffect ? params.storeName : undefined,
logoStart: prepared.logoStart,
fadeStart: prepared.fadeStart,
fadeDuration: prepared.fadeDuration,
mixedAudioUrl: prepared.audioPath ? tempFileUrl(prepared.audioPath) : undefined,
},
onProgress: (ratio) => {
const pct = 40 + Math.floor(ratio * 45);
if (pct >= lastReported + 5) {
lastReported = pct;
progress(pct, "render").catch(() => { /* non bloquant */ });
}
},
});
return await fs.promises.readFile(outputLocation);
} finally {
await fs.promises.rm(workdir, { recursive: true, force: true }).catch(() => { /* déjà absent */ });
}
}
-182
View File
@@ -1,182 +0,0 @@
import cron, { ScheduledTask } from "node-cron";
import { storage } from "../storage";
import { facebookService } from "./facebook";
import { tiktokService } from "./tiktok";
import { minioService as storageService, readMediaBuffer } from "./minio";
export class SchedulerService {
private task: ScheduledTask | null = null;
start() {
// Run every minute to check for pending posts
this.task = cron.schedule("* * * * *", async () => {
await this.processPendingPosts();
});
console.log("Scheduler service started");
}
stop() {
if (this.task) {
this.task.stop();
console.log("Scheduler service stopped");
}
}
private async processPendingPosts() {
try {
const pendingPosts = await storage.getPendingScheduledPosts();
for (const scheduledPost of pendingPosts) {
try {
// Reject legacy "both" posts - they should have been split at creation
// New posts are automatically split in /api/posts
if (scheduledPost.postType === 'both') {
const errorMsg = '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.';
console.error(`Rejecting and deleting legacy "both" post ${scheduledPost.id}: ${errorMsg}`);
// Delete the legacy post entirely (don't mark as published - that's misleading)
await storage.deleteScheduledPost(scheduledPost.id);
// Update parent post status to draft so user can reschedule
await storage.updatePost(scheduledPost.postId, {
status: "draft",
});
continue;
}
await this.publishPost(scheduledPost);
} catch (error) {
console.error(`Error publishing post ${scheduledPost.id}:`, error);
await storage.updateScheduledPost(scheduledPost.id, {
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
} catch (error) {
console.error("Error processing pending posts:", error);
}
}
private async publishPost(scheduledPost: any) {
const post = await storage.getPost(scheduledPost.postId);
const page = await storage.getSocialPage(scheduledPost.pageId);
if (!post || !page) {
throw new Error("Post or page not found");
}
console.log(`Publishing post ${post.id} to ${page.platform} page ${page.pageName}`);
// Get all media for the post (for multi-photo carousels)
const postMediaList = await storage.getPostMedia(post.id);
const mediaList: any[] = [];
for (const pm of postMediaList) {
const media = await storage.getMediaById(pm.mediaId);
if (media) {
mediaList.push(media);
}
}
// Validate that story posts have media
// Note: "both" should never reach here since they're split at creation
if (scheduledPost.postType === 'story' && mediaList.length === 0) {
const errorMsg = 'Les stories nécessitent un média. Ce post ne peut pas être publié.';
console.error(`Deleting invalid story post ${scheduledPost.id} without media: ${errorMsg}`);
// Delete the invalid scheduled post (don't mark as published - that's misleading)
await storage.deleteScheduledPost(scheduledPost.id);
// Set post to draft so user can fix and reschedule
await storage.updatePost(post.id, {
status: "draft",
});
return;
}
// Publish to Facebook/Instagram/TikTok
if (page.platform === 'facebook') {
const externalPostId = await facebookService.publishPost(post, page, scheduledPost.postType, mediaList);
// Update the scheduled post as published (clear any previous error)
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId,
error: null,
});
} else if (page.platform === 'tiktok') {
const publishId = await this.publishToTiktok(post, page, mediaList);
// 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.
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: 'PROCESSING_UPLOAD',
error: null,
});
} else if (page.platform === 'instagram') {
// Instagram publishing not yet implemented
console.warn(`Instagram publishing not yet implemented for post ${post.id}`);
await storage.updateScheduledPost(scheduledPost.id, {
error: 'Instagram publishing not yet implemented',
});
await storage.updatePost(post.id, {
status: "failed",
});
return;
} else {
throw new Error(`Unsupported platform: ${page.platform}`);
}
// Update post status
await storage.updatePost(post.id, {
status: "published",
});
// Delete local video files only when no other unpublished scheduled posts reference them
for (const mediaItem of mediaList) {
if (mediaItem.type === 'video' && mediaItem.cloudinaryPublicId) {
try {
const remainingScheduled = await storage.getScheduledPostsByPost(post.id);
const hasPending = remainingScheduled.some(sp => !sp.publishedAt);
if (!hasPending) {
await storageService.deleteMedia(mediaItem.cloudinaryPublicId, mediaItem.userId, 'video');
console.log(`[Scheduler] Deleted local video file for media ${mediaItem.id}`);
} else {
console.log(`[Scheduler] Kept local video file for media ${mediaItem.id} (pending scheduled posts still exist)`);
}
} catch (err) {
console.warn(`[Scheduler] Failed to delete local video file for media ${mediaItem.id}:`, err);
}
}
}
console.log(`Successfully published post ${post.id} to ${page.platform} page ${page.pageName}`);
}
/**
* 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.
*
* @returns le publish_id à suivre pour connaître le résultat final
*/
private async publishToTiktok(post: any, page: any, mediaList: any[]): Promise<string> {
const videoMedia = mediaList.find(m => m.type === 'video');
if (!videoMedia) {
throw new Error(
`Le compte TikTok "${page.pageName}" ne peut recevoir que des vidéos : ce post n'en contient pas.`
);
}
const videoBuffer = await readMediaBuffer(videoMedia.originalUrl);
return await tiktokService.publishVideoFromBuffer(page, videoBuffer, post.content);
}
}
export const schedulerService = new SchedulerService();
-101
View File
@@ -1,101 +0,0 @@
/**
* Génération de vignettes vidéo.
*
* Les vidéos sources sont supprimées du disque une fois publiées (le
* planificateur libère la place, la purge quotidienne fait le reste), alors que
* la ligne `media` survit pour l'historique. Sans vignette persistée, l'interface
* tente alors de lire un fichier disparu : d'où les « Video thumbnail generation
* failed » et les tuiles vides.
*
* On extrait donc une image au moment de la création du média, et elle reste
* disponible après la disparition de la vidéo.
*/
import fs from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { minioService } from './minio';
const execFileAsync = promisify(execFile);
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
/**
* Extrait une image d'une vidéo avec FFmpeg.
*
* @param videoPath chemin du fichier vidéo
* @param outputPath chemin de l'image à écrire
* @param seekTime instant de la capture, en secondes
* @returns true si l'image a bien été écrite
*/
export async function generateVideoThumbnail(
videoPath: string,
outputPath: string,
seekTime: number = 1
): Promise<boolean> {
try {
// Arguments séparés : aucun passage par le shell, les chemins ne sont jamais interprétés
await execFileAsync('ffmpeg', [
'-y', '-ss', String(seekTime), '-i', videoPath, '-vframes', '1', '-q:v', '2', outputPath,
]);
return fs.existsSync(outputPath);
} catch (error) {
console.warn('⚠️ Failed to generate video thumbnail:', error);
return false;
}
}
/**
* Produit la vignette d'une vidéo et la range dans le stockage public.
*
* Tolérante à l'échec : une vignette manquante dégrade l'affichage mais ne doit
* jamais faire échouer une publication.
*
* @param source chemin du fichier vidéo, ou son contenu en mémoire
* @returns l'URL publique de la vignette, ou null si l'extraction a échoué
*/
export async function createVideoThumbnail(source: string | Buffer): Promise<string | null> {
const stamp = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const outputPath = path.join(TEMP_DIR, `thumb-${stamp}.jpg`);
// Une vidéo reçue en mémoire doit d'abord être posée sur le disque pour FFmpeg
let videoPath: string;
let tempVideoPath: string | null = null;
try {
if (!fs.existsSync(TEMP_DIR)) {
fs.mkdirSync(TEMP_DIR, { recursive: true });
}
if (Buffer.isBuffer(source)) {
tempVideoPath = path.join(TEMP_DIR, `thumb-src-${stamp}.mp4`);
await fs.promises.writeFile(tempVideoPath, source);
videoPath = tempVideoPath;
} else {
videoPath = source;
if (!fs.existsSync(videoPath)) {
console.warn(`⚠️ [Thumbnail] Vidéo introuvable pour la vignette : ${videoPath}`);
return null;
}
}
const ok = await generateVideoThumbnail(videoPath, outputPath);
if (!ok) return null;
const buffer = await fs.promises.readFile(outputPath);
const { url } = await minioService.uploadThumbnail(buffer, `thumb-${stamp}.jpg`);
console.log(`🖼️ [Thumbnail] Vignette générée : ${url}`);
return url;
} catch (error) {
console.warn('⚠️ [Thumbnail] Génération de la vignette impossible :', error);
return null;
} finally {
for (const file of [outputPath, tempVideoPath]) {
if (file) {
await fs.promises.unlink(file).catch(() => { /* déjà absent */ });
}
}
}
}
-574
View File
@@ -1,574 +0,0 @@
/**
* Service de publication TikTok (Content Posting API v2).
*
* 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.
*/
import type { Request } from 'express';
import { storage } from '../storage';
import { resolvePublicBaseUrl } from '../utils/public_url';
import type { SocialPage } from '@shared/schema';
const AUTH_BASE_URL = 'https://www.tiktok.com/v2/auth/authorize/';
const API_BASE_URL = 'https://open.tiktokapis.com/v2';
/** Scopes demandés à l'autorisation. `video.publish` nécessite l'audit TikTok. */
const DEFAULT_SCOPES = 'user.info.basic,video.publish';
/** TikTok impose des chunks entre 5 Mo et 64 Mo. */
const MAX_SINGLE_CHUNK_SIZE = 64 * 1024 * 1024;
const CHUNK_SIZE = 32 * 1024 * 1024;
/** Longueur maximale de la légende d'une vidéo TikTok. */
const MAX_TITLE_LENGTH = 2200;
/** Renouvelle le token si sa fin de validité est à moins de 5 minutes. */
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
export interface TiktokCreatorInfo {
creatorAvatarUrl?: string;
creatorUsername?: string;
creatorNickname?: string;
privacyLevelOptions: string[];
commentDisabled: boolean;
duetDisabled: boolean;
stitchDisabled: boolean;
maxVideoPostDurationSec?: number;
}
export interface TiktokPublishOptions {
privacyLevel?: string;
disableComment?: boolean;
disableDuet?: boolean;
disableStitch?: boolean;
}
export interface TiktokPublishStatus {
status: string;
failReason?: string;
postId?: string;
}
interface TiktokTokenResponse {
access_token: string;
expires_in: number;
open_id: string;
refresh_token: string;
refresh_expires_in: number;
scope: string;
token_type: string;
}
export class TiktokService {
/**
* Identifiants de l'application développeur TikTok (globaux à l'instance).
* La base prime sur les variables d'environnement.
*/
private async getCredentials(): Promise<{ clientKey: string; clientSecret: string }> {
const config = await storage.getTiktokConfig();
const clientKey = config?.clientKey || process.env.TIKTOK_CLIENT_KEY;
const clientSecret = config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET;
if (!clientKey || !clientSecret) {
throw new Error(
"TikTok n'est pas configuré. Renseignez le client key et le client secret " +
"de votre application TikTok dans les paramètres."
);
}
return { clientKey, clientSecret };
}
async isConfigured(): Promise<boolean> {
try {
await this.getCredentials();
return true;
} catch {
return false;
}
}
/**
* URI de redirection OAuth. `req` sert 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`.
*/
getRedirectUri(req?: Request): string {
if (process.env.TIKTOK_REDIRECT_URI) {
return process.env.TIKTOK_REDIRECT_URI;
}
return `${resolvePublicBaseUrl(req)}/api/tiktok/callback`;
}
/**
* 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.
*/
async buildAuthorizationUrl(state: string, req?: Request): Promise<string> {
const { clientKey } = await this.getCredentials();
const params = new URLSearchParams({
client_key: clientKey,
scope: process.env.TIKTOK_SCOPES || DEFAULT_SCOPES,
response_type: 'code',
redirect_uri: this.getRedirectUri(req),
state,
});
return `${AUTH_BASE_URL}?${params.toString()}`;
}
/** Échange le code d'autorisation contre un couple access/refresh token. */
async exchangeCodeForToken(code: string, req?: Request): Promise<TiktokTokenResponse> {
const { clientKey, clientSecret } = await this.getCredentials();
return await this.requestToken({
client_key: clientKey,
client_secret: clientSecret,
// TikTok renvoie le code URL-encodé dans la query string
code: decodeURIComponent(code),
grant_type: 'authorization_code',
redirect_uri: this.getRedirectUri(req),
});
}
/** Renouvelle un access token expiré. TikTok rend aussi un nouveau refresh token. */
async refreshAccessToken(refreshToken: string): Promise<TiktokTokenResponse> {
const { clientKey, clientSecret } = await this.getCredentials();
return await this.requestToken({
client_key: clientKey,
client_secret: clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
}
private async requestToken(body: Record<string, string>): Promise<TiktokTokenResponse> {
const response = await fetch(`${API_BASE_URL}/oauth/token/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Cache-Control': 'no-cache',
},
body: new URLSearchParams(body).toString(),
});
const raw = await response.text();
let data: any;
try {
data = JSON.parse(raw);
} catch {
throw new Error(`Réponse TikTok illisible (${response.status}): ${raw.slice(0, 200)}`);
}
// L'endpoint OAuth signale les erreurs avec `error` + `error_description`
if (!response.ok || data.error) {
const description = data.error_description || data.error || raw.slice(0, 200);
throw new Error(`Erreur OAuth TikTok: ${description}`);
}
if (!data.access_token) {
throw new Error("Réponse TikTok inattendue : aucun access token reçu");
}
return data as TiktokTokenResponse;
}
/**
* 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.
*/
async getValidAccessToken(page: SocialPage): Promise<string> {
if (page.platform !== 'tiktok') {
throw new Error('Ce service ne gère que les comptes TikTok');
}
const expiresAt = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : 0;
const stillValid = expiresAt - TOKEN_REFRESH_MARGIN_MS > Date.now();
if (stillValid && page.accessToken) {
return page.accessToken;
}
if (!page.refreshToken) {
throw new Error(
`Le compte TikTok "${page.pageName}" doit être reconnecté (aucun refresh token enregistré).`
);
}
console.log(`🔄 [TikTok] Renouvellement du token pour ${page.pageName}`);
let tokens: TiktokTokenResponse;
try {
tokens = await this.refreshAccessToken(page.refreshToken);
} catch (error) {
await storage.updateSocialPage(page.id, {
tokenStatus: 'expired',
lastTokenCheck: new Date(),
});
throw new Error(
`Impossible de renouveler le token TikTok de "${page.pageName}". ` +
`Le compte doit être reconnecté. (${error instanceof Error ? error.message : 'erreur inconnue'})`
);
}
await this.persistTokens(page.id, tokens);
return tokens.access_token;
}
/** Enregistre un couple de tokens fraîchement obtenu sur une page. */
async persistTokens(pageId: string, tokens: TiktokTokenResponse): Promise<void> {
await storage.updateSocialPage(pageId, {
accessToken: tokens.access_token,
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
refreshToken: tokens.refresh_token,
refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000),
scopes: tokens.scope,
tokenStatus: 'valid',
lastTokenCheck: new Date(),
});
}
/** Profil public du compte, utilisé pour nommer la page à la connexion. */
async getUserInfo(accessToken: string): Promise<{
openId: string;
displayName: string;
avatarUrl?: string;
}> {
const params = new URLSearchParams({
fields: 'open_id,display_name,avatar_url',
});
const response = await fetch(`${API_BASE_URL}/user/info/?${params.toString()}`, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await this.parseApiResponse(response, 'récupération du profil');
const user = data.user || {};
return {
openId: user.open_id,
displayName: user.display_name || 'Compte TikTok',
avatarUrl: user.avatar_url,
};
}
/**
* Options réellement autorisées sur le compte (confidentialité, commentaires,
* duo, stitch, durée max). TikTok impose cet appel avant chaque publication.
*/
async getCreatorInfo(page: SocialPage): Promise<TiktokCreatorInfo> {
return await this.queryCreatorInfo(await this.getValidAccessToken(page));
}
/**
* 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.
*/
private async queryCreatorInfo(accessToken: string): Promise<TiktokCreatorInfo> {
const response = await fetch(`${API_BASE_URL}/post/publish/creator_info/query/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
});
const data = await this.parseApiResponse(response, 'récupération des options du compte');
return {
creatorAvatarUrl: data.creator_avatar_url,
creatorUsername: data.creator_username,
creatorNickname: data.creator_nickname,
privacyLevelOptions: data.privacy_level_options || [],
commentDisabled: !!data.comment_disabled,
duetDisabled: !!data.duet_disabled,
stitchDisabled: !!data.stitch_disabled,
maxVideoPostDurationSec: data.max_video_post_duration_sec,
};
}
/**
* 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.
*
* @returns le publish_id à suivre pour connaître le résultat final
*/
async publishVideoFromBuffer(
page: SocialPage,
videoBuffer: Buffer,
caption?: string,
options: TiktokPublishOptions = {}
): Promise<string> {
if (page.platform !== 'tiktok') {
throw new Error('Ce service ne gère que les comptes TikTok');
}
// Un seul renouvellement de token pour toute la publication
const accessToken = await this.getValidAccessToken(page);
const creatorInfo = await this.queryCreatorInfo(accessToken);
const privacyLevel = this.resolvePrivacyLevel(creatorInfo, options.privacyLevel, page.pageName);
const videoSize = videoBuffer.length;
const { chunkSize, totalChunkCount } = this.computeChunking(videoSize);
console.log('🎬 [TikTok] Publication en cours:', {
compte: page.pageName,
taille: `${(videoSize / 1024 / 1024).toFixed(2)} Mo`,
chunks: totalChunkCount,
confidentialite: privacyLevel,
});
// ── Étape 1 : init ────────────────────────────────────────────────────────
const initBody = {
post_info: {
title: (caption || '').slice(0, MAX_TITLE_LENGTH),
privacy_level: privacyLevel,
// Une option désactivée côté compte doit obligatoirement être déclarée désactivée
disable_comment: creatorInfo.commentDisabled || !!options.disableComment,
disable_duet: creatorInfo.duetDisabled || !!options.disableDuet,
disable_stitch: creatorInfo.stitchDisabled || !!options.disableStitch,
},
source_info: {
source: 'FILE_UPLOAD',
video_size: videoSize,
chunk_size: chunkSize,
total_chunk_count: totalChunkCount,
},
};
const initResponse = await fetch(`${API_BASE_URL}/post/publish/video/init/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
body: JSON.stringify(initBody),
});
const initData = await this.parseApiResponse(initResponse, 'initialisation de la publication');
const publishId: string = initData.publish_id;
const uploadUrl: string = initData.upload_url;
if (!publishId || !uploadUrl) {
throw new Error("TikTok n'a pas renvoyé d'identifiant de publication");
}
console.log(`📤 [TikTok] Upload initialisé: publish_id=${publishId}`);
// ── Étape 2 : upload des chunks ───────────────────────────────────────────
for (let index = 0; index < totalChunkCount; index++) {
const start = index * chunkSize;
// Le dernier chunk absorbe le reliquat
const end = index === totalChunkCount - 1 ? videoSize - 1 : start + chunkSize - 1;
const chunk = videoBuffer.subarray(start, end + 1);
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'video/mp4',
'Content-Length': chunk.length.toString(),
'Content-Range': `bytes ${start}-${end}/${videoSize}`,
},
body: new Blob([chunk], { type: 'video/mp4' }),
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(
`Échec de l'envoi du chunk ${index + 1}/${totalChunkCount} vers TikTok ` +
`(${uploadResponse.status}): ${errorText.slice(0, 200)}`
);
}
console.log(`📤 [TikTok] Chunk ${index + 1}/${totalChunkCount} envoyé`);
}
console.log(`✅ [TikTok] Vidéo transmise pour ${page.pageName}, traitement en cours côté TikTok`);
return publishId;
}
/** Interroge TikTok sur l'avancement d'une publication. */
async getPublishStatus(page: SocialPage, publishId: string): Promise<TiktokPublishStatus> {
const accessToken = await this.getValidAccessToken(page);
const response = await fetch(`${API_BASE_URL}/post/publish/status/fetch/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
body: JSON.stringify({ publish_id: publishId }),
});
const data = await this.parseApiResponse(response, 'suivi de la publication');
// Le nom du champ comporte bien une faute de frappe côté API TikTok
const postIds: string[] = data.publicaly_available_post_id || data.publicly_available_post_id || [];
return {
status: data.status,
failReason: data.fail_reason,
postId: postIds.length > 0 ? postIds[0] : undefined,
};
}
/**
* 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.
*/
async syncPendingPublications(): Promise<void> {
const pending = await storage.getScheduledPostsAwaitingPublishStatus();
if (pending.length === 0) {
return;
}
console.log(`🔎 [TikTok] Suivi de ${pending.length} publication(s) en cours...`);
for (const scheduledPost of pending) {
try {
const page = await storage.getSocialPage(scheduledPost.pageId);
if (!page || page.platform !== 'tiktok' || !scheduledPost.publishId) {
continue;
}
const status = await this.getPublishStatus(page, scheduledPost.publishId);
if (status.status === 'PUBLISH_COMPLETE') {
await storage.updateScheduledPost(scheduledPost.id, {
publishStatus: status.status,
// À défaut d'ID public (compte privé), on garde le publish_id comme référence
externalPostId: status.postId || scheduledPost.publishId,
error: null,
});
console.log(`✅ [TikTok] Publication terminée sur ${page.pageName}`);
continue;
}
if (status.status === 'FAILED') {
await storage.updateScheduledPost(scheduledPost.id, {
publishStatus: 'FAILED',
error: `Publication TikTok échouée : ${status.failReason || 'raison inconnue'}`,
});
console.error(`❌ [TikTok] Publication échouée sur ${page.pageName}: ${status.failReason}`);
continue;
}
// Filet de sécurité : au-delà de 24h, on cesse d'interroger l'API
const sentAt = scheduledPost.publishedAt ? new Date(scheduledPost.publishedAt).getTime() : 0;
if (sentAt && Date.now() - sentAt > 24 * 60 * 60 * 1000) {
await storage.updateScheduledPost(scheduledPost.id, {
publishStatus: 'FAILED',
error: `Statut TikTok toujours "${status.status}" après 24h, suivi abandonné.`,
});
console.error(`❌ [TikTok] Suivi abandonné pour ${page.pageName} (statut ${status.status})`);
continue;
}
await storage.updateScheduledPost(scheduledPost.id, { publishStatus: status.status });
} catch (error) {
console.error(
`⚠️ [TikTok] Impossible de récupérer le statut de la publication ${scheduledPost.id}:`,
error instanceof Error ? error.message : error
);
}
}
}
/**
* 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.
*/
private resolvePrivacyLevel(
creatorInfo: TiktokCreatorInfo,
requested: string | undefined,
pageName: string
): string {
const options = creatorInfo.privacyLevelOptions;
if (options.length === 0) {
throw new Error(
`Le compte TikTok "${pageName}" ne propose aucun niveau de confidentialité. ` +
`Vérifiez que le compte est bien autorisé et que l'application dispose du scope video.publish.`
);
}
const wanted = requested || 'PUBLIC_TO_EVERYONE';
if (options.includes(wanted)) {
return wanted;
}
const fallback = options.includes('SELF_ONLY') ? 'SELF_ONLY' : options[0];
console.warn(
`⚠️ [TikTok] "${wanted}" indisponible sur ${pageName} (options: ${options.join(', ')}). ` +
`Publication en ${fallback}. Tant que l'application TikTok n'est pas auditée, ` +
`les vidéos restent privées.`
);
return fallback;
}
/**
* 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).
*/
private computeChunking(videoSize: number): { chunkSize: number; totalChunkCount: number } {
if (videoSize <= MAX_SINGLE_CHUNK_SIZE) {
return { chunkSize: videoSize, totalChunkCount: 1 };
}
return {
chunkSize: CHUNK_SIZE,
totalChunkCount: Math.floor(videoSize / CHUNK_SIZE),
};
}
/**
* 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.
*/
private async parseApiResponse(response: Response, context: string): Promise<any> {
const raw = await response.text();
let payload: any;
try {
payload = JSON.parse(raw);
} catch {
throw new Error(`Réponse TikTok illisible lors de la ${context} (${response.status}): ${raw.slice(0, 200)}`);
}
const errorCode = payload?.error?.code;
if (!response.ok || (errorCode && errorCode !== 'ok')) {
const message = payload?.error?.message || raw.slice(0, 200);
throw new Error(`Erreur TikTok lors de la ${context}: ${message} (code: ${errorCode || response.status})`);
}
return payload.data || {};
}
}
export const tiktokService = new TiktokService();
-358
View File
@@ -1,358 +0,0 @@
// crypto removed, using shared encryption utility
import { db } from '../db';
import { socialPages } from '../../shared/schema';
import { eq } from 'drizzle-orm';
import { GraphAPIClient } from '../utils/graph_client';
import type { SocialPage } from '../../shared/schema';
import { encrypt, decrypt } from '../utils/encryption';
import { facebookOAuthService, FacebookAuthError } from './facebook_oauth';
/**
* Un jeton dont l'échéance est plus proche que cette fenêtre est renouvelé
* sans attendre : il reste largement le temps de réessayer les jours suivants
* si Facebook est indisponible au moment du contrôle.
*/
const RENEWAL_WINDOW_MS = 15 * 24 * 60 * 60 * 1000;
/** En deçà de cette marge, un jeton non renouvelable est signalé « expiring ». */
const WARNING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
export interface TokenCheckResult {
pageId: string;
pageName: string;
status: 'valid' | 'expiring' | 'expired' | 'error';
/** Vrai quand ce passage a effectivement réécrit un nouveau jeton. */
renewed: boolean;
error?: string;
}
/**
* Service responsible for managing Facebook Page Token lifecycle
* - Encryption at rest
* - Automated refreshing of expiring tokens
*/
export class TokenManager {
/**
* Encrypts a text using shared encryption utility
*/
static encrypt(text: string): string {
return encrypt(text);
}
/**
* Decrypts a text using shared encryption utility
*/
static decrypt(text: string): string {
return decrypt(text);
}
/**
* Checks all active pages for expiring tokens and refreshes them if needed.
*/
static async checkAndRefreshTokens(): Promise<TokenCheckResult[]> {
console.log('[TokenManager] Starting daily token check...');
const results: TokenCheckResult[] = [];
try {
// Fetch active pages
const pages = await db.query.socialPages.findMany({
where: eq(socialPages.isActive, "true")
});
for (const page of pages) {
try {
results.push(await this.processPageToken(page));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error);
await this.persistStatus(page.id, 'error', message);
results.push({
pageId: page.id,
pageName: page.pageName,
status: 'error',
renewed: false,
error: message,
});
}
}
console.log('[TokenManager] Finished token check.');
} catch (e) {
console.error('[TokenManager] Critical error in checkAndRefreshTokens:', e);
}
return results;
}
/** Contrôle (et renouvellement) d'une seule page, déclenché depuis l'UI. */
static async checkPage(pageId: string): Promise<TokenCheckResult> {
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, pageId));
if (!page) {
throw new Error('Page introuvable');
}
return this.processPageToken(page);
}
private static async processPageToken(page: SocialPage): Promise<TokenCheckResult> {
// Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure
// que 24h et se renouvelle avec le refresh token, pas via la Graph API.
if (page.platform === 'tiktok') {
return await this.processTiktokToken(page);
}
console.log(`[TokenManager] Checking page: ${page.pageName}`);
const accessToken = this.readToken(page.accessToken);
// Sans application Facebook configurée, on ne peut ni interroger
// /debug_token ni régénérer quoi que ce soit : on se rabat sur le
// contrôle historique (le jeton répond-il encore ?).
if (!(await facebookOAuthService.isConfigured())) {
return this.legacyCheck(page, accessToken);
}
let info;
try {
info = await facebookOAuthService.inspectToken(accessToken);
} catch (error) {
// Facebook injoignable : on ne dégrade pas le statut sur un incident
// réseau, sinon toute l'UI passerait au rouge le temps d'une panne.
const message = error instanceof Error ? error.message : String(error);
console.warn(`[TokenManager] Inspection impossible pour ${page.pageName}: ${message}`);
return this.legacyCheck(page, accessToken);
}
const expiresAt = info.expiresAt;
const expiringSoon =
expiresAt !== null && expiresAt.getTime() - Date.now() < RENEWAL_WINDOW_MS;
if (info.isValid && !expiringSoon) {
// Le jeton de page tient : on en profite pour prolonger le jeton
// utilisateur s'il approche de son échéance, car c'est lui qui
// permettra le prochain renouvellement.
await this.extendUserTokenIfNeeded(page);
await this.persistStatus(page.id, 'valid', null, { tokenExpiresAt: expiresAt });
return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: false };
}
const reason = info.isValid
? `Le jeton expire le ${expiresAt?.toLocaleDateString('fr-FR')}`
: info.error || 'Le jeton a été invalidé par Facebook';
console.warn(`[TokenManager] ${page.pageName}: ${reason} — tentative de renouvellement`);
// Renouvellement automatique : c'est tout l'intérêt d'avoir conservé le
// jeton utilisateur longue durée au moment de la connexion OAuth.
try {
const renewed = await this.renewFromUserToken(page);
if (renewed) {
console.log(`[TokenManager] Jeton renouvelé automatiquement pour ${page.pageName}`);
return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: true };
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[TokenManager] Renouvellement impossible pour ${page.pageName}: ${message}`);
await this.persistStatus(page.id, info.isValid ? 'expiring' : 'expired', message, {
tokenExpiresAt: expiresAt,
});
return {
pageId: page.id,
pageName: page.pageName,
status: info.isValid ? 'expiring' : 'expired',
renewed: false,
error: message,
};
}
// Pas de jeton utilisateur en base : la page a été ajoutée à la main,
// seule une reconnexion peut la remettre d'aplomb.
const manual =
`${reason}. Reconnectez la page via « Connecter des pages Facebook » ` +
'pour activer le renouvellement automatique.';
const status = info.isValid ? 'expiring' : 'expired';
await this.persistStatus(page.id, status, manual, { tokenExpiresAt: expiresAt });
return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: manual };
}
/**
* Régénère le jeton de page à partir du jeton utilisateur longue durée.
* Renvoie false quand la page n'a pas de jeton utilisateur enregistré.
*/
private static async renewFromUserToken(page: SocialPage): Promise<boolean> {
if (!page.userAccessToken) return false;
const storedUserToken = this.readToken(page.userAccessToken);
// On prolonge d'abord le jeton utilisateur : le ré-échanger repart pour
// ~60 jours, ce qui repousse d'autant la prochaine reconnexion manuelle.
// Un échec ici n'est pas bloquant tant que le jeton actuel fonctionne.
let userToken = storedUserToken;
let userTokenExpiresAt = page.userTokenExpiresAt ?? null;
try {
const extended = await facebookOAuthService.extendUserToken(storedUserToken);
userToken = extended.accessToken;
userTokenExpiresAt = extended.expiresAt;
} catch (error) {
console.warn(
`[TokenManager] Prolongation du jeton utilisateur impossible pour ${page.pageName}:`,
error instanceof Error ? error.message : error
);
}
const targets = await facebookOAuthService.listPages(userToken);
// Une page Instagram est publiée avec le jeton de la page Facebook à
// laquelle son compte professionnel est rattaché.
const match = targets.find(
(target) => target.id === page.pageId || target.instagram?.id === page.pageId
);
if (!match) {
throw new FacebookAuthError(
`La page « ${page.pageName} » n'est plus administrée par le compte Facebook connecté. ` +
'Reconnectez-la avec un compte administrateur de cette page.'
);
}
// Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire
// pas : `expiresAt` reste donc généralement nul.
let pageTokenExpiresAt: Date | null = null;
try {
pageTokenExpiresAt = (await facebookOAuthService.inspectToken(match.accessToken)).expiresAt;
} catch {
// L'inspection est un confort : le jeton fraîchement émis est bon.
}
await db.update(socialPages)
.set({
accessToken: match.accessToken,
tokenExpiresAt: pageTokenExpiresAt,
userAccessToken: encrypt(userToken),
userTokenExpiresAt,
followersCount: match.followersCount || page.followersCount,
tokenStatus: 'valid',
tokenError: null,
lastTokenCheck: new Date(),
})
.where(eq(socialPages.id, page.id));
return true;
}
/**
* Prolonge le jeton utilisateur quand son échéance approche, même si le
* jeton de page se porte bien : c'est ce qui évite de perdre la capacité de
* renouveler le jour où le jeton de page est révoqué.
*/
private static async extendUserTokenIfNeeded(page: SocialPage): Promise<void> {
if (!page.userAccessToken) return;
const expiresAt = page.userTokenExpiresAt ? new Date(page.userTokenExpiresAt).getTime() : null;
if (expiresAt !== null && expiresAt - Date.now() > RENEWAL_WINDOW_MS) return;
try {
const extended = await facebookOAuthService.extendUserToken(this.readToken(page.userAccessToken));
await db.update(socialPages)
.set({
userAccessToken: encrypt(extended.accessToken),
userTokenExpiresAt: extended.expiresAt,
})
.where(eq(socialPages.id, page.id));
console.log(`[TokenManager] Jeton utilisateur prolongé pour ${page.pageName}`);
} catch (error) {
console.warn(
`[TokenManager] Impossible de prolonger le jeton utilisateur de ${page.pageName}:`,
error instanceof Error ? error.message : error
);
}
}
/**
* Contrôle historique, utilisé quand l'application Facebook n'est pas
* configurée : on sait seulement si le jeton répond encore.
*/
private static async legacyCheck(page: SocialPage, accessToken: string): Promise<TokenCheckResult> {
const isValid = await this.verifyToken(accessToken);
if (!isValid) {
const error =
"Le jeton n'est plus accepté par Facebook. Configurez l'application Facebook " +
'dans les paramètres pour activer le renouvellement automatique, ou saisissez un nouveau jeton.';
await this.persistStatus(page.id, 'expired', error);
return { pageId: page.id, pageName: page.pageName, status: 'expired', renewed: false, error };
}
// Le jeton répond : reste l'échéance déclarée à l'ajout de la page.
const declared = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : null;
const status = declared !== null && declared - Date.now() < WARNING_WINDOW_MS ? 'expiring' : 'valid';
await this.persistStatus(page.id, status, null);
return { pageId: page.id, pageName: page.pageName, status, renewed: false };
}
/**
* Vérifie qu'un compte TikTok est toujours utilisable.
* On ne teste pas l'access token (il est renouvelé à la demande avant chaque
* publication) mais le refresh token, dont l'expiration déconnecterait le compte.
*/
private static async processTiktokToken(page: SocialPage): Promise<TokenCheckResult> {
console.log(`[TokenManager] Checking TikTok account: ${page.pageName}`);
const refreshExpiresAt = page.refreshTokenExpiresAt
? new Date(page.refreshTokenExpiresAt).getTime()
: 0;
let status: "valid" | "expiring" | "expired" = "valid";
let error: string | null = null;
if (!page.refreshToken || (refreshExpiresAt && refreshExpiresAt <= Date.now())) {
status = "expired";
error = "L'autorisation TikTok a expiré : reconnectez le compte.";
console.warn(`[TokenManager] TikTok account ${page.pageName} must be reconnected`);
} else if (refreshExpiresAt && refreshExpiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000) {
// Moins de 30 jours de validité restante
status = "expiring";
}
await this.persistStatus(page.id, status, error);
return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: error ?? undefined };
}
private static async persistStatus(
pageId: string,
status: 'valid' | 'expiring' | 'expired' | 'error',
error: string | null,
extra: { tokenExpiresAt?: Date | null } = {}
): Promise<void> {
await db.update(socialPages)
.set({
tokenStatus: status,
tokenError: error,
lastTokenCheck: new Date(),
...(extra.tokenExpiresAt !== undefined ? { tokenExpiresAt: extra.tokenExpiresAt } : {}),
})
.where(eq(socialPages.id, pageId));
}
/**
* Les jetons ont pu être enregistrés en clair (saisie manuelle) ou chiffrés
* (flux OAuth) : `decrypt` renvoie le texte tel quel s'il n'est pas chiffré.
*/
private static readToken(token: string): string {
try {
return this.decrypt(token);
} catch {
return token;
}
}
private static async verifyToken(token: string): Promise<boolean> {
try {
// Simple validation check: Get 'me' (page profile)
await GraphAPIClient.get('me', { accessToken: token });
return true;
} catch (error) {
console.error('[TokenManager] Token verification failed:', error);
return false;
}
}
}
-14
View File
@@ -1,14 +0,0 @@
import { describe, expect, it } from "vitest";
import { estimateVoiceTiming } from "./ttsSync";
describe("estimateVoiceTiming", () => {
it("ignore hashtags et liens dans le décompte", () => {
expect(estimateVoiceTiming("Venez vite #promo https://x.fr").wordCount).toBe(2);
});
it("prévient quand la voix sera trop longue", () => {
const timing = estimateVoiceTiming("mot ".repeat(200));
expect(timing.isHealthy).toBe(false);
expect(timing.warnings[0]).toMatch(/Texte long/);
});
});
-49
View File
@@ -1,49 +0,0 @@
/**
* Estimation de la durée de lecture d'un texte, affichée pendant la saisie.
*
* Auparavant, chaque pause de frappe générait une voix complète pour la
* mesurer (un appel Gemini facturé à chaque fois). Le minutage réel des mots
* vient désormais de la voix au moment du rendu ; ici, une estimation suffit.
*/
/** Débit moyen d'une voix de synthèse française, en mots par seconde. */
const WORDS_PER_SECOND = 2.6;
/** Au-delà, la voix dépasse la durée confortable d'un Reel. */
const MAX_COMFORTABLE_SECONDS = 45;
export interface SyncTiming {
wordDuration: number;
audioDuration: number;
wordCount: number;
punctuationPause: number;
isHealthy: boolean;
warnings: string[];
}
export function estimateVoiceTiming(text: string): SyncTiming {
const words = text
.replace(/#[\wÀ-ÿ]+/g, '')
.replace(/https?:\/\/\S+/g, '')
.split(/\s+/)
.filter((w) => /[A-Za-z0-9À-ÿ]/.test(w));
const pauses = (text.match(/[.!?;:]/g) ?? []).length;
const punctuationPause = 0.35;
const audioDuration = words.length / WORDS_PER_SECOND + pauses * punctuationPause;
const warnings: string[] = [];
if (audioDuration > MAX_COMFORTABLE_SECONDS) {
warnings.push(`Texte long : environ ${Math.round(audioDuration)} s de voix. Visez moins de ${MAX_COMFORTABLE_SECONDS} s.`);
}
if (words.length > 0 && words.length < 4) {
warnings.push('Texte très court : la voix ne durera que quelques secondes.');
}
return {
wordDuration: words.length ? audioDuration / words.length : 0,
audioDuration,
wordCount: words.length,
punctuationPause,
isHealthy: warnings.length === 0,
warnings,
};
}
-11
View File
@@ -1,11 +0,0 @@
declare module 'twemoji-parser' {
export interface EmojiEntity {
text: string;
url: string;
indices: [number, number];
type: string;
}
export function parse(text: string, options?: { buildUrl?: (codePoint: string, assetType: string) => string }): EmojiEntity[];
export function toCodePoints(unicodeSurrogates: string): string[];
}
+394
View File
@@ -0,0 +1,394 @@
//! Authentification par session.
//!
//! Compatible avec l'ancien couple express-session + connect-pg-simple :
//! même cookie (`connect.sid`, signé HMAC-SHA256 avec `SESSION_SECRET`),
//! même table `session`, même structure JSON (`passport.user`). Les sessions
//! ouvertes avant la mise à jour restent donc valides.
use std::ops::Deref;
use axum::extract::FromRequestParts;
use axum::http::header::{COOKIE, SET_COOKIE};
use axum::http::request::Parts;
use axum::http::{HeaderMap, HeaderValue};
use axum::response::{IntoResponse, Response};
use base64::Engine;
use chrono::Duration;
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde_json::{json, Map, Value};
use sha2::Sha256;
use crate::db::{User, UserRole};
use crate::error::AppError;
use crate::public_url::is_https;
use crate::state::AppState;
use crate::time::Ts;
pub const COOKIE_NAME: &str = "connect.sid";
/// Durée de vie d'une session : 7 jours.
pub const SESSION_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
const COOKIE_ENCODE: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'!')
.remove(b'~')
.remove(b'*')
.remove(b'\'')
.remove(b'(')
.remove(b')');
/// Session chargée depuis la base ; `data` est l'objet JSON stocké (`sess`).
#[derive(Debug, Clone)]
pub struct Session {
pub sid: String,
pub data: Value,
}
impl Session {
pub fn user_id(&self) -> Option<&str> {
self.data.get("passport")?.get("user")?.as_str()
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.data.get(key)
}
pub fn get_str(&self, key: &str) -> Option<String> {
self.data
.get(key)
.and_then(Value::as_str)
.map(str::to_string)
}
pub fn set(&mut self, key: &str, value: Value) {
if let Value::Object(map) = &mut self.data {
map.insert(key.to_string(), value);
}
}
pub fn remove(&mut self, key: &str) -> Option<Value> {
match &mut self.data {
Value::Object(map) => map.remove(key),
_ => None,
}
}
/// Enregistre les données modifiées (et prolonge la session).
pub async fn save(&self, state: &AppState) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE session SET sess = $2, expire = $3 WHERE sid = $1")
.bind(&self.sid)
.bind(&self.data)
.bind(Ts::now().add(Duration::milliseconds(SESSION_TTL_MS)))
.execute(&state.db.pool)
.await?;
Ok(())
}
}
fn sign(secret: &str, value: &str) -> String {
let mut mac =
Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepte toute clé");
mac.update(value.as_bytes());
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
format!("{value}.{}", sig.trim_end_matches('='))
}
/// `s:<sid>.<signature>` → sid, si la signature est valide.
fn unsign(secret: &str, signed: &str) -> Option<String> {
let raw = signed.strip_prefix("s:")?;
let (value, _) = raw.rsplit_once('.')?;
let expected = sign(secret, value);
// Comparaison à temps constant
let a = expected.as_bytes();
let b = raw.as_bytes();
if a.len() != b.len() {
return None;
}
let diff = a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y));
(diff == 0).then(|| value.to_string())
}
fn session_cookie_value(headers: &HeaderMap) -> Option<String> {
for header in headers.get_all(COOKIE) {
let Ok(text) = header.to_str() else { continue };
for pair in text.split(';') {
let pair = pair.trim();
if let Some(value) = pair.strip_prefix(&format!("{COOKIE_NAME}=")) {
return percent_encoding::percent_decode_str(value)
.decode_utf8()
.ok()
.map(|v| v.into_owned());
}
}
}
None
}
/// Identifiant de session (comme `uid-safe(24)` : 32 caractères base64url).
fn new_sid() -> String {
let mut bytes = [0u8; 24];
rand::thread_rng().fill_bytes(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn cookie_meta(secure: bool, expires: Ts) -> Value {
json!({
"originalMaxAge": SESSION_TTL_MS,
"expires": expires.to_iso(),
"secure": secure,
"httpOnly": true,
"path": "/",
"sameSite": "lax",
})
}
/// En-tête `Set-Cookie` de la session.
pub fn session_cookie(
state: &AppState,
headers: &HeaderMap,
sid: &str,
expires: Ts,
) -> HeaderValue {
let signed = sign(&state.config.session_secret, sid);
let encoded =
percent_encoding::utf8_percent_encode(&format!("s:{signed}"), COOKIE_ENCODE).to_string();
let mut cookie = format!(
"{COOKIE_NAME}={encoded}; Path=/; Expires={}; HttpOnly; SameSite=Lax",
expires.utc().format("%a, %d %b %Y %H:%M:%S GMT")
);
if is_https(state, headers) {
cookie.push_str("; Secure");
}
HeaderValue::from_str(&cookie).expect("cookie ASCII")
}
/// En-tête `Set-Cookie` qui efface la session côté navigateur.
pub fn clear_session_cookie() -> HeaderValue {
HeaderValue::from_static(
"connect.sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax",
)
}
/// Charge la session de la requête (cookie valide et session non expirée).
pub async fn load_session(state: &AppState, headers: &HeaderMap) -> Option<Session> {
let cookie = session_cookie_value(headers)?;
let sid = unsign(&state.config.session_secret, &cookie)?;
let row: Option<(Value,)> =
sqlx::query_as("SELECT sess FROM session WHERE sid = $1 AND expire > $2")
.bind(&sid)
.bind(Ts::now())
.fetch_optional(&state.db.pool)
.await
.ok()?;
row.map(|(data,)| Session { sid, data })
}
/// Ouvre une nouvelle session pour l'utilisateur (l'ancienne est détruite,
/// comme le faisait passport lors de la connexion).
pub async fn start_session(
state: &AppState,
headers: &HeaderMap,
previous: Option<&Session>,
user_id: &str,
) -> Result<(Session, HeaderValue), sqlx::Error> {
if let Some(previous) = previous {
destroy_session(state, &previous.sid).await?;
}
let sid = new_sid();
let expires = Ts::now().add(Duration::milliseconds(SESSION_TTL_MS));
let data = json!({
"cookie": cookie_meta(is_https(state, headers), expires),
"passport": { "user": user_id },
});
sqlx::query("INSERT INTO session (sid, sess, expire) VALUES ($1, $2, $3)")
.bind(&sid)
.bind(&data)
.bind(expires)
.execute(&state.db.pool)
.await?;
let cookie = session_cookie(state, headers, &sid, expires);
Ok((Session { sid, data }, cookie))
}
pub async fn destroy_session(state: &AppState, sid: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM session WHERE sid = $1")
.bind(sid)
.execute(&state.db.pool)
.await?;
Ok(())
}
/// Prolonge la session en base (équivalent du `touch` de connect-pg-simple).
fn touch_session(state: &AppState, sid: String) {
let state = state.clone();
tokio::spawn(async move {
let expire = Ts::now().add(Duration::milliseconds(SESSION_TTL_MS));
let _ = sqlx::query("UPDATE session SET expire = $2 WHERE sid = $1")
.bind(sid)
.bind(expire)
.execute(&state.db.pool)
.await;
});
}
/// Purge des sessions expirées (appelée périodiquement).
pub async fn prune_sessions(state: &AppState) {
let _ = sqlx::query("DELETE FROM session WHERE expire < $1")
.bind(Ts::now())
.execute(&state.db.pool)
.await;
}
/// Utilisateur connecté et sa session.
pub async fn current_user(state: &AppState, headers: &HeaderMap) -> Option<(User, Session)> {
let session = load_session(state, headers).await?;
let user_id = session.user_id()?.to_string();
let user = state.db.get_user(&user_id).await.ok()??;
touch_session(state, session.sid.clone());
Some((user, session))
}
/// Extracteur : utilisateur authentifié, sinon `401 { error: "Non authentifié" }`.
pub struct AuthUser {
pub user: User,
pub session: Session,
}
impl Deref for AuthUser {
type Target = User;
fn deref(&self) -> &User {
&self.user
}
}
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
match current_user(state, &parts.headers).await {
Some((user, session)) => Ok(AuthUser { user, session }),
None => Err(AppError::unauthorized("Non authentifié")),
}
}
}
/// Extracteur : administrateur, sinon 401 / 403.
pub struct AdminUser {
pub user: User,
pub session: Session,
}
impl Deref for AdminUser {
type Target = User;
fn deref(&self) -> &User {
&self.user
}
}
impl FromRequestParts<AppState> for AdminUser {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let AuthUser { user, session } = AuthUser::from_request_parts(parts, state).await?;
if user.role != UserRole::Admin {
return Err(AppError::forbidden(
"Accès refusé. Réservé aux administrateurs.",
));
}
Ok(AdminUser { user, session })
}
}
/// Extracteur optionnel.
pub struct MaybeUser(pub Option<(User, Session)>);
impl FromRequestParts<AppState> for MaybeUser {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
Ok(MaybeUser(current_user(state, &parts.headers).await))
}
}
/// Réponse JSON accompagnée d'un `Set-Cookie`.
pub fn with_cookie(body: Value, cookie: HeaderValue) -> Response {
let mut response = axum::Json(body).into_response();
response.headers_mut().append(SET_COOKIE, cookie);
response
}
pub fn user_json(user: &User) -> Value {
let mut map = Map::new();
map.insert("id".into(), Value::String(user.id.clone()));
map.insert("username".into(), Value::String(user.username.clone()));
map.insert("role".into(), Value::String(user.role.as_str().into()));
Value::Object(map)
}
pub async fn hash_password(password: &str) -> anyhow::Result<String> {
let password = password.to_string();
Ok(tokio::task::spawn_blocking(move || bcrypt::hash(password, 10)).await??)
}
pub async fn verify_password(password: &str, hash: &str) -> bool {
let password = password.to_string();
let hash = hash.to_string();
tokio::task::spawn_blocking(move || bcrypt::verify(password, &hash).unwrap_or(false))
.await
.unwrap_or(false)
}
/// Crée l'utilisateur `admin` / `admin` au premier démarrage.
pub async fn ensure_admin_user_exists(state: &AppState) -> anyhow::Result<()> {
if state.db.get_user_by_username("admin").await?.is_some() {
tracing::info!("✓ Utilisateur admin existe déjà");
return Ok(());
}
tracing::info!("🔧 Création de l'utilisateur admin par défaut...");
let hash = hash_password("admin").await?;
state
.db
.create_user("admin", &hash, UserRole::Admin)
.await?;
tracing::info!("✅ Utilisateur admin créé avec succès (admin / admin)");
tracing::warn!("⚠️ IMPORTANT: Changez ce mot de passe immédiatement !");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signature_matches_cookie_signature_package() {
// Valeur produite par `cookie-signature` (Node) : sign('abc', 'secret')
assert_eq!(
sign("secret", "abc"),
"abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4"
);
assert_eq!(
unsign(
"secret",
"s:abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4"
)
.as_deref(),
Some("abc")
);
assert!(unsign("secret", "s:abc.invalid").is_none());
assert!(unsign("other", "s:abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4").is_none());
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Configuration lue dans l'environnement (mêmes variables que l'ancien serveur).
use std::path::PathBuf;
use anyhow::bail;
use rand::RngCore;
#[derive(Debug, Clone)]
pub struct Config {
pub port: u16,
pub database_url: String,
pub session_secret: String,
pub production: bool,
/// URL publique (ex. https://socialflow.exemple.fr), sans « / » final.
pub app_url: Option<String>,
/// URL interne Docker par laquelle les autres services joignent l'app.
pub internal_app_url: Option<String>,
/// Frontend compilé (Vite) : `dist/public`.
pub static_dir: PathBuf,
/// Fichiers servis à la racine du domaine (vérifications de domaine).
pub public_root_dir: PathBuf,
/// Racine des fichiers servis sous /uploads.
pub uploads_dir: PathBuf,
pub audio_upload_dir: PathBuf,
pub temp_upload_dir: PathBuf,
/// Polices (DejaVuSans.ttf) pour le texte incrusté dans les images.
pub fonts_dir: PathBuf,
pub gemini_api_key: Option<String>,
pub external_api_key: Option<String>,
/// « remotion » (défaut) ou « ffmpeg ».
pub reel_renderer: String,
/// Service de rendu Remotion (conteneur Node dédié).
pub remotion_url: Option<String>,
pub ffmpeg_api_url: Option<String>,
pub ffmpeg_api_key: Option<String>,
}
fn env(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
let cwd = std::env::current_dir()?;
let production = matches!(
env("NODE_ENV").or_else(|| env("APP_ENV")).as_deref(),
Some("production")
);
let session_secret = match env("SESSION_SECRET") {
Some(secret) => secret,
None if production => {
bail!("SESSION_SECRET non défini en production. Arrêt du serveur.")
}
None => {
tracing::warn!(
"⚠️ SESSION_SECRET non défini. Utilisation d'une clé aléatoire pour le développement."
);
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
};
let database_url = match env("DATABASE_URL") {
Some(url) => url,
None => bail!("DATABASE_URL must be set. Did you forget to provision a database?"),
};
let uploads_dir = cwd.join("uploads");
let audio_upload_dir = env("AUDIO_UPLOAD_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| uploads_dir.join("audio"));
let temp_upload_dir = env("TEMP_UPLOAD_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| uploads_dir.join("temp"));
let static_dir = env("STATIC_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| cwd.join("dist").join("public"));
let fonts_dir = env("FONTS_DIR").map(PathBuf::from).unwrap_or_else(|| {
let local = cwd.join("server").join("fonts");
if local.exists() {
local
} else {
cwd.join("fonts")
}
});
Ok(Self {
port: env("PORT").and_then(|p| p.parse().ok()).unwrap_or(5000),
database_url,
session_secret,
production,
app_url: env("APP_URL").map(|u| u.trim_end_matches('/').to_string()),
internal_app_url: env("INTERNAL_APP_URL").map(|u| u.trim_end_matches('/').to_string()),
static_dir,
public_root_dir: env("PUBLIC_ROOT_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| cwd.join("public-root")),
uploads_dir,
audio_upload_dir,
temp_upload_dir,
fonts_dir,
gemini_api_key: env("GEMINI_API_KEY"),
external_api_key: env("EXTERNAL_API_KEY"),
reel_renderer: if env("REEL_RENDERER").as_deref() == Some("ffmpeg") {
"ffmpeg".into()
} else {
"remotion".into()
},
remotion_url: env("REMOTION_RENDERER_URL").map(|u| u.trim_end_matches('/').to_string()),
ffmpeg_api_url: env("FFMPEG_API_URL"),
ffmpeg_api_key: env("FFMPEG_API_KEY"),
})
}
/// Lecture directe d'une variable optionnelle (services peu configurés).
pub fn var(name: &str) -> Option<String> {
env(name)
}
}
+174
View File
@@ -0,0 +1,174 @@
//! Chiffrement des jetons au repos (AES-256-GCM), compatible avec les valeurs
//! déjà écrites par l'ancien serveur : clé dérivée par scrypt du secret
//! (`ENCRYPTION_KEY`, sinon fichier persistant), IV de 16 octets, format
//! `iv:authTag:chiffré` en hexadécimal.
use std::path::{Path, PathBuf};
use aes_gcm::aead::consts::U16;
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::aes::Aes256;
use aes_gcm::{AesGcm, Nonce};
use once_cell::sync::OnceCell;
use rand::RngCore;
use crate::config::Config;
type Cipher = AesGcm<Aes256, U16>;
static KEY: OnceCell<[u8; 32]> = OnceCell::new();
const SALT: &[u8] = b"socialflow-salt";
const DOCKER_KEY_DIR: &str = "/app/.encryption-key-data";
fn derive(secret: &str) -> [u8; 32] {
// Paramètres par défaut de crypto.scryptSync : N=16384, r=8, p=1
let params = scrypt::Params::new(14, 8, 1, 32).expect("paramètres scrypt valides");
let mut out = [0u8; 32];
scrypt::scrypt(secret.as_bytes(), SALT, &params, &mut out).expect("dérivation scrypt");
out
}
fn key_file_path() -> PathBuf {
let dir = if Path::new(DOCKER_KEY_DIR).exists() {
PathBuf::from(DOCKER_KEY_DIR)
} else {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
};
dir.join(".encryption-key")
}
/// Charge (ou crée) la clé de chiffrement. Appelé une fois au démarrage.
pub fn init(_config: &Config) {
KEY.get_or_init(load_key);
}
fn load_key() -> [u8; 32] {
if let Some(secret) = Config::var("ENCRYPTION_KEY") {
return derive(&secret);
}
let path = key_file_path();
if let Ok(saved) = std::fs::read_to_string(&path) {
let saved = saved.trim();
if !saved.is_empty() {
tracing::info!("🔐 Clé de chiffrement chargée depuis le fichier persistant");
return derive(saved);
}
}
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let secret = hex::encode(bytes);
match write_private(&path, &secret) {
Ok(()) => tracing::info!("🔐 Nouvelle clé de chiffrement générée et sauvegardée automatiquement"),
Err(_) => tracing::warn!(
"⚠️ Impossible de sauvegarder la clé de chiffrement dans un fichier. Elle sera régénérée au prochain redémarrage."
),
}
derive(&secret)
}
fn write_private(path: &Path, content: &str) -> std::io::Result<()> {
std::fs::write(path, content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
fn key() -> &'static [u8; 32] {
KEY.get_or_init(load_key)
}
pub fn encrypt(text: &str) -> String {
let cipher = Cipher::new_from_slice(key()).expect("clé de 32 octets");
let mut iv = [0u8; 16];
rand::thread_rng().fill_bytes(&mut iv);
let sealed = cipher
.encrypt(
Nonce::<U16>::from_slice(&iv),
Payload {
msg: text.as_bytes(),
aad: &[],
},
)
.expect("chiffrement AES-GCM");
// `sealed` = chiffré || tag (16 octets)
let (ciphertext, tag) = sealed.split_at(sealed.len() - 16);
format!(
"{}:{}:{}",
hex::encode(iv),
hex::encode(tag),
hex::encode(ciphertext)
)
}
/// Déchiffre ; une valeur qui n'a pas le format attendu (jeton enregistré en
/// clair avant le chiffrement) est rendue telle quelle.
pub fn decrypt(value: &str) -> String {
if !value.contains(':') {
return value.to_string();
}
let parts: Vec<&str> = value.split(':').collect();
if parts.len() != 3 {
return value.to_string();
}
let attempt = || -> Option<String> {
let iv = hex::decode(parts[0]).ok()?;
let tag = hex::decode(parts[1]).ok()?;
let mut data = hex::decode(parts[2]).ok()?;
if iv.len() != 16 || tag.len() != 16 {
return None;
}
data.extend_from_slice(&tag);
let cipher = Cipher::new_from_slice(key()).ok()?;
let plain = cipher
.decrypt(
Nonce::<U16>::from_slice(&iv),
Payload {
msg: &data,
aad: &[],
},
)
.ok()?;
String::from_utf8(plain).ok()
};
match attempt() {
Some(plain) => plain,
None => {
tracing::warn!("⚠️ Échec du déchiffrement, token probablement non chiffré");
value.to_string()
}
}
}
#[allow(dead_code)]
pub fn is_encrypted(text: &str) -> bool {
let parts: Vec<&str> = text.split(':').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_hexdigit()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() {
let _ = KEY.set(derive("test-secret"));
let sealed = encrypt("EAAB-token");
assert!(is_encrypted(&sealed));
assert_eq!(decrypt(&sealed), "EAAB-token");
assert_eq!(decrypt("plain-token"), "plain-token");
// Valeur chiffrée par l'ancien serveur Node avec la même clé
assert_eq!(
decrypt("b313bfc2eec1a7b8c9743a580c75561b:348a147444c5ef2ee708e5b8bb72d5d0:aebf8e28c21928d5ad207d48d210f4"),
"EAAB-node-token"
);
}
}
+1463
View File
File diff suppressed because it is too large. Load diff
+374
View File
@@ -0,0 +1,374 @@
//! Lignes de la base, sérialisées en camelCase comme les objets Drizzle que
//! renvoyait l'ancien serveur.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::FromRow;
use crate::time::Ts;
macro_rules! pg_enum {
($name:ident, $pg:literal, { $($variant:ident => $text:literal),+ $(,)? }) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = $pg)]
pub enum $name {
$(
#[serde(rename = $text)]
#[sqlx(rename = $text)]
$variant,
)+
}
impl $name {
pub fn as_str(&self) -> &'static str {
match self {
$( $name::$variant => $text, )+
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
$( $text => Some($name::$variant), )+
_ => None,
}
}
/// Nom du type énuméré PostgreSQL (pour les conversions `$1::type`).
#[allow(dead_code)]
pub const PG_TYPE: &'static str = $pg;
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
};
}
pg_enum!(Platform, "platform", { Facebook => "facebook", Instagram => "instagram", Tiktok => "tiktok" });
pg_enum!(PostType, "post_type", { Feed => "feed", Story => "story", Both => "both", Reel => "reel" });
pg_enum!(PostStatus, "post_status", { Draft => "draft", Scheduled => "scheduled", Published => "published", Failed => "failed" });
pg_enum!(MediaType, "media_type", { Image => "image", Video => "video" });
pg_enum!(UserRole, "user_role", { Admin => "admin", User => "user" });
pg_enum!(TokenStatus, "token_status", { Valid => "valid", Expiring => "expiring", Expired => "expired", Error => "error" });
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: String,
pub username: String,
#[serde(skip_serializing)]
pub password: String,
pub role: UserRole,
}
impl User {
pub fn is_admin(&self) -> bool {
self.role == UserRole::Admin
}
}
/// Page ou compte connecté. Les jetons sont **déchiffrés** par la couche
/// d'accès aux données ; ils ne sont jamais sérialisés vers le navigateur.
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct SocialPage {
pub id: String,
pub user_id: String,
pub platform: Platform,
pub page_id: String,
pub page_name: String,
#[serde(skip_serializing)]
pub access_token: String,
pub token_expires_at: Option<Ts>,
pub followers_count: Option<i32>,
pub is_active: String,
pub token_status: Option<TokenStatus>,
pub last_token_check: Option<Ts>,
#[serde(skip_serializing)]
pub user_access_token: Option<String>,
pub user_token_expires_at: Option<Ts>,
pub token_error: Option<String>,
#[serde(skip_serializing)]
pub refresh_token: Option<String>,
pub refresh_token_expires_at: Option<Ts>,
pub scopes: Option<String>,
pub avatar_url: Option<String>,
pub created_at: Option<Ts>,
}
impl SocialPage {
/// Vue client : sans jetons, avec l'indicateur de renouvellement automatique.
pub fn to_client(&self) -> Value {
let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
let auto_renew = match self.platform {
Platform::Tiktok => self.refresh_token.as_deref().is_some_and(|t| !t.is_empty()),
_ => self
.user_access_token
.as_deref()
.is_some_and(|t| !t.is_empty()),
};
if let Value::Object(map) = &mut value {
map.insert("autoRenew".into(), Value::Bool(auto_renew));
}
value
}
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Media {
pub id: String,
pub user_id: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub media_type: MediaType,
pub cloudinary_public_id: Option<String>,
pub original_url: String,
pub facebook_landscape_url: Option<String>,
pub facebook_feed_url: Option<String>,
pub instagram_feed_url: Option<String>,
pub instagram_story_url: Option<String>,
pub thumbnail_url: Option<String>,
pub file_name: String,
pub file_size: i32,
pub created_at: Option<Ts>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Post {
pub id: String,
pub user_id: String,
pub content: String,
pub ai_generated: String,
pub product_info: Option<Value>,
pub status: PostStatus,
pub scheduled_for: Option<Ts>,
pub generation_status: Option<String>,
pub generation_progress: Option<i32>,
pub generation_error: Option<String>,
pub created_at: Option<Ts>,
pub updated_at: Option<Ts>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct PostMedia {
pub id: String,
pub post_id: String,
pub media_id: String,
pub display_order: i32,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledPost {
pub id: String,
pub post_id: String,
pub page_id: String,
pub post_type: PostType,
pub scheduled_at: Ts,
pub published_at: Option<Ts>,
pub external_post_id: Option<String>,
pub error: Option<String>,
pub publish_id: Option<String>,
pub publish_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct AiGeneration {
pub id: String,
pub user_id: String,
pub product_info: Value,
pub generated_texts: Value,
pub selected_text: Option<String>,
pub created_at: Option<Ts>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct CloudinaryConfig {
pub id: String,
pub cloud_name: String,
pub api_key: String,
#[serde(skip_serializing)]
pub api_secret: String,
pub logo_public_id: Option<String>,
pub endpoint_url: Option<String>,
pub public_url: Option<String>,
pub created_at: Option<Ts>,
pub updated_at: Option<Ts>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct OpenrouterConfig {
pub id: String,
pub user_id: String,
#[serde(skip_serializing)]
pub api_key: String,
pub model: String,
pub system_prompt: String,
pub created_at: Option<Ts>,
pub updated_at: Option<Ts>,
}
/// Application développeur Facebook (secret déchiffré).
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct FacebookConfig {
pub id: String,
pub app_id: String,
#[serde(skip_serializing)]
pub app_secret: String,
pub created_at: Option<Ts>,
pub updated_at: Option<Ts>,
}
/// Application développeur TikTok (secret déchiffré).
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct TiktokConfig {
pub id: String,
pub client_key: String,
#[serde(skip_serializing)]
pub client_secret: String,
pub created_at: Option<Ts>,
pub updated_at: Option<Ts>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct MusicFavorite {
pub id: String,
pub user_id: String,
pub track_id: String,
pub title: String,
pub artist: String,
pub album_name: Option<String>,
pub duration: i32,
pub preview_url: String,
pub download_url: String,
pub image_url: Option<String>,
pub license: Option<String>,
pub created_at: Option<Ts>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct AppConfig {
pub id: String,
pub external_api_key: Option<String>,
pub gemini_api_key: Option<String>,
pub qwen_tts_url: Option<String>,
pub qwen_tts_api_key: Option<String>,
pub created_at: Option<Ts>,
pub updated_at: Option<Ts>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct AudioTrack {
pub id: String,
pub user_id: Option<String>,
pub title: String,
pub file_name: String,
pub url: String,
pub duration: Option<i32>,
pub created_at: Option<Ts>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct UserPagePermission {
pub id: String,
pub user_id: String,
pub page_id: String,
pub created_at: Option<Ts>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct ReelJob {
pub id: String,
pub user_id: String,
pub post_id: Option<String>,
pub kind: String,
pub params: Value,
pub status: String,
pub step: Option<String>,
pub progress: i32,
pub attempts: i32,
pub result: Option<Value>,
pub error: Option<String>,
pub heartbeat_at: Option<Ts>,
pub created_at: Ts,
pub updated_at: Ts,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct PostAnalytics {
pub id: String,
pub post_id: String,
pub fetched_at: Option<Ts>,
pub impressions: Option<i32>,
pub reach: Option<i32>,
pub engagement: Option<i32>,
pub reactions: Option<i32>,
pub comments: Option<i32>,
pub shares: Option<i32>,
pub clicks: Option<i32>,
pub raw_data: Option<Value>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct PageAnalyticsHistory {
pub id: String,
pub page_id: String,
pub date: Ts,
pub followers_count: Option<i32>,
pub page_views: Option<i32>,
pub page_reach: Option<i32>,
pub page_engagement: Option<i32>,
pub created_at: Option<Ts>,
}
/// Publication planifiée accompagnée du post et de la page (sans jetons),
/// telle que la renvoyaient les routes du calendrier et de l'historique.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledPostWithDetails {
#[serde(flatten)]
pub scheduled: ScheduledPost,
pub post: ScheduledPostPost,
pub page: Option<ScheduledPostPage>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledPostPost {
pub id: String,
pub user_id: String,
pub content: String,
pub status: PostStatus,
pub scheduled_for: Option<Ts>,
pub ai_generated: String,
pub generation_status: Option<String>,
pub generation_progress: Option<i32>,
pub created_at: Option<Ts>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledPostPage {
pub id: String,
pub page_id: String,
pub page_name: String,
pub platform: Platform,
pub avatar_url: Option<String>,
}
+102
View File
@@ -0,0 +1,102 @@
//! Erreurs HTTP au format attendu par le client : `{ "error": "message" }`.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{json, Value};
#[derive(Debug)]
pub struct AppError {
pub status: StatusCode,
pub body: Value,
}
pub type ApiResult<T = Response> = Result<T, AppError>;
impl AppError {
/// Réponse `{ error: message }` avec le statut donné.
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self {
status,
body: json!({ "error": message.into() }),
}
}
/// Corps JSON libre (ex. `{ success: false, error }`).
pub fn json(status: StatusCode, body: Value) -> Self {
Self { status, body }
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, message)
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(StatusCode::UNAUTHORIZED, message)
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, message)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, message)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
}
/// 500 avec un message fixe ; la cause est journalisée.
pub fn internal_logged(message: impl Into<String>, cause: impl std::fmt::Display) -> Self {
let message = message.into();
tracing::error!("{message}: {cause}");
Self::internal(message)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(self.status, Json(self.body)).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(error: sqlx::Error) -> Self {
AppError::internal_logged("Erreur de base de données", error)
}
}
impl From<anyhow::Error> for AppError {
fn from(error: anyhow::Error) -> Self {
tracing::error!("{error:#}");
AppError::internal(error.to_string())
}
}
/// Raccourcis pour convertir une erreur en réponse 500 au message donné,
/// comme les `catch` des anciennes routes (`res.status(500).json({ error })`).
pub trait OrApiError<T> {
/// Journalise la cause et renvoie `500 { error: message }`.
fn or_500(self, message: &str) -> Result<T, AppError>;
/// Renvoie `500 { error: <message de la cause> }`.
fn or_500_cause(self) -> Result<T, AppError>;
}
impl<T, E: std::fmt::Display> OrApiError<T> for Result<T, E> {
fn or_500(self, message: &str) -> Result<T, AppError> {
self.map_err(|e| AppError::internal_logged(message, e))
}
fn or_500_cause(self) -> Result<T, AppError> {
self.map_err(|e| {
let message = e.to_string();
tracing::error!("{message}");
AppError::internal(message)
})
}
}
+132
View File
@@ -0,0 +1,132 @@
//! Extracteurs de requête.
//!
//! `Body<T>` reproduit `express.json()` + `express.urlencoded()` : un corps
//! JSON est lu tel quel, un formulaire est converti en objet, et une requête
//! sans corps donne `{}`. Les erreurs sortent au format `{ error }`.
use axum::body::Bytes;
use axum::extract::{FromRequest, FromRequestParts, Request};
use axum::http::header::CONTENT_TYPE;
use axum::http::request::Parts;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use crate::error::AppError;
/// Taille maximale d'un corps JSON ou formulaire.
pub const MAX_JSON_BODY: usize = 25 * 1024 * 1024;
pub struct Body<T>(pub T);
impl<S, T> FromRequest<S> for Body<T>
where
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = AppError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let content_type = req
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
let bytes = Bytes::from_request(req, state)
.await
.map_err(|e| AppError::new(e.status(), e.body_text()))?;
let value = if bytes.iter().all(|b| b.is_ascii_whitespace()) {
Value::Object(Map::new())
} else if content_type.contains("json") {
serde_json::from_slice::<Value>(&bytes)
.map_err(|e| AppError::bad_request(format!("JSON invalide : {e}")))?
} else if content_type.starts_with("application/x-www-form-urlencoded") {
let pairs: Vec<(String, String)> = serde_urlencoded_pairs(&bytes);
let mut map = Map::new();
for (k, v) in pairs {
map.insert(k, Value::String(v));
}
Value::Object(map)
} else {
Value::Object(Map::new())
};
let parsed = serde_json::from_value::<T>(value)
.map_err(|e| AppError::bad_request(format!("Données invalides : {e}")))?;
Ok(Body(parsed))
}
}
fn serde_urlencoded_pairs(bytes: &[u8]) -> Vec<(String, String)> {
url::form_urlencoded::parse(bytes)
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect()
}
/// Paramètres de requête bruts (`?a=1&b=2`), comme `req.query` (valeurs texte).
pub struct QueryMap(pub std::collections::HashMap<String, String>);
impl<S: Send + Sync> FromRequestParts<S> for QueryMap {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let mut map = std::collections::HashMap::new();
if let Some(q) = parts.uri.query() {
for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
// Premier gagnant, comme une valeur simple d'Express
map.entry(k.into_owned()).or_insert_with(|| v.into_owned());
}
}
Ok(QueryMap(map))
}
}
impl QueryMap {
pub fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).map(|s| s.as_str())
}
/// Valeur non vide.
pub fn non_empty(&self, key: &str) -> Option<&str> {
self.get(key).filter(|v| !v.is_empty())
}
}
/// Petits accesseurs pour lire un corps JSON dynamique comme en JavaScript.
pub trait JsonExt {
/// Chaîne (non vide ou non) si la valeur est une chaîne.
fn str_field(&self, key: &str) -> Option<&str>;
/// Chaîne non vide après `trim()`.
fn trimmed(&self, key: &str) -> Option<String>;
/// Valeur « truthy » au sens JavaScript.
fn truthy(&self, key: &str) -> bool;
}
impl JsonExt for Value {
fn str_field(&self, key: &str) -> Option<&str> {
self.get(key).and_then(Value::as_str)
}
fn trimmed(&self, key: &str) -> Option<String> {
self.str_field(key)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn truthy(&self, key: &str) -> bool {
is_truthy(self.get(key))
}
}
pub fn is_truthy(value: Option<&Value>) -> bool {
match value {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false),
Some(Value::String(s)) => !s.is_empty(),
Some(_) => true,
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Intergiciels HTTP : en-têtes de sécurité (équivalent de helmet), limitation
//! de débit par IP et journal des appels `/api`.
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use axum::extract::{ConnectInfo, Request, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use serde_json::json;
use crate::state::AppState;
/// Fenêtre fixe par IP, comme express-rate-limit.
pub struct RateLimiter {
max: u32,
window: Duration,
hits: Mutex<HashMap<IpAddr, (Instant, u32)>>,
}
pub struct RateDecision {
pub allowed: bool,
pub limit: u32,
pub remaining: u32,
pub reset_secs: u64,
}
impl RateLimiter {
pub fn new(max: u32, window: Duration) -> Self {
Self {
max,
window,
hits: Mutex::new(HashMap::new()),
}
}
pub fn check(&self, ip: IpAddr) -> RateDecision {
let now = Instant::now();
let mut hits = self.hits.lock().expect("verrou du limiteur");
if hits.len() > 10_000 {
let window = self.window;
hits.retain(|_, (start, _)| now.duration_since(*start) < window);
}
let entry = hits.entry(ip).or_insert((now, 0));
if now.duration_since(entry.0) >= self.window {
*entry = (now, 0);
}
entry.1 += 1;
let reset = self.window.saturating_sub(now.duration_since(entry.0));
RateDecision {
allowed: entry.1 <= self.max,
limit: self.max,
remaining: self.max.saturating_sub(entry.1),
reset_secs: reset.as_secs(),
}
}
}
/// IP du client : dernier saut de `X-Forwarded-For` (posé par Nginx, `trust proxy 1`),
/// sinon l'adresse de la connexion.
pub fn client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> IpAddr {
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
if let Some(ip) = xff.split(',').map(str::trim).rfind(|s| !s.is_empty()) {
if let Ok(ip) = ip.parse() {
return ip;
}
}
}
peer.map(|p| p.ip()).unwrap_or(IpAddr::from([127, 0, 0, 1]))
}
pub async fn rate_limit(State(state): State<AppState>, request: Request, next: Next) -> Response {
let path = request.uri().path();
if !path.starts_with("/api/") {
return next.run(request).await;
}
let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0);
let ip = client_ip(request.headers(), peer);
let decision = state.global_limiter.check(ip);
if !decision.allowed {
return too_many(&decision, "Trop de requêtes, réessayez plus tard");
}
if path == "/api/auth/login" {
let auth = state.auth_limiter.check(ip);
if !auth.allowed {
return too_many(
&auth,
"Trop de tentatives de connexion, réessayez dans 15 minutes",
);
}
}
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert("ratelimit-limit", HeaderValue::from(decision.limit));
headers.insert("ratelimit-remaining", HeaderValue::from(decision.remaining));
headers.insert("ratelimit-reset", HeaderValue::from(decision.reset_secs));
response
}
fn too_many(decision: &RateDecision, message: &str) -> Response {
let mut response = (
StatusCode::TOO_MANY_REQUESTS,
axum::Json(json!({ "error": message })),
)
.into_response();
let headers = response.headers_mut();
headers.insert("ratelimit-limit", HeaderValue::from(decision.limit));
headers.insert("ratelimit-remaining", HeaderValue::from(0u32));
headers.insert("ratelimit-reset", HeaderValue::from(decision.reset_secs));
headers.insert("retry-after", HeaderValue::from(decision.reset_secs));
response
}
const CSP: &str = "default-src 'self';\
base-uri 'self';\
font-src 'self' https://fonts.gstatic.com;\
form-action 'self';\
frame-ancestors 'self';\
img-src 'self' data: https: blob:;\
object-src 'none';\
script-src 'self' 'unsafe-inline' 'unsafe-eval';\
script-src-attr 'none';\
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;\
connect-src 'self' https://graph.facebook.com https://openrouter.ai https://res.cloudinary.com wss: ws:;\
media-src 'self' https: data:;\
upgrade-insecure-requests";
/// En-têtes posés par helmet dans l'ancien serveur.
pub async fn security_headers(request: Request, next: Next) -> Response {
let is_upload = request.uri().path().starts_with("/uploads/");
let mut response = next.run(request).await;
let headers = response.headers_mut();
let mut set = |name: &'static str, value: &'static str| {
headers
.entry(HeaderName::from_static(name))
.or_insert(HeaderValue::from_static(value));
};
set("content-security-policy", CSP);
set("cross-origin-opener-policy", "same-origin");
// Les fichiers téléversés sont lus par d'autres origines (rendu des Reels)
set(
"cross-origin-resource-policy",
if is_upload {
"cross-origin"
} else {
"same-origin"
},
);
set("origin-agent-cluster", "?1");
set("referrer-policy", "no-referrer");
set(
"strict-transport-security",
"max-age=31536000; includeSubDomains",
);
set("x-content-type-options", "nosniff");
set("x-dns-prefetch-control", "off");
set("x-download-options", "noopen");
set("x-frame-options", "SAMEORIGIN");
set("x-permitted-cross-domain-policies", "none");
set("x-xss-protection", "0");
response
}
/// Journal des appels `/api` (méthode, chemin, statut, durée).
pub async fn log_requests(request: Request, next: Next) -> Response {
let path = request.uri().path().to_string();
if !path.starts_with("/api") {
return next.run(request).await;
}
let method = request.method().clone();
let started = Instant::now();
let response = next.run(request).await;
let status = response.status();
// Les 401 de /api/auth/session sont la vérification normale de session
if !(path == "/api/auth/session" && status == StatusCode::UNAUTHORIZED) {
tracing::info!(
"{method} {path} {} in {}ms",
status.as_u16(),
started.elapsed().as_millis()
);
}
response
}
+104
View File
@@ -0,0 +1,104 @@
// Quelques aides (accès aux données, réponses des services) sont gardées
// complètes même si toutes ne sont pas encore appelées.
#![allow(dead_code)]
//! SocialFlow — serveur HTTP en Rust.
//!
//! Remplace l'ancien serveur Node/Express : mêmes routes `/api`, même base
//! PostgreSQL (le schéma existant est repris tel quel puis complété au
//! démarrage), mêmes sessions (cookie `connect.sid` compatible) et même
//! chiffrement des jetons. Le frontend React compilé est servi depuis
//! `dist/public`.
mod auth;
mod config;
mod crypto;
mod db;
mod error;
mod extract;
mod http;
mod migrate;
mod public_url;
mod routes;
mod services;
mod state;
mod time;
mod upload;
use std::net::SocketAddr;
use anyhow::Context;
use tracing_subscriber::EnvFilter;
use crate::config::Config;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,tower_http=warn,hyper=warn")),
)
.with_target(false)
.init();
let config = Config::from_env()?;
crypto::init(&config);
let pool = db::connect(&config.database_url)
.await
.context("connexion à PostgreSQL impossible")?;
migrate::run(&pool).await;
let state = AppState::new(config, pool)?;
auth::ensure_admin_user_exists(&state).await?;
services::audio::fix_audio_track_encoding(&state).await;
let app = routes::router(state.clone());
let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("impossible d'écouter sur {addr}"))?;
tracing::info!("serving on port {}", state.config.port);
// Tâches de fond : publications programmées, crons (jetons, statistiques,
// purge, suivi TikTok) et file des rendus de Reels. La file démarre une
// fois le serveur à l'écoute : les rendus lisent des fichiers servis ici.
services::scheduler::start(state.clone());
services::cron::start(state.clone());
services::reels::start(state.clone());
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut sig) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
sig.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("arrêt demandé, fermeture du serveur");
}
+356
View File
@@ -0,0 +1,356 @@
//! Migration du schéma au démarrage, idempotente.
//!
//! Sur une base existante (créée par l'ancien serveur Node/Drizzle), toutes
//! les instructions sont sans effet ou complètent ce qui manque ; sur une base
//! vide, elles créent l'intégralité du schéma. Chaque instruction est exécutée
//! isolément : un échec est journalisé sans empêcher les suivantes, ni le
//! démarrage de l'application.
use sqlx::PgPool;
const ENUMS: &[&str] = &[
r#"DO $$ BEGIN CREATE TYPE "platform" AS ENUM('facebook', 'instagram', 'tiktok'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#,
r#"DO $$ BEGIN CREATE TYPE "post_type" AS ENUM('feed', 'story', 'both', 'reel'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#,
r#"DO $$ BEGIN CREATE TYPE "post_status" AS ENUM('draft', 'scheduled', 'published', 'failed'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#,
r#"DO $$ BEGIN CREATE TYPE "media_type" AS ENUM('image', 'video'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#,
r#"DO $$ BEGIN CREATE TYPE "user_role" AS ENUM('admin', 'user'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#,
r#"DO $$ BEGIN CREATE TYPE "token_status" AS ENUM('valid', 'expiring', 'expired', 'error'); EXCEPTION WHEN duplicate_object THEN null; END $$;"#,
r#"ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';"#,
r#"ALTER TYPE "platform" ADD VALUE IF NOT EXISTS 'tiktok';"#,
];
const TABLES: &[&str] = &[
r#"CREATE TABLE IF NOT EXISTS "users" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"username" text NOT NULL,
"password" text NOT NULL,
"role" "user_role" DEFAULT 'user' NOT NULL,
CONSTRAINT "users_username_unique" UNIQUE ("username")
)"#,
r#"CREATE TABLE IF NOT EXISTS "social_pages" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"platform" "platform" NOT NULL,
"page_id" text NOT NULL,
"page_name" text NOT NULL,
"access_token" text NOT NULL,
"token_expires_at" timestamp,
"followers_count" integer DEFAULT 0,
"is_active" text DEFAULT 'true' NOT NULL,
"token_status" "token_status" DEFAULT 'valid',
"last_token_check" timestamp,
"user_access_token" text,
"user_token_expires_at" timestamp,
"token_error" text,
"refresh_token" text,
"refresh_token_expires_at" timestamp,
"scopes" text,
"avatar_url" text,
"created_at" timestamp DEFAULT now()
)"#,
r#"CREATE TABLE IF NOT EXISTS "media" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"type" "media_type" NOT NULL,
"cloudinary_public_id" text,
"original_url" text NOT NULL,
"facebook_landscape_url" text,
"facebook_feed_url" text,
"instagram_feed_url" text,
"instagram_story_url" text,
"thumbnail_url" text,
"file_name" text NOT NULL,
"file_size" integer NOT NULL,
"created_at" timestamp DEFAULT now()
)"#,
r#"CREATE TABLE IF NOT EXISTS "posts" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"content" text NOT NULL,
"ai_generated" text DEFAULT 'false' NOT NULL,
"product_info" jsonb,
"status" "post_status" DEFAULT 'draft' NOT NULL,
"scheduled_for" timestamp,
"generation_status" text,
"generation_progress" integer DEFAULT 0,
"generation_error" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
)"#,
r#"CREATE TABLE IF NOT EXISTS "post_media" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"post_id" varchar NOT NULL REFERENCES "posts"("id") ON DELETE cascade,
"media_id" varchar NOT NULL REFERENCES "media"("id") ON DELETE cascade,
"display_order" integer DEFAULT 0 NOT NULL
)"#,
r#"CREATE TABLE IF NOT EXISTS "scheduled_posts" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"post_id" varchar NOT NULL REFERENCES "posts"("id") ON DELETE cascade,
"page_id" varchar NOT NULL REFERENCES "social_pages"("id") ON DELETE cascade,
"post_type" "post_type" NOT NULL,
"scheduled_at" timestamp NOT NULL,
"published_at" timestamp,
"external_post_id" text,
"error" text,
"publish_id" text,
"publish_status" text
)"#,
r#"CREATE TABLE IF NOT EXISTS "ai_generations" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"product_info" jsonb NOT NULL,
"generated_texts" jsonb NOT NULL,
"selected_text" text,
"created_at" timestamp DEFAULT now()
)"#,
r#"CREATE TABLE IF NOT EXISTS "cloudinary_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cloud_name" text NOT NULL,
"api_key" text NOT NULL,
"api_secret" text NOT NULL,
"logo_public_id" text,
"endpoint_url" text,
"public_url" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
)"#,
r#"CREATE TABLE IF NOT EXISTS "openrouter_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"api_key" text NOT NULL,
"model" text DEFAULT 'anthropic/claude-sonnet-4.5' NOT NULL,
"system_prompt" text DEFAULT 'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.' NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "openrouter_config_user_id_unique" UNIQUE ("user_id")
)"#,
r#"CREATE TABLE IF NOT EXISTS "user_page_permissions" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"page_id" varchar NOT NULL REFERENCES "social_pages"("id") ON DELETE cascade,
"created_at" timestamp DEFAULT now()
)"#,
r#"CREATE TABLE IF NOT EXISTS "post_analytics" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"post_id" varchar NOT NULL REFERENCES "posts"("id") ON DELETE cascade,
"fetched_at" timestamp DEFAULT now(),
"impressions" integer DEFAULT 0,
"reach" integer DEFAULT 0,
"engagement" integer DEFAULT 0,
"reactions" integer DEFAULT 0,
"comments" integer DEFAULT 0,
"shares" integer DEFAULT 0,
"clicks" integer DEFAULT 0,
"raw_data" jsonb
)"#,
r#"CREATE TABLE IF NOT EXISTS "page_analytics_history" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"page_id" varchar NOT NULL REFERENCES "social_pages"("id") ON DELETE cascade,
"date" timestamp NOT NULL,
"followers_count" integer DEFAULT 0,
"page_views" integer DEFAULT 0,
"page_reach" integer DEFAULT 0,
"page_engagement" integer DEFAULT 0,
"created_at" timestamp DEFAULT now()
)"#,
r#"CREATE TABLE IF NOT EXISTS "music_favorites" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"track_id" text NOT NULL,
"title" text NOT NULL,
"artist" text NOT NULL,
"album_name" text,
"duration" integer NOT NULL,
"preview_url" text NOT NULL,
"download_url" text NOT NULL,
"image_url" text,
"license" text,
"created_at" timestamp DEFAULT now()
)"#,
r#"CREATE TABLE IF NOT EXISTS "audio_tracks" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar REFERENCES "users"("id") ON DELETE cascade,
"title" text NOT NULL,
"file_name" text NOT NULL,
"url" text NOT NULL,
"duration" integer DEFAULT 0,
"created_at" timestamp DEFAULT now()
)"#,
r#"CREATE TABLE IF NOT EXISTS "app_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"external_api_key" text,
"gemini_api_key" text,
"qwen_tts_url" text,
"qwen_tts_api_key" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
)"#,
r#"CREATE TABLE IF NOT EXISTS "tiktok_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_key" text NOT NULL,
"client_secret" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
)"#,
r#"CREATE TABLE IF NOT EXISTS "facebook_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"app_id" text NOT NULL,
"app_secret" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
)"#,
r#"CREATE TABLE IF NOT EXISTS "reel_jobs" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"post_id" varchar REFERENCES "posts"("id") ON DELETE cascade,
"kind" text NOT NULL,
"params" jsonb NOT NULL,
"status" text NOT NULL DEFAULT 'pending',
"step" text,
"progress" integer NOT NULL DEFAULT 0,
"attempts" integer NOT NULL DEFAULT 0,
"result" jsonb,
"error" text,
"heartbeat_at" timestamp,
"created_at" timestamp NOT NULL DEFAULT now(),
"updated_at" timestamp NOT NULL DEFAULT now()
)"#,
// Sessions : même table que connect-pg-simple, les utilisateurs déjà
// connectés le restent après la mise à jour.
r#"CREATE TABLE IF NOT EXISTS "session" (
"sid" varchar NOT NULL COLLATE "default" PRIMARY KEY,
"sess" json NOT NULL,
"expire" timestamp(6) NOT NULL
)"#,
];
/// Colonnes ajoutées au fil des versions : complètent les bases anciennes.
const COLUMNS: &[&str] = &[
r#"ALTER TABLE "social_pages" ADD COLUMN IF NOT EXISTS "token_status" "token_status" DEFAULT 'valid'"#,
r#"ALTER TABLE "social_pages" ADD COLUMN IF NOT EXISTS "last_token_check" timestamp"#,
r#"ALTER TABLE "social_pages"
ADD COLUMN IF NOT EXISTS "refresh_token" text,
ADD COLUMN IF NOT EXISTS "refresh_token_expires_at" timestamp,
ADD COLUMN IF NOT EXISTS "scopes" text,
ADD COLUMN IF NOT EXISTS "avatar_url" text"#,
r#"ALTER TABLE "social_pages"
ADD COLUMN IF NOT EXISTS "user_access_token" text,
ADD COLUMN IF NOT EXISTS "user_token_expires_at" timestamp,
ADD COLUMN IF NOT EXISTS "token_error" text"#,
r#"ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "generation_status" text"#,
r#"ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "generation_progress" integer DEFAULT 0"#,
r#"ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "generation_error" text"#,
r#"ALTER TABLE "cloudinary_config" ADD COLUMN IF NOT EXISTS "endpoint_url" text"#,
r#"ALTER TABLE "cloudinary_config" ADD COLUMN IF NOT EXISTS "public_url" text"#,
r#"ALTER TABLE "page_analytics_history" ADD COLUMN IF NOT EXISTS "page_engagement" integer DEFAULT 0"#,
r#"ALTER TABLE "app_config" ADD COLUMN IF NOT EXISTS "gemini_api_key" text"#,
r#"ALTER TABLE "app_config"
ADD COLUMN IF NOT EXISTS "qwen_tts_url" text,
ADD COLUMN IF NOT EXISTS "qwen_tts_api_key" text"#,
r#"ALTER TABLE "media" ADD COLUMN IF NOT EXISTS "thumbnail_url" text"#,
r#"ALTER TABLE "scheduled_posts"
ADD COLUMN IF NOT EXISTS "publish_id" text,
ADD COLUMN IF NOT EXISTS "publish_status" text"#,
];
/// Index de performance (Postgres n'indexe pas les clés étrangères seul).
const INDEXES: &[(&str, &str)] = &[
(
"idx_posts_user_id",
r#"CREATE INDEX IF NOT EXISTS "idx_posts_user_id" ON "posts" ("user_id")"#,
),
(
"idx_media_user_id",
r#"CREATE INDEX IF NOT EXISTS "idx_media_user_id" ON "media" ("user_id")"#,
),
(
"idx_social_pages_user_id",
r#"CREATE INDEX IF NOT EXISTS "idx_social_pages_user_id" ON "social_pages" ("user_id")"#,
),
(
"idx_ai_generations_user_id",
r#"CREATE INDEX IF NOT EXISTS "idx_ai_generations_user_id" ON "ai_generations" ("user_id")"#,
),
(
"idx_scheduled_posts_post_id",
r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_post_id" ON "scheduled_posts" ("post_id")"#,
),
(
"idx_post_media_post_id",
r#"CREATE INDEX IF NOT EXISTS "idx_post_media_post_id" ON "post_media" ("post_id")"#,
),
(
"idx_post_media_media_id",
r#"CREATE INDEX IF NOT EXISTS "idx_post_media_media_id" ON "post_media" ("media_id")"#,
),
(
"idx_post_analytics_post_id",
r#"CREATE INDEX IF NOT EXISTS "idx_post_analytics_post_id" ON "post_analytics" ("post_id")"#,
),
(
"idx_page_analytics_history_page_date",
r#"CREATE INDEX IF NOT EXISTS "idx_page_analytics_history_page_date" ON "page_analytics_history" ("page_id", "date")"#,
),
(
"idx_user_page_permissions_user_id",
r#"CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_user_id" ON "user_page_permissions" ("user_id")"#,
),
(
"idx_user_page_permissions_page_id",
r#"CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_page_id" ON "user_page_permissions" ("page_id")"#,
),
(
"idx_scheduled_posts_page_scheduled_at",
r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_page_scheduled_at" ON "scheduled_posts" ("page_id", "scheduled_at")"#,
),
(
"idx_scheduled_posts_pending",
r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_pending" ON "scheduled_posts" ("scheduled_at") WHERE "published_at" IS NULL"#,
),
(
"idx_scheduled_posts_publish_id",
r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_publish_id" ON "scheduled_posts" ("publish_id") WHERE "publish_id" IS NOT NULL"#,
),
(
"idx_reel_jobs_queue",
r#"CREATE INDEX IF NOT EXISTS "idx_reel_jobs_queue" ON "reel_jobs" ("created_at") WHERE "status" IN ('pending', 'processing')"#,
),
(
"idx_posts_generation_status",
r#"CREATE INDEX IF NOT EXISTS "idx_posts_generation_status" ON "posts" ("generation_status") WHERE "generation_status" IS NOT NULL"#,
),
(
"IDX_session_expire",
r#"CREATE INDEX IF NOT EXISTS "IDX_session_expire" ON "session" ("expire")"#,
),
];
pub async fn run(pool: &PgPool) {
tracing::info!("[Migration] Vérification du schéma…");
let mut failures = 0;
for statement in ENUMS.iter().chain(TABLES).chain(COLUMNS) {
if let Err(error) = sqlx::raw_sql(statement).execute(pool).await {
failures += 1;
let head: String = statement
.split_whitespace()
.take(6)
.collect::<Vec<_>>()
.join(" ");
tracing::warn!("[Migration] Échec de « {head} … » : {error}");
}
}
for (name, statement) in INDEXES {
if let Err(error) = sqlx::raw_sql(statement).execute(pool).await {
failures += 1;
tracing::warn!("[Migration] Index {name} non créé : {error}");
}
}
if failures == 0 {
tracing::info!("[Migration] Schéma à jour.");
} else {
tracing::warn!("[Migration] Terminée avec {failures} avertissement(s).");
}
}
+47
View File
@@ -0,0 +1,47 @@
//! URL publique de l'application.
//!
//! Les flux OAuth (Facebook, TikTok) exigent une URI de redirection exacte.
//! `APP_URL` prime ; à défaut, l'URL est reconstruite depuis la requête
//! (en-têtes `X-Forwarded-Proto` / `X-Forwarded-Host` posés par Nginx).
use axum::http::HeaderMap;
use crate::state::AppState;
const DEV_FALLBACK: &str = "http://localhost:5555";
fn first_header_value(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
/// Base publique sans « / » final (« https://exemple.fr »).
pub fn resolve_public_base_url(state: &AppState, headers: Option<&HeaderMap>) -> String {
if let Some(url) = &state.config.app_url {
return url.clone();
}
if let Some(headers) = headers {
let host = first_header_value(headers, "x-forwarded-host")
.or_else(|| first_header_value(headers, "host"));
if let Some(host) = host {
let protocol =
first_header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".into());
return format!("{protocol}://{host}");
}
}
DEV_FALLBACK.to_string()
}
/// La requête est-elle arrivée en HTTPS (directement ou derrière le proxy) ?
pub fn is_https(state: &AppState, headers: &HeaderMap) -> bool {
if let Some(url) = &state.config.app_url {
if url.starts_with("https://") {
return true;
}
}
first_header_value(headers, "x-forwarded-proto").as_deref() == Some("https")
}
+109
View File
@@ -0,0 +1,109 @@
//! Statistiques des posts et des pages (ex-`routes/analytics.ts`).
//!
//! Routeur monté sous `/api/analytics` ; toutes les routes exigent une session.
use axum::extract::{Path, State};
use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{json, Value};
use crate::auth::AuthUser;
use crate::error::{ApiResult, AppError, OrApiError};
use crate::services::{analytics, token_manager};
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/posts/{post_id}", get(get_post_analytics))
.route("/posts/{post_id}/refresh", post(refresh_post_analytics))
.route("/pages/{page_id}/history", get(get_page_history))
.route("/pages/{page_id}/refresh", post(refresh_page_analytics))
.route("/tokens/check", post(check_tokens))
}
/// `res.json(undefined)` d'Express : 200 avec un corps vide.
fn json_or_empty(value: Option<Value>) -> Response {
match value {
Some(value) => Json(value).into_response(),
None => ([(CONTENT_TYPE, "application/json; charset=utf-8")], "").into_response(),
}
}
async fn get_post_analytics(
State(state): State<AppState>,
_user: AuthUser,
Path(post_id): Path<String>,
) -> ApiResult<Json<Value>> {
let analytics = analytics::get_post_analytics(&state, &post_id)
.await
.or_500("Failed to fetch post analytics")?;
Ok(Json(match analytics {
Some(row) => serde_json::to_value(row).or_500("Failed to fetch post analytics")?,
None => json!({}),
}))
}
async fn refresh_post_analytics(
State(state): State<AppState>,
_user: AuthUser,
Path(post_id): Path<String>,
) -> ApiResult {
const MESSAGE: &str = "Failed to refresh post analytics";
analytics::sync_post_analytics(&state, &post_id)
.await
.or_500(MESSAGE)?;
let updated = analytics::get_post_analytics(&state, &post_id)
.await
.or_500(MESSAGE)?;
let value = updated
.map(serde_json::to_value)
.transpose()
.or_500(MESSAGE)?;
Ok(json_or_empty(value))
}
async fn get_page_history(
State(state): State<AppState>,
_user: AuthUser,
Path(page_id): Path<String>,
) -> ApiResult<Json<Value>> {
let history = analytics::get_page_history(&state, &page_id)
.await
.or_500("Failed to fetch page history")?;
Ok(Json(
serde_json::to_value(history).or_500("Failed to fetch page history")?,
))
}
async fn refresh_page_analytics(
State(state): State<AppState>,
_user: AuthUser,
Path(page_id): Path<String>,
) -> ApiResult {
let fail = |error: String| {
tracing::error!("Refresh error: {error}");
AppError::internal(if error.is_empty() {
"Failed to refresh page analytics".to_string()
} else {
error
})
};
analytics::sync_page_analytics(&state, &page_id)
.await
.map_err(|e| fail(e.to_string()))?;
let page = state
.db
.get_social_page(&page_id)
.await
.map_err(|e| fail(e.to_string()))?;
// Page sans ses jetons (jamais envoyés au navigateur)
Ok(json_or_empty(page.map(|p| p.to_client())))
}
/// Contrôle manuel des jetons.
async fn check_tokens(State(state): State<AppState>, _user: AuthUser) -> Json<Value> {
token_manager::check_and_refresh_tokens(&state).await;
Json(json!({ "success": true, "message": "Token check completed" }))
}
+102
View File
@@ -0,0 +1,102 @@
//! Connexion, déconnexion, session courante.
use axum::http::header::SET_COOKIE;
use axum::http::HeaderMap;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{json, Value};
use crate::auth::{self, MaybeUser};
use crate::error::{ApiResult, AppError, OrApiError};
use crate::extract::Body;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/auth/login", post(login))
.route("/api/auth/logout", post(logout))
.route("/api/auth/session", get(session))
.route(
"/api/auth/default-password-status",
get(default_password_status),
)
}
async fn login(
axum::extract::State(state): axum::extract::State<AppState>,
headers: HeaderMap,
Body(body): Body<Value>,
) -> ApiResult {
const BAD_CREDENTIALS: &str = "Nom d'utilisateur ou mot de passe incorrect";
// passport-local : champs absents ou vides → « Missing credentials »
let username = body
.get("username")
.and_then(Value::as_str)
.filter(|s| !s.is_empty());
let password = body
.get("password")
.and_then(Value::as_str)
.filter(|s| !s.is_empty());
let (Some(username), Some(password)) = (username, password) else {
return Err(AppError::unauthorized("Missing credentials"));
};
let user = state
.db
.get_user_by_username(username)
.await
.or_500("Erreur lors de la connexion")?
.ok_or_else(|| AppError::unauthorized(BAD_CREDENTIALS))?;
if !auth::verify_password(password, &user.password).await {
return Err(AppError::unauthorized(BAD_CREDENTIALS));
}
let previous = auth::load_session(&state, &headers).await;
let (_, cookie) = auth::start_session(&state, &headers, previous.as_ref(), &user.id)
.await
.or_500("Erreur lors de la connexion")?;
Ok(auth::with_cookie(auth::user_json(&user), cookie))
}
async fn logout(
axum::extract::State(state): axum::extract::State<AppState>,
headers: HeaderMap,
) -> ApiResult {
if let Some(session) = auth::load_session(&state, &headers).await {
auth::destroy_session(&state, &session.sid)
.await
.map_err(|_| AppError::internal("Erreur lors de la déconnexion"))?;
}
let mut response = Json(json!({ "message": "Déconnecté avec succès" })).into_response();
response
.headers_mut()
.append(SET_COOKIE, auth::clear_session_cookie());
Ok(response)
}
async fn session(MaybeUser(user): MaybeUser) -> Response {
match user {
Some((user, _)) => Json(auth::user_json(&user)).into_response(),
None => AppError::unauthorized("Non authentifié").into_response(),
}
}
/// Le mot de passe de `admin` est-il toujours « admin » ?
async fn default_password_status(
axum::extract::State(state): axum::extract::State<AppState>,
) -> ApiResult<Json<Value>> {
let admin = state
.db
.get_user_by_username("admin")
.await
.or_500("Erreur lors de la vérification du statut du mot de passe")?;
let is_default = match admin {
Some(admin) => auth::verify_password("admin", &admin.password).await,
None => false,
};
Ok(Json(json!({ "isDefault": is_default })))
}
+334
View File
@@ -0,0 +1,334 @@
//! Agenda des publications (portage de la partie serveur de `shared/agenda.ts`) :
//! ce qui est déjà prévu, jour par jour et magasin par magasin, pour aider à
//! choisir la date d'un nouveau post ou Reel.
//!
//! Il réunit les publications planifiées (`scheduled_posts`) et les Reels encore
//! en montage, qui n'ont pas de ligne `scheduled_posts` avant la fin du rendu.
use std::collections::HashMap;
use serde::Serialize;
use serde_json::Value;
use crate::db::{RenderingReelJob, ScheduledPostWithDetails};
use crate::time::Ts;
const PREVIEW_LENGTH: usize = 80;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AgendaStatus {
Scheduled,
Rendering,
Published,
Failed,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgendaItem {
pub id: String,
pub post_id: String,
pub page_id: String,
pub page_name: String,
pub platform: String,
pub avatar_url: Option<String>,
pub post_type: String,
/// ISO 8601.
pub scheduled_at: String,
pub status: AgendaStatus,
pub preview: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AgendaPage {
pub id: String,
pub page_name: String,
pub platform: String,
pub avatar_url: Option<String>,
}
/// Texte d'aperçu : blancs réduits, coupé à 80 caractères (unités UTF-16).
fn preview(text: Option<&str>) -> String {
let flat = text
.unwrap_or("")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if flat.encode_utf16().count() <= PREVIEW_LENGTH {
return flat;
}
let mut out = String::new();
let mut units = 0;
for c in flat.chars() {
units += c.len_utf16();
if units > PREVIEW_LENGTH - 1 {
break;
}
out.push(c);
}
out.push('…');
out
}
fn in_range(date: Ts, start: Option<Ts>, end: Option<Ts>) -> bool {
start.is_none_or(|s| date >= s) && end.is_none_or(|e| date <= e)
}
/// Statut d'une publication planifiée : elle n'a pas de colonne dédiée.
pub fn scheduled_post_status(published_at: Option<Ts>, error: Option<&str>) -> AgendaStatus {
if published_at.is_some() {
AgendaStatus::Published
} else if error.is_some_and(|e| !e.is_empty()) {
AgendaStatus::Failed
} else {
AgendaStatus::Scheduled
}
}
pub fn agenda_item_from_scheduled_post(row: &ScheduledPostWithDetails) -> AgendaItem {
let sp = &row.scheduled;
AgendaItem {
id: sp.id.clone(),
post_id: sp.post_id.clone(),
page_id: sp.page_id.clone(),
page_name: row
.page
.as_ref()
.map(|p| p.page_name.clone())
.unwrap_or_else(|| "Page supprimée".into()),
platform: row
.page
.as_ref()
.map(|p| p.platform.as_str().to_string())
.unwrap_or_else(|| "facebook".into()),
avatar_url: row.page.as_ref().and_then(|p| p.avatar_url.clone()),
post_type: sp.post_type.as_str().to_string(),
scheduled_at: sp.scheduled_at.to_iso(),
status: scheduled_post_status(sp.published_at, sp.error.as_deref()),
preview: preview(Some(&row.post.content)),
}
}
/// Entrées d'un Reel en montage, une par page visée et visible de l'utilisateur.
/// Sans date demandée, il part dès la fin du rendu : on le place à sa création.
pub fn agenda_items_from_reel_job(
job: &RenderingReelJob,
pages_by_id: &HashMap<String, AgendaPage>,
start: Option<Ts>,
end: Option<Ts>,
) -> Vec<AgendaItem> {
let Some(post_id) = &job.post_id else {
return Vec::new();
};
let params = &job.params;
let page_ids: Vec<&str> = params
.get("pageIds")
.and_then(Value::as_array)
.map(|ids| ids.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let requested = params
.get("scheduledFor")
.and_then(Value::as_str)
.and_then(Ts::parse);
let when = requested.unwrap_or(job.created_at);
if !in_range(when, start, end) {
return Vec::new();
}
let param_text = |key: &str| {
params
.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
};
let text = job
.content
.as_deref()
.filter(|c| !c.is_empty())
.or_else(|| param_text("description"))
.or_else(|| param_text("overlayText"));
page_ids
.into_iter()
.filter_map(|page_id| {
let page = pages_by_id.get(page_id)?;
Some(AgendaItem {
id: format!("{}:{page_id}", job.id),
post_id: post_id.clone(),
page_id: page_id.to_string(),
page_name: page.page_name.clone(),
platform: page.platform.clone(),
avatar_url: page.avatar_url.clone(),
post_type: "reel".into(),
scheduled_at: when.to_iso(),
status: AgendaStatus::Rendering,
preview: preview(text),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{
Platform, PostStatus, PostType, ScheduledPost, ScheduledPostPage, ScheduledPostPost,
};
use serde_json::json;
fn page(id: &str, name: &str, platform: &str) -> AgendaPage {
AgendaPage {
id: id.into(),
page_name: name.into(),
platform: platform.into(),
avatar_url: None,
}
}
fn pages_by_id() -> HashMap<String, AgendaPage> {
[
page("a", "Magasin A", "facebook"),
page("b", "Magasin B", "tiktok"),
]
.into_iter()
.map(|p| (p.id.clone(), p))
.collect()
}
fn ts(s: &str) -> Ts {
Ts::parse(s).unwrap()
}
fn row(published_at: Option<Ts>, error: Option<&str>) -> ScheduledPostWithDetails {
ScheduledPostWithDetails {
scheduled: ScheduledPost {
id: "s".into(),
post_id: "p".into(),
page_id: "a".into(),
post_type: PostType::Reel,
scheduled_at: ts("2026-10-14T18:00:00Z"),
published_at,
external_post_id: None,
error: error.map(str::to_string),
publish_id: None,
publish_status: None,
},
post: ScheduledPostPost {
id: "p".into(),
user_id: "u".into(),
content: "Promo".into(),
status: PostStatus::Scheduled,
scheduled_for: None,
ai_generated: "false".into(),
generation_status: None,
generation_progress: None,
created_at: None,
},
page: Some(ScheduledPostPage {
id: "a".into(),
page_id: "fb-a".into(),
page_name: "Magasin A".into(),
platform: Platform::Facebook,
avatar_url: None,
}),
}
}
fn job(params: Value, created_at: Ts) -> RenderingReelJob {
RenderingReelJob {
id: "j".into(),
post_id: Some("p".into()),
created_at,
params,
content: None,
}
}
#[test]
fn status_from_publication_columns() {
assert_eq!(
agenda_item_from_scheduled_post(&row(None, None)).status,
AgendaStatus::Scheduled
);
assert_eq!(
agenda_item_from_scheduled_post(&row(Some(Ts::now()), None)).status,
AgendaStatus::Published
);
assert_eq!(
agenda_item_from_scheduled_post(&row(None, Some("boom"))).status,
AgendaStatus::Failed
);
let item = agenda_item_from_scheduled_post(&row(None, None));
assert_eq!(item.preview, "Promo");
assert_eq!(item.scheduled_at, "2026-10-14T18:00:00.000Z");
let mut orphan = row(None, None);
orphan.page = None;
let item = agenda_item_from_scheduled_post(&orphan);
assert_eq!(
(item.page_name.as_str(), item.platform.as_str()),
("Page supprimée", "facebook")
);
}
#[test]
fn one_rendering_entry_per_visible_store() {
let when = ts("2026-10-14T18:00:00Z");
let items = agenda_items_from_reel_job(
&job(
json!({ "pageIds": ["a", "b", "inconnu"], "scheduledFor": when.to_iso() }),
Ts::now(),
),
&pages_by_id(),
None,
None,
);
assert_eq!(
items.iter().map(|i| i.page_id.as_str()).collect::<Vec<_>>(),
["a", "b"]
);
assert!(items
.iter()
.all(|i| i.status == AgendaStatus::Rendering && i.post_type == "reel"));
assert_eq!(items[0].scheduled_at, when.to_iso());
assert_eq!(items[0].id, "j:a");
}
#[test]
fn reel_without_date_is_placed_at_creation_and_respects_period() {
let created_at = ts("2026-10-12T09:00:00Z");
let j = job(json!({ "pageIds": ["a"] }), created_at);
assert_eq!(
agenda_items_from_reel_job(&j, &pages_by_id(), None, None)[0].scheduled_at,
created_at.to_iso()
);
assert!(agenda_items_from_reel_job(
&j,
&pages_by_id(),
Some(ts("2026-10-13T00:00:00Z")),
None
)
.is_empty());
let mut no_post = j.clone();
no_post.post_id = None;
assert!(agenda_items_from_reel_job(&no_post, &pages_by_id(), None, None).is_empty());
}
#[test]
fn preview_text() {
let j = job(
json!({ "pageIds": ["a"], "description": " Belle\n promo " }),
Ts::now(),
);
assert_eq!(
agenda_items_from_reel_job(&j, &pages_by_id(), None, None)[0].preview,
"Belle promo"
);
let long = "x".repeat(100);
let p = preview(Some(&long));
assert_eq!(p.chars().count(), 80);
assert!(p.ends_with('…'));
assert_eq!(preview(Some(&"y".repeat(80))), "y".repeat(80));
}
}
+187
View File
@@ -0,0 +1,187 @@
//! Génération de textes par IA et configuration d'OpenRouter.
use axum::extract::State;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{json, Value};
use super::validate::{Issues, Presence};
use crate::auth::{AdminUser, AuthUser};
use crate::db::Patch;
use crate::error::{ApiResult, AppError, OrApiError};
use crate::extract::Body;
use crate::services::openrouter;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/ai/models", get(models))
.route("/api/ai/generate", post(generate))
.route("/api/ai/generations", get(generations))
.route("/api/openrouter/models", get(openrouter_models))
.route("/api/openrouter/config", get(get_config).post(save_config))
}
async fn models(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
let models = openrouter::get_available_models(&state)
.await
.or_500("Failed to fetch models")?;
Ok(Json(json!({ "models": models })))
}
async fn generate(
State(state): State<AppState>,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
// `{ model, ...productInfo } = req.body`
let mut product_info = match body {
Value::Object(map) => map,
_ => serde_json::Map::new(),
};
let model = product_info.remove("model");
let model = model.as_ref().and_then(Value::as_str);
let product_info = Value::Object(product_info);
let result = async {
let texts = openrouter::generate_post_text(&state, &product_info, &user.id, model).await?;
state
.db
.create_ai_generation(&user.id, &product_info, &Value::Array(texts.clone()))
.await?;
anyhow::Ok(texts)
}
.await;
match result {
Ok(texts) => Ok(Json(json!({ "variants": texts }))),
Err(error) => {
tracing::error!("Error generating text: {error:#}");
let (status, message) = openrouter::describe_generation_error(&error);
Err(AppError::new(status, message))
}
}
}
async fn generations(State(state): State<AppState>, admin: AdminUser) -> ApiResult<Json<Value>> {
let generations = state
.db
.get_ai_generations(&admin.id)
.await
.or_500("Failed to fetch AI generations")?;
Ok(Json(json!(generations)))
}
/// Catalogue brut des modèles OpenRouter, relayé tel quel.
async fn openrouter_models(
State(state): State<AppState>,
_user: AuthUser,
) -> ApiResult<Json<Value>> {
let result = async {
let response = state
.http
.get("https://openrouter.ai/api/v1/models")
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("OpenRouter API error: {}", response.status().as_u16());
}
Ok(response.json::<Value>().await?)
}
.await;
result.or_500("Failed to fetch OpenRouter models").map(Json)
}
async fn get_config(State(state): State<AppState>, admin: AdminUser) -> ApiResult<Json<Value>> {
let config = state
.db
.get_openrouter_config(&admin.id)
.await
.or_500("Failed to fetch OpenRouter config")?;
// La clé API n'est jamais sérialisée
Ok(Json(json!(config)))
}
async fn save_config(
State(state): State<AppState>,
admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to save OpenRouter config";
let failed = |reason: String| {
tracing::error!("Error saving OpenRouter config: {reason}");
AppError::internal(FAILED)
};
let existing = state
.db
.get_openrouter_config(&admin.id)
.await
.or_500(FAILED)?;
// Schéma zod : clé API « trim().min(1) », modèle et prompt texte facultatifs
let mut issues = Issues::new();
let presence = if existing.is_some() {
Presence::Optional
} else {
Presence::Required
};
let api_key = issues
.string(&body, "apiKey", presence)
.into_option()
.map(|k| k.trim().to_string());
if api_key.as_deref() == Some("") {
return Err(failed("apiKey : La clé API ne peut pas être vide".into()));
}
let model = issues
.string(&body, "model", Presence::Optional)
.into_option();
let system_prompt = issues
.string(&body, "systemPrompt", Presence::Optional)
.into_option();
if !issues.is_empty() {
return Err(failed(issues.zod_message()));
}
let config = match existing {
Some(existing) => {
let api_key = api_key
.filter(|k| !k.is_empty())
.unwrap_or(existing.api_key);
if api_key.is_empty() {
return Err(AppError::bad_request(
"Clé API OpenRouter manquante. Veuillez entrer une clé API valide.",
));
}
let mut patch = Patch::new()
.set("user_id", admin.id.as_str())
.set("api_key", api_key);
if let Some(model) = model {
patch = patch.set("model", model);
}
if let Some(prompt) = system_prompt {
patch = patch.set("system_prompt", prompt);
}
state
.db
.update_openrouter_config(&admin.id, patch)
.await
.map_err(|e| failed(e.to_string()))?
.ok_or_else(|| failed("configuration introuvable".into()))?
}
None => {
let api_key = api_key.unwrap_or_default();
state
.db
.create_openrouter_config(
&admin.id,
&api_key,
model.as_deref(),
system_prompt.as_deref(),
)
.await
.map_err(|e| failed(e.to_string()))?
}
};
Ok(Json(json!(config)))
}
+168
View File
@@ -0,0 +1,168 @@
//! Bibliothèque de pistes audio (envoi réservé aux administrateurs).
//! Les fichiers sont rangés directement dans le dossier audio persistant,
//! servi sous `/uploads/audio/`.
use axum::extract::{DefaultBodyLimit, Multipart, Path, State};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::{json, Value};
use crate::auth::{AdminUser, AuthUser};
use crate::error::{ApiResult, AppError, OrApiError};
use crate::services::audio::{fix_audio_track_encoding, probe_duration_secs};
use crate::state::AppState;
use crate::upload::{self, UploadRules};
const ALLOWED_AUDIO_MIME_TYPES: [&str; 4] = ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg"];
/// 50 Mo par fichier, 20 fichiers par envoi.
const MAX_AUDIO_SIZE: u64 = 50 * 1024 * 1024;
const MAX_AUDIO_FILES: usize = 20;
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/api/audio-tracks",
get(list_tracks)
.post(upload_tracks)
.layer(DefaultBodyLimit::disable()),
)
.route("/api/audio-tracks/fix-encoding", post(fix_encoding))
.route("/api/audio-tracks/{id}", delete(delete_track))
}
fn audio_filter(mime: &str, name: &str) -> Result<(), String> {
static AUDIO_EXT: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\.(mp3|wav|ogg)$").unwrap());
if ALLOWED_AUDIO_MIME_TYPES.contains(&mime) || AUDIO_EXT.is_match(name) {
Ok(())
} else {
Err(format!("Type de fichier audio non autorisé: {mime}"))
}
}
/// Titre d'une piste : nom du fichier sans son extension.
fn title_from_file_name(name: &str) -> &str {
match name.rfind('.') {
Some(pos) if !name[pos + 1..].is_empty() && !name[pos + 1..].contains('/') => &name[..pos],
_ => name,
}
}
async fn upload_tracks(
State(state): State<AppState>,
admin: AdminUser,
multipart: Multipart,
) -> ApiResult<Json<Value>> {
let dir = state.config.audio_upload_dir.clone();
let (files, _) = upload::receive_files(
multipart,
UploadRules {
fields: &["files"],
dir: &dir,
max_file_size: MAX_AUDIO_SIZE,
max_files: MAX_AUDIO_FILES,
filter: &audio_filter,
},
)
.await
.map_err(super::upload_error)?;
if files.is_empty() {
return Err(AppError::bad_request("No audio file uploaded"));
}
let mut results = Vec::with_capacity(files.len());
for file in &files {
tracing::info!(
"🎵 Audio saved locally: {} ({:.2} MB)",
file.original_name,
file.size as f64 / 1024.0 / 1024.0
);
let url = format!("/uploads/audio/{}", file.file_name);
let duration = probe_duration_secs(&file.path).await;
let created = state
.db
.create_audio_track(
Some(&admin.id),
title_from_file_name(&file.original_name),
&file.original_name,
&url,
duration,
)
.await;
match created {
Ok(track) => results.push(json!({ "success": true, "track": track })),
Err(error) => {
// Fichier retiré si l'enregistrement en base échoue
let _ = tokio::fs::remove_file(&file.path).await;
results.push(json!({
"success": false,
"fileName": file.original_name,
"error": super::pg_message(&error),
}));
}
}
}
Ok(Json(json!({ "results": results })))
}
async fn fix_encoding(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
fix_audio_track_encoding(&state).await;
let tracks = state
.db
.get_audio_tracks()
.await
.or_500("Failed to fix encoding")?;
Ok(Json(json!({ "success": true, "tracks": tracks })))
}
/// Accessible à tous : les pistes servent à habiller les Reels.
async fn list_tracks(State(state): State<AppState>, _user: AuthUser) -> ApiResult<Json<Value>> {
let tracks = state
.db
.get_audio_tracks()
.await
.or_500("Failed to fetch audio tracks")?;
Ok(Json(json!(tracks)))
}
async fn delete_track(
State(state): State<AppState>,
_admin: AdminUser,
Path(id): Path<String>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to delete audio track";
if state
.db
.get_audio_track(&id)
.await
.or_500(FAILED)?
.is_none()
{
return Err(AppError::not_found("Audio track not found"));
}
// Comme avant, le fichier local est conservé : seule la piste est retirée
state.db.delete_audio_track(&id).await.or_500(FAILED)?;
Ok(Json(json!({ "success": true })))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn titles_and_filter() {
assert_eq!(title_from_file_name("Été 2024.mp3"), "Été 2024");
assert_eq!(title_from_file_name("a.b.wav"), "a.b");
assert_eq!(title_from_file_name("sans_extension"), "sans_extension");
assert_eq!(title_from_file_name("fin."), "fin.");
assert!(audio_filter("application/octet-stream", "x.OGG").is_ok());
assert!(audio_filter("audio/mpeg", "x").is_ok());
assert_eq!(
audio_filter("video/mp4", "x.mp4").unwrap_err(),
"Type de fichier audio non autorisé: video/mp4"
);
}
}
+253
View File
@@ -0,0 +1,253 @@
//! Configurations globales : stockage des médias (ex-Cloudinary) et logo de
//! l'entreprise, service FFmpeg.
use axum::extract::{DefaultBodyLimit, Multipart, State};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{json, Value};
use super::validate::{Field, Issues, Presence};
use crate::auth::{AdminUser, AuthUser};
use crate::db::{CloudinaryConfig, Patch, Val};
use crate::error::{ApiResult, AppError, OrApiError};
use crate::extract::{is_truthy, Body};
use crate::services::{ffmpeg, media_store};
use crate::state::AppState;
use crate::upload;
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/api/cloudinary/config",
get(get_storage_config).post(save_storage_config),
)
.route(
"/api/cloudinary/logo",
post(upload_logo)
.delete(delete_logo)
.layer(DefaultBodyLimit::disable()),
)
.route(
"/api/ffmpeg/config",
get(get_ffmpeg_config).post(save_ffmpeg_config),
)
}
/// Configuration sans le secret, avec l'URL du logo.
async fn get_storage_config(
State(state): State<AppState>,
_user: AuthUser,
) -> ApiResult<Json<Value>> {
let Some(config) = state
.db
.get_cloudinary_config()
.await
.or_500("Failed to fetch Cloudinary config")?
else {
return Ok(Json(Value::Null));
};
let logo_url = config
.logo_public_id
.as_deref()
.filter(|id| !id.is_empty())
.map(media_store::build_url);
let mut value = json!(config);
if let Value::Object(map) = &mut value {
map.insert("logoUrl".into(), json!(logo_url));
}
Ok(Json(value))
}
fn set_optional_text(patch: Patch, column: &'static str, field: Field<String>) -> Patch {
match field {
Field::Absent => patch,
Field::Null => patch.set(column, Val::Text(None)),
Field::Set(value) => patch.set(column, value),
}
}
async fn save_storage_config(
State(state): State<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to save Cloudinary config";
let existing = state.db.get_cloudinary_config().await.or_500(FAILED)?;
// Mise à jour : clé et secret facultatifs (les anciens sont conservés)
let secrets = if existing.is_some() {
Presence::Optional
} else {
Presence::Required
};
let mut issues = Issues::new();
let cloud_name = issues.string(&body, "cloudName", Presence::Required);
let api_key = issues.string(&body, "apiKey", secrets);
let api_secret = issues.string(&body, "apiSecret", secrets);
let logo_public_id = issues.string(&body, "logoPublicId", Presence::Nullable);
let endpoint_url = issues.string(&body, "endpointUrl", Presence::Nullable);
let public_url = issues.string(&body, "publicUrl", Presence::Nullable);
if !issues.is_empty() {
tracing::error!("Error saving Cloudinary config: {}", issues.zod_message());
return Err(AppError::internal(FAILED));
}
let cloud_name = cloud_name.into_option().unwrap_or_default();
let config: CloudinaryConfig = match existing {
Some(existing) => {
let api_key = api_key
.into_option()
.filter(|k| !k.is_empty())
.unwrap_or(existing.api_key);
let api_secret = api_secret
.into_option()
.filter(|s| !s.is_empty())
.unwrap_or(existing.api_secret);
let mut patch = Patch::new()
.set("cloud_name", cloud_name)
.set("api_key", api_key)
.set("api_secret", api_secret);
patch = set_optional_text(patch, "logo_public_id", logo_public_id);
patch = set_optional_text(patch, "endpoint_url", endpoint_url);
patch = set_optional_text(patch, "public_url", public_url);
state
.db
.update_cloudinary_config(patch)
.await
.or_500(FAILED)?
}
None => state
.db
.create_cloudinary_config(
&cloud_name,
&api_key.into_option().unwrap_or_default(),
&api_secret.into_option().unwrap_or_default(),
logo_public_id.into_option().as_deref(),
endpoint_url.into_option().as_deref(),
public_url.into_option().as_deref(),
)
.await
.or_500(FAILED)?,
};
// Le secret n'est jamais sérialisé
Ok(Json(json!(config)))
}
async fn upload_logo(
State(state): State<AppState>,
_admin: AdminUser,
multipart: Multipart,
) -> ApiResult<Json<Value>> {
let files = super::media::receive_media(multipart, "logo").await?;
let Some(file) = files.first().cloned() else {
return Err(AppError::bad_request("No logo file uploaded"));
};
let result = store_logo(&state, &file).await;
upload::cleanup(&files).await;
match result {
Ok(value) => Ok(Json(value)),
Err(error) => {
tracing::error!("Error uploading logo: {error:#}");
Err(AppError::internal(error.to_string()))
}
}
}
async fn store_logo(state: &AppState, file: &upload::UploadedFile) -> anyhow::Result<Value> {
// Le stockage local n'a pas besoin d'identifiants : ligne créée au besoin
let config = match state.db.get_cloudinary_config().await? {
Some(config) => config,
None => {
state
.db
.create_cloudinary_config("local", "local", "local", None, None, None)
.await?
}
};
if let Some(old) = config.logo_public_id.as_deref().filter(|id| !id.is_empty()) {
if let Err(error) = media_store::delete_logo(state, old).await {
tracing::warn!("Failed to delete old logo: {error}");
}
}
let data = tokio::fs::read(&file.path).await?;
let _ = tokio::fs::remove_file(&file.path).await;
let (public_id, url) = media_store::upload_logo(state, &data, &file.original_name).await?;
state
.db
.update_cloudinary_config(Patch::new().set("logo_public_id", public_id.as_str()))
.await?;
Ok(json!({ "logoPublicId": public_id, "logoUrl": url }))
}
async fn delete_logo(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
let config = state.db.get_cloudinary_config().await.or_500_cause()?;
let Some(public_id) = config
.and_then(|c| c.logo_public_id)
.filter(|id| !id.is_empty())
else {
return Err(AppError::not_found("No logo found"));
};
media_store::delete_logo(&state, &public_id)
.await
.or_500_cause()?;
state
.db
.update_cloudinary_config(Patch::new().set("logo_public_id", Val::Text(None)))
.await
.or_500_cause()?;
Ok(Json(json!({ "success": true })))
}
async fn get_ffmpeg_config(State(state): State<AppState>, _admin: AdminUser) -> Json<Value> {
let settings = state.ffmpeg.read().await;
let api_url = settings.api_url.clone().unwrap_or_default();
let configured =
!api_url.is_empty() && settings.api_key.as_deref().is_some_and(|k| !k.is_empty());
Json(json!({ "apiUrl": api_url, "configured": configured }))
}
/// Reconfigure le service FFmpeg pour la durée de vie du serveur, puis le teste.
async fn save_ffmpeg_config(
State(state): State<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to save FFmpeg config";
let api_url = body.get("apiUrl");
if !is_truthy(api_url) {
return Err(AppError::bad_request("URL de l'API FFmpeg requise"));
}
let Some(api_url) = api_url.and_then(Value::as_str).map(str::to_string) else {
return Err(AppError::internal(FAILED));
};
{
let mut settings = state.ffmpeg.write().await;
let api_key = match body.get("apiKey").filter(|k| is_truthy(Some(k))) {
Some(Value::String(key)) => key.clone(),
Some(_) => return Err(AppError::internal(FAILED)),
// Clé déjà configurée : on la garde
None => match settings.api_key.clone().filter(|k| !k.is_empty()) {
Some(key) => key,
None => {
return Err(AppError::bad_request(
"Clé API FFmpeg requise pour la première configuration",
))
}
},
};
let url = api_url.strip_suffix('/').unwrap_or(&api_url).to_string();
tracing::info!("🎬 FFmpeg Service configured: {url}");
settings.api_url = Some(url);
settings.api_key = Some(api_key);
}
let healthy = ffmpeg::health_check(&state).await;
Ok(Json(json!({
"success": true,
"apiUrl": api_url,
"configured": true,
"healthy": healthy,
})))
}
+247
View File
@@ -0,0 +1,247 @@
//! Médiathèque : envoi, liste paginée, suppression.
use axum::extract::{DefaultBodyLimit, Multipart, Path, State};
use axum::http::HeaderValue;
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::{json, Value};
use crate::auth::AuthUser;
use crate::db::{MediaType, NewMedia};
use crate::error::{ApiResult, AppError, OrApiError};
use crate::extract::QueryMap;
use crate::services::media_store::{self, MediaSource};
use crate::services::thumbnail::{self, ThumbnailSource};
use crate::state::AppState;
use crate::upload::{self, UploadRules, UploadedFile};
/// Types MIME autorisés pour les médias.
const ALLOWED_MIME_TYPES: [&str; 7] = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"video/mp4",
"video/quicktime",
"video/webm",
];
/// 4 Go par fichier.
const MAX_MEDIA_SIZE: u64 = 4 * 1024 * 1024 * 1024;
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/api/media/upload",
post(upload_media).layer(DefaultBodyLimit::disable()),
)
.route("/api/media/fix-urls", post(fix_urls))
.route("/api/media", get(list_media))
.route("/api/media/{id}", delete(delete_media))
}
/// Filtre des médias (iOS envoie parfois `application/octet-stream`).
fn media_filter(mime: &str, _name: &str) -> Result<(), String> {
if ALLOWED_MIME_TYPES.contains(&mime) || mime == "application/octet-stream" {
Ok(())
} else {
Err(format!("Type de fichier non autorisé: {mime}"))
}
}
/// Réception d'un fichier média ou logo dans le dossier temporaire du système
/// (4 Go, 10 fichiers au plus) ; erreurs au format de multer.
pub(super) async fn receive_media(
multipart: Multipart,
field: &str,
) -> Result<Vec<UploadedFile>, AppError> {
let dir = std::env::temp_dir();
let fields = [field];
let (files, _) = upload::receive_files(
multipart,
UploadRules {
fields: &fields,
dir: &dir,
max_file_size: MAX_MEDIA_SIZE,
max_files: 10,
filter: &media_filter,
},
)
.await
.map_err(super::upload_error)?;
Ok(files)
}
/// Vidéo d'après le type MIME ou l'extension.
fn is_video(mime: &str, name: &str) -> bool {
static VIDEO_EXT: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\.(mp4|mov|avi|mkv|webm)$").unwrap());
mime.starts_with("video/") || VIDEO_EXT.is_match(name)
}
async fn upload_media(
State(state): State<AppState>,
user: AuthUser,
multipart: Multipart,
) -> ApiResult<Json<Value>> {
let files = receive_media(multipart, "file").await?;
let Some(file) = files.first().cloned() else {
return Err(AppError::bad_request("No file uploaded"));
};
let result = store_upload(&state, &user.id, &file).await;
// Fichier temporaire supprimé dans tous les cas
upload::cleanup(&files).await;
match result {
Ok(media) => Ok(Json(json!(media))),
Err(error) => {
tracing::error!("Error uploading media: {error:#}");
Err(AppError::internal(error.to_string()))
}
}
}
async fn store_upload(
state: &AppState,
user_id: &str,
file: &UploadedFile,
) -> anyhow::Result<crate::db::Media> {
tracing::info!(
"📂 Processing upload: {} ({:.2} MB)",
file.original_name,
file.size as f64 / 1024.0 / 1024.0
);
let stored =
media_store::upload_media(state, MediaSource::Path(&file.path), &file.original_name)
.await?;
let video = is_video(&file.mime, &file.original_name);
// Vignette extraite tant que la vidéo est sur le disque : elle survit à la
// suppression du fichier après publication (historique illustré)
let thumbnail_url = if video {
thumbnail::create_video_thumbnail(state, ThumbnailSource::Path(&file.path)).await
} else {
None
};
let file_size = i32::try_from(file.size)
.map_err(|_| anyhow::anyhow!("value \"{}\" is out of range for type integer", file.size))?;
let media = state
.db
.create_media(NewMedia {
user_id: user_id.to_string(),
media_type: Some(if video {
MediaType::Video
} else {
MediaType::Image
}),
cloudinary_public_id: Some(stored.public_id),
original_url: stored.original_url,
facebook_landscape_url: None,
facebook_feed_url: stored.facebook_feed_url,
instagram_feed_url: stored.instagram_feed_url,
instagram_story_url: stored.instagram_story_url,
thumbnail_url,
file_name: file.original_name.clone(),
file_size,
})
.await?;
Ok(media)
}
/// Migration ponctuelle : URL absolues `http(s)://hôte/uploads/…` → relatives.
async fn fix_urls(State(state): State<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
if !user.is_admin() {
return Err(AppError::forbidden("Admin only"));
}
let result = sqlx::query(
r#"UPDATE media SET
original_url = regexp_replace(original_url, '^https?://[^/]+(/uploads/)', '\1'),
facebook_feed_url = regexp_replace(facebook_feed_url, '^https?://[^/]+(/uploads/)', '\1'),
instagram_feed_url = regexp_replace(instagram_feed_url, '^https?://[^/]+(/uploads/)', '\1'),
instagram_story_url = regexp_replace(instagram_story_url, '^https?://[^/]+(/uploads/)', '\1')
WHERE
original_url ~ '^https?://[^/]+/uploads/'
OR facebook_feed_url ~ '^https?://[^/]+/uploads/'
OR instagram_feed_url ~ '^https?://[^/]+/uploads/'
OR instagram_story_url ~ '^https?://[^/]+/uploads/'"#,
)
.execute(&state.db.pool)
.await
.or_500("Migration failed")?;
Ok(Json(json!({ "updated": result.rows_affected() })))
}
/// Sans `limit`, toute la médiathèque est renvoyée (les écrans qui retrouvent
/// un média par son identifiant en dépendent) ; avec, `X-Total-Count` donne le total.
async fn list_media(
State(state): State<AppState>,
user: AuthUser,
query: QueryMap,
) -> ApiResult<Response> {
const FAILED: &str = "Failed to fetch media";
let limit = super::parse_positive_int(query.get("limit"));
let offset = super::parse_positive_int(query.get("offset"));
let media = state
.db
.get_media(&user.id, limit, offset)
.await
.or_500(FAILED)?;
let mut response = Json(json!(media)).into_response();
if limit.is_some() {
let total = state.db.get_media_count(&user.id).await.or_500(FAILED)?;
if let Ok(value) = HeaderValue::from_str(&total.to_string()) {
response.headers_mut().insert("X-Total-Count", value);
}
}
Ok(response)
}
async fn delete_media(
State(state): State<AppState>,
user: AuthUser,
Path(media_id): Path<String>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to delete media";
let media = state
.db
.get_media_by_id(&media_id)
.await
.or_500(FAILED)?
.filter(|m| m.user_id == user.id)
.ok_or_else(|| AppError::not_found("Media not found"))?;
if let Some(public_id) = media
.cloudinary_public_id
.as_deref()
.filter(|p| !p.is_empty())
{
media_store::delete_media(&state, public_id)
.await
.or_500(FAILED)?;
}
state.db.delete_media(&media_id).await.or_500(FAILED)?;
Ok(Json(json!({ "success": true })))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn video_detection_and_filter() {
assert!(is_video("video/mp4", "a.bin"));
assert!(is_video("application/octet-stream", "clip.MOV"));
assert!(!is_video("image/png", "a.png"));
assert!(media_filter("image/png", "a.png").is_ok());
assert!(media_filter("application/octet-stream", "a").is_ok());
assert_eq!(
media_filter("application/pdf", "a.pdf").unwrap_err(),
"Type de fichier non autorisé: application/pdf"
);
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Routes principales (portage des routes définies directement dans
//! `server/routes.ts`) : utilisateurs, paramètres, console SQL, statistiques,
//! IA, médias, pistes audio, posts, publications planifiées, pages et
//! configurations.
mod agenda;
mod ai;
mod audio;
mod configs;
mod media;
mod overlays;
mod pages;
mod posts;
mod scheduled;
mod settings;
mod sql;
mod stats;
mod users;
mod validate;
use axum::http::StatusCode;
use axum::Router;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::state::AppState;
use crate::time::Ts;
pub fn router() -> Router<AppState> {
Router::new()
.merge(users::router())
.merge(settings::router())
.merge(sql::router())
.merge(stats::router())
.merge(ai::router())
.merge(media::router())
.merge(overlays::router())
.merge(audio::router())
.merge(posts::router())
.merge(scheduled::router())
.merge(pages::router())
.merge(configs::router())
}
/// `Number.parseInt(value, 10)` limité aux entiers positifs : `None` si la
/// valeur est absente, vide, illisible ou négative.
pub(crate) fn parse_positive_int(value: Option<&str>) -> Option<i64> {
let value = value.filter(|v| !v.is_empty())?;
// parseInt ignore les blancs de tête et s'arrête au premier caractère non numérique
let rest = value.trim_start();
let (negative, digits) = match rest.as_bytes().first() {
Some(b'-') => (true, &rest[1..]),
Some(b'+') => (false, &rest[1..]),
_ => (false, rest),
};
let end = digits.bytes().take_while(u8::is_ascii_digit).count();
if end == 0 {
return None;
}
let parsed: i64 = digits[..end].parse().unwrap_or(i64::MAX);
if negative && parsed != 0 {
return None;
}
Some(parsed)
}
/// Paramètre de date (`new Date(value)`), ignoré s'il est vide ou illisible.
pub(crate) fn parse_date_param(value: Option<&str>) -> Option<Ts> {
value.filter(|v| !v.is_empty()).and_then(Ts::parse)
}
/// Erreur de réception multer : elle passait par le gestionnaire d'erreurs
/// d'Express, qui répondait `500 { message }`.
pub(crate) fn upload_error(error: AppError) -> AppError {
let message = error
.body
.get("error")
.and_then(Value::as_str)
.unwrap_or("Internal Server Error");
// multer : « Unexpected field » sans le nom du champ
let message = if message.starts_with("Unexpected field") {
"Unexpected field".to_string()
} else {
message.to_string()
};
AppError::json(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "message": message }),
)
}
/// Message d'une erreur sqlx tel que l'aurait donné le pilote `pg`
/// (message PostgreSQL brut pour une erreur de la base).
pub(crate) fn pg_message(error: &sqlx::Error) -> String {
match error {
sqlx::Error::Database(db) => db.message().to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn positive_int_like_parse_int() {
assert_eq!(parse_positive_int(None), None);
assert_eq!(parse_positive_int(Some("")), None);
assert_eq!(parse_positive_int(Some("12")), Some(12));
assert_eq!(parse_positive_int(Some(" 7abc")), Some(7));
assert_eq!(parse_positive_int(Some("1e3")), Some(1));
assert_eq!(parse_positive_int(Some("-3")), None);
assert_eq!(parse_positive_int(Some("-0")), Some(0));
assert_eq!(parse_positive_int(Some("abc")), None);
}
#[test]
fn date_param() {
assert!(parse_date_param(Some("")).is_none());
assert!(parse_date_param(Some("nope")).is_none());
assert_eq!(
parse_date_param(Some("2026-10-01T00:00:00.000Z"))
.unwrap()
.to_iso(),
"2026-10-01T00:00:00.000Z"
);
}
}
+611
View File
@@ -0,0 +1,611 @@
//! Incrustations sur une image (bandeau d'angle, pastille de prix, logo),
//! calculées côté serveur puis enregistrées comme un nouveau média.
//!
//! Remplace le traitement sharp de l'ancienne route : les éléments vectoriels
//! sont décrits en SVG et rendus avec resvg, puis composés avec `image`.
use std::io::Cursor;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use axum::extract::State;
use axum::routing::post;
use axum::{Json, Router};
use image::codecs::jpeg::JpegEncoder;
use image::{DynamicImage, ImageDecoder, ImageReader, RgbaImage};
use resvg::{tiny_skia, usvg};
use serde_json::{json, Value};
use crate::auth::AuthUser;
use crate::db::{MediaType, NewMedia};
use crate::error::{ApiResult, AppError};
use crate::extract::{is_truthy, Body, JsonExt};
use crate::services::media_store::{self, MediaSource};
use crate::state::AppState;
const RED: &str = "#dc2626";
const YELLOW: &str = "#eab308";
const RIBBON_SIZE: u32 = 150;
const BADGE_PADDING: f64 = 16.0;
const LOGO_PADDING: i64 = 20;
pub fn router() -> Router<AppState> {
Router::new().route("/api/media/apply-overlays", post(apply_overlays))
}
async fn apply_overlays(
State(state): State<AppState>,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
if !body.truthy("imageUrl") {
return Err(AppError::bad_request("Image URL required"));
}
match process(&state, &user.id, &body).await {
Ok(media) => Ok(Json(media)),
Err(error) => {
tracing::error!("💥 Error applying overlays: {error:#}");
Err(AppError::internal(error.to_string()))
}
}
}
/// Paramètres d'incrustation lus dans la requête.
#[derive(Debug, Clone, Default)]
struct OverlaySpec {
ribbon: Option<Ribbon>,
badge: Option<PriceBadge>,
logo: Option<LogoSpec>,
}
#[derive(Debug, Clone)]
struct Ribbon {
text: String,
color: &'static str,
position: String,
}
#[derive(Debug, Clone)]
struct PriceBadge {
text: String,
font_size: f64,
color: &'static str,
position: String,
}
#[derive(Debug, Clone)]
struct LogoSpec {
data: Vec<u8>,
size: String,
position: String,
/// Opacité en pourcentage ; `None` = logo opaque.
opacity: Option<f64>,
}
fn color_of(value: &Value) -> &'static str {
if value.str_field("color") == Some("red") {
RED
} else {
YELLOW
}
}
/// Texte affiché d'une valeur JSON (`${value}` en JavaScript).
fn display(value: Option<&Value>) -> String {
match value {
None => "undefined".into(),
Some(Value::String(s)) => s.clone(),
Some(Value::Null) => "null".into(),
Some(other) => other.to_string(),
}
}
async fn process(state: &AppState, user_id: &str, body: &Value) -> anyhow::Result<Value> {
let image_url = display(body.get("imageUrl"));
tracing::info!("🎨 Applying overlays server-side...");
tracing::info!("📥 Image URL: {image_url}");
let image = media_store::read_media_buffer(state, &image_url).await?;
tracing::info!("✅ Image downloaded: {} bytes", image.len());
let mut spec = OverlaySpec::default();
if let Some(ribbon) = body.get("ribbon").filter(|r| is_truthy(Some(r))) {
let Some(text) = ribbon.str_field("text") else {
anyhow::bail!("Cannot read properties of undefined (reading 'length')");
};
spec.ribbon = Some(Ribbon {
text: text.to_string(),
color: color_of(ribbon),
position: ribbon.str_field("position").unwrap_or_default().to_string(),
});
}
if let Some(badge) = body.get("priceBadge").filter(|b| is_truthy(Some(b))) {
spec.badge = Some(PriceBadge {
text: format!("{} €", display(badge.get("price"))),
font_size: badge.get("size").and_then(Value::as_f64).unwrap_or(20.0),
color: color_of(badge),
position: badge.str_field("position").unwrap_or_default().to_string(),
});
}
if let Some(logo) = body.get("logo").filter(|l| l.truthy("enabled")) {
spec.logo = load_logo(state, logo).await;
}
let fonts_dir = state.config.fonts_dir.clone();
let output = tokio::task::spawn_blocking(move || render(&image, &spec, &fonts_dir)).await??;
tracing::info!("✅ Overlays applied, uploading to local storage...");
let file_name = format!("edited_{}.jpg", chrono::Utc::now().timestamp_millis());
let stored = media_store::upload_media(state, MediaSource::Bytes(&output), &file_name).await?;
// Image retouchée : toutes les déclinaisons pointent sur l'original incrusté
let media = state
.db
.create_media(NewMedia {
user_id: user_id.to_string(),
media_type: Some(MediaType::Image),
cloudinary_public_id: Some(stored.public_id),
original_url: stored.original_url.clone(),
facebook_landscape_url: None,
facebook_feed_url: Some(stored.original_url.clone()),
instagram_feed_url: Some(stored.original_url.clone()),
instagram_story_url: Some(stored.original_url),
thumbnail_url: None,
file_name,
file_size: i32::try_from(output.len()).unwrap_or(i32::MAX),
})
.await?;
tracing::info!("✅ Image saved with ID: {}", media.id);
Ok(json!(media))
}
/// Logo de l'entreprise configuré dans les paramètres ; `None` (sans faire
/// échouer la requête) s'il est absent ou illisible.
async fn load_logo(state: &AppState, logo: &Value) -> Option<LogoSpec> {
tracing::info!("🏢 Adding logo overlay...");
let config = match state.db.get_cloudinary_config().await {
Ok(config) => config,
Err(error) => {
tracing::error!("⚠️ Failed to apply logo: {error}");
return None;
}
};
let Some(public_id) = config
.and_then(|c| c.logo_public_id)
.filter(|p| !p.is_empty())
else {
tracing::info!("⚠️ No logo configured in settings");
return None;
};
let url = media_store::build_url(&public_id);
tracing::info!("📥 Downloading logo from: {url}");
match media_store::read_media_buffer(state, &url).await {
Ok(data) => Some(LogoSpec {
data: data.to_vec(),
size: logo.str_field("size").unwrap_or("medium").to_string(),
position: logo.str_field("position").unwrap_or_default().to_string(),
opacity: logo.get("opacity").and_then(Value::as_f64),
}),
Err(error) => {
tracing::error!("⚠️ Failed to apply logo: {error}");
None
}
}
}
/// Décode une image en appliquant son orientation EXIF (`sharp().rotate()`).
fn decode_oriented(data: &[u8]) -> anyhow::Result<DynamicImage> {
let mut decoder = ImageReader::new(Cursor::new(data))
.with_guessed_format()?
.into_decoder()?;
let orientation = decoder.orientation().ok();
let mut image = DynamicImage::from_decoder(decoder)?;
if let Some(orientation) = orientation {
image.apply_orientation(orientation);
}
Ok(image)
}
fn render(data: &[u8], spec: &OverlaySpec, fonts_dir: &Path) -> anyhow::Result<Vec<u8>> {
let mut base: RgbaImage = decode_oriented(data)?.to_rgba8();
let (width, height) = base.dimensions();
tracing::info!("📐 Image dimensions: {width}x{height}");
if let Some(ribbon) = &spec.ribbon {
let geometry = ribbon_geometry(&ribbon.position, width, height);
let svg = ribbon_svg(ribbon, &geometry);
let layer = rasterize(&svg, fonts_dir)?;
image::imageops::overlay(&mut base, &layer, geometry.x, geometry.y);
}
if let Some(badge) = &spec.badge {
let geometry = badge_geometry(&badge.text, badge.font_size, &badge.position, width, height);
let svg = badge_svg(badge, &geometry);
let layer = rasterize(&svg, fonts_dir)?;
image::imageops::overlay(&mut base, &layer, geometry.x, geometry.y);
}
if let Some(logo) = &spec.logo {
match logo_layer(logo, width, height) {
Ok((layer, x, y)) => {
image::imageops::overlay(&mut base, &layer, x, y);
tracing::info!(
"✅ Logo added at position {} with {}% opacity",
logo.position,
logo.opacity.unwrap_or(100.0)
);
}
Err(error) => tracing::error!("⚠️ Failed to apply logo: {error}"),
}
}
let rgb = DynamicImage::ImageRgba8(base).to_rgb8();
let mut output = Vec::new();
JpegEncoder::new_with_quality(&mut output, 95).encode_image(&rgb)?;
Ok(output)
}
/// Bandeau triangulaire d'angle (150 px) et son texte incliné.
#[derive(Debug, Clone, PartialEq)]
struct RibbonGeometry {
x: i64,
y: i64,
polygon: String,
text_x: f64,
text_y: f64,
rotation: i32,
}
fn ribbon_geometry(position: &str, width: u32, height: u32) -> RibbonGeometry {
let s = RIBBON_SIZE as f64;
let right = width as i64 - RIBBON_SIZE as i64;
let bottom = height as i64 - RIBBON_SIZE as i64;
let size = RIBBON_SIZE;
let (x, y, polygon, text_x, text_y, rotation) = match position {
"north_east" => (
right,
0,
format!("0,0 {size},0 {size},{size}"),
s * 0.7,
s * 0.3,
45,
),
"south_west" => (
0,
bottom,
format!("0,0 0,{size} {size},{size}"),
s * 0.3,
s * 0.7,
-135,
),
"south_east" => (
right,
bottom,
format!("{size},0 0,{size} {size},{size}"),
s * 0.7,
s * 0.7,
135,
),
// north_west et toute valeur inconnue
_ => (
0,
0,
format!("0,0 {size},0 0,{size}"),
s * 0.3,
s * 0.3,
-45,
),
};
RibbonGeometry {
x,
y,
polygon,
text_x,
text_y,
rotation,
}
}
/// Taille du texte du bandeau selon sa longueur (JavaScript).
fn ribbon_font_size(text: &str) -> u32 {
match text.encode_utf16().count() {
0..=5 => 22,
6..=8 => 18,
9..=11 => 15,
_ => 12,
}
}
/// Échappe un texte pour l'insérer dans du XML.
fn xml_escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
c => out.push(c),
}
}
out
}
fn ribbon_svg(ribbon: &Ribbon, g: &RibbonGeometry) -> String {
format!(
r#"<svg width="{size}" height="{size}" xmlns="http://www.w3.org/2000/svg"><polygon points="{polygon}" fill="{color}"/><text x="{tx}" y="{ty}" font-family="Arial, sans-serif" font-size="{font}" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="middle" transform="rotate({rot}, {tx}, {ty})">{text}</text></svg>"#,
size = RIBBON_SIZE,
polygon = g.polygon,
color = ribbon.color,
tx = g.text_x,
ty = g.text_y,
font = ribbon_font_size(&ribbon.text),
rot = g.rotation,
text = xml_escape(&ribbon.text),
)
}
/// Pastille de prix : largeur estimée d'après le nombre de caractères.
#[derive(Debug, Clone, PartialEq)]
struct BadgeGeometry {
width: f64,
height: f64,
x: i64,
y: i64,
}
fn badge_geometry(
text: &str,
font_size: f64,
position: &str,
width: u32,
height: u32,
) -> BadgeGeometry {
let text_width = text.encode_utf16().count() as f64 * font_size * 0.6;
let badge_width = text_width + BADGE_PADDING * 2.0;
let badge_height = font_size + BADGE_PADDING;
let right = width as f64 - badge_width - BADGE_PADDING;
let bottom = height as f64 - badge_height - BADGE_PADDING;
let (x, y) = match position {
"north_east" => (right, BADGE_PADDING),
"south_west" => (BADGE_PADDING, bottom),
"south_east" => (right, bottom),
_ => (BADGE_PADDING, BADGE_PADDING),
};
BadgeGeometry {
width: badge_width,
height: badge_height,
x: x.round() as i64,
y: y.round() as i64,
}
}
fn badge_svg(badge: &PriceBadge, g: &BadgeGeometry) -> String {
format!(
r#"<svg width="{w}" height="{h}" xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="{w}" height="{h}" rx="{r}" ry="{r}" fill="{color}"/><text x="{cx}" y="{r}" font-family="Arial, sans-serif" font-size="{font}" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="middle">{text}</text></svg>"#,
w = g.width,
h = g.height,
r = g.height / 2.0,
cx = g.width / 2.0,
color = badge.color,
font = badge.font_size,
text = xml_escape(&badge.text),
)
}
/// Base de polices partagée : polices du système et dossier `fonts/`.
fn font_database(fonts_dir: &Path) -> Arc<usvg::fontdb::Database> {
static DB: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
DB.get_or_init(|| {
let mut db = usvg::fontdb::Database::new();
db.load_system_fonts();
db.load_fonts_dir(fonts_dir);
let has = |family: &str| {
db.faces()
.any(|face| face.families.iter().any(|(name, _)| name == family))
};
// « Arial, sans-serif » : repli sur une police réellement disponible
let fallback = ["Arial", "Liberation Sans", "DejaVu Sans", "FreeSans"]
.into_iter()
.find(|f| has(f))
.map(str::to_string);
if let Some(family) = fallback {
db.set_sans_serif_family(family);
}
Arc::new(db)
})
.clone()
}
/// Rend un SVG en image RGBA (alpha non prémultiplié).
fn rasterize(svg: &str, fonts_dir: &Path) -> anyhow::Result<RgbaImage> {
let options = usvg::Options {
fontdb: font_database(fonts_dir),
..usvg::Options::default()
};
let tree = usvg::Tree::from_str(svg, &options)?;
let size = tree.size().to_int_size();
let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height())
.ok_or_else(|| anyhow::anyhow!("dimensions SVG invalides"))?;
resvg::render(
&tree,
tiny_skia::Transform::identity(),
&mut pixmap.as_mut(),
);
let mut data = Vec::with_capacity(pixmap.data().len());
for pixel in pixmap.pixels() {
let c = pixel.demultiply();
data.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
}
RgbaImage::from_raw(size.width(), size.height(), data)
.ok_or_else(|| anyhow::anyhow!("rendu SVG invalide"))
}
/// Largeur du logo : part de la largeur de l'image, sans dépasser les marges.
fn logo_width(size: &str, width: u32) -> i64 {
let ratio = match size {
"small" => 0.35,
"large" => 0.70,
_ => 0.50,
};
let requested = (width as f64 * ratio).round() as i64;
requested.min(width as i64 - LOGO_PADDING * 2)
}
fn logo_position(position: &str, width: u32, height: u32, logo_w: i64, logo_h: i64) -> (i64, i64) {
let (w, h) = (width as i64, height as i64);
let right = w - logo_w - LOGO_PADDING;
let bottom = h - logo_h - LOGO_PADDING;
match position {
"north_east" => (right, LOGO_PADDING),
"south_west" => (LOGO_PADDING, bottom),
"south_east" => (right, bottom),
"center" => (
((w - logo_w) as f64 / 2.0).round() as i64,
((h - logo_h) as f64 / 2.0).round() as i64,
),
_ => (LOGO_PADDING, LOGO_PADDING),
}
}
fn logo_layer(logo: &LogoSpec, width: u32, height: u32) -> anyhow::Result<(RgbaImage, i64, i64)> {
let source = decode_oriented(&logo.data)?.to_rgba8();
let target_w = logo_width(&logo.size, width);
if target_w <= 0 || source.width() == 0 {
anyhow::bail!("Expected positive integer for width but received {target_w}");
}
let target_h =
((source.height() as f64 * target_w as f64 / source.width() as f64).round() as u32).max(1);
let mut resized = image::imageops::resize(
&source,
target_w as u32,
target_h,
image::imageops::FilterType::Lanczos3,
);
if target_h as i64 > height as i64 - LOGO_PADDING * 2 {
tracing::warn!(
"⚠️ Logo height {target_h}px exceeds max {}px, would be clipped",
height as i64 - LOGO_PADDING * 2
);
}
if let Some(opacity) = logo.opacity.filter(|o| *o < 100.0) {
let factor = (opacity / 100.0).clamp(0.0, 1.0);
for pixel in resized.pixels_mut() {
pixel[3] = (pixel[3] as f64 * factor).round() as u8;
}
}
let (x, y) = logo_position(&logo.position, width, height, target_w, target_h as i64);
Ok((resized, x, y))
}
/// Chemin de polices utilisé par défaut dans les tests.
#[cfg(test)]
fn test_fonts_dir() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fonts")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ribbon_positions() {
let g = ribbon_geometry("south_east", 800, 600);
assert_eq!((g.x, g.y, g.rotation), (650, 450, 135));
assert_eq!(g.polygon, "150,0 0,150 150,150");
assert!((g.text_x - 105.0).abs() < 1e-9);
let g = ribbon_geometry("inconnu", 800, 600);
assert_eq!((g.x, g.y, g.rotation), (0, 0, -45));
assert_eq!(ribbon_font_size("Promo"), 22);
assert_eq!(ribbon_font_size("Soldes!!"), 18);
assert_eq!(ribbon_font_size("Nouveauté!!"), 15);
assert_eq!(ribbon_font_size("Très grosse promo"), 12);
}
#[test]
fn badge_positions() {
let g = badge_geometry("12 €", 20.0, "north_east", 800, 600);
assert_eq!((g.width, g.height), (80.0, 36.0));
assert_eq!((g.x, g.y), (704, 16));
let g = badge_geometry("12 €", 20.0, "south_west", 800, 600);
assert_eq!((g.x, g.y), (16, 548));
}
#[test]
fn logo_sizes_and_positions() {
assert_eq!(logo_width("small", 1000), 350);
assert_eq!(logo_width("large", 1000), 700);
assert_eq!(logo_width("medium", 1000), 500);
// Plafonné aux marges
assert_eq!(logo_width("large", 100), 60);
assert_eq!(logo_position("south_east", 1000, 800, 500, 100), (480, 680));
assert_eq!(logo_position("center", 1000, 800, 500, 101), (250, 350));
assert_eq!(logo_position("north_west", 1000, 800, 500, 100), (20, 20));
}
#[test]
fn escapes_text() {
assert_eq!(xml_escape("A&B <\"c\">"), "A&amp;B &lt;&quot;c&quot;&gt;");
let ribbon = Ribbon {
text: "<b>".into(),
color: RED,
position: "north_west".into(),
};
let svg = ribbon_svg(&ribbon, &ribbon_geometry("north_west", 300, 300));
assert!(svg.contains("&lt;b&gt;</text>"));
assert!(rasterize(&svg, &test_fonts_dir()).is_ok());
}
#[test]
fn renders_overlays_on_jpeg() {
let base = RgbaImage::from_pixel(400, 300, image::Rgba([0, 0, 255, 255]));
let mut jpeg = Vec::new();
JpegEncoder::new_with_quality(&mut jpeg, 90)
.encode_image(&DynamicImage::ImageRgba8(base).to_rgb8())
.unwrap();
let mut png_logo = Vec::new();
DynamicImage::ImageRgba8(RgbaImage::from_pixel(
10,
5,
image::Rgba([255, 255, 255, 255]),
))
.write_to(&mut Cursor::new(&mut png_logo), image::ImageFormat::Png)
.unwrap();
let spec = OverlaySpec {
ribbon: Some(Ribbon {
text: "Promo".into(),
color: RED,
position: "north_west".into(),
}),
badge: Some(PriceBadge {
text: "9,99 €".into(),
font_size: 20.0,
color: YELLOW,
position: "south_east".into(),
}),
logo: Some(LogoSpec {
data: png_logo,
size: "small".into(),
position: "center".into(),
opacity: Some(50.0),
}),
};
let output = render(&jpeg, &spec, &test_fonts_dir()).unwrap();
let out = image::load_from_memory(&output).unwrap().to_rgb8();
assert_eq!(out.dimensions(), (400, 300));
// Coin du bandeau rouge
let corner = out.get_pixel(5, 5);
assert!(corner[0] > 180 && corner[2] < 80, "{corner:?}");
// Logo blanc à 50 % au centre : mélange bleu/blanc
let center = out.get_pixel(200, 150);
assert!(
center[0] > 90 && center[0] < 170 && center[2] > 200,
"{center:?}"
);
}
}
+240
View File
@@ -0,0 +1,240 @@
//! Pages et comptes connectés (saisie manuelle d'un jeton Facebook/Instagram).
use axum::extract::{Path, State};
use axum::routing::{get, put};
use axum::{Json, Router};
use chrono::Duration;
use serde_json::{json, Value};
use super::validate::{Field, Issues, Presence};
use crate::auth::AuthUser;
use crate::db::{NewSocialPage, Patch, Platform, TokenStatus, Val};
use crate::error::{ApiResult, AppError, OrApiError};
use crate::extract::{Body, JsonExt};
use crate::state::AppState;
use crate::time::Ts;
/// Durée de validité supposée d'un jeton de page saisi à la main.
const TOKEN_LIFETIME_DAYS: i64 = 60;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/pages", get(list_pages).post(create_page))
.route("/api/pages/{id}", put(update_page).delete(delete_page))
}
/// Les jetons restent au serveur : seule la vue client est renvoyée.
async fn list_pages(State(state): State<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
let pages = state
.db
.get_visible_pages(&user)
.await
.or_500("Failed to fetch pages")?;
Ok(Json(Value::Array(
pages.iter().map(|p| p.to_client()).collect(),
)))
}
const PLATFORMS: [&str; 3] = ["facebook", "instagram", "tiktok"];
/// Champs du schéma `insertSocialPageSchema` (hors `userId`), dans l'ordre
/// des colonnes ; `partial` rend tout facultatif (mise à jour).
struct PageFields {
user_id: Field<String>,
platform: Field<String>,
page_id: Field<String>,
page_name: Field<String>,
access_token: Field<String>,
followers_count: Field<i32>,
is_active: Field<String>,
user_access_token: Field<String>,
refresh_token: Field<String>,
scopes: Field<String>,
avatar_url: Field<String>,
}
fn parse_page_fields(body: &Value, partial: bool, issues: &mut Issues) -> PageFields {
let required = if partial {
Presence::Optional
} else {
Presence::Required
};
let user_id = if partial {
issues.string(body, "userId", Presence::Optional)
} else {
Field::Absent
};
let platform = issues.enumeration(body, "platform", required, &PLATFORMS);
let page_id = issues.string(body, "pageId", required);
let page_name = issues.string(body, "pageName", required);
let access_token = issues.string(body, "accessToken", required);
if partial {
issues.date(body, "tokenExpiresAt", Presence::Nullable);
}
let followers_count = issues.int(body, "followersCount", Presence::Nullable);
let is_active = issues.string(body, "isActive", Presence::Optional);
let user_access_token = issues.string(body, "userAccessToken", Presence::Nullable);
issues.date(body, "userTokenExpiresAt", Presence::Nullable);
let refresh_token = issues.string(body, "refreshToken", Presence::Nullable);
issues.date(body, "refreshTokenExpiresAt", Presence::Nullable);
let scopes = issues.string(body, "scopes", Presence::Nullable);
let avatar_url = issues.string(body, "avatarUrl", Presence::Nullable);
PageFields {
user_id,
platform,
page_id,
page_name,
access_token,
followers_count,
is_active,
user_access_token,
refresh_token,
scopes,
avatar_url,
}
}
async fn create_page(
State(state): State<AppState>,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to create page";
// Un compte TikTok passe obligatoirement par le flux OAuth (/api/tiktok/connect)
if body.str_field("platform") == Some("tiktok") {
return Err(AppError::bad_request(
"Un compte TikTok se connecte via l'autorisation TikTok, pas par saisie manuelle d'un jeton.",
));
}
let mut issues = Issues::new();
let fields = parse_page_fields(&body, false, &mut issues);
if !issues.is_empty() {
tracing::error!("Error creating page: {}", issues.zod_message());
return Err(AppError::internal(FAILED));
}
let page = state
.db
.create_social_page(NewSocialPage {
user_id: user.id.clone(),
platform: fields
.platform
.into_option()
.and_then(|p| Platform::parse(&p)),
page_id: fields.page_id.into_option().unwrap_or_default(),
page_name: fields.page_name.into_option().unwrap_or_default(),
access_token: fields.access_token.into_option().unwrap_or_default(),
token_expires_at: Some(Ts::now().add(Duration::days(TOKEN_LIFETIME_DAYS))),
followers_count: fields.followers_count.into_option(),
is_active: fields.is_active.into_option(),
user_access_token: fields.user_access_token.into_option(),
user_token_expires_at: None,
refresh_token: fields.refresh_token.into_option(),
refresh_token_expires_at: None,
scopes: fields.scopes.into_option(),
avatar_url: fields.avatar_url.into_option(),
})
.await
.or_500(FAILED)?;
Ok(Json(page.to_client()))
}
/// Ajoute un champ texte au patch s'il est présent (`null` compris).
fn set_text(patch: Patch, column: &'static str, field: Field<String>) -> Patch {
match field {
Field::Absent => patch,
Field::Null => patch.set(column, Val::Text(None)),
Field::Set(value) => patch.set(column, value),
}
}
async fn update_page(
State(state): State<AppState>,
user: AuthUser,
Path(page_id): Path<String>,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to update page";
let existing = state
.db
.get_social_page(&page_id)
.await
.or_500(FAILED)?
.filter(|p| p.user_id == user.id)
.ok_or_else(|| AppError::not_found("Page non trouvée"))?;
let mut issues = Issues::new();
let fields = parse_page_fields(&body, true, &mut issues);
if !issues.is_empty() {
tracing::error!("Error updating page: {}", issues.zod_message());
return Err(AppError::internal(FAILED));
}
let new_token = matches!(&fields.access_token, Field::Set(t) if !t.is_empty());
let mut patch = Patch::new();
patch = set_text(patch, "user_id", fields.user_id);
if let Field::Set(platform) = fields.platform.clone() {
if let Some(platform) = Platform::parse(&platform) {
patch = patch.set("platform", platform);
}
}
patch = set_text(patch, "page_id", fields.page_id);
patch = set_text(patch, "page_name", fields.page_name);
patch = set_text(patch, "access_token", fields.access_token);
match fields.followers_count {
Field::Absent => {}
Field::Null => patch = patch.set("followers_count", Val::Int(None)),
Field::Set(count) => patch = patch.set("followers_count", count),
}
patch = set_text(patch, "is_active", fields.is_active);
patch = set_text(patch, "user_access_token", fields.user_access_token);
patch = set_text(patch, "refresh_token", fields.refresh_token);
patch = set_text(patch, "scopes", fields.scopes);
patch = set_text(patch, "avatar_url", fields.avatar_url);
// Nouveau jeton : échéance recalculée et statut remis à « valide » pour que
// l'interface se mette à jour (les jetons TikTok ont leur propre cycle)
if new_token && existing.platform != Platform::Tiktok {
let now = Ts::now();
patch = patch
.set(
"token_expires_at",
now.add(Duration::days(TOKEN_LIFETIME_DAYS)),
)
.set("token_status", TokenStatus::Valid)
.set("last_token_check", now);
}
// Aucune valeur à écrire : l'ancienne mise à jour échouait
if patch.is_empty() {
tracing::error!("Error updating page: No values to set");
return Err(AppError::internal(FAILED));
}
let updated = state
.db
.update_social_page(&page_id, patch)
.await
.or_500(FAILED)?
.ok_or_else(|| AppError::internal(FAILED))?;
Ok(Json(updated.to_client()))
}
async fn delete_page(
State(state): State<AppState>,
user: AuthUser,
Path(page_id): Path<String>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to delete page";
state
.db
.get_social_page(&page_id)
.await
.or_500(FAILED)?
.filter(|p| p.user_id == user.id)
.ok_or_else(|| AppError::not_found("Page non trouvée"))?;
state.db.delete_social_page(&page_id).await.or_500(FAILED)?;
Ok(Json(json!({ "success": true })))
}
+488
View File
@@ -0,0 +1,488 @@
//! Publications : création (avec médias et programmation par page), lecture,
//! modification du texte et des médias.
use axum::extract::{Path, State};
use axum::routing::{get, patch};
use axum::{Json, Router};
use serde_json::{json, Value};
use super::validate::{Field, Issues, Presence};
use crate::auth::AuthUser;
use crate::db::{
MediaType, NewPost, NewScheduledPost, Patch, Platform, Post, PostStatus, PostType, User,
};
use crate::error::{ApiResult, AppError, OrApiError};
use crate::extract::{is_truthy, Body, JsonExt};
use crate::state::AppState;
use crate::time::Ts;
/// Nombre maximal de médias par publication.
const MAX_MEDIA: usize = 10;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/posts", get(list_posts).post(create_post))
.route("/api/posts/{id}", get(get_post).patch(update_post))
.route("/api/posts/{id}/media", patch(update_post_media))
}
async fn list_posts(State(state): State<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
let posts = state
.db
.get_posts(&user.id)
.await
.or_500("Failed to fetch posts")?;
Ok(Json(json!(posts)))
}
/// Texte d'une valeur JSON utilisée comme identifiant.
fn id_text(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
/// Médias d'une publication, au format `(mediaId, displayOrder)` :
/// `mediaIds` (chaînes ou objets `{ mediaId, displayOrder }`) ou, à défaut,
/// l'ancien champ unique `mediaId`.
fn normalize_media_items(
media_ids: Option<&Value>,
media_id: Option<&Value>,
) -> Result<Vec<(String, i32)>, String> {
if let Some(Value::Array(items)) = media_ids {
return items
.iter()
.enumerate()
.map(|(index, item)| {
let index = index as i32;
match item {
Value::String(id) => Ok((id.clone(), index)),
Value::Object(_) if item.truthy("mediaId") => {
let order = match item.get("displayOrder") {
None | Some(Value::Null) => index,
Some(Value::Number(n)) => n.as_f64().map(|f| f as i32).unwrap_or(index),
Some(Value::String(s)) => s.trim().parse().unwrap_or(index),
Some(_) => index,
};
Ok((id_text(&item["mediaId"]), order))
}
_ => Err("Invalid media format".to_string()),
}
})
.collect();
}
match media_id {
Some(id) if is_truthy(Some(id)) => Ok(vec![(id_text(id), 0)]),
_ => Ok(Vec::new()),
}
}
/// Champs du post validés (schéma `insertPostSchema`).
struct PostFields {
content: String,
ai_generated: Option<String>,
product_info: Option<Value>,
status: PostStatus,
scheduled_for: Option<Ts>,
generation_status: Option<String>,
generation_progress: Option<i32>,
generation_error: Option<String>,
}
/// Valide les champs du post ; l'erreur est le message de `ZodError`.
fn parse_post_fields(body: &Value) -> Result<PostFields, String> {
let scheduled_raw = body.get("scheduledFor");
let scheduled = is_truthy(scheduled_raw);
let mut issues = Issues::new();
let content = issues.string(body, "content", Presence::Required);
let ai_generated = issues.string(body, "aiGenerated", Presence::Optional);
let product_info = issues.json(body, "productInfo");
// Une date de publication fait passer le post en « scheduled »
let status = if scheduled {
Field::Set("scheduled".to_string())
} else {
issues.enumeration(
body,
"status",
Presence::Optional,
&["draft", "scheduled", "published", "failed"],
)
};
let scheduled_for = match scheduled_raw {
// `new Date(chaîne)` avant validation
Some(Value::String(s)) if !s.is_empty() => {
issues.converted_date("scheduledFor", Ts::parse(s))
}
_ => issues
.date(body, "scheduledFor", Presence::Nullable)
.into_option(),
};
let generation_status = issues.string(body, "generationStatus", Presence::Nullable);
let generation_progress = issues.int(body, "generationProgress", Presence::Nullable);
let generation_error = issues.string(body, "generationError", Presence::Nullable);
if !issues.is_empty() {
return Err(issues.zod_message());
}
Ok(PostFields {
content: content.into_option().unwrap_or_default(),
ai_generated: ai_generated.into_option(),
product_info: product_info.into_option(),
status: status
.into_option()
.and_then(|s| PostStatus::parse(&s))
.unwrap_or(PostStatus::Draft),
scheduled_for,
generation_status: generation_status.into_option(),
generation_progress: generation_progress.into_option(),
generation_error: generation_error.into_option(),
})
}
async fn create_post(
State(state): State<AppState>,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
match create_post_inner(&state, &user, &body).await {
Ok(Ok(post)) => Ok(Json(json!(post))),
Ok(Err(response)) => Err(response),
Err(message) => {
tracing::error!("Error creating post: {message}");
Err(AppError::internal(message))
}
}
}
/// `Err(message)` : erreur inattendue (500 avec le message) ;
/// `Ok(Err(réponse))` : refus explicite (400/403).
async fn create_post_inner(
state: &AppState,
user: &User,
body: &Value,
) -> Result<Result<Post, AppError>, String> {
let db_error = |e: sqlx::Error| super::pg_message(&e);
let media_items = normalize_media_items(body.get("mediaIds"), body.get("mediaId"))?;
let post_type = body.get("postType");
let post_type_str = post_type.and_then(Value::as_str);
let page_ids: Option<&Vec<Value>> = body.get("pageIds").and_then(Value::as_array);
let has_pages = page_ids.is_some_and(|p| !p.is_empty());
if media_items.len() > MAX_MEDIA {
return Ok(Err(AppError::bad_request(
"Maximum 10 photos autorisées par publication",
)));
}
if matches!(post_type_str, Some("story") | Some("both")) && media_items.is_empty() {
return Ok(Err(AppError::bad_request(
"Les stories nécessitent au moins un média (image ou vidéo)",
)));
}
// Reel déjà monté : une seule vidéo, publiée telle quelle sur Facebook ou TikTok
if post_type_str == Some("reel") {
if media_items.len() != 1 {
return Ok(Err(AppError::bad_request(
"Un Reel nécessite exactement une vidéo",
)));
}
let media = state
.db
.get_media_by_id(&media_items[0].0)
.await
.map_err(db_error)?;
let valid = media.is_some_and(|m| {
m.media_type == MediaType::Video && (user.is_admin() || m.user_id == user.id)
});
if !valid {
return Ok(Err(AppError::bad_request(
"Le média d'un Reel doit être une de vos vidéos",
)));
}
for page_id in page_ids.into_iter().flatten() {
let page = state
.db
.get_social_page(&id_text(page_id))
.await
.map_err(db_error)?;
if !page.is_some_and(|p| matches!(p.platform, Platform::Facebook | Platform::Tiktok)) {
return Ok(Err(AppError::bad_request(
"Les Reels ne peuvent être publiés que sur Facebook ou TikTok",
)));
}
}
}
// Un utilisateur ne publie que sur les pages qui lui sont attribuées
if !user.is_admin() && has_pages {
let accessible = state
.db
.get_user_accessible_pages(&user.id)
.await
.map_err(db_error)?;
let all_accessible = page_ids.into_iter().flatten().all(|id| {
id.as_str()
.is_some_and(|id| accessible.iter().any(|p| p.id == id))
});
if !all_accessible {
return Ok(Err(AppError::forbidden(
"Vous n'avez pas accès à certaines pages sélectionnées",
)));
}
}
if is_truthy(body.get("scheduledFor")) && !has_pages {
return Ok(Err(AppError::bad_request(
"Les posts programmés nécessitent au moins une page cible",
)));
}
let fields = parse_post_fields(body)?;
let scheduled_for = fields.scheduled_for;
let mut new_post = NewPost::new(user.id.clone(), fields.content);
if let Some(ai) = fields.ai_generated {
new_post.ai_generated = ai;
}
new_post.product_info = fields.product_info;
new_post.status = fields.status;
new_post.scheduled_for = scheduled_for;
new_post.generation_status = fields.generation_status;
new_post.generation_progress = fields.generation_progress;
new_post.generation_error = fields.generation_error;
let post = state.db.create_post(new_post).await.map_err(db_error)?;
if !media_items.is_empty() {
state
.db
.set_post_media(&post.id, &media_items, false)
.await
.map_err(db_error)?;
}
if let Some(page_ids) = page_ids.filter(|p| !p.is_empty()) {
// Sans date : publication immédiate
let scheduled_at = scheduled_for.unwrap_or_else(Ts::now);
let type_text = match post_type {
Some(v) if is_truthy(Some(v)) => id_text(v),
_ => "feed".to_string(),
};
let final_type = PostType::parse(&type_text)
.ok_or_else(|| format!("invalid input value for enum post_type: \"{type_text}\""))?;
let also_story = body.get("alsoStory") == Some(&Value::Bool(true));
for page_id in page_ids {
let page_id = id_text(page_id);
let create = |post_type| {
NewScheduledPost::new(post.id.clone(), page_id.clone(), post_type, scheduled_at)
};
if final_type == PostType::Both {
// « both » : une story et un post de fil, indépendants
state
.db
.create_scheduled_post(create(PostType::Story))
.await
.map_err(db_error)?;
state
.db
.create_scheduled_post(create(PostType::Feed))
.await
.map_err(db_error)?;
} else {
state
.db
.create_scheduled_post(create(final_type))
.await
.map_err(db_error)?;
// Reel doublé d'une story : seulement sur Facebook, TikTok n'en a pas
if final_type == PostType::Reel && also_story {
let page = state.db.get_social_page(&page_id).await.map_err(db_error)?;
if page.is_some_and(|p| p.platform == Platform::Facebook) {
state
.db
.create_scheduled_post(create(PostType::Story))
.await
.map_err(db_error)?;
}
}
}
}
}
Ok(Ok(post))
}
fn post_with_media((post, media): (Post, Vec<crate::db::Media>)) -> Value {
json!({ "post": post, "media": media })
}
async fn get_post(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<String>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to fetch post";
let found = state
.db
.get_post_with_media(&id)
.await
.or_500(FAILED)?
.ok_or_else(|| AppError::not_found("Post not found"))?;
// Admin : tout ; propriétaire : son post
if user.is_admin() || found.0.user_id == user.id {
return Ok(Json(post_with_media(found)));
}
// Utilisateur : les posts programmés sur une de ses pages
let scheduled = state
.db
.get_scheduled_posts_by_post(&id)
.await
.or_500(FAILED)?;
if !scheduled.is_empty() {
let accessible = state
.db
.get_user_accessible_pages(&user.id)
.await
.or_500(FAILED)?;
if scheduled
.iter()
.any(|sp| accessible.iter().any(|p| p.id == sp.page_id))
{
return Ok(Json(post_with_media(found)));
}
}
Err(AppError::forbidden("Unauthorized"))
}
async fn update_post(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<String>,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to update post";
let post = state
.db
.get_post(&id)
.await
.or_500(FAILED)?
.ok_or_else(|| AppError::not_found("Post not found"))?;
if post.user_id != user.id {
return Err(AppError::forbidden("Unauthorized"));
}
let mut patch = Patch::new();
match body.get("content") {
None => {}
Some(Value::String(content)) => patch = patch.set("content", content.as_str()),
// `null` ou autre type : refusé par la base (colonne texte non nulle)
Some(_) => return Err(AppError::internal(FAILED)),
}
let updated = state.db.update_post(&id, patch).await.or_500(FAILED)?;
Ok(Json(json!(updated)))
}
async fn update_post_media(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<String>,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to update post media";
let post = state
.db
.get_post(&id)
.await
.or_500(FAILED)?
.ok_or_else(|| AppError::not_found("Post not found"))?;
if post.user_id != user.id {
return Err(AppError::forbidden("Unauthorized"));
}
let Some(media_ids) = body.get("mediaIds").and_then(Value::as_array) else {
return Err(AppError::bad_request("mediaIds must be an array"));
};
if media_ids.len() > MAX_MEDIA {
return Err(AppError::bad_request(
"Maximum 10 photos autorisées par publication",
));
}
let media_ids: Vec<String> = media_ids.iter().map(id_text).collect();
state
.db
.update_post_media(&id, &media_ids)
.await
.or_500(FAILED)?;
let updated = state.db.get_post_with_media(&id).await.or_500(FAILED)?;
Ok(Json(updated.map(post_with_media).unwrap_or(Value::Null)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn media_list_normalization() {
let ids = json!(["a", { "mediaId": "b", "displayOrder": 5 }, { "mediaId": "c" }]);
assert_eq!(
normalize_media_items(Some(&ids), None).unwrap(),
vec![("a".into(), 0), ("b".into(), 5), ("c".into(), 2)]
);
// Ancien champ unique
assert_eq!(
normalize_media_items(None, Some(&json!("m"))).unwrap(),
vec![("m".into(), 0)]
);
// Un tableau prime sur mediaId ; un non-tableau est ignoré
assert_eq!(
normalize_media_items(Some(&json!([])), Some(&json!("m"))).unwrap(),
vec![]
);
assert_eq!(
normalize_media_items(Some(&json!("x")), Some(&json!("m"))).unwrap(),
vec![("m".into(), 0)]
);
assert_eq!(
normalize_media_items(None, Some(&json!(""))).unwrap(),
vec![]
);
assert_eq!(
normalize_media_items(Some(&json!([{ "id": "x" }])), None).unwrap_err(),
"Invalid media format"
);
assert!(normalize_media_items(Some(&json!([null])), None).is_err());
}
#[test]
fn post_fields_validation() {
let fields = parse_post_fields(
&json!({ "content": "Bonjour", "scheduledFor": "2026-10-20T10:00:00Z" }),
)
.unwrap();
assert_eq!(fields.status, PostStatus::Scheduled);
assert_eq!(
fields.scheduled_for.unwrap().to_iso(),
"2026-10-20T10:00:00.000Z"
);
let fields =
parse_post_fields(&json!({ "content": "x", "status": "draft", "aiGenerated": "true" }))
.unwrap();
assert_eq!(fields.status, PostStatus::Draft);
assert_eq!(fields.ai_generated.as_deref(), Some("true"));
let error = parse_post_fields(&json!({})).err().unwrap();
assert!(error.contains("\"path\": [\n \"content\"\n ]"));
assert!(error.contains("\"message\": \"Required\""));
let error = parse_post_fields(&json!({ "content": "x", "scheduledFor": "pas une date" }))
.err()
.unwrap();
assert!(error.contains("Invalid date"));
}
}
+253
View File
@@ -0,0 +1,253 @@
//! Publications planifiées (calendrier) et agenda par magasin.
use std::collections::{HashMap, HashSet};
use axum::extract::{Path, State};
use axum::routing::{get, patch};
use axum::{Json, Router};
use serde_json::{json, Value};
use super::agenda::{
agenda_item_from_scheduled_post, agenda_items_from_reel_job, AgendaItem, AgendaPage,
};
use super::validate::{Issues, Presence};
use crate::auth::AuthUser;
use crate::db::{NewScheduledPost, Patch, PostType, ScheduledPost};
use crate::error::{ApiResult, AppError, OrApiError};
use crate::extract::{is_truthy, Body, QueryMap};
use crate::state::AppState;
use crate::time::Ts;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/scheduled-posts/agenda", get(agenda))
.route(
"/api/scheduled-posts",
get(list_scheduled).post(create_scheduled),
)
.route(
"/api/scheduled-posts/{id}",
patch(update_scheduled).delete(delete_scheduled),
)
}
/// Ce qui est déjà prévu par jour et par magasin, Reels en montage compris.
async fn agenda(
State(state): State<AppState>,
user: AuthUser,
query: QueryMap,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to fetch publication agenda";
let start = super::parse_date_param(query.get("startDate"));
let end = super::parse_date_param(query.get("endDate"));
let pages = state.db.get_visible_pages(&user).await.or_500(FAILED)?;
if pages.is_empty() {
return Ok(Json(json!([])));
}
let page_ids: Vec<String> = pages.iter().map(|p| p.id.clone()).collect();
let pages_by_id: HashMap<String, AgendaPage> = pages
.into_iter()
.map(|p| {
let page = AgendaPage {
id: p.id.clone(),
page_name: p.page_name,
platform: p.platform.as_str().to_string(),
avatar_url: p.avatar_url,
};
(p.id, page)
})
.collect();
let (scheduled, jobs) = tokio::try_join!(
state.db.get_scheduled_posts_by_pages(&page_ids, start, end),
state.db.get_rendering_reel_jobs(),
)
.or_500(FAILED)?;
let mut items: Vec<AgendaItem> = scheduled
.iter()
.map(agenda_item_from_scheduled_post)
.collect();
// Un rendu qui vient de finir peut avoir déjà créé ses publications
let planned: HashSet<String> = items.iter().map(|i| i.post_id.clone()).collect();
for job in &jobs {
if job.post_id.as_ref().is_some_and(|id| planned.contains(id)) {
continue;
}
items.extend(agenda_items_from_reel_job(job, &pages_by_id, start, end));
}
items.sort_by(|a, b| a.scheduled_at.cmp(&b.scheduled_at));
Ok(Json(json!(items)))
}
/// Admin : toutes les pages ; utilisateur : les pages qui lui sont attribuées.
async fn list_scheduled(
State(state): State<AppState>,
user: AuthUser,
query: QueryMap,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to fetch scheduled posts";
// Une date illisible est ignorée plutôt que transmise à SQL
let start = super::parse_date_param(query.get("startDate"));
let end = super::parse_date_param(query.get("endDate"));
let pages = state.db.get_visible_pages(&user).await.or_500(FAILED)?;
let page_ids: Vec<String> = pages.into_iter().map(|p| p.id).collect();
let scheduled = state
.db
.get_scheduled_posts_by_pages(&page_ids, start, end)
.await
.or_500(FAILED)?;
Ok(Json(json!(scheduled)))
}
/// Publication planifiée et droit de la modifier (admin ou auteur du post).
async fn owned_scheduled_post(
state: &AppState,
user: &AuthUser,
id: &str,
failed: &str,
) -> Result<ScheduledPost, AppError> {
let scheduled = state
.db
.get_scheduled_post(id)
.await
.or_500(failed)?
.ok_or_else(|| AppError::not_found("Scheduled post not found"))?;
let post = state
.db
.get_post(&scheduled.post_id)
.await
.or_500(failed)?
.ok_or_else(|| AppError::not_found("Post not found"))?;
if !user.is_admin() && post.user_id != user.id {
return Err(AppError::forbidden("Unauthorized"));
}
Ok(scheduled)
}
async fn delete_scheduled(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<String>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to delete scheduled post";
owned_scheduled_post(&state, &user, &id, FAILED).await?;
state.db.delete_scheduled_post(&id).await.or_500(FAILED)?;
Ok(Json(json!({ "success": true })))
}
/// `new Date(valeur)` pour une chaîne ou un nombre de millisecondes.
fn js_date(value: &Value) -> Option<Ts> {
match value {
Value::String(s) => Ts::parse(s),
Value::Number(n) => n.as_f64().and_then(|f| Ts::from_millis(f as i64)),
_ => None,
}
}
/// Seules la date et la page peuvent changer ; la date est recopiée sur le post.
async fn update_scheduled(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<String>,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to update scheduled post";
let scheduled = owned_scheduled_post(&state, &user, &id, FAILED).await?;
let mut patch = Patch::new();
if let Some(value) = body.get("scheduledAt").filter(|v| is_truthy(Some(v))) {
let date = js_date(value).ok_or_else(|| AppError::internal(FAILED))?;
patch = patch.set("scheduled_at", date);
state
.db
.update_post(&scheduled.post_id, Patch::new().set("scheduled_for", date))
.await
.or_500(FAILED)?;
}
if let Some(page_id) = body.get("pageId").filter(|v| is_truthy(Some(v))) {
let Some(page_id) = page_id.as_str() else {
return Err(AppError::internal(FAILED));
};
patch = patch.set("page_id", page_id);
}
// Rien à modifier : l'ancienne requête de mise à jour échouait
if patch.is_empty() {
return Err(AppError::internal(FAILED));
}
let updated = state
.db
.update_scheduled_post(&id, patch)
.await
.or_500(FAILED)?;
Ok(Json(json!(updated)))
}
/// Création directe (schéma `insertScheduledPostSchema`). « both » crée deux
/// publications indépendantes (story + fil), renvoyées sous forme de tableau.
async fn create_scheduled(
State(state): State<AppState>,
_user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Failed to create scheduled post";
let mut issues = Issues::new();
let post_id = issues
.string(&body, "postId", Presence::Required)
.into_option();
let page_id = issues
.string(&body, "pageId", Presence::Required)
.into_option();
let post_type = issues
.enumeration(
&body,
"postType",
Presence::Required,
&["feed", "story", "both", "reel"],
)
.into_option()
.and_then(|t| PostType::parse(&t));
// Plus tolérant que l'ancien `z.date()` (qui refusait toute date JSON) :
// une date ISO ou un nombre de millisecondes est accepté.
let scheduled_at = body.get("scheduledAt").and_then(js_date);
let publish_id = issues
.string(&body, "publishId", Presence::Nullable)
.into_option();
let publish_status = issues
.string(&body, "publishStatus", Presence::Nullable)
.into_option();
let (Some(post_id), Some(page_id), Some(post_type), Some(scheduled_at)) =
(post_id, page_id, post_type, scheduled_at)
else {
tracing::error!("Error creating scheduled post: {}", issues.zod_message());
return Err(AppError::internal(FAILED));
};
if !issues.is_empty() {
tracing::error!("Error creating scheduled post: {}", issues.zod_message());
return Err(AppError::internal(FAILED));
}
let new = |post_type| NewScheduledPost {
publish_id: publish_id.clone(),
publish_status: publish_status.clone(),
..NewScheduledPost::new(post_id.clone(), page_id.clone(), post_type, scheduled_at)
};
if post_type == PostType::Both {
let (story, feed) = tokio::try_join!(
state.db.create_scheduled_post(new(PostType::Story)),
state.db.create_scheduled_post(new(PostType::Feed)),
)
.or_500(FAILED)?;
return Ok(Json(json!([story, feed])));
}
let created = state
.db
.create_scheduled_post(new(post_type))
.await
.or_500(FAILED)?;
Ok(Json(json!(created)))
}
+207
View File
@@ -0,0 +1,207 @@
//! Paramètres globaux : clé de l'API externe, clé Gemini, service Qwen TTS.
use axum::extract::State;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{json, Value};
use crate::auth::AdminUser;
use crate::db::{Patch, Val};
use crate::error::{ApiResult, AppError, OrApiError};
use crate::extract::{Body, JsonExt};
use crate::services::ffmpeg::{self, QwenTarget};
use crate::services::reels::normalize_qwen_url;
use crate::state::AppState;
const READ_FAILED: &str = "Erreur lors de la récupération de la configuration";
const SAVE_FAILED: &str = "Erreur lors de la sauvegarde de la configuration";
const DELETE_KEY_FAILED: &str = "Erreur lors de la suppression de la clé";
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/api/settings/external-api",
get(get_external_api)
.post(save_external_api)
.delete(delete_external_api),
)
.route(
"/api/settings/gemini",
get(get_gemini).post(save_gemini).delete(delete_gemini),
)
.route(
"/api/settings/qwen",
get(get_qwen).post(save_qwen).delete(delete_qwen),
)
.route("/api/settings/qwen/test", post(test_qwen))
}
fn is_set(value: &Option<String>) -> bool {
value.as_deref().is_some_and(|v| !v.is_empty())
}
/// Clé API obligatoire et non vide (`apiKey.trim() !== ""`).
fn required_api_key(body: &Value) -> Result<String, AppError> {
body.trimmed("apiKey")
.ok_or_else(|| AppError::bad_request("La clé API est requise"))
}
async fn get_external_api(
State(state): State<AppState>,
_admin: AdminUser,
) -> ApiResult<Json<Value>> {
let config = state.db.get_app_config().await.or_500(READ_FAILED)?;
Ok(Json(
json!({ "configured": config.is_some_and(|c| is_set(&c.external_api_key)) }),
))
}
async fn save_external_api(
State(state): State<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
let key = required_api_key(&body)?;
state
.db
.upsert_app_config(Patch::new().set("external_api_key", key))
.await
.or_500(SAVE_FAILED)?;
Ok(Json(json!({ "success": true, "configured": true })))
}
async fn delete_external_api(
State(state): State<AppState>,
_admin: AdminUser,
) -> ApiResult<Json<Value>> {
state
.db
.upsert_app_config(Patch::new().set_null("external_api_key"))
.await
.or_500(DELETE_KEY_FAILED)?;
Ok(Json(json!({ "success": true, "configured": false })))
}
async fn get_gemini(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
let config = state.db.get_app_config().await.or_500(READ_FAILED)?;
Ok(Json(
json!({ "configured": config.is_some_and(|c| is_set(&c.gemini_api_key)) }),
))
}
async fn save_gemini(
State(state): State<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
let key = required_api_key(&body)?;
state
.db
.upsert_app_config(Patch::new().set("gemini_api_key", key))
.await
.or_500(SAVE_FAILED)?;
Ok(Json(json!({ "success": true, "configured": true })))
}
async fn delete_gemini(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
state
.db
.upsert_app_config(Patch::new().set_null("gemini_api_key"))
.await
.or_500(DELETE_KEY_FAILED)?;
Ok(Json(json!({ "success": true, "configured": false })))
}
async fn get_qwen(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
let config = state.db.get_app_config().await.or_500(READ_FAILED)?;
let url = config
.as_ref()
.and_then(|c| c.qwen_tts_url.clone())
.unwrap_or_default();
let has_key = config.is_some_and(|c| is_set(&c.qwen_tts_api_key));
Ok(Json(json!({ "url": url, "hasApiKey": has_key })))
}
async fn save_qwen(
State(state): State<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
let Some(url) = normalize_qwen_url(body.get("url")) else {
return Err(AppError::bad_request(
"Adresse invalide (ex. http://192.168.1.20:8001)",
));
};
let mut patch = Patch::new().set("qwen_tts_url", url);
// Clé laissée vide : on garde celle déjà enregistrée
if let Some(key) = body.trimmed("apiKey") {
patch = patch.set("qwen_tts_api_key", key);
}
state
.db
.upsert_app_config(patch)
.await
.or_500(SAVE_FAILED)?;
Ok(Json(json!({ "success": true })))
}
async fn delete_qwen(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
state
.db
.upsert_app_config(
Patch::new()
.set("qwen_tts_url", Val::Text(None))
.set("qwen_tts_api_key", Val::Text(None)),
)
.await
.or_500("Erreur lors de la suppression de la configuration")?;
Ok(Json(json!({ "success": true })))
}
/// Teste la connexion depuis le service FFmpeg (c'est lui qui appelle Qwen).
/// Répond toujours 200 : `{ ok: false, error }` en cas d'échec.
async fn test_qwen(
State(state): State<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> Json<Value> {
match run_qwen_test(&state, &body).await {
Ok(value) => Json(value),
Err(error) => {
tracing::error!("Error testing Qwen config: {error:#}");
Json(json!({ "ok": false, "error": error.to_string() }))
}
}
}
async fn run_qwen_test(state: &AppState, body: &Value) -> anyhow::Result<Value> {
let config = state.db.get_app_config().await?;
let url = normalize_qwen_url(body.get("url"))
.or_else(|| config.as_ref().and_then(|c| c.qwen_tts_url.clone()))
.filter(|u| !u.is_empty());
let api_key = body
.trimmed("apiKey")
.or_else(|| config.as_ref().and_then(|c| c.qwen_tts_api_key.clone()))
.filter(|k| !k.is_empty());
let catalog = ffmpeg::list_voices(state, url.map(|url| QwenTarget { url, api_key })).await?;
let status = catalog
.qwen_status
.as_ref()
.filter(|s| crate::extract::is_truthy(Some(s)));
let Some(status) = status.filter(|_| catalog.qwen_available) else {
return Ok(
json!({ "ok": false, "error": "Service Qwen injoignable (adresse, port, clé ou pare-feu)" }),
);
};
let mut result = serde_json::Map::new();
result.insert("ok".into(), Value::Bool(true));
if let Value::Object(fields) = status {
for (key, value) in fields {
result.insert(key.clone(), value.clone());
}
}
result.insert("voices".into(), json!(catalog.qwen.len()));
Ok(Value::Object(result))
}
+645
View File
@@ -0,0 +1,645 @@
//! Console SQL (administrateurs).
//!
//! L'ancienne route passait la requête telle quelle à `db.execute` (pilote
//! `pg`, protocole simple) et renvoyait l'objet `QueryResult` de `pg` :
//! `{ command, rowCount, oid, rows, fields, … }`, ou un tableau de ces objets
//! quand la requête contenait plusieurs instructions. Les valeurs sont
//! converties comme le faisait `pg-types`, avec les exceptions de Drizzle
//! (dates, horodatages et intervalles laissés en texte brut).
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use futures::TryStreamExt;
use serde_json::{json, Map, Number, Value};
use sqlx::postgres::{PgRow, PgValueRef};
use sqlx::{Column, Either, Row, TypeInfo, ValueRef};
use super::validate::{Issues, Presence};
use crate::auth::AdminUser;
use crate::error::{ApiResult, AppError};
use crate::extract::Body;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/sql/execute", post(execute))
.route("/api/sql/tables", get(tables))
}
async fn execute(
State(state): State<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
let mut issues = Issues::new();
let query = issues.string_min(
&body,
"query",
Presence::Required,
1,
"La requête SQL ne peut pas être vide",
);
let Some(query) = query.into_option().filter(|_| issues.is_empty()) else {
return Err(AppError::json(
StatusCode::BAD_REQUEST,
json!({ "success": false, "error": issues.first_message().unwrap_or_else(|| "Données invalides".into()) }),
));
};
match run_query(&state, &query).await {
Ok(result) => Ok(Json(json!({ "success": true, "result": result }))),
Err(error) => {
tracing::error!("Error executing SQL: {error}");
let message = super::pg_message(&error);
let message = if message.is_empty() {
"Erreur lors de l'exécution de la requête SQL".to_string()
} else {
message
};
Err(AppError::json(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "success": false, "error": message }),
))
}
}
}
async fn tables(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
let names: Result<Vec<String>, sqlx::Error> = sqlx::query_scalar(
"SELECT tablename::text FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
)
.fetch_all(&state.db.pool)
.await;
match names {
Ok(names) => Ok(Json(json!({
"tables": names.into_iter().map(|t| json!({ "tablename": t })).collect::<Vec<_>>(),
}))),
Err(error) => {
tracing::error!("Error fetching tables: {error}");
Err(AppError::internal(super::pg_message(&error)))
}
}
}
/// Résultat d'une instruction en cours de lecture.
#[derive(Default)]
struct StatementResult {
rows: Vec<Value>,
fields: Vec<Value>,
}
/// Exécute la requête par le protocole simple (plusieurs instructions
/// possibles, comme avec `pg`) et assemble les résultats au format `pg`.
async fn run_query(state: &AppState, query: &str) -> Result<Value, sqlx::Error> {
let commands = statement_commands(query);
let mut results: Vec<Value> = Vec::new();
let mut current = StatementResult::default();
let mut stream = sqlx::raw_sql(query).fetch_many(&state.db.pool);
while let Some(item) = stream.try_next().await? {
match item {
Either::Right(row) => {
if current.fields.is_empty() {
current.fields = row_fields(&row);
}
current.rows.push(row_to_json(&row));
}
Either::Left(done) => {
let command = commands.get(results.len()).cloned().unwrap_or_default();
let finished = std::mem::take(&mut current);
results.push(query_result(&command, done.rows_affected(), finished));
}
}
}
Ok(match results.len() {
// Requête vide (commentaire seul…) : objet résultat vierge
0 => json!({
"command": null, "rowCount": null, "oid": null, "rows": [], "fields": [],
"_types": {}, "RowCtor": null, "rowAsArray": false, "_prebuiltEmptyResultObject": null,
}),
1 => results.pop().unwrap_or(Value::Null),
_ => Value::Array(results),
})
}
/// Objet `Result` de `pg` pour une instruction terminée.
fn query_result(command: &str, rows_affected: u64, result: StatementResult) -> Value {
// `pg` lit l'étiquette « COMMANDE [OID] LIGNES » envoyée par PostgreSQL
let (row_count, oid) = match command {
"INSERT" => (json!(rows_affected), json!(0)),
"SELECT" | "UPDATE" | "DELETE" | "MERGE" | "MOVE" | "FETCH" | "COPY" => {
(json!(rows_affected), Value::Null)
}
_ => (Value::Null, Value::Null),
};
let empty_row: Map<String, Value> = result
.fields
.iter()
.filter_map(|f| f.get("name").and_then(Value::as_str))
.map(|name| (name.to_string(), Value::Null))
.collect();
let has_fields = !result.fields.is_empty();
let field_count = result.fields.len();
// Même ordre de clés que l'objet `Result` de `pg`
let mut object = Map::new();
object.insert(
"command".into(),
if command.is_empty() {
Value::Null
} else {
json!(command)
},
);
object.insert("rowCount".into(), row_count);
object.insert("oid".into(), oid);
object.insert("rows".into(), Value::Array(result.rows));
object.insert("fields".into(), Value::Array(result.fields));
if has_fields {
// Les analyseurs de `pg` sont des fonctions : `null` une fois sérialisés
object.insert(
"_parsers".into(),
Value::Array(vec![Value::Null; field_count]),
);
}
object.insert("_types".into(), json!({}));
object.insert("RowCtor".into(), Value::Null);
object.insert("rowAsArray".into(), Value::Bool(false));
object.insert(
"_prebuiltEmptyResultObject".into(),
if has_fields {
Value::Object(empty_row)
} else {
Value::Null
},
);
Value::Object(object)
}
/// Description des colonnes (`fields` de `pg`).
fn row_fields(row: &PgRow) -> Vec<Value> {
row.columns()
.iter()
.map(|column| {
let type_info = column.type_info();
json!({
"name": column.name(),
"tableID": column.relation_id().map(|o| o.0).unwrap_or(0),
"columnID": column.relation_attribute_no().unwrap_or(0),
"dataTypeID": type_info.oid().map(|o| o.0).unwrap_or(0),
"format": "text",
})
})
.collect()
}
fn row_to_json(row: &PgRow) -> Value {
let mut object = Map::new();
for (index, column) in row.columns().iter().enumerate() {
let oid = column.type_info().oid().map(|o| o.0).unwrap_or(0);
let value = match row.try_get_raw(index) {
Ok(raw) => raw_to_json(oid, column.type_info().name(), raw),
Err(_) => Value::Null,
};
// Colonnes homonymes : la dernière l'emporte, à la place de la première
object.insert(column.name().to_string(), value);
}
Value::Object(object)
}
fn raw_to_json(oid: u32, type_name: &str, raw: PgValueRef<'_>) -> Value {
if raw.is_null() {
return Value::Null;
}
match raw.as_str() {
Ok(text) => text_to_json(oid, text),
// Valeur binaire (jamais en protocole simple) : nom du type en repli
Err(_) => Value::String(format!("<{type_name}>")),
}
}
/// Conversion d'une valeur texte PostgreSQL, comme les analyseurs de `pg-types`.
pub(crate) fn text_to_json(oid: u32, text: &str) -> Value {
match oid {
// int2, int4, oid
21 | 23 | 26 => parse_int(text),
// float4, float8
700 | 701 => parse_float(text),
16 => Value::Bool(matches!(
text,
"TRUE" | "t" | "true" | "y" | "yes" | "on" | "1"
)),
114 | 3802 => serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.into())),
17 => parse_bytea(text),
600 => parse_point(text),
// Tableaux
1000 => parse_array(text, &|e| {
Value::Bool(matches!(
e,
"TRUE" | "t" | "true" | "y" | "yes" | "on" | "1"
))
}),
1005 | 1007 | 1028 => parse_array(text, &parse_int),
1016 => parse_array(text, &|e| Value::String(e.trim().into())),
1021 | 1022 | 1231 => parse_array(text, &parse_float),
199 | 3807 => parse_array(text, &|e| {
serde_json::from_str(e).unwrap_or_else(|_| Value::String(e.into()))
}),
1115 | 1182 | 1185 => parse_array(text, &parse_date),
1001 => parse_array(text, &parse_bytea),
651 | 1014 | 1015 | 1008 | 1009 | 1040 | 1041 | 3907 | 2951 | 791 | 1183 | 1270 => {
parse_array(text, &|e| Value::String(e.into()))
}
// int8 et numeric restent du texte ; date, timestamp(tz), interval
// aussi (Drizzle désactive leur conversion) ; uuid, enums… : texte
_ => Value::String(text.into()),
}
}
/// `parseInt(value, 10)` : `null` si illisible (NaN sérialisé par JSON).
fn parse_int(text: &str) -> Value {
let trimmed = text.trim();
let (sign, digits) = match trimmed.strip_prefix('-') {
Some(rest) => (-1i64, rest),
None => (1, trimmed.strip_prefix('+').unwrap_or(trimmed)),
};
let end = digits.bytes().take_while(u8::is_ascii_digit).count();
match digits[..end].parse::<i64>() {
Ok(n) => json!(sign * n),
Err(_) => Value::Null,
}
}
/// `parseFloat` : `NaN` et `Infinity` deviennent `null` en JSON.
fn parse_float(text: &str) -> Value {
match text.trim().parse::<f64>() {
Ok(f) if f.is_finite() => {
if f.fract() == 0.0 && f.abs() < 9e15 {
json!(f as i64)
} else {
Number::from_f64(f)
.map(Value::Number)
.unwrap_or(Value::Null)
}
}
_ => Value::Null,
}
}
/// Élément de tableau date/horodatage : objet `Date` JavaScript, donc ISO 8601.
fn parse_date(text: &str) -> Value {
let mut value = text.to_string();
// Décalage « +02 » sans minutes, refusé par chrono
if let Some(pos) = value.rfind(['+', '-']) {
if pos > 10 && value.len() - pos == 3 {
value.push_str(":00");
}
}
match crate::time::parse_js_date(&value) {
Some(ts) => Value::String(ts.to_iso()),
None => Value::Null,
}
}
/// `bytea` (format hexadécimal) : objet `Buffer` sérialisé.
fn parse_bytea(text: &str) -> Value {
match text
.strip_prefix("\\x")
.and_then(|hex_text| hex::decode(hex_text).ok())
{
Some(bytes) => json!({ "type": "Buffer", "data": bytes }),
None => Value::String(text.into()),
}
}
fn parse_point(text: &str) -> Value {
let inner = text.trim().trim_start_matches('(').trim_end_matches(')');
let mut parts = inner.split(',').map(|p| p.trim().parse::<f64>().ok());
match (parts.next().flatten(), parts.next().flatten()) {
(Some(x), Some(y)) => json!({ "x": x, "y": y }),
_ => Value::String(text.into()),
}
}
/// Tableau PostgreSQL (`{1,2,NULL}`, `{"a b","c\"d"}`, imbriqué) en JSON.
fn parse_array(text: &str, element: &dyn Fn(&str) -> Value) -> Value {
let text = text.trim();
// Bornes explicites (« [0:1]={…} ») : on les ignore
let text = match (text.starts_with('['), text.find('=')) {
(true, Some(pos)) => &text[pos + 1..],
_ => text,
};
let chars: Vec<char> = text.chars().collect();
let mut pos = 0;
match parse_array_level(&chars, &mut pos, element) {
Some(value) => value,
None => Value::String(text.into()),
}
}
fn parse_array_level(
chars: &[char],
pos: &mut usize,
element: &dyn Fn(&str) -> Value,
) -> Option<Value> {
if chars.get(*pos) != Some(&'{') {
return None;
}
*pos += 1;
let mut items = Vec::new();
loop {
match chars.get(*pos)? {
'}' => {
*pos += 1;
return Some(Value::Array(items));
}
',' => {
*pos += 1;
}
'{' => items.push(parse_array_level(chars, pos, element)?),
'"' => {
*pos += 1;
let mut buf = String::new();
loop {
match chars.get(*pos)? {
'\\' => {
buf.push(*chars.get(*pos + 1)?);
*pos += 2;
}
'"' => {
*pos += 1;
break;
}
c => {
buf.push(*c);
*pos += 1;
}
}
}
items.push(element(&buf));
}
_ => {
let start = *pos;
while !matches!(chars.get(*pos), Some(',') | Some('}') | None) {
*pos += 1;
}
let raw: String = chars[start..*pos].iter().collect();
let raw = raw.trim();
items.push(if raw.eq_ignore_ascii_case("NULL") {
Value::Null
} else {
element(raw)
});
}
}
}
}
/// Commande de chaque instruction (« SELECT », « INSERT »…), dans l'ordre :
/// l'étiquette envoyée par PostgreSQL n'est pas exposée par sqlx.
fn statement_commands(query: &str) -> Vec<String> {
split_statements(query)
.iter()
.filter_map(|statement| {
let word: String = statement
.trim_start()
.chars()
.take_while(|c| c.is_ascii_alphabetic())
.collect::<String>()
.to_ascii_uppercase();
if word.is_empty() {
return None;
}
Some(match word.as_str() {
// VALUES et TABLE renvoient l'étiquette SELECT
"VALUES" | "TABLE" => "SELECT".to_string(),
"WITH" => with_command(statement),
"START" => "START".to_string(),
_ => word,
})
})
.collect()
}
/// `WITH … SELECT/INSERT/UPDATE/DELETE` : premier mot-clé principal hors parenthèses.
fn with_command(statement: &str) -> String {
let upper = statement.to_ascii_uppercase();
let mut depth = 0i32;
let mut word = String::new();
for c in upper.chars().chain(std::iter::once(' ')) {
match c {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
if c.is_ascii_alphabetic() {
word.push(c);
} else {
if depth == 0
&& matches!(
word.as_str(),
"SELECT" | "INSERT" | "UPDATE" | "DELETE" | "MERGE"
)
{
return word;
}
word.clear();
}
}
"SELECT".to_string()
}
/// Découpe une requête en instructions sur les « ; » hors chaînes,
/// identifiants entre guillemets, chaînes dollar et commentaires.
/// Les commentaires sont retirés du texte des instructions.
fn split_statements(query: &str) -> Vec<String> {
let chars: Vec<char> = query.chars().collect();
let mut statements = Vec::new();
let mut current = String::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match c {
'\'' | '"' => {
let quote = c;
current.push(c);
i += 1;
while i < chars.len() {
current.push(chars[i]);
if chars[i] == quote {
if chars.get(i + 1) == Some(&quote) {
current.push(quote);
i += 2;
continue;
}
i += 1;
break;
}
i += 1;
}
}
'-' if chars.get(i + 1) == Some(&'-') => {
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
current.push(' ');
}
'/' if chars.get(i + 1) == Some(&'*') => {
let mut depth = 0;
while i < chars.len() {
if chars[i] == '/' && chars.get(i + 1) == Some(&'*') {
depth += 1;
i += 2;
} else if chars[i] == '*' && chars.get(i + 1) == Some(&'/') {
depth -= 1;
i += 2;
if depth == 0 {
break;
}
} else {
i += 1;
}
}
current.push(' ');
}
'$' => {
// Étiquette de chaîne dollar : $$ ou $tag$
let mut j = i + 1;
while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') {
j += 1;
}
let is_tag = chars.get(j) == Some(&'$')
&& !chars.get(i + 1).is_some_and(|c| c.is_ascii_digit())
&& !current
.chars()
.last()
.is_some_and(|p| p.is_alphanumeric() || p == '_');
if is_tag {
let tag: Vec<char> = chars[i..=j].to_vec();
current.extend(&tag);
i = j + 1;
while i < chars.len() {
if chars[i..].starts_with(&tag) {
current.extend(&tag);
i += tag.len();
break;
}
current.push(chars[i]);
i += 1;
}
} else {
current.push(c);
i += 1;
}
}
';' => {
statements.push(std::mem::take(&mut current));
i += 1;
}
_ => {
current.push(c);
i += 1;
}
}
}
statements.push(current);
statements
.into_iter()
.filter(|s| !s.trim().is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scalar_conversions_follow_pg_types() {
assert_eq!(text_to_json(23, "42"), json!(42));
assert_eq!(
text_to_json(20, "9007199254740993"),
json!("9007199254740993")
);
assert_eq!(text_to_json(1700, "12.50"), json!("12.50"));
assert_eq!(text_to_json(701, "1.5"), json!(1.5));
assert_eq!(text_to_json(701, "3"), json!(3));
assert_eq!(text_to_json(701, "NaN"), Value::Null);
assert_eq!(text_to_json(16, "t"), json!(true));
assert_eq!(text_to_json(16, "f"), json!(false));
assert_eq!(
text_to_json(3802, r#"{"a": [1, 2]}"#),
json!({ "a": [1, 2] })
);
// Drizzle laisse les dates en texte brut
assert_eq!(
text_to_json(1114, "2024-05-01 08:30:00.123"),
json!("2024-05-01 08:30:00.123")
);
assert_eq!(text_to_json(2950, "a0ee-…"), json!("a0ee-…"));
assert_eq!(
text_to_json(17, "\\x0102"),
json!({ "type": "Buffer", "data": [1, 2] })
);
assert_eq!(text_to_json(600, "(1.5,2)"), json!({ "x": 1.5, "y": 2.0 }));
}
#[test]
fn array_conversions() {
assert_eq!(text_to_json(1007, "{1,2,NULL}"), json!([1, 2, null]));
assert_eq!(
text_to_json(1009, r#"{"a b","c\"d",e}"#),
json!(["a b", "c\"d", "e"])
);
assert_eq!(text_to_json(1007, "{{1,2},{3,4}}"), json!([[1, 2], [3, 4]]));
assert_eq!(text_to_json(1016, "{10,20}"), json!(["10", "20"]));
assert_eq!(text_to_json(1009, "{}"), json!([]));
assert_eq!(
text_to_json(1185, r#"{"2024-05-01 10:30:00+02"}"#),
json!(["2024-05-01T08:30:00.000Z"])
);
assert_eq!(
text_to_json(1115, r#"{"2024-05-01 08:30:00"}"#),
json!(["2024-05-01T08:30:00.000Z"])
);
}
#[test]
fn statement_splitting() {
let commands = statement_commands(
"select 'a;b'; -- commentaire ; ici\nINSERT INTO t VALUES ($$x;y$$); /* ; */ ; with x as (select 1) delete from t; values (1)",
);
assert_eq!(commands, vec!["SELECT", "INSERT", "DELETE", "SELECT"]);
assert_eq!(statement_commands(" "), Vec::<String>::new());
assert_eq!(
statement_commands("create table \"a;b\" (id int)"),
vec!["CREATE"]
);
}
#[test]
fn result_shape() {
let result = query_result(
"SELECT",
1,
StatementResult {
rows: vec![json!({ "a": 1 })],
fields: vec![json!({ "name": "a" })],
},
);
let keys: Vec<&String> = result.as_object().unwrap().keys().collect();
assert_eq!(
keys[..6],
["command", "rowCount", "oid", "rows", "fields", "_parsers"]
);
assert_eq!(result["rowCount"], 1);
assert_eq!(result["_prebuiltEmptyResultObject"], json!({ "a": null }));
let create = query_result("CREATE", 0, StatementResult::default());
assert_eq!(create["rowCount"], Value::Null);
assert_eq!(
query_result("INSERT", 2, StatementResult::default())["oid"],
0
);
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Compteurs du tableau de bord.
use axum::extract::State;
use axum::routing::get;
use axum::{Json, Router};
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime};
use serde_json::{json, Value};
use crate::auth::AuthUser;
use crate::error::{ApiResult, OrApiError};
use crate::state::AppState;
use crate::time::Ts;
pub fn router() -> Router<AppState> {
Router::new().route("/api/stats", get(stats))
}
async fn stats(State(state): State<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
let now = Ts::now();
let (yesterday_start, yesterday_end, last_month) = periods(now.0);
let stats = state
.db
.get_dashboard_stats(
&user.id,
Ts(yesterday_start),
Ts(yesterday_end),
Ts(last_month),
)
.await
.or_500("Failed to fetch stats")?;
let ai_change = change_percent(stats.ai_texts_generated, stats.ai_texts_yesterday);
let scheduled_change = change_percent(stats.scheduled_posts, stats.scheduled_posts_last_month);
Ok(Json(json!({
"scheduledPosts": stats.scheduled_posts,
"scheduledPostsChange": format_change(scheduled_change),
"scheduledPostsTrending": if scheduled_change >= 0 { "up" } else { "down" },
"connectedPages": stats.connected_pages,
"aiTextsGenerated": stats.ai_texts_generated,
"aiTextsChange": format_change(ai_change),
"aiTextsTrending": if ai_change >= 0 { "up" } else { "down" },
"mediaStored": stats.media_stored,
})))
}
/// Début d'hier, début d'aujourd'hui (minuit UTC, l'heure du conteneur) et
/// même instant le mois dernier (`setMonth(getMonth() - 1)`).
fn periods(now: NaiveDateTime) -> (NaiveDateTime, NaiveDateTime, NaiveDateTime) {
let yesterday = (now - Duration::days(1))
.date()
.and_hms_opt(0, 0, 0)
.unwrap_or(now);
let today = yesterday + Duration::days(1);
(yesterday, today, previous_month(now))
}
/// `Date.setMonth(mois - 1)` : un jour qui n'existe pas déborde sur le mois
/// suivant (31 mars → 3 mars, ou 2 mars une année bissextile).
fn previous_month(now: NaiveDateTime) -> NaiveDateTime {
let (year, month) = if now.month() == 1 {
(now.year() - 1, 12)
} else {
(now.year(), now.month() - 1)
};
let first = NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(now.date());
(first + Duration::days(i64::from(now.day()) - 1)).and_time(now.time())
}
/// Variation en pourcentage, arrondie comme `Math.round`.
fn change_percent(current: i64, previous: i64) -> i64 {
if previous > 0 {
let ratio = (current - previous) as f64 / previous as f64 * 100.0;
(ratio + 0.5).floor() as i64
} else if current > 0 {
100
} else {
0
}
}
fn format_change(change: i64) -> String {
if change > 0 {
format!("+{change}%")
} else {
format!("{change}%")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(s: &str) -> NaiveDateTime {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").unwrap()
}
#[test]
fn percentages() {
assert_eq!(format_change(change_percent(5, 0)), "+100%");
assert_eq!(format_change(change_percent(0, 0)), "0%");
assert_eq!(format_change(change_percent(3, 2)), "+50%");
assert_eq!(format_change(change_percent(1, 2)), "-50%");
assert_eq!(format_change(change_percent(2, 2)), "0%");
// Math.round(-2.5) = -2, Math.round(2.5) = 3
assert_eq!(change_percent(195, 200), -2);
assert_eq!(change_percent(41, 40), 3);
// -0,4 % arrondi donne 0 (et non « -0 »)
assert_eq!(format_change(change_percent(996, 1000)), "0%");
}
#[test]
fn utc_periods() {
let (start, end, month) = periods(at("2026-03-31 15:20:00"));
assert_eq!(start, at("2026-03-30 00:00:00"));
assert_eq!(end, at("2026-03-31 00:00:00"));
assert_eq!(month, at("2026-03-03 15:20:00"));
let (start, _, month) = periods(at("2026-01-01 00:10:00"));
assert_eq!(start, at("2025-12-31 00:00:00"));
assert_eq!(month, at("2025-12-01 00:10:00"));
}
}
+254
View File
@@ -0,0 +1,254 @@
//! Utilisateurs et permissions sur les pages (administrateurs).
use axum::extract::{Path, State};
use axum::routing::{get, patch, post};
use axum::{Json, Router};
use serde_json::{json, Value};
use super::validate::{Issues, Presence};
use crate::auth::{hash_password, user_json, AdminUser};
use crate::db::{Patch, UserRole};
use crate::error::{ApiResult, AppError, OrApiError};
use crate::extract::{Body, JsonExt};
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/users", get(list_users).post(create_user))
.route("/api/users/{id}", patch(update_user).delete(delete_user))
.route(
"/api/users/{id}/page-permissions",
get(get_permissions).post(set_permissions),
)
.route("/api/admin/migrate-permissions", post(migrate_permissions))
}
async fn list_users(State(state): State<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
let users = state
.db
.get_all_users()
.await
.or_500("Erreur lors de la récupération des utilisateurs")?;
Ok(Json(Value::Array(users.iter().map(user_json).collect())))
}
async fn create_user(
State(state): State<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Erreur lors de la création de l'utilisateur";
let mut issues = Issues::new();
let username = issues.string_min(
&body,
"username",
Presence::Required,
3,
"Le nom d'utilisateur doit contenir au moins 3 caractères",
);
let password = issues.string_min(
&body,
"password",
Presence::Required,
4,
"Le mot de passe doit contenir au moins 4 caractères",
);
let role = issues.enumeration(&body, "role", Presence::Optional, &["admin", "user"]);
if !issues.is_empty() {
tracing::error!("Error creating user: {}", issues.zod_message());
return Err(AppError::bad_request(
issues
.first_message()
.unwrap_or_else(|| "Données invalides".into()),
));
}
let (Some(username), Some(password)) = (username.into_option(), password.into_option()) else {
return Err(AppError::bad_request("Données invalides"));
};
if state
.db
.get_user_by_username(&username)
.await
.or_500(FAILED)?
.is_some()
{
return Err(AppError::conflict("Ce nom d'utilisateur existe déjà"));
}
let hash = hash_password(&password).await.or_500(FAILED)?;
let role = role
.into_option()
.and_then(|r| UserRole::parse(&r))
.unwrap_or(UserRole::User);
let user = state
.db
.create_user(&username, &hash, role)
.await
.or_500(FAILED)?;
Ok(Json(user_json(&user)))
}
async fn update_user(
State(state): State<AppState>,
_admin: AdminUser,
Path(user_id): Path<String>,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Erreur lors de la modification de l'utilisateur";
let existing = state
.db
.get_user(&user_id)
.await
.or_500(FAILED)?
.ok_or_else(|| AppError::not_found("Utilisateur non trouvé"))?;
let mut patch = Patch::new();
if let Some(username) = body.str_field("username").filter(|u| !u.is_empty()) {
if username != existing.username {
if let Some(other) = state
.db
.get_user_by_username(username)
.await
.or_500(FAILED)?
{
if other.id != user_id {
return Err(AppError::conflict("Ce nom d'utilisateur est déjà utilisé"));
}
}
patch = patch.set("username", username);
}
} else if body.truthy("username") {
// Valeur non textuelle : l'ancienne route échouait à l'écriture
return Err(AppError::internal(FAILED));
}
if body.truthy("password") {
let Some(password) = body.str_field("password") else {
return Err(AppError::internal(FAILED));
};
let hash = hash_password(password).await.or_500(FAILED)?;
patch = patch.set("password", hash);
}
if let Some(role) = body.str_field("role").and_then(UserRole::parse) {
patch = patch.set("role", role);
}
if patch.is_empty() {
return Err(AppError::bad_request("Aucune modification fournie"));
}
let updated = state
.db
.update_user(&user_id, patch)
.await
.or_500(FAILED)?
.ok_or_else(|| AppError::internal(FAILED))?;
Ok(Json(user_json(&updated)))
}
async fn delete_user(
State(state): State<AppState>,
admin: AdminUser,
Path(user_id): Path<String>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Erreur lors de la suppression de l'utilisateur";
if user_id == admin.id {
return Err(AppError::bad_request(
"Vous ne pouvez pas supprimer votre propre compte",
));
}
if state.db.get_user(&user_id).await.or_500(FAILED)?.is_none() {
return Err(AppError::not_found("Utilisateur non trouvé"));
}
state.db.delete_user(&user_id).await.or_500(FAILED)?;
Ok(Json(
json!({ "success": true, "message": "Utilisateur supprimé avec succès" }),
))
}
async fn get_permissions(
State(state): State<AppState>,
_admin: AdminUser,
Path(user_id): Path<String>,
) -> ApiResult<Json<Value>> {
let permissions = state
.db
.get_user_page_permissions(&user_id)
.await
.or_500("Erreur lors de la récupération des permissions")?;
Ok(Json(json!(permissions)))
}
async fn set_permissions(
State(state): State<AppState>,
_admin: AdminUser,
Path(user_id): Path<String>,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Erreur lors de la mise à jour des permissions";
let Some(page_ids) = body.get("pageIds").and_then(Value::as_array) else {
return Err(AppError::bad_request("pageIds doit être un tableau"));
};
// Identifiants non textuels : l'insertion échouait côté base
let page_ids: Vec<&str> = page_ids
.iter()
.map(Value::as_str)
.collect::<Option<_>>()
.ok_or_else(|| AppError::internal(FAILED))?;
state
.db
.delete_all_user_page_permissions(&user_id)
.await
.or_500(FAILED)?;
let mut permissions = Vec::with_capacity(page_ids.len());
for page_id in page_ids {
permissions.push(
state
.db
.create_page_permission(&user_id, page_id)
.await
.or_500(FAILED)?,
);
}
Ok(Json(json!(permissions)))
}
/// Donne à chaque utilisateur l'accès aux pages qu'il a lui-même connectées.
async fn migrate_permissions(
State(state): State<AppState>,
_admin: AdminUser,
) -> ApiResult<Json<Value>> {
const FAILED: &str = "Erreur lors de la migration des permissions";
let mut migrated = 0;
for user in state.db.get_all_users().await.or_500(FAILED)? {
for page in state.db.get_social_pages(&user.id).await.or_500(FAILED)? {
let existing = state
.db
.get_user_page_permissions(&user.id)
.await
.or_500(FAILED)?;
if !existing.iter().any(|p| p.page_id == page.id) {
state
.db
.create_page_permission(&user.id, &page.id)
.await
.or_500(FAILED)?;
migrated += 1;
}
}
}
Ok(Json(json!({
"success": true,
"message": format!("{migrated} permissions migrées avec succès"),
"migratedCount": migrated,
})))
}
+304
View File
@@ -0,0 +1,304 @@
//! Validation des corps de requête à la manière des schémas zod
//! (`createInsertSchema` de drizzle-zod) : mêmes champs requis, mêmes types et
//! mêmes messages d'erreur (« Required », « Expected string, received number »…).
use serde_json::{json, Value};
use crate::time::Ts;
/// Champ absent (`undefined`), `null` ou présent.
#[derive(Debug, Clone, PartialEq)]
pub enum Field<T> {
Absent,
Null,
Set(T),
}
impl<T> Field<T> {
pub fn into_option(self) -> Option<T> {
match self {
Field::Set(v) => Some(v),
_ => None,
}
}
pub fn is_absent(&self) -> bool {
matches!(self, Field::Absent)
}
}
/// Le champ peut-il manquer, être `null` ?
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Presence {
Required,
Optional,
Nullable,
}
/// Liste des problèmes relevés, dans l'ordre du schéma.
#[derive(Debug, Default)]
pub struct Issues(Vec<Value>);
/// Type reçu, au sens de zod (`getParsedType`).
fn received(value: Option<&Value>) -> &'static str {
match value {
None => "undefined",
Some(Value::Null) => "null",
Some(Value::String(_)) => "string",
Some(Value::Number(_)) => "number",
Some(Value::Bool(_)) => "boolean",
Some(Value::Array(_)) => "array",
Some(Value::Object(_)) => "object",
}
}
impl Issues {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Message du premier problème (`error.errors[0].message`).
pub fn first_message(&self) -> Option<String> {
self.0
.first()
.and_then(|i| i.get("message"))
.and_then(Value::as_str)
.map(str::to_string)
}
/// `ZodError.message` : la liste des problèmes en JSON indenté.
pub fn zod_message(&self) -> String {
serde_json::to_string_pretty(&self.0).unwrap_or_default()
}
pub fn push_invalid_type(&mut self, key: &str, expected: &str, value: Option<&Value>) {
let received = received(value);
let message = if value.is_none() {
"Required".to_string()
} else {
format!("Expected {expected}, received {received}")
};
self.0.push(json!({
"code": "invalid_type",
"expected": expected,
"received": received,
"path": [key],
"message": message,
}));
}
/// Gère absence et `null` ; `Some(valeur)` s'il faut encore la vérifier.
fn presence<'a>(
&mut self,
body: &'a Value,
key: &str,
expected: &str,
presence: Presence,
) -> Result<&'a Value, Option<Field<()>>> {
match body.get(key) {
None if presence == Presence::Required => {
self.push_invalid_type(key, expected, None);
Err(None)
}
None => Err(Some(Field::Absent)),
Some(Value::Null) if presence == Presence::Nullable => Err(Some(Field::Null)),
Some(value) => Ok(value),
}
}
fn convert<T>(outcome: Option<Field<()>>) -> Field<T> {
match outcome {
Some(Field::Null) => Field::Null,
_ => Field::Absent,
}
}
pub fn string(&mut self, body: &Value, key: &str, presence: Presence) -> Field<String> {
match self.presence(body, key, "string", presence) {
Err(outcome) => Self::convert(outcome),
Ok(Value::String(s)) => Field::Set(s.clone()),
Ok(other) => {
self.push_invalid_type(key, "string", Some(other));
Field::Absent
}
}
}
/// Chaîne d'au moins `min` caractères (longueur JavaScript).
pub fn string_min(
&mut self,
body: &Value,
key: &str,
presence: Presence,
min: usize,
message: &str,
) -> Field<String> {
let field = self.string(body, key, presence);
if let Field::Set(s) = &field {
if s.encode_utf16().count() < min {
self.0.push(json!({
"code": "too_small",
"minimum": min,
"type": "string",
"inclusive": true,
"exact": false,
"message": message,
"path": [key],
}));
}
}
field
}
pub fn enumeration(
&mut self,
body: &Value,
key: &str,
presence: Presence,
options: &[&str],
) -> Field<String> {
let expected = options
.iter()
.map(|o| format!("'{o}'"))
.collect::<Vec<_>>()
.join(" | ");
match self.presence(body, key, &expected, presence) {
Err(outcome) => Self::convert(outcome),
Ok(Value::String(s)) if options.contains(&s.as_str()) => Field::Set(s.clone()),
Ok(Value::String(s)) => {
self.0.push(json!({
"received": s,
"code": "invalid_enum_value",
"options": options,
"path": [key],
"message": format!("Invalid enum value. Expected {expected}, received '{s}'"),
}));
Field::Absent
}
Ok(other) => {
self.push_invalid_type(key, &expected, Some(other));
Field::Absent
}
}
}
/// Entier 32 bits (`integer` PostgreSQL).
pub fn int(&mut self, body: &Value, key: &str, presence: Presence) -> Field<i32> {
match self.presence(body, key, "number", presence) {
Err(outcome) => Self::convert(outcome),
Ok(Value::Number(n)) => {
let f = n.as_f64().unwrap_or(f64::NAN);
if f.fract() != 0.0 {
self.0.push(json!({
"code": "invalid_type",
"expected": "integer",
"received": "float",
"path": [key],
"message": "Expected integer, received float",
}));
return Field::Absent;
}
if f < i32::MIN as f64 {
self.0.push(json!({
"code": "too_small", "minimum": i32::MIN, "type": "number", "inclusive": true, "exact": false,
"path": [key], "message": format!("Number must be greater than or equal to {}", i32::MIN),
}));
return Field::Absent;
}
if f > i32::MAX as f64 {
self.0.push(json!({
"code": "too_big", "maximum": i32::MAX, "type": "number", "inclusive": true, "exact": false,
"path": [key], "message": format!("Number must be less than or equal to {}", i32::MAX),
}));
return Field::Absent;
}
Field::Set(f as i32)
}
Ok(other) => {
self.push_invalid_type(key, "number", Some(other));
Field::Absent
}
}
}
/// Champ `z.date()` : un corps JSON ne contient jamais d'objet Date, donc
/// toute valeur présente est refusée (seuls absence et `null` passent).
pub fn date(&mut self, body: &Value, key: &str, presence: Presence) -> Field<Ts> {
match self.presence(body, key, "date", presence) {
Err(outcome) => Self::convert(outcome),
Ok(other) => {
self.push_invalid_type(key, "date", Some(other));
Field::Absent
}
}
}
/// Date déjà convertie par la route (`new Date(chaîne)`) : invalide si illisible.
pub fn converted_date(&mut self, key: &str, value: Option<Ts>) -> Option<Ts> {
if value.is_none() {
self.0
.push(json!({ "code": "invalid_date", "path": [key], "message": "Invalid date" }));
}
value
}
/// Champ JSON libre (`jsonb`) : toute valeur JSON est acceptée.
pub fn json(&mut self, body: &Value, key: &str) -> Field<Value> {
match body.get(key) {
None => Field::Absent,
Some(Value::Null) => Field::Null,
Some(value) => Field::Set(value.clone()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn required_and_type_messages() {
let body = json!({ "password": 12 });
let mut issues = Issues::new();
issues.string(&body, "username", Presence::Required);
issues.string(&body, "password", Presence::Required);
assert_eq!(issues.first_message().as_deref(), Some("Required"));
assert!(issues
.zod_message()
.contains("\"message\": \"Expected string, received number\""));
}
#[test]
fn min_length_and_enum() {
let body = json!({ "username": "ab", "role": "boss" });
let mut issues = Issues::new();
issues.string_min(&body, "username", Presence::Required, 3, "trop court");
issues.enumeration(&body, "role", Presence::Optional, &["admin", "user"]);
assert_eq!(issues.first_message().as_deref(), Some("trop court"));
assert!(issues
.zod_message()
.contains("Invalid enum value. Expected 'admin' | 'user', received 'boss'"));
}
#[test]
fn nullable_and_dates() {
let body = json!({ "a": null, "d": "2024-01-01", "n": 1.5 });
let mut issues = Issues::new();
assert_eq!(issues.string(&body, "a", Presence::Nullable), Field::Null);
assert_eq!(issues.string(&body, "b", Presence::Nullable), Field::Absent);
assert!(issues.is_empty());
issues.date(&body, "d", Presence::Optional);
issues.int(&body, "n", Presence::Optional);
assert_eq!(
issues.first_message().as_deref(),
Some("Expected date, received string")
);
assert!(issues
.zod_message()
.contains("Expected integer, received float"));
}
}
File diff suppressed because it is too large. Load diff
+544
View File
@@ -0,0 +1,544 @@
//! Connexion et renouvellement des pages Facebook / Instagram (ex-`routes/facebook.ts`).
//!
//! Une page connectée ici embarque le jeton utilisateur longue durée qui a servi
//! à l'obtenir : c'est ce qui permet au gestionnaire de jetons de régénérer son
//! jeton de page tout seul, au lieu d'exiger un copier-coller depuis le Graph
//! Explorer tous les deux mois.
//!
//! Routeur monté sous `/api/facebook` ; toutes les routes exigent une session.
use axum::extract::{Path, State};
use axum::http::header::LOCATION;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use rand::RngCore;
use serde_json::{json, Value};
use crate::auth::AuthUser;
use crate::config::Config;
use crate::crypto::encrypt;
use crate::db::{NewSocialPage, Patch, Platform, TokenStatus};
use crate::error::{ApiResult, AppError, OrApiError};
use crate::extract::{Body, QueryMap};
use crate::services::facebook::oauth;
use crate::services::token_manager;
use crate::state::AppState;
use crate::time::Ts;
/// 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 SESSION_STATE_KEY: &str = "facebookOAuthState";
const ADMIN_ONLY: &str = "Accès refusé. Réservé aux administrateurs.";
pub fn router() -> Router<AppState> {
Router::new()
.route("/config", get(get_config).put(put_config))
.route("/connect", get(connect))
.route("/callback", get(callback))
.route("/pages/{id}/refresh", post(refresh_page))
.route("/tokens/check", post(check_tokens))
}
pub fn build_return_url(success: bool, message: &str) -> String {
let query = url::form_urlencoded::Serializer::new(String::new())
.append_pair("facebook", 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 {
match location.parse::<axum::http::HeaderValue>() {
Ok(value) => (StatusCode::FOUND, [(LOCATION, value)]).into_response(),
Err(_) => (StatusCode::FOUND, [(LOCATION, RETURN_PATH)]).into_response(),
}
}
fn redirect_error(message: &str) -> Response {
redirect(&build_return_url(false, message))
}
/// État de la configuration Facebook. L'App Secret n'est jamais renvoyé.
async fn get_config(
State(state): State<AppState>,
user: AuthUser,
headers: HeaderMap,
) -> ApiResult<Json<Value>> {
let config = state
.db
.get_facebook_config()
.await
.or_500("Impossible de lire la configuration Facebook")?;
let configured = oauth::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 app_id = config
.as_ref()
.map(|c| c.app_id.clone())
.filter(|id| !id.is_empty())
.unwrap_or_else(|| {
if Config::var("FACEBOOK_APP_ID").is_some() {
"(défini par variable d'environnement)".to_string()
} else {
String::new()
}
});
let has_app_secret = config.as_ref().is_some_and(|c| !c.app_secret.is_empty())
|| Config::var("FACEBOOK_APP_SECRET").is_some();
Ok(Json(json!({
"configured": configured,
"appId": app_id,
"hasAppSecret": has_app_secret,
"redirectUri": oauth::redirect_uri(&state, Some(&headers)),
"scopes": oauth::scopes().split(',').collect::<Vec<_>>(),
})))
}
fn js_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",
}
}
/// Champ texte validé comme `z.string().trim().min(1, message)` ;
/// `Ok(None)` = absent et facultatif.
fn required_string(
body: &Value,
key: &str,
empty_message: &str,
optional: bool,
) -> Result<Option<String>, String> {
match body.get(key) {
None if optional => Ok(None),
None => Err("Required".to_string()),
Some(Value::String(s)) => {
let trimmed = s.trim();
if trimmed.is_empty() {
Err(empty_message.to_string())
} else {
Ok(Some(trimmed.to_string()))
}
}
Some(other) => Err(format!("Expected string, received {}", js_type(other))),
}
}
/// Valide le corps de `PUT /config` ; renvoie (appId, appSecret facultatif).
pub fn validate_config_body(
body: &Value,
secret_optional: bool,
) -> Result<(String, Option<String>), String> {
if !body.is_object() {
return Err(format!("Expected object, received {}", js_type(body)));
}
let app_id = required_string(body, "appId", "L'App ID ne peut pas être vide", false);
let app_secret = required_string(
body,
"appSecret",
"L'App Secret ne peut pas être vide",
secret_optional,
);
// Première erreur dans l'ordre du schéma
let app_id = app_id?.unwrap_or_default();
let app_secret = app_secret?;
Ok((app_id, app_secret))
}
/// Enregistre les identifiants de l'application Facebook (admin).
async fn put_config(
State(state): State<AppState>,
user: AuthUser,
headers: HeaderMap,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
if !user.is_admin() {
return Err(AppError::forbidden(ADMIN_ONLY));
}
let bad_request = |message: String| {
tracing::error!("❌ [Facebook] Erreur enregistrement configuration: {message}");
AppError::bad_request(if message.is_empty() {
"Configuration Facebook invalide".to_string()
} else {
message
})
};
let existing = state
.db
.get_facebook_config()
.await
.map_err(|e| bad_request(e.to_string()))?;
// Le secret peut être omis lors d'une mise à jour : on garde l'existant
let (app_id, app_secret) =
validate_config_body(&body, existing.is_some()).map_err(bad_request)?;
let config = state
.db
.upsert_facebook_config(Some(&app_id), app_secret.as_deref())
.await
.map_err(|e| bad_request(e.to_string()))?;
Ok(Json(json!({
"success": true,
"appId": config.app_id,
"redirectUri": oauth::redirect_uri(&state, Some(&headers)),
})))
}
/// Démarre la connexion des pages Facebook. À appeler par navigation
/// complète du navigateur (window.location), pas en fetch.
async fn connect(State(state): State<AppState>, user: AuthUser, headers: HeaderMap) -> Response {
if !oauth::is_configured(&state).await {
return redirect_error(
"Facebook n'est pas configuré. Renseignez d'abord l'App ID et l'App 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 auth_url = match oauth::build_authorization_url(&state, &oauth_state, Some(&headers)).await
{
Ok(url) => url,
Err(error) => {
tracing::error!("❌ [Facebook] Erreur démarrage OAuth: {error:#}");
return redirect_error(&error.to_string());
}
};
// La session doit être écrite avant la redirection, sinon l'état est perdu
let mut session = user.session;
session.set(SESSION_STATE_KEY, Value::String(oauth_state));
if let Err(error) = session.save(&state).await {
tracing::error!("❌ [Facebook] Impossible de sauvegarder la session OAuth: {error}");
return redirect_error("Erreur de session, réessayez.");
}
redirect(&auth_url)
}
/// Résumé affiché après la connexion (« 2 pages connectées, 1 page mise à jour »).
pub fn connection_summary(created: usize, updated: usize) -> String {
let plural = |n: usize| if n > 1 { "s" } else { "" };
let mut parts = Vec::new();
if created > 0 {
parts.push(format!(
"{created} page{} connectée{}",
plural(created),
plural(created)
));
}
if updated > 0 {
parts.push(format!(
"{updated} page{} mise{} à jour",
plural(updated),
plural(updated)
));
}
parts.join(", ")
}
/// Retour d'autorisation Facebook : échange le code contre un jeton utilisateur
/// longue durée, puis crée ou met à jour chaque page administrée.
async fn callback(
State(state): State<AppState>,
user: AuthUser,
headers: HeaderMap,
query: QueryMap,
) -> Response {
if let Some(oauth_error) = query.non_empty("error") {
let description = query.non_empty("error_description").unwrap_or(oauth_error);
return redirect_error(&format!("Autorisation Facebook refusée : {description}"));
}
let AuthUser { user, mut session } = user;
let expected_state = session.get_str(SESSION_STATE_KEY);
if session.remove(SESSION_STATE_KEY).is_some() {
if let Err(error) = session.save(&state).await {
tracing::warn!(
"⚠️ [Facebook] Impossible d'effacer l'état OAuth de la session: {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 redirect_error("Requête de connexion Facebook invalide (état non reconnu).");
}
let Some(code) = query.non_empty("code") else {
return redirect_error("Aucun code d'autorisation reçu de Facebook.");
};
match connect_pages(&state, &user, code, &headers).await {
Ok(Some(summary)) => {
tracing::info!("✅ [Facebook] {summary} pour {}", user.username);
redirect(&build_return_url(
true,
&format!("{summary}. Les jetons se renouvellent désormais automatiquement."),
))
}
Ok(None) => redirect_error(
"Aucune page Facebook administrée par ce compte. Connectez-vous avec un compte administrateur de la page.",
),
Err(error) => {
tracing::error!("❌ [Facebook] Erreur callback OAuth: {error:#}");
redirect_error(&error.to_string())
}
}
}
/// Crée ou met à jour les pages issues de l'autorisation ; `None` quand le
/// compte n'administre aucune page.
async fn connect_pages(
state: &AppState,
user: &crate::db::User,
code: &str,
headers: &HeaderMap,
) -> anyhow::Result<Option<String>> {
let user_token = oauth::exchange_code_for_user_token(state, code, Some(headers)).await?;
let targets = oauth::list_pages(state, &user_token.access_token).await?;
if targets.is_empty() {
return Ok(None);
}
// Le jeton utilisateur est la clé du renouvellement automatique : il est
// conservé chiffré, sur chaque page issue de cette autorisation.
let encrypted_user_token = encrypt(&user_token.access_token);
let existing_pages = state.db.get_social_pages(&user.id).await?;
let mut created = 0;
let mut updated = 0;
for target in &targets {
let existing = existing_pages
.iter()
.find(|p| p.platform == Platform::Facebook && p.page_id == target.id);
if let Some(existing) = existing {
state
.db
.update_social_page(
&existing.id,
Patch::new()
.set("page_name", target.name.clone())
.set("access_token", target.access_token.clone())
// Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire pas.
.set("token_expires_at", None::<Ts>)
.set("user_access_token", encrypted_user_token.clone())
.set("user_token_expires_at", user_token.expires_at)
.set("followers_count", target.followers_count)
.set("token_status", TokenStatus::Valid)
.set_null("token_error")
.set("last_token_check", Ts::now())
.set("is_active", "true"),
)
.await?;
updated += 1;
continue;
}
let page = state
.db
.create_social_page(NewSocialPage {
user_id: user.id.clone(),
platform: Some(Platform::Facebook),
page_id: target.id.clone(),
page_name: target.name.clone(),
access_token: target.access_token.clone(),
token_expires_at: None,
user_access_token: Some(encrypted_user_token.clone()),
user_token_expires_at: user_token.expires_at,
followers_count: Some(target.followers_count),
is_active: Some("true".to_string()),
..Default::default()
})
.await?;
created += 1;
// Les non-admins ne voient que les pages qui leur sont explicitement
// attribuées : sans cette permission, ils ne verraient pas leur propre page.
if !user.is_admin() {
if let Err(error) = state.db.create_page_permission(&user.id, &page.id).await {
tracing::error!("⚠️ [Facebook] Impossible de créer la permission de page: {error}");
}
}
}
// Un compte Instagram professionnel publie avec le jeton de la page Facebook
// à laquelle il est rattaché : on le rafraîchit dans la foulée, sans quoi il
// resterait sur un jeton saisi à la main, condamné à expirer.
for page in existing_pages
.iter()
.filter(|p| p.platform == Platform::Instagram)
{
let Some(linked) = targets
.iter()
.find(|t| t.instagram.as_ref().is_some_and(|ig| ig.id == page.page_id))
else {
continue;
};
state
.db
.update_social_page(
&page.id,
Patch::new()
.set("access_token", linked.access_token.clone())
.set("token_expires_at", None::<Ts>)
.set("user_access_token", encrypted_user_token.clone())
.set("user_token_expires_at", user_token.expires_at)
.set("token_status", TokenStatus::Valid)
.set_null("token_error")
.set("last_token_check", Ts::now()),
)
.await?;
updated += 1;
}
Ok(Some(connection_summary(created, updated)))
}
/// Contrôle (et renouvelle si besoin) le jeton d'une page à la demande.
async fn refresh_page(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<String>,
) -> ApiResult<Json<Value>> {
let bad_gateway = |message: String| {
tracing::error!("❌ [Facebook] Erreur renouvellement du jeton: {message}");
AppError::new(
StatusCode::BAD_GATEWAY,
if message.is_empty() {
"Impossible de contrôler le jeton".to_string()
} else {
message
},
)
};
let page = state
.db
.get_social_page(&id)
.await
.map_err(|e| bad_gateway(e.to_string()))?
.ok_or_else(|| AppError::not_found("Page non trouvée"))?;
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.to_string()))?;
if !accessible.iter().any(|p| p.id == page.id) {
return Err(AppError::forbidden("Accès refusé à cette page"));
}
}
let result = token_manager::check_page(&state, &page.id)
.await
.map_err(|e| bad_gateway(e.to_string()))?;
Ok(Json(serde_json::to_value(result).or_500_cause()?))
}
/// Relance le contrôle de toutes les pages (admin).
async fn check_tokens(State(state): State<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
if !user.is_admin() {
return Err(AppError::forbidden(ADMIN_ONLY));
}
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();
Ok(Json(json!({
"checked": results.len(),
"renewed": renewed,
"failing": failing,
"results": results,
})))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn return_url_is_encoded_like_url_search_params() {
assert_eq!(
build_return_url(false, "Erreur de session, réessayez."),
"/pages?facebook=error&message=Erreur+de+session%2C+r%C3%A9essayez."
);
assert!(build_return_url(true, "ok").starts_with("/pages?facebook=success&message=ok"));
}
#[test]
fn summary_wording() {
assert_eq!(connection_summary(1, 0), "1 page connectée");
assert_eq!(connection_summary(2, 0), "2 pages connectées");
assert_eq!(connection_summary(0, 1), "1 page mise à jour");
assert_eq!(
connection_summary(3, 2),
"3 pages connectées, 2 pages mises à jour"
);
assert_eq!(connection_summary(0, 0), "");
}
#[test]
fn config_validation_matches_zod_messages() {
let ok =
validate_config_body(&json!({ "appId": " 123 ", "appSecret": " s " }), false).unwrap();
assert_eq!(ok, ("123".to_string(), Some("s".to_string())));
assert_eq!(
validate_config_body(&json!({}), false).unwrap_err(),
"Required"
);
assert_eq!(
validate_config_body(&json!({ "appId": " " }), true).unwrap_err(),
"L'App ID ne peut pas être vide"
);
assert_eq!(
validate_config_body(&json!({ "appId": "1" }), false).unwrap_err(),
"Required"
);
assert_eq!(
validate_config_body(&json!({ "appId": "1", "appSecret": "" }), true).unwrap_err(),
"L'App Secret ne peut pas être vide"
);
assert_eq!(
validate_config_body(&json!({ "appId": 12 }), true).unwrap_err(),
"Expected string, received number"
);
// Secret facultatif lors d'une mise à jour
assert_eq!(
validate_config_body(&json!({ "appId": "1" }), true).unwrap(),
("1".to_string(), None)
);
}
}
@@ -1,50 +1,142 @@
/**
* 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
*/
//! 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
import { Router, Request, Response } from 'express';
use std::sync::LazyLock;
export const legalRouter = Router();
use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use regex::{Captures, Regex};
function getCompanyInfo() {
return {
name: process.env.LEGAL_COMPANY_NAME || 'FROUARD DISTRIBUTION',
tradeName: process.env.LEGAL_TRADE_NAME || "LA FOIR'FOUILLE",
legalForm: process.env.LEGAL_COMPANY_LEGAL_FORM
|| 'Société par actions simplifiée au capital de 50 000 €',
rcs: process.env.LEGAL_COMPANY_RCS || '478 693 518 R.C.S. Nancy',
address: process.env.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: process.env.LEGAL_CONTACT_EMAIL || '[Adresse e-mail de contact à renseigner]',
};
use crate::config::Config;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/terms", get(terms))
.route("/privacy", get(privacy))
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
struct CompanyInfo {
name: String,
trade_name: String,
legal_form: String,
rcs: String,
address: String,
email: String,
}
function renderPage(title: string, body: string): string {
return `<!doctype html>
fn company_info() -> CompanyInfo {
let var = |name: &str, default: &str| Config::var(name).unwrap_or_else(|| default.to_string());
CompanyInfo {
name: var("LEGAL_COMPANY_NAME", "FROUARD DISTRIBUTION"),
trade_name: var("LEGAL_TRADE_NAME", "LA FOIR'FOUILLE"),
legal_form: var(
"LEGAL_COMPANY_LEGAL_FORM",
"Société par actions simplifiée au capital de 50 000 €",
),
rcs: var("LEGAL_COMPANY_RCS", "478 693 518 R.C.S. Nancy"),
address: var(
"LEGAL_COMPANY_ADDRESS",
"13 rue du Saule Gaillard, 54390 Frouard",
),
// Aucune adresse de contact ne figure au registre : elle doit être fournie,
// le RGPD imposant un point de contact pour l'exercice des droits.
email: var(
"LEGAL_CONTACT_EMAIL",
"[Adresse e-mail de contact à renseigner]",
),
}
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
static PLACEHOLDER: LazyLock<Regex> =
LazyLock::new(|| Regex::new("%([A-Z_]+)%").expect("expression valide"));
/// Remplit les marqueurs `%NOM%` d'un gabarit en une seule passe (les valeurs
/// insérées ne sont jamais réinterprétées).
fn fill(template: &str, values: &[(&str, &str)]) -> String {
PLACEHOLDER
.replace_all(template, |caps: &Captures| {
let key = &caps[1];
values
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| v.to_string())
.unwrap_or_else(|| caps[0].to_string())
})
.into_owned()
}
/// Mentions de l'exploitant, échappées, pour les gabarits.
fn company_values(company: &CompanyInfo) -> Vec<(&'static str, String)> {
vec![
("NAME", escape_html(&company.name)),
("TRADE_NAME", escape_html(&company.trade_name)),
("LEGAL_FORM", escape_html(&company.legal_form)),
("ADDRESS", escape_html(&company.address)),
("RCS", escape_html(&company.rcs)),
("EMAIL", escape_html(&company.email)),
]
}
fn render(title: &str, body_template: &str) -> String {
let company = company_info();
let mut values = company_values(&company);
let footer = {
let refs: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect();
fill(FOOTER_TEMPLATE, &refs)
};
values.push(("FOOTER", footer));
let body = {
let refs: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect();
fill(body_template, &refs)
};
fill(
PAGE_TEMPLATE,
&[("TITLE", &escape_html(title)), ("BODY", &body)],
)
}
fn html(content: String) -> Response {
([(CONTENT_TYPE, "text/html; charset=utf-8")], content).into_response()
}
async fn terms() -> Response {
html(render("Conditions d'utilisation", TERMS_TEMPLATE))
}
async fn privacy() -> Response {
html(render("Politique de confidentialité", PRIVACY_TEMPLATE))
}
// ----------------------------------------------------------------------------
// Gabarits (repris à l'identique de l'ancien serveur)
// ----------------------------------------------------------------------------
const PAGE_TEMPLATE: &str = r###"<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<title>%TITLE%</title>
<style>
:root { color-scheme: light dark; }
body {
@@ -72,37 +164,30 @@ function renderPage(title: string, body: string): string {
</style>
</head>
<body>
${body}
%BODY%
</body>
</html>`;
}
</html>"###;
function renderFooter(): string {
const company = getCompanyInfo();
return `<footer>
const FOOTER_TEMPLATE: &str = r###"<footer>
<p>
${escapeHtml(company.name)} &ndash; enseigne ${escapeHtml(company.tradeName)}<br>
${escapeHtml(company.legalForm)}<br>
${escapeHtml(company.address)}<br>
${escapeHtml(company.rcs)}<br>
Contact : ${escapeHtml(company.email)}
%NAME% &ndash; enseigne %TRADE_NAME%<br>
%LEGAL_FORM%<br>
%ADDRESS%<br>
%RCS%<br>
Contact : %EMAIL%
</p>
<p><a href="/terms">Conditions d'utilisation</a> &middot; <a href="/privacy">Politique de confidentialité</a></p>
</footer>`;
}
</footer>"###;
legalRouter.get('/terms', (_req: Request, res: Response) => {
const company = getCompanyInfo();
const body = `
const TERMS_TEMPLATE: &str = r###"
<h1>Conditions d'utilisation</h1>
<p class="updated">Dernière mise à jour : 11 août 2026</p>
<p>
Cette application est un outil interne de gestion des réseaux sociaux exploité par
${escapeHtml(company.name)}, ${escapeHtml(company.legalForm)}, dont le siège est situé
${escapeHtml(company.address)}, immatriculée au ${escapeHtml(company.rcs)}, qui exploite le
magasin sous l'enseigne ${escapeHtml(company.tradeName)} (« nous »). Elle permet à nos équipes
%NAME%, %LEGAL_FORM%, dont le siège est situé
%ADDRESS%, immatriculée au %RCS%, qui exploite le
magasin sous l'enseigne %TRADE_NAME% (« nous »). Elle permet à nos équipes
de créer des vidéos et des publications promotionnelles, puis de les diffuser sur les comptes
et pages officiels de nos magasins.
</p>
@@ -153,26 +238,20 @@ legalRouter.get('/terms', (_req: Request, res: Response) => {
</p>
<h2>7. Contact</h2>
<p>Pour toute question relative à ces conditions : ${escapeHtml(company.email)}</p>
<p>Pour toute question relative à ces conditions : %EMAIL%</p>
${renderFooter()}`;
%FOOTER%"###;
res.type('html').send(renderPage("Conditions d'utilisation", body));
});
legalRouter.get('/privacy', (_req: Request, res: Response) => {
const company = getCompanyInfo();
const body = `
const PRIVACY_TEMPLATE: &str = r###"
<h1>Politique de confidentialité</h1>
<p class="updated">Dernière mise à jour : 11 août 2026</p>
<p>
Cette politique décrit les données traitées par notre outil interne de gestion des réseaux
sociaux. Le responsable de traitement est ${escapeHtml(company.name)},
${escapeHtml(company.legalForm)}, dont le siège est situé ${escapeHtml(company.address)},
immatriculée au ${escapeHtml(company.rcs)}, exploitant le magasin sous l'enseigne
${escapeHtml(company.tradeName)}.
sociaux. Le responsable de traitement est %NAME%,
%LEGAL_FORM%, dont le siège est situé %ADDRESS%,
immatriculée au %RCS%, exploitant le magasin sous l'enseigne
%TRADE_NAME%.
</p>
<h2>1. Données de nos utilisateurs internes</h2>
@@ -255,7 +334,7 @@ legalRouter.get('/privacy', (_req: Request, res: Response) => {
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 à ${escapeHtml(company.email)}.
droits, écrivez à %EMAIL%.
</p>
<h2>9. Modification de cette politique</h2>
@@ -263,7 +342,34 @@ legalRouter.get('/privacy', (_req: Request, res: Response) => {
Cette politique peut être mise à jour. La date de dernière mise à jour figure en tête de page.
</p>
${renderFooter()}`;
%FOOTER%"###;
res.type('html').send(renderPage('Politique de confidentialité', body));
});
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escaping_and_single_pass_fill() {
assert_eq!(
escape_html(r#"A & <b> "c""#),
"A &amp; &lt;b&gt; &quot;c&quot;"
);
assert_eq!(
fill("%A%-%B%-%C%", &[("A", "%B%"), ("B", "x")]),
"%B%-x-%C%"
);
}
#[test]
fn pages_are_complete() {
let page = render("Conditions d'utilisation", TERMS_TEMPLATE);
assert!(page.starts_with("<!doctype html>\n<html lang=\"fr\">"));
assert!(page.contains("<title>Conditions d'utilisation</title>"));
assert!(page.contains("enseigne LA FOIR'FOUILLE<br>"));
assert!(page.ends_with("</footer>\n</body>\n</html>"));
assert!(!page.contains('%'));
let page = render("Politique de confidentialité", PRIVACY_TEMPLATE);
assert!(page.contains("<h1>Politique de confidentialité</h1>"));
assert!(!page.contains("%NAME%"));
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Assemblage des routes HTTP.
mod analytics;
mod auth;
mod core;
mod external;
mod facebook;
mod legal;
mod reels;
mod tiktok;
use axum::extract::{DefaultBodyLimit, Request, State};
use axum::http::{HeaderMap, StatusCode};
use axum::middleware;
use axum::response::{IntoResponse, Response};
use axum::Router;
use serde_json::json;
use tower::ServiceExt;
use tower_http::services::{ServeDir, ServeFile};
use crate::extract::MAX_JSON_BODY;
use crate::http;
use crate::state::AppState;
pub fn router(state: AppState) -> Router {
let api = Router::new()
.route("/healthz", axum::routing::get(healthz))
.merge(auth::router())
.merge(core::router())
.nest("/api/analytics", analytics::router())
.nest("/api/facebook", facebook::router())
.nest("/api/tiktok", tiktok::router())
.nest("/api/v1", external::router())
.merge(reels::router())
.merge(legal::router());
let config = &state.config;
for dir in [
&config.uploads_dir,
&config.audio_upload_dir,
&config.temp_upload_dir,
] {
let _ = std::fs::create_dir_all(dir);
}
for sub in ["media", "logos", "stories"] {
let _ = std::fs::create_dir_all(config.uploads_dir.join(sub));
}
// Fichiers téléversés (médias, logos, stories, musique, fichiers temporaires des rendus)
let uploads = Router::new()
.nest_service("/uploads/audio", ServeDir::new(&config.audio_upload_dir))
.nest_service("/uploads/temp", ServeDir::new(&config.temp_upload_dir))
.nest_service("/uploads", ServeDir::new(&config.uploads_dir));
api.merge(uploads)
.fallback(fallback)
.layer(DefaultBodyLimit::max(MAX_JSON_BODY))
.layer(middleware::from_fn_with_state(
state.clone(),
http::rate_limit,
))
.layer(middleware::from_fn(http::log_requests))
.layer(middleware::from_fn(http::security_headers))
.with_state(state)
}
/// Sonde de santé (Docker) : le serveur répond et la base est joignable.
async fn healthz(State(state): State<AppState>) -> Response {
match sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(&state.db.pool)
.await
{
Ok(_) => (StatusCode::OK, "ok").into_response(),
Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "database unavailable").into_response(),
}
}
/// Route inconnue : JSON pour `/api`, sinon fichiers publics puis l'application React.
async fn fallback(State(state): State<AppState>, headers: HeaderMap, request: Request) -> Response {
let path = request.uri().path().to_string();
if path == "/api" || path.starts_with("/api/") {
return if crate::auth::current_user(&state, &headers).await.is_none() {
(
StatusCode::UNAUTHORIZED,
axum::Json(json!({ "error": "Non authentifié" })),
)
.into_response()
} else {
(
StatusCode::NOT_FOUND,
axum::Json(json!({ "error": "Route introuvable" })),
)
.into_response()
};
}
// Vérifications de domaine (TikTok, Google, Meta…), sans les fichiers cachés
let is_hidden = path.split('/').any(|part| part.starts_with('.'));
if !is_hidden && path != "/" {
let candidate = state
.config
.public_root_dir
.join(path.trim_start_matches('/'));
if candidate.is_file() {
return serve(ServeFile::new(candidate), request).await;
}
}
let index = state.config.static_dir.join("index.html");
let spa = ServeDir::new(&state.config.static_dir)
.append_index_html_on_directories(true)
.fallback(ServeFile::new(index));
serve(spa, request).await
}
async fn serve<S>(service: S, request: Request) -> Response
where
S: tower::Service<Request, Error = std::convert::Infallible> + Send,
S::Response: IntoResponse,
S::Future: Send,
{
match service.oneshot(request).await {
Ok(response) => response.into_response(),
Err(never) => match never {},
}
}
+471
View File
@@ -0,0 +1,471 @@
//! Routes des Reels et de la musique (portage de `server/routes/reels.ts`).
//! Toutes exigent une session.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use base64::Engine;
use serde_json::{json, Value};
use crate::auth::AuthUser;
use crate::db::{NewMusicFavorite, NewPost, PostStatus};
use crate::error::{ApiResult, AppError, OrApiError};
use crate::extract::{is_truthy, Body, QueryMap};
use crate::services::ffmpeg::{self, VoicePreviewOptions};
use crate::services::openrouter;
use crate::services::reels::assets::{
qwen_target_from_config, resolve_gemini_api_key, resolve_logo_path, resolve_qwen_target,
resolve_store_name,
};
use crate::services::reels::params::{parse_tts_preview, parse_video_reel_params, srt_text};
use crate::services::reels::pipeline::effective_renderer;
use crate::services::reels::queue::{count_active_reel_jobs, enqueue_reel_job};
use crate::services::reels::tts_sync::estimate_voice_timing;
use crate::state::AppState;
use crate::time::Ts;
pub fn router() -> Router<AppState> {
Router::new()
// Musique (Jamendo désactivé : listes vides)
.route("/api/music/search", get(music_search))
.route("/api/music/more", get(music_more))
.route("/api/music/popular", get(music_popular))
.route(
"/api/music/favorites",
get(list_favorites).post(add_favorite),
)
.route("/api/music/favorites/check/{track_id}", get(check_favorite))
.route("/api/music/favorites/{track_id}", delete(remove_favorite))
.route("/api/music/{track_id}", get(music_details))
// Reels
.route("/api/reels/generate-text", post(generate_text))
.route("/api/reels/voices", get(voices))
.route("/api/reels/tts-preview", post(tts_preview))
.route("/api/reels/sync-info", post(sync_info))
.route("/api/reels", post(create_reel))
.route("/api/reels/config", get(reels_config))
.route("/api/reels/ongoing", get(ongoing))
.route("/api/reels/{id}", delete(delete_reel))
}
// ---------------------------------------------------------------------------
// Musique
// ---------------------------------------------------------------------------
/// `parseInt(value)` : chiffres en tête (signe et espaces tolérés), sinon NaN (null en JSON).
fn js_parse_int(value: &str) -> Value {
let trimmed = value.trim_start();
let (sign, digits) = match trimmed.strip_prefix('-') {
Some(rest) => (-1, rest),
None => (1, trimmed.strip_prefix('+').unwrap_or(trimmed)),
};
let numeric: String = digits.chars().take_while(|c| c.is_ascii_digit()).collect();
match numeric.parse::<i64>() {
Ok(n) => json!(sign * n),
Err(_) => Value::Null,
}
}
fn empty_music_page(query: &QueryMap) -> Json<Value> {
Json(json!({
"tracks": [],
"pagination": {
"offset": js_parse_int(query.get("offset").unwrap_or("0")),
"limit": js_parse_int(query.get("limit").unwrap_or("10")),
"hasMore": false,
},
}))
}
async fn music_search(_user: AuthUser, query: QueryMap) -> Json<Value> {
empty_music_page(&query)
}
async fn music_more(_user: AuthUser, query: QueryMap) -> Json<Value> {
empty_music_page(&query)
}
async fn music_popular(_user: AuthUser) -> Json<Value> {
Json(json!({ "tracks": [] }))
}
async fn list_favorites(State(state): State<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
let favorites = state
.db
.get_music_favorites(&user.id)
.await
.or_500("Erreur lors de la récupération des favoris")?;
let tracks: Vec<Value> = favorites
.into_iter()
.map(|f| {
json!({
"id": f.track_id,
"title": f.title,
"artist": f.artist,
"albumName": f.album_name.unwrap_or_default(),
"duration": f.duration,
"previewUrl": f.preview_url,
"downloadUrl": f.download_url,
"imageUrl": f.image_url.unwrap_or_default(),
"license": f.license.unwrap_or_default(),
})
})
.collect();
Ok(Json(json!({ "tracks": tracks, "isFavorites": true })))
}
async fn check_favorite(
State(state): State<AppState>,
user: AuthUser,
Path(track_id): Path<String>,
) -> ApiResult<Json<Value>> {
let is_favorite = state
.db
.is_music_favorite(&user.id, &track_id)
.await
.or_500("Erreur lors de la vérification du favori")?;
Ok(Json(json!({ "isFavorite": is_favorite })))
}
/// Valeur texte d'un champ JSON « truthy » (les nombres sont convertis comme en JS).
fn text_field(body: &Value, key: &str) -> Option<String> {
let value = body.get(key);
if !is_truthy(value) {
return None;
}
match value? {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
other => Some(other.to_string()),
}
}
async fn add_favorite(
State(state): State<AppState>,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
const ERROR: &str = "Erreur lors de l'ajout du favori";
let (Some(track_id), Some(title)) = (text_field(&body, "trackId"), text_field(&body, "title"))
else {
return Err(AppError::bad_request("trackId et title requis"));
};
if state
.db
.is_music_favorite(&user.id, &track_id)
.await
.or_500(ERROR)?
{
return Ok(Json(
json!({ "success": true, "message": "Déjà en favori" }),
));
}
let duration = body
.get("duration")
.and_then(Value::as_f64)
.map(|d| d as i32)
.unwrap_or(0);
state
.db
.add_music_favorite(NewMusicFavorite {
user_id: user.id.clone(),
track_id,
title,
artist: text_field(&body, "artist").unwrap_or_default(),
album_name: text_field(&body, "albumName"),
duration,
preview_url: text_field(&body, "previewUrl").unwrap_or_default(),
download_url: text_field(&body, "downloadUrl").unwrap_or_default(),
image_url: text_field(&body, "imageUrl"),
license: text_field(&body, "license"),
})
.await
.or_500(ERROR)?;
Ok(Json(json!({ "success": true })))
}
async fn remove_favorite(
State(state): State<AppState>,
user: AuthUser,
Path(track_id): Path<String>,
) -> ApiResult<Json<Value>> {
state
.db
.remove_music_favorite(&user.id, &track_id)
.await
.or_500("Erreur lors de la suppression du favori")?;
Ok(Json(json!({ "success": true })))
}
async fn music_details(_user: AuthUser, Path(_track_id): Path<String>) -> AppError {
AppError::not_found("Musique non trouvée")
}
// ---------------------------------------------------------------------------
// Reels
// ---------------------------------------------------------------------------
/// Textes IA pour un Reel (même service que les posts).
async fn generate_text(
State(state): State<AppState>,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
let product_info = body.get("productInfo");
if !is_truthy(product_info) {
return Err(AppError::bad_request("Informations produit requises"));
}
// Une simple chaîne sert de nom et de description du produit
let product_info = match product_info {
Some(Value::String(text)) => {
let text = text.trim();
json!({ "name": text, "description": text })
}
Some(other) => other.clone(),
None => Value::Null,
};
let model = body.get("model").and_then(Value::as_str);
let fail = |error: anyhow::Error| {
tracing::error!("❌ Error generating Reel text: {error:#}");
let (status, message) = openrouter::describe_generation_error(&error);
AppError::new(status, message)
};
let texts = openrouter::generate_post_text(&state, &product_info, &user.id, model)
.await
.map_err(fail)?;
let generated = Value::Array(texts);
state
.db
.create_ai_generation(&user.id, &product_info, &generated)
.await
.map_err(|e| fail(e.into()))?;
Ok(Json(json!({ "variants": generated })))
}
/// Voix disponibles (dont les voix clonées Qwen) et disponibilité du moteur local.
async fn voices(State(state): State<AppState>, _user: AuthUser) -> Json<Value> {
let result = async {
let config = state.db.get_app_config().await?;
ffmpeg::list_voices(&state, qwen_target_from_config(config.as_ref())).await
}
.await;
match result {
Ok(catalog) => Json(json!({
"qwenAvailable": catalog.qwen_available,
"qwen": catalog.qwen,
"qwenStatus": catalog.qwen_status,
})),
Err(error) => {
tracing::error!("❌ Error listing voices: {error}");
Json(json!({ "qwenAvailable": false, "qwen": [], "qwenStatus": null }))
}
}
}
/// Aperçu de la voix.
async fn tts_preview(
State(state): State<AppState>,
_user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
let params = parse_tts_preview(&body).map_err(AppError::bad_request)?;
let engine = params.tts_engine.as_deref();
let options = async {
Ok::<_, anyhow::Error>(VoicePreviewOptions {
voice: params.tts_voice.clone(),
engine: params.tts_engine.clone(),
style: params.tts_style.clone(),
gemini_api_key: resolve_gemini_api_key(&state, engine).await?,
qwen: resolve_qwen_target(&state, engine).await?,
})
}
.await
.map_err(|error| {
tracing::error!("❌ Error generating TTS preview: {error}");
AppError::new(StatusCode::BAD_GATEWAY, error.to_string())
})?;
let preview = ffmpeg::preview_voice(&state, &params.text, &options)
.await
.map_err(|error| {
tracing::error!("❌ Error generating TTS preview: {error}");
let status = if error.status == Some(400) {
StatusCode::BAD_REQUEST
} else {
StatusCode::BAD_GATEWAY
};
AppError::new(status, error.message)
})?;
Ok(Json(json!({
"success": true,
"audioBase64": base64::engine::general_purpose::STANDARD.encode(&preview.audio),
"duration": preview.duration,
"words": preview.words,
"engine": preview.engine,
"voice": preview.voice,
"warnings": preview.warnings,
})))
}
/// Estimation locale de la durée de la voix : ne déclenche aucune synthèse.
async fn sync_info(_user: AuthUser, Body(body): Body<Value>) -> ApiResult<Json<Value>> {
let text = body.get("text");
if !is_truthy(text) {
return Err(AppError::bad_request("Texte requis"));
}
let Some(text) = text.and_then(Value::as_str) else {
return Err(AppError::internal("Erreur de calcul de synchronisation"));
};
Ok(Json(json!(estimate_voice_timing(text))))
}
/// Crée un Reel : le rendu part dans la file persistante.
async fn create_reel(
State(state): State<AppState>,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
let mut params = parse_video_reel_params(&body).map_err(AppError::bad_request)?;
let fail = |message: String| {
tracing::error!("❌ Error initiating Reel: {message}");
AppError::json(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "success": false, "error": message }),
)
};
// Avec un SRT, le texte libre n'est plus utilisé : la voix lit les sous-titres
if let Some(cues) = &params.srt_cues {
params.overlay_text = None;
if params.description.is_none() {
params.description = Some(srt_text(cues)).filter(|d| !d.is_empty());
}
}
params.store_name = resolve_store_name(
&state,
&user.id,
params.page_ids.first().map(String::as_str),
)
.await;
let waiting = count_active_reel_jobs(&state)
.await
.map_err(|e| fail(e.to_string()))?;
let scheduled_for = match &params.scheduled_for {
Some(date) => Some(Ts::parse(date).ok_or_else(|| fail("Invalid time value".into()))?),
None => None,
};
let mut new_post = NewPost::new(
user.id.clone(),
params
.description
.clone()
.or_else(|| params.overlay_text.clone())
.unwrap_or_default(),
);
new_post.status = if scheduled_for.is_some() {
PostStatus::Scheduled
} else {
PostStatus::Draft
};
new_post.scheduled_for = scheduled_for;
new_post.generation_status = Some("pending".into());
new_post.generation_progress = Some(0);
let post = state
.db
.create_post(new_post)
.await
.map_err(|e| fail(e.to_string()))?;
let stored = serde_json::to_value(&params).map_err(|e| fail(e.to_string()))?;
enqueue_reel_job(&state, "video", &user.id, Some(&post.id), stored)
.await
.map_err(|e| fail(e.to_string()))?;
tracing::info!(
"✨ Reel en file. Post {}, {waiting} job(s) avant lui.",
post.id
);
Ok(Json(json!({
"success": true,
"postId": post.id,
"queued": waiting > 0,
"message": if waiting > 0 {
"File d'attente occupée. Votre vidéo sera traitée dès que possible."
} else {
"Traitement démarré en arrière-plan."
},
"results": [],
"videoUrl": "",
})))
}
/// Configuration des services Reels.
async fn reels_config(State(state): State<AppState>, _user: AuthUser) -> Json<Value> {
let (ffmpeg_configured, logo_url) =
tokio::join!(ffmpeg::health_check(&state), resolve_logo_path(&state));
Json(json!({
"jamendo": { "configured": false },
"freesound": { "configured": false },
"ffmpeg": { "configured": ffmpeg_configured },
// Aperçu : logo affiché par le lecteur, moteur de rendu utilisé
"logoUrl": logo_url,
"renderer": effective_renderer(&state),
}))
}
/// Reels en cours de génération.
async fn ongoing(State(state): State<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
let posts = state
.db
.get_ongoing_reel_posts(&user.id)
.await
.or_500("Erreur lors de la récupération des reels en cours")?;
Ok(Json(Value::Array(posts)))
}
/// Supprime un Reel (le post et, en cascade, ses publications et liens médias).
async fn delete_reel(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<String>,
) -> ApiResult<Json<Value>> {
const ERROR: &str = "Erreur lors de la suppression du Reel";
let post = state
.db
.get_post(&id)
.await
.or_500(ERROR)?
.ok_or_else(|| AppError::not_found("Reel non trouvé"))?;
if !user.is_admin() && post.user_id != user.id {
return Err(AppError::forbidden("Non autorisé à supprimer ce Reel"));
}
state.db.delete_post(&id).await.or_500(ERROR)?;
Ok(Json(
json!({ "success": true, "message": "Reel supprimé avec succès" }),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_int_comme_javascript() {
assert_eq!(js_parse_int("10"), json!(10));
assert_eq!(js_parse_int("12abc"), json!(12));
assert_eq!(js_parse_int(" -3"), json!(-3));
assert_eq!(js_parse_int("abc"), Value::Null);
assert_eq!(js_parse_int(""), Value::Null);
}
}
+449
View File
@@ -0,0 +1,449 @@
//! 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::extract::{Path, State};
use axum::http::header::{CONTENT_TYPE, LOCATION};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use rand::RngCore;
use serde_json::{json, Value};
use crate::auth::AuthUser;
use crate::config::Config;
use crate::db::{NewSocialPage, Patch, Platform};
use crate::error::{ApiResult, AppError};
use crate::extract::{Body, QueryMap};
use crate::services::tiktok;
use crate::state::AppState;
/// Page vers laquelle on renvoie le navigateur à la fin du flux OAuth.
const RETURN_PATH: &str = "/pages";
/// Clé de session de l'état OAuth (protection CSRF).
const OAUTH_STATE_KEY: &str = "tiktokOAuthState";
pub fn router() -> Router<AppState> {
Router::new()
.route("/config", get(get_config).put(put_config))
.route("/connect", get(connect))
.route("/callback", get(callback))
.route("/accounts/{id}/creator-info", get(creator_info))
}
fn build_return_url(success: bool, message: &str) -> String {
let query = url::form_urlencoded::Serializer::new(String::new())
.append_pair("tiktok", if success { "success" } else { "error" })
.append_pair("message", message)
.finish();
format!("{RETURN_PATH}?{query}")
}
/// Redirection 302, comme `res.redirect()` d'Express.
fn redirect(location: &str) -> Response {
(
StatusCode::FOUND,
[
(LOCATION, location.to_string()),
(CONTENT_TYPE, "text/plain; charset=utf-8".to_string()),
],
format!("Found. Redirecting to {location}"),
)
.into_response()
}
fn redirect_error(message: &str) -> Response {
redirect(&build_return_url(false, message))
}
/// État de la configuration TikTok.
/// Le client secret n'est jamais renvoyé au client.
async fn get_config(
State(state): State<AppState>,
headers: HeaderMap,
user: AuthUser,
) -> ApiResult<Json<Value>> {
let config = state.db.get_tiktok_config().await.map_err(|e| {
tracing::error!("❌ [TikTok] Erreur lecture configuration: {e}");
AppError::internal("Impossible de lire la configuration TikTok")
})?;
let configured = tiktok::is_configured(&state).await;
// Tout le monde a besoin de savoir si la connexion est possible ; seuls les
// admins voient les détails de l'application développeur.
if !user.is_admin() {
return Ok(Json(json!({ "configured": configured })));
}
let client_key = config
.as_ref()
.map(|c| c.client_key.clone())
.filter(|k| !k.is_empty())
.unwrap_or_else(|| {
if Config::var("TIKTOK_CLIENT_KEY").is_some() {
"(défini par variable d'environnement)".to_string()
} else {
String::new()
}
});
let has_client_secret = config.as_ref().is_some_and(|c| !c.client_secret.is_empty())
|| Config::var("TIKTOK_CLIENT_SECRET").is_some();
Ok(Json(json!({
"configured": configured,
"clientKey": client_key,
"hasClientSecret": has_client_secret,
"redirectUri": tiktok::redirect_uri(&state, Some(&headers)),
})))
}
/// Type d'une valeur JSON tel que le nomme zod (`Expected string, received …`).
fn zod_type(value: Option<&Value>) -> &'static str {
match value {
None => "undefined",
Some(Value::Null) => "null",
Some(Value::Bool(_)) => "boolean",
Some(Value::Number(_)) => "number",
Some(Value::String(_)) => "string",
Some(Value::Array(_)) => "array",
Some(Value::Object(_)) => "object",
}
}
/// `z.string().trim().min(1, message)` : valeur rognée, ou message d'erreur zod.
fn required_trimmed(value: Option<&Value>, empty_message: &str) -> Result<String, String> {
match value {
None => Err("Required".to_string()),
Some(Value::String(s)) => {
let trimmed = s.trim();
if trimmed.is_empty() {
Err(empty_message.to_string())
} else {
Ok(trimmed.to_string())
}
}
Some(other) => Err(format!(
"Expected string, received {}",
zod_type(Some(other))
)),
}
}
/// Validation du corps de `PUT /config` (schémas `insertTiktokConfigSchema` /
/// `updateTiktokConfigSchema`) : renvoie le premier message d'erreur, comme la route.
fn validate_config(
body: &Value,
secret_required: bool,
) -> Result<(String, Option<String>), String> {
if !body.is_object() {
return Err(format!(
"Expected object, received {}",
zod_type(Some(body))
));
}
let client_key =
required_trimmed(body.get("clientKey"), "Le client key ne peut pas être vide")?;
let secret_message = "Le client secret ne peut pas être vide";
let client_secret = match body.get("clientSecret") {
// Le secret peut être omis lors d'une mise à jour : on garde l'existant
None if !secret_required => None,
value => Some(required_trimmed(value, secret_message)?),
};
Ok((client_key, client_secret))
}
/// Enregistre les identifiants de l'application développeur TikTok (admin).
async fn put_config(
State(state): State<AppState>,
headers: HeaderMap,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
if !user.is_admin() {
return Err(AppError::forbidden(
"Accès refusé. Réservé aux administrateurs.",
));
}
let invalid = |message: String| {
tracing::error!("❌ [TikTok] Erreur enregistrement configuration: {message}");
AppError::bad_request(if message.is_empty() {
"Configuration TikTok invalide".to_string()
} else {
message
})
};
let existing = state
.db
.get_tiktok_config()
.await
.map_err(|e| invalid(tiktok::error_message(&e.into())))?;
let (client_key, client_secret) =
validate_config(&body, existing.is_none()).map_err(invalid)?;
let config = state
.db
.upsert_tiktok_config(Some(&client_key), client_secret.as_deref())
.await
.map_err(|e| invalid(tiktok::error_message(&e)))?;
Ok(Json(json!({
"success": true,
"clientKey": config.client_key,
"redirectUri": tiktok::redirect_uri(&state, Some(&headers)),
})))
}
/// Démarre la connexion d'un compte TikTok.
/// À appeler par navigation complète du navigateur (window.location), pas en fetch.
async fn connect(State(state): State<AppState>, headers: HeaderMap, user: AuthUser) -> Response {
if !tiktok::is_configured(&state).await {
return redirect_error(
"TikTok n'est pas configuré. Renseignez d'abord le client key et le client secret.",
);
}
// Protection CSRF : l'état est vérifié au retour
let mut bytes = [0u8; 16];
rand::thread_rng().fill_bytes(&mut bytes);
let oauth_state = hex::encode(bytes);
let mut session = user.session;
session.set(OAUTH_STATE_KEY, Value::String(oauth_state.clone()));
let auth_url = match tiktok::build_authorization_url(&state, &oauth_state, Some(&headers)).await
{
Ok(url) => url,
Err(error) => {
tracing::error!("❌ [TikTok] Erreur démarrage OAuth: {error:#}");
return redirect_error(&tiktok::error_message(&error));
}
};
// La session doit être écrite avant la redirection, sinon l'état est perdu
if let Err(error) = session.save(&state).await {
tracing::error!("❌ [TikTok] Impossible de sauvegarder la session OAuth: {error}");
return redirect_error("Erreur de session, réessayez.");
}
redirect(&auth_url)
}
/// Retour d'autorisation TikTok : échange le code, puis crée ou met à jour le
/// compte dans social_pages.
async fn callback(
State(state): State<AppState>,
headers: HeaderMap,
user: AuthUser,
query: QueryMap,
) -> Response {
match handle_callback(&state, &headers, user, &query).await {
Ok(response) => response,
Err(error) => {
tracing::error!("❌ [TikTok] Erreur callback OAuth: {error:#}");
redirect_error(&tiktok::error_message(&error))
}
}
}
async fn handle_callback(
state: &AppState,
headers: &HeaderMap,
user: AuthUser,
query: &QueryMap,
) -> anyhow::Result<Response> {
if let Some(oauth_error) = query.non_empty("error") {
let description = query.non_empty("error_description").unwrap_or(oauth_error);
return Ok(redirect_error(&format!(
"Autorisation TikTok refusée : {description}"
)));
}
let AuthUser { user, mut session } = user;
let expected_state = session.get_str(OAUTH_STATE_KEY);
if session.remove(OAUTH_STATE_KEY).is_some() {
if let Err(error) = session.save(state).await {
tracing::warn!("⚠️ [TikTok] Session non enregistrée après le retour OAuth: {error}");
}
}
let received_state = query.non_empty("state");
let state_ok = matches!(
(received_state, expected_state.as_deref()),
(Some(received), Some(expected)) if !expected.is_empty() && received == expected
);
if !state_ok {
return Ok(redirect_error(
"Requête de connexion TikTok invalide (état non reconnu).",
));
}
let Some(code) = query.non_empty("code") else {
return Ok(redirect_error("Aucun code d'autorisation reçu de TikTok."));
};
let tokens = tiktok::exchange_code_for_token(state, code, Some(headers)).await?;
let profile = tiktok::get_user_info(state, &tokens.access_token).await?;
let open_id = profile
.open_id
.clone()
.or_else(|| tokens.open_id.clone().filter(|id| !id.is_empty()))
.ok_or_else(|| {
anyhow::anyhow!("Réponse TikTok inattendue : identifiant du compte absent")
})?;
// Un compte déjà connecté est mis à jour plutôt que dupliqué
let existing_pages = state.db.get_social_pages(&user.id).await?;
let existing = existing_pages
.iter()
.find(|p| p.platform == Platform::Tiktok && p.page_id == open_id);
if let Some(existing) = existing {
state
.db
.update_social_page(
&existing.id,
Patch::new()
.set("page_name", profile.display_name.clone())
.set("avatar_url", profile.avatar_url.clone())
.set("is_active", "true"),
)
.await?;
tiktok::persist_tokens(state, &existing.id, &tokens).await?;
tracing::info!("🔁 [TikTok] Compte reconnecté: {}", profile.display_name);
return Ok(redirect(&build_return_url(
true,
&format!("Compte TikTok \"{}\" reconnecté.", profile.display_name),
)));
}
let (token_expires_at, refresh_token_expires_at) = tiktok::token_expirations(&tokens);
let page = state
.db
.create_social_page(NewSocialPage {
user_id: user.id.clone(),
platform: Some(Platform::Tiktok),
page_id: open_id.clone(),
page_name: profile.display_name.clone(),
access_token: tokens.access_token.clone(),
token_expires_at,
refresh_token: tokens.refresh_token.clone(),
refresh_token_expires_at,
scopes: tokens.scope.clone(),
avatar_url: profile.avatar_url.clone(),
is_active: Some("true".to_string()),
..Default::default()
})
.await?;
// Les non-admins ne voient que les pages qui leur sont explicitement
// attribuées : sans cette permission, ils ne verraient pas leur propre compte.
if !user.is_admin() {
if let Err(error) = state.db.create_page_permission(&user.id, &page.id).await {
tracing::error!("⚠️ [TikTok] Impossible de créer la permission de page: {error}");
}
}
tracing::info!(
"✅ [TikTok] Nouveau compte connecté: {} ({open_id})",
profile.display_name
);
Ok(redirect(&build_return_url(
true,
&format!("Compte TikTok \"{}\" connecté.", profile.display_name),
)))
}
/// Options réellement autorisées sur un compte (confidentialité, commentaires,
/// duo, stitch, durée max). TikTok impose de les afficher avant publication.
async fn creator_info(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<String>,
) -> ApiResult<Json<Value>> {
let bad_gateway = |error: anyhow::Error| {
tracing::error!("❌ [TikTok] Erreur récupération creator info: {error:#}");
AppError::json(
StatusCode::BAD_GATEWAY,
json!({ "error": tiktok::error_message(&error) }),
)
};
let page = state
.db
.get_social_page(&id)
.await
.map_err(|e| bad_gateway(e.into()))?;
let Some(page) = page.filter(|p| p.platform == Platform::Tiktok) else {
return Err(AppError::not_found("Compte TikTok non trouvé"));
};
if !user.is_admin() && page.user_id != user.id {
let accessible = state
.db
.get_user_accessible_pages(&user.id)
.await
.map_err(|e| bad_gateway(e.into()))?;
if !accessible.iter().any(|p| p.id == page.id) {
return Err(AppError::forbidden("Accès refusé à ce compte"));
}
}
let info = tiktok::get_creator_info(&state, &page)
.await
.map_err(bad_gateway)?;
Ok(Json(serde_json::to_value(info).unwrap_or(Value::Null)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn return_url_is_url_encoded() {
assert_eq!(
build_return_url(true, "Compte TikTok \"Nancy\" connecté."),
"/pages?tiktok=success&message=Compte+TikTok+%22Nancy%22+connect%C3%A9."
);
}
#[test]
fn config_validation_matches_zod_messages() {
assert_eq!(
validate_config(&json!({ "clientKey": " k ", "clientSecret": " s " }), true),
Ok(("k".to_string(), Some("s".to_string())))
);
assert_eq!(
validate_config(&json!({ "clientKey": "k" }), true),
Err("Required".to_string())
);
assert_eq!(
validate_config(&json!({ "clientKey": "k" }), false),
Ok(("k".to_string(), None))
);
assert_eq!(
validate_config(&json!({ "clientKey": " " }), false),
Err("Le client key ne peut pas être vide".to_string())
);
assert_eq!(
validate_config(&json!({ "clientKey": "k", "clientSecret": "" }), false),
Err("Le client secret ne peut pas être vide".to_string())
);
assert_eq!(
validate_config(&json!({ "clientKey": 3 }), false),
Err("Expected string, received number".to_string())
);
assert_eq!(
validate_config(&json!({ "clientKey": "k", "clientSecret": null }), false),
Err("Expected string, received null".to_string())
);
assert_eq!(
validate_config(&json!([]), false),
Err("Expected object, received array".to_string())
);
}
}
+468
View File
@@ -0,0 +1,468 @@
//! Statistiques des posts et des pages via l'API Graph (ex-`analytics.ts`).
use chrono::{Duration, NaiveTime};
use serde_json::{json, Value};
use crate::db::{PageAnalyticsHistory, Patch, Platform, PostAnalytics, TokenStatus};
use crate::services::facebook::graph;
use crate::state::AppState;
use crate::time::Ts;
const POST_FIELDS: &str = "insights.metric(post_impressions,post_impressions_unique,post_engaged_users,post_clicks),shares,comments.summary(true),reactions.summary(true)";
/// Codes d'erreur Graph signalant un jeton invalide ou expiré.
const AUTH_ERROR_CODES: [i64; 3] = [190, 463, 467];
/// Métriques d'une publication.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct PostMetrics {
pub impressions: i64,
pub reach: i64,
pub engagement: i64,
pub clicks: i64,
pub reactions: i64,
pub comments: i64,
pub shares: i64,
}
impl PostMetrics {
fn add(&mut self, other: &PostMetrics) {
self.impressions += other.impressions;
self.reach += other.reach;
self.engagement += other.engagement;
self.clicks += other.clicks;
self.reactions += other.reactions;
self.comments += other.comments;
self.shares += other.shares;
}
}
fn number(value: Option<&Value>) -> i64 {
value.and_then(Value::as_f64).map(|n| n as i64).unwrap_or(0)
}
/// Extrait les métriques de la réponse Graph d'un post.
pub fn parse_post_metrics(result: &Value) -> PostMetrics {
let insights = result
.get("insights")
.and_then(|i| i.get("data"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let metric = |name: &str| {
insights
.iter()
.find(|i| i.get("name").and_then(Value::as_str) == Some(name))
.map(|m| {
number(
m.get("values")
.and_then(|v| v.get(0))
.and_then(|v| v.get("value")),
)
})
.unwrap_or(0)
};
let summary_count = |key: &str| {
number(
result
.get(key)
.and_then(|r| r.get("summary"))
.and_then(|s| s.get("total_count")),
)
};
PostMetrics {
impressions: metric("post_impressions"),
reach: metric("post_impressions_unique"),
engagement: metric("post_engaged_users"),
clicks: metric("post_clicks"),
reactions: summary_count("reactions"),
comments: summary_count("comments"),
shares: number(result.get("shares").and_then(|s| s.get("count"))),
}
}
/// Dernière valeur (la plus récente) d'une métrique de page.
pub fn last_insight_value(result: &Value) -> i64 {
result
.get("data")
.and_then(Value::as_array)
.and_then(|data| data.first())
.and_then(|first| first.get("values"))
.and_then(Value::as_array)
.and_then(|values| values.last())
.map(|v| number(v.get("value")))
.unwrap_or(0)
}
fn to_i32(value: i64) -> i32 {
value.clamp(i32::MIN as i64, i32::MAX as i64) as i32
}
/// Statistiques enregistrées d'un post.
pub async fn get_post_analytics(
state: &AppState,
post_id: &str,
) -> Result<Option<PostAnalytics>, sqlx::Error> {
sqlx::query_as("SELECT * FROM post_analytics WHERE post_id = $1 LIMIT 1")
.bind(post_id)
.fetch_optional(&state.db.pool)
.await
}
/// Historique d'une page : 30 derniers relevés, du plus récent au plus ancien.
pub async fn get_page_history(
state: &AppState,
page_id: &str,
) -> Result<Vec<PageAnalyticsHistory>, sqlx::Error> {
sqlx::query_as(
"SELECT * FROM page_analytics_history WHERE page_id = $1 ORDER BY date DESC LIMIT 30",
)
.bind(page_id)
.fetch_all(&state.db.pool)
.await
}
/// Récupère les métriques d'un post sur chaque page où il a été publié et
/// met à jour `post_analytics` (somme des publications).
pub async fn sync_post_analytics(state: &AppState, post_id: &str) -> anyhow::Result<()> {
tracing::info!("[AnalyticsService] Syncing post {post_id}");
let published: Vec<_> = state
.db
.get_scheduled_posts_by_post(post_id)
.await?
.into_iter()
.filter(|sp| sp.external_post_id.is_some())
.collect();
if published.is_empty() {
tracing::info!("[AnalyticsService] No published external posts found for {post_id}");
return Ok(());
}
let mut totals = PostMetrics::default();
let mut raw_data = Vec::new();
for instance in &published {
let Some(external_id) = instance
.external_post_id
.as_deref()
.filter(|id| !id.is_empty())
else {
continue;
};
let Some(page) = state.db.get_social_page(&instance.page_id).await? else {
continue;
};
// Les métriques TikTok ne sont pas exposées par la Graph API
if page.platform == Platform::Tiktok {
continue;
}
match graph::get(
state,
external_id,
Some(&page.access_token),
&[("fields", POST_FIELDS)],
)
.await
{
Ok(result) => {
totals.add(&parse_post_metrics(&result));
raw_data.push(json!({ "id": external_id, "data": result }));
}
Err(error) => {
tracing::error!(
"[AnalyticsService] Failed to fetch for instance {}: {error}",
instance.id
);
}
}
}
let raw_data = Value::Array(raw_data);
let existing = get_post_analytics(state, post_id).await?;
if let Some(existing) = existing {
sqlx::query(
r#"UPDATE post_analytics SET fetched_at = $2, impressions = $3, reach = $4, engagement = $5,
reactions = $6, comments = $7, shares = $8, clicks = $9, raw_data = $10 WHERE id = $1"#,
)
.bind(&existing.id)
.bind(Ts::now())
.bind(to_i32(totals.impressions))
.bind(to_i32(totals.reach))
.bind(to_i32(totals.engagement))
.bind(to_i32(totals.reactions))
.bind(to_i32(totals.comments))
.bind(to_i32(totals.shares))
.bind(to_i32(totals.clicks))
.bind(&raw_data)
.execute(&state.db.pool)
.await?;
} else {
sqlx::query(
r#"INSERT INTO post_analytics (post_id, fetched_at, impressions, reach, engagement,
reactions, comments, shares, clicks, raw_data) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"#,
)
.bind(post_id)
.bind(Ts::now())
.bind(to_i32(totals.impressions))
.bind(to_i32(totals.reach))
.bind(to_i32(totals.engagement))
.bind(to_i32(totals.reactions))
.bind(to_i32(totals.comments))
.bind(to_i32(totals.shares))
.bind(to_i32(totals.clicks))
.bind(&raw_data)
.execute(&state.db.pool)
.await?;
}
Ok(())
}
/// Début du jour (UTC, l'heure du serveur) et lendemain.
pub fn day_bounds(now: Ts) -> (Ts, Ts) {
let start = Ts(now.0.date().and_time(NaiveTime::MIN));
(start, start.add(Duration::days(1)))
}
/// Synchronise les statistiques d'une page (abonnés, portée, engagement,
/// vues). Chaque métrique est récupérée séparément pour plus de résilience.
pub async fn sync_page_analytics(state: &AppState, page_id: &str) -> anyhow::Result<()> {
tracing::info!("[AnalyticsService] Syncing page {page_id}");
let Some(page) = state.db.get_social_page(page_id).await? else {
return Ok(());
};
let access_token = page.access_token.as_str();
// 1. Infos de base (abonnés) — indispensables : un échec signale
// généralement un jeton invalide, on s'arrête là.
let mut followers = page.followers_count.unwrap_or(0) as i64;
match graph::get(
state,
&page.page_id,
Some(access_token),
&[("fields", "fan_count")],
)
.await
{
Ok(info) => {
if let Some(fan_count) = info.get("fan_count").and_then(Value::as_f64) {
followers = fan_count as i64;
}
}
Err(error) => {
tracing::error!("[AnalyticsService] Failed to fetch basic info for {page_id}: {error}");
let preview: String = access_token.chars().take(10).collect();
tracing::info!("[AnalyticsService] DEBUG: Token used: {preview}...");
// Erreur d'authentification : jeton marqué expiré, on s'arrête
if graph::error_code(&error).is_some_and(|code| AUTH_ERROR_CODES.contains(&code)) {
tracing::info!("[AnalyticsService] Marking token as EXPIRED for page {page_id}");
state
.db
.update_social_page(
page_id,
Patch::new()
.set("token_status", TokenStatus::Expired)
.set("last_token_check", Ts::now()),
)
.await?;
return Ok(());
}
anyhow::bail!("Failed to fetch Basic Info: {error}");
}
}
// 2. Chaque métrique de page, indépendamment
let page_reach = fetch_insight(
state,
&page.page_id,
page_id,
access_token,
"page_impressions_unique",
)
.await;
let page_engagement = fetch_insight(
state,
&page.page_id,
page_id,
access_token,
"page_post_engagements",
)
.await;
let page_views = fetch_insight(
state,
&page.page_id,
page_id,
access_token,
"page_views_total",
)
.await;
tracing::info!(
"[AnalyticsService] Metrics: followers={followers}, reach={page_reach}, engagement={page_engagement}, views={page_views}"
);
// 3. Mise à jour de la page — le statut du jeton se rétablit tout seul
state
.db
.update_social_page(
page_id,
Patch::new()
.set("followers_count", to_i32(followers))
.set("token_status", TokenStatus::Valid)
.set("last_token_check", Ts::now()),
)
.await?;
// 4. Relevé historique : un seul par jour et par page
let (today, tomorrow) = day_bounds(Ts::now());
let existing: Option<(String,)> = sqlx::query_as(
"SELECT id FROM page_analytics_history WHERE page_id = $1 AND date >= $2 AND date < $3 LIMIT 1",
)
.bind(page_id)
.bind(today)
.bind(tomorrow)
.fetch_optional(&state.db.pool)
.await?;
if let Some((entry_id,)) = existing {
sqlx::query(
r#"UPDATE page_analytics_history SET followers_count = $2, page_reach = $3, page_views = $4,
page_engagement = $5, date = $6 WHERE id = $1"#,
)
.bind(entry_id)
.bind(to_i32(followers))
.bind(to_i32(page_reach))
.bind(to_i32(page_views))
.bind(to_i32(page_engagement))
.bind(Ts::now())
.execute(&state.db.pool)
.await?;
} else {
sqlx::query(
r#"INSERT INTO page_analytics_history (page_id, date, followers_count, page_reach, page_views, page_engagement)
VALUES ($1, $2, $3, $4, $5, $6)"#,
)
.bind(page_id)
.bind(Ts::now())
.bind(to_i32(followers))
.bind(to_i32(page_reach))
.bind(to_i32(page_views))
.bind(to_i32(page_engagement))
.execute(&state.db.pool)
.await?;
}
tracing::info!(
"[AnalyticsService] Synced page {page_id}: {followers} followers, {page_reach} reach, {page_engagement} engagement, {page_views} views"
);
Ok(())
}
/// Une métrique de page (`period=day`) ; 0 si elle est indisponible.
async fn fetch_insight(
state: &AppState,
graph_page_id: &str,
page_id: &str,
access_token: &str,
metric: &str,
) -> i64 {
let endpoint = format!("{graph_page_id}/insights/{metric}");
match graph::get(state, &endpoint, Some(access_token), &[("period", "day")]).await {
Ok(result) => last_insight_value(&result),
Err(error) => {
if graph::error_code(&error) == Some(100) {
tracing::warn!("[AnalyticsService] Metric \"{metric}\" unavailable for {page_id} (Code 100). Skipping.");
} else {
tracing::warn!(
"[AnalyticsService] Failed to fetch \"{metric}\" for {page_id}: {error}"
);
}
0
}
}
}
/// Synchronise les statistiques de toutes les pages (tâche planifiée).
/// Les statistiques passent par la Graph API : les comptes TikTok en sont exclus.
pub async fn sync_all_pages(state: &AppState) -> anyhow::Result<()> {
tracing::info!("[AnalyticsService] Starting sync for all pages...");
let pages: Vec<_> = state
.db
.get_all_social_pages()
.await?
.into_iter()
.filter(|p| p.platform != Platform::Tiktok)
.collect();
for page in &pages {
if let Err(error) = sync_page_analytics(state, &page.id).await {
tracing::error!(
"[AnalyticsService] Failed to sync page {}: {error}",
page.id
);
}
}
tracing::info!(
"[AnalyticsService] Sync complete for {} pages.",
pages.len()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn post_metrics_are_extracted() {
let result = json!({
"insights": { "data": [
{ "name": "post_impressions", "values": [{ "value": 120 }] },
{ "name": "post_impressions_unique", "values": [{ "value": 80 }] },
{ "name": "post_engaged_users", "values": [] },
{ "name": "post_clicks", "values": [{ "value": { "link clicks": 3 } }] }
]},
"shares": { "count": 4 },
"comments": { "summary": { "total_count": 5 } },
"reactions": { "summary": { "total_count": 9 } }
});
assert_eq!(
parse_post_metrics(&result),
PostMetrics {
impressions: 120,
reach: 80,
engagement: 0,
clicks: 0,
reactions: 9,
comments: 5,
shares: 4
}
);
assert_eq!(parse_post_metrics(&json!({})), PostMetrics::default());
}
#[test]
fn last_value_of_page_insight() {
let result = json!({ "data": [{ "values": [{ "value": 1 }, { "value": 7 }] }] });
assert_eq!(last_insight_value(&result), 7);
assert_eq!(last_insight_value(&json!({ "data": [] })), 0);
assert_eq!(
last_insight_value(&json!({ "data": [{ "values": [] }] })),
0
);
}
#[test]
fn day_bounds_are_utc_midnight() {
let (start, end) = day_bounds(Ts::parse("2026-10-12T17:45:12Z").unwrap());
assert_eq!(start.to_iso(), "2026-10-12T00:00:00.000Z");
assert_eq!(end.to_iso(), "2026-10-13T00:00:00.000Z");
}
}
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more