Compare commits
25
Commits
+515
-16
@@ -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()
|
||||
@@ -33,6 +34,7 @@ class AcaiAPI
|
||||
// ── Cache flags (mirror CocoDB interface) ─────────────────────────
|
||||
public static $redis = null;
|
||||
public static $redis_expireTime = 0;
|
||||
public static $redis_no_disponible = false;
|
||||
public static $force_load_cache = false;
|
||||
public static $force_redis = false;
|
||||
public static $force_redis_module = false; // consumed by external cache layer
|
||||
@@ -87,9 +89,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 +109,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 +131,7 @@ class AcaiAPI
|
||||
'order' => time() + $i,
|
||||
'width' => 640,
|
||||
'height' => 480,
|
||||
], [], ['ignoreSchema' => true, 'prefix' => $prefix]);
|
||||
], [], ['ignoreSchema' => true, 'prefix' => $prefix, 'skipTouchParent' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +156,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 +209,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 +251,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 +289,7 @@ class AcaiAPI
|
||||
'order' => time() + $i,
|
||||
'width' => 640,
|
||||
'height' => 480,
|
||||
], [], ['ignoreSchema' => true, 'prefix' => $prefix]);
|
||||
], [], ['ignoreSchema' => true, 'prefix' => $prefix, 'skipTouchParent' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,6 +299,10 @@ class AcaiAPI
|
||||
self::updateCategoryMetadata($table, $prefix);
|
||||
}
|
||||
|
||||
if ($touchParents && $count) {
|
||||
self::touchUploadParents(array_merge($parents, self::uploadParents($where, $prefix)), $prefix);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
@@ -292,8 +333,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -601,14 +648,29 @@ class AcaiAPI
|
||||
/** Connect to Redis (idempotent). */
|
||||
static function initCache()
|
||||
{
|
||||
// Sin Redis (hosting propio sin el servicio o sin la extensión, o el
|
||||
// servicio caído) la web sigue SIN caché en vez de morir con un fatal:
|
||||
// cacheGet/cacheSet ya no hacen nada si no hay conexión. Se marca para
|
||||
// no reintentar en cada consulta de la misma petición.
|
||||
if (self::$redis_no_disponible) return;
|
||||
if (!class_exists('Redis')) {
|
||||
self::$redis_no_disponible = true;
|
||||
self::$redis = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
|
||||
$redisPort = (int)(getenv('REDIS_PORT') ?: 6379);
|
||||
|
||||
if (!self::$redis) {
|
||||
self::$redis = new Redis();
|
||||
self::$redis->connect($redisHost, $redisPort);
|
||||
} elseif (!self::$redis->isConnected()) {
|
||||
self::$redis->connect($redisHost, $redisPort);
|
||||
try {
|
||||
if (!self::$redis) self::$redis = new Redis();
|
||||
if (!self::$redis->isConnected() && !self::$redis->connect($redisHost, $redisPort)) {
|
||||
throw new RuntimeException("no se pudo conectar a $redisHost:$redisPort");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
self::$redis_no_disponible = true;
|
||||
self::$redis = null;
|
||||
error_log('[cache] Redis no disponible, se sigue sin caché: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,7 +872,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 +901,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 +915,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 +1435,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 +1599,8 @@ class AcaiAPI
|
||||
array $functions,
|
||||
$ignoreSchema,
|
||||
$isInsert,
|
||||
$forceNum
|
||||
$forceNum,
|
||||
$requiredCols = null
|
||||
) {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$user = 1;
|
||||
@@ -1438,6 +1672,7 @@ class AcaiAPI
|
||||
$setParts[] = '`dragSortOrder` = ?';
|
||||
$params[] = time();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── User-supplied fields ─────────────────────────────────────────
|
||||
@@ -1477,9 +1712,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 +1730,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 +1982,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')) {
|
||||
|
||||
@@ -14,6 +14,7 @@ class CocoDB
|
||||
public static $trackData = [];
|
||||
public static $redis = null;
|
||||
public static $redis_expireTime = 0;
|
||||
public static $redis_no_disponible = false;
|
||||
public static $force_load_cache = false;
|
||||
public static $force_redis = false;
|
||||
public static $force_redis_module = false;
|
||||
@@ -1176,14 +1177,29 @@ class CocoDB
|
||||
*/
|
||||
static function initCache()
|
||||
{
|
||||
// Sin Redis (hosting propio sin el servicio o sin la extensión, o el
|
||||
// servicio caído) la web sigue SIN caché en vez de morir con un fatal:
|
||||
// cacheGet/cacheSet ya no hacen nada si no hay conexión. Se marca para
|
||||
// no reintentar en cada consulta de la misma petición.
|
||||
if (self::$redis_no_disponible) return;
|
||||
if (!class_exists('Redis')) {
|
||||
self::$redis_no_disponible = true;
|
||||
self::$redis = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
|
||||
$redisPort = (int)(getenv('REDIS_PORT') ?: 6379);
|
||||
|
||||
if (!self::$redis) {
|
||||
self::$redis = new Redis();
|
||||
self::$redis->connect($redisHost, $redisPort);
|
||||
} else if (!self::$redis->isConnected()) {
|
||||
self::$redis->connect($redisHost, $redisPort);
|
||||
try {
|
||||
if (!self::$redis) self::$redis = new Redis();
|
||||
if (!self::$redis->isConnected() && !self::$redis->connect($redisHost, $redisPort)) {
|
||||
throw new RuntimeException("no se pudo conectar a $redisHost:$redisPort");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
self::$redis_no_disponible = true;
|
||||
self::$redis = null;
|
||||
error_log('[cache] Redis no disponible, se sigue sin caché: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ class Db {
|
||||
private $link; // MySQLi Link
|
||||
|
||||
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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+134
-6
@@ -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> </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;
|
||||
}
|
||||
?>
|
||||
|
||||
+9
-1
@@ -20,7 +20,15 @@ if (file_exists($filePath)) {
|
||||
# <?=strtoupper($configuracionRecord["tienda_nombre_empresa"]);?>
|
||||
|
||||
User-agent: *
|
||||
<? if (strpos($_SERVER["HTTP_HOST"], "plandeweb.com")) {?>
|
||||
<?
|
||||
// Cerrada mientras no este publicada: la regla es `acaiRobotsBloqueados` del
|
||||
// plugin builder_saas (la misma de header.php y de la puerta de las webs
|
||||
// Astro). Un robots.txt propio de la web manda sobre esto (arriba). Sin el
|
||||
// plugin, como siempre: solo se cierran los dominios de pruebas.
|
||||
$robotsBloqueados = strpos($_SERVER["HTTP_HOST"], "plandeweb.com")
|
||||
|| (function_exists("acaiRobotsBloqueados")
|
||||
&& acaiRobotsBloqueados(is_file(dirname(__DIR__) . "/.acai"), @$configuracionRecord['pagina_publicada']));
|
||||
if ($robotsBloqueados) {?>
|
||||
Disallow: /
|
||||
<? }?>
|
||||
<? if (@$configuracionRecord['pagina_publicada']) {?>
|
||||
|
||||
+11
-2
@@ -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];
|
||||
|
||||
|
||||
+18
-2
@@ -78,16 +78,32 @@ $config_header = array(
|
||||
// prod + publicada -> se ve
|
||||
// prod + sin publicar + ?pruebas -> se ve, con el cartel rojo
|
||||
// prod + sin publicar + visitante -> mantenimiento
|
||||
//
|
||||
// La tabla de arriba es UNA regla para todas las webs (PHP y Astro) y para el
|
||||
// robots.txt: `acaiVisibilidadWeb` del plugin builder_saas (ws_respond.php),
|
||||
// que se reparte entero a todas las webs. Se llama con function_exists por la
|
||||
// misma razon que la señal va en linea: si una web no trae el plugin, se
|
||||
// queda con la RESERVA de abajo, que es la regla de siempre (y el cartel con
|
||||
// 200, sin las cabeceras 503 de `acaiCabecerasMantenimiento`).
|
||||
$esEntornoLocal = is_file(dirname(__FILE__) . "/.acai");
|
||||
|
||||
if ($esEntornoLocal || @$configuracionRecord["pagina_publicada"] || isset($_SESSION["pruebas"])) {
|
||||
if (function_exists("acaiVisibilidadWeb")) {
|
||||
$visibilidadWeb = acaiVisibilidadWeb($esEntornoLocal, @$configuracionRecord["pagina_publicada"], isset($_SESSION["pruebas"]));
|
||||
} else {
|
||||
// RESERVA (sin el plugin): la misma tabla, en linea.
|
||||
$visibilidadWeb = ($esEntornoLocal || @$configuracionRecord["pagina_publicada"]) ? "visible"
|
||||
: (isset($_SESSION["pruebas"]) ? "pruebas" : "mantenimiento");
|
||||
}
|
||||
|
||||
if ($visibilidadWeb !== "mantenimiento") {
|
||||
if (@$customCode) echo modulo('custom-header',$config_header); else echo tpl('cabecera',$config_header);
|
||||
if (!$esEntornoLocal && isset($_SESSION["pruebas"]) && @$configuracionRecord["pagina_publicada"] == 0) {
|
||||
if ($visibilidadWeb === "pruebas") {
|
||||
?>
|
||||
<div style="position:absolute;top:0px;width:150px;background-color:red;color:#fff;font-size:12px;padding:0 10px;z-index:99999999;transform:translateZ(1000px);">SITIO EN PRUEBAS</div>
|
||||
<?
|
||||
}
|
||||
} else {
|
||||
if (function_exists("acaiCabecerasMantenimiento")) acaiCabecerasMantenimiento();
|
||||
die(tpl('mantenimiento',$config_header));
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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")){
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user