CheckModpackUpdates: - Reads .modpack-checker.json Truth File from server filesystem - Falls back to manifest.json, extracts fileID, writes Truth File - NEVER seeds current_version from latest API result - Unknown version → status: pending_calibration (not up_to_date) - Removed seedCurrentVersion heuristic — replaced with Truth File - writeTruthFile() helper writes .modpack-checker.json via Wings ModpackAPIController: - calibrate() now writes Truth File after DB update - Persists across server reinstalls and cron runs wrapper.tsx: - pending_calibration: shows "Version unknown" + "Identify Version" button - Ignored servers: muted card with "Resume" button (not hidden) - Extracted renderCalibrateDropdown() for reuse - Error state shows message instead of vanishing Migration: - Updates existing unknown+detected rows to pending_calibration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
450 lines
17 KiB
PHP
450 lines
17 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. 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]);
|
|
}
|
|
}
|