Author SHA1 Message Date
Jordan DiazandClaude Opus 5.5 701bc3bb8b feat(core): el motor de las tablas nuevas depende de la version del servidor
El cambio anterior (8d92d08) creaba siempre las tablas nuevas en InnoDB. Hay
un servidor de produccion con MariaDB 5.5.68, donde InnoDB no es seguro: no
admite FULLTEXT, limita los indices a 767 bytes y usa filas COMPACT. Con
«Subir core», las tablas nuevas de una web de ese servidor habrian nacido en
InnoDB sobre 5.5.

Ahora `acaiTableEngine()` decide por la version, con el mismo patron que
`mysqlStrictMode` usa para el modo SQL:

  MariaDB >= 10.2 o MySQL >= 5.7 -> InnoDB (Barracuda/DYNAMIC por defecto)
  cualquier otro o desconocido   -> MyISAM (lo de siempre)

Contempla el disfraz "5.5.5-10.x.y-MariaDB" con el que algunos clientes
antiguos ven a MariaDB 10+. La decision pura (`acaiEngineForServerVersion`)
esta separada para poder probarla sin servidor. Usada en los dos sitios que
crean tablas: createMissingSchemaTablesAndFields y la instalacion de plugins.

Comprobado: 12 versiones (Forge 10.6, webserver2 11.4, servidor antiguo 5.5,
disfraces, MySQL 5.6/5.7/8.0, vacio) y de punta a punta contra el MariaDB de
Forge a traves del shim mysql_*: InnoDB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 17:44:26 +00:00
Jordan DiazandClaude Opus 5.5 bc63ebdb15 Merge corregir-comentario-innodb
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 17:28:40 +00:00
Jordan DiazandClaude Opus 5.5 3701072bb3 docs(core): el comentario del motor InnoDB daba una razon falsa
Decia que los INSERT parciales del CMS seguian colando porque el sql_mode de
Forge no es estricto. Es falso: el CMS fija su propio modo al conectar
(mysqlStrictMode(true) y TRADITIONAL en Db.php), y en un INSERT de una fila
ese modo rechaza lo mismo con MyISAM que con InnoDB. Solo cambia el
comentario.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 17:28:39 +00:00
Jordan DiazandClaude Opus 5 8d92d08a56 Merge innodb-tablas-nuevas: las tablas nuevas del CMS nacen en InnoDB
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:41:10 +00:00
Jordan DiazandClaude Opus 5 12dfdd5fed feat(core): las tablas nuevas del CMS nacen en InnoDB
Los dos sitios que crean tablas en caliente las creaban MyISAM: la funcion
`createMissingSchemaTablesAndFields()` (cada seccion o tabla nueva del CMS) y
la instalacion de plugins con tablas preset. Parecian codigo muerto porque
usan `mysql_query`, que PHP 8 elimino, pero estan vivos: hay un shim en
cms/lib/mysql.php que lo mapea a mysqli.

Sin esto, cambiar el molde de las webs nuevas no servia de nada: la primera
tabla que alguien creara desde el CMS volvia a nacer MyISAM y el molde se
degradaba solo con el uso.

InnoDB da recuperacion ante caida (MyISAM se corrompe con un OOM o un apagon y
hay que repararlo a mano) y bloqueo por fila. El sql_mode de Forge ya no es
estricto, asi que los INSERT parciales del CMS —AcaiAPI no rellena
`dragSortOrder` ni los checkboxes— siguen colando con un aviso igual que con
MyISAM, en vez de fallar con el error 1364.

No toca ninguna tabla existente: solo el motor de las que se creen a partir de
ahora. Contexto completo en docs/motor-innodb.md del repo de Forge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:41:02 +00:00
Jordan DiazandClaude Opus 5 a9ecad4ee1 fix(core): los ganchos de ciclo de vida de plugins funcionan en las webs locales
_pluginLifecycle validaba el token SOLO contra el WS central, que unicamente
conoce webs de produccion: en una copia local de Forge (standalone o de test)
el token nunca le valida y instalar, desinstalar, activar o desactivar un
plugin moria con "Token no valido", un mensaje que no tenia nada que ver con
el token.

Segunda via para esas copias: si el central rechaza y la web tiene .acai con
mode != production, se compara el par token+tokenHash recibido con el que la
propia web guarda en su .acai (hash_equals). No es un bypass — sin .acai, en
produccion o sin coincidencia exacta no autoriza nada —, y el secreto es el
mismo que Forge ya usa para hablar con la web.

