Author SHA1 Message Date
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
3 changed files with 59 additions and 12 deletions
+6 -1
View File
@@ -74,7 +74,12 @@ function createMissingSchemaTablesAndFields() {
$mysqlTableName = $TABLE_PREFIX . $tableName; $mysqlTableName = $TABLE_PREFIX . $tableName;
if (!in_array($mysqlTableName, $mysqlTables)) { if (!in_array($mysqlTableName, $mysqlTables)) {
notice("Creating MySQL table for schema table: $tableName<br/>\n"); 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;"); // InnoDB (antes MyISAM): las tablas nuevas nacen con recuperacion
// ante caida y bloqueo por fila. El sql_mode de Forge no es
// estricto, asi que los INSERT parciales del CMS (AcaiAPI no
// rellena dragSortOrder ni los checkboxes) siguen colando igual
// que con MyISAM. Ver docs/motor-innodb.md del repo de Forge.
$result = mysql_query("CREATE TABLE `".mysql_real_escape_string($mysqlTableName)."` (num int(10) unsigned NOT NULL auto_increment, PRIMARY KEY (num)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
if (!$result) { alert("Error creating MySQL table: $mysqlTableName<br/>\MySQL error was: ". htmlspecialchars(mysql_error()) . "\n"); } if (!$result) { alert("Error creating MySQL table: $mysqlTableName<br/>\MySQL error was: ". htmlspecialchars(mysql_error()) . "\n"); }
} }
+48 -9
View File
@@ -324,17 +324,24 @@ function _removeFile() {
} }
function _deleteAll($str) { function _deleteAll($str) {
if (!file_exists($str)) return false; if (!file_exists($str) && !is_link($str)) return false;
if (is_file($str)) { // 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); return unlink($str);
} }
elseif (is_dir($str)) { elseif (is_dir($str)) {
//Get a list of the files in this directory. // `scandir` y no `glob`: glob NO devuelve los ficheros ocultos, asi que
$scan = glob(rtrim($str,'/').'/*'); // un `.htaccess` sobrevivia al borrado, la carpeta no quedaba vacia y el
//Loop through the list of files. // rmdir de abajo fallaba en silencio. Con eso, desinstalar en produccion
foreach($scan as $index=>$path) { // un plugin con .htaccess —5 de 14 en una web real— borraba su codigo,
//Call our recursive function. // devolvia "no se ha podido desinstalar" y dejaba la operacion a medias:
_deleteAll($path); // 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. //Remove the directory itself.
return @rmdir($str); return @rmdir($str);
@@ -392,13 +399,45 @@ function _renameFile() {
} }
/**
* 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() { function _pluginLifecycle() {
// Fase 0 (2026-09-12): ejecuta los ganchos de ciclo de vida de un plugin // 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 // (install/uninstall/enable/disable/status) DENTRO de la web. Unico punto
// de entrada para Forge y para el CMS legacy. Misma autenticacion que coreUpdate. // de entrada para Forge y para el CMS legacy. Misma autenticacion que coreUpdate.
$datosAuth = json_decode(file_get_contents('php://input'), true); $datosAuth = json_decode(file_get_contents('php://input'), true);
CocoWS::setToken(@$datosAuth["token"]); CocoWS::setToken(@$datosAuth["token"]);
if (!CocoWS::validateUploadToken(@$datosAuth["tokenHash"])) { if (!CocoWS::validateUploadToken(@$datosAuth["tokenHash"])
&& !_lifecycleTokenLocalValido($datosAuth)) {
die(json_encode(['error' => ['message' => 'Token no válido', 'code' => 403]])); 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. // Solo body JSON (como _saveFile): el token no debe viajar en la query string.
+5 -2
View File
@@ -16,7 +16,10 @@ function getSchemaPlugins($force = false){
} }
$customSchemaFile = $fullPath."/custom-schema.ini.php"; $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); $customSchema = loadINI($customSchemaFile);
$exclude = array("version", "name", "description"); $exclude = array("version", "name", "description");
foreach ($exclude as $e): foreach ($exclude as $e):
@@ -207,7 +210,7 @@ function addTablePlugin($path,$file) {
$result = mysql_query("CREATE TABLE `".mysql_real_escape_string($tableNameWithPrefix)."` ( $result = mysql_query("CREATE TABLE `".mysql_real_escape_string($tableNameWithPrefix)."` (
num int(10) unsigned NOT NULL auto_increment, num int(10) unsigned NOT NULL auto_increment,
PRIMARY KEY (num) PRIMARY KEY (num)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;"); ) ENGINE=InnoDB DEFAULT CHARSET=utf8;");
if (!$result) { if (!$result) {
print "Error creating MySQL table.\n\nMySQL error was: ". htmlspecialchars(mysql_error()) . "\n"; print "Error creating MySQL table.\n\nMySQL error was: ". htmlspecialchars(mysql_error()) . "\n";
exit; exit;