File transfer

This commit is contained in:
2026-06-11 20:33:44 -05:00
parent 2cb1d58264
commit b362357bcb
58 changed files with 5096 additions and 4 deletions

View File

@@ -0,0 +1,188 @@
<?php
/**
* Autocomplete Engine — resolves command suggestions from stored definitions + player cache.
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Services\Console;
use Illuminate\Support\Facades\Cache;
use Pterodactyl\Addons\AdvancedAdmin\Models\CommandDefinition;
use Pterodactyl\Addons\AdvancedAdmin\Models\ConsolePlayerCache;
class AutocompleteEngine
{
/**
* Generate suggestions for a given query on a server.
*
* @return array<int, array{command: string, description: string, syntax: string, dangerous: bool, plugin: string}>
*/
public function suggest(int $serverId, ?int $nodeId, ?int $eggId, string $query): array
{
$query = $this->sanitize($query);
if (strlen($query) < 1) return [];
$definitions = $this->loadDefinitions($serverId, $nodeId, $eggId);
$players = $this->getOnlinePlayers($serverId);
// Determine if we're suggesting a command name or an argument
$parts = explode(' ', $query);
// No space yet → suggest command names
if (count($parts) === 1) {
return $this->suggestCommands($query, $definitions);
}
// Space present → suggest arguments for the matched command
$commandName = strtolower($parts[0]);
return $this->suggestArguments($commandName, $parts, $definitions, $players);
}
/**
* Sanitize query — prevent XSS/injection in rendered suggestions.
*/
private function sanitize(string $query): string
{
// Strip null bytes, control chars, HTML
$query = preg_replace('/[\x00-\x1F\x7F]/', '', $query);
$query = strip_tags($query);
return mb_substr(trim($query), 0, 256);
}
private function suggestCommands(string $prefix, array $definitions): array
{
$prefix = strtolower($prefix);
$results = [];
$max = config('advanced-admin.console.max_suggestions', 10);
foreach ($definitions as $def) {
$names = array_merge([$def['name']], $def['aliases'] ?? []);
foreach ($names as $name) {
if (str_starts_with(strtolower($name), $prefix)) {
$results[] = $this->formatSuggestion($def, $name);
if (count($results) >= $max) break 2;
}
}
}
return $results;
}
private function suggestArguments(string $cmd, array $parts, array $definitions, array $players): array
{
$definition = null;
foreach ($definitions as $def) {
$names = array_merge([$def['name']], $def['aliases'] ?? []);
if (in_array($cmd, array_map('strtolower', $names), true)) {
$definition = $def;
break;
}
}
if (!$definition) return [];
$argIndex = count($parts) - 2; // 0-based index of current argument
$args = $definition['args'] ?? [];
if (!isset($args[$argIndex])) return [];
$argDef = $args[$argIndex];
$partial = strtolower(end($parts));
$max = config('advanced-admin.console.max_suggestions', 10);
$results = [];
if ($argDef['type'] === 'player') {
foreach ($players as $player) {
if (str_starts_with(strtolower($player), $partial)) {
$results[] = ['command' => $player, 'description' => 'Online player', 'syntax' => '', 'dangerous' => false, 'plugin' => 'server'];
if (count($results) >= $max) break;
}
}
} elseif (!empty($argDef['suggestions'])) {
foreach ($argDef['suggestions'] as $s) {
if (str_starts_with(strtolower($s), $partial)) {
$results[] = ['command' => $s, 'description' => $argDef['name'] ?? '', 'syntax' => '', 'dangerous' => false, 'plugin' => ''];
if (count($results) >= $max) break;
}
}
}
// Return parent command suggestion with full syntax
if (empty($results)) {
$results[] = $this->formatSuggestion($definition, $definition['name']);
}
return $results;
}
private function formatSuggestion(array $def, string $displayName): array
{
$syntax = '/' . $displayName;
foreach ($def['args'] ?? [] as $arg) {
$syntax .= $arg['required'] ? " <{$arg['name']}>" : " [{$arg['name']}]";
}
return [
'command' => $displayName,
'description' => $def['description'] ?? '',
'syntax' => $syntax,
'dangerous' => $def['dangerous'] ?? false,
'danger_reason' => $def['dangerReason'] ?? null,
'plugin' => $def['plugin'] ?? 'vanilla',
'examples' => $def['examples'] ?? [],
];
}
/**
* Load command definitions for this server (server-specific → node → egg → global).
* Cached for 60 seconds per server.
*/
private function loadDefinitions(int $serverId, ?int $nodeId, ?int $eggId): array
{
$cacheKey = "addon_commands_{$serverId}_{$nodeId}_{$eggId}";
return Cache::remember($cacheKey, 60, function () use ($serverId, $nodeId, $eggId) {
// DB custom definitions
$dbDefs = CommandDefinition::query()
->where('is_active', true)
->where(function ($q) use ($serverId, $nodeId, $eggId) {
$q->whereNull('server_id')
->orWhere('server_id', $serverId);
})
->get()
->map(fn ($d) => $d->definition)
->toArray();
// Built-in JSON definitions from config/addon-commands/
$builtIn = $this->loadBuiltInDefinitions();
return array_merge($builtIn, $dbDefs);
});
}
private function loadBuiltInDefinitions(): array
{
$path = config('advanced-admin.console.definitions_path', base_path('config/addon-commands'));
$files = glob($path . '/*.json') ?: [];
$defs = [];
foreach ($files as $file) {
$raw = file_get_contents($file);
if (!$raw) continue;
$decoded = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) continue;
$defs = array_merge($defs, is_array($decoded[0] ?? null) ? $decoded : [$decoded]);
}
return $defs;
}
private function getOnlinePlayers(int $serverId): array
{
return ConsolePlayerCache::query()
->where('server_id', $serverId)
->where('last_seen_at', '>=', now()->subMinutes(
config('advanced-admin.console.player_cache_ttl_minutes', 30)
))
->pluck('player_name')
->toArray();
}
}

