Author SHA1 Message Date
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
6 changed files with 560 additions and 18 deletions
+494 -11
View File
@@ -15,6 +15,7 @@ class AcaiAPI
private static $schemaCache = []; private static $schemaCache = [];
private static $queryCaches = []; // list/query relation results private static $queryCaches = []; // list/query relation results
private static $requestGetCache = []; // unconditional per-request deduplication private static $requestGetCache = []; // unconditional per-request deduplication
private static $uploadsColumnsCache = []; // prefix => [columna => true] de {prefix}uploads
// ── Public caches (mirror CocoDB interface) ─────────────────────── // ── Public caches (mirror CocoDB interface) ───────────────────────
public static $getCaches = []; // in-memory result cache for get() public static $getCaches = []; // in-memory result cache for get()
@@ -87,9 +88,14 @@ class AcaiAPI
$lastId = 0; $lastId = 0;
$count = 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) { foreach ($records as $record) {
$record = self::uniqueEnlace($db, $prefix . $table, $record, [], $options);
list($setParts, $params, $uploadFields) = self::buildSetClause( list($setParts, $params, $uploadFields) = self::buildSetClause(
$record, $schema, $functions, $ignoreSchema, true, $forceNum $record, $schema, $functions, $ignoreSchema, true, $forceNum, $requiredCols
); );
if (empty($setParts)) continue; if (empty($setParts)) continue;
@@ -102,7 +108,7 @@ class AcaiAPI
$result = $db->execute($sql, $params ? $params : null); $result = $db->execute($sql, $params ? $params : null);
if ($result === false) { if ($result === false) {
self::error(['error' => "Error al insertar en `$table`"]); self::error(['error' => "Error al insertar en `$table`" . self::dbErrorSuffix($db)]);
continue; continue;
} }
@@ -124,7 +130,7 @@ class AcaiAPI
'order' => time() + $i, 'order' => time() + $i,
'width' => 640, 'width' => 640,
'height' => 480, 'height' => 480,
], [], ['ignoreSchema' => true, 'prefix' => $prefix]); ], [], ['ignoreSchema' => true, 'prefix' => $prefix, 'skipTouchParent' => true]);
} }
} }
} }
@@ -149,6 +155,15 @@ class AcaiAPI
self::updateCategoryMetadata($table, $prefix); 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; return !empty($options['return_last_id']) ? $lastId : $count;
} }
@@ -193,10 +208,31 @@ class AcaiAPI
if ($whereSql === null || $whereSql === '') return 0; 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; $count = 0;
foreach ($records as $record) { foreach ($records as $record) {
$lastNum = isset($record['num']) ? $record['num'] : null; $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( list($setParts, $setParams, $uploadFields) = self::buildSetClause(
$record, $schema, $functions, $ignoreSchema, false, false $record, $schema, $functions, $ignoreSchema, false, false
@@ -214,7 +250,7 @@ class AcaiAPI
$result = $db->execute($sql, $allParams ? $allParams : null); $result = $db->execute($sql, $allParams ? $allParams : null);
if ($result === false) { if ($result === false) {
self::error(['error' => "Error al actualizar `$table`"]); self::error(['error' => "Error al actualizar `$table`" . self::dbErrorSuffix($db)]);
continue; continue;
} }
$count++; $count++;
@@ -252,7 +288,7 @@ class AcaiAPI
'order' => time() + $i, 'order' => time() + $i,
'width' => 640, 'width' => 640,
'height' => 480, 'height' => 480,
], [], ['ignoreSchema' => true, 'prefix' => $prefix]); ], [], ['ignoreSchema' => true, 'prefix' => $prefix, 'skipTouchParent' => true]);
} }
} }
} }
@@ -262,6 +298,10 @@ class AcaiAPI
self::updateCategoryMetadata($table, $prefix); self::updateCategoryMetadata($table, $prefix);
} }
if ($touchParents && $count) {
self::touchUploadParents(array_merge($parents, self::uploadParents($where, $prefix)), $prefix);
}
return $count; return $count;
} }
@@ -292,8 +332,14 @@ class AcaiAPI
self::error(['info' => $sql, 'params' => $params]); 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(); $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(); $db = Db::getInstance();
$ph = implode(',', array_fill(0, count($nums), '?')); $ph = implode(',', array_fill(0, count($nums), '?'));
$fieldPh = implode(',', array_fill(0, count($uploadFields), '?')); $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, ' $selectCols = 'num, `order`, tableName, fieldName, recordNum, filePath, urlPath, '
. ($extra ? '`' . implode('`, `', $extra) . '`, ' : '')
. 'info1, info2, info3, info4, info5, alt'; . 'info1, info2, info3, info4, info5, alt';
$params = array_merge(array_values($nums), [$tableName], array_values($uploadFields)); $params = array_merge(array_values($nums), [$tableName], array_values($uploadFields));
@@ -831,10 +885,12 @@ class AcaiAPI
// Re-index for O(1) lookup // Re-index for O(1) lookup
$index = []; $index = [];
foreach ($uploads as $upload) { foreach ($uploads as $upload) {
if ($options['useAbsoluteUrls'] && isset($upload['urlPath']) if ($options['useAbsoluteUrls']) {
&& strpos($upload['urlPath'], '/cms') === 0 foreach (array_merge(['urlPath'], self::uploadThumbUrlColumns()) as $col) {
) { if (isset($upload[$col]) && strpos($upload[$col], '/cms') === 0) {
$upload['urlPath'] = 'https://' . $_SERVER['HTTP_HOST'] . $upload['urlPath']; $upload[$col] = 'https://' . $_SERVER['HTTP_HOST'] . $upload[$col];
}
}
} }
$field = $upload['fieldName']; $field = $upload['fieldName'];
$recNum = $upload['recordNum']; $recNum = $upload['recordNum'];
@@ -843,6 +899,47 @@ class AcaiAPI
return $index; 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. * 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). * 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) // WRITE HELPERS (private)
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -1366,7 +1583,8 @@ class AcaiAPI
array $functions, array $functions,
$ignoreSchema, $ignoreSchema,
$isInsert, $isInsert,
$forceNum $forceNum,
$requiredCols = null
) { ) {
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$user = 1; $user = 1;
@@ -1438,6 +1656,7 @@ class AcaiAPI
$setParts[] = '`dragSortOrder` = ?'; $setParts[] = '`dragSortOrder` = ?';
$params[] = time(); $params[] = time();
} }
} }
// ── User-supplied fields ───────────────────────────────────────── // ── User-supplied fields ─────────────────────────────────────────
@@ -1477,9 +1696,14 @@ class AcaiAPI
// - INSERT: mismo comportamiento que el Admin -> fecha actual. // - INSERT: mismo comportamiento que el Admin -> fecha actual.
// - UPDATE: se omite la columna, para no pisar con "hoy" un valor // - UPDATE: se omite la columna, para no pisar con "hoy" un valor
// ya guardado solo porque el caller mando la clave vacia. // 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' if ($schemaField && isset($schemaField['type']) && $schemaField['type'] === 'date'
&& ($value === null || (is_string($value) && trim($value) === ''))) { && ($value === null || (is_string($value) && trim($value) === ''))) {
if (!$isInsert) continue; if (!$isInsert) continue;
if (is_array($requiredCols) && !isset($requiredCols[$key])) continue;
$value = $now; $value = $now;
} }
@@ -1490,9 +1714,111 @@ class AcaiAPI
$params[] = $value; $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]; 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 * Coerces a value to its correct database representation
* based on the schema field definition. * based on the schema field definition.
@@ -1640,6 +1966,163 @@ class AcaiAPI
// ERROR HANDLING // 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 = []) static function error(array $data = [])
{ {
if (class_exists('API') && class_exists('ApiError')) { if (class_exists('API') && class_exists('ApiError')) {
+6
View File
@@ -7,6 +7,10 @@ class Db {
private $link; // MySQLi Link private $link; // MySQLi Link
private static $instance = false; // Db Class Instance 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 host: DB Host
@@ -69,12 +73,14 @@ class Db {
* @return bool * @return bool
*/ */
public function execute($sql, $params = null) { public function execute($sql, $params = null) {
$this->lastError = null;
if ($params) { if ($params) {
$stmt = $this->link->prepare($sql); $stmt = $this->link->prepare($sql);
try { try {
$stmt->execute($params); $stmt->execute($params);
} }
catch(Exception $e) { catch(Exception $e) {
$this->lastError = $e->getMessage();
return false; return false;
} }
} }
+46 -6
View File
@@ -43,6 +43,49 @@ function connectToMySQL($returnErrors = false) {
return ''; 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) { function mysqlStrictMode($strictMode) {
$currentVersion = preg_replace("/[^0-9\.]/", "", mysql_get_server_info()); $currentVersion = preg_replace("/[^0-9\.]/", "", mysql_get_server_info());
$isMySql5 = version_compare($currentVersion, '5.0.0', '>='); $isMySql5 = version_compare($currentVersion, '5.0.0', '>=');
@@ -74,12 +117,9 @@ 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");
// InnoDB (antes MyISAM): las tablas nuevas nacen con recuperacion // Motor segun el servidor (ver acaiTableEngine): InnoDB donde lo
// ante caida y bloqueo por fila. No cambia que INSERT se aceptan: // soporta bien, MyISAM en los antiguos.
// el CMS conecta en modo estricto (mysqlStrictMode / TRADITIONAL $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;");
// en Db.php) y en un INSERT de una fila eso rechaza lo mismo con
// MyISAM que con InnoDB. Ver docs/motor-innodb.md del repo 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"); }
} }
+1 -1
View File
@@ -210,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=InnoDB DEFAULT CHARSET=utf8;"); ) ENGINE=".acaiTableEngine()." 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;
+6
View File
@@ -259,6 +259,12 @@ function muestra_breadcrumb($record = array(), $previousLinks = array(),$class =
} }
if (!@$breadcrumbField || !@$breadcrumbRecord["optionsTablename"]) break; 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 = dame_registros($breadcrumbRecord["optionsTablename"], $breadcrumbRecord["optionsValueField"]."=".mysql_real_escape_string($breadcrumbRecord[$breadcrumbField]));
$breadcrumbRecord = @$breadcrumbRecord[0]; $breadcrumbRecord = @$breadcrumbRecord[0];
+7
View File
@@ -300,6 +300,13 @@ if (!$routeResolved){
$encontrado = mysql_fetch_assoc(mysql_query("SELECT * FROM ".$tableName." WHERE ".$columnName."='".$enlaceEscaped."' LIMIT 1")); $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){ if (@$encontrado){
$tabla = $tableName; $tabla = $tableName;
$registro = $encontrado["num"]; $registro = $encontrado["num"];