feat(modpackchecker): add Blueprint extension Phase 2 - core architecture
Task #26 Phase 2 Complete — Core Architecture Files created: - conf.yml: Blueprint manifest with all paths configured - admin/controller.php: Admin settings controller (BYOK key, webhook, interval) - admin/view.blade.php: Admin UI with Trinity-inspired styling - controllers/ModpackAPIController.php: Client API with all 4 platform integrations - routes/client.php: Client route for manual version checks - views/server/wrapper.tsx: React component for server overview page - database/migrations: Per-server tracking table Platform Support (all implemented): - CurseForge (BYOK API key) - Modrinth (open, no key) - Technic (open, no key) - FTB/modpacks.ch (open, no key) Detection Strategy: 1. Egg Variables (MODPACK_PLATFORM, MODPACK_ID, platform-specific vars) 2. File fingerprinting via DaemonFileRepository (manifest.json, modrinth.index.json) 3. Manual override via admin UI Next: Phase 3 - Testing on Dev Panel (64.50.188.128) Signed-off-by: Claude (Chronicler #62) <claude@firefrostgaming.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Admin\Extensions\modpackchecker;
|
||||
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\View\Factory as ViewFactory;
|
||||
use Pterodactyl\Http\Controllers\Controller;
|
||||
use Pterodactyl\BlueprintFramework\Libraries\ExtensionLibrary\Admin\BlueprintAdminLibrary as BlueprintExtensionLibrary;
|
||||
use Pterodactyl\Http\Requests\Admin\AdminFormRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class modpackcheckerExtensionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ViewFactory $view,
|
||||
private BlueprintExtensionLibrary $blueprint,
|
||||
) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
// Get current settings
|
||||
$curseforge_api_key = $this->blueprint->dbGet('modpackchecker', 'curseforge_api_key');
|
||||
$discord_webhook_url = $this->blueprint->dbGet('modpackchecker', 'discord_webhook_url');
|
||||
$check_interval = $this->blueprint->dbGet('modpackchecker', 'check_interval');
|
||||
$tier = $this->blueprint->dbGet('modpackchecker', 'tier');
|
||||
|
||||
// Set defaults if empty
|
||||
if ($check_interval == '') {
|
||||
$this->blueprint->dbSet('modpackchecker', 'check_interval', 'daily');
|
||||
$check_interval = 'daily';
|
||||
}
|
||||
if ($tier == '') {
|
||||
$this->blueprint->dbSet('modpackchecker', 'tier', 'standard');
|
||||
$tier = 'standard';
|
||||
}
|
||||
|
||||
return $this->view->make(
|
||||
'admin.extensions.modpackchecker.index', [
|
||||
'curseforge_api_key' => $curseforge_api_key,
|
||||
'discord_webhook_url' => $discord_webhook_url,
|
||||
'check_interval' => $check_interval,
|
||||
'tier' => $tier,
|
||||
'root' => '/admin/extensions/modpackchecker',
|
||||
'blueprint' => $this->blueprint,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function update(modpackcheckerSettingsFormRequest $request): RedirectResponse
|
||||
{
|
||||
$this->blueprint->dbSet('modpackchecker', 'curseforge_api_key', $request->input('curseforge_api_key') ?? '');
|
||||
$this->blueprint->dbSet('modpackchecker', 'discord_webhook_url', $request->input('discord_webhook_url') ?? '');
|
||||
$this->blueprint->dbSet('modpackchecker', 'check_interval', $request->input('check_interval') ?? 'daily');
|
||||
|
||||
return redirect()->route('admin.extensions.modpackchecker.index')->with('success', 'Settings saved successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
class modpackcheckerSettingsFormRequest extends AdminFormRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'curseforge_api_key' => 'nullable|string|max:500',
|
||||
'discord_webhook_url' => 'nullable|url|max:500',
|
||||
'check_interval' => 'required|in:daily,12h,6h',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'curseforge_api_key' => 'CurseForge API Key',
|
||||
'discord_webhook_url' => 'Discord Webhook URL',
|
||||
'check_interval' => 'Check Interval',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<form id="config-form" action="" method="POST">
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
showSaveButton();
|
||||
});
|
||||
|
||||
function showSaveButton() {
|
||||
const configForm = document.getElementById("config-form");
|
||||
const saveOverlay = document.getElementById("save-overlay");
|
||||
|
||||
configForm.addEventListener("change", function() {
|
||||
saveOverlay.style.display = "inline";
|
||||
setTimeout(() => {
|
||||
saveOverlay.style.bottom = "10px";
|
||||
}, 100);
|
||||
});
|
||||
|
||||
configForm.addEventListener("input", function() {
|
||||
saveOverlay.style.display = "inline";
|
||||
setTimeout(() => {
|
||||
saveOverlay.style.bottom = "10px";
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Save button overlay -->
|
||||
<div id="save-overlay">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" name="_method" value="PATCH" class="btn btn-primary btn-sm">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
<style>
|
||||
#save-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
transition: bottom 0.3s;
|
||||
bottom: -200px;
|
||||
right: 20px;
|
||||
z-index: 500;
|
||||
padding: 15px;
|
||||
background: #1a1a2e;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="row" style="margin-bottom: 20px;">
|
||||
<div class="col-xs-12">
|
||||
<div style="display: flex; align-items: center; gap: 15px;">
|
||||
<div style="width: 50px; height: 50px; background: linear-gradient(135deg, #FF6B35, #4ECDC4); border-radius: 10px; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="fa fa-cube" style="font-size: 24px; color: white;"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 style="margin: 0; color: #fff;">ModpackChecker</h2>
|
||||
<p style="margin: 0; color: #888;">4-Platform Modpack Version Monitoring</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- CurseForge API Key -->
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">
|
||||
<i class="fa fa-key"></i> CurseForge API Key
|
||||
</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="form-group">
|
||||
<label class="control-label">API Key (BYOK)</label>
|
||||
<input
|
||||
type="password"
|
||||
name="curseforge_api_key"
|
||||
id="curseforge_api_key"
|
||||
value="{{ $curseforge_api_key }}"
|
||||
placeholder="$2a$10$..."
|
||||
class="form-control"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="text-muted small" style="margin-top: 8px;">
|
||||
Get your free API key from
|
||||
<a href="https://console.curseforge.com/" target="_blank">console.curseforge.com</a>.
|
||||
Required for CurseForge modpack detection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Check Interval -->
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<div class="box box-info">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">
|
||||
<i class="fa fa-clock-o"></i> Check Interval
|
||||
</h3>
|
||||
<span class="label label-warning" style="margin-left: 10px;">Professional</span>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="form-group">
|
||||
<label class="control-label">Automatic Check Frequency</label>
|
||||
<select class="form-control" name="check_interval" id="check_interval">
|
||||
<option value="daily" @if($check_interval == 'daily') selected @endif>
|
||||
Daily (Recommended)
|
||||
</option>
|
||||
<option value="12h" @if($check_interval == '12h') selected @endif>
|
||||
Every 12 Hours
|
||||
</option>
|
||||
<option value="6h" @if($check_interval == '6h') selected @endif>
|
||||
Every 6 Hours
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted small" style="margin-top: 8px;">
|
||||
How often to automatically check for modpack updates.
|
||||
More frequent checks use more API quota.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Discord Webhook -->
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<div class="box box-success">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">
|
||||
<i class="fa fa-bell"></i> Discord Notifications
|
||||
</h3>
|
||||
<span class="label label-warning" style="margin-left: 10px;">Professional</span>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="form-group">
|
||||
<label class="control-label">Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
name="discord_webhook_url"
|
||||
id="discord_webhook_url"
|
||||
value="{{ $discord_webhook_url }}"
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
class="form-control"
|
||||
/>
|
||||
<p class="text-muted small" style="margin-top: 8px;">
|
||||
Receive alerts when modpack updates are available.
|
||||
Create a webhook in your Discord server settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Supported Platforms -->
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<div class="box box-default">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">
|
||||
<i class="fa fa-check-circle"></i> Supported Platforms
|
||||
</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
<li style="padding: 8px 0; border-bottom: 1px solid #333;">
|
||||
<i class="fa fa-fire" style="color: #f16436; width: 20px;"></i>
|
||||
<strong>CurseForge</strong>
|
||||
<span class="text-muted small">(Requires API Key)</span>
|
||||
</li>
|
||||
<li style="padding: 8px 0; border-bottom: 1px solid #333;">
|
||||
<i class="fa fa-leaf" style="color: #1bd96a; width: 20px;"></i>
|
||||
<strong>Modrinth</strong>
|
||||
<span class="text-muted small">(No key required)</span>
|
||||
</li>
|
||||
<li style="padding: 8px 0; border-bottom: 1px solid #333;">
|
||||
<i class="fa fa-cogs" style="color: #4a90d9; width: 20px;"></i>
|
||||
<strong>Technic</strong>
|
||||
<span class="text-muted small">(No key required)</span>
|
||||
</li>
|
||||
<li style="padding: 8px 0;">
|
||||
<i class="fa fa-cube" style="color: #e04e39; width: 20px;"></i>
|
||||
<strong>FTB (modpacks.ch)</strong>
|
||||
<span class="text-muted small">(No key required)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Info -->
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div class="callout callout-info">
|
||||
<h4><i class="fa fa-info-circle"></i> How It Works</h4>
|
||||
<p style="margin-bottom: 0;">
|
||||
ModpackChecker automatically detects modpacks via Egg Variables or file fingerprinting.
|
||||
Set <code>MODPACK_PLATFORM</code> and <code>MODPACK_ID</code> in your server's startup variables
|
||||
for the most reliable detection, or let the extension scan for <code>manifest.json</code> /
|
||||
<code>modrinth.index.json</code> files.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,37 @@
|
||||
info:
|
||||
name: "ModpackChecker"
|
||||
identifier: "modpackchecker"
|
||||
description: "4-platform modpack version checker - supports CurseForge, Modrinth, Technic, and FTB"
|
||||
flags: ""
|
||||
version: "1.0.0"
|
||||
target: "beta-2026-01"
|
||||
author: "Firefrost Gaming <dev@firefrostgaming.com>"
|
||||
icon: ""
|
||||
website: "https://firefrostgaming.com"
|
||||
|
||||
admin:
|
||||
view: "admin/view.blade.php"
|
||||
controller: "admin/controller.php"
|
||||
css: ""
|
||||
wrapper: ""
|
||||
|
||||
dashboard:
|
||||
css: ""
|
||||
wrapper: "views/server/wrapper.tsx"
|
||||
components: ""
|
||||
|
||||
data:
|
||||
directory: "modpackchecker"
|
||||
public: ""
|
||||
console: ""
|
||||
|
||||
requests:
|
||||
views: "views"
|
||||
app: ""
|
||||
routers:
|
||||
application: ""
|
||||
client: "routes/client.php"
|
||||
web: ""
|
||||
|
||||
database:
|
||||
migrations: "database/migrations"
|
||||
@@ -0,0 +1,315 @@
|
||||
<?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;
|
||||
|
||||
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)) {
|
||||
if ($curseforgeId = $this->getEggVariable($server, 'CURSEFORGE_ID')) {
|
||||
$platform = 'curseforge';
|
||||
$modpackId = $curseforgeId;
|
||||
} elseif ($modrinthId = $this->getEggVariable($server, 'MODRINTH_PROJECT_ID')) {
|
||||
$platform = 'modrinth';
|
||||
$modpackId = $modrinthId;
|
||||
} elseif ($ftbId = $this->getEggVariable($server, 'FTB_MODPACK_ID')) {
|
||||
$platform = 'ftb';
|
||||
$modpackId = $ftbId;
|
||||
} elseif ($technicSlug = $this->getEggVariable($server, 'TECHNIC_SLUG')) {
|
||||
$platform = 'technic';
|
||||
$modpackId = $technicSlug;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to file fingerprinting if not set
|
||||
if (empty($platform) || empty($modpackId)) {
|
||||
$detected = $this->detectFromFiles($server);
|
||||
if (!empty($detected['platform'])) {
|
||||
$platform = $detected['platform'];
|
||||
$modpackId = $detected['id'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If still nothing, return unknown
|
||||
if (empty($platform) || empty($modpackId)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'status' => 'unknown',
|
||||
'message' => 'Could not detect modpack. Set MODPACK_PLATFORM and MODPACK_ID in startup variables.',
|
||||
]);
|
||||
}
|
||||
|
||||
// 4. Query the appropriate API
|
||||
$versionData = $this->checkVersion($platform, $modpackId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'server_uuid' => $server->uuid,
|
||||
'platform' => $platform,
|
||||
'modpack_id' => $modpackId,
|
||||
'modpack_name' => $versionData['name'] ?? 'Unknown',
|
||||
'current_version' => $versionData['current'] ?? 'Unknown',
|
||||
'latest_version' => $versionData['latest'] ?? 'Unknown',
|
||||
'status' => $versionData['status'] ?? 'unknown',
|
||||
'error' => $versionData['error'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an egg variable value for a server
|
||||
*/
|
||||
private function getEggVariable(Server $server, string $name): ?string
|
||||
{
|
||||
$variable = $server->variables()
|
||||
->whereHas('variable', function ($query) use ($name) {
|
||||
$query->where('env_variable', $name);
|
||||
})
|
||||
->first();
|
||||
|
||||
return $variable?->variable_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to detect modpack from files
|
||||
*/
|
||||
private function detectFromFiles(Server $server): array
|
||||
{
|
||||
// Try CurseForge manifest.json
|
||||
try {
|
||||
$content = $this->fileRepository->setServer($server)->getContent('/manifest.json');
|
||||
$json = json_decode($content, true);
|
||||
if (isset($json['projectID'])) {
|
||||
return [
|
||||
'platform' => 'curseforge',
|
||||
'id' => (string) $json['projectID'],
|
||||
];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// File doesn't exist or Wings unreachable
|
||||
}
|
||||
|
||||
// Try Modrinth modrinth.index.json
|
||||
try {
|
||||
$content = $this->fileRepository->setServer($server)->getContent('/modrinth.index.json');
|
||||
$json = json_decode($content, true);
|
||||
if (isset($json['name'])) {
|
||||
// Modrinth index doesn't contain project ID directly
|
||||
// We detect it's Modrinth but need manual ID
|
||||
return [
|
||||
'platform' => 'modrinth',
|
||||
'id' => null,
|
||||
'name' => $json['name'] ?? null,
|
||||
];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check version against platform API
|
||||
*/
|
||||
private function checkVersion(string $platform, string $modpackId): array
|
||||
{
|
||||
return match ($platform) {
|
||||
'curseforge' => $this->checkCurseForge($modpackId),
|
||||
'modrinth' => $this->checkModrinth($modpackId),
|
||||
'technic' => $this->checkTechnic($modpackId),
|
||||
'ftb' => $this->checkFTB($modpackId),
|
||||
default => ['status' => 'error', 'error' => 'Unknown platform: ' . $platform],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check CurseForge API
|
||||
*/
|
||||
private function checkCurseForge(string $modpackId): array
|
||||
{
|
||||
$apiKey = $this->blueprint->dbGet('modpackchecker', 'curseforge_api_key');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'error' => 'CurseForge API key not configured. Add it in Admin > Extensions > ModpackChecker.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'x-api-key' => $apiKey,
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(10)->get("https://api.curseforge.com/v1/mods/{$modpackId}");
|
||||
|
||||
if ($response->status() === 401 || $response->status() === 403) {
|
||||
return ['status' => 'error', 'error' => 'Invalid CurseForge API key.'];
|
||||
}
|
||||
|
||||
if ($response->status() === 404) {
|
||||
return ['status' => 'error', 'error' => 'Modpack not found or delisted.'];
|
||||
}
|
||||
|
||||
if (!$response->successful()) {
|
||||
return ['status' => 'error', 'error' => 'CurseForge API error: ' . $response->status()];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$name = $data['data']['name'] ?? 'Unknown';
|
||||
$latestFile = $data['data']['latestFiles'][0] ?? null;
|
||||
$latestVersion = $latestFile['displayName'] ?? 'Unknown';
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'latest' => $latestVersion,
|
||||
'status' => 'up_to_date', // Can't determine current without server-side tracking
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return ['status' => 'error', 'error' => 'Failed to connect to CurseForge: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Modrinth API
|
||||
*/
|
||||
private function checkModrinth(string $modpackId): array
|
||||
{
|
||||
try {
|
||||
// First get project info
|
||||
$projectResponse = Http::withHeaders([
|
||||
'User-Agent' => 'ModpackChecker/1.0.0 (firefrostgaming.com)',
|
||||
])->timeout(10)->get("https://api.modrinth.com/v2/project/{$modpackId}");
|
||||
|
||||
if ($projectResponse->status() === 404) {
|
||||
return ['status' => 'error', 'error' => 'Modpack not found on Modrinth.'];
|
||||
}
|
||||
|
||||
if (!$projectResponse->successful()) {
|
||||
return ['status' => 'error', 'error' => 'Modrinth API error: ' . $projectResponse->status()];
|
||||
}
|
||||
|
||||
$projectData = $projectResponse->json();
|
||||
$name = $projectData['title'] ?? 'Unknown';
|
||||
|
||||
// Get versions
|
||||
$versionResponse = Http::withHeaders([
|
||||
'User-Agent' => 'ModpackChecker/1.0.0 (firefrostgaming.com)',
|
||||
])->timeout(10)->get("https://api.modrinth.com/v2/project/{$modpackId}/version");
|
||||
|
||||
if (!$versionResponse->successful()) {
|
||||
return [
|
||||
'name' => $name,
|
||||
'latest' => 'Unknown',
|
||||
'status' => 'error',
|
||||
'error' => 'Could not fetch versions.',
|
||||
];
|
||||
}
|
||||
|
||||
$versions = $versionResponse->json();
|
||||
$latestVersion = $versions[0]['version_number'] ?? 'Unknown';
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'latest' => $latestVersion,
|
||||
'status' => 'up_to_date',
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return ['status' => 'error', 'error' => 'Failed to connect to Modrinth: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Technic API
|
||||
*/
|
||||
private function checkTechnic(string $slug): array
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->get("https://api.technicpack.net/modpack/{$slug}?build=1");
|
||||
|
||||
if ($response->status() === 404) {
|
||||
return ['status' => 'error', 'error' => 'Modpack not found on Technic.'];
|
||||
}
|
||||
|
||||
if (!$response->successful()) {
|
||||
return ['status' => 'error', 'error' => 'Technic API error: ' . $response->status()];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (isset($data['error'])) {
|
||||
return ['status' => 'error', 'error' => $data['error']];
|
||||
}
|
||||
|
||||
$name = $data['displayName'] ?? $data['name'] ?? 'Unknown';
|
||||
$latestVersion = $data['version'] ?? 'Unknown';
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'latest' => $latestVersion,
|
||||
'status' => 'up_to_date',
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return ['status' => 'error', 'error' => 'Failed to connect to Technic: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check FTB (modpacks.ch) API
|
||||
*/
|
||||
private function checkFTB(string $modpackId): array
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->get("https://api.modpacks.ch/public/modpack/{$modpackId}");
|
||||
|
||||
if ($response->status() === 404) {
|
||||
return ['status' => 'error', 'error' => 'Modpack not found on FTB.'];
|
||||
}
|
||||
|
||||
if (!$response->successful()) {
|
||||
return ['status' => 'error', 'error' => 'FTB API error: ' . $response->status()];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (isset($data['status']) && $data['status'] === 'error') {
|
||||
return ['status' => 'error', 'error' => $data['message'] ?? 'Unknown FTB error'];
|
||||
}
|
||||
|
||||
$name = $data['name'] ?? 'Unknown';
|
||||
$versions = $data['versions'] ?? [];
|
||||
$latestVersion = !empty($versions) ? ($versions[0]['name'] ?? 'Unknown') : 'Unknown';
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'latest' => $latestVersion,
|
||||
'status' => 'up_to_date',
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return ['status' => 'error', 'error' => 'Failed to connect to FTB: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Note: Blueprint extensions use the blueprint->dbGet/dbSet methods
|
||||
// which store data in the existing settings table.
|
||||
// This migration creates a table for per-server modpack tracking.
|
||||
|
||||
Schema::create('modpackchecker_servers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('server_uuid')->unique();
|
||||
$table->string('platform')->nullable(); // curseforge, modrinth, technic, ftb
|
||||
$table->string('modpack_id')->nullable();
|
||||
$table->string('modpack_name')->nullable();
|
||||
$table->string('current_version')->nullable();
|
||||
$table->string('latest_version')->nullable();
|
||||
$table->enum('status', ['up_to_date', 'update_available', 'error', 'unknown'])->default('unknown');
|
||||
$table->timestamp('last_checked')->nullable();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
// Index for efficient lookups
|
||||
$table->index('status');
|
||||
$table->index('last_checked');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('modpackchecker_servers');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Pterodactyl\BlueprintFramework\Extensions\modpackchecker\controllers\ModpackAPIController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ModpackChecker Client Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These routes are automatically wrapped in Pterodactyl's client auth
|
||||
| middleware. The {server} parameter is injected automatically.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::post('/servers/{server}/ext/modpackchecker/check', [ModpackAPIController::class, 'manualCheck']);
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import http from '@/api/http';
|
||||
|
||||
interface VersionData {
|
||||
success: boolean;
|
||||
platform?: string;
|
||||
modpack_id?: string;
|
||||
modpack_name?: string;
|
||||
current_version?: string;
|
||||
latest_version?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ModpackVersionCard: React.FC = () => {
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [data, setData] = useState<VersionData | null>(null);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
if (!uuid) return;
|
||||
|
||||
setStatus('loading');
|
||||
try {
|
||||
const response = await http.post(`/api/client/servers/${uuid}/ext/modpackchecker/check`);
|
||||
setData(response.data);
|
||||
setStatus(response.data.success ? 'success' : 'error');
|
||||
} catch (error: any) {
|
||||
setData({
|
||||
success: false,
|
||||
error: error.response?.data?.message || 'Failed to check for updates',
|
||||
});
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platform?: string) => {
|
||||
switch (platform) {
|
||||
case 'curseforge':
|
||||
return '🔥';
|
||||
case 'modrinth':
|
||||
return '🌿';
|
||||
case 'technic':
|
||||
return '⚙️';
|
||||
case 'ftb':
|
||||
return '📦';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'up_to_date':
|
||||
return '#4ECDC4'; // Frost
|
||||
case 'update_available':
|
||||
return '#FF6B35'; // Fire
|
||||
case 'error':
|
||||
return '#ef4444';
|
||||
default:
|
||||
return '#888';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(30, 30, 46, 0.8)',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '16px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600, color: '#fff' }}>
|
||||
Modpack Version
|
||||
</h3>
|
||||
{status === 'idle' && (
|
||||
<button
|
||||
onClick={checkForUpdates}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #FF6B35, #4ECDC4)',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Check for Updates
|
||||
</button>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<button
|
||||
onClick={checkForUpdates}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
color: '#888',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === 'loading' && (
|
||||
<div style={{ color: '#888', fontSize: '13px' }}>
|
||||
<span style={{ display: 'inline-block', animation: 'pulse 1.5s infinite' }}>
|
||||
Checking version...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && data?.success && (
|
||||
<div style={{ fontSize: '13px' }}>
|
||||
<div style={{ marginBottom: '8px', color: '#ccc' }}>
|
||||
<span style={{ marginRight: '8px' }}>{getPlatformIcon(data.platform)}</span>
|
||||
<strong style={{ color: '#fff' }}>{data.modpack_name}</strong>
|
||||
<span style={{ color: '#666', marginLeft: '8px', textTransform: 'capitalize' }}>
|
||||
({data.platform})
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '16px', color: '#888' }}>
|
||||
<div>
|
||||
<span style={{ color: '#666' }}>Latest: </span>
|
||||
<span style={{ color: getStatusColor(data.status) }}>{data.latest_version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === 'error' || (status === 'success' && !data?.success)) && (
|
||||
<div style={{ color: '#ef4444', fontSize: '13px' }}>
|
||||
{data?.error || data?.message || 'Unknown error'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModpackVersionCard;
|
||||
Reference in New Issue
Block a user