Files
firefrost-services/services/modpack-version-checker/blueprint-extension/app/Http/Controllers/ModpackAPIController.php
Claude (Chronicler #63) 5c97b40237 fix(modpackchecker): Fix Technic API 401 error with dynamic build number
ROOT CAUSE (Gemini consultation):
Technic blocks requests with old/deprecated build numbers. The hardcoded
'?build=1' was being rejected as an ancient launcher version.

SOLUTION:
- Fetch current stable launcher build from /launcher/version/stable4
- Use that build number in the modpack request
- Fallback to 999 if version check fails

This 'RV-Ready' approach requires zero maintenance as Technic updates
their launcher versions over time.

ALL 4 PLATFORMS NOW WORKING:
 Modrinth
 FTB
 CurseForge
 Technic

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 10:01:53 +00:00

536 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\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 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
{
// 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.
*
* 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);
}
}