Probado en superadmin/claude-es-mi-amigo: con el token del .acai el gancho
corre (ok:true); con un token falso o vacio, 403 como siempre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:27:18 +00:00
Jordan DiazandClaude Opus 5 0bbf053703 fix(core): el borrado recursivo se dejaba los ficheros ocultos
`_deleteAll` listaba con `glob`, que NO devuelve los ficheros que empiezan por
punto. Un `.htaccess` sobrevivia al barrido, la carpeta no quedaba vacia y el
`rmdir` de despues fallaba sin decir por que.

Efecto real, encontrado desinstalando `variaciones` en produccion: se borra el
codigo del plugin, se devuelve "No se ha podido desinstalar el plugin" y la
operacion queda A MEDIAS — sin ficheros, pero con la carpeta en la web y con el
alta todavia en el CMS central. El paso que falla es el segundo de cuatro, asi
que los dos ultimos ni se intentan. En una web real son 5 de 14 los plugins con
`.htaccess`, no un caso raro.

Se cambia a `scandir`, que si los ve, saltando `.` y `..`.

Y de paso el symlink, que con `glob` tampoco se trataba: ahora se QUITA el
enlace en vez de seguirlo. Recorrerlo seria borrar lo que hay al otro lado, que
vive fuera de la carpeta que se pidio borrar. Comprobado con un enlace a un
directorio con un fichero dentro: el enlace desaparece, el fichero se queda.

Verificado dentro del container, con carpeta anidada, dos ficheros ocultos y un
symlink: la carpeta se borra entera y lo apuntado sobrevive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPL66q9F33YZxbLPUT7KW8
2026-09-13 18:08:02 +00:00
Jordan DiazandClaude Fable 5.1 b1c9ffa461 fix(plugins): el cargador ignora una carpeta con custom-schema pero sin schema
Tras un descarte del sync (que no mueve los custom-schema.ini.php, excluidos
del manifest) puede quedar una carpeta de plugin solo con su overlay. El
cargador hacia foreach sobre un schema inexistente y emitia 'Undefined array
key' en cada peticion, visible en el HTML con display_errors=1. Sin
schema.ini.php no hay plugin: la carpeta se ignora.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPL66q9F33YZxbLPUT7KW8
2026-09-13 11:23:51 +00:00
Jordan DiazandClaude Fable 5.1 e198bd780a fix(plugins): _removeFile solo borra contenido, nunca el motor
La regla anterior permitia cualquier ruta dentro de la raiz de la web, y eso
incluye cms/lib, lib o los PHP del motor. Ahora en modo fichero solo se admite
algo bajo cms/uploads/, template/, hooks/ o cache/; en modo plugin, una carpeta
directa de pluginsdir. Lo demas, 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPL66q9F33YZxbLPUT7KW8
2026-09-12 22:59:07 +00:00
Jordan DiazandClaude Fable 5.1 ea8eb6dbcb fix(plugins): red de seguridad ante exit() en un gancho y globales del CMS
Tras la revision de codigo de 273c3dd:
- Un exit()/die() dentro de un gancho no es una excepcion y dejaba un cuerpo
  que no era JSON. register_shutdown_function en _pluginLifecycle responde
  {ok:false, error, messages:[lo impreso]} si el gancho no llego al final.
- event/plugin/origin se castean solo si son escalares (un array producia un
  Warning delante del JSON).
- Los ganchos ven CURRENT_USER, menu, action, TABLE_PREFIX y SETTINGS como
  un checkpoint desde addPlugins(); documentado en el contrato.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPL66q9F33YZxbLPUT7KW8
2026-09-12 22:51:56 +00:00
Jordan DiazandClaude Fable 5.1 3565abdd88 fix(plugins): endurecer pluginLifecycle y sanear _removeFile
Tras la revision de seguridad de 273c3dd:
- pluginLifecycle exige POST con cuerpo JSON (token, tokenHash, event, plugin),
  como _saveFile: el token no viaja en la query string.
- La excepcion de un gancho se loguea entera en el servidor y al cliente va
  sin rutas absolutas.
