Files
firefrost-services/services/modpack-version-checker/blueprint-extension/Controllers/ModpackAPIController.php
Claude (Chronicler #63) 0cbea6d993 feat(modpackchecker): Phase 5 complete - Dashboard badge and cron job
Phase 5 Components (completing Pyrrhus's work):

NEW FILES:
- views/dashboard/UpdateBadge.tsx: Dashboard badge component
  - Shows 🟢 (up to date) or 🟠 (update available) next to server names
  - Global cache prevents multiple API calls on page load
  - Reads from local database, never calls external APIs directly
  - Fire (#FF6B35) and Frost (#4ECDC4) brand colors

- console/CheckModpackUpdates.php: Laravel cron command
  - Run with: php artisan modpackchecker:check
  - Loops through servers with MODPACK_PLATFORM variable
  - Checks CurseForge, Modrinth, FTB, Technic APIs
  - Rate limited (2s sleep between checks)
  - Stores results in modpackchecker_servers table

UPDATED FILES:
- Controllers/ModpackAPIController.php:
  - Added getStatus() method for dashboard badge endpoint
  - Returns all user's servers' update status in single query
  - Added DB facade import

- routes/client.php:
  - Added GET /extensions/modpackchecker/status route

- build.sh:
  - Complete rewrite for Phase 5
  - Handles both console widget AND dashboard badge
  - Auto-detects extension directory (dev vs extensions)
  - Copies CheckModpackUpdates.php to app/Console/Commands/
  - Injects UpdateBadge into ServerRow.tsx
  - Clear status output and next-steps guide

Architecture (Gemini-approved):
  CRON (hourly) → Database cache → Single API endpoint → React badge
  Dashboard badge is 'dumb' - only reads from cache, never external APIs

Completing work started by Chronicler #62 (Pyrrhus).
UpdateBadge.tsx was lost in Blueprint corruption - reconstructed from
handoff notes and architecture documentation.

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

280 lines
9.4 KiB
PHP

<?php
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;
class ModpackAPIController extends Controller
{
public function __construct(
private DaemonFileRepository $fileRepository,
private BlueprintExtensionLibrary $blueprint
) {}
/**
* Manual version check triggered from React frontend
*/
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(),
]);
}
}
/**
* Get an egg variable value for a server
*/
private function getEggVariable(Server $server, string $name): ?string
{
$variable = $server->variables()
->where('env_variable', $name)
->first();
return $variable?->server_value;
}
/**
* Attempt to detect modpack from files
*/
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 server via Wings
*/
private function readServerFile(Server $server, string $path): ?string
{
try {
$this->fileRepository->setServer($server);
return $this->fileRepository->getContent($path);
} catch (\Exception $e) {
return null;
}
}
/**
* Check CurseForge API for latest version
*/
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',
];
}
/**
* Check Modrinth API for latest version
*/
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',
];
}
/**
* Check FTB (modpacks.ch) API for latest version
*/
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',
];
}
/**
* Check Technic API for latest version
*/
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 servers (dashboard badge view)
* Called once on page load, returns status for all user's servers
*/
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);
}
}