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,119 @@
<?php
/**
* Pterodactyl Advanced Admin Addons — Service Provider
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*
* Register this provider by adding ONE line to app/Providers/AppServiceProvider.php:
* $this->app->register(\Pterodactyl\Addons\AdvancedAdmin\AddonServiceProvider::class);
*
* See patches/PATCHES.md for full patch instructions.
*/
namespace Pterodactyl\Addons\AdvancedAdmin;
use Illuminate\Support\ServiceProvider;
use Illuminate\Console\Scheduling\Schedule;
use Pterodactyl\Addons\AdvancedAdmin\Jobs\Network\CollectNetworkMetricsJob;
use Pterodactyl\Addons\AdvancedAdmin\Jobs\Network\DownsampleNetworkMetricsJob;
use Pterodactyl\Addons\AdvancedAdmin\Jobs\Network\PruneNetworkMetricsJob;
use Pterodactyl\Addons\AdvancedAdmin\Jobs\Revisions\PruneFileRevisionsJob;
use Pterodactyl\Addons\AdvancedAdmin\Jobs\Console\ExpireStalePlayersJob;
use Pterodactyl\Addons\AdvancedAdmin\Listeners\Console\ConsoleOutputListener;
use Pterodactyl\Addons\AdvancedAdmin\Services\Roles\PermissionResolver;
class AddonServiceProvider extends ServiceProvider
{
/**
* Register addon bindings.
*/
public function register(): void
{
// Merge addon config (won't overwrite user's published config)
$this->mergeConfigFrom(
__DIR__ . '/../../config/advanced-admin.php',
'advanced-admin'
);
// Bind the PermissionResolver as a singleton for performance
$this->app->singleton(PermissionResolver::class);
}
/**
* Bootstrap addon services.
*/
public function boot(): void
{
// ── Routes ────────────────────────────────────────────────────────────
$this->loadRoutesFrom(__DIR__ . '/../../routes/addon-client.php');
$this->loadRoutesFrom(__DIR__ . '/../../routes/addon-application.php');
// ── Migrations ────────────────────────────────────────────────────────
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations/addon');
// ── Scheduled Jobs ────────────────────────────────────────────────────
$this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
if (config('advanced-admin.network.enabled')) {
$schedule->job(CollectNetworkMetricsJob::class)
->everyTenSeconds()
->withoutOverlapping(5)
->onOneServer();
$schedule->job(DownsampleNetworkMetricsJob::class)
->hourly()
->withoutOverlapping(30)
->onOneServer();
$schedule->job(PruneNetworkMetricsJob::class)
->daily()
->withoutOverlapping(60)
->onOneServer();
}
if (config('advanced-admin.revisions.enabled')) {
$schedule->job(PruneFileRevisionsJob::class)
->daily()
->withoutOverlapping(60)
->onOneServer();
}
if (config('advanced-admin.console.enabled')) {
$schedule->job(ExpireStalePlayersJob::class)
->everyFiveMinutes()
->withoutOverlapping(4);
}
});
// ── Event Listeners ───────────────────────────────────────────────────
// Console output listener (parses join/leave for player cache)
// Hooked to Pterodactyl's existing console output events if they exist.
// Falls back gracefully if event is not fired by this panel version.
if (class_exists(\Pterodactyl\Events\Server\ConsoleDataReceived::class)) {
$this->app['events']->listen(
\Pterodactyl\Events\Server\ConsoleDataReceived::class,
ConsoleOutputListener::class
);
}
// ── Artisan Commands ──────────────────────────────────────────────────
if ($this->app->runningInConsole()) {
$this->commands([
Console\Commands\AddonPatchCommand::class,
Console\Commands\AddonInstallCommand::class,
Console\Commands\AddonUninstallCommand::class,
Console\Commands\CollectNetworkCommand::class,
]);
}
// ── Publishable Assets ────────────────────────────────────────────────
$this->publishes([
__DIR__ . '/../../config/advanced-admin.php' => config_path('advanced-admin.php'),
], 'advanced-admin-config');
$this->publishes([
__DIR__ . '/../../config/addon-commands' => base_path('config/addon-commands'),
], 'advanced-admin-commands');
}
}

View File

