mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
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:
1 parent
c3660f811b
commit
633875e8ec
7 files changed
+917
-450
No files matched your search
@@ -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
|
||||
|
||||
Generated
+569
-298
File diff suppressed because it is too large.
Load diff
+3
-1
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user