diff --git a/.env.example b/.env.example index 82ee91c..0751ede 100644 --- a/.env.example +++ b/.env.example @@ -30,9 +30,27 @@ OPENROUTER_API_KEY=your-openrouter-api-key-here MINIO_ENDPOINT=http://minio:9000 MINIO_PUBLIC_URL=https://media.example.com -# Optionnel: Configuration Facebook API +# ========================================== +# FACEBOOK (connexion des pages et renouvellement des jetons) +# ========================================== +# Identifiants de votre application sur https://developers.facebook.com +# Ils peuvent aussi être saisis dans l'application (Paramètres → Facebook), +# ce qui est prioritaire sur ces variables. +# Sans eux, les jetons de page doivent être collés et remplacés à la main. # FACEBOOK_APP_ID= # FACEBOOK_APP_SECRET= +# +# URI de redirection OAuth à déclarer chez Facebook. +# Par défaut : ${APP_URL}/api/facebook/callback +# FACEBOOK_REDIRECT_URI= +# +# Permissions demandées à l'autorisation. Défaut : pages_show_list, +# pages_manage_posts, pages_read_engagement, pages_manage_engagement, +# read_insights, business_management, instagram_basic, instagram_content_publish +# FACEBOOK_SCOPES= +# +# Version de la Graph API utilisée pour l'OAuth (défaut : v19.0). +# FACEBOOK_GRAPH_VERSION= # Optionnel: Configuration Instagram API # INSTAGRAM_APP_ID= diff --git a/client/src/components/facebook-token-status.tsx b/client/src/components/facebook-token-status.tsx new file mode 100644 index 0000000..8e4adeb --- /dev/null +++ b/client/src/components/facebook-token-status.tsx @@ -0,0 +1,203 @@ +/** + * Suivi et renouvellement des jetons d'accès. + * + * Un jeton de page Facebook n'affiche pas toujours de date d'expiration : celui + * qui dérive d'une connexion OAuth est permanent, et c'est sa révocation — pas + * son échéance — qui casse la publication. L'UI montre donc l'état réel remonté + * par le contrôle serveur, pas seulement un décompte de jours. + */ + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { AlertTriangle, Calendar, CheckCircle2, Facebook, RefreshCw, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, getErrorMessage, queryClient } from "@/lib/queryClient"; +import type { ClientSocialPage } from "@shared/schema"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; + +type TokenStatus = 'valid' | 'expiring' | 'expired' | 'error'; + +const STATUS_STYLES: Record = { + valid: { label: 'Jeton valide', color: 'text-green-600', bgColor: 'bg-green-100' }, + expiring: { label: 'Jeton bientôt expiré', color: 'text-orange-600', bgColor: 'bg-orange-100' }, + expired: { label: 'Jeton expiré', color: 'text-red-600', bgColor: 'bg-red-100' }, + error: { label: 'Contrôle en échec', color: 'text-red-600', bgColor: 'bg-red-100' }, +}; + +function statusOf(page: ClientSocialPage): TokenStatus { + return (page.tokenStatus as TokenStatus | null) ?? 'valid'; +} + +/** + * Lance l'autorisation Facebook. La connexion se fait par navigation complète + * du navigateur (et non en fetch) puisqu'elle passe par le site de Facebook. + */ +export function ConnectFacebookButton({ className }: { className?: string }) { + const { toast } = useToast(); + + const { data: config } = useQuery<{ configured: boolean }>({ + queryKey: ['/api/facebook/config'], + }); + + const handleClick = () => { + if (config && !config.configured) { + toast({ + title: "Facebook n'est pas configuré", + description: + "Renseignez l'App ID et l'App Secret de votre application Facebook dans les paramètres.", + variant: 'destructive', + }); + return; + } + window.location.href = '/api/facebook/connect'; + }; + + return ( + + ); +} + +/** Relance le contrôle serveur, qui renouvelle le jeton s'il le peut. */ +export function RefreshTokenButton({ page }: { page: ClientSocialPage }) { + const { toast } = useToast(); + + const refreshMutation = useMutation({ + mutationFn: async () => { + const response = await apiRequest('POST', `/api/facebook/pages/${page.id}/refresh`); + return (await response.json()) as { status: TokenStatus; renewed: boolean; error?: string }; + }, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['/api/pages'] }); + toast({ + title: result.renewed + ? 'Jeton renouvelé' + : result.status === 'valid' + ? 'Jeton valide' + : STATUS_STYLES[result.status].label, + description: + result.error || + (result.renewed + ? `Un nouveau jeton a été généré pour ${page.pageName}.` + : `Aucun renouvellement nécessaire pour ${page.pageName}.`), + variant: result.status === 'valid' ? undefined : 'destructive', + }); + }, + onError: (error: unknown) => { + toast({ + title: 'Contrôle impossible', + description: getErrorMessage(error, 'Facebook est injoignable, réessayez plus tard.'), + variant: 'destructive', + }); + }, + }); + + return ( + + ); +} + +/** + * Bloc d'état du jeton : ce que le dernier contrôle a constaté, l'échéance + * quand il y en a une, et l'erreur à corriger le cas échéant. + */ +export function TokenHealthPanel({ page }: { page: ClientSocialPage }) { + const status = statusOf(page); + const style = STATUS_STYLES[status]; + const isTiktok = page.platform === 'tiktok'; + + // Pour TikTok, c'est l'autorisation (refresh token) qui porte l'échéance. + const expiresAt = isTiktok ? page.refreshTokenExpiresAt : page.tokenExpiresAt; + + return ( +
+
+ {status === 'valid' ? ( + + ) : ( + + )} + {style.label} +
+ +
+ + {expiresAt ? ( + + {isTiktok ? 'Connexion valable jusqu\'au' : 'Expire le'}{' '} + {format(new Date(expiresAt), 'd MMMM yyyy', { locale: fr })} + + ) : page.autoRenew ? ( + Sans expiration + ) : ( + Échéance inconnue + )} +
+ + {page.autoRenew && ( +
+ + Renouvellement automatique actif +
+ )} + + {page.tokenError && ( +

+ {page.tokenError} +

+ )} + + {page.lastTokenCheck && ( +

+ Dernier contrôle : {format(new Date(page.lastTokenCheck), 'd MMM yyyy à HH:mm', { locale: fr })} +

+ )} +
+ ); +} + +/** Bandeau global : les pages qui réclament une reconnexion manuelle. */ +export function TokenAlertBanner({ pages }: { pages: ClientSocialPage[] }) { + const failing = pages.filter((page) => { + const status = statusOf(page); + return status === 'expired' || status === 'error' || status === 'expiring'; + }); + + if (failing.length === 0) return null; + + return ( +
+ +
+

+ {failing.length === 1 + ? 'Une page nécessite votre attention' + : `${failing.length} pages nécessitent votre attention`} +

+ {failing.map((page) => ( +

+ {page.pageName} — {STATUS_STYLES[statusOf(page)].label.toLowerCase()} + {page.tokenError ? ` : ${page.tokenError}` : '.'} +

+ ))} +
+
+ ); +} diff --git a/client/src/pages/mobile/pages.tsx b/client/src/pages/mobile/pages.tsx index 9bcc59e..f284a4c 100644 --- a/client/src/pages/mobile/pages.tsx +++ b/client/src/pages/mobile/pages.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react"; +import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code } from "lucide-react"; import { SiTiktok } from "react-icons/si"; import Sidebar from "@/components/sidebar"; import TopBar from "@/components/topbar"; @@ -12,69 +12,13 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient } from "@/lib/queryClient"; -import type { SocialPage } from "@shared/schema"; -import { format } from "date-fns"; -import { fr } from "date-fns/locale"; - -function getTokenExpirationStatus(expiresAt: string | null | undefined) { - if (!expiresAt) { - return { - status: 'unknown' as const, - color: 'text-gray-500', - bgColor: 'bg-gray-100', - daysLeft: null, - message: 'Date inconnue' - }; - } - - const now = new Date(); - const expiration = new Date(expiresAt); - const diffTime = expiration.getTime() - now.getTime(); - const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - if (daysLeft < 0) { - return { - status: 'expired' as const, - color: 'text-red-600', - bgColor: 'bg-red-100', - daysLeft, - message: 'Expiré' - }; - } else if (daysLeft <= 7) { - return { - status: 'urgent' as const, - color: 'text-red-600', - bgColor: 'bg-red-100', - daysLeft, - message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}` - }; - } else if (daysLeft <= 15) { - return { - status: 'warning' as const, - color: 'text-orange-600', - bgColor: 'bg-orange-100', - daysLeft, - message: `${daysLeft} jours restants` - }; - } else { - return { - status: 'good' as const, - color: 'text-green-600', - bgColor: 'bg-green-100', - daysLeft, - message: `${daysLeft} jours restants` - }; - } -} - -/** - * Un compte TikTok n'a pas de "token de page" à surveiller : ce qui compte est la - * validité de l'autorisation, portée par le refresh token (1 an). - */ -function getConnectionExpiry(page: SocialPage): string | null { - const date = page.platform === 'tiktok' ? page.refreshTokenExpiresAt : page.tokenExpiresAt; - return date ? new Date(date).toISOString() : null; -} +import type { ClientSocialPage } from "@shared/schema"; +import { + ConnectFacebookButton, + RefreshTokenButton, + TokenAlertBanner, + TokenHealthPanel, +} from "@/components/facebook-token-status"; /** * Lance l'autorisation TikTok. La connexion se fait par navigation complète du @@ -115,17 +59,21 @@ function ConnectTiktokButton() { export default function PagesManagementMobile() { const [sidebarOpen, setSidebarOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); - const [editingPage, setEditingPage] = useState(null); + const [editingPage, setEditingPage] = useState(null); const { toast } = useToast(); - // Retour du flux d'autorisation TikTok (/api/tiktok/callback redirige ici) + // Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback + // redirigent ici avec le résultat en paramètre) useEffect(() => { const params = new URLSearchParams(window.location.search); - const result = params.get('tiktok'); - if (!result) return; + const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null; + if (!network) return; + + const result = params.get(network); + const label = network === 'tiktok' ? 'TikTok' : 'Facebook'; toast({ - title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok impossible', + title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`, description: params.get('message') || undefined, variant: result === 'success' ? undefined : 'destructive', }); @@ -134,7 +82,7 @@ export default function PagesManagementMobile() { window.history.replaceState({}, '', window.location.pathname); }, [toast]); - const { data: pages = [], isLoading } = useQuery({ + const { data: pages = [], isLoading } = useQuery({ queryKey: ['/api/pages'], }); @@ -185,6 +133,9 @@ export default function PagesManagementMobile() {

