Skip to content

Conversation

@Boy132
Copy link
Member

@Boy132 Boy132 commented Nov 6, 2025

grafik

@Boy132
Copy link
Member Author

Boy132 commented Dec 1, 2025

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Dec 1, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (5)
composer.json (1)

83-85: Confirm p:plugin:composer is safe on fresh installs and when no plugins exist

Hooking php artisan p:plugin:composer into post-install-cmd means a failure in that command will break composer install (e.g., before migrations run or when the plugins table is missing/empty). Please double‑check that ComposerPluginsCommand gracefully handles “no table / no plugins yet” and exits successfully in those cases so new installs don’t fail.

app/Models/Plugin.php (1)

234-257: Use HTTP client instead of file_get_contents for update checks.

file_get_contents($this->update_url) has security and reliability issues: no timeout (can hang indefinitely), no HTTP error handling, and potential SSRF vulnerability. This was flagged in a previous review but remains unaddressed.

app/Services/Helpers/PluginService.php (3)

387-402: Missing error handling for JSON operations in setMetaData.

File::json() and json_encode() with JSON_THROW_ON_ERROR can throw JsonException, but there's no try-catch. This was flagged in a previous review but remains unaddressed.


412-420: Load order updates lack atomicity.

If a metadata write fails partway through the loop, some plugins will have updated load orders while others won't, leaving the system in an inconsistent state. This was flagged in a previous review.


351-370: SSRF protection is missing for URL downloads.

While the HTTP client now has timeouts, there's no validation to prevent access to internal network resources. An attacker could use URLs like http://localhost:6379 or cloud metadata endpoints (http://169.254.169.254/...).

Add URL validation before the HTTP request:

 public function downloadPluginFromUrl(string $url, bool $cleanDownload = false): void
 {
+    // Validate URL scheme and prevent SSRF
+    $parsed = parse_url($url);
+    if (!$parsed || !in_array($parsed['scheme'] ?? '', ['http', 'https'], true)) {
+        throw new InvalidFileUploadException('Invalid URL scheme. Only http and https are allowed.');
+    }
+    
+    $host = $parsed['host'] ?? '';
+    if (filter_var($host, FILTER_VALIDATE_IP) && 
+        !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
+        throw new InvalidFileUploadException('Access to private IP ranges is not allowed.');
+    }
+
     $info = pathinfo($url);
🧹 Nitpick comments (9)
config/panel.php (1)

72-75: Consider documenting units for max_import_size

Config structure is fine; to avoid ambiguity later, consider a short comment or doc note that max_import_size is in bytes (defaulting to 100 MB), similar to how files.max_edit_size is expressed.

app/Filament/Admin/Resources/Plugins/Pages/ListPlugins.php (1)

16-36: Tighten tab query handling and silence unused-parameter warning

Two small points here:

  1. Unused $draggedRecordKey parameter
    Since the signature must match Filament’s, it’s fine not to use it, but PHPMD complains. You can suppress the warning explicitly to make that intent clear:
-    public function reorderTable(array $order, int|string|null $draggedRecordKey = null): void
+    /** @SuppressWarnings("UnusedFormalParameter") */
+    public function reorderTable(array $order, int|string|null $draggedRecordKey = null): void
  1. Use the table query inside modifyQueryUsing instead of a captured builder
    Capturing $query = Plugin::whereCategory(...) and then returning it from modifyQueryUsing(fn () => $query) is a bit non‑idiomatic and can make it less obvious how this interacts with the base resource query. It’s clearer to filter the query that Filament passes in and compute the badge count separately:
-use Filament\Resources\Pages\ListRecords;
-use Filament\Schemas\Components\Tabs\Tab;
+use Filament\Resources\Pages\ListRecords;
+use Filament\Schemas\Components\Tabs\Tab;
+use Illuminate\Database\Eloquent\Builder;
@@
-        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);
-        }
+        foreach (PluginCategory::cases() as $category) {
+            $tabs[$category->value] = Tab::make($category->value)
+                ->label($category->getLabel())
+                ->icon($category->getIcon())
+                ->badge(Plugin::whereCategory($category->value)->count())
+                ->modifyQueryUsing(
+                    fn (Builder $query) => $query->whereCategory($category->value)
+                );
+        }

This keeps the tab behavior aligned with Filament’s usual getTabs() patterns and avoids reusing a shared builder instance.

app/Console/Commands/Plugin/DisablePluginCommand.php (1)

15-36: Optional: handle the “no plugins to disable” case before prompting

The disable flow and canDisable() check look good. For a slightly smoother UX, you might special‑case the situation where Plugin::pluck('name', 'id') is empty (no plugins in the table) and print a friendly message instead of prompting with an empty list.

app/Console/Commands/Plugin/UpdatePluginCommand.php (1)

15-36: Surface update success/failure from Plugins::updatePlugin

Right now Plugins::updatePlugin($plugin) is called and “Plugin updated.” is always printed. Given the service method itself wraps its work in a try/catch and forwards errors to handlePluginException, this command may still show a success message even if the update actually failed or the plugin was marked as errored.

Consider having updatePlugin() either:

  • return a boolean/enum status that you can check before printing the success message (and maybe adjust the exit code), or
  • rethrow/propagate failures for CLI contexts so this command can show a clear error instead of unconditional success.

The same pattern/concern likely applies to the install/uninstall commands using the facade.

app/Console/Commands/Plugin/UninstallPluginCommand.php (1)

16-41: Align CLI message/exit code with actual uninstall outcome

The uninstall flow and PluginStatus::NotInstalled guard make sense. Since Plugins::uninstallPlugin() wraps its own logic in a try/catch and delegates errors to handlePluginException, this command will still report “Plugin uninstalled …” even if something failed during uninstall.

It may be worth having uninstallPlugin() indicate success/failure (return value or throwable) so this command can:

  • avoid printing a success message when the plugin ended up in an errored state, and
  • optionally return a non‑zero exit code on failure (useful for automation).