@@ -0,0 +1,71 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Admin;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Pterodactyl\Addons\AdvancedAdmin\Http\Requests\Roles\CreateRoleRequest;
use Pterodactyl\Addons\AdvancedAdmin\Http\Requests\Roles\UpdateRoleRequest;
use Pterodactyl\Addons\AdvancedAdmin\Http\Resources\RoleResource;
use Pterodactyl\Addons\AdvancedAdmin\Models\Role;
use Pterodactyl\Addons\AdvancedAdmin\Services\Roles\RoleService;
class RoleController extends Controller
{
public function __construct(private readonly RoleService $roleService) {}
/** GET /api/application/addons/roles */
public function index(Request $request): JsonResponse
{
$this->authorizeAdmin($request);
$roles = Role::with('permissions')
->orderByDesc('priority')
->paginate(50);
return RoleResource::collection($roles)->response();
}
/** GET /api/application/addons/roles/{role} */
public function show(Request $request, Role $role): JsonResponse
{
$this->authorizeAdmin($request);
return (new RoleResource($role->load('permissions')))->response();
}
/** POST /api/application/addons/roles */
public function store(CreateRoleRequest $request): JsonResponse
{
$this->authorizeAdmin($request);
$role = $this->roleService->create($request->validated(), $request->user()->id);
return (new RoleResource($role->load('permissions')))->response()->setStatusCode(201);
}
/** PATCH /api/application/addons/roles/{role} */
public function update(UpdateRoleRequest $request, Role $role): JsonResponse
{
$this->authorizeAdmin($request);
$updated = $this->roleService->update($role, $request->validated(), $request->user()->id);
return (new RoleResource($updated->load('permissions')))->response();
}
/** DELETE /api/application/addons/roles/{role} */
public function destroy(Request $request, Role $role): JsonResponse
{
$this->authorizeAdmin($request);
$force = (bool) $request->boolean('force', false);
$this->roleService->delete($role, $request->user()->id, $force);
return response()->json(['deleted' => true]);
}
private function authorizeAdmin(Request $request): void
{
abort_unless($request->user() && $request->user()->root_admin, 403, 'Admin access required.');
}
}

View File

@@ -0,0 +1,66 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Client;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Pterodactyl\Addons\AdvancedAdmin\Models\ConsolePlayerCache;
use Pterodactyl\Addons\AdvancedAdmin\Services\Console\AutocompleteEngine;
use Pterodactyl\Models\Server;
class AutocompleteController extends Controller
{
public function __construct(private readonly AutocompleteEngine $engine) {}
/** GET /api/client/servers/{server}/addons/console/autocomplete?q= */
public function suggest(Request $request, Server $server): JsonResponse
{
$this->requireAccess($request, $server);
$query = (string) $request->get('q', '');
$suggestions = $this->engine->suggest(
$server->id,
$server->node_id,
$server->egg_id,
$query,
);
return response()->json(['data' => $suggestions]);
}
/** GET /api/client/servers/{server}/addons/console/players */
public function players(Request $request, Server $server): JsonResponse
{
$this->requireAccess($request, $server);
$players = ConsolePlayerCache::query()
->where('server_id', $server->id)
->where('last_seen_at', '>=', now()->subMinutes(
config('advanced-admin.console.player_cache_ttl_minutes', 30)
))
->orderBy('player_name')
->pluck('player_name');
return response()->json(['data' => $players]);
}
private function requireAccess(Request $request, Server $server): void
{
$user = $request->user();
abort_unless($user, 401);
$isOwner = $server->owner_id === $user->id;
$isAdmin = $user->root_admin;
if (!$isOwner && !$isAdmin) {
$subuser = $server->subusers()->where('user_id', $user->id)->first();
abort_unless($subuser && in_array('control.console', $subuser->permissions ?? [], true), 403);
}
}
}

View File

