Files
Socialflow/server/services/minio.ts
T
Claude b95cd23ebc feat(tiktok): publier les reels sur les comptes TikTok des magasins
Ajoute la gestion multi-comptes TikTok et permet d'envoyer la même vidéo
sur plusieurs pages Facebook ET plusieurs comptes TikTok en une seule
publication.

Un compte TikTok vit dans social_pages comme une page Facebook : il hérite
donc des permissions par utilisateur, de la planification et du calendrier.

Trois particularités de l'API TikTok structurent l'implémentation :
- pas de jeton saisi à la main : chaque compte passe par OAuth, et
  l'access token (24h) est renouvelé via le refresh token (1 an) avant
  chaque publication ;
- la publication est asynchrone : l'upload rend un publish_id, et un
  poller récupère l'identifiant définitif du post ;
- creator_info doit être interrogé avant chaque envoi pour ne demander
  qu'un niveau de confidentialité réellement autorisé sur le compte.

Serveur :
- service TikTok (OAuth, creator_info, upload FILE_UPLOAD par chunks,
  suivi de statut) et routes de connexion/configuration
- schéma : platform 'tiktok', refresh token et scopes sur social_pages,
  publish_id/publish_status sur scheduled_posts, table tiktok_config
- routage par plateforme dans les deux flux de reels et le planificateur
- cron de suivi des publications, token manager et analytics adaptés

Client :
- connexion et reconnexion des comptes TikTok depuis « Pages gérées »
- sélection combinée pages Facebook / comptes TikTok à la publication
- configuration de l'application TikTok dans les paramètres (admin)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 14:29:38 +00:00

129 lines
4.3 KiB
TypeScript

import fs from 'fs';
import path from 'path';
const UPLOADS_BASE = path.resolve(process.cwd(), 'uploads');
function ensureDir(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Builds the public URL for a locally stored file.
* Returns a relative path so it works on any domain/protocol.
* objectKey is the relative path from uploads/ (e.g. "media/123-file.jpg").
*/
export function buildMinioUrl(_bucketName: string, objectKey: string, _publicUrlOverride?: string | null): string {
return `/uploads/${objectKey}`;
}
/**
* Resolves a possibly-relative URL to an absolute URL for use by external
* services (Facebook API, Instagram API) that must download the file.
* Uses APP_URL env var (public-facing domain).
*/
export function resolvePublicUrl(url: string): string {
if (!url || !url.startsWith('/')) return url;
const base = (process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, '');
return `${base}${url}`;
}
/**
* Resolves a possibly-relative URL to an absolute URL for use by internal
* Docker services (FFmpeg, etc.) that communicate via internal Docker network.
* Uses INTERNAL_APP_URL env var.
*/
export function resolveInternalUrl(url: string): string {
if (!url || !url.startsWith('/')) return url;
const base = (process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, '');
return `${base}${url}`;
}
/**
* Charge un média en mémoire, que son URL soit locale (/uploads/...) ou distante.
* Nécessaire pour les APIs qui veulent recevoir le fichier plutôt qu'une URL
* (upload TikTok, Reels Facebook).
*/
export async function readMediaBuffer(url: string): Promise<Buffer> {
if (url.startsWith('/')) {
const localPath = path.join(process.cwd(), url);
if (fs.existsSync(localPath)) {
return await fs.promises.readFile(localPath);
}
}
const absoluteUrl = resolvePublicUrl(url);
const response = await fetch(absoluteUrl);
if (!response.ok) {
throw new Error(`Impossible de récupérer le média depuis ${absoluteUrl} (${response.status})`);
}
return Buffer.from(await response.arrayBuffer());
}
class LocalStorageService {
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 dir = path.join(UPLOADS_BASE, 'media');
ensureDir(dir);
const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '-');
const objectKey = `media/${Date.now()}-${safeName}`;
const filePath = path.join(UPLOADS_BASE, objectKey);
const body: Buffer = typeof file === 'string' ? fs.readFileSync(file) : file;
fs.writeFileSync(filePath, body);
const url = buildMinioUrl('', objectKey);
return { publicId: objectKey, originalUrl: url, facebookFeedUrl: url, instagramFeedUrl: url, instagramStoryUrl: url };
}
async deleteMedia(publicId: string, _userId: string, _mediaType: 'image' | 'video'): Promise<void> {
const filePath = path.join(UPLOADS_BASE, publicId);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise<string> {
const dir = path.join(UPLOADS_BASE, 'stories');
ensureDir(dir);
const safeName = path.basename(originalFileName).replace(/[^a-zA-Z0-9._-]/g, '-');
const objectKey = `stories/story-${Date.now()}-${safeName}`;
fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer);
return buildMinioUrl('', objectKey);
}
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const dir = path.join(UPLOADS_BASE, 'logos');
ensureDir(dir);
const ext = path.extname(fileName) || '.png';
const objectKey = `logos/logo-${Date.now()}${ext}`;
fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer);
return { publicId: objectKey, url: buildMinioUrl('', objectKey) };
}
async deleteLogo(publicId: string): Promise<void> {
const filePath = path.join(UPLOADS_BASE, publicId);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
}
export const minioService = new LocalStorageService();