Files
isaacclad 86fa37b9fd
Deploy Ladill Meet / deploy (push) Successful in 2m44s
Reduce Meet poll load with shared cache, ETag, and slower clients.
Heavy per-participant poll queries were saturating the shared host during
live rooms. Cache the shared room payload for 2s, return 304 when unchanged,
throttle breakout expiry and captions, and slow client polling (6s / 20s
when hidden) so concurrent attendees no longer fan out full Laravel work.
2026-07-15 19:59:52 +00:00

749 lines
30 KiB
PHP

<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Models\Participant;
use App\Models\Recording;
use App\Models\Room;
use App\Models\Session;
use App\Services\Meet\BreakoutService;
use App\Services\Meet\ChatService;
use App\Services\Meet\Media\LiveKitEgressService;
use App\Services\Meet\Media\MediaProviderInterface;
use App\Services\Meet\ParticipantPresenter;
use App\Services\Meet\RecordingService;
use App\Services\Meet\SecurityPolicyService;
use App\Services\Meet\SessionService;
use App\Services\Meet\SpaceService;
use App\Services\Meet\StageLayoutService;
use App\Services\Meet\TownHallService;
use App\Services\Meet\TranscriptService;
use App\Services\Meet\WebinarService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
class MeetingRoomController extends Controller
{
public function __construct(
protected SessionService $sessions,
protected ChatService $chat,
protected MediaProviderInterface $media,
protected RecordingService $recordings,
protected TranscriptService $transcripts,
protected SecurityPolicyService $security,
) {}
public function show(Request $request, Session $session): View|RedirectResponse
{
if (! $session->isLive()) {
return redirect()->route('meet.ended', $session);
}
$room = $session->room;
$kind = $room->isConference() ? 'conference' : 'meeting';
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
if (! $participantUuid && $request->user() && $request->user()->ownerRef() === $room->host_user_ref) {
$hostParticipant = $this->sessions->ensureJoinedParticipant($request, $session, $request->user(), 'host');
$participantUuid = $hostParticipant->uuid;
}
abort_unless($participantUuid, 403, "Please join the {$kind} first.");
$participant = Participant::where('uuid', $participantUuid)
->where('session_id', $session->id)
->firstOrFail();
if ($participant->status === 'waiting') {
return redirect()->route('meet.join.waiting', $session->room);
}
abort_unless($participant->status === 'joined', 403, "You are not in this {$kind}.");
$participant = $this->sessions->syncEventsSpeakerRole($request, $participant);
$token = $this->media->isConfigured()
? $this->sessions->generateMediaToken($participant)
: '';
$session->load(['room', 'participants']);
$room = $session->room;
$webinars = app(WebinarService::class);
$spaces = app(SpaceService::class);
$isAudioOnly = $room->isAudioOnly();
$stageLayout = app(StageLayoutService::class);
$authUser = $request->user();
$isRoomHost = $authUser && $authUser->ownerRef() === $room->host_user_ref;
$canManageConference = $participant->isHost() || $isRoomHost;
$canManageSpeakAccess = app(\App\Services\Meet\SpeakAccessService::class)->canManageSpeakAccess($participant);
$usesSpeakAccess = $room->isWebinar() || $room->isConference();
$mediaState = $this->sessions->mediaSessionState($participant);
$breakoutService = app(BreakoutService::class);
$inBreakout = $breakoutService->isInActiveBreakout($participant);
return view('meet.room.show', [
'session' => $session,
'room' => $room,
'participant' => $participant,
'mediaToken' => $token,
'mediaUrl' => $this->media->serverUrl(),
'mediaConfigured' => $this->media->isConfigured(),
'messages' => $this->chat->recentMessages($session),
'activeRecording' => $session->recordings()->where('status', 'recording')->first(),
'serverEgressAvailable' => app(LiveKitEgressService::class)->isAvailable(),
'watermark' => $room->organization?->securitySetting('watermark_enabled', false),
'isWebinar' => $room->isWebinar() || $room->isConference(),
'isConference' => $room->isConference(),
'isGreenRoom' => $session->session_mode === 'green_room',
'sessionMode' => $session->session_mode,
'presenterRefs' => (array) $session->presenter_refs,
'isAudioOnly' => $isAudioOnly,
'isSpace' => $room->isSpace(),
'canPublish' => $mediaState['can_publish'],
'audioOnlyPublish' => $mediaState['audio_only_publish'],
'speakGranted' => $mediaState['speak_granted'],
'speakRequested' => $mediaState['speak_requested'],
'usesSpeakAccess' => $usesSpeakAccess,
'canManageSpeakAccess' => $canManageSpeakAccess,
'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant),
'usesStageLayout' => $stageLayout->usesStageLayout($room) && ! $inBreakout,
'inBreakout' => $inBreakout,
'stageConfig' => $stageLayout->config($room),
'canManageConference' => $canManageConference,
'programmeSnapshot' => $room->setting('programme_snapshot'),
'linkedProgrammeItems' => (array) $room->setting('linked_programme_items', []),
]);
}
public function end(Request $request, Session $session): RedirectResponse
{
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
$participant = Participant::where('uuid', $participantUuid)->first();
abort_unless($participant?->isHost(), 403);
$this->sessions->end($session, $participant->user_ref ?? $participant->uuid);
$room = $session->room;
return redirect()->route($this->roomShowRoute($room), $room)
->with('success', $this->roomEndedMessage($room));
}
protected function roomShowRoute(Room $room): string
{
if ($room->isWebinar()) {
return 'meet.webinars.show';
}
if ($room->isConference()) {
return 'meet.conferences.show';
}
if ($room->isSpace()) {
return 'meet.spaces.show';
}
return 'meet.rooms.show';
}
protected function roomEndedMessage(Room $room): string
{
if ($room->isWebinar()) {
return 'Webinar ended.';
}
if ($room->isConference()) {
return 'Conference ended.';
}
if ($room->isSpace()) {
return 'Room closed.';
}
return 'Meeting ended.';
}
public function leave(Request $request, Session $session): RedirectResponse
{
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
$participant = Participant::where('uuid', $participantUuid)->first();
if ($participant) {
$this->sessions->leaveParticipant($participant);
$request->session()->put('meet.left.feedback', [
'session_uuid' => $session->uuid,
'participant_uuid' => $participant->uuid,
'display_name' => $participant->display_name,
'user_ref' => $participant->user_ref,
]);
}
$request->session()->forget("meet.participant.{$session->uuid}");
return redirect()->route('meet.left', $session);
}
public function raiseHand(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$participant->update(['hand_raised' => ! $participant->hand_raised]);
return response()->json(['hand_raised' => $participant->hand_raised]);
}
public function requestSpeak(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->requestSpeak($participant),
]);
}
public function cancelSpeakRequest(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->cancelSpeakRequest($participant),
]);
}
public function grantSpeak(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->grantSpeak($participant, $actor),
]);
}
public function revokeSpeak(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->revokeSpeak($participant, $actor),
]);
}
public function dismissSpeakRequest(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->dismissSpeakRequest($participant, $actor),
]);
}
public function inviteSpaceSpeaker(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
abort_unless($session->room->isSpace(), 404);
abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403);
$updated = app(SpaceService::class)->inviteToSpeak($session->room, $participant);
$avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref);
return response()->json([
'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref),
]);
}
public function acceptSpaceSpeakInvitation(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($session->room->isSpace(), 404);
$updated = app(SpaceService::class)->acceptSpeakInvitation($session->room, $participant);
$avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref);
return response()->json([
'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref),
]);
}
public function declineSpaceSpeakInvitation(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($session->room->isSpace(), 404);
$updated = app(SpaceService::class)->declineSpeakInvitation($session->room, $participant);
$avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref);
return response()->json([
'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref),
]);
}
public function dismissSpaceSpeakInvitation(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
abort_unless($session->room->isSpace(), 404);
abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403);
$updated = app(SpaceService::class)->dismissSpeakInvitation($session->room, $participant);
$avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref);
return response()->json([
'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref),
]);
}
public function promoteSpaceCoHost(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
abort_unless($session->room->isSpace(), 404);
abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403);
$updated = app(SpaceService::class)->promoteToCoHost($session->room, $participant);
$avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref);
return response()->json([
'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref),
]);
}
public function demoteSpaceSpeaker(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
abort_unless($session->room->isSpace(), 404);
abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403);
$updated = app(SpaceService::class)->demoteFromSpeaker($session->room, $participant);
$avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref);
return response()->json([
'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref),
]);
}
public function sendMessage(Request $request, Session $session): JsonResponse
{
abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'chat'), 422);
$participant = $this->currentParticipant($request, $session);
$validated = $request->validate(['body' => ['required', 'string', 'max:2000']]);
$message = $this->chat->sendPublicMessage(
$session,
$participant->display_name,
$validated['body'],
$participant->user_ref,
);
return response()->json([
'message' => [
'uuid' => $message->uuid,
'sender_name' => $message->sender_name,
'body' => $message->body,
'created_at' => $message->created_at->toIso8601String(),
],
]);
}
public function sendReaction(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$validated = $request->validate(['emoji' => ['required', 'string', 'max:32']]);
$reaction = $this->chat->sendReaction(
$session,
$participant->display_name,
$validated['emoji'],
$participant->uuid,
);
return response()->json([
'reaction' => [
'sender_name' => $reaction->sender_name,
'emoji' => $reaction->emoji,
'created_at' => $reaction->created_at->toIso8601String(),
],
]);
}
/**
* Room state for clients. Shared payload is cached briefly so concurrent
* pollers (every participant every few seconds) do not each re-run the full
* query fan-out. Per-participant media is still computed fresh.
*/
public function poll(Request $request, Session $session): JsonResponse|Response
{
if (! $session->isLive()) {
$townHall = app(TownHallService::class);
$liveSession = $townHall->successorLiveSession($session);
if ($liveSession) {
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
if ($participantUuid) {
$greenParticipant = Participant::where('uuid', $participantUuid)
->where('session_id', $session->id)
->first();
if ($greenParticipant && $this->sessions->transitionGreenRoomParticipant($request, $greenParticipant, $liveSession)) {
return response()->json([
'ended' => true,
'redirect' => route('meet.room', $liveSession),
'transition' => 'go_live',
]);
}
}
}
return response()->json([
'ended' => true,
'redirect' => route('meet.ended', $session),
]);
}
$participant = $this->currentParticipant($request, $session);
// Breakout expiry is not latency-critical; run at most once per 30s/session.
if (Cache::add("meet:breakout-close:{$session->id}", 1, 30)) {
app(BreakoutService::class)->closeExpired($session);
}
$participant->refresh();
$shared = $this->sharedPollPayload($session);
$media = $this->sessions->mediaSessionState($participant);
$etag = '"'.md5(($shared['_etag'] ?? '').'|'.$participant->id.'|'.json_encode($media)).'"';
if ($request->headers->get('If-None-Match') === $etag) {
return response('', 304)->withHeaders([
'ETag' => $etag,
'Cache-Control' => 'private, no-cache',
]);
}
unset($shared['_etag']);
return response()
->json(array_merge($shared, ['media' => $media]))
->withHeaders([
'ETag' => $etag,
'Cache-Control' => 'private, no-cache',
]);
}
/**
* Session-wide poll fields shared by every participant (2s TTL).
*
* @return array<string, mixed>
*/
protected function sharedPollPayload(Session $session): array
{
$cacheKey = "meet:poll:shared:{$session->id}";
return Cache::remember($cacheKey, 2, function () use ($session) {
// Ensure room relation is available without re-querying when already loaded.
$session->loadMissing('room');
$session->load(['participants' => fn ($q) => $q->whereIn('status', ['joined', 'waiting'])]);
$hostRef = $session->room->host_user_ref;
$avatars = ParticipantPresenter::avatarMap($session->participants, $hostRef);
$messages = $this->chat->recentMessages($session, 30);
$reactions = $session->reactions()
->where('created_at', '>=', now()->subMinutes(2))
->latest()
->limit(15)
->get();
$qa = app(\App\Services\Meet\QaService::class)->forSession($session, true);
$polls = $session->polls()->where('status', 'open')->with('votes')->get();
$breakouts = $session->breakoutRooms()->where('status', 'open')->get();
$broadcasts = $session->messages()
->where('type', 'breakout_broadcast')
->where('created_at', '>=', now()->subMinutes(30))
->latest()
->limit(5)
->get();
$files = $session->sessionFiles()
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->latest()
->limit(20)
->get();
$payload = array_merge([
'participants' => $session->participants->map(
fn ($p) => ParticipantPresenter::toArray($p, $avatars, $hostRef)
)->values()->all(),
'waiting_count' => $session->participants->where('status', 'waiting')->count(),
'messages' => $messages->map(fn ($m) => [
'uuid' => $m->uuid,
'sender_name' => $m->sender_name,
'body' => $m->body,
'type' => $m->type,
'created_at' => $m->created_at->toIso8601String(),
])->values()->all(),
'reactions' => $reactions->map(fn ($r) => [
'sender_name' => $r->sender_name,
'emoji' => $r->emoji,
'created_at' => $r->created_at->toIso8601String(),
])->values()->all(),
'qa' => $qa->map(fn ($q) => [
'uuid' => $q->uuid,
'asker_name' => $q->asker_name,
'question' => $q->question,
'status' => $q->status,
'answer' => $q->answer,
'upvotes' => $q->upvotes,
])->values()->all(),
'polls' => $polls->map(fn ($p) => [
'uuid' => $p->uuid,
'question' => $p->question,
'options' => $p->options,
'votes' => $p->votes->count(),
])->values()->all(),
'breakouts' => $breakouts->map(fn ($b) => [
'uuid' => $b->uuid,
'name' => $b->name,
'closes_at' => $b->closes_at?->toIso8601String(),
])->values()->all(),
'broadcasts' => $broadcasts->map(fn ($m) => [
'body' => $m->body,
'sender_name' => $m->sender_name,
'created_at' => $m->created_at->toIso8601String(),
])->values()->all(),
'files' => $files->map(fn ($f) => [
'uuid' => $f->uuid,
'original_name' => $f->original_name,
'file_size' => $f->file_size,
])->values()->all(),
'breakouts_active' => $breakouts->isNotEmpty(),
], app(StageLayoutService::class)->config($session->room));
$payload['_etag'] = md5(json_encode($payload));
return $payload;
});
}
public function mediaToken(Request $request, Session $session): JsonResponse
{
abort_unless($session->isLive(), 404);
$participant = $this->currentParticipant($request, $session);
return response()->json(array_merge(
['token' => $this->sessions->generateMediaToken($participant)],
$this->sessions->mediaSessionState($participant->fresh()),
['breakouts_active' => app(BreakoutService::class)->hasActiveBreakouts($session)],
));
}
public function startRecording(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($participant->isHost(), 403);
abort_if($session->room->organization?->securitySetting('recording_host_only') && ! $participant->isHost(), 403);
$host = $request->user() ?? \App\Models\User::where('public_id', $session->room->host_user_ref)->first();
abort_unless($host, 403);
$recording = $this->recordings->start($session, $host);
return response()->json([
'recording_uuid' => $recording->uuid,
'status' => $recording->status,
'capture_mode' => $this->recordings->captureMode($recording),
]);
}
public function stopRecording(Request $request, Session $session): JsonResponse
{
$participant = $this->recordingHost($request, $session);
$recording = $session->recordings()->where('status', 'recording')->firstOrFail();
$actorRef = $participant->user_ref ?? $participant->uuid;
if ($request->boolean('failed')) {
$recording = $this->recordings->fail(
$recording,
(string) $request->input('reason', 'Recording capture did not complete.'),
$actorRef,
);
return response()->json([
'status' => 'failed',
'recording_uuid' => $recording->uuid,
]);
}
$recording = $this->recordings->stop($recording, $actorRef);
return response()->json([
'status' => 'processing',
'recording_uuid' => $recording->uuid,
'capture_mode' => $this->recordings->captureMode($recording),
]);
}
public function failRecording(Request $request, Session $session, Recording $recording): JsonResponse
{
$participant = $this->recordingHost($request, $session);
abort_unless($recording->session_id === $session->id, 404);
abort_if(! in_array($recording->status, ['recording', 'processing'], true), 422);
$recording = $this->recordings->fail(
$recording,
(string) $request->input('reason', 'Recording capture did not complete.'),
$participant->user_ref ?? $participant->uuid,
);
return response()->json([
'status' => $recording->status,
'recording_uuid' => $recording->uuid,
]);
}
public function uploadRecording(Request $request, Session $session, Recording $recording): JsonResponse
{
$participant = $this->recordingHost($request, $session);
abort_unless($recording->session_id === $session->id, 404);
$maxKb = max(1, (int) config('meet.recordings.max_mb', 512)) * 1024;
$request->validate([
'recording' => ['required', 'file', 'mimetypes:video/webm,video/mp4,video/x-matroska,video/quicktime', 'max:'.$maxKb],
]);
$this->recordings->storeUpload($recording, $request->file('recording'));
return response()->json(['status' => 'ready', 'recording_uuid' => $recording->uuid]);
}
public function lock(Request $request, Session $session): JsonResponse
{
abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'lock'), 422);
$participant = $this->currentParticipant($request, $session);
abort_unless($participant->isHost(), 403);
$this->sessions->lock($session, $participant->user_ref ?? $participant->uuid);
return response()->json(['locked' => true]);
}
public function unlock(Request $request, Session $session): JsonResponse
{
abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'lock'), 422);
$participant = $this->currentParticipant($request, $session);
abort_unless($participant->isHost(), 403);
$this->sessions->unlock($session, $participant->user_ref ?? $participant->uuid);
return response()->json(['locked' => false]);
}
public function caption(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$validated = $request->validate(['text' => ['required', 'string', 'max:500']]);
if (! $session->room->setting('live_captions', false)) {
return response()->json(['ok' => false], 422);
}
$transcript = $this->transcripts->getOrCreateForSession($session);
$this->transcripts->appendSegment($transcript, $participant->display_name, $validated['text']);
return response()->json(['ok' => true]);
}
public function admit(Request $request, Session $session, Participant $participant): JsonResponse
{
$current = $this->currentParticipant($request, $session);
abort_unless($current->isHost(), 403);
abort_unless($participant->session_id === $session->id, 404);
$this->sessions->admitParticipant($participant);
return response()->json(['ok' => true, 'status' => 'joined']);
}
public function deny(Request $request, Session $session, Participant $participant): JsonResponse
{
$current = $this->currentParticipant($request, $session);
abort_unless($current->isHost(), 403);
abort_unless($participant->session_id === $session->id, 404);
$this->sessions->denyParticipant($participant);
return response()->json(['ok' => true, 'status' => 'removed']);
}
protected function currentParticipant(Request $request, Session $session): Participant
{
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
abort_unless($participantUuid, 403);
return $this->sessions->syncEventsSpeakerRole(
$request,
Participant::where('uuid', $participantUuid)
->where('session_id', $session->id)
->firstOrFail()
);
}
/** Host/co-host in the room session, or the room host after the meeting ended. */
protected function recordingHost(Request $request, Session $session): Participant
{
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
if ($participantUuid) {
$participant = Participant::query()
->where('uuid', $participantUuid)
->where('session_id', $session->id)
->first();
if ($participant?->isHost()) {
return $this->sessions->syncEventsSpeakerRole($request, $participant);
}
}
$user = $request->user();
abort_unless($user, 403);
$hostParticipant = $session->participants()
->where('user_ref', $user->ownerRef())
->whereIn('role', ['host', 'co_host'])
->orderByRaw("case when role = 'host' then 0 else 1 end")
->first();
abort_unless($hostParticipant, 403);
return $hostParticipant;
}
}