391 lines
13 KiB
PHP
391 lines
13 KiB
PHP
<?php
|
|
/**
|
|
* Utility functions for the Torn Vault Tracker application.
|
|
*
|
|
* This file contains helper functions for database operations, API calls,
|
|
* and vault record management.
|
|
*
|
|
* PHP version 8.1+
|
|
*
|
|
* @category Utility
|
|
* @package TornVaultTracker
|
|
* @author Keith Solomon <ksolomon@gmail.com>
|
|
* @license Unlicense https://unlicense.org/
|
|
* @link https://github.com/ksolomon/Torn-Vault-Tracker
|
|
*/
|
|
|
|
// Test hook for fetchLiveVaultBalance(). When set, this closure is called
|
|
// instead of executeApiCall. Set $GLOBALS['liveBalanceTestHook'] to a
|
|
// closure($url, $apiKey): array in test code. Production code leaves it null.
|
|
$GLOBALS['liveBalanceTestHook'] = $GLOBALS['liveBalanceTestHook'] ?? null;
|
|
|
|
/**
|
|
* Checks if the database is new and empty.
|
|
*
|
|
* This function checks if the 'vault' table in the database has any entries.
|
|
* If it does not, it returns true, indicating that the database is new and
|
|
* empty. Otherwise, it returns false.
|
|
*
|
|
* @return boolean True if the database is new and empty, false otherwise.
|
|
*/
|
|
function dbNew() {
|
|
$pdo = getDatabaseConnection();
|
|
$stmt = $pdo->query("SELECT COUNT(*) FROM vault");
|
|
|
|
return $stmt->fetchColumn() == 0;
|
|
}
|
|
|
|
/**
|
|
* Ensures that a user has an API key configured.
|
|
*
|
|
* @param string $user The user to check the API key for
|
|
*
|
|
* @return void
|
|
*
|
|
* @throws Exception If the user does not have an API key configured
|
|
*/
|
|
function ensureUserHasApiKey($user) {
|
|
if (!array_key_exists($user, USER_KEYS)) {
|
|
throw new ApiKeyMissingException("User does not have an API key configured.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes a GET request to the given API endpoint and returns the JSON response as an associative array.
|
|
*
|
|
* Uses the v2 Torn API authentication scheme by sending the API key in an
|
|
* `Authorization: ApiKey <key>` header. The key is never transmitted as a
|
|
* query parameter.
|
|
*
|
|
* @param string $apiEndpoint The URL of the API endpoint to call
|
|
* @param string $apiKey The v2 API key to authenticate with
|
|
*
|
|
* @throws ApiKeyMissingException If the API key is empty
|
|
* @throws CurlErrorException If the cURL call fails
|
|
* @throws JsonDataException If the response body cannot be decoded as JSON
|
|
*
|
|
* @return array The JSON response from the API
|
|
*/
|
|
function executeApiCall($apiEndpoint, $apiKey) {
|
|
if (empty($apiKey)) {
|
|
throw new ApiKeyMissingException('API key is required for executeApiCall.');
|
|
}
|
|
|
|
$headers = ["Authorization: ApiKey $apiKey"];
|
|
$ch = curl_init();
|
|
curl_setopt($ch, CURLOPT_URL, $apiEndpoint);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
|
|
$response = curl_exec($ch);
|
|
|
|
if (curl_errno($ch)) {
|
|
throw new CurlErrorException("cURL error: " . curl_error($ch));
|
|
}
|
|
|
|
curl_close($ch);
|
|
|
|
$responseData = json_decode($response, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw new JsonDataException("Failed to decode JSON response: " . json_last_error_msg());
|
|
}
|
|
|
|
return $responseData;
|
|
}
|
|
|
|
/**
|
|
* Validates the API response data for the presence of a log array.
|
|
*
|
|
* @param array $responseData The API response data to validate.
|
|
*
|
|
* @return void
|
|
*
|
|
* @throws Exception If the log data is missing or not an array.
|
|
*/
|
|
function validateApiResponse($responseData) {
|
|
if (!isset($responseData['log']) || !is_array($responseData['log'])) {
|
|
throw new ApiValidationException('Invalid log data received from the API.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process an array of log entries retrieved from the Torn v2 API.
|
|
*
|
|
* For each entry, extracts the v2 shape (`id`, `timestamp`,
|
|
* `details.title`, `data.deposited` | `data.withdrawn`) and inserts it
|
|
* via the prepared statement, which uses `ON CONFLICT(id) DO NOTHING`
|
|
* for idempotency.
|
|
*
|
|
* @param array $logEntries The array of v2 log entries to process
|
|
* @param string $user The user whose log entries are being processed
|
|
* @param PDOStatement $insertStmt A prepared statement for the idempotent
|
|
* insert (`INSERT … ON CONFLICT(id) DO NOTHING`)
|
|
*
|
|
* @return void
|
|
*/
|
|
function processLogEntries($logEntries, $user, $insertStmt) {
|
|
foreach ($logEntries as $entry) {
|
|
$id = $entry['id'] ?? null;
|
|
|
|
if (!$id) {
|
|
consoleLog('Skipping entry with no id: ' . print_r($entry, true));
|
|
continue;
|
|
}
|
|
|
|
$timestamp = $entry['timestamp'] ?? null;
|
|
$description = $entry['details']['title'] ?? null;
|
|
$hasDeposit = isset($entry['data']['deposited']);
|
|
$hasWithdraw = isset($entry['data']['withdrawn']);
|
|
|
|
if ($hasDeposit) {
|
|
$amount = (int)$entry['data']['deposited'];
|
|
} elseif ($hasWithdraw) {
|
|
$amount = -((int)$entry['data']['withdrawn']);
|
|
} else {
|
|
consoleLog('Skipping entry ' . $id . ' with no deposited/withdrawn: ' . print_r($entry, true));
|
|
continue;
|
|
}
|
|
|
|
if ($timestamp === null || $description === null) {
|
|
consoleLog('Skipping entry ' . $id . ' missing timestamp or details.title: ' . print_r($entry, true));
|
|
continue;
|
|
}
|
|
|
|
$runningBalance = isset($entry['data']['balance']) ? (int)$entry['data']['balance'] : null;
|
|
|
|
$insertStmt->bindValue(':id', $id);
|
|
$insertStmt->bindValue(':user', $user);
|
|
$insertStmt->bindValue(':timestamp', $timestamp);
|
|
$insertStmt->bindValue(':description', $description);
|
|
$insertStmt->bindValue(':amount', $amount);
|
|
$insertStmt->bindValue(':running_balance', $runningBalance);
|
|
$insertStmt->execute();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch vault records from the database
|
|
* If a user is provided, only that user's records
|
|
* are returned. Otherwise, all records are returned.
|
|
*
|
|
* @param string $user (optional) The user to filter by
|
|
*
|
|
* @return array An array of records, or an empty array on error
|
|
*/
|
|
function fetchVaultRecords($user = null) {
|
|
$pdo = getDatabaseConnection();
|
|
|
|
if ($user) {
|
|
$query = "SELECT id, user, timestamp, description, amount, running_balance FROM vault WHERE user = :user ORDER BY timestamp DESC";
|
|
$params = [':user' => $user];
|
|
} else {
|
|
$query = "SELECT id, user, timestamp, description, amount, running_balance FROM vault ORDER BY timestamp DESC";
|
|
$params = [];
|
|
}
|
|
|
|
try {
|
|
$stmt = $pdo->prepare($query);
|
|
$stmt->execute($params);
|
|
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
echo "Error fetching records: " . $e->getMessage();
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Iterate over all vault records for the given user (or all users if no user is given),
|
|
*
|
|
* @param string|null $name (optional) The user to loop over records for.
|
|
* If not provided, all users' records will be looped over.
|
|
*
|
|
* @return int The total balance of the given user (or all users if no user is given).
|
|
*/
|
|
function vaultLoop ($name=null) {
|
|
$pdo = getDatabaseConnection();
|
|
|
|
if ($name === null) {
|
|
$stmt = $pdo->query(
|
|
'SELECT COALESCE(SUM(max_balance), 0) FROM '
|
|
. '(SELECT MAX(running_balance) AS max_balance FROM vault GROUP BY user)'
|
|
);
|
|
|
|
return (int)$stmt->fetchColumn();
|
|
}
|
|
|
|
$stmt = $pdo->prepare('SELECT MAX(running_balance) FROM vault WHERE user = :user');
|
|
$stmt->bindValue(':user', $name);
|
|
$stmt->execute();
|
|
|
|
return (int)$stmt->fetchColumn();
|
|
}
|
|
|
|
/**
|
|
* Returns a string indicating the sign of a number, with a dollar symbol.
|
|
*
|
|
* @param int|float $number The number to check.
|
|
*
|
|
* @return string A string containing a dollar sign and a negative sign if the
|
|
* number is negative.
|
|
*/
|
|
function getSign($number) {
|
|
if (substr($number, 0, 1) == '-') {
|
|
return '-$';
|
|
}
|
|
|
|
return '$';
|
|
}
|
|
|
|
/**
|
|
* Fetch a single page of log entries from the Torn v2 API and store them.
|
|
*
|
|
* Performs one HTTP request, validates the response, inserts each vault
|
|
* log entry (idempotently via INSERT ... ON CONFLICT), and returns the
|
|
* pagination cursor (`_metadata.links.next`) if more pages remain.
|
|
*
|
|
* @param PDO $pdo Database connection used to insert vault entries.
|
|
* @param string $user The user whose log entries are being fetched.
|
|
* @param string $url Full URL for the v2 API request.
|
|
*
|
|
* @throws ApiKeyMissingException If no API key is configured for the user.
|
|
* @throws CurlErrorException If the HTTP request fails.
|
|
* @throws JsonDataException If the response body is not valid JSON.
|
|
* @throws ApiValidationException If the response is missing the `log` array.
|
|
*
|
|
* @return string|null The `_metadata.links.next` URL, or null when there are no more pages.
|
|
*/
|
|
function fetchAndStoreLogPage($pdo, $user, $url) {
|
|
if (!array_key_exists($user, USER_KEYS)) {
|
|
throw new ApiKeyMissingException("User does not have an API key configured.");
|
|
}
|
|
|
|
$responseData = executeApiCall($url, USER_KEYS[$user]);
|
|
validateApiResponse($responseData);
|
|
|
|
$insertStmt = $pdo->prepare(
|
|
'INSERT INTO vault (id, user, timestamp, description, amount, running_balance) '
|
|
. 'VALUES (:id, :user, :timestamp, :description, :amount, :running_balance) '
|
|
. 'ON CONFLICT(id) DO NOTHING'
|
|
);
|
|
|
|
processLogEntries($responseData['log'], $user, $insertStmt);
|
|
|
|
return $responseData['_metadata']['links']['next'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Print a variable to the console for debugging purposes.
|
|
*
|
|
* @param mixed $data The data to print to the console.
|
|
*
|
|
* @return void
|
|
*/
|
|
function consoleLog( $data ) {
|
|
echo '<script>';
|
|
echo 'console.log(' . json_encode($data) . ')';
|
|
echo '</script>';
|
|
}
|
|
|
|
/**
|
|
* Backfill the running_balance column for any user rows that have NULL.
|
|
*
|
|
* The v2 API's `data.balance` field is the vault balance after each entry.
|
|
* If the column is missing for a row, treating it as NULL means
|
|
* MAX(running_balance) ignores it. This routine fetches the latest entry's
|
|
* balance from the API and broadcasts it to all NULL rows of the user,
|
|
* so the displayed balance becomes accurate after the first sync following
|
|
* the schema upgrade.
|
|
*
|
|
* @param PDO $pdo Database connection.
|
|
* @param string $user The user whose NULL rows should be backfilled.
|
|
*
|
|
* @return void
|
|
*/
|
|
function backfillRunningBalances($pdo, $user) {
|
|
if (!array_key_exists($user, USER_KEYS)) {
|
|
throw new ApiKeyMissingException("User does not have an API key configured.");
|
|
}
|
|
|
|
$countStmt = $pdo->prepare('SELECT COUNT(*) FROM vault WHERE user = :user AND running_balance IS NULL');
|
|
$countStmt->bindValue(':user', $user);
|
|
$countStmt->execute();
|
|
|
|
if ((int)$countStmt->fetchColumn() === 0) {
|
|
return;
|
|
}
|
|
|
|
$url = 'https://api.torn.com/v2/user?selections=log&log=5850,5851&limit=1&sort=DESC';
|
|
$responseData = executeApiCall($url, USER_KEYS[$user]);
|
|
validateApiResponse($responseData);
|
|
|
|
if (empty($responseData['log'])) {
|
|
return;
|
|
}
|
|
|
|
$latest = $responseData['log'][0];
|
|
if (!isset($latest['data']['balance'])) {
|
|
return;
|
|
}
|
|
|
|
$balance = (int)$latest['data']['balance'];
|
|
|
|
$updateStmt = $pdo->prepare('UPDATE vault SET running_balance = :balance WHERE user = :user AND running_balance IS NULL');
|
|
$updateStmt->bindValue(':balance', $balance);
|
|
$updateStmt->bindValue(':user', $user);
|
|
$updateStmt->execute();
|
|
}
|
|
|
|
/**
|
|
* Fetch the live vault balance for a user from the v2 /money endpoint.
|
|
*
|
|
* Returns the `money.vault` value as an int, or null if the API call
|
|
* fails, returns invalid JSON, or doesn't include `money.vault`. The
|
|
* result is cached for the duration of the PHP request so multiple
|
|
* callers (e.g., `vaultLoop` for one user, then the all-users sum)
|
|
* don't re-fetch.
|
|
*
|
|
* @param string $user The user whose vault balance to fetch.
|
|
*
|
|
* @return int|null The vault amount in pennies, or null on failure.
|
|
*/
|
|
function fetchLiveVaultBalance($user) {
|
|
static $cache = null;
|
|
|
|
if ($cache === null) {
|
|
$cache = new WeakMap();
|
|
}
|
|
|
|
$hook = $GLOBALS['liveBalanceTestHook'] ?? null;
|
|
$cacheKey = $hook ?? true;
|
|
|
|
if (isset($cache[$cacheKey]) && array_key_exists($user, $cache[$cacheKey])) {
|
|
return $cache[$cacheKey][$user];
|
|
}
|
|
|
|
if (!array_key_exists($user, USER_KEYS)) {
|
|
return null;
|
|
}
|
|
|
|
$url = 'https://api.torn.com/v2/user?selections=money';
|
|
try {
|
|
$responseData = $hook !== null
|
|
? $hook($url, USER_KEYS[$user])
|
|
: executeApiCall($url, USER_KEYS[$user]);
|
|
|
|
if (!isset($responseData['money']['vault'])) {
|
|
return null;
|
|
}
|
|
|
|
if (!isset($cache[$cacheKey])) {
|
|
$cache[$cacheKey] = [];
|
|
}
|
|
$cache[$cacheKey][$user] = (int)$responseData['money']['vault'];
|
|
return $cache[$cacheKey][$user];
|
|
} catch (Exception $e) {
|
|
return null;
|
|
}
|
|
}
|