feat(live): polish — ended-state, chat, viewer actions, Private option
Restores the live streaming controllers / views / model from the
pre-audit WIP that got stashed on 2026-10-21, then applies the Phase 6
polish items:
## /live/new form
Add a Private visibility option that mirrors the upload modal's
wording — "Only you can see this stream. Perfect for testing without
an audience." Validator in LiveStreamController@store now permits
public|unlisted|private (was public|unlisted).
## Watch page ended-state
When the stream status is "ended", the overlay is a solid "Stream
ended" card showing the stream's duration (started_at → ended_at)
and, when a recording was published, a Watch Replay CTA linking to
the saved video. Previously an ended stream showed the generic
"Waiting for the broadcast to start" placeholder forever.
## Channel name link
The uploader's name in the meta row is now a real <a href> to the
channel page.
## Viewer action row
Subscribe + Share buttons below the player, visible to anyone except
the owner. Subscribe for guests uses the Phase 7 ?intended= pattern
so after sign-in they land back on the stream.
## Live chat (DB-backed polling)
New live_chat_messages table (migration 2026_10_21_000003). Viewers
GET /live/{stream}/chat?since=N every 3 seconds; signed-in users POST
to the same path (rate-limited at 60/min). Guests see the panel in
read-only mode with a "Sign in to chat" placeholder.
Private streams reject chat for anyone but the owner (404). Non-live
status rejects chat sends (422). Author name is snapshotted at post
time so a renamed account's old messages keep the name they used.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
639279f2a2
commit
5bf1128196
12 files changed
+2103
No files matched your search
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\LiveStream;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* The media server's authorisation hook.
|
||||
*
|
||||
* MediaMTX holds no policy of its own: before it accepts a publisher or serves
|
||||
* a viewer it POSTs here, and a non-2xx answer refuses the connection. That
|
||||
* keeps every "who may do what" decision inside the application, next to the
|
||||
* users table, rather than duplicated in a media server config.
|
||||
*
|
||||
* Deny by default, in the strongest sense: if this app is down, the media
|
||||
* server can authorise nothing at all.
|
||||
*
|
||||
* These endpoints are called ONLY by MediaMTX over loopback. They are not part
|
||||
* of the public API and reject anything arriving from elsewhere.
|
||||
*/
|
||||
class LiveAuthController extends Controller
|
||||
{
|
||||
/** Loopback addresses the media server may call from. */
|
||||
private const LOCAL_IPS = ['127.0.0.1', '::1'];
|
||||
|
||||
/**
|
||||
* Decide a publish or read attempt.
|
||||
*
|
||||
* Payload from MediaMTX: user, password, token, ip, action, path,
|
||||
* protocol, id, query.
|
||||
*/
|
||||
public function authenticate(Request $request): Response
|
||||
{
|
||||
if (! $this->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/<public_id>" 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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user