Files
firefrost-services/services/modpack-version-checker/blueprint-extension/app/Services/LicenseService.php
Claude (Chronicler #83 - The Compiler) fa5fb364c1 Fix: normalize order IDs to uppercase across all MVC endpoints
- LicenseService.php: strtoupper(trim()) before sending to Arbiter
- mvc.js: toUpperCase().trim() on activate, validate, deactivate, webhook
- verifymvc.js: toUpperCase().trim() on /verify-mvc Discord command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:25:35 -05:00

197 lines
7.4 KiB
PHP

<?php
/**
* MVC License Service — handles activation, validation, and grace period.
*
* Talks to Arbiter API at /api/mvc/* endpoints.
* Stores license state in Blueprint dbGet/dbSet.
*
* Keys stored:
* modpackchecker.order_id — BuiltByBit order ID
* modpackchecker.license_status — active|grace|expired|inactive
* modpackchecker.tier — standard|professional
* modpackchecker.grace_expires — ISO timestamp (set on first validation failure)
* modpackchecker.last_validated — ISO timestamp of last successful validation
* modpackchecker.latest_version — latest version from Arbiter
*/
namespace Pterodactyl\Services;
use Pterodactyl\BlueprintFramework\Libraries\ExtensionLibrary\Admin\BlueprintAdminLibrary as BlueprintExtensionLibrary;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LicenseService
{
private string $arbiterBase = 'https://discord-bot.firefrostgaming.com/api/mvc';
private int $graceDays = 7;
public function __construct(
private BlueprintExtensionLibrary $blueprint
) {}
/**
* Activate a license by order ID.
* Called from admin UI "Save & Activate" button.
*/
public function activate(string $orderId): array
{
$orderId = strtoupper(trim($orderId));
$domain = config('app.url');
try {
$response = Http::timeout(15)->post("{$this->arbiterBase}/activate", [
'order_id' => $orderId,
'domain' => $domain,
'ip' => request()->server('SERVER_ADDR', ''),
]);
$data = $response->json();
if ($response->successful() && ($data['status'] ?? '') === 'active') {
$this->blueprint->dbSet('modpackchecker', 'order_id', $orderId);
$this->blueprint->dbSet('modpackchecker', 'license_status', 'active');
$this->blueprint->dbSet('modpackchecker', 'tier', $data['tier'] ?? 'standard');
$this->blueprint->dbSet('modpackchecker', 'last_validated', now()->toISOString());
$this->blueprint->dbSet('modpackchecker', 'grace_expires', '');
return ['success' => true, 'tier' => $data['tier'] ?? 'standard'];
}
return ['success' => false, 'error' => $data['error'] ?? 'Activation failed'];
} catch (\Exception $e) {
Log::error('[MVC License] Activation error: ' . $e->getMessage());
return ['success' => false, 'error' => 'Could not reach license server. Try again later.'];
}
}
/**
* Validate (phone-home). Called daily by mvc:validate Artisan command.
* On failure, enters grace period. After 7 days, marks expired.
*/
public function validate(): array
{
$orderId = $this->blueprint->dbGet('modpackchecker', 'order_id');
if (empty($orderId)) {
return ['status' => 'inactive', 'message' => 'No license configured'];
}
$domain = config('app.url');
try {
$response = Http::timeout(15)->post("{$this->arbiterBase}/validate", [
'order_id' => $orderId,
'domain' => $domain,
'version' => config('app.version', '1.0.0'),
'php_version' => PHP_VERSION,
]);
$data = $response->json();
if ($response->successful() && ($data['status'] ?? '') === 'active') {
$this->blueprint->dbSet('modpackchecker', 'license_status', 'active');
$this->blueprint->dbSet('modpackchecker', 'tier', $data['tier'] ?? 'standard');
$this->blueprint->dbSet('modpackchecker', 'last_validated', now()->toISOString());
$this->blueprint->dbSet('modpackchecker', 'grace_expires', '');
if (!empty($data['latest_version'])) {
$this->blueprint->dbSet('modpackchecker', 'latest_version', $data['latest_version']);
}
return ['status' => 'active', 'tier' => $data['tier'] ?? 'standard'];
}
return $this->enterGracePeriod($data['error'] ?? 'Validation failed');
} catch (\Exception $e) {
Log::warning('[MVC License] Validation failed: ' . $e->getMessage());
return $this->enterGracePeriod('Could not reach license server');
}
}
/**
* Enter or continue grace period. After 7 days, expire.
*/
private function enterGracePeriod(string $reason): array
{
$graceExpires = $this->blueprint->dbGet('modpackchecker', 'grace_expires');
if (empty($graceExpires)) {
// First failure — start grace period
$expires = now()->addDays($this->graceDays)->toISOString();
$this->blueprint->dbSet('modpackchecker', 'license_status', 'grace');
$this->blueprint->dbSet('modpackchecker', 'grace_expires', $expires);
Log::warning("[MVC License] Grace period started. Expires: {$expires}. Reason: {$reason}");
return ['status' => 'grace', 'expires' => $expires, 'reason' => $reason];
}
// Check if grace period expired
if (now()->greaterThan($graceExpires)) {
$this->blueprint->dbSet('modpackchecker', 'license_status', 'expired');
Log::error('[MVC License] Grace period expired. License marked as expired.');
return ['status' => 'expired', 'reason' => $reason];
}
return ['status' => 'grace', 'expires' => $graceExpires, 'reason' => $reason];
}
/**
* Deactivate license on this panel.
*/
public function deactivate(): array
{
$orderId = $this->blueprint->dbGet('modpackchecker', 'order_id');
if (empty($orderId)) {
return ['success' => true];
}
try {
Http::timeout(15)->post("{$this->arbiterBase}/deactivate", [
'order_id' => $orderId,
'domain' => config('app.url'),
]);
} catch (\Exception $e) {
// Best-effort — clear local state regardless
}
$this->blueprint->dbSet('modpackchecker', 'order_id', '');
$this->blueprint->dbSet('modpackchecker', 'license_status', 'inactive');
$this->blueprint->dbSet('modpackchecker', 'tier', 'standard');
$this->blueprint->dbSet('modpackchecker', 'grace_expires', '');
return ['success' => true];
}
/**
* Check if a pro feature is allowed based on current tier + license status.
*/
public function isProFeatureAllowed(): bool
{
$status = $this->blueprint->dbGet('modpackchecker', 'license_status');
$tier = $this->blueprint->dbGet('modpackchecker', 'tier');
if ($status === 'expired') {
return false;
}
return $tier === 'professional';
}
/**
* Get full license state for the admin UI.
*/
public function getState(): array
{
return [
'order_id' => $this->blueprint->dbGet('modpackchecker', 'order_id') ?: '',
'status' => $this->blueprint->dbGet('modpackchecker', 'license_status') ?: 'inactive',
'tier' => $this->blueprint->dbGet('modpackchecker', 'tier') ?: 'standard',
'grace_expires' => $this->blueprint->dbGet('modpackchecker', 'grace_expires') ?: '',
'last_validated' => $this->blueprint->dbGet('modpackchecker', 'last_validated') ?: '',
'latest_version' => $this->blueprint->dbGet('modpackchecker', 'latest_version') ?: '',
];
}
}