feat: add configurable HLS CORS allowlists
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Command;
|
||||
|
||||
use App\Console\Command\CommandAbstract;
|
||||
use App\Entity\Repository\StationRepository;
|
||||
use App\Entity\Station;
|
||||
use App\Nginx\Nginx;
|
||||
use Plugin\AzuraCastOnDemandHls\Repository\CorsConfigurationRepository;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
abstract class AbstractCorsCommand extends CommandAbstract
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly StationRepository $stationRepository,
|
||||
protected readonly CorsConfigurationRepository $corsConfiguration,
|
||||
private readonly Nginx $nginx,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configureStationArgument(): void
|
||||
{
|
||||
$this->addArgument('station', InputArgument::REQUIRED, 'Station ID or short name.');
|
||||
}
|
||||
|
||||
protected function findStation(InputInterface $input, SymfonyStyle $io): ?Station
|
||||
{
|
||||
$station = $this->stationRepository->findByIdentifier((string)$input->getArgument('station'));
|
||||
if (!$station instanceof Station) {
|
||||
$io->error('Station not found.');
|
||||
return null;
|
||||
}
|
||||
return $station;
|
||||
}
|
||||
|
||||
protected function reloadNginx(Station $station, SymfonyStyle $io): void
|
||||
{
|
||||
$this->nginx->writeConfiguration($station, reloadIfChanged: true);
|
||||
$io->note('Station Nginx configuration was regenerated; Nginx reloads only when it changed.');
|
||||
}
|
||||
|
||||
/** @param list<string> $origins */
|
||||
protected function renderOrigins(SymfonyStyle $io, array $origins): void
|
||||
{
|
||||
if ([] === $origins) {
|
||||
$io->writeln('No cross-site origins are allowed. Same-origin playback remains available.');
|
||||
return;
|
||||
}
|
||||
$io->listing($origins);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Command;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'azuracast:ondemand-hls:cors:add', description: 'Add one allowed cross-site HLS origin for a station.')]
|
||||
final class AddCorsOriginCommand extends AbstractCorsCommand
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->configureStationArgument();
|
||||
$this->addArgument('origin', InputArgument::REQUIRED, 'An HTTP(S) origin.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$station = $this->findStation($input, $io);
|
||||
if (null === $station) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
try {
|
||||
$origins = $this->corsConfiguration->addAllowedOrigin($station->id, (string)$input->getArgument('origin'));
|
||||
$this->reloadNginx($station, $io);
|
||||
$io->success('Allowed HLS CORS origin added.');
|
||||
$this->renderOrigins($io, $origins);
|
||||
return self::SUCCESS;
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$io->error($exception->getMessage());
|
||||
return self::INVALID;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'azuracast:ondemand-hls:cors:clear', description: 'Clear all allowed cross-site HLS origins for a station.')]
|
||||
final class ClearCorsOriginsCommand extends AbstractCorsCommand
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->configureStationArgument();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$station = $this->findStation($input, $io);
|
||||
if (null === $station) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->corsConfiguration->clearAllowedOrigins($station->id);
|
||||
$this->reloadNginx($station, $io);
|
||||
$io->success('All allowed HLS CORS origins were cleared.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'azuracast:ondemand-hls:cors:list', description: 'List allowed cross-site HLS origins for a station.')]
|
||||
final class ListCorsOriginsCommand extends AbstractCorsCommand
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->configureStationArgument();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$station = $this->findStation($input, $io);
|
||||
if (null === $station) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->renderOrigins($io, $this->corsConfiguration->getAllowedOrigins($station->id));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Command;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'azuracast:ondemand-hls:cors:remove', description: 'Remove one allowed cross-site HLS origin for a station.')]
|
||||
final class RemoveCorsOriginCommand extends AbstractCorsCommand
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->configureStationArgument();
|
||||
$this->addArgument('origin', InputArgument::REQUIRED, 'An HTTP(S) origin.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$station = $this->findStation($input, $io);
|
||||
if (null === $station) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
try {
|
||||
$origins = $this->corsConfiguration->removeAllowedOrigin($station->id, (string)$input->getArgument('origin'));
|
||||
$this->reloadNginx($station, $io);
|
||||
$io->success('Allowed HLS CORS origin removed.');
|
||||
$this->renderOrigins($io, $origins);
|
||||
return self::SUCCESS;
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$io->error($exception->getMessage());
|
||||
return self::INVALID;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Command;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'azuracast:ondemand-hls:cors:set', description: 'Replace allowed cross-site HLS origins for a station.')]
|
||||
final class SetCorsOriginsCommand extends AbstractCorsCommand
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->configureStationArgument();
|
||||
$this->addArgument('origin', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'One or more HTTP(S) origins.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$station = $this->findStation($input, $io);
|
||||
if (null === $station) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
try {
|
||||
$origins = $this->corsConfiguration->replaceAllowedOrigins($station->id, $input->getArgument('origin'));
|
||||
$this->reloadNginx($station, $io);
|
||||
$io->success('Allowed HLS CORS origins replaced.');
|
||||
$this->renderOrigins($io, $origins);
|
||||
return self::SUCCESS;
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$io->error($exception->getMessage());
|
||||
return self::INVALID;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Cors\CorsConfigurationProvider;
|
||||
use Plugin\AzuraCastOnDemandHls\Cors\CorsPolicy;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\AssetAuthorizer;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CleanupService;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
@@ -20,6 +22,7 @@ final readonly class ServePlaybackAssetAction implements SingleActionInterface
|
||||
private Config $config,
|
||||
private AssetAuthorizer $authorizer,
|
||||
private CleanupService $cleanup,
|
||||
private CorsConfigurationProvider $corsConfiguration,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -34,6 +37,8 @@ final readonly class ServePlaybackAssetAction implements SingleActionInterface
|
||||
}
|
||||
|
||||
$station = $request->getStation();
|
||||
$cors = new CorsPolicy($this->corsConfiguration->getAllowedOrigins($station->id));
|
||||
$origin = $request->getHeaderLine('Origin');
|
||||
$token = (string)($params['token'] ?? '');
|
||||
$resource = (string)($params['resource'] ?? '');
|
||||
$authorized = $this->authorizer->authorize(
|
||||
@@ -49,7 +54,14 @@ final readonly class ServePlaybackAssetAction implements SingleActionInterface
|
||||
'resource' => $resource,
|
||||
'token_fingerprint' => '' !== $token ? substr(hash('sha256', $token), 0, 12) : null,
|
||||
]);
|
||||
return $this->denied($response);
|
||||
return $this->withHeaders($this->denied($response), $cors->headersFor($origin));
|
||||
}
|
||||
|
||||
if ('OPTIONS' === strtoupper($request->getMethod())) {
|
||||
return $this->withHeaders(
|
||||
$response->withStatus(204)->withHeader('Content-Length', '0'),
|
||||
$cors->headersFor($origin, true),
|
||||
);
|
||||
}
|
||||
|
||||
// Keep cleanup request-driven without adding latency to every segment request.
|
||||
@@ -82,6 +94,15 @@ final readonly class ServePlaybackAssetAction implements SingleActionInterface
|
||||
->withHeader('X-Accel-Redirect', $accelPath);
|
||||
}
|
||||
|
||||
/** @param array<string, string> $headers */
|
||||
private function withHeaders(ResponseInterface $response, array $headers): ResponseInterface
|
||||
{
|
||||
foreach ($headers as $name => $value) {
|
||||
$response = $response->withHeader($name, $value);
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function denied(Response $response): ResponseInterface
|
||||
{
|
||||
return $response
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Cors;
|
||||
|
||||
interface CorsConfigurationProvider
|
||||
{
|
||||
/** @return list<string> */
|
||||
public function getAllowedOrigins(int $stationId): array;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Cors;
|
||||
|
||||
/** Builds response headers without ever combining multiple origins into one header. */
|
||||
final class CorsPolicy
|
||||
{
|
||||
/** @param list<string> $allowedOrigins */
|
||||
public function __construct(private array $allowedOrigins)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function headersFor(?string $requestOrigin, bool $preflight = false): array
|
||||
{
|
||||
if (null === $requestOrigin || '' === trim($requestOrigin)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$origin = Origin::normalize($requestOrigin);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return ['Vary' => 'Origin'];
|
||||
}
|
||||
|
||||
if (!in_array($origin, $this->allowedOrigins, true)) {
|
||||
return ['Vary' => 'Origin'];
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Access-Control-Allow-Origin' => $origin,
|
||||
'Vary' => 'Origin',
|
||||
];
|
||||
|
||||
if ($preflight) {
|
||||
$headers += [
|
||||
'Access-Control-Allow-Methods' => 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Range',
|
||||
'Access-Control-Max-Age' => '600',
|
||||
];
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Cors;
|
||||
|
||||
final class NginxRules
|
||||
{
|
||||
/** @param list<string> $origins */
|
||||
public static function forOrigins(array $origins): string
|
||||
{
|
||||
$rules = ' set $ondemand_hls_cors_origin "";' . "\n";
|
||||
$rules .= ' set $ondemand_hls_cors_vary "";' . "\n";
|
||||
$rules .= ' if ($http_origin != "") { set $ondemand_hls_cors_vary "Origin"; }' . "\n";
|
||||
foreach ($origins as $origin) {
|
||||
$rules .= ' if ($http_origin = "' . Origin::normalize($origin)
|
||||
. '") { set $ondemand_hls_cors_origin $http_origin; }' . "\n";
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Cors;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Validates and canonicalizes web origins used by the HLS CORS allowlist. */
|
||||
final class Origin
|
||||
{
|
||||
public static function normalize(string $origin): string
|
||||
{
|
||||
$origin = trim($origin);
|
||||
if ('' === $origin || 'null' === strtolower($origin)) {
|
||||
throw new InvalidArgumentException('A CORS origin must be an absolute HTTP or HTTPS origin.');
|
||||
}
|
||||
|
||||
$parts = parse_url($origin);
|
||||
if (false === $parts
|
||||
|| !isset($parts['scheme'], $parts['host'])
|
||||
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])
|
||||
|| (isset($parts['path']) && '/' !== $parts['path'])
|
||||
) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid CORS origin: %s', $origin));
|
||||
}
|
||||
|
||||
$scheme = strtolower($parts['scheme']);
|
||||
$host = strtolower($parts['host']);
|
||||
if (!in_array($scheme, ['http', 'https'], true)
|
||||
|| '' === $host
|
||||
|| !preg_match('/^[a-z0-9.-]+$/D', $host)
|
||||
) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid CORS origin: %s', $origin));
|
||||
}
|
||||
|
||||
$port = $parts['port'] ?? null;
|
||||
if (null !== $port && ($port < 1 || $port > 65535)) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid CORS origin: %s', $origin));
|
||||
}
|
||||
|
||||
if (('https' === $scheme && 443 === $port) || ('http' === $scheme && 80 === $port)) {
|
||||
$port = null;
|
||||
}
|
||||
|
||||
return $scheme . '://' . $host . (null === $port ? '' : ':' . $port);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Entity\Migration;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260904223000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Persist allowed CORS origins for each on-demand HLS station.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(
|
||||
'CREATE TABLE ondemand_hls_station_cors ('
|
||||
. 'id INT AUTO_INCREMENT NOT NULL, '
|
||||
. 'station_id INT NOT NULL, '
|
||||
. 'allowed_origins JSON NOT NULL, '
|
||||
. 'UNIQUE INDEX uniq_ondemand_hls_station_cors_station (station_id), '
|
||||
. 'PRIMARY KEY(id)'
|
||||
. ') DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'
|
||||
);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE ondemand_hls_station_cors');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'ondemand_hls_station_cors', uniqueConstraints: [new ORM\UniqueConstraint(name: 'uniq_ondemand_hls_station_cors_station', columns: ['station_id'])])]
|
||||
final class StationCorsConfiguration
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
/** @var list<string> */
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $allowedOrigins = [];
|
||||
|
||||
/** @param list<string> $allowedOrigins */
|
||||
public function __construct(
|
||||
#[ORM\Column(name: 'station_id', type: 'integer')]
|
||||
private readonly int $stationId,
|
||||
array $allowedOrigins = [],
|
||||
) {
|
||||
$this->allowedOrigins = array_values($allowedOrigins);
|
||||
}
|
||||
|
||||
public function getStationId(): int
|
||||
{
|
||||
return $this->stationId;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function getAllowedOrigins(): array
|
||||
{
|
||||
return $this->allowedOrigins;
|
||||
}
|
||||
|
||||
/** @param list<string> $allowedOrigins */
|
||||
public function setAllowedOrigins(array $allowedOrigins): void
|
||||
{
|
||||
$this->allowedOrigins = array_values($allowedOrigins);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,15 @@ namespace Plugin\AzuraCastOnDemandHls\EventHandler;
|
||||
|
||||
use App\Event\Nginx\WriteNginxConfiguration;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Cors\CorsConfigurationProvider;
|
||||
use Plugin\AzuraCastOnDemandHls\Cors\NginxRules;
|
||||
|
||||
final readonly class NginxConfiguration
|
||||
{
|
||||
public function __construct(private Config $config)
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private CorsConfigurationProvider $corsConfiguration,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -24,6 +29,7 @@ final readonly class NginxConfiguration
|
||||
$assetDirectory = rtrim($station->getRadioTempDir(), '/')
|
||||
. '/' . $this->config->cacheDirectory . '/assets/';
|
||||
$transcodeTimeout = $this->config->transcodeTimeout;
|
||||
$corsOriginRules = $this->corsOriginRules($station->id);
|
||||
|
||||
$event->appendBlock(<<<NGINX
|
||||
# Protected on-demand HLS. Handle this route directly in PHP-FPM so denied
|
||||
@@ -47,10 +53,20 @@ final readonly class NginxConfiguration
|
||||
location ^~ /internal/ondemand-hls/{$stationId}/ {
|
||||
internal;
|
||||
access_log off;
|
||||
# The allowlist is emitted as exact Origin matches. Nginx omits an
|
||||
# add_header with an empty value, so missing/disallowed Origins do not
|
||||
# receive Access-Control-Allow-Origin. This applies after X-Accel.
|
||||
{$corsOriginRules} add_header Access-Control-Allow-Origin $ondemand_hls_cors_origin always;
|
||||
add_header Vary $ondemand_hls_cors_vary always;
|
||||
add_header Cache-Control "private, no-store, max-age=0" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
alias {$assetDirectory};
|
||||
}
|
||||
NGINX);
|
||||
}
|
||||
|
||||
private function corsOriginRules(int $stationId): string
|
||||
{
|
||||
return NginxRules::forOrigins($this->corsConfiguration->getAllowedOrigins($stationId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Plugin\AzuraCastOnDemandHls\Cors\CorsConfigurationProvider;
|
||||
use Plugin\AzuraCastOnDemandHls\Cors\Origin;
|
||||
use Plugin\AzuraCastOnDemandHls\Entity\StationCorsConfiguration;
|
||||
|
||||
final readonly class CorsConfigurationRepository implements CorsConfigurationProvider
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function getAllowedOrigins(int $stationId): array
|
||||
{
|
||||
return $this->find($stationId)?->getAllowedOrigins() ?? [];
|
||||
}
|
||||
|
||||
/** @param list<string> $origins @return list<string> */
|
||||
public function replaceAllowedOrigins(int $stationId, array $origins): array
|
||||
{
|
||||
$origins = $this->normalizeAll($origins);
|
||||
$configuration = $this->find($stationId);
|
||||
|
||||
// There is no reason to retain a database row for an empty allowlist.
|
||||
// In particular, removing the final origin should have the same stored
|
||||
// state as the explicit `cors:clear` command.
|
||||
if ([] === $origins) {
|
||||
if (null !== $configuration) {
|
||||
$this->entityManager->remove($configuration);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if (null === $configuration) {
|
||||
$configuration = new StationCorsConfiguration($stationId, $origins);
|
||||
$this->entityManager->persist($configuration);
|
||||
} else {
|
||||
$configuration->setAllowedOrigins($origins);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $origins;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function addAllowedOrigin(int $stationId, string $origin): array
|
||||
{
|
||||
$origins = $this->getAllowedOrigins($stationId);
|
||||
$origins[] = Origin::normalize($origin);
|
||||
return $this->replaceAllowedOrigins($stationId, $origins);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function removeAllowedOrigin(int $stationId, string $origin): array
|
||||
{
|
||||
$origin = Origin::normalize($origin);
|
||||
return $this->replaceAllowedOrigins(
|
||||
$stationId,
|
||||
array_values(array_filter($this->getAllowedOrigins($stationId), static fn(string $item): bool => $item !== $origin))
|
||||
);
|
||||
}
|
||||
|
||||
public function clearAllowedOrigins(int $stationId): void
|
||||
{
|
||||
$configuration = $this->find($stationId);
|
||||
if (null === $configuration) {
|
||||
return;
|
||||
}
|
||||
$this->entityManager->remove($configuration);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function find(int $stationId): ?StationCorsConfiguration
|
||||
{
|
||||
$configuration = $this->entityManager->getRepository(StationCorsConfiguration::class)
|
||||
->findOneBy(['stationId' => $stationId]);
|
||||
return $configuration instanceof StationCorsConfiguration ? $configuration : null;
|
||||
}
|
||||
|
||||
/** @param list<string> $origins @return list<string> */
|
||||
private function normalizeAll(array $origins): array
|
||||
{
|
||||
$normalized = array_map(Origin::normalize(...), $origins);
|
||||
sort($normalized, SORT_STRING);
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user