+ + + !open && setEditingPage(null)} /> @@ -267,32 +218,14 @@ export default function PagesManagementMobile() { ID: {page.pageId} - {/* Token expiration status */} - {(() => { - const expiresAt = getConnectionExpiry(page); - const expirationStatus = getTokenExpirationStatus(expiresAt); - return ( -
- -
-
- {page.platform === 'tiktok' ? 'Expiration de la connexion' : 'Expiration du token'} -
-
- {expirationStatus.message} -
- {expiresAt && ( -
- {format(new Date(expiresAt), "d MMMM yyyy", { locale: fr })} -
- )} -
- {(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && ( - - )} -
- ); - })()} + {/* État du jeton, tel que constaté par le dernier contrôle serveur */} + + + {page.platform !== 'tiktok' && ( +
+ +
+ )}
{page.platform === 'tiktok' ? ( @@ -497,7 +430,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o ); } -function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpenChange: (open: boolean) => void }) { +function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null; onOpenChange: (open: boolean) => void }) { const [accessToken, setAccessToken] = useState(''); const { toast } = useToast(); diff --git a/client/src/pages/pages.tsx b/client/src/pages/pages.tsx index 3c6fdce..d63d8e5 100644 --- a/client/src/pages/pages.tsx +++ b/client/src/pages/pages.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react"; +import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, AlertTriangle } from "lucide-react"; import { SiTiktok } from "react-icons/si"; import Sidebar from "@/components/sidebar"; import TopBar from "@/components/topbar"; @@ -12,84 +12,32 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient } from "@/lib/queryClient"; -import type { SocialPage } from "@shared/schema"; -import { format } from "date-fns"; -import { fr } from "date-fns/locale"; - -function getTokenExpirationStatus(expiresAt: string | null | undefined) { - if (!expiresAt) { - return { - status: 'unknown' as const, - color: 'text-gray-500', - bgColor: 'bg-gray-100', - daysLeft: null, - message: 'Date inconnue' - }; - } - - const now = new Date(); - const expiration = new Date(expiresAt); - const diffTime = expiration.getTime() - now.getTime(); - const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - if (daysLeft < 0) { - return { - status: 'expired' as const, - color: 'text-red-600', - bgColor: 'bg-red-100', - daysLeft, - message: 'Expiré' - }; - } else if (daysLeft <= 7) { - return { - status: 'urgent' as const, - color: 'text-red-600', - bgColor: 'bg-red-100', - daysLeft, - message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}` - }; - } else if (daysLeft <= 15) { - return { - status: 'warning' as const, - color: 'text-orange-600', - bgColor: 'bg-orange-100', - daysLeft, - message: `${daysLeft} jours restants` - }; - } else { - return { - status: 'good' as const, - color: 'text-green-600', - bgColor: 'bg-green-100', - daysLeft, - message: `${daysLeft} jours restants` - }; - } -} - -/** - * Un compte TikTok n'a pas de "token de page" à surveiller : ce qui compte est la - * validité de l'autorisation, portée par le refresh token (1 an). - */ -function getConnectionExpiry(page: SocialPage): string | null { - const date = page.platform === 'tiktok' ? page.refreshTokenExpiresAt : page.tokenExpiresAt; - return date ? new Date(date).toISOString() : null; -} +import type { ClientSocialPage } from "@shared/schema"; +import { + ConnectFacebookButton, + RefreshTokenButton, + TokenAlertBanner, + TokenHealthPanel, +} from "@/components/facebook-token-status"; export default function PagesManagement() { const [sidebarOpen, setSidebarOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); - const [editingPage, setEditingPage] = useState(null); + const [editingPage, setEditingPage] = useState(null); const { toast } = useToast(); - // Retour du flux d'autorisation TikTok (/api/tiktok/callback redirige ici) + // Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback + // redirigent ici avec le résultat en paramètre) useEffect(() => { const params = new URLSearchParams(window.location.search); - const result = params.get('tiktok'); - if (!result) return; + const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null; + if (!network) return; + + const result = params.get(network); + const label = network === 'tiktok' ? 'TikTok' : 'Facebook'; toast({ - title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok impossible', + title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`, description: params.get('message') || undefined, variant: result === 'success' ? undefined : 'destructive', }); @@ -98,7 +46,7 @@ export default function PagesManagement() { window.history.replaceState({}, '', window.location.pathname); }, [toast]); - const { data: pages = [], isLoading } = useQuery({ + const { data: pages = [], isLoading } = useQuery({ queryKey: ['/api/pages'], }); @@ -149,24 +97,29 @@ export default function PagesManagement() {

+
!open && setEditingPage(null)} /> + + {/* Permissions required notice */}
-

