diff --git a/cms/lib/classes/AcaiAPI.php b/cms/lib/classes/AcaiAPI.php index 547697f..aa9e21e 100644 --- a/cms/lib/classes/AcaiAPI.php +++ b/cms/lib/classes/AcaiAPI.php @@ -92,6 +92,7 @@ class AcaiAPI $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, $requiredCols ); @@ -201,6 +202,22 @@ class AcaiAPI 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 @@ -1760,6 +1777,113 @@ class AcaiAPI * 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 = []; diff --git a/slug.php b/slug.php index 4991129..3b2591c 100755 --- a/slug.php +++ b/slug.php @@ -300,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"];