Covers Private visibility acceptance, ended-state Watch page shows "Stream ended" (and omits the "Waiting for the broadcast" placeholder from the connection JS), channel-name link resolves to the user's channel URL, chat poll returns messages newer than ?since=, chat send rejects guests (401), chat send rejects offline streams (422), and private-stream chat 404s for non-owners. Also broadens LiveStream::isWatchableBy to keep public/unlisted streams permalinked after they end — viewers now see the Stream Ended card and the Watch Replay CTA instead of a hard 404. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
262 lines
7.5 KiB
PHP
262 lines
7.5 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* A live broadcast.
|
|
*
|
|
* The row is the authority on whether a stream may exist; the media server
|
|
* holds no state of its own and asks this table (through LiveAuthController)
|
|
* before it accepts a publisher or a viewer.
|
|
*/
|
|
class LiveStream extends Model
|
|
{
|
|
use HasFactory;
|
|
|
|
protected $table = 'livestreams';
|
|
|
|
protected $fillable = [
|
|
'user_id', 'public_id', 'title', 'description',
|
|
'status', 'visibility', 'video_id',
|
|
];
|
|
|
|
/**
|
|
* The publish token is a credential. Keeping it out of the default array
|
|
* form means it cannot be leaked by a careless toJson() on a model that
|
|
* reached a view or an API response.
|
|
*/
|
|
protected $hidden = ['publish_token', 'publish_token_expires_at'];
|
|
|
|
protected $casts = [
|
|
'publish_token_expires_at' => 'datetime',
|
|
'started_at' => 'datetime',
|
|
'ended_at' => 'datetime',
|
|
'last_seen_at' => 'datetime',
|
|
];
|
|
|
|
public const STATUS_IDLE = 'idle';
|
|
public const STATUS_LIVE = 'live';
|
|
public const STATUS_ENDED = 'ended';
|
|
public const STATUS_FAILED = 'failed';
|
|
|
|
/** How long a freshly minted publish token stays valid. */
|
|
public const PUBLISH_TOKEN_TTL_SECONDS = 120;
|
|
|
|
protected static function booted(): void
|
|
{
|
|
// Every stream gets an unpredictable public id, always — never left to
|
|
// a caller to remember.
|
|
static::creating(function (self $stream) {
|
|
$stream->public_id ??= self::newPublicId();
|
|
});
|
|
}
|
|
|
|
/* ---------------- Identity ---------------- */
|
|
|
|
/**
|
|
* 32 chars of alphanumeric entropy. Also the media path segment, which is
|
|
* why it is constrained to [a-zA-Z0-9] — it has to satisfy the path regex
|
|
* the media server is configured with.
|
|
*/
|
|
public static function newPublicId(): string
|
|
{
|
|
do {
|
|
$id = Str::random(32);
|
|
} while (self::where('public_id', $id)->exists());
|
|
|
|
return $id;
|
|
}
|
|
|
|
/** Route model binding uses the unpredictable id, never the numeric key. */
|
|
public function getRouteKeyName(): string
|
|
{
|
|
return 'public_id';
|
|
}
|
|
|
|
/** The path the media server knows this stream by. */
|
|
public function mediaPath(): string
|
|
{
|
|
return 'live/'.$this->public_id;
|
|
}
|
|
|
|
/* ---------------- Relations ---------------- */
|
|
|
|
public function user(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
/** The recording, once a finished stream has been published as a video. */
|
|
public function video(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Video::class);
|
|
}
|
|
|
|
/* ---------------- State ---------------- */
|
|
|
|
public function isLive(): bool
|
|
{
|
|
return $this->status === self::STATUS_LIVE;
|
|
}
|
|
|
|
public function isOwnedBy(?User $user): bool
|
|
{
|
|
return $user !== null && $this->user_id === $user->id;
|
|
}
|
|
|
|
/**
|
|
* May this viewer watch?
|
|
*
|
|
* Both public and unlisted streams are watchable by anyone holding the
|
|
* link — the difference is only whether the stream is listed. Ownership
|
|
* additionally grants access before it goes live, so the broadcaster can
|
|
* open their own watch page to check themselves.
|
|
*/
|
|
public function isWatchableBy(?User $user): bool
|
|
{
|
|
if ($this->isOwnedBy($user)) {
|
|
return true;
|
|
}
|
|
|
|
// Visibility first — private streams stay owner-only no matter what.
|
|
if (! in_array($this->visibility, ['public', 'unlisted'], true)) {
|
|
return false;
|
|
}
|
|
|
|
// Live streams are watchable while they're on; ended streams remain
|
|
// permalinked so viewers see "Stream ended" and the replay CTA
|
|
// instead of a hard 404.
|
|
return $this->isLive() || $this->status === self::STATUS_ENDED;
|
|
}
|
|
|
|
/* ---------------- Publish credential ---------------- */
|
|
|
|
/**
|
|
* Mint a short-lived, single-use token for the broadcaster's browser.
|
|
*
|
|
* Returned in plaintext to the owner exactly once, at the moment they press
|
|
* "Go live". Stored so the media server's auth hook can verify it, then
|
|
* consumed on first successful publish.
|
|
*/
|
|
public function issuePublishToken(): string
|
|
{
|
|
$token = Str::random(64);
|
|
|
|
$this->forceFill([
|
|
'publish_token' => hash('sha256', $token),
|
|
'publish_token_expires_at' => now()->addSeconds(self::PUBLISH_TOKEN_TTL_SECONDS),
|
|
])->save();
|
|
|
|
return $token;
|
|
}
|
|
|
|
/** Constant-time check of a presented token against the stored hash. */
|
|
public function publishTokenMatches(?string $presented): bool
|
|
{
|
|
if (! $presented || ! $this->publish_token || ! $this->publish_token_expires_at) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->publish_token_expires_at->isPast()) {
|
|
return false;
|
|
}
|
|
|
|
return hash_equals($this->publish_token, hash('sha256', $presented));
|
|
}
|
|
|
|
/** Burn the token so a captured one cannot be replayed. */
|
|
public function consumePublishToken(): void
|
|
{
|
|
$this->forceFill([
|
|
'publish_token' => null,
|
|
'publish_token_expires_at' => null,
|
|
])->save();
|
|
}
|
|
|
|
/* ---------------- Transitions ---------------- */
|
|
|
|
public function markLive(): void
|
|
{
|
|
$this->forceFill([
|
|
'status' => self::STATUS_LIVE,
|
|
'started_at' => $this->started_at ?? now(),
|
|
'last_seen_at' => now(),
|
|
'ended_at' => null,
|
|
])->save();
|
|
}
|
|
|
|
/**
|
|
* Put an ended stream back into a startable state.
|
|
*
|
|
* A stream is a reusable channel, not a one-shot: the owner keeps the same
|
|
* link and can go live on it again. Without this, any failed start — a lost
|
|
* signal, a refused camera, a server hiccup — would kill the stream
|
|
* permanently and force a new link on every retry.
|
|
*/
|
|
public function reArm(): void
|
|
{
|
|
if ($this->status !== self::STATUS_ENDED && $this->status !== self::STATUS_FAILED) {
|
|
return;
|
|
}
|
|
|
|
$this->forceFill([
|
|
'status' => self::STATUS_IDLE,
|
|
'ended_at' => null,
|
|
'current_viewers' => 0,
|
|
])->save();
|
|
}
|
|
|
|
public function markEnded(): void
|
|
{
|
|
$this->forceFill([
|
|
'status' => self::STATUS_ENDED,
|
|
'ended_at' => now(),
|
|
'current_viewers' => 0,
|
|
'publish_token' => null,
|
|
'publish_token_expires_at' => null,
|
|
])->save();
|
|
}
|
|
|
|
/* ---------------- Display ---------------- */
|
|
|
|
public function getDurationSecondsAttribute(): ?int
|
|
{
|
|
if (! $this->started_at) {
|
|
return null;
|
|
}
|
|
|
|
return ($this->ended_at ?? Carbon::now())->diffInSeconds($this->started_at);
|
|
}
|
|
|
|
public function getFormattedDurationAttribute(): string
|
|
{
|
|
$seconds = $this->duration_seconds;
|
|
|
|
if ($seconds === null) {
|
|
return '00:00';
|
|
}
|
|
|
|
return $seconds >= 3600
|
|
? sprintf('%d:%02d:%02d', intdiv($seconds, 3600), intdiv($seconds % 3600, 60), $seconds % 60)
|
|
: sprintf('%02d:%02d', intdiv($seconds, 60), $seconds % 60);
|
|
}
|
|
|
|
/* ---------------- Scopes ---------------- */
|
|
|
|
public function scopeLive($query)
|
|
{
|
|
return $query->where('status', self::STATUS_LIVE);
|
|
}
|
|
|
|
public function scopeListed($query)
|
|
{
|
|
return $query->where('visibility', 'public');
|
|
}
|
|
}
|