diff --git a/README.md b/README.md index 458ead5..8e854d2 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Configuration is read from the process environment when AzuraCast builds its con | `AZURACAST_ONDEMAND_HLS_SESSION_TTL` | `1800` | `60`–`86400` seconds | Lifetime of a playback bearer URL. | | `AZURACAST_ONDEMAND_HLS_SEGMENT_DURATION` | `6` | `2`–`20` seconds | FFmpeg HLS target segment duration. Actual segment duration can vary at codec boundaries. | | `AZURACAST_ONDEMAND_HLS_CACHE_TTL` | `86400` | At least `SESSION_TTL`, at most `2592000` seconds (30 days) | Minimum idle age before an unreferenced rendition can be removed. | -| `AZURACAST_ONDEMAND_HLS_MAX_SESSIONS` | `10` | `1`–`100` | Maximum active sessions per principal hash within one station's session store. | +| `AZURACAST_ONDEMAND_HLS_MAX_SESSIONS` | `10` | `1`–`100` | Maximum active playback renditions per principal hash within one station. | | `AZURACAST_ONDEMAND_HLS_CACHE_DIRECTORY` | `ondemand-hls` | 1–64 characters; starts with lowercase letter/digit, then lowercase letters, digits, `_`, `-` | Relative directory created beneath each station temp directory. | | `AZURACAST_ONDEMAND_HLS_FFMPEG_BINARY` | `ffmpeg` | 1–255 characters from letters, digits, `.`, `_`, `+`, `/`, `-`; must not contain `..` | FFmpeg executable name or absolute path. | | `AZURACAST_ONDEMAND_HLS_AUDIO_BITRATE` | `128k` | `10k`–`9999k` in the exact form `[1-9][0-9]{1,3}k` | AAC target bitrate and part of the rendition cache key. Use a sensible audio bitrate despite the broad validation range. | @@ -242,7 +242,7 @@ No database tables are added. State lives below each station's radio temp direct - **Concurrency:** a non-blocking per-rendition lock prevents duplicate work. A second non-blocking station-wide lock permits only one FFmpeg transcode per station, limiting CPU and PHP-worker exhaustion. - **Publication:** output is built under a random `.build-*` directory, validated against the filename allowlist and playlist rules, then renamed atomically into place. Complete cache hits touch the rendition directory to refresh its idle age. - **Sessions:** a cryptographically random 32-byte token is base64url encoded to 43 characters. Only its SHA-256 identifier is stored. Session files are written atomically with mode `0600`; session and lock directories use `0700`. -- **Principal limit:** active-session counting uses a hash of the authenticated user ID and request `REMOTE_ADDR`. It limits creation only; the playback token remains a transferable bearer credential. +- **Principal limit:** active-playback counting uses a hash of the authenticated user ID and request `REMOTE_ADDR`. A session counts only while a successfully authorized HLS asset request has occurred within the last 90 seconds; duplicate URLs for the same rendition count as one slot. This prevents player initialization/retry loops and abandoned URLs from exhausting the limit. It limits creation only; the playback token remains a transferable bearer credential. - **Delivery:** PHP validates the token, expiry, station binding, exact resource name, real path, and symlink status. It then emits an internal `X-Accel-Redirect`; Nginx serves the file without PHP streaming it. ### Cleanup diff --git a/src/Service/AssetAuthorizer.php b/src/Service/AssetAuthorizer.php index efeb663..41fdc34 100644 --- a/src/Service/AssetAuthorizer.php +++ b/src/Service/AssetAuthorizer.php @@ -34,6 +34,11 @@ final readonly class AssetAuthorizer $directory = $this->paths->asset($stationTempDirectory, $session->cacheKey); $path = ResourcePath::resolve($directory, $resource); - return null === $path ? null : ['session' => $session, 'path' => $path]; + if (null === $path) { + return null; + } + + $this->sessions->markAccessed($stationTempDirectory, $token, $now); + return ['session' => $session, 'path' => $path]; } } diff --git a/src/Service/SessionRepository.php b/src/Service/SessionRepository.php index 90064e0..9b4e588 100644 --- a/src/Service/SessionRepository.php +++ b/src/Service/SessionRepository.php @@ -13,6 +13,12 @@ use Throwable; final readonly class SessionRepository { + /** + * HLS clients normally request a playlist or segment every few seconds. + * This intentionally measures active playback rather than issued URLs. + */ + private const int ACTIVE_PLAYBACK_WINDOW = 90; + public function __construct( private Config $config, private CachePaths $paths, @@ -72,6 +78,7 @@ final readonly class SessionRepository @unlink($temporary); throw new RuntimeException('Could not publish playback session.'); } + @touch($target, $now); return ['token' => $token, 'session' => $session]; } finally { @@ -114,7 +121,8 @@ final readonly class SessionRepository return 0; } - $count = 0; + /** @var array $activeCacheKeys */ + $activeCacheKeys = []; 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; @@ -124,15 +132,35 @@ final readonly class SessionRepository $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; + if (!$session->isExpired($now) + && hash_equals($session->principalHash, $principalHash) + && $this->isRecentlyAccessed($file, $now)) { + // Multiple URLs for the same rendition are usually client retries or + // duplicate player initialization. They consume one playback slot. + $activeCacheKeys[$session->cacheKey] = true; } } } catch (Throwable) { continue; } } - return $count; + return count($activeCacheKeys); + } + + public function markAccessed(string $stationTempDirectory, string $token, ?int $now = null): void + { + if (!OpaqueToken::isValid($token)) { + return; + } + + $path = $this->sessionPath($stationTempDirectory, OpaqueToken::id($token)); + if (is_link($path) || !is_file($path)) { + return; + } + + // The session file contains no bearer token. Its mtime is a lightweight, + // race-tolerant playback heartbeat and is refreshed only after authorization. + @touch($path, $now ?? time()); } private function sessionPath(string $stationTempDirectory, string $id): string @@ -142,4 +170,10 @@ final readonly class SessionRepository } return $this->paths->sessions($stationTempDirectory) . '/' . $id . '.json'; } + + private function isRecentlyAccessed(\SplFileInfo $file, int $now): bool + { + $lastAccessedAt = $file->getMTime(); + return $lastAccessedAt >= $now - self::ACTIVE_PLAYBACK_WINDOW; + } } diff --git a/tests/Service/SessionRepositoryTest.php b/tests/Service/SessionRepositoryTest.php index da0ea04..a0dbdc3 100644 --- a/tests/Service/SessionRepositoryTest.php +++ b/tests/Service/SessionRepositoryTest.php @@ -79,6 +79,79 @@ final class SessionRepositoryTest extends TemporaryDirectoryTestCase ); } + public function testDuplicateUrlsForTheSameRenditionUseOnePlaybackSlot(): void + { + $repository = $this->repository(maxSessions: 1); + $principalHash = str_repeat('c', 64); + $cacheKey = str_repeat('b', 64); + + $first = $repository->create( + $this->temporaryDirectory, + 7, + 'abcdef123456abcdef123456', + $cacheKey, + $principalHash, + 1000, + ); + $second = $repository->create( + $this->temporaryDirectory, + 7, + 'abcdef123456abcdef123456', + $cacheKey, + $principalHash, + 1001, + ); + + self::assertNotSame($first['token'], $second['token']); + self::assertSame(1, $repository->countActiveForPrincipal($this->temporaryDirectory, $principalHash, 1001)); + } + + public function testInactiveSessionsDoNotConsumePlaybackSlots(): void + { + $config = new Config(sessionTtl: 1800, cacheTtl: 1800, maxSessionsPerPrincipal: 1); + $repository = new SessionRepository($config, new CachePaths($config)); + $principalHash = str_repeat('c', 64); + $repository->create( + $this->temporaryDirectory, + 7, + 'abcdef123456abcdef123456', + str_repeat('b', 64), + $principalHash, + 1000, + ); + + self::assertSame(0, $repository->countActiveForPrincipal($this->temporaryDirectory, $principalHash, 1091)); + $created = $repository->create( + $this->temporaryDirectory, + 7, + 'abcdef123456abcdef123457', + str_repeat('d', 64), + $principalHash, + 1091, + ); + self::assertTrue(OpaqueToken::isValid($created['token'])); + } + + public function testAuthorizedAssetAccessKeepsAPlaybackSlotActive(): void + { + $config = new Config(sessionTtl: 1800, cacheTtl: 1800, maxSessionsPerPrincipal: 1); + $repository = new SessionRepository($config, new CachePaths($config)); + $principalHash = str_repeat('c', 64); + $created = $repository->create( + $this->temporaryDirectory, + 7, + 'abcdef123456abcdef123456', + str_repeat('b', 64), + $principalHash, + 1000, + ); + + $repository->markAccessed($this->temporaryDirectory, $created['token'], 1080); + + self::assertSame(1, $repository->countActiveForPrincipal($this->temporaryDirectory, $principalHash, 1169)); + self::assertSame(0, $repository->countActiveForPrincipal($this->temporaryDirectory, $principalHash, 1171)); + } + public function testParallelCreatorsCannotExceedSessionLimit(): void { $worker = $this->temporaryDirectory . '/session-worker.php';