23 Commits
Author SHA1 Message Date
superadmin f0060f3eb8 Actualizar local desde main 2026-10-02 16:55:46 +00:00
Jordan DiazandClaude Opus 5.5 80b6541e30 fix: crear uploads/webp sin carrera en funciones.php
Varias peticiones a la vez (el prerender de Astro las lanza en paralelo) veian
la carpeta sin crear y la segunda imprimia "mkdir(): File exists" delante del
JSON de action_ws, rompiendo el build de una web recien descargada.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:16:28 +00:00
Acai ForgeandClaude Opus 5.5 775ae1c95d Merge feat/uploads-columnas-miniaturas: fotos de registros con dimensiones y miniaturas
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:50:36 +00:00
Acai ForgeandClaude Opus 5.5 49522987dd feat(core): las fotos de los registros traen dimensiones y miniaturas
AcaiAPI::batchLoadUploads (camino de cmsApi get con uploads:true, el que usan
los listados y fichas de las webs Astro) solo devolvia urlPath e info*: sin
width/height el front no reserva el hueco (CLS) y sin thumbUrlPath..4 no puede
montar un srcset. Ahora selecciona tambien width, height y URL+dimensiones de
los 4 huecos de miniatura (no las rutas de disco), y useAbsoluteUrls reescribe
tambien las URLs de las miniaturas.

