Retrait de l'ancien serveur Node/TypeScript

Le serveur Rust (server/src) le remplace entièrement : routes, services,
migrations Drizzle et scripts SQL d'initialisation sont supprimés. Le
schéma est désormais créé et migré par server/src/migrate.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
This commit is contained in:
Claude committed 2026-10-12 17:49:03 +00:00
1 parent a0f800101c
commit b451826ec5
54 files changed
+2 -13174

No files matched your search

-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
}
]
}
-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' });
}
});
-269
View File
@@ -1,269 +0,0 @@
/**
* 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';
export const legalRouter = Router();
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]',
};
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderPage(title: string, body: string): string {
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
:root { color-scheme: light dark; }
body {
max-width: 46rem;
margin: 0 auto;
padding: 2.5rem 1.25rem 4rem;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.65;
color: #1f2328;
background: #ffffff;
}
h1 { font-size: 1.75rem; margin-bottom: 0.25rem; }
h2 { font-size: 1.15rem; margin-top: 2.25rem; }
.updated { color: #6b7280; font-size: 0.875rem; margin-top: 0; }
ul { padding-left: 1.25rem; }
li { margin-bottom: 0.35rem; }
code { background: rgba(127, 127, 127, 0.15); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.9em; }
footer { margin-top: 3rem; padding-top: 1.25rem; border-top: 1px solid rgba(127,127,127,0.3); color: #6b7280; font-size: 0.875rem; }
a { color: #2563eb; }
@media (prefers-color-scheme: dark) {
body { color: #e6e6e6; background: #0f1115; }
.updated, footer { color: #9ca3af; }
a { color: #7aa7ff; }
}
</style>
</head>
<body>
${body}
</body>
</html>`;
}
function renderFooter(): string {
const company = getCompanyInfo();
return `<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)}
</p>
<p><a href="/terms">Conditions d'utilisation</a> &middot; <a href="/privacy">Politique de confidentialité</a></p>
</footer>`;
}
legalRouter.get('/terms', (_req: Request, res: Response) => {
const company = getCompanyInfo();
const body = `
<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
de créer des vidéos et des publications promotionnelles, puis de les diffuser sur les comptes
et pages officiels de nos magasins.
</p>
<h2>1. Accès au service</h2>
<p>
L'accès est réservé aux personnes que nous avons expressément autorisées, au moyen d'un compte
nominatif. Chaque utilisateur est responsable de la confidentialité de ses identifiants et des
actions effectuées depuis son compte. Nous pouvons suspendre ou retirer un accès à tout moment.
</p>
<h2>2. Comptes de réseaux sociaux connectés</h2>
<p>
L'application publie sur des comptes Facebook et TikTok qui nous appartiennent ou que nous
sommes habilités à administrer. En connectant un compte, l'utilisateur déclare disposer des
droits nécessaires pour le faire. Un compte peut être déconnecté à tout moment depuis
l'application, ou par le retrait de l'autorisation directement dans les réglages de la
plateforme concernée.
</p>
<h2>3. Contenus publiés</h2>
<p>
Les utilisateurs restent responsables des contenus qu'ils créent et diffusent. Ces contenus
doivent respecter la loi applicable ainsi que les règles des plateformes de destination,
notamment les Conditions d'utilisation et les Règles communautaires de TikTok et de Meta.
Sont notamment proscrits les contenus dont les utilisateurs ne détiennent pas les droits,
y compris pour les images et les musiques utilisées.
</p>
<h2>4. Disponibilité</h2>
<p>
Le service est fourni « en l'état ». Il dépend de services tiers (plateformes de publication,
hébergement, traitement vidéo) dont les interruptions ou modifications peuvent affecter son
fonctionnement. Nous ne garantissons ni une disponibilité continue, ni la publication effective
d'un contenu par une plateforme tierce, celle-ci pouvant la refuser selon ses propres règles.
</p>
<h2>5. Responsabilité</h2>
<p>
Dans les limites permises par la loi, notre responsabilité ne saurait être engagée au titre des
dommages indirects résultant de l'utilisation du service, notamment une perte de contenu, une
publication manquée ou la suspension d'un compte par une plateforme tierce.
</p>
<h2>6. Modification des conditions</h2>
<p>
Ces conditions peuvent être modifiées. La date de dernière mise à jour figure en tête de page.
</p>
<h2>7. Contact</h2>
<p>Pour toute question relative à ces conditions : ${escapeHtml(company.email)}</p>
${renderFooter()}`;
res.type('html').send(renderPage("Conditions d'utilisation", body));
});
legalRouter.get('/privacy', (_req: Request, res: Response) => {
const company = getCompanyInfo();
const body = `
<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)}.
</p>
<h2>1. Données de nos utilisateurs internes</h2>
<p>
Pour chaque personne autorisée à utiliser l'outil, nous conservons un identifiant de connexion,
un mot de passe stocké sous forme chiffrée, un rôle et la liste des comptes auxquels elle a
accès. Ces données servent uniquement à l'authentification et à la gestion des droits.
</p>
<h2>2. Données issues des comptes de réseaux sociaux connectés</h2>
<p>
Lorsqu'un compte TikTok est connecté, nous recevons de TikTok, avec l'autorisation explicite du
titulaire du compte et dans le cadre du périmètre <code>user.info.basic</code> :
</p>
<ul>
<li>l'identifiant technique du compte (<code>open_id</code>) ;</li>
<li>le nom d'affichage du compte ;</li>
<li>l'image de profil du compte ;</li>
<li>un jeton d'accès et un jeton de renouvellement, qui nous permettent de publier en son nom.</li>
</ul>
<p>
Nous procédons de manière équivalente pour les pages Facebook connectées. Nous ne collectons ni
la liste des abonnés, ni les messages privés, ni les données de navigation des visiteurs de ces
comptes.
</p>
<h2>3. Finalités</h2>
<p>
Ces données sont utilisées exclusivement pour identifier le compte de destination dans
l'interface et y publier les vidéos et publications créées par nos équipes, immédiatement ou à
l'heure programmée. Elles ne servent à aucune autre finalité.
</p>
<h2>4. Données que nous transmettons aux plateformes</h2>
<p>
Lors d'une publication, nous transmettons à la plateforme concernée le fichier vidéo ou image,
le texte de la publication, ainsi que les paramètres de diffusion associés (niveau de
confidentialité, autorisation des commentaires). Ces contenus sont ensuite régis par la
politique de confidentialité de la plateforme concernée.
</p>
<h2>5. Conservation et sécurité</h2>
<ul>
<li>Les jetons d'accès et de renouvellement sont chiffrés au repos (AES-256-GCM).</li>
<li>
Ils sont conservés tant que le compte reste connecté à l'application, et supprimés
immédiatement lorsque le compte en est retiré.
</li>
<li>Les médias sources sont supprimés automatiquement après leur publication et leur péremption.</li>
<li>L'accès à l'application est restreint aux personnes autorisées et protégé par mot de passe.</li>
</ul>
<h2>6. Partage avec des tiers</h2>
<p>
Nous ne vendons ni ne louons ces données, et ne les transmettons à aucun tiers à des fins
publicitaires ou de profilage. Elles ne sont communiquées qu'aux plateformes de destination
(TikTok, Meta) dans la stricte mesure nécessaire à la publication demandée, ainsi qu'à nos
prestataires techniques d'hébergement et de traitement vidéo agissant sur nos instructions.
</p>
<h2>7. Retirer une autorisation</h2>
<p>
Le titulaire d'un compte connecté peut retirer son autorisation à tout moment :
</p>
<ul>
<li>en demandant la déconnexion du compte depuis l'application, ce qui supprime les jetons ;</li>
<li>
ou directement dans TikTok, via <em>Profil &rsaquo; Paramètres et confidentialité &rsaquo;
Sécurité et autorisations &rsaquo; Gérer les autorisations des applications</em>.
</li>
</ul>
<p>
Le retrait de l'autorisation empêche toute publication ultérieure. Il n'entraîne pas la
suppression des contenus déjà publiés sur la plateforme, qui doivent être supprimés depuis
celle-ci.
</p>
<h2>8. Vos droits</h2>
<p>
Conformément au Règlement général sur la protection des données, vous disposez d'un droit
d'accès, de rectification, d'effacement, de limitation et de portabilité des données vous
concernant, ainsi que du droit d'introduire une réclamation auprès de la CNIL. Pour exercer ces
droits, écrivez à ${escapeHtml(company.email)}.
</p>
<h2>9. Modification de cette politique</h2>
<p>
Cette politique peut être mise à jour. La date de dernière mise à jour figure en tête de page.
</p>
${renderFooter()}`;
res.type('html').send(renderPage('Politique de confidentialité', body));
});
-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[];
}
+2
View File
@@ -1,3 +1,5 @@
// 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.
//!
-831
View File
@@ -1,831 +0,0 @@
import {
users,
socialPages,
media,
posts,
reelJobs,
postMedia,
scheduledPosts,
aiGenerations,
cloudinaryConfig,
openrouterConfig,
userPagePermissions,
type User,
type InsertUser,
type SocialPage,
type InsertSocialPage,
type Media,
type InsertMedia,
type Post,
type InsertPost,
type PostMedia,
type ScheduledPost,
type InsertScheduledPost,
type AiGeneration,
type InsertAiGeneration,
type CloudinaryConfig,
type InsertCloudinaryConfig,
type OpenrouterConfig,
type InsertOpenrouterConfig,
type UserPagePermission,
type InsertUserPagePermission,
musicFavorites,
type MusicFavorite,
type InsertMusicFavorite,
audioTracks,
type AudioTrack,
type InsertAudioTrack,
appConfig,
type AppConfig,
tiktokConfig,
type TiktokConfig,
type InsertTiktokConfig,
facebookConfig,
type FacebookConfig,
type InsertFacebookConfig,
} from "@shared/schema";
import { db } from "./db";
import { eq, and, or, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray, getTableColumns, sql, type SQL } from "drizzle-orm";
import { encrypt, decrypt, isEncrypted } from "./utils/encryption";
/**
* Statuts de publication asynchrone (TikTok) qui ne bougeront plus : inutile de
* continuer à interroger l'API une fois l'un d'eux atteint.
*/
export const TERMINAL_PUBLISH_STATUSES: string[] = [
'PUBLISH_COMPLETE',
'FAILED',
// Vidéo déposée dans la boîte de réception du créateur : elle attend une action
// manuelle dans TikTok et n'évoluera plus côté API.
'SEND_TO_USER_INBOX',
];
/**
* Déchiffre les tokens d'une page avant de la rendre au reste de l'application.
* Les services (Facebook, TikTok) manipulent toujours des tokens en clair.
*/
function decryptPageTokens(page: SocialPage): SocialPage {
return {
...page,
accessToken: decrypt(page.accessToken),
refreshToken: page.refreshToken ? decrypt(page.refreshToken) : page.refreshToken,
};
}
/** Bornes temporelles utilisées pour les comparaisons du tableau de bord. */
export interface DashboardStatsRange {
yesterdayStart: Date;
yesterdayEnd: Date;
lastMonth: Date;
}
export interface DashboardStats {
scheduledPosts: number;
scheduledPostsLastMonth: number;
connectedPages: number;
aiTextsGenerated: number;
aiTextsYesterday: number;
mediaStored: number;
}
/**
* Colonnes renvoyées pour une publication planifiée.
*
* On ne sélectionne pas la ligne `social_pages` entière : elle porte les jetons
* d'accès Facebook et TikTok, qui partaient jusqu'ici dans chaque réponse HTTP
* — y compris vers le navigateur, qui n'en fait rien.
*/
const SCHEDULED_POST_COLUMNS = {
...getTableColumns(scheduledPosts),
post: {
id: posts.id,
userId: posts.userId,
content: posts.content,
status: posts.status,
scheduledFor: posts.scheduledFor,
aiGenerated: posts.aiGenerated,
generationStatus: posts.generationStatus,
generationProgress: posts.generationProgress,
createdAt: posts.createdAt,
},
page: {
id: socialPages.id,
pageId: socialPages.pageId,
pageName: socialPages.pageName,
platform: socialPages.platform,
avatarUrl: socialPages.avatarUrl,
},
};
export interface IStorage {
// Users
getUser(id: string): Promise<User | undefined>;
getUserByUsername(username: string): Promise<User | undefined>;
getAllUsers(): Promise<User[]>;
getDashboardStats(userId: string, options: DashboardStatsRange): Promise<DashboardStats>;
createUser(user: InsertUser): Promise<User>;
updateUser(id: string, user: Partial<InsertUser>): Promise<User>;
deleteUser(id: string): Promise<void>;
// Social Pages
getSocialPages(userId: string): Promise<SocialPage[]>;
getAllSocialPages(): Promise<SocialPage[]>;
getSocialPage(id: string): Promise<SocialPage | undefined>;
createSocialPage(page: InsertSocialPage): Promise<SocialPage>;
updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage>;
deleteSocialPage(id: string): Promise<void>;
// Media
getMedia(userId: string, options?: { limit?: number; offset?: number }): Promise<Media[]>;
getMediaByIds(ids: string[]): Promise<Media[]>;
getMediaCount(userId: string): Promise<number>;
getMediaById(id: string): Promise<Media | undefined>;
createMedia(media: InsertMedia): Promise<Media>;
deleteMedia(id: string): Promise<void>;
getPostMedia(postId: string): Promise<PostMedia[]>;
// Posts
getPosts(userId: string): Promise<Post[]>;
getPost(id: string): Promise<Post | undefined>;
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>;
getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]>;
getRenderingReelJobs(): Promise<{ id: string; postId: string | null; createdAt: Date; params: unknown; content: string | null }[]>;
createPost(post: InsertPost): Promise<Post>;
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
deletePost(id: string): Promise<void>;
updatePostMedia(postId: string, mediaIds: string[]): Promise<void>;
// Scheduled Posts
getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
getScheduledPost(id: string): Promise<ScheduledPost | undefined>;
getScheduledPostsByPost(postId: string): Promise<ScheduledPost[]>;
createScheduledPost(scheduledPost: InsertScheduledPost): Promise<ScheduledPost>;
updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost>;
deleteScheduledPost(id: string): Promise<void>;
getPendingScheduledPosts(): Promise<ScheduledPost[]>;
getScheduledPostsAwaitingPublishStatus(): Promise<ScheduledPost[]>;
// AI Generations
getAiGenerations(userId: string): Promise<AiGeneration[]>;
createAiGeneration(generation: InsertAiGeneration): Promise<AiGeneration>;
// TikTok Config
getTiktokConfig(): Promise<TiktokConfig | undefined>;
upsertTiktokConfig(config: Partial<InsertTiktokConfig>): Promise<TiktokConfig>;
getFacebookConfig(): Promise<FacebookConfig | undefined>;
upsertFacebookConfig(config: Partial<InsertFacebookConfig>): Promise<FacebookConfig>;
// Cloudinary Config
getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>;
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
updateCloudinaryConfig(config: Partial<InsertCloudinaryConfig>): Promise<CloudinaryConfig>;
// OpenRouter Config
getOpenrouterConfig(userId: string): Promise<OpenrouterConfig | undefined>;
getAnyOpenrouterConfig(): Promise<OpenrouterConfig | undefined>;
createOpenrouterConfig(config: InsertOpenrouterConfig): Promise<OpenrouterConfig>;
updateOpenrouterConfig(userId: string, config: Partial<InsertOpenrouterConfig>): Promise<OpenrouterConfig>;
// User Page Permissions
getUserPagePermissions(userId: string): Promise<UserPagePermission[]>;
getPagePermissions(pageId: string): Promise<UserPagePermission[]>;
createPagePermission(permission: InsertUserPagePermission): Promise<UserPagePermission>;
deletePagePermission(userId: string, pageId: string): Promise<void>;
deleteAllUserPagePermissions(userId: string): Promise<void>;
getUserAccessiblePages(userId: string): Promise<SocialPage[]>;
// Music Favorites
getMusicFavorites(userId: string): Promise<MusicFavorite[]>;
addMusicFavorite(favorite: InsertMusicFavorite): Promise<MusicFavorite>;
removeMusicFavorite(userId: string, trackId: string): Promise<void>;
isMusicFavorite(userId: string, trackId: string): Promise<boolean>;
// Audio Tracks
getAudioTracks(): Promise<AudioTrack[]>;
// Audio Tracks
getAudioTrack(id: string): Promise<AudioTrack | undefined>;
createAudioTrack(track: InsertAudioTrack): Promise<AudioTrack>;
deleteAudioTrack(id: string): Promise<void>;
// App Config
getAppConfig(): Promise<AppConfig | undefined>;
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey' | 'qwenTtsUrl' | 'qwenTtsApiKey'>>): Promise<AppConfig>;
}
export class DatabaseStorage implements IStorage {
// Users
async getUser(id: string): Promise<User | undefined> {
const [user] = await db.select().from(users).where(eq(users.id, id));
return user || undefined;
}
async getUserByUsername(username: string): Promise<User | undefined> {
const [user] = await db.select().from(users).where(eq(users.username, username));
return user || undefined;
}
async getAllUsers(): Promise<User[]> {
return await db.select().from(users);
}
async createUser(insertUser: InsertUser): Promise<User> {
const [user] = await db.insert(users).values(insertUser).returning();
return user;
}
async updateUser(id: string, updateData: Partial<InsertUser>): Promise<User> {
const [updated] = await db.update(users).set(updateData).where(eq(users.id, id)).returning();
return updated;
}
async deleteUser(id: string): Promise<void> {
await db.delete(users).where(eq(users.id, id));
}
/**
* Compteurs du tableau de bord.
*
* Comptés en SQL : les charger en mémoire revenait à lire quatre tables
* entières — tous les posts, toutes les pages, tous les médias, toutes les
* générations — pour n'en garder que le nombre de lignes.
*/
async getDashboardStats(userId: string, range: DashboardStatsRange): Promise<DashboardStats> {
const [postCounts, generationCounts, pageCount, mediaCount] = await Promise.all([
db
.select({
scheduled: sql<string>`count(*) filter (where ${posts.status} = 'scheduled')`,
scheduledLastMonth: sql<string>`count(*) filter (where ${posts.status} = 'scheduled' and ${posts.createdAt} < ${range.lastMonth})`,
})
.from(posts)
.where(eq(posts.userId, userId)),
db
.select({
total: sql<string>`count(*)`,
yesterday: sql<string>`count(*) filter (where ${aiGenerations.createdAt} >= ${range.yesterdayStart} and ${aiGenerations.createdAt} < ${range.yesterdayEnd})`,
})
.from(aiGenerations)
.where(eq(aiGenerations.userId, userId)),
db.select({ total: sql<string>`count(*)` }).from(socialPages).where(eq(socialPages.userId, userId)),
db.select({ total: sql<string>`count(*)` }).from(media).where(eq(media.userId, userId)),
]);
return {
scheduledPosts: Number(postCounts[0]?.scheduled ?? 0),
scheduledPostsLastMonth: Number(postCounts[0]?.scheduledLastMonth ?? 0),
connectedPages: Number(pageCount[0]?.total ?? 0),
aiTextsGenerated: Number(generationCounts[0]?.total ?? 0),
aiTextsYesterday: Number(generationCounts[0]?.yesterday ?? 0),
mediaStored: Number(mediaCount[0]?.total ?? 0),
};
}
// Social Pages
async getSocialPages(userId: string): Promise<SocialPage[]> {
const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId));
return pages.map(page => decryptPageTokens(page));
}
/**
* Toutes les pages de l'instance, pour les vues d'administration.
* Remplace un `getAllUsers()` suivi d'une requête par utilisateur.
*/
async getAllSocialPages(): Promise<SocialPage[]> {
const pages = await db.select().from(socialPages);
return pages.map(page => decryptPageTokens(page));
}
async getSocialPage(id: string): Promise<SocialPage | undefined> {
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id));
return page ? decryptPageTokens(page) : undefined;
}
async createSocialPage(page: InsertSocialPage): Promise<SocialPage> {
// Chiffrer les tokens avant stockage
const encryptedPage = {
...page,
accessToken: encrypt(page.accessToken),
...(page.refreshToken ? { refreshToken: encrypt(page.refreshToken) } : {}),
};
const [newPage] = await db.insert(socialPages).values(encryptedPage).returning();
return decryptPageTokens(newPage);
}
async updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage> {
// Chiffrer les tokens présents dans la mise à jour
const updateData: Partial<SocialPage> = { ...page };
if (page.accessToken) {
updateData.accessToken = encrypt(page.accessToken);
}
if (page.refreshToken) {
updateData.refreshToken = encrypt(page.refreshToken);
}
const [updated] = await db.update(socialPages).set(updateData).where(eq(socialPages.id, id)).returning();
return decryptPageTokens(updated);
}
async deleteSocialPage(id: string): Promise<void> {
await db.delete(socialPages).where(eq(socialPages.id, id));
}
// Media
/**
* Médias d'un utilisateur, du plus récent au plus ancien.
* `limit` sert aux écrans qui n'en affichent qu'une poignée : sans lui, la
* médiathèque entière transitait pour en montrer cinq.
*/
async getMedia(userId: string, options: { limit?: number; offset?: number } = {}): Promise<Media[]> {
let query = db
.select()
.from(media)
.where(eq(media.userId, userId))
.orderBy(desc(media.createdAt))
.$dynamic();
if (options.limit !== undefined) query = query.limit(options.limit);
if (options.offset !== undefined) query = query.offset(options.offset);
return await query;
}
async getMediaByIds(ids: string[]): Promise<Media[]> {
if (ids.length === 0) return [];
return await db.select().from(media).where(inArray(media.id, ids));
}
/** Total de médias, pour que la pagination sache où elle s'arrête. */
async getMediaCount(userId: string): Promise<number> {
const [row] = await db
.select({ total: sql<string>`count(*)` })
.from(media)
.where(eq(media.userId, userId));
return Number(row?.total ?? 0);
}
async getMediaById(id: string): Promise<Media | undefined> {
const [mediaItem] = await db.select().from(media).where(eq(media.id, id));
return mediaItem || undefined;
}
async createMedia(mediaItem: InsertMedia): Promise<Media> {
const [newMedia] = await db.insert(media).values(mediaItem).returning();
return newMedia;
}
async deleteMedia(id: string): Promise<void> {
await db.delete(media).where(eq(media.id, id));
}
async getPostMedia(postId: string): Promise<PostMedia[]> {
return await db.select().from(postMedia).where(eq(postMedia.postId, postId)).orderBy(asc(postMedia.displayOrder));
}
// Posts
async getPosts(userId: string): Promise<Post[]> {
return await db.select().from(posts).where(eq(posts.userId, userId)).orderBy(desc(posts.createdAt));
}
async getPost(id: string): Promise<Post | undefined> {
const [post] = await db.select().from(posts).where(eq(posts.id, id));
return post || undefined;
}
async getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined> {
const post = await this.getPost(id);
if (!post) return undefined;
const postMediaLinks = await this.getPostMedia(id);
// Une seule requête pour tous les médias, puis remise dans l'ordre
// d'affichage : une requête par média rendait le coût proportionnel au
// nombre de visuels du post.
const byId = new Map(
(await this.getMediaByIds(postMediaLinks.map(link => link.mediaId))).map(item => [item.id, item])
);
const mediaItems = postMediaLinks
.map(link => byId.get(link.mediaId))
.filter((item): item is Media => item !== undefined);
return { post, media: mediaItems };
}
async createPost(post: InsertPost): Promise<Post> {
const [newPost] = await db.insert(posts).values(post).returning();
return newPost;
}
async updatePost(id: string, post: Partial<InsertPost>): Promise<Post> {
const [updated] = await db.update(posts).set({ ...post, updatedAt: new Date() }).where(eq(posts.id, id)).returning();
return updated;
}
async deletePost(id: string): Promise<void> {
await db.delete(posts).where(eq(posts.id, id));
}
async updatePostGenerationStatus(
id: string,
status: string,
progress: number,
error?: string
): Promise<Post> {
const updateData: Record<string, unknown> = {
generationStatus: status,
generationProgress: progress,
updatedAt: new Date(),
};
if (error !== undefined) {
updateData.generationError = error;
}
const [updated] = await db
.update(posts)
.set(updateData)
.where(eq(posts.id, id))
.returning();
return updated;
}
async updatePostMedia(postId: string, mediaIds: string[]): Promise<void> {
await db.delete(postMedia).where(eq(postMedia.postId, postId));
if (mediaIds.length > 0) {
const postMediaEntries = mediaIds.map((mediaId, index) => ({
postId,
mediaId,
displayOrder: index,
}));
await db.insert(postMedia).values(postMediaEntries);
}
}
// Scheduled Posts
async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<any[]> {
return this.queryScheduledPosts(eq(posts.userId, userId), startDate, endDate);
}
async getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise<any[]> {
if (pageIds.length === 0) {
return [];
}
return this.queryScheduledPosts(inArray(scheduledPosts.pageId, pageIds), startDate, endDate);
}
/**
* Publications planifiées, avec le post et la page auxquels elles se rapportent.
*
* La période est filtrée en SQL : la charger entière pour la filtrer en
* mémoire revenait à lire tout l'historique à chaque affichage du calendrier.
*/
private async queryScheduledPosts(
scope: SQL,
startDate?: Date,
endDate?: Date
): Promise<any[]> {
const conditions: SQL[] = [scope];
if (startDate) conditions.push(gte(scheduledPosts.scheduledAt, startDate));
if (endDate) conditions.push(lte(scheduledPosts.scheduledAt, endDate));
const rows = await db
.select(SCHEDULED_POST_COLUMNS)
.from(scheduledPosts)
.innerJoin(posts, eq(scheduledPosts.postId, posts.id))
.leftJoin(socialPages, eq(scheduledPosts.pageId, socialPages.id))
.where(conditions.length === 1 ? conditions[0] : and(...conditions))
.orderBy(desc(scheduledPosts.scheduledAt));
// La jointure externe renvoie un objet aux champs nuls quand la page a été
// supprimée ; les appelants attendent `page: null` dans ce cas.
return rows.map(row => ({ ...row, page: row.page?.id ? row.page : null }));
}
async getScheduledPost(id: string): Promise<ScheduledPost | undefined> {
const [scheduledPost] = await db.select().from(scheduledPosts).where(eq(scheduledPosts.id, id));
return scheduledPost || undefined;
}
async getScheduledPostsByPost(postId: string): Promise<ScheduledPost[]> {
return await db.select().from(scheduledPosts).where(eq(scheduledPosts.postId, postId));
}
async createScheduledPost(scheduledPost: InsertScheduledPost): Promise<ScheduledPost> {
const [newScheduledPost] = await db.insert(scheduledPosts).values(scheduledPost).returning();
return newScheduledPost;
}
async updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost> {
const [updated] = await db.update(scheduledPosts).set(scheduledPost).where(eq(scheduledPosts.id, id)).returning();
return updated;
}
async deleteScheduledPost(id: string): Promise<void> {
await db.delete(scheduledPosts).where(eq(scheduledPosts.id, id));
}
async getPendingScheduledPosts(): Promise<ScheduledPost[]> {
const now = new Date();
const results = await db
.select()
.from(scheduledPosts)
.where(
and(
isNull(scheduledPosts.publishedAt),
lte(scheduledPosts.scheduledAt, now)
)
);
return results;
}
/**
* Publications déjà envoyées à TikTok dont le statut final n'est pas encore
* connu (l'API répond de façon asynchrone via un publish_id).
*/
async getScheduledPostsAwaitingPublishStatus(): Promise<ScheduledPost[]> {
return await db
.select()
.from(scheduledPosts)
.where(
and(
isNotNull(scheduledPosts.publishId),
notInArray(scheduledPosts.publishStatus, TERMINAL_PUBLISH_STATUSES)
)
);
}
/**
* Reels dont la génération est en cours, plus ceux qui ont échoué dans les
* dernières 24 h (pour que l'utilisateur en voie la cause), avec l'étape
* courante du rendu. Filtré en SQL : la route est interrogée toutes les
* trois secondes pendant un rendu.
*/
async getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]> {
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
const rows = await db
.select()
.from(posts)
.where(
and(
eq(posts.userId, userId),
or(
inArray(posts.generationStatus, ['processing', 'pending']),
and(eq(posts.generationStatus, 'failed'), gte(posts.updatedAt, since), isNotNull(posts.generationError)),
),
)
)
.orderBy(desc(posts.createdAt));
if (rows.length === 0) return [];
const jobs = await db
.select({ postId: reelJobs.postId, step: reelJobs.step })
.from(reelJobs)
.where(inArray(reelJobs.postId, rows.map((p) => p.id)));
const stepByPost = new Map(jobs.map((j) => [j.postId, j.step]));
return rows.map((post) => ({ ...post, generationStep: stepByPost.get(post.id) ?? null }));
}
/**
* Reels vidéo en file ou en montage : leurs publications planifiées
* n'existent qu'à la fin du rendu, l'agenda les lit donc ici.
*/
async getRenderingReelJobs(): Promise<{ id: string; postId: string | null; createdAt: Date; params: unknown; content: string | null }[]> {
return await db
.select({
id: reelJobs.id,
postId: reelJobs.postId,
createdAt: reelJobs.createdAt,
params: reelJobs.params,
content: posts.content,
})
.from(reelJobs)
.leftJoin(posts, eq(reelJobs.postId, posts.id))
.where(and(eq(reelJobs.kind, 'video'), inArray(reelJobs.status, ['pending', 'processing'])));
}
// AI Generations
async getAiGenerations(userId: string): Promise<AiGeneration[]> {
return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt));
}
async createAiGeneration(generation: InsertAiGeneration): Promise<AiGeneration> {
const [newGeneration] = await db.insert(aiGenerations).values(generation).returning();
return newGeneration;
}
// Cloudinary Config
async getCloudinaryConfig(): Promise<CloudinaryConfig | undefined> {
const [config] = await db.select().from(cloudinaryConfig).limit(1);
return config || undefined;
}
async createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig> {
const [newConfig] = await db.insert(cloudinaryConfig).values(config).returning();
return newConfig;
}
async updateCloudinaryConfig(config: Partial<InsertCloudinaryConfig>): Promise<CloudinaryConfig> {
const existingConfig = await this.getCloudinaryConfig();
if (!existingConfig) throw new Error("Cloudinary config not initialized");
const [updated] = await db.update(cloudinaryConfig)
.set({ ...config, updatedAt: new Date() })
.where(eq(cloudinaryConfig.id, existingConfig.id))
.returning();
return updated;
}
// OpenRouter Config
async getOpenrouterConfig(userId: string): Promise<OpenrouterConfig | undefined> {
const [config] = await db.select().from(openrouterConfig).where(eq(openrouterConfig.userId, userId));
return config || undefined;
}
async getAnyOpenrouterConfig(): Promise<OpenrouterConfig | undefined> {
const [config] = await db.select().from(openrouterConfig).orderBy(desc(openrouterConfig.updatedAt)).limit(1);
return config || undefined;
}
async createOpenrouterConfig(config: InsertOpenrouterConfig): Promise<OpenrouterConfig> {
const [newConfig] = await db.insert(openrouterConfig).values(config).returning();
return newConfig;
}
async updateOpenrouterConfig(userId: string, config: Partial<InsertOpenrouterConfig>): Promise<OpenrouterConfig> {
const [updated] = await db.update(openrouterConfig)
.set({ ...config, updatedAt: new Date() })
.where(eq(openrouterConfig.userId, userId))
.returning();
return updated;
}
// User Page Permissions
async getUserPagePermissions(userId: string): Promise<UserPagePermission[]> {
return await db.select().from(userPagePermissions).where(eq(userPagePermissions.userId, userId));
}
async getPagePermissions(pageId: string): Promise<UserPagePermission[]> {
return await db.select().from(userPagePermissions).where(eq(userPagePermissions.pageId, pageId));
}
async createPagePermission(permission: InsertUserPagePermission): Promise<UserPagePermission> {
const [newPermission] = await db.insert(userPagePermissions).values(permission).returning();
return newPermission;
}
async deletePagePermission(userId: string, pageId: string): Promise<void> {
await db.delete(userPagePermissions).where(
and(
eq(userPagePermissions.userId, userId),
eq(userPagePermissions.pageId, pageId)
)
);
}
async deleteAllUserPagePermissions(userId: string): Promise<void> {
await db.delete(userPagePermissions).where(eq(userPagePermissions.userId, userId));
}
async getUserAccessiblePages(userId: string): Promise<SocialPage[]> {
const permissions = await db
.select()
.from(userPagePermissions)
.innerJoin(socialPages, eq(userPagePermissions.pageId, socialPages.id))
.where(eq(userPagePermissions.userId, userId));
return permissions.map(p => p.social_pages);
}
// Music Favorites
async getMusicFavorites(userId: string): Promise<MusicFavorite[]> {
return db.select().from(musicFavorites)
.where(eq(musicFavorites.userId, userId))
.orderBy(desc(musicFavorites.createdAt));
}
async addMusicFavorite(favorite: InsertMusicFavorite): Promise<MusicFavorite> {
const [created] = await db.insert(musicFavorites).values(favorite).returning();
return created;
}
async removeMusicFavorite(userId: string, trackId: string): Promise<void> {
await db.delete(musicFavorites).where(
and(
eq(musicFavorites.userId, userId),
eq(musicFavorites.trackId, trackId)
)
);
}
async isMusicFavorite(userId: string, trackId: string): Promise<boolean> {
const [fav] = await db.select().from(musicFavorites).where(
and(
eq(musicFavorites.userId, userId),
eq(musicFavorites.trackId, trackId)
)
);
return !!fav;
}
// Audio Tracks
async getAudioTracks(): Promise<AudioTrack[]> {
return db.select().from(audioTracks).orderBy(desc(audioTracks.createdAt));
}
async getAudioTrack(id: string): Promise<AudioTrack | undefined> {
const [track] = await db.select().from(audioTracks).where(eq(audioTracks.id, id));
return track || undefined;
}
async createAudioTrack(track: InsertAudioTrack): Promise<AudioTrack> {
const [newTrack] = await db.insert(audioTracks).values(track).returning();
return newTrack;
}
async deleteAudioTrack(id: string): Promise<void> {
await db.delete(audioTracks).where(eq(audioTracks.id, id));
}
// App Config
async getAppConfig(): Promise<AppConfig | undefined> {
const [config] = await db.select().from(appConfig).limit(1);
return config || undefined;
}
// Facebook Config (application développeur, globale à l'instance)
async getFacebookConfig(): Promise<FacebookConfig | undefined> {
const [config] = await db.select().from(facebookConfig).limit(1);
if (!config) return undefined;
return { ...config, appSecret: decrypt(config.appSecret) };
}
async upsertFacebookConfig(data: Partial<InsertFacebookConfig>): Promise<FacebookConfig> {
const values: Partial<FacebookConfig> = { ...data };
if (data.appSecret) {
values.appSecret = encrypt(data.appSecret);
}
const [existing] = await db.select().from(facebookConfig).limit(1);
if (existing) {
const [updated] = await db.update(facebookConfig)
.set({ ...values, updatedAt: new Date() })
.where(eq(facebookConfig.id, existing.id))
.returning();
return { ...updated, appSecret: decrypt(updated.appSecret) };
}
if (!values.appId || !values.appSecret) {
throw new Error("L'App ID et l'App Secret Facebook sont requis");
}
const [created] = await db.insert(facebookConfig)
.values({ appId: values.appId, appSecret: values.appSecret })
.returning();
return { ...created, appSecret: decrypt(created.appSecret) };
}
// TikTok Config
async getTiktokConfig(): Promise<TiktokConfig | undefined> {
const [config] = await db.select().from(tiktokConfig).limit(1);
if (!config) return undefined;
return { ...config, clientSecret: decrypt(config.clientSecret) };
}
async upsertTiktokConfig(data: Partial<InsertTiktokConfig>): Promise<TiktokConfig> {
const values: Partial<TiktokConfig> = { ...data };
if (data.clientSecret) {
values.clientSecret = encrypt(data.clientSecret);
}
const [existing] = await db.select().from(tiktokConfig).limit(1);
if (existing) {
const [updated] = await db.update(tiktokConfig)
.set({ ...values, updatedAt: new Date() })
.where(eq(tiktokConfig.id, existing.id))
.returning();
return { ...updated, clientSecret: decrypt(updated.clientSecret) };
}
if (!values.clientKey || !values.clientSecret) {
throw new Error("Le client key et le client secret TikTok sont requis");
}
const [created] = await db.insert(tiktokConfig)
.values({ clientKey: values.clientKey, clientSecret: values.clientSecret })
.returning();
return { ...created, clientSecret: decrypt(created.clientSecret) };
}
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey' | 'qwenTtsUrl' | 'qwenTtsApiKey'>>): Promise<AppConfig> {
const existing = await this.getAppConfig();
if (existing) {
const [updated] = await db.update(appConfig)
.set({ ...data, updatedAt: new Date() })
.where(eq(appConfig.id, existing.id))
.returning();
return updated;
}
const [created] = await db.insert(appConfig).values(data).returning();
return created;
}
}
export const storage = new DatabaseStorage();
-152
View File
@@ -1,152 +0,0 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
// Chemin du fichier de clé - utilise le volume Docker si disponible, sinon le répertoire courant
const KEY_DIR = fs.existsSync('/app/.encryption-key-data')
? '/app/.encryption-key-data'
: process.cwd();
const KEY_FILE_PATH = path.join(KEY_DIR, '.encryption-key');
// Cache de la clé pour éviter de lire le fichier à chaque appel
let cachedKey: Buffer | null = null;
let keyInitialized = false;
/**
* Génère une clé de chiffrement aléatoire et la sauvegarde dans un fichier.
* @returns La clé générée
*/
function generateAndSaveKey(): Buffer {
const randomKey = crypto.randomBytes(32).toString('hex');
try {
fs.writeFileSync(KEY_FILE_PATH, randomKey, { mode: 0o600 }); // Permissions restrictives
console.log('🔐 Nouvelle clé de chiffrement générée et sauvegardée automatiquement');
} catch (error) {
console.warn('⚠️ Impossible de sauvegarder la clé de chiffrement dans un fichier. Elle sera régénérée au prochain redémarrage.');
}
return crypto.scryptSync(randomKey, 'socialflow-salt', 32);
}
/**
* Charge la clé depuis le fichier persistant si elle existe.
* @returns La clé ou null si non trouvée
*/
function loadKeyFromFile(): Buffer | null {
try {
if (fs.existsSync(KEY_FILE_PATH)) {
const savedKey = fs.readFileSync(KEY_FILE_PATH, 'utf8').trim();
if (savedKey) {
console.log('🔐 Clé de chiffrement chargée depuis le fichier persistant');
return crypto.scryptSync(savedKey, 'socialflow-salt', 32);
}
}
} catch (error) {
console.warn('⚠️ Impossible de lire le fichier de clé de chiffrement');
}
return null;
}
/**
* Récupère la clé de chiffrement.
* Priorité: 1) Variable d'environnement, 2) Fichier persistant, 3) Génération auto
*/
function getEncryptionKey(): Buffer {
// Retourner la clé en cache si déjà initialisée
if (keyInitialized && cachedKey) {
return cachedKey;
}
// 1. Priorité à la variable d'environnement
const envKey = process.env.ENCRYPTION_KEY;
if (envKey) {
cachedKey = crypto.scryptSync(envKey, 'socialflow-salt', 32);
keyInitialized = true;
return cachedKey;
}
// 2. Essayer de charger depuis le fichier persistant
const fileKey = loadKeyFromFile();
if (fileKey) {
cachedKey = fileKey;
keyInitialized = true;
return cachedKey;
}
// 3. Générer automatiquement une nouvelle clé et la sauvegarder
cachedKey = generateAndSaveKey();
keyInitialized = true;
return cachedKey;
}
/**
* Chiffre une chaîne de texte avec AES-256-GCM.
* @param text - Le texte à chiffrer
* @returns Le texte chiffré au format: iv:authTag:encrypted (hex)
*/
export function encrypt(text: string): string {
const key = getEncryptionKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Format: iv:authTag:encrypted
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
/**
* Déchiffre une chaîne chiffrée avec AES-256-GCM.
* @param encryptedText - Le texte chiffré au format iv:authTag:encrypted
* @returns Le texte déchiffré
*/
export function decrypt(encryptedText: string): string {
// Si le texte ne contient pas le format attendu, retourner tel quel
// (pour la rétrocompatibilité avec les tokens non chiffrés)
if (!encryptedText.includes(':')) {
return encryptedText;
}
const key = getEncryptionKey();
const parts = encryptedText.split(':');
if (parts.length !== 3) {
// Format invalide, retourner tel quel (rétrocompatibilité)
return encryptedText;
}
const [ivHex, authTagHex, encrypted] = parts;
try {
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
// Si le déchiffrement échoue, retourner tel quel (rétrocompatibilité)
console.warn('⚠️ Échec du déchiffrement, token probablement non chiffré');
return encryptedText;
}
}
/**
* Vérifie si un texte est déjà chiffré (format iv:authTag:encrypted).
* @param text - Le texte à vérifier
* @returns true si le texte semble être chiffré
*/
export function isEncrypted(text: string): boolean {
if (!text.includes(':')) return false;
const parts = text.split(':');
if (parts.length !== 3) return false;
// Vérifier que les parties ressemblent à du hex
return parts.every(part => /^[a-f0-9]+$/i.test(part));
}
-94
View File
@@ -1,94 +0,0 @@
export class FacebookApiError extends Error {
constructor(
public message: string,
public status: number,
public code: number,
public fbTraceId: string
) {
super(message);
this.name = 'FacebookApiError';
}
}
interface GraphRequestConfig {
params?: Record<string, string | number | boolean>;
accessToken?: string;
}
const GRAPH_API_VERSION = 'v19.0';
const BASE_URL = `https://graph.facebook.com/${GRAPH_API_VERSION}`;
export class GraphAPIClient {
static async get<T>(endpoint: string, config: GraphRequestConfig): Promise<T> {
const url = this.buildUrl(endpoint, config);
console.log(`[GraphAPI] GET ${endpoint}`);
// In node-fetch v2 (which is typically used in CJS/TS mixes unless v3 ESM), basic fetch
// If project uses v3, imports might differ. Assuming standard fetch is available or polyfilled.
// socialflow package.json has "@types/node-fetch": "^2.6.13" but also "node-fetch" isn't explicitly in dependencies?
// Wait, package.json has "node-fetch" missing in dependencies list!
// But it has "@types/node-fetch".
// "canvas" is there. "express" is there.
// I need to check if fetch is globally available (Node 18+) or if I need to use `axios` or add `node-fetch`.
// Package.json has: "type": "module". Node 20.16.11 is used in parsing.
// Node 18+ has native fetch. I should use `global.fetch` or just `fetch`.
// Explicit import `import fetch from 'node-fetch'` might fail if not installed.
// I will use native fetch.
// Remove import above if using native.
const response = await fetch(url.toString());
return this.handleResponse<T>(response);
}
static async post<T>(endpoint: string, body: any, config: GraphRequestConfig): Promise<T> {
const url = this.buildUrl(endpoint, config);
console.log(`[GraphAPI] POST ${endpoint}`);
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
return this.handleResponse<T>(response);
}
private static buildUrl(endpoint: string, config: GraphRequestConfig): URL {
const url = new URL(`${BASE_URL}${endpoint.startsWith('/') ? endpoint : '/' + endpoint}`);
if (config.accessToken) {
url.searchParams.append('access_token', config.accessToken);
}
if (config.params) {
Object.entries(config.params).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
}
return url;
}
private static async handleResponse<T>(response: Response): Promise<T> {
const data: any = await response.json();
if (!response.ok) {
// Parse FB Error structure
const error = data.error || {};
throw new FacebookApiError(
error.message || 'Unknown Facebook API Error',
response.status,
error.code || 0,
error.fbtrace_id || ''
);
}
return data as T;
}
}
-47
View File
@@ -1,47 +0,0 @@
/**
* URL publique de l'application.
*
* Les flux OAuth (Facebook, TikTok) exigent une URI de redirection *exacte* :
* celle envoyée au moment de l'autorisation doit être identique à celle
* envoyée lors de l'échange du code, et déclarée telle quelle chez le
* fournisseur. Se rabattre en silence sur `http://localhost:5555` quand
* `APP_URL` n'est pas défini produit une URI que Facebook refuse — et
* l'erreur n'apparaît qu'au moment de connecter une page.
*
* On préfère donc, à défaut d'`APP_URL`, reconstruire l'URL à partir de la
* requête entrante : derrière Nginx, les en-têtes `X-Forwarded-Proto` et
* `X-Forwarded-Host` portent le domaine public réel (`trust proxy` est activé
* dans `server/index.ts`). Le repli localhost ne sert plus qu'au développement
* local, hors requête HTTP.
*/
import type { Request } from 'express';
const DEV_FALLBACK = 'http://localhost:5555';
/** Premier élément d'un en-tête potentiellement chaîné (« a, b, c »). */
function firstHeaderValue(value: string | undefined): string | undefined {
const first = value?.split(',')[0]?.trim();
return first || undefined;
}
/**
* Base publique sans slash final (« https://exemple.fr »).
* `APP_URL` prime toujours ; sinon la requête fait foi.
*/
export function resolvePublicBaseUrl(req?: Request): string {
const configured = process.env.APP_URL?.trim();
if (configured) {
return configured.replace(/\/$/, '');
}
if (req) {
const host = firstHeaderValue(req.get('x-forwarded-host')) || req.get('host');
if (host) {
const protocol = firstHeaderValue(req.get('x-forwarded-proto')) || req.protocol || 'http';
return `${protocol}://${host}`;
}
}
return DEV_FALLBACK;
}
-85
View File
@@ -1,85 +0,0 @@
import express, { type Express } from "express";
import fs from "fs";
import path from "path";
import { createServer as createViteServer, createLogger } from "vite";
import { type Server } from "http";
import viteConfig from "../vite.config";
import { nanoid } from "nanoid";
const viteLogger = createLogger();
export function log(message: string, source = "express") {
const formattedTime = new Date().toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
console.log(`${formattedTime} [${source}] ${message}`);
}
export async function setupVite(app: Express, server: Server) {
const serverOptions = {
middlewareMode: true,
hmr: { server },
allowedHosts: true as const,
};
const vite = await createViteServer({
...viteConfig,
configFile: false,
customLogger: {
...viteLogger,
error: (msg, options) => {
viteLogger.error(msg, options);
process.exit(1);
},
},
server: serverOptions,
appType: "custom",
});
app.use(vite.middlewares);
app.use("*", async (req, res, next) => {
const url = req.originalUrl;
try {
const clientTemplate = path.resolve(
import.meta.dirname,
"..",
"client",
"index.html",
);
// always reload the index.html file from disk incase it changes
let template = await fs.promises.readFile(clientTemplate, "utf-8");
template = template.replace(
`src="/src/main.tsx"`,
`src="/src/main.tsx?v=${nanoid()}"`,
);
const page = await vite.transformIndexHtml(url, template);
res.status(200).set({ "Content-Type": "text/html" }).end(page);
} catch (e) {
vite.ssrFixStacktrace(e as Error);
next(e);
}
});
}
export function serveStatic(app: Express) {
const distPath = path.resolve(import.meta.dirname, "public");
if (!fs.existsSync(distPath)) {
throw new Error(
`Could not find the build directory: ${distPath}, make sure to build the client first`,
);
}
app.use(express.static(distPath));
// fall through to index.html if the file doesn't exist
app.use("*", (_req, res) => {
res.sendFile(path.resolve(distPath, "index.html"));
});
}