New plugin system based on Laravel Package Development (#12998)

* use Blade view and Eloquent models for plugins

* move views

* fix style

* fix style

* revert mistake

* Update Plugin.php

delete test property "name"

* rename plugin function to settings

* last but not least - rename in Test.php

* Rename Test to Example

* fix typo

* fix style

* fix style

* fix style

* fix style - I hate tabs...

* Extract view calls

* fix method calls and style

* Move Models the the abstract class

* fix style

* Convert to traits

* Change the Example description

* Fix style

* Fix style

* Fix style

* Convert plugin function to Model static methods and delete .inc.php

* fix style

* fix style

* Use scope

* final methods blows up legacy code

* Config > \LibreNMS\Config

* convert the static string to a static method

* Correct placement in the page

* fix tabs

* fix style

* Rename from tait to hook

to make it easier to understand and be complient

* rename file

* Typo

* Started to change the docu

* change to a more usefully Device_Overview example

* and activate of course

* PluginManager

* fix .gitignore

* only php files in the root folder

* corrected .gitignore with all files :)

* Rename the Hooks and ExampleClass for better readability

* Fix style

* Fix style

* Exception handling (especially if DB is not present)

* Fix style and update schema

* fix indentation

* actually correct indent

* fix migration collation check include utf8mb4_bin

* stop phpstan whining

* A view lines documentation

* add typeHints

* Allow return null on handle

* lint

* fix return types

* fix logic of column collation check

* Fix MenuEntryHook

* switch to longtext instead of json type for now :D

* try phpstan on PHP 7.3

* set phpstan target version to 7.3

* all the typehints

* optional

* more

* Use namespace to prevent view collisions
disambiguate plugin and hook
no magic guessing of names in PluginManager, bad assumptions
remove unused plugins from the DB

* cleanup plugin menu

* cleanup on shutdown and ignore but log query error on cleanup

* instanceof must be called against an instance

* Allow multiple hooks per plugin

* Port plugin ui code to Laravel

* page instead of settings for v1 plugins

* actually working settings pages
a little url cleanup plugin/admin -> plugin/settings

* fix style

* Add page hook

* PHPstan

* Try to fix Illuminate\Http\RedirectResponse

* typehint

* Rewrite the doc

* Fix style

Co-authored-by: PipoCanaja <38363551+PipoCanaja@users.noreply.github.com>
Co-authored-by: Tony Murray <murraytony@gmail.com>
This commit is contained in:
Kevin Zink
2021-10-19 13:53:28 +02:00
committed by GitHub
parent 2d5d7e14ed
commit 98ed6bb9dc
50 changed files with 1440 additions and 215 deletions