View File

@@ -0,0 +1,83 @@
<?php
/**
* Docker Stats Collector — queries Wings REST API for per-server network stats.
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Services\Network;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Models\Node;
use Pterodactyl\Models\Server;
class DockerStatsCollector
{
/**
* Collect network stats for all running servers on a node.
* Returns array of ['server_id' => int, 'rx_bytes' => int, 'tx_bytes' => int]
*/
public function collectForNode(Node $node): array
{
$results = [];
$servers = Server::query()
->where('node_id', $node->id)
->whereNotNull('uuid')
->get();
foreach ($servers as $server) {
try {
$stats = $this->fetchStats($node, $server->uuid);
if ($stats !== null) {
$results[] = [
'server_id' => $server->id,
'node_id' => $node->id,
'rx_bytes' => $stats['network']['rx_bytes'] ?? 0,
'tx_bytes' => $stats['network']['tx_bytes'] ?? 0,
];
}
} catch (\Throwable $e) {
Log::warning("[AdvancedAdmin] Failed to collect stats for server {$server->uuid}: {$e->getMessage()}");
}
}
return $results;
}
/**
* Fetch stats from Wings for a single server UUID.
* Uses the Wings Application API (node-level auth token).
*
* SSRF Prevention: URL is constructed only from trusted Node model data,
* never from user input.
*/
private function fetchStats(Node $node, string $serverUuid): ?array
{
// Validate UUID format before use
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $serverUuid)) {
return null;
}
$scheme = $node->scheme;
$fqdn = $node->fqdn;
$port = $node->daemonListen;
// Allowlist: only known node FQDNs are used (from DB, not from user input)
$url = "{$scheme}://{$fqdn}:{$port}/api/servers/{$serverUuid}/resources";
$response = Http::withToken($node->daemon_token_id)
->withHeaders(['Authorization' => "Bearer {$node->daemon_token_id}"])
->timeout(5)
->get($url);
if (!$response->successful()) {
return null;
}
return $response->json('resources');
}
}

