<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With");
// Si es una solicitud preflight (OPTIONS), responder inmediatamente
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204); // No Content
exit;
}
date_default_timezone_set('Europe/Madrid');
defined('DS') or define('DS', str_replace('\\', '/', DIRECTORY_SEPARATOR));
defined('ROOTPATH') or define('ROOTPATH', str_replace('\\', '/', __DIR__));
// CUIDADO: display_errors y error_reporting(E_ALL) deben ser 0 en producción.
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// Definir constante de la ruta raíz
defined('PHPVERSION') or define('PHPVERSION', (float) PHP_VERSION);
defined('RELPATH') or define('RELPATH', substr(dirname($_SERVER['SCRIPT_NAME']), 1));
defined('BASEDIR') or define('BASEDIR', basename(RELPATH));
defined('BASEURL') or define('BASEURL', 'https://' . $_SERVER['SERVER_NAME'] . '/' . RELPATH);
defined('WP_ROOT') or define('WP_ROOT', $_SERVER['DOCUMENT_ROOT'] . '/');
defined('USE_OUTPUT_BUFFERING') or define('USE_OUTPUT_BUFFERING', false);
// Configuración de sesión segura
if (session_status() == PHP_SESSION_NONE) {
// Seguridad de las cookies de sesión
ini_set('session.use_only_cookies', 1);
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_samesite', 'Lax'); // 'Strict' es más seguro, 'Lax' es más compatible con enlaces externos
// Detectar si la solicitud es HTTPS
$isSecure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
(!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') ||
(!empty($_SERVER['HTTP_CF_VISITOR']) && strpos($_SERVER['HTTP_CF_VISITOR'], '"scheme":"https"') !== false);
if ($isSecure) {
ini_set('session.cookie_secure', 1);
}
// Iniciar sesión con un nombre seguro
session_name('COMPEXDB_V2');
session_start();
// Generar token CSRF si no existe en la sesión
if (!isset($_SESSION['csrf_token'])) {
try {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} catch (Exception $e) {
error_log("Error generando token CSRF: " . $e->getMessage());
// En caso de fallo crítico, no permitimos la ejecución para evitar riesgos
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Error interno de seguridad.']);
exit;
}
}
}
final class WPCrudManager {
// --- CONFIGURACIÓN PRINCIPAL ---
// ATENCIÓN: Las credenciales hardcodeadas son un RIESGO DE SEGURIDAD CRÍTICO.
// Deberás implementar un sistema de autenticación seguro aquí.
private const WP_USER = 'JuanPerezEdX'; // Usuario con permisos para publicar
private const WP_PASSWORD = '@JuanPerez2025'; // Contraseña del usuario
private const TARGET_CATEGORY_NAME = 'Noticias'; // Categoría por defecto. ¡Debe existir!
// Define una acción para el nonce de WordPress que usarás en el frontend y el backend
private const NONCE_ACTION = 'wp_crud_manager_ajax';
// --- CONFIGURACIÓN DE RUTAS ---
private const WP_ROOT_PATH = WP_ROOT;
private const SCRIPT_URL_PATH = ''; // No es necesario asignar un valor aquí, se calcula en renderInterface.
// --- CONFIGURACIÓN DE LOGS ---
private static bool $enableLogging = true;
private static string $logDirectory;
private static ?string $logFile = null;
private static array $logBuffer = [];
/**
* Punto de entrada. Decide si mostrar la interfaz o manejar una petición AJAX.
*/
public static function init(): void {
self::initLogger();
self::log("Script iniciado.");
register_shutdown_function([self::class, 'writeLog']);
// Validación básica de la petición para evitar errores si 'action' no está definido
$action = $_POST['action'] ?? null;
if ($action !== null && !empty($action)) {
self::handleAjaxRequest();
} else {
self::renderInterface();
}
}
/**
* Enrutador para las peticiones AJAX.
*/
private static function handleAjaxRequest(): void {
header('Content-Type: application/json; charset=utf-8'); // Añadido charset
ob_start(); // Inicia el buffer de salida
$requestedAction = $_POST['action'] ?? '';
self::log("Petición AJAX recibida. Acción: " . self::escapeLog($requestedAction ?: 'ninguna')); // Escapado para logs
$response = [];
try {
// --- Carga de WordPress si no está cargado ---
if (!defined('WP_LOADED')) {
$wp_load_path = self::WP_ROOT_PATH . '/wp-load.php';
if (!file_exists($wp_load_path)) {
throw new Exception("ERROR FATAL: wp-load.php no encontrado en la ruta esperada: " . $wp_load_path);
}
require_once $wp_load_path;
define('WP_LOADED', true);
self::log("WordPress cargado para la petición AJAX.");
}
// --- 1. Verificación de Token CSRF basado en sesión ---
$clientCsrfToken = $_POST['csrf_token'] ?? '';
if (!isset($_SESSION['csrf_token']) || $clientCsrfToken !== $_SESSION['csrf_token']) {
self::log("Fallo de seguridad: Token CSRF inválido o ausente. Cliente: '" . self::escapeLog($clientCsrfToken) . "', Sesión: '" . self::escapeLog($_SESSION['csrf_token'] ?? 'N/A') . "'", 'SECURITY_ALERT');
throw new Exception("Fallo de seguridad: Token CSRF no válido.");
}
// Opcional: Regenerar el token después de un uso exitoso para mayor seguridad (anti-replay)
// $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
self::log("Token CSRF de sesión verificado correctamente.");
// --- 2. Verificación de Nonce de WordPress (específico de WP) ---
// Un nonce es un token de un solo uso para proteger contra CSRF,
// complementa el token de sesión si usas funcionalidades de WP.
$nonce = $_POST['_wpnonce'] ?? $_REQUEST['_wpnonce'] ?? ''; // Convención de WordPress para nonces
if (!function_exists('wp_verify_nonce') || !wp_verify_nonce($nonce, self::NONCE_ACTION)) {
self::log("Fallo de seguridad: Nonce de WordPress inválido para acción '" . self::NONCE_ACTION . "'. Nonce recibido: '" . self::escapeLog($nonce) . "'", 'SECURITY_ALERT');
throw new Exception("Fallo de seguridad: Nonce de WordPress no válido.");
}
self::log("Nonce de WordPress '" . self::NONCE_ACTION . "' verificado correctamente.");
// --- 3. Autenticación de Usuario (¡Punto a mejorar más adelante!) ---
// Por ahora, se mantiene el login hardcodeado para que funcione el resto del script.
// La mejora futura debería basarse en el usuario actualmente logueado en WordPress,
// o un sistema de autenticación de API externo.
$user = self::login();
wp_set_current_user($user->ID);
self::log("Autenticación AJAX exitosa para usuario ID: {$user->ID}");
// --- 4. Verificación de Permisos Generales ---
if (!current_user_can('publish_posts')) {
self::log("Usuario ID: {$user->ID} no tiene permisos para 'publish_posts'.", 'SECURITY_ALERT');
throw new Exception("El usuario no tiene permisos suficientes para realizar esta acción.");
}
// --- 5. Enrutamiento de Acciones ---
switch ($requestedAction) {
case 'create':
// Permiso adicional para 'edit_posts' si se desea ser más granular
if (!current_user_can('edit_posts')) {
throw new Exception("El usuario no tiene permisos para crear posts.");
}
$response = self::ajaxCreatePost();
break;
case 'read':
// La lectura suele no requerir permisos elevados si los posts son públicos
$response = self::ajaxReadPosts();
self::log("Respuesta de lectura: " . json_encode($response));
break;
case 'update':
// Permiso para editar otros posts o sus propios posts
if (!current_user_can('edit_others_posts')) {
throw new Exception("El usuario no tiene permisos para actualizar posts.");
}
$response = self::ajaxUpdatePost();
break;
case 'delete':
// Permiso MUY IMPORTANTE para eliminar posts
if (!current_user_can('delete_posts')) {
throw new Exception("El usuario no tiene permisos para eliminar posts.");
}
$response = self::ajaxDeletePost();
break;
default:
throw new Exception('Acción AJAX no válida.');
}
} catch (Exception $e) {
self::log("ERROR: " . self::escapeLog($e->getMessage()), 'ERROR');
$response = ['success' => false, 'message' => $e->getMessage()];
http_response_code(500); // Internal Server Error
} catch (Error $e) {
self::log("ERROR FATAL PHP: " . self::escapeLog($e->getMessage()) . " en " . self::escapeLog($e->getFile()) . " línea " . $e->getLine(), 'CRITICAL');
$response = ['success' => false, 'message' => "Error interno del servidor: " . $e->getMessage()];
http_response_code(500); // Internal Server Error
} finally {
// Asegura que la respuesta se imprima incluso si hay errores
echo json_encode($response);
ob_end_flush(); // Envía el contenido del buffer
// Terminar la ejecución de PHP de forma segura en WordPress
if (function_exists('wp_die')) {
wp_die();
} else {
exit;
}
}
}
// --- LÓGICA DE OPERACIONES CRUD ---
private static function ajaxCreatePost(): array {
$title = $_POST['title'] ?? '';
$content = $_POST['content'] ?? ''; // El contenido puede tener HTML, así que se sanitiza más tarde
// Saneamiento de los datos de entrada
$cleanTitle = self::filterTextField($title);
// Para el contenido, si se permite HTML, usar wp_kses_post()
$cleanContent = function_exists('wp_kses_post') ? wp_kses_post($content) : sanitize_text_field($content);
if (empty($cleanTitle)) {
throw new Exception('El título es obligatorio.');
}
self::log("Creando post con título saneado: '" . self::escapeLog($cleanTitle) . "'");
try {
$category_id = get_cat_ID(self::TARGET_CATEGORY_NAME);
if ($category_id === 0) {
throw new Exception("La categoría '" . self::TARGET_CATEGORY_NAME . "' no existe.");
}
$post_data = [
'post_title' => $cleanTitle,
'post_content' => $cleanContent, // Usar el contenido saneado
'post_status' => 'publish',
'post_author' => get_current_user_id(),
'post_category' => [$category_id],
];
$new_post_id = wp_insert_post($post_data, true);
if (is_wp_error($new_post_id)) {
throw new Exception($new_post_id->get_error_message());
}
} catch (Exception $e) {
self::log("Error al crear post: " . self::escapeLog($e->getMessage()), 'ERROR');
throw $e; // Re-lanzar la excepción para que sea capturada por handleAjaxRequest
}
self::log("Post creado con éxito. ID: {$new_post_id}");
// Escapar el título para la salida en el mensaje de éxito
return ['success' => true, 'message' => "Post '" . self::escapeHtml($cleanTitle) . "' creado con éxito (ID: {$new_post_id})."];
}
private static function ajaxReadPosts(): array {
self::log("Leyendo posts de la categoría: '" . self::TARGET_CATEGORY_NAME . "'.");
try {
$args = [
'category_name' => self::TARGET_CATEGORY_NAME,
'posts_per_page' => 20,
'post_status' => 'publish',
];
$posts = get_posts($args);
$posts_data = [];
foreach ($posts as $post) {
$posts_data[] = [
'id' => $post->ID,
'title' => self::escapeHtml($post->post_title), // Escapar título para HTML
'content' => self::escapeHtml($post->post_content), // Escapar contenido para HTML
'link' => esc_url(get_permalink($post->ID)) // Escapar URL
];
}
} catch (Exception $e) {
self::log("Error al leer posts: " . self::escapeLog($e->getMessage()), 'ERROR');
throw $e;
}
self::log("Se encontraron " . count($posts_data) . " posts.");
return ['success' => true, 'posts' => $posts_data];
}
private static function ajaxUpdatePost(): array {
$id = !empty($_POST['id']) ? absint($_POST['id']) : 0;
$title = $_POST['title'] ?? '';
$content = $_POST['content'] ?? '';
if ($id === 0) throw new Exception('ID de post no válido.');
if (empty($title)) throw new Exception('El título es obligatorio.');
$cleanTitle = self::filterTextField($title);
$cleanContent = function_exists('wp_kses_post') ? wp_kses_post($content) : sanitize_text_field($content);
self::log("Actualizando post ID: {$id} con título: '" . self::escapeLog($cleanTitle) . "'");
try {
$post_data = [
'ID' => $id,
'post_title' => $cleanTitle,
'post_content' => $cleanContent,
];
$updated_post_id = wp_update_post($post_data, true);
if (is_wp_error($updated_post_id)) {
throw new Exception($updated_post_id->get_error_message());
}
} catch (Exception $e) {
self::log("Error al actualizar post ID: {$id}: " . self::escapeLog($e->getMessage()), 'ERROR');
throw $e;
}
self::log("Post ID: {$id} actualizado con éxito.");
return ['success' => true, 'message' => "Post '" . self::escapeHtml($cleanTitle) . "' actualizado correctamente."];
}
private static function ajaxDeletePost(): array {
$id = !empty($_POST['id']) ? absint($_POST['id']) : 0;
if ($id === 0) throw new Exception('ID de post no válido para eliminar.');
self::log("Intentando eliminar post ID: {$id}.");
try {
$result = wp_delete_post($id, true);
if ($result === false || $result === null) {
throw new Exception("No se pudo eliminar el post ID: {$id}. Es posible que ya no exista o no tengas permisos.");
}
} catch (Exception $e) {
self::log("Error al eliminar post ID: {$id}: " . self::escapeLog($e->getMessage()), 'ERROR');
throw $e;
}
self::log("Post ID: {$id} eliminado con éxito.");
return ['success' => true, 'message' => "El post (ID: {$id}) ha sido eliminado permanentemente."];
}
// --- FUNCIONES AUXILIARES Y DE SEGURIDAD ---
// ¡ADVERTENCIA CRÍTICA: Esta función de login debe ser revisada!
// No uses credenciales hardcodeadas en producción.
private static function login(): WP_User {
self::log("Intentando autenticar al usuario: '" . self::WP_USER . "'");
try {
$creds = ['user_login' => self::WP_USER, 'user_password' => self::WP_PASSWORD, 'remember' => true];
$user = wp_signon($creds, false);
if (is_wp_error($user)) {
throw new Exception("Error de autenticación: " . $user->get_error_message());
}
} catch (Exception $e) {
self::log("Fallo en login de usuario: " . self::escapeLog($e->getMessage()), 'CRITICAL');
throw $e; // Re-lanzar para que handleAjaxRequest lo capture
}
return $user;
}
/**
* Función de saneamiento personalizada para campos de texto.
* Elimina etiquetas HTML y normaliza espacios.
*/
public static function filterTextField(string $text): string {
$text = strip_tags($text);
$text = trim($text);
$text = preg_replace('/\s+/', ' ', $text);
self::log("Filtro aplicado (filterTextField): '" . self::escapeLog($text) . "'");
return $text;
}
/**
* Función de escapado personalizada para salida HTML.
* Usa ENT_QUOTES para escapar comillas simples y dobles.
*/
public static function escapeHtml(string $text): string {
return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* Función para escapar cadenas antes de escribirlas en el log,
* para evitar inyección de logs o problemas de formato.
*/
private static function escapeLog(string $message): string {
return str_replace(["\n", "\r"], ['\\n', '\\r'], filter_var($message, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH));
}
// --- SISTEMA DE LOGS ---
private static function initLogger(): void {
if (!self::$enableLogging) return;
try {
if (defined('WP_CONTENT_DIR')) {
self::$logDirectory = WP_CONTENT_DIR . '/wp-crud-logs';
self::log("WP_CONTENT_DIR definido. Usando directorio de logs: " . self::$logDirectory);
} else {
self::$logDirectory = __DIR__ . '/wp-crud-logs';
self::log("WP_CONTENT_DIR no está definido. Usando directorio de logs relativo al script: " . self::$logDirectory, 'WARNING');
}
if (!is_dir(self::$logDirectory)) {
if (!mkdir(self::$logDirectory, 0755, true)) {
throw new Exception("No se pudo crear el directorio de logs en: " . self::$logDirectory);
}
self::log("Directorio de logs creado en: " . self::$logDirectory);
}
// Protección del directorio de logs
$htaccess_path = self::$logDirectory . '/.htaccess';
if (!file_exists($htaccess_path)) {
file_put_contents($htaccess_path, 'Deny from all');
self::log("Archivo .htaccess de seguridad creado en directorio de logs.");
}
$index_path = self::$logDirectory . '/index.html';
if (!file_exists($index_path)) {
file_put_contents($index_path, '');
self::log("Archivo index.html de seguridad creado en directorio de logs.");
}
self::$logFile = self::$logDirectory . '/log_' . date('Y-m-d') . '.log';
} catch (Exception $e) {
self::$enableLogging = false; // Deshabilitar logging si falla la inicialización
error_log("WPCrudManager FATAL ERROR DE LOGGER: " . $e->getMessage());
}
}
public static function log(string $message, string $level = 'INFO'): void {
self::$logBuffer[] = "[" . date('Y-m-d H:i:s') . "] [{$level}] " . self::escapeLog($message); // Escapar mensajes de log
}
public static function writeLog(): void {
if (!self::$enableLogging || empty(self::$logBuffer) || self::$logFile === null) {
return;
}
$log_content = implode("\n", self::$logBuffer) . "\n";
file_put_contents(self::$logFile, $log_content, FILE_APPEND);
self::$logBuffer = [];
}
// --- RENDERIZADO DE LA INTERFAZ ---
public static function renderInterface(): void {
// Generar el nonce de WordPress que se usará en el frontend
// wp_create_nonce() requiere que WordPress esté cargado.
// Asegúrate de que WP_LOADED esté definido antes de llamar a esta función,
// o muévela al `init()` antes de la bifurcación a handleAjaxRequest
// para que siempre esté disponible si se renderiza la interfaz.
if (!defined('WP_LOADED')) {
$wp_load_path = self::WP_ROOT_PATH . '/wp-load.php';
if (file_exists($wp_load_path)) {
require_once $wp_load_path;
define('WP_LOADED', true);
} else {
self::log("Advertencia: wp-load.php no encontrado para generar nonce en la interfaz.", 'WARNING');
// Si wp-load.php no se carga, no se podrá generar el nonce de WP.
// Asegúrate de manejar esto en tu frontend.
}
}
$wpNonce = '';
if (function_exists('wp_create_nonce')) {
$wpNonce = wp_create_nonce(self::NONCE_ACTION);
} else {
self::log("Advertencia: wp_create_nonce no disponible. Asegúrate de que WordPress esté cargado.", 'WARNING');
}
// Obtener el token CSRF de sesión para pasarlo al frontend
$sessionCsrfToken = $_SESSION['csrf_token'] ?? '';
$scriptPath = dirname($_SERVER['PHP_SELF']);
$scriptUrl = 'https://' . $_SERVER['SERVER_NAME'] . rtrim($scriptPath, '/') . '/' . basename(__FILE__);
$escapedCategoryName = self::escapeHtml(self::TARGET_CATEGORY_NAME);
// Aquí se incluye tu archivo de vista (frontend.php)
require_once(ROOTPATH . '/views/frontend.php');
// Ejemplo de cómo frontend.php podría usar los tokens:
// En frontend.php, podrías tener algo como:
/*
<script>
const WPCrudConfig = {
ajax_url: '<?= self::escapeHtml($scriptUrl) ?>', // URL de este script
wp_nonce: '<?= self::escapeHtml($wpNonce) ?>', // Nonce de WordPress
csrf_token: '<?= self::escapeHtml($sessionCsrfToken) ?>', // Token CSRF de sesión
target_category_name: '<?= self::escapeHtml(self::TARGET_CATEGORY_NAME) ?>'
};
// Luego, en tu JS, en tus peticiones AJAX:
// data: {
// action: 'create',
// _wpnonce: WPCrudConfig.wp_nonce,
// csrf_token: WPCrudConfig.csrf_token,
// title: '...',
// content: '...'
// }
</script>
*/
}
}
// Iniciar el gestor
WPCrudManager::init();