diff --git a/services/modpack-version-checker/blueprint-extension/app/Console/Commands/CheckModpackUpdates.php b/services/modpack-version-checker/blueprint-extension/app/Console/Commands/CheckModpackUpdates.php index e223a70..1255017 100644 --- a/services/modpack-version-checker/blueprint-extension/app/Console/Commands/CheckModpackUpdates.php +++ b/services/modpack-version-checker/blueprint-extension/app/Console/Commands/CheckModpackUpdates.php @@ -17,48 +17,19 @@ * * HOW IT WORKS: * 1. Finds all servers with MODPACK_PLATFORM egg variable set - * 2. Loops through each server, checking the appropriate API + * 2. Loops through each server, checking via ModpackApiService * 3. Stores results in modpackchecker_servers database table * 4. Dashboard badges read from this table (never calling APIs directly) * * RATE LIMITING: * Each API call is followed by a 2-second sleep to avoid rate limits. - * For 50 servers, a full check takes ~2 minutes. Adjust sleep() if needed. - * - * PLATFORM SUPPORT: - * - CurseForge: Requires API key in settings (configured via admin panel) - * - Modrinth: Public API, no key needed (uses User-Agent) - * - FTB: Public API via modpacks.ch - * - Technic: Public API, uses slug instead of numeric ID - * - * DATABASE TABLE: modpackchecker_servers - * This table caches the latest check results. See migration file for schema. - * Uses updateOrInsert for upsert behavior - first run creates, subsequent update. - * - * EXAMPLE OUTPUT: - * Starting modpack update check... - * Found 12 servers with modpack configuration - * Checking: ATM9 Server (a1b2c3d4-...) - * 🟠 UPDATE AVAILABLE: All The Mods 9 - 0.2.60 - * Checking: Creative Server (e5f6g7h8-...) - * Skipping - missing platform or modpack ID - * Checking: Vanilla Server (i9j0k1l2-...) - * 🟢 Up to date: Adrenaserver - 1.7.0 - * Modpack update check complete! - * - * ERROR HANDLING: - * If an API call fails, the error is logged to the database and the command - * continues to the next server. One failure doesn't stop the whole batch. - * - * DEPENDENCIES: - * - Database table: modpackchecker_servers (from migration) - * - Settings: modpackchecker::curseforge_api_key (for CurseForge only) + * For 50 servers, a full check takes ~2 minutes. * * @package Pterodactyl\Console\Commands * @author Firefrost Gaming / Frostystyle * @version 1.0.0 + * @see ModpackApiService.php (centralized API logic) * @see ModpackAPIController.php (provides getStatus endpoint for badges) - * @see UpdateBadge.tsx (React component that displays the results) * ============================================================================= */ @@ -66,38 +37,22 @@ namespace Pterodactyl\Console\Commands; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Http; use Pterodactyl\Models\Server; +use Pterodactyl\Services\ModpackApiService; -/** - * Artisan command to check all servers for modpack updates. - * - * Run manually with: php artisan modpackchecker:check - * Or schedule in app/Console/Kernel.php: - * $schedule->command('modpackchecker:check')->everyFourHours(); - */ class CheckModpackUpdates extends Command { - /** - * The console command signature. - * - * @var string - */ protected $signature = 'modpackchecker:check'; - - /** - * The console command description (shown in `php artisan list`). - * - * @var string - */ protected $description = 'Check all servers for modpack updates'; + + public function __construct(private ModpackApiService $apiService) + { + parent::__construct(); + } /** * Execute the console command. * - * Main entry point. Finds all servers with modpack configuration - * and checks each one against its platform's API. - * * @return int Exit code (0 = success) */ public function handle(): int @@ -124,15 +79,6 @@ class CheckModpackUpdates extends Command /** * Check a single server for modpack updates. * - * This method: - * 1. Retrieves modpack configuration from egg variables - * 2. Calls the appropriate platform API - * 3. Compares current vs latest version - * 4. Stores results in database cache - * - * If any step fails, the error is logged to the database - * and the method returns (doesn't throw). - * * @param Server $server The server to check * @return void */ @@ -141,7 +87,6 @@ class CheckModpackUpdates extends Command $this->line("Checking: {$server->name} ({$server->uuid})"); try { - // Get platform and modpack ID from variables $platform = $this->getVariable($server, 'MODPACK_PLATFORM'); $modpackId = $this->getVariable($server, 'MODPACK_ID'); @@ -150,24 +95,9 @@ class CheckModpackUpdates extends Command return; } - // Get latest version from API - $latestData = $this->fetchLatestVersion($platform, $modpackId); - - if (!$latestData) { - $this->error(" Failed to fetch version data"); - $this->updateDatabase($server, [ - 'platform' => $platform, - 'modpack_id' => $modpackId, - 'error_message' => 'Failed to fetch version data', - 'update_available' => false, - ]); - return; - } - - // Get current version (from variable or file - for now just use variable) + // Centralized API Call via Service + $latestData = $this->apiService->fetchLatestVersion($platform, $modpackId); $currentVersion = $this->getVariable($server, 'MODPACK_CURRENT_VERSION'); - - // Determine if update is available $updateAvailable = $currentVersion && $currentVersion !== $latestData['version']; $this->updateDatabase($server, [ @@ -176,18 +106,20 @@ class CheckModpackUpdates extends Command 'modpack_name' => $latestData['name'], 'current_version' => $currentVersion, 'latest_version' => $latestData['version'], - 'update_available' => $updateAvailable, + 'status' => $updateAvailable ? 'update_available' : 'up_to_date', 'error_message' => null, + 'last_checked' => now(), ]); - $status = $updateAvailable ? '🟠 UPDATE AVAILABLE' : '🟢 Up to date'; - $this->info(" {$status}: {$latestData['name']} - {$latestData['version']}"); + $statusIcon = $updateAvailable ? '🟠 UPDATE AVAILABLE' : '🟢 Up to date'; + $this->info(" {$statusIcon}: {$latestData['name']} - {$latestData['version']}"); } catch (\Exception $e) { $this->error(" Error: {$e->getMessage()}"); $this->updateDatabase($server, [ + 'status' => 'error', 'error_message' => $e->getMessage(), - 'update_available' => false, + 'last_checked' => now(), ]); } } @@ -195,14 +127,8 @@ class CheckModpackUpdates extends Command /** * Get an egg variable value from a server. * - * Egg variables are the startup parameters configured in the server's - * egg. For modpack checking, we look for: - * - MODPACK_PLATFORM: Which API to query - * - MODPACK_ID: The modpack identifier for that platform - * - MODPACK_CURRENT_VERSION: What's currently installed (optional) - * * @param Server $server The server to query - * @param string $name The variable name (e.g., "MODPACK_PLATFORM") + * @param string $name The variable name * @return string|null The variable value, or null if not set */ private function getVariable(Server $server, string $name): ?string @@ -213,117 +139,21 @@ class CheckModpackUpdates extends Command return $variable?->server_value; } - /** - * Route to the appropriate platform API based on platform name. - * - * This is just a dispatcher - actual API logic is in the check* methods. - * - * @param string $platform The platform name (modrinth, curseforge, ftb, technic) - * @param string $modpackId The platform-specific modpack identifier - * @return array|null Contains [name, version] or null on failure - */ - private function fetchLatestVersion(string $platform, string $modpackId): ?array - { - return match($platform) { - 'modrinth' => $this->checkModrinth($modpackId), - 'curseforge' => $this->checkCurseForge($modpackId), - 'ftb' => $this->checkFTB($modpackId), - 'technic' => $this->checkTechnic($modpackId), - default => null, - }; - } - - private function checkModrinth(string $projectId): ?array - { - $response = Http::withHeaders([ - 'User-Agent' => 'FirefrostGaming/ModpackChecker/1.0', - ])->get("https://api.modrinth.com/v2/project/{$projectId}"); - - if (!$response->successful()) return null; - $project = $response->json(); - - $versionResponse = Http::withHeaders([ - 'User-Agent' => 'FirefrostGaming/ModpackChecker/1.0', - ])->get("https://api.modrinth.com/v2/project/{$projectId}/version"); - - $versions = $versionResponse->json(); - - return [ - 'name' => $project['title'] ?? 'Unknown', - 'version' => $versions[0]['version_number'] ?? 'Unknown', - ]; - } - - private function checkCurseForge(string $modpackId): ?array - { - $apiKey = DB::table('settings') - ->where('key', 'like', '%modpackchecker::curseforge_api_key%') - ->value('value'); - - if (!$apiKey) return null; - - $response = Http::withHeaders([ - 'x-api-key' => $apiKey, - ])->get("https://api.curseforge.com/v1/mods/{$modpackId}"); - - if (!$response->successful()) return null; - $data = $response->json()['data'] ?? []; - - return [ - 'name' => $data['name'] ?? 'Unknown', - 'version' => $data['latestFiles'][0]['displayName'] ?? 'Unknown', - ]; - } - - private function checkFTB(string $modpackId): ?array - { - $response = Http::get("https://api.modpacks.ch/public/modpack/{$modpackId}"); - if (!$response->successful()) return null; - $data = $response->json(); - - return [ - 'name' => $data['name'] ?? 'Unknown', - 'version' => $data['versions'][0]['name'] ?? 'Unknown', - ]; - } - - private function checkTechnic(string $slug): ?array - { - $response = Http::get("https://api.technicpack.net/modpack/{$slug}?build=1"); - if (!$response->successful()) return null; - $data = $response->json(); - - return [ - 'name' => $data['displayName'] ?? $data['name'] ?? 'Unknown', - 'version' => $data['version'] ?? 'Unknown', - ]; - } - /** * Store or update the modpack check results in the database. * - * Uses updateOrInsert for upsert behavior: - * - First check: Creates a new row - * - Subsequent checks: Updates the existing row - * - * The server_id column is the unique key for matching. - * - * This cache table is read by ModpackAPIController::getStatus() - * which powers the dashboard badges. + * Uses updateOrInsert for upsert behavior. + * The server_uuid column is the unique key for matching. * * @param Server $server The server being checked - * @param array $data The data to store (platform, version info, errors, etc.) + * @param array $data The data to store * @return void */ private function updateDatabase(Server $server, array $data): void { DB::table('modpackchecker_servers')->updateOrInsert( - ['server_id' => $server->id], - array_merge($data, [ - 'server_uuid' => $server->uuid, - 'last_checked' => now(), - 'updated_at' => now(), - ]) + ['server_uuid' => $server->uuid], + array_merge($data, ['updated_at' => now()]) ); } } diff --git a/services/modpack-version-checker/blueprint-extension/app/Http/Controllers/ModpackAPIController.php b/services/modpack-version-checker/blueprint-extension/app/Http/Controllers/ModpackAPIController.php index 54e024c..403583a 100644 --- a/services/modpack-version-checker/blueprint-extension/app/Http/Controllers/ModpackAPIController.php +++ b/services/modpack-version-checker/blueprint-extension/app/Http/Controllers/ModpackAPIController.php @@ -17,43 +17,23 @@ * * 1. MANUAL CHECK (manualCheck method) * - Called from: Server console "Check for Updates" button - * - Behavior: Makes LIVE API calls to modpack platforms - * - Use case: User wants current info for a specific server - * - Rate limit consideration: One server at a time, user-initiated + * - Behavior: Makes LIVE API calls via ModpackApiService + * - Rate limited: 2 requests per minute per server * * 2. DASHBOARD STATUS (getStatus method) * - Called from: Dashboard badge component (UpdateBadge.tsx) * - Behavior: Reads from LOCAL DATABASE CACHE only - NO external API calls - * - Use case: Show update indicators for all servers on dashboard - * - Why cached? Dashboard loads could mean 10+ servers × multiple users = - * rate limit hell. Cron job populates cache instead. - * - * CRITICAL DESIGN DECISION (Gemini-approved): - * The dashboard badge MUST be "dumb" - it only reads cached data. If we let - * the dashboard trigger live API calls, we'd hit rate limits within minutes - * on any panel with more than a handful of servers. The CheckModpackUpdates - * cron command handles the external API calls on a schedule with rate limiting. - * - * PLATFORM SUPPORT: - * - CurseForge: Requires API key (configured in admin panel) - * - Modrinth: No key required, uses User-Agent identification - * - FTB (Feed The Beast): Public API via modpacks.ch - * - Technic: Public API, uses slug instead of numeric ID - * - * DEPENDENCIES: - * - Blueprint Framework (BlueprintAdminLibrary for settings storage) - * - Pterodactyl's DaemonFileRepository (for file-based modpack detection) - * - Database table: modpackchecker_servers (created by migration) + * - Why cached? Prevents rate limit hell on panels with many servers * * ROUTES (defined in routes/client.php): - * - POST /api/client/servers/{server}/ext/modpackchecker/check -> manualCheck() + * - POST /api/client/extensions/modpackchecker/servers/{server}/check -> manualCheck() * - GET /api/client/extensions/modpackchecker/status -> getStatus() * * @package Pterodactyl\BlueprintFramework\Extensions\modpackchecker * @author Firefrost Gaming / Frostystyle * @version 1.0.0 + * @see ModpackApiService.php (centralized API logic) * @see CheckModpackUpdates.php (cron command that populates the cache) - * @see UpdateBadge.tsx (React component that consumes getStatus) * ============================================================================= */ @@ -62,65 +42,41 @@ namespace Pterodactyl\Http\Controllers; use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Models\Server; use Pterodactyl\Repositories\Wings\DaemonFileRepository; -use Pterodactyl\BlueprintFramework\Libraries\ExtensionLibrary\Admin\BlueprintAdminLibrary as BlueprintExtensionLibrary; +use Pterodactyl\Services\ModpackApiService; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\RateLimiter; -/** - * Handles all modpack version checking API requests. - * - * Two public endpoints: - * - manualCheck(): Live check for single server (console button) - * - getStatus(): Cached status for all servers (dashboard badge) - */ class ModpackAPIController extends Controller { public function __construct( private DaemonFileRepository $fileRepository, - private BlueprintExtensionLibrary $blueprint + private ModpackApiService $apiService ) {} /** * Manual version check triggered from the server console UI. * - * This endpoint makes LIVE API calls to external modpack platforms. - * It's designed for on-demand, user-initiated checks of a single server. + * Rate limited to 2 requests per minute per server to prevent API abuse. * - * DETECTION PRIORITY: - * 1. Egg variables (MODPACK_PLATFORM + MODPACK_ID) - most reliable - * 2. Platform-specific variables (CURSEFORGE_ID, MODRINTH_PROJECT_ID, etc.) - * 3. File fingerprinting (manifest.json, modrinth.index.json) - * - * WHY THIS ORDER? - * Egg variables are explicitly set by the server owner, so they're most - * trustworthy. File detection is a fallback for servers that were set up - * before this extension was installed. - * - * @param Request $request The incoming HTTP request (includes auth) - * @param Server $server The server to check (injected by route model binding) - * @return JsonResponse Contains: success, platform, modpack_id, modpack_name, - * latest_version, status, and error (if applicable) - * - * @example Success response: - * { - * "success": true, - * "platform": "modrinth", - * "modpack_id": "adrenaserver", - * "modpack_name": "Adrenaserver", - * "latest_version": "1.7.0+1.21.1.fabric", - * "status": "checked" - * } - * - * @example Error response (no modpack detected): - * { - * "success": false, - * "message": "Could not detect modpack. Set MODPACK_PLATFORM and MODPACK_ID in startup variables." - * } + * @param Request $request The incoming HTTP request + * @param Server $server The server to check + * @return JsonResponse */ public function manualCheck(Request $request, Server $server): JsonResponse { + // Rate Limiting: Max 2 requests per minute per server + $limitKey = 'modpack_check_' . $server->uuid; + if (RateLimiter::tooManyAttempts($limitKey, 2)) { + $seconds = RateLimiter::availableIn($limitKey); + return response()->json([ + 'success' => false, + 'message' => "Too many requests. Please wait {$seconds} seconds before checking again." + ], 429); + } + RateLimiter::hit($limitKey, 60); + // 1. Try Egg Variables first (most reliable) $platform = $this->getEggVariable($server, 'MODPACK_PLATFORM'); $modpackId = $this->getEggVariable($server, 'MODPACK_ID'); @@ -151,22 +107,16 @@ class ModpackAPIController extends Controller ]); } - // 4. Check the appropriate API + // 4. Check the appropriate API using the unified Service try { - $versionData = match($platform) { - 'curseforge' => $this->checkCurseForge($modpackId), - 'modrinth' => $this->checkModrinth($modpackId), - 'ftb' => $this->checkFTB($modpackId), - 'technic' => $this->checkTechnic($modpackId), - default => throw new \Exception("Unknown platform: {$platform}") - }; + $versionData = $this->apiService->fetchLatestVersion($platform, $modpackId); return response()->json([ 'success' => true, 'platform' => $platform, 'modpack_id' => $modpackId, - 'modpack_name' => $versionData['name'] ?? 'Unknown', - 'latest_version' => $versionData['version'] ?? 'Unknown', + 'modpack_name' => $versionData['name'], + 'latest_version' => $versionData['version'], 'status' => 'checked', ]); } catch (\Exception $e) { @@ -182,14 +132,8 @@ class ModpackAPIController extends Controller /** * Retrieve an egg variable value for a specific server. * - * Egg variables are the startup parameters defined in Pterodactyl eggs. - * For modpack detection, we look for variables like: - * - MODPACK_PLATFORM: "curseforge", "modrinth", "ftb", "technic" - * - MODPACK_ID: The platform-specific identifier - * - MODPACK_CURRENT_VERSION: What version is currently installed - * * @param Server $server The server to query - * @param string $name The environment variable name (e.g., "MODPACK_PLATFORM") + * @param string $name The environment variable name * @return string|null The variable's value, or null if not set */ private function getEggVariable(Server $server, string $name): ?string @@ -203,23 +147,10 @@ class ModpackAPIController extends Controller /** * Attempt to detect modpack platform and ID by reading server files. * - * This is a FALLBACK method when egg variables aren't set. It works by - * looking for platform-specific manifest files that modpack installers create: + * Fallback method when egg variables aren't set. * - * - CurseForge: manifest.json with manifestType="minecraftModpack" - * - Modrinth: modrinth.index.json with formatVersion field - * - * LIMITATIONS: - * - Requires the server to be running (Wings must be accessible) - * - Some modpack installations don't include these files - * - Modrinth index doesn't always contain the project ID - * - * WHY NOT FTB/TECHNIC? - * FTB and Technic launchers don't leave standardized manifest files. - * Servers using these platforms MUST set egg variables manually. - * - * @param Server $server The server to scan for manifest files - * @return array Contains: platform, modpack_id, name, version (all nullable) + * @param Server $server The server to scan + * @return array Contains: platform, modpack_id (all nullable) */ private function detectFromFiles(Server $server): array { @@ -261,17 +192,8 @@ class ModpackAPIController extends Controller /** * Read a file from the game server via the Wings daemon. * - * Uses Pterodactyl's DaemonFileRepository to communicate with Wings, - * which runs on the game server node. This allows us to read files - * from the server's filesystem without direct SSH access. - * - * FAILURE MODES: - * - Server offline: Wings can't access stopped containers - * - File doesn't exist: Returns null (caught exception) - * - Wings unreachable: Network/auth issues return null - * * @param Server $server The server whose files we're reading - * @param string $path Relative path from server root (e.g., "manifest.json") + * @param string $path Relative path from server root * @return string|null File contents, or null if unreadable */ private function readServerFile(Server $server, string $path): ?string @@ -284,228 +206,16 @@ class ModpackAPIController extends Controller } } - /** - * Query CurseForge API for latest modpack version. - * - * REQUIRES: API key configured in admin panel. - * CurseForge's API is not public - you must apply for access: - * https://docs.curseforge.com/#getting-started - * - * API ENDPOINT: GET https://api.curseforge.com/v1/mods/{modId} - * - * RATE LIMITS: CurseForge allows ~1000 requests/day for personal keys. - * The cron job handles rate limiting via sleep() between checks. - * - * @param string $modpackId CurseForge project ID (numeric string) - * @return array Contains: name, version - * @throws \Exception If API key missing or request fails - * - * @see https://docs.curseforge.com/#get-mod - */ - private function checkCurseForge(string $modpackId): array - { - $apiKey = $this->blueprint->dbGet('modpackchecker', 'curseforge_api_key'); - - if (empty($apiKey)) { - throw new \Exception('CurseForge API key not configured'); - } - - $response = Http::withHeaders([ - 'x-api-key' => $apiKey, - 'Accept' => 'application/json', - ])->get("https://api.curseforge.com/v1/mods/{$modpackId}"); - - if (!$response->successful()) { - throw new \Exception('CurseForge API request failed: ' . $response->status()); - } - - $data = $response->json(); - $mod = $data['data'] ?? []; - - return [ - 'name' => $mod['name'] ?? 'Unknown', - 'version' => $mod['latestFiles'][0]['displayName'] ?? 'Unknown', - ]; - } - - /** - * Query Modrinth API for latest modpack version. - * - * NO API KEY REQUIRED - Modrinth's API is public. - * However, they require a User-Agent header for identification. - * - * API ENDPOINTS: - * - GET https://api.modrinth.com/v2/project/{id}/version (get versions) - * - GET https://api.modrinth.com/v2/project/{id} (get project name) - * - * RATE LIMITS: 300 requests/minute (generous, but cron still throttles) - * - * NOTE: We make TWO API calls here - one for versions, one for project - * name. This could be optimized to a single call if performance matters. - * - * @param string $projectId Modrinth project ID or slug - * @return array Contains: name, version - * @throws \Exception If request fails - * - * @see https://docs.modrinth.com/api/operations/getproject/ - */ - private function checkModrinth(string $projectId): array - { - $response = Http::withHeaders([ - 'Accept' => 'application/json', - 'User-Agent' => 'FirefrostGaming/ModpackChecker/1.0', - ])->get("https://api.modrinth.com/v2/project/{$projectId}/version"); - - if (!$response->successful()) { - throw new \Exception('Modrinth API request failed: ' . $response->status()); - } - - $versions = $response->json(); - $latest = $versions[0] ?? []; - - // Get project name - $projectResponse = Http::withHeaders([ - 'Accept' => 'application/json', - 'User-Agent' => 'FirefrostGaming/ModpackChecker/1.0', - ])->get("https://api.modrinth.com/v2/project/{$projectId}"); - - $project = $projectResponse->json(); - - return [ - 'name' => $project['title'] ?? 'Unknown', - 'version' => $latest['version_number'] ?? 'Unknown', - ]; - } - - /** - * Query Feed The Beast (FTB) API for latest modpack version. - * - * NO API KEY REQUIRED - modpacks.ch is a public API. - * - * API ENDPOINT: GET https://api.modpacks.ch/public/modpack/{id} - * - * NOTE: FTB modpack IDs are numeric but often displayed differently - * in the launcher. Users may need to find the ID from the modpack URL. - * - * The versions array is sorted newest-first, so [0] is always latest. - * - * @param string $modpackId FTB modpack ID (numeric string) - * @return array Contains: name, version - * @throws \Exception If request fails - * - * @see https://api.modpacks.ch/ - */ - private function checkFTB(string $modpackId): array - { - $response = Http::withHeaders([ - 'Accept' => 'application/json', - ])->get("https://api.modpacks.ch/public/modpack/{$modpackId}"); - - if (!$response->successful()) { - throw new \Exception('FTB API request failed: ' . $response->status()); - } - - $data = $response->json(); - - return [ - 'name' => $data['name'] ?? 'Unknown', - 'version' => $data['versions'][0]['name'] ?? 'Unknown', - ]; - } - - /** - * Query Technic Platform API for latest modpack version. - * - * NO API KEY REQUIRED - Technic's API is public. - * - * IMPORTANT: Technic uses SLUGS, not numeric IDs! - * The slug is the URL-friendly name from the modpack page. - * Example: "tekkit" for https://www.technicpack.net/modpack/tekkit - * - * API ENDPOINT: GET https://api.technicpack.net/modpack/{slug}?build=1 - * - * The ?build=1 parameter includes build metadata in the response. - * - * @param string $slug Technic modpack slug (URL-friendly name) - * @return array Contains: name (displayName preferred), version - * @throws \Exception If request fails - * - * @see https://www.technicpack.net/api - */ - private function checkTechnic(string $slug): array - { - // TECHNIC API FIX (Gemini consultation, April 2026): - // Technic blocks requests with old/invalid build numbers. - // We dynamically fetch the current stable launcher build to avoid 401s. - // This "RV-Ready" approach requires zero maintenance as builds update. - - // Step 1: Get current stable launcher build number - $versionResponse = Http::get('https://api.technicpack.net/launcher/version/stable4'); - $latestBuild = $versionResponse->successful() - ? ($versionResponse->json('build') ?? 999) - : 999; // Fallback to high number if version check fails - - // Step 2: Fetch modpack data with valid build number - $response = Http::withHeaders([ - 'User-Agent' => 'FirefrostGaming/ModpackChecker/1.0', - 'Accept' => 'application/json', - ])->get("https://api.technicpack.net/modpack/{$slug}?build={$latestBuild}"); - - if (!$response->successful()) { - throw new \Exception('Technic API request failed: ' . $response->status()); - } - - $data = $response->json(); - - return [ - 'name' => $data['displayName'] ?? $data['name'] ?? 'Unknown', - 'version' => $data['version'] ?? 'Unknown', - ]; - } - /** * Get cached update status for all of a user's servers. * * THIS IS THE DASHBOARD BADGE ENDPOINT. * * CRITICAL: This method ONLY reads from the local database cache. - * It NEVER makes external API calls. This is by design. + * It NEVER makes external API calls. * - * WHY CACHED-ONLY? - * Imagine a panel with 50 servers across 20 users. If each dashboard - * load triggered 50 live API calls, you'd hit rate limits in minutes. - * Instead, the CheckModpackUpdates cron job runs hourly/daily and - * populates the modpackchecker_servers table. This endpoint just - * reads that cache. - * - * The React component (UpdateBadge.tsx) calls this ONCE on page load - * and caches the result client-side to avoid even repeated DB queries. - * - * RESPONSE FORMAT: - * Keyed by server UUID for easy lookup in the React component. - * Only includes servers that have been checked by the cron job. - * - * @param Request $request The incoming HTTP request (includes auth user) - * @return JsonResponse Keyed by server_uuid, contains update status - * - * @example Response: - * { - * "a1b2c3d4-...": { - * "update_available": true, - * "modpack_name": "All The Mods 9", - * "current_version": "0.2.51", - * "latest_version": "0.2.60" - * }, - * "e5f6g7h8-...": { - * "update_available": false, - * "modpack_name": "Adrenaserver", - * "current_version": "1.7.0", - * "latest_version": "1.7.0" - * } - * } - * - * @see CheckModpackUpdates.php (cron command that populates this cache) - * @see UpdateBadge.tsx (React component that consumes this endpoint) + * @param Request $request The incoming HTTP request + * @return JsonResponse Keyed by server_uuid */ public function getStatus(Request $request): JsonResponse { diff --git a/services/modpack-version-checker/blueprint-extension/app/Services/ModpackApiService.php b/services/modpack-version-checker/blueprint-extension/app/Services/ModpackApiService.php new file mode 100644 index 0000000..3073ebd --- /dev/null +++ b/services/modpack-version-checker/blueprint-extension/app/Services/ModpackApiService.php @@ -0,0 +1,198 @@ + + * @version 1.0.0 + * ============================================================================= + */ + +namespace Pterodactyl\Services; + +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; +use Exception; + +class ModpackApiService +{ + /** + * Fetch the latest version info for a modpack from its platform API. + * + * @param string $platform Platform name: modrinth, curseforge, ftb, technic + * @param string $modpackId Platform-specific modpack identifier + * @return array Contains: name, version + * @throws Exception If platform unknown or API call fails + */ + public function fetchLatestVersion(string $platform, string $modpackId): array + { + return match($platform) { + 'modrinth' => $this->checkModrinth($modpackId), + 'curseforge' => $this->checkCurseForge($modpackId), + 'ftb' => $this->checkFTB($modpackId), + 'technic' => $this->checkTechnic($modpackId), + default => throw new Exception("Unknown platform: {$platform}") + }; + } + + /** + * Query Modrinth API for latest modpack version. + * + * NO API KEY REQUIRED - uses User-Agent for identification. + * Rate limit: 300 requests/minute (generous) + * + * @param string $projectId Modrinth project ID or slug + * @return array Contains: name, version + * @throws Exception If API request fails + */ + private function checkModrinth(string $projectId): array + { + $headers = ['User-Agent' => 'FirefrostGaming/ModpackChecker/1.0']; + + $response = Http::withHeaders($headers) + ->get("https://api.modrinth.com/v2/project/{$projectId}"); + + if (!$response->successful()) { + throw new Exception('Modrinth API request failed: ' . $response->status()); + } + + $project = $response->json(); + + $versionResponse = Http::withHeaders($headers) + ->get("https://api.modrinth.com/v2/project/{$projectId}/version"); + + if (!$versionResponse->successful()) { + throw new Exception('Modrinth versions API failed: ' . $versionResponse->status()); + } + + $versions = $versionResponse->json(); + + return [ + 'name' => $project['title'] ?? 'Unknown', + 'version' => $versions[0]['version_number'] ?? 'Unknown', + ]; + } + + /** + * Query CurseForge API for latest modpack version. + * + * REQUIRES API KEY - configured in admin panel. + * Rate limit: ~1000 requests/day for personal keys. + * + * @param string $modpackId CurseForge project ID (numeric) + * @return array Contains: name, version + * @throws Exception If API key missing or request fails + */ + private function checkCurseForge(string $modpackId): array + { + $apiKey = DB::table('settings') + ->where('key', 'modpackchecker::curseforge_api_key') + ->value('value'); + + if (empty($apiKey)) { + throw new Exception('CurseForge API key not configured'); + } + + $response = Http::withHeaders([ + 'x-api-key' => $apiKey, + 'Accept' => 'application/json', + ])->get("https://api.curseforge.com/v1/mods/{$modpackId}"); + + if (!$response->successful()) { + throw new Exception('CurseForge API request failed: ' . $response->status()); + } + + $data = $response->json()['data'] ?? []; + + return [ + 'name' => $data['name'] ?? 'Unknown', + 'version' => $data['latestFiles'][0]['displayName'] ?? 'Unknown', + ]; + } + + /** + * Query Feed The Beast (FTB) API for latest modpack version. + * + * NO API KEY REQUIRED - modpacks.ch is public. + * + * @param string $modpackId FTB modpack ID (numeric) + * @return array Contains: name, version + * @throws Exception If API request fails + */ + private function checkFTB(string $modpackId): array + { + $response = Http::get("https://api.modpacks.ch/public/modpack/{$modpackId}"); + + if (!$response->successful()) { + throw new Exception('FTB API request failed: ' . $response->status()); + } + + $data = $response->json(); + + return [ + 'name' => $data['name'] ?? 'Unknown', + 'version' => $data['versions'][0]['name'] ?? 'Unknown', + ]; + } + + /** + * Query Technic Platform API for latest modpack version. + * + * NO API KEY REQUIRED - but requires dynamic build number. + * The build number is cached for 12 hours to reduce API calls. + * + * "RV-Ready" approach: Technic blocks old build numbers, so we + * dynamically fetch the current stable launcher build. + * + * @param string $slug Technic modpack slug (URL-friendly name) + * @return array Contains: name, version + * @throws Exception If API request fails + */ + private function checkTechnic(string $slug): array + { + // Cache the build number for 12 hours to prevent rate limits + $latestBuild = Cache::remember('modpackchecker_technic_build', 43200, function () { + $versionResponse = Http::get('https://api.technicpack.net/launcher/version/stable4'); + return $versionResponse->successful() + ? ($versionResponse->json('build') ?? 999) + : 999; + }); + + $response = Http::withHeaders([ + 'User-Agent' => 'FirefrostGaming/ModpackChecker/1.0', + 'Accept' => 'application/json', + ])->get("https://api.technicpack.net/modpack/{$slug}?build={$latestBuild}"); + + if (!$response->successful()) { + throw new Exception('Technic API request failed: ' . $response->status()); + } + + $data = $response->json(); + + return [ + 'name' => $data['displayName'] ?? $data['name'] ?? 'Unknown', + 'version' => $data['version'] ?? 'Unknown', + ]; + } +}