From 7a74e0a1633102945319a52714a372ee6e2178f7 Mon Sep 17 00:00:00 2001 From: H1ghSyst3m <60105043+H1ghSyst3m@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:27:45 +0100 Subject: [PATCH 1/8] Add mod/plugin update and uninstall features with metadata tracking Introduces update and uninstall actions for Minecraft mods/plugins, with UI enhancements for status tracking and confirmation dialogs. Adds persistent metadata management via a .modrinth-metadata.json file to track installed mods, versions, and installation dates. Updates translations, documentation, and bumps plugin version to 1.1.0. --- minecraft-modrinth/README.md | 38 +- minecraft-modrinth/lang/de/strings.php | 22 + minecraft-modrinth/lang/en/strings.php | 22 + minecraft-modrinth/plugin.json | 4 +- .../Pages/MinecraftModrinthProjectPage.php | 416 +++++++++++++++--- .../src/Services/MinecraftModrinthService.php | 176 ++++++++ 6 files changed, 605 insertions(+), 73 deletions(-) diff --git a/minecraft-modrinth/README.md b/minecraft-modrinth/README.md index d72a274b..bd9ba97e 100644 --- a/minecraft-modrinth/README.md +++ b/minecraft-modrinth/README.md @@ -1,15 +1,41 @@ # Minecraft Modrinth (by Boy132) -Easily download and install Minecraft mods and plugins directly from Modrinth within the server panel. +Easily download, update, and manage Minecraft mods and plugins directly from Modrinth within the server panel. ## Setup -Add `modrinth_mods` or `modrinth_plugins` to the _features_ of your egg to enable the mod/plugins page. +Add `modrinth_mods` or `modrinth_plugins` to the _features_ of your egg to enable the mod/plugins page. Also make sure your egg has the `minecraft` _tag_ and a _tag_ for the [mod loader](https://github.com/pelican-dev/plugins/blob/main/minecraft-modrinth/src/Enums/MinecraftLoader.php#L10-L16). (e.g. `paper` or `neoforge`) ## Features -- Browse and search Modrinth's extensive mod library -- Download mods and plugins directly to your server -- Automatic version compatibility checking -- Seamless installation to the correct server directory +- **Browse and Search**: Access Modrinth's extensive mod library with search and pagination +- **Smart Installation**: One-click install with automatic latest version selection +- **Status Tracking**: See which mods are installed directly in the Modrinth list +- **Update Detection**: Automatic detection of available updates with one-click upgrade +- **Easy Uninstall**: Remove mods/plugins with confirmation and automatic file cleanup +- **Metadata Management**: Tracks installed versions, filenames, and installation dates +- **Version Compatibility**: Automatic filtering by Minecraft version and mod loader +- **Seamless Installation**: Downloads to the correct server directory (mods/ or plugins/) +- **Multilingual**: Supports English and German translations + +## How It Works + +### Installing Mods/Plugins +1. Browse or search for mods in the Modrinth list +2. Click the **Install** button (download icon) +3. The latest compatible version is automatically downloaded and tracked + +### Managing Installed Mods +- **Installed** (green check): Mod is installed and up-to-date +- **Update** (orange refresh): Newer version available - click to upgrade +- **Uninstall** (red trash): Remove mod from server + +### Metadata Tracking +The plugin maintains a `.modrinth-metadata.json` file in your mods/plugins folder that tracks: +- Project ID and name +- Installed version ID and number +- Filename +- Installation date + +This enables accurate update detection and prevents duplicate installations diff --git a/minecraft-modrinth/lang/de/strings.php b/minecraft-modrinth/lang/de/strings.php index a6474b8f..d209d380 100644 --- a/minecraft-modrinth/lang/de/strings.php +++ b/minecraft-modrinth/lang/de/strings.php @@ -36,11 +36,33 @@ ], 'actions' => [ + 'install' => 'Installieren', 'download' => 'Herunterladen', + 'installed' => 'Installiert', + 'update' => 'Aktualisieren', + 'uninstall' => 'Deinstallieren', + ], + + 'modals' => [ + 'update_heading' => 'Mod/Plugin aktualisieren', + 'update_description' => 'Dies ersetzt Version :old_version durch Version :new_version. Die alte Datei wird gelöscht.', + 'uninstall_heading' => 'Mod/Plugin deinstallieren', + 'uninstall_description' => 'Möchten Sie :name wirklich deinstallieren? Dies wird die Datei dauerhaft von Ihrem Server löschen.', ], 'notifications' => [ + 'install_success' => 'Installation abgeschlossen', + 'install_success_body' => ':name Version :version erfolgreich installiert', + 'install_failed' => 'Installation fehlgeschlagen', + 'install_failed_body' => 'Bei der Installation ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an den Support, wenn das Problem weiterhin besteht.', 'download_started' => 'Download gestartet', 'download_failed' => 'Download konnte nicht gestartet werden', + 'update_success' => 'Aktualisierung abgeschlossen', + 'update_success_body' => 'Erfolgreich auf Version :version aktualisiert', + 'update_failed' => 'Aktualisierung fehlgeschlagen', + 'update_failed_body' => 'Bei der Aktualisierung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an den Support, wenn das Problem weiterhin besteht.', + 'uninstall_success' => 'Deinstallation abgeschlossen', + 'uninstall_failed' => 'Deinstallation fehlgeschlagen', + 'uninstall_failed_body' => 'Bei der Deinstallation ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an den Support, wenn das Problem weiterhin besteht.', ], ]; diff --git a/minecraft-modrinth/lang/en/strings.php b/minecraft-modrinth/lang/en/strings.php index fc3b36e4..589d17ea 100644 --- a/minecraft-modrinth/lang/en/strings.php +++ b/minecraft-modrinth/lang/en/strings.php @@ -36,11 +36,33 @@ ], 'actions' => [ + 'install' => 'Install', 'download' => 'Download', + 'installed' => 'Installed', + 'update' => 'Update', + 'uninstall' => 'Uninstall', + ], + + 'modals' => [ + 'update_heading' => 'Update Mod/Plugin', + 'update_description' => 'This will replace version :old_version with version :new_version. The old file will be deleted.', + 'uninstall_heading' => 'Uninstall Mod/Plugin', + 'uninstall_description' => 'Are you sure you want to uninstall :name? This will permanently delete the file from your server.', ], 'notifications' => [ + 'install_success' => 'Installation completed', + 'install_success_body' => 'Successfully installed :name version :version', + 'install_failed' => 'Installation failed', + 'install_failed_body' => 'An error occurred during installation. Please try again or contact support if the issue persists.', 'download_started' => 'Download started', 'download_failed' => 'Download could not be started', + 'update_success' => 'Update completed', + 'update_success_body' => 'Successfully updated to version :version', + 'update_failed' => 'Update failed', + 'update_failed_body' => 'An error occurred during the update. Please try again or contact support if the issue persists.', + 'uninstall_success' => 'Uninstall completed', + 'uninstall_failed' => 'Uninstall failed', + 'uninstall_failed_body' => 'An error occurred during uninstallation. Please try again or contact support if the issue persists.', ], ]; diff --git a/minecraft-modrinth/plugin.json b/minecraft-modrinth/plugin.json index fd553876..2302f9cc 100644 --- a/minecraft-modrinth/plugin.json +++ b/minecraft-modrinth/plugin.json @@ -2,8 +2,8 @@ "id": "minecraft-modrinth", "name": "Minecraft Modrinth", "author": "Boy132", - "version": "1.0.0", - "description": "Easily download minecraft mods & plugins from modrinth", + "version": "1.1.0", + "description": "Easily download, update, and manage minecraft mods & plugins from modrinth", "category": "plugin", "url": "https://github.com/pelican-dev/plugins/tree/main/minecraft-modrinth", "update_url": null, diff --git a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php index a1b9e961..54e36962 100644 --- a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php +++ b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php @@ -26,12 +26,19 @@ use Filament\Tables\Table; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Http; class MinecraftModrinthProjectPage extends Page implements HasTable { use BlockAccessInConflict; use InteractsWithTable; + /** @var array|null */ + protected ?array $installedModsMetadata = null; + + /** @var array> Cache for version data by project_id */ + protected array $versionsCache = []; + protected static string|\BackedEnum|null $navigationIcon = 'tabler-packages'; protected static ?string $slug = 'modrinth'; @@ -69,6 +76,76 @@ public function getTitle(): string return static::getNavigationLabel(); } + /** @return array */ + protected function getInstalledModsMetadata(): array + { + if ($this->installedModsMetadata === null) { + /** @var Server $server */ + $server = Filament::getTenant(); + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + $this->installedModsMetadata = MinecraftModrinth::getInstalledModsMetadata($server, $fileRepository); + } + + return $this->installedModsMetadata; + } + + /** @return array{project_id: string, project_slug: string, project_title: string, version_id: string, version_number: string, filename: string, installed_at: string}|null */ + protected function getInstalledMod(string $projectId): ?array + { + $installedMods = $this->getInstalledModsMetadata(); + + foreach ($installedMods as $mod) { + if ($mod['project_id'] === $projectId) { + return $mod; + } + } + + return null; + } + + /** @return array */ + protected function getCachedVersions(string $projectId): array + { + if (!isset($this->versionsCache[$projectId])) { + /** @var Server $server */ + $server = Filament::getTenant(); + $this->versionsCache[$projectId] = MinecraftModrinth::getModrinthVersions($projectId, $server); + } + + return $this->versionsCache[$projectId]; + } + + /** + * @param array $files + * @return array{primary: bool, filename: string, url: string}|null + */ + protected function getPrimaryFile(array $files): ?array + { + foreach ($files as $file) { + if (!empty($file['primary'])) { + return $file; + } + } + + return null; + } + + /** + * Validate and sanitize filename to prevent path traversal + * + * @throws Exception + */ + protected function validateFilename(string $filename): string + { + if ($filename === '' || str_contains($filename, '/') || str_contains($filename, '\\')) { + throw new Exception('Invalid filename: potential path traversal detected'); + } + + return basename($filename); + } + /** * @throws Exception */ @@ -105,75 +182,284 @@ public function table(Table $table): Table ]) ->recordUrl(fn (array $record) => "https://modrinth.com/{$record['project_type']}/{$record['slug']}", true) ->recordActions([ - Action::make('download') - ->schema(function (array $record) { - $schema = []; - - /** @var Server $server */ - $server = Filament::getTenant(); - - $versions = array_slice(MinecraftModrinth::getModrinthVersions($record['project_id'], $server), 0, 10); - foreach ($versions as $versionData) { - $files = $versionData['files'] ?? []; - $primaryFile = null; - - foreach ($files as $fileData) { - if ($fileData['primary']) { - $primaryFile = $fileData; - break; - } + Action::make('install') + ->iconButton() + ->icon('tabler-download') + ->color('success') + ->tooltip(trans('minecraft-modrinth::strings.actions.install')) + ->visible(function (array $record) { + $installedMod = $this->getInstalledMod($record['project_id']); + + return is_null($installedMod); + }) + ->action(function (array $record, DaemonFileRepository $fileRepository) { + try { + /** @var Server $server */ + $server = Filament::getTenant(); + + // Get latest version + $versions = MinecraftModrinth::getModrinthVersions($record['project_id'], $server); + + if (empty($versions)) { + throw new Exception('No compatible versions found'); } - $schema[] = Section::make($versionData['name']) - ->description($versionData['version_number'] . ($primaryFile ? ' (' . convert_bytes_to_readable($primaryFile['size']) . ')' : ' (' . trans('minecraft-modrinth::strings.version.no_file_found') . ')')) - ->collapsed(!$versionData['featured']) - ->collapsible() - ->icon($versionData['version_type'] === 'alpha' ? 'tabler-circle-letter-a' : ($versionData['version_type'] === 'beta' ? 'tabler-circle-letter-b' : 'tabler-circle-letter-r')) - ->iconColor($versionData['version_type'] === 'alpha' ? 'danger' : ($versionData['version_type'] === 'beta' ? 'warning' : 'success')) - ->columns(3) - ->schema([ - TextEntry::make('type') - ->badge() - ->color($versionData['version_type'] === 'alpha' ? 'danger' : ($versionData['version_type'] === 'beta' ? 'warning' : 'success')) - ->state($versionData['version_type']), - TextEntry::make('downloads') - ->badge() - ->state($versionData['downloads']), - TextEntry::make('published') - ->badge() - ->state(Carbon::parse($versionData['date_published'], 'UTC')->diffForHumans()) - ->tooltip(Carbon::parse($versionData['date_published'], 'UTC')->timezone(user()->timezone ?? 'UTC')->format('M j, Y H:i:s')), - TextEntry::make('changelog') - ->columnSpanFull() - ->markdown() - ->state($versionData['changelog']), - ]) - ->headerActions([ - Action::make('download') - ->visible(!is_null($primaryFile)) - ->action(function (DaemonFileRepository $fileRepository) use ($server, $versionData, $primaryFile) { - try { - $fileRepository->setServer($server)->pull($primaryFile['url'], ModrinthProjectType::fromServer($server)->getFolder()); - - Notification::make() - ->title(trans('minecraft-modrinth::strings.notifications.download_started')) - ->body($versionData['name']) - ->success() - ->send(); - } catch (Exception $exception) { - report($exception); - - Notification::make() - ->title(trans('minecraft-modrinth::strings.notifications.download_failed')) - ->body($exception->getMessage()) - ->danger() - ->send(); - } - }), - ]); + $latestVersion = $versions[0]; + + if (!isset($latestVersion['id'], $latestVersion['version_number'], $latestVersion['files'])) { + throw new Exception('Invalid version data structure'); + } + + $primaryFile = $this->getPrimaryFile($latestVersion['files']); + + if (!$primaryFile) { + throw new Exception('No downloadable file found'); + } + + // Download the file + $fileRepository->setServer($server)->pull($primaryFile['url'], ModrinthProjectType::fromServer($server)->getFolder()); + + // Save metadata + $saved = MinecraftModrinth::saveModMetadata( + $server, + $fileRepository, + $record['project_id'], + $record['slug'], + $record['title'], + $latestVersion['id'], + $latestVersion['version_number'], + $primaryFile['filename'] + ); + + if (!$saved) { + throw new Exception('Failed to save mod metadata'); + } + + $this->installedModsMetadata = null; + + Notification::make() + ->title(trans('minecraft-modrinth::strings.notifications.install_success')) + ->body(trans('minecraft-modrinth::strings.notifications.install_success_body', [ + 'name' => $record['title'], + 'version' => $latestVersion['version_number'], + ])) + ->success() + ->send(); + } catch (Exception $exception) { + report($exception); + + // Invalidate cache to prevent stale UI state + $this->installedModsMetadata = null; + + Notification::make() + ->title(trans('minecraft-modrinth::strings.notifications.install_failed')) + ->body(trans('minecraft-modrinth::strings.notifications.install_failed_body')) + ->danger() + ->send(); + } + }), + Action::make('update') + ->iconButton() + ->icon('tabler-refresh') + ->color('warning') + ->tooltip(trans('minecraft-modrinth::strings.actions.update')) + ->visible(function (array $record) { + $installedMod = $this->getInstalledMod($record['project_id']); + + if (is_null($installedMod)) { + return false; } - return $schema; + $versions = $this->getCachedVersions($record['project_id']); + + if (empty($versions)) { + return false; + } + + // Check if latest version is different from installed + return $installedMod['version_id'] !== $versions[0]['id']; + }) + ->requiresConfirmation() + ->modalHeading(fn (array $record) => trans('minecraft-modrinth::strings.modals.update_heading')) + ->modalDescription(function (array $record) { + $installedMod = $this->getInstalledMod($record['project_id']); + $versions = $this->getCachedVersions($record['project_id']); + + return trans('minecraft-modrinth::strings.modals.update_description', [ + 'old_version' => $installedMod['version_number'] ?? 'unknown', + 'new_version' => $versions[0]['version_number'] ?? 'unknown', + ]); + }) + ->action(function (array $record, DaemonFileRepository $fileRepository) { + try { + /** @var Server $server */ + $server = Filament::getTenant(); + + $installedMod = $this->getInstalledMod($record['project_id']); + + if (!$installedMod) { + throw new Exception('Mod not found in metadata'); + } + + $safeFilename = $this->validateFilename($installedMod['filename']); + + // Get latest version + $versions = MinecraftModrinth::getModrinthVersions($record['project_id'], $server); + + if (empty($versions)) { + throw new Exception('No compatible versions found'); + } + + $latestVersion = $versions[0]; + + if (!isset($latestVersion['id'], $latestVersion['version_number'], $latestVersion['files'])) { + throw new Exception('Invalid version data structure'); + } + + $primaryFile = $this->getPrimaryFile($latestVersion['files']); + + if (!$primaryFile) { + throw new Exception('No downloadable file found'); + } + + $folder = ModrinthProjectType::fromServer($server)->getFolder(); + + // Download new version first to avoid leaving mod in broken state if download fails + $fileRepository->setServer($server)->pull($primaryFile['url'], $folder); + + // Only delete old version after successful download (if filenames differ) + // If filenames are the same, the download already replaced the file + if ($safeFilename !== $primaryFile['filename']) { + Http::daemon($server->node) + ->post("/api/servers/{$server->uuid}/files/delete", [ + 'root' => '/', + 'files' => [$folder . '/' . $safeFilename], + ]) + ->throw(); + } + + // Update metadata + $saved = MinecraftModrinth::saveModMetadata( + $server, + $fileRepository, + $record['project_id'], + $record['slug'], + $record['title'], + $latestVersion['id'], + $latestVersion['version_number'], + $primaryFile['filename'] + ); + + if (!$saved) { + throw new Exception('Failed to save mod metadata'); + } + + $this->installedModsMetadata = null; + + Notification::make() + ->title(trans('minecraft-modrinth::strings.notifications.update_success')) + ->body(trans('minecraft-modrinth::strings.notifications.update_success_body', [ + 'version' => $latestVersion['version_number'], + ])) + ->success() + ->send(); + } catch (Exception $exception) { + report($exception); + + // Invalidate cache to prevent stale UI state + $this->installedModsMetadata = null; + + Notification::make() + ->title(trans('minecraft-modrinth::strings.notifications.update_failed')) + ->body(trans('minecraft-modrinth::strings.notifications.update_failed_body')) + ->danger() + ->send(); + } + }), + Action::make('installed') + ->iconButton() + ->icon('tabler-check') + ->color('success') + ->tooltip(trans('minecraft-modrinth::strings.actions.installed')) + ->disabled() + ->visible(function (array $record) { + $installedMod = $this->getInstalledMod($record['project_id']); + + if (is_null($installedMod)) { + return false; + } + + $versions = $this->getCachedVersions($record['project_id']); + + if (empty($versions)) { + return true; // Show installed if we can't check for updates + } + + // Show installed badge only if this is the latest version + return $installedMod['version_id'] === $versions[0]['id']; + }), + Action::make('uninstall') + ->iconButton() + ->icon('tabler-trash') + ->color('danger') + ->tooltip(trans('minecraft-modrinth::strings.actions.uninstall')) + ->visible(function (array $record) { + return !is_null($this->getInstalledMod($record['project_id'])); + }) + ->requiresConfirmation() + ->modalHeading(fn (array $record) => trans('minecraft-modrinth::strings.modals.uninstall_heading')) + ->modalDescription(fn (array $record) => trans('minecraft-modrinth::strings.modals.uninstall_description', ['name' => $record['title']])) + ->action(function (array $record, DaemonFileRepository $fileRepository) { + try { + /** @var Server $server */ + $server = Filament::getTenant(); + + $installedMod = $this->getInstalledMod($record['project_id']); + + if (!$installedMod) { + throw new Exception('Mod not found in metadata'); + } + + $safeFilename = $this->validateFilename($installedMod['filename']); + + // Remove metadata first to maintain consistency + // If file deletion fails after this, the file will exist but won't be tracked + // which is safer than having metadata pointing to a non-existent file + $metadataRemoved = MinecraftModrinth::removeModMetadata($server, $fileRepository, $record['project_id']); + + if ($metadataRemoved === false) { + throw new Exception('Failed to remove mod metadata'); + } + + // Delete the JAR file after metadata is removed + $folder = ModrinthProjectType::fromServer($server)->getFolder(); + + Http::daemon($server->node) + ->post("/api/servers/{$server->uuid}/files/delete", [ + 'root' => '/', + 'files' => [$folder . '/' . $safeFilename], + ]) + ->throw(); + + $this->installedModsMetadata = null; + + Notification::make() + ->title(trans('minecraft-modrinth::strings.notifications.uninstall_success')) + ->body($record['title']) + ->success() + ->send(); + } catch (Exception $exception) { + report($exception); + + // Invalidate cache to prevent stale UI state + $this->installedModsMetadata = null; + + Notification::make() + ->title(trans('minecraft-modrinth::strings.notifications.uninstall_failed')) + ->body(trans('minecraft-modrinth::strings.notifications.uninstall_failed_body')) + ->danger() + ->send(); + } }), ]); } diff --git a/minecraft-modrinth/src/Services/MinecraftModrinthService.php b/minecraft-modrinth/src/Services/MinecraftModrinthService.php index 40fbb7ae..767de64f 100644 --- a/minecraft-modrinth/src/Services/MinecraftModrinthService.php +++ b/minecraft-modrinth/src/Services/MinecraftModrinthService.php @@ -3,6 +3,7 @@ namespace Boy132\MinecraftModrinth\Services; use App\Models\Server; +use App\Repositories\Daemon\DaemonFileRepository; use Boy132\MinecraftModrinth\Enums\MinecraftLoader; use Boy132\MinecraftModrinth\Enums\ModrinthProjectType; use Exception; @@ -100,4 +101,179 @@ public function getModrinthVersions(string $projectId, Server $server): array } }); } + + protected function getMetadataFilePath(Server $server): string + { + $folder = ModrinthProjectType::fromServer($server)->getFolder(); + + return $folder . '/.modrinth-metadata.json'; + } + + /** @return array */ + public function getInstalledModsMetadata(Server $server, DaemonFileRepository $fileRepository): array + { + try { + $metadataPath = $this->getMetadataFilePath($server); + $content = $fileRepository->setServer($server)->getContent($metadataPath); + $metadata = json_decode($content, true); + + if (!is_array($metadata) || !isset($metadata['installed_mods']) || !is_array($metadata['installed_mods'])) { + return []; + } + + $validInstalledMods = []; + $requiredKeys = [ + 'project_id', + 'project_slug', + 'project_title', + 'version_id', + 'version_number', + 'filename', + 'installed_at', + ]; + + // Flip once for efficient comparison + $requiredKeysFlipped = array_flip($requiredKeys); + + foreach ($metadata['installed_mods'] as $entry) { + if (!is_array($entry)) { + continue; + } + + $missingKeys = array_diff_key($requiredKeysFlipped, $entry); + if (empty($missingKeys)) { + $validInstalledMods[] = $entry; + } + } + + return $validInstalledMods; + } catch (Exception $exception) { + // File doesn't exist yet or is invalid, return empty array + return []; + } + } + + public function saveModMetadata( + Server $server, + DaemonFileRepository $fileRepository, + string $projectId, + string $projectSlug, + string $projectTitle, + string $versionId, + string $versionNumber, + string $filename + ): bool { + try { + $metadata = [ + 'installed_mods' => $this->getInstalledModsMetadata($server, $fileRepository), + ]; + + // Remove any existing entry for this project + $metadata['installed_mods'] = collect($metadata['installed_mods']) + ->filter(fn ($mod) => $mod['project_id'] !== $projectId) + ->values() + ->toArray(); + + // Add new entry + $metadata['installed_mods'][] = [ + 'project_id' => $projectId, + 'project_slug' => $projectSlug, + 'project_title' => $projectTitle, + 'version_id' => $versionId, + 'version_number' => $versionNumber, + 'filename' => $filename, + 'installed_at' => now()->toIso8601String(), + ]; + + $metadataPath = $this->getMetadataFilePath($server); + $result = $fileRepository->setServer($server)->putContent( + $metadataPath, + json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ); + + if ($result === false) { + return false; + } + + return true; + } catch (Exception $exception) { + report($exception); + + return false; + } + } + + public function removeModMetadata(Server $server, DaemonFileRepository $fileRepository, string $projectId): bool + { + try { + $metadata = [ + 'installed_mods' => $this->getInstalledModsMetadata($server, $fileRepository), + ]; + + $metadata['installed_mods'] = collect($metadata['installed_mods']) + ->filter(fn ($mod) => $mod['project_id'] !== $projectId) + ->values() + ->toArray(); + + $metadataPath = $this->getMetadataFilePath($server); + $result = $fileRepository->setServer($server)->putContent( + $metadataPath, + json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ); + + if ($result === false) { + return false; + } + + return true; + } catch (Exception $exception) { + report($exception); + + return false; + } + } + + /** @return array{project_id: string, project_slug: string, project_title: string, version_id: string, version_number: string, filename: string, installed_at: string}|null */ + public function getInstalledMod(Server $server, DaemonFileRepository $fileRepository, string $projectId): ?array + { + $installedMods = $this->getInstalledModsMetadata($server, $fileRepository); + + foreach ($installedMods as $mod) { + if ($mod['project_id'] === $projectId) { + return $mod; + } + } + + return null; + } + + /** + * @param array{version_id: string, version_number: string} $installedMod + * @param array $availableVersions + */ + public function isUpdateAvailable(array $installedMod, array $availableVersions): bool + { + if (empty($availableVersions)) { + return false; + } + + $latestVersion = $availableVersions[0]; + + return $installedMod['version_id'] !== $latestVersion['id']; + } + + /** + * Get installed mods/plugins filenames from the server (for backward compatibility) + * + * @return array + */ + public function getInstalledMods(Server $server, DaemonFileRepository $fileRepository): array + { + $metadata = $this->getInstalledModsMetadata($server, $fileRepository); + + return collect($metadata) + ->pluck('filename') + ->map(fn ($name) => strtolower($name)) + ->toArray(); + } } From eb2e0c3981cf5a8b9a500cb54649ed0ae315e9b1 Mon Sep 17 00:00:00 2001 From: H1ghSyst3m <60105043+H1ghSyst3m@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:37:21 +0100 Subject: [PATCH 2/8] Remove unused download strings from language files Deleted 'download' related keys from both German and English language files as they are no longer needed. --- minecraft-modrinth/lang/de/strings.php | 3 --- minecraft-modrinth/lang/en/strings.php | 3 --- 2 files changed, 6 deletions(-) diff --git a/minecraft-modrinth/lang/de/strings.php b/minecraft-modrinth/lang/de/strings.php index d209d380..a33e549b 100644 --- a/minecraft-modrinth/lang/de/strings.php +++ b/minecraft-modrinth/lang/de/strings.php @@ -37,7 +37,6 @@ 'actions' => [ 'install' => 'Installieren', - 'download' => 'Herunterladen', 'installed' => 'Installiert', 'update' => 'Aktualisieren', 'uninstall' => 'Deinstallieren', @@ -55,8 +54,6 @@ 'install_success_body' => ':name Version :version erfolgreich installiert', 'install_failed' => 'Installation fehlgeschlagen', 'install_failed_body' => 'Bei der Installation ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an den Support, wenn das Problem weiterhin besteht.', - 'download_started' => 'Download gestartet', - 'download_failed' => 'Download konnte nicht gestartet werden', 'update_success' => 'Aktualisierung abgeschlossen', 'update_success_body' => 'Erfolgreich auf Version :version aktualisiert', 'update_failed' => 'Aktualisierung fehlgeschlagen', diff --git a/minecraft-modrinth/lang/en/strings.php b/minecraft-modrinth/lang/en/strings.php index 589d17ea..c57ff975 100644 --- a/minecraft-modrinth/lang/en/strings.php +++ b/minecraft-modrinth/lang/en/strings.php @@ -37,7 +37,6 @@ 'actions' => [ 'install' => 'Install', - 'download' => 'Download', 'installed' => 'Installed', 'update' => 'Update', 'uninstall' => 'Uninstall', @@ -55,8 +54,6 @@ 'install_success_body' => 'Successfully installed :name version :version', 'install_failed' => 'Installation failed', 'install_failed_body' => 'An error occurred during installation. Please try again or contact support if the issue persists.', - 'download_started' => 'Download started', - 'download_failed' => 'Download could not be started', 'update_success' => 'Update completed', 'update_success_body' => 'Successfully updated to version :version', 'update_failed' => 'Update failed', From f2d3bb3c993400b477c2ab3119172a659ddf5300 Mon Sep 17 00:00:00 2001 From: H1ghSyst3m <60105043+H1ghSyst3m@users.noreply.github.com> Date: Sat, 31 Jan 2026 05:16:31 +0100 Subject: [PATCH 3/8] Fix: Validate filenames and improve file save checks Added filename validation to prevent path traversal when handling files from the Modrinth API. Updated file save logic to check for failed responses instead of boolean false, improving reliability and security. --- .../Server/Pages/MinecraftModrinthProjectPage.php | 12 +++++++++--- .../src/Services/MinecraftModrinthService.php | 8 ++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php index 54e36962..67a340f5 100644 --- a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php +++ b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php @@ -216,6 +216,9 @@ public function table(Table $table): Table throw new Exception('No downloadable file found'); } + // Validate filename from Modrinth API to prevent path traversal + $safeFilename = $this->validateFilename($primaryFile['filename']); + // Download the file $fileRepository->setServer($server)->pull($primaryFile['url'], ModrinthProjectType::fromServer($server)->getFolder()); @@ -228,7 +231,7 @@ public function table(Table $table): Table $record['title'], $latestVersion['id'], $latestVersion['version_number'], - $primaryFile['filename'] + $safeFilename ); if (!$saved) { @@ -322,6 +325,9 @@ public function table(Table $table): Table throw new Exception('No downloadable file found'); } + // Validate new filename from Modrinth API to prevent path traversal + $safeNewFilename = $this->validateFilename($primaryFile['filename']); + $folder = ModrinthProjectType::fromServer($server)->getFolder(); // Download new version first to avoid leaving mod in broken state if download fails @@ -329,7 +335,7 @@ public function table(Table $table): Table // Only delete old version after successful download (if filenames differ) // If filenames are the same, the download already replaced the file - if ($safeFilename !== $primaryFile['filename']) { + if ($safeFilename !== $safeNewFilename) { Http::daemon($server->node) ->post("/api/servers/{$server->uuid}/files/delete", [ 'root' => '/', @@ -347,7 +353,7 @@ public function table(Table $table): Table $record['title'], $latestVersion['id'], $latestVersion['version_number'], - $primaryFile['filename'] + $safeNewFilename ); if (!$saved) { diff --git a/minecraft-modrinth/src/Services/MinecraftModrinthService.php b/minecraft-modrinth/src/Services/MinecraftModrinthService.php index 767de64f..41c7482f 100644 --- a/minecraft-modrinth/src/Services/MinecraftModrinthService.php +++ b/minecraft-modrinth/src/Services/MinecraftModrinthService.php @@ -186,12 +186,12 @@ public function saveModMetadata( ]; $metadataPath = $this->getMetadataFilePath($server); - $result = $fileRepository->setServer($server)->putContent( + $response = $fileRepository->setServer($server)->putContent( $metadataPath, json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ); - if ($result === false) { + if ($response->failed()) { return false; } @@ -216,12 +216,12 @@ public function removeModMetadata(Server $server, DaemonFileRepository $fileRepo ->toArray(); $metadataPath = $this->getMetadataFilePath($server); - $result = $fileRepository->setServer($server)->putContent( + $response = $fileRepository->setServer($server)->putContent( $metadataPath, json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ); - if ($result === false) { + if ($response->failed()) { return false; } From 0b9210886547820cc8d558773308bc3bea7d9c8a Mon Sep 17 00:00:00 2001 From: H1ghSyst3m <60105043+H1ghSyst3m@users.noreply.github.com> Date: Sat, 31 Jan 2026 05:34:36 +0100 Subject: [PATCH 4/8] Fix: Add checks for Modrinth project type support Added validation to ensure the server supports Modrinth mods or plugins before performing operations. Exceptions are now thrown when the project type is not available, improving error handling and preventing undefined behavior. --- .../Pages/MinecraftModrinthProjectPage.php | 44 +++++++++++++++---- .../src/Services/MinecraftModrinthService.php | 11 ++++- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php index 67a340f5..71c603a7 100644 --- a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php +++ b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php @@ -58,7 +58,9 @@ public static function getNavigationLabel(): string /** @var Server $server */ $server = Filament::getTenant(); - return ModrinthProjectType::fromServer($server)->getLabel(); + $type = ModrinthProjectType::fromServer($server); + + return $type?->getLabel() ?? 'Modrinth'; } public static function getModelLabel(): string @@ -219,8 +221,13 @@ public function table(Table $table): Table // Validate filename from Modrinth API to prevent path traversal $safeFilename = $this->validateFilename($primaryFile['filename']); + $type = ModrinthProjectType::fromServer($server); + if (!$type) { + throw new Exception('Server does not support Modrinth mods or plugins'); + } + // Download the file - $fileRepository->setServer($server)->pull($primaryFile['url'], ModrinthProjectType::fromServer($server)->getFolder()); + $fileRepository->setServer($server)->pull($primaryFile['url'], $type->getFolder()); // Save metadata $saved = MinecraftModrinth::saveModMetadata( @@ -328,7 +335,12 @@ public function table(Table $table): Table // Validate new filename from Modrinth API to prevent path traversal $safeNewFilename = $this->validateFilename($primaryFile['filename']); - $folder = ModrinthProjectType::fromServer($server)->getFolder(); + $type = ModrinthProjectType::fromServer($server); + if (!$type) { + throw new Exception('Server does not support Modrinth mods or plugins'); + } + + $folder = $type->getFolder(); // Download new version first to avoid leaving mod in broken state if download fails $fileRepository->setServer($server)->pull($primaryFile['url'], $folder); @@ -437,8 +449,13 @@ public function table(Table $table): Table throw new Exception('Failed to remove mod metadata'); } + $type = ModrinthProjectType::fromServer($server); + if (!$type) { + throw new Exception('Server does not support Modrinth mods or plugins'); + } + // Delete the JAR file after metadata is removed - $folder = ModrinthProjectType::fromServer($server)->getFolder(); + $folder = $type->getFolder(); Http::daemon($server->node) ->post("/api/servers/{$server->uuid}/files/delete", [ @@ -475,7 +492,12 @@ protected function getHeaderActions(): array /** @var Server $server */ $server = Filament::getTenant(); - $folder = ModrinthProjectType::fromServer($server)->getFolder(); + $type = ModrinthProjectType::fromServer($server); + if (!$type) { + return []; + } + + $folder = $type->getFolder(); return [ Action::make('open_folder') @@ -489,6 +511,8 @@ public function content(Schema $schema): Schema /** @var Server $server */ $server = Filament::getTenant(); + $type = ModrinthProjectType::fromServer($server); + return $schema ->components([ Grid::make(3) @@ -500,10 +524,14 @@ public function content(Schema $schema): Schema ->state(fn () => MinecraftLoader::fromServer($server)?->getLabel() ?? trans('minecraft-modrinth::strings.page.unknown')) ->badge(), TextEntry::make('installed') - ->label(fn () => trans('minecraft-modrinth::strings.page.installed', ['type' => ModrinthProjectType::fromServer($server)->getLabel()])) - ->state(function (DaemonFileRepository $fileRepository) use ($server) { + ->label(fn () => trans('minecraft-modrinth::strings.page.installed', ['type' => $type?->getLabel() ?? 'Modrinth'])) + ->state(function (DaemonFileRepository $fileRepository) use ($server, $type) { try { - $files = $fileRepository->setServer($server)->getDirectory(ModrinthProjectType::fromServer($server)->getFolder()); + if (!$type) { + return trans('minecraft-modrinth::strings.page.unknown'); + } + + $files = $fileRepository->setServer($server)->getDirectory($type->getFolder()); if (isset($files['error'])) { throw new Exception($files['error']); diff --git a/minecraft-modrinth/src/Services/MinecraftModrinthService.php b/minecraft-modrinth/src/Services/MinecraftModrinthService.php index 41c7482f..986027fc 100644 --- a/minecraft-modrinth/src/Services/MinecraftModrinthService.php +++ b/minecraft-modrinth/src/Services/MinecraftModrinthService.php @@ -102,11 +102,18 @@ public function getModrinthVersions(string $projectId, Server $server): array }); } + /** + * @throws Exception + */ protected function getMetadataFilePath(Server $server): string { - $folder = ModrinthProjectType::fromServer($server)->getFolder(); + $type = ModrinthProjectType::fromServer($server); + + if (!$type) { + throw new Exception("Server {$server->id} does not support Modrinth mods or plugins"); + } - return $folder . '/.modrinth-metadata.json'; + return $type->getFolder() . '/.modrinth-metadata.json'; } /** @return array */ From eef0dd2912670e8850f9d72949284dedec0d33e4 Mon Sep 17 00:00:00 2001 From: H1ghSyst3m <60105043+H1ghSyst3m@users.noreply.github.com> Date: Sat, 31 Jan 2026 05:42:25 +0100 Subject: [PATCH 5/8] Remove unused Section import Deleted the import of Filament\Schemas\Components\Section from MinecraftModrinthProjectPage.php as it was not used in the file. --- .../src/Filament/Server/Pages/MinecraftModrinthProjectPage.php | 1 - 1 file changed, 1 deletion(-) diff --git a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php index 71c603a7..da2b2c7d 100644 --- a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php +++ b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php @@ -17,7 +17,6 @@ use Filament\Pages\Page; use Filament\Schemas\Components\EmbeddedTable; use Filament\Schemas\Components\Grid; -use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; From 938ad6a25a81143596bad32ef272ef79b76d7fae Mon Sep 17 00:00:00 2001 From: H1ghSyst3m <60105043+H1ghSyst3m@users.noreply.github.com> Date: Sat, 31 Jan 2026 23:10:57 +0100 Subject: [PATCH 6/8] Improve mod install/update rollback and cache handling Adds rollback logic to delete downloaded files if saving mod metadata fails during install or update, ensuring consistency. Moves old file deletion after successful metadata save during updates. Ensures UI cache is invalidated after install, update, or uninstall actions. Adds defensive sorting of Modrinth versions by publish date to guarantee latest version is first. --- .../Pages/MinecraftModrinthProjectPage.php | 68 ++++++++++++------- .../src/Services/MinecraftModrinthService.php | 15 ++-- 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php index da2b2c7d..ba61115d 100644 --- a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php +++ b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php @@ -198,7 +198,6 @@ public function table(Table $table): Table /** @var Server $server */ $server = Filament::getTenant(); - // Get latest version $versions = MinecraftModrinth::getModrinthVersions($record['project_id'], $server); if (empty($versions)) { @@ -225,10 +224,8 @@ public function table(Table $table): Table throw new Exception('Server does not support Modrinth mods or plugins'); } - // Download the file $fileRepository->setServer($server)->pull($primaryFile['url'], $type->getFolder()); - // Save metadata $saved = MinecraftModrinth::saveModMetadata( $server, $fileRepository, @@ -241,10 +238,24 @@ public function table(Table $table): Table ); if (!$saved) { + // Rollback: delete the downloaded file to maintain consistency + try { + Http::daemon($server->node) + ->post("/api/servers/{$server->uuid}/files/delete", [ + 'root' => '/', + 'files' => [$type->getFolder() . '/' . $safeFilename], + ]) + ->throw(); + } catch (Exception $rollbackException) { + // Log rollback failure but continue with the original exception + report($rollbackException); + } + throw new Exception('Failed to save mod metadata'); } $this->installedModsMetadata = null; + $this->versionsCache = []; Notification::make() ->title(trans('minecraft-modrinth::strings.notifications.install_success')) @@ -257,8 +268,8 @@ public function table(Table $table): Table } catch (Exception $exception) { report($exception); - // Invalidate cache to prevent stale UI state $this->installedModsMetadata = null; + $this->versionsCache = []; Notification::make() ->title(trans('minecraft-modrinth::strings.notifications.install_failed')) @@ -285,7 +296,6 @@ public function table(Table $table): Table return false; } - // Check if latest version is different from installed return $installedMod['version_id'] !== $versions[0]['id']; }) ->requiresConfirmation() @@ -312,7 +322,6 @@ public function table(Table $table): Table $safeFilename = $this->validateFilename($installedMod['filename']); - // Get latest version $versions = MinecraftModrinth::getModrinthVersions($record['project_id'], $server); if (empty($versions)) { @@ -344,18 +353,7 @@ public function table(Table $table): Table // Download new version first to avoid leaving mod in broken state if download fails $fileRepository->setServer($server)->pull($primaryFile['url'], $folder); - // Only delete old version after successful download (if filenames differ) - // If filenames are the same, the download already replaced the file - if ($safeFilename !== $safeNewFilename) { - Http::daemon($server->node) - ->post("/api/servers/{$server->uuid}/files/delete", [ - 'root' => '/', - 'files' => [$folder . '/' . $safeFilename], - ]) - ->throw(); - } - - // Update metadata + // Update metadata before deleting old file to maintain consistency $saved = MinecraftModrinth::saveModMetadata( $server, $fileRepository, @@ -368,10 +366,35 @@ public function table(Table $table): Table ); if (!$saved) { + // Rollback: delete the newly downloaded file to restore original state + try { + Http::daemon($server->node) + ->post("/api/servers/{$server->uuid}/files/delete", [ + 'root' => '/', + 'files' => [$folder . '/' . $safeNewFilename], + ]) + ->throw(); + } catch (Exception $rollbackException) { + // Log rollback failure but continue with the original exception + report($rollbackException); + } + throw new Exception('Failed to save mod metadata'); } + // Only delete old version after successful metadata save (if filenames differ) + // If filenames are the same, the download already replaced the file + if ($safeFilename !== $safeNewFilename) { + Http::daemon($server->node) + ->post("/api/servers/{$server->uuid}/files/delete", [ + 'root' => '/', + 'files' => [$folder . '/' . $safeFilename], + ]) + ->throw(); + } + $this->installedModsMetadata = null; + $this->versionsCache = []; Notification::make() ->title(trans('minecraft-modrinth::strings.notifications.update_success')) @@ -383,8 +406,8 @@ public function table(Table $table): Table } catch (Exception $exception) { report($exception); - // Invalidate cache to prevent stale UI state $this->installedModsMetadata = null; + $this->versionsCache = []; Notification::make() ->title(trans('minecraft-modrinth::strings.notifications.update_failed')) @@ -409,10 +432,9 @@ public function table(Table $table): Table $versions = $this->getCachedVersions($record['project_id']); if (empty($versions)) { - return true; // Show installed if we can't check for updates + return true; } - // Show installed badge only if this is the latest version return $installedMod['version_id'] === $versions[0]['id']; }), Action::make('uninstall') @@ -453,7 +475,6 @@ public function table(Table $table): Table throw new Exception('Server does not support Modrinth mods or plugins'); } - // Delete the JAR file after metadata is removed $folder = $type->getFolder(); Http::daemon($server->node) @@ -464,6 +485,7 @@ public function table(Table $table): Table ->throw(); $this->installedModsMetadata = null; + $this->versionsCache = []; Notification::make() ->title(trans('minecraft-modrinth::strings.notifications.uninstall_success')) @@ -473,8 +495,8 @@ public function table(Table $table): Table } catch (Exception $exception) { report($exception); - // Invalidate cache to prevent stale UI state $this->installedModsMetadata = null; + $this->versionsCache = []; Notification::make() ->title(trans('minecraft-modrinth::strings.notifications.uninstall_failed')) diff --git a/minecraft-modrinth/src/Services/MinecraftModrinthService.php b/minecraft-modrinth/src/Services/MinecraftModrinthService.php index 986027fc..9370b865 100644 --- a/minecraft-modrinth/src/Services/MinecraftModrinthService.php +++ b/minecraft-modrinth/src/Services/MinecraftModrinthService.php @@ -88,12 +88,22 @@ public function getModrinthVersions(string $projectId, Server $server): array return cache()->remember("modrinth_versions:$projectId:$minecraftVersion:$minecraftLoader", now()->addMinutes(30), function () use ($projectId, $data) { try { - return Http::asJson() + $versions = Http::asJson() ->timeout(5) ->connectTimeout(5) ->throw() ->get("https://api.modrinth.com/v2/project/$projectId/version", $data) ->json(); + + // Defensive: sort by date_published if available to ensure latest version is first + // The API likely returns sorted results, but this provides additional safety + if (!empty($versions) && is_array($versions) && isset($versions[0]['date_published'])) { + usort($versions, function ($a, $b) { + return strcmp($b['date_published'] ?? '', $a['date_published'] ?? ''); + }); + } + + return $versions; } catch (Exception $exception) { report($exception); @@ -155,7 +165,6 @@ public function getInstalledModsMetadata(Server $server, DaemonFileRepository $f return $validInstalledMods; } catch (Exception $exception) { - // File doesn't exist yet or is invalid, return empty array return []; } } @@ -175,13 +184,11 @@ public function saveModMetadata( 'installed_mods' => $this->getInstalledModsMetadata($server, $fileRepository), ]; - // Remove any existing entry for this project $metadata['installed_mods'] = collect($metadata['installed_mods']) ->filter(fn ($mod) => $mod['project_id'] !== $projectId) ->values() ->toArray(); - // Add new entry $metadata['installed_mods'][] = [ 'project_id' => $projectId, 'project_slug' => $projectSlug, From 57d5603452ef7e2257730367361892a8a06ca7ed Mon Sep 17 00:00:00 2001 From: H1ghSyst3m <60105043+H1ghSyst3m@users.noreply.github.com> Date: Sun, 1 Feb 2026 02:33:33 +0100 Subject: [PATCH 7/8] Add installed mods view and handle unavailable mods Introduces a new 'Installed' view for Modrinth projects, allowing users to filter and display only installed mods. Adds logic to fetch installed mods' data in bulk from the Modrinth API, gracefully handles unavailable mods by displaying a message, and updates language files for new UI labels. Also ensures author metadata is stored and displayed where available. --- minecraft-modrinth/lang/de/strings.php | 3 + minecraft-modrinth/lang/en/strings.php | 3 + .../Pages/MinecraftModrinthProjectPage.php | 82 ++++++++++---- .../src/Services/MinecraftModrinthService.php | 107 ++++++++++++++++-- 4 files changed, 169 insertions(+), 26 deletions(-) diff --git a/minecraft-modrinth/lang/de/strings.php b/minecraft-modrinth/lang/de/strings.php index a33e549b..b41c5bc8 100644 --- a/minecraft-modrinth/lang/de/strings.php +++ b/minecraft-modrinth/lang/de/strings.php @@ -16,6 +16,9 @@ 'loader' => 'Loader', 'installed' => 'Installiert :type', 'unknown' => 'Unbekannt', + 'view_all' => 'Alle', + 'view_installed' => 'Installiert', + 'mod_unavailable' => 'Dieser Mod/Plugin ist auf Modrinth nicht mehr verfügbar', ], 'table' => [ diff --git a/minecraft-modrinth/lang/en/strings.php b/minecraft-modrinth/lang/en/strings.php index c57ff975..c3425aec 100644 --- a/minecraft-modrinth/lang/en/strings.php +++ b/minecraft-modrinth/lang/en/strings.php @@ -16,6 +16,9 @@ 'loader' => 'Loader', 'installed' => 'Installed :type', 'unknown' => 'Unknown', + 'view_all' => 'All', + 'view_installed' => 'Installed', + 'mod_unavailable' => 'This mod/plugin is no longer available on Modrinth', ], 'table' => [ diff --git a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php index ba61115d..6668ec09 100644 --- a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php +++ b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php @@ -32,18 +32,30 @@ class MinecraftModrinthProjectPage extends Page implements HasTable use BlockAccessInConflict; use InteractsWithTable; + public const VIEW_ALL = 'all'; + public const VIEW_INSTALLED = 'installed'; + /** @var array|null */ protected ?array $installedModsMetadata = null; /** @var array> Cache for version data by project_id */ protected array $versionsCache = []; + public string $activeView = self::VIEW_ALL; + protected static string|\BackedEnum|null $navigationIcon = 'tabler-packages'; protected static ?string $slug = 'modrinth'; protected static ?int $navigationSort = 30; + protected function queryString(): array + { + return [ + 'activeView' => ['as' => 'view', 'except' => self::VIEW_ALL], + ]; + } + public static function canAccess(): bool { /** @var Server $server */ @@ -134,8 +146,6 @@ protected function getPrimaryFile(array $files): ?array } /** - * Validate and sanitize filename to prevent path traversal - * * @throws Exception */ protected function validateFilename(string $filename): string @@ -147,6 +157,13 @@ protected function validateFilename(string $filename): string return basename($filename); } + protected function refreshIfInInstalledView(): void + { + if ($this->activeView === self::VIEW_INSTALLED) { + $this->js('$wire.$refresh()'); + } + } + /** * @throws Exception */ @@ -157,9 +174,18 @@ public function table(Table $table): Table /** @var Server $server */ $server = Filament::getTenant(); - $response = MinecraftModrinth::getModrinthProjects($server, $page, $search); + if ($this->activeView === self::VIEW_INSTALLED) { + $installedMods = $this->getInstalledModsMetadata(); + $projects = MinecraftModrinth::getInstalledModsFromModrinth($installedMods, $page); - return new LengthAwarePaginator($response['hits'], $response['total_hits'], 20, $page); + $totalCount = count($installedMods); + + return new LengthAwarePaginator($projects, $totalCount, 20, $page); + } else { + $response = MinecraftModrinth::getModrinthProjects($server, $page, $search); + + return new LengthAwarePaginator($response['hits'], $response['total_hits'], 20, $page); + } }) ->paginated([20]) ->columns([ @@ -177,11 +203,17 @@ public function table(Table $table): Table ->toggleable(), TextColumn::make('date_modified') ->icon('tabler-calendar') - ->formatStateUsing(fn ($state) => Carbon::parse($state, 'UTC')->diffForHumans()) - ->tooltip(fn ($state) => Carbon::parse($state, 'UTC')->timezone(user()->timezone ?? 'UTC')->format($table->getDefaultDateTimeDisplayFormat())) + ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state, 'UTC')->diffForHumans() : '') + ->tooltip(fn ($state) => $state ? Carbon::parse($state, 'UTC')->timezone(user()->timezone ?? 'UTC')->format($table->getDefaultDateTimeDisplayFormat()) : '') ->toggleable(), ]) - ->recordUrl(fn (array $record) => "https://modrinth.com/{$record['project_type']}/{$record['slug']}", true) + ->recordUrl(function (array $record) { + if (isset($record['unavailable']) && $record['unavailable']) { + return null; + } + + return "https://modrinth.com/{$record['project_type']}/{$record['slug']}"; + }, true) ->recordActions([ Action::make('install') ->iconButton() @@ -216,7 +248,6 @@ public function table(Table $table): Table throw new Exception('No downloadable file found'); } - // Validate filename from Modrinth API to prevent path traversal $safeFilename = $this->validateFilename($primaryFile['filename']); $type = ModrinthProjectType::fromServer($server); @@ -234,11 +265,11 @@ public function table(Table $table): Table $record['title'], $latestVersion['id'], $latestVersion['version_number'], - $safeFilename + $safeFilename, + $record['author'] ?? null ); if (!$saved) { - // Rollback: delete the downloaded file to maintain consistency try { Http::daemon($server->node) ->post("/api/servers/{$server->uuid}/files/delete", [ @@ -247,7 +278,6 @@ public function table(Table $table): Table ]) ->throw(); } catch (Exception $rollbackException) { - // Log rollback failure but continue with the original exception report($rollbackException); } @@ -340,7 +370,6 @@ public function table(Table $table): Table throw new Exception('No downloadable file found'); } - // Validate new filename from Modrinth API to prevent path traversal $safeNewFilename = $this->validateFilename($primaryFile['filename']); $type = ModrinthProjectType::fromServer($server); @@ -350,10 +379,8 @@ public function table(Table $table): Table $folder = $type->getFolder(); - // Download new version first to avoid leaving mod in broken state if download fails $fileRepository->setServer($server)->pull($primaryFile['url'], $folder); - // Update metadata before deleting old file to maintain consistency $saved = MinecraftModrinth::saveModMetadata( $server, $fileRepository, @@ -362,11 +389,11 @@ public function table(Table $table): Table $record['title'], $latestVersion['id'], $latestVersion['version_number'], - $safeNewFilename + $safeNewFilename, + $record['author'] ?? null ); if (!$saved) { - // Rollback: delete the newly downloaded file to restore original state try { Http::daemon($server->node) ->post("/api/servers/{$server->uuid}/files/delete", [ @@ -375,15 +402,12 @@ public function table(Table $table): Table ]) ->throw(); } catch (Exception $rollbackException) { - // Log rollback failure but continue with the original exception report($rollbackException); } throw new Exception('Failed to save mod metadata'); } - // Only delete old version after successful metadata save (if filenames differ) - // If filenames are the same, the download already replaced the file if ($safeFilename !== $safeNewFilename) { Http::daemon($server->node) ->post("/api/servers/{$server->uuid}/files/delete", [ @@ -486,6 +510,7 @@ public function table(Table $table): Table $this->installedModsMetadata = null; $this->versionsCache = []; + $this->refreshIfInInstalledView(); Notification::make() ->title(trans('minecraft-modrinth::strings.notifications.uninstall_success')) @@ -497,6 +522,7 @@ public function table(Table $table): Table $this->installedModsMetadata = null; $this->versionsCache = []; + $this->refreshIfInInstalledView(); Notification::make() ->title(trans('minecraft-modrinth::strings.notifications.uninstall_failed')) @@ -521,6 +547,24 @@ protected function getHeaderActions(): array $folder = $type->getFolder(); return [ + Action::make('view_all') + ->label(trans('minecraft-modrinth::strings.page.view_all')) + ->color($this->activeView === self::VIEW_ALL ? 'primary' : 'gray') + ->action(function () { + $this->resetTable(); + $this->activeView = self::VIEW_ALL; + $this->js('$wire.$refresh()'); + }) + ->button(), + Action::make('view_installed') + ->label(trans('minecraft-modrinth::strings.page.view_installed')) + ->color($this->activeView === self::VIEW_INSTALLED ? 'primary' : 'gray') + ->action(function () { + $this->resetTable(); + $this->activeView = self::VIEW_INSTALLED; + $this->js('$wire.$refresh()'); + }) + ->button(), Action::make('open_folder') ->label(fn () => trans('minecraft-modrinth::strings.page.open_folder', ['folder' => $folder])) ->url(fn () => ListFiles::getUrl(['path' => $folder]), true), diff --git a/minecraft-modrinth/src/Services/MinecraftModrinthService.php b/minecraft-modrinth/src/Services/MinecraftModrinthService.php index 9370b865..a6ef97f5 100644 --- a/minecraft-modrinth/src/Services/MinecraftModrinthService.php +++ b/minecraft-modrinth/src/Services/MinecraftModrinthService.php @@ -70,6 +70,97 @@ public function getModrinthProjects(Server $server, int $page = 1, ?string $sear }); } + /** + * @param array $installedMods + * @return array> + */ + public function getInstalledModsFromModrinth(array $installedMods, int $page = 1): array + { + if (empty($installedMods)) { + return []; + } + + $projectIds = collect($installedMods)->pluck('project_id')->unique()->values()->all(); + + $perPage = 20; + $offset = ($page - 1) * $perPage; + $pageIds = array_slice($projectIds, $offset, $perPage); + + if (empty($pageIds)) { + return []; + } + + $idsParam = '["' . implode('","', $pageIds) . '"]'; + $key = 'modrinth_bulk:' . md5($idsParam); + + $modrinthProjects = cache()->remember($key, now()->addMinutes(30), function () use ($idsParam) { + try { + return Http::asJson() + ->timeout(10) + ->connectTimeout(5) + ->throw() + ->get('https://api.modrinth.com/v2/projects', [ + 'ids' => $idsParam, + ]) + ->json(); + } catch (Exception $exception) { + report($exception); + + return []; + } + }); + + $modrinthMap = []; + foreach ($modrinthProjects as $project) { + if (isset($project['id'])) { + $modrinthMap[$project['id']] = $project; + } + } + + $results = []; + foreach ($pageIds as $projectId) { + $installedMod = null; + foreach ($installedMods as $mod) { + if ($mod['project_id'] === $projectId) { + $installedMod = $mod; + break; + } + } + + if (!$installedMod) { + continue; + } + + if (isset($modrinthMap[$projectId])) { + $project = $modrinthMap[$projectId]; + $project['project_id'] = $project['id']; + if (isset($project['updated']) && !isset($project['date_modified'])) { + $project['date_modified'] = $project['updated']; + } + // Use stored author from metadata if available, since bulk API doesn't include it + if (isset($installedMod['author']) && !isset($project['author'])) { + $project['author'] = $installedMod['author']; + } + $results[] = $project; + } else { + $results[] = [ + 'project_id' => $installedMod['project_id'], + 'slug' => $installedMod['project_slug'], + 'title' => $installedMod['project_title'], + 'description' => trans('minecraft-modrinth::strings.page.mod_unavailable'), + 'icon_url' => null, + 'author' => $installedMod['author'] ?? '', + 'downloads' => 0, + 'date_modified' => $installedMod['installed_at'], + 'project_type' => '', + 'unavailable' => true, + ]; + } + } + + return $results; + } + /** @return array */ public function getModrinthVersions(string $projectId, Server $server): array { @@ -95,8 +186,6 @@ public function getModrinthVersions(string $projectId, Server $server): array ->get("https://api.modrinth.com/v2/project/$projectId/version", $data) ->json(); - // Defensive: sort by date_published if available to ensure latest version is first - // The API likely returns sorted results, but this provides additional safety if (!empty($versions) && is_array($versions) && isset($versions[0]['date_published'])) { usort($versions, function ($a, $b) { return strcmp($b['date_published'] ?? '', $a['date_published'] ?? ''); @@ -149,7 +238,6 @@ public function getInstalledModsMetadata(Server $server, DaemonFileRepository $f 'installed_at', ]; - // Flip once for efficient comparison $requiredKeysFlipped = array_flip($requiredKeys); foreach ($metadata['installed_mods'] as $entry) { @@ -177,7 +265,8 @@ public function saveModMetadata( string $projectTitle, string $versionId, string $versionNumber, - string $filename + string $filename, + ?string $author = null ): bool { try { $metadata = [ @@ -189,7 +278,7 @@ public function saveModMetadata( ->values() ->toArray(); - $metadata['installed_mods'][] = [ + $modEntry = [ 'project_id' => $projectId, 'project_slug' => $projectSlug, 'project_title' => $projectTitle, @@ -198,6 +287,12 @@ public function saveModMetadata( 'filename' => $filename, 'installed_at' => now()->toIso8601String(), ]; + + if ($author !== null) { + $modEntry['author'] = $author; + } + + $metadata['installed_mods'][] = $modEntry; $metadataPath = $this->getMetadataFilePath($server); $response = $fileRepository->setServer($server)->putContent( @@ -277,8 +372,6 @@ public function isUpdateAvailable(array $installedMod, array $availableVersions) } /** - * Get installed mods/plugins filenames from the server (for backward compatibility) - * * @return array */ public function getInstalledMods(Server $server, DaemonFileRepository $fileRepository): array From 2da40315a8e6ea448fd4e7711b9213ce5fc30a35 Mon Sep 17 00:00:00 2001 From: H1ghSyst3m <60105043+H1ghSyst3m@users.noreply.github.com> Date: Sun, 1 Feb 2026 03:13:23 +0100 Subject: [PATCH 8/8] Add optional author field to installed mod metadata Updated type annotations and logic in both MinecraftModrinthProjectPage and MinecraftModrinthService to support an optional 'author' field in installed mod metadata arrays. This allows mod author information to be stored and retrieved when available. --- .../Server/Pages/MinecraftModrinthProjectPage.php | 8 +++++--- .../src/Services/MinecraftModrinthService.php | 10 +++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php index 6668ec09..0acaa661 100644 --- a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php +++ b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php @@ -33,9 +33,10 @@ class MinecraftModrinthProjectPage extends Page implements HasTable use InteractsWithTable; public const VIEW_ALL = 'all'; + public const VIEW_INSTALLED = 'installed'; - /** @var array|null */ + /** @var array|null */ protected ?array $installedModsMetadata = null; /** @var array> Cache for version data by project_id */ @@ -49,6 +50,7 @@ class MinecraftModrinthProjectPage extends Page implements HasTable protected static ?int $navigationSort = 30; + /** @return array */ protected function queryString(): array { return [ @@ -104,7 +106,7 @@ protected function getInstalledModsMetadata(): array return $this->installedModsMetadata; } - /** @return array{project_id: string, project_slug: string, project_title: string, version_id: string, version_number: string, filename: string, installed_at: string}|null */ + /** @return array{project_id: string, project_slug: string, project_title: string, version_id: string, version_number: string, filename: string, installed_at: string, author?: string}|null */ protected function getInstalledMod(string $projectId): ?array { $installedMods = $this->getInstalledModsMetadata(); @@ -208,7 +210,7 @@ public function table(Table $table): Table ->toggleable(), ]) ->recordUrl(function (array $record) { - if (isset($record['unavailable']) && $record['unavailable']) { + if (!empty($record['unavailable'])) { return null; } diff --git a/minecraft-modrinth/src/Services/MinecraftModrinthService.php b/minecraft-modrinth/src/Services/MinecraftModrinthService.php index a6ef97f5..ef6bf56c 100644 --- a/minecraft-modrinth/src/Services/MinecraftModrinthService.php +++ b/minecraft-modrinth/src/Services/MinecraftModrinthService.php @@ -71,7 +71,7 @@ public function getModrinthProjects(Server $server, int $page = 1, ?string $sear } /** - * @param array $installedMods + * @param array $installedMods * @return array> */ public function getInstalledModsFromModrinth(array $installedMods, int $page = 1): array @@ -215,7 +215,7 @@ protected function getMetadataFilePath(Server $server): string return $type->getFolder() . '/.modrinth-metadata.json'; } - /** @return array */ + /** @return array */ public function getInstalledModsMetadata(Server $server, DaemonFileRepository $fileRepository): array { try { @@ -287,11 +287,11 @@ public function saveModMetadata( 'filename' => $filename, 'installed_at' => now()->toIso8601String(), ]; - + if ($author !== null) { $modEntry['author'] = $author; } - + $metadata['installed_mods'][] = $modEntry; $metadataPath = $this->getMetadataFilePath($server); @@ -342,7 +342,7 @@ public function removeModMetadata(Server $server, DaemonFileRepository $fileRepo } } - /** @return array{project_id: string, project_slug: string, project_title: string, version_id: string, version_number: string, filename: string, installed_at: string}|null */ + /** @return array{project_id: string, project_slug: string, project_title: string, version_id: string, version_number: string, filename: string, installed_at: string, author?: string}|null */ public function getInstalledMod(Server $server, DaemonFileRepository $fileRepository, string $projectId): ?array { $installedMods = $this->getInstalledModsMetadata($server, $fileRepository);