Skip to content
Merged
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
13 changes: 8 additions & 5 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ permissions:
jobs:
phpunit:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.2', '8.4']

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Validate composer.json and composer.lock
run: composer validate --strict

- name: Cache Composer packages
id: composer-cache
uses: actions/cache@v3
Expand All @@ -33,14 +33,17 @@ jobs:
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.0'
php-version: ${{ matrix.php }}
extensions: mbstring, pdo, pdo_mysql
ini-values: |
memory_limit = 512M
coverage: none

- name: Validate composer.json
run: composer validate --strict

- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-suggest

- name: Run tests
run: "./vendor/bin/phpunit --version"
run: ./vendor/bin/phpunit --bootstrap vendor/autoload.php tests/isolated
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Verifier-Server is a web server designed to handle BYOND user verification.
cd Verifier-server
```

2. Install dependencies:
2. Install dependencies (PHP 8.2 or newer):
```sh
composer install
```
Expand Down Expand Up @@ -95,3 +95,20 @@ To insert a new `ss13` and `discord` record, run the following script:
## License

This project is licensed under the MIT License.

## Offline verification checks

Run `php vendor/bin/phpunit --bootstrap vendor/autoload.php tests/isolated`.
These contract tests inject storage that never writes files or opens databases;
they do not load `.env`, start the server, or contact verification services.
The older tests outside this directory use configured storage and are not run by CI.

Run `php -d xdebug.mode=off scripts/benchmark_lookups.php` for a synthetic
lookup comparison. It uses in-memory lists and performs no persistence or HTTP.
Lookup columns are cached until the list changes, preserving existing loose
comparison semantics and original row keys. A snapshot and columns consume
additional memory; database reads still follow the configured storage behavior.

Verification HTTP requests accept `application/x-www-form-urlencoded` bodies.
Body fields override corresponding legacy headers. Internal header-string calls
remain supported. Successful registrations explicitly return HTTP 200.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
}
],
"require": {
"php": "^7.4 || ^8.0",
"php": "^8.2",
"vlucas/phpdotenv": "^5.4",
"react/http": "^1.6",
"monolog/monolog": "^2.1.1 || ^3.0"
Expand Down
21 changes: 21 additions & 0 deletions scripts/benchmark_lookups.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
$state = new VerifierServer\PersistentState('benchmark-only');
$endpoint = new VerifierServer\Endpoints\VerifiedEndpoint($state);
foreach ([1000, 10000, 50000] as $size) {
$rows = [];
for ($i = 0; $i < $size; ++$i) $rows[] = ['discord' => (string) (100000 + $i), 'ss13' => 'ckey' . $i];
$state->setVerifyList($rows, false);
$target = (string) (100000 + $size - 1);
$start = hrtime(true);
for ($i = 0; $i < 200; ++$i) {
$index = array_search('absent', array_column($rows, 'ss13'));
if ($index === false) $index = array_search($target, array_column($rows, 'discord'));
}
$before = (hrtime(true) - $start) / 1e6;
$start = hrtime(true);
for ($i = 0; $i < 200; ++$i) $index = $endpoint->getIndex($target, 'absent');
$after = (hrtime(true) - $start) / 1e6;
if ($index !== $size - 1) throw new RuntimeException('Lookup mismatch');
printf("%d rows, 200 lookups: columns %.3f ms; cached columns %.3f ms\n", $size, $before, $after);
}
27 changes: 7 additions & 20 deletions src/VerifierServer/Endpoints/SS14VerifiedEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,7 @@ public function __construct(protected &$state)
*/
protected function post(ServerRequestInterface|string $request, int|string &$response, array &$headers, string &$body, bool $bypass_token = false): void
{
$formData = $request instanceof ServerRequestInterface
? $request->getHeaders()
: (is_string($request)
? self::parseHeaders($request)
: []);
$formData = $this->verificationFields($request);

$methodType = isset($formData['method'])
? strtolower(trim(is_array($formData['method']) ? $formData['method'][0] : $formData['method']))
Expand Down Expand Up @@ -136,16 +132,15 @@ protected function post(ServerRequestInterface|string $request, int|string &$res
*/
protected function __post(array &$list, string $ss14, string $discord, int|string &$response, array &$headers, string &$body): void
{
$existingDiscordIndex = array_search($discord, array_column($list, 'discord'));
$existingSS14Index = array_search($ss14, array_column($list, 'ss14'));
if ($existingDiscordIndex !== false || $existingSS14Index !== false) {
if ($this->findIndex('discord', $discord, $list) !== false || $this->findIndex('ss14', $ss14, $list) !== false) {
$response = Response::STATUS_FORBIDDEN;
$headers = ['Content-Type' => 'text/plain'];
$body = 'Forbidden';
return;
}

$this->add($discord, $ss14);
$response = Response::STATUS_OK;

$headers = ['Content-Type' => 'application/json'];
$headers['Content-Length'] = ($body = $this->__content())
Expand Down Expand Up @@ -175,23 +170,15 @@ public function remove(string $discord, string $ss14 = ''): ?array
public function fetch(int|string $key, string $value): ?array
{
$list = $this->state->getVerifyList();
return ($existingIndex = array_search($value, array_column($list, $key))) === false
? null
: $list[$existingIndex] ?? null;
$index = $this->findIndex((string) $key, $value, $list);
return $index === false ? null : $list[$index];
}

public function getIndex(string $discord = '', string $ss14 = ''): int|string|false
{
if ($discord === '' && $ss14 === '') return false;
$list = $this->state->getVerifyList();
if ($discord !== '') {
$index = array_search($discord, array_column($list, 'discord'));
if ($index !== false) return $index;
}
if ($ss14 !== '') {
$index = array_search($ss14, array_column($list, 'ss14'));
if ($index !== false) return $index;
}
return false;
if ($discord !== '' && ($index = $this->findIndex('discord', $discord, $list)) !== false) return $index;
return $ss14 !== '' ? $this->findIndex('ss14', $ss14, $list) : false;
}
}
16 changes: 16 additions & 0 deletions src/VerifierServer/Endpoints/Traits/RequestParserTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ public static function parseHeaders(string $request): array
[]);
}

/** Accept form bodies while preserving legacy header-based internal requests. */
protected function verificationFields($request): array
{
if (!$request instanceof \Psr\Http\Message\ServerRequestInterface) {
return self::parseHeaders($request);
}
$fields = array_change_key_case($request->getHeaders(), CASE_LOWER);
$contentType = strtolower(trim(explode(';', $request->getHeaderLine('Content-Type'))[0]));
if ($contentType === 'application/x-www-form-urlencoded') {
parse_str((string) $request->getBody(), $form);
foreach ($form as $key => $value) $form[$key] = is_string($value) ? $value : '';
$fields = array_replace($fields, $form);
}
return $fields;
}

public function getQueryParams(string $request): array
{
return self::__getQueryParams($request);
Expand Down
40 changes: 28 additions & 12 deletions src/VerifierServer/Endpoints/VerifiedEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,27 @@
*/
class VerifiedEndpoint extends Endpoint
{
private ?array $lookupSnapshot = null;
private array $lookupColumns = [];

/** Snapshot comparison also detects changes through SS14's reference accessor. */
protected function findIndex(string $column, string $value, ?array $list = null): int|string|false
{
$list = $list ?? $this->state->getVerifyList();
if ($this->lookupSnapshot !== $list) {
$this->lookupSnapshot = $list;
$this->lookupColumns = [];
}
if (!array_key_exists($column, $this->lookupColumns)) {
$values = [];
foreach ($list as $index => $item) {
if (array_key_exists($column, $item)) $values[$index] = $item[$column];
}
$this->lookupColumns[$column] = $values;
}
return array_search($value, $this->lookupColumns[$column]);
}

public function __construct(protected &$state)
{}

Expand Down Expand Up @@ -139,11 +160,7 @@ protected function __content(): string
*/
protected function post(ServerRequestInterface|string $request, int|string &$response, array &$headers, string &$body, bool $bypass_token = false): void
{
$formData = $request instanceof ServerRequestInterface
? $request->getHeaders()
: (is_string($request)
? self::parseHeaders($request)
: []);
$formData = $this->verificationFields($request);

$methodType = isset($formData['method'])
? strtolower(trim(is_array($formData['method']) ? $formData['method'][0] : $formData['method']))
Expand Down Expand Up @@ -207,16 +224,15 @@ protected function post(ServerRequestInterface|string $request, int|string &$res
*/
protected function __post(array &$list, string $ckey, string $discord, int|string &$response, array &$headers, string &$body): void
{
$existingDiscordIndex = array_search($discord, array_column($list, 'discord'));
$existingCkeyIndex = array_search($ckey, array_column($list, 'ss13'));
if ($existingDiscordIndex !== false || $existingCkeyIndex !== false) {
if ($this->findIndex('discord', $discord, $list) !== false || $this->findIndex('ss13', $ckey, $list) !== false) {
$response = Response::STATUS_FORBIDDEN;
$headers = ['Content-Type' => 'text/plain'];
$body = 'Forbidden';
return;
}

$this->add($discord, $ckey);
$response = Response::STATUS_OK;

$headers = ['Content-Type' => 'application/json'];
$headers['Content-Length'] = ($body = $this->__content())
Expand Down Expand Up @@ -270,12 +286,12 @@ public function remove(string $discord, string $ckey = ''): ?array
return $this->removeIndex($existingIndex);
}

public function getIndex(string $discord, string $ckey = ''): int|string|false
public function getIndex(string $discord = '', string $ckey = ''): int|string|false
{
if ($discord === '' && $ckey === '') return false;
$list = $this->state->getVerifyList();
$existingIndex = array_search($ckey, array_column($list, 'ss13'));
if ($ckey && $existingIndex === false) $existingIndex = array_search($discord, array_column($list, 'discord'));
return $existingIndex;
if ($ckey !== '' && ($index = $this->findIndex('ss13', $ckey, $list)) !== false) return $index;
return $discord !== '' ? $this->findIndex('discord', $discord, $list) : false;
}

public function removeIndex(int|string $existingIndex): array
Expand Down
95 changes: 95 additions & 0 deletions tests/isolated/VerificationContractTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use React\Http\Message\ServerRequest;
use VerifierServer\PersistentState;
use VerifierServer\SS14PersistentState;
use VerifierServer\Endpoints\VerifiedEndpoint;
use VerifierServer\Endpoints\SS14VerifiedEndpoint;

final class OfflineVerificationState extends PersistentState
{
public static function writeJson(string $file, array $data): void {}
}
final class OfflineSS14VerificationState extends SS14PersistentState
{
public static function writeJson(string $file, array $data): void {}
}
final class VerificationContractTest extends TestCase
{
/** @dataProvider platforms */
public function testRegistrationAndDuplicateContracts(bool $ss14): void
{
$state = $ss14 ? new OfflineSS14VerificationState('test-token') : new OfflineVerificationState('test-token');
$state->setVerifyList([], false);
$endpoint = $ss14 ? new SS14VerifiedEndpoint($state) : new VerifiedEndpoint($state);
$field = $ss14 ? 'ss14' : 'ckey';
$request = new ServerRequest('POST', '/verified', ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8'], http_build_query(['token' => 'test-token', 'discord' => '123', $field => 'alice']));
$status = 404; $headers = []; $body = '';
$endpoint->handle('POST', $request, $status, $headers, $body);
self::assertSame(200, $status);
self::assertCount(1, json_decode($body, true));
self::assertSame(0, $endpoint->getIndex('123'));
$endpoint->handle('POST', $request, $status, $headers, $body);
self::assertSame(403, $status);
self::assertCount(1, $state->getVerifyList());
$request = new ServerRequest('POST', '/verified', ['Content-Type' => 'application/x-www-form-urlencoded'], 'token=wrong&discord=456&'.$field.'=bob');
$endpoint->handle('POST', $request, $status, $headers, $body);
self::assertSame(401, $status);
self::assertCount(1, $state->getVerifyList());
}
public function testDuplicateCkeyCannotLinkAnotherDiscord(): void
{
$state = new OfflineVerificationState('test-token');
$state->setVerifyList([['discord' => '123', 'ss13' => 'alice']], false);
$endpoint = new VerifiedEndpoint($state);
$status = 404; $headers = []; $body = '';
$request = new ServerRequest('POST', '/verified', ['Token' => 'test-token', 'Discord' => '456', 'Ckey' => 'alice']);
$endpoint->handle('POST', $request, $status, $headers, $body);
self::assertSame(403, $status);
self::assertCount(1, $state->getVerifyList());
$request = new ServerRequest('POST', '/verified', ['Token' => 'wrong', 'Content-Type' => 'application/x-www-form-urlencoded'], 'token=test-token&discord=456&ckey=bob');
$endpoint->handle('POST', $request, $status, $headers, $body);
self::assertSame(200, $status);
self::assertCount(2, $state->getVerifyList());
}

public function platforms(): array { return [[false], [true]]; }

public function testLegacyHeadersAndDiscordOnlyDeletion(): void
{
$state = new OfflineVerificationState('test-token');
$state->setVerifyList([], false);
$endpoint = new VerifiedEndpoint($state);
$status = 404; $headers = []; $body = '';
$endpoint->handle('POST', "token: test-token".PHP_EOL."discord: 123".PHP_EOL."ckey: alice", $status, $headers, $body);
self::assertSame(200, $status);
$endpoint->handle('POST', "token: test-token".PHP_EOL."discord: 123".PHP_EOL."method: delete", $status, $headers, $body);
self::assertSame(200, $status);
self::assertSame([], $state->getVerifyList());
}

public function testSparseKeysAndLookupPrecedence(): void
{
$state = new OfflineVerificationState('test-token');
$state->setVerifyList([7 => ['discord' => '123', 'ss13' => 'alice'], 12 => ['discord' => '456', 'ss13' => 'bob']], false);
$endpoint = new VerifiedEndpoint($state);
self::assertSame(12, $endpoint->getIndex('123', 'bob'));
self::assertSame(7, $endpoint->getIndex('123', 'absent'));
self::assertFalse($endpoint->getIndex('', ''));
self::assertFalse($endpoint->getIndex('absent'));
$ss14 = new OfflineSS14VerificationState('test-token');
$ss14->setVerifyList([7 => ['discord' => '123', 'ss14' => 'alice'], 12 => ['discord' => '456', 'ss14' => 'bob']], false);
$endpoint = new SS14VerifiedEndpoint($ss14);
self::assertSame(7, $endpoint->getIndex('123', 'bob'));
self::assertSame(['discord' => '456', 'ss14' => 'bob'], $endpoint->fetch('ss14', 'bob'));
self::assertNull($endpoint->fetch('ss14', 'absent'));
$live = &$ss14->getVerifyList();
$live[7]['discord'] = '999';
self::assertFalse($endpoint->getIndex('123'));
self::assertSame(7, $endpoint->getIndex('999'));
$ss14->setVerifyList([], false);
self::assertFalse($endpoint->getIndex('123'));
self::assertNull($endpoint->fetch('ss14', 'bob'));
}
}
Loading