PHP Classes

File: src/Chronicle/Handlers/Replica.php

Recommend this page to a friend!
  Classes of Scott Arciszewski   Chronicle   src/Chronicle/Handlers/Replica.php   Download  
File: src/Chronicle/Handlers/Replica.php
Role: Class source
Content type: text/plain
Description: Class source
Class: Chronicle
Append arbitrary data to a storage container
Author: By
Last change: Optional feature: Cache responses for a very short period of time.

This will allow us to serve a lot more requests per second.
Document new behavior.
Fix replica URIs for pagination
Use correct table name
Fix replica pagination
Add pagination to replica
Add pagination to /since
Fix #53
Wrong method
Make replicas easier to crawl.
Add JSON header to error responses
Add subindex for replicas
Proper whitespace
Docblocks
Don't double-escape.
Concurrent Chronicles

Add support for multiple instances via the ?instance=name parameter.

To implement, add something like this to your local/settings.json in the
instances key:

"public_prefix" => "table_name_prefix"

Then run bin/make-tables.php as normal.

Every instance is totally independent of each other. They have their own

* Clients
* Chain data
* Cross-Signing Targets and Policies
* Replications

If merged, I will document these features and roll it into v1.1.0
Docblock consistency, fix composer internal server
Type safety
Upgrade composer dependencies
Minor bugfixes.
Date: 1 year ago
Size: 18,991 bytes
 

Contents

