From 242a75bf3dc773ac2883578c55fa163bbc5c217a Mon Sep 17 00:00:00 2001 From: Boy132 Date: Sat, 20 Dec 2025 00:32:13 +0100 Subject: [PATCH] Plugin system (#1866) --- Dockerfile | 5 +- Dockerfile.dev | 5 +- .../Plugin/ComposerPluginsCommand.php | 25 + .../Commands/Plugin/DisablePluginCommand.php | 37 ++ .../Commands/Plugin/InstallPluginCommand.php | 38 ++ .../Commands/Plugin/ListPluginsCommand.php | 28 + .../Commands/Plugin/MakePluginCommand.php | 135 +++++ app/Console/Commands/Plugin/Plugin.stub | 25 + app/Console/Commands/Plugin/PluginConfig.stub | 5 + .../Commands/Plugin/PluginProvider.stub | 18 + .../Plugin/UninstallPluginCommand.php | 43 ++ .../Commands/Plugin/UpdatePluginCommand.php | 37 ++ app/Contracts/Plugins/HasPluginSettings.php | 18 + app/Enums/PluginCategory.php | 27 + app/Enums/PluginStatus.php | 43 ++ .../Resources/Plugins/Pages/ListPlugins.php | 43 ++ .../Resources/Plugins/PluginResource.php | 313 +++++++++++ app/Models/Plugin.php | 393 ++++++++++++++ app/Models/Role.php | 6 + app/Policies/PluginPolicy.php | 10 + app/Providers/AppServiceProvider.php | 6 + app/Providers/Filament/AdminPanelProvider.php | 10 +- app/Providers/Filament/AppPanelProvider.php | 10 +- .../Filament/ServerPanelProvider.php | 10 +- app/Services/Helpers/LanguageService.php | 18 +- app/Services/Helpers/PluginService.php | 486 ++++++++++++++++++ app/helpers.php | 7 + composer.json | 5 +- config/panel.php | 5 + database/Seeders/DatabaseSeeder.php | 12 + lang/en/admin/plugin.php | 58 +++ plugins/.gitignore | 2 + vite.config.js | 3 + 33 files changed, 1875 insertions(+), 11 deletions(-) create mode 100644 app/Console/Commands/Plugin/ComposerPluginsCommand.php create mode 100644 app/Console/Commands/Plugin/DisablePluginCommand.php create mode 100644 app/Console/Commands/Plugin/InstallPluginCommand.php create mode 100644 app/Console/Commands/Plugin/ListPluginsCommand.php create mode 100644 app/Console/Commands/Plugin/MakePluginCommand.php create mode 100644 app/Console/Commands/Plugin/Plugin.stub create mode 100644 app/Console/Commands/Plugin/PluginConfig.stub create mode 100644 app/Console/Commands/Plugin/PluginProvider.stub create mode 100644 app/Console/Commands/Plugin/UninstallPluginCommand.php create mode 100644 app/Console/Commands/Plugin/UpdatePluginCommand.php create mode 100644 app/Contracts/Plugins/HasPluginSettings.php create mode 100644 app/Enums/PluginCategory.php create mode 100644 app/Enums/PluginStatus.php create mode 100644 app/Filament/Admin/Resources/Plugins/Pages/ListPlugins.php create mode 100644 app/Filament/Admin/Resources/Plugins/PluginResource.php create mode 100644 app/Models/Plugin.php create mode 100644 app/Policies/PluginPolicy.php create mode 100644 app/Services/Helpers/PluginService.php create mode 100644 lang/en/admin/plugin.php create mode 100644 plugins/.gitignore diff --git a/Dockerfile b/Dockerfile index dfc400808..8d10b4aae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/Dockerfile.dev b/Dockerfile.dev index fc9ecb0fd..008e55d6e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -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 \ diff --git a/app/Console/Commands/Plugin/ComposerPluginsCommand.php b/app/Console/Commands/Plugin/ComposerPluginsCommand.php new file mode 100644 index 000000000..c1668f6da --- /dev/null +++ b/app/Console/Commands/Plugin/ComposerPluginsCommand.php @@ -0,0 +1,25 @@ +manageComposerPackages(); + } catch (Exception $exception) { + report($exception); + + $this->error($exception->getMessage()); + } + } +} diff --git a/app/Console/Commands/Plugin/DisablePluginCommand.php b/app/Console/Commands/Plugin/DisablePluginCommand.php new file mode 100644 index 000000000..3aef3aecc --- /dev/null +++ b/app/Console/Commands/Plugin/DisablePluginCommand.php @@ -0,0 +1,37 @@ +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.'); + } +} diff --git a/app/Console/Commands/Plugin/InstallPluginCommand.php b/app/Console/Commands/Plugin/InstallPluginCommand.php new file mode 100644 index 000000000..1ebae4bff --- /dev/null +++ b/app/Console/Commands/Plugin/InstallPluginCommand.php @@ -0,0 +1,38 @@ +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.'); + } +} diff --git a/app/Console/Commands/Plugin/ListPluginsCommand.php b/app/Console/Commands/Plugin/ListPluginsCommand.php new file mode 100644 index 000000000..9173910c1 --- /dev/null +++ b/app/Console/Commands/Plugin/ListPluginsCommand.php @@ -0,0 +1,28 @@ +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(); + } +} diff --git a/app/Console/Commands/Plugin/MakePluginCommand.php b/app/Console/Commands/Plugin/MakePluginCommand.php new file mode 100644 index 000000000..a8f2eb1d4 --- /dev/null +++ b/app/Console/Commands/Plugin/MakePluginCommand.php @@ -0,0 +1,135 @@ +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.'); + } +} diff --git a/app/Console/Commands/Plugin/Plugin.stub b/app/Console/Commands/Plugin/Plugin.stub new file mode 100644 index 000000000..ab6ba7233 --- /dev/null +++ b/app/Console/Commands/Plugin/Plugin.stub @@ -0,0 +1,25 @@ +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' : '') . '.'); + } +} diff --git a/app/Console/Commands/Plugin/UpdatePluginCommand.php b/app/Console/Commands/Plugin/UpdatePluginCommand.php new file mode 100644 index 000000000..a6c4b10fe --- /dev/null +++ b/app/Console/Commands/Plugin/UpdatePluginCommand.php @@ -0,0 +1,37 @@ +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.'); + } +} diff --git a/app/Contracts/Plugins/HasPluginSettings.php b/app/Contracts/Plugins/HasPluginSettings.php new file mode 100644 index 000000000..1ed556c6c --- /dev/null +++ b/app/Contracts/Plugins/HasPluginSettings.php @@ -0,0 +1,18 @@ + $data + */ + public function saveSettings(array $data): void; +} diff --git a/app/Enums/PluginCategory.php b/app/Enums/PluginCategory.php new file mode 100644 index 000000000..23e18b231 --- /dev/null +++ b/app/Enums/PluginCategory.php @@ -0,0 +1,27 @@ + 'tabler-package', + self::Theme => 'tabler-palette', + self::Language => 'tabler-language', + }; + } + + public function getLabel(): string + { + return trans('admin/plugin.category_enum.' . $this->value); + } +} diff --git a/app/Enums/PluginStatus.php b/app/Enums/PluginStatus.php new file mode 100644 index 000000000..180adef47 --- /dev/null +++ b/app/Enums/PluginStatus.php @@ -0,0 +1,43 @@ + '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); + } +} diff --git a/app/Filament/Admin/Resources/Plugins/Pages/ListPlugins.php b/app/Filament/Admin/Resources/Plugins/Pages/ListPlugins.php new file mode 100644 index 000000000..08d7776aa --- /dev/null +++ b/app/Filament/Admin/Resources/Plugins/Pages/ListPlugins.php @@ -0,0 +1,43 @@ +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; + } +} diff --git a/app/Filament/Admin/Resources/Plugins/PluginResource.php b/app/Filament/Admin/Resources/Plugins/PluginResource.php new file mode 100644 index 000000000..9a398fd04 --- /dev/null +++ b/app/Filament/Admin/Resources/Plugins/PluginResource.php @@ -0,0 +1,313 @@ +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('/'), + ]; + } +} diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php new file mode 100644 index 000000000..abdc25867 --- /dev/null +++ b/app/Models/Plugin.php @@ -0,0 +1,393 @@ + '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 + */ + 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 */ + 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 $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); + }); + } +} diff --git a/app/Models/Role.php b/app/Models/Role.php index 02a99f469..baede714e 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -52,6 +52,12 @@ class Role extends BaseRole 'panelLog' => [ 'view', ], + 'plugin' => [ + 'viewList', + 'create', + 'update', + 'delete', + ], ]; public const MODEL_ICONS = [ diff --git a/app/Policies/PluginPolicy.php b/app/Policies/PluginPolicy.php new file mode 100644 index 000000000..0e7063250 --- /dev/null +++ b/app/Policies/PluginPolicy.php @@ -0,0 +1,10 @@ +loadPlugins(); } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 64d9f1184..4dc1deff3 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -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; } } diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php index a4a19ef3c..e991a124b 100644 --- a/app/Providers/Filament/AppPanelProvider.php +++ b/app/Providers/Filament/AppPanelProvider.php @@ -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; } } diff --git a/app/Providers/Filament/ServerPanelProvider.php b/app/Providers/Filament/ServerPanelProvider.php index 8d7f6f6eb..55574d87b 100644 --- a/app/Providers/Filament/ServerPanelProvider.php +++ b/app/Providers/Filament/ServerPanelProvider.php @@ -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; } } diff --git a/app/Services/Helpers/LanguageService.php b/app/Services/Helpers/LanguageService.php index 41c91e2b0..aa8b87c9f 100644 --- a/app/Services/Helpers/LanguageService.php +++ b/app/Services/Helpers/LanguageService.php @@ -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 */ 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))); } } diff --git a/app/Services/Helpers/PluginService.php b/app/Services/Helpers/PluginService.php new file mode 100644 index 000000000..aff5ac6a9 --- /dev/null +++ b/app/Services/Helpers/PluginService.php @@ -0,0 +1,486 @@ +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 $newPackages + * @param null|array $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 $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 $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()); + } +} diff --git a/app/helpers.php b/app/helpers.php index 9fa2ec051..d258fd2b3 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -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 { diff --git a/composer.json b/composer.json index 9ea239bc1..d0331df03 100644 --- a/composer.json +++ b/composer.json @@ -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 -} +} \ No newline at end of file diff --git a/config/panel.php b/config/panel.php index c03b626b1..848b4761d 100644 --- a/config/panel.php +++ b/config/panel.php @@ -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), + ], ]; diff --git a/database/Seeders/DatabaseSeeder.php b/database/Seeders/DatabaseSeeder.php index b2e7c20e4..f1d885862 100644 --- a/database/Seeders/DatabaseSeeder.php +++ b/database/Seeders/DatabaseSeeder.php @@ -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); + } + } } } diff --git a/lang/en/admin/plugin.php b/lang/en/admin/plugin.php new file mode 100644 index 000000000..0342da5b9 --- /dev/null +++ b/lang/en/admin/plugin.php @@ -0,0 +1,58 @@ + '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', + ], +]; diff --git a/plugins/.gitignore b/plugins/.gitignore new file mode 100644 index 000000000..c96a04f00 --- /dev/null +++ b/plugins/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index be2ab1d10..fb5e86baa 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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, }),