feat(security): implement security fixes - Add AES-256-GCM encryption for Facebook/Instagram tokens - Add helmet for HTTP security headers - Add rate limiting (5 login attempts, 100 req/15min) - Add file upload validation (50MB max, MIME type whitelist) - Secure SQL console endpoint (disabled in production) - Enforce SESSION_SECRET in production

This commit is contained in:
Michael committed 2026-01-07 14:57:36 +01:00
1 parent c3660f811b
commit 633875e8ec
7 files changed
+917 -450

No files matched your search

+4
View File
@@ -16,6 +16,10 @@ APP_URL=http://localhost:5555
# Clé secrète pour les sessions (CHANGEZ CETTE VALEUR) # Clé secrète pour les sessions (CHANGEZ CETTE VALEUR)
SESSION_SECRET=your-secret-key-change-me-to-random-string SESSION_SECRET=your-secret-key-change-me-to-random-string
# Clé de chiffrement pour les tokens Facebook/Instagram (OBLIGATOIRE en production)
# Générer avec: openssl rand -hex 32
ENCRYPTION_KEY=your-32-byte-hex-key-here
# Clé API OpenRouter pour la génération de texte IA # Clé API OpenRouter pour la génération de texte IA
# Obtenez votre clé sur https://openrouter.ai/ # Obtenez votre clé sur https://openrouter.ai/
OPENROUTER_API_KEY=your-openrouter-api-key-here OPENROUTER_API_KEY=your-openrouter-api-key-here
+569 -298
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -64,8 +64,10 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"emoji-regex": "^10.6.0", "emoji-regex": "^10.6.0",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^7.5.1",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"framer-motion": "^11.13.1", "framer-motion": "^11.13.1",
"helmet": "^8.1.0",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
@@ -125,4 +127,4 @@
"optionalDependencies": { "optionalDependencies": {
"bufferutil": "^4.0.8" "bufferutil": "^4.0.8"
} }
} }
+61 -14
View File
@@ -2,6 +2,9 @@ import express, { type Request, Response, NextFunction } from "express";
import session from "express-session"; import session from "express-session";
import connectPgSimple from "connect-pg-simple"; import connectPgSimple from "connect-pg-simple";
import pg from "pg"; import pg from "pg";
import crypto from "crypto";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import passport from "./auth"; import passport from "./auth";
import { registerRoutes } from "./routes"; import { registerRoutes } from "./routes";
import { setupVite, serveStatic, log } from "./vite"; import { setupVite, serveStatic, log } from "./vite";
@@ -11,6 +14,42 @@ import { ensureAdminUserExists } from "./init-admin";
const app = express(); const app = express();
const PgSession = connectPgSimple(session); const PgSession = connectPgSimple(session);
// 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:"],
}
},
crossOriginEmbedderPolicy: false,
}));
// Rate limiting global - 100 requêtes par 15 minutes par IP
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: { error: 'Trop de requêtes, réessayez plus tard' },
standardHeaders: true,
legacyHeaders: false,
});
// Rate limiting strict pour l'authentification - 5 tentatives par 15 minutes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
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);
declare module 'http' { declare module 'http' {
interface IncomingMessage { interface IncomingMessage {
rawBody: unknown rawBody: unknown
@@ -23,28 +62,36 @@ app.use(express.json({
})); }));
app.use(express.urlencoded({ extended: false })); app.use(express.urlencoded({ extended: false }));
// Configuration des sessions // Validation renforcée du SESSION_SECRET
if (!process.env.SESSION_SECRET) { if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'production') {
console.warn('⚠️ SESSION_SECRET non défini. Utilisation d\'une clé par défaut (NON SÉCURISÉ en 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');
// Déterminer si on utilise HTTPS basé sur APP_URL // Déterminer si on utilise HTTPS basé sur APP_URL
const isHttps = process.env.APP_URL?.startsWith('https://') || false; const isHttps = process.env.APP_URL?.startsWith('https://') || false;
// Configuration du store de session pour production // Configuration du store de session pour production
const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL
? new PgSession({ ? new PgSession({
pool: new pg.Pool({ pool: new pg.Pool({
connectionString: process.env.DATABASE_URL, connectionString: process.env.DATABASE_URL,
}), }),
tableName: 'session', tableName: 'session',
createTableIfMissing: true, createTableIfMissing: true,
}) })
: undefined; // MemoryStore par défaut en dev : undefined; // MemoryStore par défaut en dev
app.use(session({ app.use(session({
store: sessionStore, store: sessionStore,
secret: process.env.SESSION_SECRET || 'your-secret-key-change-me', secret: sessionSecret,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
@@ -77,7 +124,7 @@ app.use((req, res, next) => {
if (path === "/api/auth/session" && res.statusCode === 401) { if (path === "/api/auth/session" && res.statusCode === 401) {
return; return;
} }
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`; let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
if (capturedJsonResponse) { if (capturedJsonResponse) {
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`; logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
@@ -119,17 +166,17 @@ app.use((req, res, next) => {
// this serves both the API and the client. // this serves both the API and the client.
// It is the only port that is not firewalled. // It is the only port that is not firewalled.
const port = parseInt(process.env.PORT || '5000', 10); const port = parseInt(process.env.PORT || '5000', 10);
// Initialiser l'utilisateur admin par défaut avant de démarrer le serveur // Initialiser l'utilisateur admin par défaut avant de démarrer le serveur
await ensureAdminUserExists(); await ensureAdminUserExists();
server.listen({ server.listen({
port, port,
host: "0.0.0.0", host: "0.0.0.0",
reusePort: true, reusePort: true,
}, () => { }, () => {
log(`serving on port ${port}`); log(`serving on port ${port}`);
// Démarrer le scheduler pour les publications programmées // Démarrer le scheduler pour les publications programmées
schedulerService.start(); schedulerService.start();
}); });
+154 -126
View File
@@ -11,7 +11,27 @@ import { cloudinaryService } from "./services/cloudinary";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema"; 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 type { User, InsertUser, ScheduledPost } from "@shared/schema";
const upload = multer({ storage: multer.memoryStorage() }); // 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'
];
// Configuration multer avec validation de taille et type
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB max
files: 10 // 10 fichiers max
},
fileFilter: (req, file, cb) => {
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Type de fichier non autorisé: ${file.mimetype}`));
}
}
});
// Middleware pour vérifier l'authentification // Middleware pour vérifier l'authentification
function requireAuth(req: Request, res: Response, next: NextFunction) { function requireAuth(req: Request, res: Response, next: NextFunction) {
@@ -83,14 +103,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
app.get("/api/users", requireAdmin, async (req, res) => { app.get("/api/users", requireAdmin, async (req, res) => {
try { try {
const allUsers = await storage.getAllUsers(); const allUsers = await storage.getAllUsers();
// Ne pas envoyer les mots de passe // Ne pas envoyer les mots de passe
const safeUsers = allUsers.map(user => ({ const safeUsers = allUsers.map(user => ({
id: user.id, id: user.id,
username: user.username, username: user.username,
role: user.role, role: user.role,
})); }));
res.json(safeUsers); res.json(safeUsers);
} catch (error) { } catch (error) {
console.error("Error fetching users:", error); console.error("Error fetching users:", error);
@@ -143,7 +163,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
try { try {
const userId = req.params.id; const userId = req.params.id;
const { username, password, role } = req.body; const { username, password, role } = req.body;
// Vérifier si l'utilisateur existe // Vérifier si l'utilisateur existe
const existingUser = await storage.getUser(userId); const existingUser = await storage.getUser(userId);
if (!existingUser) { if (!existingUser) {
@@ -151,7 +171,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
const updateData: Partial<InsertUser> = {}; const updateData: Partial<InsertUser> = {};
if (username && username !== existingUser.username) { if (username && username !== existingUser.username) {
// Vérifier si le nouveau username est déjà pris // Vérifier si le nouveau username est déjà pris
const userWithSameUsername = await storage.getUserByUsername(username); const userWithSameUsername = await storage.getUserByUsername(username);
@@ -160,12 +180,12 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
updateData.username = username; updateData.username = username;
} }
if (password) { if (password) {
// Hasher le nouveau mot de passe // Hasher le nouveau mot de passe
updateData.password = await bcrypt.hash(password, 10); updateData.password = await bcrypt.hash(password, 10);
} }
if (role && (role === "admin" || role === "user")) { if (role && (role === "admin" || role === "user")) {
updateData.role = role; updateData.role = role;
} }
@@ -175,7 +195,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
const updatedUser = await storage.updateUser(userId, updateData); const updatedUser = await storage.updateUser(userId, updateData);
res.json({ res.json({
id: updatedUser.id, id: updatedUser.id,
username: updatedUser.username, username: updatedUser.username,
@@ -192,7 +212,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
try { try {
const userId = req.params.id; const userId = req.params.id;
const currentUser = req.user as User; const currentUser = req.user as User;
// Empêcher l'admin de se supprimer lui-même // Empêcher l'admin de se supprimer lui-même
if (userId === currentUser.id) { if (userId === currentUser.id) {
return res.status(400).json({ error: "Vous ne pouvez pas supprimer votre propre compte" }); return res.status(400).json({ error: "Vous ne pouvez pas supprimer votre propre compte" });
@@ -205,7 +225,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
await storage.deleteUser(userId); await storage.deleteUser(userId);
res.json({ success: true, message: "Utilisateur supprimé avec succès" }); res.json({ success: true, message: "Utilisateur supprimé avec succès" });
} catch (error: any) { } catch (error: any) {
console.error("Error deleting user:", error); console.error("Error deleting user:", error);
@@ -240,7 +260,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Créer les nouvelles permissions // Créer les nouvelles permissions
const permissions = await Promise.all( const permissions = await Promise.all(
pageIds.map(pageId => pageIds.map(pageId =>
storage.createPagePermission({ userId, pageId }) storage.createPagePermission({ userId, pageId })
) )
); );
@@ -276,10 +296,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
} }
res.json({ res.json({
success: true, success: true,
message: `${migratedCount} permissions migrées avec succès`, message: `${migratedCount} permissions migrées avec succès`,
migratedCount migratedCount
}); });
} catch (error) { } catch (error) {
console.error("Error migrating permissions:", error); console.error("Error migrating permissions:", error);
@@ -291,14 +311,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
app.get("/api/auth/default-password-status", async (req, res) => { app.get("/api/auth/default-password-status", async (req, res) => {
try { try {
const adminUser = await storage.getUserByUsername("admin"); const adminUser = await storage.getUserByUsername("admin");
if (!adminUser) { if (!adminUser) {
return res.json({ isDefault: false }); return res.json({ isDefault: false });
} }
// Vérifier si le mot de passe correspond à "admin" // Vérifier si le mot de passe correspond à "admin"
const isDefaultPassword = await bcrypt.compare("admin", adminUser.password); const isDefaultPassword = await bcrypt.compare("admin", adminUser.password);
res.json({ isDefault: isDefaultPassword }); res.json({ isDefault: isDefaultPassword });
} catch (error) { } catch (error) {
console.error("Error checking default password:", error); console.error("Error checking default password:", error);
@@ -306,8 +326,16 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// Route SQL (réservée aux admins) // 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) => { app.post("/api/sql/execute", requireAdmin, async (req, res) => {
// Vérification de sécurité: désactivé en production sauf si ALLOW_SQL_CONSOLE=true
if (process.env.NODE_ENV === 'production' && process.env.ALLOW_SQL_CONSOLE !== 'true') {
return res.status(403).json({
success: false,
error: "Console SQL désactivée en production pour des raisons de sécurité"
});
}
try { try {
// Validation Zod // Validation Zod
const sqlQuerySchema = z.object({ const sqlQuerySchema = z.object({
@@ -318,7 +346,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
const { db } = await import("./db"); const { db } = await import("./db");
const result = await db.execute(validatedData.query); const result = await db.execute(validatedData.query);
res.json({ res.json({
success: true, success: true,
result, result,
@@ -345,7 +373,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
const result = await db.execute<{ tablename: string }>( const result = await db.execute<{ tablename: string }>(
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;` `SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;`
); );
res.json({ res.json({
tables: result.rows || result, tables: result.rows || result,
}); });
@@ -363,7 +391,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
try { try {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
const posts = await storage.getPosts(userId); const posts = await storage.getPosts(userId);
const pages = await storage.getSocialPages(userId); const pages = await storage.getSocialPages(userId);
const media = await storage.getMedia(userId); const media = await storage.getMedia(userId);
@@ -379,7 +407,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
const yesterday = new Date(now); const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
yesterday.setHours(0, 0, 0, 0); yesterday.setHours(0, 0, 0, 0);
const lastMonth = new Date(now); const lastMonth = new Date(now);
lastMonth.setMonth(lastMonth.getMonth() - 1); lastMonth.setMonth(lastMonth.getMonth() - 1);
@@ -399,7 +427,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
}).length; }).length;
// Calculer les variations en pourcentage // Calculer les variations en pourcentage
const aiTextChange = aiTextsYesterday > 0 const aiTextChange = aiTextsYesterday > 0
? Math.round(((currentAiTexts - aiTextsYesterday) / aiTextsYesterday) * 100) ? Math.round(((currentAiTexts - aiTextsYesterday) / aiTextsYesterday) * 100)
: currentAiTexts > 0 ? 100 : 0; : currentAiTexts > 0 ? 100 : 0;
@@ -469,10 +497,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Check if Cloudinary is configured (shared config used for all users) // Check if Cloudinary is configured (shared config used for all users)
const cloudinaryConfig = await storage.getAnyCloudinaryConfig(); const cloudinaryConfig = await storage.getAnyCloudinaryConfig();
if (!cloudinaryConfig) { if (!cloudinaryConfig) {
return res.status(400).json({ return res.status(400).json({
error: "Cloudinary not configured. Please ask an administrator to configure Cloudinary in Settings first." error: "Cloudinary not configured. Please ask an administrator to configure Cloudinary in Settings first."
}); });
} }
@@ -543,7 +571,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Load with Sharp to process // Load with Sharp to process
const sharp = (await import("sharp")).default; const sharp = (await import("sharp")).default;
let image = sharp(imageBuffer); let image = sharp(imageBuffer);
// Get image metadata for dimensions // Get image metadata for dimensions
const metadata = await image.metadata(); const metadata = await image.metadata();
const width = metadata.width!; const width = metadata.width!;
@@ -562,33 +590,33 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Position mapping for each corner // Position mapping for each corner
const positions: Record<string, { x: number; y: number; polygon: string; textX: number; textY: number; textRotation: number }> = { const positions: Record<string, { x: number; y: number; polygon: string; textX: number; textY: number; textRotation: number }> = {
north_west: { north_west: {
x: 0, x: 0,
y: 0, y: 0,
polygon: `0,0 ${ribbonSize},0 0,${ribbonSize}`, // Top-left triangle polygon: `0,0 ${ribbonSize},0 0,${ribbonSize}`, // Top-left triangle
textX: ribbonSize * 0.3, textX: ribbonSize * 0.3,
textY: ribbonSize * 0.3, textY: ribbonSize * 0.3,
textRotation: -45 textRotation: -45
}, },
north_east: { north_east: {
x: width - ribbonSize, x: width - ribbonSize,
y: 0, y: 0,
polygon: `0,0 ${ribbonSize},0 ${ribbonSize},${ribbonSize}`, // Top-right triangle polygon: `0,0 ${ribbonSize},0 ${ribbonSize},${ribbonSize}`, // Top-right triangle
textX: ribbonSize * 0.7, textX: ribbonSize * 0.7,
textY: ribbonSize * 0.3, textY: ribbonSize * 0.3,
textRotation: 45 textRotation: 45
}, },
south_west: { south_west: {
x: 0, x: 0,
y: height - ribbonSize, y: height - ribbonSize,
polygon: `0,0 0,${ribbonSize} ${ribbonSize},${ribbonSize}`, // Bottom-left triangle polygon: `0,0 0,${ribbonSize} ${ribbonSize},${ribbonSize}`, // Bottom-left triangle
textX: ribbonSize * 0.3, textX: ribbonSize * 0.3,
textY: ribbonSize * 0.7, textY: ribbonSize * 0.7,
textRotation: -135 textRotation: -135
}, },
south_east: { south_east: {
x: width - ribbonSize, x: width - ribbonSize,
y: height - ribbonSize, y: height - ribbonSize,
polygon: `${ribbonSize},0 0,${ribbonSize} ${ribbonSize},${ribbonSize}`, // Bottom-right triangle polygon: `${ribbonSize},0 0,${ribbonSize} ${ribbonSize},${ribbonSize}`, // Bottom-right triangle
textX: ribbonSize * 0.7, textX: ribbonSize * 0.7,
textY: ribbonSize * 0.7, textY: ribbonSize * 0.7,
@@ -642,8 +670,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
const badgeSvg = ` const badgeSvg = `
<svg width="${badgeWidth}" height="${badgeHeight}"> <svg width="${badgeWidth}" height="${badgeHeight}">
<rect x="0" y="0" width="${badgeWidth}" height="${badgeHeight}" <rect x="0" y="0" width="${badgeWidth}" height="${badgeHeight}"
rx="${badgeHeight/2}" ry="${badgeHeight/2}" fill="${color}"/> rx="${badgeHeight / 2}" ry="${badgeHeight / 2}" fill="${color}"/>
<text x="${badgeWidth/2}" y="${badgeHeight/2}" <text x="${badgeWidth / 2}" y="${badgeHeight / 2}"
font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold" font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold"
fill="white" text-anchor="middle" dominant-baseline="middle"> fill="white" text-anchor="middle" dominant-baseline="middle">
${badgeText} ${badgeText}
@@ -661,7 +689,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Add logo overlay // Add logo overlay
if (logo && logo.enabled) { if (logo && logo.enabled) {
console.log("🏢 Adding logo overlay..."); console.log("🏢 Adding logo overlay...");
// Get Cloudinary config to get logo public ID // Get Cloudinary config to get logo public ID
const cloudinaryConfig = await storage.getAnyCloudinaryConfig(); const cloudinaryConfig = await storage.getAnyCloudinaryConfig();
if (cloudinaryConfig && cloudinaryConfig.logoPublicId) { if (cloudinaryConfig && cloudinaryConfig.logoPublicId) {
@@ -669,44 +697,44 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Build logo URL from Cloudinary // Build logo URL from Cloudinary
const logoUrl = `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`; const logoUrl = `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`;
console.log("📥 Downloading logo from:", logoUrl); console.log("📥 Downloading logo from:", logoUrl);
// Download logo // Download logo
const logoResponse = await fetch(logoUrl); const logoResponse = await fetch(logoUrl);
const logoBuffer = Buffer.from(await logoResponse.arrayBuffer()); const logoBuffer = Buffer.from(await logoResponse.arrayBuffer());
// Determine logo size based on selection with safety cap // Determine logo size based on selection with safety cap
const padding = 20; const padding = 20;
const maxLogoWidth = width - (padding * 2); // Ensure logo fits within canvas const maxLogoWidth = width - (padding * 2); // Ensure logo fits within canvas
const logoSizePercentages = { const logoSizePercentages = {
small: 0.35, // 35% of image width small: 0.35, // 35% of image width
medium: 0.50, // 50% of image width medium: 0.50, // 50% of image width
large: 0.70 // 70% 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 requestedWidth = Math.round(width * logoSizePercentages[logo.size as keyof typeof logoSizePercentages] || logoSizePercentages.medium);
const logoWidth = Math.min(requestedWidth, maxLogoWidth); const logoWidth = Math.min(requestedWidth, maxLogoWidth);
// Resize logo preserving aspect ratio and apply opacity // Resize logo preserving aspect ratio and apply opacity
const resizedLogo = await sharp(logoBuffer) const resizedLogo = await sharp(logoBuffer)
.resize({ width: logoWidth, fit: 'contain' }) .resize({ width: logoWidth, fit: 'contain' })
.ensureAlpha() .ensureAlpha()
.toBuffer(); .toBuffer();
// Get resized logo dimensions // Get resized logo dimensions
const logoMetadata = await sharp(resizedLogo).metadata(); const logoMetadata = await sharp(resizedLogo).metadata();
const logoHeight = logoMetadata.height || logoWidth; const logoHeight = logoMetadata.height || logoWidth;
// Ensure logo fits within height as well // Ensure logo fits within height as well
const maxLogoHeight = height - (padding * 2); const maxLogoHeight = height - (padding * 2);
if (logoHeight > maxLogoHeight) { if (logoHeight > maxLogoHeight) {
console.warn(`⚠️ Logo height ${logoHeight}px exceeds max ${maxLogoHeight}px, would be clipped`); console.warn(`⚠️ Logo height ${logoHeight}px exceeds max ${maxLogoHeight}px, would be clipped`);
} }
// Calculate position with padding // Calculate position with padding
let logoX = padding; let logoX = padding;
let logoY = padding; let logoY = padding;
if (logo.position === 'north_east') { if (logo.position === 'north_east') {
logoX = width - logoWidth - padding; logoX = width - logoWidth - padding;
} else if (logo.position === 'south_west') { } else if (logo.position === 'south_west') {
@@ -718,7 +746,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
logoX = Math.round((width - logoWidth) / 2); logoX = Math.round((width - logoWidth) / 2);
logoY = Math.round((height - logoHeight) / 2); logoY = Math.round((height - logoHeight) / 2);
} }
// Apply opacity by manipulating alpha channel // Apply opacity by manipulating alpha channel
let logoWithOpacity = resizedLogo; let logoWithOpacity = resizedLogo;
if (logo.opacity < 100) { if (logo.opacity < 100) {
@@ -729,7 +757,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
.linear(opacityFactor, 0) .linear(opacityFactor, 0)
.toBuffer(); .toBuffer();
} }
// Add logo overlay // Add logo overlay
overlays.push({ overlays.push({
input: logoWithOpacity, input: logoWithOpacity,
@@ -737,7 +765,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
left: logoX, left: logoX,
blend: 'over' blend: 'over'
}); });
console.log(`✅ Logo added at position ${logo.position} with ${logo.opacity}% opacity`); console.log(`✅ Logo added at position ${logo.position} with ${logo.opacity}% opacity`);
} catch (logoError) { } catch (logoError) {
console.error("⚠️ Failed to apply logo:", logoError); console.error("⚠️ Failed to apply logo:", logoError);
@@ -761,8 +789,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Check if Cloudinary is configured (shared config used for all users) // Check if Cloudinary is configured (shared config used for all users)
const cloudinaryConfig = await storage.getAnyCloudinaryConfig(); const cloudinaryConfig = await storage.getAnyCloudinaryConfig();
if (!cloudinaryConfig) { if (!cloudinaryConfig) {
return res.status(400).json({ return res.status(400).json({
error: "Cloudinary not configured. Please ask an administrator to configure Cloudinary in Settings first." error: "Cloudinary not configured. Please ask an administrator to configure Cloudinary in Settings first."
}); });
} }
@@ -804,11 +832,11 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
const mediaId = req.params.id; const mediaId = req.params.id;
// Get media to find cloudinary public ID // Get media to find cloudinary public ID
const allMedia = await storage.getMedia(userId); const allMedia = await storage.getMedia(userId);
const mediaToDelete = allMedia.find(m => m.id === mediaId); const mediaToDelete = allMedia.find(m => m.id === mediaId);
if (!mediaToDelete) { if (!mediaToDelete) {
return res.status(404).json({ error: "Media not found" }); return res.status(404).json({ error: "Media not found" });
} }
@@ -816,7 +844,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Delete from Cloudinary // Delete from Cloudinary
if (mediaToDelete.cloudinaryPublicId) { if (mediaToDelete.cloudinaryPublicId) {
await cloudinaryService.deleteMedia( await cloudinaryService.deleteMedia(
mediaToDelete.cloudinaryPublicId, mediaToDelete.cloudinaryPublicId,
userId, userId,
mediaToDelete.type mediaToDelete.type
); );
@@ -824,7 +852,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Delete from database // Delete from database
await storage.deleteMedia(mediaId); await storage.deleteMedia(mediaId);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error("Error deleting media:", error); console.error("Error deleting media:", error);
@@ -850,10 +878,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
const { pageIds, postType, mediaId, mediaIds, ...postFields } = req.body; const { pageIds, postType, mediaId, mediaIds, ...postFields } = req.body;
// Convert mediaIds to standardized format: array of { mediaId, displayOrder } // Convert mediaIds to standardized format: array of { mediaId, displayOrder }
let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = []; let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = [];
if (mediaIds && Array.isArray(mediaIds)) { if (mediaIds && Array.isArray(mediaIds)) {
// New format: array of objects or strings // New format: array of objects or strings
finalMediaItems = mediaIds.map((item: any, index: number) => { finalMediaItems = mediaIds.map((item: any, index: number) => {
@@ -873,52 +901,52 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Legacy single mediaId // Legacy single mediaId
finalMediaItems = [{ mediaId, displayOrder: 0 }]; finalMediaItems = [{ mediaId, displayOrder: 0 }];
} }
// Validate max 10 photos // Validate max 10 photos
if (finalMediaItems.length > 10) { if (finalMediaItems.length > 10) {
return res.status(400).json({ error: "Maximum 10 photos autorisées par publication" }); return res.status(400).json({ error: "Maximum 10 photos autorisées par publication" });
} }
// Validate that stories require media // Validate that stories require media
if ((postType === 'story' || postType === 'both') && finalMediaItems.length === 0) { 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)" }); 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) // Security: Verify user has access to all specified pages (unless admin)
if (user.role !== 'admin' && pageIds && Array.isArray(pageIds) && pageIds.length > 0) { if (user.role !== 'admin' && pageIds && Array.isArray(pageIds) && pageIds.length > 0) {
const accessiblePages = await storage.getUserAccessiblePages(userId); const accessiblePages = await storage.getUserAccessiblePages(userId);
const accessiblePageIds = accessiblePages.map(p => p.id); const accessiblePageIds = accessiblePages.map(p => p.id);
const hasAccessToAllPages = pageIds.every(pageId => const hasAccessToAllPages = pageIds.every(pageId =>
accessiblePageIds.includes(pageId) accessiblePageIds.includes(pageId)
); );
if (!hasAccessToAllPages) { if (!hasAccessToAllPages) {
return res.status(403).json({ return res.status(403).json({
error: "Vous n'avez pas accès à certaines pages sélectionnées" error: "Vous n'avez pas accès à certaines pages sélectionnées"
}); });
} }
} }
// Convert scheduledFor string to Date if provided // Convert scheduledFor string to Date if provided
if (postFields.scheduledFor && typeof postFields.scheduledFor === 'string') { if (postFields.scheduledFor && typeof postFields.scheduledFor === 'string') {
postFields.scheduledFor = new Date(postFields.scheduledFor); postFields.scheduledFor = new Date(postFields.scheduledFor);
} }
// Set status to "scheduled" if scheduledFor is provided, otherwise "draft" // Set status to "scheduled" if scheduledFor is provided, otherwise "draft"
if (postFields.scheduledFor) { if (postFields.scheduledFor) {
postFields.status = "scheduled"; postFields.status = "scheduled";
// Validate that scheduled posts require at least one page // Validate that scheduled posts require at least one page
if (!pageIds || !Array.isArray(pageIds) || pageIds.length === 0) { 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" }); return res.status(400).json({ error: "Les posts programmés nécessitent au moins une page cible" });
} }
} }
// Create the post // Create the post
const postData = insertPostSchema.parse({ ...postFields, userId }); const postData = insertPostSchema.parse({ ...postFields, userId });
const post = await storage.createPost(postData); const post = await storage.createPost(postData);
// Link media to post if provided (with display order) // Link media to post if provided (with display order)
if (finalMediaItems.length > 0) { if (finalMediaItems.length > 0) {
const postMediaValues = finalMediaItems.map(item => ({ const postMediaValues = finalMediaItems.map(item => ({
@@ -928,15 +956,15 @@ export async function registerRoutes(app: Express): Promise<Server> {
})); }));
await db.insert(postMedia).values(postMediaValues); await db.insert(postMedia).values(postMediaValues);
} }
// Create scheduled posts for each selected page // Create scheduled posts for each selected page
if (pageIds && Array.isArray(pageIds) && pageIds.length > 0) { if (pageIds && Array.isArray(pageIds) && pageIds.length > 0) {
const scheduledAt = postFields.scheduledFor const scheduledAt = postFields.scheduledFor
? new Date(postFields.scheduledFor) ? new Date(postFields.scheduledFor)
: new Date(); // Publish immediately if no date specified : new Date(); // Publish immediately if no date specified
const finalPostType = postType || 'feed'; const finalPostType = postType || 'feed';
for (const pageId of pageIds) { for (const pageId of pageIds) {
// If postType is "both", create two separate scheduled posts (story + feed) // If postType is "both", create two separate scheduled posts (story + feed)
if (finalPostType === 'both') { if (finalPostType === 'both') {
@@ -962,7 +990,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
} }
} }
res.json(post); res.json(post);
} catch (error) { } catch (error) {
console.error("Error creating post:", error); console.error("Error creating post:", error);
@@ -997,7 +1025,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
if (scheduledPostsForPost.length > 0) { if (scheduledPostsForPost.length > 0) {
const accessiblePages = await storage.getUserAccessiblePages(userId); const accessiblePages = await storage.getUserAccessiblePages(userId);
const accessiblePageIds = accessiblePages.map(p => p.id); const accessiblePageIds = accessiblePages.map(p => p.id);
// Vérifier si au moins une page du post est accessible // Vérifier si au moins une page du post est accessible
const hasAccess = scheduledPostsForPost.some(sp => accessiblePageIds.includes(sp.pageId)); const hasAccess = scheduledPostsForPost.some(sp => accessiblePageIds.includes(sp.pageId));
if (hasAccess) { if (hasAccess) {
@@ -1075,19 +1103,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
const { startDate, endDate } = req.query; const { startDate, endDate } = req.query;
const start = startDate ? new Date(startDate as string) : undefined; const start = startDate ? new Date(startDate as string) : undefined;
const end = endDate ? new Date(endDate as string) : undefined; const end = endDate ? new Date(endDate as string) : undefined;
let scheduledPosts; let scheduledPosts;
if (user.role === 'admin') { if (user.role === 'admin') {
// Admin voit tous les posts programmés - on récupère toutes les pages // Admin voit tous les posts programmés - on récupère toutes les pages
const allPages = await storage.getAllUsers().then(users => const allPages = await storage.getAllUsers().then(users =>
Promise.all(users.map(u => storage.getSocialPages(u.id))) Promise.all(users.map(u => storage.getSocialPages(u.id)))
).then(pagesArrays => pagesArrays.flat()); ).then(pagesArrays => pagesArrays.flat());
const allPageIds = allPages.map(p => p.id); const allPageIds = allPages.map(p => p.id);
if (allPageIds.length > 0) { if (allPageIds.length > 0) {
scheduledPosts = await storage.getScheduledPostsByPages(allPageIds, start, end); scheduledPosts = await storage.getScheduledPostsByPages(allPageIds, start, end);
} else { } else {
@@ -1097,14 +1125,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
// User voit uniquement les posts programmés sur les pages qui lui sont attribuées // User voit uniquement les posts programmés sur les pages qui lui sont attribuées
const accessiblePages = await storage.getUserAccessiblePages(userId); const accessiblePages = await storage.getUserAccessiblePages(userId);
const accessiblePageIds = accessiblePages.map(p => p.id); const accessiblePageIds = accessiblePages.map(p => p.id);
if (accessiblePageIds.length > 0) { if (accessiblePageIds.length > 0) {
scheduledPosts = await storage.getScheduledPostsByPages(accessiblePageIds, start, end); scheduledPosts = await storage.getScheduledPostsByPages(accessiblePageIds, start, end);
} else { } else {
scheduledPosts = []; scheduledPosts = [];
} }
} }
res.json(scheduledPosts); res.json(scheduledPosts);
} catch (error) { } catch (error) {
console.error("Error fetching scheduled posts:", error); console.error("Error fetching scheduled posts:", error);
@@ -1117,23 +1145,23 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
const { id } = req.params; const { id } = req.params;
// Verify the scheduled post exists // Verify the scheduled post exists
const scheduledPost = await storage.getScheduledPost(id); const scheduledPost = await storage.getScheduledPost(id);
if (!scheduledPost) { if (!scheduledPost) {
return res.status(404).json({ error: "Scheduled post not found" }); return res.status(404).json({ error: "Scheduled post not found" });
} }
const post = await storage.getPost(scheduledPost.postId); const post = await storage.getPost(scheduledPost.postId);
if (!post) { if (!post) {
return res.status(404).json({ error: "Post not found" }); return res.status(404).json({ error: "Post not found" });
} }
// Admin peut tout supprimer, user peut supprimer uniquement ses propres posts // Admin peut tout supprimer, user peut supprimer uniquement ses propres posts
if (user.role !== 'admin' && post.userId !== userId) { if (user.role !== 'admin' && post.userId !== userId) {
return res.status(403).json({ error: "Unauthorized" }); return res.status(403).json({ error: "Unauthorized" });
} }
await storage.deleteScheduledPost(id); await storage.deleteScheduledPost(id);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
@@ -1147,37 +1175,37 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
const { id } = req.params; const { id } = req.params;
// Verify the scheduled post exists // Verify the scheduled post exists
const scheduledPost = await storage.getScheduledPost(id); const scheduledPost = await storage.getScheduledPost(id);
if (!scheduledPost) { if (!scheduledPost) {
return res.status(404).json({ error: "Scheduled post not found" }); return res.status(404).json({ error: "Scheduled post not found" });
} }
const post = await storage.getPost(scheduledPost.postId); const post = await storage.getPost(scheduledPost.postId);
if (!post) { if (!post) {
return res.status(404).json({ error: "Post not found" }); return res.status(404).json({ error: "Post not found" });
} }
// Admin peut tout modifier, user peut modifier uniquement ses propres posts // Admin peut tout modifier, user peut modifier uniquement ses propres posts
if (user.role !== 'admin' && post.userId !== userId) { if (user.role !== 'admin' && post.userId !== userId) {
return res.status(403).json({ error: "Unauthorized" }); return res.status(403).json({ error: "Unauthorized" });
} }
// Only allow updating scheduledAt and pageId // Only allow updating scheduledAt and pageId
const { scheduledAt, pageId } = req.body; const { scheduledAt, pageId } = req.body;
const updateData: Partial<ScheduledPost> = {}; const updateData: Partial<ScheduledPost> = {};
if (scheduledAt) { if (scheduledAt) {
updateData.scheduledAt = new Date(scheduledAt); updateData.scheduledAt = new Date(scheduledAt);
// Synchroniser avec la table posts // Synchroniser avec la table posts
await storage.updatePost(scheduledPost.postId, { scheduledFor: new Date(scheduledAt) }); await storage.updatePost(scheduledPost.postId, { scheduledFor: new Date(scheduledAt) });
} }
if (pageId) { if (pageId) {
updateData.pageId = pageId; updateData.pageId = pageId;
} }
const updated = await storage.updateScheduledPost(id, updateData); const updated = await storage.updateScheduledPost(id, updateData);
res.json(updated); res.json(updated);
} catch (error) { } catch (error) {
@@ -1189,7 +1217,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
app.post("/api/scheduled-posts", requireAuth, async (req, res) => { app.post("/api/scheduled-posts", requireAuth, async (req, res) => {
try { try {
const scheduledPostData = insertScheduledPostSchema.parse(req.body); const scheduledPostData = insertScheduledPostSchema.parse(req.body);
// If postType is "both", create two separate scheduled posts (story + feed) // If postType is "both", create two separate scheduled posts (story + feed)
// This prevents retry loops - each post type is independent // This prevents retry loops - each post type is independent
if (scheduledPostData.postType === 'both') { if (scheduledPostData.postType === 'both') {
@@ -1220,9 +1248,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
try { try {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
let pages: SocialPage[]; let pages: SocialPage[];
if (user.role === 'admin') { if (user.role === 'admin') {
// Les admins voient toutes les pages de tous les utilisateurs // Les admins voient toutes les pages de tous les utilisateurs
const allUsers = await storage.getAllUsers(); const allUsers = await storage.getAllUsers();
@@ -1234,7 +1262,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Les utilisateurs normaux voient uniquement les pages auxquelles ils ont accès // Les utilisateurs normaux voient uniquement les pages auxquelles ils ont accès
pages = await storage.getUserAccessiblePages(userId); pages = await storage.getUserAccessiblePages(userId);
} }
res.json(pages); res.json(pages);
} catch (error) { } catch (error) {
console.error("Error fetching pages:", error); console.error("Error fetching pages:", error);
@@ -1246,15 +1274,15 @@ export async function registerRoutes(app: Express): Promise<Server> {
try { try {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
// Calculate token expiration date (60 days from now) // Calculate token expiration date (60 days from now)
const tokenExpiresAt = new Date(); const tokenExpiresAt = new Date();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60); tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
const pageData = insertSocialPageSchema.parse({ const pageData = insertSocialPageSchema.parse({
...req.body, ...req.body,
userId, userId,
tokenExpiresAt tokenExpiresAt
}); });
const page = await storage.createSocialPage(pageData); const page = await storage.createSocialPage(pageData);
res.json(page); res.json(page);
@@ -1269,21 +1297,21 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
const pageId = req.params.id; const pageId = req.params.id;
const existingPage = await storage.getSocialPage(pageId); const existingPage = await storage.getSocialPage(pageId);
if (!existingPage || existingPage.userId !== userId) { if (!existingPage || existingPage.userId !== userId) {
return res.status(404).json({ error: "Page non trouvée" }); return res.status(404).json({ error: "Page non trouvée" });
} }
let pageData = insertSocialPageSchema.partial().parse(req.body); let pageData = insertSocialPageSchema.partial().parse(req.body);
// If accessToken is being updated, recalculate expiration date (60 days from now) // If accessToken is being updated, recalculate expiration date (60 days from now)
if (pageData.accessToken) { if (pageData.accessToken) {
const tokenExpiresAt = new Date(); const tokenExpiresAt = new Date();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60); tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
pageData = { ...pageData, tokenExpiresAt }; pageData = { ...pageData, tokenExpiresAt };
} }
const updatedPage = await storage.updateSocialPage(pageId, pageData); const updatedPage = await storage.updateSocialPage(pageId, pageData);
res.json(updatedPage); res.json(updatedPage);
} catch (error) { } catch (error) {
@@ -1297,12 +1325,12 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
const pageId = req.params.id; const pageId = req.params.id;
const existingPage = await storage.getSocialPage(pageId); const existingPage = await storage.getSocialPage(pageId);
if (!existingPage || existingPage.userId !== userId) { if (!existingPage || existingPage.userId !== userId) {
return res.status(404).json({ error: "Page non trouvée" }); return res.status(404).json({ error: "Page non trouvée" });
} }
await storage.deleteSocialPage(pageId); await storage.deleteSocialPage(pageId);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
@@ -1330,7 +1358,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
const config = await storage.getCloudinaryConfig(userId); const config = await storage.getCloudinaryConfig(userId);
if (!config) { if (!config) {
return res.json(null); return res.json(null);
} }
@@ -1348,10 +1376,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
try { try {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
// Check if config already exists // Check if config already exists
const existingConfig = await storage.getCloudinaryConfig(userId); const existingConfig = await storage.getCloudinaryConfig(userId);
let config; let config;
if (existingConfig) { if (existingConfig) {
// Pour les mises à jour, utiliser le schéma qui rend les secrets optionnels // Pour les mises à jour, utiliser le schéma qui rend les secrets optionnels
@@ -1359,14 +1387,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
...req.body, ...req.body,
userId, userId,
}); });
// Si apiKey/apiSecret ne sont pas fournis, garder les anciens // Si apiKey/apiSecret ne sont pas fournis, garder les anciens
const finalData = { const finalData = {
...updateData, ...updateData,
apiKey: updateData.apiKey || existingConfig.apiKey, apiKey: updateData.apiKey || existingConfig.apiKey,
apiSecret: updateData.apiSecret || existingConfig.apiSecret, apiSecret: updateData.apiSecret || existingConfig.apiSecret,
}; };
config = await storage.updateCloudinaryConfig(userId, finalData); config = await storage.updateCloudinaryConfig(userId, finalData);
} else { } else {
// Pour les créations, exiger tous les champs // Pour les créations, exiger tous les champs
@@ -1399,8 +1427,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Check if Cloudinary is configured // Check if Cloudinary is configured
const cloudinaryConfig = await storage.getCloudinaryConfig(userId); const cloudinaryConfig = await storage.getCloudinaryConfig(userId);
if (!cloudinaryConfig) { if (!cloudinaryConfig) {
return res.status(400).json({ return res.status(400).json({
error: "Cloudinary not configured. Please configure Cloudinary first." error: "Cloudinary not configured. Please configure Cloudinary first."
}); });
} }
@@ -1490,7 +1518,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
const config = await storage.getOpenrouterConfig(userId); const config = await storage.getOpenrouterConfig(userId);
if (!config) { if (!config) {
return res.json(null); return res.json(null);
} }
@@ -1508,10 +1536,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
try { try {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
// Check if config already exists // Check if config already exists
const existingConfig = await storage.getOpenrouterConfig(userId); const existingConfig = await storage.getOpenrouterConfig(userId);
let config; let config;
if (existingConfig) { if (existingConfig) {
// Pour les mises à jour, utiliser le schéma qui rend apiKey optionnel // Pour les mises à jour, utiliser le schéma qui rend apiKey optionnel
@@ -1519,13 +1547,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
...req.body, ...req.body,
userId, userId,
}); });
// Si apiKey n'est pas fourni, garder l'ancien // Si apiKey n'est pas fourni, garder l'ancien
const finalData = { const finalData = {
...updateData, ...updateData,
apiKey: updateData.apiKey || existingConfig.apiKey, apiKey: updateData.apiKey || existingConfig.apiKey,
}; };
config = await storage.updateOpenrouterConfig(userId, finalData); config = await storage.updateOpenrouterConfig(userId, finalData);
} else { } else {
// Pour les créations, exiger tous les champs // Pour les créations, exiger tous les champs
+34 -11
View File
@@ -1,15 +1,15 @@
import { import {
users, users,
socialPages, socialPages,
media, media,
posts, posts,
postMedia, postMedia,
scheduledPosts, scheduledPosts,
aiGenerations, aiGenerations,
cloudinaryConfig, cloudinaryConfig,
openrouterConfig, openrouterConfig,
userPagePermissions, userPagePermissions,
type User, type User,
type InsertUser, type InsertUser,
type SocialPage, type SocialPage,
type InsertSocialPage, type InsertSocialPage,
@@ -31,6 +31,7 @@ import {
} from "@shared/schema"; } from "@shared/schema";
import { db } from "./db"; import { db } from "./db";
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm"; import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
import { encrypt, decrypt, isEncrypted } from "./utils/encryption";
export interface IStorage { export interface IStorage {
// Users // Users
@@ -131,21 +132,43 @@ export class DatabaseStorage implements IStorage {
// Social Pages // Social Pages
async getSocialPages(userId: string): Promise<SocialPage[]> { async getSocialPages(userId: string): Promise<SocialPage[]> {
return await db.select().from(socialPages).where(eq(socialPages.userId, userId)); const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId));
// Déchiffrer les tokens pour chaque page
return pages.map(page => ({
...page,
accessToken: decrypt(page.accessToken)
}));
} }
async getSocialPage(id: string): Promise<SocialPage | undefined> { async getSocialPage(id: string): Promise<SocialPage | undefined> {
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id)); const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id));
if (page) {
// Déchiffrer le token
page.accessToken = decrypt(page.accessToken);
}
return page || undefined; return page || undefined;
} }
async createSocialPage(page: InsertSocialPage): Promise<SocialPage> { async createSocialPage(page: InsertSocialPage): Promise<SocialPage> {
const [newPage] = await db.insert(socialPages).values(page).returning(); // Chiffrer le token avant stockage
const encryptedPage = {
...page,
accessToken: encrypt(page.accessToken)
};
const [newPage] = await db.insert(socialPages).values(encryptedPage).returning();
// Retourner avec le token déchiffré
newPage.accessToken = decrypt(newPage.accessToken);
return newPage; return newPage;
} }
async updateSocialPage(id: string, page: Partial<InsertSocialPage>): Promise<SocialPage> { async updateSocialPage(id: string, page: Partial<InsertSocialPage>): Promise<SocialPage> {
const [updated] = await db.update(socialPages).set(page).where(eq(socialPages.id, id)).returning(); // Chiffrer le token si présent dans la mise à jour
const updateData = page.accessToken
? { ...page, accessToken: encrypt(page.accessToken) }
: page;
const [updated] = await db.update(socialPages).set(updateData).where(eq(socialPages.id, id)).returning();
// Retourner avec le token déchiffré
updated.accessToken = decrypt(updated.accessToken);
return updated; return updated;
} }
@@ -219,7 +242,7 @@ export class DatabaseStorage implements IStorage {
async updatePostMedia(postId: string, mediaIds: string[]): Promise<void> { async updatePostMedia(postId: string, mediaIds: string[]): Promise<void> {
await db.delete(postMedia).where(eq(postMedia.postId, postId)); await db.delete(postMedia).where(eq(postMedia.postId, postId));
if (mediaIds.length > 0) { if (mediaIds.length > 0) {
const postMediaEntries = mediaIds.map((mediaId, index) => ({ const postMediaEntries = mediaIds.map((mediaId, index) => ({
postId, postId,
@@ -423,7 +446,7 @@ export class DatabaseStorage implements IStorage {
.from(userPagePermissions) .from(userPagePermissions)
.innerJoin(socialPages, eq(userPagePermissions.pageId, socialPages.id)) .innerJoin(socialPages, eq(userPagePermissions.pageId, socialPages.id))
.where(eq(userPagePermissions.userId, userId)); .where(eq(userPagePermissions.userId, userId));
return permissions.map(p => p.social_pages); return permissions.map(p => p.social_pages);
} }
} }
+92
View File
@@ -0,0 +1,92 @@
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
/**
* Récupère la clé de chiffrement depuis les variables d'environnement.
* Dérive une clé de 32 bytes pour AES-256.
*/
function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY;
if (!key) {
// En développement, utiliser une clé par défaut (non sécurisé pour la production)
if (process.env.NODE_ENV !== 'production') {
console.warn('⚠️ ENCRYPTION_KEY non défini. Utilisation d\'une clé par défaut (développement uniquement)');
return crypto.scryptSync('dev-default-key-not-secure', 'salt', 32);
}
throw new Error('ENCRYPTION_KEY non défini dans les variables d\'environnement');
}
// Dériver une clé de 32 bytes depuis la clé fournie
return crypto.scryptSync(key, 'socialflow-salt', 32);
}
/**
* 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));
}