feat: add secure on-demand HLS playback plugin
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Tests\Service;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\ResourcePath;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\AssetAuthorizer;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
|
||||
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
|
||||
|
||||
#[CoversClass(AssetAuthorizer::class)]
|
||||
final class AssetAuthorizerTest extends TemporaryDirectoryTestCase
|
||||
{
|
||||
public function testPlaylistAndSegmentRequireValidUnexpiredStationBoundSession(): void
|
||||
{
|
||||
$config = new Config(sessionTtl: 60, cacheTtl: 60);
|
||||
$paths = new CachePaths($config);
|
||||
$sessions = new SessionRepository($config, $paths);
|
||||
$cacheKey = str_repeat('a', 64);
|
||||
$assetDirectory = $paths->asset($this->temporaryDirectory, $cacheKey);
|
||||
mkdir($assetDirectory, 0755, true);
|
||||
file_put_contents($assetDirectory . '/master.m3u8', '#EXTM3U');
|
||||
file_put_contents($assetDirectory . '/media.m3u8', '#EXTM3U');
|
||||
file_put_contents($assetDirectory . '/segment00000.ts', 'segment');
|
||||
|
||||
$created = $sessions->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
$cacheKey,
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
$authorizer = new AssetAuthorizer($sessions, $paths);
|
||||
|
||||
self::assertNotNull($authorizer->authorize($this->temporaryDirectory, 7, $created['token'], 'master.m3u8', 1059));
|
||||
self::assertNotNull($authorizer->authorize($this->temporaryDirectory, 7, $created['token'], 'segment00000.ts', 1059));
|
||||
self::assertNull($authorizer->authorize($this->temporaryDirectory, 8, $created['token'], 'master.m3u8', 1059));
|
||||
self::assertNull($authorizer->authorize($this->temporaryDirectory, 7, $created['token'], 'master.m3u8', 1060));
|
||||
self::assertNull($authorizer->authorize($this->temporaryDirectory, 7, 'x' . substr($created['token'], 1), 'master.m3u8', 1059));
|
||||
self::assertNull($authorizer->authorize($this->temporaryDirectory, 7, $created['token'], '../master.m3u8', 1059));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Tests\Service;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CleanupService;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
|
||||
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
#[CoversClass(CleanupService::class)]
|
||||
final class CleanupServiceTest extends TemporaryDirectoryTestCase
|
||||
{
|
||||
public function testExpiredSessionsAndUnreferencedAssetsAreRemoved(): void
|
||||
{
|
||||
$config = new Config(sessionTtl: 60, cacheTtl: 60);
|
||||
$paths = new CachePaths($config);
|
||||
$sessions = new SessionRepository($config, $paths);
|
||||
$cacheKey = str_repeat('a', 64);
|
||||
$created = $sessions->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
$cacheKey,
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
$asset = $paths->asset($this->temporaryDirectory, $cacheKey);
|
||||
mkdir($asset, 0755, true);
|
||||
file_put_contents($asset . '/master.m3u8', '#EXTM3U');
|
||||
touch($asset, 1000);
|
||||
|
||||
$cleanup = new CleanupService($config, $paths, new NullLogger());
|
||||
$result = $cleanup->run($this->temporaryDirectory, 1061);
|
||||
|
||||
self::assertSame(['sessions' => 1, 'assets' => 1], $result);
|
||||
self::assertNull($sessions->find($this->temporaryDirectory, $created['token']));
|
||||
self::assertDirectoryDoesNotExist($asset);
|
||||
}
|
||||
|
||||
public function testActiveSessionPreventsCacheDeletion(): void
|
||||
{
|
||||
$config = new Config(sessionTtl: 120, cacheTtl: 120);
|
||||
$paths = new CachePaths($config);
|
||||
$sessions = new SessionRepository($config, $paths);
|
||||
$cacheKey = str_repeat('a', 64);
|
||||
$sessions->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
$cacheKey,
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
$asset = $paths->asset($this->temporaryDirectory, $cacheKey);
|
||||
mkdir($asset, 0755, true);
|
||||
touch($asset, 1);
|
||||
|
||||
$cleanup = new CleanupService($config, $paths, new NullLogger());
|
||||
self::assertSame(['sessions' => 0, 'assets' => 0], $cleanup->run($this->temporaryDirectory, 1050));
|
||||
self::assertDirectoryExists($asset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Tests\Service;
|
||||
|
||||
use App\Entity\Station;
|
||||
use App\Entity\StationMedia;
|
||||
use App\Flysystem\StationFilesystems;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\TranscodeException;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\HlsCache;
|
||||
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
#[CoversClass(HlsCache::class)]
|
||||
final class HlsCacheTest extends TemporaryDirectoryTestCase
|
||||
{
|
||||
public function testGeneratesAValidatedPackageAndReusesIt(): void
|
||||
{
|
||||
$source = $this->temporaryDirectory . '/source.mp3';
|
||||
$counter = $this->temporaryDirectory . '/ffmpeg-calls';
|
||||
file_put_contents($source, 'input');
|
||||
$script = $this->makeFfmpegScript($counter, false);
|
||||
$cache = $this->cache($source, $script);
|
||||
$station = new Station(7, $this->temporaryDirectory);
|
||||
$media = new StationMedia(2, 'abcdefghijklmnopqrstuvwx', 123, 'source.mp3');
|
||||
|
||||
$key = $cache->ensure($station, $media);
|
||||
self::assertSame($key, $cache->ensure($station, $media));
|
||||
self::assertSame("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-STREAM-INF:BANDWIDTH=140800,CODECS=\"mp4a.40.2\"\nmedia.m3u8\n", file_get_contents($this->temporaryDirectory . '/ondemand-hls/assets/' . $key . '/master.m3u8'));
|
||||
self::assertFileExists($this->temporaryDirectory . '/ondemand-hls/assets/' . $key . '/media.m3u8');
|
||||
self::assertFileExists($this->temporaryDirectory . '/ondemand-hls/assets/' . $key . '/segment00000.ts');
|
||||
self::assertSame("1\n", file_get_contents($counter));
|
||||
}
|
||||
|
||||
public function testRejectsMalformedFfmpegOutputWithoutPublishingIt(): void
|
||||
{
|
||||
$source = $this->temporaryDirectory . '/source.mp3';
|
||||
file_put_contents($source, 'input');
|
||||
$script = $this->makeFfmpegScript($this->temporaryDirectory . '/ffmpeg-calls', true);
|
||||
$cache = $this->cache($source, $script);
|
||||
$station = new Station(7, $this->temporaryDirectory);
|
||||
$media = new StationMedia(2, 'abcdefghijklmnopqrstuvwx', 123, 'source.mp3');
|
||||
|
||||
$this->expectException(TranscodeException::class);
|
||||
try {
|
||||
$cache->ensure($station, $media);
|
||||
} finally {
|
||||
self::assertDirectoryDoesNotExist($this->temporaryDirectory . '/ondemand-hls/assets/' . $cache->key($station, $media));
|
||||
}
|
||||
}
|
||||
|
||||
private function cache(string $source, string $ffmpeg): HlsCache
|
||||
{
|
||||
$config = new Config(cacheTtl: 1800, ffmpegBinary: $ffmpeg);
|
||||
$filesystem = new class($source) {
|
||||
public function __construct(private string $source)
|
||||
{
|
||||
}
|
||||
|
||||
public function fileExists(string $path): bool
|
||||
{
|
||||
return 'source.mp3' === $path;
|
||||
}
|
||||
|
||||
public function withLocalFile(string $path, callable $callback): mixed
|
||||
{
|
||||
return $callback($this->source);
|
||||
}
|
||||
};
|
||||
|
||||
return new HlsCache(
|
||||
$config,
|
||||
new CachePaths($config),
|
||||
new StationFilesystems($filesystem),
|
||||
new NullLogger(),
|
||||
);
|
||||
}
|
||||
|
||||
private function makeFfmpegScript(string $counter, bool $malformed): string
|
||||
{
|
||||
$script = $this->temporaryDirectory . '/fake-ffmpeg-' . bin2hex(random_bytes(4)) . '.sh';
|
||||
$segment = $malformed
|
||||
? "printf '#EXTM3U\\n../outside.ts\\n' > \"\$output\"\n"
|
||||
: "printf '#EXTM3U\\n#EXT-X-ENDLIST\\nsegment00000.ts\\n' > \"\$output\"\nsegment_file=\$(printf \"\$segment\" 0)\nprintf segment > \"\$segment_file\"\n";
|
||||
file_put_contents($script, "#!/bin/sh\nset -eu\noutput=''\nsegment=''\nprevious=''\nfor argument in \"\$@\"; do\n if [ \"\$previous\" = '-hls_segment_filename' ]; then segment=\"\$argument\"; fi\n previous=\"\$argument\"\n output=\"\$argument\"\ndone\nprintf '1\\n' >> " . escapeshellarg($counter) . "\n" . $segment);
|
||||
chmod($script, 0700);
|
||||
return $script;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Tests\Service;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Domain\PlaybackSession;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\OpaqueToken;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
|
||||
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
#[CoversClass(SessionRepository::class)]
|
||||
#[CoversClass(PlaybackSession::class)]
|
||||
final class SessionRepositoryTest extends TemporaryDirectoryTestCase
|
||||
{
|
||||
public function testValidSessionCreationAndLookup(): void
|
||||
{
|
||||
$repository = $this->repository();
|
||||
$created = $repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
str_repeat('b', 64),
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
|
||||
self::assertTrue(OpaqueToken::isValid($created['token']));
|
||||
self::assertSame(1060, $created['session']->expiresAt);
|
||||
self::assertEquals($created['session'], $repository->find($this->temporaryDirectory, $created['token']));
|
||||
}
|
||||
|
||||
public function testInvalidAndModifiedTokensCannotLoadSession(): void
|
||||
{
|
||||
$repository = $this->repository();
|
||||
$created = $repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
str_repeat('b', 64),
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
$token = $created['token'];
|
||||
$mutated = ('A' === $token[0] ? 'B' : 'A') . substr($token, 1);
|
||||
|
||||
self::assertNull($repository->find($this->temporaryDirectory, 'invalid'));
|
||||
self::assertNull($repository->find($this->temporaryDirectory, $mutated));
|
||||
}
|
||||
|
||||
public function testConcurrentSessionLimitIsEnforced(): void
|
||||
{
|
||||
$repository = $this->repository(maxSessions: 2);
|
||||
for ($i = 0; $i < 2; ++$i) {
|
||||
$repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
hash('sha256', 'cache-' . $i),
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
}
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Maximum concurrent playback sessions reached.');
|
||||
$repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
str_repeat('d', 64),
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
}
|
||||
|
||||
public function testParallelCreatorsCannotExceedSessionLimit(): void
|
||||
{
|
||||
$worker = $this->temporaryDirectory . '/session-worker.php';
|
||||
$autoload = dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
file_put_contents($worker, <<<'PHP'
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require $argv[1];
|
||||
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\SessionLimitException;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
|
||||
|
||||
$config = new Config(sessionTtl: 60, cacheTtl: 60, maxSessionsPerPrincipal: 1);
|
||||
$repository = new SessionRepository($config, new CachePaths($config));
|
||||
touch($argv[5] . '/ready-' . $argv[6]);
|
||||
while (!is_file($argv[4])) {
|
||||
usleep(1000);
|
||||
}
|
||||
|
||||
try {
|
||||
$repository->create(
|
||||
$argv[2],
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
hash('sha256', 'cache-' . $argv[6]),
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
echo 'created';
|
||||
} catch (SessionLimitException) {
|
||||
echo 'limited';
|
||||
}
|
||||
PHP);
|
||||
|
||||
$marker = $this->temporaryDirectory . '/start';
|
||||
$processes = [];
|
||||
foreach (['one', 'two'] as $id) {
|
||||
$process = new Process([
|
||||
PHP_BINARY,
|
||||
$worker,
|
||||
$autoload,
|
||||
$this->temporaryDirectory,
|
||||
$marker,
|
||||
$this->temporaryDirectory,
|
||||
$id,
|
||||
]);
|
||||
$process->start();
|
||||
$processes[] = $process;
|
||||
}
|
||||
|
||||
$deadline = microtime(true) + 5;
|
||||
while ((!is_file($this->temporaryDirectory . '/ready-one')
|
||||
|| !is_file($this->temporaryDirectory . '/ready-two'))
|
||||
&& microtime(true) < $deadline) {
|
||||
usleep(1000);
|
||||
}
|
||||
self::assertFileExists($this->temporaryDirectory . '/ready-one');
|
||||
self::assertFileExists($this->temporaryDirectory . '/ready-two');
|
||||
touch($marker);
|
||||
|
||||
$results = [];
|
||||
foreach ($processes as $process) {
|
||||
self::assertSame(0, $process->wait(), $process->getErrorOutput());
|
||||
$results[] = trim($process->getOutput());
|
||||
}
|
||||
sort($results);
|
||||
self::assertSame(['created', 'limited'], $results);
|
||||
|
||||
$sessionFiles = glob($this->temporaryDirectory . '/ondemand-hls/sessions/*.json') ?: [];
|
||||
self::assertCount(1, $sessionFiles);
|
||||
}
|
||||
|
||||
private function repository(int $maxSessions = 10): SessionRepository
|
||||
{
|
||||
$config = new Config(sessionTtl: 60, cacheTtl: 60, maxSessionsPerPrincipal: $maxSessions);
|
||||
return new SessionRepository($config, new CachePaths($config));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user