Compare commits

..
Author SHA1 Message Date
Claude 086ef956e2 Reels : option « Publier aussi en story » sur Facebook
Une case sur les écrans de création (bureau, mobile) et de programmation
d'un Reel double la publication d'une story vidéo sur les pages Facebook
sélectionnées. TikTok n'ayant pas de stories, l'option ne le concerne pas.

- Publication immédiate : la story part juste après le Reel, à partir de la
  vidéo rendue envoyée en binaire (nouvelle publishVideoStoryFromBuffer).
  Un échec de la story est noté dans l'historique sans faire échouer le Reel.
- Publication programmée : une entrée « story » est planifiée à côté du
  Reel ; le planificateur la publie comme les autres stories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPMYHUDaZyrG3xRCM5iGVr
2026-10-05 06:50:33 +00:00
16 changed files with 256 additions and 602 deletions

No files matched your search

+6 -40
View File
@@ -945,42 +945,8 @@ POST /api/analytics/tokens/check
Ces routes sont optimisées pour les appels de scripts d'intégration tierce.
Toutes les requêtes doivent contenir le header `X-API-Key`.
Les requêtes JSON sous `/api/v1` peuvent peser jusqu'à 15 Mo (contre 100 Ko ailleurs), pour transporter une image en base64.
### Envoyer une image dans la médiathèque
Envoie une image directement, sans hébergeur public intermédiaire. L'`id` renvoyé se passe en `mediaId` à `/publish` ou à `PATCH /posts/:id`.
Formats acceptés : JPEG, PNG, WebP, GIF (reconnus par leur contenu, pas par l'extension) — 10 Mo maximum.
```http
POST /api/v1/media
```
**Body (multipart/form-data) :** champ `file` contenant l'image.
```bash
curl -X POST https://socialflow.example/api/v1/media \
-H "X-API-Key: $SOCIALFLOW_API_KEY" \
-F "file=@galette-chaise.jpg"
```
**Ou body (JSON) :** l'image en base64, brute ou en data URL.
```json
{ "imageData": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." }
```
**Réponse (201 Created) :**
```json
{
"id": "med_ext_123",
"url": "/uploads/media/external-1791141548234.jpg",
"type": "image",
"fileName": "external-1791141548234.jpg",
"fileSize": 2483120
}
```
**Erreurs :** `400` (aucune image, format non reconnu), `401` (clé absente ou invalide), `413` (image de plus de 10 Mo).
### Publier un message avec image
Crée une publication et programme sa publication. L'image peut être fournie de quatre façons (une seule par requête) :
- `mediaId` : image déjà envoyée via `POST /api/v1/media` ;
- `imageData` : image en base64 (ou data URL) ;
- `imageUrl` : URL publique que Socialflow télécharge lui-même ;
- en multipart/form-data, un fichier dans le champ `image` (les autres champs en texte, `pageIds` en tableau JSON ou séparés par des virgules).
### Publier un message avec téléchargement d'image
Crée une publication en téléchargeant automatiquement l'image depuis une URL publique fournie (ex: depuis votre ERP ou flux e-commerce), et programme sa publication.
```http
POST /api/v1/publish
```
@@ -988,7 +954,7 @@ POST /api/v1/publish
```json
{
"content": "Découvrez cet article exceptionnel !",
"mediaId": "med_ext_123",
"imageUrl": "https://mon-site.com/images/produit.jpg",
"pageIds": ["page_999"],
"scheduledAt": "2026-06-02T08:00:00.000Z",
"postType": "feed"
@@ -1008,8 +974,8 @@ POST /api/v1/publish
{ "pageId": "page_999", "pageName": "Ma Page", "scheduledPostId": "sch_ext_001" }
],
"media": {
"id": "med_ext_123",
"url": "/uploads/media/external-1791141548234.jpg"
"id": "med_ext_999",
"url": "/uploads/media/external-17169.jpg"
}
}
}
@@ -1052,7 +1018,7 @@ PATCH /api/v1/posts/:id
"imageUrl": "https://mon-site.com/images/produit-rectifie.jpg"
}
```
*Note : Tous les champs sont facultatifs. Une nouvelle image (`imageUrl`, `imageData`, `mediaId` ou fichier multipart `image`) remplace le média précédent.*
*Note : Tous les champs sont facultatifs. L'envoi d'une nouvelle URL d'image écrase et remplace le média précédent.*
**Réponse (200 OK) :**
*(Retourne la publication modifiée avec sa liste de planification actualisée).*
@@ -0,0 +1,29 @@
import { Switch } from "@/components/ui/switch";
interface StoryToggleProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}
/**
* Double le Reel d'une story sur les pages Facebook sélectionnées. TikTok n'a
* pas de stories : l'option ne le concerne pas.
*/
export function StoryToggle({ checked, onCheckedChange }: StoryToggleProps) {
return (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/50 p-3">
<label htmlFor="reel-also-story" className="space-y-0.5 cursor-pointer">
<div className="text-sm font-medium">Publier aussi en story</div>
<div className="text-xs text-muted-foreground">
La vidéo est aussi publiée en story sur les pages Facebook sélectionnées (visible 24 h).
</div>
</label>
<Switch
id="reel-also-story"
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
data-testid="switch-reel-also-story"
/>
</div>
);
}
+4
View File
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { StoryToggle } from "@/components/reels/story-toggle";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast";
@@ -83,6 +84,7 @@ export default function MobileNewReel() {
const [isPlaying, setIsPlaying] = useState<string | null>(null);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
const [alsoStory, setAlsoStory] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true);
@@ -272,6 +274,7 @@ export default function MobileNewReel() {
description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(),
alsoStory,
musicVolume: musicVolume[0] / 100,
stabilize: stabilize,
drawText: true,
@@ -610,6 +613,7 @@ export default function MobileNewReel() {
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div>
)}
+4
View File
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { StoryToggle } from "@/components/reels/story-toggle";
import { Slider } from "@/components/ui/slider";
import {
Select,
@@ -78,6 +79,7 @@ export default function NewReel() {
const [drawText, setDrawText] = useState(true);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
const [alsoStory, setAlsoStory] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true);
@@ -415,6 +417,7 @@ export default function NewReel() {
description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(),
alsoStory,
musicVolume: musicVolume[0] / 100,
ttsEnabled,
ttsEngine,
@@ -938,6 +941,7 @@ export default function NewReel() {
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div>
)}
+4
View File
@@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { StoryToggle } from "@/components/reels/story-toggle";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
@@ -30,6 +31,7 @@ export default function ScheduleReel() {
const [description, setDescription] = useState("");
const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [alsoStory, setAlsoStory] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({ queryKey: ["/api/pages"] });
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ["/api/media"] });
@@ -83,6 +85,7 @@ export default function ScheduleReel() {
mediaIds: [selectedVideo!.id],
pageIds: selectedPages,
scheduledFor: scheduledDate!.toISOString(),
alsoStory,
});
return response.json();
},
@@ -250,6 +253,7 @@ export default function ScheduleReel() {
<SiFacebook className="w-4 h-4 text-[#1877F2]" /> Pages Facebook
</Label>
{facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div>
)}
{tiktokAccounts.length > 0 && (
+5 -9
View File
@@ -1,5 +1,4 @@
import express, { type Request, Response, NextFunction } from "express";
import type { IncomingMessage } from "http";
import path from "path";
import fs from "fs";
import session from "express-session";
@@ -88,14 +87,11 @@ declare module 'http' {
rawBody: unknown
}
}
const keepRawBody = (req: IncomingMessage, _res: unknown, buf: Buffer) => {
req.rawBody = buf;
};
// L'API externe accepte des images en base64 (imageData) : 10 MB d'image font
// ~13,4 MB une fois encodés. Ce parseur passe avant le parseur global (100 KB),
// qui ignore ensuite un corps déjà lu.
app.use('/api/v1', express.json({ limit: '15mb', verify: keepRawBody }));
app.use(express.json({ verify: keepRawBody }));
app.use(express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
}
}));
app.use(express.urlencoded({ extended: false }));
// Validation renforcée du SESSION_SECRET
+13 -1
View File
@@ -1268,7 +1268,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
try {
const user = req.user as User;
const userId = user.id;
const { pageIds, postType, mediaId, mediaIds, ...postFields } = req.body;
const { pageIds, postType, mediaId, mediaIds, alsoStory, ...postFields } = req.body;
// Convert mediaIds to standardized format: array of { mediaId, displayOrder }
let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = [];
@@ -1395,6 +1395,18 @@ export async function registerRoutes(app: Express): Promise<Server> {
postType: finalPostType,
scheduledAt,
});
// Reel doublé d'une story : seulement sur Facebook, TikTok n'en a pas
if (finalPostType === 'reel' && alsoStory === true) {
const page = await storage.getSocialPage(pageId);
if (page?.platform === 'facebook') {
await storage.createScheduledPost({
postId: post.id,
pageId,
postType: 'story',
scheduledAt,
});
}
}
}
}
}
-196
View File
@@ -1,196 +0,0 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { AddressInfo } from "net";
import type { Server } from "http";
const API_KEY = "cle-de-test";
const mocks = vi.hoisted(() => ({
storage: {
getAppConfig: vi.fn(),
getAllUsers: vi.fn(),
getUser: vi.fn(),
getSocialPage: vi.fn(),
getMediaById: vi.fn(),
createMedia: vi.fn(),
createPost: vi.fn(),
createScheduledPost: vi.fn(),
},
insertValues: vi.fn(),
uploadMedia: vi.fn(),
}));
vi.mock("../storage", () => ({ storage: mocks.storage }));
vi.mock("../db", () => ({ db: { insert: () => ({ values: mocks.insertValues }) } }));
vi.mock("../services/minio", () => ({ minioService: { uploadMedia: mocks.uploadMedia } }));
const { externalRouter } = await import("./external");
// Un PNG valide (signature) de la taille voulue
function png(size: number): Buffer {
const header = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
return Buffer.concat([header, Buffer.alloc(Math.max(0, size - header.length))]);
}
let server: Server;
let baseUrl: string;
beforeAll(async () => {
// Même montage que server/index.ts : parseur large pour /api/v1, puis global
const app = express();
app.use("/api/v1", express.json({ limit: "15mb" }));
app.use(express.json());
app.use("/api/v1", externalRouter);
server = app.listen(0);
await new Promise((resolve) => server.once("listening", resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1`;
});
afterAll(() => {
server.close();
});
beforeEach(() => {
vi.clearAllMocks();
mocks.storage.getAppConfig.mockResolvedValue({ externalApiKey: API_KEY });
mocks.storage.getAllUsers.mockResolvedValue([{ id: "admin-1", role: "admin" }]);
mocks.storage.getSocialPage.mockImplementation(async (id: string) =>
id === "page-1" ? { id, pageName: "Ma Page" } : undefined
);
mocks.uploadMedia.mockImplementation(async (_buffer: Buffer, fileName: string) => ({
publicId: `media/${fileName}`,
originalUrl: `/uploads/media/${fileName}`,
facebookFeedUrl: null,
instagramFeedUrl: null,
instagramStoryUrl: null,
}));
mocks.storage.createMedia.mockImplementation(async (data: Record<string, unknown>) => ({ id: "media-1", ...data }));
mocks.storage.createPost.mockImplementation(async (data: Record<string, unknown>) => ({ id: "post-1", ...data }));
mocks.storage.createScheduledPost.mockImplementation(async (data: Record<string, unknown>) => ({ id: `sp-${data.postType}`, ...data }));
});
function form(fields: Record<string, string>, file?: { field: string; buffer: Buffer; name: string; type?: string }) {
const body = new FormData();
for (const [key, value] of Object.entries(fields)) body.append(key, value);
if (file) body.append(file.field, new Blob([file.buffer], { type: file.type ?? "image/png" }), file.name);
return body;
}
describe("POST /api/v1/media", () => {
it("refuse sans clé API", async () => {
const res = await fetch(`${baseUrl}/media`, { method: "POST", body: form({}, { field: "file", buffer: png(100), name: "a.png" }) });
expect(res.status).toBe(401);
expect(mocks.uploadMedia).not.toHaveBeenCalled();
});
it("accepte un fichier multipart et le range dans la médiathèque de l'admin", async () => {
const res = await fetch(`${baseUrl}/media`, {
method: "POST",
headers: { "X-API-Key": API_KEY },
body: form({}, { field: "file", buffer: png(3 * 1024 * 1024), name: "galette.png", type: "application/octet-stream" }),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ id: "media-1", type: "image", fileSize: 3 * 1024 * 1024 });
expect(body.url).toMatch(/^\/uploads\/media\/external-\d+\.png$/);
expect(mocks.storage.createMedia).toHaveBeenCalledWith(expect.objectContaining({ userId: "admin-1" }));
});
it("accepte une image base64 bien au-delà de 100 KB", async () => {
const res = await fetch(`${baseUrl}/media`, {
method: "POST",
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ imageData: `data:image/png;base64,${png(2 * 1024 * 1024).toString("base64")}` }),
});
expect(res.status).toBe(201);
expect(mocks.uploadMedia.mock.calls[0][0].length).toBe(2 * 1024 * 1024);
});
it("renvoie 413 au-delà de 10 MB", async () => {
const res = await fetch(`${baseUrl}/media`, {
method: "POST",
headers: { "X-API-Key": API_KEY },
body: form({}, { field: "file", buffer: png(10 * 1024 * 1024 + 1), name: "enorme.png" }),
});
expect(res.status).toBe(413);
expect(mocks.uploadMedia).not.toHaveBeenCalled();
});
it("refuse un fichier qui n'est pas une image", async () => {
const res = await fetch(`${baseUrl}/media`, {
method: "POST",
headers: { "X-API-Key": API_KEY },
body: form({}, { field: "file", buffer: Buffer.from("<html></html>"), name: "page.png" }),
});
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/Format/);
});
it("refuse une requête sans image", async () => {
const res = await fetch(`${baseUrl}/media`, {
method: "POST",
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
});
describe("POST /api/v1/publish", () => {
it("programme un post avec un mediaId déjà envoyé", async () => {
mocks.storage.getMediaById.mockResolvedValue({ id: "media-9", originalUrl: "/uploads/media/x.png", type: "image" });
const res = await fetch(`${baseUrl}/publish`, {
method: "POST",
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ content: "Bonjour", mediaId: "media-9", pageIds: ["page-1"], scheduledAt: "2026-10-05T12:15:00+02:00", postType: "both" }),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.post.media).toEqual({ id: "media-9", url: "/uploads/media/x.png" });
expect(body.post.pages).toHaveLength(2);
expect(mocks.insertValues).toHaveBeenCalledWith({ postId: "post-1", mediaId: "media-9", displayOrder: 0 });
expect(mocks.uploadMedia).not.toHaveBeenCalled();
});
it("accepte l'image en multipart avec pageIds en texte", async () => {
const res = await fetch(`${baseUrl}/publish`, {
method: "POST",
headers: { "X-API-Key": API_KEY },
body: form({ content: "Bonjour", pageIds: "page-1", scheduledAt: "2026-10-05T10:15:00Z" }, { field: "image", buffer: png(500_000), name: "chaise.png" }),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.post.media.id).toBe("media-1");
expect(body.post.pages).toEqual([{ pageId: "page-1", pageName: "Ma Page", scheduledPostId: "sp-feed" }]);
});
it("refuse un mediaId inconnu sans créer de post", async () => {
mocks.storage.getMediaById.mockResolvedValue(undefined);
const res = await fetch(`${baseUrl}/publish`, {
method: "POST",
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ content: "Bonjour", mediaId: "absent", pageIds: ["page-1"] }),
});
expect(res.status).toBe(400);
expect(mocks.storage.createPost).not.toHaveBeenCalled();
});
it("refuse plusieurs sources d'image", async () => {
const res = await fetch(`${baseUrl}/publish`, {
method: "POST",
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ content: "Bonjour", mediaId: "media-9", imageUrl: "https://exemple.fr/a.png", pageIds: ["page-1"] }),
});
expect(res.status).toBe(400);
expect(mocks.storage.createPost).not.toHaveBeenCalled();
});
it("exige une image pour une story", async () => {
const res = await fetch(`${baseUrl}/publish`, {
method: "POST",
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ content: "Bonjour", pageIds: ["page-1"], postType: "story" }),
});
expect(res.status).toBe(400);
});
});
+73 -215
View File
@@ -1,60 +1,20 @@
import { Router, type Request, type Response as ExpressResponse, type NextFunction } from "express";
import { Router } from "express";
import { z } from "zod";
import multer from "multer";
import path from "path";
import { storage } from "../storage";
import { db } from "../db";
import { postMedia, type Media } from "@shared/schema";
import { postMedia } from "@shared/schema";
import { minioService } from "../services/minio";
import { requireApiKey } from "../middleware/apiKey";
import { MAX_EXTERNAL_IMAGE_SIZE, decodeImageData, normalizePageIds, validateImage } from "../services/imageData";
const router = Router();
router.use(requireApiKey);
/** Erreur imputable à la requête : renvoyée telle quelle avec son code HTTP. */
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
// Fichier gardé en mémoire : 10 MB max, le contenu est validé par sa signature
const imageUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_EXTERNAL_IMAGE_SIZE, files: 1 },
});
/**
* Accepte un fichier image en multipart/form-data sous le champ `field`.
* Sans effet sur les requêtes JSON. Les erreurs de multer deviennent des
* réponses JSON explicites au lieu de remonter au gestionnaire global.
*/
function acceptImageFile(field: string) {
const middleware = imageUpload.single(field);
return (req: Request, res: ExpressResponse, next: NextFunction) => {
middleware(req, res, (err: unknown) => {
if (!err) return next();
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
return res.status(413).json({ error: "Image trop volumineuse (max 10 MB)" });
}
if (err.code === "LIMIT_UNEXPECTED_FILE") {
return res.status(400).json({ error: `Fichier attendu dans le champ « ${field} »` });
}
}
const message = err instanceof Error ? err.message : "Requête multipart invalide";
return res.status(400).json({ error: message });
});
};
}
const publishSchema = z.object({
content: z.string().min(1, "Le contenu est requis"),
imageUrl: z.string().url("URL d'image invalide").optional(),
imageData: z.string().min(1).optional(),
mediaId: z.string().min(1).optional(),
pageIds: z.preprocess(normalizePageIds, z.array(z.string()).min(1, "Au moins une page est requise")),
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(),
@@ -99,151 +59,6 @@ async function downloadImage(url: string): Promise<{ buffer: Buffer; ext: string
return { buffer, ext, mimeType: contentType };
}
/** Enregistre une image dans la médiathèque de `ownerId`. */
async function storeImage(buffer: Buffer, ownerId: string, prefix: string): Promise<Media> {
let detected: { mimeType: string; ext: string };
try {
detected = validateImage(buffer);
} catch (error) {
const tooLarge = buffer.length > MAX_EXTERNAL_IMAGE_SIZE;
throw new ApiError(tooLarge ? 413 : 400, error instanceof Error ? error.message : "Image invalide");
}
const fileName = `${prefix}-${Date.now()}${detected.ext}`;
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, detected.mimeType);
return storage.createMedia({
userId: ownerId,
type: "image",
cloudinaryPublicId: uploaded.publicId,
originalUrl: uploaded.originalUrl,
facebookFeedUrl: uploaded.facebookFeedUrl,
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
fileName,
fileSize: buffer.length,
});
}
interface ImageSources {
file?: Express.Multer.File;
imageUrl?: string;
imageData?: string;
mediaId?: string;
}
function countImageSources({ file, imageUrl, imageData, mediaId }: ImageSources): number {
return [file, imageUrl, imageData, mediaId].filter((source) => source !== undefined).length;
}
/**
* Résout l'image d'une publication, quelle que soit la façon dont elle a été
* transmise : fichier multipart, base64 (`imageData`), média déjà envoyé via
* POST /api/v1/media (`mediaId`) ou URL publique à télécharger (`imageUrl`).
*/
async function resolveImage(sources: ImageSources, ownerId: string, prefix: string): Promise<Media | null> {
if (countImageSources(sources) > 1) {
throw new ApiError(400, "Une seule source d'image à la fois : fichier, imageData, mediaId ou imageUrl");
}
for (const key of ["imageUrl", "imageData", "mediaId"] as const) {
if (sources[key] !== undefined && typeof sources[key] !== "string") {
throw new ApiError(400, `${key} doit être une chaîne`);
}
}
if (sources.mediaId !== undefined) {
const media = await storage.getMediaById(sources.mediaId);
if (!media) {
throw new ApiError(400, `Média introuvable: ${sources.mediaId}`);
}
return media;
}
if (sources.file) {
return storeImage(sources.file.buffer, ownerId, prefix);
}
if (sources.imageData !== undefined) {
let buffer: Buffer;
try {
buffer = decodeImageData(sources.imageData);
} catch (error) {
throw new ApiError(400, error instanceof Error ? error.message : "imageData invalide");
}
return storeImage(buffer, ownerId, prefix);
}
if (sources.imageUrl !== undefined) {
const { buffer } = await downloadImage(sources.imageUrl);
return storeImage(buffer, ownerId, prefix);
}
return null;
}
/** Propriétaire des médias et posts créés : `userId` fourni, sinon le premier admin. */
async function resolveOwnerId(userId: string | undefined): Promise<string> {
if (userId) {
const user = await storage.getUser(userId);
if (!user) {
throw new ApiError(400, `Utilisateur introuvable: ${userId}`);
}
return userId;
}
const users = await storage.getAllUsers();
const admin = users.find((u) => u.role === "admin");
if (!admin) {
throw new ApiError(500, "Aucun utilisateur admin trouvé");
}
return admin.id;
}
function sendError(res: ExpressResponse, error: unknown, context: string) {
if (error instanceof ApiError) {
return res.status(error.status).json({ error: error.message });
}
console.error(`[external API] ${context} error:`, error);
const message = error instanceof Error ? error.message : "Erreur interne";
return res.status(500).json({ error: message });
}
/**
* POST /api/v1/media
*
* Envoie une image dans la médiathèque, sans passer par un hébergeur public.
* Renvoie un `id` à passer en `mediaId` à /publish ou PATCH /posts/:id.
*
* Deux formats :
* multipart/form-data champ `file` (+ `userId` optionnel)
* application/json { "imageData": "<base64 ou data URL>", "userId"?: "..." }
*
* Formats : JPEG, PNG, WebP, GIF — 10 MB max.
*/
router.post("/media", acceptImageFile("file"), async (req, res) => {
try {
const { imageData, userId } = (req.body ?? {}) as { imageData?: unknown; userId?: unknown };
if (imageData !== undefined && typeof imageData !== "string") {
return res.status(400).json({ error: "imageData doit être une chaîne base64" });
}
if (!req.file && imageData === undefined) {
return res.status(400).json({
error: "Aucune image reçue : envoyez un fichier (multipart, champ « file ») ou imageData (base64)",
});
}
if (req.file && imageData !== undefined) {
return res.status(400).json({ error: "Envoyez soit un fichier, soit imageData, pas les deux" });
}
const ownerId = await resolveOwnerId(typeof userId === "string" && userId ? userId : undefined);
const media = await resolveImage({ file: req.file, imageData }, ownerId, "external");
return res.status(201).json({
id: media!.id,
url: media!.originalUrl,
type: media!.type,
fileName: media!.fileName,
fileSize: media!.fileSize,
});
} catch (error) {
return sendError(res, error, "POST /media");
}
});
/**
* POST /api/v1/publish
*
@@ -256,18 +71,12 @@ router.post("/media", acceptImageFile("file"), async (req, res) => {
* Body:
* content string Texte de la publication
* imageUrl string? URL publique de l'image à télécharger
* imageData string? Image en base64 (ou data URL)
* mediaId string? Image déjà envoyée via POST /api/v1/media
* 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)
*
* Accepte aussi multipart/form-data : mêmes champs en texte (pageIds en
* tableau JSON ou séparés par des virgules) et l'image dans le champ `image`.
* Une seule source d'image par requête.
*/
router.post("/publish", acceptImageFile("image"), async (req, res) => {
router.post("/publish", async (req, res) => {
try {
const parsed = publishSchema.safeParse(req.body);
if (!parsed.success) {
@@ -277,10 +86,23 @@ router.post("/publish", acceptImageFile("image"), async (req, res) => {
});
}
const { content, imageUrl, imageData, mediaId, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data;
const imageSources: ImageSources = { file: req.file, imageUrl, imageData, mediaId };
const { content, imageUrl, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data;
const ownerId = await resolveOwnerId(bodyUserId);
// 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 }> = [];
@@ -293,11 +115,31 @@ router.post("/publish", acceptImageFile("image"), async (req, res) => {
}
// Validation story → image obligatoire
if ((postType === "story" || postType === "both") && countImageSources(imageSources) === 0) {
return res.status(400).json({ error: "Les stories nécessitent une image (fichier, imageData, mediaId ou imageUrl)" });
if ((postType === "story" || postType === "both") && !imageUrl) {
return res.status(400).json({ error: "Les stories nécessitent une image (imageUrl requis)" });
}
const mediaRecord = await resolveImage(imageSources, ownerId, "external");
// 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;
@@ -367,7 +209,9 @@ router.post("/publish", acceptImageFile("image"), async (req, res) => {
},
});
} catch (error) {
return sendError(res, error, "POST /publish");
console.error("[external API] Error:", error);
const message = error instanceof Error ? error.message : "Erreur interne";
return res.status(500).json({ error: message });
}
});
@@ -519,15 +363,11 @@ router.get("/posts", async (req, res) => {
* content string? Nouveau texte
* scheduledAt ISO8601? Nouvelle date/heure de publication
* imageUrl string? URL d'une nouvelle image (remplace l'existante)
* imageData string? Nouvelle image en base64 (remplace l'existante)
* mediaId string? Image déjà envoyée via POST /api/v1/media
*
* Accepte aussi multipart/form-data avec l'image dans le champ `image`.
*/
router.patch("/posts/:id", acceptImageFile("image"), async (req, res) => {
router.patch("/posts/:id", async (req, res) => {
try {
const { id } = req.params;
const { content, scheduledAt, imageUrl, imageData, mediaId } = req.body ?? {};
const { content, scheduledAt, imageUrl } = req.body;
// Vérifier que le post existe et est modifiable
const post = await storage.getPost(id);
@@ -538,9 +378,6 @@ router.patch("/posts/:id", acceptImageFile("image"), async (req, res) => {
return res.status(400).json({ error: "Impossible de modifier un post déjà publié" });
}
// Image résolue d'abord : une image refusée ne laisse pas le post à moitié modifié
const mediaRecord = await resolveImage({ file: req.file, imageUrl, imageData, mediaId }, post.userId, "external-edit");
// Mettre à jour le contenu si fourni
if (content !== undefined) {
await storage.updatePost(id, { content });
@@ -560,8 +397,27 @@ router.patch("/posts/:id", acceptImageFile("image"), async (req, res) => {
}
}
// Remplacer les médias existants par la nouvelle image
if (mediaRecord) {
// 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]);
}
@@ -605,7 +461,9 @@ router.patch("/posts/:id", acceptImageFile("image"), async (req, res) => {
},
});
} catch (error) {
return sendError(res, error, "PATCH /posts/:id");
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 });
}
});
+55
View File
@@ -514,6 +514,61 @@ export class FacebookService {
return finishData.post_id || videoId;
}
/**
* Publie une vidéo en story Facebook en envoyant directement les données
* binaires (start → upload → finish sur /video_stories). Sert à doubler un
* Reel d'une story : la vidéo rendue est déjà en mémoire.
*/
async publishVideoStoryFromBuffer(page: SocialPage, videoBuffer: Buffer): Promise<string> {
if (!page.accessToken) throw new Error('No access token found for this page');
const accessToken = page.accessToken;
const fileSize = videoBuffer.length;
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`, {
method: 'POST',
});
if (!startRes.ok) {
const error = await startRes.json() as FacebookError;
throw new Error(`Facebook Story API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const { video_id: videoId, upload_url: uploadUrl } = await startRes.json() as { video_id: string; upload_url: string };
const uploadRes = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'Content-Type': 'application/octet-stream',
'offset': '0',
'file_size': fileSize.toString(),
},
body: new Blob([videoBuffer], { type: 'video/mp4' }),
});
if (!uploadRes.ok) {
throw new Error(`Facebook Story API error (UPLOAD phase): ${await uploadRes.text()}`);
}
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
});
const finishRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`, {
method: 'POST',
});
if (!finishRes.ok) {
const error = await finishRes.json() as FacebookError;
throw new Error(`Facebook Story API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishRes.json() as { success: boolean; post_id?: string };
console.log(`✅ Story vidéo publiée sur ${page.pageName}`);
return finishData.post_id || videoId;
}
/**
* Publie une vidéo comme Reel Facebook
*
-65
View File
@@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest";
import { MAX_EXTERNAL_IMAGE_SIZE, decodeImageData, normalizePageIds, sniffImageMime, validateImage } from "./imageData";
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0]);
const GIF = Buffer.from("GIF89a\0\0", "latin1");
const WEBP = Buffer.concat([Buffer.from("RIFF"), Buffer.alloc(4), Buffer.from("WEBPVP8 ")]);
describe("sniffImageMime", () => {
it("reconnaît les formats acceptés par leur signature", () => {
expect(sniffImageMime(PNG)).toBe("image/png");
expect(sniffImageMime(JPEG)).toBe("image/jpeg");
expect(sniffImageMime(GIF)).toBe("image/gif");
expect(sniffImageMime(WEBP)).toBe("image/webp");
});
it("refuse le reste", () => {
expect(sniffImageMime(Buffer.from("<html></html>"))).toBeNull();
expect(sniffImageMime(Buffer.alloc(0))).toBeNull();
});
});
describe("decodeImageData", () => {
it("décode le base64 brut et les data URL", () => {
const base64 = PNG.toString("base64");
expect(decodeImageData(base64).equals(PNG)).toBe(true);
expect(decodeImageData(`data:image/png;base64,${base64}`).equals(PNG)).toBe(true);
});
it("tolère les retours à la ligne du base64", () => {
const base64 = JPEG.toString("base64");
expect(decodeImageData(`${base64.slice(0, 4)}\n${base64.slice(4)}`).equals(JPEG)).toBe(true);
});
it("refuse ce qui n'est pas du base64", () => {
expect(() => decodeImageData("https://exemple.fr/image.jpg")).toThrow(/base64/);
expect(() => decodeImageData("")).toThrow(/base64/);
});
});
describe("validateImage", () => {
it("renvoie type et extension", () => {
expect(validateImage(JPEG)).toEqual({ mimeType: "image/jpeg", ext: ".jpg" });
});
it("refuse une image au-delà de 10 MB", () => {
const big = Buffer.concat([PNG, Buffer.alloc(MAX_EXTERNAL_IMAGE_SIZE)]);
expect(() => validateImage(big)).toThrow(/trop volumineuse/);
});
it("refuse un format non reconnu", () => {
expect(() => validateImage(Buffer.from("pas une image"))).toThrow(/Format/);
});
});
describe("normalizePageIds", () => {
it("laisse un tableau intact", () => {
expect(normalizePageIds(["a", "b"])).toEqual(["a", "b"]);
});
it("accepte un tableau JSON ou une liste séparée par des virgules", () => {
expect(normalizePageIds('["a","b"]')).toEqual(["a", "b"]);
expect(normalizePageIds("a, b,")).toEqual(["a", "b"]);
});
});
-72
View File
@@ -1,72 +0,0 @@
// Décodage et identification des images reçues par l'API externe : le type
// annoncé par le client (multipart, data URL) n'est pas fiable, iOS et bien des
// scripts envoient « application/octet-stream », donc on lit les octets.
export const MAX_EXTERNAL_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
export const IMAGE_EXTENSIONS: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
};
/** Type MIME d'après la signature du fichier, ou null si ce n'est pas une image acceptée. */
export function sniffImageMime(buffer: Buffer): string | null {
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return "image/jpeg";
}
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
return "image/png";
}
if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString("latin1"))) {
return "image/gif";
}
if (buffer.length >= 12 && buffer.subarray(0, 4).toString("latin1") === "RIFF" && buffer.subarray(8, 12).toString("latin1") === "WEBP") {
return "image/webp";
}
return null;
}
/**
* Décode une image transmise en base64, brute ou sous forme de data URL
* (`data:image/png;base64,...`).
*/
export function decodeImageData(data: string): Buffer {
const match = /^data:[^;,]*;base64,([\s\S]*)$/.exec(data.trim());
const base64 = (match ? match[1] : data).replace(/\s+/g, "");
if (base64.length === 0 || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(base64)) {
throw new Error("imageData doit être une image encodée en base64 (ou une data URL)");
}
return Buffer.from(base64, "base64");
}
/** Vérifie taille et format ; renvoie le type MIME et l'extension détectés. */
export function validateImage(buffer: Buffer): { mimeType: string; ext: string } {
if (buffer.length > MAX_EXTERNAL_IMAGE_SIZE) {
throw new Error(`Image trop volumineuse (${(buffer.length / 1024 / 1024).toFixed(1)} MB, max 10 MB)`);
}
const mimeType = sniffImageMime(buffer);
if (!mimeType) {
throw new Error("Format d'image non reconnu. Formats acceptés : JPEG, PNG, WebP, GIF");
}
return { mimeType, ext: IMAGE_EXTENSIONS[mimeType] };
}
/**
* `pageIds` arrive en tableau en JSON, mais en texte en multipart :
* on accepte un tableau JSON (`["a","b"]`) ou une liste séparée par des virgules.
*/
export function normalizePageIds(value: unknown): unknown {
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (trimmed.startsWith("[")) {
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
return trimmed.split(",").map((id) => id.trim()).filter(Boolean);
}
+59 -3
View File
@@ -7,7 +7,7 @@
* cas de l'ancien `publishReel(originalUrl)`).
*/
import type { Media } from "@shared/schema";
import type { Media, SocialPage } from "@shared/schema";
import { storage } from "../../storage";
import { minioService } from "../minio";
import { createVideoThumbnail } from "../thumbnail";
@@ -19,6 +19,8 @@ export interface PagePublishResult {
success: boolean;
reelId?: string;
error?: string;
/** Échec de la story associée : n'empêche pas le Reel d'être publié. */
storyError?: string;
}
/** Range la vidéo rendue dans la médiathèque, avec sa vignette. */
@@ -49,6 +51,9 @@ export async function storeRenderedVideo(
/**
* Publie (ou planifie) la vidéo sur chaque page, crée les entrées
* `scheduled_posts` et met à jour le statut du post.
*
* Avec `alsoStory`, chaque page Facebook reçoit aussi la vidéo en story (TikTok
* n'a pas de stories).
*/
export async function publishReelToPages(options: {
postId: string;
@@ -56,8 +61,9 @@ export async function publishReelToPages(options: {
videoBuffer: Buffer;
description: string;
scheduledFor?: string;
alsoStory?: boolean;
}): Promise<PagePublishResult[]> {
const { postId, pageIds, videoBuffer, description, scheduledFor } = options;
const { postId, pageIds, videoBuffer, description, scheduledFor, alsoStory } = options;
const results: PagePublishResult[] = [];
for (const pageId of pageIds) {
@@ -82,9 +88,18 @@ export async function publishReelToPages(options: {
postType: "reel",
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
});
const withStory = alsoStory && page.platform === "facebook";
if (scheduledFor) {
// Le planificateur publiera à l'heure dite
if (withStory) {
await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "story",
scheduledAt: new Date(scheduledFor),
});
}
results.push({ pageId, success: true, reelId: "scheduled" });
continue;
}
@@ -96,7 +111,8 @@ export async function publishReelToPages(options: {
publishedAt: new Date(),
externalPostId: reelId,
});
results.push({ pageId, success: true, reelId });
const storyError = withStory ? await publishStory(postId, page, videoBuffer) : undefined;
results.push({ pageId, success: true, reelId, storyError });
} else {
// TikTok finalise la publication de son côté : l'envoi est marqué comme
// effectué pour ne pas republier, et le poller de statut renseignera
@@ -126,6 +142,46 @@ export async function publishReelToPages(options: {
return results;
}
/**
* Publie la story d'une page juste après son Reel : un échec est consigné sur
* son entrée planifiée (visible dans l'historique) et renvoyé, sans faire
* échouer le Reel.
*/
async function publishStory(
postId: string,
page: SocialPage,
videoBuffer: Buffer,
): Promise<string | undefined> {
// Créée seulement maintenant : en attente pendant l'envoi du Reel, le
// planificateur aurait pu la publier une seconde fois.
let scheduledStoryId: string | undefined;
try {
({ id: scheduledStoryId } = await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "story",
scheduledAt: new Date(),
}));
const storyId = await facebookService.publishVideoStoryFromBuffer(page, videoBuffer);
await storage.updateScheduledPost(scheduledStoryId, {
publishedAt: new Date(),
externalPostId: storyId,
});
return undefined;
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur inconnue";
console.error(`❌ [Reels] Story sur ${page.pageName} impossible :`, error);
if (scheduledStoryId) {
// Marquée comme traitée pour que le planificateur ne la republie pas en boucle
await storage.updateScheduledPost(scheduledStoryId, {
publishedAt: new Date(),
error: message,
}).catch((e) => console.error(`⚠️ [Reels] Échec de la story non enregistré :`, e));
}
return message;
}
}
/** Message d'erreur lisible lorsque toutes les publications ont échoué. */
export function describePublishFailure(results: PagePublishResult[]): string {
const details = results.map((r) => r.error).filter(Boolean).join(" ; ");
+1
View File
@@ -84,6 +84,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
videoBuffer,
description: params.description || params.overlayText || "",
scheduledFor: params.scheduledFor,
alsoStory: params.alsoStory,
});
if (!results.some((r) => r.success)) {
+1 -1
View File
@@ -10,7 +10,7 @@ describe("videoReelParamsSchema", () => {
it("applique les valeurs par défaut", () => {
const params = videoReelParamsSchema.parse({ videoMediaId: "v", pageIds: ["p"] });
expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25, showLogo: true });
expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25, showLogo: true, alsoStory: false });
});
it("accepte les anciens paramètres encore stockés dans des jobs", () => {
+2
View File
@@ -32,6 +32,8 @@ export const videoReelParamsSchema = z.object({
ttsEngine: z.enum(TTS_ENGINES).optional(),
ttsStyle: z.enum(TTS_STYLES).optional(),
scheduledFor: optionalText,
// Publie aussi la vidéo en story sur les pages Facebook (sans effet sur TikTok)
alsoStory: z.boolean().default(false),
// Ancien réglage, ignoré : le minutage vient désormais de la voix elle-même
wordDuration: z.number().optional(),
fontSize: z.number().int().min(16).max(200).default(64),