Files
Socialflow/server/routes.ts
T
Claude c097c5c8f3 fix(openrouter): nettoyer la clé API souillée par un copier-coller
OpenRouter répondait « 401 Missing Authentication header », ce qui laisse
croire à un en-tête absent alors qu'il était bien envoyé.

Vérification faite contre l'API : ce message n'apparaît pas quand la clé
est invalide (l'API répond alors « User not found »), mais quand le jeton
placé derrière « Bearer » n'a pas la forme d'une clé — préfixe « Bearer »
recopié depuis un exemple, guillemets laissés autour de la valeur, ou
caractère invisible ramassé lors d'un copier-coller. String.trim() ne
retire pas ces derniers.

La clé est désormais nettoyée à l'enregistrement et à la lecture ; ce
second point remet en marche les clés déjà stockées sans ressaisie.

En cas de 401, le message indique en plus la longueur de la clé et si
elle commence par « sk-or- », sans jamais en révéler le contenu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-12 08:14:47 +00:00

1965 lines
69 KiB
TypeScript

import express, { type Express, type Request, type Response, type NextFunction } from "express";
import { createServer, type Server } from "http";
import fs from "fs";
import os from "os";
import path from "path";
import { storage } from "./storage";
import { db, pool } from "./db";
import multer from "multer";
import bcrypt from "bcrypt";
import passport from "./auth";
import { z } from "zod";
import { openRouterService, describeGenerationError, sanitizeApiKey } from "./services/openrouter";
import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
import { analyticsRouter } from "./routes/analytics";
import { reelsRouter } from "./routes/reels";
import { remotionRouter } from "./routes/remotion";
import { externalRouter } from "./routes/external";
import { tiktokRouter } from "./routes/tiktok";
import { legalRouter } from "./routes/legal";
import { insertAudioTrackSchema } from "@shared/schema";
import * as musicMetadata from "music-metadata";
// Types MIME autorisés pour les uploads
const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'video/mp4', 'video/quicktime', 'video/webm'
];
// Types MIME autorisés pour l'audio
const ALLOWED_AUDIO_MIME_TYPES = [
'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg'
];
// Configuration multer avec validation de taille et type
// Configuration multer avec validation de taille (4GB) et stockage disque temporaire
const upload = multer({
storage: multer.diskStorage({
destination: os.tmpdir(),
filename: (req, file, cb) => {
// Nettoyer le nom de fichier pour éviter les problèmes d'encodage
const safeName = file.originalname.replace(/[^a-zA-Z0-9.]/g, '_');
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + '-' + safeName);
}
}),
limits: {
fileSize: 4 * 1024 * 1024 * 1024, // 4GB max
files: 10 // 10 fichiers max
},
fileFilter: (req, file, cb) => {
// Accepter plus de formats vidéo si nécessaire (ex: mkv, avi) pour le transcodage
// Mais on garde les limites actuelles pour l'instant
if (ALLOWED_MIME_TYPES.includes(file.mimetype) || file.mimetype === 'application/octet-stream') { // iOS envoie parfois octet-stream
cb(null, true);
} else {
cb(new Error(`Type de fichier non autorisé: ${file.mimetype}`));
}
}
});
// Setup audio upload with different filters
// Files saved directly to the persistent local folder
const AUDIO_UPLOAD_DIR = process.env.AUDIO_UPLOAD_DIR || '/app/uploads/audio';
// Ensure the directory exists at startup
import { mkdirSync } from 'fs';
try { mkdirSync(AUDIO_UPLOAD_DIR, { recursive: true }); } catch { /* already exists */ }
const audioUpload = multer({
storage: multer.diskStorage({
destination: AUDIO_UPLOAD_DIR,
filename: (req, file, cb) => {
const safeName = file.originalname.replace(/[^a-zA-Z0-9.]/g, '_');
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + '-' + safeName);
}
}),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB max for audio
files: 20
},
fileFilter: (req, file, cb) => {
if (ALLOWED_AUDIO_MIME_TYPES.includes(file.mimetype) || file.originalname.match(/\.(mp3|wav|ogg)$/i)) {
cb(null, true);
} else {
cb(new Error(`Type de fichier audio non autorisé: ${file.mimetype}`));
}
}
});
// Middleware pour vérifier l'authentification
function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: "Non authentifié" });
}
next();
}
// Middleware pour vérifier le rôle admin
function requireAdmin(req: Request, res: Response, next: NextFunction) {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: "Non authentifié" });
}
const user = req.user as User;
if (user.role !== "admin") {
return res.status(403).json({ error: "Accès refusé. Réservé aux administrateurs." });
}
next();
}
export async function registerRoutes(app: Express): Promise<Server> {
// Routes d'authentification
app.post("/api/auth/login", (req, res, next) => {
passport.authenticate("local", (err: any, user: User | false, info: any) => {
if (err) {
return next(err);
}
if (!user) {
return res.status(401).json({ error: info?.message || "Authentification échouée" });
}
req.logIn(user, (err) => {
if (err) {
return next(err);
}
return res.json({
id: user.id,
username: user.username,
role: user.role,
});
});
})(req, res, next);
});
app.post("/api/auth/logout", (req, res) => {
req.logout((err) => {
if (err) {
return res.status(500).json({ error: "Erreur lors de la déconnexion" });
}
res.json({ message: "Déconnecté avec succès" });
});
});
app.get("/api/auth/session", (req, res) => {
if (req.isAuthenticated()) {
const user = req.user as User;
res.json({
id: user.id,
username: user.username,
role: user.role,
});
} else {
res.status(401).json({ error: "Non authentifié" });
}
});
// Route pour obtenir la liste de tous les utilisateurs (réservée aux admins)
app.get("/api/users", requireAdmin, async (req, res) => {
try {
const allUsers = await storage.getAllUsers();
// Ne pas envoyer les mots de passe
const safeUsers = allUsers.map(user => ({
id: user.id,
username: user.username,
role: user.role,
}));
res.json(safeUsers);
} catch (error) {
console.error("Error fetching users:", error);
res.status(500).json({ error: "Erreur lors de la récupération des utilisateurs" });
}
});
// Route pour créer un nouvel utilisateur (réservée aux admins)
app.post("/api/users", requireAdmin, async (req, res) => {
try {
// Validation Zod
const createUserSchema = insertUserSchema.extend({
username: insertUserSchema.shape.username.min(3, "Le nom d'utilisateur doit contenir au moins 3 caractères"),
password: insertUserSchema.shape.password.min(4, "Le mot de passe doit contenir au moins 4 caractères"),
});
const validatedData = createUserSchema.parse(req.body);
// Vérifier si l'utilisateur existe déjà
const existingUser = await storage.getUserByUsername(validatedData.username);
if (existingUser) {
return res.status(409).json({ error: "Ce nom d'utilisateur existe déjà" });
}
// Hasher le mot de passe
const hashedPassword = await bcrypt.hash(validatedData.password, 10);
const newUser = await storage.createUser({
username: validatedData.username,
password: hashedPassword,
role: validatedData.role || "user",
});
res.json({
id: newUser.id,
username: newUser.username,
role: newUser.role,
});
} catch (error: any) {
console.error("Error creating user:", error);
if (error.name === 'ZodError') {
return res.status(400).json({ error: error.errors[0]?.message || "Données invalides" });
}
res.status(500).json({ error: "Erreur lors de la création de l'utilisateur" });
}
});
// Route pour modifier un utilisateur (réservée aux admins)
app.patch("/api/users/:id", requireAdmin, async (req, res) => {
try {
const userId = req.params.id;
const { username, password, role } = req.body;
// Vérifier si l'utilisateur existe
const existingUser = await storage.getUser(userId);
if (!existingUser) {
return res.status(404).json({ error: "Utilisateur non trouvé" });
}
const updateData: Partial<InsertUser> = {};
if (username && username !== existingUser.username) {
// Vérifier si le nouveau username est déjà pris
const userWithSameUsername = await storage.getUserByUsername(username);
if (userWithSameUsername && userWithSameUsername.id !== userId) {
return res.status(409).json({ error: "Ce nom d'utilisateur est déjà utilisé" });
}
updateData.username = username;
}
if (password) {
// Hasher le nouveau mot de passe
updateData.password = await bcrypt.hash(password, 10);
}
if (role && (role === "admin" || role === "user")) {
updateData.role = role;
}
if (Object.keys(updateData).length === 0) {
return res.status(400).json({ error: "Aucune modification fournie" });
}
const updatedUser = await storage.updateUser(userId, updateData);
res.json({
id: updatedUser.id,
username: updatedUser.username,
role: updatedUser.role,
});
} catch (error: any) {
console.error("Error updating user:", error);
res.status(500).json({ error: "Erreur lors de la modification de l'utilisateur" });
}
});
// Route pour supprimer un utilisateur (réservée aux admins)
app.delete("/api/users/:id", requireAdmin, async (req, res) => {
try {
const userId = req.params.id;
const currentUser = req.user as User;
// Empêcher l'admin de se supprimer lui-même
if (userId === currentUser.id) {
return res.status(400).json({ error: "Vous ne pouvez pas supprimer votre propre compte" });
}
// Vérifier si l'utilisateur existe
const existingUser = await storage.getUser(userId);
if (!existingUser) {
return res.status(404).json({ error: "Utilisateur non trouvé" });
}
await storage.deleteUser(userId);
res.json({ success: true, message: "Utilisateur supprimé avec succès" });
} catch (error: any) {
console.error("Error deleting user:", error);
res.status(500).json({ error: "Erreur lors de la suppression de l'utilisateur" });
}
});
// Route pour obtenir les permissions d'un utilisateur (réservée aux admins)
app.get("/api/users/:id/page-permissions", requireAdmin, async (req, res) => {
try {
const userId = req.params.id;
const permissions = await storage.getUserPagePermissions(userId);
res.json(permissions);
} catch (error) {
console.error("Error fetching user permissions:", error);
res.status(500).json({ error: "Erreur lors de la récupération des permissions" });
}
});
// Route pour mettre à jour les permissions d'un utilisateur (réservée aux admins)
app.post("/api/users/:id/page-permissions", requireAdmin, async (req, res) => {
try {
const userId = req.params.id;
const { pageIds } = req.body as { pageIds: string[] };
if (!Array.isArray(pageIds)) {
return res.status(400).json({ error: "pageIds doit être un tableau" });
}
// Supprimer toutes les permissions existantes de l'utilisateur
await storage.deleteAllUserPagePermissions(userId);
// Créer les nouvelles permissions
const permissions = await Promise.all(
pageIds.map(pageId =>
storage.createPagePermission({ userId, pageId })
)
);
res.json(permissions);
} catch (error) {
console.error("Error updating user permissions:", error);
res.status(500).json({ error: "Erreur lors de la mise à jour des permissions" });
}
});
// Route pour migrer les permissions existantes (réservée aux admins)
app.post("/api/admin/migrate-permissions", requireAdmin, async (req, res) => {
try {
// Récupérer tous les utilisateurs
const allUsers = await storage.getAllUsers();
let migratedCount = 0;
for (const user of allUsers) {
// Récupérer toutes les pages créées par cet utilisateur
const userPages = await storage.getSocialPages(user.id);
for (const page of userPages) {
// Vérifier si une permission existe déjà
const existingPermissions = await storage.getUserPagePermissions(user.id);
const hasPermission = existingPermissions.some(p => p.pageId === page.id);
if (!hasPermission) {
// Créer la permission
await storage.createPagePermission({ userId: user.id, pageId: page.id });
migratedCount++;
}
}
}
res.json({
success: true,
message: `${migratedCount} permissions migrées avec succès`,
migratedCount
});
} catch (error) {
console.error("Error migrating permissions:", error);
res.status(500).json({ error: "Erreur lors de la migration des permissions" });
}
});
// Route pour vérifier si le mot de passe admin par défaut a été changé
app.get("/api/auth/default-password-status", async (req, res) => {
try {
const adminUser = await storage.getUserByUsername("admin");
if (!adminUser) {
return res.json({ isDefault: false });
}
// Vérifier si le mot de passe correspond à "admin"
const isDefaultPassword = await bcrypt.compare("admin", adminUser.password);
res.json({ isDefault: isDefaultPassword });
} catch (error) {
console.error("Error checking default password status:", error);
res.status(500).json({ error: "Erreur lors de la vérification du statut du mot de passe" });
}
});
// External API (clé API, pas de session)
app.use("/api/v1", externalRouter);
// Paramètres de l'API externe (admin seulement)
app.get("/api/settings/external-api", requireAdmin, async (req, res) => {
try {
const config = await storage.getAppConfig();
res.json({ configured: !!(config?.externalApiKey) });
} catch (error) {
console.error("Error fetching app config:", error);
res.status(500).json({ error: "Erreur lors de la récupération de la configuration" });
}
});
app.post("/api/settings/external-api", requireAdmin, async (req, res) => {
try {
const { apiKey } = req.body;
if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") {
return res.status(400).json({ error: "La clé API est requise" });
}
await storage.upsertAppConfig({ externalApiKey: apiKey.trim() });
res.json({ success: true, configured: true });
} catch (error) {
console.error("Error saving app config:", error);
res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" });
}
});
app.delete("/api/settings/external-api", requireAdmin, async (req, res) => {
try {
await storage.upsertAppConfig({ externalApiKey: null });
res.json({ success: true, configured: false });
} catch (error) {
console.error("Error deleting app config:", error);
res.status(500).json({ error: "Erreur lors de la suppression de la clé" });
}
});
// Google Gemini API Key (TTS)
app.get("/api/settings/gemini", requireAdmin, async (req, res) => {
try {
const config = await storage.getAppConfig();
res.json({ configured: !!(config?.geminiApiKey) });
} catch (error) {
console.error("Error fetching Gemini config:", error);
res.status(500).json({ error: "Erreur lors de la récupération de la configuration" });
}
});
app.post("/api/settings/gemini", requireAdmin, async (req, res) => {
try {
const { apiKey } = req.body;
if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") {
return res.status(400).json({ error: "La clé API est requise" });
}
await storage.upsertAppConfig({ geminiApiKey: apiKey.trim() });
res.json({ success: true, configured: true });
} catch (error) {
console.error("Error saving Gemini config:", error);
res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" });
}
});
app.delete("/api/settings/gemini", requireAdmin, async (req, res) => {
try {
await storage.upsertAppConfig({ geminiApiKey: null });
res.json({ success: true, configured: false });
} catch (error) {
console.error("Error deleting Gemini config:", error);
res.status(500).json({ error: "Erreur lors de la suppression de la clé" });
}
});
// Analytics Routes
app.use("/api/analytics", requireAuth, analyticsRouter);
// Fichiers servis à la racine du domaine, sans authentification : vérification
// de propriété du domaine par TikTok ("URL properties"), Google, Meta…
// Monté ici pour être servi avant le catch-all du frontend, et sans dépendre
// d'une reconstruction du client.
app.use(express.static(path.resolve(import.meta.dirname, "..", "public-root"), {
dotfiles: "ignore",
index: false,
}));
// Pages légales publiques (exigées par TikTok pour l'audit de l'application)
app.use(legalRouter);
// TikTok Routes (OAuth multi-comptes) — avant le routeur Reels monté sur /api
app.use("/api/tiktok", requireAuth, tiktokRouter);
// Reels & Music Routes
app.use("/api", requireAuth, reelsRouter);
app.use("/api/remotion", requireAuth, remotionRouter);
// Route SQL (réservée aux admins) - DÉSACTIVÉE EN PRODUCTION sauf si explicitement autorisée
app.post("/api/sql/execute", requireAdmin, async (req, res) => {
// Vérification de sécurité désactivée pour permettre l'accès
// if (process.env.NODE_ENV === 'production' && process.env.ALLOW_SQL_CONSOLE !== 'true') { ... }
try {
// Validation Zod
const sqlQuerySchema = z.object({
query: z.string().min(1, "La requête SQL ne peut pas être vide"),
});
const validatedData = sqlQuerySchema.parse(req.body);
const { db } = await import("./db");
const result = await db.execute(validatedData.query);
res.json({
success: true,
result,
});
} catch (error: any) {
console.error("Error executing SQL:", error);
if (error.name === 'ZodError') {
return res.status(400).json({
success: false,
error: error.errors[0]?.message || "Données invalides",
});
}
res.status(500).json({
success: false,
error: error.message || "Erreur lors de l'exécution de la requête SQL",
});
}
});
// Route pour obtenir la liste des tables (réservée aux admins)
app.get("/api/sql/tables", requireAdmin, async (req, res) => {
try {
const { db } = await import("./db");
const result = await db.execute<{ tablename: string }>(
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;`
);
res.json({
tables: result.rows || result,
});
} catch (error: any) {
console.error("Error fetching tables:", error);
res.status(500).json({
error: error.message || "Erreur lors de la récupération des tables",
});
}
});
// Stats endpoint
app.get("/api/stats", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const posts = await storage.getPosts(userId);
const pages = await storage.getSocialPages(userId);
const media = await storage.getMedia(userId);
const aiGenerations = await storage.getAiGenerations(userId);
// Statistiques actuelles
const scheduledPosts = posts.filter(p => p.status === "scheduled").length;
const currentAiTexts = aiGenerations.length;
const currentMedia = media.length;
// Calculer les statistiques de la période précédente (hier pour les textes IA, mois dernier pour les posts)
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
yesterday.setHours(0, 0, 0, 0);
const lastMonth = new Date(now);
lastMonth.setMonth(lastMonth.getMonth() - 1);
// Textes IA générés hier
const aiTextsYesterday = aiGenerations.filter(gen => {
if (!gen.createdAt) return false;
const genDate = new Date(gen.createdAt);
genDate.setHours(0, 0, 0, 0);
return genDate.getTime() === yesterday.getTime();
}).length;
// Posts planifiés le mois dernier
const scheduledPostsLastMonth = posts.filter(p => {
if (!p.createdAt) return false;
const createdDate = new Date(p.createdAt);
return p.status === "scheduled" && createdDate < lastMonth;
}).length;
// Calculer les variations en pourcentage
const aiTextChange = aiTextsYesterday > 0
? Math.round(((currentAiTexts - aiTextsYesterday) / aiTextsYesterday) * 100)
: currentAiTexts > 0 ? 100 : 0;
const scheduledPostsChange = scheduledPostsLastMonth > 0
? Math.round(((scheduledPosts - scheduledPostsLastMonth) / scheduledPostsLastMonth) * 100)
: scheduledPosts > 0 ? 100 : 0;
res.json({
scheduledPosts,
scheduledPostsChange: scheduledPostsChange > 0 ? `+${scheduledPostsChange}%` : `${scheduledPostsChange}%`,
scheduledPostsTrending: scheduledPostsChange >= 0 ? "up" : "down",
connectedPages: pages.length,
aiTextsGenerated: currentAiTexts,
aiTextsChange: aiTextChange > 0 ? `+${aiTextChange}%` : `${aiTextChange}%`,
aiTextsTrending: aiTextChange >= 0 ? "up" : "down",
mediaStored: media.length,
});
} catch (error) {
console.error("Error fetching stats:", error);
res.status(500).json({ error: "Failed to fetch stats" });
}
});
// Get available AI models from OpenRouter
app.get("/api/ai/models", requireAdmin, async (req, res) => {
try {
const models = await openRouterService.getAvailableModels();
res.json({ models });
} catch (error) {
console.error("Error fetching models:", error);
res.status(500).json({ error: "Failed to fetch models" });
}
});
// AI text generation
app.post("/api/ai/generate", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const { model, ...productInfo } = req.body;
const generatedTexts = await openRouterService.generatePostText(productInfo, userId, model);
// Save to database
await storage.createAiGeneration({
userId,
productInfo,
generatedTexts,
});
res.json({ variants: generatedTexts });
} catch (error) {
console.error("Error generating text:", error);
const { status, message } = describeGenerationError(error);
res.status(status).json({ error: message });
}
});
// Media upload
app.post("/api/media/upload", requireAuth, upload.single("file"), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: "No file uploaded" });
}
console.log(`📂 Processing upload: ${req.file.originalname} (${(req.file.size / 1024 / 1024).toFixed(2)} MB)`);
const user = req.user as User;
const userId = user.id;
// Upload using file path (streamed from disk)
// req.file.path est disponible avec DiskStorage
const uploadResult = await cloudinaryService.uploadMedia(
req.file.path,
req.file.originalname,
userId,
req.file.mimetype
);
// Clean up temp file immediately after upload to free disk space
await fs.promises.unlink(req.file.path).catch(err => console.error("Failed to cleanup temp file:", err));
const mediaItem = await storage.createMedia({
userId,
// Détection basique basée sur le mimetype ou le résultat Cloudinary
type: req.file.mimetype.startsWith("video/") || req.file.originalname.match(/\.(mp4|mov|avi|mkv)$/i) ? "video" : "image",
cloudinaryPublicId: uploadResult.publicId,
originalUrl: uploadResult.originalUrl,
facebookFeedUrl: uploadResult.facebookFeedUrl,
instagramFeedUrl: uploadResult.instagramFeedUrl,
instagramStoryUrl: uploadResult.instagramStoryUrl,
fileName: req.file.originalname,
fileSize: req.file.size,
});
res.json(mediaItem);
} catch (error) {
console.error("Error uploading media:", error);
// Attempt cleanup on error
if (req.file && req.file.path) {
await fs.promises.unlink(req.file.path).catch(() => { });
}
const errorMessage = error instanceof Error ? error.message : "Failed to upload media";
res.status(500).json({ error: errorMessage });
}
});
// One-time migration: fix absolute localhost URLs → relative paths in media table
app.post("/api/media/fix-urls", requireAuth, async (req, res) => {
try {
const user = req.user as User;
if ((user as any).role !== 'admin') {
return res.status(403).json({ error: "Admin only" });
}
// Convert any absolute URL like http(s)://hostname/uploads/... → /uploads/...
const result = await pool.query(`
UPDATE media SET
original_url = regexp_replace(original_url, '^https?://[^/]+(/uploads/)', '\\1'),
facebook_feed_url = regexp_replace(facebook_feed_url, '^https?://[^/]+(/uploads/)', '\\1'),
instagram_feed_url = regexp_replace(instagram_feed_url, '^https?://[^/]+(/uploads/)', '\\1'),
instagram_story_url= regexp_replace(instagram_story_url,'^https?://[^/]+(/uploads/)', '\\1')
WHERE
original_url ~ '^https?://[^/]+/uploads/'
OR facebook_feed_url ~ '^https?://[^/]+/uploads/'
OR instagram_feed_url ~ '^https?://[^/]+/uploads/'
OR instagram_story_url ~ '^https?://[^/]+/uploads/'
`);
res.json({ updated: result.rowCount });
} catch (error) {
console.error("Error fixing media URLs:", error);
res.status(500).json({ error: "Migration failed" });
}
});
// Get media
app.get("/api/media", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const media = await storage.getMedia(userId);
res.json(media);
} catch (error) {
console.error("Error fetching media:", error);
res.status(500).json({ error: "Failed to fetch media" });
}
});
// Apply overlays to image using Sharp (server-side processing)
// IMPORTANT: This must be BEFORE /api/media/:id to avoid being intercepted by :id parameter
app.post("/api/media/apply-overlays", requireAuth, async (req, res) => {
try {
const { imageUrl, ribbon, priceBadge, logo } = req.body;
const user = req.user as User;
const userId = user.id;
if (!imageUrl) {
return res.status(400).json({ error: "Image URL required" });
}
console.log("🎨 Applying overlays server-side with Sharp...");
console.log("📥 Image URL:", imageUrl);
console.log("🎀 Ribbon:", ribbon);
console.log("💰 Price Badge:", priceBadge);
console.log("🏢 Logo:", logo);
// Download image from URL
const imageResponse = await fetch(imageUrl);
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
console.log("✅ Image downloaded:", imageBuffer.length, "bytes");
// Load with Sharp to process
const sharp = (await import("sharp")).default;
let image = sharp(imageBuffer).rotate();
// Get image metadata for dimensions
const metadata = await image.metadata();
const width = metadata.width!;
const height = metadata.height!;
console.log(`📐 Image dimensions: ${width}x${height}`);
// Create SVG overlays
const overlays: any[] = [];
// Add ribbon overlay
if (ribbon) {
const ribbonSize = 150;
const color = ribbon.color === 'red' ? '#dc2626' : '#eab308';
const fontSize = ribbon.text.length <= 5 ? 22 : ribbon.text.length <= 8 ? 18 : ribbon.text.length <= 11 ? 15 : 12;
// Position mapping for each corner
const positions: Record<string, { x: number; y: number; polygon: string; textX: number; textY: number; textRotation: number }> = {
north_west: {
x: 0,
y: 0,
polygon: `0,0 ${ribbonSize},0 0,${ribbonSize}`, // Top-left triangle
textX: ribbonSize * 0.3,
textY: ribbonSize * 0.3,
textRotation: -45
},
north_east: {
x: width - ribbonSize,
y: 0,
polygon: `0,0 ${ribbonSize},0 ${ribbonSize},${ribbonSize}`, // Top-right triangle
textX: ribbonSize * 0.7,
textY: ribbonSize * 0.3,
textRotation: 45
},
south_west: {
x: 0,
y: height - ribbonSize,
polygon: `0,0 0,${ribbonSize} ${ribbonSize},${ribbonSize}`, // Bottom-left triangle
textX: ribbonSize * 0.3,
textY: ribbonSize * 0.7,
textRotation: -135
},
south_east: {
x: width - ribbonSize,
y: height - ribbonSize,
polygon: `${ribbonSize},0 0,${ribbonSize} ${ribbonSize},${ribbonSize}`, // Bottom-right triangle
textX: ribbonSize * 0.7,
textY: ribbonSize * 0.7,
textRotation: 135
}
};
const pos = positions[ribbon.position] || positions.north_west;
const ribbonSvg = `
<svg width="${ribbonSize}" height="${ribbonSize}" xmlns="http://www.w3.org/2000/svg">
<polygon points="${pos.polygon}" fill="${color}"/>
<text x="${pos.textX}" y="${pos.textY}"
font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold"
fill="white" text-anchor="middle" dominant-baseline="middle"
transform="rotate(${pos.textRotation}, ${pos.textX}, ${pos.textY})">
${ribbon.text}
</text>
</svg>
`;
overlays.push({
input: Buffer.from(ribbonSvg),
top: pos.y,
left: pos.x
});
}
// Add price badge overlay
if (priceBadge) {
const padding = 16;
const badgeText = `${priceBadge.price} €`;
const fontSize = priceBadge.size;
const textWidth = badgeText.length * fontSize * 0.6; // Approximate
const badgeWidth = textWidth + padding * 2;
const badgeHeight = fontSize + padding;
const color = priceBadge.color === 'red' ? '#dc2626' : '#eab308';
let x = padding;
let y = padding;
if (priceBadge.position === 'north_east') {
x = width - badgeWidth - padding;
} else if (priceBadge.position === 'south_west') {
y = height - badgeHeight - padding;
} else if (priceBadge.position === 'south_east') {
x = width - badgeWidth - padding;
y = height - badgeHeight - padding;
}
const badgeSvg = `
<svg width="${badgeWidth}" height="${badgeHeight}">
<rect x="0" y="0" width="${badgeWidth}" height="${badgeHeight}"
rx="${badgeHeight / 2}" ry="${badgeHeight / 2}" fill="${color}"/>
<text x="${badgeWidth / 2}" y="${badgeHeight / 2}"
font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold"
fill="white" text-anchor="middle" dominant-baseline="middle">
${badgeText}
</text>
</svg>
`;
overlays.push({
input: Buffer.from(badgeSvg),
top: y,
left: x
});
}
// Add logo overlay
if (logo && logo.enabled) {
console.log("🏢 Adding logo overlay...");
// Get Cloudinary config to get logo public ID
const cloudinaryConfig = await storage.getCloudinaryConfig();
if (cloudinaryConfig && cloudinaryConfig.logoPublicId) {
try {
// Build logo URL from Cloudinary
const logoUrl = buildMinioUrl(cloudinaryConfig.cloudName, cloudinaryConfig.logoPublicId, cloudinaryConfig.publicUrl);
console.log("📥 Downloading logo from:", logoUrl);
// Download logo
const logoResponse = await fetch(logoUrl);
const logoBuffer = Buffer.from(await logoResponse.arrayBuffer());
// Determine logo size based on selection with safety cap
const padding = 20;
const maxLogoWidth = width - (padding * 2); // Ensure logo fits within canvas
const logoSizePercentages = {
small: 0.35, // 35% of image width
medium: 0.50, // 50% of image width
large: 0.70 // 70% of image width
};
const requestedWidth = Math.round(width * logoSizePercentages[logo.size as keyof typeof logoSizePercentages] || logoSizePercentages.medium);
const logoWidth = Math.min(requestedWidth, maxLogoWidth);
// Resize logo preserving aspect ratio and apply opacity
const resizedLogo = await sharp(logoBuffer)
.resize({ width: logoWidth, fit: 'contain' })
.ensureAlpha()
.toBuffer();
// Get resized logo dimensions
const logoMetadata = await sharp(resizedLogo).metadata();
const logoHeight = logoMetadata.height || logoWidth;
// Ensure logo fits within height as well
const maxLogoHeight = height - (padding * 2);
if (logoHeight > maxLogoHeight) {
console.warn(`⚠️ Logo height ${logoHeight}px exceeds max ${maxLogoHeight}px, would be clipped`);
}
// Calculate position with padding
let logoX = padding;
let logoY = padding;
if (logo.position === 'north_east') {
logoX = width - logoWidth - padding;
} else if (logo.position === 'south_west') {
logoY = height - logoHeight - padding;
} else if (logo.position === 'south_east') {
logoX = width - logoWidth - padding;
logoY = height - logoHeight - padding;
} else if (logo.position === 'center') {
logoX = Math.round((width - logoWidth) / 2);
logoY = Math.round((height - logoHeight) / 2);
}
// Apply opacity by manipulating alpha channel
let logoWithOpacity = resizedLogo;
if (logo.opacity < 100) {
// Create a semi-transparent version of the logo
const opacityFactor = logo.opacity / 100;
logoWithOpacity = await sharp(resizedLogo)
.ensureAlpha()
.linear(opacityFactor, 0)
.toBuffer();
}
// Add logo overlay
overlays.push({
input: logoWithOpacity,
top: logoY,
left: logoX,
blend: 'over'
});
console.log(`✅ Logo added at position ${logo.position} with ${logo.opacity}% opacity`);
} catch (logoError) {
console.error("⚠️ Failed to apply logo:", logoError);
// Continue without logo if it fails
}
} else {
console.log("⚠️ No logo configured in settings");
}
}
// Apply all overlays
if (overlays.length > 0) {
image = image.composite(overlays);
}
// Convert to buffer
const outputBuffer = await image.jpeg({ quality: 95 }).toBuffer();
console.log("✅ Overlays applied, uploading to local storage...");
// Upload to local storage
const uploadResult = await cloudinaryService.uploadMedia(
outputBuffer,
`edited_${Date.now()}.jpg`,
userId,
'image/jpeg'
);
// Save to database
// IMPORTANT: For edited images with overlays, use originalUrl for ALL formats
// because Cloudinary transformations would recreate versions WITHOUT the overlays
const mediaItem = await storage.createMedia({
userId,
type: "image",
cloudinaryPublicId: uploadResult.publicId,
originalUrl: uploadResult.originalUrl,
facebookFeedUrl: uploadResult.originalUrl, // Use original with overlays
instagramFeedUrl: uploadResult.originalUrl, // Use original with overlays
instagramStoryUrl: uploadResult.originalUrl, // Use original with overlays
fileName: `edited_${Date.now()}.jpg`,
fileSize: outputBuffer.length,
});
console.log("✅ Image saved with ID:", mediaItem.id);
res.json(mediaItem);
} catch (error) {
console.error("💥 Error applying overlays:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to apply overlays";
res.status(500).json({ error: errorMessage });
}
});
// Delete media
app.delete("/api/media/:id", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const mediaId = req.params.id;
// Get media to find cloudinary public ID
const allMedia = await storage.getMedia(userId);
const mediaToDelete = allMedia.find(m => m.id === mediaId);
if (!mediaToDelete) {
return res.status(404).json({ error: "Media not found" });
}
// Delete from Cloudinary
if (mediaToDelete.cloudinaryPublicId) {
await cloudinaryService.deleteMedia(
mediaToDelete.cloudinaryPublicId,
userId,
mediaToDelete.type
);
}
// Delete from database
await storage.deleteMedia(mediaId);
res.json({ success: true });
} catch (error) {
console.error("Error deleting media:", error);
res.status(500).json({ error: "Failed to delete media" });
}
});
// Audio Tracks Management (Admin Only) - Multi-file upload (stored locally)
app.post("/api/audio-tracks", requireAdmin, audioUpload.array("files", 20), async (req, res) => {
try {
const files = req.files as Express.Multer.File[];
if (!files || files.length === 0) {
return res.status(400).json({ error: "No audio file uploaded" });
}
const user = req.user as User;
const results = [];
for (const file of files) {
try {
console.log(`🎵 Audio saved locally: ${file.originalname} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
// Multer/busboy reçoit le nom en latin1, mais le navigateur l'envoie en UTF-8 → on reconvertit
const decodedName = Buffer.from(file.originalname, 'latin1').toString('utf8');
const publicUrl = `/uploads/audio/${file.filename}`;
let duration = 0;
try {
const meta = await musicMetadata.parseFile(file.path);
duration = Math.round(meta.format.duration ?? 0);
} catch { /* non-fatal */ }
const track = await storage.createAudioTrack({
userId: user.id,
title: decodedName.replace(/\.[^/.]+$/, ""),
fileName: decodedName,
url: publicUrl,
duration,
});
results.push({ success: true, track });
} catch (fileError) {
// Remove the file if DB insert fails
await fs.promises.unlink(file.path).catch(() => { });
results.push({ success: false, fileName: file.originalname, error: fileError instanceof Error ? fileError.message : "Save failed" });
}
}
res.json({ results });
} catch (error) {
console.error("Error uploading audio tracks:", error);
if (req.files) {
for (const file of req.files as Express.Multer.File[]) {
await fs.promises.unlink(file.path).catch(() => { });
}
}
const errorMessage = error instanceof Error ? error.message : "Failed to upload audio tracks";
res.status(500).json({ error: errorMessage });
}
});
// Route admin : correction des titres corrompus (latin1 lu comme UTF-8)
// Se déclenche aussi automatiquement au démarrage
const fixAudioTrackEncoding = async () => {
try {
const tracks = await storage.getAudioTracks();
let fixed = 0;
for (const track of tracks) {
// Détecte les séquences typiques latin1/utf8 mal interprétées (ex: é, à , è)
if (/[\xC0-\xC3][\x80-\xBF]/.test(track.title)) {
const corrected = Buffer.from(track.title, 'latin1').toString('utf8');
const correctedFileName = Buffer.from(track.fileName, 'latin1').toString('utf8');
await pool.query(
`UPDATE audio_tracks SET title = $1, file_name = $2 WHERE id = $3`,
[corrected, correctedFileName, track.id]
);
fixed++;
}
}
if (fixed > 0) console.log(`🎵 Fixed encoding for ${fixed} audio track(s).`);
} catch (e) {
console.error('Error fixing audio track encoding:', e);
}
};
// Lancer la correction au démarrage
fixAudioTrackEncoding().catch(() => { });
app.post("/api/audio-tracks/fix-encoding", requireAdmin, async (req, res) => {
try {
await fixAudioTrackEncoding();
const tracks = await storage.getAudioTracks();
res.json({ success: true, tracks });
} catch (error) {
res.status(500).json({ error: "Failed to fix encoding" });
}
});
app.get("/api/audio-tracks", requireAuth, async (req, res) => {
try {
// Both admin and regular users can list audio tracks (to pick them for reels)
const tracks = await storage.getAudioTracks();
res.json(tracks);
} catch (error) {
console.error("Error fetching audio tracks:", error);
res.status(500).json({ error: "Failed to fetch audio tracks" });
}
});
app.delete("/api/audio-tracks/:id", requireAdmin, async (req, res) => {
try {
const { id } = req.params;
const track = await storage.getAudioTrack(id);
if (!track) {
return res.status(404).json({ error: "Audio track not found" });
}
// Try to delete from MinIO if the URL matches the MinIO public URL pattern
const minioBase = (process.env.MINIO_PUBLIC_URL || process.env.MINIO_ENDPOINT || '').replace(/\/$/, '');
if (minioBase && track.url.startsWith(minioBase)) {
// URL = ${minioBase}/${bucket}/${objectKey} → extract objectKey
const withoutBase = track.url.slice(minioBase.length + 1); // strip "base/"
const objectKey = withoutBase.split('/').slice(1).join('/'); // strip bucket
if (objectKey) {
try {
await cloudinaryService.deleteMedia(objectKey, track.userId || '', 'video');
} catch (minioError) {
console.warn(`Failed to delete audio from MinIO: ${objectKey}`, minioError);
}
}
}
await storage.deleteAudioTrack(id);
res.json({ success: true });
} catch (error) {
console.error("Error deleting audio track:", error);
res.status(500).json({ error: "Failed to delete audio track" });
}
});
// Posts
app.get("/api/posts", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const posts = await storage.getPosts(userId);
res.json(posts);
} catch (error) {
console.error("Error fetching posts:", error);
res.status(500).json({ error: "Failed to fetch posts" });
}
});
app.post("/api/posts", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const { pageIds, postType, mediaId, mediaIds, ...postFields } = req.body;
// Convert mediaIds to standardized format: array of { mediaId, displayOrder }
let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = [];
if (mediaIds && Array.isArray(mediaIds)) {
// New format: array of objects or strings
finalMediaItems = mediaIds.map((item: any, index: number) => {
if (typeof item === 'string') {
// Legacy array of strings: use index as displayOrder
return { mediaId: item, displayOrder: index };
} else if (item.mediaId) {
// New format: object with mediaId and displayOrder
return {
mediaId: item.mediaId,
displayOrder: item.displayOrder ?? index,
};
}
throw new Error("Invalid media format");
});
} else if (mediaId) {
// Legacy single mediaId
finalMediaItems = [{ mediaId, displayOrder: 0 }];
}
// Validate max 10 photos
if (finalMediaItems.length > 10) {
return res.status(400).json({ error: "Maximum 10 photos autorisées par publication" });
}
// Validate that stories require media
if ((postType === 'story' || postType === 'both') && finalMediaItems.length === 0) {
return res.status(400).json({ error: "Les stories nécessitent au moins un média (image ou vidéo)" });
}
// Security: Verify user has access to all specified pages (unless admin)
if (user.role !== 'admin' && pageIds && Array.isArray(pageIds) && pageIds.length > 0) {
const accessiblePages = await storage.getUserAccessiblePages(userId);
const accessiblePageIds = accessiblePages.map(p => p.id);
const hasAccessToAllPages = pageIds.every(pageId =>
accessiblePageIds.includes(pageId)
);
if (!hasAccessToAllPages) {
return res.status(403).json({
error: "Vous n'avez pas accès à certaines pages sélectionnées"
});
}
}
// Convert scheduledFor string to Date if provided
if (postFields.scheduledFor && typeof postFields.scheduledFor === 'string') {
postFields.scheduledFor = new Date(postFields.scheduledFor);
}
// Set status to "scheduled" if scheduledFor is provided, otherwise "draft"
if (postFields.scheduledFor) {
postFields.status = "scheduled";
// Validate that scheduled posts require at least one page
if (!pageIds || !Array.isArray(pageIds) || pageIds.length === 0) {
return res.status(400).json({ error: "Les posts programmés nécessitent au moins une page cible" });
}
}
// Create the post
const postData = insertPostSchema.parse({ ...postFields, userId });
const post = await storage.createPost(postData);
// Link media to post if provided (with display order)
if (finalMediaItems.length > 0) {
const postMediaValues = finalMediaItems.map(item => ({
postId: post.id,
mediaId: item.mediaId,
displayOrder: item.displayOrder,
}));
await db.insert(postMedia).values(postMediaValues);
}
// Create scheduled posts for each selected page
if (pageIds && Array.isArray(pageIds) && pageIds.length > 0) {
const scheduledAt = postFields.scheduledFor
? new Date(postFields.scheduledFor)
: new Date(); // Publish immediately if no date specified
const finalPostType = postType || 'feed';
for (const pageId of pageIds) {
// If postType is "both", create two separate scheduled posts (story + feed)
if (finalPostType === 'both') {
await storage.createScheduledPost({
postId: post.id,
pageId,
postType: 'story',
scheduledAt,
});
await storage.createScheduledPost({
postId: post.id,
pageId,
postType: 'feed',
scheduledAt,
});
} else {
await storage.createScheduledPost({
postId: post.id,
pageId,
postType: finalPostType,
scheduledAt,
});
}
}
}
res.json(post);
} catch (error) {
console.error("Error creating post:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to create post";
res.status(500).json({ error: errorMessage });
}
});
app.get("/api/posts/:id", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const { id } = req.params;
const postWithMedia = await storage.getPostWithMedia(id);
if (!postWithMedia) {
return res.status(404).json({ error: "Post not found" });
}
// Admin peut voir tous les posts
if (user.role === 'admin') {
return res.json(postWithMedia);
}
// Propriétaire peut voir son propre post
if (postWithMedia.post.userId === userId) {
return res.json(postWithMedia);
}
// Utilisateur standard peut voir les posts des pages qui lui sont assignées
const scheduledPostsForPost = await storage.getScheduledPostsByPost(id);
if (scheduledPostsForPost.length > 0) {
const accessiblePages = await storage.getUserAccessiblePages(userId);
const accessiblePageIds = accessiblePages.map(p => p.id);
// Vérifier si au moins une page du post est accessible
const hasAccess = scheduledPostsForPost.some(sp => accessiblePageIds.includes(sp.pageId));
if (hasAccess) {
return res.json(postWithMedia);
}
}
return res.status(403).json({ error: "Unauthorized" });
} catch (error) {
console.error("Error fetching post:", error);
res.status(500).json({ error: "Failed to fetch post" });
}
});
app.patch("/api/posts/:id", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const { id } = req.params;
const { content } = req.body;
const post = await storage.getPost(id);
if (!post) {
return res.status(404).json({ error: "Post not found" });
}
if (post.userId !== userId) {
return res.status(403).json({ error: "Unauthorized" });
}
const updatedPost = await storage.updatePost(id, { content });
res.json(updatedPost);
} catch (error) {
console.error("Error updating post:", error);
res.status(500).json({ error: "Failed to update post" });
}
});
app.patch("/api/posts/:id/media", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const { id } = req.params;
const { mediaIds } = req.body;
const post = await storage.getPost(id);
if (!post) {
return res.status(404).json({ error: "Post not found" });
}
if (post.userId !== userId) {
return res.status(403).json({ error: "Unauthorized" });
}
if (!Array.isArray(mediaIds)) {
return res.status(400).json({ error: "mediaIds must be an array" });
}
if (mediaIds.length > 10) {
return res.status(400).json({ error: "Maximum 10 photos autorisées par publication" });
}
await storage.updatePostMedia(id, mediaIds);
const updatedPostWithMedia = await storage.getPostWithMedia(id);
res.json(updatedPostWithMedia);
} catch (error) {
console.error("Error updating post media:", error);
res.status(500).json({ error: "Failed to update post media" });
}
});
// Scheduled posts
app.get("/api/scheduled-posts", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const { startDate, endDate } = req.query;
const start = startDate ? new Date(startDate as string) : undefined;
const end = endDate ? new Date(endDate as string) : undefined;
let scheduledPosts;
if (user.role === 'admin') {
// Admin voit tous les posts programmés - on récupère toutes les pages
const allPages = await storage.getAllUsers().then(users =>
Promise.all(users.map(u => storage.getSocialPages(u.id)))
).then(pagesArrays => pagesArrays.flat());
const allPageIds = allPages.map(p => p.id);
if (allPageIds.length > 0) {
scheduledPosts = await storage.getScheduledPostsByPages(allPageIds, start, end);
} else {
scheduledPosts = [];
}
} else {
// User voit uniquement les posts programmés sur les pages qui lui sont attribuées
const accessiblePages = await storage.getUserAccessiblePages(userId);
const accessiblePageIds = accessiblePages.map(p => p.id);
if (accessiblePageIds.length > 0) {
scheduledPosts = await storage.getScheduledPostsByPages(accessiblePageIds, start, end);
} else {
scheduledPosts = [];
}
}
res.json(scheduledPosts);
} catch (error) {
console.error("Error fetching scheduled posts:", error);
res.status(500).json({ error: "Failed to fetch scheduled posts" });
}
});
app.delete("/api/scheduled-posts/:id", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const { id } = req.params;
// Verify the scheduled post exists
const scheduledPost = await storage.getScheduledPost(id);
if (!scheduledPost) {
return res.status(404).json({ error: "Scheduled post not found" });
}
const post = await storage.getPost(scheduledPost.postId);
if (!post) {
return res.status(404).json({ error: "Post not found" });
}
// Admin peut tout supprimer, user peut supprimer uniquement ses propres posts
if (user.role !== 'admin' && post.userId !== userId) {
return res.status(403).json({ error: "Unauthorized" });
}
await storage.deleteScheduledPost(id);
res.json({ success: true });
} catch (error) {
console.error("Error deleting scheduled post:", error);
res.status(500).json({ error: "Failed to delete scheduled post" });
}
});
app.patch("/api/scheduled-posts/:id", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const { id } = req.params;
// Verify the scheduled post exists
const scheduledPost = await storage.getScheduledPost(id);
if (!scheduledPost) {
return res.status(404).json({ error: "Scheduled post not found" });
}
const post = await storage.getPost(scheduledPost.postId);
if (!post) {
return res.status(404).json({ error: "Post not found" });
}
// Admin peut tout modifier, user peut modifier uniquement ses propres posts
if (user.role !== 'admin' && post.userId !== userId) {
return res.status(403).json({ error: "Unauthorized" });
}
// Only allow updating scheduledAt and pageId
const { scheduledAt, pageId } = req.body;
const updateData: Partial<ScheduledPost> = {};
if (scheduledAt) {
updateData.scheduledAt = new Date(scheduledAt);
// Synchroniser avec la table posts
await storage.updatePost(scheduledPost.postId, { scheduledFor: new Date(scheduledAt) });
}
if (pageId) {
updateData.pageId = pageId;
}
const updated = await storage.updateScheduledPost(id, updateData);
res.json(updated);
} catch (error) {
console.error("Error updating scheduled post:", error);
res.status(500).json({ error: "Failed to update scheduled post" });
}
});
app.post("/api/scheduled-posts", requireAuth, async (req, res) => {
try {
const scheduledPostData = insertScheduledPostSchema.parse(req.body);
// If postType is "both", create two separate scheduled posts (story + feed)
// This prevents retry loops - each post type is independent
if (scheduledPostData.postType === 'both') {
const [storyPost, feedPost] = await Promise.all([
storage.createScheduledPost({
...scheduledPostData,
postType: 'story' as const,
}),
storage.createScheduledPost({
...scheduledPostData,
postType: 'feed' as const,
}),
]);
// Return array so frontend knows it's a split operation
res.json([storyPost, feedPost]);
} else {
const scheduledPost = await storage.createScheduledPost(scheduledPostData);
res.json(scheduledPost);
}
} catch (error) {
console.error("Error creating scheduled post:", error);
res.status(500).json({ error: "Failed to create scheduled post" });
}
});
// Social pages
app.get("/api/pages", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
let pages: SocialPage[];
if (user.role === 'admin') {
// Les admins voient toutes les pages de tous les utilisateurs
const allUsers = await storage.getAllUsers();
const allPages = await Promise.all(
allUsers.map(u => storage.getSocialPages(u.id))
);
pages = allPages.flat();
} else {
// Les utilisateurs normaux voient uniquement les pages auxquelles ils ont accès
pages = await storage.getUserAccessiblePages(userId);
}
res.json(pages);
} catch (error) {
console.error("Error fetching pages:", error);
res.status(500).json({ error: "Failed to fetch pages" });
}
});
app.post("/api/pages", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
// TikTok n'autorise pas la saisie manuelle d'un token : le compte doit
// passer par le flux OAuth (/api/tiktok/connect).
if (req.body?.platform === 'tiktok') {
return res.status(400).json({
error: "Un compte TikTok se connecte via l'autorisation TikTok, pas par saisie manuelle d'un jeton.",
});
}
// Calculate token expiration date (60 days from now)
const tokenExpiresAt = new Date();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
const pageData = insertSocialPageSchema.parse({
...req.body,
userId,
tokenExpiresAt
});
const page = await storage.createSocialPage(pageData);
res.json(page);
} catch (error) {
console.error("Error creating page:", error);
res.status(500).json({ error: "Failed to create page" });
}
});
app.put("/api/pages/:id", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const pageId = req.params.id;
const existingPage = await storage.getSocialPage(pageId);
if (!existingPage || existingPage.userId !== userId) {
return res.status(404).json({ error: "Page non trouvée" });
}
let parsedData = insertSocialPageSchema.partial().parse(req.body);
let pageData: Partial<SocialPage> = { ...parsedData };
// If accessToken is being updated, recalculate expiration date (60 days from now)
// AND reset the status to valid so the UI updates immediately
// (les tokens TikTok ont leur propre cycle de vie, géré par le service OAuth)
if (parsedData.accessToken && existingPage.platform !== 'tiktok') {
const tokenExpiresAt = new Date();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
pageData = {
...pageData,
tokenExpiresAt,
tokenStatus: 'valid',
lastTokenCheck: new Date()
};
}
const updatedPage = await storage.updateSocialPage(pageId, pageData);
res.json(updatedPage);
} catch (error) {
console.error("Error updating page:", error);
res.status(500).json({ error: "Failed to update page" });
}
});
app.delete("/api/pages/:id", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const pageId = req.params.id;
const existingPage = await storage.getSocialPage(pageId);
if (!existingPage || existingPage.userId !== userId) {
return res.status(404).json({ error: "Page non trouvée" });
}
await storage.deleteSocialPage(pageId);
res.json({ success: true });
} catch (error) {
console.error("Error deleting page:", error);
res.status(500).json({ error: "Failed to delete page" });
}
});
// AI Generations
app.get("/api/ai/generations", requireAdmin, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const generations = await storage.getAiGenerations(userId);
res.json(generations);
} catch (error) {
console.error("Error fetching AI generations:", error);
res.status(500).json({ error: "Failed to fetch AI generations" });
}
});
// Cloudinary Config
app.get("/api/cloudinary/config", requireAuth, async (req, res) => {
try {
// Configuration est maintenant globale, plus de "userId"
const config = await storage.getCloudinaryConfig();
if (!config) {
return res.json(null);
}
// Don't send the API secret to the client
const { apiSecret, ...safeConfig } = config;
const logoUrl = config.logoPublicId ? buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl) : null;
res.json({ ...safeConfig, logoUrl });
} catch (error) {
console.error("Error fetching Cloudinary config:", error);
res.status(500).json({ error: "Failed to fetch Cloudinary config" });
}
});
app.post("/api/cloudinary/config", requireAdmin, async (req, res) => {
try {
// Check if config already exists globally
const existingConfig = await storage.getCloudinaryConfig();
let config;
if (existingConfig) {
// Pour les mises à jour, utiliser le schéma qui rend les secrets optionnels
const updateData = updateCloudinaryConfigSchema.parse(req.body);
// Si apiKey/apiSecret ne sont pas fournis, garder les anciens
const finalData = {
...updateData,
apiKey: updateData.apiKey || existingConfig.apiKey,
apiSecret: updateData.apiSecret || existingConfig.apiSecret,
};
config = await storage.updateCloudinaryConfig(finalData);
} else {
// Pour les créations, exiger tous les champs
const configData = insertCloudinaryConfigSchema.parse(req.body);
config = await storage.createCloudinaryConfig(configData);
}
// Don't send the API secret back
const { apiSecret, ...safeConfig } = config;
res.json(safeConfig);
} catch (error) {
console.error("Error saving Cloudinary config:", error);
res.status(500).json({ error: "Failed to save Cloudinary config" });
}
});
// Upload company logo
app.post("/api/cloudinary/logo", requireAdmin, upload.single("logo"), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: "No logo file uploaded" });
}
// Get or auto-create config row (local storage doesn't need credentials)
let cloudinaryConfig = await storage.getCloudinaryConfig();
if (!cloudinaryConfig) {
cloudinaryConfig = await storage.createCloudinaryConfig({
cloudName: 'local', apiKey: 'local', apiSecret: 'local',
});
}
// Delete old logo if exists
if (cloudinaryConfig.logoPublicId) {
try {
await cloudinaryService.deleteLogo(cloudinaryConfig.logoPublicId);
} catch (error) {
console.warn("Failed to delete old logo:", error);
// Continue anyway - not critical
}
}
// Read from disk (multer uses diskStorage, not memoryStorage)
const logoBuffer = await fs.promises.readFile(req.file.path);
await fs.promises.unlink(req.file.path).catch(() => {});
const uploadResult = await cloudinaryService.uploadLogo(
logoBuffer,
req.file.originalname
);
// Update global config with new logo public ID
const updatedConfig = await storage.updateCloudinaryConfig({
logoPublicId: uploadResult.publicId,
});
res.json({
logoPublicId: uploadResult.publicId,
logoUrl: uploadResult.url,
});
} catch (error) {
console.error("Error uploading logo:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to upload logo";
res.status(500).json({ error: errorMessage });
}
});
// Delete company logo
app.delete("/api/cloudinary/logo", requireAdmin, async (req, res) => {
try {
const cloudinaryConfig = await storage.getCloudinaryConfig();
if (!cloudinaryConfig || !cloudinaryConfig.logoPublicId) {
return res.status(404).json({ error: "No logo found" });
}
// Delete from Cloudinary
await cloudinaryService.deleteLogo(cloudinaryConfig.logoPublicId);
// Remove from global config
await storage.updateCloudinaryConfig({
logoPublicId: null,
});
res.json({ success: true });
} catch (error) {
console.error("Error deleting logo:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to delete logo";
res.status(500).json({ error: errorMessage });
}
});
// OpenRouter models list
app.get("/api/openrouter/models", requireAuth, async (req, res) => {
try {
const response = await fetch("https://openrouter.ai/api/v1/models", {
headers: {
"Content-Type": "application/json",
}
});
if (!response.ok) {
throw new Error(`OpenRouter API error: ${response.status}`);
}
const data = await response.json();
res.json(data);
} catch (error) {
console.error("Error fetching OpenRouter models:", error);
res.status(500).json({ error: "Failed to fetch OpenRouter models" });
}
});
// OpenRouter configuration
app.get("/api/openrouter/config", requireAdmin, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const config = await storage.getOpenrouterConfig(userId);
if (!config) {
return res.json(null);
}
// Don't send the API key to the client
const { apiKey, ...safeConfig } = config;
res.json(safeConfig);
} catch (error) {
console.error("Error fetching OpenRouter config:", error);
res.status(500).json({ error: "Failed to fetch OpenRouter config" });
}
});
app.post("/api/openrouter/config", requireAdmin, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
// Check if config already exists
const existingConfig = await storage.getOpenrouterConfig(userId);
// Une clé collée depuis un navigateur traîne souvent un « Bearer », des
// guillemets ou un caractère invisible. Stockée telle quelle, elle fait
// répondre « Missing Authentication header » à OpenRouter.
if (typeof req.body?.apiKey === 'string') {
req.body.apiKey = sanitizeApiKey(req.body.apiKey);
}
let config;
if (existingConfig) {
// Pour les mises à jour, utiliser le schéma qui rend apiKey optionnel
const updateData = updateOpenrouterConfigSchema.parse({
...req.body,
userId,
});
// Si apiKey n'est pas fourni ou vide, garder l'ancien
const incomingKey = updateData.apiKey?.trim() || undefined;
const finalData = {
...updateData,
apiKey: incomingKey || existingConfig.apiKey,
};
if (!finalData.apiKey) {
return res.status(400).json({ error: "Clé API OpenRouter manquante. Veuillez entrer une clé API valide." });
}
config = await storage.updateOpenrouterConfig(userId, finalData);
} else {
// Pour les créations, exiger tous les champs
const configData = insertOpenrouterConfigSchema.parse({
...req.body,
userId,
});
config = await storage.createOpenrouterConfig(configData);
}
// Don't send the API key back
const { apiKey, ...safeConfig } = config;
res.json(safeConfig);
} catch (error) {
console.error("Error saving OpenRouter config:", error);
res.status(500).json({ error: "Failed to save OpenRouter config" });
}
});
// FFmpeg API Configuration
app.get("/api/ffmpeg/config", requireAdmin, async (req, res) => {
try {
const user = req.user as User;
// Lire depuis les variables d'environnement ou un storage dédié
// Pour simplifier, on renvoie juste si c'est configuré ou non
const apiUrl = process.env.FFMPEG_API_URL || "";
res.json({
apiUrl: apiUrl,
configured: !!apiUrl && !!process.env.FFMPEG_API_KEY,
});
} catch (error) {
console.error("Error fetching FFmpeg config:", error);
res.status(500).json({ error: "Failed to fetch FFmpeg config" });
}
});
app.post("/api/ffmpeg/config", requireAdmin, async (req, res) => {
try {
const { apiUrl, apiKey } = req.body;
if (!apiUrl) {
return res.status(400).json({ error: "URL de l'API FFmpeg requise" });
}
// Configurer le service FFmpeg avec les nouvelles valeurs
const { ffmpegService } = await import("./services/ffmpeg");
// Si apiKey fourni, configurer avec
if (apiKey) {
ffmpegService.configure(apiUrl, apiKey);
} else if (process.env.FFMPEG_API_KEY) {
// Garder la clé existante
ffmpegService.configure(apiUrl, process.env.FFMPEG_API_KEY);
} else {
return res.status(400).json({ error: "Clé API FFmpeg requise pour la première configuration" });
}
// Stocker dans les variables d'environnement (pour cette session)
process.env.FFMPEG_API_URL = apiUrl;
if (apiKey) {
process.env.FFMPEG_API_KEY = apiKey;
}
// Tester la connexion
const isHealthy = await ffmpegService.healthCheck();
res.json({
success: true,
apiUrl: apiUrl,
configured: true,
healthy: isHealthy,
});
} catch (error) {
console.error("Error saving FFmpeg config:", error);
res.status(500).json({ error: "Failed to save FFmpeg config" });
}
});
const httpServer = createServer(app);
return httpServer;
}