mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 15:38:10 +02:00
Retrait de l'ancien serveur Node/TypeScript
Le serveur Rust (server/src) le remplace entièrement : routes, services, migrations Drizzle et scripts SQL d'initialisation sont supprimés. Le schéma est désormais créé et migré par server/src/migrate.rs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
This commit is contained in:
54 files changed
+2
-13174
No files matched your search
@@ -1,79 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { db } from '../db';
|
||||
import { AnalyticsService } from '../services/analytics';
|
||||
import { TokenManager } from '../services/token_manager';
|
||||
import { postAnalytics, pageAnalyticsHistory, socialPages } from '../../shared/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get Post Analytics
|
||||
router.get('/posts/:postId', async (req, res) => {
|
||||
try {
|
||||
const { postId } = req.params;
|
||||
const analytics = await db.query.postAnalytics.findFirst({
|
||||
where: eq(postAnalytics.postId, postId)
|
||||
});
|
||||
res.json(analytics || {});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch post analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh Post Analytics
|
||||
router.post('/posts/:postId/refresh', async (req, res) => {
|
||||
try {
|
||||
const { postId } = req.params;
|
||||
await AnalyticsService.syncPostAnalytics(postId);
|
||||
const updated = await db.query.postAnalytics.findFirst({
|
||||
where: eq(postAnalytics.postId, postId)
|
||||
});
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to refresh post analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get Page Analytics History
|
||||
router.get('/pages/:pageId/history', async (req, res) => {
|
||||
try {
|
||||
const { pageId } = req.params;
|
||||
const history = await db.query.pageAnalyticsHistory.findMany({
|
||||
where: eq(pageAnalyticsHistory.pageId, pageId),
|
||||
orderBy: [desc(pageAnalyticsHistory.date)],
|
||||
limit: 30 // Last 30 entries
|
||||
});
|
||||
res.json(history);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch page history' });
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh Page Analytics
|
||||
router.post('/pages/:pageId/refresh', async (req, res) => {
|
||||
try {
|
||||
const { pageId } = req.params;
|
||||
await AnalyticsService.syncPageAnalytics(pageId);
|
||||
|
||||
const page = await db.query.socialPages.findFirst({
|
||||
where: eq(socialPages.id, pageId)
|
||||
});
|
||||
res.json(page);
|
||||
} catch (error: any) {
|
||||
console.error('Refresh error:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to refresh page analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
// Manual Token Check Trigger
|
||||
router.post('/tokens/check', async (req, res) => {
|
||||
try {
|
||||
// This runs in background, but we can await it for manual trigger
|
||||
await TokenManager.checkAndRefreshTokens();
|
||||
res.json({ success: true, message: 'Token check completed' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Token check failed' });
|
||||
}
|
||||
});
|
||||
|
||||
export const analyticsRouter = router;
|
||||
@@ -1,497 +0,0 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import path from "path";
|
||||
import { storage } from "../storage";
|
||||
import { db } from "../db";
|
||||
import { postMedia } from "@shared/schema";
|
||||
import { minioService } from "../services/minio";
|
||||
import { requireApiKey } from "../middleware/apiKey";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireApiKey);
|
||||
|
||||
const publishSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu est requis"),
|
||||
imageUrl: z.string().url("URL d'image invalide").optional(),
|
||||
pageIds: z.array(z.string()).min(1, "Au moins une page est requise"),
|
||||
scheduledAt: z.string().datetime({ offset: true }).optional(),
|
||||
postType: z.enum(["feed", "story", "both"]).default("feed"),
|
||||
userId: z.string().optional(),
|
||||
});
|
||||
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
};
|
||||
|
||||
async function downloadImage(url: string): Promise<{ buffer: Buffer; ext: string; mimeType: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Impossible de télécharger l'image (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type")?.split(";")[0].trim() ?? "";
|
||||
const ext = IMAGE_MIME_TYPES[contentType];
|
||||
|
||||
if (!ext) {
|
||||
throw new Error(`Type MIME non supporté: ${contentType}. Formats acceptés: JPEG, PNG, WebP, GIF`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
if (buffer.length > MAX_SIZE) {
|
||||
throw new Error(`Image trop volumineuse (${(buffer.length / 1024 / 1024).toFixed(1)} MB, max 10 MB)`);
|
||||
}
|
||||
|
||||
return { buffer, ext, mimeType: contentType };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/publish
|
||||
*
|
||||
* Crée une publication (avec image optionnelle téléchargée depuis une URL)
|
||||
* et la programme pour une ou plusieurs pages Facebook/Instagram.
|
||||
*
|
||||
* Headers:
|
||||
* X-API-Key: <EXTERNAL_API_KEY>
|
||||
*
|
||||
* Body:
|
||||
* content string Texte de la publication
|
||||
* imageUrl string? URL publique de l'image à télécharger
|
||||
* pageIds string[] IDs des pages cibles (social_pages.id)
|
||||
* scheduledAt ISO8601? Date/heure de publication (absent = immédiat)
|
||||
* postType feed|story|both Type de publication (défaut: feed)
|
||||
* userId string? ID utilisateur propriétaire (défaut: premier admin)
|
||||
*/
|
||||
router.post("/publish", async (req, res) => {
|
||||
try {
|
||||
const parsed = publishSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: "Données invalides",
|
||||
details: parsed.error.flatten().fieldErrors,
|
||||
});
|
||||
}
|
||||
|
||||
const { content, imageUrl, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data;
|
||||
|
||||
// Résoudre l'utilisateur propriétaire
|
||||
let ownerId = bodyUserId;
|
||||
if (!ownerId) {
|
||||
const users = await storage.getAllUsers();
|
||||
const admin = users.find((u) => u.role === "admin");
|
||||
if (!admin) {
|
||||
return res.status(500).json({ error: "Aucun utilisateur admin trouvé" });
|
||||
}
|
||||
ownerId = admin.id;
|
||||
} else {
|
||||
const user = await storage.getUser(ownerId);
|
||||
if (!user) {
|
||||
return res.status(400).json({ error: `Utilisateur introuvable: ${ownerId}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier que toutes les pages existent
|
||||
const resolvedPages: Array<{ id: string; pageName: string }> = [];
|
||||
for (const pageId of pageIds) {
|
||||
const page = await storage.getSocialPage(pageId);
|
||||
if (!page) {
|
||||
return res.status(400).json({ error: `Page introuvable: ${pageId}` });
|
||||
}
|
||||
resolvedPages.push({ id: page.id, pageName: page.pageName });
|
||||
}
|
||||
|
||||
// Validation story → image obligatoire
|
||||
if ((postType === "story" || postType === "both") && !imageUrl) {
|
||||
return res.status(400).json({ error: "Les stories nécessitent une image (imageUrl requis)" });
|
||||
}
|
||||
|
||||
// Télécharger l'image si fournie
|
||||
let mediaRecord: { id: string; originalUrl: string } | null = null;
|
||||
if (imageUrl) {
|
||||
const { buffer, ext, mimeType } = await downloadImage(imageUrl);
|
||||
|
||||
const fileName = `external-${Date.now()}${ext}`;
|
||||
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType);
|
||||
|
||||
const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image";
|
||||
mediaRecord = await storage.createMedia({
|
||||
userId: ownerId,
|
||||
type: mediaType,
|
||||
cloudinaryPublicId: uploaded.publicId,
|
||||
originalUrl: uploaded.originalUrl,
|
||||
facebookFeedUrl: uploaded.facebookFeedUrl,
|
||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||
fileName,
|
||||
fileSize: buffer.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Créer le post
|
||||
const scheduledFor = scheduledAt ? new Date(scheduledAt) : null;
|
||||
|
||||
const post = await storage.createPost({
|
||||
userId: ownerId,
|
||||
content,
|
||||
status: scheduledFor ? "scheduled" : "scheduled",
|
||||
scheduledFor: scheduledFor ?? new Date(),
|
||||
aiGenerated: "false",
|
||||
});
|
||||
|
||||
// Lier le média si présent
|
||||
if (mediaRecord) {
|
||||
await db.insert(postMedia).values({
|
||||
postId: post.id,
|
||||
mediaId: mediaRecord.id,
|
||||
displayOrder: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Créer les scheduled_posts par page
|
||||
const scheduledAt_ = scheduledFor ?? new Date();
|
||||
const createdSchedules: Array<{ pageId: string; pageName: string; scheduledPostId: string }> = [];
|
||||
|
||||
for (const page of resolvedPages) {
|
||||
if (postType === "both") {
|
||||
const story = await storage.createScheduledPost({
|
||||
postId: post.id,
|
||||
pageId: page.id,
|
||||
postType: "story",
|
||||
scheduledAt: scheduledAt_,
|
||||
});
|
||||
const feed = await storage.createScheduledPost({
|
||||
postId: post.id,
|
||||
pageId: page.id,
|
||||
postType: "feed",
|
||||
scheduledAt: scheduledAt_,
|
||||
});
|
||||
createdSchedules.push(
|
||||
{ pageId: page.id, pageName: page.pageName, scheduledPostId: story.id },
|
||||
{ pageId: page.id, pageName: page.pageName, scheduledPostId: feed.id }
|
||||
);
|
||||
} else {
|
||||
const sp = await storage.createScheduledPost({
|
||||
postId: post.id,
|
||||
pageId: page.id,
|
||||
postType: postType,
|
||||
scheduledAt: scheduledAt_,
|
||||
});
|
||||
createdSchedules.push({ pageId: page.id, pageName: page.pageName, scheduledPostId: sp.id });
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
post: {
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
status: post.status,
|
||||
scheduledAt: scheduledAt_,
|
||||
postType,
|
||||
pages: createdSchedules,
|
||||
media: mediaRecord
|
||||
? { id: mediaRecord.id, url: mediaRecord.originalUrl }
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[external API] Error:", error);
|
||||
const message = error instanceof Error ? error.message : "Erreur interne";
|
||||
return res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/pages
|
||||
*
|
||||
* Liste toutes les pages disponibles (id + nom + plateforme).
|
||||
* Utile pour connaître les pageIds à passer à /publish.
|
||||
*/
|
||||
router.get("/pages", async (_req, res) => {
|
||||
try {
|
||||
const users = await storage.getAllUsers();
|
||||
const admin = users.find((u) => u.role === "admin");
|
||||
if (!admin) {
|
||||
return res.status(500).json({ error: "Aucun utilisateur admin trouvé" });
|
||||
}
|
||||
|
||||
const pages = await storage.getSocialPages(admin.id);
|
||||
return res.json(
|
||||
pages.map((p) => ({
|
||||
id: p.id,
|
||||
pageName: p.pageName,
|
||||
platform: p.platform,
|
||||
pageId: p.pageId,
|
||||
tokenStatus: p.tokenStatus,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[external API] Error listing pages:", error);
|
||||
return res.status(500).json({ error: "Erreur interne" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/posts
|
||||
*
|
||||
* Liste les publications planifiées (à venir).
|
||||
*
|
||||
* Query params:
|
||||
* pageId string? Filtrer par page spécifique
|
||||
* from ISO8601? Date de début (scheduledAt >= from)
|
||||
* to ISO8601? Date de fin (scheduledAt <= to)
|
||||
* status string? Statut du post (défaut: scheduled)
|
||||
*/
|
||||
router.get("/posts", async (req, res) => {
|
||||
try {
|
||||
const { pageId, from, to, status } = req.query as Record<string, string | undefined>;
|
||||
const postStatus = status || "scheduled";
|
||||
|
||||
// Récupérer toutes les pages (ou une seule si filtrée)
|
||||
let pageIds: string[];
|
||||
if (pageId) {
|
||||
const page = await storage.getSocialPage(pageId);
|
||||
if (!page) {
|
||||
return res.status(404).json({ error: `Page introuvable: ${pageId}` });
|
||||
}
|
||||
pageIds = [pageId];
|
||||
} else {
|
||||
pageIds = (await storage.getAllSocialPages()).map(p => p.id);
|
||||
}
|
||||
|
||||
if (pageIds.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
const startDate = from ? new Date(from) : undefined;
|
||||
const endDate = to ? new Date(to) : undefined;
|
||||
|
||||
const scheduledPosts = await storage.getScheduledPostsByPages(pageIds, startDate, endDate);
|
||||
|
||||
// Grouper par postId, filtrer par statut et posts non publiés
|
||||
const postMap = new Map<string, {
|
||||
id: string;
|
||||
content: string;
|
||||
status: string;
|
||||
scheduledFor: Date | null;
|
||||
createdAt: Date | null;
|
||||
media: Array<{ id: string; url: string; type: string }>;
|
||||
schedules: Array<{
|
||||
id: string;
|
||||
pageId: string;
|
||||
pageName: string;
|
||||
platform: string;
|
||||
postType: string;
|
||||
scheduledAt: Date;
|
||||
}>;
|
||||
}>();
|
||||
|
||||
for (const sp of scheduledPosts) {
|
||||
// sp = { ...scheduled_posts, post: Post, page: SocialPage }
|
||||
const post = sp.post;
|
||||
if (!post) continue;
|
||||
|
||||
// Ne montrer que les posts non publiés avec le bon statut
|
||||
if (sp.publishedAt) continue;
|
||||
if (post.status !== postStatus && postStatus !== "all") continue;
|
||||
|
||||
if (!postMap.has(post.id)) {
|
||||
// Récupérer les médias du post, en une requête plutôt qu'une par média
|
||||
const postMediaLinks = await storage.getPostMedia(post.id);
|
||||
const byId = new Map(
|
||||
(await storage.getMediaByIds(postMediaLinks.map(link => link.mediaId)))
|
||||
.map(item => [item.id, item])
|
||||
);
|
||||
const mediaItems = postMediaLinks
|
||||
.map(link => byId.get(link.mediaId))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== undefined)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
url: item.originalUrl,
|
||||
type: item.type,
|
||||
}));
|
||||
|
||||
postMap.set(post.id, {
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
status: post.status,
|
||||
scheduledFor: post.scheduledFor,
|
||||
createdAt: post.createdAt,
|
||||
media: mediaItems,
|
||||
schedules: [],
|
||||
});
|
||||
}
|
||||
|
||||
const page = sp.page;
|
||||
postMap.get(post.id)!.schedules.push({
|
||||
id: sp.id,
|
||||
pageId: sp.pageId,
|
||||
pageName: page?.pageName || "Inconnu",
|
||||
platform: page?.platform || "facebook",
|
||||
postType: sp.postType,
|
||||
scheduledAt: sp.scheduledAt,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(Array.from(postMap.values()));
|
||||
} catch (error) {
|
||||
console.error("[external API] GET /posts error:", error);
|
||||
return res.status(500).json({ error: "Erreur interne" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/posts/:id
|
||||
*
|
||||
* Modifier une publication planifiée.
|
||||
*
|
||||
* Body:
|
||||
* content string? Nouveau texte
|
||||
* scheduledAt ISO8601? Nouvelle date/heure de publication
|
||||
* imageUrl string? URL d'une nouvelle image (remplace l'existante)
|
||||
*/
|
||||
router.patch("/posts/:id", async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { content, scheduledAt, imageUrl } = req.body;
|
||||
|
||||
// Vérifier que le post existe et est modifiable
|
||||
const post = await storage.getPost(id);
|
||||
if (!post) {
|
||||
return res.status(404).json({ error: "Post introuvable" });
|
||||
}
|
||||
if (post.status === "published") {
|
||||
return res.status(400).json({ error: "Impossible de modifier un post déjà publié" });
|
||||
}
|
||||
|
||||
// Mettre à jour le contenu si fourni
|
||||
if (content !== undefined) {
|
||||
await storage.updatePost(id, { content });
|
||||
}
|
||||
|
||||
// Mettre à jour la date de planification si fournie
|
||||
if (scheduledAt !== undefined) {
|
||||
const newDate = new Date(scheduledAt);
|
||||
await storage.updatePost(id, { scheduledFor: newDate });
|
||||
|
||||
// Propager à tous les scheduled_posts du post
|
||||
const scheduledPosts = await storage.getScheduledPostsByPost(id);
|
||||
for (const sp of scheduledPosts) {
|
||||
if (!sp.publishedAt) {
|
||||
await storage.updateScheduledPost(sp.id, { scheduledAt: newDate });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remplacer le média si une nouvelle imageUrl est fournie
|
||||
if (imageUrl !== undefined) {
|
||||
// Résoudre le userId pour le stockage du média
|
||||
const ownerId = post.userId;
|
||||
const { buffer, ext, mimeType } = await downloadImage(imageUrl);
|
||||
const fileName = `external-edit-${Date.now()}${ext}`;
|
||||
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType);
|
||||
const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image";
|
||||
const mediaRecord = await storage.createMedia({
|
||||
userId: ownerId,
|
||||
type: mediaType,
|
||||
cloudinaryPublicId: uploaded.publicId,
|
||||
originalUrl: uploaded.originalUrl,
|
||||
facebookFeedUrl: uploaded.facebookFeedUrl,
|
||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||
fileName,
|
||||
fileSize: buffer.length,
|
||||
});
|
||||
|
||||
// Remplacer les médias existants par le nouveau
|
||||
await storage.updatePostMedia(id, [mediaRecord.id]);
|
||||
}
|
||||
|
||||
// Retourner le post mis à jour
|
||||
const updatedPost = await storage.getPost(id);
|
||||
const postMediaLinks = await storage.getPostMedia(id);
|
||||
const mediaItems: Array<{ id: string; url: string; type: string }> = [];
|
||||
for (const link of postMediaLinks) {
|
||||
const mediaItem = await storage.getMediaById(link.mediaId);
|
||||
if (mediaItem) {
|
||||
mediaItems.push({
|
||||
id: mediaItem.id,
|
||||
url: mediaItem.originalUrl,
|
||||
type: mediaItem.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const scheduledPosts = await storage.getScheduledPostsByPost(id);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
post: {
|
||||
id: updatedPost!.id,
|
||||
content: updatedPost!.content,
|
||||
status: updatedPost!.status,
|
||||
scheduledFor: updatedPost!.scheduledFor,
|
||||
createdAt: updatedPost!.createdAt,
|
||||
media: mediaItems,
|
||||
schedules: await Promise.all(scheduledPosts.filter(sp => !sp.publishedAt).map(async sp => {
|
||||
const page = await storage.getSocialPage(sp.pageId);
|
||||
return {
|
||||
id: sp.id,
|
||||
pageId: sp.pageId,
|
||||
pageName: page?.pageName || "Inconnu",
|
||||
platform: page?.platform || "facebook",
|
||||
postType: sp.postType,
|
||||
scheduledAt: sp.scheduledAt,
|
||||
};
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[external API] PATCH /posts/:id error:", error);
|
||||
const message = error instanceof Error ? error.message : "Erreur interne";
|
||||
return res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/posts/:id
|
||||
*
|
||||
* Supprimer un post planifié et toutes ses planifications associées.
|
||||
* La suppression en cascade gère les scheduled_posts et post_media.
|
||||
*/
|
||||
router.delete("/posts/:id", async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const post = await storage.getPost(id);
|
||||
if (!post) {
|
||||
return res.status(404).json({ error: "Post introuvable" });
|
||||
}
|
||||
if (post.status === "published") {
|
||||
return res.status(400).json({ error: "Impossible de supprimer un post déjà publié" });
|
||||
}
|
||||
|
||||
await storage.deletePost(id);
|
||||
|
||||
return res.json({ success: true, deleted: id });
|
||||
} catch (error) {
|
||||
console.error("[external API] DELETE /posts/:id error:", error);
|
||||
return res.status(500).json({ error: "Erreur interne" });
|
||||
}
|
||||
});
|
||||
|
||||
export { router as externalRouter };
|
||||
@@ -1,300 +0,0 @@
|
||||
/**
|
||||
* 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' });
|
||||
}
|
||||
});
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Pages légales publiques (conditions d'utilisation et politique de confidentialité).
|
||||
*
|
||||
* TikTok exige une URL publique pour chacune lors de la création de l'application
|
||||
* développeur, et les relecteurs les ouvrent réellement pendant l'audit. Elles sont
|
||||
* servies en HTML depuis le serveur, sans authentification, pour rester lisibles
|
||||
* même par un robot qui n'exécute pas le JavaScript du client.
|
||||
*
|
||||
* Les mentions de l'exploitant sont préremplies avec l'identité de la société
|
||||
* exploitante et restent surchargeables par variables d'environnement :
|
||||
* LEGAL_COMPANY_NAME, LEGAL_TRADE_NAME, LEGAL_COMPANY_LEGAL_FORM,
|
||||
* LEGAL_COMPANY_RCS, LEGAL_COMPANY_ADDRESS, LEGAL_CONTACT_EMAIL
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
|
||||
export const legalRouter = Router();
|
||||
|
||||
function getCompanyInfo() {
|
||||
return {
|
||||
name: process.env.LEGAL_COMPANY_NAME || 'FROUARD DISTRIBUTION',
|
||||
tradeName: process.env.LEGAL_TRADE_NAME || "LA FOIR'FOUILLE",
|
||||
legalForm: process.env.LEGAL_COMPANY_LEGAL_FORM
|
||||
|| 'Société par actions simplifiée au capital de 50 000 €',
|
||||
rcs: process.env.LEGAL_COMPANY_RCS || '478 693 518 R.C.S. Nancy',
|
||||
address: process.env.LEGAL_COMPANY_ADDRESS || '13 rue du Saule Gaillard, 54390 Frouard',
|
||||
// Aucune adresse de contact ne figure au registre : elle doit être fournie,
|
||||
// le RGPD imposant un point de contact pour l'exercice des droits.
|
||||
email: process.env.LEGAL_CONTACT_EMAIL || '[Adresse e-mail de contact à renseigner]',
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderPage(title: string, body: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 4rem;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.65;
|
||||
color: #1f2328;
|
||||
background: #ffffff;
|
||||
}
|
||||
h1 { font-size: 1.75rem; margin-bottom: 0.25rem; }
|
||||
h2 { font-size: 1.15rem; margin-top: 2.25rem; }
|
||||
.updated { color: #6b7280; font-size: 0.875rem; margin-top: 0; }
|
||||
ul { padding-left: 1.25rem; }
|
||||
li { margin-bottom: 0.35rem; }
|
||||
code { background: rgba(127, 127, 127, 0.15); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.9em; }
|
||||
footer { margin-top: 3rem; padding-top: 1.25rem; border-top: 1px solid rgba(127,127,127,0.3); color: #6b7280; font-size: 0.875rem; }
|
||||
a { color: #2563eb; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #e6e6e6; background: #0f1115; }
|
||||
.updated, footer { color: #9ca3af; }
|
||||
a { color: #7aa7ff; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderFooter(): string {
|
||||
const company = getCompanyInfo();
|
||||
return `<footer>
|
||||
<p>
|
||||
${escapeHtml(company.name)} – enseigne ${escapeHtml(company.tradeName)}<br>
|
||||
${escapeHtml(company.legalForm)}<br>
|
||||
${escapeHtml(company.address)}<br>
|
||||
${escapeHtml(company.rcs)}<br>
|
||||
Contact : ${escapeHtml(company.email)}
|
||||
</p>
|
||||
<p><a href="/terms">Conditions d'utilisation</a> · <a href="/privacy">Politique de confidentialité</a></p>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
legalRouter.get('/terms', (_req: Request, res: Response) => {
|
||||
const company = getCompanyInfo();
|
||||
|
||||
const body = `
|
||||
<h1>Conditions d'utilisation</h1>
|
||||
<p class="updated">Dernière mise à jour : 11 août 2026</p>
|
||||
|
||||
<p>
|
||||
Cette application est un outil interne de gestion des réseaux sociaux exploité par
|
||||
${escapeHtml(company.name)}, ${escapeHtml(company.legalForm)}, dont le siège est situé
|
||||
${escapeHtml(company.address)}, immatriculée au ${escapeHtml(company.rcs)}, qui exploite le
|
||||
magasin sous l'enseigne ${escapeHtml(company.tradeName)} (« nous »). Elle permet à nos équipes
|
||||
de créer des vidéos et des publications promotionnelles, puis de les diffuser sur les comptes
|
||||
et pages officiels de nos magasins.
|
||||
</p>
|
||||
|
||||
<h2>1. Accès au service</h2>
|
||||
<p>
|
||||
L'accès est réservé aux personnes que nous avons expressément autorisées, au moyen d'un compte
|
||||
nominatif. Chaque utilisateur est responsable de la confidentialité de ses identifiants et des
|
||||
actions effectuées depuis son compte. Nous pouvons suspendre ou retirer un accès à tout moment.
|
||||
</p>
|
||||
|
||||
<h2>2. Comptes de réseaux sociaux connectés</h2>
|
||||
<p>
|
||||
L'application publie sur des comptes Facebook et TikTok qui nous appartiennent ou que nous
|
||||
sommes habilités à administrer. En connectant un compte, l'utilisateur déclare disposer des
|
||||
droits nécessaires pour le faire. Un compte peut être déconnecté à tout moment depuis
|
||||
l'application, ou par le retrait de l'autorisation directement dans les réglages de la
|
||||
plateforme concernée.
|
||||
</p>
|
||||
|
||||
<h2>3. Contenus publiés</h2>
|
||||
<p>
|
||||
Les utilisateurs restent responsables des contenus qu'ils créent et diffusent. Ces contenus
|
||||
doivent respecter la loi applicable ainsi que les règles des plateformes de destination,
|
||||
notamment les Conditions d'utilisation et les Règles communautaires de TikTok et de Meta.
|
||||
Sont notamment proscrits les contenus dont les utilisateurs ne détiennent pas les droits,
|
||||
y compris pour les images et les musiques utilisées.
|
||||
</p>
|
||||
|
||||
<h2>4. Disponibilité</h2>
|
||||
<p>
|
||||
Le service est fourni « en l'état ». Il dépend de services tiers (plateformes de publication,
|
||||
hébergement, traitement vidéo) dont les interruptions ou modifications peuvent affecter son
|
||||
fonctionnement. Nous ne garantissons ni une disponibilité continue, ni la publication effective
|
||||
d'un contenu par une plateforme tierce, celle-ci pouvant la refuser selon ses propres règles.
|
||||
</p>
|
||||
|
||||
<h2>5. Responsabilité</h2>
|
||||
<p>
|
||||
Dans les limites permises par la loi, notre responsabilité ne saurait être engagée au titre des
|
||||
dommages indirects résultant de l'utilisation du service, notamment une perte de contenu, une
|
||||
publication manquée ou la suspension d'un compte par une plateforme tierce.
|
||||
</p>
|
||||
|
||||
<h2>6. Modification des conditions</h2>
|
||||
<p>
|
||||
Ces conditions peuvent être modifiées. La date de dernière mise à jour figure en tête de page.
|
||||
</p>
|
||||
|
||||
<h2>7. Contact</h2>
|
||||
<p>Pour toute question relative à ces conditions : ${escapeHtml(company.email)}</p>
|
||||
|
||||
${renderFooter()}`;
|
||||
|
||||
res.type('html').send(renderPage("Conditions d'utilisation", body));
|
||||
});
|
||||
|
||||
legalRouter.get('/privacy', (_req: Request, res: Response) => {
|
||||
const company = getCompanyInfo();
|
||||
|
||||
const body = `
|
||||
<h1>Politique de confidentialité</h1>
|
||||
<p class="updated">Dernière mise à jour : 11 août 2026</p>
|
||||
|
||||
<p>
|
||||
Cette politique décrit les données traitées par notre outil interne de gestion des réseaux
|
||||
sociaux. Le responsable de traitement est ${escapeHtml(company.name)},
|
||||
${escapeHtml(company.legalForm)}, dont le siège est situé ${escapeHtml(company.address)},
|
||||
immatriculée au ${escapeHtml(company.rcs)}, exploitant le magasin sous l'enseigne
|
||||
${escapeHtml(company.tradeName)}.
|
||||
</p>
|
||||
|
||||
<h2>1. Données de nos utilisateurs internes</h2>
|
||||
<p>
|
||||
Pour chaque personne autorisée à utiliser l'outil, nous conservons un identifiant de connexion,
|
||||
un mot de passe stocké sous forme chiffrée, un rôle et la liste des comptes auxquels elle a
|
||||
accès. Ces données servent uniquement à l'authentification et à la gestion des droits.
|
||||
</p>
|
||||
|
||||
<h2>2. Données issues des comptes de réseaux sociaux connectés</h2>
|
||||
<p>
|
||||
Lorsqu'un compte TikTok est connecté, nous recevons de TikTok, avec l'autorisation explicite du
|
||||
titulaire du compte et dans le cadre du périmètre <code>user.info.basic</code> :
|
||||
</p>
|
||||
<ul>
|
||||
<li>l'identifiant technique du compte (<code>open_id</code>) ;</li>
|
||||
<li>le nom d'affichage du compte ;</li>
|
||||
<li>l'image de profil du compte ;</li>
|
||||
<li>un jeton d'accès et un jeton de renouvellement, qui nous permettent de publier en son nom.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Nous procédons de manière équivalente pour les pages Facebook connectées. Nous ne collectons ni
|
||||
la liste des abonnés, ni les messages privés, ni les données de navigation des visiteurs de ces
|
||||
comptes.
|
||||
</p>
|
||||
|
||||
<h2>3. Finalités</h2>
|
||||
<p>
|
||||
Ces données sont utilisées exclusivement pour identifier le compte de destination dans
|
||||
l'interface et y publier les vidéos et publications créées par nos équipes, immédiatement ou à
|
||||
l'heure programmée. Elles ne servent à aucune autre finalité.
|
||||
</p>
|
||||
|
||||
<h2>4. Données que nous transmettons aux plateformes</h2>
|
||||
<p>
|
||||
Lors d'une publication, nous transmettons à la plateforme concernée le fichier vidéo ou image,
|
||||
le texte de la publication, ainsi que les paramètres de diffusion associés (niveau de
|
||||
confidentialité, autorisation des commentaires). Ces contenus sont ensuite régis par la
|
||||
politique de confidentialité de la plateforme concernée.
|
||||
</p>
|
||||
|
||||
<h2>5. Conservation et sécurité</h2>
|
||||
<ul>
|
||||
<li>Les jetons d'accès et de renouvellement sont chiffrés au repos (AES-256-GCM).</li>
|
||||
<li>
|
||||
Ils sont conservés tant que le compte reste connecté à l'application, et supprimés
|
||||
immédiatement lorsque le compte en est retiré.
|
||||
</li>
|
||||
<li>Les médias sources sont supprimés automatiquement après leur publication et leur péremption.</li>
|
||||
<li>L'accès à l'application est restreint aux personnes autorisées et protégé par mot de passe.</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Partage avec des tiers</h2>
|
||||
<p>
|
||||
Nous ne vendons ni ne louons ces données, et ne les transmettons à aucun tiers à des fins
|
||||
publicitaires ou de profilage. Elles ne sont communiquées qu'aux plateformes de destination
|
||||
(TikTok, Meta) dans la stricte mesure nécessaire à la publication demandée, ainsi qu'à nos
|
||||
prestataires techniques d'hébergement et de traitement vidéo agissant sur nos instructions.
|
||||
</p>
|
||||
|
||||
<h2>7. Retirer une autorisation</h2>
|
||||
<p>
|
||||
Le titulaire d'un compte connecté peut retirer son autorisation à tout moment :
|
||||
</p>
|
||||
<ul>
|
||||
<li>en demandant la déconnexion du compte depuis l'application, ce qui supprime les jetons ;</li>
|
||||
<li>
|
||||
ou directement dans TikTok, via <em>Profil › Paramètres et confidentialité ›
|
||||
Sécurité et autorisations › Gérer les autorisations des applications</em>.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Le retrait de l'autorisation empêche toute publication ultérieure. Il n'entraîne pas la
|
||||
suppression des contenus déjà publiés sur la plateforme, qui doivent être supprimés depuis
|
||||
celle-ci.
|
||||
</p>
|
||||
|
||||
<h2>8. Vos droits</h2>
|
||||
<p>
|
||||
Conformément au Règlement général sur la protection des données, vous disposez d'un droit
|
||||
d'accès, de rectification, d'effacement, de limitation et de portabilité des données vous
|
||||
concernant, ainsi que du droit d'introduire une réclamation auprès de la CNIL. Pour exercer ces
|
||||
droits, écrivez à ${escapeHtml(company.email)}.
|
||||
</p>
|
||||
|
||||
<h2>9. Modification de cette politique</h2>
|
||||
<p>
|
||||
Cette politique peut être mise à jour. La date de dernière mise à jour figure en tête de page.
|
||||
</p>
|
||||
|
||||
${renderFooter()}`;
|
||||
|
||||
res.type('html').send(renderPage('Politique de confidentialité', body));
|
||||
});
|
||||
@@ -1,470 +0,0 @@
|
||||
/**
|
||||
* Routes API pour les Reels Facebook et la musique Jamendo
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import type { User } from '@shared/schema';
|
||||
import { storage } from '../storage';
|
||||
import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg';
|
||||
import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
|
||||
import { srtText } from '@shared/srt';
|
||||
import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue';
|
||||
import {
|
||||
qwenTargetFromConfig,
|
||||
resolveGeminiApiKey,
|
||||
resolveLogoPath,
|
||||
resolveQwenTarget,
|
||||
resolveStoreName,
|
||||
} from '../services/reels/assets';
|
||||
import { configuredRenderer } from '../services/reels/videoPipeline';
|
||||
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
||||
|
||||
import { estimateVoiceTiming } from '../services/ttsSync';
|
||||
/** Piste musicale telle qu'attendue par le client. */
|
||||
interface MusicTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
albumName: string;
|
||||
duration: number;
|
||||
previewUrl: string;
|
||||
downloadUrl: string;
|
||||
imageUrl: string;
|
||||
license: string;
|
||||
}
|
||||
|
||||
export const reelsRouter = Router();
|
||||
|
||||
// ============================================
|
||||
// ROUTES MUSIQUE JAMENDO
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Recherche de musiques par durée
|
||||
* GET /api/music/search?minDuration=10&maxDuration=60&genre=pop&limit=10
|
||||
*/
|
||||
reelsRouter.get('/music/search', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
minDuration = '10',
|
||||
maxDuration = '120',
|
||||
genre,
|
||||
limit = '10',
|
||||
offset = '0',
|
||||
search,
|
||||
} = req.query;
|
||||
|
||||
res.json({
|
||||
tracks: [],
|
||||
pagination: {
|
||||
offset: parseInt(offset as string),
|
||||
limit: parseInt(limit as string),
|
||||
hasMore: false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error searching music:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la recherche de musique' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Voir plus de musiques (pagination)
|
||||
* GET /api/music/more?offset=10&minDuration=10&maxDuration=60
|
||||
*/
|
||||
reelsRouter.get('/music/more', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
minDuration = '10',
|
||||
maxDuration = '120',
|
||||
genre,
|
||||
limit = '10',
|
||||
offset = '0',
|
||||
search,
|
||||
} = req.query;
|
||||
|
||||
res.json({
|
||||
tracks: [],
|
||||
pagination: {
|
||||
offset: parseInt(offset as string),
|
||||
limit: parseInt(limit as string),
|
||||
hasMore: false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading more music:', error);
|
||||
res.status(500).json({ error: 'Erreur lors du chargement des musiques' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtenir les tracks populaires
|
||||
* GET /api/music/popular
|
||||
*/
|
||||
reelsRouter.get('/music/popular', async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ tracks: [] });
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching popular music:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la récupération des musiques populaires' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// ROUTES FAVORIS MUSIQUE
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Obtenir les favoris de l'utilisateur
|
||||
* GET /api/music/favorites
|
||||
*/
|
||||
reelsRouter.get('/music/favorites', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const favorites = await storage.getMusicFavorites(user.id);
|
||||
|
||||
// Convertir en format MusicTrack pour le client
|
||||
const tracks: MusicTrack[] = favorites.map(f => ({
|
||||
id: f.trackId,
|
||||
title: f.title,
|
||||
artist: f.artist,
|
||||
albumName: f.albumName || '',
|
||||
duration: f.duration,
|
||||
previewUrl: f.previewUrl,
|
||||
downloadUrl: f.downloadUrl,
|
||||
imageUrl: f.imageUrl || '',
|
||||
license: f.license || '',
|
||||
}));
|
||||
|
||||
res.json({ tracks, isFavorites: true });
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching music favorites:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la récupération des favoris' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Vérifier si un track est en favori
|
||||
* GET /api/music/favorites/check/:trackId
|
||||
*/
|
||||
reelsRouter.get('/music/favorites/check/:trackId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const { trackId } = req.params;
|
||||
|
||||
const isFavorite = await storage.isMusicFavorite(user.id, trackId);
|
||||
res.json({ isFavorite });
|
||||
} catch (error) {
|
||||
console.error('❌ Error checking music favorite:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la vérification du favori' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Ajouter un favori
|
||||
* POST /api/music/favorites
|
||||
*/
|
||||
reelsRouter.post('/music/favorites', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const { trackId, title, artist, albumName, duration, previewUrl, downloadUrl, imageUrl, license } = req.body;
|
||||
|
||||
if (!trackId || !title) {
|
||||
return res.status(400).json({ error: 'trackId et title requis' });
|
||||
}
|
||||
|
||||
// Vérifier si déjà en favori
|
||||
const alreadyFavorite = await storage.isMusicFavorite(user.id, trackId);
|
||||
if (alreadyFavorite) {
|
||||
return res.json({ success: true, message: 'Déjà en favori' });
|
||||
}
|
||||
|
||||
await storage.addMusicFavorite({
|
||||
userId: user.id,
|
||||
trackId,
|
||||
title,
|
||||
artist: artist || '',
|
||||
albumName: albumName || null,
|
||||
duration: duration || 0,
|
||||
previewUrl: previewUrl || '',
|
||||
downloadUrl: downloadUrl || '',
|
||||
imageUrl: imageUrl || null,
|
||||
license: license || null,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('❌ Error adding music favorite:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de l\'ajout du favori' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Supprimer un favori
|
||||
* DELETE /api/music/favorites/:trackId
|
||||
*/
|
||||
reelsRouter.delete('/music/favorites/:trackId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const { trackId } = req.params;
|
||||
|
||||
await storage.removeMusicFavorite(user.id, trackId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('❌ Error removing music favorite:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la suppression du favori' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// ROUTE DÉTAILS TRACK (après les routes /music/favorites et /music/popular)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Détails d'un track
|
||||
* GET /api/music/:trackId
|
||||
*/
|
||||
reelsRouter.get('/music/:trackId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { trackId } = req.params;
|
||||
return res.status(404).json({ error: 'Musique non trouvée' });
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching music details:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la récupération des détails' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// ROUTES REELS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Générer des textes IA pour un Reel (utilise le même service que les posts)
|
||||
* POST /api/reels/generate-text
|
||||
*/
|
||||
reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const { productInfo, model } = req.body;
|
||||
|
||||
if (!productInfo) {
|
||||
return res.status(400).json({ error: 'Informations produit requises' });
|
||||
}
|
||||
|
||||
// Convertir la chaîne de texte simple en objet ProductInfo
|
||||
// Si l'utilisateur envoie une chaîne, on l'utilise comme description du produit
|
||||
const productInfoObject = typeof productInfo === 'string'
|
||||
? {
|
||||
name: productInfo.trim(), // Utiliser le texte comme nom
|
||||
description: productInfo.trim(), // Et comme description
|
||||
}
|
||||
: productInfo;
|
||||
|
||||
const generatedTexts = await openRouterService.generatePostText(productInfoObject, user.id, model);
|
||||
|
||||
// Sauvegarder la génération
|
||||
await storage.createAiGeneration({
|
||||
userId: user.id,
|
||||
productInfo: productInfoObject,
|
||||
generatedTexts,
|
||||
});
|
||||
|
||||
res.json({ variants: generatedTexts });
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating Reel text:', error);
|
||||
const { status, message } = describeGenerationError(error);
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Voix disponibles (dont les voix clonées Qwen) et disponibilité du moteur local
|
||||
* GET /api/reels/voices
|
||||
*/
|
||||
reelsRouter.get('/reels/voices', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const catalog = await ffmpegService.listVoices(qwenTargetFromConfig(await storage.getAppConfig()));
|
||||
res.json({ qwenAvailable: catalog.qwen_available, qwen: catalog.qwen, qwenStatus: catalog.qwen_status });
|
||||
} catch (error) {
|
||||
console.error('❌ Error listing voices:', error);
|
||||
res.json({ qwenAvailable: false, qwen: [], qwenStatus: null });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Prévisualiser la voix TTS
|
||||
* POST /api/reels/tts-preview
|
||||
*/
|
||||
reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
|
||||
const parsed = ttsPreviewSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Paramètres invalides' });
|
||||
}
|
||||
const { text, ttsVoice, ttsEngine, ttsStyle } = parsed.data;
|
||||
|
||||
try {
|
||||
const preview = await ffmpegService.previewVoice(text, {
|
||||
voice: ttsVoice,
|
||||
engine: ttsEngine,
|
||||
style: ttsStyle,
|
||||
geminiApiKey: await resolveGeminiApiKey(ttsEngine),
|
||||
qwen: await resolveQwenTarget(ttsEngine),
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
audioBase64: preview.audio.toString('base64'),
|
||||
duration: preview.duration,
|
||||
words: preview.words,
|
||||
engine: preview.engine,
|
||||
voice: preview.voice,
|
||||
warnings: preview.warnings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating TTS preview:', error);
|
||||
const message = error instanceof Error ? error.message : 'Erreur lors de la génération de la voix';
|
||||
res.status(error instanceof FFmpegServiceError && error.status === 400 ? 400 : 502).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculer la synchronisation texte/voix TTS
|
||||
* POST /api/reels/sync-info
|
||||
*/
|
||||
reelsRouter.post('/reels/sync-info', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { text } = req.body;
|
||||
if (!text) {
|
||||
return res.status(400).json({ error: 'Texte requis' });
|
||||
}
|
||||
// Estimation locale : ne déclenche aucune synthèse (payante avec Gemini)
|
||||
res.json(estimateVoiceTiming(text));
|
||||
} catch (error) {
|
||||
console.error('❌ Error calculating sync:', error);
|
||||
res.status(500).json({ error: 'Erreur de calcul de synchronisation' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Créer et publier un Reel : le rendu part dans la file persistante.
|
||||
* POST /api/reels
|
||||
*/
|
||||
reelsRouter.post('/reels', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const parsed = videoReelParamsSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Paramètres invalides' });
|
||||
}
|
||||
|
||||
const { srtCues } = parsed.data;
|
||||
const params: VideoReelParams = {
|
||||
...parsed.data,
|
||||
// Avec un SRT, le texte libre n'est plus utilisé : la voix lit les sous-titres
|
||||
overlayText: srtCues ? undefined : parsed.data.overlayText,
|
||||
description: parsed.data.description || (srtCues ? srtText(srtCues) : undefined),
|
||||
storeName: await resolveStoreName(user.id, parsed.data.pageIds[0]),
|
||||
};
|
||||
|
||||
const waiting = await countActiveReelJobs();
|
||||
|
||||
const post = await storage.createPost({
|
||||
userId: user.id,
|
||||
content: params.description || params.overlayText || '',
|
||||
aiGenerated: 'false',
|
||||
status: params.scheduledFor ? 'scheduled' : 'draft',
|
||||
scheduledFor: params.scheduledFor ? new Date(params.scheduledFor) : undefined,
|
||||
generationStatus: 'pending',
|
||||
generationProgress: 0,
|
||||
});
|
||||
|
||||
await enqueueReelJob({ kind: 'video', userId: user.id, postId: post.id, params });
|
||||
console.log(`✨ Reel en file. Post ${post.id}, ${waiting} job(s) avant lui.`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
postId: post.id,
|
||||
queued: waiting > 0,
|
||||
message: waiting > 0
|
||||
? "File d'attente occupée. Votre vidéo sera traitée dès que possible."
|
||||
: 'Traitement démarré en arrière-plan.',
|
||||
results: [],
|
||||
videoUrl: '',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error initiating Reel:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur lors de l\'initialisation du Reel',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtenir la configuration des services Reels
|
||||
* GET /api/reels/config
|
||||
*/
|
||||
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const [ffmpegConfigured, logoUrl] = await Promise.all([
|
||||
ffmpegService.healthCheck().catch(() => false),
|
||||
resolveLogoPath(),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
jamendo: { configured: false },
|
||||
freesound: { configured: false },
|
||||
ffmpeg: { configured: ffmpegConfigured },
|
||||
// Aperçu : logo affiché par le lecteur, moteur de rendu utilisé
|
||||
logoUrl: logoUrl ?? null,
|
||||
renderer: configuredRenderer(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching Reels config:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la récupération de la configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Reels en cours de génération
|
||||
* GET /api/reels/ongoing
|
||||
*/
|
||||
reelsRouter.get('/reels/ongoing', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const ongoing = await storage.getOngoingReelPosts(user.id);
|
||||
res.json(ongoing);
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching ongoing reels:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la récupération des reels en cours' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Supprimer un Reel
|
||||
* DELETE /api/reels/:id
|
||||
*/
|
||||
reelsRouter.delete('/reels/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const { id } = req.params;
|
||||
|
||||
// Vérifier si le post existe
|
||||
const post = await storage.getPost(id);
|
||||
if (!post) {
|
||||
return res.status(404).json({ error: 'Reel non trouvé' });
|
||||
}
|
||||
|
||||
// Vérifier les permissions (admin ou propriétaire)
|
||||
if (user.role !== 'admin' && post.userId !== user.id) {
|
||||
return res.status(403).json({ error: 'Non autorisé à supprimer ce Reel' });
|
||||
}
|
||||
|
||||
// Supprimer le post (cascade supprimera scheduled_posts et liens media)
|
||||
await storage.deletePost(id);
|
||||
|
||||
res.json({ success: true, message: 'Reel supprimé avec succès' });
|
||||
} catch (error) {
|
||||
console.error('❌ Error deleting Reel:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la suppression du Reel' });
|
||||
}
|
||||
});
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* 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',
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user