diff --git a/.env.example b/.env.example index dd2a092..cfca62a 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,10 @@ MINIO_PUBLIC_URL=https://media.example.com # Optionnel: Configuration Instagram API # INSTAGRAM_APP_ID= # INSTAGRAM_APP_SECRET= + +# ========================================== +# API EXTERNE +# ========================================== +# Clé d'accès pour l'API externe /api/v1/* +# Générer avec: openssl rand -hex 32 +EXTERNAL_API_KEY=your-secure-random-key-here diff --git a/client/src/pages/mobile/settings.tsx b/client/src/pages/mobile/settings.tsx index 2efe65a..e60fe13 100644 --- a/client/src/pages/mobile/settings.tsx +++ b/client/src/pages/mobile/settings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Settings as SettingsIcon, Bell, Brain, Cloud, Image, Upload, X } from "lucide-react"; +import { Settings as SettingsIcon, Bell, Brain, Cloud, Image, Upload, X, Plug, Key } from "lucide-react"; import { useQuery, useMutation } from "@tanstack/react-query"; import Sidebar from "@/components/sidebar"; import TopBar from "@/components/topbar"; @@ -27,6 +27,7 @@ export default function SettingsMobile() { const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."); const [logoFile, setLogoFile] = useState(null); const [logoPreview, setLogoPreview] = useState(null); + const [externalApiKey, setExternalApiKey] = useState(""); const { toast } = useToast(); const { data: session } = useQuery<{ id: string; username: string; role: string }>({ @@ -70,6 +71,36 @@ export default function SettingsMobile() { const hasExistingConfig = !!openrouterConfig; + const { data: externalApiConfig } = useQuery({ + queryKey: ['/api/settings/external-api'], + enabled: isAdmin, + }); + + const hasExistingExternalApiConfig = !!(externalApiConfig as any)?.configured; + + const saveExternalApiMutation = useMutation({ + mutationFn: () => apiRequest('POST', '/api/settings/external-api', { apiKey: externalApiKey }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] }); + setExternalApiKey(""); + toast({ title: "Clé API sauvegardée", description: "La clé API externe a été enregistrée" }); + }, + onError: () => { + toast({ title: "Erreur", description: "Impossible de sauvegarder la clé API", variant: "destructive" }); + }, + }); + + const deleteExternalApiMutation = useMutation({ + mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] }); + toast({ title: "Clé API supprimée", description: "L'accès à l'API externe a été révoqué" }); + }, + onError: () => { + toast({ title: "Erreur", description: "Impossible de supprimer la clé API", variant: "destructive" }); + }, + }); + const saveCloudinaryMutation = useMutation({ mutationFn: () => { const payload: any = { @@ -489,6 +520,61 @@ export default function SettingsMobile() { )} + {/* API externe */} + {isAdmin && ( + + + + + API externe + + + Clé d'accès pour créer et programmer des publications via l'API externe + + + + {hasExistingExternalApiConfig && ( +
+
+ + Clé active +
+ +
+ )} +
+ + setExternalApiKey(e.target.value)} + placeholder={hasExistingExternalApiConfig ? "Nouvelle clé..." : "Entrez une clé sécurisée"} + className="min-h-[48px]" + /> +

+ Header X-API-Key dans vos requêtes +

