start project

This commit is contained in:
2026-05-15 16:33:34 +03:30
parent 553238b0fb
commit d756661b45
50 changed files with 3523 additions and 0 deletions

24
README.md Normal file
View File

@@ -0,0 +1,24 @@
# Nyone
Nyone is a Laravel/Inertia live streaming platform scaffold. The first implementation supports one creator channel per user, OBS publishing through MediaMTX RTMP, HLS playback, channel follows, live chat, creator-selected VOD recording, and basic admin moderation.
## Local Setup
```bash
composer install
npm install
cp .env.example .env
php artisan key:generate
php artisan migrate --seed
npm run dev
php artisan serve
```
Configure MediaMTX from `deploy/mediamtx.yml.example`, replacing `change-this-secret` with `MEDIAMTX_SHARED_SECRET`.
Creators use:
- Server: `STREAMING_RTMP_INGEST_URL`
- Stream key: `<channel-slug>?token=<generated-stream-key>`
Viewer playback uses `STREAMING_HLS_PUBLIC_URL/<channel-slug>/index.m3u8`.

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Models\Broadcast;
use App\Models\Channel;
use App\Models\User;
use App\Models\Vod;
use Inertia\Inertia;
use Inertia\Response;
class AdminDashboardController extends Controller
{
public function __invoke(): Response
{
return Inertia::render('admin/dashboard', [
'stats' => [
'users' => User::query()->count(),
'channels' => Channel::query()->count(),
'live_channels' => Channel::query()->where('is_live', true)->count(),
'vods' => Vod::query()->count(),
],
'liveBroadcasts' => Broadcast::query()
->where('status', Broadcast::STATUS_LIVE)
->with('channel.user')
->latest('started_at')
->limit(20)
->get()
->map(fn (Broadcast $broadcast) => [
'id' => $broadcast->id,
'title' => $broadcast->title,
'started_at' => $broadcast->started_at?->toIso8601String(),
'channel' => [
'id' => $broadcast->channel->id,
'slug' => $broadcast->channel->slug,
'display_name' => $broadcast->channel->display_name,
'owner' => $broadcast->channel->user->name,
],
]),
'channels' => Channel::query()
->with('user')
->latest()
->limit(25)
->get()
->map(fn (Channel $channel) => [
'id' => $channel->id,
'slug' => $channel->slug,
'display_name' => $channel->display_name,
'is_live' => $channel->is_live,
'suspended_at' => $channel->suspended_at?->toIso8601String(),
'owner' => [
'id' => $channel->user->id,
'name' => $channel->user->name,
],
]),
]);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers;
use App\Models\AdminAuditLog;
use App\Models\Broadcast;
use App\Models\Channel;
use App\Models\User;
use App\Models\Vod;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class AdminModerationController extends Controller
{
public function suspendChannel(Request $request, Channel $channel): RedirectResponse
{
$channel->forceFill([
'suspended_at' => now(),
'is_live' => false,
'live_broadcast_id' => null,
'viewer_count' => 0,
])->save();
$this->audit($request, $channel, 'channel.suspended');
return back()->with('success', 'Channel suspended.');
}
public function restoreChannel(Request $request, Channel $channel): RedirectResponse
{
$channel->forceFill(['suspended_at' => null])->save();
$this->audit($request, $channel, 'channel.restored');
return back()->with('success', 'Channel restored.');
}
public function suspendUser(Request $request, User $user): RedirectResponse
{
$user->forceFill(['suspended_at' => now()])->save();
$user->channel?->forceFill([
'is_live' => false,
'live_broadcast_id' => null,
'viewer_count' => 0,
])->save();
$this->audit($request, $user, 'user.suspended');
return back()->with('success', 'User suspended.');
}
public function stopBroadcast(Request $request, Broadcast $broadcast): RedirectResponse
{
$broadcast->forceFill([
'status' => Broadcast::STATUS_ENDED,
'ended_at' => $broadcast->ended_at ?? now(),
])->save();
$broadcast->channel->forceFill([
'is_live' => false,
'live_broadcast_id' => null,
'viewer_count' => 0,
])->save();
$this->audit($request, $broadcast, 'broadcast.stopped');
return back()->with('success', 'Broadcast stopped.');
}
public function deleteVod(Request $request, Vod $vod): RedirectResponse
{
$vod->delete();
$this->audit($request, $vod, 'vod.deleted');
return back()->with('success', 'VOD deleted.');
}
private function audit(Request $request, object $subject, string $action): void
{
AdminAuditLog::query()->create([
'admin_user_id' => $request->user()?->id,
'subject_type' => $subject::class,
'subject_id' => $subject->id,
'action' => $action,
'created_at' => now(),
]);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers;
use App\Models\Channel;
use App\Models\ChatMessage;
use App\Models\Vod;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ChannelController extends Controller
{
public function show(Request $request, Channel $channel): Response
{
abort_if($channel->isSuspended(), 404);
$channel->load(['category', 'liveBroadcast', 'user']);
$channel->loadCount('follows');
$currentBroadcast = $channel->liveBroadcast;
return Inertia::render('channels/show', [
'channel' => [
'id' => $channel->id,
'slug' => $channel->slug,
'display_name' => $channel->display_name,
'description' => $channel->description,
'is_live' => $channel->is_live,
'viewer_count' => $channel->viewer_count,
'followers_count' => $channel->follows_count,
'category' => $channel->category ? [
'id' => $channel->category->id,
'name' => $channel->category->name,
'slug' => $channel->category->slug,
] : null,
'owner' => [
'id' => $channel->user->id,
'name' => $channel->user->name,
],
],
'currentBroadcast' => $currentBroadcast ? [
'id' => $currentBroadcast->id,
'title' => $currentBroadcast->title,
'hls_url' => $currentBroadcast->hls_url,
'recording_enabled' => $currentBroadcast->recording_enabled,
'started_at' => $currentBroadcast->started_at?->toIso8601String(),
] : null,
'vods' => $channel->vods()
->where(function ($query) {
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->latest('published_at')
->limit(12)
->get()
->map(fn (Vod $vod) => [
'id' => $vod->id,
'title' => $vod->title,
'playback_url' => $vod->playback_url,
'duration_seconds' => $vod->duration_seconds,
'published_at' => $vod->published_at?->toIso8601String(),
'expires_at' => $vod->expires_at?->toIso8601String(),
]),
'chatMessages' => ChatMessage::query()
->where('channel_id', $channel->id)
->when($currentBroadcast, fn ($query) => $query->where('broadcast_id', $currentBroadcast->id))
->with('user')
->latest()
->limit(50)
->get()
->reverse()
->values()
->map(fn (ChatMessage $message) => [
'id' => $message->id,
'body' => $message->body,
'created_at' => $message->created_at?->toIso8601String(),
'user' => [
'id' => $message->user->id,
'name' => $message->user->name,
],
]),
'isFollowing' => $request->user()
? $channel->follows()->where('user_id', $request->user()->id)->exists()
: false,
]);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Models\Channel;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ChatMessageController extends Controller
{
public function store(Request $request, Channel $channel): RedirectResponse
{
abort_if($request->user()->isSuspended() || $channel->isSuspended(), 403);
abort_unless($channel->is_live && $channel->live_broadcast_id, 422, 'Chat is available only while the channel is live.');
$validated = $request->validate([
'body' => ['required', 'string', 'max:500'],
]);
$channel->chatMessages()->create([
'broadcast_id' => $channel->live_broadcast_id,
'user_id' => $request->user()->id,
'body' => trim($validated['body']),
]);
return back();
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,182 @@
<?php
namespace App\Http\Controllers;
use App\Models\Broadcast;
use App\Models\Category;
use App\Models\Channel;
use App\Services\Streaming\StreamKeyManager;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class CreatorDashboardController extends Controller
{
public function show(Request $request, StreamKeyManager $streamKeys): Response
{
$channel = $request->user()
->channel()
->with(['category', 'liveBroadcast', 'streamKey'])
->first();
return Inertia::render('dashboard', [
'channel' => $channel ? $this->channelPayload($channel) : null,
'plainStreamKey' => session('plain_stream_key'),
'streaming' => [
'ingestServer' => $streamKeys->ingestServer(),
'tokenizedPath' => $channel && session('plain_stream_key')
? $streamKeys->tokenizedPath($channel, (string) session('plain_stream_key'))
: null,
],
'categories' => Category::query()
->orderBy('name')
->get(['id', 'name', 'slug'])
->map(fn (Category $category) => [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
]),
'recentBroadcasts' => $channel
? $channel->broadcasts()->with('vod')->latest()->limit(10)->get()->map(fn (Broadcast $broadcast) => [
'id' => $broadcast->id,
'title' => $broadcast->title,
'status' => $broadcast->status,
'recording_enabled' => $broadcast->recording_enabled,
'started_at' => $broadcast->started_at?->toIso8601String(),
'ended_at' => $broadcast->ended_at?->toIso8601String(),
'has_vod' => $broadcast->vod !== null,
])
: [],
]);
}
public function storeChannel(Request $request, StreamKeyManager $streamKeys): RedirectResponse
{
abort_if($request->user()->channel()->exists(), 422, 'This account already owns a channel.');
$validated = $request->validate([
'display_name' => ['required', 'string', 'max:80'],
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', 'unique:channels,slug'],
'description' => ['nullable', 'string', 'max:1000'],
'category_id' => ['nullable', 'exists:categories,id'],
]);
$channel = $request->user()->channel()->create([
...$validated,
'display_name' => trim($validated['display_name']),
'slug' => Str::slug($validated['slug']),
]);
$plainStreamKey = $streamKeys->rotate($channel);
return redirect()
->route('dashboard')
->with('plain_stream_key', $plainStreamKey)
->with('success', 'Channel created. Save the stream key now; it will not be shown again.');
}
public function updateChannel(Request $request): RedirectResponse
{
$channel = $request->user()->channel;
abort_unless($channel, 404);
$validated = $request->validate([
'display_name' => ['required', 'string', 'max:80'],
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', Rule::unique('channels', 'slug')->ignore($channel)],
'description' => ['nullable', 'string', 'max:1000'],
'category_id' => ['nullable', 'exists:categories,id'],
]);
$channel->update([
...$validated,
'display_name' => trim($validated['display_name']),
'slug' => Str::slug($validated['slug']),
]);
return back()->with('success', 'Channel updated.');
}
public function rotateStreamKey(Request $request, StreamKeyManager $streamKeys): RedirectResponse
{
$channel = $request->user()->channel;
abort_unless($channel, 404);
$plainStreamKey = $streamKeys->rotate($channel);
return back()
->with('plain_stream_key', $plainStreamKey)
->with('success', 'Stream key rotated. Update OBS before going live again.');
}
public function storeBroadcast(Request $request): RedirectResponse
{
$channel = $request->user()->channel;
abort_unless($channel, 404);
abort_if($channel->isSuspended(), 403);
abort_if(
$channel->broadcasts()->whereIn('status', [Broadcast::STATUS_PENDING, Broadcast::STATUS_LIVE])->exists(),
422,
'Finish the current broadcast before creating another.',
);
$validated = $request->validate([
'title' => ['required', 'string', 'max:120'],
'recording_enabled' => ['nullable', 'boolean'],
]);
$channel->broadcasts()->create([
'title' => $validated['title'],
'status' => Broadcast::STATUS_PENDING,
'recording_enabled' => (bool) ($validated['recording_enabled'] ?? false),
'mediamtx_path' => $channel->slug,
]);
return back()->with('success', 'Broadcast is ready. Start streaming from OBS.');
}
public function stopBroadcast(Request $request, Broadcast $broadcast): RedirectResponse
{
abort_unless($broadcast->channel->user_id === $request->user()->id, 403);
$broadcast->forceFill([
'status' => Broadcast::STATUS_ENDED,
'ended_at' => $broadcast->ended_at ?? now(),
])->save();
$broadcast->channel->forceFill([
'is_live' => false,
'viewer_count' => 0,
'live_broadcast_id' => null,
])->save();
return back()->with('success', 'Broadcast ended.');
}
private function channelPayload(Channel $channel): array
{
return [
'id' => $channel->id,
'slug' => $channel->slug,
'display_name' => $channel->display_name,
'description' => $channel->description,
'is_live' => $channel->is_live,
'viewer_count' => $channel->viewer_count,
'stream_key_last_used_at' => $channel->streamKey?->last_used_at?->toIso8601String(),
'category_id' => $channel->category_id,
'live_broadcast' => $channel->liveBroadcast ? [
'id' => $channel->liveBroadcast->id,
'title' => $channel->liveBroadcast->title,
'status' => $channel->liveBroadcast->status,
'hls_url' => $channel->liveBroadcast->hls_url,
'recording_enabled' => $channel->liveBroadcast->recording_enabled,
'started_at' => $channel->liveBroadcast->started_at?->toIso8601String(),
] : null,
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Models\Channel;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class FollowController extends Controller
{
public function store(Request $request, Channel $channel): RedirectResponse
{
abort_if($channel->isSuspended(), 404);
$channel->follows()->firstOrCreate([
'user_id' => $request->user()->id,
]);
return back();
}
public function destroy(Request $request, Channel $channel): RedirectResponse
{
$channel->follows()->where('user_id', $request->user()->id)->delete();
return back();
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Channel;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Features;
class HomeController extends Controller
{
public function __invoke(Request $request): Response
{
$liveChannels = Channel::query()
->where('is_live', true)
->whereNull('suspended_at')
->with(['category', 'liveBroadcast', 'user'])
->withCount('follows')
->orderByDesc('viewer_count')
->latest()
->limit(24)
->get()
->map(fn (Channel $channel) => $this->channelCard($channel));
return Inertia::render('welcome', [
'canRegister' => Features::enabled(Features::registration()),
'liveChannels' => $liveChannels,
'categories' => Category::query()
->orderBy('name')
->get(['id', 'name', 'slug'])
->map(fn (Category $category) => [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
]),
]);
}
private function channelCard(Channel $channel): array
{
return [
'id' => $channel->id,
'slug' => $channel->slug,
'display_name' => $channel->display_name,
'description' => $channel->description,
'thumbnail_path' => $channel->thumbnail_path,
'viewer_count' => $channel->viewer_count,
'followers_count' => $channel->follows_count ?? 0,
'category' => $channel->category ? [
'id' => $channel->category->id,
'name' => $channel->category->name,
'slug' => $channel->category->slug,
] : null,
'broadcast' => $channel->liveBroadcast ? [
'id' => $channel->liveBroadcast->id,
'title' => $channel->liveBroadcast->title,
'started_at' => $channel->liveBroadcast->started_at?->toIso8601String(),
] : null,
'owner' => [
'id' => $channel->user->id,
'name' => $channel->user->name,
],
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use App\Services\Streaming\MediaMtxService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class MediaMtxController extends Controller
{
public function auth(Request $request, MediaMtxService $mediaMtx): Response
{
$this->ensureSecret($request);
return $mediaMtx->authorize($request->all())
? response()->noContent()
: response('Unauthorized', 403);
}
public function ready(Request $request, MediaMtxService $mediaMtx): Response
{
$this->ensureSecret($request);
$mediaMtx->markReady(
(string) $request->input('path', $request->query('path', '')),
(string) $request->input('source_type', $request->query('source_type', '')),
(string) $request->input('source_id', $request->query('source_id', '')),
);
return response()->noContent();
}
public function notReady(Request $request, MediaMtxService $mediaMtx): Response
{
$this->ensureSecret($request);
$mediaMtx->markNotReady((string) $request->input('path', $request->query('path', '')));
return response()->noContent();
}
public function recordingComplete(Request $request, MediaMtxService $mediaMtx): Response
{
$this->ensureSecret($request);
$mediaMtx->recordSegmentComplete(
(string) $request->input('path', $request->query('path', '')),
(string) $request->input('segment_path', $request->query('segment_path', '')),
$request->input('duration', $request->query('duration')),
);
return response()->noContent();
}
private function ensureSecret(Request $request): void
{
$expected = (string) config('streaming.mediamtx_shared_secret');
$provided = (string) ($request->header('X-MediaMTX-Secret')
?: $request->query('secret')
?: $request->input('secret'));
abort_if($expected === '' || ! hash_equals($expected, $provided), 403);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next): Response
{
abort_unless($request->user()?->is_admin, 403);
return $next($request);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['admin_user_id', 'subject_type', 'subject_id', 'action', 'metadata', 'created_at'])]
class AdminAuditLog extends Model
{
public $timestamps = false;
protected function casts(): array
{
return [
'metadata' => 'array',
'created_at' => 'datetime',
];
}
public function admin(): BelongsTo
{
return $this->belongsTo(User::class, 'admin_user_id');
}
}

54
app/Models/Broadcast.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Database\Factories\BroadcastFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'channel_id',
'title',
'status',
'recording_enabled',
'mediamtx_path',
'source_type',
'source_id',
'hls_url',
'viewer_peak',
'started_at',
'ended_at',
])]
class Broadcast extends Model
{
/** @use HasFactory<BroadcastFactory> */
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_LIVE = 'live';
public const STATUS_ENDED = 'ended';
protected function casts(): array
{
return [
'recording_enabled' => 'boolean',
'started_at' => 'datetime',
'ended_at' => 'datetime',
];
}
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class);
}
public function vod(): HasOne
{
return $this->hasOne(Vod::class);
}
}

21
app/Models/Category.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Database\Factories\CategoryFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'slug', 'description'])]
class Category extends Model
{
/** @use HasFactory<CategoryFactory> */
use HasFactory;
public function channels(): HasMany
{
return $this->hasMany(Channel::class);
}
}

89
app/Models/Channel.php Normal file
View File

@@ -0,0 +1,89 @@
<?php
namespace App\Models;
use Database\Factories\ChannelFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'user_id',
'category_id',
'live_broadcast_id',
'slug',
'display_name',
'description',
'avatar_path',
'banner_path',
'thumbnail_path',
'is_live',
'viewer_count',
'suspended_at',
])]
class Channel extends Model
{
/** @use HasFactory<ChannelFactory> */
use HasFactory;
protected function casts(): array
{
return [
'is_live' => 'boolean',
'suspended_at' => 'datetime',
];
}
public function getRouteKeyName(): string
{
return 'slug';
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function streamKey(): HasOne
{
return $this->hasOne(StreamKey::class);
}
public function broadcasts(): HasMany
{
return $this->hasMany(Broadcast::class);
}
public function liveBroadcast(): BelongsTo
{
return $this->belongsTo(Broadcast::class, 'live_broadcast_id');
}
public function vods(): HasMany
{
return $this->hasMany(Vod::class);
}
public function follows(): HasMany
{
return $this->hasMany(Follow::class);
}
public function chatMessages(): HasMany
{
return $this->hasMany(ChatMessage::class);
}
public function isSuspended(): bool
{
return $this->suspended_at !== null || $this->user?->suspended_at !== null;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable(['channel_id', 'broadcast_id', 'user_id', 'body'])]
class ChatMessage extends Model
{
use SoftDeletes;
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class);
}
public function broadcast(): BelongsTo
{
return $this->belongsTo(Broadcast::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

21
app/Models/Follow.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['channel_id', 'user_id'])]
class Follow extends Model
{
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

24
app/Models/StreamKey.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['channel_id', 'key_hash', 'last_used_at', 'revoked_at'])]
class StreamKey extends Model
{
protected function casts(): array
{
return [
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
];
}
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class);
}
}

42
app/Models/Vod.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'broadcast_id',
'channel_id',
'title',
'storage_path',
'playback_url',
'duration_seconds',
'size_bytes',
'published_at',
'expires_at',
])]
class Vod extends Model
{
use SoftDeletes;
protected function casts(): array
{
return [
'published_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public function broadcast(): BelongsTo
{
return $this->belongsTo(Broadcast::class);
}
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class);
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Services\Streaming;
use App\Models\Broadcast;
use App\Models\Channel;
use App\Models\Vod;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class MediaMtxService
{
public function authorize(array $payload): bool
{
$action = (string) ($payload['action'] ?? '');
$path = $this->extractPath($payload);
$channel = $this->channelForPath($path);
if (! $channel || $channel->isSuspended()) {
return false;
}
if (in_array($action, ['read', 'playback'], true)) {
return true;
}
if ($action !== 'publish') {
return false;
}
$token = (string) ($payload['token'] ?? $this->queryValue((string) ($payload['query'] ?? ''), 'token'));
if ($token === '' || ! $channel->streamKey || $channel->streamKey->revoked_at) {
return false;
}
$authorized = Hash::check($token, $channel->streamKey->key_hash);
if ($authorized) {
$channel->streamKey->forceFill(['last_used_at' => now()])->save();
}
return $authorized;
}
public function markReady(string $path, ?string $sourceType = null, ?string $sourceId = null): ?Broadcast
{
$channel = $this->channelForPath($path);
if (! $channel || $channel->isSuspended()) {
return null;
}
$broadcast = $channel->broadcasts()
->where('status', Broadcast::STATUS_PENDING)
->latest()
->first();
if (! $broadcast) {
$broadcast = $channel->broadcasts()->create([
'title' => 'Untitled live stream',
'status' => Broadcast::STATUS_PENDING,
'recording_enabled' => false,
]);
}
$broadcast->forceFill([
'status' => Broadcast::STATUS_LIVE,
'mediamtx_path' => $channel->slug,
'source_type' => $sourceType,
'source_id' => $sourceId,
'hls_url' => $this->hlsUrl($channel),
'started_at' => $broadcast->started_at ?? now(),
'ended_at' => null,
])->save();
$channel->forceFill([
'is_live' => true,
'live_broadcast_id' => $broadcast->id,
])->save();
return $broadcast;
}
public function markNotReady(string $path): ?Broadcast
{
$channel = $this->channelForPath($path);
if (! $channel) {
return null;
}
$broadcast = $channel->live_broadcast_id
? Broadcast::find($channel->live_broadcast_id)
: $channel->broadcasts()->where('status', Broadcast::STATUS_LIVE)->latest()->first();
if ($broadcast) {
$broadcast->forceFill([
'status' => Broadcast::STATUS_ENDED,
'ended_at' => $broadcast->ended_at ?? now(),
])->save();
}
$channel->forceFill([
'is_live' => false,
'viewer_count' => 0,
'live_broadcast_id' => null,
])->save();
return $broadcast;
}
public function recordSegmentComplete(string $path, string $segmentPath, ?string $duration = null): ?Vod
{
$channel = $this->channelForPath($path);
if (! $channel) {
return null;
}
$broadcast = $channel->broadcasts()
->whereIn('status', [Broadcast::STATUS_LIVE, Broadcast::STATUS_ENDED])
->latest('started_at')
->first();
if (! $broadcast || ! $broadcast->recording_enabled) {
$this->deleteSegmentIfSafe($segmentPath);
return null;
}
return Vod::query()->firstOrCreate(
['broadcast_id' => $broadcast->id],
[
'channel_id' => $channel->id,
'title' => $broadcast->title,
'storage_path' => $segmentPath,
'duration_seconds' => $this->durationSeconds($duration),
'size_bytes' => is_file($segmentPath) ? filesize($segmentPath) : null,
'published_at' => now(),
'expires_at' => now()->addDays((int) config('streaming.vod_retention_days')),
],
);
}
public function hlsUrl(Channel $channel): string
{
return rtrim((string) config('streaming.hls_public_url'), '/').'/'.rawurlencode($channel->slug).'/index.m3u8';
}
public function extractPath(array $payload): string
{
$path = trim((string) ($payload['path'] ?? ''), '/');
if ($path === '') {
return '';
}
return Str::afterLast($path, '/');
}
private function channelForPath(string $path): ?Channel
{
if ($path === '') {
return null;
}
return Channel::query()
->where('slug', $path)
->with(['streamKey', 'user'])
->first();
}
private function queryValue(string $query, string $key): ?string
{
parse_str($query, $values);
return isset($values[$key]) ? (string) $values[$key] : null;
}
private function durationSeconds(?string $duration): ?int
{
if ($duration === null || $duration === '') {
return null;
}
preg_match('/[\d.]+/', $duration, $matches);
return isset($matches[0]) ? (int) round((float) $matches[0]) : null;
}
private function deleteSegmentIfSafe(string $segmentPath): void
{
if ($segmentPath === '' || ! is_file($segmentPath)) {
return;
}
$recordingsRoot = realpath((string) config('streaming.recordings_path'));
$segmentRealPath = realpath($segmentPath);
if (! $recordingsRoot || ! $segmentRealPath || ! Str::startsWith($segmentRealPath, $recordingsRoot)) {
return;
}
@unlink($segmentRealPath);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Services\Streaming;
use App\Models\Channel;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class StreamKeyManager
{
public function rotate(Channel $channel): string
{
$plainTextKey = Str::random(48);
$channel->streamKey()->updateOrCreate(
['channel_id' => $channel->id],
[
'key_hash' => Hash::make($plainTextKey),
'revoked_at' => null,
],
);
return $plainTextKey;
}
public function tokenizedPath(Channel $channel, string $plainTextKey): string
{
return "{$channel->slug}?token={$plainTextKey}";
}
public function ingestServer(): string
{
return rtrim((string) config('streaming.rtmp_ingest_url'), '/');
}
}

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\Broadcast;
use App\Models\Channel;
use Illuminate\Database\Eloquent\Factories\Factory;
class BroadcastFactory extends Factory
{
public function definition(): array
{
return [
'channel_id' => Channel::factory(),
'title' => fake()->sentence(4),
'status' => Broadcast::STATUS_PENDING,
'recording_enabled' => false,
];
}
public function live(): static
{
return $this->state(fn (array $attributes) => [
'status' => Broadcast::STATUS_LIVE,
'started_at' => now(),
'hls_url' => 'http://localhost:8888/demo/index.m3u8',
]);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class CategoryFactory extends Factory
{
public function definition(): array
{
$name = fake()->unique()->words(2, true);
return [
'name' => Str::headline($name),
'slug' => Str::slug($name),
'description' => fake()->optional()->sentence(),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Database\Factories;
use App\Models\Category;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class ChannelFactory extends Factory
{
public function definition(): array
{
$name = fake()->unique()->words(2, true);
return [
'user_id' => User::factory(),
'category_id' => Category::factory(),
'slug' => Str::slug($name),
'display_name' => Str::headline($name),
'description' => fake()->sentence(),
'is_live' => false,
'viewer_count' => 0,
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(false)->index();
$table->timestamp('suspended_at')->nullable()->index();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['is_admin', 'suspended_at']);
});
}
};

View File

@@ -0,0 +1,119 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->timestamps();
});
Schema::create('channels', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
$table->unsignedBigInteger('live_broadcast_id')->nullable()->index();
$table->string('slug')->unique();
$table->string('display_name');
$table->text('description')->nullable();
$table->string('avatar_path')->nullable();
$table->string('banner_path')->nullable();
$table->string('thumbnail_path')->nullable();
$table->boolean('is_live')->default(false)->index();
$table->unsignedInteger('viewer_count')->default(0);
$table->timestamp('suspended_at')->nullable()->index();
$table->timestamps();
});
Schema::create('stream_keys', function (Blueprint $table) {
$table->id();
$table->foreignId('channel_id')->unique()->constrained()->cascadeOnDelete();
$table->string('key_hash');
$table->timestamp('last_used_at')->nullable();
$table->timestamp('revoked_at')->nullable()->index();
$table->timestamps();
});
Schema::create('broadcasts', function (Blueprint $table) {
$table->id();
$table->foreignId('channel_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->string('status')->default('pending')->index();
$table->boolean('recording_enabled')->default(false);
$table->string('mediamtx_path')->nullable()->index();
$table->string('source_type')->nullable();
$table->string('source_id')->nullable();
$table->string('hls_url')->nullable();
$table->unsignedInteger('viewer_peak')->default(0);
$table->timestamp('started_at')->nullable()->index();
$table->timestamp('ended_at')->nullable()->index();
$table->timestamps();
});
Schema::create('vods', function (Blueprint $table) {
$table->id();
$table->foreignId('broadcast_id')->unique()->constrained()->cascadeOnDelete();
$table->foreignId('channel_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->string('storage_path');
$table->string('playback_url')->nullable();
$table->unsignedInteger('duration_seconds')->nullable();
$table->unsignedBigInteger('size_bytes')->nullable();
$table->timestamp('published_at')->nullable()->index();
$table->timestamp('expires_at')->nullable()->index();
$table->softDeletes();
$table->timestamps();
});
Schema::create('follows', function (Blueprint $table) {
$table->id();
$table->foreignId('channel_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['channel_id', 'user_id']);
});
Schema::create('chat_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('channel_id')->constrained()->cascadeOnDelete();
$table->foreignId('broadcast_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('body', 500);
$table->softDeletes();
$table->timestamps();
});
Schema::create('admin_audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('admin_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('subject_type');
$table->unsignedBigInteger('subject_id');
$table->string('action');
$table->json('metadata')->nullable();
$table->timestamp('created_at')->nullable();
$table->index(['subject_type', 'subject_id']);
});
}
public function down(): void
{
Schema::dropIfExists('admin_audit_logs');
Schema::dropIfExists('chat_messages');
Schema::dropIfExists('follows');
Schema::dropIfExists('vods');
Schema::dropIfExists('broadcasts');
Schema::dropIfExists('stream_keys');
Schema::dropIfExists('channels');
Schema::dropIfExists('categories');
}
};

View File

@@ -0,0 +1,38 @@
<?php
namespace Database\Seeders;
use App\Models\Category;
use App\Models\User;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
collect([
['name' => 'Gaming', 'slug' => 'gaming'],
['name' => 'Music', 'slug' => 'music'],
['name' => 'Talk Shows', 'slug' => 'talk-shows'],
['name' => 'Education', 'slug' => 'education'],
['name' => 'Creative', 'slug' => 'creative'],
])->each(fn (array $category) => Category::query()->firstOrCreate(
['slug' => $category['slug']],
['name' => $category['name']],
));
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
User::factory()->create([
'name' => 'Admin User',
'email' => 'admin@example.com',
'is_admin' => true,
]);
}
}

31
deploy/mediamtx.yml Normal file
View File

@@ -0,0 +1,31 @@
authMethod: http
authHTTPAddress: http://127.0.0.1:8001/internal/mediamtx/auth?secret=change-this-secret
authHTTPExclude:
- action: read
- action: playback
- action: api
- action: metrics
- action: pprof
rtmp: yes
rtmpAddress: :19935
hls: yes
hlsAddress: :19988
hlsVariant: mpegts
hlsAllowOrigins:
- http://127.0.0.1:8001
- http://localhost:8001
record: yes
recordPath: ./storage/app/private/recordings/%path/%Y-%m-%d_%H-%M-%S-%f
pathDefaults:
runOnReady: curl -X POST "http://127.0.0.1:8001/internal/mediamtx/ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
runOnReadyRestart: no
runOnNotReady: curl -X POST "http://127.0.0.1:8001/internal/mediamtx/not-ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
runOnRecordSegmentComplete: curl -X POST "http://127.0.0.1:8001/internal/mediamtx/recording-complete?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "segment_path=$MTX_SEGMENT_PATH" --data-urlencode "duration=$MTX_SEGMENT_DURATION"
paths:
all_others:
source: publisher

View File

@@ -0,0 +1,31 @@
authMethod: http
authHTTPAddress: http://127.0.0.1:8001/internal/mediamtx/auth?secret=change-this-secret
authHTTPExclude:
- action: read
- action: playback
- action: api
- action: metrics
- action: pprof
rtmp: yes
rtmpAddress: :19935
hls: yes
hlsAddress: :19988
hlsVariant: mpegts
hlsAllowOrigins:
- http://127.0.0.1:8001
- http://localhost:8001
record: yes
recordPath: ./storage/app/private/recordings/%path/%Y-%m-%d_%H-%M-%S-%f
pathDefaults:
runOnReady: curl -X POST "http://127.0.0.1:8001/internal/mediamtx/ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
runOnReadyRestart: no
runOnNotReady: curl -X POST "http://127.0.0.1:8001/internal/mediamtx/not-ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
runOnRecordSegmentComplete: curl -X POST "http://127.0.0.1:8001/internal/mediamtx/recording-complete?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "segment_path=$MTX_SEGMENT_PATH" --data-urlencode "duration=$MTX_SEGMENT_DURATION"
paths:
all_others:
source: publisher

View File

@@ -0,0 +1,198 @@
import { Head, Link, router } from '@inertiajs/react';
import { Ban, Radio, Shield, StopCircle, Undo2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
type LiveBroadcast = {
id: number;
title: string;
started_at: string | null;
channel: {
id: number;
slug: string;
display_name: string;
owner: string;
};
};
type AdminChannel = {
id: number;
slug: string;
display_name: string;
is_live: boolean;
suspended_at: string | null;
owner: {
id: number;
name: string;
};
};
type Props = {
stats: {
users: number;
channels: number;
live_channels: number;
vods: number;
};
liveBroadcasts: LiveBroadcast[];
channels: AdminChannel[];
};
export default function AdminDashboard({
stats,
liveBroadcasts,
channels,
}: Props) {
return (
<>
<Head title="Admin" />
<div className="grid gap-6 p-4 md:p-6">
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-md border">
<Shield className="size-5" />
</span>
<div>
<h1 className="text-2xl font-semibold">Admin</h1>
<p className="text-sm text-muted-foreground">
Moderate live streams, channels, users, and
recordings.
</p>
</div>
</div>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat label="Users" value={stats.users} />
<Stat label="Channels" value={stats.channels} />
<Stat label="Live channels" value={stats.live_channels} />
<Stat label="VODs" value={stats.vods} />
</section>
<section className="rounded-md border bg-card p-5">
<div className="mb-4 flex items-center gap-2">
<Radio className="size-5 text-red-600" />
<h2 className="font-semibold">Live broadcasts</h2>
</div>
<div className="grid gap-3">
{liveBroadcasts.map((broadcast) => (
<div
key={broadcast.id}
className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-center md:justify-between"
>
<div>
<div className="font-medium">
{broadcast.title}
</div>
<div className="text-sm text-muted-foreground">
{broadcast.channel.display_name} by{' '}
{broadcast.channel.owner}
</div>
</div>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm">
<Link
href={`/channels/${broadcast.channel.slug}`}
>
Open
</Link>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() =>
router.post(
`/admin/broadcasts/${broadcast.id}/stop`,
)
}
>
<StopCircle className="size-4" />
Stop
</Button>
</div>
</div>
))}
{liveBroadcasts.length === 0 && (
<div className="rounded-md border border-dashed p-6 text-sm text-muted-foreground">
No channels are live.
</div>
)}
</div>
</section>
<section className="rounded-md border bg-card p-5">
<h2 className="mb-4 font-semibold">Recent channels</h2>
<div className="grid gap-3">
{channels.map((channel) => (
<div
key={channel.id}
className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-center md:justify-between"
>
<div>
<div className="font-medium">
{channel.display_name}
</div>
<div className="text-sm text-muted-foreground">
@{channel.slug} owned by{' '}
{channel.owner.name}
</div>
</div>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm">
<Link
href={`/channels/${channel.slug}`}
>
Open
</Link>
</Button>
{channel.suspended_at ? (
<Button
variant="outline"
size="sm"
onClick={() =>
router.post(
`/admin/channels/${channel.slug}/restore`,
)
}
>
<Undo2 className="size-4" />
Restore
</Button>
) : (
<Button
variant="destructive"
size="sm"
onClick={() =>
router.post(
`/admin/channels/${channel.slug}/suspend`,
)
}
>
<Ban className="size-4" />
Suspend
</Button>
)}
</div>
</div>
))}
</div>
</section>
</div>
</>
);
}
AdminDashboard.layout = {
breadcrumbs: [
{
title: 'Admin',
href: '/admin',
},
],
};
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md border bg-card p-4">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-semibold">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
import {
Heart,
LogIn,
MessageSquare,
Radio,
Send,
Users,
Video,
} from 'lucide-react';
import type { FormEvent } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { VideoPlayer } from '@/components/video-player';
import type {
BroadcastSummary,
ChannelDetail,
ChatMessage,
VodSummary,
} from '@/types';
type Props = {
channel: ChannelDetail;
currentBroadcast: BroadcastSummary | null;
vods: VodSummary[];
chatMessages: ChatMessage[];
isFollowing: boolean;
};
export default function ChannelShow({
channel,
currentBroadcast,
vods,
chatMessages,
isFollowing,
}: Props) {
const { auth } = usePage().props;
const chatForm = useForm({ body: '' });
function submitChat(event: FormEvent) {
event.preventDefault();
chatForm.post(`/channels/${channel.slug}/chat`, {
preserveScroll: true,
onSuccess: () => chatForm.reset('body'),
});
}
return (
<>
<Head title={channel.display_name} />
<div className="grid min-h-[calc(100vh-4rem)] gap-0 xl:grid-cols-[1fr_360px]">
<main className="min-w-0">
<div className="bg-black">
<VideoPlayer
src={currentBroadcast?.hls_url ?? null}
title={
currentBroadcast?.title ?? channel.display_name
}
/>
</div>
<section className="grid gap-6 p-4 md:p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
{channel.is_live ? (
<span className="inline-flex items-center gap-2 rounded-md bg-red-600 px-2 py-1 text-xs font-medium text-white">
<Radio className="size-3" />
LIVE
</span>
) : (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
Offline
</span>
)}
{channel.category && (
<span className="rounded-md border px-2 py-1 text-xs">
{channel.category.name}
</span>
)}
</div>
<h1 className="text-2xl font-semibold">
{currentBroadcast?.title ??
channel.display_name}
</h1>
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<span>{channel.display_name}</span>
<span className="inline-flex items-center gap-1">
<Users className="size-4" />
{channel.viewer_count} viewers
</span>
<span>
{channel.followers_count} followers
</span>
</div>
{channel.description && (
<p className="max-w-3xl text-sm leading-6 text-muted-foreground">
{channel.description}
</p>
)}
</div>
{auth.user ? (
<Button
variant={
isFollowing ? 'outline' : 'default'
}
onClick={() =>
isFollowing
? router.delete(
`/channels/${channel.slug}/follow`,
{
preserveScroll: true,
},
)
: router.post(
`/channels/${channel.slug}/follow`,
undefined,
{
preserveScroll: true,
},
)
}
>
<Heart className="size-4" />
{isFollowing ? 'Following' : 'Follow'}
</Button>
) : (
<Button asChild variant="outline">
<Link href="/login">
<LogIn className="size-4" />
Log in to follow
</Link>
</Button>
)}
</div>
<section className="grid gap-3">
<div className="flex items-center gap-2">
<Video className="size-5" />
<h2 className="font-semibold">Recent VODs</h2>
</div>
{vods.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{vods.map((vod) => (
<div
key={vod.id}
className="rounded-md border p-4"
>
<div className="font-medium">
{vod.title}
</div>
<div className="mt-1 text-sm text-muted-foreground">
Expires{' '}
{vod.expires_at
? new Date(
vod.expires_at,
).toLocaleDateString()
: 'later'}
</div>
{vod.playback_url ? (
<Button
asChild
variant="outline"
size="sm"
className="mt-3"
>
<a href={vod.playback_url}>
Watch VOD
</a>
</Button>
) : (
<div className="mt-3 text-sm text-muted-foreground">
Processing recording
</div>
)}
</div>
))}
</div>
) : (
<div className="rounded-md border border-dashed p-6 text-sm text-muted-foreground">
No public recordings yet.
</div>
)}
</section>
</section>
</main>
<aside className="flex min-h-[480px] flex-col border-l bg-card">
<div className="flex h-14 items-center gap-2 border-b px-4">
<MessageSquare className="size-5" />
<h2 className="font-semibold">Live chat</h2>
</div>
<div className="flex-1 space-y-3 overflow-y-auto p-4">
{chatMessages.map((message) => (
<div key={message.id} className="text-sm">
<span className="font-medium">
{message.user.name}
</span>
<span className="ml-2 text-muted-foreground">
{message.body}
</span>
</div>
))}
{chatMessages.length === 0 && (
<div className="text-sm text-muted-foreground">
{channel.is_live
? 'No messages yet.'
: 'Chat appears when the channel is live.'}
</div>
)}
</div>
<div className="border-t p-4">
{auth.user ? (
<form onSubmit={submitChat} className="flex gap-2">
<Input
value={chatForm.data.body}
onChange={(event) =>
chatForm.setData(
'body',
event.target.value,
)
}
placeholder={
channel.is_live
? 'Send a message'
: 'Channel is offline'
}
disabled={!channel.is_live}
/>
<Button
type="submit"
size="icon"
disabled={
!channel.is_live || chatForm.processing
}
>
<Send className="size-4" />
</Button>
</form>
) : (
<Button
asChild
variant="outline"
className="w-full"
>
<Link href="/login">Log in to chat</Link>
</Button>
)}
</div>
</aside>
</div>
</>
);
}
ChannelShow.layout = {
breadcrumbs: [
{
title: 'Channel',
href: '/',
},
],
};

View File

@@ -0,0 +1,541 @@
import { Head, router, useForm } from '@inertiajs/react';
import {
Copy,
KeyRound,
Radio,
RotateCw,
Save,
StopCircle,
Video,
} from 'lucide-react';
import type { FormEvent } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useClipboard } from '@/hooks/use-clipboard';
import type { BroadcastSummary, Category } from '@/types';
type CreatorChannel = {
id: number;
slug: string;
display_name: string;
description: string | null;
is_live: boolean;
viewer_count: number;
stream_key_last_used_at: string | null;
category_id: number | null;
live_broadcast: BroadcastSummary | null;
};
type Props = {
channel: CreatorChannel | null;
plainStreamKey: string | null;
streaming: {
ingestServer: string;
tokenizedPath: string | null;
};
categories: Category[];
recentBroadcasts: BroadcastSummary[];
};
export default function Dashboard({
channel,
plainStreamKey,
streaming,
categories,
recentBroadcasts,
}: Props) {
const [, copy] = useClipboard();
const createForm = useForm({
display_name: '',
slug: '',
description: '',
category_id: '',
});
const channelForm = useForm({
display_name: channel?.display_name ?? '',
slug: channel?.slug ?? '',
description: channel?.description ?? '',
category_id: channel?.category_id ? String(channel.category_id) : '',
});
const broadcastForm = useForm({
title: '',
recording_enabled: false,
});
function createChannel(event: FormEvent) {
event.preventDefault();
createForm.post('/creator/channel');
}
function updateChannel(event: FormEvent) {
event.preventDefault();
channelForm.patch('/creator/channel');
}
function createBroadcast(event: FormEvent) {
event.preventDefault();
broadcastForm.post('/creator/broadcasts', {
onSuccess: () => broadcastForm.reset('title', 'recording_enabled'),
});
}
return (
<>
<Head title="Creator Studio" />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-semibold">
Creator Studio
</h1>
<p className="text-sm text-muted-foreground">
Manage your channel, stream key, and OBS broadcast
sessions.
</p>
</div>
{channel && (
<Button asChild variant="outline">
<a href={`/channels/${channel.slug}`}>
<Radio className="size-4" />
View channel
</a>
</Button>
)}
</div>
{!channel ? (
<section className="max-w-2xl rounded-md border bg-card p-5">
<div className="mb-5">
<h2 className="font-semibold">
Create your channel
</h2>
<p className="text-sm text-muted-foreground">
Each account can own one channel in this
version.
</p>
</div>
<form onSubmit={createChannel} className="grid gap-4">
<Field
label="Display name"
error={createForm.errors.display_name}
>
<Input
value={createForm.data.display_name}
onChange={(event) =>
createForm.setData(
'display_name',
event.target.value,
)
}
placeholder="Nyone Live"
/>
</Field>
<Field label="Slug" error={createForm.errors.slug}>
<Input
value={createForm.data.slug}
onChange={(event) =>
createForm.setData(
'slug',
event.target.value.toLowerCase(),
)
}
placeholder="nyone-live"
/>
</Field>
<Field label="Category">
<CategorySelect
categories={categories}
value={createForm.data.category_id}
onChange={(value) =>
createForm.setData('category_id', value)
}
/>
</Field>
<Field
label="Description"
error={createForm.errors.description}
>
<textarea
value={createForm.data.description}
onChange={(event) =>
createForm.setData(
'description',
event.target.value,
)
}
className="min-h-28 rounded-md border bg-background px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</Field>
<Button
type="submit"
disabled={createForm.processing}
>
<Save className="size-4" />
Create channel
</Button>
</form>
</section>
) : (
<div className="grid gap-6 xl:grid-cols-[1fr_360px]">
<div className="grid gap-6">
<section className="rounded-md border bg-card p-5">
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<h2 className="font-semibold">
Channel details
</h2>
<p className="text-sm text-muted-foreground">
Public metadata used in the
directory and channel page.
</p>
</div>
<span className="rounded-md border px-2 py-1 text-xs">
{channel.is_live
? `${channel.viewer_count} viewers`
: 'Offline'}
</span>
</div>
<form
onSubmit={updateChannel}
className="grid gap-4"
>
<Field
label="Display name"
error={channelForm.errors.display_name}
>
<Input
value={
channelForm.data.display_name
}
onChange={(event) =>
channelForm.setData(
'display_name',
event.target.value,
)
}
/>
</Field>
<Field
label="Slug"
error={channelForm.errors.slug}
>
<Input
value={channelForm.data.slug}
onChange={(event) =>
channelForm.setData(
'slug',
event.target.value.toLowerCase(),
)
}
/>
</Field>
<Field label="Category">
<CategorySelect
categories={categories}
value={channelForm.data.category_id}
onChange={(value) =>
channelForm.setData(
'category_id',
value,
)
}
/>
</Field>
<Field
label="Description"
error={channelForm.errors.description}
>
<textarea
value={
channelForm.data.description ??
''
}
onChange={(event) =>
channelForm.setData(
'description',
event.target.value,
)
}
className="min-h-28 rounded-md border bg-background px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</Field>
<Button
type="submit"
disabled={channelForm.processing}
>
<Save className="size-4" />
Save channel
</Button>
</form>
</section>
<section className="rounded-md border bg-card p-5">
<div className="mb-5">
<h2 className="font-semibold">
Broadcast setup
</h2>
<p className="text-sm text-muted-foreground">
Create a pending broadcast, then start
OBS with your stream key.
</p>
</div>
<form
onSubmit={createBroadcast}
className="grid gap-4 md:grid-cols-[1fr_auto]"
>
<Field
label="Stream title"
error={broadcastForm.errors.title}
>
<Input
value={broadcastForm.data.title}
onChange={(event) =>
broadcastForm.setData(
'title',
event.target.value,
)
}
placeholder="Building live with Laravel"
/>
</Field>
<label className="mt-6 flex h-9 items-center gap-2 rounded-md border px-3 text-sm">
<input
type="checkbox"
checked={
broadcastForm.data
.recording_enabled
}
onChange={(event) =>
broadcastForm.setData(
'recording_enabled',
event.target.checked,
)
}
/>
Record VOD
</label>
<Button
type="submit"
disabled={broadcastForm.processing}
className="md:col-span-2"
>
<Video className="size-4" />
Prepare broadcast
</Button>
</form>
</section>
<section className="rounded-md border bg-card p-5">
<h2 className="mb-4 font-semibold">
Recent broadcasts
</h2>
<div className="grid gap-2">
{recentBroadcasts.map((broadcast) => (
<div
key={broadcast.id}
className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-center md:justify-between"
>
<div>
<div className="font-medium">
{broadcast.title}
</div>
<div className="text-sm text-muted-foreground">
{broadcast.status}{' '}
{broadcast.recording_enabled
? 'with recording'
: 'live only'}
</div>
</div>
{broadcast.status !== 'ended' && (
<Button
variant="outline"
size="sm"
onClick={() =>
router.post(
`/creator/broadcasts/${broadcast.id}/stop`,
)
}
>
<StopCircle className="size-4" />
Stop
</Button>
)}
</div>
))}
{recentBroadcasts.length === 0 && (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
No broadcasts yet.
</div>
)}
</div>
</section>
</div>
<aside className="grid content-start gap-6">
<section className="rounded-md border bg-card p-5">
<div className="mb-4 flex items-center gap-2">
<KeyRound className="size-5 text-amber-600" />
<h2 className="font-semibold">
OBS stream key
</h2>
</div>
<div className="grid gap-3 text-sm">
<CopyRow
label="Server"
value={streaming.ingestServer}
onCopy={copy}
/>
<CopyRow
label="Stream key"
value={
streaming.tokenizedPath ??
'Rotate the key to reveal it once.'
}
onCopy={copy}
muted={!streaming.tokenizedPath}
/>
{plainStreamKey && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
This key is shown once. Store it in
OBS before leaving this page.
</div>
)}
<Button
variant="outline"
onClick={() =>
router.post(
'/creator/stream-key/rotate',
)
}
>
<RotateCw className="size-4" />
Rotate key
</Button>
</div>
</section>
{channel.live_broadcast && (
<section className="rounded-md border bg-card p-5">
<h2 className="mb-3 font-semibold">
Current live session
</h2>
<div className="space-y-2 text-sm">
<div className="font-medium">
{channel.live_broadcast.title}
</div>
<div className="text-muted-foreground">
{channel.live_broadcast
.recording_enabled
? 'Recording enabled'
: 'Live only'}
</div>
<Button
variant="destructive"
size="sm"
onClick={() =>
router.post(
`/creator/broadcasts/${channel.live_broadcast?.id}/stop`,
)
}
>
<StopCircle className="size-4" />
Stop broadcast
</Button>
</div>
</section>
)}
</aside>
</div>
)}
</div>
</>
);
}
Dashboard.layout = {
breadcrumbs: [
{
title: 'Creator Studio',
href: '/dashboard',
},
],
};
function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<label className="grid gap-2">
<Label>{label}</Label>
{children}
{error && <span className="text-sm text-destructive">{error}</span>}
</label>
);
}
function CategorySelect({
categories,
value,
onChange,
}: {
categories: Category[];
value: string;
onChange: (value: string) => void;
}) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-9 rounded-md border bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<option value="">No category</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
);
}
function CopyRow({
label,
value,
muted = false,
onCopy,
}: {
label: string;
value: string;
muted?: boolean;
onCopy: (text: string) => Promise<boolean>;
}) {
return (
<div className="grid gap-2">
<div className="text-xs font-medium text-muted-foreground uppercase">
{label}
</div>
<div className="flex gap-2">
<code
className={`min-w-0 flex-1 rounded-md border px-3 py-2 text-xs ${muted ? 'text-muted-foreground' : ''}`}
>
{value}
</code>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => void onCopy(value)}
>
<Copy className="size-4" />
</Button>
</div>
</div>
);
}

