mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-16 19:59:42 +02:00
Un jeton de page collé à la main meurt au bout de 60 jours — ou du jour au lendemain si le mot de passe change, si le compte perd son rôle d'admin ou si Meta révoque l'autorisation — et on ne l'apprenait qu'à la première publication en échec : le contrôle quotidien existant se contentait de marquer la page « expired ». On reprend la chaîne recommandée par Meta, comme dans PleinR : autorisation OAuth, échange du code contre un jeton utilisateur longue durée (~60 j), puis dérivation des jetons de page — qui n'expirent pas. Le jeton utilisateur est conservé chiffré sur chaque page : c'est lui qui permet au TokenManager de régénérer un jeton révoqué sans intervention humaine, et de se prolonger lui-même avant échéance. - facebook_config : App ID / App Secret de l'application, saisis dans les paramètres (chiffrés en base) ou par variables d'environnement. - /api/facebook : configuration, connexion OAuth, callback qui crée ou met à jour les pages, contrôle d'une page à la demande, contrôle global. - TokenManager : /debug_token pour connaître l'état réel du jeton, puis renouvellement automatique 15 jours avant l'échéance ou dès révocation ; repli sur le contrôle historique tant que l'application n'est pas configurée ou si Facebook est injoignable, pour ne pas passer l'UI au rouge sur une panne réseau. Le cron passe à deux fois par jour. - Les comptes Instagram professionnels rattachés à une page reçoivent le même traitement, puisqu'ils publient avec le jeton de cette page. - L'UI montre l'état constaté (jeton valide, bientôt expiré, révoqué), l'erreur à corriger et un bouton de contrôle immédiat ; un bandeau signale les pages à reconnecter. - /api/pages ne renvoie plus aucun jeton au navigateur, seulement un indicateur de renouvellement automatique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
290 lines
9.2 KiB
TypeScript
290 lines
9.2 KiB
TypeScript
/**
|
|
* Renouvellement des tokens Facebook.
|
|
*
|
|
* Le problème que ce service résout : un token de page saisi à la main finit
|
|
* toujours par mourir (60 jours pour un token issu du Graph Explorer, ou du
|
|
* jour au lendemain si le mot de passe change, si l'utilisateur perd son rôle
|
|
* d'admin, ou si Meta révoque l'autorisation) — et on ne l'apprend qu'au
|
|
* moment où une publication échoue.
|
|
*
|
|
* La parade est celle recommandée par Meta :
|
|
* 1. l'utilisateur autorise l'application (OAuth) ;
|
|
* 2. le code est échangé contre un token utilisateur court ;
|
|
* 3. ce token court est échangé contre un token utilisateur longue durée
|
|
* (~60 jours) — c'est lui que l'on conserve, chiffré ;
|
|
* 4. les tokens de page dérivés d'un token utilisateur longue durée
|
|
* n'expirent pas.
|
|
*
|
|
* Conserver le token utilisateur permet de régénérer un token de page sans
|
|
* intervention humaine, et de prolonger le token utilisateur lui-même avant
|
|
* son échéance : tant que l'utilisateur ne retire pas son autorisation, la
|
|
* chaîne se renouvelle toute seule.
|
|
*/
|
|
|
|
import { storage } from '../storage';
|
|
|
|
const GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || 'v19.0';
|
|
const GRAPH_URL = `https://graph.facebook.com/${GRAPH_VERSION}`;
|
|
|
|
/** Permissions nécessaires pour publier et lire les statistiques d'une page. */
|
|
const DEFAULT_SCOPES = [
|
|
'pages_show_list',
|
|
'pages_manage_posts',
|
|
'pages_read_engagement',
|
|
'pages_manage_engagement',
|
|
'read_insights',
|
|
'business_management',
|
|
'instagram_basic',
|
|
'instagram_content_publish',
|
|
].join(',');
|
|
|
|
export interface FacebookLongLivedToken {
|
|
accessToken: string;
|
|
/** Nul quand Facebook ne renvoie pas d'échéance (jeton sans expiration). */
|
|
expiresAt: Date | null;
|
|
}
|
|
|
|
export interface FacebookPageTarget {
|
|
id: string;
|
|
name: string;
|
|
accessToken: string;
|
|
followersCount: number;
|
|
/** Compte Instagram professionnel rattaché à la page, s'il existe. */
|
|
instagram: { id: string; name: string } | null;
|
|
}
|
|
|
|
/** Résultat de /debug_token : ce que Meta sait vraiment du jeton. */
|
|
export interface FacebookTokenInfo {
|
|
isValid: boolean;
|
|
/** Nul = n'expire pas (cas normal d'un token de page). */
|
|
expiresAt: Date | null;
|
|
scopes: string[];
|
|
error: string | null;
|
|
}
|
|
|
|
export class FacebookAuthError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'FacebookAuthError';
|
|
}
|
|
}
|
|
|
|
export class FacebookOAuthService {
|
|
/**
|
|
* Identifiants de l'application Facebook (globaux à l'instance).
|
|
* La base prime sur les variables d'environnement.
|
|
*/
|
|
private async getCredentials(): Promise<{ appId: string; appSecret: string }> {
|
|
const config = await storage.getFacebookConfig();
|
|
|
|
const appId = config?.appId || process.env.FACEBOOK_APP_ID;
|
|
const appSecret = config?.appSecret || process.env.FACEBOOK_APP_SECRET;
|
|
|
|
if (!appId || !appSecret) {
|
|
throw new FacebookAuthError(
|
|
"Facebook n'est pas configuré. Renseignez l'App ID et l'App Secret de " +
|
|
'votre application Facebook dans les paramètres.'
|
|
);
|
|
}
|
|
|
|
return { appId, appSecret };
|
|
}
|
|
|
|
async isConfigured(): Promise<boolean> {
|
|
try {
|
|
await this.getCredentials();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
getRedirectUri(): string {
|
|
if (process.env.FACEBOOK_REDIRECT_URI) {
|
|
return process.env.FACEBOOK_REDIRECT_URI;
|
|
}
|
|
const base = (process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, '');
|
|
return `${base}/api/facebook/callback`;
|
|
}
|
|
|
|
getScopes(): string {
|
|
return process.env.FACEBOOK_SCOPES || DEFAULT_SCOPES;
|
|
}
|
|
|
|
/** URL d'autorisation à ouvrir pour connecter les pages d'un compte Facebook. */
|
|
async buildAuthorizationUrl(state: string): Promise<string> {
|
|
const { appId } = await this.getCredentials();
|
|
|
|
const params = new URLSearchParams({
|
|
client_id: appId,
|
|
redirect_uri: this.getRedirectUri(),
|
|
state,
|
|
scope: this.getScopes(),
|
|
response_type: 'code',
|
|
});
|
|
|
|
return `https://www.facebook.com/${GRAPH_VERSION}/dialog/oauth?${params.toString()}`;
|
|
}
|
|
|
|
/**
|
|
* Échange le code d'autorisation contre un token utilisateur **longue durée**.
|
|
* L'étape intermédiaire (token court) n'est jamais conservée : seul le token
|
|
* longue durée rend les tokens de page permanents.
|
|
*/
|
|
async exchangeCodeForUserToken(code: string): Promise<FacebookLongLivedToken> {
|
|
const { appId, appSecret } = await this.getCredentials();
|
|
|
|
const shortLived = await this.graphGet<{ access_token?: string }>('/oauth/access_token', {
|
|
client_id: appId,
|
|
client_secret: appSecret,
|
|
redirect_uri: this.getRedirectUri(),
|
|
code,
|
|
}, "échange du code d'autorisation");
|
|
|
|
if (!shortLived.access_token) {
|
|
throw new FacebookAuthError('Facebook n\'a pas renvoyé de jeton pour ce code d\'autorisation.');
|
|
}
|
|
|
|
return this.extendUserToken(shortLived.access_token);
|
|
}
|
|
|
|
/**
|
|
* Prolonge un token utilisateur. Appelé au retour d'OAuth (token court →
|
|
* ~60 jours) mais aussi périodiquement : ré-échanger un token longue durée
|
|
* encore valide repart pour 60 jours, ce qui évite la reconnexion manuelle.
|
|
*/
|
|
async extendUserToken(userToken: string): Promise<FacebookLongLivedToken> {
|
|
const { appId, appSecret } = await this.getCredentials();
|
|
|
|
const longLived = await this.graphGet<{ access_token?: string; expires_in?: number }>(
|
|
'/oauth/access_token',
|
|
{
|
|
grant_type: 'fb_exchange_token',
|
|
client_id: appId,
|
|
client_secret: appSecret,
|
|
fb_exchange_token: userToken,
|
|
},
|
|
'prolongation du jeton utilisateur'
|
|
);
|
|
|
|
if (!longLived.access_token) {
|
|
throw new FacebookAuthError("Facebook n'a pas renvoyé de jeton longue durée.");
|
|
}
|
|
|
|
return {
|
|
accessToken: longLived.access_token,
|
|
// expires_in absent ou nul = jeton sans expiration connue.
|
|
expiresAt: longLived.expires_in
|
|
? new Date(Date.now() + longLived.expires_in * 1000)
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pages administrées par le compte, avec leur token de page.
|
|
* Ces tokens sont permanents lorsqu'ils dérivent d'un token utilisateur
|
|
* longue durée : c'est le cœur du renouvellement automatique.
|
|
*/
|
|
async listPages(userToken: string): Promise<FacebookPageTarget[]> {
|
|
const response = await this.graphGet<{
|
|
data?: {
|
|
id: string;
|
|
name: string;
|
|
access_token: string;
|
|
fan_count?: number;
|
|
followers_count?: number;
|
|
instagram_business_account?: { id: string; name?: string; username?: string };
|
|
}[];
|
|
}>(
|
|
'/me/accounts',
|
|
{
|
|
fields: 'id,name,access_token,fan_count,followers_count,instagram_business_account{id,name,username}',
|
|
limit: '200',
|
|
access_token: userToken,
|
|
},
|
|
'récupération des pages'
|
|
);
|
|
|
|
return (response.data ?? []).map((page) => ({
|
|
id: page.id,
|
|
name: page.name,
|
|
accessToken: page.access_token,
|
|
followersCount: page.followers_count ?? page.fan_count ?? 0,
|
|
instagram: page.instagram_business_account
|
|
? {
|
|
id: page.instagram_business_account.id,
|
|
name:
|
|
page.instagram_business_account.username ||
|
|
page.instagram_business_account.name ||
|
|
page.name,
|
|
}
|
|
: null,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Interroge Meta sur l'état réel d'un jeton. Un simple appel /me ne dit pas
|
|
* quand le jeton expire : /debug_token le dit, ce qui permet de renouveler
|
|
* *avant* la panne plutôt qu'après.
|
|
*/
|
|
async inspectToken(token: string): Promise<FacebookTokenInfo> {
|
|
const { appId, appSecret } = await this.getCredentials();
|
|
|
|
const response = await this.graphGet<{
|
|
data?: {
|
|
is_valid?: boolean;
|
|
expires_at?: number;
|
|
scopes?: string[];
|
|
error?: { message?: string };
|
|
};
|
|
}>(
|
|
'/debug_token',
|
|
{
|
|
input_token: token,
|
|
// Jeton d'application : `app_id|app_secret`, accepté par /debug_token.
|
|
access_token: `${appId}|${appSecret}`,
|
|
},
|
|
'inspection du jeton'
|
|
);
|
|
|
|
const data = response.data ?? {};
|
|
|
|
return {
|
|
isValid: data.is_valid === true,
|
|
// `expires_at: 0` signifie « n'expire pas » chez Meta.
|
|
expiresAt: data.expires_at ? new Date(data.expires_at * 1000) : null,
|
|
scopes: data.scopes ?? [],
|
|
error: data.error?.message ?? null,
|
|
};
|
|
}
|
|
|
|
private async graphGet<T>(
|
|
endpoint: string,
|
|
params: Record<string, string>,
|
|
context: string
|
|
): Promise<T> {
|
|
const url = new URL(`${GRAPH_URL}${endpoint}`);
|
|
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url.toString());
|
|
} catch (error) {
|
|
throw new FacebookAuthError(
|
|
`Facebook injoignable (${context}) : ${error instanceof Error ? error.message : 'erreur réseau'}`
|
|
);
|
|
}
|
|
|
|
const payload: any = await response.json().catch(() => ({}));
|
|
|
|
if (!response.ok || payload?.error) {
|
|
const message = payload?.error?.message || `HTTP ${response.status}`;
|
|
throw new FacebookAuthError(`Facebook (${context}) : ${message}`);
|
|
}
|
|
|
|
return payload as T;
|
|
}
|
|
}
|
|
|
|
export const facebookOAuthService = new FacebookOAuthService();
|