feat: add secure on-demand HLS playback plugin
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user