* @version 1.0.0 * ============================================================================= */ namespace Pterodactyl\Services; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Cache; use Pterodactyl\BlueprintFramework\Libraries\ExtensionLibrary\Admin\BlueprintAdminLibrary as BlueprintExtensionLibrary; use Exception; class ModpackApiService { public function __construct( private BlueprintExtensionLibrary $blueprint ) {} /** * 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::timeout(10)->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::timeout(10)->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(); $latestVer = $versions[0] ?? null; return [ 'name' => $project['title'] ?? 'Unknown', 'version' => $latestVer['version_number'] ?? 'Unknown', 'file_id' => $latestVer ? ($latestVer['id'] ?? null) : null, ]; } /** * 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 = trim($this->blueprint->dbGet('modpackchecker', 'curseforge_api_key') ?? ''); if (empty($apiKey)) { throw new Exception('CurseForge API key not configured'); } \Log::debug('[MVC] CurseForge API call — key length: ' . strlen($apiKey) . ', modpack: ' . $modpackId); $response = Http::timeout(10)->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'] ?? []; $latestFile = $data['latestFiles'][0] ?? null; // Extract clean version from displayName (e.g. "All the Mods 9-0.1.0" → "0.1.0") $displayName = $latestFile['displayName'] ?? 'Unknown'; $cleanVersion = $displayName; if (preg_match('/[\d]+\.[\d]+\.[\d]+/', $displayName, $matches)) { $cleanVersion = $matches[0]; } return [ 'name' => $data['name'] ?? 'Unknown', 'version' => $cleanVersion, 'display_name' => $displayName, 'file_id' => $latestFile ? (string) $latestFile['id'] : null, ]; } /** * 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::timeout(10)->get("https://api.modpacks.ch/public/modpack/{$modpackId}"); if (!$response->successful()) { throw new Exception('FTB API request failed: ' . $response->status()); } $data = $response->json(); $latestVer = $data['versions'][0] ?? null; return [ 'name' => $data['name'] ?? 'Unknown', 'version' => $latestVer['name'] ?? 'Unknown', 'file_id' => $latestVer ? (string) ($latestVer['id'] ?? null) : null, ]; } /** * Fetch file/version history for date-time seeding. * Returns array of ['file_id', 'version', 'display_name', 'release_date']. */ public function fetchFileHistory(string $platform, string $modpackId, int $limit = 20): array { return match($platform) { 'curseforge' => $this->fetchCurseForgeHistory($modpackId, $limit), 'modrinth' => $this->fetchModrinthHistory($modpackId, $limit), 'ftb' => $this->fetchFtbHistory($modpackId, $limit), default => [] }; } private function fetchCurseForgeHistory(string $modpackId, int $limit): array { $apiKey = trim($this->blueprint->dbGet('modpackchecker', 'curseforge_api_key') ?? ''); if (empty($apiKey)) return []; $response = Http::timeout(15)->withHeaders([ 'x-api-key' => $apiKey, 'Accept' => 'application/json', ])->get("https://api.curseforge.com/v1/mods/{$modpackId}/files", [ 'pageSize' => $limit, ]); if (!$response->successful()) return []; return collect($response->json()['data'] ?? []) ->map(fn($f) => [ 'file_id' => (string) $f['id'], 'version' => $f['displayName'] ?? 'Unknown', 'display_name' => $f['displayName'] ?? 'Unknown', 'release_date' => $f['fileDate'] ?? null, ]) ->toArray(); } private function fetchModrinthHistory(string $projectId, int $limit): array { $response = Http::timeout(15) ->withHeaders(['User-Agent' => 'FirefrostGaming/ModpackChecker/1.0']) ->get("https://api.modrinth.com/v2/project/{$projectId}/version"); if (!$response->successful()) return []; return collect($response->json() ?? []) ->take($limit) ->map(fn($v) => [ 'file_id' => $v['id'] ?? null, 'version' => $v['version_number'] ?? 'Unknown', 'display_name' => $v['name'] ?? $v['version_number'] ?? 'Unknown', 'release_date' => $v['date_published'] ?? null, ]) ->toArray(); } private function fetchFtbHistory(string $modpackId, int $limit): array { $response = Http::timeout(15) ->get("https://api.modpacks.ch/public/modpack/{$modpackId}"); if (!$response->successful()) return []; return collect($response->json()['versions'] ?? []) ->take($limit) ->map(fn($v) => [ 'file_id' => (string) ($v['id'] ?? ''), 'version' => $v['name'] ?? 'Unknown', 'display_name' => $v['name'] ?? 'Unknown', 'release_date' => isset($v['updated']) ? date('c', $v['updated']) : null, ]) ->toArray(); } /** * 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::timeout(10)->get('https://api.technicpack.net/launcher/version/stable4'); return $versionResponse->successful() ? ($versionResponse->json('build') ?? 999) : 999; }); $response = Http::timeout(10)->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', 'file_id' => $data['version'] ?? null, ]; } }