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)
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
# Obtenez votre clé sur https://openrouter.ai/
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",
"emoji-regex": "^10.6.0",
"express": "^4.21.2",
"express-rate-limit": "^7.5.1",
"express-session": "^1.18.1",
"framer-motion": "^11.13.1",
"helmet": "^8.1.0",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2",
@@ -125,4 +127,4 @@
"optionalDependencies": {
"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 connectPgSimple from "connect-pg-simple";
import pg from "pg";
import crypto from "crypto";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import passport from "./auth";
import { registerRoutes } from "./routes";
import { setupVite, serveStatic, log } from "./vite";
@@ -11,6 +14,42 @@ import { ensureAdminUserExists } from "./init-admin";
const app = express();
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' {
interface IncomingMessage {
rawBody: unknown
@@ -23,28 +62,36 @@ app.use(express.json({
}));
app.use(express.urlencoded({ extended: false }));
// Configuration des sessions
if (!process.env.SESSION_SECRET) {
console.warn('⚠️ SESSION_SECRET non défini. Utilisation d\'une clé par défaut (NON SÉCURISÉ en production)');
// Validation renforcée du SESSION_SECRET
if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'production') {
console.error('❌ SESSION_SECRET non défini en production. Arrêt du serveur.');
process.exit(1);
}
if (!process.env.SESSION_SECRET) {
console.warn('⚠️ SESSION_SECRET non défini. Utilisation d\'une clé aléatoire pour le développement.');
}
// Générer un secret aléatoire pour le dev si non défini
const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
// Déterminer si on utilise HTTPS basé sur APP_URL
const isHttps = process.env.APP_URL?.startsWith('https://') || false;
// Configuration du store de session pour production
const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL
? new PgSession({
pool: new pg.Pool({
connectionString: process.env.DATABASE_URL,
}),
tableName: 'session',
createTableIfMissing: true,
})
pool: new pg.Pool({
connectionString: process.env.DATABASE_URL,
}),
tableName: 'session',
createTableIfMissing: true,
})
: undefined; // MemoryStore par défaut en dev
app.use(session({
store: sessionStore,
secret: process.env.SESSION_SECRET || 'your-secret-key-change-me',
secret: sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
@@ -77,7 +124,7 @@ app.use((req, res, next) => {
if (path === "/api/auth/session" && res.statusCode === 401) {
return;
}
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
if (capturedJsonResponse) {
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
@@ -119,17 +166,17 @@ app.use((req, res, next) => {
// this serves both the API and the client.
// It is the only port that is not firewalled.
const port = parseInt(process.env.PORT || '5000', 10);
// Initialiser l'utilisateur admin par défaut avant de démarrer le serveur
await ensureAdminUserExists();
server.listen({
port,
host: "0.0.0.0",
reusePort: true,
}, () => {
log(`serving on port ${port}`);
// Démarrer le scheduler pour les publications programmées
schedulerService.start();
});
+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 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
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) => {
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);
@@ -143,7 +163,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
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) {
@@ -151,7 +171,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
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);
@@ -160,12 +180,12 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
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;
}
@@ -175,7 +195,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
const updatedUser = await storage.updateUser(userId, updateData);
res.json({
id: updatedUser.id,
username: updatedUser.username,
@@ -192,7 +212,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
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" });
@@ -205,7 +225,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
await storage.deleteUser(userId);
res.json({ success: true, message: "Utilisateur supprimé avec succès" });
} catch (error: any) {
console.error("Error deleting user:", error);
@@ -240,7 +260,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Créer les nouvelles permissions
const permissions = await Promise.all(
pageIds.map(pageId =>
pageIds.map(pageId =>
storage.createPagePermission({ userId, pageId })
)
);
@@ -276,10 +296,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
}
res.json({
success: true,
res.json({
success: true,
message: `${migratedCount} permissions migrées avec succès`,
migratedCount
migratedCount
});
} catch (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) => {
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:", 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) => {
// 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 {
// Validation Zod
const sqlQuerySchema = z.object({
@@ -318,7 +346,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
const { db } = await import("./db");
const result = await db.execute(validatedData.query);
res.json({
success: true,
result,
@@ -345,7 +373,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
const result = await db.execute<{ tablename: string }>(
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;`
);
res.json({
tables: result.rows || result,
});
@@ -363,7 +391,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
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);
@@ -379,7 +407,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
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);
@@ -399,7 +427,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
}).length;
// Calculer les variations en pourcentage
const aiTextChange = aiTextsYesterday > 0
const aiTextChange = aiTextsYesterday > 0
? Math.round(((currentAiTexts - aiTextsYesterday) / aiTextsYesterday) * 100)
: 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)
const cloudinaryConfig = await storage.getAnyCloudinaryConfig();
if (!cloudinaryConfig) {
return res.status(400).json({
error: "Cloudinary not configured. Please ask an administrator to configure Cloudinary in Settings first."
return res.status(400).json({
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
const sharp = (await import("sharp")).default;
let image = sharp(imageBuffer);
// Get image metadata for dimensions
const metadata = await image.metadata();
const width = metadata.width!;
@@ -562,33 +590,33 @@ export async function registerRoutes(app: Express): Promise<Server> {
// 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,
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,
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,
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,
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,
@@ -642,8 +670,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
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}"
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}
@@ -661,7 +689,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Add logo overlay
if (logo && logo.enabled) {
console.log("🏢 Adding logo overlay...");
// Get Cloudinary config to get logo public ID
const cloudinaryConfig = await storage.getAnyCloudinaryConfig();
if (cloudinaryConfig && cloudinaryConfig.logoPublicId) {
@@ -669,44 +697,44 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Build logo URL from Cloudinary
const logoUrl = `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`;
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') {
@@ -718,7 +746,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
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) {
@@ -729,7 +757,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
.linear(opacityFactor, 0)
.toBuffer();
}
// Add logo overlay
overlays.push({
input: logoWithOpacity,
@@ -737,7 +765,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
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);
@@ -761,8 +789,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Check if Cloudinary is configured (shared config used for all users)
const cloudinaryConfig = await storage.getAnyCloudinaryConfig();
if (!cloudinaryConfig) {
return res.status(400).json({
error: "Cloudinary not configured. Please ask an administrator to configure Cloudinary in Settings first."
return res.status(400).json({
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 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" });
}
@@ -816,7 +844,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Delete from Cloudinary
if (mediaToDelete.cloudinaryPublicId) {
await cloudinaryService.deleteMedia(
mediaToDelete.cloudinaryPublicId,
mediaToDelete.cloudinaryPublicId,
userId,
mediaToDelete.type
);
@@ -824,7 +852,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Delete from database
await storage.deleteMedia(mediaId);
res.json({ success: true });
} catch (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 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) => {
@@ -873,52 +901,52 @@ export async function registerRoutes(app: Express): Promise<Server> {
// 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 =>
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"
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 => ({
@@ -928,15 +956,15 @@ export async function registerRoutes(app: Express): Promise<Server> {
}));
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)
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') {
@@ -962,7 +990,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
}
}
res.json(post);
} catch (error) {
console.error("Error creating post:", error);
@@ -997,7 +1025,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
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) {
@@ -1075,19 +1103,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
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 =>
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 {
@@ -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
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);
@@ -1117,23 +1145,23 @@ export async function registerRoutes(app: Express): Promise<Server> {
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) {
@@ -1147,37 +1175,37 @@ export async function registerRoutes(app: Express): Promise<Server> {
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) {
@@ -1189,7 +1217,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
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') {
@@ -1220,9 +1248,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
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();
@@ -1234,7 +1262,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// 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);
@@ -1246,15 +1274,15 @@ export async function registerRoutes(app: Express): Promise<Server> {
try {
const user = req.user as User;
const userId = user.id;
// Calculate token expiration date (60 days from now)
const tokenExpiresAt = new Date();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
const pageData = insertSocialPageSchema.parse({
...req.body,
const pageData = insertSocialPageSchema.parse({
...req.body,
userId,
tokenExpiresAt
tokenExpiresAt
});
const page = await storage.createSocialPage(pageData);
res.json(page);
@@ -1269,21 +1297,21 @@ export async function registerRoutes(app: Express): Promise<Server> {
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 pageData = insertSocialPageSchema.partial().parse(req.body);
// If accessToken is being updated, recalculate expiration date (60 days from now)
if (pageData.accessToken) {
const tokenExpiresAt = new Date();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
pageData = { ...pageData, tokenExpiresAt };
}
const updatedPage = await storage.updateSocialPage(pageId, pageData);
res.json(updatedPage);
} catch (error) {
@@ -1297,12 +1325,12 @@ export async function registerRoutes(app: Express): Promise<Server> {
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) {
@@ -1330,7 +1358,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User;
const userId = user.id;
const config = await storage.getCloudinaryConfig(userId);
if (!config) {
return res.json(null);
}
@@ -1348,10 +1376,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
try {
const user = req.user as User;
const userId = user.id;
// Check if config already exists
const existingConfig = await storage.getCloudinaryConfig(userId);
let config;
if (existingConfig) {
// 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,
userId,
});
// 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(userId, finalData);
} else {
// 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
const cloudinaryConfig = await storage.getCloudinaryConfig(userId);
if (!cloudinaryConfig) {
return res.status(400).json({
error: "Cloudinary not configured. Please configure Cloudinary first."
return res.status(400).json({
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 userId = user.id;
const config = await storage.getOpenrouterConfig(userId);
if (!config) {
return res.json(null);
}
@@ -1508,10 +1536,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
try {
const user = req.user as User;
const userId = user.id;
// Check if config already exists
const existingConfig = await storage.getOpenrouterConfig(userId);
let config;
if (existingConfig) {
// 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,
userId,
});
// Si apiKey n'est pas fourni, garder l'ancien
const finalData = {
...updateData,
apiKey: updateData.apiKey || existingConfig.apiKey,
};
config = await storage.updateOpenrouterConfig(userId, finalData);
} else {
// Pour les créations, exiger tous les champs
+34 -11
View File
@@ -1,15 +1,15 @@
import {
users,
socialPages,
media,
posts,
import {
users,
socialPages,
media,
posts,
postMedia,
scheduledPosts,
aiGenerations,
cloudinaryConfig,
openrouterConfig,
userPagePermissions,
type User,
type User,
type InsertUser,
type SocialPage,
type InsertSocialPage,
@@ -31,6 +31,7 @@ import {
} from "@shared/schema";
import { db } from "./db";
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
import { encrypt, decrypt, isEncrypted } from "./utils/encryption";
export interface IStorage {
// Users
@@ -131,21 +132,43 @@ export class DatabaseStorage implements IStorage {
// Social Pages
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> {
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;
}
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;
}
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;
}
@@ -219,7 +242,7 @@ export class DatabaseStorage implements IStorage {
async updatePostMedia(postId: string, mediaIds: string[]): Promise<void> {
await db.delete(postMedia).where(eq(postMedia.postId, postId));
if (mediaIds.length > 0) {
const postMediaEntries = mediaIds.map((mediaId, index) => ({
postId,
@@ -423,7 +446,7 @@ export class DatabaseStorage implements IStorage {
.from(userPagePermissions)
.innerJoin(socialPages, eq(userPagePermissions.pageId, socialPages.id))
.where(eq(userPagePermissions.userId, userId));
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));
}