mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-14 10:48:20 +02:00
Sans APP_URL, Facebook et TikTok recevaient http://localhost:5555/api/{facebook,tiktok}/callback : une URI que les fournisseurs refusent, donc aucune page connectable derrière un reverse proxy. Le repli localhost n'est plus utilisé que hors requête HTTP ; en service, la base publique est reconstruite depuis X-Forwarded-Proto / X-Forwarded-Host (trust proxy est déjà activé). - `resolvePublicBaseUrl(req)` centralise la résolution (APP_URL prioritaire) - l'URI de redirection est déduite de la requête au démarrage du flux, à l'échange du code et dans l'affichage des paramètres : les trois restent identiques, comme l'exige OAuth - docker-compose ne force plus APP_URL à localhost, qui rendait l'auto détection inopérante - cookie de session en `secure: 'auto'` à défaut d'APP_URL, pour ne pas émettre un cookie non sécurisé derrière un proxy HTTPS Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Db2EazEcvnqgTTeg8oGTif
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
/**
|
|
* Connexion et renouvellement des pages Facebook / Instagram.
|
|
*
|
|
* Une page connectée ici embarque le jeton utilisateur longue durée qui a servi
|
|
* à l'obtenir : c'est ce qui permet au `TokenManager` de régénérer son jeton de
|
|
* page tout seul, au lieu d'exiger un copier-coller depuis le Graph Explorer
|
|
* tous les deux mois.
|
|
*/
|
|
|
|
import crypto from 'crypto';
|
|
import { Router, Request, Response } from 'express';
|
|
import type { User } from '@shared/schema';
|
|
import { storage } from '../storage';
|
|
import { facebookOAuthService } from '../services/facebook_oauth';
|
|
import { TokenManager } from '../services/token_manager';
|
|
import { encrypt } from '../utils/encryption';
|
|
import { insertFacebookConfigSchema, updateFacebookConfigSchema } from '@shared/schema';
|
|
|
|
export const facebookRouter = Router();
|
|
|
|
/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */
|
|
const RETURN_PATH = '/pages';
|
|
|
|
function isAdmin(req: Request): boolean {
|
|
const user = req.user as User | undefined;
|
|
return user?.role === 'admin';
|
|
}
|
|
|
|
function buildReturnUrl(status: 'success' | 'error', message: string): string {
|
|
const params = new URLSearchParams({ facebook: status, message });
|
|
return `${RETURN_PATH}?${params.toString()}`;
|
|
}
|
|
|
|
/**
|
|
* État de la configuration Facebook.
|
|
* L'App Secret n'est jamais renvoyé au client.
|
|
*/
|
|
facebookRouter.get('/config', async (req: Request, res: Response) => {
|
|
try {
|
|
const config = await storage.getFacebookConfig();
|
|
const configured = await facebookOAuthService.isConfigured();
|
|
|
|
// Tout le monde a besoin de savoir si la connexion est possible ; seuls les
|
|
// admins voient les détails de l'application développeur.
|
|
if (!isAdmin(req)) {
|
|
return res.json({ configured });
|
|
}
|
|
|
|
res.json({
|
|
configured,
|
|
appId: config?.appId || (process.env.FACEBOOK_APP_ID ? "(défini par variable d'environnement)" : ''),
|
|
hasAppSecret: !!(config?.appSecret || process.env.FACEBOOK_APP_SECRET),
|
|
redirectUri: facebookOAuthService.getRedirectUri(req),
|
|
scopes: facebookOAuthService.getScopes().split(','),
|
|
});
|
|
} catch (error) {
|
|
console.error('❌ [Facebook] Erreur lecture configuration:', error);
|
|
res.status(500).json({ error: 'Impossible de lire la configuration Facebook' });
|
|
}
|
|
});
|
|
|
|
/** Enregistre les identifiants de l'application Facebook (admin). */
|
|
facebookRouter.put('/config', async (req: Request, res: Response) => {
|
|
try {
|
|
if (!isAdmin(req)) {
|
|
return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' });
|
|
}
|
|
|
|
const existing = await storage.getFacebookConfig();
|
|
// Le secret peut être omis lors d'une mise à jour : on garde l'existant
|
|
const schema = existing ? updateFacebookConfigSchema : insertFacebookConfigSchema;
|
|
const data = schema.parse(req.body);
|
|
|
|
const config = await storage.upsertFacebookConfig(data);
|
|
|
|
res.json({
|
|
success: true,
|
|
appId: config.appId,
|
|
redirectUri: facebookOAuthService.getRedirectUri(req),
|
|
});
|
|
} catch (error: any) {
|
|
console.error('❌ [Facebook] Erreur enregistrement configuration:', error);
|
|
res.status(400).json({
|
|
error: error?.errors?.[0]?.message || error?.message || 'Configuration Facebook invalide',
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Démarre la connexion des pages Facebook.
|
|
* À appeler par navigation complète du navigateur (window.location), pas en fetch.
|
|
*/
|
|
facebookRouter.get('/connect', async (req: Request, res: Response) => {
|
|
try {
|
|
if (!(await facebookOAuthService.isConfigured())) {
|
|
return res.redirect(
|
|
buildReturnUrl('error', "Facebook n'est pas configuré. Renseignez d'abord l'App ID et l'App Secret.")
|
|
);
|
|
}
|
|
|
|
// Protection CSRF : l'état est vérifié au retour
|
|
const state = crypto.randomBytes(16).toString('hex');
|
|
(req.session as any).facebookOAuthState = state;
|
|
|
|
const authUrl = await facebookOAuthService.buildAuthorizationUrl(state, req);
|
|
|
|
// La session doit être écrite avant la redirection, sinon l'état est perdu
|
|
req.session.save((err) => {
|
|
if (err) {
|
|
console.error('❌ [Facebook] Impossible de sauvegarder la session OAuth:', err);
|
|
return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.'));
|
|
}
|
|
res.redirect(authUrl);
|
|
});
|
|
} catch (error) {
|
|
console.error('❌ [Facebook] Erreur démarrage OAuth:', error);
|
|
res.redirect(
|
|
buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion Facebook')
|
|
);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Retour d'autorisation Facebook : échange le code contre un jeton utilisateur
|
|
* longue durée, puis crée ou met à jour chaque page administrée.
|
|
*/
|
|
facebookRouter.get('/callback', async (req: Request, res: Response) => {
|
|
const user = req.user as User;
|
|
const { code, state, error: oauthError, error_description: oauthErrorDescription } = req.query;
|
|
|
|
try {
|
|
if (oauthError) {
|
|
const description = (oauthErrorDescription as string) || (oauthError as string);
|
|
return res.redirect(buildReturnUrl('error', `Autorisation Facebook refusée : ${description}`));
|
|
}
|
|
|
|
const expectedState = (req.session as any)?.facebookOAuthState;
|
|
delete (req.session as any)?.facebookOAuthState;
|
|
|
|
if (!state || !expectedState || state !== expectedState) {
|
|
return res.redirect(buildReturnUrl('error', 'Requête de connexion Facebook invalide (état non reconnu).'));
|
|
}
|
|
|
|
if (!code || typeof code !== 'string') {
|
|
return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de Facebook."));
|
|
}
|
|
|
|
const userToken = await facebookOAuthService.exchangeCodeForUserToken(code, req);
|
|
const targets = await facebookOAuthService.listPages(userToken.accessToken);
|
|
|
|
if (targets.length === 0) {
|
|
return res.redirect(
|
|
buildReturnUrl(
|
|
'error',
|
|
"Aucune page Facebook administrée par ce compte. Connectez-vous avec un compte administrateur de la page."
|
|
)
|
|
);
|
|
}
|
|
|
|
// Le jeton utilisateur est la clé du renouvellement automatique : il est
|
|
// conservé chiffré, sur chaque page issue de cette autorisation.
|
|
const encryptedUserToken = encrypt(userToken.accessToken);
|
|
const existingPages = await storage.getSocialPages(user.id);
|
|
|
|
let created = 0;
|
|
let updated = 0;
|
|
|
|
for (const target of targets) {
|
|
const existing = existingPages.find(
|
|
(page) => page.platform === 'facebook' && page.pageId === target.id
|
|
);
|
|
|
|
if (existing) {
|
|
await storage.updateSocialPage(existing.id, {
|
|
pageName: target.name,
|
|
accessToken: target.accessToken,
|
|
// Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire pas.
|
|
tokenExpiresAt: null,
|
|
userAccessToken: encryptedUserToken,
|
|
userTokenExpiresAt: userToken.expiresAt,
|
|
followersCount: target.followersCount,
|
|
tokenStatus: 'valid',
|
|
tokenError: null,
|
|
lastTokenCheck: new Date(),
|
|
isActive: 'true',
|
|
});
|
|
updated++;
|
|
continue;
|
|
}
|
|
|
|
const page = await storage.createSocialPage({
|
|
userId: user.id,
|
|
platform: 'facebook',
|
|
pageId: target.id,
|
|
pageName: target.name,
|
|
accessToken: target.accessToken,
|
|
tokenExpiresAt: null,
|
|
userAccessToken: encryptedUserToken,
|
|
userTokenExpiresAt: userToken.expiresAt,
|
|
followersCount: target.followersCount,
|
|
isActive: 'true',
|
|
});
|
|
created++;
|
|
|
|
// Les non-admins ne voient que les pages qui leur sont explicitement
|
|
// attribuées : sans cette permission, ils ne verraient pas leur propre page.
|
|
if (user.role !== 'admin') {
|
|
try {
|
|
await storage.createPagePermission({ userId: user.id, pageId: page.id });
|
|
} catch (permissionError) {
|
|
console.error('⚠️ [Facebook] Impossible de créer la permission de page:', permissionError);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Un compte Instagram professionnel publie avec le jeton de la page Facebook
|
|
// à laquelle il est rattaché : on le rafraîchit dans la foulée, sans quoi il
|
|
// resterait sur un jeton saisi à la main, condamné à expirer.
|
|
for (const page of existingPages) {
|
|
if (page.platform !== 'instagram') continue;
|
|
const linked = targets.find((target) => target.instagram?.id === page.pageId);
|
|
if (!linked) continue;
|
|
|
|
await storage.updateSocialPage(page.id, {
|
|
accessToken: linked.accessToken,
|
|
tokenExpiresAt: null,
|
|
userAccessToken: encryptedUserToken,
|
|
userTokenExpiresAt: userToken.expiresAt,
|
|
tokenStatus: 'valid',
|
|
tokenError: null,
|
|
lastTokenCheck: new Date(),
|
|
});
|
|
updated++;
|
|
}
|
|
|
|
const summary = [
|
|
created > 0 ? `${created} page${created > 1 ? 's' : ''} connectée${created > 1 ? 's' : ''}` : null,
|
|
updated > 0 ? `${updated} page${updated > 1 ? 's' : ''} mise${updated > 1 ? 's' : ''} à jour` : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
|
|
console.log(`✅ [Facebook] ${summary} pour ${user.username}`);
|
|
res.redirect(
|
|
buildReturnUrl('success', `${summary}. Les jetons se renouvellent désormais automatiquement.`)
|
|
);
|
|
} catch (error) {
|
|
console.error('❌ [Facebook] Erreur callback OAuth:', error);
|
|
res.redirect(
|
|
buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion Facebook impossible')
|
|
);
|
|
}
|
|
});
|
|
|
|
/** Contrôle (et renouvelle si besoin) le jeton d'une page à la demande. */
|
|
facebookRouter.post('/pages/:id/refresh', async (req: Request, res: Response) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const page = await storage.getSocialPage(req.params.id);
|
|
|
|
if (!page) {
|
|
return res.status(404).json({ error: 'Page non trouvée' });
|
|
}
|
|
|
|
if (user.role !== 'admin' && page.userId !== user.id) {
|
|
const accessible = await storage.getUserAccessiblePages(user.id);
|
|
if (!accessible.some((p) => p.id === page.id)) {
|
|
return res.status(403).json({ error: 'Accès refusé à cette page' });
|
|
}
|
|
}
|
|
|
|
const result = await TokenManager.checkPage(page.id);
|
|
res.json(result);
|
|
} catch (error) {
|
|
console.error('❌ [Facebook] Erreur renouvellement du jeton:', error);
|
|
res.status(502).json({
|
|
error: error instanceof Error ? error.message : 'Impossible de contrôler le jeton',
|
|
});
|
|
}
|
|
});
|
|
|
|
/** Relance le contrôle de toutes les pages (admin). */
|
|
facebookRouter.post('/tokens/check', async (req: Request, res: Response) => {
|
|
try {
|
|
if (!isAdmin(req)) {
|
|
return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' });
|
|
}
|
|
|
|
const results = await TokenManager.checkAndRefreshTokens();
|
|
res.json({
|
|
checked: results.length,
|
|
renewed: results.filter((r) => r.renewed).length,
|
|
failing: results.filter((r) => r.status === 'expired' || r.status === 'error').length,
|
|
results,
|
|
});
|
|
} catch (error) {
|
|
console.error('❌ [Facebook] Erreur contrôle global des jetons:', error);
|
|
res.status(500).json({ error: 'Contrôle des jetons impossible' });
|
|
}
|
|
});
|