- _removeFile (preexistente): la ruta la mandaba el cliente sin sanear y
  _deleteAll borraba cualquier cosa alcanzable, ../ incluido. Ahora en modo
  plugin solo una carpeta directa de pluginsdir y en modo fichero solo dentro
  de la raiz de la web; nunca la raiz ni pluginsdir, nunca fuera por symlink.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPL66q9F33YZxbLPUT7KW8
2026-09-12 22:50:20 +00:00
Jordan DiazandClaude Fable 5.1 273c3dd781 feat(plugins): ganchos de ciclo de vida y checkpoints boot_plugins y post_variables
Fase 0 del instalador de plugins (docs/plugins/fase0-instalador-diseno.md del
repo de Forge). Un plugin puede llevar install.php, uninstall.php, enable.php,
disable.php y status.php; los ejecuta runPluginLifecycle() dentro de la web
por action_ws=pluginLifecycle, sea Forge o el CMS legacy quien instale.
showInstall() reutiliza el mismo nucleo (installPluginArtifacts) en vez de
duplicar install.sql/schemaPresets.

Checkpoints nuevos, sin efecto hasta que un plugin los declare:
- boot_plugins: en init.php, con la BD ya conectada.
- post_variables: tras cada require de lib/variables.php (slug.php, sesion.php,
  lib/recursos.php), una vez por peticion. Un plugin llega DESPUES del bloque
  de cache de variables.php y puede pisarlo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPL66q9F33YZxbLPUT7KW8