9
app/Plugins/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Ignore everything in this directory
*
# Except these
!/.gitignore
!/*.php
!/Hooks/
!/Hooks/**
!/ExamplePlugin/
!/ExamplePlugin/**

View File

@@ -0,0 +1,32 @@
<?php
/*
* ExampleSettingsPlugin.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2021 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Plugins\ExamplePlugin;
use App\Plugins\Hooks\DeviceOverviewHook;
class DeviceOverview extends DeviceOverviewHook
{
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Plugins\ExamplePlugin;
use App\Plugins\Hooks\MenuEntryHook;
class Menu extends MenuEntryHook
{
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* ExampleSettingsPlugin.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2021 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Plugins\ExamplePlugin;
use App\Plugins\Hooks\PageHook;
class Page extends PageHook
{
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Plugins\ExamplePlugin;
use App\Plugins\Hooks\PortTabHook;
class PortTab extends PortTabHook
{
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* ExampleSettingsPlugin.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2021 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Plugins\ExamplePlugin;
use App\Plugins\Hooks\SettingsHook;
class Settings extends SettingsHook
{
}

View File

@@ -0,0 +1,16 @@
<div class="row">
<div class="col-md-12">
<div class="panel panel-default panel-condensed">
<div class="panel-heading">
<strong>{{ $title }}</strong> <a href="{{ url('device/' . $device->device_id . '/notes') }}">[EDIT]</a>
</div>
<div class="panel-body">
<div class="row">
<div class="col-sm-12">
{!! Str::markdown($device->notes) !!}
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
<a href="{{ url('plugin/ExamplePlugin') }}"><i class="fa fa-coffee fa-fw fa-lg" aria-hidden="true"></i> Example Menu</a>

View File

@@ -0,0 +1,8 @@
<div class="panel panel-default">
<div class="panel-body ">
<div class="pull-left" style="margin-top: 5px;">
<span style="font-size: 20px;">{{ $title }}</a></span><br>
Description
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
This is a port plugin tab plugin for port {{ $port->getLabel() }}

View File

@@ -0,0 +1,94 @@
<div style="margin: 15px;">
<h4>{{ $plugin_name }} Settings:</h4>
<!-- Example of free-form settings, real plugins should use specific fields -->
<!-- All input fields should be in the settings array (settings[]) -->
<form method="post" style="margin: 15px">
@csrf
<table id="settings-table">
<tr>
<th>Name</th>
<th>Value</th>
</tr>
@forelse($settings as $name => $value)
<tr id="settings-row-{{ $name }}">
<td>
{{ $name }}
</td>
<td>
<input id="value-{{ $value }}" type="text" name="settings[{{ $name }}]" value="{{ $value }}">
<button type="button" onclick="deleteSetting('{{ $name }}')" class="delete-button"><i class="fa fa-trash"></i></button>
</td>
</tr>
@empty
<tr>
<td>No settings yet</td>
</tr>
@endforelse
</table>
<div style="margin: 15px 0;">
<input id="new-setting-name" style="display: inline-block;" type="text" placeholder="Name">
<input id="new-setting-value" style="display: inline-block;" type="text" placeholder="Value">
<button type="button" onclick="newSetting()">Add Setting</button>
</div>
<div>
<button type="submit">Save</button>
</div>
</form>
</div>
<script>
function newSetting() {
var name = document.getElementById('new-setting-name').value;
var value = document.getElementById('new-setting-value').value;
var existing = document.getElementById('value-' + name);
if (existing) {
existing.value = value;
} else {
// insert setting
var newValue = document.createElement('input');
newValue.id = 'value-' + name;
newValue.type = 'text';
newValue.name = 'settings[' + name + ']';
newValue.value = value;
var deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'delete-button';
deleteButton.onclick = () => deleteSetting(name);
var deleteIcon = document.createElement('i');
deleteIcon.className = 'fa fa-trash';
deleteButton.appendChild(deleteIcon);
var row = document.createElement('tr');
row.id = 'settings-row-' + name;
var col1 = document.createElement('td');
var col2 = document.createElement('td');
col1.innerText = name;
col2.appendChild(newValue);
col2.appendChild(document.createTextNode(' '));
col2.appendChild(deleteButton);
row.appendChild(col1);
row.appendChild(col2);
document.getElementById('settings-table').appendChild(row);
}
document.getElementById('new-setting-name').value = '';
document.getElementById('new-setting-value').value = '';
}
function deleteSetting(name) {
document.getElementById('settings-row-' + name).remove();
}
</script>
<style>
#settings-table td, #settings-table th {
padding: .2em;
}
.delete-button {
padding: 3px 5px;
}
</style>

16
app/Plugins/Hook.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
namespace App\Plugins;
interface Hook
{
/**
* Will be called by the plugin manager to check if the user is authorized. Will be called with Dependency Injection.
*/
// public function authorize(): bool;
/**
* Will be called by the plugin manager to execute this plugin at the correct time. Will be called with Dependency Injection.
*/
// public function handle();
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* DeviceHook.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2021 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Plugins\Hooks;
use App\Models\Device;
use App\Models\User;
use Illuminate\Support\Str;
abstract class DeviceOverviewHook
{
/** @var string */
public $view = 'resources.views.device-overview';
public function authorize(User $user, Device $device, array $settings): bool
{
return true;
}
public function data(Device $device): array
{
return [
'title' => __CLASS__,
'device' => $device,
];
}
final public function handle(string $pluginName, Device $device): \Illuminate\Contracts\View\View
{
return view(Str::start($this->view, "$pluginName::"), $this->data($device));
}
}