View File

@@ -0,0 +1,116 @@
<?php
/**
* Diff Service — generates unified diffs between two text contents.
*
* Uses SebastianBergmann\Diff if available, falls back to simple line diff.
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Services\Revisions;
class DiffService
{
/**
* Generate a unified diff between old and new content.
* Returns array of lines with type: 'context' | 'added' | 'removed' | 'header'
*/
public function diff(string $oldContent, string $newContent, string $fileName = 'file'): array
{
if (class_exists(\SebastianBergmann\Diff\Differ::class)) {
return $this->diffViaSebastian($oldContent, $newContent, $fileName);
}
return $this->diffNative($oldContent, $newContent);
}
/**
* Compute a structured diff using SebastianBergmann\Diff.
*/
private function diffViaSebastian(string $old, string $new, string $fileName): array
{
$differ = new \SebastianBergmann\Diff\Differ(
new \SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder("--- a/{$fileName}\n+++ b/{$fileName}\n")
);
$rawDiff = $differ->diff($old, $new);
return $this->parseUnifiedDiff($rawDiff);
}
/**
* Simple pure-PHP line-level diff (fallback).
*/
private function diffNative(string $old, string $new): array
{
$oldLines = explode("\n", $old);
$newLines = explode("\n", $new);
$result = [];
// LCS-based diff
$lcs = $this->lcs($oldLines, $newLines);
$i = 0; $j = 0; $k = 0;
while ($i < count($oldLines) || $j < count($newLines)) {
if ($k < count($lcs) && $i < count($oldLines) && $oldLines[$i] === $lcs[$k] && $j < count($newLines) && $newLines[$j] === $lcs[$k]) {
$result[] = ['type' => 'context', 'content' => $oldLines[$i], 'line_old' => $i + 1, 'line_new' => $j + 1];
$i++; $j++; $k++;
} elseif ($j < count($newLines) && ($k >= count($lcs) || $newLines[$j] !== $lcs[$k])) {
$result[] = ['type' => 'added', 'content' => $newLines[$j], 'line_new' => $j + 1];
$j++;
} else {
$result[] = ['type' => 'removed', 'content' => $oldLines[$i], 'line_old' => $i + 1];
$i++;
}
}
return $result;
}
private function parseUnifiedDiff(string $diff): array
{
$lines = explode("\n", $diff);
$result = [];
$oldLine = 0; $newLine = 0;
foreach ($lines as $line) {
if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
$result[] = ['type' => 'header', 'content' => $line];
} elseif (str_starts_with($line, '@@')) {
// Parse @@ -old +new @@
preg_match('/@@ -(\d+).*\+(\d+)/', $line, $m);
$oldLine = (int) ($m[1] ?? 0);
$newLine = (int) ($m[2] ?? 0);
$result[] = ['type' => 'header', 'content' => $line];
} elseif (str_starts_with($line, '-')) {
$result[] = ['type' => 'removed', 'content' => substr($line, 1), 'line_old' => $oldLine++];
} elseif (str_starts_with($line, '+')) {
$result[] = ['type' => 'added', 'content' => substr($line, 1), 'line_new' => $newLine++];
} elseif (str_starts_with($line, ' ')) {
$result[] = ['type' => 'context', 'content' => substr($line, 1), 'line_old' => $oldLine++, 'line_new' => $newLine++];
}
}
return $result;
}
/**
* Longest Common Subsequence (for native diff).
*/
private function lcs(array $a, array $b): array
{
$m = count($a); $n = count($b);
$dp = array_fill(0, $m + 1, array_fill(0, $n + 1, 0));
for ($i = 1; $i <= $m; $i++) {
for ($j = 1; $j <= $n; $j++) {
$dp[$i][$j] = $a[$i-1] === $b[$j-1]
? $dp[$i-1][$j-1] + 1
: max($dp[$i-1][$j], $dp[$i][$j-1]);
}
}
$result = []; $i = $m; $j = $n;
while ($i > 0 && $j > 0) {
if ($a[$i-1] === $b[$j-1]) { array_unshift($result, $a[$i-1]); $i--; $j--; }
elseif ($dp[$i-1][$j] > $dp[$i][$j-1]) { $i--; }
else { $j--; }
}
return $result;
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* Exclusion Matcher — determines if a file path matches any exclusion glob pattern.
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Services\Revisions;
class ExclusionMatcher
{
/** @var string[] */
private array $patterns;
public function __construct()
{
$this->patterns = config('advanced-admin.revisions.excluded_patterns', []);
}
/**
* Returns true if the file should be excluded from tracking.
*/
public function isExcluded(string $filePath): bool
{
$basename = basename($filePath);
foreach ($this->patterns as $pattern) {
// Match against both full path and basename
if (fnmatch($pattern, $basename, FNM_CASEFOLD)) {
return true;
}
if (fnmatch($pattern, $filePath, FNM_CASEFOLD | FNM_PATHNAME)) {
return true;
}
// Also check for case-insensitive substring matches on sensitive keywords
if (str_contains(strtolower($basename), 'secret') ||
str_contains(strtolower($basename), 'password') ||
str_contains(strtolower($basename), 'token') && str_ends_with($basename, '.json')
) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,166 @@
<?php
/**
* Revision Service — orchestrates creating, restoring, and pruning revisions.
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Services\Revisions;
use Illuminate\Support\Facades\DB;
use Pterodactyl\Addons\AdvancedAdmin\Models\FileRevision;
use Pterodactyl\Addons\AdvancedAdmin\Models\RoleAuditLog;
use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\RevisionStorageService;
use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\ExclusionMatcher;
class RevisionService
{
public function __construct(
private readonly RevisionStorageService $storage,
private readonly ExclusionMatcher $exclusions,
) {}
/**
* Record a new revision for a file.
* Returns null if the file should be skipped (excluded, too large, binary unchanged, duplicate).
*/
public function record(
int $serverId,
string $filePath,
string $content,
?int $authorId = null,
string $summary = ''
): ?FileRevision {
// Security: sanitize path
$filePath = $this->storage->guardFilePath($filePath);
// Skip excluded files
if ($this->exclusions->isExcluded($filePath)) {
return null;
}
// Skip files above size limit
$maxSize = config('advanced-admin.revisions.max_file_size', 10 * 1024 * 1024);
if (strlen($content) > $maxSize) {
return null;
}
$pathHash = $this->storage->pathHash($serverId, $filePath);
$contentHash = $this->storage->contentHash($content);
// Deduplicate: if latest revision has same content, skip
$latest = FileRevision::query()
->where('server_id', $serverId)
->where('path_hash', $pathHash)
->orderByDesc('revision_number')
->first();
if ($latest && $latest->content_hash === $contentHash) {
return null; // No change
}
$revisionNumber = ($latest?->revision_number ?? 0) + 1;
return DB::transaction(function () use (
$serverId, $filePath, $pathHash, $contentHash,
$revisionNumber, $content, $authorId, $summary
) {
$stored = $this->storage->store($serverId, $pathHash, $revisionNumber, $content);
$revision = FileRevision::create([
'server_id' => $serverId,
'file_path' => $filePath,
'path_hash' => $pathHash,
'content_hash' => $contentHash,
'revision_number' => $revisionNumber,
'storage_key' => $stored['storage_key'],
'storage_type' => 'full',
'size_bytes' => $stored['size_bytes'],
'compressed_size' => $stored['compressed_size'],
'author_id' => $authorId,
'change_summary' => $summary ?: 'File edited via panel',
]);
$this->updateStorageUsage($serverId, $stored['compressed_size']);
$this->enforceRetentionLimits($serverId, $pathHash);
RoleAuditLog::record($authorId, 'revision.created', 'file_revision', $revision->id, [
'server_id' => $serverId,
'file_path' => $filePath,
'revision' => $revisionNumber,
]);
return $revision;
});
}
/**
* Restore a revision — returns the stored content.
*/
public function restore(FileRevision $revision, ?int $actorId = null): string
{
$content = $this->storage->retrieve($revision->storage_key);
RoleAuditLog::record($actorId, 'revision.restored', 'file_revision', $revision->id, [
'server_id' => $revision->server_id,
'file_path' => $revision->file_path,
'revision' => $revision->revision_number,
]);
return $content;
}
/**
* Delete a specific revision from storage and DB.
*/
public function delete(FileRevision $revision): void
{
DB::transaction(function () use ($revision) {
$this->storage->delete($revision->storage_key);
$this->updateStorageUsage($revision->server_id, -$revision->compressed_size);
$revision->delete();
});
}
/**
* Enforce per-file and per-server retention limits.
* Deletes oldest revisions beyond the cap.
*/
public function enforceRetentionLimits(int $serverId, string $pathHash): void
{
$maxPerFile = config('advanced-admin.revisions.max_revisions_per_file', 50);
$revisions = FileRevision::query()
->where('server_id', $serverId)
->where('path_hash', $pathHash)
->orderByDesc('revision_number')
->get();
$toDelete = $revisions->slice($maxPerFile);
foreach ($toDelete as $old) {
$this->delete($old);
}
// Per-server storage cap
$maxStorage = config('advanced-admin.revisions.max_storage_per_server', 500 * 1024 * 1024);
$usage = \Pterodactyl\Addons\AdvancedAdmin\Models\FileRevisionStorageUsage::find($serverId);
if ($usage && $usage->total_bytes > $maxStorage) {
// Delete oldest revisions across all files until under cap
$oldest = FileRevision::query()
->where('server_id', $serverId)
->orderBy('created_at')
->first();
if ($oldest) $this->delete($oldest);
}
}
private function updateStorageUsage(int $serverId, int $delta): void
{
\Pterodactyl\Addons\AdvancedAdmin\Models\FileRevisionStorageUsage::updateOrCreate(
['server_id' => $serverId],
['total_bytes' => DB::raw("total_bytes + {$delta}")]
);
}
}

View File

@@ -0,0 +1,134 @@
<?php
/**
* File Revision Storage Service
* Handles compressed storage, path traversal prevention, and binary detection.
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Services\Revisions;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
class RevisionStorageService
{
private string $disk = 'local';
/**
* Store file content, returning the storage key.
* Content is gzip-compressed before storage.
*/
public function store(int $serverId, string $pathHash, int $revisionNumber, string $content): array
{
$key = $this->buildKey($serverId, $pathHash, $revisionNumber);
$compressed = gzencode($content, 6);
if ($compressed === false) {
throw new RuntimeException('Failed to compress revision content.');
}
Storage::disk($this->disk)->put($key, $compressed);
return [
'storage_key' => $key,
'size_bytes' => strlen($content),
'compressed_size' => strlen($compressed),
];
}
/**
* Retrieve and decompress stored content.
*/
public function retrieve(string $storageKey): string
{
$this->guardKey($storageKey);
$compressed = Storage::disk($this->disk)->get($storageKey);
if ($compressed === null) {
throw new RuntimeException("Revision file not found: {$storageKey}");
}
$content = gzdecode($compressed);
if ($content === false) {
throw new RuntimeException("Failed to decompress revision: {$storageKey}");
}
return $content;
}
/**
* Delete a stored revision file.
*/
public function delete(string $storageKey): void
{
$this->guardKey($storageKey);
Storage::disk($this->disk)->delete($storageKey);
}
/**
* Get the raw compressed file for streaming download.
*/
public function readStream(string $storageKey)
{
$this->guardKey($storageKey);
return Storage::disk($this->disk)->readStream($storageKey);
}
/**
* Detect if content is binary (non-diffable).
*/
public function isBinary(string $content): bool
{
// Check for null bytes — reliable binary indicator
return str_contains($content, "\0");
}
/**
* Compute SHA-256 of content.
*/
public function contentHash(string $content): string
{
return hash('sha256', $content);
}
/**
* Compute path hash (server-scoped, prevents cross-server lookup).
*/
public function pathHash(int $serverId, string $filePath): string
{
return hash('sha256', $serverId . ':' . $filePath);
}
/**
* Validate a file path is within the server's allowed root.
* Prevents path traversal attacks.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function guardFilePath(string $filePath): string
{
// Normalize — reject absolute paths or traversal sequences
$normalized = str_replace('\\', '/', $filePath);
if (str_starts_with($normalized, '/') || str_contains($normalized, '../') || str_contains($normalized, './')) {
abort(422, 'Invalid file path.');
}
return $normalized;
}
// ─── Internal ─────────────────────────────────────────────────────────────
private function buildKey(int $serverId, string $pathHash, int $revisionNumber): string
{
$base = config('advanced-admin.revisions.storage_path', 'addons/revisions');
return "{$base}/{$serverId}/{$pathHash}/{$revisionNumber}.gz";
}
/**
* Prevent storage key manipulation — only allow keys within our storage prefix.
*/
private function guardKey(string $storageKey): void
{
$base = config('advanced-admin.revisions.storage_path', 'addons/revisions');
if (!str_starts_with($storageKey, $base . '/')) {
abort(403, 'Invalid storage key.');
}
}
}

View File

@@ -0,0 +1,123 @@
<?php
/**
* Permission Resolver — Core of the Role-Based Permission System
*
* Resolution order (first match wins):
* 1. Server-level explicit DENY → false
* 2. Server-level explicit ALLOW → true
* 3. Role DENY (highest priority role first) → false
* 4. Role ALLOW → true
* 5. Default → false
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Services\Roles;
use Illuminate\Support\Collection;
use Pterodactyl\Addons\AdvancedAdmin\Models\Role;
use Pterodactyl\Addons\AdvancedAdmin\Models\RoleAuditLog;
use Pterodactyl\Addons\AdvancedAdmin\Models\ServerPermissionOverride;
use Pterodactyl\Addons\AdvancedAdmin\Models\ServerRoleAssignment;
class PermissionResolver
{
/**
* Resolve whether a user has a permission on a server.
*
* @return bool
*/
public function resolve(int $userId, int $serverId, string $permission): bool
{
// 1 & 2: Check server-level override
$override = ServerPermissionOverride::query()
->where('server_id', $serverId)
->where('user_id', $userId)
->where('permission', $permission)
->first();
if ($override && !$override->isUnset()) {
return $override->isAllow();
}
// 3 & 4: Check role-level (sorted by priority desc — highest wins)
$roles = $this->getUserRolesForServer($userId, $serverId);
foreach ($roles as $role) {
$value = $role->getPermission($permission);
if ($value === 'deny') return false;
if ($value === 'allow') return true;
}
// 5: Default deny
return false;
}
/**
* Get full effective permission map for a user on a server.
* Returns array of [permission => ['value' => bool, 'source' => string]]
*
* @param string[] $allPermissions
* @return array<string, array{value: bool, source: string}>
*/
public function resolveAll(int $userId, int $serverId, array $allPermissions): array
{
$overrides = ServerPermissionOverride::query()
->where('server_id', $serverId)
->where('user_id', $userId)
->get()
->keyBy('permission');
$roles = $this->getUserRolesForServer($userId, $serverId);
$result = [];
foreach ($allPermissions as $permission) {
[$value, $source] = $this->resolveWithSource($permission, $overrides, $roles);
$result[$permission] = ['value' => $value, 'source' => $source];
}
return $result;
}
/**
* Get user's assigned roles for a server, ordered by priority DESC.
*/
public function getUserRolesForServer(int $userId, int $serverId): Collection
{
return ServerRoleAssignment::query()
->where('server_id', $serverId)
->where('user_id', $userId)
->with(['role.permissions'])
->get()
->map(fn ($a) => $a->role)
->filter()
->sortByDesc('priority')
->values();
}
// ─── Internal ─────────────────────────────────────────────────────────────
private function resolveWithSource(
string $permission,
Collection $overrides,
Collection $roles
): array {
// Server override
if ($overrides->has($permission)) {
$o = $overrides->get($permission);
if (!$o->isUnset()) {
return [$o->isAllow(), 'server_override'];
}
}
// Role
foreach ($roles as $role) {
$value = $role->getPermission($permission);
if ($value === 'deny') return [false, "role:{$role->name}"];
if ($value === 'allow') return [true, "role:{$role->name}"];
}
return [false, 'default'];
}
}

View File

@@ -0,0 +1,201 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Services\Roles;
use Illuminate\Validation\ValidationException;
use Pterodactyl\Addons\AdvancedAdmin\Models\Role;
use Pterodactyl\Addons\AdvancedAdmin\Models\RoleAuditLog;
use Pterodactyl\Addons\AdvancedAdmin\Models\RolePermission;
use Pterodactyl\Addons\AdvancedAdmin\Models\ServerPermissionOverride;
use Pterodactyl\Addons\AdvancedAdmin\Models\ServerRoleAssignment;
use Illuminate\Support\Facades\DB;
class RoleService
{
/**
* All known Pterodactyl permission keys (validated against).
*/
public const KNOWN_PERMISSIONS = [
'websocket.connect',
'control.console', 'control.start', 'control.stop', 'control.restart',
'user.read', 'user.create', 'user.update', 'user.delete',
'file.read', 'file.read-content', 'file.create', 'file.update',
'file.delete', 'file.archive', 'file.sftp',
'backup.read', 'backup.create', 'backup.delete', 'backup.download', 'backup.restore',
'allocation.read', 'allocation.create', 'allocation.update', 'allocation.delete',
'startup.read', 'startup.update', 'startup.docker-image',
'database.read', 'database.create', 'database.update', 'database.delete', 'database.view-password',
'schedule.read', 'schedule.create', 'schedule.update', 'schedule.delete',
'settings.read', 'settings.rename', 'settings.reinstall',
'activity.read',
];
public function create(array $data, int $actorId): Role
{
return DB::transaction(function () use ($data, $actorId) {
$role = Role::create([
'name' => $data['name'],
'description' => $data['description'] ?? null,
'color' => $data['color'] ?? '#6366f1',
'priority' => $data['priority'] ?? 0,
'is_default' => $data['is_default'] ?? false,
'created_by' => $actorId,
]);
if (!empty($data['permissions'])) {
$this->syncPermissions($role, $data['permissions'], $actorId);
}
RoleAuditLog::record($actorId, 'role.create', 'role', $role->id, ['name' => $role->name]);
return $role;
});
}
public function update(Role $role, array $data, int $actorId): Role
{
return DB::transaction(function () use ($role, $data, $actorId) {
$role->update(array_filter([
'name' => $data['name'] ?? null,
'description' => $data['description'] ?? null,
'color' => $data['color'] ?? null,
'priority' => $data['priority'] ?? null,
'is_default' => $data['is_default'] ?? null,
], fn ($v) => $v !== null));
if (isset($data['permissions'])) {
$this->syncPermissions($role, $data['permissions'], $actorId);
}
RoleAuditLog::record($actorId, 'role.update', 'role', $role->id, $data);
return $role->refresh();
});
}
public function delete(Role $role, int $actorId, bool $force = false): void
{
if ($role->isInUse() && !$force) {
throw ValidationException::withMessages([
'role' => ['This role is still assigned to users. Pass force=true to delete anyway.'],
]);
}
DB::transaction(function () use ($role, $actorId) {
RoleAuditLog::record($actorId, 'role.delete', 'role', $role->id, ['name' => $role->name]);
$role->delete();
});
}
public function syncPermissions(Role $role, array $permissions, int $actorId): void
{
// Validate all permission names
$invalid = array_diff(array_keys($permissions), self::KNOWN_PERMISSIONS);
if (!empty($invalid)) {
throw ValidationException::withMessages([
'permissions' => ['Unknown permissions: ' . implode(', ', $invalid)],
]);
}
DB::transaction(function () use ($role, $permissions, $actorId) {
$role->permissions()->delete();
foreach ($permissions as $perm => $value) {
if (!in_array($value, ['allow', 'deny'], true)) continue;
RolePermission::create([
'role_id' => $role->id,
'permission' => $perm,
'value' => $value,
]);
}
RoleAuditLog::record($actorId, 'role.permissions_updated', 'role', $role->id, ['permissions' => $permissions]);
});
}
public function assignToUser(int $serverId, int $userId, int $roleId, int $actorId): ServerRoleAssignment
{
// Prevent privilege escalation: actor's max role priority must be >= target role priority
$targetRole = Role::findOrFail($roleId);
$actorMaxPrio = $this->getMaxPriorityForUser($actorId, $serverId);
if ($targetRole->priority > $actorMaxPrio) {
throw ValidationException::withMessages([
'role' => ['You cannot assign a role with higher priority than your own.'],
]);
}
$assignment = ServerRoleAssignment::firstOrCreate([
'server_id' => $serverId,
'user_id' => $userId,
'role_id' => $roleId,
], [
'assigned_by' => $actorId,
]);
RoleAuditLog::record($actorId, 'role.assign', 'user', $userId, [
'server_id' => $serverId,
'role_id' => $roleId,
'role_name' => $targetRole->name,
]);
return $assignment;
}
public function unassignFromUser(int $serverId, int $userId, int $roleId, int $actorId): void
{
ServerRoleAssignment::query()
->where('server_id', $serverId)
->where('user_id', $userId)
->where('role_id', $roleId)
->delete();
RoleAuditLog::record($actorId, 'role.unassign', 'user', $userId, [
'server_id' => $serverId,
'role_id' => $roleId,
]);
}
public function setOverride(
int $serverId,
int $userId,
string $permission,
string $value,
int $actorId
): ServerPermissionOverride {
if (!in_array($permission, self::KNOWN_PERMISSIONS, true)) {
throw ValidationException::withMessages(['permission' => ['Unknown permission key.']]);
}
if (!in_array($value, ['allow', 'deny', 'unset'], true)) {
throw ValidationException::withMessages(['value' => ['Value must be allow, deny, or unset.']]);
}
$override = ServerPermissionOverride::updateOrCreate(
['server_id' => $serverId, 'user_id' => $userId, 'permission' => $permission],
['value' => $value, 'set_by' => $actorId]
);
RoleAuditLog::record($actorId, 'override.set', 'user', $userId, [
'server_id' => $serverId,
'permission' => $permission,
'value' => $value,
]);
return $override;
}
private function getMaxPriorityForUser(int $userId, int $serverId): int
{
// If admin, return PHP_INT_MAX
$user = \Pterodactyl\Models\User::find($userId);
if ($user && $user->root_admin) return PHP_INT_MAX;
return ServerRoleAssignment::query()
->where('server_id', $serverId)
->where('user_id', $userId)
->with('role')
->get()
->max(fn ($a) => $a->role->priority ?? 0) ?? 0;
}
}