Author SHA1 Message Date
Acai ForgeandClaude Opus 5.5 bb532515ce feat: header.php y robots consultan la regla única de builder_saas
header.php decide con acaiVisibilidadWeb (y 503 + Retry-After con
acaiCabecerasMantenimiento) si el plugin la trae; si no, la reserva en
línea de siempre. cms/robots.php cierra con Disallow: / mientras la web no
esté publicada (acaiRobotsBloqueados); un robots.txt propio manda.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:27:40 +00:00
Acai ForgeandClaude Opus 5.5 d1235f2e20 Merge fix/cache-sin-redis: sin Redis la web sigue sin caché en vez de morir
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 15:27:20 +00:00
Acai ForgeandClaude Opus 5.5 cb03ecd239 fix(cache): sin Redis la web sigue sin caché en vez de morir
initCache() de CocoDB y AcaiAPI conectaba sin capturar errores: en un
hosting sin Redis (o sin la extensión) el plugin cache_handler tumbaba la
web entera con un RedisException. Ahora se marca como no disponible para
el resto de la petición y cacheGet/cacheSet, que ya comprobaban la
conexión, no hacen nada. Con Redis el comportamiento no cambia.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 17:08:52 +00:00
Jordan DiazandClaude Opus 5.5 80b6541e30 fix: crear uploads/webp sin carrera en funciones.php
Varias peticiones a la vez (el prerender de Astro las lanza en paralelo) veian
la carpeta sin crear y la segunda imprimia "mkdir(): File exists" delante del
JSON de action_ws, rompiendo el build de una web recien descargada.

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:28:21 +00:00
Jordan Diaz 15c3db882d feat(core): cambiar las fotos de un registro sella su updatedDate 2026-09-26 13:34:07 +00:00
Jordan Diaz 17f5d91c8d feat(core): el enlace es unico entre todas las tablas (AcaiAPI::uniqueEnlace) y slug.php registra duplicados 2026-09-24 09:34:22 +00:00
Jordan Diaz 7097b79111 fix(core): las migas no revientan con un registro sin su campo de migas (ficha en blanco) 2026-09-24 07:35:23 +00:00
Jordan Diaz 4e46322b02 feat(core): regla unica de valores por defecto en los INSERT (AcaiAPI::insertDefaults) 2026-09-24 07:21:53 +00:00
Jordan Diaz 184038b786 fix(core): una fecha ausente solo se rellena si la columna es NOT NULL sin DEFAULT 2026-09-24 00:17:04 +00:00
Jordan Diaz 14ddf1608e fix(core): altas rellenan fechas que faltan y el error de insert/update trae el mensaje de MySQL 2026-09-23 07:59:03 +00:00
Jordan DiazandClaude Opus 5.5 8659715132 Merge motor-segun-version: el motor de las tablas nuevas depende del servidor
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 17:44:27 +00:00
Jordan DiazandClaude Opus 5.5 701bc3bb8b feat(core): el motor de las tablas nuevas depende de la version del servidor
El cambio anterior (8d92d08) creaba siempre las tablas nuevas en InnoDB. Hay
un servidor de produccion con MariaDB 5.5.68, donde InnoDB no es seguro: no
admite FULLTEXT, limita los indices a 767 bytes y usa filas COMPACT. Con
«Subir core», las tablas nuevas de una web de ese servidor habrian nacido en
InnoDB sobre 5.5.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:41:02 +00:00
9 changed files with 634 additions and 28 deletions
+515 -16
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()
@@ -33,6 +34,7 @@ class AcaiAPI
// ── Cache flags (mirror CocoDB interface) ───────────────────────── // ── Cache flags (mirror CocoDB interface) ─────────────────────────
public static $redis = null; public static $redis = null;
public static $redis_expireTime = 0; public static $redis_expireTime = 0;
public static $redis_no_disponible = false;
public static $force_load_cache = false; public static $force_load_cache = false;
public static $force_redis = false; public static $force_redis = false;
public static $force_redis_module = false; // consumed by external cache layer public static $force_redis_module = false; // consumed by external cache layer
@@ -87,9 +89,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 +109,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 +131,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 +156,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 +209,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 +251,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 +289,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 +299,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 +333,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;
} }
@@ -601,14 +648,29 @@ class AcaiAPI
/** Connect to Redis (idempotent). */ /** Connect to Redis (idempotent). */
static function initCache() 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'; $redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
$redisPort = (int)(getenv('REDIS_PORT') ?: 6379); $redisPort = (int)(getenv('REDIS_PORT') ?: 6379);
if (!self::$redis) { try {
self::$redis = new Redis(); if (!self::$redis) self::$redis = new Redis();
self::$redis->connect($redisHost, $redisPort); if (!self::$redis->isConnected() && !self::$redis->connect($redisHost, $redisPort)) {
} elseif (!self::$redis->isConnected()) { throw new RuntimeException("no se pudo conectar a $redisHost:$redisPort");
self::$redis->connect($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(); $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 +901,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 +915,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 +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) // WRITE HELPERS (private)
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -1366,7 +1599,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 +1672,7 @@ class AcaiAPI
$setParts[] = '`dragSortOrder` = ?'; $setParts[] = '`dragSortOrder` = ?';
$params[] = time(); $params[] = time();
} }
} }
// ── User-supplied fields ───────────────────────────────────────── // ── User-supplied fields ─────────────────────────────────────────
@@ -1477,9 +1712,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 +1730,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 +1982,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')) {
+21 -5
View File
@@ -14,6 +14,7 @@ class CocoDB
public static $trackData = []; public static $trackData = [];
public static $redis = null; public static $redis = null;
public static $redis_expireTime = 0; public static $redis_expireTime = 0;
public static $redis_no_disponible = false;
public static $force_load_cache = false; public static $force_load_cache = false;
public static $force_redis = false; public static $force_redis = false;
public static $force_redis_module = false; public static $force_redis_module = false;
@@ -1176,14 +1177,29 @@ class CocoDB
*/ */
static function initCache() 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'; $redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
$redisPort = (int)(getenv('REDIS_PORT') ?: 6379); $redisPort = (int)(getenv('REDIS_PORT') ?: 6379);
if (!self::$redis) { try {
self::$redis = new Redis(); if (!self::$redis) self::$redis = new Redis();
self::$redis->connect($redisHost, $redisPort); if (!self::$redis->isConnected() && !self::$redis->connect($redisHost, $redisPort)) {
} else if (!self::$redis->isConnected()) { throw new RuntimeException("no se pudo conectar a $redisHost:$redisPort");
self::$redis->connect($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());
} }
} }
+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 -1
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,7 +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");
$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"); } 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=MyISAM 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;
+9 -1
View File
@@ -20,7 +20,15 @@ if (file_exists($filePath)) {
# <?=strtoupper($configuracionRecord["tienda_nombre_empresa"]);?> # <?=strtoupper($configuracionRecord["tienda_nombre_empresa"]);?>
User-agent: * 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: / Disallow: /
<? }?> <? }?>
<? if (@$configuracionRecord['pagina_publicada']) {?> <? if (@$configuracionRecord['pagina_publicada']) {?>
+11 -2
View File
@@ -7,8 +7,11 @@ $modulosCargados = array();
$recursosCSS = array(); $recursosCSS = array();
// HASTA AUQI // HASTA AUQI
if (!file_exists(__DIR__.'/'.CMS_FOLDER.'/uploads/webp/')) { // Sin carrera: varias peticiones a la vez (el prerender de Astro las lanza en
mkdir(__DIR__.'/'.CMS_FOLDER.'/uploads/webp/'); // 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; global $configuracionTienda;
$configuracionRecord = CocoDB::get("configuracion", "", null, 1); $configuracionRecord = CocoDB::get("configuracion", "", null, 1);
@@ -259,6 +262,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];
+18 -2
View File
@@ -78,16 +78,32 @@ $config_header = array(
// prod + publicada -> se ve // prod + publicada -> se ve
// prod + sin publicar + ?pruebas -> se ve, con el cartel rojo // prod + sin publicar + ?pruebas -> se ve, con el cartel rojo
// prod + sin publicar + visitante -> mantenimiento // 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"); $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 (@$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> <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 { } else {
if (function_exists("acaiCabecerasMantenimiento")) acaiCabecerasMantenimiento();
die(tpl('mantenimiento',$config_header)); die(tpl('mantenimiento',$config_header));
} }
?> ?>
+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"];