@@ -0,0 +1,150 @@
<?php
/**
* File Revision Controller — client API
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Client;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Pterodactyl\Addons\AdvancedAdmin\Models\FileRevision;
use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\DiffService;
use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\RevisionService;
use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\RevisionStorageService;
use Pterodactyl\Models\Server;
class FileRevisionController extends Controller
{
public function __construct(
private readonly RevisionService $revisionService,
private readonly DiffService $diffService,
private readonly RevisionStorageService $storage,
) {}
/** GET /api/client/servers/{server}/addons/files/revisions?path= */
public function index(Request $request, Server $server): JsonResponse
{
$this->requirePermission($request, $server, 'file.read');
$path = $request->string('path', '')->toString();
abort_if(empty($path), 422, 'path parameter is required.');
$path = $this->storage->guardFilePath($path);
$pathHash = $this->storage->pathHash($server->id, $path);
$revisions = FileRevision::query()
->where('server_id', $server->id)
->where('path_hash', $pathHash)
->with('author:id,username,email')
->orderByDesc('revision_number')
->paginate(25);
return response()->json($revisions);
}
/** GET /api/client/servers/{server}/addons/files/revisions/{revision} */
public function show(Request $request, Server $server, FileRevision $revision): JsonResponse
{
$this->requirePermission($request, $server, 'file.read');
$this->assertBelongsToServer($revision, $server);
return response()->json(['data' => $revision->load('author:id,username,email')]);
}
/** GET /api/client/servers/{server}/addons/files/revisions/{revision}/diff */
public function diff(Request $request, Server $server, FileRevision $revision): JsonResponse
{
$this->requirePermission($request, $server, 'file.read');
$this->assertBelongsToServer($revision, $server);
$content = $this->storage->retrieve($revision->storage_key);
// If binary, return a placeholder diff
if ($this->storage->isBinary($content)) {
return response()->json(['binary' => true, 'lines' => []]);
}
// Fetch previous revision for comparison
$previous = FileRevision::query()
->where('server_id', $server->id)
->where('path_hash', $revision->path_hash)
->where('revision_number', $revision->revision_number - 1)
->first();
$oldContent = $previous ? $this->storage->retrieve($previous->storage_key) : '';
$lines = $this->diffService->diff($oldContent, $content, basename($revision->file_path));
return response()->json(['binary' => false, 'lines' => $lines]);
}
/** GET /api/client/servers/{server}/addons/files/revisions/{revision}/download */
public function download(Request $request, Server $server, FileRevision $revision): StreamedResponse
{
$this->requirePermission($request, $server, 'file.read');
$this->assertBelongsToServer($revision, $server);
$content = $this->storage->retrieve($revision->storage_key);
$filename = basename($revision->file_path) . ".rev{$revision->revision_number}";
return response()->streamDownload(function () use ($content) {
echo $content;
}, $filename, ['Content-Type' => 'application/octet-stream']);
}
/** POST /api/client/servers/{server}/addons/files/revisions/{revision}/restore */
public function restore(Request $request, Server $server, FileRevision $revision): JsonResponse
{
$this->requirePermission($request, $server, 'file.update');
$this->assertBelongsToServer($revision, $server);
$content = $this->revisionService->restore($revision, $request->user()->id);
return response()->json([
'restored' => true,
'content' => $content,
'revision' => $revision->revision_number,
]);
}
/** DELETE /api/client/servers/{server}/addons/files/revisions/{revision} */
public function destroy(Request $request, Server $server, FileRevision $revision): JsonResponse
{
// Only admins can manually delete revisions
abort_unless($request->user()?->root_admin, 403, 'Admin access required.');
$this->assertBelongsToServer($revision, $server);
$this->revisionService->delete($revision);
return response()->json(['deleted' => true]);
}
// ─── Helpers ──────────────────────────────────────────────────────────────
private function requirePermission(Request $request, Server $server, string $permission): void
{
$user = $request->user();
abort_unless($user, 401);
$isOwner = $server->owner_id === $user->id;
$isAdmin = $user->root_admin;
if (!$isOwner && !$isAdmin) {
$subuser = $server->subusers()->where('user_id', $user->id)->first();
abort_unless(
$subuser && in_array($permission, $subuser->permissions ?? [], true),
403,
"Permission '{$permission}' required."
);
}
}
private function assertBelongsToServer(FileRevision $revision, Server $server): void
{
abort_unless($revision->server_id === $server->id, 404);
}
}

View File