This would keep behavior consistent with whatever you decide for the install/update commands.

app/Filament/Admin/Resources/Plugins/PluginResource.php (2)

113-129: Consider wrapping plugin operations in try-catch for user feedback.

The install action calls Plugins::installPlugin() without catching exceptions. If installation fails (e.g., composer issues, migration failures), the user won't see a meaningful error notification—the exception will propagate up.

Apply this pattern to provide user feedback on failure:

                     ->action(function (Plugin $plugin, $livewire) {
+                        try {
                             Plugins::installPlugin($plugin, !$plugin->isTheme() || !Plugins::hasThemePluginEnabled());

                             redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));

                             Notification::make()
                                 ->success()
                                 ->title(trans('admin/plugin.notifications.installed'))
                                 ->send();
+                        } catch (Exception $exception) {
+                            report($exception);
+
+                            Notification::make()
+                                ->danger()
+                                ->title(trans('admin/plugin.notifications.install_failed'))
+                                ->body($exception->getMessage())
+                                ->send();
+                        }
                     }),

The same pattern should be applied to update, enable, disable, delete, and uninstall actions.


264-268: URL validation with endsWith('.zip') is case-sensitive.

The validation ->endsWith('.zip') won't match .ZIP or .Zip. Consider using a case-insensitive check.

                     TextInput::make('url')
                         ->required()
                         ->url()
-                        ->endsWith('.zip'),
+                        ->rules(['regex:/\.zip$/i']),
app/Models/Plugin.php (1)

367-376: Variable name $provider is misleading in getCommands().

The callback variable $provider should be $command or $file for clarity, since this method discovers command classes, not providers.

-        return array_map(fn ($provider) => $this->namespace . '\\Console\\Commands\\' . str($provider->getRelativePathname())->remove('.php', false), File::allFiles($path));
+        return array_map(fn ($file) => $this->namespace . '\\Console\\Commands\\' . str($file->getRelativePathname())->remove('.php', false), File::allFiles($path));
app/Facades/Plugins.php (1)

11-31: PHPDoc annotations are missing optional parameters for some methods.

The downloadPluginFromFile and downloadPluginFromUrl annotations don't include the $cleanDownload parameter that exists in PluginService.

- * @method static void downloadPluginFromFile(UploadedFile $file)
- * @method static void downloadPluginFromUrl(string $url)
+ * @method static void downloadPluginFromFile(UploadedFile $file, bool $cleanDownload = false)
+ * @method static void downloadPluginFromUrl(string $url, bool $cleanDownload = false)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d2e2346 and f18f8a0.