Permissions Facebook requises pour la publication vidéo

+

Connectez vos pages plutôt que de coller un jeton

- Le token de page doit inclure : pages_manage_posts{" "} - pages_read_engagement{" "} - publish_video + « Connecter des pages Facebook » récupère les jetons de page via l'autorisation Facebook : + ils n'expirent pas et SocialFlow les régénère seul en cas de révocation. Un jeton collé à la + main expire au bout de 60 jours et devra être remplacé manuellement.

- Dans Graph API Explorer → Sélectionnez votre app → Ajoutez ces 3 permissions → Générez un User Token → Échangez via /me/accounts pour obtenir le Page Token. + Le jeton doit couvrir : pages_manage_posts{" "} + pages_read_engagement{" "} + publish_video

@@ -246,34 +199,11 @@ export default function PagesManagement() { ID: {page.pageId} - {/* Token expiration status */} - {(() => { - const expiresAt = getConnectionExpiry(page); - const expirationStatus = getTokenExpirationStatus(expiresAt); - return ( -
- -
-
- {page.platform === 'tiktok' ? 'Expiration de la connexion' : 'Expiration du token'} -
-
- {expirationStatus.message} -
- {expiresAt && ( -
- {format(new Date(expiresAt), "d MMMM yyyy", { locale: fr })} -
- )} -
- {(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && ( - - )} -
- ); - })()} + {/* État du jeton, tel que constaté par le dernier contrôle serveur */} +
+ {page.platform !== 'tiktok' && } {page.platform === 'tiktok' ? ( // Un token TikTok ne se saisit pas : on repasse par l'autorisation + + + )} + {isAdmin && ( diff --git a/server/cron.ts b/server/cron.ts index 3919b3b..03e41be 100644 --- a/server/cron.ts +++ b/server/cron.ts @@ -15,12 +15,17 @@ export function startTokenCron() { }); console.log('[Cron] TikTok publish status job scheduled (*/2 * * * *).'); - // Run every day at midnight (00:00) — Token health check - cron.schedule('0 0 * * *', async () => { - console.log('[Cron] Running daily token check...'); - await TokenManager.checkAndRefreshTokens(); + // Deux fois par jour (00:00 et 12:00) — contrôle et renouvellement des jetons. + // Un jeton révoqué par Meta ne prévient pas : passer deux fois par jour + // réduit d'autant la fenêtre pendant laquelle une publication échouerait. + cron.schedule('0 0,12 * * *', async () => { + console.log('[Cron] Running token check...'); + const results = await TokenManager.checkAndRefreshTokens(); + const renewed = results.filter(r => r.renewed).length; + const failing = results.filter(r => r.status === 'expired' || r.status === 'error').length; + console.log(`[Cron] Token check done: ${results.length} contrôlés, ${renewed} renouvelés, ${failing} en échec.`); }); - console.log('[Cron] Token refresh job scheduled (0 0 * * *).'); + console.log('[Cron] Token refresh job scheduled (0 0,12 * * *).'); // Run twice daily (08:00 and 20:00) — Analytics sync for all pages cron.schedule('0 8,20 * * *', async () => { diff --git a/server/migrate.ts b/server/migrate.ts index 69ae34e..e005eed 100644 --- a/server/migrate.ts +++ b/server/migrate.ts @@ -187,6 +187,25 @@ export async function migrate() { ADD COLUMN IF NOT EXISTS "avatar_url" text; `); + // facebook_config (application développeur Facebook, globale) + await client.query(` + CREATE TABLE IF NOT EXISTS "facebook_config" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "app_id" text NOT NULL, + "app_secret" text NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() + ); + `); + + // social_pages : renouvellement automatique des tokens Facebook + await client.query(` + ALTER TABLE "social_pages" + ADD COLUMN IF NOT EXISTS "user_access_token" text, + ADD COLUMN IF NOT EXISTS "user_token_expires_at" timestamp, + ADD COLUMN IF NOT EXISTS "token_error" text; + `); + // scheduled_posts : suivi de la publication asynchrone TikTok await client.query(` ALTER TABLE "scheduled_posts" diff --git a/server/routes.ts b/server/routes.ts index 40780a0..9c7f566 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -18,6 +18,7 @@ import { reelsRouter } from "./routes/reels"; import { remotionRouter } from "./routes/remotion"; import { externalRouter } from "./routes/external"; import { tiktokRouter } from "./routes/tiktok"; +import { facebookRouter } from "./routes/facebook"; import { legalRouter } from "./routes/legal"; import { insertAudioTrackSchema } from "@shared/schema"; import * as musicMetadata from "music-metadata"; @@ -475,6 +476,7 @@ export async function registerRoutes(app: Express): Promise { // TikTok Routes (OAuth multi-comptes) — avant le routeur Reels monté sur /api app.use("/api/tiktok", requireAuth, tiktokRouter); + app.use("/api/facebook", requireAuth, facebookRouter); // Reels & Music Routes app.use("/api", requireAuth, reelsRouter); @@ -1553,6 +1555,19 @@ export async function registerRoutes(app: Express): Promise { }); // Social pages + + /** + * Vue client d'une page : sans jetons, avec un indicateur de renouvellement + * automatique (vrai quand un jeton utilisateur longue durée est enregistré). + */ + function toClientPage(page: SocialPage) { + const { accessToken, userAccessToken, refreshToken, ...safe } = page; + return { + ...safe, + autoRenew: page.platform === 'tiktok' ? !!refreshToken : !!userAccessToken, + }; + } + app.get("/api/pages", requireAuth, async (req, res) => { try { const user = req.user as User; @@ -1572,7 +1587,9 @@ export async function registerRoutes(app: Express): Promise { pages = await storage.getUserAccessiblePages(userId); } - res.json(pages); + // Les jetons n'ont rien à faire dans le navigateur : on ne renvoie que + // l'information dont l'UI a besoin, dont la capacité à se renouveler seule. + res.json(pages.map(toClientPage)); } catch (error) { console.error("Error fetching pages:", error); res.status(500).json({ error: "Failed to fetch pages" }); @@ -1602,7 +1619,7 @@ export async function registerRoutes(app: Express): Promise { tokenExpiresAt }); const page = await storage.createSocialPage(pageData); - res.json(page); + res.json(toClientPage(page)); } catch (error) { console.error("Error creating page:", error); res.status(500).json({ error: "Failed to create page" }); @@ -1638,7 +1655,7 @@ export async function registerRoutes(app: Express): Promise { } const updatedPage = await storage.updateSocialPage(pageId, pageData); - res.json(updatedPage); + res.json(toClientPage(updatedPage)); } catch (error) { console.error("Error updating page:", error); res.status(500).json({ error: "Failed to update page" }); diff --git a/server/routes/facebook.ts b/server/routes/facebook.ts new file mode 100644 index 0000000..952dcb1 --- /dev/null +++ b/server/routes/facebook.ts @@ -0,0 +1,300 @@ +/** + * 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(), + 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(), + }); + } 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); + + // 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); + 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' }); + } +}); diff --git a/server/services/facebook_oauth.ts b/server/services/facebook_oauth.ts new file mode 100644 index 0000000..a5b52da --- /dev/null +++ b/server/services/facebook_oauth.ts @@ -0,0 +1,289 @@ +/** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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( + endpoint: string, + params: Record, + context: string + ): Promise { + 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(); diff --git a/server/services/token_manager.ts b/server/services/token_manager.ts index c823fb4..82dd90a 100644 --- a/server/services/token_manager.ts +++ b/server/services/token_manager.ts @@ -1,10 +1,31 @@ // crypto removed, using shared encryption utility import { db } from '../db'; -import { socialPages, tokenStatusEnum } from '../../shared/schema'; -import { eq, and, lte } from 'drizzle-orm'; +import { socialPages } from '../../shared/schema'; +import { eq } from 'drizzle-orm'; import { GraphAPIClient } from '../utils/graph_client'; +import type { SocialPage } from '../../shared/schema'; import { encrypt, decrypt } from '../utils/encryption'; +import { facebookOAuthService, FacebookAuthError } from './facebook_oauth'; + +/** + * Un jeton dont l'échéance est plus proche que cette fenêtre est renouvelé + * sans attendre : il reste largement le temps de réessayer les jours suivants + * si Facebook est indisponible au moment du contrôle. + */ +const RENEWAL_WINDOW_MS = 15 * 24 * 60 * 60 * 1000; + +/** En deçà de cette marge, un jeton non renouvelable est signalé « expiring ». */ +const WARNING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +export interface TokenCheckResult { + pageId: string; + pageName: string; + status: 'valid' | 'expiring' | 'expired' | 'error'; + /** Vrai quand ce passage a effectivement réécrit un nouveau jeton. */ + renewed: boolean; + error?: string; +} /** * Service responsible for managing Facebook Page Token lifecycle @@ -30,8 +51,9 @@ export class TokenManager { /** * Checks all active pages for expiring tokens and refreshes them if needed. */ - static async checkAndRefreshTokens() { + static async checkAndRefreshTokens(): Promise { console.log('[TokenManager] Starting daily token check...'); + const results: TokenCheckResult[] = []; try { // Fetch active pages @@ -41,15 +63,18 @@ export class TokenManager { for (const page of pages) { try { - await this.processPageToken(page); + results.push(await this.processPageToken(page)); } catch (error) { + const message = error instanceof Error ? error.message : String(error); console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error); - await db.update(socialPages) - .set({ - tokenStatus: "error", - lastTokenCheck: new Date() - }) - .where(eq(socialPages.id, page.id)); + await this.persistStatus(page.id, 'error', message); + results.push({ + pageId: page.id, + pageName: page.pageName, + status: 'error', + renewed: false, + error: message, + }); } } @@ -57,46 +82,211 @@ export class TokenManager { } catch (e) { console.error('[TokenManager] Critical error in checkAndRefreshTokens:', e); } + + return results; } - private static async processPageToken(page: any) { + /** Contrôle (et renouvellement) d'une seule page, déclenché depuis l'UI. */ + static async checkPage(pageId: string): Promise { + const [page] = await db.select().from(socialPages).where(eq(socialPages.id, pageId)); + if (!page) { + throw new Error('Page introuvable'); + } + return this.processPageToken(page); + } + + private static async processPageToken(page: SocialPage): Promise { // Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure // que 24h et se renouvelle avec le refresh token, pas via la Graph API. if (page.platform === 'tiktok') { return await this.processTiktokToken(page); } - let accessToken = page.accessToken; - - // Attempt to decrypt. If fails, assume it's plain text (migration phase) - try { - if (accessToken.includes(':')) { - accessToken = this.decrypt(accessToken); - } - } catch (e) { - // Not encrypted or format error, proceed with original - } - console.log(`[TokenManager] Checking page: ${page.pageName}`); + const accessToken = this.readToken(page.accessToken); + + // Sans application Facebook configurée, on ne peut ni interroger + // /debug_token ni régénérer quoi que ce soit : on se rabat sur le + // contrôle historique (le jeton répond-il encore ?). + if (!(await facebookOAuthService.isConfigured())) { + return this.legacyCheck(page, accessToken); + } + + let info; + try { + info = await facebookOAuthService.inspectToken(accessToken); + } catch (error) { + // Facebook injoignable : on ne dégrade pas le statut sur un incident + // réseau, sinon toute l'UI passerait au rouge le temps d'une panne. + const message = error instanceof Error ? error.message : String(error); + console.warn(`[TokenManager] Inspection impossible pour ${page.pageName}: ${message}`); + return this.legacyCheck(page, accessToken); + } + + const expiresAt = info.expiresAt; + const expiringSoon = + expiresAt !== null && expiresAt.getTime() - Date.now() < RENEWAL_WINDOW_MS; + + if (info.isValid && !expiringSoon) { + // Le jeton de page tient : on en profite pour prolonger le jeton + // utilisateur s'il approche de son échéance, car c'est lui qui + // permettra le prochain renouvellement. + await this.extendUserTokenIfNeeded(page); + await this.persistStatus(page.id, 'valid', null, { tokenExpiresAt: expiresAt }); + return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: false }; + } + + const reason = info.isValid + ? `Le jeton expire le ${expiresAt?.toLocaleDateString('fr-FR')}` + : info.error || 'Le jeton a été invalidé par Facebook'; + console.warn(`[TokenManager] ${page.pageName}: ${reason} — tentative de renouvellement`); + + // Renouvellement automatique : c'est tout l'intérêt d'avoir conservé le + // jeton utilisateur longue durée au moment de la connexion OAuth. + try { + const renewed = await this.renewFromUserToken(page); + if (renewed) { + console.log(`[TokenManager] Jeton renouvelé automatiquement pour ${page.pageName}`); + return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: true }; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[TokenManager] Renouvellement impossible pour ${page.pageName}: ${message}`); + await this.persistStatus(page.id, info.isValid ? 'expiring' : 'expired', message, { + tokenExpiresAt: expiresAt, + }); + return { + pageId: page.id, + pageName: page.pageName, + status: info.isValid ? 'expiring' : 'expired', + renewed: false, + error: message, + }; + } + + // Pas de jeton utilisateur en base : la page a été ajoutée à la main, + // seule une reconnexion peut la remettre d'aplomb. + const manual = + `${reason}. Reconnectez la page via « Connecter des pages Facebook » ` + + 'pour activer le renouvellement automatique.'; + const status = info.isValid ? 'expiring' : 'expired'; + await this.persistStatus(page.id, status, manual, { tokenExpiresAt: expiresAt }); + return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: manual }; + } + + /** + * Régénère le jeton de page à partir du jeton utilisateur longue durée. + * Renvoie false quand la page n'a pas de jeton utilisateur enregistré. + */ + private static async renewFromUserToken(page: SocialPage): Promise { + if (!page.userAccessToken) return false; + + const storedUserToken = this.readToken(page.userAccessToken); + + // On prolonge d'abord le jeton utilisateur : le ré-échanger repart pour + // ~60 jours, ce qui repousse d'autant la prochaine reconnexion manuelle. + // Un échec ici n'est pas bloquant tant que le jeton actuel fonctionne. + let userToken = storedUserToken; + let userTokenExpiresAt = page.userTokenExpiresAt ?? null; + try { + const extended = await facebookOAuthService.extendUserToken(storedUserToken); + userToken = extended.accessToken; + userTokenExpiresAt = extended.expiresAt; + } catch (error) { + console.warn( + `[TokenManager] Prolongation du jeton utilisateur impossible pour ${page.pageName}:`, + error instanceof Error ? error.message : error + ); + } + + const targets = await facebookOAuthService.listPages(userToken); + // Une page Instagram est publiée avec le jeton de la page Facebook à + // laquelle son compte professionnel est rattaché. + const match = targets.find( + (target) => target.id === page.pageId || target.instagram?.id === page.pageId + ); + + if (!match) { + throw new FacebookAuthError( + `La page « ${page.pageName} » n'est plus administrée par le compte Facebook connecté. ` + + 'Reconnectez-la avec un compte administrateur de cette page.' + ); + } + + // Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire + // pas : `expiresAt` reste donc généralement nul. + let pageTokenExpiresAt: Date | null = null; + try { + pageTokenExpiresAt = (await facebookOAuthService.inspectToken(match.accessToken)).expiresAt; + } catch { + // L'inspection est un confort : le jeton fraîchement émis est bon. + } + + await db.update(socialPages) + .set({ + accessToken: match.accessToken, + tokenExpiresAt: pageTokenExpiresAt, + userAccessToken: encrypt(userToken), + userTokenExpiresAt, + followersCount: match.followersCount || page.followersCount, + tokenStatus: 'valid', + tokenError: null, + lastTokenCheck: new Date(), + }) + .where(eq(socialPages.id, page.id)); + + return true; + } + + /** + * Prolonge le jeton utilisateur quand son échéance approche, même si le + * jeton de page se porte bien : c'est ce qui évite de perdre la capacité de + * renouveler le jour où le jeton de page est révoqué. + */ + private static async extendUserTokenIfNeeded(page: SocialPage): Promise { + if (!page.userAccessToken) return; + + const expiresAt = page.userTokenExpiresAt ? new Date(page.userTokenExpiresAt).getTime() : null; + if (expiresAt !== null && expiresAt - Date.now() > RENEWAL_WINDOW_MS) return; + + try { + const extended = await facebookOAuthService.extendUserToken(this.readToken(page.userAccessToken)); + await db.update(socialPages) + .set({ + userAccessToken: encrypt(extended.accessToken), + userTokenExpiresAt: extended.expiresAt, + }) + .where(eq(socialPages.id, page.id)); + console.log(`[TokenManager] Jeton utilisateur prolongé pour ${page.pageName}`); + } catch (error) { + console.warn( + `[TokenManager] Impossible de prolonger le jeton utilisateur de ${page.pageName}:`, + error instanceof Error ? error.message : error + ); + } + } + + /** + * Contrôle historique, utilisé quand l'application Facebook n'est pas + * configurée : on sait seulement si le jeton répond encore. + */ + private static async legacyCheck(page: SocialPage, accessToken: string): Promise { const isValid = await this.verifyToken(accessToken); - if (isValid) { - await db.update(socialPages) - .set({ - tokenStatus: "valid", - lastTokenCheck: new Date() - }) - .where(eq(socialPages.id, page.id)); - } else { - await db.update(socialPages) - .set({ - tokenStatus: "expired", - lastTokenCheck: new Date() - }) - .where(eq(socialPages.id, page.id)); - console.warn(`[TokenManager] Token expired for ${page.pageName}`); + if (!isValid) { + const error = + "Le jeton n'est plus accepté par Facebook. Configurez l'application Facebook " + + 'dans les paramètres pour activer le renouvellement automatique, ou saisissez un nouveau jeton.'; + await this.persistStatus(page.id, 'expired', error); + return { pageId: page.id, pageName: page.pageName, status: 'expired', renewed: false, error }; } + + // Le jeton répond : reste l'échéance déclarée à l'ajout de la page. + const declared = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : null; + const status = declared !== null && declared - Date.now() < WARNING_WINDOW_MS ? 'expiring' : 'valid'; + await this.persistStatus(page.id, status, null); + return { pageId: page.id, pageName: page.pageName, status, renewed: false }; } /** @@ -104,7 +294,7 @@ export class TokenManager { * On ne teste pas l'access token (il est renouvelé à la demande avant chaque * publication) mais le refresh token, dont l'expiration déconnecterait le compte. */ - private static async processTiktokToken(page: any) { + private static async processTiktokToken(page: SocialPage): Promise { console.log(`[TokenManager] Checking TikTok account: ${page.pageName}`); const refreshExpiresAt = page.refreshTokenExpiresAt @@ -112,21 +302,47 @@ export class TokenManager { : 0; let status: "valid" | "expiring" | "expired" = "valid"; + let error: string | null = null; if (!page.refreshToken || (refreshExpiresAt && refreshExpiresAt <= Date.now())) { status = "expired"; + error = "L'autorisation TikTok a expiré : reconnectez le compte."; console.warn(`[TokenManager] TikTok account ${page.pageName} must be reconnected`); } else if (refreshExpiresAt && refreshExpiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000) { // Moins de 30 jours de validité restante status = "expiring"; } + await this.persistStatus(page.id, status, error); + return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: error ?? undefined }; + } + + private static async persistStatus( + pageId: string, + status: 'valid' | 'expiring' | 'expired' | 'error', + error: string | null, + extra: { tokenExpiresAt?: Date | null } = {} + ): Promise { await db.update(socialPages) .set({ tokenStatus: status, - lastTokenCheck: new Date() + tokenError: error, + lastTokenCheck: new Date(), + ...(extra.tokenExpiresAt !== undefined ? { tokenExpiresAt: extra.tokenExpiresAt } : {}), }) - .where(eq(socialPages.id, page.id)); + .where(eq(socialPages.id, pageId)); + } + + /** + * Les jetons ont pu être enregistrés en clair (saisie manuelle) ou chiffrés + * (flux OAuth) : `decrypt` renvoie le texte tel quel s'il n'est pas chiffré. + */ + private static readToken(token: string): string { + try { + return this.decrypt(token); + } catch { + return token; + } } private static async verifyToken(token: string): Promise { diff --git a/server/storage.ts b/server/storage.ts index 34e3fe0..6c5f40e 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -39,6 +39,9 @@ import { tiktokConfig, type TiktokConfig, type InsertTiktokConfig, + facebookConfig, + type FacebookConfig, + type InsertFacebookConfig, } from "@shared/schema"; import { db } from "./db"; import { eq, and, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray } from "drizzle-orm"; @@ -121,6 +124,8 @@ export interface IStorage { // TikTok Config getTiktokConfig(): Promise; upsertTiktokConfig(config: Partial): Promise; + getFacebookConfig(): Promise; + upsertFacebookConfig(config: Partial): Promise; // Cloudinary Config getCloudinaryConfig(): Promise; @@ -612,6 +617,38 @@ export class DatabaseStorage implements IStorage { return config || undefined; } + // Facebook Config (application développeur, globale à l'instance) + async getFacebookConfig(): Promise { + const [config] = await db.select().from(facebookConfig).limit(1); + if (!config) return undefined; + return { ...config, appSecret: decrypt(config.appSecret) }; + } + + async upsertFacebookConfig(data: Partial): Promise { + const values: Partial = { ...data }; + if (data.appSecret) { + values.appSecret = encrypt(data.appSecret); + } + + const [existing] = await db.select().from(facebookConfig).limit(1); + if (existing) { + const [updated] = await db.update(facebookConfig) + .set({ ...values, updatedAt: new Date() }) + .where(eq(facebookConfig.id, existing.id)) + .returning(); + return { ...updated, appSecret: decrypt(updated.appSecret) }; + } + + if (!values.appId || !values.appSecret) { + throw new Error("L'App ID et l'App Secret Facebook sont requis"); + } + + const [created] = await db.insert(facebookConfig) + .values({ appId: values.appId, appSecret: values.appSecret }) + .returning(); + return { ...created, appSecret: decrypt(created.appSecret) }; + } + // TikTok Config async getTiktokConfig(): Promise { const [config] = await db.select().from(tiktokConfig).limit(1); diff --git a/shared/schema.ts b/shared/schema.ts index d4d48c3..ce17633 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -38,6 +38,15 @@ export const socialPages = pgTable("social_pages", { tokenStatus: tokenStatusEnum("token_status").default("valid"), lastTokenCheck: timestamp("last_token_check"), + // Jeton utilisateur longue durée (~60 j) obtenu via OAuth Facebook, chiffré au + // repos. C'est lui qui permet de régénérer un token de page sans intervention + // humaine : sans lui, un token révoqué impose une reconnexion manuelle. + userAccessToken: text("user_access_token"), + userTokenExpiresAt: timestamp("user_token_expires_at"), + // Dernière erreur rencontrée lors du contrôle du token, affichée dans l'UI + // pour que l'utilisateur sache quoi faire (reconnecter, réautoriser…). + tokenError: text("token_error"), + // OAuth TikTok : l'access token n'est valable que 24h, il doit être renouvelé // avec le refresh token (valable 1 an) avant chaque publication. refreshToken: text("refresh_token"), @@ -93,6 +102,7 @@ export const insertSocialPageSchema = createInsertSchema(socialPages).omit({ createdAt: true, tokenStatus: true, // Managed by System lastTokenCheck: true, // Managed by System + tokenError: true, // Managed by System }); export const insertPostAnalyticsSchema = createInsertSchema(postAnalytics).omit({ @@ -143,6 +153,20 @@ export const openrouterConfig = pgTable("openrouter_config", { // Application développeur TikTok (globale) : un seul client_key pour toute // l'instance, chaque magasin autorise ensuite son propre compte via OAuth. +/** + * Application développeur Facebook (globale à l'instance). Elle rend possible le + * renouvellement automatique des tokens de page : sans app id / app secret, on + * ne peut ni échanger un code OAuth, ni prolonger un jeton, ni interroger + * /debug_token. + */ +export const facebookConfig = pgTable("facebook_config", { + id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), + appId: text("app_id").notNull(), + appSecret: text("app_secret").notNull(), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}); + export const tiktokConfig = pgTable("tiktok_config", { id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), clientKey: text("client_key").notNull(), @@ -432,6 +456,19 @@ export const updateCloudinaryConfigSchema = insertCloudinaryConfigSchema.partial apiSecret: true, }); +export const insertFacebookConfigSchema = createInsertSchema(facebookConfig).omit({ + id: true, + createdAt: true, + updatedAt: true, +}).extend({ + appId: z.string().trim().min(1, "L'App ID ne peut pas être vide"), + appSecret: z.string().trim().min(1, "L'App Secret ne peut pas être vide"), +}); + +export const updateFacebookConfigSchema = insertFacebookConfigSchema.partial({ + appSecret: true, +}); + export const insertTiktokConfigSchema = createInsertSchema(tiktokConfig).omit({ id: true, createdAt: true, @@ -504,6 +541,19 @@ export type InsertAudioTrack = z.infer; export type AppConfig = typeof appConfig.$inferSelect; +/** + * Page telle qu'elle est renvoyée au navigateur : les jetons restent au serveur, + * `autoRenew` dit simplement si la page sait se renouveler toute seule. + */ +export type ClientSocialPage = Omit< + SocialPage, + 'accessToken' | 'userAccessToken' | 'refreshToken' +> & { autoRenew: boolean }; + +export type FacebookConfig = typeof facebookConfig.$inferSelect; +export type InsertFacebookConfig = z.infer; +export type UpdateFacebookConfig = z.infer; + export type TiktokConfig = typeof tiktokConfig.$inferSelect; export type InsertTiktokConfig = z.infer; export type UpdateTiktokConfig = z.infer;