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