mirror of
https://github.com/pelican-dev/panel.git
synced 2025-12-24 11:04:01 +01:00
Plugin system (#1866)
This commit is contained in:
parent
2ab4c81e2a
commit
242a75bf3d
@ -76,13 +76,14 @@ RUN chown root:www-data ./ \
|
||||
# Files should not have execute set, but directories need it
|
||||
&& find ./ -type d -exec chmod 750 {} \; \
|
||||
# Create necessary directories
|
||||
&& mkdir -p /pelican-data/storage /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
|
||||
# Symlinks for env, database, and avatars
|
||||
&& mkdir -p /pelican-data/storage /pelican-data/plugins /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
|
||||
# Symlinks for env, database, storage, and plugins
|
||||
&& ln -s /pelican-data/.env ./.env \
|
||||
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
|
||||
&& ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
|
||||
&& ln -s /pelican-data/storage/avatars /var/www/html/storage/app/public/avatars \
|
||||
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
|
||||
&& ln -s /pelican-data/plugins /var/www/html/plugins \
|
||||
# Allow www-data write permissions where necessary
|
||||
&& chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
|
||||
&& chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
|
||||
|
||||
@ -80,13 +80,14 @@ RUN chown root:www-data ./ \
|
||||
# Files should not have execute set, but directories need it
|
||||
&& find ./ -type d -exec chmod 750 {} \; \
|
||||
# Create necessary directories
|
||||
&& mkdir -p /pelican-data/storage /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
|
||||
# Symlinks for env, database, and avatars
|
||||
&& mkdir -p /pelican-data/storage /pelican-data/plugins /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
|
||||
# Symlinks for env, database, storage, and plugins
|
||||
&& ln -s /pelican-data/.env ./.env \
|
||||
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
|
||||
&& ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
|
||||
&& ln -s /pelican-data/storage/avatars /var/www/html/storage/app/public/avatars \
|
||||
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
|
||||
&& ln -s /pelican-data/plugins /var/www/html/plugins \
|
||||
# Allow www-data write permissions where necessary
|
||||
&& chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
|
||||
&& chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
|
||||
|
||||
25
app/Console/Commands/Plugin/ComposerPluginsCommand.php
Normal file
25
app/Console/Commands/Plugin/ComposerPluginsCommand.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Services\Helpers\PluginService;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ComposerPluginsCommand extends Command
|
||||
{
|
||||
protected $signature = 'p:plugin:composer';
|
||||
|
||||
protected $description = 'Makes sure the needed composer packages for all installed plugins are available.';
|
||||
|
||||
public function handle(PluginService $pluginService): void
|
||||
{
|
||||
try {
|
||||
$pluginService->manageComposerPackages();
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
|
||||
$this->error($exception->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Console/Commands/Plugin/DisablePluginCommand.php
Normal file
37
app/Console/Commands/Plugin/DisablePluginCommand.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use App\Services\Helpers\PluginService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class DisablePluginCommand extends Command
|
||||
{
|
||||
protected $signature = 'p:plugin:disable {id?}';
|
||||
|
||||
protected $description = 'Disables a plugin';
|
||||
|
||||
public function handle(PluginService $pluginService): void
|
||||
{
|
||||
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
|
||||
|
||||
$plugin = Plugin::find($id);
|
||||
|
||||
if (!$plugin) {
|
||||
$this->error('Plugin does not exist!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$plugin->canDisable()) {
|
||||
$this->error("Plugin can't be disabled!");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$pluginService->disablePlugin($plugin);
|
||||
|
||||
$this->info('Plugin disabled.');
|
||||
}
|
||||
}
|
||||
38
app/Console/Commands/Plugin/InstallPluginCommand.php
Normal file
38
app/Console/Commands/Plugin/InstallPluginCommand.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Enums\PluginStatus;
|
||||
use App\Models\Plugin;
|
||||
use App\Services\Helpers\PluginService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class InstallPluginCommand extends Command
|
||||
{
|
||||
protected $signature = 'p:plugin:install {id?}';
|
||||
|
||||
protected $description = 'Installs a plugin';
|
||||
|
||||
public function handle(PluginService $pluginService): void
|
||||
{
|
||||
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
|
||||
|
||||
$plugin = Plugin::find($id);
|
||||
|
||||
if (!$plugin) {
|
||||
$this->error('Plugin does not exist!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($plugin->status !== PluginStatus::NotInstalled) {
|
||||
$this->error('Plugin is already installed!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$pluginService->installPlugin($plugin);
|
||||
|
||||
$this->info('Plugin installed and enabled.');
|
||||
}
|
||||
}
|
||||
28
app/Console/Commands/Plugin/ListPluginsCommand.php
Normal file
28
app/Console/Commands/Plugin/ListPluginsCommand.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ListPluginsCommand extends Command
|
||||
{
|
||||
protected $signature = 'p:plugin:list';
|
||||
|
||||
protected $description = 'List all installed plugins';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$plugins = Plugin::query()->get(['name', 'author', 'status', 'version', 'panels', 'category']);
|
||||
|
||||
if (count($plugins) < 1) {
|
||||
$this->warn('No plugins installed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->table(['Name', 'Author', 'Status', 'Version', 'Panels', 'Category'], $plugins->toArray());
|
||||
|
||||
$this->output->newLine();
|
||||
}
|
||||
}
|
||||
135
app/Console/Commands/Plugin/MakePluginCommand.php
Normal file
135
app/Console/Commands/Plugin/MakePluginCommand.php
Normal file
@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Enums\PluginCategory;
|
||||
use App\Enums\PluginStatus;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MakePluginCommand extends Command
|
||||
{
|
||||
protected $signature = 'p:plugin:make
|
||||
{--name=}
|
||||
{--author=}
|
||||
{--description=}
|
||||
{--category=}
|
||||
{--url=}
|
||||
{--updateUrl=}
|
||||
{--panels=}
|
||||
{--panelVersion=}';
|
||||
|
||||
protected $description = 'Create a new plugin';
|
||||
|
||||
public function __construct(private Filesystem $filesystem)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$name = $this->option('name') ?? $this->ask('Name');
|
||||
$name = preg_replace('/[^A-Za-z0-9 ]/', '', Str::ascii($name));
|
||||
|
||||
$id = Str::slug($name);
|
||||
|
||||
if ($this->filesystem->exists(plugin_path($id))) {
|
||||
$this->error('Plugin with that name already exists!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$author = $this->option('author') ?? $this->ask('Author', cache('plugin.author'));
|
||||
$author = preg_replace('/[^A-Za-z0-9 ]/', '', Str::ascii($author));
|
||||
cache()->forever('plugin.author', $author);
|
||||
|
||||
$namespace = Str::studly($author) . '\\' . Str::studly($name);
|
||||
$class = Str::studly($name . 'Plugin');
|
||||
|
||||
if (class_exists('\\' . $namespace . '\\' . $class)) {
|
||||
$this->error('Plugin class with that name already exists!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info('Creating Plugin "' . $name . '" (' . $id . ') by ' . $author);
|
||||
|
||||
$description = $this->option('description') ?? $this->ask('Description (can be empty)');
|
||||
|
||||
$category = $this->option('category') ?? $this->choice('Category', collect(PluginCategory::cases())->mapWithKeys(fn (PluginCategory $category) => [$category->value => $category->getLabel()])->toArray(), PluginCategory::Plugin->value);
|
||||
|
||||
if (!PluginCategory::tryFrom($category)) {
|
||||
$this->error('Unknown plugin category!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$url = $this->option('url') ?? $this->ask('URL (can be empty)');
|
||||
$updateUrl = $this->option('updateUrl') ?? $this->ask('Update URL (can be empty)');
|
||||
|
||||
$panels = $this->option('panels');
|
||||
if (!$panels) {
|
||||
if ($this->confirm('Should the plugin be available on all panels?', true)) {
|
||||
$panels = null;
|
||||
} else {
|
||||
$panels = $this->choice('Panels (comma separated list)', [
|
||||
'admin' => 'Admin Area',
|
||||
'server' => 'Client Area',
|
||||
'app' => 'Server List',
|
||||
], multiple: true);
|
||||
}
|
||||
}
|
||||
$panels = is_string($panels) ? explode(',', $panels) : $panels;
|
||||
|
||||
$panelVersion = $this->option('panelVersion');
|
||||
if (!$panelVersion) {
|
||||
$panelVersion = $this->ask('Required panel version (leave empty for no constraint)', config('app.version') === 'canary' ? null : config('app.version'));
|
||||
|
||||
if ($panelVersion && $this->confirm("Should the version constraint be minimal instead of strict? ($panelVersion or higher instead of only $panelVersion)")) {
|
||||
$panelVersion = "^$panelVersion";
|
||||
}
|
||||
}
|
||||
|
||||
$composerPackages = null;
|
||||
// TODO: ask for composer packages?
|
||||
|
||||
// Create base directory
|
||||
$this->filesystem->makeDirectory(plugin_path($id));
|
||||
|
||||
// Write plugin.json
|
||||
$this->filesystem->put(plugin_path($id, 'plugin.json'), json_encode([
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'author' => $author,
|
||||
'version' => '1.0.0',
|
||||
'description' => $description,
|
||||
'category' => $category,
|
||||
'url' => $url,
|
||||
'update_url' => $updateUrl,
|
||||
'namespace' => $namespace,
|
||||
'class' => $class,
|
||||
'panels' => $panels,
|
||||
'panel_version' => $panelVersion,
|
||||
'composer_packages' => $composerPackages,
|
||||
'meta' => [
|
||||
'status' => PluginStatus::Enabled,
|
||||
'status_message' => null,
|
||||
],
|
||||
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
// Create src directory and create main class
|
||||
$this->filesystem->makeDirectory(plugin_path($id, 'src'));
|
||||
$this->filesystem->put(plugin_path($id, 'src', $class . '.php'), Str::replace(['$namespace$', '$class$', '$id$'], [$namespace, $class, $id], file_get_contents(__DIR__ . '/Plugin.stub')));
|
||||
|
||||
// Create Providers directory and create service provider
|
||||
$this->filesystem->makeDirectory(plugin_path($id, 'src', 'Providers'));
|
||||
$this->filesystem->put(plugin_path($id, 'src', 'Providers', $class . 'Provider.php'), Str::replace(['$namespace$', '$class$'], [$namespace, $class], file_get_contents(__DIR__ . '/PluginProvider.stub')));
|
||||
|
||||
// Create config directory and create config file
|
||||
$this->filesystem->makeDirectory(plugin_path($id, 'config'));
|
||||
$this->filesystem->put(plugin_path($id, 'config', $id . '.php'), Str::replace(['$name$'], [$name], file_get_contents(__DIR__ . '/PluginConfig.stub')));
|
||||
|
||||
$this->info('Plugin created.');
|
||||
}
|
||||
}
|
||||
25
app/Console/Commands/Plugin/Plugin.stub
Normal file
25
app/Console/Commands/Plugin/Plugin.stub
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace $namespace$;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
class $class$ implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return '$id$';
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
// Allows you to use any configuration option that is available to the panel.
|
||||
// This includes registering resources, custom pages, themes, render hooks and more.
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void
|
||||
{
|
||||
// Is run only when the panel that the plugin is being registered to is actually in-use. It is executed by a middleware class.
|
||||
}
|
||||
}
|
||||
5
app/Console/Commands/Plugin/PluginConfig.stub
Normal file
5
app/Console/Commands/Plugin/PluginConfig.stub
Normal file
@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Config values for $name$
|
||||
];
|
||||
18
app/Console/Commands/Plugin/PluginProvider.stub
Normal file
18
app/Console/Commands/Plugin/PluginProvider.stub
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace $namespace$\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class $class$Provider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
43
app/Console/Commands/Plugin/UninstallPluginCommand.php
Normal file
43
app/Console/Commands/Plugin/UninstallPluginCommand.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Enums\PluginStatus;
|
||||
use App\Models\Plugin;
|
||||
use App\Services\Helpers\PluginService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class UninstallPluginCommand extends Command
|
||||
{
|
||||
protected $signature = 'p:plugin:uninstall {id?} {--delete : Delete the plugin files}';
|
||||
|
||||
protected $description = 'Uninstalls a plugin';
|
||||
|
||||
public function handle(PluginService $pluginService): void
|
||||
{
|
||||
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
|
||||
|
||||
$plugin = Plugin::find($id);
|
||||
|
||||
if (!$plugin) {
|
||||
$this->error('Plugin does not exist!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($plugin->status === PluginStatus::NotInstalled) {
|
||||
$this->error('Plugin is not installed!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$deleteFiles = $this->option('delete');
|
||||
if ($this->input->isInteractive() && !$deleteFiles) {
|
||||
$deleteFiles = $this->confirm('Do you also want to delete the plugin files?');
|
||||
}
|
||||
|
||||
$pluginService->uninstallPlugin($plugin, $deleteFiles);
|
||||
|
||||
$this->info('Plugin uninstalled' . ($deleteFiles ? ' and files deleted' : '') . '.');
|
||||
}
|
||||
}
|
||||
37
app/Console/Commands/Plugin/UpdatePluginCommand.php
Normal file
37
app/Console/Commands/Plugin/UpdatePluginCommand.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use App\Services\Helpers\PluginService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class UpdatePluginCommand extends Command
|
||||
{
|
||||
protected $signature = 'p:plugin:update {id?}';
|
||||
|
||||
protected $description = 'Updates a plugin';
|
||||
|
||||
public function handle(PluginService $pluginService): void
|
||||
{
|
||||
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
|
||||
|
||||
$plugin = Plugin::find($id);
|
||||
|
||||
if (!$plugin) {
|
||||
$this->error('Plugin does not exist!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$plugin->isUpdateAvailable()) {
|
||||
$this->error("Plugin doesn't need updating!");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$pluginService->updatePlugin($plugin);
|
||||
|
||||
$this->info('Plugin updated.');
|
||||
}
|
||||
}
|
||||
18
app/Contracts/Plugins/HasPluginSettings.php
Normal file
18
app/Contracts/Plugins/HasPluginSettings.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Plugins;
|
||||
|
||||
use Filament\Schemas\Components\Component;
|
||||
|
||||
interface HasPluginSettings
|
||||
{
|
||||
/**
|
||||
* @return Component[]
|
||||
*/
|
||||
public function getSettingsForm(): array;
|
||||
|
||||
/**
|
||||
* @param array<mixed, mixed> $data
|
||||
*/
|
||||
public function saveSettings(array $data): void;
|
||||
}
|
||||
27
app/Enums/PluginCategory.php
Normal file
27
app/Enums/PluginCategory.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Filament\Support\Contracts\HasIcon;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
enum PluginCategory: string implements HasIcon, HasLabel
|
||||
{
|
||||
case Plugin = 'plugin';
|
||||
case Theme = 'theme';
|
||||
case Language = 'language';
|
||||
|
||||
public function getIcon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Plugin => 'tabler-package',
|
||||
self::Theme => 'tabler-palette',
|
||||
self::Language => 'tabler-language',
|
||||
};
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return trans('admin/plugin.category_enum.' . $this->value);
|
||||
}
|
||||
}
|
||||
43
app/Enums/PluginStatus.php
Normal file
43
app/Enums/PluginStatus.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Filament\Support\Contracts\HasColor;
|
||||
use Filament\Support\Contracts\HasIcon;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
enum PluginStatus: string implements HasColor, HasIcon, HasLabel
|
||||
{
|
||||
case NotInstalled = 'not_installed';
|
||||
case Disabled = 'disabled';
|
||||
case Enabled = 'enabled';
|
||||
case Errored = 'errored';
|
||||
case Incompatible = 'incompatible';
|
||||
|
||||
public function getIcon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::NotInstalled => 'tabler-heart-off',
|
||||
self::Disabled => 'tabler-heart-x',
|
||||
self::Enabled => 'tabler-heart-check',
|
||||
self::Errored => 'tabler-heart-broken',
|
||||
self::Incompatible => 'tabler-heart-cancel',
|
||||
};
|
||||
}
|
||||
|
||||
public function getColor(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::NotInstalled => 'gray',
|
||||
self::Disabled => 'warning',
|
||||
self::Enabled => 'success',
|
||||
self::Errored => 'danger',
|
||||
self::Incompatible => 'danger',
|
||||
};
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return trans('admin/plugin.status_enum.' . $this->value);
|
||||
}
|
||||
}
|
||||
43
app/Filament/Admin/Resources/Plugins/Pages/ListPlugins.php
Normal file
43
app/Filament/Admin/Resources/Plugins/Pages/ListPlugins.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Plugins\Pages;
|
||||
|
||||
use App\Enums\PluginCategory;
|
||||
use App\Filament\Admin\Resources\Plugins\PluginResource;
|
||||
use App\Models\Plugin;
|
||||
use App\Services\Helpers\PluginService;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
|
||||
class ListPlugins extends ListRecords
|
||||
{
|
||||
protected static string $resource = PluginResource::class;
|
||||
|
||||
public function reorderTable(array $order, int|string|null $draggedRecordKey = null): void
|
||||
{
|
||||
/** @var PluginService $pluginService */
|
||||
$pluginService = app(PluginService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
|
||||
|
||||
$pluginService->updateLoadOrder($order);
|
||||
}
|
||||
|
||||
public function getTabs(): array
|
||||
{
|
||||
$tabs = [
|
||||
'all' => Tab::make('all')
|
||||
->label(trans('admin/plugin.all'))
|
||||
->badge(Plugin::count()),
|
||||
];
|
||||
|
||||
foreach (PluginCategory::cases() as $category) {
|
||||
$query = Plugin::whereCategory($category->value);
|
||||
$tabs[$category->value] = Tab::make($category->value)
|
||||
->label($category->getLabel())
|
||||
->icon($category->getIcon())
|
||||
->badge($query->count())
|
||||
->modifyQueryUsing(fn () => $query);
|
||||
}
|
||||
|
||||
return $tabs;
|
||||
}
|
||||
}
|
||||
313
app/Filament/Admin/Resources/Plugins/PluginResource.php
Normal file
313
app/Filament/Admin/Resources/Plugins/PluginResource.php
Normal file
@ -0,0 +1,313 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Plugins;
|
||||
|
||||
use App\Enums\PluginStatus;
|
||||
use App\Filament\Admin\Resources\Plugins\Pages\ListPlugins;
|
||||
use App\Models\Plugin;
|
||||
use App\Services\Helpers\PluginService;
|
||||
use Exception;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Support\Enums\IconSize;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class PluginResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Plugin::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'tabler-packages';
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return trans('admin/plugin.nav_title');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return trans('admin/plugin.model_label');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return trans('admin/plugin.model_label_plural');
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return (string) static::getEloquentQuery()->count() ?: null;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->openRecordUrlInNewTab()
|
||||
->reorderable('load_order')
|
||||
->authorizeReorder(fn () => user()?->can('update plugin'))
|
||||
->reorderRecordsTriggerAction(fn (Action $action, bool $isReordering) => $action->hiddenLabel()->tooltip($isReordering ? trans('admin/plugin.apply_load_order') : trans('admin/plugin.change_load_order')))
|
||||
->defaultSort('load_order')
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(trans('admin/plugin.name'))
|
||||
->description(fn (Plugin $plugin) => (strlen($plugin->description) > 80) ? substr($plugin->description, 0, 80).'...' : $plugin->description)
|
||||
->icon(fn (Plugin $plugin) => $plugin->isUpdateAvailable() ? 'tabler-versions-off' : 'tabler-versions')
|
||||
->iconColor(fn (Plugin $plugin) => $plugin->isUpdateAvailable() ? 'danger' : 'success')
|
||||
->tooltip(fn (Plugin $plugin) => $plugin->isUpdateAvailable() ? trans('admin/plugin.update_available') : null)
|
||||
->sortable()
|
||||
->searchable(),
|
||||
TextColumn::make('author')
|
||||
->label(trans('admin/plugin.author'))
|
||||
->sortable(),
|
||||
TextColumn::make('version')
|
||||
->label(trans('admin/plugin.version'))
|
||||
->sortable(),
|
||||
TextColumn::make('category')
|
||||
->label(trans('admin/plugin.category'))
|
||||
->badge()
|
||||
->sortable()
|
||||
->visible(fn ($livewire) => $livewire->activeTab === 'all'),
|
||||
TextColumn::make('status')
|
||||
->label(trans('admin/plugin.status'))
|
||||
->badge()
|
||||
->tooltip(fn (Plugin $plugin) => $plugin->status_message)
|
||||
->sortable(),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('view')
|
||||
->label(trans('filament-actions::view.single.label'))
|
||||
->icon(fn (Plugin $plugin) => $plugin->getReadme() ? 'tabler-eye' : 'tabler-eye-share')
|
||||
->color('gray')
|
||||
->visible(fn (Plugin $plugin) => $plugin->getReadme() || $plugin->url)
|
||||
->url(fn (Plugin $plugin) => !$plugin->getReadme() ? $plugin->url : null, true)
|
||||
->slideOver(true)
|
||||
->modalHeading('Readme')
|
||||
->modalSubmitAction(fn (Plugin $plugin) => Action::make('visit_website')
|
||||
->label(trans('admin/plugin.visit_website'))
|
||||
->visible(!is_null($plugin->url))
|
||||
->url($plugin->url, true)
|
||||
)
|
||||
->modalCancelActionLabel(trans('filament::components/modal.actions.close.label'))
|
||||
->schema(fn (Plugin $plugin) => $plugin->getReadme() ? [
|
||||
TextEntry::make('readme')
|
||||
->hiddenLabel()
|
||||
->markdown()
|
||||
->state(fn (Plugin $plugin) => $plugin->getReadme()),
|
||||
] : null),
|
||||
Action::make('settings')
|
||||
->label(trans('admin/plugin.settings'))
|
||||
->authorize(fn (Plugin $plugin) => user()?->can('update', $plugin))
|
||||
->icon('tabler-settings')
|
||||
->color('primary')
|
||||
->visible(fn (Plugin $plugin) => $plugin->status === PluginStatus::Enabled && $plugin->hasSettings())
|
||||
->schema(fn (Plugin $plugin) => $plugin->getSettingsForm())
|
||||
->action(fn (array $data, Plugin $plugin) => $plugin->saveSettings($data))
|
||||
->slideOver(),
|
||||
ActionGroup::make([
|
||||
Action::make('install')
|
||||
->label(trans('admin/plugin.install'))
|
||||
->authorize(fn (Plugin $plugin) => user()?->can('update', $plugin))
|
||||
->icon('tabler-terminal')
|
||||
->color('success')
|
||||
->hidden(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled)
|
||||
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
|
||||
$pluginService->installPlugin($plugin, !$plugin->isTheme() || !$pluginService->hasThemePluginEnabled());
|
||||
|
||||
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(trans('admin/plugin.notifications.installed'))
|
||||
->send();
|
||||
}),
|
||||
Action::make('update')
|
||||
->label(trans('admin/plugin.update'))
|
||||
->authorize(fn (Plugin $plugin) => user()?->can('update', $plugin))
|
||||
->icon('tabler-download')
|
||||
->color('success')
|
||||
->visible(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled && $plugin->isUpdateAvailable())
|
||||
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
|
||||
$pluginService->updatePlugin($plugin);
|
||||
|
||||
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(trans('admin/plugin.notifications.updated'))
|
||||
->send();
|
||||
}),
|
||||
Action::make('enable')
|
||||
->label(trans('admin/plugin.enable'))
|
||||
->authorize(fn (Plugin $plugin) => user()?->can('update', $plugin))
|
||||
->icon('tabler-check')
|
||||
->color('success')
|
||||
->visible(fn (Plugin $plugin) => $plugin->canEnable())
|
||||
->requiresConfirmation(fn (Plugin $plugin, PluginService $pluginService) => $plugin->isTheme() && $pluginService->hasThemePluginEnabled())
|
||||
->modalHeading(fn (Plugin $plugin, PluginService $pluginService) => $plugin->isTheme() && $pluginService->hasThemePluginEnabled() ? trans('admin/plugin.enable_theme_modal.heading') : null)
|
||||
->modalDescription(fn (Plugin $plugin, PluginService $pluginService) => $plugin->isTheme() && $pluginService->hasThemePluginEnabled() ? trans('admin/plugin.enable_theme_modal.description') : null)
|
||||
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
|
||||
$pluginService->enablePlugin($plugin);
|
||||
|
||||
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(trans('admin/plugin.notifications.updated'))
|
||||
->send();
|
||||
}),
|
||||
Action::make('disable')
|
||||
->label(trans('admin/plugin.disable'))
|
||||
->authorize(fn (Plugin $plugin) => user()?->can('update', $plugin))
|
||||
->icon('tabler-x')
|
||||
->color('warning')
|
||||
->visible(fn (Plugin $plugin) => $plugin->canDisable())
|
||||
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
|
||||
$pluginService->disablePlugin($plugin);
|
||||
|
||||
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(trans('admin/plugin.notifications.disabled'))
|
||||
->send();
|
||||
}),
|
||||
Action::make('delete')
|
||||
->label(trans('filament-actions::delete.single.label'))
|
||||
->authorize(fn (Plugin $plugin) => user()?->can('delete', $plugin))
|
||||
->icon('tabler-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled)
|
||||
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
|
||||
$pluginService->deletePlugin($plugin);
|
||||
|
||||
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(trans('admin/plugin.notifications.deleted'))
|
||||
->send();
|
||||
}),
|
||||
Action::make('uninstall')
|
||||
->label(trans('admin/plugin.uninstall'))
|
||||
->authorize(fn (Plugin $plugin) => user()?->can('update', $plugin))
|
||||
->icon('tabler-terminal')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->hidden(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled)
|
||||
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
|
||||
$pluginService->uninstallPlugin($plugin);
|
||||
|
||||
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(trans('admin/plugin.notifications.uninstalled'))
|
||||
->send();
|
||||
}),
|
||||
]),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('import_from_file')
|
||||
->label(trans('admin/plugin.import_from_file'))
|
||||
->authorize(fn () => user()?->can('create', Plugin::class))
|
||||
->icon('tabler-file-download')
|
||||
->iconButton()
|
||||
->iconSize(IconSize::ExtraLarge)
|
||||
->schema([
|
||||
// TODO: switch to new file upload
|
||||
FileUpload::make('file')
|
||||
->required()
|
||||
->acceptedFileTypes(['application/zip', 'application/zip-compressed', 'application/x-zip-compressed'])
|
||||
->preserveFilenames()
|
||||
->previewable(false)
|
||||
->storeFiles(false),
|
||||
])
|
||||
->action(function ($data, $livewire, PluginService $pluginService) {
|
||||
try {
|
||||
/** @var UploadedFile $file */
|
||||
$file = $data['file'];
|
||||
|
||||
$pluginName = str($file->getClientOriginalName())->before('.zip')->toString();
|
||||
|
||||
if (Plugin::where('id', $pluginName)->exists()) {
|
||||
throw new Exception(trans('admin/plugin.notifications.import_exists'));
|
||||
}
|
||||
|
||||
$pluginService->downloadPluginFromFile($file);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(trans('admin/plugin.notifications.imported'))
|
||||
->send();
|
||||
|
||||
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title(trans('admin/plugin.notifications.import_failed'))
|
||||
->body($exception->getMessage())
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
Action::make('import_from_url')
|
||||
->label(trans('admin/plugin.import_from_url'))
|
||||
->authorize(fn () => user()?->can('create', Plugin::class))
|
||||
->icon('tabler-world-download')
|
||||
->iconButton()
|
||||
->iconSize(IconSize::ExtraLarge)
|
||||
->schema([
|
||||
TextInput::make('url')
|
||||
->required()
|
||||
->url()
|
||||
->endsWith('.zip'),
|
||||
])
|
||||
->action(function ($data, $livewire, PluginService $pluginService) {
|
||||
try {
|
||||
$pluginName = str($data['url'])->before('.zip')->explode('/')->last();
|
||||
|
||||
if (Plugin::where('id', $pluginName)->exists()) {
|
||||
throw new Exception(trans('admin/plugin.notifications.import_exists'));
|
||||
}
|
||||
|
||||
$pluginService->downloadPluginFromUrl($data['url']);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(trans('admin/plugin.notifications.imported'))
|
||||
->send();
|
||||
|
||||
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title(trans('admin/plugin.notifications.import_failed'))
|
||||
->body($exception->getMessage())
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
])
|
||||
->emptyStateIcon('tabler-packages')
|
||||
->emptyStateDescription('')
|
||||
->emptyStateHeading(trans('admin/plugin.no_plugins'));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListPlugins::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
393
app/Models/Plugin.php
Normal file
393
app/Models/Plugin.php
Normal file
@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Contracts\Plugins\HasPluginSettings;
|
||||
use App\Enums\PluginCategory;
|
||||
use App\Enums\PluginStatus;
|
||||
use App\Facades\Plugins;
|
||||
use Exception;
|
||||
use Filament\Schemas\Components\Component;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use JsonException;
|
||||
use Sushi\Sushi;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property string $author
|
||||
* @property string $version
|
||||
* @property string|null $description
|
||||
* @property PluginCategory $category
|
||||
* @property string|null $url
|
||||
* @property string|null $update_url
|
||||
* @property string $namespace
|
||||
* @property string $class
|
||||
* @property string|null $panels
|
||||
* @property string|null $panel_version
|
||||
* @property string|null $composer_packages
|
||||
* @property PluginStatus $status
|
||||
* @property string|null $status_message
|
||||
* @property int $load_order
|
||||
*/
|
||||
class Plugin extends Model implements HasPluginSettings
|
||||
{
|
||||
use Sushi;
|
||||
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $fillable = [
|
||||
'status',
|
||||
'status_message',
|
||||
'load_order',
|
||||
];
|
||||
|
||||
/** @return string[] */
|
||||
public function getSchema(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'string',
|
||||
'name' => 'string',
|
||||
'author' => 'string',
|
||||
'version' => 'string',
|
||||
'description' => 'string',
|
||||
'category' => 'string',
|
||||
'url' => 'string',
|
||||
'update_url' => 'string',
|
||||
'namespace' => 'string',
|
||||
'class' => 'string',
|
||||
'panels' => 'string',
|
||||
'panel_version' => 'string',
|
||||
'composer_packages' => 'string',
|
||||
'status' => 'string',
|
||||
'status_message' => 'string',
|
||||
'load_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{
|
||||
* id: string,
|
||||
* name: string,
|
||||
* author: string,
|
||||
* version: string,
|
||||
* description: ?string,
|
||||
* category: string,
|
||||
* url: ?string,
|
||||
* update_url: ?string,
|
||||
* namespace: string,
|
||||
* class: string,
|
||||
* panels: ?string,
|
||||
* panel_version: ?string,
|
||||
* composer_packages: ?string,
|
||||
* status: string,
|
||||
* status_message: ?string,
|
||||
* load_order: int
|
||||
* }>
|
||||
*/
|
||||
public function getRows(): array
|
||||
{
|
||||
$plugins = [];
|
||||
|
||||
$directories = File::directories(base_path('plugins/'));
|
||||
foreach ($directories as $directory) {
|
||||
$plugin = File::basename($directory);
|
||||
|
||||
$path = plugin_path($plugin, 'plugin.json');
|
||||
if (!file_exists($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = File::json($path, JSON_THROW_ON_ERROR);
|
||||
|
||||
if ($data['id'] !== $plugin) {
|
||||
throw new Exception("Plugin id mismatch for folder name ($plugin) and id in plugin.json ({$data['id']})!");
|
||||
}
|
||||
|
||||
$panels = null;
|
||||
if (array_key_exists('panels', $data)) {
|
||||
$panels = $data['panels'];
|
||||
$panels = is_array($panels) ? implode(',', $panels) : $panels;
|
||||
}
|
||||
|
||||
$composerPackages = null;
|
||||
if (array_key_exists('composer_packages', $data)) {
|
||||
$composerPackages = $data['composer_packages'];
|
||||
$composerPackages = is_array($composerPackages) ? json_encode($composerPackages, JSON_THROW_ON_ERROR) : $composerPackages;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => $data['id'],
|
||||
'name' => $data['name'],
|
||||
'author' => $data['author'],
|
||||
'version' => Arr::get($data, 'version', '1.0.0'),
|
||||
'description' => Arr::get($data, 'description', null),
|
||||
'category' => $data['category'],
|
||||
'url' => Arr::get($data, 'url', null),
|
||||
'update_url' => Arr::get($data, 'update_url', null),
|
||||
'namespace' => $data['namespace'],
|
||||
'class' => $data['class'],
|
||||
'panels' => $panels,
|
||||
'panel_version' => Arr::get($data, 'panel_version', null),
|
||||
'composer_packages' => $composerPackages,
|
||||
|
||||
'status' => Str::lower(Arr::get($data, 'meta.status', PluginStatus::NotInstalled->value)),
|
||||
'status_message' => Arr::get($data, 'meta.status_message', null),
|
||||
'load_order' => Arr::integer($data, 'meta.load_order', 0),
|
||||
];
|
||||
|
||||
$plugins[] = $data;
|
||||
} catch (Exception $exception) {
|
||||
if (config('panel.plugin.dev_mode', false)) {
|
||||
throw ($exception);
|
||||
}
|
||||
|
||||
report($exception);
|
||||
|
||||
if (!$exception instanceof JsonException) {
|
||||
$plugins[] = [
|
||||
'id' => $data['id'] ?? Str::uuid(),
|
||||
'name' => $data['name'] ?? $plugin,
|
||||
'author' => $data['author'] ?? 'Unknown',
|
||||
'version' => '0.0.0',
|
||||
'description' => 'Plugin.json is invalid!',
|
||||
'category' => PluginCategory::Plugin->value,
|
||||
'url' => null,
|
||||
'update_url' => null,
|
||||
'namespace' => 'Error',
|
||||
'class' => 'Error',
|
||||
'panels' => null,
|
||||
'panel_version' => null,
|
||||
'composer_packages' => null,
|
||||
|
||||
'status' => PluginStatus::Errored->value,
|
||||
'status_message' => $exception->getMessage(),
|
||||
'load_order' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $plugins;
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => PluginStatus::class,
|
||||
'category' => PluginCategory::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function fullClass(): string
|
||||
{
|
||||
return '\\' . $this->namespace . '\\' . $this->class;
|
||||
}
|
||||
|
||||
public function shouldLoad(?string $panelId = null): bool
|
||||
{
|
||||
return ($this->status === PluginStatus::Enabled || $this->status === PluginStatus::Errored) && (is_null($panelId) || !$this->panels || in_array($panelId, explode(',', $this->panels)));
|
||||
}
|
||||
|
||||
public function canEnable(): bool
|
||||
{
|
||||
return $this->status === PluginStatus::Disabled && $this->isCompatible();
|
||||
}
|
||||
|
||||
public function canDisable(): bool
|
||||
{
|
||||
return $this->status !== PluginStatus::Disabled && $this->status !== PluginStatus::NotInstalled && $this->isCompatible();
|
||||
}
|
||||
|
||||
public function isCompatible(): bool
|
||||
{
|
||||
$currentPanelVersion = config('app.version', 'canary');
|
||||
|
||||
return !$this->panel_version || $currentPanelVersion === 'canary' || version_compare($currentPanelVersion, str($this->panel_version)->trim('^'), $this->isPanelVersionStrict() ? '=' : '>=');
|
||||
}
|
||||
|
||||
public function isPanelVersionStrict(): bool
|
||||
{
|
||||
if (!$this->panel_version) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !str($this->panel_version)->startsWith('^');
|
||||
}
|
||||
|
||||
public function isTheme(): bool
|
||||
{
|
||||
return $this->category === PluginCategory::Theme;
|
||||
}
|
||||
|
||||
public function isLanguage(): bool
|
||||
{
|
||||
return $this->category === PluginCategory::Language;
|
||||
}
|
||||
|
||||
/** @return null|array<string, array{version: string, download_url: string}> */
|
||||
private function getUpdateData(): ?array
|
||||
{
|
||||
if (!$this->update_url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cache()->remember("plugins.$this->id.update", now()->addMinutes(10), function () {
|
||||
try {
|
||||
$data = Http::timeout(5)->connectTimeout(1)->get($this->update_url)->throw()->json();
|
||||
|
||||
// Support update jsons that cover multiple plugins
|
||||
if (array_key_exists($this->id, $data)) {
|
||||
$data = $data[$this->id];
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public function isUpdateAvailable(): bool
|
||||
{
|
||||
$panelVersion = config('app.version', 'canary');
|
||||
|
||||
if ($panelVersion === 'canary') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$updateData = $this->getUpdateData();
|
||||
if ($updateData) {
|
||||
if (array_key_exists($panelVersion, $updateData)) {
|
||||
return version_compare($updateData[$panelVersion]['version'], $this->version, '>');
|
||||
}
|
||||
|
||||
if (array_key_exists('*', $updateData)) {
|
||||
return version_compare($updateData['*']['version'], $this->version, '>');
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getDownloadUrlForUpdate(): ?string
|
||||
{
|
||||
$panelVersion = config('app.version', 'canary');
|
||||
|
||||
if ($panelVersion === 'canary') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$updateData = $this->getUpdateData();
|
||||
if ($updateData) {
|
||||
if (array_key_exists($panelVersion, $updateData)) {
|
||||
return $updateData[$panelVersion]['download_url'];
|
||||
}
|
||||
|
||||
if (array_key_exists('*', $updateData)) {
|
||||
return $updateData['*']['download_url'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function hasSettings(): bool
|
||||
{
|
||||
try {
|
||||
$pluginObject = filament($this->id);
|
||||
|
||||
return $pluginObject instanceof HasPluginSettings;
|
||||
} catch (Exception) {
|
||||
// Plugin is not loaded on the current panel, so no settings
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return Component[] */
|
||||
public function getSettingsForm(): array
|
||||
{
|
||||
try {
|
||||
$pluginObject = filament($this->id);
|
||||
|
||||
if ($pluginObject instanceof HasPluginSettings) {
|
||||
return $pluginObject->getSettingsForm();
|
||||
}
|
||||
} catch (Exception) {
|
||||
// Plugin is not loaded on the current panel, so no settings
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @param array<mixed, mixed> $data */
|
||||
public function saveSettings(array $data): void
|
||||
{
|
||||
try {
|
||||
$pluginObject = filament($this->id);
|
||||
|
||||
if ($pluginObject instanceof HasPluginSettings) {
|
||||
$pluginObject->saveSettings($data);
|
||||
}
|
||||
} catch (Exception) {
|
||||
// Plugin is not loaded on the current panel, so no settings
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function getProviders(): array
|
||||
{
|
||||
$path = plugin_path($this->id, 'src', 'Providers');
|
||||
|
||||
if (File::missing($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn ($provider) => $this->namespace . '\\Providers\\' . str($provider->getRelativePathname())->remove('.php', false), File::allFiles($path));
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function getCommands(): array
|
||||
{
|
||||
$path = plugin_path($this->id, 'src', 'Console', 'Commands');
|
||||
|
||||
if (File::missing($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn ($provider) => $this->namespace . '\\Console\\Commands\\' . str($provider->getRelativePathname())->remove('.php', false), File::allFiles($path));
|
||||
}
|
||||
|
||||
public function getSeeder(): ?string
|
||||
{
|
||||
$name = Str::studly($this->name);
|
||||
$seeder = "\Database\Seeders\\{$name}Seeder";
|
||||
|
||||
return class_exists($seeder) ? $seeder : null;
|
||||
}
|
||||
|
||||
public function getReadme(): ?string
|
||||
{
|
||||
return cache()->remember("plugins.$this->id.readme", now()->addMinutes(5), function () {
|
||||
$path = plugin_path($this->id, 'README.md');
|
||||
|
||||
if (File::missing($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return File::get($path);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -52,6 +52,12 @@ class Role extends BaseRole
|
||||
'panelLog' => [
|
||||
'view',
|
||||
],
|
||||
'plugin' => [
|
||||
'viewList',
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
],
|
||||
];
|
||||
|
||||
public const MODEL_ICONS = [
|
||||
|
||||
10
app/Policies/PluginPolicy.php
Normal file
10
app/Policies/PluginPolicy.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
class PluginPolicy
|
||||
{
|
||||
use DefaultAdminPolicies;
|
||||
|
||||
protected string $modelName = 'plugin';
|
||||
}
|
||||
@ -22,6 +22,7 @@ use App\Models\Server;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use App\Models\UserSSHKey;
|
||||
use App\Services\Helpers\PluginService;
|
||||
use App\Services\Helpers\SoftwareVersionService;
|
||||
use Dedoc\Scramble\Scramble;
|
||||
use Dedoc\Scramble\Support\Generator\OpenApi;
|
||||
@ -125,5 +126,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function register(): void
|
||||
{
|
||||
Scramble::ignoreDefaultRoutes();
|
||||
|
||||
/** @var PluginService $pluginService */
|
||||
$pluginService = app(PluginService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
|
||||
|
||||
$pluginService->loadPlugins();
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Admin\Pages\ListLogs;
|
||||
use App\Filament\Admin\Pages\ViewLogs;
|
||||
use App\Services\Helpers\PluginService;
|
||||
use Boquizo\FilamentLogViewer\FilamentLogViewerPlugin;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
@ -14,7 +15,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return parent::panel($panel)
|
||||
$panel = parent::panel($panel)
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->homeUrl('/')
|
||||
@ -45,5 +46,12 @@ class AdminPanelProvider extends PanelProvider
|
||||
->navigationGroup(fn () => trans('admin/dashboard.advanced'))
|
||||
->navigationIcon('tabler-file-info'),
|
||||
]);
|
||||
|
||||
/** @var PluginService $pluginService */
|
||||
$pluginService = app(PluginService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
|
||||
|
||||
$pluginService->loadPanelPlugins($panel);
|
||||
|
||||
return $panel;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Services\Helpers\PluginService;
|
||||
use Boquizo\FilamentLogViewer\FilamentLogViewerPlugin;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
@ -11,7 +12,7 @@ class AppPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return parent::panel($panel)
|
||||
$panel = parent::panel($panel)
|
||||
->id('app')
|
||||
->default()
|
||||
->breadcrumbs(false)
|
||||
@ -29,5 +30,12 @@ class AppPanelProvider extends PanelProvider
|
||||
FilamentLogViewerPlugin::make()
|
||||
->authorize(false),
|
||||
]);
|
||||
|
||||
/** @var PluginService $pluginService */
|
||||
$pluginService = app(PluginService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
|
||||
|
||||
$pluginService->loadPanelPlugins($panel);
|
||||
|
||||
return $panel;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ use App\Filament\Admin\Resources\Servers\Pages\EditServer;
|
||||
use App\Filament\App\Resources\Servers\Pages\ListServers;
|
||||
use App\Http\Middleware\Activity\ServerSubject;
|
||||
use App\Models\Server;
|
||||
use App\Services\Helpers\PluginService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
@ -15,7 +16,7 @@ class ServerPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return parent::panel($panel)
|
||||
$panel = parent::panel($panel)
|
||||
->id('server')
|
||||
->path('server')
|
||||
->homeUrl(fn () => Filament::getPanel('app')->getUrl())
|
||||
@ -44,5 +45,12 @@ class ServerPanelProvider extends PanelProvider
|
||||
->tenantMiddleware([
|
||||
ServerSubject::class,
|
||||
]);
|
||||
|
||||
/** @var PluginService $pluginService */
|
||||
$pluginService = app(PluginService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
|
||||
|
||||
$pluginService->loadPanelPlugins($panel);
|
||||
|
||||
return $panel;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,20 +11,34 @@ class LanguageService
|
||||
'en',
|
||||
];
|
||||
|
||||
public function __construct(protected PluginService $pluginService) {}
|
||||
|
||||
public function isLanguageTranslated(string $countryCode = 'en'): bool
|
||||
{
|
||||
return in_array($countryCode, self::TRANSLATED_COMPLETELY, true);
|
||||
}
|
||||
|
||||
public function getLanguageDisplayName(string $code): string
|
||||
{
|
||||
$key = 'profile.current_language_name';
|
||||
$trans = trans($key, locale: $code);
|
||||
|
||||
return $trans !== $key ? $trans : title_case(Locale::getDisplayName($code, $code));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array-key, string>
|
||||
*/
|
||||
public function getAvailableLanguages(string $path = 'lang'): array
|
||||
{
|
||||
return collect(File::directories(base_path($path)))->mapWithKeys(function ($path) {
|
||||
$baseLanguages = collect(File::directories(base_path($path)))->mapWithKeys(function ($path) {
|
||||
$code = basename($path);
|
||||
|
||||
return [$code => title_case(Locale::getDisplayName($code, $code))];
|
||||
return [$code => $this->getLanguageDisplayName($code)];
|
||||
})->toArray();
|
||||
|
||||
$pluginLanguages = collect($this->pluginService->getPluginLanguages())->mapWithKeys(fn ($code) => [$code => $this->getLanguageDisplayName($code)])->toArray();
|
||||
|
||||
return array_sort(array_unique(array_merge($baseLanguages, $pluginLanguages)));
|
||||
}
|
||||
}
|
||||
|
||||
486
app/Services/Helpers/PluginService.php
Normal file
486
app/Services/Helpers/PluginService.php
Normal file
@ -0,0 +1,486 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Helpers;
|
||||
|
||||
use App\Enums\PluginStatus;
|
||||
use App\Exceptions\Service\InvalidFileUploadException;
|
||||
use App\Models\Plugin;
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use Exception;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Console\Application as ConsoleApplication;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ZipArchive;
|
||||
|
||||
class PluginService
|
||||
{
|
||||
public function __construct(private Application $app) {}
|
||||
|
||||
public function loadPlugins(): void
|
||||
{
|
||||
// Don't load any plugins during tests
|
||||
if ($this->app->runningUnitTests()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var ClassLoader $classLoader */
|
||||
$classLoader = File::getRequire(base_path('vendor/autoload.php'));
|
||||
|
||||
$plugins = Plugin::query()->orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
try {
|
||||
// Filter out plugins that are not compatible with the current panel version
|
||||
if (!$plugin->isCompatible()) {
|
||||
$this->setStatus($plugin, PluginStatus::Incompatible, 'This Plugin is only compatible with Panel version ' . $plugin->panel_version . (!$plugin->isPanelVersionStrict() ? ' or newer' : '') . ' but you are using version ' . config('app.version') . '!');
|
||||
|
||||
continue;
|
||||
} else {
|
||||
// Make sure to update the status if a plugin is no longer incompatible (e.g. because the user changed their panel version)
|
||||
if ($plugin->status === PluginStatus::Incompatible) {
|
||||
$this->disablePlugin($plugin);
|
||||
}
|
||||
}
|
||||
|
||||
// Always autoload src directory to make sure all class names can be resolved (e.g. in migrations)
|
||||
$namespace = $plugin->namespace . '\\';
|
||||
if (!array_key_exists($namespace, $classLoader->getPrefixesPsr4())) {
|
||||
$classLoader->setPsr4($namespace, plugin_path($plugin->id, 'src/'));
|
||||
|
||||
$classLoader->addPsr4('Database\Factories\\', plugin_path($plugin->id, 'database/Factories/'));
|
||||
$classLoader->addPsr4('Database\Seeders\\', plugin_path($plugin->id, 'database/Seeders/'));
|
||||
}
|
||||
|
||||
// Load config
|
||||
$config = plugin_path($plugin->id, 'config', $plugin->id . '.php');
|
||||
if (file_exists($config)) {
|
||||
config()->set($plugin->id, require $config);
|
||||
}
|
||||
|
||||
// Filter out plugins that should not be loaded (e.g. because they are disabled or not installed yet)
|
||||
if (!$plugin->shouldLoad()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load translations
|
||||
$translations = plugin_path($plugin->id, 'lang');
|
||||
if (file_exists($translations)) {
|
||||
$this->app->afterResolving('translator', function ($translator) use ($plugin, $translations) {
|
||||
if ($plugin->isLanguage()) {
|
||||
$translator->addPath($translations);
|
||||
} else {
|
||||
$translator->addNamespace($plugin->id, $translations);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Register service providers
|
||||
foreach ($plugin->getProviders() as $provider) {
|
||||
if (!class_exists($provider) || !is_subclass_of($provider, ServiceProvider::class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->app->register($provider);
|
||||
}
|
||||
|
||||
// Resolve artisan commands
|
||||
foreach ($plugin->getCommands() as $command) {
|
||||
if (!class_exists($command) || !is_subclass_of($command, Command::class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ConsoleApplication::starting(function ($artisan) use ($command) {
|
||||
$artisan->resolve($command);
|
||||
});
|
||||
}
|
||||
|
||||
// Load migrations
|
||||
$migrations = plugin_path($plugin->id, 'database', 'migrations');
|
||||
if (file_exists($migrations)) {
|
||||
$this->app->afterResolving('migrator', function ($migrator) use ($migrations) {
|
||||
$migrator->path($migrations);
|
||||
});
|
||||
}
|
||||
|
||||
// Load views
|
||||
$views = plugin_path($plugin->id, 'resources', 'views');
|
||||
if (file_exists($views)) {
|
||||
$this->app->afterResolving('view', function ($view) use ($plugin, $views) {
|
||||
$view->addNamespace($plugin->id, $views);
|
||||
});
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
$this->handlePluginException($plugin, $exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function loadPanelPlugins(Panel $panel): void
|
||||
{
|
||||
// Don't load any plugins during tests
|
||||
if ($this->app->runningUnitTests()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$plugins = Plugin::query()->orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
try {
|
||||
if (!$plugin->shouldLoad($panel->getId())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pluginClass = $plugin->fullClass();
|
||||
|
||||
if (!class_exists($pluginClass)) {
|
||||
throw new Exception('Class "' . $pluginClass . '" not found');
|
||||
}
|
||||
|
||||
$panel->plugin(new $pluginClass());
|
||||
|
||||
if ($plugin->status === PluginStatus::Errored) {
|
||||
$this->enablePlugin($plugin);
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
$this->handlePluginException($plugin, $exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|array<string, string> $newPackages
|
||||
* @param null|array<string, string> $oldPackages
|
||||
*/
|
||||
public function manageComposerPackages(?array $newPackages = [], ?array $oldPackages = null): void
|
||||
{
|
||||
$newPackages ??= [];
|
||||
|
||||
$plugins = Plugin::query()->orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
if (!$plugin->composer_packages) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$plugin->shouldLoad()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$pluginPackages = json_decode($plugin->composer_packages, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$newPackages = array_merge($newPackages, $pluginPackages);
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
|
||||
$oldPackages = collect($oldPackages)
|
||||
->filter(fn ($version, $package) => !array_key_exists($package, $newPackages))
|
||||
->keys()
|
||||
->unique()
|
||||
->toArray();
|
||||
|
||||
if (count($oldPackages) > 0) {
|
||||
$result = Process::path(base_path())->timeout(600)->run(['composer', 'remove', ...$oldPackages]);
|
||||
if ($result->failed()) {
|
||||
throw new Exception('Could not remove old composer packages: ' . $result->errorOutput());
|
||||
}
|
||||
}
|
||||
|
||||
$newPackages = collect($newPackages)
|
||||
->map(fn ($version, $package) => "$package:$version")
|
||||
->flatten()
|
||||
->unique()
|
||||
->toArray();
|
||||
|
||||
if (count($newPackages) > 0) {
|
||||
$result = Process::path(base_path())->timeout(600)->run(['composer', 'require', ...$newPackages]);
|
||||
if ($result->failed()) {
|
||||
throw new Exception('Could not require new composer packages: ' . $result->errorOutput());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function runPluginMigrations(Plugin $plugin): void
|
||||
{
|
||||
$migrations = plugin_path($plugin->id, 'database', 'migrations');
|
||||
if (file_exists($migrations)) {
|
||||
$success = Artisan::call('migrate', ['--realpath' => true, '--path' => $migrations, '--force' => true]) === 0;
|
||||
|
||||
if (!$success) {
|
||||
throw new Exception("Could not run migrations for plugin '{$plugin->id}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function rollbackPluginMigrations(Plugin $plugin): void
|
||||
{
|
||||
$migrations = plugin_path($plugin->id, 'database', 'migrations');
|
||||
if (file_exists($migrations)) {
|
||||
$success = Artisan::call('migrate:rollback', ['--realpath' => true, '--path' => $migrations, '--force' => true]) === 0;
|
||||
|
||||
if (!$success) {
|
||||
throw new Exception("Could not rollback migrations for plugin '{$plugin->id}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function runPluginSeeder(Plugin $plugin): void
|
||||
{
|
||||
$seeder = $plugin->getSeeder();
|
||||
if ($seeder) {
|
||||
$success = Artisan::call('db:seed', ['--class' => $seeder, '--force' => true]) === 0;
|
||||
|
||||
if (!$success) {
|
||||
throw new Exception("Could not run seeder for plugin '{$plugin->id}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function buildAssets(): bool
|
||||
{
|
||||
try {
|
||||
$result = Process::path(base_path())->timeout(300)->run('yarn install');
|
||||
if ($result->failed()) {
|
||||
throw new Exception('Could not install dependencies: ' . $result->errorOutput());
|
||||
}
|
||||
|
||||
$result = Process::path(base_path())->timeout(600)->run('yarn build');
|
||||
if ($result->failed()) {
|
||||
throw new Exception('Could not build assets: ' . $result->errorOutput());
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception $exception) {
|
||||
if ($this->isDevModeActive()) {
|
||||
throw ($exception);
|
||||
}
|
||||
|
||||
report($exception);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function installPlugin(Plugin $plugin, bool $enable = true): void
|
||||
{
|
||||
try {
|
||||
$this->manageComposerPackages(json_decode($plugin->composer_packages, true, 512));
|
||||
|
||||
if ($enable) {
|
||||
$this->enablePlugin($plugin);
|
||||
} else {
|
||||
if ($plugin->status === PluginStatus::NotInstalled) {
|
||||
$this->disablePlugin($plugin);
|
||||
}
|
||||
}
|
||||
|
||||
$this->buildAssets();
|
||||
|
||||
$this->runPluginMigrations($plugin);
|
||||
|
||||
$this->runPluginSeeder($plugin);
|
||||
} catch (Exception $exception) {
|
||||
$this->handlePluginException($plugin, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatePlugin(Plugin $plugin): void
|
||||
{
|
||||
try {
|
||||
$downloadUrl = $plugin->getDownloadUrlForUpdate();
|
||||
if ($downloadUrl) {
|
||||
$this->downloadPluginFromUrl($downloadUrl, true);
|
||||
|
||||
$this->installPlugin($plugin, false);
|
||||
|
||||
cache()->forget("plugins.$plugin->id.update");
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
$this->handlePluginException($plugin, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
public function uninstallPlugin(Plugin $plugin, bool $deleteFiles = false): void
|
||||
{
|
||||
try {
|
||||
$pluginPackages = json_decode($plugin->composer_packages, true, 512);
|
||||
|
||||
$this->rollbackPluginMigrations($plugin);
|
||||
|
||||
if ($deleteFiles) {
|
||||
$this->deletePlugin($plugin);
|
||||
} else {
|
||||
$this->setStatus($plugin, PluginStatus::NotInstalled);
|
||||
}
|
||||
|
||||
$this->buildAssets();
|
||||
|
||||
$this->manageComposerPackages(oldPackages: $pluginPackages);
|
||||
} catch (Exception $exception) {
|
||||
$this->handlePluginException($plugin, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadPluginFromFile(UploadedFile $file, bool $cleanDownload = false): void
|
||||
{
|
||||
// Validate file size to prevent zip bombs
|
||||
$maxSize = config('panel.plugin.max_import_size');
|
||||
if ($file->getSize() > $maxSize) {
|
||||
throw new Exception("Zip file too large. ($maxSize MiB)");
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if (!$zip->open($file->getPathname())) {
|
||||
throw new Exception('Could not open zip file.');
|
||||
}
|
||||
|
||||
// Validate zip contents before extraction
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$filename = $zip->getNameIndex($i);
|
||||
if (Str::contains($filename, '..') || Str::startsWith($filename, '/')) {
|
||||
$zip->close();
|
||||
throw new Exception('Zip file contains invalid path traversal sequences.');
|
||||
}
|
||||
}
|
||||
|
||||
$pluginName = str($file->getClientOriginalName())->before('.zip')->toString();
|
||||
|
||||
if ($cleanDownload) {
|
||||
File::deleteDirectory(plugin_path($pluginName));
|
||||
}
|
||||
|
||||
$extractPath = $zip->locateName($pluginName . '/plugin.json') !== false ? base_path('plugins') : plugin_path($pluginName);
|
||||
|
||||
if (!$zip->extractTo($extractPath)) {
|
||||
$zip->close();
|
||||
throw new Exception('Could not extract zip file.');
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
public function downloadPluginFromUrl(string $url, bool $cleanDownload = false): void
|
||||
{
|
||||
$info = pathinfo($url);
|
||||
$tmpDir = TemporaryDirectory::make()->deleteWhenDestroyed();
|
||||
$tmpPath = $tmpDir->path($info['basename']);
|
||||
|
||||
$content = Http::timeout(60)->connectTimeout(5)->throw()->get($url)->body();
|
||||
|
||||
// Validate file size to prevent zip bombs
|
||||
$maxSize = config('panel.plugin.max_import_size');
|
||||
if (strlen($content) > $maxSize) {
|
||||
throw new InvalidFileUploadException("Zip file too large. ($maxSize MiB)");
|
||||
}
|
||||
|
||||
if (!file_put_contents($tmpPath, $content)) {
|
||||
throw new InvalidFileUploadException('Could not write temporary file.');
|
||||
}
|
||||
|
||||
$this->downloadPluginFromFile(new UploadedFile($tmpPath, $info['basename'], 'application/zip'), $cleanDownload);
|
||||
}
|
||||
|
||||
public function deletePlugin(Plugin $plugin): void
|
||||
{
|
||||
File::deleteDirectory(plugin_path($plugin->id));
|
||||
}
|
||||
|
||||
public function enablePlugin(string|Plugin $plugin): void
|
||||
{
|
||||
$this->setStatus($plugin, PluginStatus::Enabled);
|
||||
}
|
||||
|
||||
public function disablePlugin(string|Plugin $plugin): void
|
||||
{
|
||||
$this->setStatus($plugin, PluginStatus::Disabled);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function setMetaData(string|Plugin $plugin, array $data): void
|
||||
{
|
||||
$path = plugin_path($plugin instanceof Plugin ? $plugin->id : $plugin, 'plugin.json');
|
||||
|
||||
if (File::exists($path)) {
|
||||
$pluginData = File::json($path, JSON_THROW_ON_ERROR);
|
||||
$metaData = array_merge($pluginData['meta'] ?? [], $data);
|
||||
$pluginData['meta'] = $metaData;
|
||||
|
||||
File::put($path, json_encode($pluginData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$plugin = $plugin instanceof Plugin ? $plugin : Plugin::findOrFail($plugin);
|
||||
$plugin->update($metaData);
|
||||
}
|
||||
}
|
||||
|
||||
private function setStatus(string|Plugin $plugin, PluginStatus $status, ?string $message = null): void
|
||||
{
|
||||
$this->setMetaData($plugin, [
|
||||
'status' => $status,
|
||||
'status_message' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<int, string> $order */
|
||||
public function updateLoadOrder(array $order): void
|
||||
{
|
||||
foreach ($order as $i => $plugin) {
|
||||
$this->setMetaData($plugin, [
|
||||
'load_order' => $i,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function hasThemePluginEnabled(): bool
|
||||
{
|
||||
$plugins = Plugin::query()->orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
if ($plugin->isTheme() && $plugin->status === PluginStatus::Enabled) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function getPluginLanguages(): array
|
||||
{
|
||||
$languages = [];
|
||||
|
||||
$plugins = Plugin::query()->orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
if ($plugin->status !== PluginStatus::Enabled || !$plugin->isLanguage()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$languages = array_merge($languages, collect(File::directories(plugin_path($plugin->id, 'lang')))->map(fn ($path) => basename($path))->toArray());
|
||||
}
|
||||
|
||||
return array_unique($languages);
|
||||
}
|
||||
|
||||
public function isDevModeActive(): bool
|
||||
{
|
||||
return config('panel.plugin.dev_mode', false);
|
||||
}
|
||||
|
||||
private function handlePluginException(string|Plugin $plugin, Exception $exception): void
|
||||
{
|
||||
if ($this->isDevModeActive()) {
|
||||
throw ($exception);
|
||||
}
|
||||
|
||||
report($exception);
|
||||
|
||||
$this->setStatus($plugin, PluginStatus::Errored, $exception->getMessage());
|
||||
}
|
||||
}
|
||||
@ -87,6 +87,13 @@ if (!function_exists('resolve_path')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('plugin_path')) {
|
||||
function plugin_path(string $plugin, string ...$paths): string
|
||||
{
|
||||
return join_paths(base_path('plugins'), $plugin, implode('/', $paths));
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('get_ip_from_hostname')) {
|
||||
function get_ip_from_hostname(string $hostname): string|bool
|
||||
{
|
||||
|
||||
@ -81,7 +81,8 @@
|
||||
"@php artisan filament:upgrade"
|
||||
],
|
||||
"post-install-cmd": [
|
||||
"php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
"php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"php artisan p:plugin:composer"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
@ -97,4 +98,4 @@
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
}
|
||||
@ -69,4 +69,9 @@ return [
|
||||
'webhook' => [
|
||||
'prune_days' => env('APP_WEBHOOK_PRUNE_DAYS', 30),
|
||||
],
|
||||
|
||||
'plugin' => [
|
||||
'dev_mode' => env('PANEL_PLUGIN_DEV_MODE', false),
|
||||
'max_import_size' => env('PANEL_PLUGIN_MAX_IMPORT_SIZE', 1024 * 1024 * 100),
|
||||
],
|
||||
];
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use App\Models\Role;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
@ -15,5 +16,16 @@ class DatabaseSeeder extends Seeder
|
||||
$this->call(EggSeeder::class);
|
||||
|
||||
Role::firstOrCreate(['name' => Role::ROOT_ADMIN]);
|
||||
|
||||
$plugins = Plugin::query()->orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
if (!$plugin->shouldLoad()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($seeder = $plugin->getSeeder()) {
|
||||
$this->call($seeder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
lang/en/admin/plugin.php
Normal file
58
lang/en/admin/plugin.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'nav_title' => 'Plugins',
|
||||
'model_label' => 'Plugin',
|
||||
'model_label_plural' => 'Plugins',
|
||||
|
||||
'name' => 'Name',
|
||||
'update_available' => 'An update for this plugin is available',
|
||||
'author' => 'Author',
|
||||
'version' => 'Version',
|
||||
'category' => 'Category',
|
||||
'status' => 'Status',
|
||||
'visit_website' => 'Visit Website',
|
||||
'settings' => 'Settings',
|
||||
'install' => 'Install',
|
||||
'uninstall' => 'Uninstall',
|
||||
'update' => 'Update',
|
||||
'enable' => 'Enable',
|
||||
'disable' => 'Disable',
|
||||
'import_from_file' => 'Import from File',
|
||||
'import_from_url' => 'Import from URL',
|
||||
'no_plugins' => 'No Plugins',
|
||||
'all' => 'All',
|
||||
'change_load_order' => 'Change load order',
|
||||
'apply_load_order' => 'Apply load order',
|
||||
|
||||
'enable_theme_modal' => [
|
||||
'heading' => 'Theme already enabled',
|
||||
'description' => 'You already have a theme enabled. Enabling multiple themes can result in visual bugs. Do you want to continue?',
|
||||
],
|
||||
|
||||
'status_enum' => [
|
||||
'not_installed' => 'Not Installed',
|
||||
'disabled' => 'Disabled',
|
||||
'enabled' => 'Enabled',
|
||||
'errored' => 'Errored',
|
||||
'incompatible' => 'Incompatible',
|
||||
],
|
||||
|
||||
'category_enum' => [
|
||||
'plugin' => 'Plugin',
|
||||
'theme' => 'Theme',
|
||||
'language' => 'Language Pack',
|
||||
],
|
||||
|
||||
'notifications' => [
|
||||
'installed' => 'Plugin installed',
|
||||
'uninstalled' => 'Plugin uninstalled',
|
||||
'deleted' => 'Plugin deleted',
|
||||
'updated' => 'Plugin updated',
|
||||
'enabled' => 'Plugin enabled',
|
||||
'disabled' => 'Plugin disabled',
|
||||
'imported' => 'Plugin imported',
|
||||
'import_exists' => 'A plugin with that id already exists',
|
||||
'import_failed' => 'Could not import plugin',
|
||||
],
|
||||
];
|
||||
2
plugins/.gitignore
vendored
Normal file
2
plugins/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@ -9,6 +9,9 @@ export default defineConfig({
|
||||
input: [
|
||||
...globSync('resources/css/**/*.css'),
|
||||
...globSync('resources/js/**/*.js'),
|
||||
|
||||
...globSync('plugins/*/resources/css/**/*.css'),
|
||||
...globSync('plugins/*/resources/js/**/*.js'),
|
||||
],
|
||||
refresh: true,
|
||||
}),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user