Added professional-grade documentation throughout the codebase so any developer can pick up this project and understand it immediately. PHILOSOPHY: 'Hand someone the repo and say: here's what we built, here's WHY we built it this way, here's where it's going. Make it better.' — Michael NEW FILES: - blueprint-extension/README.md - Complete developer onboarding guide (400+ lines) - Architecture diagram showing cron → cache → badge flow - Installation steps, configuration, usage - API reference with example responses - Troubleshooting guide - Design decisions with rationale ENHANCED DOCUMENTATION: ModpackAPIController.php: - 60-line file header explaining purpose, architecture, critical decisions - Detailed docblocks on every method - Explains WHY dashboard reads cache-only (rate limits) - Documents all four platform APIs with links - Example request/response for each endpoint CheckModpackUpdates.php: - 50-line file header with usage examples - Recommended cron schedule - Example console output - Documents rate limiting strategy - Explains relationship to dashboard badges UpdateBadge.tsx: - 50-line file header explaining the 'dumb badge' architecture - Detailed comments on global cache pattern - Documents the fetch-once deduplication strategy - Explains render conditions and why each exists - Brand color documentation (Fire/Frost) - Accessibility notes (aria-label) WHAT A NEW DEVELOPER NOW KNOWS: 1. The 'why' behind every architectural decision 2. How the cron → cache → badge flow prevents rate limits 3. Which methods call external APIs vs read cache 4. How to add a new platform 5. How to troubleshoot common issues 6. The relationship between all components This codebase is now ready to hand to a contractor with the words: 'This was made great. Make it awesome.' Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
523 lines
20 KiB
PHP
523 lines
20 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 to modpack platforms
|
||
* - Use case: User wants current info for a specific server
|
||
* - Rate limit consideration: One server at a time, user-initiated
|
||
*
|
||
* 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)
|
||
*
|
||
* ROUTES (defined in routes/client.php):
|
||
* - POST /api/client/servers/{server}/ext/modpackchecker/check -> manualCheck()
|
||
* - GET /api/client/extensions/modpackchecker/status -> getStatus()
|
||
*
|
||
* @package Pterodactyl\BlueprintFramework\Extensions\modpackchecker
|
||
* @author Firefrost Gaming (Chroniclers #52, #62, #63)
|
||
* @version 1.0.0
|
||
* @see CheckModpackUpdates.php (cron command that populates the cache)
|
||
* @see UpdateBadge.tsx (React component that consumes getStatus)
|
||
* =============================================================================
|
||
*/
|
||
|
||
namespace Pterodactyl\BlueprintFramework\Extensions\modpackchecker\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 Illuminate\Http\Request;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* 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
|
||
) {}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* 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."
|
||
* }
|
||
*/
|
||
public function manualCheck(Request $request, Server $server): JsonResponse
|
||
{
|
||
// 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,
|
||
'message' => 'Could not detect modpack. Set MODPACK_PLATFORM and MODPACK_ID in startup variables.',
|
||
]);
|
||
}
|
||
|
||
// 4. Check the appropriate API
|
||
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}")
|
||
};
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'platform' => $platform,
|
||
'modpack_id' => $modpackId,
|
||
'modpack_name' => $versionData['name'] ?? 'Unknown',
|
||
'latest_version' => $versionData['version'] ?? 'Unknown',
|
||
'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.
|
||
*
|
||
* 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")
|
||
* @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.
|
||
*
|
||
* This is a FALLBACK method when egg variables aren't set. It works by
|
||
* looking for platform-specific manifest files that modpack installers create:
|
||
*
|
||
* - 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)
|
||
*/
|
||
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 (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 (isset($data['formatVersion'])) {
|
||
return [
|
||
'platform' => 'modrinth',
|
||
'modpack_id' => $data['dependencies']['minecraft'] ?? null,
|
||
'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.
|
||
*
|
||
* 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")
|
||
* @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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
{
|
||
$response = Http::withHeaders([
|
||
'Accept' => 'application/json',
|
||
])->get("https://api.technicpack.net/modpack/{$slug}?build=1");
|
||
|
||
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.
|
||
*
|
||
* 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)
|
||
*/
|
||
public function getStatus(Request $request): JsonResponse
|
||
{
|
||
$user = $request->user();
|
||
|
||
// Get all servers the user has access to
|
||
$serverIds = $user->accessibleServers()->pluck('id')->toArray();
|
||
|
||
// Query our cache table for these servers
|
||
$statuses = DB::table('modpackchecker_servers')
|
||
->whereIn('server_id', $serverIds)
|
||
->get()
|
||
->keyBy('server_uuid');
|
||
|
||
$result = [];
|
||
foreach ($statuses as $uuid => $status) {
|
||
$result[$uuid] = [
|
||
'update_available' => (bool) $status->update_available,
|
||
'modpack_name' => $status->modpack_name,
|
||
'current_version' => $status->current_version,
|
||
'latest_version' => $status->latest_version,
|
||
];
|
||
}
|
||
|
||
return response()->json($result);
|
||
}
|
||
}
|