diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b25ce93..da21c31 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 @@ -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 diff --git a/README.md b/README.md index a50c3d6..6a74fe6 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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. diff --git a/composer.json b/composer.json index c5ba001..1e9f8f0 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/scripts/benchmark_lookups.php b/scripts/benchmark_lookups.php new file mode 100644 index 0000000..744214a --- /dev/null +++ b/scripts/benchmark_lookups.php @@ -0,0 +1,21 @@ + (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); +} diff --git a/src/VerifierServer/Endpoints/SS14VerifiedEndpoint.php b/src/VerifierServer/Endpoints/SS14VerifiedEndpoint.php index 1b5ffaf..d8a7a9b 100644 --- a/src/VerifierServer/Endpoints/SS14VerifiedEndpoint.php +++ b/src/VerifierServer/Endpoints/SS14VerifiedEndpoint.php @@ -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'])) @@ -136,9 +132,7 @@ 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'; @@ -146,6 +140,7 @@ protected function __post(array &$list, string $ss14, string $discord, int|strin } $this->add($discord, $ss14); + $response = Response::STATUS_OK; $headers = ['Content-Type' => 'application/json']; $headers['Content-Length'] = ($body = $this->__content()) @@ -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; } } diff --git a/src/VerifierServer/Endpoints/Traits/RequestParserTrait.php b/src/VerifierServer/Endpoints/Traits/RequestParserTrait.php index 7a31748..c650bc6 100644 --- a/src/VerifierServer/Endpoints/Traits/RequestParserTrait.php +++ b/src/VerifierServer/Endpoints/Traits/RequestParserTrait.php @@ -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); diff --git a/src/VerifierServer/Endpoints/VerifiedEndpoint.php b/src/VerifierServer/Endpoints/VerifiedEndpoint.php index e54bf69..8b3b580 100644 --- a/src/VerifierServer/Endpoints/VerifiedEndpoint.php +++ b/src/VerifierServer/Endpoints/VerifiedEndpoint.php @@ -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) {} @@ -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'])) @@ -207,9 +224,7 @@ 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'; @@ -217,6 +232,7 @@ protected function __post(array &$list, string $ckey, string $discord, int|strin } $this->add($discord, $ckey); + $response = Response::STATUS_OK; $headers = ['Content-Type' => 'application/json']; $headers['Content-Length'] = ($body = $this->__content()) @@ -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 diff --git a/tests/isolated/VerificationContractTest.php b/tests/isolated/VerificationContractTest.php new file mode 100644 index 0000000..d7550f9 --- /dev/null +++ b/tests/isolated/VerificationContractTest.php @@ -0,0 +1,95 @@ +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')); + } +}