@@ -0,0 +1,116 @@
<?php
/**
* Network Metrics Controller — client API
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Client;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Pterodactyl\Models\Server;
class NetworkMetricsController extends Controller
{
/** GET /api/client/servers/{server}/addons/network/live */
public function live(Request $request, Server $server): JsonResponse
{
$this->requireAccess($request, $server);
// Latest raw metric (most recent 30 seconds)
$latest = DB::table('addon_network_metrics')
->where('server_id', $server->id)
->where('resolution', 'raw')
->where('recorded_at', '>=', now()->subSeconds(30))
->orderByDesc('recorded_at')
->select('rx_bytes', 'tx_bytes', 'recorded_at')
->first();
return response()->json(['data' => $latest]);
}
/** GET /api/client/servers/{server}/addons/network/history */
public function history(Request $request, Server $server): JsonResponse
{
$this->requireAccess($request, $server);
$resolution = $request->get('resolution', '5m');
abort_unless(in_array($resolution, ['raw', '5m', '1h', '1d'], true), 422, 'Invalid resolution.');
$hours = match ($resolution) {
'raw' => 1,
'5m' => 24,
'1h' => 168,
'1d' => 8760,
};
$metrics = DB::table('addon_network_metrics')
->where('server_id', $server->id)
->where('resolution', $resolution)
->where('recorded_at', '>=', now()->subHours($hours))
->orderBy('recorded_at')
->select('rx_bytes', 'tx_bytes', 'recorded_at')
->get();
return response()->json(['data' => $metrics]);
}
/** GET /api/client/servers/{server}/addons/network/ports */
public function ports(Request $request, Server $server): JsonResponse
{
$this->requireAccess($request, $server);
if (!config('advanced-admin.network.iptables_enabled')) {
return response()->json(['data' => [], 'unavailable' => true, 'reason' => 'iptables collection not enabled']);
}
$ports = DB::table('addon_network_port_metrics')
->where('server_id', $server->id)
->where('resolution', '5m')
->where('recorded_at', '>=', now()->subHour())
->groupBy('port', 'protocol')
->select('port', 'protocol', DB::raw('SUM(rx_bytes) as rx_bytes'), DB::raw('SUM(tx_bytes) as tx_bytes'))
->orderByDesc('rx_bytes')
->limit(50)
->get();
return response()->json(['data' => $ports]);
}
/** GET /api/client/servers/{server}/addons/network/flows */
public function flows(Request $request, Server $server): JsonResponse
{
$this->requireAccess($request, $server);
$flows = DB::table('addon_network_flows')
->where(function ($q) use ($server) {
$q->where('src_server_id', $server->id)
->orWhere('dst_server_id', $server->id);
})
->where('recorded_at', '>=', now()->subHours(24))
->select('src_server_id', 'dst_server_id', DB::raw('SUM(bytes) as bytes'), DB::raw('SUM(packets) as packets'))
->groupBy('src_server_id', 'dst_server_id')
->orderByDesc('bytes')
->limit(50)
->get();
return response()->json(['data' => $flows]);
}
private function requireAccess(Request $request, Server $server): void
{
$user = $request->user();
abort_unless($user, 401);
$isOwner = $server->owner_id === $user->id;
$isAdmin = $user->root_admin;
if (!$isOwner && !$isAdmin) {
$subuser = $server->subusers()->where('user_id', $user->id)->first();
abort_unless($subuser, 403);
}
}
}

View File

