Files
firefrost-services/services/modpack-version-checker/blueprint-extension/app/Http/Controllers/ModpackAPIController.php
Claude (Chronicler #83 - The Compiler) b0aa52c2c8 fix(blueprint): Review fixes — API key lookup, tier enforcement, safety
Fixes 10 issues from Blueprint extension code review:
- CurseForge API key now reads via Blueprint dbGet() matching admin save
- PRO-tier fields (webhook, interval) enforced server-side, not just UI
- json_decode results validated before accessing parsed data
- Null user guard on getStatus() endpoint
- 429 response uses consistent error key format
- Modrinth slug derivation strips special chars, documented as fallback
- Check interval dropdown reflects saved value
- API key input changed to password type
- TypeScript error typing narrowed from any to unknown
- Removed unused DB import from ModpackApiService

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:53:37 -05:00

256 lines
9.8 KiB
PHP

<?php
/**
* =============================================================================
* MODPACK VERSION CHECKER - API CONTROLLER
* =============================================================================
*
* Part of the ModpackChecker Blueprint extension for Pterodactyl Panel.
*
* PURPOSE:
* Provides API endpoints for checking modpack versions across multiple platforms
* (CurseForge, Modrinth, FTB, Technic). Supports both on-demand manual checks
* from the server console and cached status retrieval for the dashboard badge.
*
* ARCHITECTURE OVERVIEW:
* This controller serves two distinct use cases:
*
* 1. MANUAL CHECK (manualCheck method)
* - Called from: Server console "Check for Updates" button
* - 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
* - Why cached? Prevents rate limit hell on panels with many servers
*
* ROUTES (defined in routes/client.php):
* - 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 <dev@firefrostgaming.com>
* @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. 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,
'platform' => $platform ?? null,
'modpack_id' => $modpackId ?? null,
'error' => 'Could not detect modpack. Set MODPACK_PLATFORM and MODPACK_ID in startup variables.',
]);
}
// 4. Check the appropriate API using the unified Service
try {
$versionData = $this->apiService->fetchLatestVersion($platform, $modpackId);
return response()->json([
'success' => true,
'platform' => $platform,
'modpack_id' => $modpackId,
'modpack_name' => $versionData['name'],
'latest_version' => $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
$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);
}
}