manualCheck() * - GET /api/client/extensions/modpackchecker/status -> getStatus() * * @package Pterodactyl\BlueprintFramework\Extensions\modpackchecker * @author Firefrost Gaming (Chroniclers #52, #62, #63) * @version 1.0.0 * @see CheckModpackUpdates.php (cron command that populates the cache) * @see UpdateBadge.tsx (React component that consumes getStatus) * ============================================================================= */ 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 Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\DB; /** * 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 ) {} /** * 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. * * 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." * } */ public function manualCheck(Request $request, Server $server): JsonResponse { // 1. Try Egg Variables first (most reliable) $platform = $this->getEggVariable($server, 'MODPACK_PLATFORM'); $modpackId = $this->getEggVariable($server, 'MODPACK_ID'); // Also check platform-specific variables if (empty($modpackId)) { $modpackId = match($platform) { 'curseforge' => $this->getEggVariable($server, 'CURSEFORGE_ID'), 'modrinth' => $this->getEggVariable($server, 'MODRINTH_PROJECT_ID'), 'ftb' => $this->getEggVariable($server, 'FTB_MODPACK_ID'), 'technic' => $this->getEggVariable($server, 'TECHNIC_SLUG'), default => null }; } // 2. If no egg variables, try file detection if (empty($platform) || empty($modpackId)) { $detected = $this->detectFromFiles($server); $platform = $platform ?: ($detected['platform'] ?? null); $modpackId = $modpackId ?: ($detected['modpack_id'] ?? null); } // 3. If still nothing, return helpful error if (empty($platform) || empty($modpackId)) { return response()->json([ 'success' => false, 'message' => 'Could not detect modpack. Set MODPACK_PLATFORM and MODPACK_ID in startup variables.', ]); } // 4. Check the appropriate API 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}") }; return response()->json([ 'success' => true, 'platform' => $platform, 'modpack_id' => $modpackId, 'modpack_name' => $versionData['name'] ?? 'Unknown', 'latest_version' => $versionData['version'] ?? 'Unknown', 'status' => 'checked', ]); } catch (\Exception $e) { return response()->json([ 'success' => false, 'platform' => $platform, 'modpack_id' => $modpackId, 'error' => $e->getMessage(), ]); } } /** * 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") * @return string|null The variable's value, or null if not set */ private function getEggVariable(Server $server, string $name): ?string { $variable = $server->variables() ->where('env_variable', $name) ->first(); return $variable?->server_value; } /** * 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: * * - 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) */ private function detectFromFiles(Server $server): array { try { // Try CurseForge manifest.json $manifest = $this->readServerFile($server, 'manifest.json'); if ($manifest) { $data = json_decode($manifest, true); if (isset($data['manifestType']) && $data['manifestType'] === 'minecraftModpack') { return [ 'platform' => 'curseforge', 'modpack_id' => $data['projectID'] ?? null, 'name' => $data['name'] ?? null, 'version' => $data['version'] ?? null, ]; } } // Try Modrinth modrinth.index.json $modrinthIndex = $this->readServerFile($server, 'modrinth.index.json'); if ($modrinthIndex) { $data = json_decode($modrinthIndex, true); if (isset($data['formatVersion'])) { return [ 'platform' => 'modrinth', 'modpack_id' => $data['dependencies']['minecraft'] ?? null, 'name' => $data['name'] ?? null, 'version' => $data['versionId'] ?? null, ]; } } } catch (\Exception $e) { // File detection failed, return empty } return []; } /** * 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") * @return string|null File contents, or null if unreadable */ private function readServerFile(Server $server, string $path): ?string { try { $this->fileRepository->setServer($server); return $this->fileRepository->getContent($path); } catch (\Exception $e) { return null; } } /** * 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. * * 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) */ public function getStatus(Request $request): JsonResponse { $user = $request->user(); // Get all servers the user has access to $serverIds = $user->accessibleServers()->pluck('id')->toArray(); // Query our cache table for these servers $statuses = DB::table('modpackchecker_servers') ->whereIn('server_id', $serverIds) ->get() ->keyBy('server_uuid'); $result = []; foreach ($statuses as $uuid => $status) { $result[$uuid] = [ 'update_available' => (bool) $status->update_available, 'modpack_name' => $status->modpack_name, 'current_version' => $status->current_version, 'latest_version' => $status->latest_version, ]; } return response()->json($result); } }