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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
### Unreleased

### v1.3.0 (2020-01-16)

* Add StaticAssetUrlProvider to provide simple cache-busted local URLs for CSS etc in local
dev or remote (e.g. cloud storage / s3) urls in production.

### v1.2.1 (2019-11-15)

* Allow DeploymentConfig->map() to return values in standalone environment
Expand Down
128 changes: 128 additions & 0 deletions src/Assets/StaticAssetUrlProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php


namespace Ingenerator\PHPUtils\Assets;


use InvalidArgumentException;
use RuntimeException;

/**
* Simple / basic provider for CSS, JS, image etc asset URLs supporting cache-busting in dev and remote hosting in prod
*
* This class provides a very simple mechanism for generating URLs to a site's inbuilt CSS/JS/etc. It operates in two
* modes.
*
* In `local` (developer workstation) mode, it assumes asset files are reachable on the same web host as the main site,
* with the files located below the webserver's document root. Assets are served with a `?v=...` querystring based on
* the file modification time, providing automatic cachebusting during development. Assets that don't exist will throw
* an exception to help identify typos / incorrect relative urls during development.
*
* In `remote` (CI / qa / production) mode, it assumes asset files have been uploaded to a remote file hosting service
* (google cloud / s3 / etc) during the build process. The application prefixes all asset URLs with a host/path prefix
* pointing to that service, or to a CDN or similar. Usually this path prefix - set at build time - will contain an SHA
* or similar identifier so that the assets are in sync with the application.
*
* A build script should therefore:
*
* * Compile the assets as required
* * Choose a suitable remote hosting path for this specific version of the assets
* * Upload them to the remote hosting in the versioned path
* * Create a config file for the application containing the URL prefix - e.g.
* `<?php return 'http://my.cool.cdn/project/version-a923';`
* this file should then be deployed alongside the application.
*
* All this class does is read that file to get the URL prefix, and concat it onto the front of every asset path.
*
*/
class StaticAssetUrlProvider
{
const MODE_LOCAL = 'local';
const MODE_REMOTE = 'remote';

/**
* @var string
*/
protected $local_asset_path;

/**
* @var string
*/
protected $mode;

/**
* @var string
*/
protected $remote_asset_url;

/**
* @param string $mode local or remote mode (see above) - commonly toggle based on runtime environment
* @param string $local_asset_path base path on disk that all assets are relative to (in development)
* @param string $asset_base_url_file path to a php file generated at build time that returns the URL host/path prefix for remote mode
*/
public function __construct(
string $mode,
string $local_asset_path,
string $asset_base_url_file
) {
if ( ! in_array($mode, [static::MODE_LOCAL, static::MODE_REMOTE])) {
throw new InvalidArgumentException('Invalid asset mode `'.$mode.'` for '.__CLASS__);
}
$this->mode = $mode;
$this->local_asset_path = rtrim($local_asset_path, '/');

if ($this->mode === static::MODE_REMOTE) {
$this->remote_asset_url = $this->loadAssetBaseUrl($asset_base_url_file);
}
}

protected function loadAssetBaseUrl(string $asset_base_url_file): string
{
if ( ! file_exists($asset_base_url_file)) {
throw new InvalidArgumentException('No asset base url file at '.$asset_base_url_file);
}

$url = require $asset_base_url_file;

if (empty($url) OR ! is_string($url)) {
throw new RuntimeException('Invalid content in asset base url file '.$asset_base_url_file);
}

return $url;
}

/**
* Generates a URL for a static asset, given its relative path.
*
* @param string $rel_path the path of the asset within the docroot / uploaded asset files
*
* @return string the full URL to render to the client
*/
public function getUrl(string $rel_path): string
{
if ($rel_path[0] !== '/') {
$rel_path = '/'.$rel_path;
}

if ($this->mode === static::MODE_LOCAL) {
return $this->getLocalTimestampedUrl($rel_path);
} else {
return $this->remote_asset_url.$rel_path;
}
}

/**
* @param string $rel_path
*
* @return string
*/
protected function getLocalTimestampedUrl(string $rel_path): string
{
$local_path = $this->local_asset_path.$rel_path;
if ( ! file_exists($local_path)) {
throw new RuntimeException('Undefined asset file '.$local_path);
}

return $rel_path.'?v='.filemtime($local_path);
}
}
132 changes: 132 additions & 0 deletions test/unit/Assets/StaticAssetUrlProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php


namespace test\unit\Ingenerator\PHPUtils\Assets;


