From bee6f53f23310666542574636ad8e0ba106658e1 Mon Sep 17 00:00:00 2001
From: michaelschal <35957947-michaelschal@users.noreply.replit.com>
Date: Wed, 1 Oct 2025 14:14:59 +0000
Subject: [PATCH] Integrate Cloudinary for cloud-based image and video storage
Implement Cloudinary integration for uploading, storing, and transforming media files, including a new settings section for user configuration and API endpoints for media management.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/p0c5kxJ
---
client/src/pages/settings.tsx | 113 +++++++++++++++++++++++++++++---
docker-compose.yml | 2 -
replit.md | 30 ++++++---
server/routes.ts | 120 +++++++++++++++++++++++++++++-----
server/services/cloudinary.ts | 106 ++++++++++++++++++++++++++++++
server/storage.ts | 27 ++++++++
6 files changed, 363 insertions(+), 35 deletions(-)
create mode 100644 server/services/cloudinary.ts
diff --git a/client/src/pages/settings.tsx b/client/src/pages/settings.tsx
index 95cd3da..606011c 100644
--- a/client/src/pages/settings.tsx
+++ b/client/src/pages/settings.tsx
@@ -1,5 +1,6 @@
-import { useState } from "react";
-import { Settings as SettingsIcon, Bell, Key, Shield } from "lucide-react";
+import { useState, useEffect } from "react";
+import { Settings as SettingsIcon, Bell, Key, Shield, Cloud } from "lucide-react";
+import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -7,11 +8,52 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
+import { useToast } from "@/hooks/use-toast";
+import { apiRequest, queryClient } from "@/lib/queryClient";
export default function Settings() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [notifications, setNotifications] = useState(true);
const [autoPublish, setAutoPublish] = useState(true);
+ const [cloudName, setCloudName] = useState("");
+ const [apiKey, setApiKey] = useState("");
+ const [apiSecret, setApiSecret] = useState("");
+ const { toast } = useToast();
+
+ const { data: cloudinaryConfig } = useQuery({
+ queryKey: ['/api/cloudinary/config'],
+ });
+
+ useEffect(() => {
+ if (cloudinaryConfig) {
+ setCloudName((cloudinaryConfig as any).cloudName || "");
+ setApiKey((cloudinaryConfig as any).apiKey || "");
+ }
+ }, [cloudinaryConfig]);
+
+ const saveCloudinaryMutation = useMutation({
+ mutationFn: () =>
+ apiRequest('POST', '/api/cloudinary/config', {
+ cloudName,
+ apiKey,
+ apiSecret,
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
+ toast({
+ title: "Configuration sauvegardée",
+ description: "Vos paramètres Cloudinary ont été enregistrés",
+ });
+ setApiSecret(""); // Clear the secret after saving
+ },
+ onError: () => {
+ toast({
+ title: "Erreur",
+ description: "Impossible de sauvegarder la configuration",
+ variant: "destructive",
+ });
+ },
+ });
return (
@@ -109,6 +151,67 @@ export default function Settings() {
+
+
+
+
+ Configuration Cloudinary
+
+
+ Configurez Cloudinary pour le stockage d'images dans le cloud
+
+
+
+
+
+
setCloudName(e.target.value)}
+ placeholder="mon-cloud-name"
+ data-testid="input-cloud-name"
+ />
+
+ Trouvez-le dans votre tableau de bord Cloudinary
+
+
+
+
+ setApiKey(e.target.value)}
+ placeholder="123456789012345"
+ data-testid="input-api-key"
+ />
+
+
+
+
setApiSecret(e.target.value)}
+ placeholder="••••••••••••••••"
+ data-testid="input-api-secret"
+ />
+
+ Votre API Secret restera sécurisé et ne sera jamais exposé
+
+
+
+
+
+
@@ -125,12 +228,6 @@ export default function Settings() {
-
-
-
-
diff --git a/docker-compose.yml b/docker-compose.yml
index 1483697..e2be7be 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -40,8 +40,6 @@ services:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
SESSION_SECRET: ${SESSION_SECRET:-your-secret-key-change-me}
APP_URL: ${APP_URL:-http://localhost:5000}
- volumes:
- - ./uploads:/app/uploads
networks:
- internal
- nginx_default
diff --git a/replit.md b/replit.md
index 86c53d0..17103d5 100644
--- a/replit.md
+++ b/replit.md
@@ -55,7 +55,7 @@ Preferred communication style: Simple, everyday language.
- `/api/pages/*` - Social page management
- `/api/posts/*` - Post and scheduling management
-**File Upload**: Multer middleware for handling multipart/form-data with in-memory storage strategy
+**File Upload**: Multer middleware for handling multipart/form-data with in-memory storage strategy, integrated with Cloudinary for cloud storage
**Background Processing**:
- Node-cron for scheduled task execution
@@ -65,7 +65,7 @@ Preferred communication style: Simple, everyday language.
**Session Management**: Stateless authentication with session cookies (connect-pg-simple for PostgreSQL session store)
**Key Architectural Patterns**:
-- Service layer pattern (OpenRouterService, ImageProcessor, SchedulerService)
+- Service layer pattern (OpenRouterService, CloudinaryService, SchedulerService)
- Storage abstraction layer for database operations
- Middleware-based request processing pipeline
- Separation of concerns between routes, services, and storage
@@ -80,8 +80,9 @@ Preferred communication style: Simple, everyday language.
Core Tables:
- `users` - User authentication and profiles
+- `cloudinary_config` - Cloudinary configuration per user (cloud name, API key, API secret)
- `social_pages` - Connected Facebook/Instagram pages with access tokens
-- `media` - Uploaded media files with platform-specific processed versions
+- `media` - Uploaded media files with Cloudinary public IDs and transformation URLs
- `posts` - Post content with AI generation tracking and status
- `scheduled_posts` - Scheduled publication queue with page assignments
- `ai_generations` - History of AI-generated content
@@ -101,20 +102,21 @@ Core Tables:
### Media Processing Pipeline
-**Image Processing Library**: Sharp for high-performance image manipulation
+**Cloud Storage**: Cloudinary for image/video storage and transformation
**Format Optimization Strategy**:
- Facebook Feed: 1200x630px (landscape format)
- Instagram Feed: 1080x1080px (square format)
- Instagram Story: 1080x1920px (vertical format)
-**Storage**: Local filesystem storage in `/uploads` directory with unique UUID-based filenames
+**Storage**: Cloud-based storage via Cloudinary CDN with automatic transformations
**Processing Flow**:
-1. Upload original file to memory buffer
-2. Generate three optimized versions for different platforms
-3. Store all versions with references in database
-4. Serve via `/uploads` static route
+1. Upload original file to memory buffer via Multer
+2. Upload to Cloudinary with unique public_id
+3. Cloudinary automatically generates transformation URLs for all platform formats
+4. Store Cloudinary public_id and transformation URLs in database
+5. Serve optimized images via Cloudinary CDN
## External Dependencies
@@ -126,6 +128,16 @@ Core Tables:
- Configuration: Requires `OPENROUTER_API_KEY` environment variable
- Temperature: 0.8 for creative variation
+### Cloud Storage
+
+**Cloudinary**:
+- Purpose: Cloud-based image and video storage with automatic transformations
+- Configuration: User-configurable via Settings page (cloud name, API key, API secret)
+- Storage: Images stored in Cloudinary cloud with unique public IDs
+- Transformations: Automatic generation of platform-specific formats (Facebook Feed, Instagram Feed, Instagram Story)
+- CDN: Global content delivery network for fast image loading
+- Status: Fully integrated for media uploads
+
### Social Media APIs
**Facebook Graph API**:
diff --git a/server/routes.ts b/server/routes.ts
index d96cf0d..3643989 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -3,16 +3,12 @@ import { createServer, type Server } from "http";
import { storage } from "./storage";
import multer from "multer";
import { openRouterService } from "./services/openrouter";
-import { imageProcessor } from "./services/imageProcessor";
-import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema } from "@shared/schema";
-import express from "express";
-import path from "path";
+import { cloudinaryService } from "./services/cloudinary";
+import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema } from "@shared/schema";
const upload = multer({ storage: multer.memoryStorage() });
export async function registerRoutes(app: Express): Promise {
- // Serve uploaded files
- app.use("/uploads", express.static(path.join(process.cwd(), "uploads")));
// Stats endpoint
app.get("/api/stats", async (req, res) => {
@@ -69,21 +65,32 @@ export async function registerRoutes(app: Express): Promise {
}
const userId = "demo-user"; // In real app, get from session/auth
+
+ // Check if Cloudinary is configured
+ const cloudinaryConfig = await storage.getCloudinaryConfig(userId);
- const originalUrl = await imageProcessor.saveOriginalFile(req.file.buffer, req.file.originalname);
-
- let processedUrls = null;
- if (req.file.mimetype.startsWith("image/")) {
- processedUrls = await imageProcessor.processImage(req.file.buffer, req.file.originalname);
+ if (!cloudinaryConfig) {
+ return res.status(400).json({
+ error: "Cloudinary not configured. Please configure Cloudinary in Settings first."
+ });
}
+ // Upload to Cloudinary
+ const uploadResult = await cloudinaryService.uploadMedia(
+ req.file.buffer,
+ req.file.originalname,
+ userId,
+ req.file.mimetype
+ );
+
const mediaItem = await storage.createMedia({
userId,
type: req.file.mimetype.startsWith("video/") ? "video" : "image",
- originalUrl,
- facebookFeedUrl: processedUrls?.facebookFeed || null,
- instagramFeedUrl: processedUrls?.instagramFeed || null,
- instagramStoryUrl: processedUrls?.instagramStory || null,
+ cloudinaryPublicId: uploadResult.publicId,
+ originalUrl: uploadResult.originalUrl,
+ facebookFeedUrl: uploadResult.facebookFeedUrl,
+ instagramFeedUrl: uploadResult.instagramFeedUrl,
+ instagramStoryUrl: uploadResult.instagramStoryUrl,
fileName: req.file.originalname,
fileSize: req.file.size,
});
@@ -91,7 +98,8 @@ export async function registerRoutes(app: Express): Promise {
res.json(mediaItem);
} catch (error) {
console.error("Error uploading media:", error);
- res.status(500).json({ error: "Failed to upload media" });
+ const errorMessage = error instanceof Error ? error.message : "Failed to upload media";
+ res.status(500).json({ error: errorMessage });
}
});
@@ -107,6 +115,39 @@ export async function registerRoutes(app: Express): Promise {
}
});
+ // Delete media
+ app.delete("/api/media/:id", async (req, res) => {
+ try {
+ const userId = "demo-user"; // In real app, get from session/auth
+ const mediaId = req.params.id;
+
+ // Get media to find cloudinary public ID
+ const allMedia = await storage.getMedia(userId);
+ const mediaToDelete = allMedia.find(m => m.id === mediaId);
+
+ if (!mediaToDelete) {
+ return res.status(404).json({ error: "Media not found" });
+ }
+
+ // Delete from Cloudinary
+ if (mediaToDelete.cloudinaryPublicId) {
+ await cloudinaryService.deleteMedia(
+ mediaToDelete.cloudinaryPublicId,
+ userId,
+ mediaToDelete.type
+ );
+ }
+
+ // Delete from database
+ await storage.deleteMedia(mediaId);
+
+ res.json({ success: true });
+ } catch (error) {
+ console.error("Error deleting media:", error);
+ res.status(500).json({ error: "Failed to delete media" });
+ }
+ });
+
// Posts
app.get("/api/posts", async (req, res) => {
try {
@@ -195,6 +236,53 @@ export async function registerRoutes(app: Express): Promise {
}
});
+ // Cloudinary Config
+ app.get("/api/cloudinary/config", async (req, res) => {
+ try {
+ const userId = "demo-user"; // In real app, get from session/auth
+ const config = await storage.getCloudinaryConfig(userId);
+
+ if (!config) {
+ return res.json(null);
+ }
+
+ // Don't send the API secret to the client
+ const { apiSecret, ...safeConfig } = config;
+ res.json(safeConfig);
+ } catch (error) {
+ console.error("Error fetching Cloudinary config:", error);
+ res.status(500).json({ error: "Failed to fetch Cloudinary config" });
+ }
+ });
+
+ app.post("/api/cloudinary/config", async (req, res) => {
+ try {
+ const userId = "demo-user"; // In real app, get from session/auth
+
+ const configData = insertCloudinaryConfigSchema.parse({
+ ...req.body,
+ userId,
+ });
+
+ // Check if config already exists
+ const existingConfig = await storage.getCloudinaryConfig(userId);
+
+ let config;
+ if (existingConfig) {
+ config = await storage.updateCloudinaryConfig(userId, configData);
+ } else {
+ config = await storage.createCloudinaryConfig(configData);
+ }
+
+ // Don't send the API secret back
+ const { apiSecret, ...safeConfig } = config;
+ res.json(safeConfig);
+ } catch (error) {
+ console.error("Error saving Cloudinary config:", error);
+ res.status(500).json({ error: "Failed to save Cloudinary config" });
+ }
+ });
+
const httpServer = createServer(app);
return httpServer;
diff --git a/server/services/cloudinary.ts b/server/services/cloudinary.ts
new file mode 100644
index 0000000..97321ca
--- /dev/null
+++ b/server/services/cloudinary.ts
@@ -0,0 +1,106 @@
+import { v2 as cloudinary } from 'cloudinary';
+import { storage } from '../storage';
+
+class CloudinaryService {
+ async uploadMedia(
+ buffer: Buffer,
+ fileName: string,
+ userId: string,
+ mimeType: string
+ ): Promise<{
+ publicId: string;
+ originalUrl: string;
+ facebookFeedUrl: string | null;
+ instagramFeedUrl: string | null;
+ instagramStoryUrl: string | null;
+ }> {
+ // Get user's Cloudinary config
+ const config = await storage.getCloudinaryConfig(userId);
+
+ if (!config) {
+ throw new Error('Cloudinary configuration not found. Please configure Cloudinary in Settings.');
+ }
+
+ // Configure Cloudinary with user's credentials
+ cloudinary.config({
+ cloud_name: config.cloudName,
+ api_key: config.apiKey,
+ api_secret: config.apiSecret,
+ secure: true,
+ });
+
+ const isVideo = mimeType.startsWith('video/');
+
+ // Upload to Cloudinary
+ const uploadResult = await new Promise((resolve, reject) => {
+ const uploadStream = cloudinary.uploader.upload_stream(
+ {
+ folder: 'social-flow',
+ public_id: `${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`,
+ resource_type: isVideo ? 'video' : 'image',
+ },
+ (error, result) => {
+ if (error) reject(error);
+ else resolve(result);
+ }
+ );
+
+ uploadStream.end(buffer);
+ });
+
+ const publicId = uploadResult.public_id;
+ const originalUrl = uploadResult.secure_url;
+
+ // Generate transformation URLs only for images
+ let facebookFeedUrl = null;
+ let instagramFeedUrl = null;
+ let instagramStoryUrl = null;
+
+ if (!isVideo) {
+ facebookFeedUrl = cloudinary.url(publicId, {
+ transformation: [
+ { width: 1200, height: 630, crop: 'fill', gravity: 'auto' }
+ ]
+ });
+ instagramFeedUrl = cloudinary.url(publicId, {
+ transformation: [
+ { width: 1080, height: 1080, crop: 'fill', gravity: 'auto' }
+ ]
+ });
+ instagramStoryUrl = cloudinary.url(publicId, {
+ transformation: [
+ { width: 1080, height: 1920, crop: 'fill', gravity: 'auto' }
+ ]
+ });
+ }
+
+ return {
+ publicId,
+ originalUrl,
+ facebookFeedUrl,
+ instagramFeedUrl,
+ instagramStoryUrl,
+ };
+ }
+
+ async deleteMedia(publicId: string, userId: string, mediaType: 'image' | 'video'): Promise {
+ const config = await storage.getCloudinaryConfig(userId);
+
+ if (!config) {
+ throw new Error('Cloudinary configuration not found');
+ }
+
+ cloudinary.config({
+ cloud_name: config.cloudName,
+ api_key: config.apiKey,
+ api_secret: config.apiSecret,
+ secure: true,
+ });
+
+ await cloudinary.uploader.destroy(publicId, {
+ resource_type: mediaType,
+ });
+ }
+}
+
+export const cloudinaryService = new CloudinaryService();
diff --git a/server/storage.ts b/server/storage.ts
index 41867bb..c967f6d 100644
--- a/server/storage.ts
+++ b/server/storage.ts
@@ -5,6 +5,7 @@ import {
posts,
scheduledPosts,
aiGenerations,
+ cloudinaryConfig,
type User,
type InsertUser,
type SocialPage,
@@ -17,6 +18,8 @@ import {
type InsertScheduledPost,
type AiGeneration,
type InsertAiGeneration,
+ type CloudinaryConfig,
+ type InsertCloudinaryConfig,
} from "@shared/schema";
import { db } from "./db";
import { eq, and, gte, lte, desc, isNull } from "drizzle-orm";
@@ -58,6 +61,11 @@ export interface IStorage {
// AI Generations
getAiGenerations(userId: string): Promise;
createAiGeneration(generation: InsertAiGeneration): Promise;
+
+ // Cloudinary Config
+ getCloudinaryConfig(userId: string): Promise;
+ createCloudinaryConfig(config: InsertCloudinaryConfig): Promise;
+ updateCloudinaryConfig(userId: string, config: Partial): Promise;
}
export class DatabaseStorage implements IStorage {
@@ -208,6 +216,25 @@ export class DatabaseStorage implements IStorage {
const [newGeneration] = await db.insert(aiGenerations).values(generation).returning();
return newGeneration;
}
+
+ // Cloudinary Config
+ async getCloudinaryConfig(userId: string): Promise {
+ const [config] = await db.select().from(cloudinaryConfig).where(eq(cloudinaryConfig.userId, userId));
+ return config || undefined;
+ }
+
+ async createCloudinaryConfig(config: InsertCloudinaryConfig): Promise {
+ const [newConfig] = await db.insert(cloudinaryConfig).values(config).returning();
+ return newConfig;
+ }
+
+ async updateCloudinaryConfig(userId: string, config: Partial): Promise {
+ const [updated] = await db.update(cloudinaryConfig)
+ .set({ ...config, updatedAt: new Date() })
+ .where(eq(cloudinaryConfig.userId, userId))
+ .returning();
+ return updated;
+ }
}
export const storage = new DatabaseStorage();