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
3 changes: 1 addition & 2 deletions src/VerifierServer/Endpoints/SS14VerifiedEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,7 @@ public function add(string $discord, string $ss14): void
'ss14' => $ss14,
'create_time' => date('Y-m-d H:i:s')
];
$this->state::writeJson($this->state->getJsonPath(), $list);
$this->state->setVerifyList($list);
$this->state->saveVerifyList($list);
}

public function remove(string $discord, string $ss14 = ''): ?array
Expand Down
6 changes: 2 additions & 4 deletions src/VerifierServer/Endpoints/VerifiedEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,7 @@ public function add(string $discord, string $ckey): void
'ss13' => $ckey,
'create_time' => date('Y-m-d H:i:s')
];
$this->state::writeJson($this->state->getJsonPath(), $list);
$this->state->setVerifyList($list);
$this->state->saveVerifyList($list);
}

public function remove(string $discord, string $ckey = ''): ?array
Expand All @@ -298,8 +297,7 @@ public function removeIndex(int|string $existingIndex): array
{
$list = $this->state->getVerifyList();
$splice = array_splice($list, (int)$existingIndex, 1);
$this->state::writeJson($this->state->getJsonPath(), $list);
$this->state->setVerifyList($list);
$this->state->saveVerifyList($list);
return $splice;
}

