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

+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();