mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-17 20:26:24 +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
227 lines
8.4 KiB
TypeScript
227 lines
8.4 KiB
TypeScript
/**
|
|
* Routes de gestion des comptes TikTok.
|
|
*
|
|
* Un compte TikTok de magasin se connecte en OAuth (TikTok n'autorise pas la
|
|
* saisie manuelle d'un token), puis vit dans `social_pages` comme une page
|
|
* Facebook : mêmes permissions, même planification, même calendrier.
|
|
*/
|
|
|
|
import crypto from 'crypto';
|
|
import { Router, Request, Response } from 'express';
|
|
import type { User } from '@shared/schema';
|
|
import { storage } from '../storage';
|
|
import { tiktokService } from '../services/tiktok';
|
|
import { insertTiktokConfigSchema, updateTiktokConfigSchema } from '@shared/schema';
|
|
|
|
export const tiktokRouter = 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({ tiktok: status, message });
|
|
return `${RETURN_PATH}?${params.toString()}`;
|
|
}
|
|
|
|
/**
|
|
* État de la configuration TikTok.
|
|
* Le client secret n'est jamais renvoyé au client.
|
|
*/
|
|
tiktokRouter.get('/config', async (req: Request, res: Response) => {
|
|
try {
|
|
const config = await storage.getTiktokConfig();
|
|
const configured = await tiktokService.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,
|
|
clientKey: config?.clientKey || (process.env.TIKTOK_CLIENT_KEY ? '(défini par variable d\'environnement)' : ''),
|
|
hasClientSecret: !!(config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET),
|
|
redirectUri: tiktokService.getRedirectUri(req),
|
|
});
|
|
} catch (error) {
|
|
console.error('❌ [TikTok] Erreur lecture configuration:', error);
|
|
res.status(500).json({ error: 'Impossible de lire la configuration TikTok' });
|
|
}
|
|
});
|
|
|
|
/** Enregistre les identifiants de l'application développeur TikTok (admin). */
|
|
tiktokRouter.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.getTiktokConfig();
|
|
// Le secret peut être omis lors d'une mise à jour : on garde l'existant
|
|
const schema = existing ? updateTiktokConfigSchema : insertTiktokConfigSchema;
|
|
const data = schema.parse(req.body);
|
|
|
|
const config = await storage.upsertTiktokConfig(data);
|
|
|
|
res.json({
|
|
success: true,
|
|
clientKey: config.clientKey,
|
|
redirectUri: tiktokService.getRedirectUri(req),
|
|
});
|
|
} catch (error: any) {
|
|
console.error('❌ [TikTok] Erreur enregistrement configuration:', error);
|
|
res.status(400).json({
|
|
error: error?.errors?.[0]?.message || error?.message || 'Configuration TikTok invalide',
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Démarre la connexion d'un compte TikTok.
|
|
* À appeler par navigation complète du navigateur (window.location), pas en fetch.
|
|
*/
|
|
tiktokRouter.get('/connect', async (req: Request, res: Response) => {
|
|
try {
|
|
if (!(await tiktokService.isConfigured())) {
|
|
return res.redirect(
|
|
buildReturnUrl('error', "TikTok n'est pas configuré. Renseignez d'abord le client key et le client secret.")
|
|
);
|
|
}
|
|
|
|
// Protection CSRF : l'état est vérifié au retour
|
|
const state = crypto.randomBytes(16).toString('hex');
|
|
(req.session as any).tiktokOAuthState = state;
|
|
|
|
const authUrl = await tiktokService.buildAuthorizationUrl(state, req);
|
|
|
|
// La session doit être écrite avant la redirection, sinon l'état est perdu
|
|
req.session.save((err) => {
|
|
if (err) {
|
|
console.error('❌ [TikTok] Impossible de sauvegarder la session OAuth:', err);
|
|
return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.'));
|
|
}
|
|
res.redirect(authUrl);
|
|
});
|
|
} catch (error) {
|
|
console.error('❌ [TikTok] Erreur démarrage OAuth:', error);
|
|
res.redirect(
|
|
buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion TikTok')
|
|
);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Retour d'autorisation TikTok : échange le code, puis crée ou met à jour le
|
|
* compte dans social_pages.
|
|
*/
|
|
tiktokRouter.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 TikTok refusée : ${description}`));
|
|
}
|
|
|
|
const expectedState = (req.session as any)?.tiktokOAuthState;
|
|
delete (req.session as any)?.tiktokOAuthState;
|
|
|
|
if (!state || !expectedState || state !== expectedState) {
|
|
return res.redirect(buildReturnUrl('error', 'Requête de connexion TikTok invalide (état non reconnu).'));
|
|
}
|
|
|
|
if (!code || typeof code !== 'string') {
|
|
return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de TikTok."));
|
|
}
|
|
|
|
const tokens = await tiktokService.exchangeCodeForToken(code, req);
|
|
const profile = await tiktokService.getUserInfo(tokens.access_token);
|
|
const openId = profile.openId || tokens.open_id;
|
|
|
|
// Un compte déjà connecté est mis à jour plutôt que dupliqué
|
|
const existingPages = await storage.getSocialPages(user.id);
|
|
const existing = existingPages.find(p => p.platform === 'tiktok' && p.pageId === openId);
|
|
|
|
if (existing) {
|
|
await storage.updateSocialPage(existing.id, {
|
|
pageName: profile.displayName,
|
|
avatarUrl: profile.avatarUrl || null,
|
|
isActive: 'true',
|
|
});
|
|
await tiktokService.persistTokens(existing.id, tokens);
|
|
|
|
console.log(`🔁 [TikTok] Compte reconnecté: ${profile.displayName}`);
|
|
return res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" reconnecté.`));
|
|
}
|
|
|
|
const page = await storage.createSocialPage({
|
|
userId: user.id,
|
|
platform: 'tiktok',
|
|
pageId: openId,
|
|
pageName: profile.displayName,
|
|
accessToken: tokens.access_token,
|
|
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
|
refreshToken: tokens.refresh_token,
|
|
refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000),
|
|
scopes: tokens.scope,
|
|
avatarUrl: profile.avatarUrl || null,
|
|
isActive: 'true',
|
|
});
|
|
|
|
// Les non-admins ne voient que les pages qui leur sont explicitement
|
|
// attribuées : sans cette permission, ils ne verraient pas leur propre compte.
|
|
if (user.role !== 'admin') {
|
|
try {
|
|
await storage.createPagePermission({ userId: user.id, pageId: page.id });
|
|
} catch (permissionError) {
|
|
console.error('⚠️ [TikTok] Impossible de créer la permission de page:', permissionError);
|
|
}
|
|
}
|
|
|
|
console.log(`✅ [TikTok] Nouveau compte connecté: ${profile.displayName} (${openId})`);
|
|
res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" connecté.`));
|
|
} catch (error) {
|
|
console.error('❌ [TikTok] Erreur callback OAuth:', error);
|
|
res.redirect(
|
|
buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion TikTok impossible')
|
|
);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Options réellement autorisées sur un compte (confidentialité, commentaires,
|
|
* duo, stitch, durée max). TikTok impose de les afficher avant publication.
|
|
*/
|
|
tiktokRouter.get('/accounts/:id/creator-info', async (req: Request, res: Response) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const page = await storage.getSocialPage(req.params.id);
|
|
|
|
if (!page || page.platform !== 'tiktok') {
|
|
return res.status(404).json({ error: 'Compte TikTok non trouvé' });
|
|
}
|
|
|
|
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é à ce compte' });
|
|
}
|
|
}
|
|
|
|
const creatorInfo = await tiktokService.getCreatorInfo(page);
|
|
res.json(creatorInfo);
|
|
} catch (error) {
|
|
console.error('❌ [TikTok] Erreur récupération creator info:', error);
|
|
res.status(502).json({
|
|
error: error instanceof Error ? error.message : 'Impossible de contacter TikTok',
|
|
});
|
|
}
|
|
});
|