Expand Down
88 changes: 76 additions & 12 deletions src/VerifierServer/PersistentState.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,7 @@ public function __construct(
*/
protected function initializeDatabase(): void
{
$env = self::loadEnvConfig();
$this->pdo = new PDO(
$env['DB_DSN'],
$env['DB_USERNAME'],
$env['DB_PASSWORD'],
isset($env['DB_OPTIONS']) ? json_decode($env['DB_OPTIONS'], true) : null
);
$this->pdo = $this->connect();
if (strpos($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME), 'mysql') !== false) {
if ($this->pdo->exec("CREATE TABLE IF NOT EXISTS {$this->table_name} (
id INT AUTO_INCREMENT PRIMARY KEY,
Expand All @@ -97,6 +91,44 @@ protected function initializeDatabase(): void
}
}

/**
* Opens the database connection from the environment configuration.
* Overridden by tests to inject an in-memory connection.
*/
protected function connect(): PDO
{
$env = self::loadEnvConfig();
return new PDO(
$env['DB_DSN'],
$env['DB_USERNAME'],
$env['DB_PASSWORD'],
isset($env['DB_OPTIONS']) && $env['DB_OPTIONS'] !== '' ? json_decode($env['DB_OPTIONS'], true) : null
);
}

/**
* Columns persisted for each record. Rows read back from a database also carry `id`,
* which must not be fed back into the insert statement.
*/
protected function recordColumns(): array
{
return ['ss13', 'discord', 'create_time'];
}

/**
* Persists a replacement list through the configured storage and updates the cache.
* Filesystem storage writes the JSON file; database storage rewrites the table.
*/
public function saveVerifyList(array $list): void
{
if ($this->storageType === 'filesystem') {
static::writeJson($this->getJsonPath(), $list);
$this->setVerifyList($list, false);
return;
}
$this->setVerifyList($list, true);
}

/**
* Retrieves the verification list.
*
Expand Down Expand Up @@ -142,17 +174,40 @@ public function getVerifyList(bool $getLocalCache = false): array
public function setVerifyList(array $list, bool $write = true): void
{
if ($write && $this->storageType !== 'filesystem') {
$this->rewriteTable($list);
}
$this->verifyList = $list;
}

/**
* Replaces every row of the table inside one transaction with only the record columns.
*
* @throws PDOException If any statement fails; the transaction is rolled back.
*/
protected function rewriteTable(array $list): void
{
$columns = $this->recordColumns();
$placeholders = implode(', ', array_map(fn($column) => ":$column", $columns));
$this->pdo->beginTransaction();
try {
if ($this->pdo->exec("DELETE FROM {$this->table_name}") === false) {
throw new PDOException("Failed to delete from {$this->table_name}: " . implode(", ", $this->pdo->errorInfo()));
}
if (($stmt = $this->pdo->prepare("INSERT INTO {$this->table_name} (ss13, discord, create_time) VALUES (:ss13, :discord, :create_time)")) === false) {
if (($stmt = $this->pdo->prepare("INSERT INTO {$this->table_name} (" . implode(', ', $columns) . ") VALUES ($placeholders)")) === false) {
throw new PDOException("Failed to prepare statement.");
}
foreach ($list as $item) if (!$stmt->execute($item)) {
throw new PDOException("Failed to execute statement.");
foreach ($list as $item) {
$row = [];
foreach ($columns as $column) $row[$column] = isset($item[$column]) ? (string) $item[$column] : null;
if (!$stmt->execute($row)) {
throw new PDOException("Failed to execute statement.");
}
}
$this->pdo->commit();
} catch (\Throwable $e) {
if ($this->pdo->inTransaction()) $this->pdo->rollBack();
throw $e;
}
$this->verifyList = $list;
}

/**
Expand All @@ -172,7 +227,16 @@ public function getToken(): string
*/
public function getJsonPath(): string
{
return getcwd() . '/' . $this->json_path;
return self::isAbsolutePath($this->json_path)
? $this->json_path
: getcwd() . '/' . $this->json_path;
}

/** True for `/path`, `C:\path`, `C:/path` and `\\server\share` paths. */
public static function isAbsolutePath(string $path): bool
{
return $path !== ''
&& ($path[0] === '/' || $path[0] === '\\' || preg_match('~^[A-Za-z]:[\\\\/]~', $path) === 1);
}

/**
Expand Down
26 changes: 9 additions & 17 deletions src/VerifierServer/SS14PersistentState.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,7 @@ public function __construct(
*/
protected function initializeDatabase(): void
{
$env = self::loadEnvConfig();
$this->pdo = new PDO(
$env['DB_DSN'],
$env['DB_USERNAME'],
$env['DB_PASSWORD'],
isset($env['DB_OPTIONS']) ? json_decode($env['DB_OPTIONS'], true) : null
);
$this->pdo = $this->connect();
if (strpos($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME), 'mysql') !== false) {
if ($this->pdo->exec("CREATE TABLE IF NOT EXISTS {$this->table_name} (
id INT AUTO_INCREMENT PRIMARY KEY,
Expand Down Expand Up @@ -102,7 +96,8 @@ public function &getVerifyList(bool $getLocalCache = false): array
if ($result === false) {
throw new PDOException("Failed to fetch data: " . implode(", ", $this->pdo->errorInfo()));
}
return $this->verifyList = $result;
$this->verifyList = $result;
return $this->verifyList;
}

/**
Expand All @@ -122,16 +117,13 @@ public function &getVerifyList(bool $getLocalCache = false): array
public function setVerifyList(array $list, bool $write = true): void
{
if ($write && $this->storageType !== 'filesystem') {
if ($this->pdo->exec("DELETE FROM {$this->table_name}") === false) {
throw new PDOException("Failed to delete from {$this->table_name}: " . implode(", ", $this->pdo->errorInfo()));
}
if (($stmt = $this->pdo->prepare("INSERT INTO {$this->table_name} (discord, ss14, create_time) VALUES (:discord, :ss14, :create_time)")) === false) {
throw new PDOException("Failed to prepare statement.");
}
foreach ($list as $item) if (!$stmt->execute($item)) {
throw new PDOException("Failed to execute statement.");
}
$this->rewriteTable($list);
}
$this->verifyList = $list;
}

protected function recordColumns(): array
{
return ['discord', 'ss14', 'create_time'];
}
}
28 changes: 26 additions & 2 deletions src/VerifierServer/Traits/JsonStorageTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,41 @@ public static function loadJsonFile(string $json_path): ?array
if ($data === false) {
throw new RuntimeException("Failed to read $json_path");
}
return json_decode($data, true) ?: [];
if (trim($data) === '') {
return [];
}
$decoded = json_decode($data, true);
if (!is_array($decoded)) {
// A damaged file must not silently become an empty list that the next write then persists.
throw new RuntimeException("Invalid JSON in $json_path: " . json_last_error_msg());
}
return $decoded;
}

/**
* Writes the given data to a file in JSON format.
*
* The data is written to a temporary file next to the target and renamed into
* place, so a crash or full disk mid-write leaves the previous file intact.
*
* @param string $file The path to the file where the JSON data will be written.
* @param mixed $data The data to be encoded as JSON and written to the file.
*
* @throws RuntimeException If the data cannot be encoded or the file cannot be written.
*/
public static function writeJson(string $file, array $data): void
{
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
if (!is_dir($directory = dirname($file))) {
mkdir($directory, 0777, true);
}
$temporary = tempnam($directory, basename($file) . '.');
if ($temporary === false) {
throw new RuntimeException("Failed to create a temporary file beside $file");
}
if (file_put_contents($temporary, $json) !== strlen($json) || !rename($temporary, $file)) {
@unlink($temporary);
throw new RuntimeException("Failed to write $file");
}
}
}
117 changes: 117 additions & 0 deletions tests/isolated/StorageIntegrityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use VerifierServer\Endpoints\SS14VerifiedEndpoint;
use VerifierServer\Endpoints\VerifiedEndpoint;
use VerifierServer\PersistentState;
use VerifierServer\SS14PersistentState;

final class MemoryDatabaseState extends PersistentState
{
protected function connect(): PDO { return new PDO('sqlite::memory:'); }
public static function writeJson(string $file, array $data): void { throw new LogicException('database storage must not write JSON'); }
}
final class MemorySS14DatabaseState extends SS14PersistentState
{
protected function connect(): PDO { return new PDO('sqlite::memory:'); }
public static function writeJson(string $file, array $data): void { throw new LogicException('database storage must not write JSON'); }
}

final class StorageIntegrityTest extends TestCase
{
private string $directory;

protected function setUp(): void
{
$this->directory = sys_get_temp_dir() . '/verifier-storage-' . bin2hex(random_bytes(4));
mkdir($this->directory);
}

protected function tearDown(): void
{
foreach (glob($this->directory . '/*') ?: [] as $file) unlink($file);
rmdir($this->directory);
}

public function testCorruptFileIsRefusedInsteadOfBecomingAnEmptyList(): void
{
$file = $this->directory . '/verify.json';
file_put_contents($file, '[{"discord":"123456789012345678","ss13":"ali');
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Invalid JSON');
PersistentState::loadJsonFile($file);
}

public function testEmptyAndMissingFilesLoadAsEmptyLists(): void
{
$file = $this->directory . '/nested/verify.json';
self::assertSame([], PersistentState::loadJsonFile($file));
self::assertFileExists($file);
file_put_contents($file, "\n");
self::assertSame([], PersistentState::loadJsonFile($file));
unlink($file);
rmdir($this->directory . '/nested');
}

public function testWriteReplacesTheFileAtomicallyAndLeavesNoTemporaryFiles(): void
{
$file = $this->directory . '/verify.json';
$records = [['discord' => '123456789012345678', 'ss13' => 'alice', 'create_time' => '2026-01-01 00:00:00']];
PersistentState::writeJson($file, $records);
self::assertSame($records, json_decode(file_get_contents($file), true));
PersistentState::writeJson($file, []);
self::assertSame([], json_decode(file_get_contents($file), true));
self::assertSame(['verify.json'], array_map('basename', glob($this->directory . '/*')));
}

public function testInvalidUtf8IsSubstitutedRatherThanWritingAnEmptyFile(): void
{
$file = $this->directory . '/verify.json';
PersistentState::writeJson($file, [['discord' => '123456789012345678', 'ss13' => "al\xffice"]]);
self::assertSame("al\u{FFFD}ice", json_decode(file_get_contents($file), true)[0]['ss13']);
}

public function testAbsoluteJsonPathIsUsedAsIs(): void
{
$file = $this->directory . '/absolute.json';
$state = new PersistentState('test-token', 'filesystem', $file);
self::assertSame($file, $state->getJsonPath());
self::assertSame(getcwd() . '/json/verify.json', (new PersistentState('test-token'))->getJsonPath());
self::assertTrue(PersistentState::isAbsolutePath('C:\\verifier\\verify.json'));
self::assertTrue(PersistentState::isAbsolutePath('D:/verifier/verify.json'));
self::assertTrue(PersistentState::isAbsolutePath('\\\\host\\share\\verify.json'));
self::assertFalse(PersistentState::isAbsolutePath('json/verify.json'));
$endpoint = new VerifiedEndpoint($state);
$endpoint->add('123456789012345678', 'alice');
self::assertSame('alice', json_decode(file_get_contents($file), true)[0]['ss13']);
$reopened = new PersistentState('test-token', 'filesystem', $file);
self::assertSame('alice', $reopened->getVerifyList()[0]['ss13']);
}

public function testDatabaseStorageSurvivesRepeatedInsertsAndDeletes(): void
{
$state = new MemoryDatabaseState('test-token', 'database', 'unused.json', 'verify_list');
$endpoint = new VerifiedEndpoint($state);
$endpoint->add('123456789012345678', 'alice');
$endpoint->add('234567890123456789', 'bob');
$rows = $state->getVerifyList();
self::assertCount(2, $rows);
self::assertSame(['alice', 'bob'], array_column($rows, 'ss13'));
self::assertArrayHasKey('id', $rows[0]);
self::assertSame(0, $endpoint->getIndex('123456789012345678'));
self::assertSame(['123456789012345678'], array_column($endpoint->remove('123456789012345678'), 'discord'));
self::assertSame(['bob'], array_column($state->getVerifyList(), 'ss13'));
$endpoint->add('345678901234567890', 'carol');
self::assertSame(['bob', 'carol'], array_column($state->getVerifyList(), 'ss13'));
}

public function testSs14DatabaseStorageUsesItsOwnColumns(): void
{
$state = new MemorySS14DatabaseState('test-token', 'database', 'unused.json', 'ss14_verify_list');
$endpoint = new SS14VerifiedEndpoint($state);
$endpoint->add('123456789012345678', 'alice');
$endpoint->add('234567890123456789', 'bob');
self::assertSame(['alice', 'bob'], array_column($state->getVerifyList(), 'ss14'));
self::assertSame('bob', $endpoint->fetch('discord', '234567890123456789')['ss14']);
}
}
Loading