@@ -0,0 +1,140 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Http\Controllers\Client;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Pterodactyl\Addons\AdvancedAdmin\Http\Resources\RoleResource;
use Pterodactyl\Addons\AdvancedAdmin\Http\Resources\EffectivePermissionResource;
use Pterodactyl\Addons\AdvancedAdmin\Models\ServerRoleAssignment;
use Pterodactyl\Addons\AdvancedAdmin\Services\Roles\PermissionResolver;
use Pterodactyl\Addons\AdvancedAdmin\Services\Roles\RoleService;
use Pterodactyl\Models\Server;
class ServerRoleController extends Controller
{
public function __construct(
private readonly RoleService $roleService,
private readonly PermissionResolver $resolver,
) {}
/** GET /api/client/servers/{server}/addons/roles/users */
public function listUsers(Request $request, Server $server): JsonResponse
{
$this->authorizeServerAdmin($request, $server);
$assignments = ServerRoleAssignment::query()
->where('server_id', $server->id)
->with(['user', 'role.permissions'])
->get()
->groupBy('user_id')
->map(function ($items) {
$user = $items->first()->user;
$roles = $items->pluck('role');
return ['user' => $user, 'roles' => $roles];
})
->values();
return response()->json(['data' => $assignments]);
}
/** POST /api/client/servers/{server}/addons/roles/assign */
public function assign(Request $request, Server $server): JsonResponse
{
$this->authorizeServerAdmin($request, $server);
$data = $request->validate([
'user_id' => 'required|integer|exists:users,id',
'role_id' => 'required|integer|exists:addon_roles,id',
]);
$assignment = $this->roleService->assignToUser(
$server->id,
$data['user_id'],
$data['role_id'],
$request->user()->id,
);
return response()->json(['data' => $assignment], 201);
}
/** DELETE /api/client/servers/{server}/addons/roles/assign */
public function unassign(Request $request, Server $server): JsonResponse
{
$this->authorizeServerAdmin($request, $server);
$data = $request->validate([
'user_id' => 'required|integer|exists:users,id',
'role_id' => 'required|integer|exists:addon_roles,id',
]);
$this->roleService->unassignFromUser(
$server->id,
$data['user_id'],
$data['role_id'],
$request->user()->id,
);
return response()->json(['deleted' => true]);
}
/** GET /api/client/servers/{server}/addons/permissions/effective/{userId} */
public function effectivePermissions(Request $request, Server $server, int $targetUserId): JsonResponse
{
$this->authorizeServerAdmin($request, $server);
$effective = $this->resolver->resolveAll(
$targetUserId,
$server->id,
RoleService::KNOWN_PERMISSIONS
);
return response()->json(['data' => $effective]);
}
/** PUT /api/client/servers/{server}/addons/permissions/override */
public function setOverride(Request $request, Server $server): JsonResponse
{
$this->authorizeServerAdmin($request, $server);
$data = $request->validate([
'user_id' => 'required|integer|exists:users,id',
'permission' => 'required|string|max:100',
'value' => 'required|in:allow,deny,unset',
]);
$override = $this->roleService->setOverride(
$server->id,
$data['user_id'],
$data['permission'],
$data['value'],
$request->user()->id,
);
return response()->json(['data' => $override]);
}
private function authorizeServerAdmin(Request $request, Server $server): void
{
$user = $request->user();
abort_unless($user, 401);
$isOwner = $server->owner_id === $user->id;
$isAdmin = $user->root_admin;
// Subusers with user.update can also manage roles on their server
$isSubuserAdmin = false;
if (!$isOwner && !$isAdmin) {
$subuser = $server->subusers()->where('user_id', $user->id)->first();
$isSubuserAdmin = $subuser && in_array('user.update', $subuser->permissions ?? [], true);
}
abort_unless($isOwner || $isAdmin || $isSubuserAdmin, 403, 'Insufficient privileges.');
}
}

View File

