mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-16 11:49:30 +02:00
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:
1 parent
9e6fe9fe31
commit
391acfdb83
9 files changed
+539
-2
No files matched your search
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user