Files
whatsapp/upload.php
2026-02-21 07:37:36 -05:00

757 lines
26 KiB
PHP

<?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);
}
?>