<?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;
}
}
|