View File

@@ -0,0 +1,50 @@
<?php
/*
* PluginMenuEntry.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2021 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Plugins\Hooks;
use App\Models\User;
use Illuminate\Support\Str;
abstract class MenuEntryHook
{
/** @var string */
public $view = 'resources.views.menu';
public function authorize(User $user, array $settings): bool
{
return true;
}
public function data(): array
{
return [];
}
final public function handle(string $pluginName): array
{
return [Str::start($this->view, "$pluginName::"), $this->data()];
}
}

View File

@@ -0,0 +1,53 @@
<?php
/*
* SettingsHook.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2021 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Plugins\Hooks;
use App\Models\User;
use Illuminate\Support\Str;
abstract class PageHook
{
/** @var string */
public $view = 'resources.views.page';
public function authorize(User $user): bool
{
return true;
}
public function data(): array
{
return [
];
}
final public function handle(string $pluginName): array
{
return array_merge([
'settings_view' => Str::start($this->view, "$pluginName::"),
], $this->data());
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* PortPluginTab.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2021 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Plugins\Hooks;
use App\Models\Port;
use App\Models\User;
use App\Plugins\Hook;
use Illuminate\Support\Str;
abstract class PortTabHook implements Hook
{
/** @var string */
public $view = 'resources.views.port-tab';
public function authorize(User $user, Port $port, array $settings): bool
{
return true;
}
public function data(Port $port): array
{
return [
'title' => __CLASS__,
'port' => $port,
];
}
final public function handle(string $pluginName, Port $port): \Illuminate\Contracts\View\View
{
return view(Str::start($this->view, "$pluginName::"), $this->data($port));
}
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* SettingsHook.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2021 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Plugins\Hooks;
use App\Models\User;
use Illuminate\Support\Str;
abstract class SettingsHook
{
/** @var string */
public $view = 'resources.views.settings';
public function authorize(User $user, array $settings): bool
{
return true;
}
public function data(array $settings): array
{
return [
'settings' => $settings,
];
}
final public function handle(string $pluginName, array $settings): array
{
return array_merge([
'settings_view' => Str::start($this->view, "$pluginName::"),
], $this->data($settings));
}
}

View File

@@ -0,0 +1,256 @@
<?php
/*
* PluginManager.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2021 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Plugins;
use App\Exceptions\PluginException;
use App\Models\Plugin;
use Exception;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Log;
class PluginManager
{
/** @var Collection */
private $hooks;
/** @var Collection */
private $plugins;
/** @var array */
private $validPlugins = [];
public function __construct()
{
$this->hooks = new Collection;
}
/**
* Publish plugin hook, this is the main way to hook into different parts of LibreNMS.
* plugin_name should be unique. For internal (user) plugins in the app/Plugins directory, the directory name will be used.
* Hook type will be the full class name of the hook from app/Plugins/Hooks.
*
* @param string $pluginName
* @param string $hookType
* @param string $implementationClass
* @return bool
*/
public function publishHook(string $pluginName, string $hookType, string $implementationClass): bool
{
try {
$instance = new $implementationClass;
$this->validPlugins[$pluginName] = 1;
if ($instance instanceof $hookType && $this->pluginEnabled($pluginName)) {
if (! $this->hooks->has($hookType)) {
$this->hooks->put($hookType, new Collection);
}
$this->hooks->get($hookType)->push([
'plugin_name' => $pluginName,
'instance' => $instance,
]);
return true;
}
} catch (Exception $e) {
Log::error("Error when loading hook $implementationClass of type $hookType for $pluginName: " . $e->getMessage());
}
return false;
}
/**
* Check if there are any valid hooks
*
* @param string $hookType
* @param array $args
* @param string|null $plugin only for this plugin if set
* @return bool
*/
public function hasHooks(string $hookType, array $args = [], ?string $plugin = null): bool
{
return $this->hooksFor($hookType, $args, $plugin)->isNotEmpty();
}
/**
* Coll all hooks for the given hook type.
* args will be available for injection into the handle method to pass data through
* settings is automatically injected
*
* @param string $hookType
* @param array $args
* @param string|null $plugin only for this plugin if set
* @return \Illuminate\Support\Collection
*/
public function call(string $hookType, array $args = [], ?string $plugin = null): Collection
{
try {
return $this->hooksFor($hookType, $args, $plugin)
->map(function ($hook) use ($args) {
return app()->call([$hook['instance'], 'handle'], $this->fillArgs($args, $hook['plugin_name']));
});
} catch (Exception $e) {
Log::error("Error calling hook $hookType: " . $e->getMessage());
return new Collection;
}
}
/**
* Get the settings stored in the database for a plugin.
* One plugin shares the settings across all hooks
*
* @param string $pluginName
* @return array
*/
public function getSettings(string $pluginName): array
{
return (array) $this->getPlugin($pluginName)->settings;
}
/**
* Save settings array to the database for the given plugin
*
* @param string $pluginName
* @param array $settings
* @return bool
*/
public function setSettings(string $pluginName, array $settings): bool
{
$plugin = $this->getPlugin($pluginName);
$plugin->settings = $settings;
return $plugin->save();
}
/**
* Check if plugin exists.
* Does not create a DB entry if it does not exist.
*
* @param string $pluginName
* @return bool
*/
public function pluginExists(string $pluginName): bool
{
return $this->getPlugins()->has($pluginName);
}
/**
* Check if plugin of the given name is enabled.
* Creates DB entry if one does not exist yet.
*
* @param string $pluginName
* @return bool
*/
public function pluginEnabled(string $pluginName): bool
{
return $this->getPlugin($pluginName)->plugin_active;
}
/**
* Remove plugins that do not have any registered hooks.
*/
public function cleanupPlugins(): void
{
try {
$valid = array_keys($this->validPlugins);
Plugin::versionTwo()->whereNotIn('plugin_name', $valid)->get()->each->delete();
} catch (QueryException $qe) {
Log::error('Failed to clean up plugins: ' . $qe->getMessage());
}
}
protected function getPlugin(string $name): ?Plugin
{
$plugin = $this->getPlugins()->get($name);
if (! $plugin) {
try {
$plugin = Plugin::create([
'plugin_name' => $name,
'plugin_active' => 1,
'version' => 2,
]);
$this->getPlugins()->put($name, $plugin);
} catch (QueryException $e) {
// DB not migrated/connected
}
}
return $plugin;
}
protected function getPlugins(): Collection
{
if ($this->plugins === null) {
try {
$this->plugins = Plugin::versionTwo()->get()->keyBy('plugin_name');
} catch (QueryException $e) {
// DB not migrated/connected
$this->plugins = new Collection;
}
}
return $this->plugins;
}
/**
* @param string $hookType
* @param array $args
* @param string|null $onlyPlugin
* @return \Illuminate\Support\Collection
*/
protected function hooksFor(string $hookType, array $args, ?string $onlyPlugin): Collection
{
if (! $this->hooks->has($hookType)) {
return new Collection;
}
return $this->hooks->get($hookType)
->when($onlyPlugin, function (Collection $hooks, $only) {
return $hooks->where('plugin_name', $only);
})
->filter(function ($hook) use ($args) {
return app()->call([$hook['instance'], 'authorize'], $this->fillArgs($args, $hook['plugin_name']));
});
}
protected function fillArgs(array $args, string $pluginName): array
{
if (isset($args['settings'])) {
throw new PluginException('You cannot inject "settings", this is a reserved name');
}
if (isset($args['pluginName'])) {
throw new PluginException('You cannot inject "pluginName", this is a reserved name');
}
return array_merge($args, [
'pluginName' => $pluginName,
'settings' => $this->getSettings($pluginName),
]);
}
}