1
resume Normal file
View File

@@ -0,0 +1 @@
codex resume 019e28d9-26f3-7ff3-b0f7-d0d833e46d7d

View File

@@ -0,0 +1,92 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Fortify\Features;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered()
{
$response = $this->get(route('login'));
$response->assertOk();
}
public function test_users_can_authenticate_using_the_login_screen()
{
$user = User::factory()->create();
$response = $this->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
}
public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge()
{
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$user = User::factory()->withTwoFactor()->create();
$response = $this->post(route('login'), [
'email' => $user->email,
'password' => 'password',
]);
$response->assertRedirect(route('two-factor.login'));
$response->assertSessionHas('login.id', $user->id);
$this->assertGuest();
}
public function test_users_can_not_authenticate_with_invalid_password()
{
$user = User::factory()->create();
$this->post(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
}
public function test_users_can_logout()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('logout'));
$response->assertRedirect(route('home'));
$this->assertGuest();
}
public function test_users_are_rate_limited()
{
$user = User::factory()->create();
RateLimiter::increment(md5('login'.implode('|', [$user->email, '127.0.0.1'])), amount: 5);
$response = $this->post(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertTooManyRequests();
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Features;
use Tests\TestCase;
class EmailVerificationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::emailVerification());
}
public function test_email_verification_screen_can_be_rendered()
{
$user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get(route('verification.notice'));
$response->assertOk();
}
public function test_email_can_be_verified()
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
}
public function test_email_is_not_verified_with_invalid_hash()
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')],
);
$this->actingAs($user)->get($verificationUrl);
Event::assertNotDispatched(Verified::class);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
public function test_email_is_not_verified_with_invalid_user_id(): void
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => 123, 'hash' => sha1($user->email)],
);
$this->actingAs($user)->get($verificationUrl);
Event::assertNotDispatched(Verified::class);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
public function test_verified_user_is_redirected_to_dashboard_from_verification_prompt(): void
{
$user = User::factory()->create();
Event::fake();
$response = $this->actingAs($user)->get(route('verification.notice'));
Event::assertNotDispatched(Verified::class);
$response->assertRedirect(route('dashboard', absolute: false));
}
public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void
{
$user = User::factory()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$this->actingAs($user)->get($verificationUrl)
->assertRedirect(route('dashboard', absolute: false).'?verified=1');
Event::assertNotDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_confirm_password_screen_can_be_rendered()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('password.confirm'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('auth/confirm-password'),
);
}
public function test_password_confirmation_requires_authentication()
{
$response = $this->get(route('password.confirm'));
$response->assertRedirect(route('login'));
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Laravel\Fortify\Features;
use Tests\TestCase;
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::resetPasswords());
}
public function test_reset_password_link_screen_can_be_rendered()
{
$response = $this->get(route('password.request'));
$response->assertOk();
}
public function test_reset_password_link_can_be_requested()
{
Notification::fake();
$user = User::factory()->create();
$this->post(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class);
}
public function test_reset_password_screen_can_be_rendered()
{
Notification::fake();
$user = User::factory()->create();
$this->post(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get(route('password.reset', $notification->token));
$response->assertOk();
return true;
});
}
public function test_password_can_be_reset_with_valid_token()
{
Notification::fake();
$user = User::factory()->create();
$this->post(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->post(route('password.update'), [
'token' => $notification->token,
'email' => $user->email,
'password' => 'password',
'password_confirmation' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('login'));
return true;
});
}
public function test_password_cannot_be_reset_with_invalid_token(): void
{
$user = User::factory()->create();
$response = $this->post(route('password.update'), [
'token' => 'invalid-token',
'email' => $user->email,
'password' => 'newpassword123',
'password_confirmation' => 'newpassword123',
]);
$response->assertSessionHasErrors('email');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Tests\Feature\Auth;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Fortify\Features;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::registration());
}
public function test_registration_screen_can_be_rendered()
{
$response = $this->get(route('register'));
$response->assertOk();
}
public function test_new_users_can_register()
{
$response = $this->post(route('register.store'), [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features;
use Tests\TestCase;
class TwoFactorChallengeTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
}
public function test_two_factor_challenge_redirects_to_login_when_not_authenticated(): void
{
$response = $this->get(route('two-factor.login'));
$response->assertRedirect(route('login'));
}
public function test_two_factor_challenge_can_be_rendered(): void
{
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$user = User::factory()->withTwoFactor()->create();
$this->post(route('login'), [
'email' => $user->email,
'password' => 'password',
]);
$this->get(route('two-factor.login'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('auth/two-factor-challenge'),
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Laravel\Fortify\Features;
use Tests\TestCase;
class VerificationNotificationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::emailVerification());
}
public function test_sends_verification_notification(): void
{
Notification::fake();
$user = User::factory()->unverified()->create();
$this->actingAs($user)
->post(route('verification.send'))
->assertRedirect(route('home'));
Notification::assertSentTo($user, VerifyEmail::class);
}
public function test_does_not_send_verification_notification_if_email_is_verified(): void
{
Notification::fake();
$user = User::factory()->create();
$this->actingAs($user)
->post(route('verification.send'))
->assertRedirect(route('dashboard', absolute: false));
Notification::assertNothingSent();
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Tests\Feature;
use App\Models\Category;
use App\Models\Channel;
use App\Models\StreamKey;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CreatorChannelTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_create_one_channel_and_receives_one_time_stream_key(): void
{
$user = User::factory()->create();
$category = Category::factory()->create();
$response = $this->actingAs($user)->post(route('creator.channel.store'), [
'display_name' => 'Nyone Live',
'slug' => 'nyone-live',
'description' => 'Live builds and demos.',
'category_id' => $category->id,
]);
$response
->assertRedirect(route('dashboard', absolute: false))
->assertSessionHas('plain_stream_key');
$this->assertDatabaseHas(Channel::class, [
'user_id' => $user->id,
'slug' => 'nyone-live',
'display_name' => 'Nyone Live',
'category_id' => $category->id,
]);
$this->assertDatabaseCount(StreamKey::class, 1);
}
public function test_user_cannot_create_more_than_one_channel(): void
{
$user = User::factory()->create();
Channel::factory()->for($user)->create();
$response = $this->actingAs($user)->post(route('creator.channel.store'), [
'display_name' => 'Second Channel',
'slug' => 'second-channel',
]);
$response->assertUnprocessable();
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DashboardTest extends TestCase
{
use RefreshDatabase;
public function test_guests_are_redirected_to_the_login_page()
{
$response = $this->get(route('dashboard'));
$response->assertRedirect(route('login'));
}
public function test_authenticated_users_can_visit_the_dashboard()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('dashboard'));
$response->assertOk();
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use RefreshDatabase;
public function test_returns_a_successful_response()
{
$response = $this->get(route('home'));
$response->assertOk();
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Tests\Feature;
use App\Models\Broadcast;
use App\Models\Channel;
use App\Services\Streaming\StreamKeyManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MediaMtxIntegrationTest extends TestCase
{
use RefreshDatabase;
public function test_mediamtx_publish_auth_accepts_valid_stream_key(): void
{
$channel = Channel::factory()->create(['slug' => 'valid-channel']);
$plainTextKey = app(StreamKeyManager::class)->rotate($channel);
$response = $this->post('/internal/mediamtx/auth?secret=local-dev-secret', [
'action' => 'publish',
'protocol' => 'rtmp',
'path' => 'valid-channel',
'token' => $plainTextKey,
]);
$response->assertNoContent();
}
public function test_mediamtx_publish_auth_rejects_invalid_key(): void
{
Channel::factory()->create(['slug' => 'valid-channel']);
$response = $this->post('/internal/mediamtx/auth?secret=local-dev-secret', [
'action' => 'publish',
'protocol' => 'rtmp',
'path' => 'valid-channel',
'token' => 'wrong-key',
]);
$response->assertForbidden();
}
public function test_mediamtx_ready_and_not_ready_hooks_update_broadcast_state(): void
{
$channel = Channel::factory()->create(['slug' => 'demo']);
$broadcast = Broadcast::factory()->for($channel)->create([
'title' => 'Demo stream',
'recording_enabled' => true,
]);
$this->post('/internal/mediamtx/ready?secret=local-dev-secret', [
'path' => 'demo',
'source_type' => 'rtmp',
'source_id' => 'publisher-1',
])->assertNoContent();
$broadcast->refresh();
$channel->refresh();
$this->assertSame(Broadcast::STATUS_LIVE, $broadcast->status);
$this->assertTrue($channel->is_live);
$this->assertSame($broadcast->id, $channel->live_broadcast_id);
$this->assertSame('http://localhost:8888/demo/index.m3u8', $broadcast->hls_url);
$this->post('/internal/mediamtx/not-ready?secret=local-dev-secret', [
'path' => 'demo',
])->assertNoContent();
$broadcast->refresh();
$channel->refresh();
$this->assertSame(Broadcast::STATUS_ENDED, $broadcast->status);
$this->assertFalse($channel->is_live);
$this->assertNull($channel->live_broadcast_id);
}
public function test_recording_complete_creates_vod_when_recording_is_enabled(): void
{
$channel = Channel::factory()->create(['slug' => 'demo']);
Broadcast::factory()->for($channel)->live()->create([
'title' => 'Recorded stream',
'recording_enabled' => true,
'started_at' => now(),
]);
$this->post('/internal/mediamtx/recording-complete?secret=local-dev-secret', [
'path' => 'demo',
'segment_path' => storage_path('app/private/recordings/demo/segment.ts'),
'duration' => '12.4s',
])->assertNoContent();
$this->assertDatabaseHas('vods', [
'channel_id' => $channel->id,
'title' => 'Recorded stream',
'duration_seconds' => 12,
]);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Tests\Feature\Settings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProfileUpdateTest extends TestCase
{
use RefreshDatabase;
public function test_profile_page_is_displayed()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get(route('profile.edit'));
$response->assertOk();
}
public function test_profile_information_can_be_updated()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => 'test@example.com',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('profile.edit'));
$user->refresh();
$this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at);
}
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => $user->email,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('profile.edit'));
$this->assertNotNull($user->refresh()->email_verified_at);
}
public function test_user_can_delete_their_account()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->delete(route('profile.destroy'), [
'password' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('home'));
$this->assertGuest();
$this->assertNull($user->fresh());
}
public function test_correct_password_must_be_provided_to_delete_account()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('profile.edit'))
->delete(route('profile.destroy'), [
'password' => 'wrong-password',
]);
$response
->assertSessionHasErrors('password')
->assertRedirect(route('profile.edit'));
$this->assertNotNull($user->fresh());
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Tests\Feature\Settings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features;
use Tests\TestCase;
class SecurityTest extends TestCase
{
use RefreshDatabase;
public function test_security_page_is_displayed()
{
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('security.edit'))
->assertInertia(fn (Assert $page) => $page
->component('settings/security')
->where('canManageTwoFactor', true)
->where('twoFactorEnabled', false),
);
}
public function test_security_page_requires_password_confirmation_when_enabled()
{
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
$user = User::factory()->create();
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$response = $this->actingAs($user)
->get(route('security.edit'));
$response->assertRedirect(route('password.confirm'));
}
public function test_security_page_does_not_require_password_confirmation_when_disabled()
{
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
$user = User::factory()->create();
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => false,
]);
$this->actingAs($user)
->get(route('security.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/security'),
);
}
public function test_security_page_renders_without_two_factor_when_feature_is_disabled()
{
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
config(['fortify.features' => []]);
$user = User::factory()->create();
$this->actingAs($user)
->get(route('security.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/security')
->where('canManageTwoFactor', false)
->missing('twoFactorEnabled')
->missing('requiresConfirmation'),
);
}
public function test_password_can_be_updated()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('security.edit'));
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
}
public function test_correct_password_must_be_provided_to_update_password()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasErrors('current_password')
->assertRedirect(route('security.edit'));
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Tests\Feature;
use App\Models\Broadcast;
use App\Models\Channel;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ViewerInteractionTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_follow_channel(): void
{
$viewer = User::factory()->create();
$channel = Channel::factory()->create(['slug' => 'demo']);
$this->actingAs($viewer)
->post(route('channels.follow', $channel))
->assertRedirect();
$this->assertDatabaseHas('follows', [
'channel_id' => $channel->id,
'user_id' => $viewer->id,
]);
}
public function test_authenticated_user_can_chat_while_channel_is_live(): void
{
$viewer = User::factory()->create();
$channel = Channel::factory()->create(['slug' => 'demo', 'is_live' => true]);
$broadcast = Broadcast::factory()->for($channel)->live()->create();
$channel->forceFill(['live_broadcast_id' => $broadcast->id])->save();
$this->actingAs($viewer)
->post(route('channels.chat.store', $channel), [
'body' => 'Hello chat',
])
->assertRedirect();
$this->assertDatabaseHas('chat_messages', [
'channel_id' => $channel->id,
'broadcast_id' => $broadcast->id,
'user_id' => $viewer->id,
'body' => 'Hello chat',
]);
}
public function test_anonymous_users_can_watch_channel_page_but_not_chat(): void
{
$channel = Channel::factory()->create(['slug' => 'demo']);
$this->get(route('channels.show', $channel))->assertOk();
$this->post(route('channels.chat.store', $channel), [
'body' => 'No auth',
])->assertRedirect(route('login'));
}
}

23
tests/TestCase.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Laravel\Fortify\Features;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
protected function skipUnlessFortifyHas(string $feature, ?string $message = null): void
{
if (! Features::enabled($feature)) {
$this->markTestSkipped($message ?? "Fortify feature [{$feature}] is not enabled.");
}
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use RefreshDatabase;
public function test_that_true_is_true()
{
$this->assertTrue(true);
}
}