up
This commit is contained in:
+129
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* core/App.php
|
||||
* Bootstrap y punto de entrada del ERP.
|
||||
*
|
||||
* Uso desde index.php (punto de entrada del módulo ERP):
|
||||
*
|
||||
* define('APP_ROOT', __DIR__);
|
||||
* require_once __DIR__ . '/core/App.php';
|
||||
* App::run();
|
||||
*
|
||||
* Los archivos lab_*.php legacy en raíz siguen funcionando directamente
|
||||
* sin pasar por App — compatibilidad total garantizada.
|
||||
*/
|
||||
|
||||
// ─── Constante de raíz del proyecto ─────────────────────────────────────────
|
||||
if (!defined('APP_ROOT')) {
|
||||
define('APP_ROOT', dirname(__DIR__));
|
||||
}
|
||||
|
||||
// ─── Dependencias del core ───────────────────────────────────────────────────
|
||||
require_once APP_ROOT . '/config/config.php';
|
||||
require_once APP_ROOT . '/core/Helpers.php';
|
||||
require_once APP_ROOT . '/core/Auth.php';
|
||||
require_once APP_ROOT . '/core/Rbac.php';
|
||||
require_once APP_ROOT . '/core/Router.php';
|
||||
require_once APP_ROOT . '/core/Layout.php';
|
||||
|
||||
class App
|
||||
{
|
||||
// ─── Punto de entrada ────────────────────────────────────────────────────
|
||||
|
||||
public static function run(): void
|
||||
{
|
||||
self::boot();
|
||||
|
||||
$router = new Router();
|
||||
|
||||
// Rutas públicas: sin verificación de sesión
|
||||
if (!$router->isPublic()) {
|
||||
Auth::requireLogin();
|
||||
// Enfermeros van a su portal propio, no al panel ERP
|
||||
if (Auth::isEnfermero()) {
|
||||
header('Location: ' . APP_ROOT . '/../enfermero_portal.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
self::dispatch($router);
|
||||
}
|
||||
|
||||
// ─── Bootstrap ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inicializa la sesión y la zona horaria.
|
||||
* Se puede llamar también desde archivos legacy para obtener sesión lista.
|
||||
*/
|
||||
public static function boot(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// Cookies de sesión seguras
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
date_default_timezone_set(defined('TIMEZONE') ? TIMEZONE : 'America/Bogota');
|
||||
}
|
||||
|
||||
// ─── Dispatch ───────────────────────────────────────────────────────────
|
||||
|
||||
private static function dispatch(Router $router): void
|
||||
{
|
||||
try {
|
||||
$viewFile = $router->resolveFile();
|
||||
} catch (RuntimeException $e) {
|
||||
self::render404($router->getModule(), $router->getView());
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar permiso de módulo (solo en rutas protegidas)
|
||||
if (!$router->isPublic()) {
|
||||
$module = $router->getModule();
|
||||
// Si el módulo está registrado en SYSTEM_MODULES, verificar acceso
|
||||
if (defined('SYSTEM_MODULES') && array_key_exists($module, SYSTEM_MODULES)) {
|
||||
if (!Rbac::hasModule($module)) {
|
||||
self::render403($module);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ejecutar la vista del módulo
|
||||
include $viewFile;
|
||||
}
|
||||
|
||||
// ─── Páginas de error ────────────────────────────────────────────────────
|
||||
|
||||
private static function render404(string $module, string $view): void
|
||||
{
|
||||
http_response_code(404);
|
||||
Layout::open('Página no encontrada', 'fas fa-exclamation-triangle');
|
||||
echo '<div class="container-fluid py-5 text-center">';
|
||||
echo '<i class="fas fa-exclamation-triangle fa-4x text-warning mb-3"></i>';
|
||||
echo '<h2>404 — Módulo no encontrado</h2>';
|
||||
echo '<p class="text-muted">El módulo <code>' . esc($module) . '</code> / vista <code>' . esc($view) . '</code> no existe.</p>';
|
||||
echo '<a href="' . esc(APP_URL ?? '/') . '" class="btn btn-primary"><i class="fas fa-home me-1"></i>Ir al inicio</a>';
|
||||
echo '</div>';
|
||||
Layout::close();
|
||||
}
|
||||
|
||||
private static function render403(string $module): void
|
||||
{
|
||||
http_response_code(403);
|
||||
Layout::open('Acceso denegado', 'fas fa-lock');
|
||||
echo '<div class="container-fluid py-5 text-center">';
|
||||
echo '<i class="fas fa-lock fa-4x text-danger mb-3"></i>';
|
||||
echo '<h2>403 — Sin permiso</h2>';
|
||||
echo '<p class="text-muted">Tu rol no tiene acceso al módulo <code>' . esc($module) . '</code>.</p>';
|
||||
echo '<a href="' . esc(APP_URL ?? '/') . '" class="btn btn-secondary"><i class="fas fa-arrow-left me-1"></i>Volver</a>';
|
||||
echo '</div>';
|
||||
Layout::close();
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
/**
|
||||
* core/Auth.php
|
||||
* Gestión de sesión y autenticación de usuarios del panel.
|
||||
* Extraído de config/config.php para separar responsabilidades.
|
||||
*
|
||||
* USO:
|
||||
* require_once __DIR__ . '/../core/Auth.php';
|
||||
* Auth::requireLogin(); // en cualquier vista protegida
|
||||
* Auth::requireNotEnfermero(); // excluir enfermeros del panel admin
|
||||
*/
|
||||
|
||||
class Auth
|
||||
{
|
||||
// ─── Verificación de sesión ─────────────────────────────────────────────
|
||||
|
||||
public static function isLoggedIn(): bool
|
||||
{
|
||||
return isset($_SESSION['admin_logged_in']) && $_SESSION['admin_logged_in'] === true;
|
||||
}
|
||||
|
||||
public static function requireLogin(string $redirect = 'login.php'): void
|
||||
{
|
||||
if (!self::isLoggedIn()) {
|
||||
header("Location: $redirect");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function requireNotEnfermero(string $redirect = 'enfermero_portal.php'): void
|
||||
{
|
||||
if (self::isEnfermero()) {
|
||||
header("Location: $redirect");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Datos del usuario en sesión ────────────────────────────────────────
|
||||
|
||||
public static function id(): ?int
|
||||
{
|
||||
return isset($_SESSION['admin_user']['id'])
|
||||
? (int) $_SESSION['admin_user']['id']
|
||||
: null;
|
||||
}
|
||||
|
||||
public static function role(): string
|
||||
{
|
||||
return $_SESSION['admin_user']['role'] ?? 'admin';
|
||||
}
|
||||
|
||||
public static function fullName(): string
|
||||
{
|
||||
return $_SESSION['admin_user']['full_name']
|
||||
?? $_SESSION['admin_user']['username']
|
||||
?? 'Usuario';
|
||||
}
|
||||
|
||||
public static function roleId(): ?int
|
||||
{
|
||||
$id = $_SESSION['admin_user']['role_id'] ?? null;
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public static function isEnfermero(): bool
|
||||
{
|
||||
return self::role() === 'enfermero';
|
||||
}
|
||||
|
||||
public static function isAdmin(): bool
|
||||
{
|
||||
return self::role() === 'admin';
|
||||
}
|
||||
|
||||
public static function isSuperAdmin(): bool
|
||||
{
|
||||
return self::role() === 'superadmin';
|
||||
}
|
||||
|
||||
// ─── Login / Logout ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Intenta autenticar usuario contra la BD.
|
||||
* Devuelve array con datos del usuario o false si falla.
|
||||
*/
|
||||
public static function attempt(string $username, string $password): array|false
|
||||
{
|
||||
// Delegar en la función legacy de config.php mientras coexistan
|
||||
// (authenticateUser está definida en config/config.php)
|
||||
return authenticateUser($username, $password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Establece la sesión tras un login exitoso.
|
||||
*/
|
||||
public static function login(array $userData): void
|
||||
{
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['admin_logged_in'] = true;
|
||||
$_SESSION['admin_user'] = $userData;
|
||||
$_SESSION['login_time'] = time();
|
||||
}
|
||||
|
||||
public static function logout(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(
|
||||
session_name(), '', time() - 42000,
|
||||
$p['path'], $p['domain'], $p['secure'], $p['httponly']
|
||||
);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
// ─── Tiempo de sesión ───────────────────────────────────────────────────
|
||||
|
||||
public static function isSessionExpired(): bool
|
||||
{
|
||||
if (!isset($_SESSION['login_time'])) return false;
|
||||
$timeout = defined('SESSION_TIMEOUT') ? SESSION_TIMEOUT : 1800;
|
||||
return (time() - $_SESSION['login_time']) > $timeout;
|
||||
}
|
||||
|
||||
public static function refreshSession(): void
|
||||
{
|
||||
$_SESSION['login_time'] = time();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
/**
|
||||
* core/Helpers.php
|
||||
* Funciones de utilidad globales del ERP.
|
||||
* Consolida helpers duplicados de api/lab/_helpers.php y config/config.php.
|
||||
*
|
||||
* REQUIERE: config/config.php ya cargado (para APP_ROOT si se usa).
|
||||
*/
|
||||
|
||||
// ─── Seguridad / Output ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Escapa una cadena para salida HTML segura.
|
||||
*/
|
||||
if (!function_exists('esc')) {
|
||||
function esc(?string $value): string
|
||||
{
|
||||
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Respuestas JSON ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Envía respuesta JSON de éxito y termina la ejecución.
|
||||
*
|
||||
* @param array $payload Datos adicionales a fusionar en la respuesta.
|
||||
* @param string $message Mensaje descriptivo opcional.
|
||||
*/
|
||||
if (!function_exists('jsonOk')) {
|
||||
function jsonOk(array $payload = [], string $message = ''): void
|
||||
{
|
||||
if (ob_get_level() > 0) {
|
||||
ob_clean();
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$resp = ['ok' => true, 'success' => true];
|
||||
if ($message !== '') {
|
||||
$resp['message'] = $message;
|
||||
}
|
||||
echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía respuesta JSON de error con código HTTP y termina la ejecución.
|
||||
*
|
||||
* @param string $message Descripción del error.
|
||||
* @param int $code Código HTTP (400, 401, 403, 404, 500…).
|
||||
*/
|
||||
if (!function_exists('jsonError')) {
|
||||
function jsonError(string $message, int $code = 400): void
|
||||
{
|
||||
if (ob_get_level() > 0) {
|
||||
ob_clean();
|
||||
}
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(
|
||||
['ok' => false, 'success' => false, 'error' => $message, 'code' => $code],
|
||||
JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Request ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detiene la ejecución con 405 JSON si el método HTTP no coincide.
|
||||
*
|
||||
* @param string $method Método esperado: 'GET', 'POST', etc.
|
||||
*/
|
||||
if (!function_exists('requireMethod')) {
|
||||
function requireMethod(string $method): void
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) {
|
||||
jsonError('Método no permitido', 405);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee y decodifica el body JSON del request actual.
|
||||
* Se cachea en la misma petición.
|
||||
*
|
||||
* @return array Datos decodificados o array vacío si el body no es JSON válido.
|
||||
*/
|
||||
if (!function_exists('inputJson')) {
|
||||
function inputJson(): array
|
||||
{
|
||||
static $parsed = null;
|
||||
if ($parsed === null) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$parsed = json_decode($raw ?: '{}', true) ?? [];
|
||||
}
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Texto / Formato ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convierte bytes a representación legible (KB, MB, GB).
|
||||
*/
|
||||
if (!function_exists('formatBytes')) {
|
||||
function formatBytes(int $bytes, int $precision = 2): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = $bytes > 0 ? floor(log($bytes) / log(1024)) : 0;
|
||||
$pow = min($pow, count($units) - 1);
|
||||
return round($bytes / (1024 ** $pow), $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea una fecha a formato legible en español.
|
||||
*
|
||||
* @param string|null $date Fecha en cualquier formato reconocible por strtotime.
|
||||
* @param bool $time Si true incluye la hora.
|
||||
*/
|
||||
if (!function_exists('formatDate')) {
|
||||
function formatDate(?string $date, bool $time = false): string
|
||||
{
|
||||
if (!$date) {
|
||||
return '—';
|
||||
}
|
||||
$ts = strtotime($date);
|
||||
$format = $time ? 'd/m/Y H:i' : 'd/m/Y';
|
||||
return $ts ? date($format, $ts) : esc($date);
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
/**
|
||||
* core/Layout.php
|
||||
* Renderiza el HTML envolvente de cada vista del ERP.
|
||||
*
|
||||
* USO en una vista de módulo (modules/{m}/views/{v}.php):
|
||||
*
|
||||
* Layout::open('Dashboard Turnero', 'fas fa-ticket-alt');
|
||||
* // ... contenido de la vista ...
|
||||
* Layout::close();
|
||||
*
|
||||
* Layout::open() emite DOCTYPE, <head>, Bootstrap, sidebar y abre <main>.
|
||||
* Layout::close() cierra </main>, agrega scripts y </body></html>.
|
||||
*/
|
||||
|
||||
class Layout
|
||||
{
|
||||
// ─── Apertura ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emite el HTML inicial de la página (head + sidebar + apertura de main).
|
||||
*
|
||||
* @param string $title Título de la pestaña y del encabezado.
|
||||
* @param string $icon Clase FontAwesome del ícono (ej. 'fas fa-ticket-alt').
|
||||
*/
|
||||
public static function open(string $title = 'Panel', string $icon = 'fas fa-th-large'): void
|
||||
{
|
||||
$appName = defined('APP_NAME') ? APP_NAME : 'ERP System';
|
||||
$safeTitle = htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$safeIcon = htmlspecialchars($icon, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
// Exponer variables para sidebar.php
|
||||
$GLOBALS['_LAYOUT_TITLE'] = $title;
|
||||
$GLOBALS['_LAYOUT_ICON'] = $icon;
|
||||
|
||||
// Detectar base URL (para assets relativos)
|
||||
$base = defined('APP_URL') ? rtrim(APP_URL, '/') : '';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $safeTitle ?> — <?= htmlspecialchars($appName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></title>
|
||||
|
||||
<!-- Bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Font Awesome 6 -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<!-- Estilos del sistema -->
|
||||
<link href="<?= $base ?>/assets/css/styles.css?v=12" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
/* Variables del tema ERP */
|
||||
:root {
|
||||
--sidebar-width: 260px;
|
||||
--header-height: 56px;
|
||||
}
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.main-content { margin-left: 0; }
|
||||
}
|
||||
/* Toast container */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1090;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Toast container global -->
|
||||
<div id="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
<?php
|
||||
// Sidebar dinámico
|
||||
$sidebarFile = APP_ROOT . '/shared/components/sidebar.php';
|
||||
if (file_exists($sidebarFile)) {
|
||||
$SIDEBAR_TITLE = $title;
|
||||
$SIDEBAR_ICON = $icon;
|
||||
include $sidebarFile;
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Contenido principal -->
|
||||
<main class="main-content">
|
||||
<!-- Barra de título de módulo -->
|
||||
<div class="bg-white border-bottom px-4 py-3 d-flex align-items-center justify-content-between">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="<?= $safeIcon ?> me-2 text-primary"></i><?= $safeTitle ?>
|
||||
</h5>
|
||||
<small class="text-muted d-none d-sm-block">
|
||||
<?= htmlspecialchars(Auth::fullName(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>
|
||||
<span class="badge bg-secondary ms-1"><?= htmlspecialchars(Auth::role(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></span>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<?php
|
||||
}
|
||||
|
||||
// ─── Cierre ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cierra la sección de contenido y emite los scripts finales.
|
||||
*/
|
||||
public static function close(): void
|
||||
{
|
||||
$base = defined('APP_URL') ? rtrim(APP_URL, '/') : '';
|
||||
?>
|
||||
</div><!-- /.p-4 -->
|
||||
</main><!-- /.main-content -->
|
||||
|
||||
<!-- Bootstrap 5 JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Helper JS global: toasts y fetch wrapper -->
|
||||
<script>
|
||||
/**
|
||||
* erp.toast(message, type)
|
||||
* Muestra un Bootstrap Toast en la esquina superior derecha.
|
||||
* type: 'success' | 'danger' | 'warning' | 'info'
|
||||
*/
|
||||
window.erp = window.erp || {};
|
||||
|
||||
erp.toast = function(message, type = 'success') {
|
||||
const id = 'toast-' + Date.now();
|
||||
const icons = { success: 'fa-check-circle', danger: 'fa-times-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle' };
|
||||
const icon = icons[type] || icons.info;
|
||||
const html = `
|
||||
<div id="${id}" class="toast align-items-center text-bg-${type} border-0 mb-2" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="4000">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body"><i class="fas ${icon} me-2"></i>${message}</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Cerrar"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('toast-container').insertAdjacentHTML('beforeend', html);
|
||||
const el = document.getElementById(id);
|
||||
const toast = new bootstrap.Toast(el);
|
||||
toast.show();
|
||||
el.addEventListener('hidden.bs.toast', () => el.remove());
|
||||
};
|
||||
|
||||
/**
|
||||
* erp.fetch(url, options)
|
||||
* Wrapper de fetch que muestra toast en caso de error y devuelve el JSON parseado.
|
||||
* Lanza un Error si ok === false.
|
||||
*/
|
||||
erp.fetch = async function(url, options = {}) {
|
||||
try {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || 'Error desconocido');
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
erp.toast(err.message, 'danger');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
/**
|
||||
* core/ModuleRegistry.php
|
||||
* Catálogo dinámico de módulos del ERP.
|
||||
*
|
||||
* Fuentes de datos (prioridad en orden):
|
||||
* 1. Base de datos — tabla system_modules (si la migración 004 se ejecutó)
|
||||
* 2. Archivos modules/{slug}/module.php (escaneo de la carpeta modules/)
|
||||
* 3. Constante SYSTEM_MODULES de config.php (fallback mínimo)
|
||||
*
|
||||
* Los datos de BD prevalecen sobre los descriptores locales para los campos
|
||||
* is_active y sort_order (el administrador puede cambiarlos via UI).
|
||||
*
|
||||
* CACHÉ: estática por request (no Redis necesario).
|
||||
*/
|
||||
|
||||
class ModuleRegistry
|
||||
{
|
||||
/** @var array<string, array>|null Cache del registro completo */
|
||||
private static ?array $registry = null;
|
||||
|
||||
/** Orden de visualización de categorías en el sidebar */
|
||||
private const CATEGORY_ORDER = ['bot', 'lab', 'turnero', 'clinico', 'reportes', 'sistema'];
|
||||
|
||||
/** Etiquetas e íconos de cada categoría */
|
||||
private const CATEGORIES = [
|
||||
'bot' => ['label' => 'WhatsApp', 'icon' => 'fab fa-whatsapp'],
|
||||
'lab' => ['label' => 'Laboratorio', 'icon' => 'fas fa-flask'],
|
||||
'turnero' => ['label' => 'Turnero', 'icon' => 'fas fa-ticket-alt'],
|
||||
'clinico' => ['label' => 'Clínico', 'icon' => 'fas fa-vials'],
|
||||
'reportes'=> ['label' => 'Reportes', 'icon' => 'fas fa-chart-bar'],
|
||||
'sistema' => ['label' => 'Sistema', 'icon' => 'fas fa-cog'],
|
||||
];
|
||||
|
||||
// ─── API pública ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retorna todos los módulos registrados (activos e inactivos).
|
||||
*
|
||||
* @return array<string, array> Indexado por slug.
|
||||
*/
|
||||
public static function getAll(): array
|
||||
{
|
||||
self::load();
|
||||
return self::$registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna solo los módulos ACTIVOS que el usuario actual puede ver.
|
||||
* Si el usuario es admin sin role_id → todos los activos.
|
||||
*
|
||||
* @return array<string, array> Indexado por slug.
|
||||
*/
|
||||
public static function forCurrentUser(): array
|
||||
{
|
||||
self::load();
|
||||
$result = [];
|
||||
foreach (self::$registry as $slug => $mod) {
|
||||
if (!$mod['is_active']) {
|
||||
continue;
|
||||
}
|
||||
// hasModule() definida en config.php / core/Rbac.php (retrocompat)
|
||||
if (function_exists('hasModule') && !hasModule($slug)) {
|
||||
continue;
|
||||
}
|
||||
$result[$slug] = $mod;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna módulos activos agrupados por categoría para el sidebar.
|
||||
* Solo incluye módulos accesibles por el usuario actual.
|
||||
*
|
||||
* @return array ['bot' => ['label'=>'WhatsApp','icon'=>'...','modules'=>[...]], ...]
|
||||
*/
|
||||
public static function forSidebar(): array
|
||||
{
|
||||
$userModules = self::forCurrentUser();
|
||||
|
||||
// Agrupar por categoría
|
||||
$grouped = [];
|
||||
foreach ($userModules as $slug => $mod) {
|
||||
$cat = $mod['category'] ?? 'sistema';
|
||||
if (!isset($grouped[$cat])) {
|
||||
$catMeta = self::CATEGORIES[$cat] ?? ['label' => ucfirst($cat), 'icon' => 'fas fa-cube'];
|
||||
$grouped[$cat] = [
|
||||
'label' => $catMeta['label'],
|
||||
'icon' => $catMeta['icon'],
|
||||
'modules' => [],
|
||||
];
|
||||
}
|
||||
$grouped[$cat]['modules'][$slug] = $mod;
|
||||
}
|
||||
|
||||
// Ordenar grupos según CATEGORY_ORDER
|
||||
$ordered = [];
|
||||
foreach (self::CATEGORY_ORDER as $cat) {
|
||||
if (isset($grouped[$cat])) {
|
||||
// Ordenar módulos dentro de la categoría por sort_order
|
||||
uasort($grouped[$cat]['modules'], fn($a, $b) => $a['sort_order'] <=> $b['sort_order']);
|
||||
$ordered[$cat] = $grouped[$cat];
|
||||
}
|
||||
}
|
||||
// Categorías extra no definidas en CATEGORY_ORDER al final
|
||||
foreach ($grouped as $cat => $data) {
|
||||
if (!isset($ordered[$cat])) {
|
||||
$ordered[$cat] = $data;
|
||||
}
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna el descriptor de un módulo por slug, o null si no existe.
|
||||
*/
|
||||
public static function get(string $slug): ?array
|
||||
{
|
||||
self::load();
|
||||
return self::$registry[$slug] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activa o desactiva un módulo en la base de datos.
|
||||
*
|
||||
* @throws RuntimeException Si no se puede conectar a la BD.
|
||||
*/
|
||||
public static function setActive(string $slug, bool $active): bool
|
||||
{
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE system_modules SET is_active = ? WHERE slug = ?"
|
||||
);
|
||||
$ok = $stmt->execute([(int) $active, $slug]);
|
||||
if ($ok) {
|
||||
self::$registry = null; // invalidar caché
|
||||
}
|
||||
return $ok;
|
||||
} catch (Exception $e) {
|
||||
error_log("ModuleRegistry::setActive error: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza el sort_order de un módulo.
|
||||
*/
|
||||
public static function setSortOrder(string $slug, int $order): bool
|
||||
{
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE system_modules SET sort_order = ? WHERE slug = ?"
|
||||
);
|
||||
$ok = $stmt->execute([$order, $slug]);
|
||||
if ($ok) {
|
||||
self::$registry = null;
|
||||
}
|
||||
return $ok;
|
||||
} catch (Exception $e) {
|
||||
error_log("ModuleRegistry::setSortOrder error: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalida la caché para forzar recarga en la próxima llamada.
|
||||
* Útil después de cambios por el administrador.
|
||||
*/
|
||||
public static function flush(): void
|
||||
{
|
||||
self::$registry = null;
|
||||
}
|
||||
|
||||
// ─── Carga interna ──────────────────────────────────────────────────────
|
||||
|
||||
private static function load(): void
|
||||
{
|
||||
if (self::$registry !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$registry = [];
|
||||
|
||||
// 1. Escanear module.php locales para obtener metadatos base
|
||||
self::loadFromFiles();
|
||||
|
||||
// 2. Sobreescribir is_active / sort_order desde la BD (si existe)
|
||||
self::mergeFromDatabase();
|
||||
|
||||
// 3. Fallback: añadir slugs de SYSTEM_MODULES que no tengan module.php
|
||||
self::fillFromConstant();
|
||||
}
|
||||
|
||||
/**
|
||||
* Escanea modules/{slug}/module.php y carga sus descriptores.
|
||||
*/
|
||||
private static function loadFromFiles(): void
|
||||
{
|
||||
if (!defined('APP_ROOT')) {
|
||||
return;
|
||||
}
|
||||
$pattern = APP_ROOT . '/modules/*/module.php';
|
||||
foreach (glob($pattern) ?: [] as $file) {
|
||||
$data = @include $file;
|
||||
if (is_array($data) && isset($data['slug'])) {
|
||||
self::$registry[$data['slug']] = self::normalize($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee la tabla system_modules y sobreescribe campos controlados por el admin.
|
||||
*/
|
||||
private static function mergeFromDatabase(): void
|
||||
{
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
// Verificar si existe la tabla
|
||||
$check = $pdo->query("SHOW TABLES LIKE 'system_modules'");
|
||||
if (!$check || $check->rowCount() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = $pdo->query(
|
||||
"SELECT slug, name, icon, category, route, is_active, sort_order, oleada, description
|
||||
FROM system_modules ORDER BY sort_order ASC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$slug = $row['slug'];
|
||||
if (isset(self::$registry[$slug])) {
|
||||
// Actualizar solo los campos que maneja el admin
|
||||
self::$registry[$slug]['is_active'] = (bool)(int)$row['is_active'];
|
||||
self::$registry[$slug]['sort_order'] = (int)$row['sort_order'];
|
||||
// Si en BD hay datos más completos, usar los de BD
|
||||
if (!empty($row['name'])) self::$registry[$slug]['name'] = $row['name'];
|
||||
if (!empty($row['icon'])) self::$registry[$slug]['icon'] = $row['icon'];
|
||||
if (!empty($row['category'])) self::$registry[$slug]['category'] = $row['category'];
|
||||
if (!empty($row['route'])) self::$registry[$slug]['route'] = $row['route'];
|
||||
if (!empty($row['description'])) self::$registry[$slug]['description'] = $row['description'];
|
||||
} else {
|
||||
// Módulo solo en BD (sin module.php local)
|
||||
self::$registry[$slug] = self::normalize([
|
||||
'slug' => $slug,
|
||||
'name' => $row['name'],
|
||||
'icon' => $row['icon'],
|
||||
'category' => $row['category'],
|
||||
'route' => $row['route'],
|
||||
'is_active' => (bool)(int)$row['is_active'],
|
||||
'sort_order' => (int)$row['sort_order'],
|
||||
'oleada' => (int)$row['oleada'],
|
||||
'description' => $row['description'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// BD no disponible → seguir con datos de archivos
|
||||
error_log("ModuleRegistry DB merge error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrega módulos del array SYSTEM_MODULES que no estén ya registrados.
|
||||
* Garantiza compatibilidad si no se ejecutó la migración 004.
|
||||
*/
|
||||
private static function fillFromConstant(): void
|
||||
{
|
||||
if (!defined('SYSTEM_MODULES')) {
|
||||
return;
|
||||
}
|
||||
foreach (SYSTEM_MODULES as $slug => $name) {
|
||||
if (!isset(self::$registry[$slug])) {
|
||||
self::$registry[$slug] = self::normalize([
|
||||
'slug' => $slug,
|
||||
'name' => $name,
|
||||
'icon' => 'fas fa-cube',
|
||||
'category' => 'sistema',
|
||||
'route' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 99,
|
||||
'oleada' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza un descriptor garantizando que todos los campos existen.
|
||||
*/
|
||||
private static function normalize(array $data): array
|
||||
{
|
||||
return [
|
||||
'slug' => $data['slug'] ?? '',
|
||||
'name' => $data['name'] ?? ucfirst($data['slug'] ?? ''),
|
||||
'icon' => $data['icon'] ?? 'fas fa-cube',
|
||||
'category' => $data['category'] ?? 'sistema',
|
||||
'route' => $data['route'] ?? null,
|
||||
'is_active' => (bool)($data['is_active'] ?? true),
|
||||
'sort_order' => (int)($data['sort_order'] ?? 99),
|
||||
'oleada' => (int)($data['oleada'] ?? 0),
|
||||
'description' => $data['description'] ?? '',
|
||||
'links' => $data['links'] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
/**
|
||||
* core/Rbac.php
|
||||
* Control de acceso basado en roles (RBAC) con granularidad de acciones.
|
||||
* Consolida la lógica de hasModule() / requireAdmin() dispersa en _helpers.php
|
||||
* y config.php con soporte para los nuevos campos can_* (Migración 001).
|
||||
*
|
||||
* USO EN VISTAS:
|
||||
* Rbac::requireLogin();
|
||||
* Rbac::requireModule('lab_domicilios');
|
||||
* if (Rbac::can('lab_reportes', 'export')) { ... }
|
||||
*
|
||||
* USO EN API (reemplaza funciones sueltas de _helpers.php):
|
||||
* Rbac::requireAdmin();
|
||||
* Rbac::requireModule('turnero', 'create');
|
||||
*/
|
||||
|
||||
class Rbac
|
||||
{
|
||||
// ─── Consulta básica de módulo ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Comprueba si el usuario en sesión tiene acceso (cualquier nivel) al módulo.
|
||||
* Retrocompatible: admin sin role_id tiene acceso total.
|
||||
*/
|
||||
public static function hasModule(string $slug): bool
|
||||
{
|
||||
$modules = $_SESSION['admin_user']['modules'] ?? null;
|
||||
|
||||
if ($modules === null) {
|
||||
// Sesión legacy: solo admins pasan
|
||||
return ($_SESSION['admin_user']['role'] ?? '') === 'admin'
|
||||
|| ($_SESSION['admin_user']['role'] ?? '') === 'superadmin';
|
||||
}
|
||||
|
||||
return in_array($slug, $modules, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprueba si el usuario tiene una acción específica sobre el módulo.
|
||||
*
|
||||
* $action: 'view' | 'create' | 'edit' | 'delete' | 'export'
|
||||
*
|
||||
* Los roles admin y superadmin tienen acceso total a todo.
|
||||
*/
|
||||
public static function can(string $slug, string $action = 'view'): bool
|
||||
{
|
||||
$role = $_SESSION['admin_user']['role'] ?? 'admin';
|
||||
|
||||
// Superadmin y admin tienen acceso total
|
||||
if (in_array($role, ['superadmin', 'admin'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Primero verificar que tiene el módulo
|
||||
if (!self::hasModule($slug)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Leer permisos granulares desde sesión (cargados en authenticateUser)
|
||||
$perms = $_SESSION['admin_user']['module_permissions'] ?? [];
|
||||
|
||||
// Formato anterior compatible: 'write' / 'read'
|
||||
if (isset($perms[$slug]) && is_string($perms[$slug])) {
|
||||
$legacy = $perms[$slug];
|
||||
if ($action === 'view') return true; // read implica view
|
||||
return $legacy === 'write';
|
||||
}
|
||||
|
||||
// Formato nuevo: array con can_* (post Migración 001)
|
||||
if (isset($perms[$slug]) && is_array($perms[$slug])) {
|
||||
return (bool) ($perms[$slug]["can_{$action}"] ?? false);
|
||||
}
|
||||
|
||||
// Sin permisos granulares → view por defecto
|
||||
return $action === 'view';
|
||||
}
|
||||
|
||||
// ─── Guardas / Requisitos ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detiene con 403 JSON si el usuario no está autenticado.
|
||||
* Para uso en vistas, redirige al login.
|
||||
*/
|
||||
public static function requireLogin(string $redirect = null): void
|
||||
{
|
||||
if (!isset($_SESSION['admin_logged_in']) || $_SESSION['admin_logged_in'] !== true) {
|
||||
if ($redirect !== null) {
|
||||
header("Location: $redirect");
|
||||
exit;
|
||||
}
|
||||
self::jsonForbidden('No autenticado', 401);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detiene con 403 si el usuario no es admin ni superadmin.
|
||||
*/
|
||||
public static function requireAdmin(): void
|
||||
{
|
||||
$role = $_SESSION['admin_user']['role'] ?? '';
|
||||
if (!in_array($role, ['admin', 'superadmin'], true)) {
|
||||
self::jsonForbidden('Acceso restringido a administradores', 403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detiene con 403 si el usuario no tiene acceso al módulo.
|
||||
* Opcionalmente exige una acción específica.
|
||||
*/
|
||||
public static function requireModule(string $slug, string $action = 'view'): void
|
||||
{
|
||||
if (!self::can($slug, $action)) {
|
||||
self::jsonForbidden("Sin permiso en módulo '{$slug}' ({$action})", 403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias semántico: detiene si no tiene permiso de escritura en el módulo.
|
||||
*/
|
||||
public static function requireWrite(string $slug): void
|
||||
{
|
||||
self::requireModule($slug, 'edit');
|
||||
}
|
||||
|
||||
// ─── Helpers internos ───────────────────────────────────────────────────
|
||||
|
||||
private static function jsonForbidden(string $msg, int $code = 403): void
|
||||
{
|
||||
// Si ya enviamos headers JSON (contexto API), responder JSON
|
||||
if (isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
// Contexto HTML: página de error simple
|
||||
http_response_code($code);
|
||||
echo "<h1>{$code}</h1><p>" . htmlspecialchars($msg) . "</p>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─── Utilidades de sesión ────────────────────────────────────────────────
|
||||
|
||||
public static function currentRole(): string
|
||||
{
|
||||
return $_SESSION['admin_user']['role'] ?? 'admin';
|
||||
}
|
||||
|
||||
public static function currentModules(): array
|
||||
{
|
||||
return $_SESSION['admin_user']['modules'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga en sesión los permisos granulares (can_*) desde la BD.
|
||||
* Se llama desde authenticateUser() después de la Migración 001.
|
||||
*/
|
||||
public static function loadGranularPermissions(int $roleId): array
|
||||
{
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$rows = $db->fetchAll(
|
||||
"SELECT module_slug, can_view, can_create, can_edit, can_delete, can_export, extra_json
|
||||
FROM role_modules
|
||||
WHERE role_id = ?",
|
||||
[$roleId]
|
||||
);
|
||||
|
||||
$perms = [];
|
||||
foreach ($rows as $row) {
|
||||
$perms[$row['module_slug']] = [
|
||||
'can_view' => (bool) $row['can_view'],
|
||||
'can_create' => (bool) $row['can_create'],
|
||||
'can_edit' => (bool) $row['can_edit'],
|
||||
'can_delete' => (bool) $row['can_delete'],
|
||||
'can_export' => (bool) $row['can_export'],
|
||||
'extra' => $row['extra_json'] ? json_decode($row['extra_json'], true) : [],
|
||||
];
|
||||
}
|
||||
return $perms;
|
||||
} catch (Exception $e) {
|
||||
error_log("[Rbac] Error cargando permisos: " . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Funciones globales de retrocompatibilidad ──────────────────────────────
|
||||
// Las vistas y APIs legacy que llaman hasModule(), isEnfermero(), etc.
|
||||
// siguen funcionando sin cambios apuntando a la clase Rbac.
|
||||
|
||||
if (!function_exists('hasModule')) {
|
||||
function hasModule(string $slug): bool { return Rbac::hasModule($slug); }
|
||||
}
|
||||
if (!function_exists('canDo')) {
|
||||
function canDo(string $slug, string $action): bool { return Rbac::can($slug, $action); }
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
/**
|
||||
* core/Router.php
|
||||
* Enrutador central del ERP.
|
||||
*
|
||||
* Mapea la URL entrante a modules/{m}/views/{v}.php.
|
||||
* Soporta rutas limpias (/turnero/dashboard) y parámetros explícitos (?m=&v=).
|
||||
*
|
||||
* RUTAS PÚBLICAS (sin autenticación):
|
||||
* turnero/display → pantalla TV para sala de espera
|
||||
* turnero/kiosko → pantalla táctil para tomar turno
|
||||
*/
|
||||
|
||||
class Router
|
||||
{
|
||||
/** Módulo por defecto cuando no se especifica ?m= */
|
||||
private const DEFAULT_MODULE = 'dashboard';
|
||||
|
||||
/** Vista por defecto cuando no se especifica ?v= */
|
||||
private const DEFAULT_VIEW = 'index';
|
||||
|
||||
/** Rutas que NO requieren sesión iniciada. Formato: 'modulo/vista' */
|
||||
private const PUBLIC_ROUTES = [
|
||||
'turnero/display',
|
||||
'turnero/kiosko',
|
||||
];
|
||||
|
||||
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
|
||||
private const SLUG_PATTERN = '/^[a-zA-Z0-9_]{1,64}$/';
|
||||
|
||||
private string $module;
|
||||
private string $view;
|
||||
|
||||
// ─── Constructor ────────────────────────────────────────────────────────
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
[$this->module, $this->view] = $this->resolveRoute();
|
||||
}
|
||||
|
||||
// ─── API pública ────────────────────────────────────────────────────────
|
||||
|
||||
public function getModule(): string { return $this->module; }
|
||||
public function getView(): string { return $this->view; }
|
||||
|
||||
/**
|
||||
* Indica si la ruta actual es pública (no requiere login).
|
||||
*/
|
||||
public function isPublic(): bool
|
||||
{
|
||||
return in_array($this->module . '/' . $this->view, self::PUBLIC_ROUTES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve la ruta y devuelve el archivo de vista correspondiente.
|
||||
* Lanza una excepción si el archivo no existe.
|
||||
*
|
||||
* @return string Ruta absoluta al archivo de vista.
|
||||
* @throws RuntimeException Si el módulo/vista no se encuentran.
|
||||
*/
|
||||
public function resolveFile(): string
|
||||
{
|
||||
$file = APP_ROOT . "/modules/{$this->module}/views/{$this->view}.php";
|
||||
|
||||
if (!file_exists($file)) {
|
||||
throw new RuntimeException(
|
||||
"Vista no encontrada: {$this->module}/{$this->view}",
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
// ─── Internos ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Determina módulo y vista a partir de la URL.
|
||||
* Prioridad: PATH_INFO limpio → parámetros GET ?m= ?v=
|
||||
*
|
||||
* @return array{string, string}
|
||||
*/
|
||||
private function resolveRoute(): array
|
||||
{
|
||||
// 1. Intentar ruta limpia desde PATH_INFO o REQUEST_URI
|
||||
// Formato: /modulo/vista → e.g. /turnero/dashboard
|
||||
$path = $this->cleanPath();
|
||||
if ($path !== null) {
|
||||
[$m, $v] = $path;
|
||||
if ($this->isValidSlug($m) && $this->isValidSlug($v)) {
|
||||
return [$m, $v];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Parámetros GET ?m=module&v=view
|
||||
$m = $_GET['m'] ?? self::DEFAULT_MODULE;
|
||||
$v = $_GET['v'] ?? self::DEFAULT_VIEW;
|
||||
|
||||
// Sanitizar: solo caracteres seguros
|
||||
$m = preg_replace('/[^a-zA-Z0-9_]/', '', $m);
|
||||
$v = preg_replace('/[^a-zA-Z0-9_]/', '', $v);
|
||||
|
||||
if (!$this->isValidSlug($m)) $m = self::DEFAULT_MODULE;
|
||||
if (!$this->isValidSlug($v)) $v = self::DEFAULT_VIEW;
|
||||
|
||||
return [$m, $v];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae módulo y vista de la REQUEST_URI limpia.
|
||||
* Retorna null si la URI no corresponde al formato /m/v.
|
||||
*
|
||||
* @return array{string,string}|null
|
||||
*/
|
||||
private function cleanPath(): ?array
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
|
||||
// Eliminar query string
|
||||
$pos = strpos($uri, '?');
|
||||
if ($pos !== false) {
|
||||
$uri = substr($uri, 0, $pos);
|
||||
}
|
||||
|
||||
// Eliminar el subdirectorio base si el proyecto no está en la raíz
|
||||
$scriptDir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/');
|
||||
if ($scriptDir !== '' && str_starts_with($uri, $scriptDir)) {
|
||||
$uri = substr($uri, strlen($scriptDir));
|
||||
}
|
||||
|
||||
// Normalizar y dividir
|
||||
$parts = array_values(array_filter(explode('/', trim($uri, '/'))));
|
||||
|
||||
if (count($parts) >= 2) {
|
||||
return [$parts[0], $parts[1]];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isValidSlug(string $slug): bool
|
||||
{
|
||||
return (bool) preg_match(self::SLUG_PATTERN, $slug);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user