📒 Files selected for processing (17)
  • app/Console/Commands/Plugin/ComposerPluginsCommand.php (1 hunks)
  • app/Console/Commands/Plugin/DisablePluginCommand.php (1 hunks)
  • app/Console/Commands/Plugin/InstallPluginCommand.php (1 hunks)
  • app/Console/Commands/Plugin/MakePluginCommand.php (1 hunks)
  • app/Console/Commands/Plugin/UninstallPluginCommand.php (1 hunks)
  • app/Console/Commands/Plugin/UpdatePluginCommand.php (1 hunks)
  • app/Facades/Plugins.php (1 hunks)
  • app/Filament/Admin/Resources/Plugins/Pages/ListPlugins.php (1 hunks)
  • app/Filament/Admin/Resources/Plugins/PluginResource.php (1 hunks)
  • app/Models/Plugin.php (1 hunks)
  • app/Models/Role.php (1 hunks)
  • app/Providers/Filament/AdminPanelProvider.php (3 hunks)
  • app/Providers/Filament/AppPanelProvider.php (3 hunks)
  • app/Services/Helpers/PluginService.php (1 hunks)
  • composer.json (2 hunks)
  • config/panel.php (1 hunks)
  • lang/en/admin/plugin.php (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
  • app/Models/Role.php
  • lang/en/admin/plugin.php
  • app/Console/Commands/Plugin/ComposerPluginsCommand.php
  • app/Providers/Filament/AdminPanelProvider.php
  • app/Console/Commands/Plugin/MakePluginCommand.php
🧰 Additional context used
🧬 Code graph analysis (7)
app/Console/Commands/Plugin/UninstallPluginCommand.php (3)
app/Facades/Plugins.php (1)
  • Plugins (32-38)
app/Console/Commands/Plugin/InstallPluginCommand.php (1)
  • handle (16-37)
app/Services/Helpers/PluginService.php (1)
  • uninstallPlugin (291-310)
app/Console/Commands/Plugin/InstallPluginCommand.php (3)
app/Models/Plugin.php (1)
  • Plugin (36-390)
app/Facades/Plugins.php (1)
  • Plugins (32-38)
app/Services/Helpers/PluginService.php (1)
  • installPlugin (257-276)
app/Console/Commands/Plugin/DisablePluginCommand.php (3)
app/Models/Plugin.php (2)
  • Plugin (36-390)
  • canDisable (203-206)
app/Facades/Plugins.php (1)
  • Plugins (32-38)
app/Services/Helpers/PluginService.php (1)
  • disablePlugin (382-385)
app/Console/Commands/Plugin/UpdatePluginCommand.php (3)
app/Models/Plugin.php (2)
  • Plugin (36-390)
  • isUpdateAvailable (259-279)
app/Facades/Plugins.php (1)
  • Plugins (32-38)
app/Services/Helpers/PluginService.php (1)
  • updatePlugin (278-289)
app/Providers/Filament/AppPanelProvider.php (5)
app/Facades/Plugins.php (1)
  • Plugins (32-38)
app/Providers/Filament/PanelProvider.php (1)
  • panel (28-75)
app/Providers/Filament/AdminPanelProvider.php (1)
  • panel (16-53)
app/Providers/Filament/ServerPanelProvider.php (1)
  • panel (17-52)
app/Services/Helpers/PluginService.php (1)
  • loadPanelPlugins (123-152)
app/Filament/Admin/Resources/Plugins/PluginResource.php (4)
app/Facades/Plugins.php (1)
  • Plugins (32-38)
app/Filament/Admin/Resources/Plugins/Pages/ListPlugins.php (1)
  • ListPlugins (12-40)
app/Models/Plugin.php (9)
  • Plugin (36-390)
  • isUpdateAvailable (259-279)
  • getReadme (378-389)
  • hasSettings (303-314)
  • getSettingsForm (319-332)
  • saveSettings (337-348)
  • isTheme (224-227)
  • canEnable (198-201)
  • canDisable (203-206)
app/Services/Helpers/PluginService.php (6)
  • installPlugin (257-276)
  • hasThemePluginEnabled (422-432)
  • enablePlugin (377-380)
  • disablePlugin (382-385)
  • downloadPluginFromFile (312-349)
  • downloadPluginFromUrl (351-370)
app/Filament/Admin/Resources/Plugins/Pages/ListPlugins.php (3)
app/Facades/Plugins.php (1)
  • Plugins (32-38)
app/Models/Plugin.php (1)
  • Plugin (36-390)
app/Services/Helpers/PluginService.php (1)
  • updateLoadOrder (413-420)
🪛 PHPMD (2.15.0)
app/Filament/Admin/Resources/Plugins/Pages/ListPlugins.php

16-16: Avoid unused parameters such as '$draggedRecordKey'. (undefined)

(UnusedFormalParameter)

🔇 Additional comments (10)
app/Providers/Filament/AppPanelProvider.php (1)

5-36: Panel plugin loading wiring looks consistent

Importing Plugins and delegating to Plugins::loadPanelPlugins($panel) after building the base panel matches the pattern in the other panel providers and keeps plugin wiring centralized in PluginService. This looks good.

app/Console/Commands/Plugin/InstallPluginCommand.php (1)

16-37: Install flow and guards look reasonable

The command’s control flow—resolving the plugin, validating existence and PluginStatus::NotInstalled, then delegating to Plugins::installPlugin()—is straightforward and consistent with the other lifecycle commands. From this file’s perspective the implementation looks solid.

app/Filament/Admin/Resources/Plugins/PluginResource.php (3)

49-56: Table configuration and reordering look correct.

The table is configured with proper reordering capability, authorization check for reorder permission, and default sorting by load_order. The tooltip for the reorder trigger action provides good UX feedback.


57-82: Table columns are well-structured with appropriate visual indicators.

Good use of icons and colors to indicate update availability status. The description truncation at 80 characters and conditional category visibility based on active tab are sensible UX decisions.


270-295: Good error handling for URL import action.

The try-catch pattern with user notification for failures is correctly implemented. This matches the pattern used in the file import action.

app/Models/Plugin.php (3)

97-178: The getRows() method properly handles plugin discovery with fallback for errors.

Good defensive coding: the method catches exceptions during JSON parsing, reports them, and creates an errored plugin entry with helpful debugging info. The dev mode check ensures developers see the full exception.


208-213: Version comparison logic is now correct.

The version_compare($currentPanelVersion, $this->panel_version, ...) order is correct—it checks if the current panel version meets the plugin's requirement.


353-362: Provider discovery uses consistent namespace building.

The getProviders() method correctly builds fully-qualified class names from the plugin's namespace and discovered provider files.

app/Services/Helpers/PluginService.php (1)

28-121: The loadPlugins() method is well-structured with proper isolation.

Good design choices:

  • Unit test bypass prevents test pollution
  • PSR-4 autoload registration uses correct getPrefixesPsr4() check
  • Config, translations, providers, commands, migrations, and views are loaded with proper path validation
  • Exception handling per-plugin prevents one broken plugin from blocking others
app/Facades/Plugins.php (1)

32-38: Facade implementation is correct and follows Laravel conventions.

The facade correctly maps to PluginService::class and will resolve the service from the container.

@Boy132 Boy132 marked this pull request as ready for review December 10, 2025 09:25
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (5)
app/Services/Helpers/PluginService.php (4)

35-36: Add runtime type check for ClassLoader.

File::getRequire() returns mixed. If vendor/autoload.php is corrupted or returns unexpected value, this could cause a fatal error.

-        /** @var ClassLoader $classLoader */
-        $classLoader = File::getRequire(base_path('vendor/autoload.php'));
+        $classLoader = File::getRequire(base_path('vendor/autoload.php'));
+
+        if (!$classLoader instanceof ClassLoader) {
+            throw new Exception('Invalid autoloader: expected ClassLoader instance.');
+        }

257-276: json_decode without JSON_THROW_ON_ERROR may cause silent failures.

Line 260 calls json_decode($plugin->composer_packages, true, 512) without JSON_THROW_ON_ERROR. Malformed JSON silently returns null, which is passed to manageComposerPackages().

-            $this->manageComposerPackages(json_decode($plugin->composer_packages, true, 512));
+            $composerPackages = $plugin->composer_packages
+                ? json_decode($plugin->composer_packages, true, 512, JSON_THROW_ON_ERROR)
+                : null;
+            $this->manageComposerPackages($composerPackages);

294-313: Same json_decode issue in uninstallPlugin.

Line 297 has the same issue as installPlugin - json_decode without JSON_THROW_ON_ERROR.

-            $pluginPackages = json_decode($plugin->composer_packages, true, 512);
+            $pluginPackages = $plugin->composer_packages
+                ? json_decode($plugin->composer_packages, true, 512, JSON_THROW_ON_ERROR)
+                : null;

390-405: Missing error handling for JSON operations in setMetaData.

File::json() and json_encode() with JSON_THROW_ON_ERROR can throw JsonException, but there's no try-catch. If plugin.json is malformed, this propagates an unhandled exception.

     private function setMetaData(string|Plugin $plugin, array $data): void
     {
         $path = plugin_path($plugin instanceof Plugin ? $plugin->id : $plugin, 'plugin.json');
 
         if (File::exists($path)) {
+            try {
                 $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);
+            } catch (\JsonException $e) {
+                report($e);
+                throw new Exception("Failed to update plugin metadata: {$e->getMessage()}");
+            }
         }
     }
