feat: add external API for publishing posts with image URL and scheduling

- New POST /api/v1/publish endpoint: download image from URL, create post and schedule it per page
- New GET /api/v1/pages endpoint: list available pages for external callers
- API key auth via X-API-Key header, configurable from admin settings (stored in DB)
- New app_config table (migration included) to store the external API key
- Admin-only settings section (desktop + mobile) to set/revoke the key
- Fallback to EXTERNAL_API_KEY env var if no DB key is configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-05-06 13:12:27 +02:00
1 parent 9e6fe9fe31
commit 391acfdb83
9 files changed
+539 -2

No files matched your search

+7
View File
@@ -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
+87 -1
View File
@@ -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<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(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() {
</CardContent>
</Card>
)}
{/* API externe */}
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<Plug className="w-5 h-5" />
API externe
</CardTitle>
<CardDescription className="text-xs">
Clé d'accès pour créer et programmer des publications via l'API externe
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-5 pt-0">
{hasExistingExternalApiConfig && (
<div className="rounded-lg bg-muted p-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<Key className="w-4 h-4" />
<span>Clé active</span>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => deleteExternalApiMutation.mutate()}
disabled={deleteExternalApiMutation.isPending}
>
{deleteExternalApiMutation.isPending ? "..." : "Révoquer"}
</Button>
</div>
)}
<div className="space-y-2">
<Label htmlFor="externalApiKeyMobile">
{hasExistingExternalApiConfig ? "Nouvelle clé (remplace l'actuelle)" : "Clé API"}
</Label>
<Input
id="externalApiKeyMobile"
type="password"
value={externalApiKey}
onChange={(e) => setExternalApiKey(e.target.value)}
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé..." : "Entrez une clé sécurisée"}
className="min-h-[48px]"
/>
<p className="text-xs text-muted-foreground">
Header <code className="bg-muted px-1 rounded">X-API-Key</code> dans vos requêtes
</p>
</div>
<Button
onClick={() => saveExternalApiMutation.mutate()}
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
className="w-full min-h-[48px]"
>
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
</Button>
</CardContent>
</Card>
)}
</div>
</main>
</div>
+86 -1
View File
@@ -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<string | null>(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() {
</Card>
)}
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<Plug className="w-5 h-5" />
API externe
</CardTitle>
<CardDescription>
Clé d'accès pour créer et programmer des publications via l'API externe (<code className="text-xs bg-muted px-1 rounded">POST /api/v1/publish</code>)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{hasExistingExternalApiConfig && (
<div className="rounded-lg bg-muted p-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<Key className="w-4 h-4" />
<span>Clé API configurée et active</span>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => deleteExternalApiMutation.mutate()}
disabled={deleteExternalApiMutation.isPending}
>
{deleteExternalApiMutation.isPending ? "Suppression..." : "Révoquer"}
</Button>
</div>
)}
<div className="space-y-2">
<Label htmlFor="externalApiKey">
{hasExistingExternalApiConfig ? "Nouvelle clé API (remplace l'actuelle)" : "Clé API"}
</Label>
<Input
id="externalApiKey"
type="password"
value={externalApiKey}
onChange={(e) => setExternalApiKey(e.target.value)}
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé pour remplacer l'actuelle" : "Générez une clé sécurisée"}
/>
<p className="text-xs text-muted-foreground">
Utilisez cette clé dans le header <code className="bg-muted px-1 rounded">X-API-Key</code> de vos requêtes
</p>
</div>
<Button
onClick={() => saveExternalApiMutation.mutate()}
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
className="w-full"
>
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
</Button>
</CardContent>
</Card>
)}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
+28
View File
@@ -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" });
}
}
+10
View File
@@ -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);
+39
View File
@@ -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<Server> {
}
});
// 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);
+248
View File
@@ -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<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: 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 };
+25
View File
@@ -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<FreesoundConfig | undefined>;
createFreesoundConfig(config: InsertFreesoundConfig): Promise<FreesoundConfig>;
updateFreesoundConfig(userId: string, config: Partial<InsertFreesoundConfig>): Promise<FreesoundConfig>;
// App Config
getAppConfig(): Promise<AppConfig | undefined>;
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey'>>): Promise<AppConfig>;
}
export class DatabaseStorage implements IStorage {
@@ -584,6 +590,25 @@ export class DatabaseStorage implements IStorage {
.returning();
return updated;
}
// App Config
async getAppConfig(): Promise<AppConfig | undefined> {
const [config] = await db.select().from(appConfig).limit(1);
return config || undefined;
}
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey'>>): Promise<AppConfig> {
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();
+9
View File
@@ -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<typeof insertFreesoundConfigSchema>;
export type UpdateFreesoundConfig = z.infer<typeof updateFreesoundConfigSchema>;