feat: add secure on-demand HLS playback plugin

This commit is contained in:
root
2026-09-04 20:23:10 +02:00
commit 6fae6a3a43
35 changed files with 2283 additions and 0 deletions
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Controller;
use App\Controller\SingleActionInterface;
use App\Http\Response;
use App\Http\ServerRequest;
use Plugin\AzuraCastOnDemandHls\Config;
use Plugin\AzuraCastOnDemandHls\Service\AssetAuthorizer;
use Plugin\AzuraCastOnDemandHls\Service\CleanupService;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use Throwable;
final readonly class ServePlaybackAssetAction implements SingleActionInterface
{
public function __construct(
private Config $config,
private AssetAuthorizer $authorizer,
private CleanupService $cleanup,
private LoggerInterface $logger,
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
array $params,
): ResponseInterface {
if (!$this->config->enabled) {
return $this->denied($response);
}
$station = $request->getStation();
$token = (string)($params['token'] ?? '');
$resource = (string)($params['resource'] ?? '');
$authorized = $this->authorizer->authorize(
$station->getRadioTempDir(),
$station->id,
$token,
$resource,
);
if (null === $authorized) {
$this->logger->warning('Rejected on-demand HLS asset request.', [
'station_id' => $station->id,
'resource' => $resource,
'token_fingerprint' => '' !== $token ? substr(hash('sha256', $token), 0, 12) : null,
]);
return $this->denied($response);
}
// Keep cleanup request-driven without adding latency to every segment request.
if (0 === random_int(0, 99)) {
try {
$this->cleanup->run($station->getRadioTempDir());
} catch (Throwable $exception) {
// Delivery is already authorized; maintenance failures must not
// interrupt playback or turn a recoverable cleanup issue into 5xx.
$this->logger->warning('Request-driven on-demand HLS cleanup failed.', [
'station_id' => $station->id,
'exception' => $exception,
]);
}
}
$accelPath = '/internal/ondemand-hls/' . $station->id
. '/' . $authorized['session']->cacheKey . '/' . $resource;
$contentType = str_ends_with($resource, '.m3u8')
? 'application/vnd.apple.mpegurl'
: 'video/mp2t';
return $response
->withHeader('Content-Type', $contentType)
->withHeader('Content-Disposition', 'inline')
->withHeader('Cache-Control', 'private, no-store, max-age=0')
->withHeader('Pragma', 'no-cache')
->withHeader('X-Content-Type-Options', 'nosniff')
->withHeader('X-Accel-Redirect', $accelPath);
}
private function denied(Response $response): ResponseInterface
{
return $response
->withJson(['error' => 'Invalid or expired playback session.'], 403)
->withHeader('Cache-Control', 'no-store');
}
}