diff --git a/.env.example b/.env.example
index c0660ea..5458e0f 100644
--- a/.env.example
+++ b/.env.example
@@ -63,3 +63,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
+
+MC_RCON_HOST=minecraft
+MC_RCON_PORT=25575
+MC_RCON_PASSWORD=
+MC_RCON_ACCESS_COMMAND="whitelist add {username}"
+MC_RCON_TIMEOUT=5
diff --git a/app/Filament/Resources/AccessRequests/Tables/AccessRequestsTable.php b/app/Filament/Resources/AccessRequests/Tables/AccessRequestsTable.php
index 01e574d..3e0a971 100644
--- a/app/Filament/Resources/AccessRequests/Tables/AccessRequestsTable.php
+++ b/app/Filament/Resources/AccessRequests/Tables/AccessRequestsTable.php
@@ -2,12 +2,20 @@
namespace App\Filament\Resources\AccessRequests\Tables;
+use App\Enums\AccessRequestStatus;
+use App\Models\AccessRequests;
+use App\Services\AccessRequestApprover;
+use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
+use Filament\Notifications\Notification;
+use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
+use Illuminate\Support\Facades\Auth;
+use Throwable;
class AccessRequestsTable
{
@@ -45,6 +53,33 @@ class AccessRequestsTable
//
])
->recordActions([
+ Action::make('accept')
+ ->label('Accepter')
+ ->icon(Heroicon::CheckCircle)
+ ->color('success')
+ ->requiresConfirmation()
+ ->modalHeading('Accepter la demande')
+ ->modalDescription(fn (AccessRequests $record): string => "Le joueur {$record->game_username} sera ajoute via RCON.")
+ ->visible(fn (AccessRequests $record): bool => $record->status === AccessRequestStatus::PENDING)
+ ->action(function (AccessRequests $record, AccessRequestApprover $approver): void {
+ try {
+ $approver->approve($record, Auth::user());
+
+ Notification::make()
+ ->success()
+ ->title('Demande acceptee')
+ ->send();
+ } catch (Throwable $exception) {
+ report($exception);
+
+ Notification::make()
+ ->danger()
+ ->title('Acceptation impossible')
+ ->body($exception->getMessage())
+ ->persistent()
+ ->send();
+ }
+ }),
ViewAction::make(),
EditAction::make(),
])
diff --git a/app/Http/Controllers/AccessRequestsController.php b/app/Http/Controllers/AccessRequestsController.php
index 85f4e79..15bf2c3 100644
--- a/app/Http/Controllers/AccessRequestsController.php
+++ b/app/Http/Controllers/AccessRequestsController.php
@@ -2,37 +2,27 @@
namespace App\Http\Controllers;
+use App\Enums\AccessRequestStatus;
use App\Http\Requests\AccessRequestsRequest;
use App\Models\AccessRequests;
+use App\Models\GameServers;
+use Illuminate\Http\JsonResponse;
class AccessRequestsController extends Controller
{
- public function index()
+ public function store(AccessRequestsRequest $request): JsonResponse
{
- return AccessRequests::all();
- }
+ $validated = $request->validated();
+ $serverId = $validated['server_id']
+ ?? GameServers::where('slug', $validated['server_slug'])->valueOrFail('id');
- public function store(AccessRequestsRequest $request)
- {
- return AccessRequests::create($request->validated());
- }
+ $accessRequest = AccessRequests::create([
+ 'game_username' => $validated['game_username'],
+ 'message' => $validated['message'] ?? '',
+ 'server_id' => $serverId,
+ 'status' => AccessRequestStatus::PENDING,
+ ]);
- public function show(AccessRequests $accessRequests)
- {
- return $accessRequests;
- }
-
- public function update(AccessRequestsRequest $request, AccessRequests $accessRequests)
- {
- $accessRequests->update($request->validated());
-
- return $accessRequests;
- }
-
- public function destroy(AccessRequests $accessRequests)
- {
- $accessRequests->delete();
-
- return response()->json();
+ return response()->json($accessRequest, 201);
}
}
diff --git a/app/Http/Requests/AccessRequestsRequest.php b/app/Http/Requests/AccessRequestsRequest.php
index 3448655..0fe6525 100644
--- a/app/Http/Requests/AccessRequestsRequest.php
+++ b/app/Http/Requests/AccessRequestsRequest.php
@@ -9,11 +9,10 @@ class AccessRequestsRequest extends FormRequest
public function rules(): array
{
return [
- 'status' => ['required'],
- 'message' => ['required'],
- 'reviewed_by' => ['nullable', 'exists:users'],
- 'server_id' => ['required', 'exists:game_servers'],
- 'reviewed_at' => ['nullable', 'date'],
+ 'game_username' => ['required', 'string', 'regex:/^[A-Za-z0-9_]{3,16}$/'],
+ 'message' => ['nullable', 'string', 'max:255'],
+ 'server_id' => ['nullable', 'required_without:server_slug', 'ulid', 'exists:game_servers,id'],
+ 'server_slug' => ['nullable', 'required_without:server_id', 'string', 'exists:game_servers,slug'],
];
}
diff --git a/app/Models/AccessRequests.php b/app/Models/AccessRequests.php
index a0ca35e..203396a 100644
--- a/app/Models/AccessRequests.php
+++ b/app/Models/AccessRequests.php
@@ -20,6 +20,8 @@ class AccessRequests extends Model
'game_username',
'message',
'server_id',
+ 'reviewed_by',
+ 'reviewed_at',
];
public function reviewedBy(): BelongsTo
@@ -36,8 +38,8 @@ class AccessRequests extends Model
{
return [
'id' => 'string',
- 'reviewed_at' => 'timestamp',
- 'status' => AccessRequestStatus::class,
+ 'reviewed_at' => 'datetime',
+ 'status' => AccessRequestStatus::class,
];
}
}
diff --git a/app/Services/AccessRequestApprover.php b/app/Services/AccessRequestApprover.php
new file mode 100644
index 0000000..a993198
--- /dev/null
+++ b/app/Services/AccessRequestApprover.php
@@ -0,0 +1,37 @@
+refresh();
+
+ if ($accessRequest->status !== AccessRequestStatus::PENDING) {
+ throw new DomainException('Only pending access requests can be accepted.');
+ }
+
+ $output = $this->rconAccessGrantor->grant($accessRequest);
+
+ DB::transaction(function () use ($accessRequest, $reviewer): void {
+ $accessRequest->forceFill([
+ 'status' => AccessRequestStatus::ACCEPTED,
+ 'reviewed_by' => $reviewer?->getKey(),
+ 'reviewed_at' => now(),
+ ])->save();
+ });
+
+ return $output;
+ }
+}
diff --git a/app/Services/MinecraftRconClient.php b/app/Services/MinecraftRconClient.php
new file mode 100644
index 0000000..ad25a55
--- /dev/null
+++ b/app/Services/MinecraftRconClient.php
@@ -0,0 +1,162 @@
+connect();
+
+ try {
+ $this->authenticate();
+
+ $requestId = $this->nextRequestId();
+ $this->writePacket($requestId, self::COMMAND_PACKET_TYPE, $command);
+
+ $response = $this->readPacket();
+
+ if ($response['id'] !== $requestId) {
+ throw new RuntimeException('Unexpected RCON response id.');
+ }
+
+ return $response['body'];
+ } finally {
+ $this->disconnect();
+ }
+ }
+
+ private function connect(): void
+ {
+ $host = config('rcon.host');
+ $port = (int) config('rcon.port');
+ $timeout = (float) config('rcon.timeout');
+
+ if (blank($host)) {
+ throw new RuntimeException('MC_RCON_HOST is not configured.');
+ }
+
+ $socket = @stream_socket_client(
+ "tcp://{$host}:{$port}",
+ $errorCode,
+ $errorMessage,
+ $timeout,
+ );
+
+ if ($socket === false) {
+ throw new RuntimeException("Unable to connect to Minecraft RCON: {$errorMessage} ({$errorCode}).");
+ }
+
+ $this->socket = $socket;
+ $seconds = (int) floor($timeout);
+ $microseconds = (int) (($timeout - $seconds) * 1_000_000);
+
+ stream_set_timeout($this->socket, $seconds, $microseconds);
+ }
+
+ private function authenticate(): void
+ {
+ $password = config('rcon.password');
+
+ if (blank($password)) {
+ throw new RuntimeException('MC_RCON_PASSWORD is not configured.');
+ }
+
+ $requestId = $this->nextRequestId();
+ $this->writePacket($requestId, self::AUTH_PACKET_TYPE, (string) $password);
+
+ do {
+ $response = $this->readPacket();
+ } while ($response['type'] !== self::COMMAND_PACKET_TYPE);
+
+ if ($response['id'] === -1 || $response['id'] !== $requestId) {
+ throw new RuntimeException('Minecraft RCON authentication failed.');
+ }
+ }
+
+ private function disconnect(): void
+ {
+ if (is_resource($this->socket)) {
+ fclose($this->socket);
+ }
+
+ $this->socket = null;
+ }
+
+ private function nextRequestId(): int
+ {
+ return $this->requestId++;
+ }
+
+ private function writePacket(int $requestId, int $type, string $body): void
+ {
+ $payload = pack('VV', $requestId, $type).$body."\x00\x00";
+ $packet = pack('V', strlen($payload)).$payload;
+ $bytesWritten = 0;
+
+ while ($bytesWritten < strlen($packet)) {
+ $written = fwrite($this->socket, substr($packet, $bytesWritten));
+
+ if ($written === false || $written === 0) {
+ throw new RuntimeException('Unable to write RCON packet.');
+ }
+
+ $bytesWritten += $written;
+ }
+ }
+
+ /**
+ * @return array{id: int, type: int, body: string}
+ */
+ private function readPacket(): array
+ {
+ $sizeData = $this->readBytes(4);
+ $size = unpack('V', $sizeData)[1];
+
+ if ($size < 10) {
+ throw new RuntimeException('Invalid RCON packet size.');
+ }
+
+ $packet = $this->readBytes($size);
+ $header = unpack('Vid/Vtype', substr($packet, 0, 8));
+ $id = $header['id'] >= 0x80000000
+ ? $header['id'] - 0x100000000
+ : $header['id'];
+
+ return [
+ 'id' => $id,
+ 'type' => $header['type'],
+ 'body' => substr($packet, 8, -2),
+ ];
+ }
+
+ private function readBytes(int $length): string
+ {
+ $data = '';
+
+ while (strlen($data) < $length) {
+ $chunk = fread($this->socket, $length - strlen($data));
+
+ if ($chunk === false || $chunk === '') {
+ throw new RuntimeException('Unable to read RCON packet.');
+ }
+
+ $data .= $chunk;
+ }
+
+ return $data;
+ }
+}
diff --git a/app/Services/RconAccessGrantor.php b/app/Services/RconAccessGrantor.php
new file mode 100644
index 0000000..1bed71c
--- /dev/null
+++ b/app/Services/RconAccessGrantor.php
@@ -0,0 +1,28 @@
+rconClient->command($this->commandFor($accessRequest->game_username));
+ }
+
+ private function commandFor(string $gameUsername): string
+ {
+ $accessCommand = (string) config('rcon.access_command', 'whitelist add {username}');
+
+ if (! str_contains($accessCommand, '{username}')) {
+ $accessCommand = trim($accessCommand).' {username}';
+ }
+
+ return str_replace('{username}', $gameUsername, $accessCommand);
+ }
+}
diff --git a/config/rcon.php b/config/rcon.php
new file mode 100644
index 0000000..b5136cc
--- /dev/null
+++ b/config/rcon.php
@@ -0,0 +1,9 @@
+ env('MC_RCON_HOST', 'minecraft'),
+ 'port' => (int) env('MC_RCON_PORT', 25575),
+ 'password' => env('MC_RCON_PASSWORD'),
+ 'timeout' => (float) env('MC_RCON_TIMEOUT', 5),
+ 'access_command' => env('MC_RCON_ACCESS_COMMAND', 'whitelist add {username}'),
+];
diff --git a/phpunit.xml b/phpunit.xml
index a85936a..e7f0a48 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -23,7 +23,8 @@
-
+
+
diff --git a/routes/api.php b/routes/api.php
index 6f26ffb..b80e50b 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,10 +1,6 @@
json([
- 'status' => 'ok'
- ]);
-});
+Route::post('/access-requests', [AccessRequestsController::class, 'store']);
diff --git a/tests/Feature/AccessRequestApproverTest.php b/tests/Feature/AccessRequestApproverTest.php
new file mode 100644
index 0000000..a057c9a
--- /dev/null
+++ b/tests/Feature/AccessRequestApproverTest.php
@@ -0,0 +1,98 @@
+set('rcon.access_command', 'whitelist add {username}');
+
+ $rconClient = new FakeMinecraftRconClient;
+ $this->app->instance(MinecraftRconClient::class, $rconClient);
+ $reviewer = User::factory()->create();
+ $accessRequest = $this->pendingAccessRequest('LeonPlayer');
+
+ $output = app(AccessRequestApprover::class)->approve($accessRequest, $reviewer);
+
+ $this->assertSame('Added LeonPlayer to the whitelist', $output);
+ $this->assertSame(['whitelist add LeonPlayer'], $rconClient->commands);
+
+ $this->assertDatabaseHas('access_requests', [
+ 'id' => $accessRequest->id,
+ 'status' => AccessRequestStatus::ACCEPTED->value,
+ 'reviewed_by' => $reviewer->id,
+ ]);
+
+ $this->assertNotNull($accessRequest->fresh()->reviewed_at);
+ }
+
+ public function test_it_does_not_accept_when_rcon_fails(): void
+ {
+ $this->app->instance(MinecraftRconClient::class, new FakeMinecraftRconClient(shouldFail: true));
+ $accessRequest = $this->pendingAccessRequest('LeonPlayer');
+
+ $this->expectException(RuntimeException::class);
+
+ try {
+ app(AccessRequestApprover::class)->approve($accessRequest);
+ } finally {
+ $this->assertDatabaseHas('access_requests', [
+ 'id' => $accessRequest->id,
+ 'status' => AccessRequestStatus::PENDING->value,
+ 'reviewed_by' => null,
+ 'reviewed_at' => null,
+ ]);
+ }
+ }
+
+ private function pendingAccessRequest(string $gameUsername): AccessRequests
+ {
+ $server = GameServers::create([
+ 'name' => 'Survival',
+ 'slug' => 'survival',
+ ]);
+
+ return AccessRequests::create([
+ 'status' => AccessRequestStatus::PENDING,
+ 'game_username' => $gameUsername,
+ 'message' => '',
+ 'server_id' => $server->id,
+ ]);
+ }
+}
+
+class FakeMinecraftRconClient extends MinecraftRconClient
+{
+ /**
+ * @var array
+ */
+ public array $commands = [];
+
+ public function __construct(
+ private readonly bool $shouldFail = false,
+ ) {}
+
+ public function command(string $command): string
+ {
+ $this->commands[] = $command;
+
+ if ($this->shouldFail) {
+ throw new RuntimeException('rcon failed');
+ }
+
+ return 'Added LeonPlayer to the whitelist';
+ }
+}
diff --git a/tests/Feature/AccessRequestsApiTest.php b/tests/Feature/AccessRequestsApiTest.php
new file mode 100644
index 0000000..1d8a74e
--- /dev/null
+++ b/tests/Feature/AccessRequestsApiTest.php
@@ -0,0 +1,80 @@
+ 'Survival',
+ 'slug' => 'survival',
+ ]);
+
+ $response = $this->postJson('/api/access-requests', [
+ 'game_username' => 'LeonPlayer',
+ 'message' => 'Je veux rejoindre le serveur.',
+ 'server_id' => $server->id,
+ ]);
+
+ $response
+ ->assertCreated()
+ ->assertJsonPath('game_username', 'LeonPlayer')
+ ->assertJsonPath('message', 'Je veux rejoindre le serveur.')
+ ->assertJsonPath('server_id', $server->id)
+ ->assertJsonPath('status', AccessRequestStatus::PENDING->value);
+
+ $this->assertDatabaseHas('access_requests', [
+ 'game_username' => 'LeonPlayer',
+ 'message' => 'Je veux rejoindre le serveur.',
+ 'server_id' => $server->id,
+ 'status' => AccessRequestStatus::PENDING->value,
+ ]);
+ }
+
+ public function test_api_always_creates_pending_access_requests(): void
+ {
+ $server = GameServers::create([
+ 'name' => 'Survival',
+ 'slug' => 'survival',
+ ]);
+
+ $this->postJson('/api/access-requests', [
+ 'game_username' => 'LeonPlayer',
+ 'server_id' => $server->id,
+ 'status' => AccessRequestStatus::ACCEPTED->value,
+ ])->assertCreated();
+
+ $this->assertDatabaseHas('access_requests', [
+ 'game_username' => 'LeonPlayer',
+ 'server_id' => $server->id,
+ 'status' => AccessRequestStatus::PENDING->value,
+ ]);
+ }
+
+ public function test_access_request_can_be_created_with_a_server_slug(): void
+ {
+ $server = GameServers::create([
+ 'name' => 'Survival',
+ 'slug' => 'survival',
+ ]);
+
+ $this->postJson('/api/access-requests', [
+ 'game_username' => 'LeonPlayer',
+ 'server_slug' => 'survival',
+ ])->assertCreated();
+
+ $this->assertDatabaseHas('access_requests', [
+ 'game_username' => 'LeonPlayer',
+ 'server_id' => $server->id,
+ 'status' => AccessRequestStatus::PENDING->value,
+ ]);
+ }
+}