Jitsi (docker-jitsi-meet, version figée) derrière Nginx sur meet.syllabuslab.fr, accès par jeton JWT signé par le portail, modération du micro, mains levées synchronisées avec l'application. Pare-feu : UDP 10000 et pont interne br-visio (accords d'Adam). Jicofo n'authentifie plus lui-même : il donnait le rôle de propriétaire aux élèves. Capacité ramenée à 20 VM. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
230 lines
7.7 KiB
PHP
230 lines
7.7 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\Eleve;
|
|
use App\Models\Seance;
|
|
use App\Services\Visio\JetonVisio;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\View\View;
|
|
|
|
/**
|
|
* Visio de classe (étape 15). Jitsi porte le son et l'image ; le portail
|
|
* décide qui entre (jeton JWT par personne, salle imprévisible), et tient
|
|
* l'état de la classe : mains levées (premier arrivé en haut), salle
|
|
* d'attente, chat. Cet état vit dans le cache (Redis), le temps de la visio.
|
|
*/
|
|
class VisioController extends Controller
|
|
{
|
|
private const DUREE_ETAT = 6 * 3600;
|
|
|
|
// Un élève est « en salle d'attente » s'il a donné signe de vie depuis.
|
|
private const PRESENCE_SECONDES = 15;
|
|
|
|
public function __construct(private readonly JetonVisio $jetons) {}
|
|
|
|
// ---- Prof -------------------------------------------------------------
|
|
|
|
public function prof(Seance $seance): View
|
|
{
|
|
$prof = $this->verifierProf($seance);
|
|
|
|
return view('seances.visio-prof', [
|
|
'seance' => $seance->load(['classe', 'lab']),
|
|
'jeton' => $seance->visioOuverte() ? $this->jetons->pour($seance, 'prof-'.$prof->id, $prof->name, true) : null,
|
|
]);
|
|
}
|
|
|
|
public function ouvrir(Seance $seance): RedirectResponse
|
|
{
|
|
$this->verifierProf($seance);
|
|
abort_if($seance->classe->estArchivee() || $seance->statut === Seance::TERMINEE, 409);
|
|
|
|
if (! $seance->visioOuverte()) {
|
|
$seance->update([
|
|
'visio_ouverte_a' => now(),
|
|
'visio_salle' => 'syllabus-'.$seance->id.'-'.Str::lower(Str::random(16)),
|
|
'visio_chat' => false,
|
|
]);
|
|
}
|
|
|
|
return redirect()->route('seances.visio.prof', $seance);
|
|
}
|
|
|
|
public function fermer(Seance $seance): RedirectResponse
|
|
{
|
|
$this->verifierProf($seance);
|
|
$seance->update(['visio_ouverte_a' => null, 'visio_salle' => null, 'visio_chat' => false]);
|
|
Cache::forget($this->cle($seance));
|
|
|
|
return redirect()->route('seances.attente', $seance);
|
|
}
|
|
|
|
public function etatProf(Seance $seance): JsonResponse
|
|
{
|
|
$this->verifierProf($seance);
|
|
$etat = $this->etat($seance);
|
|
$limite = time() - self::PRESENCE_SECONDES;
|
|
|
|
$mains = collect($etat['mains'])->sortBy('depuis')->map(fn ($m, $id) => $m + [
|
|
'eleve_id' => $id,
|
|
'participant_id' => $etat['participants'][$id] ?? null,
|
|
])->values();
|
|
$attente = collect($etat['presences'])
|
|
->filter(fn ($p, $id) => $p['vu'] >= $limite && ! isset($etat['participants'][$id]))
|
|
->map(fn ($p) => ['nom' => $p['nom']])->values();
|
|
|
|
return response()->json([
|
|
'ouverte' => $seance->visioOuverte(),
|
|
'chat' => $seance->visio_chat,
|
|
'mains' => $mains,
|
|
'attente' => $attente,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Le prof a donné la parole dans Jitsi (askToUnmute, qui autorise le
|
|
* micro quand la modération est active) : la main est baissée.
|
|
*/
|
|
public function parole(Seance $seance, Eleve $eleve): JsonResponse
|
|
{
|
|
$this->verifierProf($seance);
|
|
abort_if($eleve->classe_id !== $seance->classe_id, 404);
|
|
$this->modifier($seance, function (array $etat) use ($eleve) {
|
|
unset($etat['mains'][$eleve->id]);
|
|
|
|
return $etat;
|
|
});
|
|
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
|
|
public function chat(Request $request, Seance $seance): JsonResponse
|
|
{
|
|
$this->verifierProf($seance);
|
|
$seance->update(['visio_chat' => $request->boolean('actif')]);
|
|
|
|
return response()->json(['chat' => $seance->visio_chat]);
|
|
}
|
|
|
|
// ---- Élève ------------------------------------------------------------
|
|
|
|
public function eleve(Seance $seance): View
|
|
{
|
|
$eleve = $this->verifierEleve($seance);
|
|
|
|
return view('seances.visio-eleve', [
|
|
'seance' => $seance->load(['classe', 'lab']),
|
|
'eleve' => $eleve,
|
|
'jeton' => $seance->visioOuverte() ? $this->jetons->pour($seance, 'eleve-'.$eleve->id, $this->nomVisible($eleve), false) : null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Interrogé régulièrement par la page de l'élève : sert aussi de signe de
|
|
* présence (salle d'attente côté prof).
|
|
*/
|
|
public function etatEleve(Seance $seance): JsonResponse
|
|
{
|
|
$eleve = $this->verifierEleve($seance);
|
|
$etat = $this->modifier($seance, function (array $etat) use ($eleve) {
|
|
$etat['presences'][$eleve->id] = ['nom' => $this->nomVisible($eleve), 'vu' => time()];
|
|
|
|
return $etat;
|
|
});
|
|
|
|
return response()->json([
|
|
'ouverte' => $seance->visioOuverte(),
|
|
'chat' => $seance->visio_chat,
|
|
'main_levee' => isset($etat['mains'][$eleve->id]),
|
|
]);
|
|
}
|
|
|
|
public function main(Request $request, Seance $seance): JsonResponse
|
|
{
|
|
$eleve = $this->verifierEleve($seance);
|
|
$levee = $request->boolean('levee');
|
|
$this->modifier($seance, function (array $etat) use ($eleve, $levee) {
|
|
if ($levee) {
|
|
$etat['mains'][$eleve->id] ??= ['nom' => $this->nomVisible($eleve), 'depuis' => time()];
|
|
} else {
|
|
unset($etat['mains'][$eleve->id]);
|
|
}
|
|
|
|
return $etat;
|
|
});
|
|
|
|
return response()->json(['main_levee' => $levee]);
|
|
}
|
|
|
|
public function participant(Request $request, Seance $seance): JsonResponse
|
|
{
|
|
$eleve = $this->verifierEleve($seance);
|
|
$participant = $request->validate(['participant_id' => ['required', 'string', 'max:64', 'regex:/^[A-Za-z0-9_-]+$/']])['participant_id'];
|
|
$this->modifier($seance, function (array $etat) use ($eleve, $participant) {
|
|
$etat['participants'][$eleve->id] = $participant;
|
|
|
|
return $etat;
|
|
});
|
|
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
|
|
// ---- Interne ----------------------------------------------------------
|
|
|
|
private function verifierProf(Seance $seance)
|
|
{
|
|
$prof = Auth::guard('web')->user();
|
|
abort_if($seance->classe->user_id !== $prof->id, 403);
|
|
|
|
return $prof;
|
|
}
|
|
|
|
private function verifierEleve(Seance $seance): Eleve
|
|
{
|
|
$eleve = Auth::guard('eleve')->user();
|
|
abort_if($eleve->classe_id !== $seance->classe_id || $seance->classe->estArchivee(), 403);
|
|
|
|
return $eleve;
|
|
}
|
|
|
|
/**
|
|
* Minimisation : visible de toute la classe, donc prénom et initiale du nom.
|
|
*/
|
|
private function nomVisible(Eleve $eleve): string
|
|
{
|
|
return $eleve->prenom.($eleve->nom ? ' '.mb_strtoupper(mb_substr($eleve->nom, 0, 1)).'.' : '');
|
|
}
|
|
|
|
private function cle(Seance $seance): string
|
|
{
|
|
return "visio:{$seance->id}";
|
|
}
|
|
|
|
/**
|
|
* @return array{mains: array<int, array{nom: string, depuis: int}>, participants: array<int, string>, presences: array<int, array{nom: string, vu: int}>}
|
|
*/
|
|
private function etat(Seance $seance): array
|
|
{
|
|
return Cache::get($this->cle($seance)) ?? ['mains' => [], 'participants' => [], 'presences' => []];
|
|
}
|
|
|
|
/**
|
|
* Lecture-modification-écriture sous verrou : deux élèves qui lèvent la
|
|
* main au même instant ne s'écrasent pas.
|
|
*/
|
|
private function modifier(Seance $seance, callable $changement): array
|
|
{
|
|
return Cache::lock($this->cle($seance).':verrou', 5)->block(3, function () use ($seance, $changement) {
|
|
$etat = $changement($this->etat($seance));
|
|
Cache::put($this->cle($seance), $etat, self::DUREE_ETAT);
|
|
|
|
return $etat;
|
|
});
|
|
}
|
|
}
|