feat: replace Cloudinary with MinIO for all media storage

- Create server/services/minio.ts: S3-compatible service (same interface as
  cloudinaryService) using @aws-sdk/client-s3 and MINIO_ENDPOINT env var
- Replace all cloudinaryService imports with minioService across routes.ts,
  routes/reels.ts, routes/remotion.ts, services/media-purge.ts
- Replace Cloudinary logo URL pattern (res.cloudinary.com/...) with
  buildMinioUrl() helper throughout all server routes
- GET /api/cloudinary/config now returns a logoUrl field (full MinIO URL)
  so clients never need to build the URL themselves
- Update settings pages (desktop + mobile): labels changed to MinIO
  (Bucket Name, Access Key, Secret Key)
- Update image-editor.tsx: remove Cloudinary URL transformation, use logoUrl
- Update media-utils.ts: simplify getVideoThumbnailUrl (no URL transforms)
- Add MINIO_ENDPOINT and MINIO_PUBLIC_URL to docker-compose.yml + .env.example
- DB table reuse: cloudinary_config.cloud_name = bucket, api_key = access key,
  api_secret = secret key — no migration needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-03-24 21:05:37 +01:00
1 parent b0e11cb8ab
commit e39fa1026d
13 files changed
+1832 -118

No files matched your search

