Dominando la Orientación a Objetos: Abstracción, Herencia y Traits en un Proyecto Real
Como arquitecto de software, me parece un enfoque brillante. En la industria existe el mito de que si no usas un Framework o una arquitectura MVC completa, tu código es «espagueti» o no es profesional.
Vamos a derribar ese mito en la introducción de tu artículo, demostrando que la profesionalidad no la da una estructura de carpetas, sino el respeto a los contratos y la cohesión de los objetos.
Referencia AI: r24: Versión 2.0.1 (Qween)
introducción redactada:
🚀 Más allá del MVC: Arquitectura basada en Contratos y Patrones de Diseño en PHP Puro
Introducción
En el desarrollo web moderno, parece existir una regla no escrita: si no utilizas un Framework (como Laravel o Symfony) o no estructuras tu proyecto bajo el patrón MVC (Modelo-Vista-Controlador), tu código está condenado a ser obsoleto, rígido y difícil de mantener. Esto es un mito.
El verdadero software profesional y escalable no nace de dónde guardas tus archivos físicos, sino de cómo interactúan tus objetos.
En este artículo, abordaremos la construcción de un sistema de notificaciones en PHP desde cero, prescindiendo deliberadamente de un armazón MVC tradicional y de gestores de dependencias como Composer. ¿El objetivo? Regresar a las bases de la Programación Orientada a Objetos (POO) pura para demostrar cómo el desacoplamiento, el tipado fuerte y los patrones creacionales pueden sostener un sistema de nivel empresarial.
🧠 ¿Por qué es Escalable y Profesional sin ser MVC?
Nuestra arquitectura no se apoya en la división de capas visuales, sino en tres pilares fundamentales del diseño de software:
1. Programación por Contrato (Desacoplamiento Real)
En lugar de que nuestro punto de entrada (api.php) dependa de clases concretas de Email o SMS, depende de una interfaz: Sendable. Al programar hacia una abstracción, el sistema se vuelve agnóstico a la implementación. Podemos añadir notificaciones por WhatsApp, Telegram o Slack mañana mismo sin alterar una sola línea del código que procesa la petición.
2. Centralización de la Creación (Patrón Factory)
Dejamos atrás los bloques if/else interminables para decidir qué objeto instanciar. Al delegar esta responsabilidad a una NotificationFactory, el «cómo se crea un objeto» queda aislado del «cómo se usa». Cumplimos a rajatabla con el principio de Responsabilidad Única (SRP).
3. Composición sobre Herencia (Traits)
¿Cómo dotamos de capacidades de auditoría (Logging) a nuestras clases sin forzar una herencia vertical absurda? Mediante un LoggerTrait. Esto nos da una flexibilidad horizontal que muchas arquitecturas MVC rígidas pasan por alto.
🛡️ Seguridad y Robustez de Producción
Prescindir de MVC no significa prescindir de la seguridad. Este proyecto aborda el ciclo de vida completo de una petición HTTP de forma segura:
- Defensa en el Cliente: Encapsulamiento de JavaScript mediante IIFE (evitando la contaminación del alcance global) y renderizado seguro contra XSS.
- Defensa en el Servidor: Validación estricta de tokens CSRF (Cross-Site Request Forgery), sanitización de tipos mediante filtros nativos de PHP y control de excepciones personalizadas para no exponer trazas internas del servidor.
Al finalizar esta lectura, comprenderás por qué dominar estos conceptos puros te convertirá en un mejor desarrollador, independientemente de si el día de mañana decides usar Laravel, Symfony o Node.js. Al final del día, los frameworks cambian; la buena arquitectura permanece.
📁 ESTRUCTURA FINAL DE DIRECTORIOS
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | /050032/ │ ├── .htaccess ├── api.php ├── index.php │ ├── config/ │ ├── .htaccess │ └── config.php │ ├── logs/ │ ├── .htaccess │ ├── notifications.log │ └── php_errors.log │ └── classes/ ├── Sendable.php ├── NotificationException.php ├── LoggerTrait.php ├── Notification.php ├── EmailNotification.php ├── SMSNotification.php └── NotificationFactory.php |
1️⃣ .htaccess (RAÍZ)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | # ============================================================================= # CONFIGURACIÓN DE SEGURIDAD - .htaccess # Sistema de Notificaciones POO - Producción # ============================================================================= # Deshabilitar información del servidor Header always unset X-Powered-By ServerSignature Off # 1. Prevenir el listado de directorios Options -Indexes # 2. Protección de archivos sensibles <FilesMatch "(\.htaccess|\.htpasswd|\.ini|\.phps|\.fla|\.psd|\.log|\.sh|\.bak|\.config|\.env)$"> <IfModule mod_authz_core.c> Require all denied </IfModule> <IfModule !mod_authz_core.c> Order Allow,Deny Deny from all </IfModule> </FilesMatch> # 3. Bloquear acceso directo a carpetas sensibles <IfModule mod_rewrite.c> RewriteEngine On RewriteBase /050032/ RewriteRule ^config/ - [F,L] RewriteRule ^vendor/ - [F,L] RewriteRule ^logs/ - [F,L] </IfModule> # 4. Protección específica para archivos de configuración <FilesMatch "^(config|database|settings|credentials|\.env)\.(php|ini|json)$"> <IfModule mod_authz_core.c> Require all denied </IfModule> </FilesMatch> # 5. Forzar UTF-8 AddDefaultCharset UTF-8 # 6. Cabeceras de Seguridad (Security Headers) <IfModule mod_headers.c> # Previene Clickjacking Header set X-Frame-Options "SAMEORIGIN" # Previene MIME Sniffing Header set X-Content-Type-Options "nosniff" # Habilita filtro XSS del navegador Header set X-XSS-Protection "1; mode=block" # Política de Referencia Header set Referrer-Policy "strict-origin-when-cross-origin" # Content Security Policy (CSP) - incluye blob: para imágenes Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; form-action 'self';" # Permissions Policy Header set Permissions-Policy "geolocation=(), microphone=(), camera=()" # Cache Control para archivos dinámicos <FilesMatch "\.(html|htm|php)$"> Header set Cache-Control "no-cache, no-store, must-revalidate" Header set Pragma "no-cache" Header set Expires "0" </FilesMatch> # Cache para archivos estáticos <FilesMatch "\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$"> Header set Cache-Control "max-age=31536000, public, immutable" </FilesMatch> </IfModule> # 7. Configuración PHP (si está permitido en .htaccess) <IfModule mod_php.c> php_flag display_errors Off php_flag log_errors On php_value error_log /var/log/php_errors.log php_value upload_max_filesize 5M php_value post_max_size 6M php_value max_execution_time 30 php_value max_input_time 60 php_value memory_limit 256M </IfModule> # 8. Configuración de Rewrite <IfModule mod_rewrite.c> RewriteEngine On RewriteBase /050032/ # Redirigir HTTP a HTTPS (descomentar si usas SSL) # RewriteCond %{HTTPS} off # RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] </IfModule> |
2️⃣ config/.htaccess
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | # ============================================================================= # PROTECCIÓN ABSOLUTA DEL DIRECTORIO CONFIG # Múltiples capas de seguridad # ============================================================================= # Denegar TODO acceso HTTP directo <IfModule mod_authz_core.c> Require all denied </IfModule> <IfModule !mod_authz_core.c> Order Deny,Allow Deny from all </IfModule> # Bloquear ejecución de scripts PHP desde web <FilesMatch "\.(php|php5|phtml|inc)$"> <IfModule mod_authz_core.c> Require all denied </IfModule> </FilesMatch> # Prevenir descarga de CUALQUIER archivo <FilesMatch "\.(php|inc|conf|ini|env|example|json|key|pem|crt|txt|log)$"> <IfModule mod_authz_core.c> Require all denied </IfModule> </FilesMatch> # Cabeceras de seguridad adicionales <IfModule mod_headers.c> Header set X-Content-Type-Options "nosniff" Header set X-Frame-Options "DENY" Header set X-XSS-Protection "1; mode=block" </IfModule> # Deshabilitar DirectoryIndex DirectoryIndex disabled # Prevenir listado de directorios Options -Indexes |
3️⃣ config/config.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 | <?php /** * ============================================================================= * CONFIGURACIÓN DEL SISTEMA - config.php * Archivo de configuración centralizado y protegido * Soporte para emails HTML/Texto y envío vía mail() o SMTP * ============================================================================= * * @package NotificationSystem\Config * @version 1.1.0 * @access private (protegido por .htaccess) */ declare(strict_types=1); // Prevenir acceso directo vía web if (PHP_SAPI !== 'cli' && isset($_SERVER['REQUEST_URI']) && stripos($_SERVER['REQUEST_URI'], 'config.php') !== false) { http_response_code(403); exit('Acceso denegado'); } // ============================================================================= // CONFIGURACIÓN DE SESIÓN SEGURA (MEJORADA PARA COMPARTIR SESIÓN) // ============================================================================= // Detectar si la conexión es HTTPS (incluyendo proxies) $isHttps = false; if (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) === 'on') { $isHttps = true; } elseif (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') { $isHttps = true; } elseif (isset($_SERVER['HTTP_X_FORWARDED_SSL']) && strtolower($_SERVER['HTTP_X_FORWARDED_SSL']) === 'on') { $isHttps = true; } $sessionOptions = [ 'cookie_httponly' => true, 'cookie_secure' => $isHttps, // Solo si HTTPS 'cookie_samesite' => 'Lax', // Cambiado a Lax para compatibilidad AJAX 'cookie_path' => '/', // Ruta explícita 'cookie_domain' => '', // Dominio actual (sin subdominio fijo) 'use_strict_mode' => true, 'gc_maxlifetime' => 3600 ]; foreach ($sessionOptions as $key => $value) { ini_set('session.' . $key, (string)$value); } // Iniciar sesión si no está activa if (session_status() === PHP_SESSION_NONE) { session_start(); } // ============================================================================= // GENERACIÓN Y REGENERACIÓN DE TOKEN CSRF // ============================================================================= // Regenerar token cada 24 horas para seguridad adicional $tokenTime = $_SESSION['csrf_token_time'] ?? 0; $tokenLifetime = 86400; // 24 horas en segundos if (empty($_SESSION['csrf_token']) || (time() - $tokenTime > $tokenLifetime)) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token_time'] = time(); } // ============================================================================= // CONSTANTES DE ENTORNO // ============================================================================= /** * Modo de la aplicación: 'production' o 'development' */ define('APP_ENV', getenv('APP_ENV') ?: 'production'); /** * Ruta base absoluta del proyecto */ define('ROOT_PATH', dirname(__DIR__)); /** * Ruta absoluta al directorio config */ define('CONFIG_PATH', __DIR__); /** * Ruta absoluta al directorio de logs */ define('LOGS_PATH', ROOT_PATH . '/logs'); // Crear directorio de logs si no existe if (!is_dir(LOGS_PATH)) { @mkdir(LOGS_PATH, 0755, true); } // ============================================================================= // CONFIGURACIÓN DE EMAIL // ============================================================================= /** * Método de envío de emails: 'mail' o 'smtp' */ define('EMAIL_METHOD', getenv('EMAIL_METHOD') ?: 'smtp'); /** * Formato del email: 'html' o 'text' * - 'html': Envía emails con formato HTML (recomendado) * - 'text': Envía emails en texto plano */ define('EMAIL_FORMAT', getenv('EMAIL_FORMAT') ?: 'html'); /** * Email "From" por defecto */ define('EMAIL_FROM_ADDRESS', getenv('EMAIL_FROM_ADDRESS') ?: 'webmaster822@gmail.com'); define('EMAIL_FROM_NAME', getenv('EMAIL_FROM_NAME') ?: 'Sistema de Notificaciones'); /** * Email de respuesta (Reply-To) */ define('EMAIL_REPLY_TO', getenv('EMAIL_REPLY_TO') ?: 'webmaster822@gmail.com'); /** * Charset por defecto para emails */ define('EMAIL_CHARSET', 'UTF-8'); // ============================================================================= // CONFIGURACIÓN SMTP (solo si EMAIL_METHOD = 'smtp') // ============================================================================= /** * Host del servidor SMTP */ define('SMTP_HOST', getenv('SMTP_HOST') ?: 'smtp.gmail.com'); /** * Puerto SMTP: 25, 465 (SSL), 587 (STARTTLS) */ define('SMTP_PORT', (int)(getenv('SMTP_PORT') ?: 587)); /** * Cifrado SMTP: 'tls', 'ssl' o '' (ninguno) */ define('SMTP_SECURE', getenv('SMTP_SECURE') ?: 'tls'); /** * Autenticación SMTP */ define('SMTP_AUTH', filter_var(getenv('SMTP_AUTH') ?: 'true', FILTER_VALIDATE_BOOLEAN)); define('SMTP_USERNAME', getenv('SMTP_USERNAME') ?: 'online.events.service@gmail.com'); define('SMTP_PASSWORD', getenv('SMTP_PASSWORD') ?: 'pcmxllkmztoreooe'); /** * Timeout para conexiones SMTP (en segundos) */ define('SMTP_TIMEOUT', (int)(getenv('SMTP_TIMEOUT') ?: 30)); /** * Debug SMTP: 0=off, 1=commands, 2=commands+data, 3=+connection, 4=full */ define('SMTP_DEBUG', APP_ENV === 'development' ? 2 : 0); /** * Verificar certificados SSL en SMTP */ define('SMTP_VERIFY_CERTS', filter_var(getenv('SMTP_VERIFY_CERTS') ?: 'true', FILTER_VALIDATE_BOOLEAN)); // ============================================================================= // CONFIGURACIÓN DE EMAILS HTML // ============================================================================= /** * Ruta a la plantilla HTML base para emails * Debe contener {{subject}}, {{body}}, {{footer}} */ define('EMAIL_HTML_TEMPLATE', ROOT_PATH . '/templates/email-base.html'); /** * CSS inline para emails HTML (compatibilidad con clientes de email) */ define('EMAIL_HTML_CSS', ' body { font-family: Arial, sans-serif; line-height: 1.6; color: #333333; background-color: #f4f4f4; margin: 0; padding: 0; } .email-container { max-width: 600px; margin: 20px auto; background: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .email-header { background: #2563eb; color: #ffffff; padding: 20px; text-align: center; } .email-header h1 { margin: 0; font-size: 24px; } .email-body { padding: 30px 20px; } .email-body p { margin: 0 0 15px 0; } .email-footer { background: #f8fafc; padding: 15px 20px; text-align: center; font-size: 12px; color: #64748b; border-top: 1px solid #e2e8f0; } .email-footer a { color: #2563eb; text-decoration: none; } '); /** * Footer por defecto para emails HTML */ define('EMAIL_HTML_FOOTER', ' <div class="email-footer"> <p>Este es un mensaje automático del Sistema de Notificaciones.</p> <p>© ' . date('Y') . ' <a href="https://tudominio.com">tudominio.com</a>. Todos los derechos reservados.</p> <p>Si no solicitaste este mensaje, puedes ignorarlo de forma segura.</p> </div> '); // ============================================================================= // CONFIGURACIÓN DE SEGURIDAD // ============================================================================= /** * Clave para hashing y tokens (generar con: bin2hex(random_bytes(32))) * NOTA: Las credenciales se mantienen en constantes protegidas según requerimiento */ define('APP_KEY', getenv('APP_KEY') ?: 'be8c9f644ce04bd3a387b53b82a906fe2812a7168fbf131fde38fadce255e7f2'); /** * Tiempo de vida de la sesión en segundos */ define('SESSION_LIFETIME', (int)(getenv('SESSION_LIFETIME') ?: 3600)); /** * Rate limiting */ define('RATE_LIMIT_MAX', (int)(getenv('RATE_LIMIT_MAX') ?: 10)); define('RATE_LIMIT_WINDOW', (int)(getenv('RATE_LIMIT_WINDOW') ?: 60)); // ============================================================================= // VALIDACIÓN DE CONFIGURACIÓN EN PRODUCCIÓN // ============================================================================= if (APP_ENV === 'production') { // Validar APP_KEY (solo log, no detener según requerimiento) if (strpos(APP_KEY, 'be8c9f644ce04bd3a387b53b82a906fe2812a7168fbf131fde38fadce255e7f2') !== false) { error_log('CRITICAL: APP_KEY no ha sido configurado correctamente en producción'); } // Validar configuración SMTP if (EMAIL_METHOD === 'smtp') { if (empty(SMTP_USERNAME) || empty(SMTP_PASSWORD)) { error_log('WARNING: Credenciales SMTP vacías con EMAIL_METHOD=smtp'); } } } // ============================================================================= // FUNCIONES AUXILIARES // ============================================================================= /** * Obtiene una variable de entorno con fallback * * @param string $key Clave de la variable de entorno * @param mixed $default Valor por defecto * @return mixed Valor de la variable o default */ function env(string $key, $default = null) { $value = getenv($key); return $value === false ? $default : $value; } /** * Verifica si la configuración de email está completa * * @return bool True si está configurado correctamente */ function isEmailConfigured(): bool { if (EMAIL_METHOD === 'mail') { return true; } if (EMAIL_METHOD === 'smtp') { return !empty(SMTP_HOST) && !empty(SMTP_USERNAME) && !empty(SMTP_PASSWORD); } return false; } /** * Obtiene la configuración de PHPMailer como array * * @return array Configuración completa para PHPMailer */ function getPhpMailerConfig(): array { return [ 'Host' => SMTP_HOST, 'Port' => SMTP_PORT, 'SMTPAuth' => SMTP_AUTH, 'Username' => SMTP_USERNAME, 'Password' => SMTP_PASSWORD, 'SMTPSecure' => SMTP_SECURE, 'Timeout' => SMTP_TIMEOUT, 'SMTPDebug' => SMTP_DEBUG, 'Debugoutput' => 'error_log', 'SMTPAutoTLS' => SMTP_SECURE !== '', 'SMTPOptions' => [ 'ssl' => [ 'verify_peer' => SMTP_VERIFY_CERTS, 'verify_peer_name' => SMTP_VERIFY_CERTS, 'allow_self_signed' => !SMTP_VERIFY_CERTS ] ] ]; } /** * Genera el cuerpo HTML para un email * * @param string $subject Asunto del email * @param string $body Cuerpo del mensaje (texto plano o HTML) * @param string $footer Footer personalizado (opcional) * @return string HTML completo del email */ function generateEmailHTML(string $subject, string $body, string $footer = ''): string { // Si el body ya contiene HTML, usarlo directamente if (stripos($body, '<html') !== false || stripos($body, '<body') !== false) { $content = $body; } else { // Convertir texto plano a HTML seguro $content = '<p>' . nl2br(htmlspecialchars($body, ENT_QUOTES, EMAIL_CHARSET)) . '</p>'; } // Usar footer personalizado o el por defecto $finalFooter = !empty($footer) ? $footer : EMAIL_HTML_FOOTER; // Plantilla base $template = ' <!DOCTYPE html> <html lang="es"> <head> <meta charset="' . EMAIL_CHARSET . '"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>' . htmlspecialchars($subject, ENT_QUOTES, EMAIL_CHARSET) . '</title> <style>' . EMAIL_HTML_CSS . '</style> </head> <body> <div class="email-container"> <div class="email-header"> <h1>' . htmlspecialchars(EMAIL_FROM_NAME, ENT_QUOTES, EMAIL_CHARSET) . '</h1> </div> <div class="email-body"> ' . $content . ' </div> ' . $finalFooter . ' </div> </body> </html>'; return $template; } /** * Convierte HTML a texto plano para versión alternativa del email * * @param string $html Contenido HTML a convertir * @return string Texto plano equivalente */ function htmlToPlainText(string $html): string { // Eliminar tags HTML pero mantener estructura básica $text = strip_tags($html, '<p><br><ul><ol><li><strong><em>'); // Convertir <br> a saltos de línea $text = preg_replace('/<br\s*\/?>/i', "\n", $text); // Convertir bloques a saltos de línea dobles $text = preg_replace('/<\/(p|div|li)>/i', "\n", $text); // Eliminar tags restantes $text = strip_tags($text); // Normalizar espacios $text = preg_replace('/\n{3,}/', "\n", $text); return trim($text); } |
4️⃣ logs/.htaccess
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | # ============================================================================= # PROTECCIÓN DE CARPETA LOGS # ============================================================================= # Denegar todo acceso directo <IfModule mod_authz_core.c> Require all denied </IfModule> <IfModule !mod_authz_core.c> Order Allow,Deny Deny from all </IfModule> # Prevenir ejecución de PHP <FilesMatch "\.(php|php5|phtml|inc)$"> <IfModule mod_authz_core.c> Require all denied </IfModule> </FilesMatch> # Permitir escritura solo desde PHP local <Files "notifications.log"> <IfModule mod_authz_core.c> Require local </IfModule> </Files> |
5️⃣ classes/Sendable.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | <?php /** * ============================================================================= * INTERFAZ SENDABLE * Contrato para todas las clases de notificación * ============================================================================= * * @package NotificationSystem\Classes * @version 1.0.0 */ declare(strict_types=1); namespace App\Classes; interface Sendable { /** * Envía la notificación al receptor * * @return bool Retorna true si el envío fue exitoso * @throws NotificationException Si ocurre un error durante el envío */ public function enviar(): bool; /** * Valida los datos de la notificación antes de enviar * * @return void * @throws NotificationException Si los datos no son válidos */ public function validar(): void; } |
6️⃣ classes/NotificationException.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | <?php /** * ============================================================================= * CLASE NOTIFICATIONEXCEPTION * Manejo de errores personalizado para el sistema de notificaciones * ============================================================================= * * @package NotificationSystem\Classes * @version 1.0.0 */ declare(strict_types=1); namespace App\Classes; use Exception; class NotificationException extends Exception { /** * @var string Mensaje amigable para el usuario final */ private string $userFriendlyMessage; /** * @var int Código de error interno */ private int $errorCode; /** * Constructor de la excepción * * @param string $message Mensaje de error técnico * @param string $userMessage Mensaje amigable para el usuario * @param int $code Código de error interno * @param Exception|null $previous Excepción anterior */ public function __construct( string $message = "Error en la notificación", string $userMessage = "Ha ocurrido un error. Por favor, inténtelo de nuevo.", int $code = 1000, ?Exception $previous = null ) { parent::__construct($message, $code, $previous); $this->userFriendlyMessage = $userMessage; $this->errorCode = $code; } /** * Obtiene el mensaje amigable para el usuario * * @return string Mensaje seguro para mostrar */ public function getUserFriendlyMessage(): string { return htmlspecialchars($this->userFriendlyMessage, ENT_QUOTES, 'UTF-8'); } /** * Obtiene el código de error interno * * @return int Código de error */ public function getErrorCode(): int { return $this->errorCode; } /** * Obtiene el mensaje técnico (para logs) * * @return string Mensaje técnico */ public function getTechnicalMessage(): string { return $this->getMessage(); } /** * Representación en string de la excepción * * @return string Información completa */ public function __toString(): string { return __CLASS__ . ": [{$this->errorCode}] {$this->getMessage()} en {$this->getFile()}:{$this->getLine()}"; } } |
7️⃣ classes/LoggerTrait.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | <?php /** * ============================================================================= * TRAIT LOGGERTRAIT * Funcionalidad horizontal de logging para el sistema de notificaciones * ============================================================================= * * @package NotificationSystem\Classes * @version 1.0.0 */ declare(strict_types=1); namespace App\Classes; trait LoggerTrait { /** * @var string Ruta absoluta al archivo de logs */ private const LOG_PATH = LOGS_PATH . '/notifications.log'; /** * @var int Tamaño máximo del archivo de log antes de rotar (10MB) */ private const MAX_LOG_SIZE = 10485760; /** * @var int Número máximo de archivos de log rotados */ private const MAX_ROTATED_FILES = 5; /** * Registra una operación en el archivo de logs * * @param string $type Tipo de operación * @param string $target Destinatario * @param string $status Estado * @param array $context Contexto adicional * @return void */ public function logOperation( string $type, string $target, string $status, array $context = [] ): void { // Sanitización de datos para prevenir log injection $target = $this->sanitizeLogData($target); $type = $this->sanitizeLogData($type); $status = $this->sanitizeLogData($status); // Rotación de logs si es necesario $this->rotateLogIfNeeded(); // Construir mensaje de log $logMessage = $this->buildLogMessage($type, $target, $status, $context); // Escritura segura con lock para evitar race conditions $result = @file_put_contents( self::LOG_PATH, $logMessage, FILE_APPEND | LOCK_EX ); if ($result === false) { $error = error_get_last(); $errorMsg = $error !== null ? $error['message'] : 'Unknown error'; error_log("Error al escribir en el archivo de logs: " . self::LOG_PATH . " - " . $errorMsg); } } /** * Sanitiza los datos antes de escribirlos en el log * * @param string $data Datos a sanitizar * @return string Datos sanitizados */ private function sanitizeLogData(string $data): string { // Eliminar saltos de línea para prevenir log injection $data = preg_replace('/[\r\n\t]/', ' ', $data); // Limitar longitud máxima $data = mb_substr($data, 0, 255, 'UTF-8'); // Escapar caracteres especiales $data = htmlspecialchars($data, ENT_QUOTES | ENT_HTML5, 'UTF-8'); return trim($data); } /** * Construye el mensaje de log formateado * * @param string $type Tipo de operación * @param string $target Destinatario * @param string $status Estado * @param array $context Contexto adicional * @return string Mensaje de log formateado */ private function buildLogMessage( string $type, string $target, string $status, array $context ): string { $timestamp = date('Y-m-d H:i:s'); $ipAddress = $this->getClientIP(); $requestId = bin2hex(random_bytes(4)); $contextStr = !empty($context) ? ' | Context: ' . json_encode($context, JSON_UNESCAPED_UNICODE) : ''; return sprintf( "[%s] [ID:%s] [IP:%s] Type: %s | To: %s | Status: %s%s" . PHP_EOL, $timestamp, $requestId, $ipAddress, $type, $target, $status, $contextStr ); } /** * Rota el archivo de log si excede el tamaño máximo * * @return void */ private function rotateLogIfNeeded(): void { if (!file_exists(self::LOG_PATH)) { return; } $fileSize = @filesize(self::LOG_PATH); if ($fileSize === false || $fileSize < self::MAX_LOG_SIZE) { return; } // Rotar archivos existentes for ($i = self::MAX_ROTATED_FILES; $i >= 1; $i--) { $oldFile = self::LOG_PATH . '.' . $i; $newFile = self::LOG_PATH . '.' . ($i + 1); if (file_exists($oldFile)) { if ($i === self::MAX_ROTATED_FILES) { @unlink($oldFile); } else { @rename($oldFile, $newFile); } } } // Rotar el archivo actual @rename(self::LOG_PATH, self::LOG_PATH . '.1'); } /** * Obtiene la dirección IP del cliente * * @return string Dirección IP */ private function getClientIP(): string { $ipKeys = [ 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ]; foreach ($ipKeys as $key) { if (!empty($_SERVER[$key])) { $ip = explode(',', $_SERVER[$key])[0]; if (filter_var($ip, FILTER_VALIDATE_IP)) { return $this->sanitizeLogData($ip); } } } return 'UNKNOWN'; } } |
8️⃣ classes/Notification.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 | <?php /** * ============================================================================= * CLASE ABSTRACTA NOTIFICATION * Clase base para todas las notificaciones del sistema * ============================================================================= * * @package NotificationSystem\Classes * @version 1.0.0 */ declare(strict_types=1); namespace App\Classes; require_once __DIR__ . '/LoggerTrait.php'; require_once __DIR__ . '/Sendable.php'; require_once __DIR__ . '/NotificationException.php'; abstract class Notification implements Sendable { use LoggerTrait; /** * @var string Receptor de la notificación */ protected string $receptor; /** * @var string Mensaje de la notificación */ protected string $mensaje; /** * Constructor de la notificación * * @param string $receptor Destinatario de la notificación * @param string $mensaje Contenido de la notificación * @throws NotificationException Si los parámetros son inválidos */ public function __construct(string $receptor, string $mensaje) { // Validación de parámetros if (empty($receptor) || trim($receptor) === '') { throw new NotificationException( "El receptor no puede estar vacío", "Por favor, proporcione un destinatario válido.", 1001 ); } if (empty($mensaje) || trim($mensaje) === '') { throw new NotificationException( "El mensaje no puede estar vacío", "Por favor, escriba un mensaje.", 1002 ); } if (strlen($mensaje) > 5000) { throw new NotificationException( "El mensaje excede la longitud máxima permitida (5000 caracteres)", "El mensaje es demasiado largo. Por favor, redúzcalo.", 1003 ); } // Sanitización básica $this->receptor = $this->sanitizarEntrada($receptor); $this->mensaje = $this->sanitizarEntrada($mensaje); } /** * Sanitiza la entrada de datos * * @param string $data Datos a sanitizar * @return string Datos sanitizados */ private function sanitizarEntrada(string $data): string { // Eliminar tags HTML peligrosos $data = strip_tags($data, '<p><br><strong><em><ul><ol><li><a>'); // Normalizar espacios en blanco $data = preg_replace('/\s+/', ' ', $data); // Eliminar caracteres de control $data = preg_replace('/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/', '', $data); return trim($data); } /** * Método abstracto para validar datos específicos del tipo de notificación * * @return void * @throws NotificationException Si la validación falla */ abstract public function validar(): void; /** * Envía la notificación (implementado por clases hijas) * * @return bool True si el envío fue exitoso * @throws NotificationException Si ocurre un error durante el envío */ abstract public function enviar(): bool; /** * Obtiene el receptor de la notificación * * @return string Receptor */ public function getReceptor(): string { return $this->receptor; } /** * Obtiene el mensaje de la notificación * * @return string Mensaje */ public function getMensaje(): string { return $this->mensaje; } /** * Representación en string de la notificación * * @return string Información de la notificación */ public function __toString(): string { return sprintf( "Notification: To=%s, Message=%s", $this->receptor, mb_substr($this->mensaje, 0, 50, 'UTF-8') ); } } |
9️⃣ classes/EmailNotification.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 | <?php /** * ============================================================================= * CLASE EMAILNOTIFICATION * Implementación concreta para envío de notificaciones por email * Soporte dual: mail() nativo o PHPMailer con SMTP * Soporte para emails HTML y texto plano * Compatible con PHP 7.4+ * ============================================================================= * * @package NotificationSystem\Classes * @version 1.0.0 (Corregido TypeError footer null) */ declare(strict_types=1); namespace App\Classes; // Incluir configuración protegida require_once __DIR__ . '/../config/config.php'; // Incluir dependencias de la clase require_once __DIR__ . '/Notification.php'; require_once __DIR__ . '/Sendable.php'; require_once __DIR__ . '/NotificationException.php'; // Cargar PHPMailer si se usa SMTP o formato HTML if (defined('EMAIL_METHOD') && (EMAIL_METHOD === 'smtp' || EMAIL_FORMAT === 'html')) { $composerAutoload = __DIR__ . '/../vendor/autoload.php'; $manualPath = __DIR__ . '/../vendor/phpmailer/phpmailer/src'; if (file_exists($composerAutoload)) { require_once $composerAutoload; } elseif (file_exists($manualPath . '/Exception.php')) { require_once $manualPath . '/Exception.php'; require_once $manualPath . '/PHPMailer.php'; require_once $manualPath . '/SMTP.php'; } } use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\Exception as PHPMailerException; class EmailNotification extends Notification implements Sendable { /** * @var string Asunto del email */ private string $asunto; /** * @var string|null Footer personalizado para emails HTML */ private ?string $footerPersonalizado; /** * @var array Archivos adjuntos para enviar */ private array $adjuntos = []; /** * Constructor * * @param string $receptor Email del destinatario * @param string $mensaje Cuerpo del mensaje * @param string $asunto Asunto del email * @param string|null $footerPersonalizado Footer personalizado (opcional) */ public function __construct( string $receptor, string $mensaje, string $asunto = 'Notificación del Sistema', ?string $footerPersonalizado = null ) { parent::__construct($receptor, $mensaje); $this->asunto = $this->sanitizarAsunto($asunto); $this->footerPersonalizado = $footerPersonalizado; } /** * Sanitiza el asunto del email * * @param string $asunto Asunto a sanitizar * @return string Asunto limpio */ private function sanitizarAsunto(string $asunto): string { $asunto = strip_tags($asunto); $asunto = preg_replace('/[\r\n]/', ' ', $asunto); return mb_substr(trim($asunto), 0, 255, 'UTF-8'); } /** * Valida el formato del email del receptor * * @return void * @throws NotificationException Si el email no es válido */ public function validar(): void { if (!filter_var($this->receptor, FILTER_VALIDATE_EMAIL)) { throw new NotificationException( "El formato del correo no es válido", "Por favor, verifique que el correo electrónico sea correcto.", 2001 ); } // Validación opcional de DNS (no bloqueante) $domain = substr(strrchr($this->receptor, "@"), 1); if (!empty($domain) && !checkdnsrr($domain, 'MX') && !checkdnsrr($domain, 'A')) { error_log("Advertencia: Dominio '{$domain}' sin registros DNS válidos"); } } /** * Envía la notificación por email * * @return bool True si el envío fue exitoso * @throws NotificationException Si ocurre un error durante el envío */ public function enviar(): bool { try { $this->validar(); // Elegir método de envío según configuración $envioExitoso = false; switch (EMAIL_METHOD) { case 'smtp': $envioExitoso = $this->enviarViaSMTP(); break; case 'mail': $envioExitoso = $this->enviarViaMailNative(); break; default: throw new NotificationException( "Método de envío no configurado: " . EMAIL_METHOD, "Error de configuración del sistema.", 2010 ); } // Registrar operación $status = $envioExitoso ? 'SUCCESS' : 'FAILED'; $this->logOperation('EMAIL', $this->receptor, $status, [ 'method' => EMAIL_METHOD, 'format' => EMAIL_FORMAT, 'asunto' => $this->asunto ]); if (!$envioExitoso) { throw new NotificationException( "Falló el envío del email", "No pudimos enviar el correo. Por favor, inténtelo más tarde.", 2004 ); } return true; } catch (NotificationException $e) { throw $e; } catch (PHPMailerException $e) { $this->logOperation('EMAIL', $this->receptor, 'ERROR', [ 'error' => $e->getMessage() ]); throw new NotificationException( "Error en PHPMailer: " . $e->getMessage(), "Ha ocurrido un error técnico al enviar el correo.", 2098, $e ); } catch (\Exception $e) { $this->logOperation('EMAIL', $receptor ?? $this->receptor, 'ERROR', [ 'error' => $e->getMessage() ]); throw new NotificationException( "Error inesperado al enviar email", "Ha ocurrido un error técnico. Por favor, contacte soporte.", 2099, $e ); } } /** * Envía email usando función mail() nativa de PHP * * @return bool Resultado del envío */ private function enviarViaMailNative(): bool { if (EMAIL_FORMAT === 'html') { return $this->enviarHtmlViaMailNative(); } return $this->enviarTextoViaMailNative(); } /** * Envía email en texto plano usando mail() nativa * * @return bool Resultado del envío */ private function enviarTextoViaMailNative(): bool { $headers = [ 'From: ' . EMAIL_FROM_NAME . ' <' . EMAIL_FROM_ADDRESS . '>', 'Reply-To: ' . EMAIL_REPLY_TO, 'X-Mailer: PHP/' . phpversion(), 'MIME-Version: 1.0', 'Content-Type: text/plain; charset=' . EMAIL_CHARSET, 'Content-Transfer-Encoding: 8bit' ]; $headerString = implode("\r\n", $headers); return @mail( $this->receptor, $this->asunto, $this->mensaje, $headerString ) === true; } /** * Envía email en HTML usando mail() nativa (limitado) * * @return bool Resultado del envío */ private function enviarHtmlViaMailNative(): bool { // CORRECCIÓN: Asegurar que footer sea string, no null $htmlBody = generateEmailHTML($this->asunto, $this->mensaje, $this->footerPersonalizado ?? ''); $textBody = htmlToPlainText($htmlBody); // Boundary para multipart $boundary = '=_'.md5(uniqid((string)rand(), true)); $headers = [ 'From: ' . EMAIL_FROM_NAME . ' <' . EMAIL_FROM_ADDRESS . '>', 'Reply-To: ' . EMAIL_REPLY_TO, 'X-Mailer: PHP/' . phpversion(), 'MIME-Version: 1.0', 'Content-Type: multipart/alternative; boundary="'.$boundary.'"' ]; $headerString = implode("\r\n", $headers); // Cuerpo multipart $body = "--{$boundary}\r\n"; $body .= "Content-Type: text/plain; charset=" . EMAIL_CHARSET . "\r\n"; $body .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; $body .= $textBody . "\r\n\r\n"; $body .= "--{$boundary}\r\n"; $body .= "Content-Type: text/html; charset=" . EMAIL_CHARSET . "\r\n"; $body .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; $body .= $htmlBody . "\r\n\r\n"; $body .= "--{$boundary}--"; return @mail( $this->receptor, $this->asunto, $body, $headerString ) === true; } /** * Envía email usando PHPMailer con configuración SMTP * Soporte completo para HTML, texto alternativo, adjuntos, etc. * * @return bool Resultado del envío * @throws PHPMailerException Si falla el envío */ private function enviarViaSMTP(): bool { $mail = new PHPMailer(true); try { // Configuración del servidor $config = getPhpMailerConfig(); $mail->isSMTP(); $mail->Host = $config['Host']; $mail->SMTPAuth = $config['SMTPAuth']; $mail->Username = $config['Username']; $mail->Password = $config['Password']; $mail->SMTPSecure = $config['SMTPSecure'] === 'ssl' ? PHPMailer::ENCRYPTION_SMTPS : PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = $config['Port']; $mail->Timeout = $config['Timeout']; $mail->SMTPDebug = $config['SMTPDebug']; $mail->Debugoutput = $config['Debugoutput']; $mail->SMTPOptions = $config['SMTPOptions']; // Configuración del mensaje $mail->setFrom(EMAIL_FROM_ADDRESS, EMAIL_FROM_NAME); $mail->addReplyTo(EMAIL_REPLY_TO, EMAIL_FROM_NAME); $mail->addAddress($this->receptor); $mail->CharSet = EMAIL_CHARSET; $mail->Subject = $this->asunto; if (EMAIL_FORMAT === 'html') { // Email HTML con versión alternativa en texto plano // CORRECCIÓN: Asegurar que footer sea string, no null $htmlBody = generateEmailHTML($this->asunto, $this->mensaje, $this->footerPersonalizado ?? ''); $textBody = htmlToPlainText($htmlBody); $mail->isHTML(true); $mail->Body = $htmlBody; $mail->AltBody = $textBody; } else { // Email en texto plano $mail->isHTML(false); $mail->Body = $this->mensaje; } // Adjuntar archivos si existen foreach ($this->adjuntos as $adjunto) { $mail->addAttachment($adjunto['path'], $adjunto['name'] ?? ''); } // Enviar return $mail->send(); } catch (PHPMailerException $e) { throw $e; } } /** * Agrega un adjunto al email (solo SMTP) * * @param string $path Ruta del archivo a adjuntar * @param string|null $name Nombre personalizado del adjunto * @return self * @throws NotificationException Si el archivo no existe */ public function adjuntarArchivo(string $path, ?string $name = null): self { if (EMAIL_METHOD !== 'smtp') { throw new NotificationException( "Adjuntos solo disponibles con EMAIL_METHOD=smtp", "Configure EMAIL_METHOD=smtp para adjuntos.", 2020 ); } if (!file_exists($path)) { throw new NotificationException( "El archivo a adjuntar no existe: {$path}", "Error al adjuntar archivo.", 2021 ); } if (!is_readable($path)) { throw new NotificationException( "El archivo no es legible: {$path}", "Error de permisos al adjuntar archivo.", 2022 ); } // Guardar para adjuntar en enviarViaSMTP $this->adjuntos[] = ['path' => $path, 'name' => $name]; return $this; } /** * Obtiene el asunto del email * * @return string Asunto */ public function getAsunto(): string { return $this->asunto; } /** * Verifica si el email será enviado en formato HTML * * @return bool True si es HTML */ public function esFormatoHTML(): bool { return EMAIL_FORMAT === 'html'; } /** * Obtiene los adjuntos configurados * * @return array Lista de adjuntos */ public function getAdjuntos(): array { return $this->adjuntos; } } |
🔟 classes/SMSNotification.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | <?php /** * ============================================================================= * CLASE SMSNOTIFICATION * Implementación concreta para envío de notificaciones por SMS * ============================================================================= * * @package NotificationSystem\Classes * @version 1.0.0 */ declare(strict_types=1); namespace App\Classes; require_once __DIR__ . '/Notification.php'; require_once __DIR__ . '/Sendable.php'; require_once __DIR__ . '/NotificationException.php'; class SMSNotification extends Notification implements Sendable { /** * @var string Código de país */ private string $codigoPais; /** * @var bool Modo simulación (true para desarrollo, false para producción) */ private bool $modoSimulacion = true; /** * Constructor * * @param string $receptor Número de teléfono * @param string $mensaje Contenido del SMS * @param string $codigoPais Código de país (ej: +34, +52, +1) */ public function __construct( string $receptor, string $mensaje, string $codigoPais = '+34' ) { parent::__construct($receptor, $mensaje); $this->codigoPais = $this->validarCodigoPais($codigoPais); // Detectar modo simulación según entorno $this->modoSimulacion = (APP_ENV !== 'production'); } /** * Valida y sanitiza el código de país * * @param string $codigoPais Código de país * @return string Código validado */ private function validarCodigoPais(string $codigoPais): string { $codigoPais = trim($codigoPais); if (strpos($codigoPais, '+') !== 0) { $codigoPais = '+' . ltrim($codigoPais, '+'); } if (!preg_match('/^\+\d{1,3}$/', $codigoPais)) { return '+34'; // Valor por defecto } return $codigoPais; } /** * Valida los datos específicos de SMS * * @return void * @throws NotificationException Si la validación falla */ public function validar(): void { $digitos = preg_replace('/[^\d]/', '', $this->receptor); // Validación flexible: 7-15 dígitos para cubrir más países if (strlen($digitos) < 7 || strlen($digitos) > 15) { throw new NotificationException( "El número de teléfono no tiene una longitud válida", "Por favor, verifique el número de teléfono.", 3002 ); } if (strlen($this->mensaje) > 160) { throw new NotificationException( "El mensaje SMS excede los 160 caracteres", "El mensaje es demasiado largo para SMS.", 3003 ); } if (empty(trim($this->mensaje))) { throw new NotificationException( "El mensaje SMS no puede estar vacío", "Por favor, escriba un mensaje.", 3004 ); } } /** * Envía la notificación por SMS * * @return bool True si el envío fue exitoso * @throws NotificationException Si ocurre un error durante el envío */ public function enviar(): bool { try { $this->validar(); $numeroCompleto = $this->codigoPais . preg_replace('/[^\d]/', '', $this->receptor); // Envío real o simulado según modo if ($this->modoSimulacion) { $envioExitoso = $this->simularEnvioSMS($numeroCompleto); } else { $envioExitoso = $this->enviarSMSReal($numeroCompleto); } $status = $envioExitoso ? 'SUCCESS' : 'FAILED'; $this->logOperation('SMS', $numeroCompleto, $status, [ 'codigo_pais' => $this->codigoPais, 'simulacion' => $this->modoSimulacion ]); if (!$envioExitoso) { throw new NotificationException( "Falló el envío del SMS", "No pudimos enviar el SMS. Inténtelo más tarde.", 3005 ); } return true; } catch (NotificationException $e) { throw $e; } catch (\Exception $e) { $this->logOperation('SMS', $this->receptor, 'ERROR', [ 'error' => $e->getMessage() ]); throw new NotificationException( "Error inesperado al enviar SMS", "Ha ocurrido un error técnico. Contacte soporte.", 3099, $e ); } } /** * Simula el envío de SMS para desarrollo * * @param string $numeroCompleto Número completo * @return bool Resultado simulado */ private function simularEnvioSMS(string $numeroCompleto): bool { // En desarrollo, siempre retorna true con log error_log("[SMS SIMULADO] Enviando a: {$numeroCompleto} | Mensaje: " . substr($this->mensaje, 0, 50)); return true; } /** * Envía SMS real mediante API externa (Twilio, Vonage, etc.) * * @param string $numeroCompleto Número completo * @return bool Resultado del envío */ private function enviarSMSReal(string $numeroCompleto): bool { // Integración con API real de SMS // Ejemplo con Twilio (descomentar y configurar en producción): /* try { $twilio = new \Twilio\Rest\Client( getenv('TWILIO_ACCOUNT_SID'), getenv('TWILIO_AUTH_TOKEN') ); $message = $twilio->messages->create($numeroCompleto, [ 'from' => getenv('TWILIO_PHONE_NUMBER'), 'body' => $this->mensaje ]); return $message->sid !== null; } catch (\Exception $e) { error_log("Error Twilio: " . $e->getMessage()); return false; } */ // Por defecto retorna false si no hay integración configurada error_log("SMS REAL: No hay API configurada para envío de SMS"); return false; } /** * Obtiene número completo con código de país * * @return string Número completo */ public function getNumeroCompleto(): string { return $this->codigoPais . preg_replace('/[^\d]/', '', $this->receptor); } /** * Obtiene el código de país * * @return string Código de país */ public function getCodigoPais(): string { return $this->codigoPais; } /** * Establece el modo de simulación * * @param bool $modo True para simulación, false para envío real * @return self */ public function setModoSimulacion(bool $modo): self { $this->modoSimulacion = $modo; return $this; } /** * Verifica si está en modo simulación * * @return bool True si es simulación */ public function esSimulacion(): bool { return $this->modoSimulacion; } } |
1️⃣1️⃣ classes/NotificationFactory.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 | <?php /** * ============================================================================= * CLASE NOTIFICATIONFACTORY * Patrón Factory para creación de instancias de notificaciones * ============================================================================= * * @package NotificationSystem\Classes * @version 1.0.0 */ declare(strict_types=1); namespace App\Classes; require_once __DIR__ . '/Sendable.php'; require_once __DIR__ . '/Notification.php'; require_once __DIR__ . '/EmailNotification.php'; require_once __DIR__ . '/SMSNotification.php'; require_once __DIR__ . '/NotificationException.php'; class NotificationFactory { /** * @var array Mapeo de tipos a clases */ private const NOTIFICATION_TYPES = [ 'email' => EmailNotification::class, 'sms' => SMSNotification::class ]; /** * Constructor privado para prevenir instanciación */ private function __construct() {} /** * Crea una instancia de notificación * * @param string $tipo Tipo de notificación * @param string $receptor Destinatario * @param string $mensaje Contenido * @param array $options Opciones adicionales * @return Notification Instancia creada * @throws NotificationException Si ocurre un error */ public static function create( string $tipo, string $receptor, string $mensaje, array $options = [] ): Notification { $tipo = strtolower(trim($tipo)); if (!self::tipoValido($tipo)) { throw new NotificationException( "El tipo de notificación '{$tipo}' no está soportado", "El tipo de notificación seleccionado no es válido.", 4001 ); } $clase = self::NOTIFICATION_TYPES[$tipo]; if (!class_exists($clase)) { throw new NotificationException( "La clase de notificación no existe", "Error interno del sistema.", 4002 ); } try { switch ($tipo) { case 'email': return new $clase( $receptor, $mensaje, $options['asunto'] ?? 'Notificación del Sistema', $options['footer'] ?? null ); case 'sms': return new $clase( $receptor, $mensaje, $options['codigo_pais'] ?? '+34' ); default: throw new NotificationException( "Tipo de notificación no implementado", "Tipo de notificación no soportado.", 4004 ); } } catch (NotificationException $e) { throw $e; } catch (\Exception $e) { throw new NotificationException( "Error al crear la notificación: " . $e->getMessage(), "No se pudo crear la notificación.", 4099, $e ); } } /** * Valida si el tipo está soportado * * @param string $tipo Tipo a validar * @return bool True si es válido */ public static function tipoValido(string $tipo): bool { return isset(self::NOTIFICATION_TYPES[strtolower(trim($tipo))]); } /** * Obtiene tipos disponibles * * @return array Lista de tipos disponibles */ public static function getTiposDisponibles(): array { return array_keys(self::NOTIFICATION_TYPES); } /** * Verifica si una clase existe para un tipo dado * * @param string $tipo Tipo de notificación * @return bool True si la clase existe */ public static function claseExiste(string $tipo): bool { $tipo = strtolower(trim($tipo)); if (!isset(self::NOTIFICATION_TYPES[$tipo])) { return false; } return class_exists(self::NOTIFICATION_TYPES[$tipo]); } } |
1️⃣2️⃣ api.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 | <?php /** * ============================================================================= * API ENDPOINT - api.php * Controlador principal para recepción de solicitudes AJAX * Versión corregida para producción (Manejo de Throwable, Sesiones y Constantes) * Compatible con PHP 7.4+ * ============================================================================= */ declare(strict_types=1); // ============================================================================= // BUFFER DE SALIDA (Prevenir whitespace antes de headers) // ============================================================================= ob_start(); // ============================================================================= // POLYFILL para PHP < 8.0 // ============================================================================= if (!function_exists('str_starts_with')) { function str_starts_with(string $haystack, string $needle): bool { return $needle !== '' && strpos($haystack, $needle) === 0; } } if (!function_exists('str_contains')) { function str_contains(string $haystack, string $needle): bool { return $needle !== '' && strpos($haystack, $needle) !== false; } } // ============================================================================= // FUNCIONES DE RESPUESTA JSON (Helper para consistencia) // ============================================================================= /** * Envía una respuesta JSON y termina la ejecución */ function enviarRespuestaJson(bool $success, string $message, int $code = 200, array $extra = []): void { // Limpiar buffer de salida previo si hubiera if (ob_get_level()) { ob_end_clean(); } // Headers de seguridad y tipo if (!headers_sent()) { http_response_code($code); header('Content-Type: application/json; charset=UTF-8'); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: SAMEORIGIN'); header('X-XSS-Protection: 1; mode=block'); header('Referrer-Policy: strict-origin-when-cross-origin'); } $payload = array_merge([ 'success' => $success, 'message' => $message ], $extra); $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); if ($json === false) { // Fallback extremo si json_encode falla http_response_code(500); echo '{"success":false,"message":"Error codificando respuesta JSON"}'; } else { echo $json; } exit; } // ============================================================================= // CARGA DE CONFIGURACIÓN (PRIMERO) // ============================================================================= $configPath = __DIR__ . '/config/config.php'; if (!file_exists($configPath)) { error_log("ERROR CRÍTICO: config.php no encontrado: {$configPath}"); enviarRespuestaJson(false, 'Error de configuración del servidor (Config missing).', 500); } require_once $configPath; // ============================================================================= // VALIDACIÓN DE CONSTANTES CRÍTICAS // ============================================================================= $constantesRequeridas = ['APP_ENV', 'LOGS_PATH', 'EMAIL_METHOD', 'EMAIL_FORMAT']; foreach ($constantesRequeridas as $const) { if (!defined($const)) { error_log("ERROR CRÍTICO: Constante no definida: {$const}"); enviarRespuestaJson(false, 'Error de configuración del servidor (Constantes).', 500); } } // ============================================================================= // CONFIGURACIÓN DE ERRORES Y ENTORNO // ============================================================================= // Definir zona horaria por defecto para evitar warnings en date() date_default_timezone_set('UTC'); // O la que corresponda a tu proyecto, ej: 'Europe/Madrid' if (APP_ENV === 'production') { ini_set('display_errors', '0'); ini_set('log_errors', '1'); // Validar que el path de logs sea escribible antes de asignar if (is_dir(LOGS_PATH) && is_writable(LOGS_PATH)) { ini_set('error_log', LOGS_PATH . '/php_errors.log'); } else { error_log("WARNING: LOGS_PATH no es escribible o no existe: " . LOGS_PATH); } error_reporting(0); } else { ini_set('display_errors', '1'); error_reporting(E_ALL); } // ============================================================================= // GESTIÓN DE SESIÓN // ============================================================================= // Asegurar que la sesión esté iniciada antes de acceder a $_SESSION if (session_status() === PHP_SESSION_NONE) { // Configuración básica de seguridad para la sesión si se inicia aquí ini_set('session.cookie_httponly', '1'); ini_set('session.use_strict_mode', '1'); session_start(); } // ============================================================================= // INCLUSIÓN DE CLASES // ============================================================================= $classesDir = __DIR__ . '/classes/'; $requiredFiles = [ 'NotificationException.php', 'Sendable.php', 'LoggerTrait.php', 'Notification.php', 'EmailNotification.php', 'SMSNotification.php', 'NotificationFactory.php' ]; foreach ($requiredFiles as $file) { $path = $classesDir . $file; if (!file_exists($path)) { error_log("ERROR: Archivo de clase faltante: {$path}"); enviarRespuestaJson(false, 'Error de configuración del servidor (Clases).', 500, [ 'debug' => APP_ENV === 'development' ? "Missing: {$file}" : null ]); } require_once $path; } // Importación de Namespaces (Asegurar que los archivos tengan namespace App\Classes) use App\Classes\NotificationFactory; use App\Classes\NotificationException; // ============================================================================= // FUNCIONES AUXILIARES // ============================================================================= /** * Obtiene todos los encabezados HTTP de la solicitud actual */ function getHeadersCompatibles(bool $log = false): array { $headers = []; if (function_exists('getallheaders')) { $headers = getallheaders(); if (is_array($headers)) { if ($log) logHeaders($headers); return $headers; } } if (function_exists('apache_request_headers')) { $headers = apache_request_headers(); if (is_array($headers)) { if ($log) logHeaders($headers); return $headers; } } // Fallback usando $_SERVER $specialHeaders = [ 'CONTENT_TYPE' => 'Content-Type', 'CONTENT_LENGTH' => 'Content-Length', ]; foreach ($_SERVER as $key => $value) { if (substr($key, 0, 5) === 'HTTP_') { $name = substr($key, 5); $name = str_replace('_', ' ', $name); $name = ucwords(strtolower($name)); $name = str_replace(' ', '-', $name); $headers[$name] = $value; } } foreach ($specialHeaders as $serverKey => $headerName) { if (isset($_SERVER[$serverKey]) && !isset($headers[$headerName])) { $headers[$headerName] = $_SERVER[$serverKey]; } } if ($log) logHeaders($headers); return $headers; } /** * Registra los encabezados en el log */ function logHeaders(array $headers): void { // Evitar loguear datos sensibles si fuera necesario, aquí se loguean todos $json = json_encode($headers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); error_log("[HEADERS] Encabezados de la solicitud:\n" . $json); } /** * Sanitiza el input del usuario */ function sanitizarInput($input, int $maxLength = 2000): ?string { if ($input === null || $input === false) { return null; } $input = (string)$input; // Permitir etiquetas HTML básicas seguras $input = strip_tags($input, '<p><br><strong><em><ul><ol><li><a>'); // Eliminar caracteres de control $input = preg_replace('/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/', '', $input); // Normalizar espacios $input = preg_replace('/\s+/', ' ', $input); // Limitar longitud $input = mb_substr($input, 0, $maxLength, 'UTF-8'); return trim($input) !== '' ? trim($input) : null; } /** * Valida el token CSRF comparando el token del cliente con el de la sesión */ function validarCSRF(string $clientToken, string $sessionToken): bool { return !empty($clientToken) && !empty($sessionToken) && strlen($clientToken) === strlen($sessionToken) && hash_equals($sessionToken, $clientToken); } /** * Busca el token CSRF en los headers de forma CASE-INSENSITIVE */ function obtenerCsrfTokenDeHeaders(array $headers): string { // Nombres posibles del header CSRF (variaciones de case) $csrfHeaderNames = ['X-Csrf-Token', 'X-CSRF-TOKEN', 'x-csrf-token', 'X-CSRF-Token']; foreach ($headers as $name => $value) { foreach ($csrfHeaderNames as $csrfName) { if (strcasecmp($name, $csrfName) === 0) { return trim((string)$value); } } } return ''; } // ============================================================================= // CONFIGURACIÓN DE HEADERS DE RESPUESTA (Antes de cualquier output) // ============================================================================= // Nota: La función enviarRespuestaJson ya maneja headers, pero los establecemos aquí // para el flujo normal exitoso también. header('Content-Type: application/json; charset=UTF-8'); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: SAMEORIGIN'); header('X-XSS-Protection: 1; mode=block'); header('Referrer-Policy: strict-origin-when-cross-origin'); // Solo permitir POST if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['success' => false, 'message' => 'Método no permitido. Use POST.'], JSON_UNESCAPED_UNICODE); exit; } // ============================================================================= // PROCESAMIENTO PRINCIPAL // ============================================================================= try { // ========================================================================== // VALIDACIÓN CSRF CORREGIDA - CASE INSENSITIVE // ========================================================================== $headers = getHeadersCompatibles(true); // Búsqueda case-insensitive del token CSRF en headers $clientToken = obtenerCsrfTokenDeHeaders($headers); // Fallback: intentar obtener de $_POST si el header falla if (empty($clientToken)) { $clientToken = $_POST['csrf_token'] ?? ''; } // Obtener token de sesión $sessionToken = $_SESSION['csrf_token'] ?? ''; // Debug logging (solo development) if (APP_ENV === 'development') { error_log(sprintf( "CSRF DEBUG - Client: '%s' | Session: '%s' | Match: %s", substr($clientToken, 0, 8) . '...', substr($sessionToken, 0, 8) . '...', validarCSRF($clientToken, $sessionToken) ? 'YES' : 'NO' )); } // Validación robusta con logging para debugging if (!validarCSRF($clientToken, $sessionToken)) { error_log(sprintf( "CSRF VALIDATION FAILED - IP: %s | Client: %s | Session: %s | User-Agent: %s", $_SERVER['REMOTE_ADDR'] ?? 'UNKNOWN', empty($clientToken) ? 'EMPTY' : substr($clientToken, 0, 8) . '...', empty($sessionToken) ? 'EMPTY' : substr($sessionToken, 0, 8) . '...', $_SERVER['HTTP_USER_AGENT'] ?? 'UNKNOWN' )); http_response_code(403); echo json_encode([ 'success' => false, 'message' => 'Token de seguridad inválido. Recarga la página.' ], JSON_UNESCAPED_UNICODE); exit; } // ========================================================================== // OBTENER Y SANITIZAR DATOS // ========================================================================== $type = sanitizarInput($_POST['type'] ?? '', 50); $to = sanitizarInput($_POST['to'] ?? '', 255); $msg = sanitizarInput($_POST['message'] ?? '', 5000); // ========================================================================== // VALIDACIONES DE ENTRADA // ========================================================================== if ($type === null || $to === null || $msg === null) { throw new Exception("Todos los campos son obligatorios"); } // Verificar que la clase Factory tenga el método estático if (!method_exists(NotificationFactory::class, 'tipoValido')) { throw new Error("Clase NotificationFactory incompleta"); } if (!NotificationFactory::tipoValido($type)) { throw new Exception("El tipo '{$type}' no es válido"); } if (strlen($to) < 3 || strlen($msg) < 1) { throw new Exception("Datos inválidos"); } // Verificar configuración de email si es necesario if ($type === 'email' && !function_exists('isEmailConfigured')) { throw new Error("Función isEmailConfigured no disponible"); } if ($type === 'email' && !isEmailConfigured()) { error_log("ERROR: Intento de envío de email sin configuración válida"); throw new Exception("El sistema de email no está configurado correctamente"); } // ========================================================================== // CREAR Y ENVIAR NOTIFICACIÓN // ========================================================================== $notification = NotificationFactory::create($type, $to, $msg, [ 'asunto' => 'Notificación del Sistema', 'codigo_pais' => '+34' ]); $resultado = $notification->enviar(); if (!$resultado) { throw new Exception("Falló el envío de la notificación"); } // ========================================================================== // RESPUESTA EXITOSA // ========================================================================== http_response_code(200); $responseData = [ 'success' => true, 'message' => '¡Notificación enviada con éxito!', 'tipo' => $type, 'method' => $type === 'email' ? EMAIL_METHOD : null, 'format' => $type === 'email' ? EMAIL_FORMAT : null, 'timestamp' => date('Y-m-d H:i:s') ]; echo json_encode($responseData, JSON_UNESCAPED_UNICODE); } catch (NotificationException $e) { // Errores específicos de la aplicación de notificación http_response_code(400); $response = [ 'success' => false, 'message' => $e->getUserFriendlyMessage(), 'error_code' => $e->getErrorCode() ]; echo json_encode($response, JSON_UNESCAPED_UNICODE); error_log("NotificationException [{$e->getErrorCode()}]: " . $e->getTechnicalMessage()); } catch (Throwable $e) { // CAPTURA CRÍTICA: Captura Exception, Error, TypeError, ParseError, etc. // Esto evita el Error 500 blanco por errores fatales no manejados http_response_code(500); $isProd = (defined('APP_ENV') && APP_ENV === 'production'); $response = [ 'success' => false, 'message' => $isProd ? 'Error interno del servidor. Contacte soporte.' : $e->getMessage(), 'error_code' => 5099, 'debug' => $isProd ? null : [ 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString() ] ]; echo json_encode($response, JSON_UNESCAPED_UNICODE); error_log("Throwable [" . get_class($e) . "]: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine()); } // Limpiar buffer y finalizar if (ob_get_level()) { ob_end_flush(); } exit; |
1️⃣3️⃣ index.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 | <?php /** * ============================================================================= * API ENDPOINT - api.php * Controlador principal para recepción de solicitudes AJAX * Versión corregida para producción (Manejo de Throwable, Sesiones y Constantes) * Compatible con PHP 7.4+ * ============================================================================= */ declare(strict_types=1); // ============================================================================= // BUFFER DE SALIDA (Prevenir whitespace antes de headers) // ============================================================================= ob_start(); // ============================================================================= // POLYFILL para PHP < 8.0 // ============================================================================= if (!function_exists('str_starts_with')) { function str_starts_with(string $haystack, string $needle): bool { return $needle !== '' && strpos($haystack, $needle) === 0; } } if (!function_exists('str_contains')) { function str_contains(string $haystack, string $needle): bool { return $needle !== '' && strpos($haystack, $needle) !== false; } } // ============================================================================= // FUNCIONES DE RESPUESTA JSON (Helper para consistencia) // ============================================================================= /** * Envía una respuesta JSON y termina la ejecución */ function enviarRespuestaJson(bool $success, string $message, int $code = 200, array $extra = []): void { // Limpiar buffer de salida previo si hubiera if (ob_get_level()) { ob_end_clean(); } // Headers de seguridad y tipo if (!headers_sent()) { http_response_code($code); header('Content-Type: application/json; charset=UTF-8'); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: SAMEORIGIN'); header('X-XSS-Protection: 1; mode=block'); header('Referrer-Policy: strict-origin-when-cross-origin'); } $payload = array_merge([ 'success' => $success, 'message' => $message ], $extra); $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); if ($json === false) { // Fallback extremo si json_encode falla http_response_code(500); echo '{"success":false,"message":"Error codificando respuesta JSON"}'; } else { echo $json; } exit; } // ============================================================================= // CARGA DE CONFIGURACIÓN (PRIMERO) // ============================================================================= $configPath = __DIR__ . '/config/config.php'; if (!file_exists($configPath)) { error_log("ERROR CRÍTICO: config.php no encontrado: {$configPath}"); enviarRespuestaJson(false, 'Error de configuración del servidor (Config missing).', 500); } require_once $configPath; // ============================================================================= // VALIDACIÓN DE CONSTANTES CRÍTICAS // ============================================================================= $constantesRequeridas = ['APP_ENV', 'LOGS_PATH', 'EMAIL_METHOD', 'EMAIL_FORMAT']; foreach ($constantesRequeridas as $const) { if (!defined($const)) { error_log("ERROR CRÍTICO: Constante no definida: {$const}"); enviarRespuestaJson(false, 'Error de configuración del servidor (Constantes).', 500); } } // ============================================================================= // CONFIGURACIÓN DE ERRORES Y ENTORNO // ============================================================================= // Definir zona horaria por defecto para evitar warnings en date() date_default_timezone_set('UTC'); // O la que corresponda a tu proyecto, ej: 'Europe/Madrid' if (APP_ENV === 'production') { ini_set('display_errors', '0'); ini_set('log_errors', '1'); // Validar que el path de logs sea escribible antes de asignar if (is_dir(LOGS_PATH) && is_writable(LOGS_PATH)) { ini_set('error_log', LOGS_PATH . '/php_errors.log'); } else { error_log("WARNING: LOGS_PATH no es escribible o no existe: " . LOGS_PATH); } error_reporting(0); } else { ini_set('display_errors', '1'); error_reporting(E_ALL); } // ============================================================================= // GESTIÓN DE SESIÓN // ============================================================================= // Asegurar que la sesión esté iniciada antes de acceder a $_SESSION if (session_status() === PHP_SESSION_NONE) { // Configuración básica de seguridad para la sesión si se inicia aquí ini_set('session.cookie_httponly', '1'); ini_set('session.use_strict_mode', '1'); session_start(); } // ============================================================================= // INCLUSIÓN DE CLASES // ============================================================================= $classesDir = __DIR__ . '/classes/'; $requiredFiles = [ 'NotificationException.php', 'Sendable.php', 'LoggerTrait.php', 'Notification.php', 'EmailNotification.php', 'SMSNotification.php', 'NotificationFactory.php' ]; foreach ($requiredFiles as $file) { $path = $classesDir . $file; if (!file_exists($path)) { error_log("ERROR: Archivo de clase faltante: {$path}"); enviarRespuestaJson(false, 'Error de configuración del servidor (Clases).', 500, [ 'debug' => APP_ENV === 'development' ? "Missing: {$file}" : null ]); } require_once $path; } // Importación de Namespaces (Asegurar que los archivos tengan namespace App\Classes) use App\Classes\NotificationFactory; use App\Classes\NotificationException; // ============================================================================= // FUNCIONES AUXILIARES // ============================================================================= /** * Obtiene todos los encabezados HTTP de la solicitud actual */ function getHeadersCompatibles(bool $log = false): array { $headers = []; if (function_exists('getallheaders')) { $headers = getallheaders(); if (is_array($headers)) { if ($log) logHeaders($headers); return $headers; } } if (function_exists('apache_request_headers')) { $headers = apache_request_headers(); if (is_array($headers)) { if ($log) logHeaders($headers); return $headers; } } // Fallback usando $_SERVER $specialHeaders = [ 'CONTENT_TYPE' => 'Content-Type', 'CONTENT_LENGTH' => 'Content-Length', ]; foreach ($_SERVER as $key => $value) { if (substr($key, 0, 5) === 'HTTP_') { $name = substr($key, 5); $name = str_replace('_', ' ', $name); $name = ucwords(strtolower($name)); $name = str_replace(' ', '-', $name); $headers[$name] = $value; } } foreach ($specialHeaders as $serverKey => $headerName) { if (isset($_SERVER[$serverKey]) && !isset($headers[$headerName])) { $headers[$headerName] = $_SERVER[$serverKey]; } } if ($log) logHeaders($headers); return $headers; } /** * Registra los encabezados en el log */ function logHeaders(array $headers): void { // Evitar loguear datos sensibles si fuera necesario, aquí se loguean todos $json = json_encode($headers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); error_log("[HEADERS] Encabezados de la solicitud:\n" . $json); } /** * Sanitiza el input del usuario */ function sanitizarInput($input, int $maxLength = 2000): ?string { if ($input === null || $input === false) { return null; } $input = (string)$input; // Permitir etiquetas HTML básicas seguras $input = strip_tags($input, '<p><br><strong><em><ul><ol><li><a>'); // Eliminar caracteres de control $input = preg_replace('/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/', '', $input); // Normalizar espacios $input = preg_replace('/\s+/', ' ', $input); // Limitar longitud $input = mb_substr($input, 0, $maxLength, 'UTF-8'); return trim($input) !== '' ? trim($input) : null; } /** * Valida el token CSRF comparando el token del cliente con el de la sesión */ function validarCSRF(string $clientToken, string $sessionToken): bool { return !empty($clientToken) && !empty($sessionToken) && strlen($clientToken) === strlen($sessionToken) && hash_equals($sessionToken, $clientToken); } /** * Busca el token CSRF en los headers de forma CASE-INSENSITIVE */ function obtenerCsrfTokenDeHeaders(array $headers): string { // Nombres posibles del header CSRF (variaciones de case) $csrfHeaderNames = ['X-Csrf-Token', 'X-CSRF-TOKEN', 'x-csrf-token', 'X-CSRF-Token']; foreach ($headers as $name => $value) { foreach ($csrfHeaderNames as $csrfName) { if (strcasecmp($name, $csrfName) === 0) { return trim((string)$value); } } } return ''; } // ============================================================================= // CONFIGURACIÓN DE HEADERS DE RESPUESTA (Antes de cualquier output) // ============================================================================= // Nota: La función enviarRespuestaJson ya maneja headers, pero los establecemos aquí // para el flujo normal exitoso también. header('Content-Type: application/json; charset=UTF-8'); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: SAMEORIGIN'); header('X-XSS-Protection: 1; mode=block'); header('Referrer-Policy: strict-origin-when-cross-origin'); // Solo permitir POST if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['success' => false, 'message' => 'Método no permitido. Use POST.'], JSON_UNESCAPED_UNICODE); exit; } // ============================================================================= // PROCESAMIENTO PRINCIPAL // ============================================================================= try { // ========================================================================== // VALIDACIÓN CSRF CORREGIDA - CASE INSENSITIVE // ========================================================================== $headers = getHeadersCompatibles(true); // Búsqueda case-insensitive del token CSRF en headers $clientToken = obtenerCsrfTokenDeHeaders($headers); // Fallback: intentar obtener de $_POST si el header falla if (empty($clientToken)) { $clientToken = $_POST['csrf_token'] ?? ''; } // Obtener token de sesión $sessionToken = $_SESSION['csrf_token'] ?? ''; // Debug logging (solo development) if (APP_ENV === 'development') { error_log(sprintf( "CSRF DEBUG - Client: '%s' | Session: '%s' | Match: %s", substr($clientToken, 0, 8) . '...', substr($sessionToken, 0, 8) . '...', validarCSRF($clientToken, $sessionToken) ? 'YES' : 'NO' )); } // Validación robusta con logging para debugging if (!validarCSRF($clientToken, $sessionToken)) { error_log(sprintf( "CSRF VALIDATION FAILED - IP: %s | Client: %s | Session: %s | User-Agent: %s", $_SERVER['REMOTE_ADDR'] ?? 'UNKNOWN', empty($clientToken) ? 'EMPTY' : substr($clientToken, 0, 8) . '...', empty($sessionToken) ? 'EMPTY' : substr($sessionToken, 0, 8) . '...', $_SERVER['HTTP_USER_AGENT'] ?? 'UNKNOWN' )); http_response_code(403); echo json_encode([ 'success' => false, 'message' => 'Token de seguridad inválido. Recarga la página.' ], JSON_UNESCAPED_UNICODE); exit; } // ========================================================================== // OBTENER Y SANITIZAR DATOS // ========================================================================== $type = sanitizarInput($_POST['type'] ?? '', 50); $to = sanitizarInput($_POST['to'] ?? '', 255); $msg = sanitizarInput($_POST['message'] ?? '', 5000); // ========================================================================== // VALIDACIONES DE ENTRADA // ========================================================================== if ($type === null || $to === null || $msg === null) { throw new Exception("Todos los campos son obligatorios"); } // Verificar que la clase Factory tenga el método estático if (!method_exists(NotificationFactory::class, 'tipoValido')) { throw new Error("Clase NotificationFactory incompleta"); } if (!NotificationFactory::tipoValido($type)) { throw new Exception("El tipo '{$type}' no es válido"); } if (strlen($to) < 3 || strlen($msg) < 1) { throw new Exception("Datos inválidos"); } // Verificar configuración de email si es necesario if ($type === 'email' && !function_exists('isEmailConfigured')) { throw new Error("Función isEmailConfigured no disponible"); } if ($type === 'email' && !isEmailConfigured()) { error_log("ERROR: Intento de envío de email sin configuración válida"); throw new Exception("El sistema de email no está configurado correctamente"); } // ========================================================================== // CREAR Y ENVIAR NOTIFICACIÓN // ========================================================================== $notification = NotificationFactory::create($type, $to, $msg, [ 'asunto' => 'Notificación del Sistema', 'codigo_pais' => '+34' ]); $resultado = $notification->enviar(); if (!$resultado) { throw new Exception("Falló el envío de la notificación"); } // ========================================================================== // RESPUESTA EXITOSA // ========================================================================== http_response_code(200); $responseData = [ 'success' => true, 'message' => '¡Notificación enviada con éxito!', 'tipo' => $type, 'method' => $type === 'email' ? EMAIL_METHOD : null, 'format' => $type === 'email' ? EMAIL_FORMAT : null, 'timestamp' => date('Y-m-d H:i:s') ]; echo json_encode($responseData, JSON_UNESCAPED_UNICODE); } catch (NotificationException $e) { // Errores específicos de la aplicación de notificación http_response_code(400); $response = [ 'success' => false, 'message' => $e->getUserFriendlyMessage(), 'error_code' => $e->getErrorCode() ]; echo json_encode($response, JSON_UNESCAPED_UNICODE); error_log("NotificationException [{$e->getErrorCode()}]: " . $e->getTechnicalMessage()); } catch (Throwable $e) { // CAPTURA CRÍTICA: Captura Exception, Error, TypeError, ParseError, etc. // Esto evita el Error 500 blanco por errores fatales no manejados http_response_code(500); $isProd = (defined('APP_ENV') && APP_ENV === 'production'); $response = [ 'success' => false, 'message' => $isProd ? 'Error interno del servidor. Contacte soporte.' : $e->getMessage(), 'error_code' => 5099, 'debug' => $isProd ? null : [ 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString() ] ]; echo json_encode($response, JSON_UNESCAPED_UNICODE); error_log("Throwable [" . get_class($e) . "]: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine()); } // Limpiar buffer y finalizar if (ob_get_level()) { ob_end_flush(); } exit; |
✅ CHECKLIST FINAL DE IMPLEMENTACIÓN
| Paso | Acción | Comando |
|---|---|---|
| 1 | Crear directorios | mkdir -p config logs |
| 2 | Crear todos los archivos | Copiar códigos arriba |
| 3 | Instalar PHPMailer | composer require phpmailer/phpmailer |
| 4 | Editar config.php | SMTP_USERNAME, SMTP_PASSWORD, APP_KEY |
| 5 | Generar APP_KEY | php -r "echo bin2hex(random_bytes(32));" |
| 6 | Configurar permisos | chmod 750 config && chmod 640 config/config.php |
| 7 | Crear archivos de log | touch logs/notifications.log logs/php_errors.log |
| 8 | Permisos de logs | chmod 644 logs/*.log |
| 9 | Verificar protección | curl -I https://tudominio.com/050032/config/config.php (debe dar 403) |
| 10 | Probar envío | Enviar email de prueba HTML y SMS |
🚀 COMANDOS DE IMPLEMENTACIÓN RÁPIDA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | # Navegar al directorio del proyecto cd /ruta/a/050032/ # Crear directorios mkdir -p config logs # Crear archivos de log touch logs/notifications.log logs/php_errors.log # Instalar PHPMailer (si tienes Composer) composer require phpmailer/phpmailer # Configurar permisos chmod 750 config chmod 640 config/config.php chmod 644 config/.htaccess chmod 755 logs chmod 644 logs/*.log chmod 644 classes/*.php chmod 644 *.php *.htaccess # Generar APP_KEY php -r "echo bin2hex(random_bytes(32));" # Verificar estructura find . -type f | sort |
📦 SISTEMA COMPLETO ENTREGADO – LISTO PARA PRODUCCIÓN
✅ Dark Mode Profesional – Paleta accesible, animaciones, responsive
✅ HTML Email Support – Plantillas, CSS inline, versión texto alternativo
✅ PHPMailer + SMTP – Autenticación, TLS/SSL, certificados
✅ Dual Method – Switch entre mail() y SMTP vía config.php
✅ Config Segura – Directorio protegido, validación PHP, permisos
✅ Production Ready – Strict types, error handling, logging seguro, CSRF, rate limiting
