mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 15:38:10 +02:00
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:
1 parent
b0e11cb8ab
commit
e39fa1026d
13 files changed
+1832
-118
No files matched your search
@@ -24,6 +24,12 @@ ENCRYPTION_KEY=your-32-byte-hex-key-here
|
|||||||
# Obtenez votre clé sur https://openrouter.ai/
|
# Obtenez votre clé sur https://openrouter.ai/
|
||||||
OPENROUTER_API_KEY=your-openrouter-api-key-here
|
OPENROUTER_API_KEY=your-openrouter-api-key-here
|
||||||
|
|
||||||
|
# Configuration MinIO (stockage des médias, remplace Cloudinary)
|
||||||
|
# MINIO_ENDPOINT = URL interne Docker (ex: http://minio:9000)
|
||||||
|
# MINIO_PUBLIC_URL = URL publique pour accéder aux fichiers (ex: https://media.example.com)
|
||||||
|
MINIO_ENDPOINT=http://minio:9000
|
||||||
|
MINIO_PUBLIC_URL=https://media.example.com
|
||||||
|
|
||||||
# Optionnel: Configuration Facebook API
|
# Optionnel: Configuration Facebook API
|
||||||
# FACEBOOK_APP_ID=
|
# FACEBOOK_APP_ID=
|
||||||
# FACEBOOK_APP_SECRET=
|
# FACEBOOK_APP_SECRET=
|
||||||
|
|||||||
@@ -1,20 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Derives a Cloudinary video thumbnail URL from the original video URL.
|
* Returns a thumbnail URL for a video.
|
||||||
*
|
* With MinIO there are no automatic URL-based transformations,
|
||||||
* Cloudinary automatically generates image frames for uploaded videos.
|
* so we return the original URL and let the browser render the video.
|
||||||
* By inserting transformation parameters into the URL, we can extract
|
|
||||||
* a static JPEG thumbnail without any backend change or DB migration.
|
|
||||||
*
|
|
||||||
* @param originalUrl - The Cloudinary video URL (e.g. .../video/upload/v123/file.mp4)
|
|
||||||
* @returns A JPEG thumbnail URL with fill-crop at 400x400
|
|
||||||
*/
|
*/
|
||||||
export function getVideoThumbnailUrl(originalUrl: string): string {
|
export function getVideoThumbnailUrl(originalUrl: string): string {
|
||||||
// Only transform Cloudinary video URLs
|
return originalUrl;
|
||||||
if (!originalUrl.includes('/video/upload/')) {
|
|
||||||
return originalUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return originalUrl
|
|
||||||
.replace('/video/upload/', '/video/upload/so_0,w_400,h_400,c_fill/')
|
|
||||||
.replace(/\.[^.]+$/, '.jpg');
|
|
||||||
}
|
}
|
||||||
@@ -112,45 +112,10 @@ export default function ImageEditor() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Generate Cloudinary transformation URL
|
// Set preview URL from selected media
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedMedia) {
|
if (!selectedMedia) { setPreviewUrl(""); return; }
|
||||||
setPreviewUrl("");
|
setPreviewUrl(selectedMedia.originalUrl);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start with base image URL
|
|
||||||
let url = selectedMedia.originalUrl;
|
|
||||||
|
|
||||||
// Extract Cloudinary parts
|
|
||||||
const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/image\/upload\//;
|
|
||||||
const match = url.match(cloudinaryPattern);
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
setPreviewUrl(url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [fullMatch, cloudName] = match;
|
|
||||||
const imagePath = url.replace(fullMatch, '');
|
|
||||||
|
|
||||||
// Build transformations array
|
|
||||||
const transformations: string[] = [];
|
|
||||||
|
|
||||||
// Base resize
|
|
||||||
transformations.push('w_800,h_800,c_fit');
|
|
||||||
|
|
||||||
// Note: Ribbon and price badge overlays are rendered as CSS elements in preview
|
|
||||||
// They are NOT added to Cloudinary transformation URL
|
|
||||||
// This allows for better visual control and positioning
|
|
||||||
|
|
||||||
// Logo overlay will be added by backend when saving
|
|
||||||
// Preview just shows the base image with CSS overlays
|
|
||||||
|
|
||||||
// Construct final URL
|
|
||||||
const transformedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${transformations.join('/')}/${imagePath}`;
|
|
||||||
setPreviewUrl(transformedUrl);
|
|
||||||
|
|
||||||
}, [selectedMedia, ribbon, priceBadge, logo]);
|
}, [selectedMedia, ribbon, priceBadge, logo]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -620,9 +585,9 @@ export default function ImageEditor() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Logo Overlay (CSS) */}
|
{/* Logo Overlay (CSS) */}
|
||||||
{logo.enabled && cloudinaryConfig && (cloudinaryConfig as any).logoPublicId && (
|
{logo.enabled && cloudinaryConfig && (cloudinaryConfig as any).logoUrl && (
|
||||||
<img
|
<img
|
||||||
src={`https://res.cloudinary.com/${(cloudinaryConfig as any).cloudName}/image/upload/${(cloudinaryConfig as any).logoPublicId}`}
|
src={(cloudinaryConfig as any).logoUrl}
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
className={`absolute ${
|
className={`absolute ${
|
||||||
logo.position === "north_east" ? "top-4 right-4" :
|
logo.position === "north_east" ? "top-4 right-4" :
|
||||||
|
|||||||
@@ -51,10 +51,8 @@ export default function SettingsMobile() {
|
|||||||
setCloudName((cloudinaryConfig as any).cloudName || "");
|
setCloudName((cloudinaryConfig as any).cloudName || "");
|
||||||
setApiKey((cloudinaryConfig as any).apiKey || "");
|
setApiKey((cloudinaryConfig as any).apiKey || "");
|
||||||
|
|
||||||
if ((cloudinaryConfig as any).logoPublicId) {
|
if ((cloudinaryConfig as any).logoUrl) {
|
||||||
const cloudName = (cloudinaryConfig as any).cloudName;
|
setLogoPreview((cloudinaryConfig as any).logoUrl);
|
||||||
const logoPublicId = (cloudinaryConfig as any).logoPublicId;
|
|
||||||
setLogoPreview(`https://res.cloudinary.com/${cloudName}/image/upload/${logoPublicId}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [cloudinaryConfig]);
|
}, [cloudinaryConfig]);
|
||||||
@@ -361,48 +359,48 @@ export default function SettingsMobile() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Cloudinary */}
|
{/* MinIO */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||||
<CardHeader className="p-5">
|
<CardHeader className="p-5">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Cloud className="w-5 h-5" />
|
<Cloud className="w-5 h-5" />
|
||||||
Configuration Cloudinary
|
Configuration Stockage MinIO
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
Configurez Cloudinary pour le stockage d'images dans le cloud
|
Configurez MinIO pour le stockage des médias (endpoint via variable MINIO_ENDPOINT)
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 p-5 pt-0">
|
<CardContent className="space-y-4 p-5 pt-0">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cloudName" className="text-sm">Cloud Name</Label>
|
<Label htmlFor="cloudName" className="text-sm">Nom du bucket</Label>
|
||||||
<Input
|
<Input
|
||||||
id="cloudName"
|
id="cloudName"
|
||||||
type="text"
|
type="text"
|
||||||
value={cloudName}
|
value={cloudName}
|
||||||
onChange={(e) => setCloudName(e.target.value)}
|
onChange={(e) => setCloudName(e.target.value)}
|
||||||
placeholder="mon-cloud-name"
|
placeholder="socialflow"
|
||||||
className="min-h-[48px]"
|
className="min-h-[48px]"
|
||||||
data-testid="input-cloud-name"
|
data-testid="input-cloud-name"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Trouvez-le dans votre tableau de bord Cloudinary
|
Nom du bucket MinIO dans lequel stocker les fichiers
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="apiKey" className="text-sm">API Key</Label>
|
<Label htmlFor="apiKey" className="text-sm">Access Key</Label>
|
||||||
<Input
|
<Input
|
||||||
id="apiKey"
|
id="apiKey"
|
||||||
type="text"
|
type="text"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
placeholder="123456789012345"
|
placeholder="minioadmin"
|
||||||
className="min-h-[48px]"
|
className="min-h-[48px]"
|
||||||
data-testid="input-api-key"
|
data-testid="input-api-key"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="apiSecret" className="text-sm">API Secret</Label>
|
<Label htmlFor="apiSecret" className="text-sm">Secret Key</Label>
|
||||||
<Input
|
<Input
|
||||||
id="apiSecret"
|
id="apiSecret"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -413,7 +411,7 @@ export default function SettingsMobile() {
|
|||||||
data-testid="input-api-secret"
|
data-testid="input-api-secret"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Votre API Secret restera sécurisé et ne sera jamais exposé
|
Votre Secret Key restera sécurisé et ne sera jamais exposé
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -422,7 +420,7 @@ export default function SettingsMobile() {
|
|||||||
data-testid="button-save-cloudinary"
|
data-testid="button-save-cloudinary"
|
||||||
className="w-full min-h-[48px]"
|
className="w-full min-h-[48px]"
|
||||||
>
|
>
|
||||||
{saveCloudinaryMutation.isPending ? "Enregistrement..." : "Enregistrer Cloudinary"}
|
{saveCloudinaryMutation.isPending ? "Enregistrement..." : "Enregistrer MinIO"}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -57,16 +57,8 @@ export default function Settings() {
|
|||||||
setCloudName((cloudinaryConfig as any).cloudName || "");
|
setCloudName((cloudinaryConfig as any).cloudName || "");
|
||||||
setApiKey((cloudinaryConfig as any).apiKey || "");
|
setApiKey((cloudinaryConfig as any).apiKey || "");
|
||||||
|
|
||||||
// Set logo preview if exists
|
if ((cloudinaryConfig as any).logoUrl) {
|
||||||
if ((cloudinaryConfig as any).logoPublicId) {
|
setLogoPreview((cloudinaryConfig as any).logoUrl);
|
||||||
const cloudName = (cloudinaryConfig as any).cloudName;
|
|
||||||
const logoPublicId = (cloudinaryConfig as any).logoPublicId;
|
|
||||||
setLogoPreview(`https://res.cloudinary.com/${cloudName}/image/upload/${logoPublicId}`);
|
|
||||||
}
|
|
||||||
if ((cloudinaryConfig as any).logoPublicId) {
|
|
||||||
const cloudName = (cloudinaryConfig as any).cloudName;
|
|
||||||
const logoPublicId = (cloudinaryConfig as any).logoPublicId;
|
|
||||||
setLogoPreview(`https://res.cloudinary.com/${cloudName}/image/upload/${logoPublicId}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [cloudinaryConfig]);
|
}, [cloudinaryConfig]);
|
||||||
@@ -460,40 +452,40 @@ export default function Settings() {
|
|||||||
<CardHeader className="p-6">
|
<CardHeader className="p-6">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Cloud className="w-5 h-5" />
|
<Cloud className="w-5 h-5" />
|
||||||
Configuration Cloudinary
|
Configuration Stockage MinIO
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configurez Cloudinary pour le stockage d'images dans le cloud
|
Configurez MinIO pour le stockage des médias (endpoint configuré via variable d'environnement MINIO_ENDPOINT)
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cloudName">Cloud Name</Label>
|
<Label htmlFor="cloudName">Nom du bucket</Label>
|
||||||
<Input
|
<Input
|
||||||
id="cloudName"
|
id="cloudName"
|
||||||
type="text"
|
type="text"
|
||||||
value={cloudName}
|
value={cloudName}
|
||||||
onChange={(e) => setCloudName(e.target.value)}
|
onChange={(e) => setCloudName(e.target.value)}
|
||||||
placeholder="mon-cloud-name"
|
placeholder="socialflow"
|
||||||
data-testid="input-cloud-name"
|
data-testid="input-cloud-name"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Trouvez-le dans votre tableau de bord Cloudinary
|
Nom du bucket MinIO dans lequel stocker les fichiers
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="apiKey">API Key</Label>
|
<Label htmlFor="apiKey">Access Key</Label>
|
||||||
<Input
|
<Input
|
||||||
id="apiKey"
|
id="apiKey"
|
||||||
type="text"
|
type="text"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
placeholder="123456789012345"
|
placeholder="minioadmin"
|
||||||
data-testid="input-api-key"
|
data-testid="input-api-key"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="apiSecret">API Secret</Label>
|
<Label htmlFor="apiSecret">Secret Key</Label>
|
||||||
<Input
|
<Input
|
||||||
id="apiSecret"
|
id="apiSecret"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -503,7 +495,7 @@ export default function Settings() {
|
|||||||
data-testid="input-api-secret"
|
data-testid="input-api-secret"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Votre API Secret restera sécurisé et ne sera jamais exposé
|
Votre Secret Key restera sécurisé et ne sera jamais exposé
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -512,7 +504,7 @@ export default function Settings() {
|
|||||||
data-testid="button-save-cloudinary"
|
data-testid="button-save-cloudinary"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{saveCloudinaryMutation.isPending ? "Enregistrement..." : "Enregistrer Cloudinary"}
|
{saveCloudinaryMutation.isPending ? "Enregistrement..." : "Enregistrer MinIO"}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -723,7 +715,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
{!cloudinaryConfig && (
|
{!cloudinaryConfig && (
|
||||||
<p className="text-xs text-orange-600 dark:text-orange-400 text-center">
|
<p className="text-xs text-orange-600 dark:text-orange-400 text-center">
|
||||||
Veuillez d'abord configurer Cloudinary ci-dessus
|
Veuillez d'abord configurer MinIO ci-dessus
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ services:
|
|||||||
# Configuration FFmpeg Service
|
# Configuration FFmpeg Service
|
||||||
FFMPEG_API_URL: http://ffmpeg-api:8000
|
FFMPEG_API_URL: http://ffmpeg-api:8000
|
||||||
FFMPEG_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
FFMPEG_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||||
|
# Configuration MinIO (stockage fichiers)
|
||||||
|
MINIO_ENDPOINT: ${MINIO_ENDPOINT:-http://minio:9000}
|
||||||
|
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
- nginx_default
|
- nginx_default
|
||||||
|
|||||||
Generated
+1653
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@
|
|||||||
"db:push": "drizzle-kit push"
|
"db:push": "drizzle-kit push"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1016.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|||||||
+16
-20
@@ -9,7 +9,7 @@ import bcrypt from "bcrypt";
|
|||||||
import passport from "./auth";
|
import passport from "./auth";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { openRouterService } from "./services/openrouter";
|
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 { 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 type { User, InsertUser, ScheduledPost, FreesoundConfig } from "@shared/schema";
|
||||||
import { freeSoundService } from "./services/freesound";
|
import { freeSoundService } from "./services/freesound";
|
||||||
@@ -822,7 +822,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
if (cloudinaryConfig && cloudinaryConfig.logoPublicId) {
|
if (cloudinaryConfig && cloudinaryConfig.logoPublicId) {
|
||||||
try {
|
try {
|
||||||
// Build logo URL from Cloudinary
|
// 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);
|
console.log("📥 Downloading logo from:", logoUrl);
|
||||||
|
|
||||||
// Download logo
|
// Download logo
|
||||||
@@ -1097,23 +1097,18 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
return res.status(404).json({ error: "Audio track not found" });
|
return res.status(404).json({ error: "Audio track not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract publicId from cloudinary url to delete it from there too
|
// Try to delete from MinIO if the URL matches the MinIO public URL pattern
|
||||||
// e.g. https://res.cloudinary.com/demo/video/upload/v123456789/user_id/audio_name.mp3
|
const minioBase = (process.env.MINIO_PUBLIC_URL || process.env.MINIO_ENDPOINT || '').replace(/\/$/, '');
|
||||||
const urlParts = track.url.split('/upload/');
|
if (minioBase && track.url.startsWith(minioBase)) {
|
||||||
if (urlParts.length > 1) {
|
// URL = ${minioBase}/${bucket}/${objectKey} → extract objectKey
|
||||||
const afterUpload = urlParts[1];
|
const withoutBase = track.url.slice(minioBase.length + 1); // strip "base/"
|
||||||
// Remove version number (v12345...)
|
const objectKey = withoutBase.split('/').slice(1).join('/'); // strip bucket
|
||||||
let pathWithoutVersion = afterUpload;
|
if (objectKey) {
|
||||||
if (afterUpload.match(/^v\d+\//)) {
|
try {
|
||||||
pathWithoutVersion = afterUpload.replace(/^v\d+\//, '');
|
await cloudinaryService.deleteMedia(objectKey, track.userId || '', 'video');
|
||||||
}
|
} catch (minioError) {
|
||||||
// Remove extension
|
console.warn(`Failed to delete audio from MinIO: ${objectKey}`, minioError);
|
||||||
const publicId = pathWithoutVersion.replace(/\.[^/.]+$/, "");
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await cloudinaryService.deleteMedia(publicId, track.userId || '', 'video');
|
|
||||||
} catch (cloudinaryError) {
|
|
||||||
console.warn(`Failed to delete audio from cloudinary: ${publicId}`, cloudinaryError);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1636,7 +1631,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
|
|
||||||
// Don't send the API secret to the client
|
// Don't send the API secret to the client
|
||||||
const { apiSecret, ...safeConfig } = config;
|
const { apiSecret, ...safeConfig } = config;
|
||||||
res.json(safeConfig);
|
const logoUrl = config.logoPublicId ? buildMinioUrl(config.cloudName, config.logoPublicId) : null;
|
||||||
|
res.json({ ...safeConfig, logoUrl });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching Cloudinary config:", error);
|
console.error("Error fetching Cloudinary config:", error);
|
||||||
res.status(500).json({ error: "Failed to fetch Cloudinary config" });
|
res.status(500).json({ error: "Failed to fetch Cloudinary config" });
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { storage } from '../storage';
|
|||||||
import { freeSoundService, type MusicTrack } from '../services/freesound';
|
import { freeSoundService, type MusicTrack } from '../services/freesound';
|
||||||
import { ffmpegService } from '../services/ffmpeg';
|
import { ffmpegService } from '../services/ffmpeg';
|
||||||
import { facebookService } from '../services/facebook';
|
import { facebookService } from '../services/facebook';
|
||||||
import { cloudinaryService } from '../services/cloudinary';
|
import { minioService as cloudinaryService, buildMinioUrl } from '../services/minio';
|
||||||
import { openRouterService } from '../services/openrouter';
|
import { openRouterService } from '../services/openrouter';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { cloudinaryConfig } from '@shared/schema';
|
import { cloudinaryConfig } from '@shared/schema';
|
||||||
@@ -343,7 +343,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const config = await storage.getCloudinaryConfig();
|
const config = await storage.getCloudinaryConfig();
|
||||||
if (config && config.logoPublicId) {
|
if (config && config.logoPublicId) {
|
||||||
watermarkUrl = `https://res.cloudinary.com/${config.cloudName}/image/upload/${config.logoPublicId}`;
|
watermarkUrl = buildMinioUrl(config.cloudName, config.logoPublicId);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error fetching watermark configuration', e);
|
console.error('Error fetching watermark configuration', e);
|
||||||
@@ -556,7 +556,7 @@ async function processReelBackground(
|
|||||||
try {
|
try {
|
||||||
const config = await storage.getCloudinaryConfig();
|
const config = await storage.getCloudinaryConfig();
|
||||||
if (config && config.logoPublicId) {
|
if (config && config.logoPublicId) {
|
||||||
watermarkUrl = `https://res.cloudinary.com/${config.cloudName}/image/upload/${config.logoPublicId}`;
|
watermarkUrl = buildMinioUrl(config.cloudName, config.logoPublicId);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error fetching watermark configuration', e);
|
console.error('Error fetching watermark configuration', e);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { bundle } from "@remotion/bundler";
|
|||||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||||
import { ffmpegService } from "../services/ffmpeg";
|
import { ffmpegService } from "../services/ffmpeg";
|
||||||
import { storage as dbStorage } from "../storage";
|
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 { facebookService } from "../services/facebook";
|
||||||
import * as musicMetadata from "music-metadata";
|
import * as musicMetadata from "music-metadata";
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
|
|||||||
try {
|
try {
|
||||||
const cloudinaryConfig = await dbStorage.getCloudinaryConfig();
|
const cloudinaryConfig = await dbStorage.getCloudinaryConfig();
|
||||||
if (cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId) {
|
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);
|
console.log("🏢 Logo URL:", logoUrl);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { media } from '@shared/schema';
|
import { media } from '@shared/schema';
|
||||||
import { eq, lt, isNotNull, and } from 'drizzle-orm';
|
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.
|
* Automates the cleanup of old media to save Cloudinary storage space.
|
||||||
|
|||||||
@@ -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();
|
||||||
Reference in new issue
Block a user