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:
ghassanandClaude Opus 4.7 committed 2026-10-21 12:41:56 +03:00
1 parent 639279f2a2
commit 5bf1128196
12 files changed
+2103

No files matched your search

+208
View File
@@ -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();
}
}
@@ -0,0 +1,292 @@
<?php
namespace App\Http\Controllers;
use App\Models\LiveChatMessage;
use App\Models\LiveStream;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
/**
* Live streaming — broadcaster and viewer surfaces.
*
* The media plane (MediaMTX) is deliberately dumb: this controller decides who
* may broadcast, mints the short-lived credential the browser publishes with,
* and hands out the URLs. See LiveAuthController for the hook the media server
* calls back on.
*/
class LiveStreamController extends Controller
{
public function __construct()
{
// Watching is open; everything that creates or controls a stream needs
// an account.
$this->middleware('auth')->except(['index', 'watch', 'status', 'chatPoll']);
}
/** Live now, plus the viewer's own streams when signed in. */
public function index(): \Illuminate\View\View
{
$live = LiveStream::live()
->listed()
->with('user')
->latest('started_at')
->limit(60)
->get();
$mine = Auth::check()
? LiveStream::where('user_id', Auth::id())->latest()->limit(20)->get()
: collect();
return view('live.index', compact('live', 'mine'));
}
/** The "start a broadcast" form. */
public function create(): \Illuminate\View\View
{
return view('live.create');
}
/**
* Create the stream record. This does NOT start anything — it reserves an
* identity and a path; the browser goes live from the broadcast page.
*/
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
$data = $request->validate([
'title' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'visibility' => ['required', Rule::in(['public', 'unlisted', 'private'])],
]);
// One live broadcast per account at a time. Without this a single user
// can pin open as many encoder slots as they can open tabs.
$alreadyLive = LiveStream::where('user_id', Auth::id())
->where('status', LiveStream::STATUS_LIVE)
->exists();
if ($alreadyLive) {
return back()
->withInput()
->withErrors(['title' => 'You already have a stream running. End it before starting another.']);
}
$stream = LiveStream::create([
'user_id' => Auth::id(),
'title' => $data['title'],
'description' => $data['description'] ?? null,
'visibility' => $data['visibility'],
'status' => LiveStream::STATUS_IDLE,
]);
return redirect()->route('live.broadcast', $stream);
}
/** The broadcaster's camera page. Owner only. */
public function broadcast(LiveStream $stream): \Illuminate\View\View
{
abort_unless($stream->isOwnedBy(Auth::user()), 403);
// An ended stream is not a dead one — the owner may go live on it
// again from the same link.
$stream->reArm();
return view('live.broadcast', [
'stream' => $stream,
'whipUrl' => $this->whipUrl($stream),
'watchUrl' => route('live.watch', $stream),
'iceServers' => $this->iceServers(),
]);
}
/**
* Mint the publish credential.
*
* Separate from the page load so the token's short life starts when the
* user actually presses "Go live", not when they opened the tab and went
* to find a tripod.
*/
public function token(LiveStream $stream): JsonResponse
{
abort_unless($stream->isOwnedBy(Auth::user()), 403);
// Pressing "Go live" on a stream that previously ended restarts it
// rather than being refused. A failed attempt must never burn the link.
$stream->reArm();
return response()->json([
'token' => $stream->issuePublishToken(),
'expires_in' => LiveStream::PUBLISH_TOKEN_TTL_SECONDS,
'whip_url' => $this->whipUrl($stream),
'ice_servers' => $this->iceServers(),
]);
}
/** The viewer page. */
public function watch(LiveStream $stream): \Illuminate\View\View
{
abort_unless($stream->isWatchableBy(Auth::user()), 404);
$stream->loadMissing('user');
return view('live.watch', [
'stream' => $stream,
'whepUrl' => $this->whepUrl($stream),
'hlsUrl' => $this->hlsUrl($stream),
'isOwner' => $stream->isOwnedBy(Auth::user()),
'iceServers' => $this->iceServers(),
]);
}
/** Lightweight poll for the viewer page: is it live, how many watching. */
public function status(LiveStream $stream): JsonResponse
{
abort_unless($stream->isWatchableBy(Auth::user()), 404);
return response()->json([
'status' => $stream->status,
'live' => $stream->isLive(),
'viewers' => $stream->current_viewers,
'duration' => $stream->formatted_duration,
]);
}
/** End the broadcast. Owner only. */
public function stop(LiveStream $stream): JsonResponse
{
abort_unless($stream->isOwnedBy(Auth::user()), 403);
$stream->markEnded();
return response()->json([
'success' => true,
'status' => $stream->status,
'duration' => $stream->formatted_duration,
]);
}
/* ---------------- URLs ---------------- */
/*
* These are same-origin paths, proxied by nginx to the media server on
* loopback. Same-origin matters: the page is served over HTTPS, and a
* mixed-content or cross-origin media URL would be blocked by the browser
* before WebRTC ever started.
*/
private function whipUrl(LiveStream $stream): string
{
return url('/live-rtc/'.$stream->mediaPath().'/whip');
}
private function whepUrl(LiveStream $stream): string
{
return url('/live-rtc/'.$stream->mediaPath().'/whep');
}
private function hlsUrl(LiveStream $stream): string
{
return url('/live-hls/'.$stream->mediaPath().'/index.m3u8');
}
/**
* ICE servers for the browser.
*
* No inbound UDP port is forwarded to this server, so a broadcaster outside
* the local network can only reach it through a TURN relay. On the LAN the
* host candidates work directly and TURN is never used.
*
* Configured through the environment so credentials never sit in the repo.
*/
private function iceServers(): array
{
$servers = [];
if ($stun = config('live.stun_url')) {
$servers[] = ['urls' => $stun];
}
if ($turn = config('live.turn_url')) {
$servers[] = array_filter([
'urls' => $turn,
'username' => config('live.turn_username'),
'credential' => config('live.turn_credential'),
]);
}
return $servers;
}
/**
* GET poll: viewer asks for every message in this stream newer than
* ?since=<id> (defaults to 0 for a cold open). Returns up to 50
* messages — more than enough for a 3-second polling window, bounded
* to protect the DB when a client is slow to reconnect.
*/
public function chatPoll(Request $request, LiveStream $stream): JsonResponse
{
// Private streams are owner-only; everything else is open to viewers.
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
abort(404);
}
$since = (int) $request->query('since', 0);
$rows = LiveChatMessage::where('livestream_id', $stream->id)
->where('id', '>', $since)
->orderBy('id')
->limit(50)
->get(['id', 'user_id', 'author_name', 'body', 'created_at']);
return response()->json([
'messages' => $rows->map(fn ($m) => [
'id' => $m->id,
'uid' => $m->user_id,
'name' => $m->author_name,
'body' => $m->body,
'at' => $m->created_at?->toISOString(),
])->all(),
]);
}
/**
* POST a chat message. Rate-limited to roughly one message per second
* per user via Laravel's built-in throttle, applied via the route.
* Body is capped at 400 chars (long enough for a sentence, short enough
* that the panel doesn't shear into a wall of text).
*/
public function chatSend(Request $request, LiveStream $stream): JsonResponse
{
if ($stream->status !== LiveStream::STATUS_LIVE) {
return response()->json(['error' => 'Chat is closed when the stream isn\'t live.'], 422);
}
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
abort(404);
}
$data = $request->validate([
'body' => ['required', 'string', 'max:400'],
]);
$user = Auth::user();
$msg = LiveChatMessage::create([
'livestream_id' => $stream->id,
'user_id' => $user->id,
'author_name' => $user->name,
'body' => trim($data['body']),
'created_at' => now(),
]);
return response()->json([
'ok' => true,
'message' => [
'id' => $msg->id,
'uid' => $msg->user_id,
'name' => $msg->author_name,
'body' => $msg->body,
'at' => $msg->created_at?->toISOString(),
],
]);
}
}