Class file image Download
<?php declare(strict_types=1); namespace ParagonIE\Chronicle\Handlers; use ParagonIE\Chronicle\{ Chronicle, Exception\FilesystemException, Exception\InvalidInstanceException, Exception\ReplicationSourceNotFound, Exception\HashNotFound, HandlerInterface, Pagination }; use Psr\Http\Message\{ RequestInterface, ResponseInterface }; /** * Class Replica * @package ParagonIE\Chronicle\Handlers */ class Replica implements HandlerInterface { use Pagination; const NOTICE = 'This is replicated data from another Chronicle.'; /** @var string */ protected $method = 'index'; /** @var int */ protected $source = 0; /** * Replica constructor. * @param string $method */ public function __construct(string $method = 'index') { $this->method = $method; } /** * The handler gets invoked by the router. This accepts a Request * and returns a Response. * * @param RequestInterface $request * @param ResponseInterface $response * @param array $args * @return ResponseInterface * * @throws \Exception * @throws FilesystemException * @throws \Psr\Cache\InvalidArgumentException */ public function __invoke( RequestInterface $request, ResponseInterface $response, array $args = [] ): ResponseInterface { $cache = Chronicle::getFromCache($request); if (!is_null($cache)) { return $cache; } if (!empty($args['source'])) { try { $this->selectReplication((string) $args['source']); } catch (ReplicationSourceNotFound $ex) { return Chronicle::errorResponse($response, 'Unknown URI', 404); } } elseif ($this->method === 'index') { return $this->getIndex(); } else { return Chronicle::errorResponse($response, 'No replication source given', 404); } try { switch ($this->method) { case 'export': return $this->exportChain($args); case 'lasthash': return $this->getLastHash(); case 'hash': if (!empty($args['hash'])) { return $this->getByHash($args); } break; case 'subindex': return $this->getSubIndex((string) $args['source']); case 'since': if (!empty($args['hash'])) { return $this->getSince($args); } break; } } catch (\Throwable $ex) { return Chronicle::errorResponse( $response, $ex->getMessage(), 404 ); } return Chronicle::errorResponse($response, 'Unknown URI', 404); } /** * Gets the entire Blakechain. * * @param array $args * @return ResponseInterface * * @throws \Exception * @return ResponseInterface * @throws FilesystemException * @throws InvalidInstanceException */ public function exportChain(array $args = []): ResponseInterface { /** @var bool $paginated */ $paginated = Chronicle::shouldPaginate(); /** @var int $total */ $total = 0; /** @var int $offset */ $offset = 0; /** @var int $limit */ $limit = 0; $response = [ 'version' => Chronicle::VERSION, 'datetime' => (new \DateTime())->format(\DateTime::ATOM), 'status' => 'OK', ]; if ($paginated) { $response['paginated'] = true; $total = (int) Chronicle::getDatabase()->cell( "SELECT count(id) FROM " . Chronicle::getTableName('replication_chain') . " WHERE source = ?", $this->source ); /** @var int $offset */ $offset = (int) $this->getOffset((string) ($args['page'] ?? '')); /** @var int $limit */ $limit = Chronicle::getPageSize(); $page = (int) ($args['page'] ?? 1); if ($page > 1) { $response['prev'] = '/replica/' . (string) ($args['source']) . '/export/' . ($page - 1); } if ($offset + $limit <= $total) { if ($page < 1) { $page = 1; } $response['next'] = '/replica/' . (string) ($args['source']) . '/export/' . ($page + 1); } $response['results'] = $this->getPartialChain($offset, $limit); } else { $fullChain = $this->getFullChain(); $response['total'] = count($fullChain); $response['results'] = $fullChain; } return Chronicle::getSapient()->createSignedJsonResponse( 200, $response, Chronicle::getSigningKey() ); } /** * Gets the entire Blakechain. * * @return ResponseInterface * * @throws \Exception * @throws FilesystemException */ public function exportChainLegacy(): ResponseInterface { return Chronicle::getSapient()->createSignedJsonResponse( 200, [ 'version' => Chronicle::VERSION, 'datetime' => (new \DateTime())->format(\DateTime::ATOM), 'status' => 'OK', 'notice' => static::NOTICE, 'results' => $this->getFullChain() ], Chronicle::getSigningKey() ); } /** * Get information about a particular entry, given its hash. * * @param array $args * @return ResponseInterface * * @throws \Exception * @throws HashNotFound * @throws FilesystemException * @throws InvalidInstanceException */ public function getByHash(array $args = []): ResponseInterface { /** @var array<int, array<string, string>> $record */ $record = Chronicle::getDatabase()->run( "SELECT data AS contents, prevhash, currhash, summaryhash, created, publickey, signature FROM " . Chronicle::getTableName('replication_chain') . " WHERE source = ? AND ( currhash = ? OR summaryhash = ? ) ", $this->source, $args['hash'], $args['hash'] ); if (!$record) { throw new HashNotFound('No record found matching this hash.'); } return Chronicle::getSapient()->createSignedJsonResponse( 200, [ 'version' => Chronicle::VERSION, 'datetime' => (new \DateTime())->format(\DateTime::ATOM), 'status' => 'OK', 'notice' => static::NOTICE, 'results' => $record ], Chronicle::getSigningKey() ); } /** * List the latest current hash and summary hash for this replica * * @return ResponseInterface * * @throws FilesystemException * @throws InvalidInstanceException */ public function getLastHash(): ResponseInterface { /** @var array<string, string> $lasthash */ $lasthash = Chronicle::getDatabase()->row( 'SELECT currhash, summaryhash FROM ' . Chronicle::getTableName('replication_chain') . ' WHERE source = ? ORDER BY id DESC LIMIT 1', $this->source ); return Chronicle::getSapient()->createSignedJsonResponse( 200, [ 'version' => Chronicle::VERSION, 'datetime' => (new \DateTime())->format(\DateTime::ATOM), 'status' => 'OK', 'notice' => static::NOTICE, 'results' => [ 'curr-hash' => $lasthash['currhash'], 'summary-hash' => $lasthash['summaryhash'] ] ], Chronicle::getSigningKey() ); } /** * List all replicated Chronicles and their respective URIs * * @return ResponseInterface * * @throws \Exception * @throws FilesystemException * @throws InvalidInstanceException */ protected function getIndex(): ResponseInterface { /** @var array<int, array<string, string>> $replicationSources */ $replicationSources = Chronicle::getDatabase()->run( "SELECT uniqueid, url AS canonical, name, publickey AS serverPublicKey FROM " . Chronicle::getTableName('replication_sources') ); /** * @var int $idx * @var array<string, string> $row */ foreach ($replicationSources as $idx => $row) { $replicationSources[$idx]['index'] = '/replica/' . $row['uniqueid']; $replicationSources[$idx]['urls'] = [ [ 'uri' => '/replica/' . $row['uniqueid'] . '/lasthash', 'description' => 'Get information about the latest entry in this replicated Chronicle' ], [ 'uri' => '/replica/' . $row['uniqueid'] . '/lookup/{hash}', 'description' => 'Lookup the information for the given hash in this replicated Chronicle' ], [ 'uri' => '/replica/' . $row['uniqueid'] . '/since/{hash}', 'description' => 'List all new entries since a given hash in this replicated Chronicle' ], [ 'uri' => '/replica/' . $row['uniqueid'] . '/export', 'description' => 'Export the entire replicated Chronicle' ] ]; } return Chronicle::getSapient()->createSignedJsonResponse( 200, [ 'version' => Chronicle::VERSION, 'datetime' => (new \DateTime())->format(\DateTime::ATOM), 'status' => 'OK', 'results' => $replicationSources ], Chronicle::getSigningKey() ); } /** * Sub-index of the /replica/{identifier} request * * @param string $replica * @return ResponseInterface * @throws FilesystemException * @throws InvalidInstanceException */ protected function getSubIndex(string $replica): ResponseInterface { /** @var array<string, string> $source */ $source = Chronicle::getDatabase()->row( "SELECT uniqueid, url AS canonical, name, publickey AS serverPublicKey FROM " . Chronicle::getTableName('replication_sources') . " WHERE uniqueid = ?", $replica ); return Chronicle::getSapient()->createSignedJsonResponse( 200, [ 'version' => Chronicle::VERSION, 'datetime' => (new \DateTime())->format(\DateTime::ATOM), 'status' => 'OK', 'results' => [ 'uniqueid' => $source['uniqueid'], 'serverPublicKey' => $source['serverPublicKey'], 'canonical' => $source['canonical'], 'urls' => [ [ 'uri' => '/replica/' . $replica . '/lasthash', 'description' => 'Get information about the latest entry in this replicated Chronicle' ], [ 'uri' => '/replica/' . $replica . '/lookup/{hash}', 'description' => 'Lookup the information for the given hash in this replicated Chronicle' ], [ 'uri' => '/replica/' . $replica . '/since/{hash}', 'description' => 'List all new entries since a given hash in this replicated Chronicle' ], [ 'uri' => '/replica/' . $replica. '/export', 'description' => 'Export the entire replicated Chronicle' ] ] ] ], Chronicle::getSigningKey() ); } /** * Get updates to the replica since a given hash * * @param array $args * @return ResponseInterface * * @throws \Exception * @throws FilesystemException * @throws HashNotFound * @throws InvalidInstanceException */ public function getSince(array $args = []): ResponseInterface { /** @var bool $paginated */ $paginated = Chronicle::shouldPaginate(); /** @var int $total */ $total = 0; /** @var int $offset */ $offset = 0; /** @var int $limit */ $limit = 0; /** @var int $id */ $id = Chronicle::getDatabase()->cell( "SELECT id FROM " . Chronicle::getTableName('replication_chain') . " WHERE source = ? AND ( currhash = ? OR summaryhash = ? ) ORDER BY id ASC ", $this->source, $args['hash'], $args['hash'] ); if (!$id) { throw new HashNotFound('No record found matching this hash.'); } /** @var string $sinceQuery */ $sinceQuery = "SELECT data AS contents, prevhash, currhash, summaryhash, created, publickey, signature FROM " . Chronicle::getTableName('replication_chain') . " WHERE source = ? AND id > ?"; // Append an offset and limit to the query string if applicable if ($paginated) { $total = (int) Chronicle::getDatabase()->cell( "SELECT count(id) FROM " . Chronicle::getTableName('replication_chain') . " WHERE source = ? AND id > ?", $this->source, $id ); /** @var int $offset */ $offset = (int) $this->getOffset((string) ($args['page'] ?? '')); /** @var int $limit */ $limit = Chronicle::getPageSize(); $sinceQuery .= $this->formatOffsetSuffix($offset, $limit); } // Fetch the results /** @var array<int, array<string, string>> $since */ $since = Chronicle::getDatabase()->run($sinceQuery, $this->source, $id); if (!$total) { $total = count($since); } // Process the response $response = [ 'version' => Chronicle::VERSION, 'datetime' => (new \DateTime())->format(\DateTime::ATOM), 'status' => 'OK' ]; // Add total and optional 'next' URL if ($paginated) { $response['paginated'] = true; $page = (int) ($args['page'] ?? 1); if ($page > 1) { $response['prev'] = '/replica/' . (string) ($args['source']) . '/since/' . (string)($args['hash']) . '/' . ($page - 1); } if ($offset + $limit <= $total) { if ($page < 1) { $page = 1; } $response['next'] = '/replica/' . (string) ($args['source']) . '/since/' . (string)($args['hash']) . '/' . ($page + 1); } $response['total'] = $total; } else { $response['total'] = count($since); } $response['results'] = $since; return Chronicle::getSapient()->createSignedJsonResponse( 200, $response, Chronicle::getSigningKey() ); } /** * Get a subset of the total chain. * * @param int $offset * @param int $limit * @return array * @throws InvalidInstanceException */ protected function getPartialChain(int $offset, int $limit): array { return $this->getChain( "SELECT * FROM " . Chronicle::getTableName('replication_chain') . " WHERE source = ? ORDER BY id ASC" . $this->formatOffsetSuffix($offset, $limit) ); } /** * Get the entire chain, as-is, as of the time of the request. * * @return array * @throws InvalidInstanceException */ protected function getFullChain(): array { return $this->getChain( "SELECT * FROM " . Chronicle::getTableName('replication_chain') . " WHERE source = ? ORDER BY id ASC" ); } /** * @param string $queryString * @return array */ protected function getChain(string $queryString): array { $chain = []; /** @var array<int, array<string, string>> $rows */ $rows = Chronicle::getDatabase()->run($queryString, $this->source); /** @var array<string, string> $row */ foreach ($rows as $row) { $chain[] = [ 'contents' => $row['data'], 'prev' => $row['prevhash'], 'hash' => $row['currhash'], 'summary' => $row['summaryhash'], 'created' => $row['created'], 'publickey' => $row['publickey'], 'signature' => $row['signature'] ]; } return $chain; } /** * Given a unique ID, set this object's source property to the respective * database record ID (to use in future querying). * * @param string $uniqueId * @return self * * @throws ReplicationSourceNotFound * @throws InvalidInstanceException */ protected function selectReplication(string $uniqueId): self { /** @var int $source */ $source = Chronicle::getDatabase()->cell( "SELECT id FROM " . Chronicle::getTableName('replication_sources') . " WHERE uniqueid = ?", $uniqueId ); if (!$source) { throw new ReplicationSourceNotFound(); } $this->source = (int) $source; return $this; } }