File transfer
This commit is contained in:
188
app/Addons/AdvancedAdmin/Services/Console/AutocompleteEngine.php
Normal file
188
app/Addons/AdvancedAdmin/Services/Console/AutocompleteEngine.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
116
app/Addons/AdvancedAdmin/Services/Revisions/DiffService.php
Normal file
116
app/Addons/AdvancedAdmin/Services/Revisions/DiffService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
166
app/Addons/AdvancedAdmin/Services/Revisions/RevisionService.php
Normal file
166
app/Addons/AdvancedAdmin/Services/Revisions/RevisionService.php
Normal 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}")]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
123
app/Addons/AdvancedAdmin/Services/Roles/PermissionResolver.php
Normal file
123
app/Addons/AdvancedAdmin/Services/Roles/PermissionResolver.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
201
app/Addons/AdvancedAdmin/Services/Roles/RoleService.php
Normal file
201
app/Addons/AdvancedAdmin/Services/Roles/RoleService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user