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) * ============================================================================= */ namespace Pterodactyl\Http\Controllers; use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Models\Server; use Pterodactyl\Repositories\Wings\DaemonFileRepository; use Pterodactyl\Services\ModpackApiService; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\RateLimiter; class ModpackAPIController extends Controller { public function __construct( private DaemonFileRepository $fileRepository, private ModpackApiService $apiService ) {} /** * Manual version check triggered from the server console UI. * * Rate limited to 2 requests per minute per server to prevent API abuse. * * @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, 'error' => "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'); // 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. Check modpack_installations table (Pterodactyl — may not exist) if (empty($platform) || empty($modpackId)) { try { $installation = DB::table('modpack_installations') ->where('server_id', $server->id) ->first(); if ($installation) { $platform = $platform ?: ($installation->provider ?? null); $modpackId = $modpackId ?: (string) ($installation->modpack_id ?? ''); } } catch (\Exception $e) { // Table doesn't exist on this panel — skip } } // 3. If still nothing, try file detection if (empty($platform) || empty($modpackId)) { $detected = $this->detectFromFiles($server); $platform = $platform ?: ($detected['platform'] ?? null); $modpackId = $modpackId ?: ($detected['modpack_id'] ?? null); } // 4. If still nothing, check cached cron data if (empty($platform) || empty($modpackId)) { $cached = DB::table('modpackchecker_servers') ->where('server_uuid', $server->uuid) ->first(); if ($cached && !empty($cached->platform) && !empty($cached->modpack_id)) { $platform = $cached->platform; $modpackId = $cached->modpack_id; } } // 5. If still nothing, return helpful error if (empty($platform) || empty($modpackId)) { return response()->json([ 'success' => false, 'platform' => $platform ?? null, 'modpack_id' => $modpackId ?? null, 'error' => 'Could not detect modpack. Set MODPACK_PLATFORM and MODPACK_ID in startup variables.', ]); } // 6. Check the appropriate API using the unified Service try { $versionData = $this->apiService->fetchLatestVersion($platform, $modpackId); // Get cached current_version for comparison $cached = DB::table('modpackchecker_servers') ->where('server_uuid', $server->uuid) ->first(); $currentVersion = $cached->current_version ?? null; return response()->json([ 'success' => true, 'platform' => $platform, 'modpack_id' => $modpackId, 'modpack_name' => $versionData['name'], 'current_version' => $currentVersion, 'latest_version' => $versionData['version'], 'update_available' => $currentVersion && $currentVersion !== $versionData['version'], '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. * * @param Server $server The server to query * @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 { $variable = $server->variables() ->where('env_variable', $name) ->first(); return $variable?->server_value; } /** * Attempt to detect modpack platform and ID by reading server files. * * Fallback method when egg variables aren't set. * * @param Server $server The server to scan * @return array Contains: platform, modpack_id (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 (is_array($data) && 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 (is_array($data) && isset($data['formatVersion'])) { // Best-effort slug derivation from pack name for API lookups. // dependencies.minecraft is a MC version (e.g. "1.20.1"), NOT a project ID. // NOTE: This may not match the actual Modrinth slug if the project name // differs from its URL slug. Set MODPACK_ID egg variable for reliability. $slug = isset($data['name']) ? preg_replace('/[^a-z0-9-]/', '', strtolower(str_replace(' ', '-', $data['name']))) : null; return [ 'platform' => 'modrinth', 'modpack_id' => $slug, '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. * * @param Server $server The server whose files we're reading * @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 { try { $this->fileRepository->setServer($server); return $this->fileRepository->getContent($path); } catch (\Exception $e) { return null; } } /** * 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. * * @param Request $request The incoming HTTP request * @return JsonResponse Keyed by server_uuid */ public function getStatus(Request $request): JsonResponse { $user = $request->user(); if (!$user) { return response()->json([], 401); } // Get all server UUIDs the user has access to // Root admins can see all servers; regular users only see assigned servers if (method_exists($user, 'root_admin') ? $user->root_admin : ($user->root_admin ?? false)) { $serverUuids = \Pterodactyl\Models\Server::pluck('uuid')->toArray(); } else { $serverUuids = $user->accessibleServers()->pluck('uuid')->toArray(); } // Query our cache table for these servers $statuses = DB::table('modpackchecker_servers') ->whereIn('server_uuid', $serverUuids) ->get() ->keyBy('server_uuid'); $result = []; foreach ($statuses as $uuid => $status) { $result[$uuid] = [ 'update_available' => $status->status === 'update_available', 'modpack_name' => $status->modpack_name, 'current_version' => $status->current_version, 'latest_version' => $status->latest_version, ]; } return response()->json($result); } /** * Get cached status for a single server (zero-click widget). * Reads from DB only — no external API calls. */ public function serverStatus(Request $request, Server $server): JsonResponse { $cached = DB::table('modpackchecker_servers') ->where('server_uuid', $server->uuid) ->first(); if (!$cached || $cached->is_ignored ?? false) { return response()->json([ 'configured' => false, 'is_ignored' => $cached->is_ignored ?? false, ]); } return response()->json([ 'configured' => !empty($cached->platform) && $cached->status !== 'unconfigured', 'platform' => $cached->platform, 'modpack_name' => $cached->modpack_name, 'current_version' => $cached->current_version, 'latest_version' => $cached->latest_version, 'current_file_id' => $cached->current_file_id ?? null, 'latest_file_id' => $cached->latest_file_id ?? null, 'update_available' => $cached->status === 'update_available', 'last_checked' => $cached->last_checked, 'detection_method' => $cached->detection_method ?? 'unknown', 'is_ignored' => $cached->is_ignored ?? false, ]); } /** * Get release history for recalibrate dropdown. * Makes external API call — rate limited. */ public function releases(Request $request, Server $server): JsonResponse { $limitKey = 'modpack_releases_' . $server->uuid; if (RateLimiter::tooManyAttempts($limitKey, 2)) { $seconds = RateLimiter::availableIn($limitKey); return response()->json(['error' => "Too many requests. Wait {$seconds}s."], 429); } RateLimiter::hit($limitKey, 60); $cached = DB::table('modpackchecker_servers') ->where('server_uuid', $server->uuid) ->first(); if (!$cached || empty($cached->platform) || empty($cached->modpack_id)) { return response()->json(['releases' => []]); } $releases = $this->apiService->fetchFileHistory($cached->platform, $cached->modpack_id, 10); return response()->json(['releases' => $releases]); } /** * Recalibrate current version (user selects from release list). * Sets is_user_overridden = true. */ public function calibrate(Request $request, Server $server): JsonResponse { $fileId = $request->input('file_id'); $version = $request->input('version'); if (empty($version)) { return response()->json(['error' => 'version is required'], 400); } $updated = DB::table('modpackchecker_servers') ->where('server_uuid', $server->uuid) ->update([ 'current_version' => $version, 'current_file_id' => $fileId, 'is_user_overridden' => true, 'updated_at' => now(), ]); if (!$updated) { return response()->json(['error' => 'Server not found in modpack cache'], 404); } // Re-evaluate update status $cached = DB::table('modpackchecker_servers') ->where('server_uuid', $server->uuid) ->first(); $updateAvailable = false; if ($cached->latest_file_id && $fileId) { $updateAvailable = $cached->latest_file_id !== $fileId; } else { $updateAvailable = $cached->latest_version !== $version; } DB::table('modpackchecker_servers') ->where('server_uuid', $server->uuid) ->update([ 'status' => $updateAvailable ? 'update_available' : 'up_to_date', ]); // Write Truth File to server filesystem $modpackId = $cached->modpack_id ?? ''; try { $this->fileRepository->setServer($server); $this->fileRepository->putContent('.modpack-checker.json', json_encode([ 'extension' => 'modpackchecker', 'project_id' => $modpackId, 'file_id' => $fileId, 'version' => $version, 'calibrated_at' => now()->toIso8601String(), ], JSON_PRETTY_PRINT)); } catch (\Exception $e) { \Log::warning('[MVC] Could not write Truth File on calibrate: ' . $e->getMessage()); } return response()->json([ 'success' => true, 'current_version' => $version, 'update_available' => $updateAvailable, ]); } /** * Toggle is_ignored flag for a server. */ public function toggleIgnore(Request $request, Server $server): JsonResponse { $existing = DB::table('modpackchecker_servers') ->where('server_uuid', $server->uuid) ->first(); if (!$existing) { // Create a record to track the ignore DB::table('modpackchecker_servers')->insert([ 'server_uuid' => $server->uuid, 'is_ignored' => true, 'status' => 'ignored', 'updated_at' => now(), ]); return response()->json(['is_ignored' => true]); } $newState = !($existing->is_ignored ?? false); DB::table('modpackchecker_servers') ->where('server_uuid', $server->uuid) ->update([ 'is_ignored' => $newState, 'updated_at' => now(), ]); return response()->json(['is_ignored' => $newState]); } }