File transfer
This commit is contained in:
119
app/Addons/AdvancedAdmin/AddonServiceProvider.php
Normal file
119
app/Addons/AdvancedAdmin/AddonServiceProvider.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Addons/AdvancedAdmin/Http/Resources/RoleResource.php
Normal file
31
app/Addons/AdvancedAdmin/Http/Resources/RoleResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/Addons/AdvancedAdmin/Models/FileRevision.php
Normal file
51
app/Addons/AdvancedAdmin/Models/FileRevision.php
Normal 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');
|
||||
}
|
||||
}
|
||||
20
app/Addons/AdvancedAdmin/Models/FileRevisionStorageUsage.php
Normal file
20
app/Addons/AdvancedAdmin/Models/FileRevisionStorageUsage.php
Normal 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'];
|
||||
}
|
||||
73
app/Addons/AdvancedAdmin/Models/Role.php
Normal file
73
app/Addons/AdvancedAdmin/Models/Role.php
Normal 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;
|
||||
}
|
||||
}
|
||||
52
app/Addons/AdvancedAdmin/Models/RoleAuditLog.php
Normal file
52
app/Addons/AdvancedAdmin/Models/RoleAuditLog.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
23
app/Addons/AdvancedAdmin/Models/RolePermission.php
Normal file
23
app/Addons/AdvancedAdmin/Models/RolePermission.php
Normal 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');
|
||||
}
|
||||
}
|
||||
21
app/Addons/AdvancedAdmin/Models/ServerPermissionOverride.php
Normal file
21
app/Addons/AdvancedAdmin/Models/ServerPermissionOverride.php
Normal 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'; }
|
||||
}
|
||||
33
app/Addons/AdvancedAdmin/Models/ServerRoleAssignment.php
Normal file
33
app/Addons/AdvancedAdmin/Models/ServerRoleAssignment.php
Normal 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');
|
||||
}
|
||||
}
|
||||
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