diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0db60f1
--- /dev/null
+++ b/README.md
@@ -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: `?token=`
+
+Viewer playback uses `STREAMING_HLS_PUBLIC_URL//index.m3u8`.
diff --git a/app/Http/Controllers/AdminDashboardController.php b/app/Http/Controllers/AdminDashboardController.php
new file mode 100644
index 0000000..e78d430
--- /dev/null
+++ b/app/Http/Controllers/AdminDashboardController.php
@@ -0,0 +1,58 @@
+ [
+ '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,
+ ],
+ ]),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/AdminModerationController.php b/app/Http/Controllers/AdminModerationController.php
new file mode 100644
index 0000000..a2b1887
--- /dev/null
+++ b/app/Http/Controllers/AdminModerationController.php
@@ -0,0 +1,89 @@
+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(),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/ChannelController.php b/app/Http/Controllers/ChannelController.php
new file mode 100644
index 0000000..7f54d6a
--- /dev/null
+++ b/app/Http/Controllers/ChannelController.php
@@ -0,0 +1,87 @@
+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,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/ChatMessageController.php b/app/Http/Controllers/ChatMessageController.php
new file mode 100644
index 0000000..0995946
--- /dev/null
+++ b/app/Http/Controllers/ChatMessageController.php
@@ -0,0 +1,28 @@
+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();
+ }
+}
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
new file mode 100644
index 0000000..8677cd5
--- /dev/null
+++ b/app/Http/Controllers/Controller.php
@@ -0,0 +1,8 @@
+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,
+ ];
+ }
+}
diff --git a/app/Http/Controllers/FollowController.php b/app/Http/Controllers/FollowController.php
new file mode 100644
index 0000000..cb14e1b
--- /dev/null
+++ b/app/Http/Controllers/FollowController.php
@@ -0,0 +1,28 @@
+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();
+ }
+}
diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php
new file mode 100644
index 0000000..c0029c9
--- /dev/null
+++ b/app/Http/Controllers/HomeController.php
@@ -0,0 +1,67 @@
+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,
+ ],
+ ];
+ }
+}
diff --git a/app/Http/Controllers/MediaMtxController.php b/app/Http/Controllers/MediaMtxController.php
new file mode 100644
index 0000000..c090741
--- /dev/null
+++ b/app/Http/Controllers/MediaMtxController.php
@@ -0,0 +1,64 @@
+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);
+ }
+}
diff --git a/app/Http/Middleware/EnsureUserIsAdmin.php b/app/Http/Middleware/EnsureUserIsAdmin.php
new file mode 100644
index 0000000..7b062a7
--- /dev/null
+++ b/app/Http/Middleware/EnsureUserIsAdmin.php
@@ -0,0 +1,17 @@
+user()?->is_admin, 403);
+
+ return $next($request);
+ }
+}
diff --git a/app/Models/AdminAuditLog.php b/app/Models/AdminAuditLog.php
new file mode 100644
index 0000000..99eff3e
--- /dev/null
+++ b/app/Models/AdminAuditLog.php
@@ -0,0 +1,26 @@
+ 'array',
+ 'created_at' => 'datetime',
+ ];
+ }
+
+ public function admin(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'admin_user_id');
+ }
+}
diff --git a/app/Models/Broadcast.php b/app/Models/Broadcast.php
new file mode 100644
index 0000000..e7f7bed
--- /dev/null
+++ b/app/Models/Broadcast.php
@@ -0,0 +1,54 @@
+ */
+ 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);
+ }
+}
diff --git a/app/Models/Category.php b/app/Models/Category.php
new file mode 100644
index 0000000..526d0d3
--- /dev/null
+++ b/app/Models/Category.php
@@ -0,0 +1,21 @@
+ */
+ use HasFactory;
+
+ public function channels(): HasMany
+ {
+ return $this->hasMany(Channel::class);
+ }
+}
diff --git a/app/Models/Channel.php b/app/Models/Channel.php
new file mode 100644
index 0000000..f035cb6
--- /dev/null
+++ b/app/Models/Channel.php
@@ -0,0 +1,89 @@
+ */
+ 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;
+ }
+}
diff --git a/app/Models/ChatMessage.php b/app/Models/ChatMessage.php
new file mode 100644
index 0000000..24f0c11
--- /dev/null
+++ b/app/Models/ChatMessage.php
@@ -0,0 +1,29 @@
+belongsTo(Channel::class);
+ }
+
+ public function broadcast(): BelongsTo
+ {
+ return $this->belongsTo(Broadcast::class);
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/app/Models/Follow.php b/app/Models/Follow.php
new file mode 100644
index 0000000..cc3b0d2
--- /dev/null
+++ b/app/Models/Follow.php
@@ -0,0 +1,21 @@
+belongsTo(Channel::class);
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/app/Models/StreamKey.php b/app/Models/StreamKey.php
new file mode 100644
index 0000000..6cc6ff7
--- /dev/null
+++ b/app/Models/StreamKey.php
@@ -0,0 +1,24 @@
+ 'datetime',
+ 'revoked_at' => 'datetime',
+ ];
+ }
+
+ public function channel(): BelongsTo
+ {
+ return $this->belongsTo(Channel::class);
+ }
+}
diff --git a/app/Models/Vod.php b/app/Models/Vod.php
new file mode 100644
index 0000000..18d9e5b
--- /dev/null
+++ b/app/Models/Vod.php
@@ -0,0 +1,42 @@
+ 'datetime',
+ 'expires_at' => 'datetime',
+ ];
+ }
+
+ public function broadcast(): BelongsTo
+ {
+ return $this->belongsTo(Broadcast::class);
+ }
+
+ public function channel(): BelongsTo
+ {
+ return $this->belongsTo(Channel::class);
+ }
+}
diff --git a/app/Services/Streaming/MediaMtxService.php b/app/Services/Streaming/MediaMtxService.php
new file mode 100644
index 0000000..17b881e
--- /dev/null
+++ b/app/Services/Streaming/MediaMtxService.php
@@ -0,0 +1,207 @@
+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);
+ }
+}
diff --git a/app/Services/Streaming/StreamKeyManager.php b/app/Services/Streaming/StreamKeyManager.php
new file mode 100644
index 0000000..a3b7ba7
--- /dev/null
+++ b/app/Services/Streaming/StreamKeyManager.php
@@ -0,0 +1,35 @@
+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'), '/');
+ }
+}
diff --git a/database/.gitignore b/database/.gitignore
new file mode 100644
index 0000000..9b19b93
--- /dev/null
+++ b/database/.gitignore
@@ -0,0 +1 @@
+*.sqlite*
diff --git a/database/factories/BroadcastFactory.php b/database/factories/BroadcastFactory.php
new file mode 100644
index 0000000..6079ea7
--- /dev/null
+++ b/database/factories/BroadcastFactory.php
@@ -0,0 +1,29 @@
+ 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',
+ ]);
+ }
+}
diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php
new file mode 100644
index 0000000..9e07238
--- /dev/null
+++ b/database/factories/CategoryFactory.php
@@ -0,0 +1,20 @@
+unique()->words(2, true);
+
+ return [
+ 'name' => Str::headline($name),
+ 'slug' => Str::slug($name),
+ 'description' => fake()->optional()->sentence(),
+ ];
+ }
+}
diff --git a/database/factories/ChannelFactory.php b/database/factories/ChannelFactory.php
new file mode 100644
index 0000000..bc79715
--- /dev/null
+++ b/database/factories/ChannelFactory.php
@@ -0,0 +1,26 @@
+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,
+ ];
+ }
+}
diff --git a/database/migrations/2026_05_15_000000_add_platform_columns_to_users_table.php b/database/migrations/2026_05_15_000000_add_platform_columns_to_users_table.php
new file mode 100644
index 0000000..a73c1ab
--- /dev/null
+++ b/database/migrations/2026_05_15_000000_add_platform_columns_to_users_table.php
@@ -0,0 +1,23 @@
+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']);
+ });
+ }
+};
diff --git a/database/migrations/2026_05_15_000001_create_streaming_tables.php b/database/migrations/2026_05_15_000001_create_streaming_tables.php
new file mode 100644
index 0000000..93d6d46
--- /dev/null
+++ b/database/migrations/2026_05_15_000001_create_streaming_tables.php
@@ -0,0 +1,119 @@
+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');
+ }
+};
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
new file mode 100644
index 0000000..3010971
--- /dev/null
+++ b/database/seeders/DatabaseSeeder.php
@@ -0,0 +1,38 @@
+ '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,
+ ]);
+ }
+}
diff --git a/deploy/mediamtx.yml b/deploy/mediamtx.yml
new file mode 100644
index 0000000..f3df484
--- /dev/null
+++ b/deploy/mediamtx.yml
@@ -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
diff --git a/deploy/mediamtx.yml.example b/deploy/mediamtx.yml.example
new file mode 100644
index 0000000..f3df484
--- /dev/null
+++ b/deploy/mediamtx.yml.example
@@ -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
diff --git a/resources/js/pages/admin/dashboard.tsx b/resources/js/pages/admin/dashboard.tsx
new file mode 100644
index 0000000..39122cc
--- /dev/null
+++ b/resources/js/pages/admin/dashboard.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+
+
Admin
+
+ Moderate live streams, channels, users, and
+ recordings.
+
+
+
+
+
+
+
+
+
+
Live broadcasts
+
+
+ {liveBroadcasts.map((broadcast) => (
+
+
+
+ {broadcast.title}
+
+
+ {broadcast.channel.display_name} by{' '}
+ {broadcast.channel.owner}
+
+
+
+
+
+
+
+ ))}
+ {liveBroadcasts.length === 0 && (
+
+ No channels are live.
+
+ )}
+
+
+
+
+ Recent channels
+
+ {channels.map((channel) => (
+
+
+
+ {channel.display_name}
+
+
+ @{channel.slug} owned by{' '}
+ {channel.owner.name}
+
+
+
+
+ {channel.suspended_at ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+
+ >
+ );
+}
+
+AdminDashboard.layout = {
+ breadcrumbs: [
+ {
+ title: 'Admin',
+ href: '/admin',
+ },
+ ],
+};
+
+function Stat({ label, value }: { label: string; value: number }) {
+ return (
+
+ );
+}
diff --git a/resources/js/pages/channels/show.tsx b/resources/js/pages/channels/show.tsx
new file mode 100644
index 0000000..6b897e3
--- /dev/null
+++ b/resources/js/pages/channels/show.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {channel.is_live ? (
+
+
+ LIVE
+
+ ) : (
+
+ Offline
+
+ )}
+ {channel.category && (
+
+ {channel.category.name}
+
+ )}
+
+
+ {currentBroadcast?.title ??
+ channel.display_name}
+
+
+ {channel.display_name}
+
+
+ {channel.viewer_count} viewers
+
+
+ {channel.followers_count} followers
+
+
+ {channel.description && (
+
+ {channel.description}
+
+ )}
+
+
+ {auth.user ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
Recent VODs
+
+ {vods.length > 0 ? (
+
+ {vods.map((vod) => (
+
+
+ {vod.title}
+
+
+ Expires{' '}
+ {vod.expires_at
+ ? new Date(
+ vod.expires_at,
+ ).toLocaleDateString()
+ : 'later'}
+
+ {vod.playback_url ? (
+
+ ) : (
+
+ Processing recording
+
+ )}
+
+ ))}
+
+ ) : (
+
+ No public recordings yet.
+
+ )}
+
+
+
+
+
+
+ >
+ );
+}
+
+ChannelShow.layout = {
+ breadcrumbs: [
+ {
+ title: 'Channel',
+ href: '/',
+ },
+ ],
+};
diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx
new file mode 100644
index 0000000..86c1070
--- /dev/null
+++ b/resources/js/pages/dashboard.tsx
@@ -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 (
+ <>
+
+
+
+
+
+ Creator Studio
+
+
+ Manage your channel, stream key, and OBS broadcast
+ sessions.
+
+
+ {channel && (
+
+ )}
+
+
+ {!channel ? (
+
+
+
+ Create your channel
+
+
+ Each account can own one channel in this
+ version.
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ Channel details
+
+
+ Public metadata used in the
+ directory and channel page.
+
+
+
+ {channel.is_live
+ ? `${channel.viewer_count} viewers`
+ : 'Offline'}
+
+
+
+
+
+
+
+
+
+ Recent broadcasts
+
+
+ {recentBroadcasts.map((broadcast) => (
+
+
+
+ {broadcast.title}
+
+
+ {broadcast.status}{' '}
+ {broadcast.recording_enabled
+ ? 'with recording'
+ : 'live only'}
+
+
+ {broadcast.status !== 'ended' && (
+
+ )}
+
+ ))}
+ {recentBroadcasts.length === 0 && (
+
+ No broadcasts yet.
+
+ )}
+
+
+
+
+
+
+ )}
+
+ >
+ );
+}
+
+Dashboard.layout = {
+ breadcrumbs: [
+ {
+ title: 'Creator Studio',
+ href: '/dashboard',
+ },
+ ],
+};
+
+function Field({
+ label,
+ error,
+ children,
+}: {
+ label: string;
+ error?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function CategorySelect({
+ categories,
+ value,
+ onChange,
+}: {
+ categories: Category[];
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ return (
+
+ );
+}
+
+function CopyRow({
+ label,
+ value,
+ muted = false,
+ onCopy,
+}: {
+ label: string;
+ value: string;
+ muted?: boolean;
+ onCopy: (text: string) => Promise;
+}) {
+ return (
+
+
+ {label}
+
+
+
+ {value}
+
+
+
+
+ );
+}
diff --git a/resume b/resume
new file mode 100644
index 0000000..f6248c7
--- /dev/null
+++ b/resume
@@ -0,0 +1 @@
+codex resume 019e28d9-26f3-7ff3-b0f7-d0d833e46d7d
diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php
new file mode 100644
index 0000000..6f52e2e
--- /dev/null
+++ b/tests/Feature/Auth/AuthenticationTest.php
@@ -0,0 +1,92 @@
+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();
+ }
+}
diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php
new file mode 100644
index 0000000..8037ffc
--- /dev/null
+++ b/tests/Feature/Auth/EmailVerificationTest.php
@@ -0,0 +1,119 @@
+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());
+ }
+}
diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php
new file mode 100644
index 0000000..41eadbe
--- /dev/null
+++ b/tests/Feature/Auth/PasswordConfirmationTest.php
@@ -0,0 +1,33 @@
+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'));
+ }
+}
diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php
new file mode 100644
index 0000000..4e331e7
--- /dev/null
+++ b/tests/Feature/Auth/PasswordResetTest.php
@@ -0,0 +1,95 @@
+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');
+ }
+}
diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php
new file mode 100644
index 0000000..4aa36b8
--- /dev/null
+++ b/tests/Feature/Auth/RegistrationTest.php
@@ -0,0 +1,39 @@
+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));
+ }
+}
diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php
new file mode 100644
index 0000000..64ca5b3
--- /dev/null
+++ b/tests/Feature/Auth/TwoFactorChallengeTest.php
@@ -0,0 +1,49 @@
+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'),
+ );
+ }
+}
diff --git a/tests/Feature/Auth/VerificationNotificationTest.php b/tests/Feature/Auth/VerificationNotificationTest.php
new file mode 100644
index 0000000..8b033a7
--- /dev/null
+++ b/tests/Feature/Auth/VerificationNotificationTest.php
@@ -0,0 +1,48 @@
+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();
+ }
+}
diff --git a/tests/Feature/CreatorChannelTest.php b/tests/Feature/CreatorChannelTest.php
new file mode 100644
index 0000000..78e45d7
--- /dev/null
+++ b/tests/Feature/CreatorChannelTest.php
@@ -0,0 +1,54 @@
+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();
+ }
+}
diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php
new file mode 100644
index 0000000..10a9671
--- /dev/null
+++ b/tests/Feature/DashboardTest.php
@@ -0,0 +1,27 @@
+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();
+ }
+}
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
new file mode 100644
index 0000000..77932df
--- /dev/null
+++ b/tests/Feature/ExampleTest.php
@@ -0,0 +1,18 @@
+get(route('home'));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/MediaMtxIntegrationTest.php b/tests/Feature/MediaMtxIntegrationTest.php
new file mode 100644
index 0000000..78163aa
--- /dev/null
+++ b/tests/Feature/MediaMtxIntegrationTest.php
@@ -0,0 +1,99 @@
+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,
+ ]);
+ }
+}
diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php
new file mode 100644
index 0000000..e6c95ce
--- /dev/null
+++ b/tests/Feature/Settings/ProfileUpdateTest.php
@@ -0,0 +1,99 @@
+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());
+ }
+}
diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php
new file mode 100644
index 0000000..449cfca
--- /dev/null
+++ b/tests/Feature/Settings/SecurityTest.php
@@ -0,0 +1,129 @@
+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'));
+ }
+}
diff --git a/tests/Feature/ViewerInteractionTest.php b/tests/Feature/ViewerInteractionTest.php
new file mode 100644
index 0000000..ea09c62
--- /dev/null
+++ b/tests/Feature/ViewerInteractionTest.php
@@ -0,0 +1,61 @@
+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'));
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..530b3cb
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,23 @@
+withoutVite();
+ }
+
+ protected function skipUnlessFortifyHas(string $feature, ?string $message = null): void
+ {
+ if (! Features::enabled($feature)) {
+ $this->markTestSkipped($message ?? "Fortify feature [{$feature}] is not enabled.");
+ }
+ }
+}
diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php
new file mode 100644
index 0000000..2cc6866
--- /dev/null
+++ b/tests/Unit/ExampleTest.php
@@ -0,0 +1,16 @@
+assertTrue(true);
+ }
+}