Skip to content

Commit

Permalink
Merge pull request #61 from paragonie/short-lived-cache
Browse files Browse the repository at this point in the history
Optional feature: Cache responses for a very short period of time.
  • Loading branch information
paragonie-scott committed Sep 27, 2019
2 parents 9dc58e6 + bd073d3 commit 9a1b028
Show file tree
Hide file tree
Showing 10 changed files with 375 additions and 4 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"php": "^7",
"ext-json": "*",
"ext-pdo": "*",
"cache/memcached-adapter": "^1",
"guzzlehttp/guzzle": "^6",
"paragonie/blakechain": ">= 1.0.2",
"paragonie/easydb": "^2.7",
Expand Down
27 changes: 27 additions & 0 deletions docs/06-config.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# Chronicle Configuration

## HTTP Response Caching

Caching was implemented since version 1.2.0.

If your Chronicle instance is receiving a lot of traffic, it may be
beneficial to cache HTTP responses for a few seconds.

To enable caching, first you will need to install the **Memcached**
extension from PECL.

(There are [instructions available online for installing Memcached on Debian](https://serverpilot.io/docs/how-to-install-the-php-memcache-extension).
If you need instructions for your operating system, please inquire with their support team.)

Next, edit `local/settings.json` and set `"cache"` to an integer greater than 0
to cache responses for a given number of seconds.

It's recommended to set this to a value greater than `1` but less than `60`.
Some applications may dislike responses that are more than 1 minute old.

```json5
{
// ...
"cache": 15,
// ...
}
```

## Pagination

Pagination was implemented since version 1.2.0.
Expand Down
2 changes: 2 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
<issueHandlers>
<InvalidScope errorLevel="suppress" />
<MissingClosureReturnType errorLevel="suppress" />
<PropertyNotSetInConstructor errorLevel="info" /> <!-- Memcached false positive -->
<RedundantConditionGivenDocblockType errorLevel="suppress" />
<UndefinedClass errorLevel="info" /><!-- Memcached is optional (PECL) -->
<UndefinedConstant errorLevel="suppress" />
</issueHandlers>
</psalm>
60 changes: 60 additions & 0 deletions src/Chronicle/Chronicle.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,17 @@
RequestInterface,
ResponseInterface
};
use Slim\Http\Response;