app/Filament/Admin/Resources/Plugins/PluginResource.php (1)

239-243: Plugin ID validation relies on filename, not actual plugin.json content.

The duplicate check at line 241 validates against the zip filename rather than the id field in plugin.json inside the archive. A zip named new-plugin.zip containing a plugin.json with id="existing-plugin" would bypass this check.

Consider extracting and validating plugin.json before full extraction, or perform this check in PluginService::downloadPluginFromFile() which has access to the zip contents.

🧹 Nitpick comments (2)
app/Filament/Admin/Resources/Plugins/PluginResource.php (1)

275-281: URL-based plugin name extraction is fragile.

The plugin name extraction at line 277 assumes the URL path has the plugin name as the last segment before .zip. URLs with query parameters (e.g., example.com/download?file=plugin.zip) or different structures could yield unexpected results.

Consider validating the extracted plugin name is non-empty and contains only valid characters.

                     try {
-                            $pluginName = str($data['url'])->before('.zip')->explode('/')->last();
+                            $pluginName = str($data['url'])->before('.zip')->explode('/')->last();
+
+                            if (empty($pluginName) || !preg_match('/^[a-zA-Z0-9_-]+$/', $pluginName)) {
+                                throw new Exception(trans('admin/plugin.notifications.invalid_plugin_name'));
+                            }
app/Services/Helpers/PluginService.php (1)

354-373: Consider SSRF protection for URL downloads.

The HTTP client usage with timeouts (line 360) is good, but there's no validation to prevent access to internal network resources. An attacker could potentially probe internal services via URLs like http://localhost:6379 or cloud metadata endpoints.

Consider adding URL scheme and private IP validation:

 public function downloadPluginFromUrl(string $url, bool $cleanDownload = false): void
 {
+    $parsed = parse_url($url);
+    if (!$parsed || !in_array($parsed['scheme'] ?? '', ['http', 'https'])) {
+        throw new InvalidFileUploadException('Invalid URL scheme.');
+    }
+
+    $host = $parsed['host'] ?? '';
+    if (filter_var($host, FILTER_VALIDATE_IP) &&
+        !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
+        throw new InvalidFileUploadException('Access to private IP ranges is not allowed.');
+    }
+
     $info = pathinfo($url);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f18f8a0 and 8cf1c2e.

📒 Files selected for processing (3)
  • app/Filament/Admin/Resources/Plugins/PluginResource.php (1 hunks)
  • app/Models/Plugin.php (1 hunks)
  • app/Services/Helpers/PluginService.php (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
app/Models/Plugin.php (4)
app/Facades/Plugins.php (1)
  • Plugins (32-38)
app/helpers.php (1)
  • plugin_path (91-94)
app/Services/Helpers/PluginService.php (1)
  • isDevModeActive (454-457)
app/Contracts/Plugins/HasPluginSettings.php (2)
  • getSettingsForm (12-12)
  • saveSettings (17-17)
🔇 Additional comments (16)
app/Filament/Admin/Resources/Plugins/PluginResource.php (4)

1-49: LGTM - Navigation and label configuration.

The navigation setup with localized labels, dynamic badge count, and proper icon configuration follows Filament conventions well.


50-83: LGTM - Table configuration is well-structured.

The table setup with sortable/searchable columns, conditional visibility, authorization for reordering, and visual indicators for update availability is well-implemented.


114-216: Action group is well-organized with proper guards.

The action handlers have appropriate authorization, visibility conditions, confirmation modals where needed, and consistent notification/redirect patterns. The theme plugin enabling logic at line 122 correctly handles the exclusive theme constraint.


307-312: LGTM - Pages configuration.

The route configuration correctly maps the index to the ListPlugins page.

app/Models/Plugin.php (6)

98-183: LGTM - Plugin discovery with good validation.

The getRows() method properly validates that the plugin folder name matches the id in plugin.json (line 114-116), has appropriate error handling with dev-mode awareness, and correctly reads the panel_version field (line 142).


213-218: LGTM - Version comparison logic is correct.

The isCompatible() method now correctly compares the current panel version against the plugin's required version (line 217). The strict mode (=) vs non-strict (>=) handling via isPanelVersionStrict() is well-implemented.


239-262: LGTM - Update data fetching with proper HTTP handling.

The getUpdateData() method now uses Laravel's HTTP client with appropriate timeouts (lines 248), caching (line 246), and error handling. The support for multi-plugin update JSONs (lines 251-253) is a nice touch.


264-306: LGTM - Update availability and download URL methods.

Both isUpdateAvailable() and getDownloadUrlForUpdate() correctly use the $panelVersion variable for array key lookup (lines 275, 297) and include wildcard fallback support.


308-353: LGTM - Settings delegation pattern.

The settings methods cleanly delegate to the loaded plugin instance via filament($this->id), with appropriate HasPluginSettings interface checks and silent handling when plugins aren't loaded on the current panel.


383-394: LGTM - README caching.

The getReadme() method appropriately caches the README content to avoid repeated file reads.

app/Services/Helpers/PluginService.php (6)

28-121: LGTM - Plugin loading with proper PSR-4 registration.

The loadPlugins() method now correctly uses getPrefixesPsr4() for namespace checking (line 55), has proper compatibility filtering, and comprehensive loading of config, translations, providers, commands, migrations, and views with appropriate error handling.


154-206: LGTM - Composer package management with proper error handling.

The method now uses JSON_THROW_ON_ERROR (line 173) and sets appropriate timeouts (600s) for composer operations (lines 188, 201). The error handling and package aggregation logic is well-structured.


232-255: LGTM - Asset building with timeouts.

The method now includes appropriate timeouts for yarn install (300s) and yarn build (600s), addressing previous concerns about indefinite blocking.


278-292: LGTM - Update plugin with null URL guard.

The updatePlugin() method now properly checks if $downloadUrl is truthy before proceeding (line 282), addressing the previous concern about null URLs.


315-352: LGTM - Zip extraction with security validations.

The method now includes file size validation (lines 318-321) and path traversal checks (lines 330-336), addressing previous security concerns. The extraction path logic (line 344) correctly handles both flat and nested zip structures.


425-468: LGTM - Helper methods.

The hasThemePluginEnabled(), getPluginLanguages(), isDevModeActive(), and handlePluginException() methods are well-implemented with clear logic and appropriate patterns.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
app/Models/Plugin.php (1)

349-371: Fix FQCN building for nested Providers/Commands paths (slashes in getRelativePathname()).
This still produces invalid namespaces if nested dirs exist (e.g. Sub/FooProvider).

-        return array_map(fn ($provider) => $this->namespace . '\\Providers\\' . str($provider->getRelativePathname())->remove('.php', false), File::allFiles($path));
+        return array_map(
+            fn ($provider) => $this->namespace . '\\Providers\\' .
+                str($provider->getRelativePathname())
+                    ->remove('.php', false)
+                    ->replace(['/', '\\'], '\\'),
+            File::allFiles($path)
+        );
 ...
-        return array_map(fn ($provider) => $this->namespace . '\\Console\\Commands\\' . str($provider->getRelativePathname())->remove('.php', false), File::allFiles($path));
+        return array_map(
+            fn ($provider) => $this->namespace . '\\Console\\Commands\\' .
+                str($provider->getRelativePathname())
+                    ->remove('.php', false)
+                    ->replace(['/', '\\'], '\\'),
+            File::allFiles($path)
+        );
🧹 Nitpick comments (1)
database/Seeders/DatabaseSeeder.php (1)

21-33: Make plugin seeding more resilient (stable class naming + don’t let one plugin break all seeds).
Deriving the class from $plugin->name is brittle; also consider skipping errored plugins for seeding and isolating failures so the core seed still completes.

 use App\Models\Plugin;
 use App\Models\Role;
 use Illuminate\Database\Seeder;
 use Illuminate\Support\Str;
 
 class DatabaseSeeder extends Seeder
 {
     public function run(): void
     {
         $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;
             }
 
-            $name = Str::studly($plugin->name);
+            // Prefer stable identifiers over display names for class conventions
+            $name = Str::studly($plugin->id);
             $seeder = "\\{$plugin->namespace}\Database\Seeders\\{$name}Seeder";
 
             if (class_exists($seeder)) {
-                $this->call($seeder);
+                try {
+                    $this->call($seeder);
+                } catch (\Throwable $e) {
+                    report($e);
+                    // continue seeding other plugins
+                }
             }
         }
     }
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6ac5745 and c9764c0.

📒 Files selected for processing (2)
  • app/Models/Plugin.php (1 hunks)
  • database/Seeders/DatabaseSeeder.php (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
database/Seeders/DatabaseSeeder.php (1)
app/Models/Plugin.php (2)
  • Plugin (37-385)
  • shouldLoad (196-199)
app/Models/Plugin.php (4)
app/Facades/Plugins.php (1)
  • Plugins (32-38)
app/helpers.php (1)
  • plugin_path (91-94)
app/Services/Helpers/PluginService.php (1)
  • isDevModeActive (454-457)
app/Contracts/Plugins/HasPluginSettings.php (2)
  • getSettingsForm (12-12)
  • saveSettings (17-17)
🔇 Additional comments (1)
app/Models/Plugin.php (1)

37-52: The concern about Sushi model persistence is unfounded. All status, load_order, and status_message changes go through PluginService::setMetaData() (lines 390–403), which explicitly writes to plugin.json via File::put() before calling $plugin->update(). No direct update() calls exist elsewhere in the codebase—the only instance is within the controlled service method. The fillable fields are appropriate and not misleading since callers do not interact with the model directly; they use service methods that handle persistence correctly.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (10)
app/Models/Plugin.php (5)

149-176: Undefined variable $data in catch block.

If File::json() throws before $data is assigned, the catch block's references to $data['id'], $data['name'], and $data['author'] on lines 158-160 will cause undefined variable notices.

Initialize $data = [] before the try block to ensure deterministic behavior.


206-209: canDisable() should not require isCompatible().

If a plugin becomes incompatible after a panel upgrade, administrators won't be able to disable it since isCompatible() returns false. This traps users with incompatible plugins they cannot manage.

Remove the isCompatible() check from canDisable():

 public function canDisable(): bool
 {
-    return $this->status !== PluginStatus::Disabled && $this->status !== PluginStatus::NotInstalled && $this->isCompatible();
+    return $this->status !== PluginStatus::Disabled && $this->status !== PluginStatus::NotInstalled;
 }

211-216: Caret constraints not properly handled by version_compare().

When panel_version is ^1.0.0, isPanelVersionStrict() returns false, but isCompatible() still passes the raw ^1.0.0 string to version_compare(). The caret character causes incorrect comparison results.

Strip the caret before comparison:

 public function isCompatible(): bool
 {
     $currentPanelVersion = config('app.version', 'canary');
 
-    return !$this->panel_version || $currentPanelVersion === 'canary' || version_compare($currentPanelVersion, $this->panel_version, $this->isPanelVersionStrict() ? '=' : '>=');
+    if (!$this->panel_version || $currentPanelVersion === 'canary') {
+        return true;
+    }
+
+    $required = ltrim($this->panel_version, '^');
+    return version_compare($currentPanelVersion, $required, $this->isPanelVersionStrict() ? '=' : '>=');
 }

244-260: Add validation that HTTP response is an array.

Http::get()->json() can return non-array values (scalar, null) for malformed responses. The array_key_exists() calls on lines 249 and 253 will fail if $data is not an array.

             try {
                 $data = Http::timeout(5)->connectTimeout(1)->get($this->update_url)->throw()->json();
+                
+                if (!is_array($data)) {
+                    return null;
+                }
 
                 // Support update jsons that cover multiple plugins
-                if (array_key_exists($this->id, $data)) {
+                if (array_key_exists($this->id, $data) && is_array($data[$this->id])) {
                     $data = $data[$this->id];
                 }

350-371: Nested directory paths produce invalid namespace separators.

getRelativePathname() returns paths with OS-specific directory separators (e.g., Sub/Provider.php). The current implementation would produce Namespace\Providers\Sub/Provider with a forward slash instead of a proper namespace separator.

-    return array_map(fn ($provider) => $this->namespace . '\\Providers\\' . str($provider->getRelativePathname())->remove('.php', false), File::allFiles($path));
+    return array_map(fn ($provider) => $this->namespace . '\\Providers\\' . str($provider->getRelativePathname())->remove('.php', false)->replace('/', '\\'), File::allFiles($path));

Apply the same fix to getCommands():

-    return array_map(fn ($provider) => $this->namespace . '\\Console\\Commands\\' . str($provider->getRelativePathname())->remove('.php', false), File::allFiles($path));
+    return array_map(fn ($command) => $this->namespace . '\\Console\\Commands\\' . str($command->getRelativePathname())->remove('.php', false)->replace('/', '\\'), File::allFiles($path));
app/Services/Helpers/PluginService.php (5)

35-36: Add runtime type validation for ClassLoader.

File::getRequire() returns mixed. If vendor/autoload.php is corrupted or returns an unexpected value, subsequent method calls will cause fatal errors.

-    /** @var ClassLoader $classLoader */
-    $classLoader = File::getRequire(base_path('vendor/autoload.php'));
+    $classLoader = File::getRequire(base_path('vendor/autoload.php'));
+    
+    if (!$classLoader instanceof ClassLoader) {
+        throw new Exception('Invalid autoloader: expected ClassLoader instance.');
+    }

270-291: json_decode silently returns null for malformed JSON.

Line 273 calls json_decode() without JSON_THROW_ON_ERROR. If composer_packages contains malformed JSON, null is passed to manageComposerPackages() and the error is silently ignored.

     public function installPlugin(Plugin $plugin, bool $enable = true): void
     {
         try {
-            $this->manageComposerPackages(json_decode($plugin->composer_packages, true, 512));
+            $composerPackages = $plugin->composer_packages 
+                ? json_decode($plugin->composer_packages, true, 512, JSON_THROW_ON_ERROR) 
+                : null;
+            $this->manageComposerPackages($composerPackages);

369-388: SSRF vulnerability - add URL validation.

The method accepts arbitrary URLs without validation. Attackers could access internal resources via http://localhost:6379, cloud metadata endpoints, or use non-HTTP schemes like file://.

Add URL validation before the HTTP request:

 public function downloadPluginFromUrl(string $url, bool $cleanDownload = false): void
 {
+    // Validate URL scheme
+    $parsed = parse_url($url);
+    if (!$parsed || !in_array($parsed['scheme'] ?? '', ['http', 'https'], true)) {
+        throw new InvalidFileUploadException('Invalid URL scheme. Only http and https are allowed.');
+    }
+    
+    // Prevent access to private networks
+    $host = $parsed['host'] ?? '';
+    if (filter_var($host, FILTER_VALIDATE_IP)) {
+        if (!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
+            throw new InvalidFileUploadException('Access to private IP ranges is not allowed.');
+        }
+    }
+
     $info = pathinfo($url);

405-420: Add error handling for JSON operations.

File::json() and json_encode() with JSON_THROW_ON_ERROR can throw JsonException. If plugin.json is malformed, this propagates an unhandled exception.

 private function setMetaData(string|Plugin $plugin, array $data): void
 {
     $path = plugin_path($plugin instanceof Plugin ? $plugin->id : $plugin, 'plugin.json');
 
     if (File::exists($path)) {
+        try {
             $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));
+        } catch (\JsonException $e) {
+            report($e);
+            throw new Exception("Failed to update plugin metadata: {$e->getMessage()}");
+        }
 
         $plugin = $plugin instanceof Plugin ? $plugin : Plugin::findOrFail($plugin);
         $plugin->update($metaData);
     }
 }

353-359: Validate plugin name extracted from filename.

The plugin name is extracted directly from the uploaded filename without sanitization. A malicious filename like ../../etc.zip would result in $pluginName = "../../etc", which could cause path traversal when used in plugin_path() or File::deleteDirectory().

     $pluginName = str($file->getClientOriginalName())->before('.zip')->toString();
+    
+    // Validate plugin name to prevent directory traversal
+    if (preg_match('/[^a-zA-Z0-9_-]/', $pluginName)) {
+        throw new Exception('Invalid plugin name. Only alphanumeric characters, dashes, and underscores are allowed.');
+    }
 
     if ($cleanDownload) {
🧹 Nitpick comments (1)
app/Services/Helpers/PluginService.php (1)

293-307: Consider providing feedback when no update URL is available.

The null check at line 297 prevents the crash, but when getDownloadUrlForUpdate() returns null (e.g., for canary builds), the method silently does nothing. Consider logging a warning or throwing an exception so users know why the update didn't proceed.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c9764c0 and 0ba6534.

📒 Files selected for processing (4)
  • app/Facades/Plugins.php (1 hunks)
  • app/Models/Plugin.php (1 hunks)
  • app/Services/Helpers/PluginService.php (1 hunks)
  • database/Seeders/DatabaseSeeder.php (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • database/Seeders/DatabaseSeeder.php
🔇 Additional comments (4)
app/Facades/Plugins.php (1)

1-40: LGTM - Well-structured Laravel facade.

The facade follows Laravel conventions correctly. The PHPDoc annotations provide good IDE support for the proxied methods.

app/Services/Helpers/PluginService.php (3)

159-207: LGTM - Good timeout and error handling for Composer operations.

The method properly handles JSON parsing with JSON_THROW_ON_ERROR, includes 600-second timeouts on Process calls, and has appropriate error handling.


245-268: LGTM - Proper timeouts added for asset building.

Both yarn install (5 min) and yarn build (10 min) now have appropriate timeouts, addressing the previous hanging concern.


469-484: LGTM - Well-structured helper methods and centralized exception handling.

The handlePluginException() method provides consistent dev-mode-aware error handling. The query helper methods (hasThemePluginEnabled, getPluginLanguages, isDevModeActive) are clean and straightforward.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (5)
app/Services/Helpers/PluginService.php (5)

35-37: Validate autoloader type before using it
File::getRequire() returns mixed; if vendor/autoload.php returns something unexpected, later calls can fatally error.

         /** @var ClassLoader $classLoader */
-        $classLoader = File::getRequire(base_path('vendor/autoload.php'));
+        $classLoader = File::getRequire(base_path('vendor/autoload.php'));
+        if (!$classLoader instanceof ClassLoader) {
+            throw new Exception('Invalid Composer autoloader: expected ' . ClassLoader::class);
+        }

272-293: Don’t silently swallow malformed composer_packages JSON
json_decode(..., 512) returns null on malformed JSON; that can lead to missing package installs/removals with no clear error.

     public function installPlugin(Plugin $plugin, bool $enable = true): void
     {
         try {
-            $this->manageComposerPackages(json_decode($plugin->composer_packages, true, 512));
+            $composerPackages = $plugin->composer_packages
+                ? json_decode($plugin->composer_packages, true, 512, JSON_THROW_ON_ERROR)
+                : [];
+            $this->manageComposerPackages($composerPackages);
@@
         } 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);
+            $pluginPackages = $plugin->composer_packages
+                ? json_decode($plugin->composer_packages, true, 512, JSON_THROW_ON_ERROR)
+                : [];
@@
             $this->manageComposerPackages(oldPackages: $pluginPackages);
         } catch (Exception $exception) {
             $this->handlePluginException($plugin, $exception);
         }
     }

Also applies to: 311-330


332-369: Fix max_import_size units + address remaining ZipSlip/SSRF risks

  1. UploadedFile::getSize() and strlen($content) are bytes, but the error message says “MiB”; if config is MiB, current comparisons will wrongly reject almost all uploads (likely matching the reported unzip/import failure).
  2. URL downloads still allow SSRF (no scheme/host/IP allowlist, redirect policy, or streaming to file).
  3. Zip validation should also reject backslashes / drive letters and ensure extracted paths stay within the intended directory.
     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)");
+        $maxMiB = (int) config('panel.plugin.max_import_size');
+        $maxBytes = $maxMiB * 1024 * 1024;
+        if ($file->getSize() !== null && $file->getSize() > $maxBytes) {
+            throw new Exception("Zip file too large. ({$maxMiB} MiB)");
         }
@@
         for ($i = 0; $i < $zip->numFiles; $i++) {
             $filename = $zip->getNameIndex($i);
-            if (Str::contains($filename, '..') || Str::startsWith($filename, '/')) {
+            if (
+                $filename === false ||
+                Str::contains($filename, '..') ||
+                Str::contains($filename, '\\') ||       // windows path traversal
+                Str::startsWith($filename, '/') ||      // absolute *nix
+                preg_match('/^[a-zA-Z]:\//', $filename) // absolute windows drive
+            ) {
                 $zip->close();
                 throw new Exception('Zip file contains invalid path traversal sequences.');
             }
         }
@@
     public function downloadPluginFromUrl(string $url, bool $cleanDownload = false): void
     {
+        // TODO: Enforce SSRF policy (scheme allowlist, block private IP ranges, limit redirects).
         $info = pathinfo($url);
         $tmpDir = TemporaryDirectory::make()->deleteWhenDestroyed();
         $tmpPath = $tmpDir->path($info['basename']);
 
-        $content = Http::timeout(60)->connectTimeout(5)->throw()->get($url)->body();
+        $content = Http::timeout(60)->connectTimeout(5)->throw()->get($url)->body();
@@
-        $maxSize = config('panel.plugin.max_import_size');
-        if (strlen($content) > $maxSize) {
-            throw new InvalidFileUploadException("Zip file too large. ($maxSize  MiB)");
+        $maxMiB = (int) config('panel.plugin.max_import_size');
+        $maxBytes = $maxMiB * 1024 * 1024;
+        if (strlen($content) > $maxBytes) {
+            throw new InvalidFileUploadException("Zip file too large. ({$maxMiB} MiB)");
         }

If you want to fully close the SSRF + large-file memory issues, the next step is: validate URL (scheme + resolved IP not private/reserved), set max_redirects, and stream to $tmpPath instead of loading into memory.

Also applies to: 371-390


408-422: Harden plugin.json read/modify/write (exceptions + atomicity + locking)
File::json(..., JSON_THROW_ON_ERROR) / json_encode(..., JSON_THROW_ON_ERROR) can throw, and current writes are non-atomic (risking corruption / partial updates, especially with concurrent actions like load-order updates).

     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);
+            try {
+                $pluginData = File::json($path, JSON_THROW_ON_ERROR);
+                $metaData = array_merge($pluginData['meta'] ?? [], $data);
+                $pluginData['meta'] = $metaData;
+
+                $payload = json_encode($pluginData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+                File::put($path, $payload, true); // consider atomic temp+rename if needed
+
+                $plugin = $plugin instanceof Plugin ? $plugin : Plugin::findOrFail($plugin);
+                $plugin->update($metaData);
+            } catch (\Throwable $e) {
+                report($e);
+                throw new Exception("Failed to update plugin metadata: {$e->getMessage()}");
+            }
         }
     }

Also consider wrapping updateLoadOrder() in a DB transaction and/or batching writes so partial failures don’t leave mixed load orders.

Also applies to: 432-440


355-362: Sanitize $pluginName before using it in filesystem paths
$pluginName comes from the client filename and is used in plugin_path(...) / deletion; enforce a strict allowlist to prevent weird paths and collisions.

         $pluginName = str($file->getClientOriginalName())->before('.zip')->toString();
+        if (!preg_match('/^[A-Za-z0-9_-]+$/', $pluginName)) {
+            $zip->close();
+            throw new Exception('Invalid plugin name.');
+        }
🧹 Nitpick comments (2)
app/Services/Helpers/PluginService.php (2)

126-154: Instantiate panel plugin via container (avoid hard ctor requirement)
new $pluginClass() forces a zero-arg constructor; using the container is more robust and consistent with Laravel conventions.

-                $panel->plugin(new $pluginClass());
+                $panel->plugin($this->app->make($pluginClass));

28-124: Catch Throwable (not only Exception) in plugin lifecycle flows
TypeErrors / Errors from plugin code won’t be caught today, so status won’t be set to Errored outside dev mode.

-            } catch (Exception $exception) {
+            } catch (\Throwable $exception) {
                 $this->handlePluginException($plugin, $exception);
             }

…and update the handler signature:

-    private function handlePluginException(string|Plugin $plugin, Exception $exception): void
+    private function handlePluginException(string|Plugin $plugin, \Throwable $exception): void

Also applies to: 126-155, 161-209, 247-270, 272-293, 295-309, 311-330, 476-485

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0ba6534 and a0ed5b9.

📒 Files selected for processing (1)
  • app/Services/Helpers/PluginService.php (1 hunks)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (6)
app/Services/Helpers/PluginService.php (6)

35-37: Validate vendor/autoload.php return type before using it
File::getRequire() is mixed; calling getPrefixesPsr4() will fatal if it’s not a ClassLoader.

         /** @var ClassLoader $classLoader */
-        $classLoader = File::getRequire(base_path('vendor/autoload.php'));
+        $classLoader = File::getRequire(base_path('vendor/autoload.php'));
+        if (!$classLoader instanceof ClassLoader) {
+            throw new Exception('Invalid Composer autoloader instance.');
+        }

272-293: Don’t silently accept malformed composer_packages JSON during install/uninstall
json_decode(..., 512) can return null on malformed JSON, causing confusing downstream behavior.

     public function installPlugin(Plugin $plugin, bool $enable = true): void
     {
         try {
-            $this->manageComposerPackages(json_decode($plugin->composer_packages, true, 512));
+            $pluginPackages = $plugin->composer_packages
+                ? json_decode($plugin->composer_packages, true, 512, JSON_THROW_ON_ERROR)
+                : null;
+            $this->manageComposerPackages($pluginPackages);
@@
         } 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);
+            $pluginPackages = $plugin->composer_packages
+                ? json_decode($plugin->composer_packages, true, 512, JSON_THROW_ON_ERROR)
+                : null;

Also applies to: 311-330


355-363: Sanitize $pluginName before using it in paths / deletions
$pluginName comes from client filename and is used in plugin_path() and File::deleteDirectory(). Reject unexpected characters to avoid traversal/foot-guns.

         $pluginName = str($file->getClientOriginalName())->before('.zip')->toString();
+        if (!preg_match('/^[a-zA-Z0-9_-]+$/', $pluginName)) {
+            $zip->close();
+            throw new Exception('Invalid plugin archive name.');
+        }

346-369: Zip extraction hardening is still incomplete (path traversal / zip bomb / symlinks)
Current checks ('..' and leading /) are not sufficient; also no max entry count / max uncompressed size, and extraction happens directly into the target directory.

Minimum improvements:

  • Validate each entry’s normalized path (including backslashes / Windows drive letters).
  • Enforce: max entries, max per-file uncompressed size, max total uncompressed size.
  • Extract to a temp dir first, validate expected layout (must contain plugin.json), then atomically move into place.

371-390: SSRF + memory risk in downloadPluginFromUrl() (needs allowlist/denylist + streaming + redirect control)
This fetches arbitrary URLs with default redirect behavior and loads the full body into memory before size checks.

Concrete fixes to consider:

  • Parse/validate scheme (http/https only), block localhost/private/reserved IPs (incl. DNS rebinding considerations).
  • Disable redirects (or strictly limit and re-validate the final host).
  • Stream to disk (Guzzle sink) and enforce a hard max download size.

408-422: Add try/catch + atomic write/locking around plugin.json updates
File::json(..., JSON_THROW_ON_ERROR) / json_encode(..., JSON_THROW_ON_ERROR) / filesystem writes can throw; current code can crash flows and also has read-modify-write race potential.

At minimum:

  • catch JsonException + Throwable
  • write via temp file + rename (or flock) to avoid corrupted/partial writes
  • consider not calling $plugin->update($metaData) if the file write failed
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a0ed5b9 and c3cfb21.

📒 Files selected for processing (2)
  • app/Models/Plugin.php (1 hunks)
  • app/Services/Helpers/PluginService.php (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/Models/Plugin.php

Copy link
Member

@notAreYouScared notAreYouScared left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to go, once the update script backs up the new folders/files that will be created by plugins!
We wouldn't want people to lose plugins after every update!

Copy link
Member

@lancepioch lancepioch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two main things:

  1. Use dependency injection instead of facade for Plugins
  2. Do not use Sushi

@Boy132 Boy132 merged commit 242a75b into main Dec 19, 2025
25 checks passed
@Boy132 Boy132 deleted the boy132/plugin-system branch December 19, 2025 23:32
@github-actions github-actions bot locked and limited conversation to collaborators Dec 19, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants