384 lines
15 KiB
PHP
384 lines
15 KiB
PHP
<?php
|
|
/**
|
|
* Simple MediaService
|
|
* - fetchFromGraph(mediaId) -> gets Graph API URL and downloads
|
|
* - storeContent($bytes, $destPath)
|
|
* - createThumbnail($srcPath, $thumbPath)
|
|
*/
|
|
class MediaService {
|
|
private $token;
|
|
private $apiUrl;
|
|
private $uploadsDir;
|
|
|
|
public function __construct() {
|
|
// Usar constante WHATSAPP_TOKEN (que ya tiene fallback a env var) si getConfigFromDB retorna vacío
|
|
$dbToken = getConfigFromDB('whatsapp_token', '');
|
|
$this->token = !empty($dbToken) ? $dbToken : (defined('WHATSAPP_TOKEN') ? WHATSAPP_TOKEN : getenv('WHATSAPP_TOKEN'));
|
|
|
|
$dbApiUrl = getConfigFromDB('whatsapp_api_url', '');
|
|
$this->apiUrl = rtrim(!empty($dbApiUrl) ? $dbApiUrl : (defined('WHATSAPP_API_URL') ? WHATSAPP_API_URL : 'https://graph.facebook.com/v22.0/'), '/');
|
|
|
|
$this->uploadsDir = __DIR__ . '/../uploads/media';
|
|
if (!is_dir($this->uploadsDir)) mkdir($this->uploadsDir, 0755, true);
|
|
}
|
|
|
|
/**
|
|
* Hacer petición GET al Graph API con fallback si curl falla
|
|
*/
|
|
private function graphApiRequest($url) {
|
|
// Intentar con curl primero
|
|
$ch = curl_init();
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_URL => $url,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->token],
|
|
CURLOPT_TIMEOUT => 15,
|
|
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
|
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$err = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if (!$err && $http >= 200 && $http < 400 && $resp) {
|
|
return $resp;
|
|
}
|
|
|
|
// Fallback: file_get_contents con stream context
|
|
error_log("[MediaService::graphApiRequest] curl failed (http={$http} err={$err}), trying file_get_contents...");
|
|
return $this->fileGetContentsWithAuth($url);
|
|
}
|
|
|
|
public function fetchAndStoreFromGraph($mediaId, $subdir = '') {
|
|
// Obtener metadata (url) via Graph API
|
|
$endpoint = "{$this->apiUrl}/{$mediaId}";
|
|
$resp = $this->graphApiRequest($endpoint);
|
|
if ($resp === false) {
|
|
throw new Exception('Graph API fetch failed for media ' . $mediaId);
|
|
}
|
|
$decoded = json_decode($resp, true);
|
|
$mediaUrl = $decoded['url'] ?? ($decoded['data'][0]['url'] ?? null);
|
|
if (!$mediaUrl) throw new Exception('No media url from Graph');
|
|
|
|
// Descargar contenido usando cascade robusto (curl, file_get_contents, shell/proc/wget)
|
|
$content = $this->downloadUrl($mediaUrl);
|
|
if ($content === false) {
|
|
// Obtener URL fresca del Graph API (la anterior puede haber expirado tras el intento fallido)
|
|
error_log("[MediaService] downloadUrl failed, getting fresh URL...");
|
|
$resp2 = $this->graphApiRequest($endpoint);
|
|
if ($resp2) {
|
|
$decoded2 = json_decode($resp2, true);
|
|
$freshUrl = $decoded2['url'] ?? $mediaUrl;
|
|
} else {
|
|
$freshUrl = $mediaUrl;
|
|
}
|
|
$content = $this->downloadUrl($freshUrl);
|
|
}
|
|
if ($content === false) throw new Exception('Failed to download media content from ' . $mediaUrl);
|
|
|
|
// Detectar extension desde mime o url
|
|
$mime = $decoded['mime_type'] ?? null;
|
|
$ext = $this->extensionFromMime($mime) ?: pathinfo(parse_url($mediaUrl, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'bin';
|
|
|
|
$dir = $this->uploadsDir . ($subdir ? '/' . trim($subdir, '/') : '');
|
|
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
|
|
|
$filename = uniqid('m_') . '.' . $ext;
|
|
$fullPath = $dir . '/' . $filename;
|
|
file_put_contents($fullPath, $content);
|
|
|
|
$result = ['local_file' => 'uploads/media' . ($subdir ? '/' . trim($subdir, '/') : '') . '/' . $filename, 'mime_type' => $mime, 'size' => filesize($fullPath)];
|
|
|
|
// Si es imagen, generar miniatura
|
|
if ($this->isImageMime($mime) || preg_match('#^jpe?g|png|gif$#i', $ext)) {
|
|
$thumbPath = $dir . '/thumb_' . $filename . '.jpg';
|
|
$thumbRel = dirname($result['local_file']) . '/thumb_' . $filename . '.jpg';
|
|
if ($this->createThumbnail($fullPath, $thumbPath)) {
|
|
$result['local_thumb'] = $thumbRel;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
// Nuevo: descargar y almacenar desde una URL directa
|
|
public function fetchAndStoreFromUrl($url, $subdir = '') {
|
|
$content = $this->downloadUrl($url);
|
|
if ($content === false) {
|
|
$content = $this->downloadUrl($url, ['Referer: https://graph.facebook.com']);
|
|
}
|
|
if ($content === false) throw new Exception('Failed to download media content from url ' . $url);
|
|
|
|
// intentar inferir mime
|
|
$mime = null;
|
|
$ext = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'bin';
|
|
$dir = $this->uploadsDir . ($subdir ? '/' . trim($subdir, '/') : '');
|
|
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
|
$filename = uniqid('m_') . '.' . $ext;
|
|
$fullPath = $dir . '/' . $filename;
|
|
file_put_contents($fullPath, $content);
|
|
|
|
$result = ['local_file' => 'uploads/media' . ($subdir ? '/' . trim($subdir, '/') : '') . '/' . $filename, 'mime_type' => $mime, 'size' => filesize($fullPath)];
|
|
|
|
// Si parece imagen, intentar crear thumb
|
|
if (preg_match('#jpe?g|png|gif#i', $ext)) {
|
|
$thumbPath = $dir . '/thumb_' . $filename . '.jpg';
|
|
$thumbRel = dirname($result['local_file']) . '/thumb_' . $filename . '.jpg';
|
|
if ($this->createThumbnail($fullPath, $thumbPath)) {
|
|
$result['local_thumb'] = $thumbRel;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function downloadUrl($url, $extraHeaders = []) {
|
|
// Incluir Authorization Bearer si está configurado (necesario para algunos endpoints)
|
|
$authHeader = [];
|
|
if (!empty($this->token)) {
|
|
$authHeader[] = 'Authorization: Bearer ' . $this->token;
|
|
}
|
|
$headers = array_merge(['User-Agent: WhatsAppMediaFetcher/1.0', 'Accept: */*'], $authHeader, $extraHeaders);
|
|
|
|
$ch = curl_init();
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_URL => $url,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_CONNECTTIMEOUT => 30,
|
|
CURLOPT_TIMEOUT => 180,
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
CURLOPT_BUFFERSIZE => 65536,
|
|
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
|
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
|
]);
|
|
$content = curl_exec($ch);
|
|
$err = curl_error($ch);
|
|
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$errno = curl_errno($ch);
|
|
curl_close($ch);
|
|
|
|
// Si curl funcionó correctamente, retornar contenido
|
|
if (!$err && $http >= 200 && $http < 400 && $content !== false && strlen($content) > 0) {
|
|
return $content;
|
|
}
|
|
|
|
// Fallback: file_get_contents con stream context (usa implementación SSL diferente a curl)
|
|
error_log("[MediaService::downloadUrl] curl failed (http={$http} errno={$errno} err={$err}), trying file_get_contents fallback...");
|
|
$content = $this->fileGetContentsWithAuth($url);
|
|
if ($content !== false && strlen($content) > 0) {
|
|
error_log("[MediaService::downloadUrl] file_get_contents OK, " . strlen($content) . " bytes");
|
|
return $content;
|
|
}
|
|
|
|
// Fallback: exec wget (solo funciona en CLI, no en php-fpm con exec deshabilitado)
|
|
$content = $this->shellDownload($url, $headers);
|
|
if ($content !== false) {
|
|
return $content;
|
|
}
|
|
|
|
error_log("[MediaService::downloadUrl] FAIL url=" . substr($url, 0, 120) . " http={$http} err={$err} all_fallbacks_failed");
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Descargar usando file_get_contents con stream context (usa implementación SSL nativa de PHP, diferente a curl)
|
|
*/
|
|
private function fileGetContentsWithAuth($url) {
|
|
$opts = [
|
|
'http' => [
|
|
'method' => 'GET',
|
|
'header' => "Authorization: Bearer {$this->token}\r\nUser-Agent: WhatsAppMediaFetcher/1.0\r\nAccept: */*\r\n",
|
|
'follow_location' => true,
|
|
'max_redirects' => 5,
|
|
'timeout' => 120,
|
|
'ignore_errors' => false,
|
|
],
|
|
'ssl' => [
|
|
'verify_peer' => true,
|
|
'verify_peer_name' => true,
|
|
],
|
|
];
|
|
$ctx = stream_context_create($opts);
|
|
$content = @file_get_contents($url, false, $ctx);
|
|
if ($content === false) {
|
|
$lastErr = error_get_last();
|
|
error_log("[MediaService::fileGetContentsWithAuth] FAIL: " . ($lastErr['message'] ?? 'unknown'));
|
|
return false;
|
|
}
|
|
return $content;
|
|
}
|
|
|
|
/**
|
|
* Fallback: descargar con wget via exec/proc_open (solo funciona en CLI)
|
|
*/
|
|
private function shellDownload($url, $headers = []) {
|
|
// Verificar si exec está realmente disponible
|
|
// php_admin_value[disable_functions] NO aparece en ini_get('disable_functions')
|
|
// así que hacemos un test real con try/catch
|
|
$execAvailable = function_exists('exec');
|
|
if ($execAvailable) {
|
|
try {
|
|
@exec('echo 1', $testOut, $testRc);
|
|
} catch (\Error $e) {
|
|
$execAvailable = false;
|
|
}
|
|
}
|
|
|
|
if (!$execAvailable) {
|
|
return $this->procOpenDownload($url, $headers);
|
|
}
|
|
|
|
$tmpFile = tempnam(sys_get_temp_dir(), 'wget_');
|
|
$headerArgs = '';
|
|
foreach ($headers as $h) {
|
|
$headerArgs .= ' --header=' . escapeshellarg($h);
|
|
}
|
|
$cmd = 'wget -q --prefer-family=IPv4 -O ' . escapeshellarg($tmpFile) . $headerArgs . ' ' . escapeshellarg($url) . ' 2>&1';
|
|
try {
|
|
exec($cmd, $output, $exitCode);
|
|
} catch (\Error $e) {
|
|
error_log("[MediaService::shellDownload] exec() threw Error: " . $e->getMessage());
|
|
if (file_exists($tmpFile)) unlink($tmpFile);
|
|
return $this->procOpenDownload($url, $headers);
|
|
}
|
|
|
|
if ($exitCode === 0 && file_exists($tmpFile) && filesize($tmpFile) > 0) {
|
|
$content = file_get_contents($tmpFile);
|
|
unlink($tmpFile);
|
|
error_log("[MediaService::shellDownload] wget OK, " . strlen($content) . " bytes");
|
|
return $content;
|
|
}
|
|
|
|
error_log("[MediaService::shellDownload] wget FAIL exit={$exitCode}");
|
|
if (file_exists($tmpFile)) unlink($tmpFile);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Descargar con proc_open (alternativa a exec)
|
|
*/
|
|
private function procOpenDownload($url, $headers = []) {
|
|
// Test real de disponibilidad: php_admin_value no se refleja en ini_get
|
|
$procAvailable = function_exists('proc_open');
|
|
if ($procAvailable) {
|
|
try {
|
|
$testProc = @proc_open('echo 1', [1 => ['pipe', 'w']], $testPipes);
|
|
if (is_resource($testProc)) {
|
|
fclose($testPipes[1]);
|
|
proc_close($testProc);
|
|
} else {
|
|
$procAvailable = false;
|
|
}
|
|
} catch (\Error $e) {
|
|
$procAvailable = false;
|
|
}
|
|
}
|
|
if (!$procAvailable) {
|
|
error_log("[MediaService::procOpenDownload] proc_open not available, skipping");
|
|
return false;
|
|
}
|
|
|
|
$tmpFile = tempnam(sys_get_temp_dir(), 'wget_');
|
|
$headerArgs = '';
|
|
foreach ($headers as $h) {
|
|
$headerArgs .= ' --header=' . escapeshellarg($h);
|
|
}
|
|
$cmd = 'wget -q --prefer-family=IPv4 -O ' . escapeshellarg($tmpFile) . $headerArgs . ' ' . escapeshellarg($url);
|
|
|
|
try {
|
|
$proc = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
|
} catch (\Error $e) {
|
|
error_log("[MediaService::procOpenDownload] proc_open() threw Error: " . $e->getMessage());
|
|
if (file_exists($tmpFile)) unlink($tmpFile);
|
|
return false;
|
|
}
|
|
if (!is_resource($proc)) {
|
|
error_log("[MediaService::procOpenDownload] proc_open failed");
|
|
if (file_exists($tmpFile)) unlink($tmpFile);
|
|
return false;
|
|
}
|
|
$stdout = stream_get_contents($pipes[1]); fclose($pipes[1]);
|
|
$stderr = stream_get_contents($pipes[2]); fclose($pipes[2]);
|
|
$exitCode = proc_close($proc);
|
|
|
|
if ($exitCode === 0 && file_exists($tmpFile) && filesize($tmpFile) > 0) {
|
|
$content = file_get_contents($tmpFile);
|
|
unlink($tmpFile);
|
|
error_log("[MediaService::procOpenDownload] OK, " . strlen($content) . " bytes");
|
|
return $content;
|
|
}
|
|
|
|
error_log("[MediaService::procOpenDownload] FAIL exit={$exitCode} err={$stderr}");
|
|
if (file_exists($tmpFile)) unlink($tmpFile);
|
|
return false;
|
|
}
|
|
|
|
public function extensionFromMime($mime) {
|
|
if (!$mime) return null;
|
|
// Remover parámetros del mime type (ej: "audio/ogg; codecs=opus" -> "audio/ogg")
|
|
$baseMime = strtolower(trim(explode(';', $mime)[0]));
|
|
$map = [
|
|
'image/jpeg' => 'jpg',
|
|
'image/png' => 'png',
|
|
'image/gif' => 'gif',
|
|
'image/webp' => 'webp',
|
|
'image/svg+xml' => 'svg',
|
|
'audio/mpeg' => 'mp3',
|
|
'audio/ogg' => 'ogg',
|
|
'audio/opus' => 'ogg',
|
|
'audio/aac' => 'aac',
|
|
'audio/amr' => 'amr',
|
|
'audio/mp4' => 'm4a',
|
|
'video/mp4' => 'mp4',
|
|
'video/3gpp' => '3gp',
|
|
'video/quicktime' => 'mov',
|
|
'video/webm' => 'webm',
|
|
'text/csv' => 'csv',
|
|
'text/plain' => 'txt',
|
|
'application/pdf' => 'pdf',
|
|
'application/msword' => 'doc',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
|
'application/vnd.ms-excel' => 'xls',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
|
'application/vnd.ms-powerpoint' => 'ppt',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
|
|
'application/zip' => 'zip',
|
|
'application/x-rar-compressed' => 'rar',
|
|
'application/octet-stream' => 'bin',
|
|
];
|
|
return $map[$baseMime] ?? null;
|
|
}
|
|
|
|
public function isImageMime($mime) {
|
|
return strpos($mime, 'image/') === 0;
|
|
}
|
|
|
|
public function createThumbnail($src, $dest, $maxW = 400, $maxH = 400) {
|
|
if (!function_exists('getimagesize')) return false;
|
|
$info = @getimagesize($src);
|
|
if (!$info) return false;
|
|
list($w, $h) = $info;
|
|
$mime = $info['mime'];
|
|
|
|
switch ($mime) {
|
|
case 'image/jpeg': $srcImg = imagecreatefromjpeg($src); break;
|
|
case 'image/png': $srcImg = imagecreatefrompng($src); break;
|
|
case 'image/gif': $srcImg = imagecreatefromgif($src); break;
|
|
default: return false;
|
|
}
|
|
|
|
$ratio = min($maxW / $w, $maxH / $h, 1);
|
|
$nw = (int)($w * $ratio);
|
|
$nh = (int)($h * $ratio);
|
|
|
|
$thumb = imagecreatetruecolor($nw, $nh);
|
|
imagecopyresampled($thumb, $srcImg, 0,0,0,0, $nw, $nh, $w, $h);
|
|
imagejpeg($thumb, $dest, 85);
|
|
imagedestroy($thumb);
|
|
imagedestroy($srcImg);
|
|
return true;
|
|
}
|
|
}
|