/**
* Class Chronicle
* @package ParagonIE\Chronicle
*/
class Chronicle
{
/** @var ResponseCache|null $cache */
protected static $cache;

/** @var EasyDB $easyDb */
protected static $easyDb;

Expand All @@ -52,6 +56,62 @@ class Chronicle
/* This constant denotes the Chronicle version running, server-side */
const VERSION = '1.2.x';

/**
* @return ResponseCache|null
* @throws Exception\CacheMisuseException
* @throws \Psr\Cache\InvalidArgumentException
*/
public static function getResponseCache()
{
if (empty(self::$cache)) {
if (empty(self::$settings['cache'])) {
return null;
}
if (!ResponseCache::isAvailable()) {
return null;
}
self::$cache = new ResponseCache((int) self::$settings['cache']);
}
return self::$cache;
}

/**
* @param RequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
*
* @throws \Exception
* @throws \Psr\Cache\InvalidArgumentException
*/
public static function cache(
RequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$cache = self::getResponseCache();
if (!empty($cache)) {
$cache->saveResponse((string) $request->getUri(), $response);
}
return $response;
}

/**
* @param RequestInterface $request
* @return ResponseInterface|null
*
* @throws \Exception
* @throws \Psr\Cache\InvalidArgumentException
*/
public static function getFromCache(RequestInterface $request)
{
$cache = self::getResponseCache();
if (empty($cache)) {
return null;
}
/** @var Response $response */
$response = $cache->loadResponse((string) $request->getUri());
return $response;
}

/**
* @param string $name
* @param bool $dontEscape
Expand Down
12 changes: 12 additions & 0 deletions src/Chronicle/Exception/CacheMisuseException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace ParagonIE\Chronicle\Exception;

/**
* Class CacheMisuseException
* @package ParagonIE\Chronicle\Exception
*/
class CacheMisuseException extends BaseException
{

}
13 changes: 9 additions & 4 deletions src/Chronicle/Handlers/Lookup.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,27 +44,32 @@ public function __construct(string $method = 'index')
* @return ResponseInterface
*
* @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;
}
try {
// Whitelist of acceptable methods:
switch ($this->method) {
case 'export':
return $this->exportChain($args);
return Chronicle::cache($request, $this->exportChain($args));
case 'lasthash':
return $this->getLastHash();
return Chronicle::cache($request, $this->getLastHash());
case 'hash':
if (!empty($args['hash'])) {
return $this->getByHash($args);
return Chronicle::cache($request, $this->getByHash($args));
}
break;
case 'since':
if (!empty($args['hash'])) {
return $this->getSince($args);
return Chronicle::cache($request, $this->getSince($args));
}
break;
}
Expand Down
5 changes: 5 additions & 0 deletions src/Chronicle/Handlers/Replica.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,17 @@ public function __construct(string $method = 'index')
*
* @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']);
Expand Down
192 changes: 192 additions & 0 deletions src/Chronicle/ResponseCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace ParagonIE\Chronicle;

use Cache\Adapter\Memcached\MemcachedCachePool;
use ParagonIE\Chronicle\Exception\CacheMisuseException;
use ParagonIE\ConstantTime\Base32;
use Psr\Cache\CacheItemInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use Slim\Http\Headers;
use Slim\Http\Response;
use Slim\Http\Stream;

/**
* Class ResponseCache
* @package ParagonIE\Chronicle
*/
class ResponseCache
{
/** @var string $cacheKey */
private $cacheKey = '';

/** @var int|null $lifetime */
private $lifetime;

/** @var MemcachedCachePool $memcached */
private $memcached;

/**
* ResponseCache constructor.
* @param int $lifetime
* @throws \Psr\Cache\InvalidArgumentException
* @throws CacheMisuseException
*/
public function __construct(int $lifetime = 0)
{
if (!self::isAvailable()) {
throw new CacheMisuseException('Memcached is not installed.');
}
$client = new \Memcached();
$client->addServer('localhost', 11211);
if ($lifetime > 0) {
$this->lifetime = $lifetime;
}
$this->memcached = new MemcachedCachePool($client);
$this->loadCacheKey();
}

/**
* @return string
* @throws \Psr\Cache\InvalidArgumentException
* @throws CacheMisuseException
*/
public function loadCacheKey()
{
if (!empty($this->cacheKey)) {
return $this->cacheKey;
}
if ($this->memcached->hasItem('ChronicleCacheKey')) {
/** @var CacheItemInterface $item */
$item = $this->memcached->getItem('ChronicleCacheKey');
return (string) $item->get();
}
try {
$key = sodium_crypto_shorthash_keygen();
} catch (\Throwable $ex) {
throw new CacheMisuseException('CSPRNG failure', 0, $ex);
}
/** @var CacheItemInterface $item */
$item = $this->memcached->getItem('ChronicleCacheKey');
$item->set($key);
$item->expiresAfter(null);
$this->memcached->save($item);
return $key;
}

/**
* @return bool
*/
public static function isAvailable(): bool
{
return extension_loaded('memcached') && class_exists('Memcached');
}

/**
* @param string $input
* @return string
* @throws CacheMisuseException
* @throws \Psr\Cache\InvalidArgumentException
* @throws \SodiumException
*/
public function getCacheKey(string $input): string
{
return 'Chronicle|' . Base32::encodeUnpadded(
sodium_crypto_shorthash($input, $this->loadCacheKey())
);
}

/**
* @param string $uri
* @return Response|null
* @throws CacheMisuseException
* @throws \Psr\Cache\InvalidArgumentException
* @throws \SodiumException
*/
public function loadResponse(string $uri)
{
$key = $this->getCacheKey($uri);
if (!$this->memcached->hasItem($key)) {
return null;
}
/** @var CacheItemInterface $item */
$item = $this->memcached->getItem($key);
/** @var string|null $cached */
$cached = $item->get();
if (!is_string($cached)) {
return null;
}
return $this->deserializeResponse($cached);
}

/**
* @param string $uri
* @param ResponseInterface $response
* @return void
* @throws CacheMisuseException
* @throws \Psr\Cache\InvalidArgumentException
* @throws \SodiumException
*/
public function saveResponse(string $uri, ResponseInterface $response)
{
$key = $this->getCacheKey($uri);
/** @var CacheItemInterface $item */
$item = $this->memcached->getItem($key);
$item->set($this->serializeResponse($response));
$item->expiresAfter($this->lifetime);
$this->memcached->save($item);
}

/**
* @param string $serialized
* @return Response
*/
public function deserializeResponse(string $serialized): Response
{
/** @var array<string, string|array|int> $decoded */
$decoded = json_decode($serialized, true);
$status = (int) $decoded['status'];
$headers = (array) $decoded['headers'];
/** @var string $body */
$body = $decoded['body'];

return new Response(
$status,
new Headers($headers),
self::fromString($body)
);
}

/**
* Create a Stream object from a string.
*
* @param string $input
* @return StreamInterface
* @throws \Error
*/
public static function fromString(string $input): StreamInterface
{
/** @var resource $stream */
$stream = \fopen('php://temp', 'w+');
if (!\is_resource($stream)) {
throw new \Error('Could not create stream');
}
\fwrite($stream, $input);
\rewind($stream);
return new Stream($stream);
}

/**
* @param ResponseInterface $response
* @return string
*/
public function serializeResponse(ResponseInterface $response): string
{
return json_encode([
'status' => $response->getStatusCode(),
'headers' => $response->getHeaders(),
'body' => $response->getBody()->getContents()
]);
}
}
Loading

0 comments on commit 9a1b028

Please sign in to comment.