use org\bovigo\vfs\vfsStream;
use org\bovigo\vfs\vfsStreamDirectory;
use PHPUnit\Framework\TestCase;
use Ingenerator\PHPUtils\Assets\StaticAssetUrlProvider;

class StaticAssetUrlProviderTest extends TestCase
{

protected $options = [
'mode' => StaticAssetUrlProvider::MODE_LOCAL,
'local_asset_path' => __DIR__,
'asset_base_url_file' => __FILE__,
];

/**
* @var vfsStreamDirectory
*/
protected $vfs;

public function test_it_is_initialisable()
{
$this->assertInstanceOf(StaticAssetUrlProvider::class, $this->newSubject());
}

public function test_it_throws_in_invalid_mode()
{
$this->options['mode'] = 'some-junk';
$this->expectException(\InvalidArgumentException::class);
$this->newSubject();
}

public function test_in_local_mode_get_url_throws_if_file_does_not_exist()
{
$subject = $this->newSubject();
$this->expectException(\RuntimeException::class);
$subject->getUrl('assets/some-file.css');
}

/**
* @testWith ["assets/my-file.css"]
* ["/assets/my-file.css"]
*/
public function test_in_local_mode_get_url_returns_absolute_url_with_mtime_suffix($rel_path)
{
vfsStream::create(
[
'assets' => [
'my-file.css' => 'some-content',
],
],
$this->vfs->getChild('docroot')
);
$this->vfs->getChild('docroot/assets/my-file.css')->lastModified(123456);
$this->assertSame(
'/assets/my-file.css?v=123456',
$this->newSubject()->getUrl($rel_path)
);
}

public function test_in_remote_mode_get_url_throws_if_asset_base_url_file_does_not_exist()
{
$this->options['asset_base_url_file'] = $this->vfs->url().'/no-such-file.php';
$this->options['mode'] = StaticAssetUrlProvider::MODE_REMOTE;
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('no-such-file.php');
$this->newSubject();
}

/**
* @testWith [""]
* ["some content that is not php"]
* ["<?php $a = 1;"]
* ["<?php return '';"]
*/
public function test_in_remote_mode_get_url_throws_if_asset_base_url_file_does_not_return_string($file_content)
{
vfsStream::create(
['asset-base-url.php' => $file_content],
$this->vfs
);

$this->options['asset_base_url_file'] = $this->vfs->getChild('asset-base-url.php')->url();
$this->options['mode'] = StaticAssetUrlProvider::MODE_REMOTE;
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Invalid content in asset base url');
$this->newSubject();
}

/**
* @testWith ["assets/my-file.css"]
* ["/assets/my-file.css"]
*/
public function test_it_remote_mode_get_url_returns_url_prefixed_with_base_url($rel_path)
{
vfsStream::create(
['asset-base-url.php' => '<?php return "https://i.am.the.walrus/branch/sha";'],
$this->vfs
);

$this->options['asset_base_url_file'] = $this->vfs->getChild('asset-base-url.php')->url();
$this->options['mode'] = StaticAssetUrlProvider::MODE_REMOTE;

$this->assertSame(
'https://i.am.the.walrus/branch/sha/assets/my-file.css',
$this->newSubject()->getUrl($rel_path)
);
}

protected function setUp()
{
parent::setUp();
$this->vfs = vfsStream::setup('vfs', NULL, ['docroot' => []]);
$this->options['local_asset_path'] = $this->vfs->getChild('docroot')->url();
$this->options['asset_base_url_file'] = $this->vfs->url().'/asset-base-url.php';
}

protected function newSubject()
{
return new StaticAssetUrlProvider(
$this->options['mode'],
$this->options['local_asset_path'],
$this->options['asset_base_url_file']
);
}

}
5 changes: 3 additions & 2 deletions test/unit/DeploymentConfig/ConfigValueDecrypterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,11 @@ public function test_it_throws_if_required_decryption_key_is_not_present()
$valid_encrypted_val = sodium_crypto_box_seal('whoops', sodium_crypto_box_publickey($kp));
// The actual encrypted value is fine, but the problem is that the keypair it specifies isn't present with the
// correct name.
$subject = $this->newSubject();
$other_keypair_name = uniqid('very');
$subject = $this->newSubject();
$this->expectException(InvalidConfigException::class);
$this->expectExceptionMessage('Unknown config decryption key');
$subject->decrypt('#SECRET-very#'.$valid_encrypted_val);
$subject->decrypt("#SECRET-$other_keypair_name#$valid_encrypted_val");
}

public function provider_failed_decrypt()
Expand Down