diff --git a/README.md b/README.md index 43466da..336b576 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,53 @@ Verifier-Server is a web server designed to handle BYOND user verification. - `GET /verified`: Retrieve the list of verified users. - `POST /verified`: Add or delete a verified user. Requires a valid token. +- `GET /ss14verified` and `POST /ss14verified`: the same contract for Space Station 14 + accounts, with an `ss14` field instead of `ckey`. + +A trailing slash is accepted (`/verified/`), and `/` is an alias of `/verified`. + +## HTTP contract + +This is the contract [Civilizationbot](https://github.com/Valgorithms/Civilizationbot) +relies on for `approveme`; keep it backward compatible. + +### Records + +```json +[{"discord": "123456789012345678", "ss13": "alice", "create_time": "2026-01-01 12:00:00"}] +``` + +Each record links exactly one Discord user id to one BYOND ckey. A `ckey` is +stored in its normalised form: lowercased, with `.`, `_`, `-`, `+` and spaces +removed, which is how BYOND and the bot compare keys. + +### `POST /verified` + +Body: `application/x-www-form-urlencoded` (the legacy `Field: value` header +form is still accepted for in-process callers). Fields: + +| Field | Required | Meaning | +| --------- | -------- | ------- | +| `token` | yes | Must equal the configured `TOKEN`. | +| `discord` | insert | Discord user id, 17 to 20 digits. | +| `ckey` | insert | BYOND key, normalised before storage. | +| `method` | no | `delete` removes the record matching `ckey`, else `discord`. Anything else inserts. | + +Status codes: + +| Status | Meaning | +| ------ | ------- | +| 200 | Inserted (body: the full list) or deleted (body: the removed records). | +| 400 | `discord` or `ckey` is malformed. Nothing was stored. | +| 401 | Missing or wrong `token`, or the server still uses the default token. | +| 403 | The Discord id or the (normalised) ckey is already registered. | +| 404 | `delete` found no matching record. | +| 405 | Unsupported HTTP method. | + +### `GET /verified` + +Returns the full list as JSON. There is no read token; do not host the +server on a public interface unless that is intended. ## Running Tests diff --git a/src/VerifierServer/Endpoints/SS14VerifiedEndpoint.php b/src/VerifierServer/Endpoints/SS14VerifiedEndpoint.php index d8a7a9b..a1480d1 100644 --- a/src/VerifierServer/Endpoints/SS14VerifiedEndpoint.php +++ b/src/VerifierServer/Endpoints/SS14VerifiedEndpoint.php @@ -50,6 +50,24 @@ class SS14VerifiedEndpoint extends VerifiedEndpoint public function __construct(protected &$state) {} + /** SS14 user names keep their spelling; they only need to be printable and bounded. */ + protected function validateRecord(string $discord, string &$account): ?string + { + if (preg_match(self::DISCORD_ID_PATTERN, $discord) !== 1) { + return 'discord must be a Discord user id (17 to 20 digits)'; + } + $account = trim($account); + if ($account === '' || strlen($account) > 256 || preg_match('/[\x00-\x1F\x7F]/', $account) === 1) { + return 'ss14 must be a printable user name of at most 256 bytes'; + } + return null; + } + + protected function findAccount(string $account, array $list): int|string|false + { + return $this->findIndex('ss14', $account, $list); + } + /** * Handles POST requests by parsing the request data and performing actions based on the method type. * @@ -132,7 +150,13 @@ 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 { - if ($this->findIndex('discord', $discord, $list) !== false || $this->findIndex('ss14', $ss14, $list) !== false) { + if ($problem = $this->validateRecord($discord, $ss14)) { + $response = Response::STATUS_BAD_REQUEST; + $headers = ['Content-Type' => 'text/plain']; + $body = "Bad Request: $problem"; + return; + } + if ($this->findIndex('discord', $discord, $list) !== false || $this->findAccount($ss14, $list) !== false) { $response = Response::STATUS_FORBIDDEN; $headers = ['Content-Type' => 'text/plain']; $body = 'Forbidden'; diff --git a/src/VerifierServer/Endpoints/VerifiedEndpoint.php b/src/VerifierServer/Endpoints/VerifiedEndpoint.php index 8b3b580..546adfa 100644 --- a/src/VerifierServer/Endpoints/VerifiedEndpoint.php +++ b/src/VerifierServer/Endpoints/VerifiedEndpoint.php @@ -48,25 +48,66 @@ */ class VerifiedEndpoint extends Endpoint { + /** Discord user ids are snowflakes: 17 to 20 decimal digits. */ + public const DISCORD_ID_PATTERN = '/^[0-9]{17,20}$/D'; + /** A normalised BYOND ckey: lowercase letters and digits only. */ + public const CKEY_PATTERN = '/^[a-z0-9]{1,256}$/D'; + 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 + protected function findIndex(string $column, string $value, ?array $list = null, ?callable $normalize = 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)) { + $cacheKey = $normalize ? "$column#normalized" : $column; + if (!array_key_exists($cacheKey, $this->lookupColumns)) { $values = []; foreach ($list as $index => $item) { - if (array_key_exists($column, $item)) $values[$index] = $item[$column]; + if (!array_key_exists($column, $item)) continue; + $values[$index] = $normalize && is_string($item[$column]) ? $normalize($item[$column]) : $item[$column]; } - $this->lookupColumns[$column] = $values; + $this->lookupColumns[$cacheKey] = $values; + } + return array_search($normalize ? $normalize($value) : $value, $this->lookupColumns[$cacheKey]); + } + + /** + * Reduces a BYOND key to its ckey form the same way Civilizationbot's + * sanitizeInput() and HostedVerificationSource::validate() do, so that + * two spellings of one account can never both be registered. + */ + public static function normalizeCkey(string $key): string + { + return str_replace(['.', '_', '-', '+', ' '], '', strtolower(trim($key))); + } + + /** + * Validates and canonicalises a record before it is stored. + * + * @return string|null A human-readable problem, or null when the record is acceptable. + * `$account` is replaced with its canonical spelling on success. + */ + protected function validateRecord(string $discord, string &$account): ?string + { + if (preg_match(self::DISCORD_ID_PATTERN, $discord) !== 1) { + return 'discord must be a Discord user id (17 to 20 digits)'; } - return array_search($value, $this->lookupColumns[$column]); + $account = self::normalizeCkey($account); + if (preg_match(self::CKEY_PATTERN, $account) !== 1) { + return 'ckey must be a BYOND key (letters and digits after normalisation)'; + } + return null; + } + + /** Index of a record whose account matches `$account` after normalisation, or false. */ + protected function findAccount(string $account, array $list): int|string|false + { + return $this->findIndex('ss13', $account, $list, [self::class, 'normalizeCkey']); } public function __construct(protected &$state) @@ -210,10 +251,11 @@ protected function post(ServerRequestInterface|string $request, int|string &$res /** * Handles the default verification process. * - * This method checks if the provided `ckey` or `discord` already exists in the list. - * If either exists, it sets the response to a 403 Forbidden status. - * If neither exists, it adds the new entry to the list, writes the updated list to a JSON file, - * updates the state, and sets the response to a 200 OK status. + * This method first validates the record (400 Bad Request when the Discord id or ckey + * is malformed), then checks if the provided `ckey` or `discord` already exists in the list, + * comparing ckeys after normalisation. If either exists, it sets the response to a + * 403 Forbidden status. If neither exists, it adds the new entry to the list, writes the + * updated list to a JSON file, updates the state, and sets the response to a 200 OK status. * * @param array $list The list of existing entries. * @param string $ckey The ckey to be verified. @@ -224,7 +266,13 @@ 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 { - if ($this->findIndex('discord', $discord, $list) !== false || $this->findIndex('ss13', $ckey, $list) !== false) { + if ($problem = $this->validateRecord($discord, $ckey)) { + $response = Response::STATUS_BAD_REQUEST; + $headers = ['Content-Type' => 'text/plain']; + $body = "Bad Request: $problem"; + return; + } + if ($this->findIndex('discord', $discord, $list) !== false || $this->findAccount($ckey, $list) !== false) { $response = Response::STATUS_FORBIDDEN; $headers = ['Content-Type' => 'text/plain']; $body = 'Forbidden'; diff --git a/tests/isolated/RecordValidationTest.php b/tests/isolated/RecordValidationTest.php new file mode 100644 index 0000000..827f303 --- /dev/null +++ b/tests/isolated/RecordValidationTest.php @@ -0,0 +1,118 @@ + 'application/x-www-form-urlencoded'], http_build_query($fields + ['token' => 'test-token'])); + $status = 404; $headers = []; $body = ''; + $endpoint->handle('POST', $request, $status, $headers, $body); + return [$status, $body]; + } + + private function endpoint(array $list = []): VerifiedEndpoint + { + $state = new ValidationMemoryState('test-token'); + $state->setVerifyList($list, false); + return new VerifiedEndpoint($state); + } + + /** @dataProvider malformedRecords */ + public function testMalformedRecordsAreRejectedWith400(array $fields, string $problem): void + { + $endpoint = $this->endpoint(); + [$status, $body] = $this->post($endpoint, $fields); + self::assertSame(400, $status); + self::assertStringContainsString($problem, $body); + self::assertSame([], $endpoint->getState()->getVerifyList()); + } + + public function malformedRecords(): array + { + return [ + 'no fields' => [[], 'discord'], + 'short discord' => [['discord' => '123', 'ckey' => 'alice'], 'discord'], + 'non-numeric discord' => [['discord' => '12345678901234567x', 'ckey' => 'alice'], 'discord'], + 'mention' => [['discord' => '<@123456789012345678>', 'ckey' => 'alice'], 'discord'], + 'empty ckey' => [['discord' => '123456789012345678', 'ckey' => ''], 'ckey'], + 'ckey of separators only' => [['discord' => '123456789012345678', 'ckey' => '_ -.'], 'ckey'], + 'control characters' => [['discord' => '123456789012345678', 'ckey' => "al\x01ice"], 'ckey'], + 'unicode' => [['discord' => '123456789012345678', 'ckey' => 'alĂ­ce'], 'ckey'], + ]; + } + + public function testKeysAreStoredAsNormalisedCkeys(): void + { + $endpoint = $this->endpoint(); + [$status, $body] = $this->post($endpoint, ['discord' => '123456789012345678', 'ckey' => ' Al_ice-Smith.Jr ']); + self::assertSame(200, $status); + self::assertSame('alicesmithjr', json_decode($body, true)[0]['ss13']); + self::assertSame('alicesmithjr', $endpoint->getState()->getVerifyList()[0]['ss13']); + self::assertSame(0, $endpoint->getIndex('', 'alicesmithjr')); + } + + public function testAnotherSpellingOfARegisteredKeyIsADuplicate(): void + { + $endpoint = $this->endpoint([['discord' => '123456789012345678', 'ss13' => 'alice']]); + [$status] = $this->post($endpoint, ['discord' => '234567890123456789', 'ckey' => 'Al_ice']); + self::assertSame(403, $status); + $endpoint = $this->endpoint([['discord' => '123456789012345678', 'ss13' => 'Al Ice']]); + [$status] = $this->post($endpoint, ['discord' => '234567890123456789', 'ckey' => 'alice']); + self::assertSame(403, $status, 'legacy records are compared after normalisation too'); + self::assertCount(1, $endpoint->getState()->getVerifyList()); + [$status] = $this->post($endpoint, ['discord' => '234567890123456789', 'ckey' => 'bob']); + self::assertSame(200, $status); + self::assertCount(2, $endpoint->getState()->getVerifyList()); + } + + public function testLegacyHeaderRequestsAreValidatedToo(): void + { + $endpoint = $this->endpoint(); + $status = 404; $headers = []; $body = ''; + $endpoint->handle('POST', "token: test-token".PHP_EOL."discord: 42".PHP_EOL."ckey: alice", $status, $headers, $body); + self::assertSame(400, $status); + $endpoint->handle('POST', "token: test-token".PHP_EOL."discord: 123456789012345678".PHP_EOL."ckey: Alice", $status, $headers, $body); + self::assertSame(200, $status); + self::assertSame('alice', $endpoint->getState()->getVerifyList()[0]['ss13']); + } + + public function testDeletionDoesNotRequireAValidRecord(): void + { + $endpoint = $this->endpoint([['discord' => '123456789012345678', 'ss13' => 'alice']]); + [$status] = $this->post($endpoint, ['method' => 'delete', 'discord' => 'alice', 'ckey' => 'alice']); + self::assertSame(200, $status); + self::assertSame([], $endpoint->getState()->getVerifyList()); + } + + public function testSs14NamesKeepTheirSpellingButMustBePrintable(): void + { + $state = new ValidationMemorySS14State('test-token'); + $state->setVerifyList([], false); + $endpoint = new SS14VerifiedEndpoint($state); + [$status] = $this->post($endpoint, ['discord' => '123456789012345678', 'ss14' => 'Space Captain_42']); + self::assertSame(200, $status); + self::assertSame('Space Captain_42', $state->getVerifyList()[0]['ss14']); + [$status] = $this->post($endpoint, ['discord' => '234567890123456789', 'ss14' => "bad\x00name"]); + self::assertSame(400, $status); + [$status] = $this->post($endpoint, ['discord' => '123', 'ss14' => 'Someone']); + self::assertSame(400, $status); + [$status] = $this->post($endpoint, ['discord' => '234567890123456789', 'ss14' => 'Space Captain_42']); + self::assertSame(403, $status); + } +} diff --git a/tests/isolated/VerificationContractTest.php b/tests/isolated/VerificationContractTest.php index d7550f9..a5c2fc9 100644 --- a/tests/isolated/VerificationContractTest.php +++ b/tests/isolated/VerificationContractTest.php @@ -24,16 +24,16 @@ public function testRegistrationAndDuplicateContracts(bool $ss14): void $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'])); + $request = new ServerRequest('POST', '/verified', ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8'], http_build_query(['token' => 'test-token', 'discord' => '100000000000000123', $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')); + self::assertSame(0, $endpoint->getIndex('100000000000000123')); $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'); + $request = new ServerRequest('POST', '/verified', ['Content-Type' => 'application/x-www-form-urlencoded'], 'token=wrong&discord=100000000000000456&'.$field.'=bob'); $endpoint->handle('POST', $request, $status, $headers, $body); self::assertSame(401, $status); self::assertCount(1, $state->getVerifyList()); @@ -41,14 +41,14 @@ public function testRegistrationAndDuplicateContracts(bool $ss14): void public function testDuplicateCkeyCannotLinkAnotherDiscord(): void { $state = new OfflineVerificationState('test-token'); - $state->setVerifyList([['discord' => '123', 'ss13' => 'alice']], false); + $state->setVerifyList([['discord' => '100000000000000123', 'ss13' => 'alice']], false); $endpoint = new VerifiedEndpoint($state); $status = 404; $headers = []; $body = ''; - $request = new ServerRequest('POST', '/verified', ['Token' => 'test-token', 'Discord' => '456', 'Ckey' => 'alice']); + $request = new ServerRequest('POST', '/verified', ['Token' => 'test-token', 'Discord' => '100000000000000456', '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'); + $request = new ServerRequest('POST', '/verified', ['Token' => 'wrong', 'Content-Type' => 'application/x-www-form-urlencoded'], 'token=test-token&discord=100000000000000456&ckey=bob'); $endpoint->handle('POST', $request, $status, $headers, $body); self::assertSame(200, $status); self::assertCount(2, $state->getVerifyList()); @@ -62,9 +62,9 @@ public function testLegacyHeadersAndDiscordOnlyDeletion(): void $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); + $endpoint->handle('POST', "token: test-token".PHP_EOL."discord: 100000000000000123".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); + $endpoint->handle('POST', "token: test-token".PHP_EOL."discord: 100000000000000123".PHP_EOL."method: delete", $status, $headers, $body); self::assertSame(200, $status); self::assertSame([], $state->getVerifyList()); } @@ -72,24 +72,24 @@ public function testLegacyHeadersAndDiscordOnlyDeletion(): void public function testSparseKeysAndLookupPrecedence(): void { $state = new OfflineVerificationState('test-token'); - $state->setVerifyList([7 => ['discord' => '123', 'ss13' => 'alice'], 12 => ['discord' => '456', 'ss13' => 'bob']], false); + $state->setVerifyList([7 => ['discord' => '100000000000000123', 'ss13' => 'alice'], 12 => ['discord' => '100000000000000456', 'ss13' => 'bob']], false); $endpoint = new VerifiedEndpoint($state); - self::assertSame(12, $endpoint->getIndex('123', 'bob')); - self::assertSame(7, $endpoint->getIndex('123', 'absent')); + self::assertSame(12, $endpoint->getIndex('100000000000000123', 'bob')); + self::assertSame(7, $endpoint->getIndex('100000000000000123', '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); + $ss14->setVerifyList([7 => ['discord' => '100000000000000123', 'ss14' => 'alice'], 12 => ['discord' => '100000000000000456', '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::assertSame(7, $endpoint->getIndex('100000000000000123', 'bob')); + self::assertSame(['discord' => '100000000000000456', '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')); + $live[7]['discord'] = '100000000000000999'; + self::assertFalse($endpoint->getIndex('100000000000000123')); + self::assertSame(7, $endpoint->getIndex('100000000000000999')); $ss14->setVerifyList([], false); - self::assertFalse($endpoint->getIndex('123')); + self::assertFalse($endpoint->getIndex('100000000000000123')); self::assertNull($endpoint->fetch('ss14', 'bob')); } }