2026-09-12 22:40:42 +00:00
7 changed files with 339 additions and 18 deletions
+46 -1
View File
@@ -43,6 +43,49 @@ function connectToMySQL($returnErrors = false) {
return '';
}
/**
* Motor de almacenamiento para las tablas que crea el CMS.
*
* InnoDB da recuperacion ante caida (MyISAM se corrompe con un apagon o un OOM
* y hay que repararlo a mano) y bloqueo por fila. Pero en servidores antiguos
* no es seguro: MariaDB 5.5 (hay un servidor de produccion con 5.5.68) no
* admite FULLTEXT en InnoDB, limita los indices a 767 bytes y usa filas
* COMPACT. Por eso el motor se decide por la version del servidor, igual que
* mysqlStrictMode decide el modo SQL:
*
* MariaDB >= 10.2 o MySQL >= 5.7 -> InnoDB (Barracuda/DYNAMIC por defecto)
* cualquier otro o desconocido -> MyISAM (lo de siempre)
*
* No cambia que INSERT se aceptan: el CMS conecta en modo estricto
* (mysqlStrictMode / TRADITIONAL en Db.php) y en un INSERT de una fila eso
* rechaza lo mismo con los dos motores. Ver docs/motor-innodb.md (repo Forge).
*/
function acaiTableEngine() {
static $engine = null;
if ($engine === null) {
$engine = acaiEngineForServerVersion(mysql_get_server_info());
}
return $engine;
}
/** Decision pura, separada para poder probarla sin servidor. */
function acaiEngineForServerVersion($serverInfo) {
$info = trim((string) $serverInfo);
// Clientes antiguos ven MariaDB 10+ como "5.5.5-10.6.25-MariaDB": el
// "5.5.5-" es un disfraz de compatibilidad, la version real va detras.
if (preg_match('/^5\.5\.5-(\d+\.\d+(?:\.\d+)?)/', $info, $m)) {
$version = $m[1];
$isMariaDb = true;
} elseif (preg_match('/^(\d+\.\d+(?:\.\d+)?)/', $info, $m)) {
$version = $m[1];
$isMariaDb = stripos($info, 'mariadb') !== false;
} else {
return 'MyISAM';
}
$minimo = $isMariaDb ? '10.2' : '5.7';
return version_compare($version, $minimo, '>=') ? 'InnoDB' : 'MyISAM';
}
function mysqlStrictMode($strictMode) {
$currentVersion = preg_replace("/[^0-9\.]/", "", mysql_get_server_info());
$isMySql5 = version_compare($currentVersion, '5.0.0', '>=');
@@ -74,7 +117,9 @@ function createMissingSchemaTablesAndFields() {
$mysqlTableName = $TABLE_PREFIX . $tableName;
if (!in_array($mysqlTableName, $mysqlTables)) {
notice("Creating MySQL table for schema table: $tableName<br/>\n");
$result = mysql_query("CREATE TABLE `".mysql_real_escape_string($mysqlTableName)."` (num int(10) unsigned NOT NULL auto_increment, PRIMARY KEY (num)) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;");
// Motor segun el servidor (ver acaiTableEngine): InnoDB donde lo
// soporta bien, MyISAM en los antiguos.
$result = mysql_query("CREATE TABLE `".mysql_real_escape_string($mysqlTableName)."` (num int(10) unsigned NOT NULL auto_increment, PRIMARY KEY (num)) ENGINE=".acaiTableEngine()." DEFAULT CHARSET=utf8mb4;");
if (!$result) { alert("Error creating MySQL table: $mysqlTableName<br/>\MySQL error was: ". htmlspecialchars(mysql_error()) . "\n"); }
}
+127 -11
View File
@@ -30,6 +30,7 @@ if (isset($_REQUEST["action_ws"])){
case "coreUpdate": _coreUpdate(realpath($GLOBALS["APP"]["pluginsdir"]."/../../lib")); break;
case "pluginsUpdate": _coreUpdate(realpath($GLOBALS["APP"]["pluginsdir"]),true); break;
case "removeFile": _removeFile(); break;
case "pluginLifecycle": _pluginLifecycle(); break;
case "clearCache": _clearCache(); break;
case "renameFile": _renameFile();
default : die(json_encode(["result" => 0,"error" => "Método no permitido"]));
@@ -309,9 +310,12 @@ function _removeFile() {
die(json_encode(['error' => ['message' => 'Datos no enviados', 'code' => 403]]));
}
$filePath = $fileData['file'];
if (isset($fileData['plugin'])) {
$filePath = $GLOBALS['APP']['pluginsdir'].'/'.$filePath;
// Saneado (2026-09-12): la ruta la manda el cliente. En modo plugin solo se
// borra una carpeta DIRECTA de pluginsdir; en modo fichero, algo dentro de la
// raiz de la web. Nunca la raiz ni pluginsdir en si, nunca fuera por ../ o symlink.
$filePath = _removeFileRutaSegura((string) $fileData['file'], isset($fileData['plugin']));
if ($filePath === false) {
die(json_encode(['error' => ['message' => 'Ruta no permitida', 'code' => 403]]));
}
$deleted = _deleteAll($filePath);
@@ -320,17 +324,24 @@ function _removeFile() {
}
function _deleteAll($str) {
if (!file_exists($str)) return false;
if (is_file($str)) {
if (!file_exists($str) && !is_link($str)) return false;
// Un symlink se quita, NUNCA se sigue: borrar su contenido seria borrar algo
// que vive fuera de la carpeta que se pidio borrar.
if (is_link($str) || is_file($str)) {
return unlink($str);
}
elseif (is_dir($str)) {
//Get a list of the files in this directory.
$scan = glob(rtrim($str,'/').'/*');
//Loop through the list of files.
foreach($scan as $index=>$path) {
//Call our recursive function.
_deleteAll($path);
// `scandir` y no `glob`: glob NO devuelve los ficheros ocultos, asi que
// un `.htaccess` sobrevivia al borrado, la carpeta no quedaba vacia y el
// rmdir de abajo fallaba en silencio. Con eso, desinstalar en produccion
// un plugin con .htaccess —5 de 14 en una web real— borraba su codigo,
// devolvia "no se ha podido desinstalar" y dejaba la operacion a medias:
// sin ficheros, pero con la carpeta y con el alta en el central.
$entradas = @scandir($str);
if ($entradas === false) return false;
foreach ($entradas as $nombre) {
if ($nombre === '.' || $nombre === '..') continue;
_deleteAll(rtrim($str, '/') . '/' . $nombre);
}
//Remove the directory itself.
return @rmdir($str);
@@ -386,3 +397,108 @@ function _renameFile() {
die(json_encode(['success' => file_exists($newFile)]));
}
/**
* Segunda via de autenticacion del ciclo de vida, para las copias LOCALES de
* Forge (standalone o de test). El WS central solo conoce webs de produccion:
* el token de una web sin dominio real nunca le valida, asi que los ganchos
* (install/uninstall/enable/disable) quedaban inservibles ahi con un
* "Token no valido" que no tenia nada que ver con el token.
*
* No es un bypass: se compara el par token+tokenHash recibido con el que la
* propia web guarda en su .acai, que es exactamente el que Forge envia. Sin
* .acai (produccion), con mode=production, o sin coincidencia exacta, no
* autoriza nada.
*/
function _lifecycleTokenLocalValido($datosAuth) {
$acaiPath = __DIR__ . "/../../../.acai";
if (!is_file($acaiPath)) {
return false;
}
$acai = @json_decode(@file_get_contents($acaiPath), true);
if (!is_array($acai) || @$acai["mode"] === "production") {
return false;
}
$token = (string) @$datosAuth["token"];
$hash = (string) @$datosAuth["tokenHash"];
$tokenLocal = (string) @$acai["token"];
$hashLocal = (string) @$acai["tokenHash"];
if ($token === "" || $hash === "" || $tokenLocal === "" || $hashLocal === "") {
return false;
}
return hash_equals($tokenLocal, $token) && hash_equals($hashLocal, $hash);
}
function _pluginLifecycle() {
// Fase 0 (2026-09-12): ejecuta los ganchos de ciclo de vida de un plugin
// (install/uninstall/enable/disable/status) DENTRO de la web. Unico punto
// de entrada para Forge y para el CMS legacy. Misma autenticacion que coreUpdate.
$datosAuth = json_decode(file_get_contents('php://input'), true);
CocoWS::setToken(@$datosAuth["token"]);
if (!CocoWS::validateUploadToken(@$datosAuth["tokenHash"])
&& !_lifecycleTokenLocalValido($datosAuth)) {
die(json_encode(['error' => ['message' => 'Token no válido', 'code' => 403]]));
}
// Solo body JSON (como _saveFile): el token no debe viajar en la query string.
$datos = json_decode(file_get_contents('php://input'), true);
if (!is_array($datos) || empty($datos["tokenHash"])) {
die(json_encode(['error' => ['message' => 'pluginLifecycle exige POST con cuerpo JSON', 'code' => 400]]));
}
header('Content-Type: application/json');
if (!function_exists("runPluginLifecycle")) {
// El cargador de plugins de esta web es anterior a los ganchos: el sync
// respeta ficheros personalizados y puede haber entregado este y no aquel.
die(json_encode(["ok" => false, "error" => "El cargador de plugins de esta web no soporta ganchos (cms/lib/plugins/index.php desactualizado)"]));
}
// Red de seguridad: exit()/die() dentro de un gancho no es una excepcion y
// se saltaria el die(json_encode(...)) de abajo dejando un cuerpo que no es
// JSON. Si al cerrar no hemos respondido, respondemos nosotros.
$GLOBALS["_pluginLifecycleRespondido"] = false;
register_shutdown_function(function () {
if (!empty($GLOBALS["_pluginLifecycleRespondido"])) return;
$salida = "";
while (ob_get_level() > 0) { $salida .= (string) ob_get_clean(); }
echo json_encode(array(
"ok" => false,
"error" => "El gancho ha terminado con exit()/die() sin devolver resultado",
"messages" => $salida !== "" ? array(trim($salida)) : array(),
));
});
$event = is_scalar(@$datos["event"]) ? (string) $datos["event"] : "";
$plugin = is_scalar(@$datos["plugin"]) ? (string) $datos["plugin"] : "";
$origin = is_scalar(@$datos["origin"]) ? (string) $datos["origin"] : "";
$context = array(
"origin" => in_array($origin, array("forge", "legacy"), true) ? $origin : "desconocido",
"dropData" => filter_var(@$datos["dropData"], FILTER_VALIDATE_BOOLEAN),
// Misma señal de entorno que header.php: el .acai solo existe en las copias locales.
"mode" => file_exists(__DIR__ . "/../../../.acai") ? "local" : "production",
);
$resultado = runPluginLifecycle($plugin, $event, $context);
$GLOBALS["_pluginLifecycleRespondido"] = true;
die(json_encode($resultado));
}
function _removeFileRutaSegura($ruta, $esPlugin) {
$pluginsDir = realpath($GLOBALS['APP']['pluginsdir']);
$raizWeb = $pluginsDir ? realpath($pluginsDir . '/../../..') : false;
if (!$pluginsDir || !$raizWeb || $ruta === '') return false;
if ($esPlugin) {
$nombre = basename($ruta);
if ($nombre !== $ruta || $nombre === '.' || $nombre === '..') return false;
$base = $pluginsDir;
$real = realpath($pluginsDir . '/' . $nombre);
} else {
// Lista blanca: contenido de la web, nunca su motor ni su configuracion.
if (strpos($ruta, "\0") !== false || substr($ruta, 0, 1) === '/') return false;
$base = false;
foreach (array('cms/uploads', 'template', 'hooks', 'cache') as $permitido) {
if (strpos($ruta, $permitido . '/') === 0) { $base = realpath($raizWeb . '/' . $permitido); break; }
}
if (!$base) return false;
$real = realpath($raizWeb . '/' . $ruta);
}
if (!$real || $real === $base || $real === $pluginsDir || $real === $raizWeb) return false;
if (strpos($real, $base . DIRECTORY_SEPARATOR) !== 0) return false;
return $real;
}
+8
View File
@@ -27,6 +27,14 @@ displaySocketErrors();
if ($SETTINGS['isInstalled']) {
connectToMySQL();
}
// Checkpoint `boot_plugins` (2026-09-12): el CMS esta cargado y la BD conectada.
// Los plugins que lo declaren en [checkPoints] se inicializan aqui en cada
// peticion (instalar/reconciliar lo suyo, autodiagnostico). Ningun plugin del
// catalogo lo declaraba al crearlo: no cambia nada hasta que uno lo use.
if (function_exists('addPlugins')) {
$bootPluginsVar = array();
addPlugins('boot_plugins', $bootPluginsVar);
}
function enableErrorReporting() {
if (!defined('E_STRICT')) { define('E_STRICT', 2048); } // define E_STRICT for PHP < 5
error_reporting(E_ALL | E_STRICT); // display all errors
+133 -5
View File
@@ -16,7 +16,10 @@ function getSchemaPlugins($force = false){
}
$customSchemaFile = $fullPath."/custom-schema.ini.php";
if (file_exists($customSchemaFile)){
// Sin schema.ini.php no hay plugin: un custom-schema huerfano (p.ej. tras
// un descarte del sync, que no mueve los custom-schema) se ignora en vez
// de avisar en cada peticion.
if (isset($schemaPlugins[$dirname]) && file_exists($customSchemaFile)){
$customSchema = loadINI($customSchemaFile);
$exclude = array("version", "name", "description");
foreach ($exclude as $e):
@@ -75,9 +78,8 @@ function showInstall($path){
<div id="page-content">
<h3>Instalando el plugin</h3>
<? $borro = true; ?>
<? if (installSQL($path)){ if ($borro) rename($path."/install.sql",$path."/install.sql.bck"); ?><p>Instalando tablas necesarias</p><?}else{?><p class="text-danger">Error instalando la base de datos</p><?}?>
<? if (installSchemas($path)){ if ($borro) rename($path."/schemaPresets",$path."/schemaPresets.bck"); ?><p>Instalando Schemas</p><?}else{?><p class="text-danger">Error instalando los esquemas</p><?}?>
<? $mensajesInstall = []; installPluginArtifacts($path, $mensajesInstall); ?>
<? foreach ($mensajesInstall as $mensajeInstall): ?><p><?=htmlspecialchars($mensajeInstall);?></p><? endforeach; ?>
<? if ($result==2){?>
<p>&nbsp;</p>
<a href="">Recarga para cargar el plugin</a>
@@ -208,7 +210,7 @@ function addTablePlugin($path,$file) {
$result = mysql_query("CREATE TABLE `".mysql_real_escape_string($tableNameWithPrefix)."` (
num int(10) unsigned NOT NULL auto_increment,
PRIMARY KEY (num)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;");
) ENGINE=".acaiTableEngine()." DEFAULT CHARSET=utf8;");
if (!$result) {
print "Error creating MySQL table.\n\nMySQL error was: ". htmlspecialchars(mysql_error()) . "\n";
exit;
@@ -356,4 +358,130 @@ function query($sql)
}
}
// ---------------------------------------------------------------------------
// Ciclo de vida de plugins (fase 0, 2026-09-12).
//
// Ganchos OPCIONALES en la carpeta del plugin: install.php, uninstall.php,
// enable.php, disable.php y status.php. Los ejecuta runPluginLifecycle()
// DENTRO de la web, instale quien instale (Forge o el CMS legacy), a traves de
// action_ws=pluginLifecycle (cms/lib/handlers/handler_ws.php).
//
// Contrato de un gancho: se incluye en un ambito propio con $plugin, $pluginDir,
// $event, $context y $dropData; debe ser IDEMPOTENTE (puede ejecutarse dos veces);
// puede devolver ["ok" => bool, "messages" => [..], "status" => [..]] o
// simplemente terminar. status.php devuelve semaforos:
// [["clave" => "...", "estado" => "ok|warn|error", "texto" => "..."], ...].
// Lo que el gancho imprima se recoge como mensaje, nunca sale por la respuesta.
// El gancho ve $CURRENT_USER, $menu, $action, $TABLE_PREFIX y $SETTINGS como un
// checkpoint; cualquier otro global lo declara el. NUNCA exit()/die(): no es una
// excepcion, se salta el resultado y solo la red de seguridad de handler_ws.php
// evita una respuesta que no sea JSON.
// ---------------------------------------------------------------------------
function pluginLifecycleEvents() {
return array("install", "uninstall", "enable", "disable", "status");
}
function pluginFolderIsValid($folder) {
return is_string($folder) && $folder !== "" && $folder !== "." && $folder !== ".."
&& preg_match('/^[a-zA-Z0-9._-]+$/', $folder) === 1;
}
function pluginDirResolved($folder) {
// Ruta real del plugin, o false si no existe o se sale de pluginsdir.
$pluginsDir = realpath($GLOBALS["APP"]["pluginsdir"]);
if (!$pluginsDir || !pluginFolderIsValid($folder)) return false;
$dir = realpath($pluginsDir . "/" . $folder);
if (!$dir || !is_dir($dir)) return false;
if (strpos($dir, $pluginsDir . DIRECTORY_SEPARATOR) !== 0) return false;
return $dir;
}
function installPluginArtifacts($path, &$messages) {
// Lo que hacia showInstall() sin el HTML: install.sql y schemaPresets/ una
// sola vez (se renombran a .bck, como siempre). false si el SQL fallo.
$ok = true;
if (file_exists($path . "/install.sql")) {
if (installSQL($path)) {
@rename($path . "/install.sql", $path . "/install.sql.bck");
$messages[] = "install.sql ejecutado";
} else {
$ok = false;
$messages[] = "install.sql ha fallado";
}
}
if (file_exists($path . "/schemaPresets")) {
installSchemas($path); // renombra schemaPresets -> schemaPresets.bck
$messages[] = "schemaPresets instalados";
}
return $ok;
}
function _includePluginHook($hookFile, $plugin, $pluginDir, $event, $context, $dropData) {
// Ambito propio: el gancho no ve las variables de quien lo llama, pero si
// los mismos globales del CMS que ve un checkpoint desde addPlugins().
global $CURRENT_USER, $menu, $action, $TABLE_PREFIX, $SETTINGS;
ob_start();
$ret = include $hookFile;
$salida = trim((string) ob_get_clean());
return array("ret" => $ret, "output" => $salida);
}
function _pluginCoreStatus($folder, $pluginDir) {
$schemas = getSchemaPlugins(true);
$enabled = !empty($schemas[$folder]["enabled"]);
$pendiente = file_exists($pluginDir . "/install.sql") || file_exists($pluginDir . "/schemaPresets");
$status = array(
array("clave" => "instalado", "estado" => "ok", "texto" => "Plugin instalado en la web"),
array("clave" => "activado", "estado" => $enabled ? "ok" : "warn",
"texto" => $enabled ? "Plugin activado" : "Plugin desactivado"),
array("clave" => "core_ganchos", "estado" => "ok", "texto" => "El core de esta web soporta ganchos de plugins"),
);
if ($pendiente) {
$status[] = array("clave" => "instalacion_pendiente", "estado" => "warn",
"texto" => "install.sql o schemaPresets pendientes de ejecutar");
}
return $status;
}
function runPluginLifecycle($folder, $event, $context = array()) {
$res = array("ok" => false, "event" => $event, "plugin" => $folder, "ran" => false,
"messages" => array(), "status" => array(), "error" => null);
if (!pluginFolderIsValid($folder)) { $res["error"] = "Nombre de plugin no valido"; return $res; }
if (!in_array($event, pluginLifecycleEvents(), true)) { $res["error"] = "Evento no valido"; return $res; }
$pluginDir = pluginDirResolved($folder);
if (!$pluginDir) { $res["error"] = "El plugin no existe en esta web"; return $res; }
if (!file_exists($pluginDir . "/schema.ini.php")) { $res["error"] = "El plugin no tiene schema.ini.php"; return $res; }
$dropData = !empty($context["dropData"]);
try {
if ($event === "install" && !installPluginArtifacts($pluginDir, $res["messages"])) {
$res["error"] = "install.sql ha fallado";
return $res;
}
if ($event === "status") $res["status"] = _pluginCoreStatus($folder, $pluginDir);
$hookOk = true;
$hookFile = $pluginDir . "/" . $event . ".php";
if (file_exists($hookFile)) {
$res["ran"] = true;
$hook = _includePluginHook($hookFile, $folder, $pluginDir, $event, $context, $dropData);
if ($hook["output"] !== "") $res["messages"][] = $hook["output"];
if (is_array($hook["ret"])) {
if (array_key_exists("ok", $hook["ret"]) && !$hook["ret"]["ok"]) $hookOk = false;
foreach ((array) @$hook["ret"]["messages"] as $m) $res["messages"][] = (string) $m;
foreach ((array) @$hook["ret"]["status"] as $st) if (is_array($st)) $res["status"][] = $st;
if (!empty($hook["ret"]["error"])) $res["error"] = (string) $hook["ret"]["error"];
}
}
$res["ok"] = $hookOk;
if (!$hookOk && !$res["error"]) $res["error"] = "El gancho " . $event . ".php ha devuelto ok=false";
} catch (Throwable $e) {
// El detalle completo (ruta, linea) va al log del servidor; al cliente,
// el mensaje sin rutas absolutas.
error_log("[pluginLifecycle] " . $folder . "/" . $event . ".php: " . $e->getMessage()
. " en " . $e->getFile() . ":" . $e->getLine());
$raiz = realpath($GLOBALS["APP"]["pluginsdir"] . "/../../..");
$res["ok"] = false;
$res["error"] = "Excepcion en " . $event . ".php: " . ($raiz ? str_replace($raiz, "", $e->getMessage()) : $e->getMessage());
}
return $res;
}
?>
+8
View File
@@ -1,5 +1,13 @@
<?
require_once "variables.php";
// Checkpoint `post_variables` (2026-09-12): variables.php ya ha corrido. Un plugin
// puede pisar aqui lo que decida la web (p.ej. la cache) antes de resolver la
// pagina. Una sola vez por peticion aunque se cargue desde varios sitios.
if (function_exists("addPlugins") && !defined("POST_VARIABLES_DISPARADO")) {
define("POST_VARIABLES_DISPARADO", true);
$postVariablesVar = array();
addPlugins("post_variables", $postVariablesVar);
}
require_once "FileJoiner.php";
$fileType = "css";
+8
View File
@@ -4,6 +4,14 @@ if (session_status() == PHP_SESSION_NONE) { session_start(); }
require_once(dirname(__FILE__)."/lib/Module.php");
require_once(dirname(__FILE__)."/lib/Resource.class.php");
require_once(dirname(__FILE__)."/lib/variables.php");
// Checkpoint `post_variables` (2026-09-12): variables.php ya ha corrido. Un plugin
// puede pisar aqui lo que decida la web (p.ej. la cache) antes de resolver la
// pagina. Una sola vez por peticion aunque se cargue desde varios sitios.
if (function_exists("addPlugins") && !defined("POST_VARIABLES_DISPARADO")) {
define("POST_VARIABLES_DISPARADO", true);
$postVariablesVar = array();
addPlugins("post_variables", $postVariablesVar);
}
require_once(dirname(__FILE__)."/lib/minifier.php");
if (file_exists(dirname(__FILE__)."/hooks/autorequire-files")){
+8
View File
@@ -5,6 +5,14 @@ if (@$sep[1]){
header("Location: http://".$_SERVER["HTTP_HOST"].$sep[0]."");
}
require_once "lib/variables.php";
// Checkpoint `post_variables` (2026-09-12): variables.php ya ha corrido. Un plugin
// puede pisar aqui lo que decida la web (p.ej. la cache) antes de resolver la
// pagina. Una sola vez por peticion aunque se cargue desde varios sitios.
if (function_exists("addPlugins") && !defined("POST_VARIABLES_DISPARADO")) {
define("POST_VARIABLES_DISPARADO", true);
$postVariablesVar = array();
addPlugins("post_variables", $postVariablesVar);
}
$sufijo = ".html";
if (@$_REQUEST["tipo"]=="barra") $sufijo="";