refactor(modpackchecker): Batch 2 fixes - centralized service, rate limiting, schema fixes
NEW: app/Services/ModpackApiService.php
- Centralized API logic for all 4 platforms
- Technic build number cached for 12 hours (RV-Ready)
- Single source of truth for API calls
Controller (ModpackAPIController.php):
- Now uses injected ModpackApiService instead of duplicated code
- Added RateLimiter: 2 requests/minute per server on manualCheck()
- Returns 429 with countdown when rate limited
- Removed 400+ lines of duplicated API code
Console Command (CheckModpackUpdates.php):
- FIXED: updateDatabase() now uses server_uuid (not server_id)
- FIXED: status column uses strings ('update_available', 'up_to_date', 'error')
- FIXED: Technic API now uses dynamic build via service
- Now uses injected ModpackApiService
SECURITY:
- Rate limiting prevents API key abuse via button spam
- Technic build caching reduces external API calls
Reviewed by: Gemini AI (Architecture Consultant)
Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
This commit is contained in:
@@ -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 <dev@firefrostgaming.com>
|
||||
* @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()])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <dev@firefrostgaming.com>
|
||||
* @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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* =============================================================================
|
||||
* MODPACK VERSION CHECKER - API SERVICE
|
||||
* =============================================================================
|
||||
*
|
||||
* Centralized service for all modpack platform API interactions.
|
||||
* Used by both the Controller (manual checks) and Command (cron checks).
|
||||
*
|
||||
* SUPPORTED PLATFORMS:
|
||||
* - Modrinth: Public API with User-Agent requirement
|
||||
* - CurseForge: Requires API key (configured in admin panel)
|
||||
* - FTB: Public API via modpacks.ch
|
||||
* - Technic: Public API with dynamic build number caching
|
||||
*
|
||||
* WHY A SERVICE?
|
||||
* DRY principle - both the manual check button and the cron job need
|
||||
* the same API logic. Centralizing it here means:
|
||||
* - One place to fix bugs
|
||||
* - One place to add new platforms
|
||||
* - Consistent error handling
|
||||
* - Shared caching (e.g., Technic build number)
|
||||
*
|
||||
* @package Pterodactyl\BlueprintFramework\Extensions\modpackchecker
|
||||
* @author Firefrost Gaming / Frostystyle <dev@firefrostgaming.com>
|
||||
* @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',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user