+16 -20
View File
@@ -9,7 +9,7 @@ import bcrypt from "bcrypt";
import passport from "./auth";
import { z } from "zod";
import { openRouterService } from "./services/openrouter";
import { cloudinaryService } from "./services/cloudinary";
import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertFreesoundConfigSchema, updateFreesoundConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import type { User, InsertUser, ScheduledPost, FreesoundConfig } from "@shared/schema";
import { freeSoundService } from "./services/freesound";
@@ -822,7 +822,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
if (cloudinaryConfig && cloudinaryConfig.logoPublicId) {
try {
// Build logo URL from Cloudinary
const logoUrl = `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`;
const logoUrl = buildMinioUrl(cloudinaryConfig.cloudName, cloudinaryConfig.logoPublicId);
console.log("📥 Downloading logo from:", logoUrl);
// Download logo
@@ -1097,23 +1097,18 @@ export async function registerRoutes(app: Express): Promise<Server> {
return res.status(404).json({ error: "Audio track not found" });
}
// Try to extract publicId from cloudinary url to delete it from there too
// e.g. https://res.cloudinary.com/demo/video/upload/v123456789/user_id/audio_name.mp3
const urlParts = track.url.split('/upload/');
if (urlParts.length > 1) {
const afterUpload = urlParts[1];
// Remove version number (v12345...)
let pathWithoutVersion = afterUpload;
if (afterUpload.match(/^v\d+\//)) {
pathWithoutVersion = afterUpload.replace(/^v\d+\//, '');
}
// Remove extension
const publicId = pathWithoutVersion.replace(/\.[^/.]+$/, "");
try {
await cloudinaryService.deleteMedia(publicId, track.userId || '', 'video');
} catch (cloudinaryError) {
console.warn(`Failed to delete audio from cloudinary: ${publicId}`, cloudinaryError);
// Try to delete from MinIO if the URL matches the MinIO public URL pattern
const minioBase = (process.env.MINIO_PUBLIC_URL || process.env.MINIO_ENDPOINT || '').replace(/\/$/, '');
if (minioBase && track.url.startsWith(minioBase)) {
// URL = ${minioBase}/${bucket}/${objectKey} → extract objectKey
const withoutBase = track.url.slice(minioBase.length + 1); // strip "base/"
const objectKey = withoutBase.split('/').slice(1).join('/'); // strip bucket
if (objectKey) {
try {
await cloudinaryService.deleteMedia(objectKey, track.userId || '', 'video');
} catch (minioError) {
console.warn(`Failed to delete audio from MinIO: ${objectKey}`, minioError);
}
}
}
@@ -1636,7 +1631,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Don't send the API secret to the client
const { apiSecret, ...safeConfig } = config;
res.json(safeConfig);
const logoUrl = config.logoPublicId ? buildMinioUrl(config.cloudName, config.logoPublicId) : null;
res.json({ ...safeConfig, logoUrl });
} catch (error) {
console.error("Error fetching Cloudinary config:", error);
res.status(500).json({ error: "Failed to fetch Cloudinary config" });
+3 -3
View File
@@ -8,7 +8,7 @@ import { storage } from '../storage';
import { freeSoundService, type MusicTrack } from '../services/freesound';
import { ffmpegService } from '../services/ffmpeg';
import { facebookService } from '../services/facebook';
import { cloudinaryService } from '../services/cloudinary';
import { minioService as cloudinaryService, buildMinioUrl } from '../services/minio';
import { openRouterService } from '../services/openrouter';
import { db } from '../db';
import { cloudinaryConfig } from '@shared/schema';
@@ -343,7 +343,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
try {
const config = await storage.getCloudinaryConfig();
if (config && config.logoPublicId) {
watermarkUrl = `https://res.cloudinary.com/${config.cloudName}/image/upload/${config.logoPublicId}`;
watermarkUrl = buildMinioUrl(config.cloudName, config.logoPublicId);
}
} catch (e) {
console.error('Error fetching watermark configuration', e);
@@ -556,7 +556,7 @@ async function processReelBackground(
try {
const config = await storage.getCloudinaryConfig();
if (config && config.logoPublicId) {
watermarkUrl = `https://res.cloudinary.com/${config.cloudName}/image/upload/${config.logoPublicId}`;
watermarkUrl = buildMinioUrl(config.cloudName, config.logoPublicId);
}
} catch (e) {
console.error('Error fetching watermark configuration', e);
+2 -2
View File
@@ -6,7 +6,7 @@ import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { ffmpegService } from "../services/ffmpeg";
import { storage as dbStorage } from "../storage";
import { cloudinaryService } from "../services/cloudinary";
import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio";
import { facebookService } from "../services/facebook";
import * as musicMetadata from "music-metadata";
@@ -128,7 +128,7 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
try {
const cloudinaryConfig = await dbStorage.getCloudinaryConfig();
if (cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId) {
logoUrl = `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`;
logoUrl = buildMinioUrl(cloudinaryConfig.cloudName, cloudinaryConfig.logoPublicId);
console.log("🏢 Logo URL:", logoUrl);
}
} catch (e) {
+1 -1
View File
@@ -1,7 +1,7 @@
import { db } from '../db';
import { media } from '@shared/schema';
import { eq, lt, isNotNull, and } from 'drizzle-orm';
import { cloudinaryService } from './cloudinary';
import { minioService as cloudinaryService } from './minio';
/**
* Automates the cleanup of old media to save Cloudinary storage space.
+112
View File
@@ -0,0 +1,112 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { storage } from '../storage';
import fs from 'fs';
import path from 'path';
function getS3Client(accessKey: string, secretKey: string): S3Client {
return new S3Client({
endpoint: process.env.MINIO_ENDPOINT || 'http://localhost:9000',
region: 'us-east-1',
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
forcePathStyle: true,
});
}
/**
* Builds the public URL for a stored object.
* MINIO_PUBLIC_URL = public-facing base (e.g. https://media.example.com)
* Object URL = ${MINIO_PUBLIC_URL}/${bucketName}/${objectKey}
*/
export function buildMinioUrl(bucketName: string, objectKey: string): string {
const base = (process.env.MINIO_PUBLIC_URL || process.env.MINIO_ENDPOINT || 'http://localhost:9000').replace(/\/$/, '');
return `${base}/${bucketName}/${objectKey}`;
}
class MinioService {
async uploadMedia(
file: Buffer | string,
fileName: string,
_userId: string,
mimeType: string
): Promise<{
publicId: string;
originalUrl: string;
facebookFeedUrl: string | null;
instagramFeedUrl: string | null;
instagramStoryUrl: string | null;
}> {
const config = await storage.getCloudinaryConfig();
if (!config) throw new Error('MinIO non configuré. Veuillez configurer le stockage dans les Paramètres.');
const client = getS3Client(config.apiKey, config.apiSecret);
const bucket = config.cloudName;
const safeName = path.basename(fileName).replace(/\s+/g, '-');
const objectKey = `social-flow/${Date.now()}-${safeName}`;
const body: Buffer = typeof file === 'string' ? fs.readFileSync(file) : file;
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: objectKey,
Body: body,
ContentType: mimeType,
}));
const url = buildMinioUrl(bucket, objectKey);
return { publicId: objectKey, originalUrl: url, facebookFeedUrl: url, instagramFeedUrl: url, instagramStoryUrl: url };
}
async deleteMedia(publicId: string, _userId: string, _mediaType: 'image' | 'video'): Promise<void> {
const config = await storage.getCloudinaryConfig();
if (!config) throw new Error('MinIO non configuré.');
const client = getS3Client(config.apiKey, config.apiSecret);
await client.send(new DeleteObjectCommand({ Bucket: config.cloudName, Key: publicId }));
}
async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise<string> {
const config = await storage.getCloudinaryConfig();
if (!config) throw new Error('MinIO non configuré.');
const client = getS3Client(config.apiKey, config.apiSecret);
const safeName = path.basename(originalFileName).replace(/\s+/g, '-');
const objectKey = `social-flow/stories/story-${Date.now()}-${safeName}`;
await client.send(new PutObjectCommand({
Bucket: config.cloudName,
Key: objectKey,
Body: buffer,
ContentType: 'image/jpeg',
}));
return buildMinioUrl(config.cloudName, objectKey);
}
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const config = await storage.getCloudinaryConfig();
if (!config) throw new Error('MinIO non configuré.');
const client = getS3Client(config.apiKey, config.apiSecret);
const ext = path.extname(fileName) || '.png';
const objectKey = `social-flow/logos/logo-${Date.now()}${ext}`;
await client.send(new PutObjectCommand({
Bucket: config.cloudName,
Key: objectKey,
Body: buffer,
ContentType: fileName.match(/\.png$/i) ? 'image/png' : 'image/jpeg',
}));
return { publicId: objectKey, url: buildMinioUrl(config.cloudName, objectKey) };
}
async deleteLogo(publicId: string): Promise<void> {
const config = await storage.getCloudinaryConfig();
if (!config) throw new Error('MinIO non configuré.');
const client = getS3Client(config.apiKey, config.apiSecret);
await client.send(new DeleteObjectCommand({ Bucket: config.cloudName, Key: publicId }));
}
}
export const minioService = new MinioService();