@@ -0,0 +1,31 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Http\Requests\Roles;
use Illuminate\Foundation\Http\FormRequest;
class CreateRoleRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() && $this->user()->root_admin;
}
public function rules(): array
{
return [
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'color' => 'nullable|string|regex:/^#[0-9a-fA-F]{6}$/',
'priority' => 'nullable|integer|min:0|max:9999',
'is_default' => 'nullable|boolean',
'permissions' => 'nullable|array',
'permissions.*' => 'in:allow,deny',
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Http\Requests\Roles;
use Illuminate\Foundation\Http\FormRequest;
class UpdateRoleRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() && $this->user()->root_admin;
}
public function rules(): array
{
return [
'name' => 'sometimes|string|max:100',
'description' => 'sometimes|nullable|string|max:500',
'color' => 'sometimes|nullable|string|regex:/^#[0-9a-fA-F]{6}$/',
'priority' => 'sometimes|integer|min:0|max:9999',
'is_default' => 'sometimes|boolean',
'permissions' => 'sometimes|array',
'permissions.*' => 'in:allow,deny',
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class RoleResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'color' => $this->color,
'priority' => $this->priority,
'is_default' => $this->is_default,
'in_use' => $this->whenLoaded('assignments', fn () => $this->assignments->count() > 0),
'permissions' => $this->whenLoaded('permissions', function () {
return $this->permissions->pluck('value', 'permission');
}),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
/**
* Collect Network Metrics Job — runs every 10 seconds per node.
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Jobs\Network;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Pterodactyl\Addons\AdvancedAdmin\Services\Network\DockerStatsCollector;
use Pterodactyl\Models\Node;
class CollectNetworkMetricsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 30;
public function handle(DockerStatsCollector $collector): void
{
if (!config('advanced-admin.network.enabled')) return;
$nodes = Node::all();
$now = now();
foreach ($nodes as $node) {
$stats = $collector->collectForNode($node);
if (empty($stats)) continue;
// Batch insert for efficiency
$rows = array_map(fn ($s) => [
'server_id' => $s['server_id'],
'node_id' => $s['node_id'],
'rx_bytes' => $s['rx_bytes'],
'tx_bytes' => $s['tx_bytes'],
'resolution' => 'raw',
'recorded_at' => $now,
'created_at' => $now,
], $stats);
DB::table('addon_network_metrics')->insert($rows);
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $server_id
* @property string $file_path
* @property string $path_hash
* @property string $content_hash
* @property int $revision_number
* @property string $storage_key
* @property string $storage_type
* @property int $size_bytes
* @property int $compressed_size
* @property int $author_id
* @property string $change_summary
*/
class FileRevision extends Model
{
protected $table = 'addon_file_revisions';
public $timestamps = false;
protected $fillable = [
'server_id', 'file_path', 'path_hash', 'content_hash',
'revision_number', 'storage_key', 'storage_type',
'size_bytes', 'compressed_size', 'author_id', 'change_summary',
];
protected $casts = [
'created_at' => 'datetime',
];
public function server(): BelongsTo
{
return $this->belongsTo(\Pterodactyl\Models\Server::class, 'server_id');
}
public function author(): BelongsTo
{
return $this->belongsTo(\Pterodactyl\Models\User::class, 'author_id');
}
}

View File

@@ -0,0 +1,20 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Models;
use Illuminate\Database\Eloquent\Model;
class FileRevisionStorageUsage extends Model
{
protected $table = 'addon_file_revision_storage_usage';
protected $primaryKey = 'server_id';
public $incrementing = false;
public $timestamps = false;
protected $fillable = ['server_id', 'total_bytes'];
}

View File

@@ -0,0 +1,73 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property string $name
* @property string $description
* @property string $color
* @property int $priority
* @property bool $is_default
* @property int $created_by
*/
class Role extends Model
{
protected $table = 'addon_roles';
protected $fillable = [
'name',
'description',
'color',
'priority',
'is_default',
'created_by',
];
protected $casts = [
'priority' => 'integer',
'is_default' => 'boolean',
];
public function permissions(): HasMany
{
return $this->hasMany(RolePermission::class, 'role_id');
}
public function assignments(): HasMany
{
return $this->hasMany(ServerRoleAssignment::class, 'role_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(\Pterodactyl\Models\User::class, 'created_by');
}
/**
* Check if this role is still assigned anywhere.
*/
public function isInUse(): bool
{
return $this->assignments()->exists();
}
/**
* Get permission value for a given key.
* Returns 'allow', 'deny', or null (not set).
*/
public function getPermission(string $permission): ?string
{
$perm = $this->permissions()->where('permission', $permission)->first();
return $perm?->value;
}
}

View File

@@ -0,0 +1,52 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Models;
use Illuminate\Database\Eloquent\Model;
class RoleAuditLog extends Model
{
protected $table = 'addon_role_audit_log';
public $timestamps = false;
protected $fillable = [
'actor_id',
'action',
'target_type',
'target_id',
'payload',
'ip_address',
];
protected $casts = [
'payload' => 'array',
'created_at' => 'datetime',
];
/**
* Write a structured audit entry.
*/
public static function record(
?int $actorId,
string $action,
string $targetType = '',
?int $targetId = null,
array $payload = [],
string $ip = ''
): void {
static::create([
'actor_id' => $actorId,
'action' => $action,
'target_type' => $targetType,
'target_id' => $targetId,
'payload' => $payload,
'ip_address' => $ip ?: request()->ip(),
'created_at' => now(),
]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RolePermission extends Model
{
protected $table = 'addon_role_permissions';
protected $fillable = ['role_id', 'permission', 'value'];
public function role(): BelongsTo
{
return $this->belongsTo(Role::class, 'role_id');
}
}

View File

@@ -0,0 +1,21 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Models;
use Illuminate\Database\Eloquent\Model;
class ServerPermissionOverride extends Model
{
protected $table = 'addon_server_permission_overrides';
protected $fillable = ['server_id', 'user_id', 'permission', 'value', 'set_by'];
public function isAllow(): bool { return $this->value === 'allow'; }
public function isDeny(): bool { return $this->value === 'deny'; }
public function isUnset(): bool { return $this->value === 'unset'; }
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ServerRoleAssignment extends Model
{
protected $table = 'addon_server_role_assignments';
protected $fillable = ['server_id', 'user_id', 'role_id', 'assigned_by'];
public function role(): BelongsTo
{
return $this->belongsTo(Role::class, 'role_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(\Pterodactyl\Models\User::class, 'user_id');
}
public function server(): BelongsTo
{
return $this->belongsTo(\Pterodactyl\Models\Server::class, 'server_id');
}
}

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;
}
}