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,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(),
];
}
}