fromMediaServer($request)) { return response('', 403); } $action = (string) $request->input('action'); // Control-plane actions carry no stream path, so they are settled // before any attempt to resolve one. if (in_array($action, ['api', 'metrics', 'pprof'], true)) { return $this->authorizeApi(); } $path = (string) $request->input('path'); $stream = $this->resolveStream($path); if (! $stream) { // An unknown path is not an error worth explaining — a publisher // probing for valid stream ids learns nothing from this. return response('', 403); } return match ($action) { 'publish' => $this->authorizePublish($request, $stream), 'read' => $this->authorizeRead($stream), default => response('', 403), }; } /** * The media server's own control API. * * It authenticates against this same hook, so without an explicit allow the * app could not read live state from the server it runs alongside. Safe * because it is already loopback-gated above — nothing off this host can * reach it. */ private function authorizeApi(): Response { return response('', 200); } /** * A publisher must present the short-lived token minted for this stream * when its owner pressed "Go live". The token is single-use: it is burned * here, so a captured one cannot start a second broadcast. */ private function authorizePublish(Request $request, LiveStream $stream): Response { // MediaMTX passes HTTP Basic credentials through as user/password. $presented = (string) ($request->input('password') ?: $request->input('token')); if (! $stream->publishTokenMatches($presented)) { Log::warning('Live publish refused', [ 'stream' => $stream->public_id, 'ip' => $request->input('ip'), ]); return response('', 403); } $stream->consumePublishToken(); $stream->markLive(); Log::info('Live publish authorised', [ 'stream' => $stream->public_id, 'user_id' => $stream->user_id, ]); return response('', 200); } /** * Reads are anonymous by design — a viewer fetching HLS parts carries no * credentials — so the gate is the stream's own state: it must be live and * not private. The unpredictable path is what keeps an unlisted stream * unlisted. */ private function authorizeRead(LiveStream $stream): Response { if (! $stream->isLive()) { return response('', 403); } if (! in_array($stream->visibility, ['public', 'unlisted'], true)) { return response('', 403); } return response('', 200); } /** * Lifecycle notifications from the media server. * * The auth hook only fires on the way in, so without this a stream whose * phone lost signal would read as live forever. */ public function hook(Request $request): Response { if (! $this->fromMediaServer($request)) { return response('', 403); } $stream = $this->resolveStream((string) $request->input('path')); if (! $stream) { return response('', 204); } match ((string) $request->input('event')) { 'publish' => $stream->markLive(), // Only a stream that is actually live can end. A late hook from a // previous, failed session must not kill the one just started. 'unpublish' => $stream->isLive() ? $stream->markEnded() : null, 'read' => $this->viewerJoined($stream), 'unread' => $this->viewerLeft($stream), default => null, }; return response('', 204); } /* ---------------- Viewer accounting ---------------- */ /* * Counted with atomic SQL increments rather than read-modify-write: viewers * arrive concurrently, and two joins landing together would otherwise * record one. */ private function viewerJoined(LiveStream $stream): void { LiveStream::where('id', $stream->id)->increment('current_viewers'); // peak_viewers is a high-water mark, so it only ever moves up. LiveStream::where('id', $stream->id) ->whereColumn('current_viewers', '>', 'peak_viewers') ->update(['peak_viewers' => DB::raw('current_viewers')]); } private function viewerLeft(LiveStream $stream): void { // Floor at zero: a missed join (a restart, a dropped hook) must not // drive the count negative and show "-1 watching". LiveStream::where('id', $stream->id) ->where('current_viewers', '>', 0) ->decrement('current_viewers'); } /* ---------------- Helpers ---------------- */ /** * Only the media server on this host may ask. Deliberately checks the raw * connection address rather than a forwarded header — a header can be * spoofed by anyone who can reach the app. */ private function fromMediaServer(Request $request): bool { return in_array($request->server('REMOTE_ADDR'), self::LOCAL_IPS, true); } /** Turn "live/" into the stream it names. */ private function resolveStream(string $path): ?LiveStream { if (! preg_match('#^live/([A-Za-z0-9]{32})$#', $path, $m)) { return null; } return LiveStream::where('public_id', $m[1])->first(); } }