Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

namespace Boy132\PlayerCounter\Extensions\Query\Schemas;

use App\Models\Allocation;
use Boy132\PlayerCounter\Extensions\Query\QueryTypeSchemaInterface;
use Exception;

class TeamSpeakQueryTypeSchema implements QueryTypeSchemaInterface
{
public function getId(): string
{
return 'teamspeak';
}

public function getName(): string
{
return 'TeamSpeak 3 (ServerQuery)';
}

public function resolvePort(Allocation $allocation): ?int
{
$server = $allocation->server;
if (!$server) {
return null;
}

$variable = $server->variables()->where('env_variable', 'QUERY_PORT')->first();
if (!$variable) {
return null;
}

$port = (int) ($variable->server_value ?: $variable->default_value);

return $port > 0 ? $port : null;
}

/** @return ?array{hostname: string, map: string, current_players: int, max_players: int, players: ?array<array{id: string, name: string}>} */
public function process(string $ip, int $port): ?array
{
$socket = null;

try {
$socket = @fsockopen($ip, $port, $errno, $errstr, 5);
if ($socket === false) {
throw new Exception("Could not connect to TeamSpeak ServerQuery: $errstr ($errno)");
}

stream_set_timeout($socket, 5);

// Read greeting: "TS3", welcome text, empty line
$greeting = fgets($socket);
if (!str_starts_with(trim($greeting), 'TS3')) {
throw new Exception('Not a TeamSpeak 3 ServerQuery interface');
}
Comment on lines +52 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find and read the file
find . -name "TeamSpeakQueryTypeSchema.php" -type f

Repository: pelican-dev/plugins

Length of output: 137


🏁 Script executed:

# Also check for composer.json to understand PHP version requirements
find . -name "composer.json" -type f

Repository: pelican-dev/plugins

Length of output: 45


🏁 Script executed:

# Read the file to verify the concerns
cat -n ./player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php

Repository: pelican-dev/plugins

Length of output: 5772


🏁 Script executed:

# Check for composer.json in player-counter directory
cat -n ./player-counter/composer.json 2>/dev/null || echo "No composer.json in player-counter"

Repository: pelican-dev/plugins

Length of output: 97


🏁 Script executed:

# Check PHP version requirements at repository root
find . -maxdepth 2 -name "composer.json" -type f | head -5

Repository: pelican-dev/plugins

Length of output: 45


🏁 Script executed:

# Check if there are any other files that might indicate PHP version or linting rules
find . -maxdepth 2 \( -name ".php-version" -o -name "phpstan.neon" -o -name "pint.json" \) -type f

Repository: pelican-dev/plugins

Length of output: 89


🏁 Script executed:

# Check PHPStan and Pint configurations
cat -n ./phpstan.neon

Repository: pelican-dev/plugins

Length of output: 385


🏁 Script executed:

# Check Pint configuration
cat -n ./pint.json

Repository: pelican-dev/plugins

Length of output: 367


Guard greeting reads and catch \Throwable to prevent uncaught runtime failures.

Line 53: fgets() can return false on timeout or stream closure. Calling trim($greeting) with a false value throws TypeError in PHP 8+, which is not caught by catch (Exception) since TypeError extends \Error, not \Exception.

Additionally, line 97 has a missing parameter type hint on readUntilError($socket), which violates PHPStan level 6 checking.

Proposed fix
 use Exception;
+use Throwable;
@@
-            $greeting = fgets($socket);
-            if (!str_starts_with(trim($greeting), 'TS3')) {
+            $greeting = fgets($socket);
+            if (!is_string($greeting) || !str_starts_with(trim($greeting), 'TS3')) {
                 throw new Exception('Not a TeamSpeak 3 ServerQuery interface');
             }
@@
-        } catch (Exception $exception) {
+        } catch (Throwable $exception) {
             report($exception);
         } finally {
@@
-    private function readUntilError($socket): string
+    private function readUntilError(\Socket|false $socket): string
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php`
around lines 52 - 55, The greeting read uses fgets($socket) and then
trim($greeting) without validating the return value and the exception handler
only catches Exception, so change the greeting guard to check for falsy/false
return from fgets before calling trim and throw a descriptive Exception if
reading failed; also broaden the catch to catch \Throwable where the greeting is
validated (reference fgets and trim usage and the surrounding try/catch block)
so TypeError/\Error are handled. Additionally, add the missing parameter type
hint to the readUntilError($socket) function signature (use the same
socket/resource type used elsewhere) to satisfy PHPStan level 6 and ensure
consistent typing.

fgets($socket); // welcome line
fgets($socket); // empty line

// Select first virtual server
fwrite($socket, "use sid=1\n");
$this->readUntilError($socket);

// Get server info
fwrite($socket, "serverinfo\n");
$infoLine = $this->readUntilError($socket);

// Get client list
fwrite($socket, "clientlist\n");
$clientLine = $this->readUntilError($socket);

fwrite($socket, "quit\n");

$info = $this->parseLine($infoLine);
$clients = $this->parseClientList($clientLine);

return [
'hostname' => $this->unescape($info['virtualserver_name'] ?? 'Unknown'),
'map' => 'TeamSpeak',
'current_players' => count($clients),
'max_players' => (int) ($info['virtualserver_maxclients'] ?? 0),
'players' => array_map(fn ($c) => [
'id' => $c['clid'] ?? '0',
'name' => $this->unescape($c['client_nickname'] ?? 'Unknown'),
], $clients),
];
} catch (Exception $exception) {
report($exception);
} finally {
if (!empty($socket)) {
fclose($socket);
}
}

return null;
}

/** @param resource $socket */
private function readUntilError($socket): string
{
$data = '';
while (!feof($socket)) {
$line = fgets($socket);
if ($line === false) {
break;
}
$trimmed = trim($line);
if (str_starts_with($trimmed, 'error ')) {
break;
}
if ($trimmed !== '') {
$data = $trimmed;
}
}

return $data;
}
Comment on lines +98 to +116
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "TeamSpeakQueryTypeSchema.php" | head -20

Repository: pelican-dev/plugins

Length of output: 137


🏁 Script executed:

cat -n ./player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php | sed -n '90,130p'

Repository: pelican-dev/plugins

Length of output: 1385


🏁 Script executed:

cat -n ./player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php | head -50

Repository: pelican-dev/plugins

Length of output: 1787


🏁 Script executed:

cat -n ./player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php | sed -n '50,100p'

Repository: pelican-dev/plugins

Length of output: 2182


🏁 Script executed:

rg "readUntilError" ./player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php

Repository: pelican-dev/plugins

Length of output: 273


🏁 Script executed:

cat -n ./player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php | sed -n '116,127p'

Repository: pelican-dev/plugins

Length of output: 514


readUntilError() silently converts command failures into successful results and lacks socket parameter typing.

The function stops on any error ... line without validating whether id=0. When a TS3 ServerQuery command fails (e.g., error id=1024 msg=...), the accumulated data is still returned and subsequently parsed as a successful response, masking the actual failure. Additionally, the untyped $socket parameter causes PHPStan failures in strict mode.

Proposed fix
-    private function readUntilError($socket): string
+    /**
+     * `@param` resource $socket
+     */
+    private function readUntilError(mixed $socket): string
     {
         $data = '';
         while (!feof($socket)) {
             $line = fgets($socket);
             if ($line === false) {
                 break;
             }
             $trimmed = trim($line);
             if (str_starts_with($trimmed, 'error ')) {
+                $error = $this->parseLine($trimmed);
+                if (($error['id'] ?? '1') !== '0') {
+                    throw new Exception('TeamSpeak ServerQuery command failed: ' . ($error['msg'] ?? 'unknown'));
+                }
                 break;
             }
             if ($trimmed !== '') {
                 $data = $trimmed;
             }
         }
         return $data;
     }
🧰 Tools
🪛 GitHub Check: PHPStan (8.2)

[failure] 97-97:
Method Boy132\PlayerCounter\Extensions\Query\Schemas\TeamSpeakQueryTypeSchema::readUntilError() has parameter $socket with no type specified.

🪛 GitHub Check: PHPStan (8.3)

[failure] 97-97:
Method Boy132\PlayerCounter\Extensions\Query\Schemas\TeamSpeakQueryTypeSchema::readUntilError() has parameter $socket with no type specified.

🪛 GitHub Check: PHPStan (8.4)

[failure] 97-97:
Method Boy132\PlayerCounter\Extensions\Query\Schemas\TeamSpeakQueryTypeSchema::readUntilError() has parameter $socket with no type specified.

🪛 GitHub Check: PHPStan (8.5)

[failure] 97-97:
Method Boy132\PlayerCounter\Extensions\Query\Schemas\TeamSpeakQueryTypeSchema::readUntilError() has parameter $socket with no type specified.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php`
around lines 97 - 114, The readUntilError function currently treats any "error
..." line as a termination but still returns accumulated data (and overwrites
rather than accumulates), hiding failures and lacks socket typing; update
readUntilError to declare the socket parameter as a resource in the PHPDoc
(e.g., `@param` resource $socket) and change the loop so it accumulates non-empty
trimmed lines into $data (append instead of overwrite) and when encountering a
line that starts with "error " parse the id (e.g., regex for id=(\d+)) and if
the id is not "0" throw/return a proper exception or error object containing the
id and message instead of returning $data, otherwise (id==0) break and return
the accumulated success data; reference the readUntilError function and the
local variables $socket, $line, $trimmed, and $data to locate where to apply
these changes.


/** @return array<string, string> */
private function parseLine(string $line): array
{
$result = [];
foreach (explode(' ', $line) as $token) {
if (str_contains($token, '=')) {
[$key, $value] = explode('=', $token, 2);
$result[$key] = $value;
}
}

return $result;
}

/** @return array<int, array<string, string>> */
private function parseClientList(string $line): array
{
$clients = [];
foreach (explode('|', $line) as $entry) {
$client = $this->parseLine($entry);
// Skip ServerQuery clients (client_type=1)
if (($client['client_type'] ?? '0') === '1') {
continue;
}
$clients[] = $client;
}

return $clients;
}

private function unescape(string $value): string
{
return str_replace(
['\\s', '\\p', '\\/', '\\\\', '\\n', '\\r', '\\t', '\\a', '\\b', '\\f', '\\v'],
[' ', '|', '/', '\\', "\n", "\r", "\t", "\x07", "\x08", "\x0C", "\x0B"],
$value
);
}
}
28 changes: 25 additions & 3 deletions player-counter/src/Models/GameQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,26 @@ public function runQuery(Allocation $allocation): ?array
return null;
}

$ip = config('player-counter.use_alias') && is_ip($allocation->alias) ? $allocation->alias : $allocation->ip;
$ip = static::resolveIp($allocation);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the GameQuery.php file
find . -name "GameQuery.php" -type f

Repository: pelican-dev/plugins

Length of output: 104


🏁 Script executed:

# Read the GameQuery.php file to inspect the code
cat -n player-counter/src/Models/GameQuery.php

Repository: pelican-dev/plugins

Length of output: 2940


🏁 Script executed:

# Look for PHPStan configuration
find . -name "phpstan.neon*" -o -name "phpstan.json" -o -name ".phpstan*" 2>/dev/null | head -20

Repository: pelican-dev/plugins

Length of output: 77


🏁 Script executed:

# Search for any CI configuration that runs PHPStan
find . -name ".github" -o -name ".gitlab-ci.yml" -o -name ".travis.yml" -o -name "*.yml" 2>/dev/null | grep -E "(github|gitlab|github)" | head -10

Repository: pelican-dev/plugins

Length of output: 146


🏁 Script executed:

# Check if there are other usages of this private method besides lines 42 and 70
rg "resolveIp" player-counter/src/Models/GameQuery.php -n

Repository: pelican-dev/plugins

Length of output: 233


🏁 Script executed:

cat -n phpstan.neon

Repository: pelican-dev/plugins

Length of output: 385


🏁 Script executed:

cat -n .github/workflows/lint.yml

Repository: pelican-dev/plugins

Length of output: 2537


Use self::resolveIp() to fix unsafe private static dispatch.

Lines 42 and 70 call a private static method via static::, which violates PHPStan analysis at level 6. Late static binding is semantically inconsistent with private visibility—private methods cannot be overridden, so static:: serves no purpose. Change to self::.

Proposed fix
-        $ip = static::resolveIp($allocation);
+        $ip = self::resolveIp($allocation);

Apply to lines 42 and 70.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$ip = static::resolveIp($allocation);
$ip = self::resolveIp($allocation);
🧰 Tools
🪛 GitHub Check: PHPStan (8.2)

[failure] 42-42:
Unsafe call to private method Boy132\PlayerCounter\Models\GameQuery::resolveIp() through static::.

🪛 GitHub Check: PHPStan (8.3)

[failure] 42-42:
Unsafe call to private method Boy132\PlayerCounter\Models\GameQuery::resolveIp() through static::.

🪛 GitHub Check: PHPStan (8.4)

[failure] 42-42:
Unsafe call to private method Boy132\PlayerCounter\Models\GameQuery::resolveIp() through static::.

🪛 GitHub Check: PHPStan (8.5)

[failure] 42-42:
Unsafe call to private method Boy132\PlayerCounter\Models\GameQuery::resolveIp() through static::.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@player-counter/src/Models/GameQuery.php` at line 42, Calls to the private
static method resolveIp in GameQuery are using late static binding
(static::resolveIp), which trips PHPStan level 6 because private methods cannot
be overridden; change those call sites to self::resolveIp (both occurrences
currently at the resolveIp invocations) so the private static dispatch is safe
and consistent with visibility.

$ip = is_ipv6($ip) ? '[' . $ip . ']' : $ip;

/** @var QueryTypeService $service */
$service = app(QueryTypeService::class);

return $service->get($this->query_type)?->process($ip, $allocation->port + ($this->query_port_offset ?? 0));
$schema = $service->get($this->query_type);
if (!$schema) {
return null;
}

// Allow schema to provide its own port resolution (e.g. from server variables)
if (method_exists($schema, 'resolvePort')) {
$resolvedPort = $schema->resolvePort($allocation);
if ($resolvedPort !== null) {
return $schema->process($ip, $resolvedPort);
}
}

return $schema->process($ip, $allocation->port + ($this->query_port_offset ?? 0));
}

public static function canRunQuery(?Allocation $allocation): bool
Expand All @@ -54,8 +67,17 @@ public static function canRunQuery(?Allocation $allocation): bool
return false;
}

$ip = config('player-counter.use_alias') && is_ip($allocation->alias) ? $allocation->alias : $allocation->ip;
$ip = static::resolveIp($allocation);

return !in_array($ip, ['0.0.0.0', '::']);
}

protected static function resolveIp(Allocation $allocation): string
{
if (config('player-counter.use_alias') && !empty($allocation->ip_alias)) {
return $allocation->ip_alias;
}

return $allocation->ip;
}
}
2 changes: 2 additions & 0 deletions player-counter/src/Providers/PlayerCounterPluginProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Boy132\PlayerCounter\Extensions\Query\Schemas\MinecraftBedrockQueryTypeSchema;
use Boy132\PlayerCounter\Extensions\Query\Schemas\MinecraftJavaQueryTypeSchema;
use Boy132\PlayerCounter\Extensions\Query\Schemas\SourceQueryTypeSchema;
use Boy132\PlayerCounter\Extensions\Query\Schemas\TeamSpeakQueryTypeSchema;
use Boy132\PlayerCounter\Filament\Server\Widgets\ServerPlayerWidget;
use Boy132\PlayerCounter\Models\EggGameQuery;
use Boy132\PlayerCounter\Models\GameQuery;
Expand All @@ -35,6 +36,7 @@ public function register(): void
$service->register(new MinecraftJavaQueryTypeSchema());
$service->register(new MinecraftBedrockQueryTypeSchema());
$service->register(new CitizenFXQueryTypeSchema());
$service->register(new TeamSpeakQueryTypeSchema());

return $service;
});
Expand Down