Solo pide las columnas que existen en {prefix}uploads (SHOW COLUMNS una vez por
prefijo y peticion): una web antigua sin alguna no se queda sin fotos.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:28:21 +00:00
Jordan Diaz 15c3db882d feat(core): cambiar las fotos de un registro sella su updatedDate 2026-09-26 13:34:07 +00:00
Jordan Diaz 17f5d91c8d feat(core): el enlace es unico entre todas las tablas (AcaiAPI::uniqueEnlace) y slug.php registra duplicados 2026-09-24 09:34:22 +00:00
Jordan Diaz 7097b79111 fix(core): las migas no revientan con un registro sin su campo de migas (ficha en blanco) 2026-09-24 07:35:23 +00:00
Jordan Diaz 4e46322b02 feat(core): regla unica de valores por defecto en los INSERT (AcaiAPI::insertDefaults) 2026-09-24 07:21:53 +00:00
Jordan Diaz 184038b786 fix(core): una fecha ausente solo se rellena si la columna es NOT NULL sin DEFAULT 2026-09-24 00:17:04 +00:00
Jordan Diaz 14ddf1608e fix(core): altas rellenan fechas que faltan y el error de insert/update trae el mensaje de MySQL 2026-09-23 07:59:03 +00:00
Jordan DiazandClaude Opus 5.5 8659715132 Merge motor-segun-version: el motor de las tablas nuevas depende del servidor
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 17:44:27 +00:00
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
10 changed files with 857 additions and 31 deletions
+494 -11
View File
@@ -15,6 +15,7 @@ class AcaiAPI
private static $schemaCache = [];
private static $queryCaches = []; // list/query relation results
private static $requestGetCache = []; // unconditional per-request deduplication
private static $uploadsColumnsCache = []; // prefix => [columna => true] de {prefix}uploads
// ── Public caches (mirror CocoDB interface) ───────────────────────
public static $getCaches = []; // in-memory result cache for get()
@@ -87,9 +88,14 @@ class AcaiAPI
$lastId = 0;
$count = 0;
// Columnas que MySQL exige en un INSERT (NOT NULL sin DEFAULT): solo a
// esas se les pone valor si no vienen. null = no se pudo saber.
$requiredCols = $ignoreSchema ? [] : self::requiredColumns($db, $prefix . $table);
foreach ($records as $record) {
$record = self::uniqueEnlace($db, $prefix . $table, $record, [], $options);
list($setParts, $params, $uploadFields) = self::buildSetClause(
$record, $schema, $functions, $ignoreSchema, true, $forceNum
$record, $schema, $functions, $ignoreSchema, true, $forceNum, $requiredCols
);
if (empty($setParts)) continue;
@@ -102,7 +108,7 @@ class AcaiAPI
$result = $db->execute($sql, $params ? $params : null);
if ($result === false) {
self::error(['error' => "Error al insertar en `$table`"]);
self::error(['error' => "Error al insertar en `$table`" . self::dbErrorSuffix($db)]);
continue;
}
@@ -124,7 +130,7 @@ class AcaiAPI
'order' => time() + $i,
'width' => 640,
'height' => 480,
], [], ['ignoreSchema' => true, 'prefix' => $prefix]);
], [], ['ignoreSchema' => true, 'prefix' => $prefix, 'skipTouchParent' => true]);
}
}
}
@@ -149,6 +155,15 @@ class AcaiAPI
self::updateCategoryMetadata($table, $prefix);
}
// Fotos nuevas = registro cambiado (ver "FOTOS -> FECHA DEL REGISTRO").
// skipTouchParent: las que registra el propio insert/update del padre,
// que ya sello su fecha en el mismo SET.
if ($table === 'uploads' && $count && empty($options['skipTouchParent'])) {
$pairs = [];
foreach ($records as $r) $pairs[] = [$r['tableName'] ?? '', $r['recordNum'] ?? ''];
self::touchUploadParents($pairs, $prefix);
}
return !empty($options['return_last_id']) ? $lastId : $count;
}
@@ -193,10 +208,31 @@ class AcaiAPI
if ($whereSql === null || $whereSql === '') return 0;
// Fotos cambiadas = registro cambiado: los padres de ANTES (y abajo los
// de despues, por si la foto cambia de registro).
$touchParents = ($table === 'uploads' && empty($options['skipTouchParent']));
$parents = $touchParents ? self::uploadParents($where, $prefix) : [];
$count = 0;
foreach ($records as $record) {
$lastNum = isset($record['num']) ? $record['num'] : null;
if (isset($record['enlace'])) {
// Solo si el enlace CAMBIA: el formulario reenvia todos los
// campos al guardar, y un registro que ya compartia enlace
// (duplicado anterior a esta regla) no debe quedar bloqueado
// por editar otra cosa.
$targetNums = [];
$sinCambio = true;
foreach ((array) $db->executeS("SELECT num, enlace FROM `{$prefix}{$table}` WHERE " . $whereSql, $whereParams ?: null) as $row) {
if (!isset($row['num'])) continue;
$targetNums[] = (string) $row['num'];
if ((string) $row['enlace'] !== trim((string) $record['enlace'])) $sinCambio = false;
}
if ($targetNums && !$sinCambio) {
$record = self::uniqueEnlace($db, $prefix . $table, $record, $targetNums, $options);
}
}
list($setParts, $setParams, $uploadFields) = self::buildSetClause(
$record, $schema, $functions, $ignoreSchema, false, false
@@ -214,7 +250,7 @@ class AcaiAPI
$result = $db->execute($sql, $allParams ? $allParams : null);
if ($result === false) {
self::error(['error' => "Error al actualizar `$table`"]);
self::error(['error' => "Error al actualizar `$table`" . self::dbErrorSuffix($db)]);
continue;
}
$count++;
@@ -252,7 +288,7 @@ class AcaiAPI
'order' => time() + $i,
'width' => 640,
'height' => 480,
], [], ['ignoreSchema' => true, 'prefix' => $prefix]);
], [], ['ignoreSchema' => true, 'prefix' => $prefix, 'skipTouchParent' => true]);
}
}
}
@@ -262,6 +298,10 @@ class AcaiAPI
self::updateCategoryMetadata($table, $prefix);
}
if ($touchParents && $count) {
self::touchUploadParents(array_merge($parents, self::uploadParents($where, $prefix)), $prefix);
}
return $count;
}
@@ -292,8 +332,14 @@ class AcaiAPI
self::error(['info' => $sql, 'params' => $params]);
}
// Fotos borradas = registro cambiado: hay que saber de quien eran ANTES.
$parents = ($table === 'uploads' && empty($options['skipTouchParent']))
? self::uploadParents($where, $prefix) : [];
$db = Db::getInstance();
return $db->execute($sql, $params ? $params : null) !== false;
$ok = $db->execute($sql, $params ? $params : null) !== false;
if ($ok && $parents) self::touchUploadParents($parents, $prefix);
return $ok;
}
@@ -810,7 +856,15 @@ class AcaiAPI
$db = Db::getInstance();
$ph = implode(',', array_fill(0, count($nums), '?'));
$fieldPh = implode(',', array_fill(0, count($uploadFields), '?'));
// width/height y las miniaturas (URL + dimensiones de los 4 huecos) van
// con cada foto: sin ellas el front no puede reservar el hueco (CLS) ni
// montar un srcset. Las rutas de disco (thumbFilePath*) no salen. Solo
// las que existan en la tabla: una web antigua sin alguna no se queda
// sin fotos por un SELECT roto.
$extra = self::existingUploadsColumns(
array_merge(['width', 'height'], self::uploadThumbColumns()), $prefix);
$selectCols = 'num, `order`, tableName, fieldName, recordNum, filePath, urlPath, '
. ($extra ? '`' . implode('`, `', $extra) . '`, ' : '')
. 'info1, info2, info3, info4, info5, alt';
$params = array_merge(array_values($nums), [$tableName], array_values($uploadFields));
@@ -831,10 +885,12 @@ class AcaiAPI
// Re-index for O(1) lookup
$index = [];
foreach ($uploads as $upload) {
if ($options['useAbsoluteUrls'] && isset($upload['urlPath'])
&& strpos($upload['urlPath'], '/cms') === 0
) {
$upload['urlPath'] = 'https://' . $_SERVER['HTTP_HOST'] . $upload['urlPath'];
if ($options['useAbsoluteUrls']) {
foreach (array_merge(['urlPath'], self::uploadThumbUrlColumns()) as $col) {
if (isset($upload[$col]) && strpos($upload[$col], '/cms') === 0) {
$upload[$col] = 'https://' . $_SERVER['HTTP_HOST'] . $upload[$col];
}
}
}
$field = $upload['fieldName'];
$recNum = $upload['recordNum'];
@@ -843,6 +899,47 @@ class AcaiAPI
return $index;
}
/** Sufijos de los 4 huecos de miniatura de `uploads` ('' = el primero). */
const UPLOAD_THUMB_SLOTS = ['', '2', '3', '4'];
/** Columnas de URL de las miniaturas: thumbUrlPath, thumbUrlPath2..4. */
private static function uploadThumbUrlColumns()
{
return array_map(function ($n) { return "thumbUrlPath$n"; }, self::UPLOAD_THUMB_SLOTS);
}
/** URL + ancho + alto de cada hueco de miniatura (sin rutas de disco). */
private static function uploadThumbColumns()
{
$cols = [];
foreach (self::UPLOAD_THUMB_SLOTS as $n) {
array_push($cols, "thumbUrlPath$n", "thumbWidth$n", "thumbHeight$n");
}
return $cols;
}
/**
* De `$wanted`, las columnas que existen en `{$prefix}uploads` (en su
* orden). Un SHOW COLUMNS por prefijo y petición; si falla, ninguna: el
* SELECT vuelve a las columnas de siempre en vez de romperse.
*/
private static function existingUploadsColumns(array $wanted, $prefix)
{
if (!isset(self::$uploadsColumnsCache[$prefix])) {
$existing = [];
try {
foreach ((array) Db::getInstance()->executeS("SHOW COLUMNS FROM `{$prefix}uploads`") as $col) {
if (isset($col['Field'])) $existing[$col['Field']] = true;
}
} catch (Exception $e) {
error_log('[AcaiAPI] SHOW COLUMNS de ' . $prefix . 'uploads fallido: ' . $e->getMessage());
}
self::$uploadsColumnsCache[$prefix] = $existing;
}
$cols = self::$uploadsColumnsCache[$prefix];
return array_values(array_filter($wanted, function ($c) use ($cols) { return isset($cols[$c]); }));
}
/**
* Injects upload arrays from the pre-built index into a single record row.
* Each upload field becomes an array of upload objects (same as CocoDB).
@@ -1322,6 +1419,126 @@ class AcaiAPI
}
// ═══════════════════════════════════════════════════════════════════
// FOTOS -> FECHA DEL REGISTRO
// ═══════════════════════════════════════════════════════════════════
//
// Cambiar las fotos de un registro (filas de `uploads` con
// tableName/recordNum) NO tocaba su `updatedDate`: el sync de Forge decide
// por esa fecha que esta pendiente, y un registro al que solo se le
// cambiaban las fotos (desde VSCode, el agente o el panel de fotos) nunca
// se publicaba (reydimobel, 2026-09-26: siete categorias con las fotos
// sustituidas en local y produccion enseñando las antiguas, en 404).
// Regla: toda escritura en `uploads` sella la fecha de los registros a los
// que pertenecen esas fotos. insert/update/delete de AcaiAPI lo hacen
// solos; quien escriba `uploads` por SQL directo llama a
// `touchUploadParents` (builder_saas/mcp_respond.php).
/** Cache por peticion: tabla completa => [columna => true] | null. */
private static $auditColsCache = [];
/**
* Los registros `[tableName, recordNum]` (sin repetir) a los que pertenecen
* las fotos de `uploads` que cumplen `$where` (mismos formatos que
* buildWhere). Las fotos sin registro todavia (recordNum vacio, las
* temporales de preSaveTempId) no cuentan. Nunca lanza: [] si falla.
*/
static function uploadParents($where, $prefix = null)
{
global $TABLE_PREFIX;
$prefix = $prefix === null ? $TABLE_PREFIX : $prefix;
$params = [];
$whereSql = self::buildWhere($where, $params);
if (!$whereSql) return [];
try {
$rows = Db::getInstance()->executeS(
"SELECT DISTINCT tableName, recordNum FROM `{$prefix}uploads` WHERE {$whereSql}",
$params ?: null
);
} catch (\Throwable $e) {
return [];
}
$pairs = [];
foreach ((array) $rows as $row) {
if (is_array($row)) $pairs[] = [$row['tableName'] ?? '', $row['recordNum'] ?? ''];
}
return $pairs;
}
/**
* Sella `updatedDate` (y `updatedByUserNum` si existe) de los registros
* `[[tableName, recordNum], ...]`. Salta lo que no es un registro de
* verdad: tabla con nombre raro, recordNum no numerico o 0, tabla sin
* columna `updatedDate`. Nunca lanza ni corta al llamador: un fallo aqui
* no puede impedir guardar una foto. Devuelve cuantos registros sello.
*/
static function touchUploadParents($pairs, $prefix = null)
{
global $TABLE_PREFIX;
$prefix = $prefix === null ? $TABLE_PREFIX : $prefix;
$porTabla = [];
foreach ((array) $pairs as $pair) {
$tabla = is_array($pair) ? (string) ($pair[0] ?? '') : '';
$num = is_array($pair) ? (string) ($pair[1] ?? '') : '';
if (!preg_match('/^[A-Za-z0-9_]+$/', $tabla) || !ctype_digit($num) || (int) $num === 0) continue;
$porTabla[$tabla][(int) $num] = true;
}
$sellados = 0;
foreach ($porTabla as $tabla => $nums) {
$sellados += self::touchRecords($prefix . $tabla, array_keys($nums));
}
if ($sellados) self::$requestGetCache = [];
return $sellados;
}
private static function touchRecords($fullTable, array $nums)
{
try {
$db = Db::getInstance();
$cols = self::auditColumns($db, $fullTable);
if (!$cols || !isset($cols['updatedDate'])) return 0;
$set = ['`updatedDate` = ?'];
$params = [date('Y-m-d H:i:s')];
if (isset($cols['updatedByUserNum'])) {
$user = (class_exists('API') && isset(API::$user['num'])) ? ((int) API::$user['num'] ?: 1) : 1;
$set[] = '`updatedByUserNum` = ?';
$params[] = $user;
}
$ph = implode(',', array_fill(0, count($nums), '?'));
$ok = $db->execute(
"UPDATE `{$fullTable}` SET " . implode(', ', $set) . " WHERE num IN ($ph)",
array_merge($params, $nums)
);
return $ok === false ? 0 : count($nums);
} catch (\Throwable $e) {
return 0;
}
}
/** `[columna => true]` de las columnas de auditoria de `$fullTable`, o null. */
private static function auditColumns($db, $fullTable)
{
if (array_key_exists($fullTable, self::$auditColsCache)) {
return self::$auditColsCache[$fullTable];
}
try {
$rows = $db->executeS(
"SELECT COLUMN_NAME FROM information_schema.COLUMNS"
. " WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?"
. " AND COLUMN_NAME IN ('updatedDate', 'updatedByUserNum')",
[$fullTable]
);
} catch (\Throwable $e) {
return self::$auditColsCache[$fullTable] = null;
}
$cols = [];
foreach ((array) $rows as $row) {
if (is_array($row) && !empty($row['COLUMN_NAME'])) $cols[$row['COLUMN_NAME']] = true;
}
return self::$auditColsCache[$fullTable] = $cols;
}
// ═══════════════════════════════════════════════════════════════════
// WRITE HELPERS (private)
// ═══════════════════════════════════════════════════════════════════
@@ -1366,7 +1583,8 @@ class AcaiAPI
array $functions,
$ignoreSchema,
$isInsert,
$forceNum
$forceNum,
$requiredCols = null
) {
$now = date('Y-m-d H:i:s');
$user = 1;
@@ -1438,6 +1656,7 @@ class AcaiAPI
$setParts[] = '`dragSortOrder` = ?';
$params[] = time();
}
}
// ── User-supplied fields ─────────────────────────────────────────
@@ -1477,9 +1696,14 @@ class AcaiAPI
// - INSERT: mismo comportamiento que el Admin -> fecha actual.
// - UPDATE: se omite la columna, para no pisar con "hoy" un valor
// ya guardado solo porque el caller mando la clave vacia.
// - Pero SOLO si la columna la exige (NOT NULL sin DEFAULT). En una
// que admite NULL, vacio significa "sin fecha": se omite y queda
// NULL (antes quedaba "ahora": una nota con deleted_at vacio
// nacia borrada). Si no se sabe ($requiredCols null), como antes.
if ($schemaField && isset($schemaField['type']) && $schemaField['type'] === 'date'
&& ($value === null || (is_string($value) && trim($value) === ''))) {
if (!$isInsert) continue;
if (is_array($requiredCols) && !isset($requiredCols[$key])) continue;
$value = $now;
}
@@ -1490,9 +1714,111 @@ class AcaiAPI
$params[] = $value;
}
// ── Valores por defecto del INSERT (regla UNICA) ──────────────────
if ($isInsert && !$ignoreSchema && $schema) {
list($defParts, $defParams) = self::insertDefaults(
$setParts, $schema, $requiredCols, $now);
$setParts = array_merge($setParts, $defParts);
$params = array_merge($params, $defParams);
}
return [$setParts, $params, $uploadFields];
}
/**
* Valores de las columnas que un INSERT no trae. UNICA fuente de la regla
* (antes repartida entre el Admin clasico, el server Python y aqui):
*
* 1. Default EXPLICITO del schema, siempre que el campo falte: checkbox
* marcado por defecto -> checkedValue; defaultContent (textbox,
* wysiwyg, codigo) o defaultValue. Nunca se evalua como PHP: un
* default con `<?` se ignora.
* 2. Sin default explicito, SOLO si la columna es NOT NULL sin DEFAULT
* (MySQL rechazaria el INSERT): checkbox -> uncheckedValue; si no, por
* tipo de columna (fecha -> ahora, numero -> 0, enum -> primer valor,
* resto -> ''), como el Admin clasico. Incluye columnas obligatorias
* que el schema no declara (p.ej. flags de `accounts`).
* 3. Nada mas: una columna que admite NULL o tiene DEFAULT se deja a MySQL
* (vacio puede significar algo: deleted_at = no borrada).
*
* $requiredCols null = no se pudo consultar la tabla: solo se aplica (1).
*/
private static function insertDefaults(array $setParts, array $schema, $requiredCols, $now)
{
$present = [];
foreach ($setParts as $part) {
if (preg_match('/^`([^`]+)`/', $part, $m)) $present[$m[1]] = true;
}
$parts = [];
$params = [];
$add = function ($col, $val) use (&$parts, &$params, &$present) {
$parts[] = "`$col` = ?";
$params[] = $val;
$present[$col] = true;
};
foreach ($schema as $field => $def) {
if (!is_array($def) || isset($present[$field])) continue;
$type = isset($def['type']) ? $def['type'] : '';
if (in_array($type, ['upload', 'separator', 'none'], true)) continue;
if (!empty($def['isSystemField'])) continue;
$explicit = self::schemaDefault($def);
if ($explicit !== null) {
$add($field, $explicit);
} elseif (is_array($requiredCols) && isset($requiredCols[$field])) {
$add($field, $type === 'checkbox'
? (string) (isset($def['uncheckedValue']) && $def['uncheckedValue'] !== '' ? $def['uncheckedValue'] : '0')
: self::columnFallback($requiredCols[$field], $now));
}
}
if (is_array($requiredCols)) {
foreach ($requiredCols as $col => $meta) {
if (isset($present[$col]) || $col === 'num') continue;
$add($col, self::columnFallback($meta, $now));
}
}
return [$parts, $params];
}
/** Default explicito del schema para un campo, o null si no declara uno. */
private static function schemaDefault(array $def)
{
$type = isset($def['type']) ? $def['type'] : '';
if ($type === 'checkbox') {
$checked = isset($def['checkedByDefault']) ? $def['checkedByDefault'] : 0;
if (!in_array($checked, [1, '1', true], true)) return null;
return (string) (isset($def['checkedValue']) && $def['checkedValue'] !== '' ? $def['checkedValue'] : '1');
}
$keys = in_array($type, ['textbox', 'wysiwyg', 'codigo'], true)
? ['defaultContent', 'defaultValue'] : ['defaultValue'];
foreach ($keys as $k) {
if (!isset($def[$k]) || !is_scalar($def[$k])) continue;
$v = (string) $def[$k];
if ($v === '' || strpos($v, '<?') !== false) continue;
return $v;
}
return null;
}
/** Valor por tipo de columna para una columna NOT NULL sin DEFAULT. */
private static function columnFallback(array $meta, $now)
{
$t = isset($meta['data_type']) ? $meta['data_type'] : '';
if (in_array($t, ['datetime', 'timestamp'], true)) return $now;
if ($t === 'date') return substr($now, 0, 10);
if ($t === 'time') return '00:00:00';
if ($t === 'year') return substr($now, 0, 4);
if (in_array($t, ['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint',
'decimal', 'numeric', 'float', 'double', 'real', 'bit'], true)) return 0;
if ($t === 'enum' && preg_match("/^enum\('((?:[^'\\\\]|\\\\.|'')*)'/i",
isset($meta['column_type']) ? $meta['column_type'] : '', $m)) {
return str_replace("''", "'", $m[1]);
}
return '';
}
/**
* Coerces a value to its correct database representation
* based on the schema field definition.
@@ -1640,6 +1966,163 @@ class AcaiAPI
// ERROR HANDLING
// ═══════════════════════════════════════════════════════════════════
/**
* ": <mensaje de MySQL>" del ultimo execute() fallido, o "" si no lo hay.
* Sin el, un alta que MySQL rechaza (p.ej. "Field 'x' doesn't have a
* default value") llegaba como "Error al insertar" a secas y el agente
* reintentaba a ciegas. isset(): una web con Db.php personalizado (sin
* lastError) sigue funcionando, solo que sin el detalle.
*/
/** Cache por peticion: tablas de la BD con columna `enlace` (null = sin consultar). */
private static $enlaceTablesCache = null;
/** Tablas de la BD actual con columna `enlace`, o null si no se pudo saber. */
private static function enlaceTables($db)
{
if (self::$enlaceTablesCache !== null) return self::$enlaceTablesCache ?: null;
try {
$rows = $db->executeS(
"SELECT TABLE_NAME FROM information_schema.COLUMNS"
. " WHERE TABLE_SCHEMA = DATABASE() AND COLUMN_NAME = 'enlace'"
);
} catch (\Throwable $e) {
self::$enlaceTablesCache = false;
return null;
}
$tables = [];
foreach ((array) $rows as $row) {
if (is_array($row) && !empty($row['TABLE_NAME'])) $tables[] = $row['TABLE_NAME'];
}
self::$enlaceTablesCache = $tables;
return $tables;
}
/**
* Quien usa ya `$enlace` en CUALQUIER tabla con columna enlace, sin contar
* `$fullTable` + `$excludeNums`. Devuelve [tabla, num] o null.
*/
private static function enlaceOwner($db, $enlace, $fullTable, array $excludeNums)
{
$tables = self::enlaceTables($db);
if (!$tables) return null;
$parts = [];
$params = [];
foreach ($tables as $t) {
$parts[] = "SELECT ? AS t, num FROM `$t` WHERE enlace = ?";
$params[] = $t;
$params[] = $enlace;
}
try {
$rows = $db->executeS(implode(" UNION ALL ", $parts), $params);
} catch (\Throwable $e) {
return null;
}
foreach ((array) $rows as $row) {
if ($row['t'] === $fullTable && in_array((string) $row['num'], $excludeNums, true)) continue;
return [$row['t'], $row['num']];
}
return null;
}
/**
* `enlace` UNICO entre todas las tablas: slug.php resuelve una URL
* buscandola en todas las tablas con columna enlace, asi que dos registros
* con el mismo enlace dejan uno inaccesible. Si choca:
* - options['enlaceOnConflict'] === 'error' -> error claro (enlace escrito
* a mano: no se le cambia al usuario una URL que ha elegido).
* - si no (por defecto) -> sufijo -2, -3... hasta que quede libre, para
* no romper importadores, hooks ni formularios.
* Se ignoran vacio, '#' y URLs externas. Devuelve el record (copia).
*/
private static function uniqueEnlace($db, $fullTable, array $record, array $excludeNums, array $options)
{
if (!isset($record['enlace']) || !is_string($record['enlace'])) return $record;
$enlace = trim($record['enlace']);
if ($enlace === '' || $enlace === '#' || preg_match('#^(https?:)?//#i', $enlace)) return $record;
$tables = self::enlaceTables($db);
if (!$tables || !in_array($fullTable, $tables, true)) return $record;
if (count($excludeNums) > 1) {
self::error(['error' => [
'message' => "No se puede poner el mismo enlace '$enlace' a varios registros a la vez",
'code' => 'ENLACE_DUPLICADO',
]]);
return $record;
}
$owner = self::enlaceOwner($db, $enlace, $fullTable, $excludeNums);
if (!$owner) return $record;
$prefix = defined('TABLE_PREFIX') ? TABLE_PREFIX : (isset($GLOBALS['TABLE_PREFIX']) ? $GLOBALS['TABLE_PREFIX'] : 'cms_');
$ownerName = strpos($owner[0], $prefix) === 0 ? substr($owner[0], strlen($prefix)) : $owner[0];
// La home ('/') no admite sufijo: un segundo '/' es siempre un error.
if (rtrim($enlace, '/') === '' || (isset($options['enlaceOnConflict']) && $options['enlaceOnConflict'] === 'error')) {
self::error(['error' => [
'message' => "El enlace '$enlace' ya lo usa $ownerName #{$owner[1]}: elige otro",
'code' => 'ENLACE_DUPLICADO',
]]);
return $record;
}
$slash = substr($enlace, -1) === '/' ? '/' : '';
$base = rtrim($enlace, '/');
for ($i = 2; $i <= 200; $i++) {
$candidate = $base . '-' . $i . $slash;
if (!self::enlaceOwner($db, $candidate, $fullTable, $excludeNums)) {
$record['enlace'] = $candidate;
return $record;
}
}
self::error(['error' => [
'message' => "No se encontro un enlace libre para '$enlace'",
'code' => 'ENLACE_DUPLICADO',
]]);
return $record;
}
/** Cache por peticion: tabla completa => [columna => true]. */
private static $requiredColsCache = [];
/**
* Columnas de `$fullTable` que un INSERT tiene que traer: NOT NULL, sin
* DEFAULT y no auto_increment. Devuelve [columna => [data_type, column_type]], o null si no se
* pudo consultar (el llamador no rellena nada en ese caso). Solo usa
* Db::executeS, que existe en todas las versiones de Db.php.
*/
private static function requiredColumns($db, $fullTable)
{
if (array_key_exists($fullTable, self::$requiredColsCache)) {
return self::$requiredColsCache[$fullTable];
}
try {
$rows = $db->executeS(
"SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE FROM information_schema.COLUMNS"
. " WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?"
. " AND IS_NULLABLE = 'NO' AND COLUMN_DEFAULT IS NULL"
. " AND EXTRA NOT LIKE '%auto_increment%'",
[$fullTable]
);
} catch (\Throwable $e) {
return self::$requiredColsCache[$fullTable] = null;
}
$cols = [];
foreach ((array) $rows as $row) {
if (!is_array($row) || empty($row['COLUMN_NAME'])) continue;
$cols[$row['COLUMN_NAME']] = [
'data_type' => strtolower((string) ($row['DATA_TYPE'] ?? '')),
'column_type' => (string) ($row['COLUMN_TYPE'] ?? ''),
];
}
return self::$requiredColsCache[$fullTable] = $cols;
}
private static function dbErrorSuffix($db)
{
return (is_object($db) && isset($db->lastError) && $db->lastError !== '')
? ': ' . $db->lastError
: '';
}
static function error(array $data = [])
{
if (class_exists('API') && class_exists('ApiError')) {
+6
View File
@@ -8,6 +8,10 @@ class Db {
private static $instance = false; // Db Class Instance
/** Mensaje del ultimo execute() fallido (null si fue bien). AcaiAPI lo
* devuelve en su error para que el caller sepa POR QUE fallo. */
public $lastError = null;
/**
* @param host: DB Host
* @param user: DB user
@@ -69,12 +73,14 @@ class Db {
* @return bool
*/
public function execute($sql, $params = null) {
$this->lastError = null;
if ($params) {
$stmt = $this->link->prepare($sql);
try {
$stmt->execute($params);
}
catch(Exception $e) {
$this->lastError = $e->getMessage();
return false;
}
}
+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;
}
?>
+11 -2
View File
@@ -7,8 +7,11 @@ $modulosCargados = array();
$recursosCSS = array();
// HASTA AUQI
if (!file_exists(__DIR__.'/'.CMS_FOLDER.'/uploads/webp/')) {
mkdir(__DIR__.'/'.CMS_FOLDER.'/uploads/webp/');
// Sin carrera: varias peticiones a la vez (el prerender de Astro las lanza en
// paralelo) veian la carpeta sin crear y la segunda imprimia "mkdir(): File
// exists" delante del JSON de action_ws, rompiendo el build de una web nueva.
if (!is_dir(__DIR__.'/'.CMS_FOLDER.'/uploads/webp/')) {
@mkdir(__DIR__.'/'.CMS_FOLDER.'/uploads/webp/', 0775, true);
}
global $configuracionTienda;
$configuracionRecord = CocoDB::get("configuracion", "", null, 1);
@@ -259,6 +262,12 @@ function muestra_breadcrumb($record = array(), $previousLinks = array(),$class =
}
if (!@$breadcrumbField || !@$breadcrumbRecord["optionsTablename"]) break;
// Registro sin valor en su campo de migas (p.ej. producto sin
// categoria): la consulta quedaba "num=" -> error SQL -> excepcion que
// tumbaba el modulo entero y la ficha salia en blanco. Las migas se
// quedan en este nivel.
$valorMigas = @$breadcrumbRecord[$breadcrumbField];
if ($valorMigas === null || $valorMigas === '' || is_array($valorMigas)) break;
$breadcrumbRecord = dame_registros($breadcrumbRecord["optionsTablename"], $breadcrumbRecord["optionsValueField"]."=".mysql_real_escape_string($breadcrumbRecord[$breadcrumbField]));
$breadcrumbRecord = @$breadcrumbRecord[0];
+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")){
+15
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="";
@@ -292,6 +300,13 @@ if (!$routeResolved){
$encontrado = mysql_fetch_assoc(mysql_query("SELECT * FROM ".$tableName." WHERE ".$columnName."='".$enlaceEscaped."' LIMIT 1"));
// Enlace repetido entre tablas: se mantiene el comportamiento de
// siempre (gana la ULTIMA tabla recorrida; hay webs que dependen de
// ello, p.ej. apartados + servicios con el mismo enlace) pero ya no en
// silencio. Los nuevos duplicados los impide AcaiAPI::uniqueEnlace.
if (@$encontrado && @$tabla){
error_log("[slug] enlace duplicado '".$enlace."': ".$tabla."#".$registro." y ".$tableName."#".@$encontrado["num"]." (se sirve el segundo)");
}
if (@$encontrado){
$tabla = $tableName;
$registro = $encontrado["num"];