Plugin system (#1866)

This commit is contained in:
Boy132 2025-12-20 00:32:13 +01:00 committed by GitHub
parent 2ab4c81e2a
commit 242a75bf3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1875 additions and 11 deletions

View File

@ -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 \

View File

@ -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 \

View 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());
}
}
}

View 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.');
}
}

View 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.');
}
}

View 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();
}
}

View 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.');
}
}

View 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.
}
}

View File

@ -0,0 +1,5 @@
<?php
return [
// Config values for $name$
];

View 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
{
//
}
}

View 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' : '') . '.');
}
}

View 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.');
}
}

View 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;
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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
View 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);
});
}
}

View File

@ -52,6 +52,12 @@ class Role extends BaseRole
'panelLog' => [
'view',
],
'plugin' => [
'viewList',
'create',
'update',
'delete',
],
];
public const MODEL_ICONS = [

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class PluginPolicy
{
use DefaultAdminPolicies;
protected string $modelName = 'plugin';
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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)));
}
}

View 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());
}
}

View File

@ -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
{

View File

@ -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
}
}

View File

@ -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),
],
];

View File

@ -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
View 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
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -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,
}),