feat: add secure on-demand HLS playback plugin
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class Config
|
||||
{
|
||||
public function __construct(
|
||||
public bool $enabled = true,
|
||||
public int $sessionTtl = 1800,
|
||||
public int $segmentDuration = 6,
|
||||
public int $cacheTtl = 86400,
|
||||
public int $maxSessionsPerPrincipal = 10,
|
||||
public string $cacheDirectory = 'ondemand-hls',
|
||||
public string $ffmpegBinary = 'ffmpeg',
|
||||
public string $audioBitrate = '128k',
|
||||
public int $transcodeTimeout = 600,
|
||||
) {
|
||||
if ($sessionTtl < 60 || $sessionTtl > 86400) {
|
||||
throw new InvalidArgumentException('Session TTL must be between 60 and 86400 seconds.');
|
||||
}
|
||||
if ($segmentDuration < 2 || $segmentDuration > 20) {
|
||||
throw new InvalidArgumentException('Segment duration must be between 2 and 20 seconds.');
|
||||
}
|
||||
if ($cacheTtl < $sessionTtl || $cacheTtl > 2592000) {
|
||||
throw new InvalidArgumentException('Cache TTL must be at least the session TTL and no more than 30 days.');
|
||||
}
|
||||
if ($maxSessionsPerPrincipal < 1 || $maxSessionsPerPrincipal > 100) {
|
||||
throw new InvalidArgumentException('Maximum sessions must be between 1 and 100.');
|
||||
}
|
||||
if (1 !== preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/D', $cacheDirectory)) {
|
||||
throw new InvalidArgumentException('Cache directory must be a safe relative directory name.');
|
||||
}
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9._+\/-]{1,255}$/D', $ffmpegBinary) || str_contains($ffmpegBinary, '..')) {
|
||||
throw new InvalidArgumentException('Invalid FFmpeg binary path.');
|
||||
}
|
||||
if (1 !== preg_match('/^[1-9][0-9]{1,3}k$/D', $audioBitrate)) {
|
||||
throw new InvalidArgumentException('Audio bitrate must look like 128k.');
|
||||
}
|
||||
if ($transcodeTimeout < 30 || $transcodeTimeout > 3600) {
|
||||
throw new InvalidArgumentException('Transcode timeout must be between 30 and 3600 seconds.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromEnvironment(): self
|
||||
{
|
||||
return new self(
|
||||
enabled: self::bool('AZURACAST_ONDEMAND_HLS_ENABLED', true),
|
||||
sessionTtl: self::int('AZURACAST_ONDEMAND_HLS_SESSION_TTL', 1800),
|
||||
segmentDuration: self::int('AZURACAST_ONDEMAND_HLS_SEGMENT_DURATION', 6),
|
||||
cacheTtl: self::int('AZURACAST_ONDEMAND_HLS_CACHE_TTL', 86400),
|
||||
maxSessionsPerPrincipal: self::int('AZURACAST_ONDEMAND_HLS_MAX_SESSIONS', 10),
|
||||
cacheDirectory: self::string('AZURACAST_ONDEMAND_HLS_CACHE_DIRECTORY', 'ondemand-hls'),
|
||||
ffmpegBinary: self::string('AZURACAST_ONDEMAND_HLS_FFMPEG_BINARY', 'ffmpeg'),
|
||||
audioBitrate: self::string('AZURACAST_ONDEMAND_HLS_AUDIO_BITRATE', '128k'),
|
||||
transcodeTimeout: self::int('AZURACAST_ONDEMAND_HLS_TRANSCODE_TIMEOUT', 600),
|
||||
);
|
||||
}
|
||||
|
||||
private static function string(string $name, string $default): string
|
||||
{
|
||||
$value = getenv($name);
|
||||
return false === $value || '' === trim($value) ? $default : trim($value);
|
||||
}
|
||||
|
||||
private static function int(string $name, int $default): int
|
||||
{
|
||||
$value = getenv($name);
|
||||
return false === $value || '' === trim($value) ? $default : (int)$value;
|
||||
}
|
||||
|
||||
private static function bool(string $name, bool $default): bool
|
||||
{
|
||||
$value = getenv($name);
|
||||
if (false === $value || '' === trim($value)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$parsed = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
|
||||
if (null === $parsed) {
|
||||
throw new InvalidArgumentException(sprintf('%s must be a boolean.', $name));
|
||||
}
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Controller;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\Repository\StationMediaRepository;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\TranscodeException;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\TranscodeBusyException;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\SessionLimitException;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\Principal;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CleanupService;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\HlsCache;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\OnDemandEligibility;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final readonly class CreatePlaybackAction implements SingleActionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private StationMediaRepository $mediaRepository,
|
||||
private OnDemandEligibility $eligibility,
|
||||
private HlsCache $cache,
|
||||
private SessionRepository $sessions,
|
||||
private CleanupService $cleanup,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params,
|
||||
): ResponseInterface {
|
||||
if (!$this->config->enabled) {
|
||||
return $response->withJson(['error' => 'On-demand HLS is disabled.'], 404);
|
||||
}
|
||||
|
||||
$station = $request->getStation();
|
||||
$mediaId = (string)($params['media_id'] ?? '');
|
||||
$media = $this->mediaRepository->requireForStation($mediaId, $station);
|
||||
|
||||
if (!$this->eligibility->isEligible($media)) {
|
||||
return $response->withJson(['error' => 'Media is not available for on-demand playback.'], 404);
|
||||
}
|
||||
|
||||
$stationTemp = $station->getRadioTempDir();
|
||||
try {
|
||||
$this->cleanup->run($stationTemp);
|
||||
$cacheKey = $this->cache->ensure($station, $media);
|
||||
$created = $this->sessions->create(
|
||||
stationTempDirectory: $stationTemp,
|
||||
stationId: $station->id,
|
||||
mediaId: $media->unique_id,
|
||||
cacheKey: $cacheKey,
|
||||
principalHash: Principal::fromRequest($request),
|
||||
);
|
||||
} catch (TranscodeBusyException) {
|
||||
return $response
|
||||
->withJson(['error' => 'HLS generation is already in progress. Retry shortly.'], 503)
|
||||
->withHeader('Retry-After', '10');
|
||||
} catch (TranscodeException $exception) {
|
||||
$this->logger->error('On-demand HLS generation failed.', [
|
||||
'station_id' => $station->id,
|
||||
'media_id' => $media->unique_id,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
return $response->withJson(['error' => 'HLS media is temporarily unavailable.'], 503);
|
||||
} catch (SessionLimitException $exception) {
|
||||
return $response->withJson(['error' => $exception->getMessage()], 429);
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->logger->error('On-demand HLS session creation failed.', [
|
||||
'station_id' => $station->id,
|
||||
'media_id' => $media->unique_id,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
return $response->withJson(['error' => 'Could not create playback session.'], 500);
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->error('Unexpected on-demand HLS playback failure.', [
|
||||
'station_id' => $station->id,
|
||||
'media_id' => $media->unique_id,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
return $response->withJson(['error' => 'HLS media is temporarily unavailable.'], 503);
|
||||
}
|
||||
|
||||
$session = $created['session'];
|
||||
$url = $request->getRouter()->named(
|
||||
'ondemand-hls:asset',
|
||||
[
|
||||
'station_id' => $station->id,
|
||||
'token' => $created['token'],
|
||||
'resource' => 'master.m3u8',
|
||||
],
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
$this->logger->info('Created on-demand HLS playback session.', [
|
||||
'station_id' => $station->id,
|
||||
'media_id' => $media->unique_id,
|
||||
'session_id_prefix' => substr($session->id, 0, 12),
|
||||
'expires_at' => $session->expiresAt,
|
||||
]);
|
||||
|
||||
return $response->withJson([
|
||||
'url' => $url,
|
||||
'expires_at' => $session->expiresAt,
|
||||
'media_id' => $media->unique_id,
|
||||
], 201)->withHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Domain;
|
||||
|
||||
use JsonException;
|
||||
use UnexpectedValueException;
|
||||
|
||||
final readonly class PlaybackSession
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public int $stationId,
|
||||
public string $mediaId,
|
||||
public string $cacheKey,
|
||||
public string $principalHash,
|
||||
public int $createdAt,
|
||||
public int $expiresAt,
|
||||
) {
|
||||
if (1 !== preg_match('/^[a-f0-9]{64}$/D', $id)) {
|
||||
throw new UnexpectedValueException('Invalid session ID.');
|
||||
}
|
||||
if ($stationId < 1 || 1 !== preg_match('/^[a-zA-Z0-9-]{1,64}$/D', $mediaId)) {
|
||||
throw new UnexpectedValueException('Invalid session media binding.');
|
||||
}
|
||||
if (1 !== preg_match('/^[a-f0-9]{64}$/D', $cacheKey)) {
|
||||
throw new UnexpectedValueException('Invalid cache key.');
|
||||
}
|
||||
if (1 !== preg_match('/^[a-f0-9]{64}$/D', $principalHash)) {
|
||||
throw new UnexpectedValueException('Invalid principal hash.');
|
||||
}
|
||||
if ($createdAt < 1 || $expiresAt <= $createdAt) {
|
||||
throw new UnexpectedValueException('Invalid session timestamps.');
|
||||
}
|
||||
}
|
||||
|
||||
public function isExpired(int $now): bool
|
||||
{
|
||||
return $now >= $this->expiresAt;
|
||||
}
|
||||
|
||||
public function belongsToStation(int $stationId): bool
|
||||
{
|
||||
return $this->stationId === $stationId;
|
||||
}
|
||||
|
||||
/** @return array<string, int|string> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'station_id' => $this->stationId,
|
||||
'media_id' => $this->mediaId,
|
||||
'cache_key' => $this->cacheKey,
|
||||
'principal_hash' => $this->principalHash,
|
||||
'created_at' => $this->createdAt,
|
||||
'expires_at' => $this->expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$required = ['id', 'station_id', 'media_id', 'cache_key', 'principal_hash', 'created_at', 'expires_at'];
|
||||
foreach ($required as $key) {
|
||||
if (!array_key_exists($key, $data)) {
|
||||
throw new UnexpectedValueException('Malformed session record.');
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
(string)$data['id'],
|
||||
(int)$data['station_id'],
|
||||
(string)$data['media_id'],
|
||||
(string)$data['cache_key'],
|
||||
(string)$data['principal_hash'],
|
||||
(int)$data['created_at'],
|
||||
(int)$data['expires_at'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\EventHandler;
|
||||
|
||||
use App\Event\Nginx\WriteNginxConfiguration;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
|
||||
final readonly class NginxConfiguration
|
||||
{
|
||||
public function __construct(private Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(WriteNginxConfiguration $event): void
|
||||
{
|
||||
if (!$this->config->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$station = $event->getStation();
|
||||
$stationId = $station->id;
|
||||
$assetDirectory = rtrim($station->getRadioTempDir(), '/')
|
||||
. '/' . $this->config->cacheDirectory . '/assets/';
|
||||
$transcodeTimeout = $this->config->transcodeTimeout;
|
||||
|
||||
$event->appendBlock(<<<NGINX
|
||||
# Protected on-demand HLS. Handle this route directly in PHP-FPM so denied
|
||||
# bearer-token requests never leave this access_log-off location.
|
||||
location ^~ /api/station/{$stationId}/ondemand-hls/playback/ {
|
||||
access_log off;
|
||||
|
||||
include fastcgi_params;
|
||||
fastcgi_read_timeout {$transcodeTimeout};
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME \$realpath_root/index.php;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param PHP_SELF /index.php;
|
||||
fastcgi_param DOCUMENT_ROOT \$realpath_root;
|
||||
fastcgi_pass php-fpm-www;
|
||||
}
|
||||
|
||||
# Successful authorization redirects internally here. Keeping access logs
|
||||
# disabled in the final X-Accel location prevents the original bearer URL
|
||||
# from being logged after Nginx performs the internal redirect.
|
||||
location ^~ /internal/ondemand-hls/{$stationId}/ {
|
||||
internal;
|
||||
access_log off;
|
||||
add_header Cache-Control "private, no-store, max-age=0" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
alias {$assetDirectory};
|
||||
}
|
||||
NGINX);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class SessionLimitException extends RuntimeException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Maximum concurrent playback sessions reached.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Exception;
|
||||
|
||||
final class TranscodeBusyException extends TranscodeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class TranscodeException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Security;
|
||||
|
||||
final class OpaqueToken
|
||||
{
|
||||
public const int BYTES = 32;
|
||||
public const int ENCODED_LENGTH = 43;
|
||||
|
||||
public static function generate(): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode(random_bytes(self::BYTES)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
public static function isValid(string $token): bool
|
||||
{
|
||||
return self::ENCODED_LENGTH === strlen($token)
|
||||
&& 1 === preg_match('/^[A-Za-z0-9_-]{43}$/D', $token);
|
||||
}
|
||||
|
||||
public static function id(string $token): string
|
||||
{
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Security;
|
||||
|
||||
use App\Http\ServerRequest;
|
||||
use Throwable;
|
||||
|
||||
final class Principal
|
||||
{
|
||||
public static function fromRequest(ServerRequest $request): string
|
||||
{
|
||||
try {
|
||||
$userId = (string)$request->getUser()->id;
|
||||
} catch (Throwable) {
|
||||
$userId = 'anonymous';
|
||||
}
|
||||
|
||||
$server = $request->getServerParams();
|
||||
$ip = (string)($server['REMOTE_ADDR'] ?? 'unknown');
|
||||
|
||||
return hash('sha256', $userId . "\0" . $ip);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Security;
|
||||
|
||||
final class ResourcePath
|
||||
{
|
||||
public static function isAllowed(string $resource): bool
|
||||
{
|
||||
return 1 === preg_match(
|
||||
'/\A(?:master\.m3u8|media\.m3u8|segment[0-9]{5,9}\.ts)\z/D',
|
||||
$resource
|
||||
);
|
||||
}
|
||||
|
||||
public static function resolve(string $assetDirectory, string $resource): ?string
|
||||
{
|
||||
if (!self::isAllowed($resource) || is_link($assetDirectory)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$base = realpath($assetDirectory);
|
||||
if (false === $base || !is_dir($base)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = $base . DIRECTORY_SEPARATOR . $resource;
|
||||
if (is_link($candidate) || !is_file($candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$real = realpath($candidate);
|
||||
if (false === $real || !str_starts_with($real, $base . DIRECTORY_SEPARATOR)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $real;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use Plugin\AzuraCastOnDemandHls\Domain\PlaybackSession;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\ResourcePath;
|
||||
|
||||
final readonly class AssetAuthorizer
|
||||
{
|
||||
public function __construct(
|
||||
private SessionRepository $sessions,
|
||||
private CachePaths $paths,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{session:PlaybackSession,path:string}|null */
|
||||
public function authorize(
|
||||
string $stationTempDirectory,
|
||||
int $stationId,
|
||||
string $token,
|
||||
string $resource,
|
||||
?int $now = null,
|
||||
): ?array {
|
||||
if (!ResourcePath::isAllowed($resource)) {
|
||||
return null;
|
||||
}
|
||||
$session = $this->sessions->find($stationTempDirectory, $token);
|
||||
$now ??= time();
|
||||
if (null === $session || $session->isExpired($now) || !$session->belongsToStation($stationId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$directory = $this->paths->asset($stationTempDirectory, $session->cacheKey);
|
||||
$path = ResourcePath::resolve($directory, $resource);
|
||||
return null === $path ? null : ['session' => $session, 'path' => $path];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use RuntimeException;
|
||||
|
||||
final readonly class CachePaths
|
||||
{
|
||||
public function __construct(private Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function root(string $stationTempDirectory): string
|
||||
{
|
||||
return rtrim($stationTempDirectory, DIRECTORY_SEPARATOR)
|
||||
. DIRECTORY_SEPARATOR . $this->config->cacheDirectory;
|
||||
}
|
||||
|
||||
public function sessions(string $stationTempDirectory): string
|
||||
{
|
||||
return $this->root($stationTempDirectory) . '/sessions';
|
||||
}
|
||||
|
||||
public function assets(string $stationTempDirectory): string
|
||||
{
|
||||
return $this->root($stationTempDirectory) . '/assets';
|
||||
}
|
||||
|
||||
public function locks(string $stationTempDirectory): string
|
||||
{
|
||||
return $this->root($stationTempDirectory) . '/locks';
|
||||
}
|
||||
|
||||
public function asset(string $stationTempDirectory, string $cacheKey): string
|
||||
{
|
||||
if (1 !== preg_match('/^[a-f0-9]{64}$/D', $cacheKey)) {
|
||||
throw new RuntimeException('Unsafe cache key.');
|
||||
}
|
||||
return $this->assets($stationTempDirectory) . '/' . $cacheKey;
|
||||
}
|
||||
|
||||
public function ensure(string $stationTempDirectory): void
|
||||
{
|
||||
$directories = [
|
||||
$this->root($stationTempDirectory) => 0755,
|
||||
$this->sessions($stationTempDirectory) => 0700,
|
||||
$this->assets($stationTempDirectory) => 0755,
|
||||
$this->locks($stationTempDirectory) => 0700,
|
||||
];
|
||||
foreach ($directories as $directory => $mode) {
|
||||
if (is_link($directory)) {
|
||||
throw new RuntimeException('Refusing to use a symlinked HLS cache directory.');
|
||||
}
|
||||
// Another request may create the same directory after is_dir().
|
||||
// Suppress that benign E_WARNING, then verify the final state.
|
||||
if (!is_dir($directory) && !@mkdir($directory, $mode, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('Could not create the HLS cache directory.');
|
||||
}
|
||||
chmod($directory, $mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Domain\PlaybackSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
final readonly class CleanupService
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private CachePaths $paths,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{sessions:int,assets:int} */
|
||||
public function run(string $stationTempDirectory, ?int $now = null): array
|
||||
{
|
||||
$now ??= time();
|
||||
$this->paths->ensure($stationTempDirectory);
|
||||
$activeCaches = [];
|
||||
$removedSessions = 0;
|
||||
$removedAssets = 0;
|
||||
|
||||
foreach (new \FilesystemIterator($this->paths->sessions($stationTempDirectory), \FilesystemIterator::SKIP_DOTS) as $file) {
|
||||
if ($file->isLink() || !$file->isFile() || 1 !== preg_match('/^[a-f0-9]{64}\.json$/D', $file->getFilename())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$raw = file_get_contents($file->getPathname());
|
||||
$data = false !== $raw ? json_decode($raw, true, 32, JSON_THROW_ON_ERROR) : null;
|
||||
$session = is_array($data) ? PlaybackSession::fromArray($data) : null;
|
||||
if (null === $session || $session->isExpired($now)) {
|
||||
@unlink($file->getPathname());
|
||||
++$removedSessions;
|
||||
} else {
|
||||
$activeCaches[$session->cacheKey] = true;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
@unlink($file->getPathname());
|
||||
++$removedSessions;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (new \FilesystemIterator($this->paths->assets($stationTempDirectory), \FilesystemIterator::SKIP_DOTS) as $item) {
|
||||
$name = $item->getFilename();
|
||||
$validCache = 1 === preg_match('/^[a-f0-9]{64}$/D', $name);
|
||||
$staleBuild = str_starts_with($name, '.build-') && $item->getMTime() < $now - 3600;
|
||||
$expiredCache = $validCache
|
||||
&& !isset($activeCaches[$name])
|
||||
&& $item->getMTime() < $now - $this->config->cacheTtl;
|
||||
if ($staleBuild || $expiredCache) {
|
||||
$this->removeTree($item->getPathname());
|
||||
++$removedAssets;
|
||||
}
|
||||
}
|
||||
|
||||
if ($removedSessions > 0 || $removedAssets > 0) {
|
||||
$this->logger->info('Cleaned on-demand HLS cache.', [
|
||||
'sessions' => $removedSessions,
|
||||
'assets' => $removedAssets,
|
||||
]);
|
||||
}
|
||||
return ['sessions' => $removedSessions, 'assets' => $removedAssets];
|
||||
}
|
||||
|
||||
private function removeTree(string $path): void
|
||||
{
|
||||
if (is_link($path) || is_file($path)) {
|
||||
@unlink($path);
|
||||
return;
|
||||
}
|
||||
if (!is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
foreach (new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS) as $item) {
|
||||
$this->removeTree($item->getPathname());
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use App\Entity\Station;
|
||||
use App\Entity\StationMedia;
|
||||
use App\Flysystem\StationFilesystems;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\TranscodeBusyException;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\TranscodeException;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\ResourcePath;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
final readonly class HlsCache
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private CachePaths $paths,
|
||||
private StationFilesystems $stationFilesystems,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function key(Station $station, StationMedia $media): string
|
||||
{
|
||||
return hash('sha256', implode(':', [
|
||||
'v1',
|
||||
$station->id,
|
||||
$media->storage_location_id,
|
||||
$media->unique_id,
|
||||
$media->mtime,
|
||||
$this->config->segmentDuration,
|
||||
$this->config->audioBitrate,
|
||||
]));
|
||||
}
|
||||
|
||||
public function ensure(Station $station, StationMedia $media): string
|
||||
{
|
||||
$stationTemp = $station->getRadioTempDir();
|
||||
$this->paths->ensure($stationTemp);
|
||||
$cacheKey = $this->key($station, $media);
|
||||
$target = $this->paths->asset($stationTemp, $cacheKey);
|
||||
|
||||
if ($this->isComplete($target)) {
|
||||
@touch($target);
|
||||
return $cacheKey;
|
||||
}
|
||||
|
||||
$lockPath = $this->paths->locks($stationTemp) . '/' . $cacheKey . '.lock';
|
||||
$lock = fopen($lockPath, 'c');
|
||||
if (false === $lock) {
|
||||
throw new TranscodeException('Could not open HLS generation lock.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
throw new TranscodeBusyException('This HLS rendition is already being generated.');
|
||||
}
|
||||
if ($this->isComplete($target)) {
|
||||
@touch($target);
|
||||
return $cacheKey;
|
||||
}
|
||||
|
||||
// Limit synchronous FFmpeg work to one process per station. This keeps
|
||||
// authenticated bursts from exhausting PHP workers and host CPU.
|
||||
$stationLock = fopen($this->paths->locks($stationTemp) . '/transcode.lock', 'c');
|
||||
if (false === $stationLock) {
|
||||
throw new TranscodeException('Could not open station transcoding lock.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!flock($stationLock, LOCK_EX | LOCK_NB)) {
|
||||
throw new TranscodeBusyException('Another HLS rendition is being generated for this station.');
|
||||
}
|
||||
|
||||
$temporary = $this->paths->assets($stationTemp)
|
||||
. '/.build-' . $cacheKey . '-' . bin2hex(random_bytes(6));
|
||||
if (!mkdir($temporary, 0755, true) && !is_dir($temporary)) {
|
||||
throw new TranscodeException('Could not create temporary HLS build directory.');
|
||||
}
|
||||
|
||||
try {
|
||||
$filesystem = $this->stationFilesystems->getMediaFilesystem($station);
|
||||
if (!$filesystem->fileExists($media->path)) {
|
||||
throw new TranscodeException('The source media file does not exist.');
|
||||
}
|
||||
|
||||
$filesystem->withLocalFile(
|
||||
$media->path,
|
||||
fn(string $source): bool => $this->transcode($source, $temporary)
|
||||
);
|
||||
$this->writeMasterPlaylist($temporary);
|
||||
$this->validateBuild($temporary);
|
||||
|
||||
if (is_dir($target)) {
|
||||
$this->removeTree($target);
|
||||
}
|
||||
if (!rename($temporary, $target)) {
|
||||
throw new TranscodeException('Could not publish generated HLS assets.');
|
||||
}
|
||||
chmod($target, 0755);
|
||||
} catch (Throwable $exception) {
|
||||
$this->removeTree($temporary);
|
||||
if ($exception instanceof TranscodeException) {
|
||||
throw $exception;
|
||||
}
|
||||
throw new TranscodeException('HLS generation failed.', 0, $exception);
|
||||
}
|
||||
} finally {
|
||||
flock($stationLock, LOCK_UN);
|
||||
fclose($stationLock);
|
||||
}
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
|
||||
$this->logger->info('Generated on-demand HLS cache.', [
|
||||
'station_id' => $station->id,
|
||||
'media_id' => $media->unique_id,
|
||||
'cache_key_prefix' => substr($cacheKey, 0, 12),
|
||||
]);
|
||||
return $cacheKey;
|
||||
}
|
||||
|
||||
private function transcode(string $source, string $destination): bool
|
||||
{
|
||||
if (!is_file($source)) {
|
||||
throw new TranscodeException('Resolved media source is not a regular file.');
|
||||
}
|
||||
|
||||
$process = new Process([
|
||||
$this->config->ffmpegBinary,
|
||||
'-hide_banner', '-loglevel', 'error', '-nostdin', '-y',
|
||||
'-i', $source,
|
||||
'-map', '0:a:0', '-vn',
|
||||
'-c:a', 'aac', '-b:a', $this->config->audioBitrate,
|
||||
'-f', 'hls',
|
||||
'-hls_time', (string)$this->config->segmentDuration,
|
||||
'-hls_playlist_type', 'vod',
|
||||
'-hls_flags', 'independent_segments+temp_file',
|
||||
'-hls_segment_filename', $destination . '/segment%05d.ts',
|
||||
$destination . '/media.m3u8',
|
||||
]);
|
||||
$process->setTimeout($this->config->transcodeTimeout);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (ProcessTimedOutException $exception) {
|
||||
throw new TranscodeException('FFmpeg timed out.', 0, $exception);
|
||||
}
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$detail = trim(substr($process->getErrorOutput(), 0, 1000));
|
||||
$this->logger->error('FFmpeg failed to generate on-demand HLS.', [
|
||||
'exit_code' => $process->getExitCode(),
|
||||
'error' => $detail,
|
||||
]);
|
||||
throw new TranscodeException('FFmpeg failed to generate HLS output.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function writeMasterPlaylist(string $directory): void
|
||||
{
|
||||
$bitsPerSecond = ((int)rtrim($this->config->audioBitrate, 'k')) * 1100;
|
||||
$playlist = "#EXTM3U\n#EXT-X-VERSION:3\n"
|
||||
. '#EXT-X-STREAM-INF:BANDWIDTH=' . $bitsPerSecond . ',CODECS="mp4a.40.2"' . "\n"
|
||||
. "media.m3u8\n";
|
||||
if (false === file_put_contents($directory . '/master.m3u8', $playlist, LOCK_EX)) {
|
||||
throw new TranscodeException('Could not write the HLS master playlist.');
|
||||
}
|
||||
}
|
||||
|
||||
private function validateBuild(string $directory): void
|
||||
{
|
||||
foreach (['master.m3u8', 'media.m3u8'] as $playlist) {
|
||||
if (null === ResourcePath::resolve($directory, $playlist)) {
|
||||
throw new TranscodeException('FFmpeg produced an incomplete HLS package.');
|
||||
}
|
||||
}
|
||||
|
||||
$segments = glob($directory . '/segment*.ts') ?: [];
|
||||
if ([] === $segments) {
|
||||
throw new TranscodeException('FFmpeg produced no HLS segments.');
|
||||
}
|
||||
|
||||
foreach (new \FilesystemIterator($directory, \FilesystemIterator::SKIP_DOTS) as $file) {
|
||||
if ($file->isLink() || !$file->isFile() || !ResourcePath::isAllowed($file->getFilename())) {
|
||||
throw new TranscodeException('FFmpeg produced an unexpected HLS cache entry.');
|
||||
}
|
||||
chmod($file->getPathname(), 0644);
|
||||
}
|
||||
|
||||
$manifest = file_get_contents($directory . '/media.m3u8');
|
||||
if (false === $manifest || str_contains($manifest, '/') || str_contains($manifest, '\\')) {
|
||||
throw new TranscodeException('Generated media playlist contains an unsafe resource path.');
|
||||
}
|
||||
foreach (preg_split('/\R/', $manifest) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ('' !== $line && '#' !== $line[0] && !ResourcePath::isAllowed($line)) {
|
||||
throw new TranscodeException('Generated media playlist references an unexpected resource.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isComplete(string $directory): bool
|
||||
{
|
||||
return null !== ResourcePath::resolve($directory, 'master.m3u8')
|
||||
&& null !== ResourcePath::resolve($directory, 'media.m3u8')
|
||||
&& [] !== (glob($directory . '/segment*.ts') ?: []);
|
||||
}
|
||||
|
||||
private function removeTree(string $path): void
|
||||
{
|
||||
if (!file_exists($path) && !is_link($path)) {
|
||||
return;
|
||||
}
|
||||
if (is_link($path) || is_file($path)) {
|
||||
@unlink($path);
|
||||
return;
|
||||
}
|
||||
foreach (new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS) as $item) {
|
||||
$this->removeTree($item->getPathname());
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use App\Entity\StationMedia;
|
||||
use App\Entity\StationPlaylistMedia;
|
||||
|
||||
final class OnDemandEligibility
|
||||
{
|
||||
public function isEligible(StationMedia $media): bool
|
||||
{
|
||||
foreach ($media->playlists as $playlistMedia) {
|
||||
if (!$playlistMedia instanceof StationPlaylistMedia) {
|
||||
continue;
|
||||
}
|
||||
$playlist = $playlistMedia->playlist;
|
||||
if ($playlist->is_enabled && $playlist->include_in_on_demand) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use App\Entity\Station;
|
||||
use App\Sync\Task\AbstractTask;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Throwable;
|
||||
|
||||
final class ScheduledCleanupTask extends AbstractTask
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
private readonly CleanupService $cleanup,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSchedulePattern(): string
|
||||
{
|
||||
return '17 * * * *';
|
||||
}
|
||||
|
||||
public function run(bool $force = false): void
|
||||
{
|
||||
if (!$this->config->enabled && !$force) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->iterateStations() as $station) {
|
||||
try {
|
||||
/** @var Station $station */
|
||||
$this->cleanup->run($station->getRadioTempDir());
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->error('Scheduled on-demand HLS cleanup failed.', [
|
||||
'station_id' => $station->id,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Domain\PlaybackSession;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\SessionLimitException;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\OpaqueToken;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final readonly class SessionRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private CachePaths $paths,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{token:string, session:PlaybackSession} */
|
||||
public function create(
|
||||
string $stationTempDirectory,
|
||||
int $stationId,
|
||||
string $mediaId,
|
||||
string $cacheKey,
|
||||
string $principalHash,
|
||||
?int $now = null,
|
||||
): array {
|
||||
$now ??= time();
|
||||
$this->paths->ensure($stationTempDirectory);
|
||||
|
||||
if (1 !== preg_match('/^[a-f0-9]{64}$/D', $principalHash)) {
|
||||
throw new RuntimeException('Unsafe principal identifier.');
|
||||
}
|
||||
$lockPath = $this->paths->locks($stationTempDirectory) . '/sessions-' . $principalHash . '.lock';
|
||||
$lock = fopen($lockPath, 'c');
|
||||
if (false === $lock) {
|
||||
throw new RuntimeException('Could not open the session creation lock.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!flock($lock, LOCK_EX)) {
|
||||
throw new RuntimeException('Could not acquire the session creation lock.');
|
||||
}
|
||||
if ($this->countActiveForPrincipal($stationTempDirectory, $principalHash, $now)
|
||||
>= $this->config->maxSessionsPerPrincipal) {
|
||||
throw new SessionLimitException();
|
||||
}
|
||||
|
||||
$token = OpaqueToken::generate();
|
||||
$id = OpaqueToken::id($token);
|
||||
$session = new PlaybackSession(
|
||||
id: $id,
|
||||
stationId: $stationId,
|
||||
mediaId: $mediaId,
|
||||
cacheKey: $cacheKey,
|
||||
principalHash: $principalHash,
|
||||
createdAt: $now,
|
||||
expiresAt: $now + $this->config->sessionTtl,
|
||||
);
|
||||
|
||||
$target = $this->sessionPath($stationTempDirectory, $id);
|
||||
$temporary = $target . '.tmp-' . bin2hex(random_bytes(6));
|
||||
$json = json_encode($session->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||
if (false === file_put_contents($temporary, $json, LOCK_EX)) {
|
||||
throw new RuntimeException('Could not persist playback session.');
|
||||
}
|
||||
chmod($temporary, 0600);
|
||||
if (!rename($temporary, $target)) {
|
||||
@unlink($temporary);
|
||||
throw new RuntimeException('Could not publish playback session.');
|
||||
}
|
||||
|
||||
return ['token' => $token, 'session' => $session];
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
public function find(string $stationTempDirectory, string $token): ?PlaybackSession
|
||||
{
|
||||
if (!OpaqueToken::isValid($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $this->sessionPath($stationTempDirectory, OpaqueToken::id($token));
|
||||
if (is_link($path) || !is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = file_get_contents($path);
|
||||
if (false === $raw || strlen($raw) > 4096) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
$session = PlaybackSession::fromArray($data);
|
||||
return hash_equals($session->id, OpaqueToken::id($token)) ? $session : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function countActiveForPrincipal(string $stationTempDirectory, string $principalHash, int $now): int
|
||||
{
|
||||
$directory = $this->paths->sessions($stationTempDirectory);
|
||||
if (!is_dir($directory)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach (new \FilesystemIterator($directory, \FilesystemIterator::SKIP_DOTS) as $file) {
|
||||
if (!$file->isFile() || $file->isLink() || 1 !== preg_match('/^[a-f0-9]{64}\.json$/D', $file->getFilename())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$raw = file_get_contents($file->getPathname());
|
||||
$data = false !== $raw ? json_decode($raw, true, 32, JSON_THROW_ON_ERROR) : null;
|
||||
if (is_array($data)) {
|
||||
$session = PlaybackSession::fromArray($data);
|
||||
if (!$session->isExpired($now) && hash_equals($session->principalHash, $principalHash)) {
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function sessionPath(string $stationTempDirectory, string $id): string
|
||||
{
|
||||
if (1 !== preg_match('/^[a-f0-9]{64}$/D', $id)) {
|
||||
throw new RuntimeException('Unsafe session ID.');
|
||||
}
|
||||
return $this->paths->sessions($stationTempDirectory) . '/' . $id . '.json';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user