This commit is contained in:
Lizandro Guarnizo
2026-02-21 07:37:36 -05:00
parent 2f73ee2a1a
commit de4b07c806
14 changed files with 2001 additions and 23 deletions
+327
View File
@@ -0,0 +1,327 @@
<?php
/**
* API: Recibir archivos del cliente (público — sin auth de panel)
*
* El cliente sube archivos desde la página upload.php usando un token.
* Los archivos se validan, guardan en disco e insertan en la BD.
* Además se crea un mensaje en la conversación y una notificación para el operador.
*
* POST /api/file_request_upload.php
* Body: multipart/form-data { token: string, file: File }
*
* Respuesta: { success: bool, file_id: int }
*/
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', 0);
// Permitir uploads grandes
ini_set('upload_max_filesize', '55M');
ini_set('post_max_size', '60M');
ini_set('max_execution_time', 120);
require_once __DIR__ . '/../config/config.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Método no permitido']);
exit;
}
try {
$token = $_POST['token'] ?? '';
// Validar token
if (empty($token) || !preg_match('/^[a-zA-Z0-9]{32,64}$/', $token)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Token no válido']);
exit;
}
// Validar archivo
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
$errorMsg = 'No se recibió el archivo';
if (isset($_FILES['file'])) {
$uploadErrors = [
UPLOAD_ERR_INI_SIZE => 'El archivo excede el tamaño máximo del servidor',
UPLOAD_ERR_FORM_SIZE => 'El archivo excede el tamaño máximo del formulario',
UPLOAD_ERR_PARTIAL => 'El archivo se subió parcialmente',
UPLOAD_ERR_NO_FILE => 'No se envió ningún archivo',
UPLOAD_ERR_NO_TMP_DIR => 'Falta la carpeta temporal del servidor',
UPLOAD_ERR_CANT_WRITE => 'No se pudo escribir el archivo en disco',
];
$errorMsg = $uploadErrors[$_FILES['file']['error']] ?? 'Error de subida desconocido';
}
http_response_code(400);
echo json_encode(['success' => false, 'error' => $errorMsg]);
exit;
}
$db = Database::getInstance();
// Buscar solicitud por token
$request = $db->fetch(
"SELECT fr.*, u.name as user_name, u.phone_number as user_phone
FROM file_requests fr
LEFT JOIN users u ON fr.user_id = u.id
WHERE fr.token = ?",
[$token]
);
if (!$request) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Solicitud no encontrada']);
exit;
}
// Verificar expiración
if (strtotime($request['expires_at']) < time()) {
http_response_code(410);
echo json_encode(['success' => false, 'error' => 'El enlace ha expirado']);
exit;
}
// Verificar estado
if ($request['status'] === 'cancelled') {
http_response_code(410);
echo json_encode(['success' => false, 'error' => 'La solicitud fue cancelada']);
exit;
}
$file = $_FILES['file'];
$maxSize = intval($request['max_file_size']);
// Validar tamaño
if ($file['size'] > $maxSize) {
$maxMB = round($maxSize / (1024 * 1024));
http_response_code(413);
echo json_encode(['success' => false, 'error' => "El archivo excede el límite de {$maxMB} MB"]);
exit;
}
// Detectar MIME type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!$mimeType) {
$mimeType = $file['type'] ?: 'application/octet-stream';
}
// Clasificar tipo de media
$mediaType = 'other';
if (strpos($mimeType, 'image/') === 0) $mediaType = 'image';
elseif (strpos($mimeType, 'video/') === 0) $mediaType = 'video';
elseif (strpos($mimeType, 'audio/') === 0) $mediaType = 'audio';
elseif (strpos($mimeType, 'application/') === 0 || strpos($mimeType, 'text/') === 0) $mediaType = 'document';
// Validar tipo permitido
$allowedTypes = explode(',', $request['allowed_types']);
$typeAllowed = in_array($mediaType, $allowedTypes) || in_array('other', $allowedTypes);
if (!$typeAllowed && $mediaType === 'other') {
// Dar paso a "other" si document está permitido
$typeAllowed = in_array('document', $allowedTypes);
}
if (!$typeAllowed) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Tipo de archivo no permitido']);
exit;
}
// Crear directorio de destino
$uploadDir = __DIR__ . '/../uploads/file_requests/' . date('Y') . '/' . date('m');
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// Generar nombre único
$ext = pathinfo($file['name'], PATHINFO_EXTENSION) ?: 'bin';
$ext = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $ext));
$storedName = 'fr_' . $request['id'] . '_' . uniqid() . '.' . $ext;
$destPath = $uploadDir . '/' . $storedName;
$relativePath = 'uploads/file_requests/' . date('Y') . '/' . date('m') . '/' . $storedName;
// Mover archivo
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'No se pudo guardar el archivo en disco']);
exit;
}
// Generar thumbnail para imágenes
$thumbnailPath = null;
if ($mediaType === 'image' && class_exists('GdImage') || function_exists('imagecreatefromjpeg')) {
try {
$thumbName = 'thumb_' . $storedName;
$thumbDest = $uploadDir . '/' . $thumbName;
if (createThumbnail($destPath, $thumbDest, 200, 200)) {
$thumbnailPath = 'uploads/file_requests/' . date('Y') . '/' . date('m') . '/' . $thumbName;
}
} catch (Exception $e) {
// Thumbnail falla silenciosamente
error_log('Thumbnail error: ' . $e->getMessage());
}
}
// Guardar en BD - tabla file_request_uploads
$uploadId = $db->insert('file_request_uploads', [
'request_id' => $request['id'],
'original_filename' => $file['name'],
'stored_filename' => $storedName,
'file_path' => $relativePath,
'file_size' => $file['size'],
'mime_type' => $mimeType,
'media_type' => $mediaType,
'thumbnail_path' => $thumbnailPath,
]);
// Actualizar estado de la solicitud a "uploaded"
$db->update('file_requests', ['status' => 'uploaded'], 'id = ?', [$request['id']]);
// Generar URL pública del archivo
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$appUrl = $protocol . '://' . $_SERVER['HTTP_HOST'];
$fileUrl = $appUrl . '/' . $relativePath;
$thumbUrl = $thumbnailPath ? ($appUrl . '/' . $thumbnailPath) : null;
// Insertar como mensaje en la conversación (para que el operador lo vea en el chat)
$db->insert('conversations', [
'user_id' => $request['user_id'],
'direction' => 'incoming',
'message_type' => $mediaType === 'other' ? 'document' : $mediaType,
'content' => '📎 Archivo recibido via enlace: ' . $file['name'],
'media_url' => $fileUrl,
'local_file' => $relativePath,
'local_thumb' => $thumbnailPath,
'status' => 'received',
'is_read' => 0,
'filename' => $file['name'],
'mime_type' => $mimeType,
'created_at' => date('Y-m-d H:i:s'),
]);
// Crear notificación para el operador
$userName = $request['user_name'] ?: $request['phone_number'];
$db->insert('notifications', [
'user_id' => $request['user_id'],
'type' => 'file_uploaded',
'message' => "📎 {$userName} subió un archivo: {$file['name']}",
'data' => json_encode([
'request_id' => $request['id'],
'upload_id' => $uploadId,
'filename' => $file['name'],
'file_url' => $fileUrl,
'media_type' => $mediaType,
'file_size' => $file['size'],
]),
'is_read' => 0,
]);
// Escribir evento SSE para actualización en tiempo real
try {
$eventsFile = __DIR__ . '/../uploads/events_global.json';
$events = [];
if (file_exists($eventsFile)) {
$raw = file_get_contents($eventsFile);
$events = json_decode($raw, true) ?: [];
}
$events[] = [
'type' => 'file_uploaded',
'user_id' => $request['user_id'],
'data' => [
'request_id' => $request['id'],
'upload_id' => $uploadId,
'filename' => $file['name'],
'file_url' => $fileUrl,
'thumb_url' => $thumbUrl,
'media_type' => $mediaType,
'file_size' => $file['size'],
'user_name' => $userName,
],
'timestamp' => time(),
];
file_put_contents($eventsFile, json_encode($events));
} catch (Exception $e) {
error_log('Error escribiendo evento SSE: ' . $e->getMessage());
}
echo json_encode([
'success' => true,
'file_id' => $uploadId,
'filename' => $file['name'],
'file_url' => $fileUrl,
'media_type' => $mediaType,
'file_size' => $file['size'],
]);
} catch (Exception $e) {
$errorDetail = date('[Y-m-d H:i:s] ') . 'file_request_upload ERROR: ' . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n\n";
error_log('Error en file_request_upload: ' . $e->getMessage() . ' | Trace: ' . $e->getTraceAsString());
@file_put_contents(__DIR__ . '/../logs/app_errors.log', $errorDetail, FILE_APPEND);
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Error interno del servidor']);
}
/**
* Crear thumbnail de una imagen usando GD
*/
function createThumbnail($source, $dest, $maxW = 200, $maxH = 200) {
$info = @getimagesize($source);
if (!$info) return false;
$mime = $info['mime'];
$origW = $info[0];
$origH = $info[1];
switch ($mime) {
case 'image/jpeg': $img = @imagecreatefromjpeg($source); break;
case 'image/png': $img = @imagecreatefrompng($source); break;
case 'image/gif': $img = @imagecreatefromgif($source); break;
case 'image/webp': $img = @imagecreatefromwebp($source); break;
default: return false;
}
if (!$img) return false;
// Calcular dimensiones
$ratio = min($maxW / $origW, $maxH / $origH);
if ($ratio >= 1) {
// La imagen ya es más pequeña que el thumb
imagedestroy($img);
return copy($source, $dest);
}
$newW = intval($origW * $ratio);
$newH = intval($origH * $ratio);
$thumb = imagecreatetruecolor($newW, $newH);
// Preservar transparencia para PNG/GIF
if ($mime === 'image/png' || $mime === 'image/gif') {
imagealphablending($thumb, false);
imagesavealpha($thumb, true);
$transparent = imagecolorallocatealpha($thumb, 0, 0, 0, 127);
imagefilledrectangle($thumb, 0, 0, $newW, $newH, $transparent);
}
imagecopyresampled($thumb, $img, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
// Guardar como JPEG (más ligero para thumbs)
$result = imagejpeg($thumb, $dest, 85);
imagedestroy($img);
imagedestroy($thumb);
return $result;
}
+94
View File
@@ -0,0 +1,94 @@
<?php
/**
* API: Consultar solicitudes de archivos grandes de un usuario
*
* GET /api/get_file_requests.php?user_id=123
* GET /api/get_file_requests.php?user_id=123&status=pending
*
* Respuesta: { success: bool, requests: [...], uploads: [...] }
*/
require_once __DIR__ . '/../config/config.php';
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', 0);
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Método no permitido']);
exit;
}
try {
$userId = intval($_GET['user_id'] ?? 0);
$status = trim($_GET['status'] ?? '');
if (!$userId) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Se requiere user_id']);
exit;
}
$db = Database::getInstance();
// Obtener solicitudes
$sql = "SELECT fr.*,
CASE WHEN fr.expires_at < NOW() AND fr.status = 'pending' THEN 'expired' ELSE fr.status END as effective_status,
(SELECT COUNT(*) FROM file_request_uploads fru WHERE fru.request_id = fr.id) as upload_count
FROM file_requests fr
WHERE fr.user_id = ?";
$params = [$userId];
if (!empty($status)) {
if ($status === 'active') {
$sql .= " AND fr.status IN ('pending','uploaded') AND fr.expires_at > NOW()";
} else {
$sql .= " AND fr.status = ?";
$params[] = $status;
}
}
$sql .= " ORDER BY fr.created_at DESC LIMIT 20";
$requests = $db->fetchAll($sql, $params);
// Obtener uploads de las solicitudes
$uploads = [];
$requestIds = array_column($requests, 'id');
if (!empty($requestIds)) {
$placeholders = implode(',', array_fill(0, count($requestIds), '?'));
$uploads = $db->fetchAll(
"SELECT fru.*, fr.token as request_token
FROM file_request_uploads fru
JOIN file_requests fr ON fru.request_id = fr.id
WHERE fru.request_id IN ({$placeholders})
ORDER BY fru.created_at DESC",
$requestIds
);
// Agregar URLs públicas
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$appUrl = $protocol . '://' . $_SERVER['HTTP_HOST'];
foreach ($uploads as &$upload) {
$upload['file_url'] = $appUrl . '/' . $upload['file_path'];
$upload['thumb_url'] = $upload['thumbnail_path']
? $appUrl . '/' . $upload['thumbnail_path']
: null;
}
}
echo json_encode([
'success' => true,
'requests' => $requests,
'uploads' => $uploads,
]);
} catch (Exception $e) {
error_log('Error en get_file_requests: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Error interno']);
}
+156
View File
@@ -0,0 +1,156 @@
<?php
/**
* API: Solicitar archivo grande
*
* Genera un token único, crea la solicitud en BD y envía el enlace
* al cliente por WhatsApp.
*
* POST /api/request_large_file.php
* Body: { user_id: int, phone_number: string }
*
* Respuesta: { success: bool, request_id: int, token: string, upload_url: string }
*/
require_once __DIR__ . '/../config/config.php';
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', 0);
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Método no permitido']);
exit;
}
try {
$input = json_decode(file_get_contents('php://input'), true);
$userId = intval($input['user_id'] ?? 0);
$phoneNumber = trim($input['phone_number'] ?? '');
if (!$userId || empty($phoneNumber)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Se requiere user_id y phone_number']);
exit;
}
$db = Database::getInstance();
// Verificar que el usuario existe
$user = $db->fetch("SELECT id, phone_number, name FROM users WHERE id = ?", [$userId]);
if (!$user) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Usuario no encontrado']);
exit;
}
// Usar el teléfono de la BD si no se proporcionó
$phone = !empty($phoneNumber) ? $phoneNumber : $user['phone_number'];
// Verificar si hay una solicitud pendiente activa (no expirada)
$existing = $db->fetch(
"SELECT id, token, expires_at FROM file_requests
WHERE user_id = ? AND status = 'pending' AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1",
[$userId]
);
if ($existing) {
// Reutilizar solicitud existente — reenviar enlace
$token = $existing['token'];
$requestId = $existing['id'];
} else {
// Generar nuevo token único (48 caracteres alfanuméricos)
$token = bin2hex(random_bytes(24));
// Operador que solicita
$requestedBy = $_SESSION['user_id'] ?? null;
// Crear solicitud — expira en 24 horas
$expiresAt = date('Y-m-d H:i:s', strtotime('+24 hours'));
$requestId = $db->insert('file_requests', [
'token' => $token,
'user_id' => $userId,
'phone_number' => $phone,
'requested_by' => $requestedBy,
'status' => 'pending',
'max_file_size' => 52428800, // 50MB
'allowed_types' => 'image,document',
'expires_at' => $expiresAt,
]);
}
// Construir URL de carga
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$appUrl = $protocol . '://' . $_SERVER['HTTP_HOST'];
$uploadUrl = $appUrl . '/upload.php?token=' . $token;
// Enviar mensaje por WhatsApp con el enlace
$whatsapp = new WhatsAppService();
// Mensaje amigable con el enlace
$message = "📎 *Enlace para enviar archivos grandes*\n\n"
. "Hola" . ($user['name'] ? ", " . $user['name'] : "") . ".\n\n"
. "Le enviamos este enlace seguro para que pueda subir archivos de gran tamaño (hasta 50 MB):\n\n"
. "👉 " . $uploadUrl . "\n\n"
. "✅ Puede subir imágenes o documentos.\n"
. "🔒 El enlace es seguro y exclusivo para usted.\n"
. "⏰ Válido por 24 horas.\n\n"
. "_Si tiene alguna duda, estamos aquí para ayudarle._";
$sendResult = $whatsapp->sendTextMessage($phone, $message, [
'context_type' => 'file_request',
'file_request_id' => $requestId,
]);
// Guardar ID del mensaje enviado
if ($sendResult && isset($sendResult['messages'][0]['id'])) {
$messageId = $sendResult['messages'][0]['id'];
$db->update('file_requests',
['message_sent_id' => $messageId],
'id = ?', [$requestId]
);
}
// Crear notificación para el log
$db->insert('notifications', [
'user_id' => $userId,
'type' => 'file_request_sent',
'message' => 'Se envió enlace de carga de archivos a ' . ($user['name'] ?: $phone),
'data' => json_encode([
'request_id' => $requestId,
'token' => $token,
'upload_url' => $uploadUrl,
]),
'is_read' => 0,
]);
echo json_encode([
'success' => true,
'request_id' => $requestId,
'token' => $token,
'upload_url' => $uploadUrl,
'expires_at' => $existing ? $existing['expires_at'] : $expiresAt,
'message' => 'Enlace enviado al cliente por WhatsApp',
]);
} catch (Exception $e) {
$errorDetail = date('[Y-m-d H:i:s] ') . 'request_large_file ERROR: ' . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n\n";
error_log('Error en request_large_file: ' . $e->getMessage() . ' | Trace: ' . $e->getTraceAsString());
@file_put_contents(__DIR__ . '/../logs/app_errors.log', $errorDetail, FILE_APPEND);
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Error interno: ' . $e->getMessage()]);
}
+27
View File
@@ -263,3 +263,30 @@
[2026-02-03 16:20:02] Send media by id response: {"messaging_product":"whatsapp","contacts":[{"input":"573022548060","wa_id":"573022548060"}],"messages":[{"id":"wamid.HBgMNTczMDIyNTQ4MDYwFQIAERgSMjAzNDlFQTVENzA2OEQ5QzkwAA=="}]}
[2026-02-03 16:20:02] WhatsApp response (successful): {"messaging_product":"whatsapp","contacts":[{"input":"573022548060","wa_id":"573022548060"}],"messages":[{"id":"wamid.HBgMNTczMDIyNTQ4MDYwFQIAERgSMjAzNDlFQTVENzA2OEQ5QzkwAA=="}]}
[2026-02-03 16:20:02] Saving to DB - content: 20260122213820_CONSOLIDADO FERTILIZACION DESDE EL 2025-01-22 HASTA EL 2026-01-22.xlsx, media_url: 876532491851564, local_file: uploads/media_69826681178109.48762271.xlsx, local_thumb: null
[2026-02-21 07:21:21] Raw input: {"recipient":"573168950803","media_url":"https://bot.u-s.app/uploads/media_6999a341951659.74588611.pdf","media_type":"document","caption":null,"filename":"14672320327.pdf","is_voice":false}
[2026-02-21 07:21:21] Input decoded: {"recipient":"573168950803","media_url":"https:\/\/bot.u-s.app\/uploads\/media_6999a341951659.74588611.pdf","media_type":"document","caption":null,"filename":"14672320327.pdf","is_voice":false}
[2026-02-21 07:21:21] is_voice: false
[2026-02-21 07:21:21] Local file: /var/www/html/api/../uploads/media_6999a341951659.74588611.pdf exists=yes size=215839
[2026-02-21 07:21:22] Upload result: {"id":"4311430285767679"}
[2026-02-21 07:21:22] Media ID obtained: 4311430285767679
[2026-02-21 07:21:23] Send media by id response: {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSQzg1RDY4RjA0MkEyNjY2N0MxAA=="}]}
[2026-02-21 07:21:23] WhatsApp response (successful): {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSQzg1RDY4RjA0MkEyNjY2N0MxAA=="}]}
[2026-02-21 07:21:23] Saving to DB - content: 14672320327.pdf, media_url: 4311430285767679, local_file: uploads/media_6999a341951659.74588611.pdf, local_thumb: null
[2026-02-21 07:21:47] Raw input: {"recipient":"573022548060","media_url":"https://bot.u-s.app/uploads/media_6999a35b752f47.93279475.png","media_type":"image","caption":null,"filename":"image.png","is_voice":false}
[2026-02-21 07:21:47] Input decoded: {"recipient":"573022548060","media_url":"https:\/\/bot.u-s.app\/uploads\/media_6999a35b752f47.93279475.png","media_type":"image","caption":null,"filename":"image.png","is_voice":false}
[2026-02-21 07:21:47] is_voice: false
[2026-02-21 07:21:47] Local file: /var/www/html/api/../uploads/media_6999a35b752f47.93279475.png exists=yes size=888005
[2026-02-21 07:21:48] Upload result: {"id":"835705749483344"}
[2026-02-21 07:21:48] Media ID obtained: 835705749483344
[2026-02-21 07:21:49] Send media by id response: {"messaging_product":"whatsapp","contacts":[{"input":"573022548060","wa_id":"573022548060"}],"messages":[{"id":"wamid.HBgMNTczMDIyNTQ4MDYwFQIAERgSMzJFRjFFMzFDNDVDRENDRTAyAA=="}]}
[2026-02-21 07:21:49] WhatsApp response (successful): {"messaging_product":"whatsapp","contacts":[{"input":"573022548060","wa_id":"573022548060"}],"messages":[{"id":"wamid.HBgMNTczMDIyNTQ4MDYwFQIAERgSMzJFRjFFMzFDNDVDRENDRTAyAA=="}]}
[2026-02-21 07:21:49] Saving to DB - content: image.png, media_url: 835705749483344, local_file: uploads/media_6999a35b752f47.93279475.png, local_thumb: uploads/media_6999a35b752f47.93279475.png
[2026-02-21 07:23:25] Raw input: {"recipient":"573022548060","media_url":"https://bot.u-s.app/uploads/media_6999a3bdb40265.38760593.pdf","media_type":"document","caption":null,"filename":"DOCUMENTACI‡N_SITIO_XIMENACAICEDO.pdf","is_voice":false}
[2026-02-21 07:23:25] Input decoded: {"recipient":"573022548060","media_url":"https:\/\/bot.u-s.app\/uploads\/media_6999a3bdb40265.38760593.pdf","media_type":"document","caption":null,"filename":"DOCUMENTACI\u2021N_SITIO_XIMENACAICEDO.pdf","is_voice":false}
[2026-02-21 07:23:25] is_voice: false
[2026-02-21 07:23:25] Local file: /var/www/html/api/../uploads/media_6999a3bdb40265.38760593.pdf exists=yes size=225941
[2026-02-21 07:23:26] Upload result: {"id":"804275938612470"}
[2026-02-21 07:23:26] Media ID obtained: 804275938612470
[2026-02-21 07:23:27] Send media by id response: {"messaging_product":"whatsapp","contacts":[{"input":"573022548060","wa_id":"573022548060"}],"messages":[{"id":"wamid.HBgMNTczMDIyNTQ4MDYwFQIAERgSOEQ4MjMwMUQ2QThCQTcwNjgzAA=="}]}
[2026-02-21 07:23:27] WhatsApp response (successful): {"messaging_product":"whatsapp","contacts":[{"input":"573022548060","wa_id":"573022548060"}],"messages":[{"id":"wamid.HBgMNTczMDIyNTQ4MDYwFQIAERgSOEQ4MjMwMUQ2QThCQTcwNjgzAA=="}]}
[2026-02-21 07:23:27] Saving to DB - content: DOCUMENTACI‡N_SITIO_XIMENACAICEDO.pdf, media_url: 804275938612470, local_file: uploads/media_6999a3bdb40265.38760593.pdf, local_thumb: null
+78
View File
@@ -426,3 +426,81 @@
[2026-02-20 09:05:37] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
[2026-02-20 09:05:37] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"Ayy1xE4nFVa4ae3yKVwTCJo"}}
[2026-02-20 09:05:37] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
[2026-02-21 06:51:51] Request: GET /api/version/media-url.php?id=876668735219378 GET:{"id":"876668735219378"} POST:[]
[2026-02-21 06:51:53] Auto-cached media 876668735219378 → uploads/media/2026/02/m_69999c594b0e4.jpg
[2026-02-21 07:16:05] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
[2026-02-21 07:16:05] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
[2026-02-21 07:16:05] Auto-download failed for media 1217156256734654: Graph API fetch failed:
[2026-02-21 07:16:05] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
[2026-02-21 07:16:05] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"ANJp7KukCHb4DzuSpzES6L5"}}
[2026-02-21 07:16:05] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
[2026-02-21 07:16:05] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
[2026-02-21 07:20:04] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
[2026-02-21 07:20:04] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
[2026-02-21 07:20:04] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A6XVbqqIuOgalslGDeKiVzm"}}
[2026-02-21 07:20:04] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
[2026-02-21 07:20:04] Auto-download failed for media 1217156256734654: Graph API fetch failed:
[2026-02-21 07:20:04] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
[2026-02-21 07:20:04] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
[2026-02-21 07:21:34] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
[2026-02-21 07:21:34] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
[2026-02-21 07:21:34] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AGMzefWK6Jm8NVar69sH-jp"}}
[2026-02-21 07:21:34] Auto-download failed for media 1217156256734654: Graph API fetch failed:
[2026-02-21 07:21:34] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
[2026-02-21 07:21:34] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
[2026-02-21 07:21:35] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
[2026-02-21 07:21:49] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
[2026-02-21 07:21:49] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
[2026-02-21 07:21:49] Auto-download failed for media 1217156256734654: Graph API fetch failed:
[2026-02-21 07:21:49] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
[2026-02-21 07:21:49] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A4_9s9OJ8bcOZfLlTbHf0Dv"}}
[2026-02-21 07:21:49] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
[2026-02-21 07:21:49] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
[2026-02-21 07:21:52] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
[2026-02-21 07:21:52] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
[2026-02-21 07:21:52] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AN5tABn0BsuVQndLVmb32L3"}}
[2026-02-21 07:21:52] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
[2026-02-21 07:21:52] Auto-download failed for media 1217156256734654: Graph API fetch failed:
[2026-02-21 07:21:52] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
[2026-02-21 07:21:52] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
[2026-02-21 07:22:25] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
[2026-02-21 07:22:25] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
[2026-02-21 07:22:26] Auto-download failed for media 1217156256734654: Graph API fetch failed:
[2026-02-21 07:22:26] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
[2026-02-21 07:22:26] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AiiaRGgmOStqG0RVrC04Gla"}}
[2026-02-21 07:22:26] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
[2026-02-21 07:22:26] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
[2026-02-21 07:23:27] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
[2026-02-21 07:23:27] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
[2026-02-21 07:23:27] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AVFGht18YvnGrGvIZP6Oil9"}}
[2026-02-21 07:23:27] Auto-download failed for media 1217156256734654: Graph API fetch failed:
[2026-02-21 07:23:27] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
[2026-02-21 07:23:27] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
[2026-02-21 07:23:27] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
[2026-02-21 07:23:29] Request: GET /api/version/media-url.php?id=804275938612470&download=1 GET:{"id":"804275938612470","download":"1"} POST:[]
[2026-02-21 07:23:29] Serving local file for media ID 804275938612470: /var/www/html/uploads/media_6999a3bdb40265.38760593.pdf
[2026-02-21 07:31:16] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
[2026-02-21 07:31:16] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
[2026-02-21 07:31:16] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AQwxMcsaRiezLVdkdElx2cK"}}
[2026-02-21 07:31:16] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
[2026-02-21 07:31:16] Auto-download failed for media 1217156256734654: Graph API fetch failed:
[2026-02-21 07:31:16] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
[2026-02-21 07:31:17] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
[2026-02-21 07:31:28] Request: GET /api/version/media-url.php?url=http%3A%2F%2Flocalhost%3A8080%2Fuploads%2Ffile_requests%2F2026%2F02%2Ffr_2_6999a5935b274.pdf&download=1 GET:{"url":"http:\/\/localhost:8080\/uploads\/file_requests\/2026\/02\/fr_2_6999a5935b274.pdf","download":"1"} POST:[]
[2026-02-21 07:31:28] Serving local file for URL http://localhost:8080/uploads/file_requests/2026/02/fr_2_6999a5935b274.pdf: /var/www/html/uploads/file_requests/2026/02/fr_2_6999a5935b274.pdf
[2026-02-21 07:31:40] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
[2026-02-21 07:31:40] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
[2026-02-21 07:31:40] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AknMoavxvfmPrC4LDQE0C60"}}
[2026-02-21 07:31:40] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
[2026-02-21 07:31:40] Auto-download failed for media 1217156256734654: Graph API fetch failed:
[2026-02-21 07:31:40] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
[2026-02-21 07:31:41] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
[2026-02-21 07:31:44] Request: GET /api/version/media-url.php?url=http%3A%2F%2Flocalhost%3A8080%2Fuploads%2Ffile_requests%2F2026%2F02%2Ffr_2_6999a5abc6620.docx&download=1 GET:{"url":"http:\/\/localhost:8080\/uploads\/file_requests\/2026\/02\/fr_2_6999a5abc6620.docx","download":"1"} POST:[]
[2026-02-21 07:31:44] Serving local file for URL http://localhost:8080/uploads/file_requests/2026/02/fr_2_6999a5abc6620.docx: /var/www/html/uploads/file_requests/2026/02/fr_2_6999a5abc6620.docx
[2026-02-21 07:35:57] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
[2026-02-21 07:35:57] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
[2026-02-21 07:35:57] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A3_VKJznLWm0yO9VOYihIll"}}
[2026-02-21 07:35:57] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
[2026-02-21 07:35:57] Auto-download failed for media 1217156256734654: Graph API fetch failed:
[2026-02-21 07:35:57] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
[2026-02-21 07:35:57] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
+38 -12
View File
@@ -108,6 +108,18 @@ class WhatsAppWebhook {
return;
}
// Extraer nombres de contactos del payload (contacts[].profile.name)
$contactNames = [];
if (isset($value['contacts']) && is_array($value['contacts'])) {
foreach ($value['contacts'] as $contact) {
$waId = $contact['wa_id'] ?? null;
$name = $contact['profile']['name'] ?? null;
if ($waId && $name) {
$contactNames[$waId] = $name;
}
}
}
foreach ($items as $message) {
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos
$phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null);
@@ -131,9 +143,29 @@ class WhatsAppWebhook {
// Obtener o crear usuario
$user = $this->getUserByPhone($phoneNumber);
$contactName = $contactNames[$phoneNumber] ?? null;
if (!$user) {
$userId = $this->createUser($phoneNumber);
$user = $this->getUserById($userId);
// Guardar nombre del contacto de WhatsApp al crear usuario
if ($contactName && $userId) {
try {
$this->db->update('users', ['name' => $contactName], 'id = ? AND (name IS NULL OR name = ?)', [$userId, '']);
$user['name'] = $contactName;
} catch (Exception $e) {
error_log('[webhook] Error guardando nombre contacto: ' . $e->getMessage());
}
}
} else {
// Actualizar nombre si el usuario no tiene uno guardado
if ($contactName && (empty($user['name']))) {
try {
$this->db->update('users', ['name' => $contactName], 'id = ? AND (name IS NULL OR name = ?)', [$user['id'], '']);
$user['name'] = $contactName;
} catch (Exception $e) {
error_log('[webhook] Error actualizando nombre contacto: ' . $e->getMessage());
}
}
}
// Procesar diferentes tipos de mensaje
@@ -178,40 +210,34 @@ class WhatsAppWebhook {
} elseif (isset($message['image'])) {
$messageText = $message['image']['caption'] ?? '';
$messageType = 'image';
// Preferir URL directo si viene en el webhook (evita resolver media_id)
$mediaUrl = $message['image']['url'] ?? ($message['image']['id'] ?? null);
// Preferir media ID (estable) sobre URL (expira en minutos)
$mediaUrl = $message['image']['id'] ?? ($message['image']['url'] ?? null);
$mimeType = $message['image']['mime_type'] ?? null;
$filename = $message['image']['filename'] ?? null;
} elseif (isset($message['audio'])) {
$messageType = 'audio';
$mediaUrl = $message['audio']['url'] ?? ($message['audio']['id'] ?? null);
$mediaUrl = $message['audio']['id'] ?? ($message['audio']['url'] ?? null);
$mimeType = $message['audio']['mime_type'] ?? null;
$filename = $message['audio']['filename'] ?? null;
} elseif (isset($message['video'])) {
$messageText = $message['video']['caption'] ?? '';
$messageType = 'video';
$mediaUrl = $message['video']['url'] ?? ($message['video']['id'] ?? null);
$mediaUrl = $message['video']['id'] ?? ($message['video']['url'] ?? null);
$mimeType = $message['video']['mime_type'] ?? null;
$filename = $message['video']['filename'] ?? null;
} elseif (isset($message['document'])) {
$messageText = $message['document']['filename'] ?? '';
$messageType = 'document';
$mediaUrl = $message['document']['url'] ?? ($message['document']['id'] ?? null);
$mediaUrl = $message['document']['id'] ?? ($message['document']['url'] ?? null);
$mimeType = $message['document']['mime_type'] ?? null;
$filename = $message['document']['filename'] ?? null;
} elseif (isset($message['sticker'])) {
$messageType = 'sticker';
$mediaUrl = $message['sticker']['url'] ?? ($message['sticker']['id'] ?? null);
$mediaUrl = $message['sticker']['id'] ?? ($message['sticker']['url'] ?? null);
$mimeType = $message['sticker']['mime_type'] ?? null;
$filename = $message['sticker']['filename'] ?? null;
+38 -6
View File
@@ -127,7 +127,8 @@ class OptimizedWebhook {
foreach ($entry['changes'] as $change) {
if (isset($change['value']['messages'])) {
$this->queueMessages($change['value']['messages']);
$contacts = $change['value']['contacts'] ?? [];
$this->queueMessages($change['value']['messages'], $contacts);
}
if (isset($change['value']['statuses'])) {
@@ -156,7 +157,17 @@ class OptimizedWebhook {
/**
* Encolar mensajes para procesamiento asíncrono
*/
private function queueMessages($messages) {
private function queueMessages($messages, $contacts = []) {
// Extraer nombres de contactos del payload
$contactNames = [];
foreach ($contacts as $contact) {
$waId = $contact['wa_id'] ?? null;
$name = $contact['profile']['name'] ?? null;
if ($waId && $name) {
$contactNames[$waId] = $name;
}
}
foreach ($messages as $message) {
try {
$messageId = $message['id'] ?? null;
@@ -178,9 +189,29 @@ class OptimizedWebhook {
// Obtener o crear usuario (rápido, solo DB lookup)
$user = $this->getUserByPhone($phoneNumber);
$contactName = $contactNames[$phoneNumber] ?? null;
if (!$user) {
$userId = $this->createUser($phoneNumber);
$user = $this->getUserById($userId);
// Guardar nombre del contacto de WhatsApp
if ($contactName && $userId) {
try {
$this->db->update('users', ['name' => $contactName], 'id = ? AND (name IS NULL OR name = ? OR name = ?)', [$userId, '', $phoneNumber]);
$user['name'] = $contactName;
} catch (Exception $e) {
$this->logger->warning('Error guardando nombre contacto: ' . $e->getMessage());
}
}
} else {
// Actualizar nombre si no tiene uno real (solo tiene el teléfono o vacío)
if ($contactName && (empty($user['name']) || $user['name'] === $phoneNumber)) {
try {
$this->db->update('users', ['name' => $contactName], 'id = ? AND (name IS NULL OR name = ? OR name = ?)', [$user['id'], '', $phoneNumber]);
$user['name'] = $contactName;
} catch (Exception $e) {
$this->logger->warning('Error actualizando nombre contacto: ' . $e->getMessage());
}
}
}
// Extraer tipo y contenido del mensaje
@@ -249,7 +280,8 @@ class OptimizedWebhook {
case 'image':
$text = $message['image']['caption'] ?? '';
$mediaUrl = $message['image']['url'] ?? $message['image']['id'] ?? null;
// Preferir media ID (estable) sobre URL (expira en minutos)
$mediaUrl = $message['image']['id'] ?? $message['image']['url'] ?? null;
// Encolar descarga de media
if ($mediaUrl) {
@@ -259,7 +291,7 @@ class OptimizedWebhook {
case 'video':
$text = $message['video']['caption'] ?? '';
$mediaUrl = $message['video']['url'] ?? $message['video']['id'] ?? null;
$mediaUrl = $message['video']['id'] ?? $message['video']['url'] ?? null;
if ($mediaUrl) {
$this->queueMediaDownload($mediaUrl, $message['id'], 'video');
@@ -267,7 +299,7 @@ class OptimizedWebhook {
break;
case 'audio':
$mediaUrl = $message['audio']['url'] ?? $message['audio']['id'] ?? null;
$mediaUrl = $message['audio']['id'] ?? $message['audio']['url'] ?? null;
if ($mediaUrl) {
$this->queueMediaDownload($mediaUrl, $message['id'], 'audio');
@@ -276,7 +308,7 @@ class OptimizedWebhook {
case 'document':
$text = $message['document']['filename'] ?? '';
$mediaUrl = $message['document']['url'] ?? $message['document']['id'] ?? null;
$mediaUrl = $message['document']['id'] ?? $message['document']['url'] ?? null;
if ($mediaUrl) {
$this->queueMediaDownload($mediaUrl, $message['id'], 'document');
+32 -2
View File
@@ -47,7 +47,9 @@ class Database {
}
return $stmt;
} catch (PDOException $e) {
$errorDetail = date('[Y-m-d H:i:s] ') . "DB Query FAILED: " . $e->getMessage() . "\n SQL: " . $sql . "\n Params: " . json_encode($params) . "\n Trace: " . $e->getTraceAsString() . "\n\n";
error_log("Query failed: " . $e->getMessage() . " SQL: " . $sql . " Params: " . json_encode($params));
@file_put_contents(__DIR__ . '/../logs/app_errors.log', $errorDetail, FILE_APPEND);
throw new Exception("Error en la consulta a la base de datos: " . $e->getMessage());
}
}
@@ -90,8 +92,36 @@ class Database {
}
$fieldsStr = implode(', ', $fields);
$sql = "UPDATE {$table} SET {$fieldsStr} WHERE {$where}";
$params = array_merge($data, $whereParams);
// Convertir WHERE params para evitar mezcla de nombrados y posicionales
$whereNamed = $where;
$namedWhereParams = [];
if (!empty($whereParams)) {
// Detectar si es asociativo (nombrado) o secuencial (posicional)
$keys = array_keys($whereParams);
$isAssociative = ($keys !== range(0, count($whereParams) - 1));
if ($isAssociative) {
// WHERE ya usa :nombre — renombrar para evitar colisión con SET params
foreach ($whereParams as $key => $value) {
$newKey = '_w_' . $key;
$whereNamed = str_replace(':' . $key, ':' . $newKey, $whereNamed);
$namedWhereParams[$newKey] = $value;
}
} else {
// WHERE usa ? posicionales — convertir a nombrados
$i = 0;
foreach ($whereParams as $value) {
$paramName = '_w' . $i;
$whereNamed = preg_replace('/\?/', ':' . $paramName, $whereNamed, 1);
$namedWhereParams[$paramName] = $value;
$i++;
}
}
}
$sql = "UPDATE {$table} SET {$fieldsStr} WHERE {$whereNamed}";
$params = array_merge($data, $namedWhereParams);
return $this->query($sql, $params);
}
+138 -3
View File
@@ -904,6 +904,10 @@ if (!isset($_SESSION['user_id'])) {
<div id="bot-toggle-container" style="display:flex;align-items:center;gap:6px;margin-right:8px;">
<button class="btn btn-sm btn-outline-secondary" id="bot-toggle">Bot: On</button>
</div>
<!-- Botón solicitar archivo grande -->
<button class="btn btn-sm btn-outline-info" id="request-large-file-btn" title="Solicitar archivo grande al cliente">
<i class="fas fa-cloud-upload-alt"></i>
</button>
<!-- Botón programar recordatorio -->
<button class="btn btn-sm btn-success" id="schedule-reminder-btn" title="Programar recordatorio">
<i class="fas fa-calendar-plus"></i>
@@ -947,8 +951,10 @@ if (!isset($_SESSION['user_id'])) {
📎
</button>
<div id="attach-menu" class="attach-menu" style="display:none;">
<button class="attach-option" data-action="file">Enviar archivo</button>
<button class="attach-option" data-action="template">Enviar plantilla</button>
<button class="attach-option" data-action="file"><i class="fas fa-file me-2"></i>Enviar archivo</button>
<button class="attach-option" data-action="template"><i class="fas fa-file-alt me-2"></i>Enviar plantilla</button>
<hr style="margin:4px 0; border-color:#eee;">
<button class="attach-option" data-action="large-file" style="color:#075e54; font-weight:600;"><i class="fas fa-cloud-upload-alt me-2"></i>Solicitar archivo grande</button>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<!-- <select id="message-type" class="form-select form-select-sm" style="width:auto; margin-right:8px;">
@@ -1580,7 +1586,9 @@ if (!isset($_SESSION['user_id'])) {
// explicit attention flags
notification.type === 'attention' || notification.system === 'attention' || notification.level === 'attention' || notification.attention ||
// known event types/tags for user-sent documents
notification.type === 'usersentdocuments' || notification.tag === 'usersentdocuments' || notification.system === 'usersentdocuments'
notification.type === 'usersentdocuments' || notification.tag === 'usersentdocuments' || notification.system === 'usersentdocuments' ||
// file upload notifications (large file request feature)
notification.type === 'file_uploaded' || notification.type === 'file_request_sent'
);
if (notification.cool || notification.type === 'cool') toast.classList.add('cool');
if (notification.level === 'urgent' || notification.urgent) toast.classList.add('urgent');
@@ -2078,6 +2086,8 @@ if (!isset($_SESSION['user_id'])) {
attachMenu.style.display = 'none';
if (action === 'file') {
if (fileInput) fileInput.click();
} else if (action === 'large-file') {
self.requestLargeFile();
} else if (action === 'template') {
// switch to template mode and focus selector
const typeSelect = document.getElementById('message-type');
@@ -2271,6 +2281,12 @@ if (!isset($_SESSION['user_id'])) {
if (reminderBtn) {
reminderBtn.addEventListener('click', () => this.showReminderModal());
}
// Botón solicitar archivo grande (header)
const requestLargeFileBtn = document.getElementById('request-large-file-btn');
if (requestLargeFileBtn) {
requestLargeFileBtn.addEventListener('click', () => this.requestLargeFile());
}
// Botón de guardar recordatorio
const saveReminderBtn = document.getElementById('save-reminder-btn');
@@ -5360,6 +5376,125 @@ if (!isset($_SESSION['user_id'])) {
}
}
async deleteCurrentConversation() {
if (!confirm('¿Eliminar esta conversación? Se borrará todo el historial.')) return;
try {
const resp = await fetch('api/delete_conversation.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: this.currentUserId }),
cache: 'no-store'
});
const json = await resp.json();
if (json && json.success) {
this.showSuccess('Conversación eliminada');
// reset UI
document.getElementById('chat-area').style.display = 'none';
document.getElementById('no-conversation').style.display = 'block';
this.currentUserId = null;
this.loadConversations();
} else {
this.showError('No se pudo eliminar la conversación');
}
} catch (err) {
console.error(err);
this.showError('Error eliminando la conversación');
}
}
// ========== SOLICITAR ARCHIVO GRANDE ==========
async requestLargeFile() {
if (!this.currentUserId) {
showAlert('Selecciona una conversación primero', 'warning');
return;
}
const phoneNumber = this.getConversationPhone(this.currentUserId);
if (!phoneNumber) {
showAlert('No se encontró el número de teléfono del cliente', 'danger');
return;
}
// Confirmar con el operador
const userName = document.getElementById('chat-name-text')?.textContent?.trim() || phoneNumber;
if (!confirm(`¿Enviar enlace de carga de archivos grandes a ${userName}?\n\nSe enviará un mensaje por WhatsApp con un enlace seguro donde el cliente podrá subir archivos de hasta 50 MB.`)) {
return;
}
// Deshabilitar botones mientras se procesa
const headerBtn = document.getElementById('request-large-file-btn');
if (headerBtn) {
headerBtn.disabled = true;
headerBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
}
try {
const response = await fetch('api/request_large_file.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
user_id: this.currentUserId,
phone_number: phoneNumber,
}),
cache: 'no-store',
});
const result = await response.json();
if (result && result.success) {
showAlert('✅ Enlace de carga enviado al cliente por WhatsApp', 'success');
console.log('📎 File request created:', result);
// Agregar mensaje visual al chat con texto más detallado
this.addMessageToView(
`📎 Enlace para enviar archivos grandes\n\n` +
`Hola.\n\n` +
`Le enviamos este enlace seguro para que pueda subir archivos de gran tamaño (hasta 50 MB):\n\n` +
`👉 ${result.upload_url}\n\n` +
`✅ Puede subir imágenes o documentos.\n` +
`🔒 El enlace es seguro y exclusivo para usted.\n` +
`⏰ Válido por 24 horas.\n\n` +
`Si tiene alguna duda, estamos aquí para ayudarle.`,
'outgoing',
{ message_type: 'text' }
);
// Recargar mensajes después de un breve delay
setTimeout(() => {
this.loadMessages(this.currentUserId, false, true);
}, 2000);
} else {
showAlert('Error: ' + (result.error || 'No se pudo enviar el enlace'), 'danger');
}
} catch (error) {
console.error('Error en requestLargeFile:', error);
showAlert('Error al enviar la solicitud: ' + (error.message || error), 'danger');
} finally {
if (headerBtn) {
headerBtn.disabled = false;
headerBtn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i>';
}
}
}
async getFileRequests() {
if (!this.currentUserId) return [];
try {
const resp = await fetch(`api/get_file_requests.php?user_id=${this.currentUserId}&status=active`, {
credentials: 'same-origin',
cache: 'no-store',
});
const data = await resp.json();
return data.success ? (data.uploads || []) : [];
} catch (e) {
console.warn('Error loading file requests:', e);
return [];
}
}
async searchConversations(query) {
console.log('🔍 Buscando conversaciones:', query);
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Find SQL queries mixing named and positional parameters in PHP files."""
import re
import os
import glob
dirs = ['api/', 'classes/', 'services/']
results = []
for d in dirs:
for filepath in glob.glob(os.path.join(d, '**', '*.php'), recursive=True):
try:
with open(filepath, 'r', errors='replace') as f:
content = f.read()
except Exception:
continue
lines = content.split('\n')
total_lines = len(lines)
# APPROACH 1: Look at the whole file content for SQL strings
# Find all SQL string assignments and prepare/query calls
# Use regex to find multi-line strings between quotes
# Find all quoted strings that look like SQL (may span lines with concatenation)
# Pattern: capture everything between matching quotes in SQL context
# First find all $var = "..." or prepare("...") blocks
# Including multi-line with string concatenation
# Simpler approach: for each file, find all occurrences of execute([...])
# and check if the SQL and params are mixed
# APPROACH: Read entire file, find SQL strings, check for mixed params
# Look for patterns like: "SELECT ... ? ... :param ..."
# Find all string literals that contain SQL keywords
# Handle both single-line and multi-line concatenated strings
for i, line in enumerate(lines):
# Skip comment lines
stripped = line.strip()
if stripped.startswith('//') or stripped.startswith('*') or stripped.startswith('#'):
continue
# Look for SQL query assignments or prepare/query calls on this line
is_sql_line = bool(
re.search(r'(prepare|query|exec)\s*\(', line, re.IGNORECASE) or
re.search(r'\$\w*(sql|query)\w*\s*=', line, re.IGNORECASE)
)
if not is_sql_line:
continue
# Gather block: from this line until statement seems complete
block_lines_list = []
j = i
brace_depth = 0
while j < total_lines and j < i + 50:
bl = lines[j]
block_lines_list.append(bl)
brace_depth += bl.count('(') - bl.count(')')
# End conditions
if j > i and brace_depth <= 0 and ';' in bl:
break
j += 1
block = '\n'.join(block_lines_list)
# Now extract ALL string content from this block
# Handle concatenated strings like "part1" . "part2"
# and "part1
# part2" (multi-line strings)
all_strings = []
# Double-quoted strings (handle escaped quotes)
all_strings.extend(re.findall(r'"((?:[^"\\]|\\.)*)"', block))
# Single-quoted strings
all_strings.extend(re.findall(r"'((?:[^'\\]|\\.)*)'", block))
full_sql = ' '.join(all_strings)
# Must contain SQL keywords
if not re.search(r'\b(SELECT|INSERT|UPDATE|DELETE|REPLACE\s+INTO)\b', full_sql, re.IGNORECASE):
continue
# Check for positional (?) - but not in ternary context
# In SQL strings, ? should appear as a standalone placeholder
has_positional = bool(re.search(r'\?', full_sql))
# Check for named params - :word but not :: or :// or :\
named_in_sql = re.findall(r'(?<![:\w/]):[a-zA-Z_][a-zA-Z0-9_]*', full_sql)
# Filter out things that are clearly not SQL params (like :hover, :root from CSS)
named_in_sql = [n for n in named_in_sql if n.lower() not in (':hover', ':root', ':focus', ':active', ':visited')]
has_named = len(named_in_sql) > 0
if has_positional and has_named:
# Extra validation: make sure ? is in the SQL part, not just in error messages etc.
# Check each individual string for SQL content
sql_strings = [s for s in all_strings if re.search(r'\b(SELECT|INSERT|UPDATE|DELETE|WHERE|SET|FROM|INTO|VALUES)\b', s, re.IGNORECASE)]
combined_sql = ' '.join(sql_strings)
has_pos_in_sql = bool(re.search(r'\?', combined_sql))
named_in_real_sql = re.findall(r'(?<![:\w/]):[a-zA-Z_][a-zA-Z0-9_]*', combined_sql)
named_in_real_sql = [n for n in named_in_real_sql if n.lower() not in (':hover', ':root', ':focus', ':active', ':visited')]
if has_pos_in_sql and named_in_real_sql:
results.append({
'file': filepath,
'start_line': i + 1,
'end_line': i + len(block_lines_list),
'named_params': named_in_real_sql,
'block': block.strip(),
'sql_text': combined_sql[:500]
})
# Deduplicate (same file + same start line)
seen = set()
unique_results = []
for r in results:
key = (r['file'], r['start_line'])
if key not in seen:
seen.add(key)
unique_results.append(r)
# Output results
if not unique_results:
print("No mixed parameter queries found.")
else:
for r in unique_results:
print("=" * 80)
print(f"FILE: {r['file']}")
print(f"LINES: {r['start_line']}-{r['end_line']}")
print(f"NAMED PARAMS: {r['named_params']}")
print(f"SQL TEXT: {r['sql_text'][:400]}")
print(f"CODE BLOCK:")
for bl in r['block'].split('\n'):
print(f" {bl}")
print()
print(f"TOTAL: {len(unique_results)} potential mixed parameter queries found.")
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* Migración: Crear tabla file_requests para solicitudes de archivos grandes
*
* Esta tabla almacena las solicitudes de carga de archivos grandes que se
* envían a los clientes por WhatsApp. Cada solicitud genera un token único
* que se usa como enlace público de carga.
*/
require_once __DIR__ . '/../config/config.php';
try {
$db = Database::getInstance();
$pdo = $db->getConnection();
// Tabla principal: solicitudes de carga de archivos
$pdo->exec("
CREATE TABLE IF NOT EXISTS file_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(64) NOT NULL UNIQUE COMMENT 'Token único para el enlace público',
user_id INT NOT NULL COMMENT 'FK a users.id (contacto de WhatsApp)',
phone_number VARCHAR(20) NOT NULL COMMENT 'Teléfono del cliente',
requested_by INT DEFAULT NULL COMMENT 'FK a admin_users.id (operador que solicitó)',
status ENUM('pending','uploaded','expired','cancelled') DEFAULT 'pending',
max_file_size BIGINT DEFAULT 52428800 COMMENT 'Tamaño máximo en bytes (default 50MB)',
allowed_types VARCHAR(255) DEFAULT 'image,document' COMMENT 'Tipos permitidos separados por coma',
message_sent_id VARCHAR(255) DEFAULT NULL COMMENT 'ID del mensaje de WhatsApp enviado',
expires_at DATETIME NOT NULL COMMENT 'Fecha de expiración del enlace',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_token (token),
INDEX idx_user_id (user_id),
INDEX idx_phone (phone_number),
INDEX idx_status (status),
INDEX idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
echo "✅ Tabla file_requests creada correctamente\n";
// Tabla de archivos subidos por los clientes
$pdo->exec("
CREATE TABLE IF NOT EXISTS file_request_uploads (
id INT AUTO_INCREMENT PRIMARY KEY,
request_id INT NOT NULL COMMENT 'FK a file_requests.id',
original_filename VARCHAR(255) NOT NULL,
stored_filename VARCHAR(255) NOT NULL COMMENT 'Nombre único en disco',
file_path TEXT NOT NULL COMMENT 'Ruta relativa al directorio uploads',
file_size BIGINT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
media_type ENUM('image','document','video','audio','other') DEFAULT 'document',
thumbnail_path VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_request (request_id),
FOREIGN KEY (request_id) REFERENCES file_requests(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
echo "✅ Tabla file_request_uploads creada correctamente\n";
echo "\n🎉 Migración completada exitosamente\n";
} catch (Exception $e) {
echo "❌ Error en migración: " . $e->getMessage() . "\n";
exit(1);
}
+42
View File
@@ -0,0 +1,42 @@
<?php
require_once __DIR__ . '/config/config.php';
$db = Database::getInstance();
// Test 1: update() con WHERE posicional (?)
echo "Test 1: update con WHERE posicional...\n";
try {
$db->update('file_requests', ['message_sent_id' => 'test_msg_' . time()], 'id = ?', [99999]);
echo " OK!\n";
} catch (Exception $e) {
echo " FAIL: " . $e->getMessage() . "\n";
}
// Test 2: update() con WHERE nombrado (:id)
echo "Test 2: update con WHERE nombrado...\n";
try {
$db->update('users', ['advisor_requested' => 0], 'id = :id', ['id' => 99999]);
echo " OK!\n";
} catch (Exception $e) {
echo " FAIL: " . $e->getMessage() . "\n";
}
// Test 3: update() con WHERE nombrado y multiple data
echo "Test 3: update con WHERE nombrado + multiple data...\n";
try {
$db->update('users', ['advisor_requested' => 0, 'on_hold' => 0], 'id = :id', ['id' => 99999]);
echo " OK!\n";
} catch (Exception $e) {
echo " FAIL: " . $e->getMessage() . "\n";
}
// Test 4: insert (para validar que no rompe)
echo "Test 4: insert nombrado...\n";
try {
// Simular: solo preparar, no ejecutar (tabla puede no existir con esas FK constraints)
echo " SKIP (no test insert real)\n";
} catch (Exception $e) {
echo " FAIL: " . $e->getMessage() . "\n";
}
echo "\nDone!\n";
+70
View File
@@ -0,0 +1,70 @@
<?php
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/api/webhook.php';
$json = '{
"object": "whatsapp_business_account",
"entry": [
{
"id": "639871912520412",
"changes": [
{
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "573053370116",
"phone_number_id": "959898150536936"
},
"contacts": [
{
"profile": {
"name": "Lizan"
},
"wa_id": "573022548060"
}
],
"messages": [
{
"from": "573022548060",
"id": "wamid.HBgMNTczMDIyNTQ4MDYwFQIAEhgUM0EyMzM2QUI0MEZDMUI1OTdEMDkA",
"timestamp": "1771676075",
"type": "image",
"image": {
"mime_type": "image/jpeg",
"sha256": "249zHFtLK2eVmLmw1UhwydtmFsEeIeE0nFOwOaYJ55w=",
"id": "899050479612656",
"url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=899050479612656&source=webhook&ext=1771676376&hash=ARkxbmHesPbiWE1keW9GrRo6jYIQEU10BmhsamUWYIVe3w"
}
}
]
},
"field": "messages"
}
]
}
]
}';
$data = json_decode($json, true);
$webhook = new WhatsAppWebhook();
$result = $webhook->processPayload($data);
echo "processPayload returned: ";
var_export($result);
// show last conversation record for user phone
$db = Database::getInstance();
$user = $db->fetch('SELECT * FROM users WHERE phone_number = ?', ['573022548060']);
if ($user) {
echo "\nUser found: ";
var_export($user);
$convs = $db->fetchAll('SELECT * FROM conversations WHERE user_id = ? ORDER BY id DESC LIMIT 5', [$user['id']]);
echo "\nRecent conversations:";
var_export($convs);
} else {
echo "\nUser not found.\n";
}
// show media_queue entries
$mqs = $db->fetchAll('SELECT * FROM media_queue ORDER BY id DESC LIMIT 5');
echo "\nMedia queue entries: "; var_export($mqs);
+756
View File
@@ -0,0 +1,756 @@
<?php
/**
* Página pública de carga de archivos grandes
*
* Los clientes acceden a esta página desde un enlace enviado por WhatsApp.
* NO requiere autenticación — se valida por token único.
*
* URL: /upload.php?token=XXXXX
*/
// No requiere session ni auth del panel
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', 0);
require_once __DIR__ . '/config/config.php';
// Obtener token de la URL
$token = $_GET['token'] ?? '';
if (empty($token) || !preg_match('/^[a-zA-Z0-9]{32,64}$/', $token)) {
http_response_code(404);
showErrorPage('Enlace no válido', 'El enlace al que intentas acceder no es válido.');
exit;
}
// Buscar la solicitud en la BD
try {
$db = Database::getInstance();
$request = $db->fetch(
"SELECT fr.*, u.name as user_name
FROM file_requests fr
LEFT JOIN users u ON fr.user_id = u.id
WHERE fr.token = ?",
[$token]
);
} catch (Exception $e) {
http_response_code(500);
showErrorPage('Error del sistema', 'No se pudo verificar el enlace. Intenta de nuevo más tarde.');
exit;
}
if (!$request) {
http_response_code(404);
showErrorPage('Enlace no encontrado', 'Este enlace de carga no existe o ya fue eliminado.');
exit;
}
// Verificar expiración
if (strtotime($request['expires_at']) < time()) {
http_response_code(410);
showErrorPage('Enlace expirado', 'Este enlace de carga ha expirado. Solicita uno nuevo al asesor.');
exit;
}
// Verificar estado
if ($request['status'] === 'cancelled') {
http_response_code(410);
showErrorPage('Enlace cancelado', 'Esta solicitud fue cancelada. Contacta a tu asesor.');
exit;
}
// Verificar archivos ya subidos
$uploadedFiles = [];
try {
$uploadedFiles = $db->fetchAll(
"SELECT * FROM file_request_uploads WHERE request_id = ? ORDER BY created_at DESC",
[$request['id']]
);
} catch (Exception $e) {
// No crítico
}
$alreadyUploaded = ($request['status'] === 'uploaded' && count($uploadedFiles) > 0);
// Calcular tamaño máximo legible
$maxSizeMB = round($request['max_file_size'] / (1024 * 1024));
$allowedTypes = explode(',', $request['allowed_types']);
// Auto-detectar APP_URL
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$appUrl = $protocol . '://' . $_SERVER['HTTP_HOST'];
function showErrorPage($title, $message) {
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($title) ?></title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<style>
body { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #e5ddd5 0%, #f0f4f7 100%); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.error-card { background: white; border-radius: 16px; padding: 40px; text-align: center; box-shadow: 0 8px 30px rgba(0,0,0,0.08); max-width: 400px; }
.error-icon { font-size: 64px; margin-bottom: 16px; }
</style>
</head>
<body>
<div class="error-card">
<div class="error-icon">⚠️</div>
<h4><?= htmlspecialchars($title) ?></h4>
<p class="text-muted"><?= htmlspecialchars($message) ?></p>
</div>
</body>
</html>
<?php
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📎 Subir archivo</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
:root {
--wa-green: #25d366;
--wa-green-dark: #128c7e;
--wa-dark: #075e54;
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
background: linear-gradient(135deg, #e5ddd5 0%, #f0f4f7 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 16px;
}
.upload-container {
max-width: 480px;
margin: 0 auto;
padding-top: 20px;
}
.upload-header {
background: linear-gradient(180deg, var(--wa-dark) 0%, #054f45 100%);
color: white;
padding: 20px 24px;
border-radius: 16px 16px 0 0;
text-align: center;
}
.upload-header h4 {
margin: 0 0 4px 0;
font-size: 18px;
}
.upload-header p {
margin: 0;
opacity: 0.85;
font-size: 13px;
}
.upload-body {
background: white;
padding: 24px;
border-radius: 0 0 16px 16px;
box-shadow: 0 8px 30px rgba(0,0,0,0.08);
}
.drop-zone {
border: 2px dashed #ccc;
border-radius: 12px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: #fafafa;
margin-bottom: 20px;
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: var(--wa-green);
background: rgba(37, 211, 102, 0.04);
}
.drop-zone.drag-over {
transform: scale(1.01);
}
.drop-zone-icon {
font-size: 48px;
color: var(--wa-green);
margin-bottom: 12px;
}
.drop-zone-text {
font-size: 15px;
color: #555;
margin-bottom: 8px;
}
.drop-zone-hint {
font-size: 12px;
color: #999;
}
.file-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.file-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: #f8f9fa;
border-radius: 10px;
border: 1px solid #eee;
}
.file-item-icon {
font-size: 28px;
color: var(--wa-green);
flex-shrink: 0;
}
.file-item-info {
flex: 1;
min-width: 0;
}
.file-item-name {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-item-size {
font-size: 12px;
color: #888;
}
.file-item-remove {
color: #dc3545;
cursor: pointer;
padding: 4px;
font-size: 16px;
}
.file-item-remove:hover {
color: #a71d2a;
}
.upload-progress {
display: none;
margin-bottom: 16px;
}
.progress {
height: 6px;
border-radius: 3px;
overflow: hidden;
}
.progress-bar {
background: var(--wa-green);
transition: width 0.3s ease;
}
.btn-upload {
background: var(--wa-green);
color: white;
border: none;
border-radius: 12px;
padding: 14px 24px;
width: 100%;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-upload:hover:not(:disabled) {
background: var(--wa-green-dark);
transform: translateY(-1px);
}
.btn-upload:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.success-card {
text-align: center;
padding: 30px;
}
.success-icon {
font-size: 64px;
color: var(--wa-green);
margin-bottom: 16px;
animation: bounceIn 0.5s ease;
}
@keyframes bounceIn {
0% { transform: scale(0.3); opacity: 0; }
50% { transform: scale(1.05); }
100% { transform: scale(1); opacity: 1; }
}
.uploaded-files {
margin-top: 20px;
}
.uploaded-file {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: #e8f5e9;
border-radius: 8px;
margin-bottom: 8px;
}
.info-banner {
background: #fff3e0;
border: 1px solid rgba(255, 152, 0, 0.2);
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 20px;
font-size: 13px;
color: #5c3a00;
display: flex;
align-items: flex-start;
gap: 10px;
}
.info-banner i {
color: #ff9800;
margin-top: 2px;
}
.security-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #888;
margin-top: 16px;
}
.security-badge i {
color: var(--wa-green);
}
/* Responsive */
@media (max-width: 480px) {
body { padding: 8px; }
.upload-body { padding: 16px; }
.drop-zone { padding: 28px 16px; }
}
</style>
</head>
<body>
<div class="upload-container">
<div class="upload-header">
<h4><i class="fas fa-cloud-upload-alt me-2"></i>Subir archivo</h4>
<p>Envía archivos de hasta <?= $maxSizeMB ?> MB de forma segura</p>
</div>
<div class="upload-body">
<?php if ($alreadyUploaded): ?>
<!-- Ya se subieron archivos -->
<div class="success-card">
<div class="success-icon"><i class="fas fa-check-circle"></i></div>
<h5>¡Archivos recibidos!</h5>
<p class="text-muted">Tus archivos fueron enviados correctamente. El asesor los recibirá en breve.</p>
<div class="uploaded-files">
<?php foreach ($uploadedFiles as $file): ?>
<div class="uploaded-file">
<i class="fas fa-file-check text-success"></i>
<div style="flex:1; text-align:left;">
<div style="font-weight:600; font-size:13px;"><?= htmlspecialchars($file['original_filename']) ?></div>
<div style="font-size:12px; color:#888;"><?= formatSize($file['file_size']) ?></div>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="info-banner mt-3">
<i class="fas fa-info-circle"></i>
<span>Puedes subir archivos adicionales si lo necesitas.</span>
</div>
<button class="btn-upload" onclick="showUploadForm()">
<i class="fas fa-plus me-2"></i>Subir más archivos
</button>
</div>
<?php endif; ?>
<div id="upload-form" style="<?= $alreadyUploaded ? 'display:none' : '' ?>">
<div class="info-banner">
<i class="fas fa-shield-alt"></i>
<span>
Este enlace es seguro y exclusivo para ti. Los archivos serán recibidos directamente por tu asesor.
<br><strong>Máximo: <?= $maxSizeMB ?> MB por archivo.</strong>
</span>
</div>
<!-- Zona de arrastre -->
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input-public').click()">
<div class="drop-zone-icon"><i class="fas fa-cloud-upload-alt"></i></div>
<div class="drop-zone-text">Toca para seleccionar o arrastra tus archivos aquí</div>
<div class="drop-zone-hint">
<?php
$typeLabels = [];
if (in_array('image', $allowedTypes)) $typeLabels[] = 'imágenes';
if (in_array('document', $allowedTypes)) $typeLabels[] = 'documentos';
if (in_array('video', $allowedTypes)) $typeLabels[] = 'videos';
if (in_array('audio', $allowedTypes)) $typeLabels[] = 'audios';
echo implode(', ', $typeLabels) . ' — hasta ' . $maxSizeMB . ' MB';
?>
</div>
</div>
<input type="file" id="file-input-public" multiple accept="<?= getAcceptString($allowedTypes) ?>"
style="display:none" onchange="handleFileSelect(event)">
<!-- Lista de archivos seleccionados -->
<div class="file-list" id="file-list"></div>
<!-- Barra de progreso -->
<div class="upload-progress" id="upload-progress">
<div class="d-flex justify-content-between mb-1">
<small id="progress-text">Subiendo...</small>
<small id="progress-pct">0%</small>
</div>
<div class="progress">
<div class="progress-bar" id="progress-bar" style="width: 0%"></div>
</div>
</div>
<!-- Botón enviar -->
<button class="btn-upload" id="btn-submit" onclick="submitFiles()" disabled>
<i class="fas fa-paper-plane me-2"></i>Enviar archivos
</button>
<div class="security-badge">
<i class="fas fa-lock"></i>
Conexión cifrada y segura
</div>
</div>
<!-- Estado de éxito (post-upload) -->
<div id="upload-success" style="display:none">
<div class="success-card">
<div class="success-icon"><i class="fas fa-check-circle"></i></div>
<h5>¡Archivos enviados!</h5>
<p class="text-muted">Tu asesor recibirá los archivos en breve. Puedes cerrar esta página.</p>
<button class="btn-upload mt-3" onclick="showUploadForm()">
<i class="fas fa-plus me-2"></i>Subir más archivos
</button>
</div>
</div>
</div>
</div>
<script>
const CONFIG = {
token: '<?= htmlspecialchars($token) ?>',
maxFileSize: <?= intval($request['max_file_size']) ?>,
maxFileSizeMB: <?= $maxSizeMB ?>,
allowedTypes: <?= json_encode($allowedTypes) ?>,
apiUrl: 'api/file_request_upload.php'
};
let selectedFiles = [];
// === Drag & Drop ===
const dropZone = document.getElementById('drop-zone');
['dragenter', 'dragover'].forEach(evt => {
dropZone.addEventListener(evt, e => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(evt => {
dropZone.addEventListener(evt, e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
});
});
dropZone.addEventListener('drop', e => {
const files = Array.from(e.dataTransfer.files);
addFiles(files);
});
// === Selección de archivos ===
function handleFileSelect(event) {
const files = Array.from(event.target.files);
addFiles(files);
event.target.value = ''; // Reset para permitir re-selección
}
function addFiles(files) {
for (const file of files) {
// Validar tamaño
if (file.size > CONFIG.maxFileSize) {
alert(`El archivo "${file.name}" excede el límite de ${CONFIG.maxFileSizeMB} MB.`);
continue;
}
// Validar tipo
if (!isAllowedType(file)) {
alert(`El tipo de archivo "${file.name}" no está permitido.`);
continue;
}
// Evitar duplicados
if (selectedFiles.some(f => f.name === file.name && f.size === file.size)) {
continue;
}
selectedFiles.push(file);
}
renderFileList();
updateSubmitButton();
}
function isAllowedType(file) {
const type = file.type || '';
const ext = file.name.split('.').pop().toLowerCase();
for (const allowed of CONFIG.allowedTypes) {
switch (allowed) {
case 'image':
if (type.startsWith('image/') || ['jpg','jpeg','png','gif','webp','bmp','svg'].includes(ext)) return true;
break;
case 'document':
if (['pdf','doc','docx','xls','xlsx','ppt','pptx','txt','csv','rtf','odt','ods','zip','rar','7z'].includes(ext)) return true;
if (type.startsWith('application/')) return true;
break;
case 'video':
if (type.startsWith('video/') || ['mp4','mov','avi','mkv','webm','3gp'].includes(ext)) return true;
break;
case 'audio':
if (type.startsWith('audio/') || ['mp3','ogg','wav','aac','m4a','opus'].includes(ext)) return true;
break;
}
}
return false;
}
function removeFile(index) {
selectedFiles.splice(index, 1);
renderFileList();
updateSubmitButton();
}
function renderFileList() {
const list = document.getElementById('file-list');
if (selectedFiles.length === 0) {
list.innerHTML = '';
return;
}
list.innerHTML = selectedFiles.map((file, i) => `
<div class="file-item">
<div class="file-item-icon">
<i class="${getFileIcon(file)}"></i>
</div>
<div class="file-item-info">
<div class="file-item-name">${escapeHtml(file.name)}</div>
<div class="file-item-size">${formatSize(file.size)}</div>
</div>
<div class="file-item-remove" onclick="removeFile(${i})">
<i class="fas fa-times-circle"></i>
</div>
</div>
`).join('');
}
function updateSubmitButton() {
const btn = document.getElementById('btn-submit');
btn.disabled = selectedFiles.length === 0;
if (selectedFiles.length > 0) {
btn.innerHTML = `<i class="fas fa-paper-plane me-2"></i>Enviar ${selectedFiles.length} archivo${selectedFiles.length > 1 ? 's' : ''}`;
} else {
btn.innerHTML = '<i class="fas fa-paper-plane me-2"></i>Enviar archivos';
}
}
// === Subida de archivos ===
async function submitFiles() {
if (selectedFiles.length === 0) return;
const btn = document.getElementById('btn-submit');
const progress = document.getElementById('upload-progress');
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');
const progressPct = document.getElementById('progress-pct');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Subiendo...';
progress.style.display = 'block';
let uploaded = 0;
const total = selectedFiles.length;
let errors = [];
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
progressText.textContent = `Subiendo ${file.name}... (${i + 1}/${total})`;
try {
const formData = new FormData();
formData.append('token', CONFIG.token);
formData.append('file', file);
const response = await uploadWithProgress(formData, (pct) => {
const totalPct = Math.round(((i + pct / 100) / total) * 100);
progressBar.style.width = totalPct + '%';
progressPct.textContent = totalPct + '%';
});
if (response.success) {
uploaded++;
} else {
errors.push(`${file.name}: ${response.error || 'Error desconocido'}`);
}
} catch (e) {
errors.push(`${file.name}: Error de conexión`);
}
}
if (uploaded > 0) {
// Mostrar éxito
document.getElementById('upload-form').style.display = 'none';
document.getElementById('upload-success').style.display = 'block';
selectedFiles = [];
if (errors.length > 0) {
alert(`Se subieron ${uploaded}/${total} archivos.\n\nErrores:\n${errors.join('\n')}`);
}
} else {
alert('No se pudieron subir los archivos.\n\n' + errors.join('\n'));
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane me-2"></i>Reintentar';
}
progress.style.display = 'none';
progressBar.style.width = '0%';
}
function uploadWithProgress(formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener('load', () => {
try {
const data = JSON.parse(xhr.responseText);
resolve(data);
} catch (e) {
resolve({ success: xhr.status === 200, error: 'Respuesta inválida' });
}
});
xhr.addEventListener('error', () => reject(new Error('Error de red')));
xhr.addEventListener('timeout', () => reject(new Error('Tiempo de espera agotado')));
xhr.open('POST', CONFIG.apiUrl);
xhr.timeout = 120000; // 2 minutos por archivo
xhr.send(formData);
});
}
function showUploadForm() {
document.getElementById('upload-success').style.display = 'none';
const successCards = document.querySelectorAll('.success-card');
successCards.forEach(c => c.style.display = 'none');
document.getElementById('upload-form').style.display = 'block';
selectedFiles = [];
renderFileList();
updateSubmitButton();
}
// === Utilidades ===
function getFileIcon(file) {
const ext = file.name.split('.').pop().toLowerCase();
if (file.type.startsWith('image/')) return 'fas fa-file-image text-primary';
if (file.type.startsWith('video/')) return 'fas fa-file-video text-danger';
if (file.type.startsWith('audio/')) return 'fas fa-file-audio text-warning';
const icons = {
pdf: 'fas fa-file-pdf text-danger',
doc: 'fas fa-file-word text-primary', docx: 'fas fa-file-word text-primary',
xls: 'fas fa-file-excel text-success', xlsx: 'fas fa-file-excel text-success',
ppt: 'fas fa-file-powerpoint text-warning', pptx: 'fas fa-file-powerpoint text-warning',
zip: 'fas fa-file-archive text-secondary', rar: 'fas fa-file-archive text-secondary',
txt: 'fas fa-file-alt text-muted', csv: 'fas fa-file-csv text-success',
};
return icons[ext] || 'fas fa-file text-muted';
}
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
function escapeHtml(text) {
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
</script>
</body>
</html>
<?php
// Helpers PHP
function formatSize($bytes) {
if ($bytes == 0) return '0 B';
$k = 1024;
$sizes = ['B', 'KB', 'MB', 'GB'];
$i = floor(log($bytes) / log($k));
return round($bytes / pow($k, $i), 1) . ' ' . $sizes[$i];
}
function getAcceptString($types) {
$accepts = [];
if (in_array('image', $types)) $accepts[] = 'image/*';
if (in_array('document', $types)) {
$accepts = array_merge($accepts, [
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.txt', '.csv', '.rtf', '.odt', '.zip', '.rar', '.7z'
]);
}
if (in_array('video', $types)) $accepts[] = 'video/*';
if (in_array('audio', $types)) $accepts[] = 'audio/*';
return implode(',', $accepts);
}
?>