start project
This commit is contained in:
24
README.md
Normal file
24
README.md
Normal 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`.
|
||||||
58
app/Http/Controllers/AdminDashboardController.php
Normal file
58
app/Http/Controllers/AdminDashboardController.php
Normal 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,
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/Http/Controllers/AdminModerationController.php
Normal file
89
app/Http/Controllers/AdminModerationController.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Http/Controllers/ChannelController.php
Normal file
87
app/Http/Controllers/ChannelController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Controllers/ChatMessageController.php
Normal file
28
app/Http/Controllers/ChatMessageController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
182
app/Http/Controllers/CreatorDashboardController.php
Normal file
182
app/Http/Controllers/CreatorDashboardController.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Controllers/FollowController.php
Normal file
28
app/Http/Controllers/FollowController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Http/Controllers/HomeController.php
Normal file
67
app/Http/Controllers/HomeController.php
Normal 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,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/Http/Controllers/MediaMtxController.php
Normal file
64
app/Http/Controllers/MediaMtxController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
17
app/Http/Middleware/EnsureUserIsAdmin.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Models/AdminAuditLog.php
Normal file
26
app/Models/AdminAuditLog.php
Normal 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
54
app/Models/Broadcast.php
Normal 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
21
app/Models/Category.php
Normal 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
89
app/Models/Channel.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Models/ChatMessage.php
Normal file
29
app/Models/ChatMessage.php
Normal 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
21
app/Models/Follow.php
Normal 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
24
app/Models/StreamKey.php
Normal 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
42
app/Models/Vod.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
207
app/Services/Streaming/MediaMtxService.php
Normal file
207
app/Services/Streaming/MediaMtxService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Services/Streaming/StreamKeyManager.php
Normal file
35
app/Services/Streaming/StreamKeyManager.php
Normal 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
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sqlite*
|
||||||
29
database/factories/BroadcastFactory.php
Normal file
29
database/factories/BroadcastFactory.php
Normal 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
database/factories/CategoryFactory.php
Normal file
20
database/factories/CategoryFactory.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
database/factories/ChannelFactory.php
Normal file
26
database/factories/ChannelFactory.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
38
database/seeders/DatabaseSeeder.php
Normal file
38
database/seeders/DatabaseSeeder.php
Normal 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
31
deploy/mediamtx.yml
Normal 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
|
||||||
31
deploy/mediamtx.yml.example
Normal file
31
deploy/mediamtx.yml.example
Normal 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
|
||||||
198
resources/js/pages/admin/dashboard.tsx
Normal file
198
resources/js/pages/admin/dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
resources/js/pages/channels/show.tsx
Normal file
264
resources/js/pages/channels/show.tsx
Normal 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: '/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
541
resources/js/pages/dashboard.tsx
Normal file
541
resources/js/pages/dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
tests/Feature/Auth/AuthenticationTest.php
Normal file
92
tests/Feature/Auth/AuthenticationTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
119
tests/Feature/Auth/EmailVerificationTest.php
Normal file
119
tests/Feature/Auth/EmailVerificationTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
33
tests/Feature/Auth/PasswordConfirmationTest.php
Normal file
33
tests/Feature/Auth/PasswordConfirmationTest.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
95
tests/Feature/Auth/PasswordResetTest.php
Normal file
95
tests/Feature/Auth/PasswordResetTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
39
tests/Feature/Auth/RegistrationTest.php
Normal file
39
tests/Feature/Auth/RegistrationTest.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
49
tests/Feature/Auth/TwoFactorChallengeTest.php
Normal file
49
tests/Feature/Auth/TwoFactorChallengeTest.php
Normal 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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
tests/Feature/Auth/VerificationNotificationTest.php
Normal file
48
tests/Feature/Auth/VerificationNotificationTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
54
tests/Feature/CreatorChannelTest.php
Normal file
54
tests/Feature/CreatorChannelTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
tests/Feature/DashboardTest.php
Normal file
27
tests/Feature/DashboardTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
tests/Feature/ExampleTest.php
Normal file
18
tests/Feature/ExampleTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
99
tests/Feature/MediaMtxIntegrationTest.php
Normal file
99
tests/Feature/MediaMtxIntegrationTest.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
tests/Feature/Settings/ProfileUpdateTest.php
Normal file
99
tests/Feature/Settings/ProfileUpdateTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
129
tests/Feature/Settings/SecurityTest.php
Normal file
129
tests/Feature/Settings/SecurityTest.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
61
tests/Feature/ViewerInteractionTest.php
Normal file
61
tests/Feature/ViewerInteractionTest.php
Normal 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
23
tests/TestCase.php
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tests/Unit/ExampleTest.php
Normal file
16
tests/Unit/ExampleTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user