+
+ +
+
+ )} diff --git a/client/src/pages/settings.tsx b/client/src/pages/settings.tsx index b9593e4..e0b43c2 100644 --- a/client/src/pages/settings.tsx +++ b/client/src/pages/settings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video } from "lucide-react"; +import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug } from "lucide-react"; import { useQuery, useMutation } from "@tanstack/react-query"; import Sidebar from "@/components/sidebar"; import TopBar from "@/components/topbar"; @@ -29,6 +29,7 @@ export default function Settings() { const [logoPreview, setLogoPreview] = useState(null); const [ffmpegApiUrl, setFfmpegApiUrl] = useState(""); const [ffmpegApiKey, setFfmpegApiKey] = useState(""); + const [externalApiKey, setExternalApiKey] = useState(""); const { toast } = useToast(); const { data: session } = useQuery<{ id: string; username: string; role: string }>({ @@ -54,6 +55,11 @@ export default function Settings() { queryKey: ['/api/ffmpeg/config'], }); + const { data: externalApiConfig } = useQuery({ + queryKey: ['/api/settings/external-api'], + enabled: isAdmin, + }); + useEffect(() => { if (cloudinaryConfig) { setCloudName((cloudinaryConfig as any).cloudName || ""); @@ -251,6 +257,31 @@ export default function Settings() { }, }); + const hasExistingExternalApiConfig = !!(externalApiConfig as any)?.configured; + + const saveExternalApiMutation = useMutation({ + mutationFn: () => apiRequest('POST', '/api/settings/external-api', { apiKey: externalApiKey }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] }); + setExternalApiKey(""); + toast({ title: "Clé API sauvegardée", description: "La clé API externe a été enregistrée" }); + }, + onError: () => { + toast({ title: "Erreur", description: "Impossible de sauvegarder la clé API", variant: "destructive" }); + }, + }); + + const deleteExternalApiMutation = useMutation({ + mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] }); + toast({ title: "Clé API supprimée", description: "L'accès à l'API externe a été révoqué" }); + }, + onError: () => { + toast({ title: "Erreur", description: "Impossible de supprimer la clé API", variant: "destructive" }); + }, + }); + const saveFfmpegMutation = useMutation({ mutationFn: () => { const payload: any = { @@ -689,6 +720,60 @@ export default function Settings() { )} + {isAdmin && ( + + + + + API externe + + + Clé d'accès pour créer et programmer des publications via l'API externe (POST /api/v1/publish) + + + + {hasExistingExternalApiConfig && ( +
+
+ + Clé API configurée et active +
+ +
+ )} +
+ + setExternalApiKey(e.target.value)} + placeholder={hasExistingExternalApiConfig ? "Nouvelle clé pour remplacer l'actuelle" : "Générez une clé sécurisée"} + /> +

+ Utilisez cette clé dans le header X-API-Key de vos requêtes +

+
+ +
+
+ )} + diff --git a/server/middleware/apiKey.ts b/server/middleware/apiKey.ts new file mode 100644 index 0000000..d01aa0a --- /dev/null +++ b/server/middleware/apiKey.ts @@ -0,0 +1,28 @@ +import type { Request, Response, NextFunction } from "express"; +import { storage } from "../storage"; + +export async function requireApiKey(req: Request, res: Response, next: NextFunction) { + const provided = req.headers["x-api-key"]; + + if (!provided) { + return res.status(401).json({ error: "Clé API manquante (header X-API-Key requis)" }); + } + + try { + const config = await storage.getAppConfig(); + const key = config?.externalApiKey || process.env.EXTERNAL_API_KEY; + + if (!key) { + return res.status(503).json({ error: "API externe non configurée" }); + } + + if (provided !== key) { + return res.status(401).json({ error: "Clé API invalide" }); + } + + next(); + } catch (error) { + console.error("[apiKey middleware] Error:", error); + return res.status(500).json({ error: "Erreur interne lors de la vérification de la clé API" }); + } +} diff --git a/server/migrate.ts b/server/migrate.ts index 6aedd8d..eca3cb5 100644 --- a/server/migrate.ts +++ b/server/migrate.ts @@ -142,6 +142,16 @@ export async function migrate() { ); `); + // app_config (global application settings) + await client.query(` + CREATE TABLE IF NOT EXISTS "app_config" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "external_api_key" text, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() + ); + `); + console.log("[Migration] Safe migration completed."); } catch (error) { console.error("[Migration] Error during migration:", error); diff --git a/server/routes.ts b/server/routes.ts index f6aea76..8ef0b7e 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -16,6 +16,7 @@ import { freeSoundService } from "./services/freesound"; import { analyticsRouter } from "./routes/analytics"; import { reelsRouter } from "./routes/reels"; import { remotionRouter } from "./routes/remotion"; +import { externalRouter } from "./routes/external"; import { insertAudioTrackSchema } from "@shared/schema"; import * as musicMetadata from "music-metadata"; @@ -382,6 +383,44 @@ export async function registerRoutes(app: Express): Promise { } }); + // External API (clé API, pas de session) + app.use("/api/v1", externalRouter); + + // Paramètres de l'API externe (admin seulement) + app.get("/api/settings/external-api", requireAdmin, async (req, res) => { + try { + const config = await storage.getAppConfig(); + res.json({ configured: !!(config?.externalApiKey) }); + } catch (error) { + console.error("Error fetching app config:", error); + res.status(500).json({ error: "Erreur lors de la récupération de la configuration" }); + } + }); + + app.post("/api/settings/external-api", requireAdmin, async (req, res) => { + try { + const { apiKey } = req.body; + if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") { + return res.status(400).json({ error: "La clé API est requise" }); + } + await storage.upsertAppConfig({ externalApiKey: apiKey.trim() }); + res.json({ success: true, configured: true }); + } catch (error) { + console.error("Error saving app config:", error); + res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" }); + } + }); + + app.delete("/api/settings/external-api", requireAdmin, async (req, res) => { + try { + await storage.upsertAppConfig({ externalApiKey: null }); + res.json({ success: true, configured: false }); + } catch (error) { + console.error("Error deleting app config:", error); + res.status(500).json({ error: "Erreur lors de la suppression de la clé" }); + } + }); + // Analytics Routes app.use("/api/analytics", requireAuth, analyticsRouter); diff --git a/server/routes/external.ts b/server/routes/external.ts new file mode 100644 index 0000000..172b47e --- /dev/null +++ b/server/routes/external.ts @@ -0,0 +1,248 @@ +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 = { + "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: + * + * 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: String(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" }); + } +}); + +export { router as externalRouter }; diff --git a/server/storage.ts b/server/storage.ts index f3eea0f..e2cf15e 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -37,6 +37,8 @@ import { audioTracks, type AudioTrack, type InsertAudioTrack, + appConfig, + type AppConfig, } from "@shared/schema"; import { db } from "./db"; import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm"; @@ -126,6 +128,10 @@ export interface IStorage { getFreesoundConfig(userId: string): Promise; createFreesoundConfig(config: InsertFreesoundConfig): Promise; updateFreesoundConfig(userId: string, config: Partial): Promise; + + // App Config + getAppConfig(): Promise; + upsertAppConfig(data: Partial>): Promise; } export class DatabaseStorage implements IStorage { @@ -584,6 +590,25 @@ export class DatabaseStorage implements IStorage { .returning(); return updated; } + + // App Config + async getAppConfig(): Promise { + const [config] = await db.select().from(appConfig).limit(1); + return config || undefined; + } + + async upsertAppConfig(data: Partial>): Promise { + const existing = await this.getAppConfig(); + if (existing) { + const [updated] = await db.update(appConfig) + .set({ ...data, updatedAt: new Date() }) + .where(eq(appConfig.id, existing.id)) + .returning(); + return updated; + } + const [created] = await db.insert(appConfig).values(data).returning(); + return created; + } } export const storage = new DatabaseStorage(); diff --git a/shared/schema.ts b/shared/schema.ts index 53dcd6f..026f3a4 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -215,6 +215,13 @@ export const musicFavorites = pgTable("music_favorites", { createdAt: timestamp("created_at").defaultNow(), }); +export const appConfig = pgTable("app_config", { + id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), + externalApiKey: text("external_api_key"), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}); + export const audioTracks = pgTable("audio_tracks", { id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), userId: varchar("user_id").references(() => users.id, { onDelete: "cascade" }), // Nullable if uploaded by system/admin globally @@ -477,6 +484,8 @@ export const updateFreesoundConfigSchema = insertFreesoundConfigSchema.omit({ }); export type FreesoundConfig = typeof freesoundConfig.$inferSelect; + +export type AppConfig = typeof appConfig.$inferSelect; export type InsertFreesoundConfig = z.infer; export type UpdateFreesoundConfig = z.infer;