Tras un descarte del sync (que no mueve los custom-schema.ini.php, excluidos del manifest) puede quedar una carpeta de plugin solo con su overlay. El cargador hacia foreach sobre un schema inexistente y emitia 'Undefined array key' en cada peticion, visible en el HTML con display_errors=1. Sin schema.ini.php no hay plugin: la carpeta se ignora. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HPL66q9F33YZxbLPUT7KW8
488 lines
19 KiB
PHP
488 lines
19 KiB
PHP
<?
|
|
$schemaPlugins = array();
|
|
|
|
function getSchemaPlugins($force = false){
|
|
global $schemaPlugins;
|
|
$pluginsDir = $GLOBALS["APP"]["pluginsdir"];
|
|
|
|
if (!$force && @$schemaPlugins) return $schemaPlugins;
|
|
|
|
foreach (scandir($pluginsDir) as $dirname) {
|
|
$fullPath = $pluginsDir."/".$dirname;
|
|
if (is_dir($fullPath)){
|
|
$schemaFile = $fullPath."/schema.ini.php";
|
|
if (file_exists($schemaFile)){
|
|
$schemaPlugins[$dirname] = loadINI($schemaFile);
|
|
}
|
|
|
|
$customSchemaFile = $fullPath."/custom-schema.ini.php";
|
|
// 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):
|
|
unset($customSchema[$e]);
|
|
endforeach;
|
|
foreach($schemaPlugins[$dirname] as $key => $value):
|
|
if (is_array($value)){
|
|
foreach($value as $kv => $vv):
|
|
if (isset($customSchema[$key][$kv]) && @$customSchema[$key][$kv] != "" && @$customSchema[$key][$kv] != $vv) $schemaPlugins[$dirname][$key][$kv] = $customSchema[$key][$kv];
|
|
endforeach;
|
|
}else{
|
|
if (isset($customSchema[$key]) && @$customSchema[$key] != "" && @$customSchema[$key] != $value) $schemaPlugins[$dirname][$key] = $customSchema[$key];
|
|
}
|
|
endforeach;
|
|
|
|
}
|
|
}
|
|
}
|
|
return $schemaPlugins;
|
|
}
|
|
function installSQL($path){
|
|
if (file_exists($path."/install.sql")){
|
|
try{
|
|
sqlImport($path."/install.sql");
|
|
return 1;
|
|
}catch(Exception $e){
|
|
return 0;
|
|
}
|
|
}else{
|
|
return 1;
|
|
}
|
|
|
|
}
|
|
function installSchemas($path){
|
|
if (file_exists($path."/schemaPresets")){
|
|
foreach (scandir($path."/schemaPresets") as $dirname) {
|
|
if (strpos($dirname,".ini")){
|
|
addTablePlugin($path."/schemaPresets",$dirname);
|
|
}
|
|
}
|
|
rename($path."/schemaPresets/",$path."/schemaPresets.bck/");
|
|
return 0;
|
|
}else{
|
|
return 1;
|
|
}
|
|
}
|
|
function showInstall($path){
|
|
global $TABLE_PREFIX,$menu,$action,$schema;
|
|
//
|
|
$result = 0;
|
|
showHeader();
|
|
?>
|
|
<header class="navbar navbar-default navbar-fixed-top">
|
|
<h4 class="titular"><?php echo $schema['menuName'] ?> - <span class="balance">0</span></h4>
|
|
</header>
|
|
|
|
<div id="page-content">
|
|
<h3>Instalando el plugin</h3>
|
|
<? $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>
|
|
<? }?>
|
|
</div>
|
|
<?
|
|
showFooter();
|
|
exit;
|
|
}
|
|
|
|
function addPlugins($type,&$var, $fieldtype = null){
|
|
global $CURRENT_USER,$menu,$action;
|
|
$schemaPlugins = getSchemaPlugins();
|
|
if (!@$schemaPlugins) return;
|
|
$pluginsInserted = array();
|
|
|
|
$allowedFields = array("db_type", "db_type_config");
|
|
|
|
foreach($schemaPlugins as $key => $value):
|
|
if (!@$CURRENT_USER["isAdmin"] && $value["adminOnly"]) continue;
|
|
if (!@$value["enabled"]) continue;
|
|
if (@$value["checkPoints"]){
|
|
foreach($allowedFields as $allowedField):
|
|
$value["checkPoints"][$allowedField] = "";
|
|
endforeach;
|
|
|
|
foreach($value["checkPoints"] as $checkPoint => $controller):
|
|
if ($checkPoint==$type && isset($value["checkPoints"][$type])){
|
|
|
|
$enabled = true;
|
|
$pluginsInserted[] = $key;
|
|
// DEFINIMOS LAS VARIABLES DE OPCIONES PARA EL PLUGIN
|
|
$options = array();
|
|
$options["templatePath"] = str_replace($GLOBALS["PROGRAM_DIR"]."/","",$GLOBALS["APP"]["pluginsdir"])."/".$key;
|
|
$options["pluginName"] = $key;
|
|
if (@$value["newFieldType"]){
|
|
$options["fieldType"] = $value["newFieldType"];
|
|
if (@$fieldtype){
|
|
foreach($options["fieldType"] as $kvar => $vvar):
|
|
if (isset($var["field"][$kvar])) $options["fieldType"][$kvar] = $var["field"][$kvar];
|
|
endforeach;
|
|
}
|
|
}
|
|
|
|
// SI EXISTEN FILTROS DE MENU
|
|
if (@$value["menuFilter"][$type]){
|
|
$sepMenu = array_filter(explode(",",$value["menuFilter"][$type]));
|
|
if (@$sepMenu && !in_array(@$menu,@$sepMenu)){
|
|
$enabled = false;
|
|
}
|
|
}
|
|
// SI EXISTEN FILTROS DE ACTION
|
|
if (@$value["actionFilter"][$type]){
|
|
$sepAction = array_filter(explode(",",$value["actionFilter"][$type]));
|
|
if (@$sepAction && !in_array(@$action,@$sepAction)){
|
|
$enabled = false;
|
|
}
|
|
}
|
|
|
|
// SI EXISTEN FILTROS DE TIPO DE CAMPO
|
|
if (@$value["newFieldType"]["type"] && @$fieldtype && @$value["newFieldType"]["type"] != $fieldtype) {
|
|
$enabled = false;
|
|
}
|
|
|
|
// SI EXISTE UN ARCHIVO DE INSTALACION LO EJECUTAMOS
|
|
if (file_exists($GLOBALS["APP"]["pluginsdir"]."/".$key."/install.sql") || file_exists($GLOBALS["APP"]["pluginsdir"]."/".$key."/schemaPresets")){
|
|
$enabled = false;
|
|
showInstall($GLOBALS["APP"]["pluginsdir"]."/".$key);
|
|
}
|
|
// SI NOS PIDE EL TYPE DE CAMPO Y EXISTE EN EL SCHEMA DEL PLUGIN LO AÑADIMOS
|
|
if ($enabled && @$type=="db_type" && @$value["newFieldType"]["type"]){
|
|
array_push($var, array("value" => $value["newFieldType"]["type"], "name" => $value["newFieldType"]["label"]));
|
|
continue;
|
|
}
|
|
|
|
if ($enabled && @$type=="db_type_config" && @$value["newFieldType"]){
|
|
?>
|
|
<fieldset data-plugin="<?=$options["pluginName"];?>" data-fieldtype="<?=$value["newFieldType"]["type"];?>" class="fieldPluginContainer">
|
|
<legend><b>Plugin Options</b></legend>
|
|
|
|
<? foreach ($value["newFieldType"] as $keyFT => $vFT):?>
|
|
<? if (@$keyFT=="label" || @$keyFT=="type") continue;?>
|
|
<? if (@$var[$keyFT]) $vFT = @$var[$keyFT];?>
|
|
<div class="<?=$keyFT;?>">
|
|
<div class="label"><?=$keyFT;?></div>
|
|
<input type="text" name="plugin[<?=$options["pluginName"];?>][<?=$keyFT;?>]" value="<?=$vFT;?>">
|
|
</div>
|
|
|
|
<? endforeach;?>
|
|
</fieldset>
|
|
<?
|
|
continue;
|
|
}
|
|
// SI SE PERMITE EJECUTAMOS EL PLUGIN
|
|
if ($enabled && @$value["checkPoints"][$type]) {
|
|
include $GLOBALS["APP"]["pluginsdir"]."/".$key."/".@$value["checkPoints"][$type];
|
|
}
|
|
}
|
|
endforeach;
|
|
}
|
|
|
|
endforeach;
|
|
return $pluginsInserted;
|
|
}
|
|
|
|
function addTablePlugin($path,$file) {
|
|
global $TABLE_PREFIX, $APP;
|
|
//rename($path."/".$file,$GLOBALS["APP"]["datadir"]."schemaPresets/".$file);
|
|
|
|
$presetTableName = str_replace(".ini.php","",$file);
|
|
|
|
// error checking
|
|
$errors = "";
|
|
$tableNameErrors = getTablenameErrors($presetTableName);
|
|
|
|
// create new schema data
|
|
$schemaPresetDir = $path."/";
|
|
$newSchema = loadSchema($presetTableName, $schemaPresetDir) or die("Couldn't load preset schema");
|
|
|
|
$newSchema['menuName'] = $presetTableName; // change menu name
|
|
$newSchema['menuOrder'] = time(); // use time to sort to bottom
|
|
|
|
|
|
// create mysql table
|
|
// (this isn't required but done here so we catch get mysql errors creating the table)
|
|
// createMissingSchemaTablesAndFields() creates if this doesn't.
|
|
$tableNameWithPrefix = $TABLE_PREFIX . $presetTableName;
|
|
$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;");
|
|
if (!$result) {
|
|
print "Error creating MySQL table.\n\nMySQL error was: ". htmlspecialchars(mysql_error()) . "\n";
|
|
exit;
|
|
}
|
|
|
|
|
|
// save new schema
|
|
saveSchema($presetTableName, $newSchema);
|
|
|
|
// Create schema table and fields in MySQL
|
|
createMissingSchemaTablesAndFields();
|
|
clearAlertsAndNotices(); // don't display alerts about adding new fields
|
|
|
|
}
|
|
/************************/
|
|
|
|
function sqlImport($file)
|
|
{
|
|
|
|
$delimiter = ';';
|
|
$file = fopen($file, 'r');
|
|
$isFirstRow = true;
|
|
$isMultiLineComment = false;
|
|
$sql = '';
|
|
|
|
while (!feof($file)) {
|
|
|
|
$row = fgets($file);
|
|
|
|
// remove BOM for utf-8 encoded file
|
|
if ($isFirstRow) {
|
|
$row = preg_replace('/^\x{EF}\x{BB}\x{BF}/', '', $row);
|
|
$isFirstRow = false;
|
|
}
|
|
|
|
// 1. ignore empty string and comment row
|
|
if (trim($row) == '' || preg_match('/^\s*(#|--\s)/sUi', $row)) {
|
|
continue;
|
|
}
|
|
|
|
// 2. clear comments
|
|
$row = trim(clearSQL($row, $isMultiLineComment));
|
|
|
|
// 3. parse delimiter row
|
|
if (preg_match('/^DELIMITER\s+[^ ]+/sUi', $row)) {
|
|
$delimiter = preg_replace('/^DELIMITER\s+([^ ]+)$/sUi', '$1', $row);
|
|
continue;
|
|
}
|
|
|
|
// 4. separate sql queries by delimiter
|
|
$offset = 0;
|
|
while (strpos($row, $delimiter, $offset) !== false) {
|
|
$delimiterOffset = strpos($row, $delimiter, $offset);
|
|
if (isQuoted($delimiterOffset, $row)) {
|
|
$offset = $delimiterOffset + strlen($delimiter);
|
|
} else {
|
|
$sql = trim($sql . ' ' . trim(substr($row, 0, $delimiterOffset)));
|
|
query($sql);
|
|
|
|
$row = substr($row, $delimiterOffset + strlen($delimiter));
|
|
$offset = 0;
|
|
$sql = '';
|
|
}
|
|
}
|
|
$sql = trim($sql . ' ' . $row);
|
|
}
|
|
if (strlen($sql) > 0) {
|
|
query($row);
|
|
}
|
|
|
|
fclose($file);
|
|
}
|
|
|
|
/**
|
|
* Remove comments from sql
|
|
*
|
|
* @param string sql
|
|
* @param boolean is multicomment line
|
|
* @return string
|
|
*/
|
|
function clearSQL($sql, &$isMultiComment)
|
|
{
|
|
if ($isMultiComment) {
|
|
if (preg_match('#\*/#sUi', $sql)) {
|
|
$sql = preg_replace('#^.*\*/\s*#sUi', '', $sql);
|
|
$isMultiComment = false;
|
|
} else {
|
|
$sql = '';
|
|
}
|
|
if(trim($sql) == ''){
|
|
return $sql;
|
|
}
|
|
}
|
|
|
|
$offset = 0;
|
|
while (preg_match('{--\s|#|/\*[^!]}sUi', $sql, $matched, PREG_OFFSET_CAPTURE, $offset)) {
|
|
list($comment, $foundOn) = $matched[0];
|
|
if (isQuoted($foundOn, $sql)) {
|
|
$offset = $foundOn + strlen($comment);
|
|
} else {
|
|
if (substr($comment, 0, 2) == '/*') {
|
|
$closedOn = strpos($sql, '*/', $foundOn);
|
|
if ($closedOn !== false) {
|
|
$sql = substr($sql, 0, $foundOn) . substr($sql, $closedOn + 2);
|
|
} else {
|
|
$sql = substr($sql, 0, $foundOn);
|
|
$isMultiComment = true;
|
|
}
|
|
} else {
|
|
$sql = substr($sql, 0, $foundOn);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return $sql;
|
|
}
|
|
|
|
/**
|
|
* Check if "offset" position is quoted
|
|
*
|
|
* @param int $offset
|
|
* @param string $text
|
|
* @return boolean
|
|
*/
|
|
function isQuoted($offset, $text)
|
|
{
|
|
if ($offset > strlen($text))
|
|
$offset = strlen($text);
|
|
|
|
$isQuoted = false;
|
|
for ($i = 0; $i < $offset; $i++) {
|
|
if ($text[$i] == "'")
|
|
$isQuoted = !$isQuoted;
|
|
if ($text[$i] == "\\" && $isQuoted)
|
|
$i++;
|
|
}
|
|
return $isQuoted;
|
|
}
|
|
|
|
function query($sql)
|
|
{
|
|
//echo '#<strong>SQL CODE TO RUN:</strong><br>' . htmlspecialchars($sql) . ';<br><br>';
|
|
if (!$query = mysql_query($sql)) {
|
|
throw new Exception("Cannot execute request to the database {$sql}: " . mysql_error());
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
?>
|