ajoute la visio de classe : Jitsi auto-hébergé, jetons du portail, prof seul modérateur
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>
This commit is contained in:
parent
dd4f9c3c55
commit
ec4eae3744
36 changed files with 1406 additions and 18 deletions
|
|
@ -5,6 +5,20 @@ durcissement_ports_ouverts:
|
||||||
- 80
|
- 80
|
||||||
- 443
|
- 443
|
||||||
|
|
||||||
|
# Ports UDP ouverts. 10000 : média de la visio (Jitsi Videobridge, étape 15,
|
||||||
|
# accord d'Adam le 2026-10-09). Le relais TURN sur 443 pour les réseaux qui
|
||||||
|
# bloquent l'UDP reste à faire (docs/dette.md).
|
||||||
|
durcissement_ports_udp_ouverts:
|
||||||
|
- 10000
|
||||||
|
|
||||||
# Délai avant retour automatique à l'état d'avant durcissement, si la vérification
|
# Délai avant retour automatique à l'état d'avant durcissement, si la vérification
|
||||||
# post-changement échoue (voir CLAUDE.md et le prompt de déploiement, règle 4).
|
# post-changement échoue (voir CLAUDE.md et le prompt de déploiement, règle 4).
|
||||||
durcissement_delai_filet_secondes: 300
|
durcissement_delai_filet_secondes: 300
|
||||||
|
|
||||||
|
# Ponts Docker dont les conteneurs peuvent se parler entre eux. br_netfilter
|
||||||
|
# fait passer le trafic interne d'un pont par la chaîne forward (politique
|
||||||
|
# drop) : sans cette règle, les conteneurs d'un même réseau ne se joignent
|
||||||
|
# pas. Seul le trafic qui entre ET ressort par ce pont est accepté.
|
||||||
|
# br-visio : Jitsi (étape 15, accord d'Adam le 2026-10-12).
|
||||||
|
durcissement_ponts_internes:
|
||||||
|
- br-visio
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/sbin/nft -f
|
#!/usr/sbin/nft -f
|
||||||
# Géré par Ansible (rôle durcissement) — ne pas modifier à la main.
|
# Géré par Ansible (rôle durcissement) — ne pas modifier à la main.
|
||||||
# Politique : tout est refusé en entrée sauf loopback, trafic déjà établi,
|
# Politique : tout est refusé en entrée sauf loopback, trafic déjà établi,
|
||||||
# ICMP, et les ports {{ durcissement_ports_ouverts | join(', ') }}.
|
# ICMP, les ports TCP {{ durcissement_ports_ouverts | join(', ') }}{% if durcissement_ports_udp_ouverts | default([]) %} et UDP {{ durcissement_ports_udp_ouverts | join(', ') }}{% endif %}.
|
||||||
|
|
||||||
flush ruleset
|
flush ruleset
|
||||||
|
|
||||||
|
|
@ -17,10 +17,18 @@ table inet filter {
|
||||||
meta l4proto ipv6-icmp accept
|
meta l4proto ipv6-icmp accept
|
||||||
|
|
||||||
tcp dport { {{ durcissement_ports_ouverts | join(', ') }} } accept
|
tcp dport { {{ durcissement_ports_ouverts | join(', ') }} } accept
|
||||||
|
{% if durcissement_ports_udp_ouverts | default([]) %}
|
||||||
|
udp dport { {{ durcissement_ports_udp_ouverts | join(', ') }} } accept
|
||||||
|
{% endif %}
|
||||||
}
|
}
|
||||||
|
|
||||||
chain forward {
|
chain forward {
|
||||||
type filter hook forward priority 0; policy drop;
|
type filter hook forward priority 0; policy drop;
|
||||||
|
{% for pont in durcissement_ponts_internes | default([]) %}
|
||||||
|
|
||||||
|
# Trafic interne au réseau Docker {{ pont }} (conteneur ↔ conteneur).
|
||||||
|
iifname "{{ pont }}" oifname "{{ pont }}" accept
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% if reseau_veth_gw_subnet is defined %}
|
{% if reseau_veth_gw_subnet is defined %}
|
||||||
# Un seul sens autorisé : réseau externe OpenStack -> Internet (NAT),
|
# Un seul sens autorisé : réseau externe OpenStack -> Internet (NAT),
|
||||||
|
|
|
||||||
|
|
@ -16,3 +16,6 @@ portail_domaine_statut: status.syllabuslab.fr
|
||||||
portail_statut_slug: syllabus
|
portail_statut_slug: syllabus
|
||||||
portail_ip_publique: 163.172.4.195
|
portail_ip_publique: 163.172.4.195
|
||||||
portail_port_uptime_kuma: 3001
|
portail_port_uptime_kuma: 3001
|
||||||
|
# Étape 15 : visio (Jitsi, rôle visio), activée quand ce nom résout vers le serveur.
|
||||||
|
portail_domaine_visio: meet.syllabuslab.fr
|
||||||
|
portail_port_visio: 8090
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,20 @@
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
||||||
- name: noter si la page de statut peut être activée
|
- name: vérifier le DNS du sous-domaine de la visio
|
||||||
|
ansible.builtin.command: getent ahostsv4 {{ portail_domaine_visio }}
|
||||||
|
register: portail_dns_visio
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: noter si la page de statut et la visio peuvent être activées
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
portail_statut_actif: "{{ portail_ip_publique in portail_dns_statut.stdout }}"
|
portail_statut_actif: "{{ portail_ip_publique in portail_dns_statut.stdout }}"
|
||||||
|
portail_visio_actif: "{{ portail_ip_publique in portail_dns_visio.stdout }}"
|
||||||
portail_noms_certificat: >-
|
portail_noms_certificat: >-
|
||||||
{{ [portail_domaine] + portail_domaines_alias
|
{{ [portail_domaine] + portail_domaines_alias
|
||||||
+ ([portail_domaine_statut] if portail_ip_publique in portail_dns_statut.stdout else []) }}
|
+ ([portail_domaine_statut] if portail_ip_publique in portail_dns_statut.stdout else [])
|
||||||
|
+ ([portail_domaine_visio] if portail_ip_publique in portail_dns_visio.stdout else []) }}
|
||||||
|
|
||||||
- name: vérifier si le certificat existe déjà
|
- name: vérifier si le certificat existe déjà
|
||||||
ansible.builtin.stat:
|
ansible.builtin.stat:
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name {{ portail_domaine }} {{ portail_domaines_alias | join(' ') }}{% if portail_statut_actif %} {{ portail_domaine_statut }}{% endif %};
|
server_name {{ portail_domaine }} {{ portail_domaines_alias | join(' ') }}{% if portail_statut_actif %} {{ portail_domaine_statut }}{% endif %}{% if portail_visio_actif | default(false) %} {{ portail_domaine_visio }}{% endif %};
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
# Dans une location (pas au niveau server) : la location exacte que
|
# Dans une location (pas au niveau server) : la location exacte que
|
||||||
# certbot ajoute pour le défi ACME au renouvellement reste prioritaire.
|
# certbot ajoute pour le défi ACME au renouvellement reste prioritaire.
|
||||||
|
|
@ -117,6 +117,48 @@ server {
|
||||||
error_log /var/log/nginx/statut-error.log;
|
error_log /var/log/nginx/statut-error.log;
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if portail_visio_actif | default(false) %}
|
||||||
|
|
||||||
|
# Visio (Jitsi Meet, rôle visio). Pas l'extrait d'en-têtes commun : son
|
||||||
|
# X-Frame-Options et sa Permissions-Policy interdiraient l'intégration dans
|
||||||
|
# le portail et la caméra. Intégration permise au seul portail.
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name {{ portail_domaine_visio }};
|
||||||
|
server_tokens off;
|
||||||
|
include snippets/syllabus-tls.conf;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Content-Security-Policy "frame-ancestors https://{{ portail_domaine }}" always;
|
||||||
|
|
||||||
|
# Pas de page d'accueil Jitsi : on n'entre dans une salle que depuis le portail.
|
||||||
|
location = / {
|
||||||
|
return 302 https://{{ portail_domaine }}/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /xmpp-websocket {
|
||||||
|
proxy_pass http://127.0.0.1:{{ portail_port_visio }};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:{{ portail_port_visio }};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /var/log/nginx/visio-access.log;
|
||||||
|
error_log /var/log/nginx/visio-error.log;
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
# Avant le premier certificat : HTTP seul, avec les noms du domaine pour que
|
# Avant le premier certificat : HTTP seul, avec les noms du domaine pour que
|
||||||
# certbot (authentificateur nginx) trouve le bon bloc.
|
# certbot (authentificateur nginx) trouve le bon bloc.
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,7 @@ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" alway
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
# Caméra, micro et partage d'écran : délégués à la seule visio intégrée
|
||||||
|
# (étape 15) ; refusés à tout autre site.
|
||||||
|
add_header Permissions-Policy "camera=(self \"https://{{ portail_domaine_visio }}\"), microphone=(self \"https://{{ portail_domaine_visio }}\"), display-capture=(self \"https://{{ portail_domaine_visio }}\"), geolocation=(), payment=()" always;
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
|
|
||||||
17
ansible/roles/visio/defaults/main.yml
Normal file
17
ansible/roles/visio/defaults/main.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
# Étape 15 : visio de classe (Jitsi Meet auto-hébergé, docker-jitsi-meet).
|
||||||
|
visio_version: stable-11248
|
||||||
|
visio_domaine: meet.syllabuslab.fr
|
||||||
|
visio_dossier: /opt/visio
|
||||||
|
visio_port_web: 8090
|
||||||
|
visio_ip_publique: 163.172.4.195
|
||||||
|
visio_port_media: 10000
|
||||||
|
# Émetteur et audience des jetons signés par le portail (portail/app/Services/Visio).
|
||||||
|
visio_jwt_app_id: syllabus
|
||||||
|
visio_jwt_audience: syllabus-visio
|
||||||
|
# Mémoire plafonnée (Java) : la visio prend sur la capacité des VM d'élèves.
|
||||||
|
visio_memoire_jvb: 1024m
|
||||||
|
visio_memoire_jicofo: 512m
|
||||||
|
# Utilisateur des conteneurs docker-jitsi-meet (ils refusent de démarrer
|
||||||
|
# si leurs volumes ne lui appartiennent pas).
|
||||||
|
visio_uid_conteneurs: 1000
|
||||||
7
ansible/roles/visio/handlers/main.yml
Normal file
7
ansible/roles/visio/handlers/main.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
- name: remettre en cache la configuration du portail
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: runuser -u www-data -- php artisan config:cache
|
||||||
|
args:
|
||||||
|
chdir: /var/www/portail
|
||||||
|
changed_when: true
|
||||||
126
ansible/roles/visio/tasks/main.yml
Normal file
126
ansible/roles/visio/tasks/main.yml
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
---
|
||||||
|
# Étape 15 : visio de classe. Jitsi Meet (docker-jitsi-meet, version figée)
|
||||||
|
# derrière le Nginx de l'hôte ; accès par jeton JWT signé par le portail.
|
||||||
|
- name: créer les dossiers de la visio
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0750"
|
||||||
|
loop:
|
||||||
|
- /etc/visio
|
||||||
|
- "{{ visio_dossier }}"
|
||||||
|
|
||||||
|
- name: créer les dossiers des conteneurs (au nom de leur utilisateur)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ visio_uid_conteneurs }}"
|
||||||
|
group: "{{ visio_uid_conteneurs }}"
|
||||||
|
mode: "0750"
|
||||||
|
loop:
|
||||||
|
- "{{ visio_dossier }}/config"
|
||||||
|
- "{{ visio_dossier }}/config/web"
|
||||||
|
- "{{ visio_dossier }}/config/storage/web"
|
||||||
|
- "{{ visio_dossier }}/config/storage/transcripts"
|
||||||
|
- "{{ visio_dossier }}/config/tmp/web-load-test"
|
||||||
|
- "{{ visio_dossier }}/config/prosody/config"
|
||||||
|
- "{{ visio_dossier }}/config/prosody/prosody-plugins-custom"
|
||||||
|
- "{{ visio_dossier }}/config/storage/prosody"
|
||||||
|
- "{{ visio_dossier }}/config/jicofo"
|
||||||
|
- "{{ visio_dossier }}/config/jvb"
|
||||||
|
|
||||||
|
# Générés une seule fois sur le serveur, jamais dans Git ni affichés.
|
||||||
|
- name: générer les secrets de la visio (première fois seulement)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/visio/secrets.env
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
force: false
|
||||||
|
content: |
|
||||||
|
JICOFO_AUTH_PASSWORD={{ lookup('community.general.random_string', length=40, special=false) }}
|
||||||
|
JVB_AUTH_PASSWORD={{ lookup('community.general.random_string', length=40, special=false) }}
|
||||||
|
JWT_APP_SECRET={{ lookup('community.general.random_string', length=64, special=false) }}
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: déployer la configuration de la visio
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: visio.env.j2
|
||||||
|
dest: /etc/visio/visio.env
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0640"
|
||||||
|
register: visio_config
|
||||||
|
|
||||||
|
- name: déployer docker-compose.yml de la visio
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: docker-compose.yml.j2
|
||||||
|
dest: "{{ visio_dossier }}/docker-compose.yml"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0640"
|
||||||
|
register: visio_compose
|
||||||
|
|
||||||
|
- name: démarrer Jitsi (web, prosody, jicofo, jvb)
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ visio_dossier }}"
|
||||||
|
project_name: visio
|
||||||
|
state: present
|
||||||
|
pull: missing
|
||||||
|
recreate: "{{ 'always' if (visio_config.changed or visio_compose.changed) else 'auto' }}"
|
||||||
|
|
||||||
|
- name: attendre que Jitsi web réponde sur 127.0.0.1
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://127.0.0.1:{{ visio_port_web }}/"
|
||||||
|
status_code: [200]
|
||||||
|
register: visio_web
|
||||||
|
retries: 20
|
||||||
|
delay: 3
|
||||||
|
until: visio_web.status == 200
|
||||||
|
|
||||||
|
- name: vérifier que JVB écoute l'UDP {{ visio_port_media }}
|
||||||
|
ansible.builtin.shell:
|
||||||
|
cmd: "ss -Hulnp 'sport = :{{ visio_port_media }}' | grep -q java"
|
||||||
|
register: visio_jvb
|
||||||
|
retries: 20
|
||||||
|
delay: 3
|
||||||
|
until: visio_jvb.rc == 0
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
# Jicofo doit voir un JVB opérationnel : c'est la preuve que les quatre
|
||||||
|
# services se parlent (cassé tant que le pare-feu jetait le trafic entre
|
||||||
|
# conteneurs, voir durcissement_ponts_internes).
|
||||||
|
- name: vérifier que Jicofo voit un JVB opérationnel
|
||||||
|
ansible.builtin.shell:
|
||||||
|
cmd: >-
|
||||||
|
docker compose -p visio -f {{ visio_dossier }}/docker-compose.yml exec -T jicofo
|
||||||
|
curl -s http://127.0.0.1:8888/stats
|
||||||
|
register: visio_stats_jicofo
|
||||||
|
retries: 20
|
||||||
|
delay: 5
|
||||||
|
until: >-
|
||||||
|
visio_stats_jicofo.rc == 0 and
|
||||||
|
(visio_stats_jicofo.stdout | from_json).bridge_selector.operational_bridge_count | int >= 1
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
# Le portail signe les jetons avec le même secret (portail/config/visio.php).
|
||||||
|
- name: lire le secret de signature des jetons
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: /etc/visio/secrets.env
|
||||||
|
register: visio_secrets_brut
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: transmettre la configuration de la visio au portail
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /var/www/portail/.env
|
||||||
|
regexp: "^{{ item.cle }}="
|
||||||
|
line: "{{ item.cle }}={{ item.valeur }}"
|
||||||
|
loop:
|
||||||
|
- { cle: "VISIO_DOMAINE", valeur: "{{ visio_domaine }}" }
|
||||||
|
- { cle: "VISIO_JWT_APP_ID", valeur: "{{ visio_jwt_app_id }}" }
|
||||||
|
- { cle: "VISIO_JWT_AUDIENCE", valeur: "{{ visio_jwt_audience }}" }
|
||||||
|
- { cle: "VISIO_JWT_SECRET", valeur: "{{ visio_secrets_brut.content | b64decode | regex_search('JWT_APP_SECRET=(\\S+)', '\\1') | first }}" }
|
||||||
|
no_log: true
|
||||||
|
notify: remettre en cache la configuration du portail
|
||||||
88
ansible/roles/visio/templates/docker-compose.yml.j2
Normal file
88
ansible/roles/visio/templates/docker-compose.yml.j2
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Géré par Ansible (rôle visio). D'après docker-jitsi-meet {{ visio_version }}
|
||||||
|
# (docker-compose.yml officiel), adapté à ce serveur :
|
||||||
|
# - Docker tourne sans pont par défaut ni règles iptables (étape 5) : JVB est
|
||||||
|
# sur le réseau de l'hôte (UDP {{ visio_port_media }} sans NAT) et joint Prosody
|
||||||
|
# par 127.0.0.1 ;
|
||||||
|
# - le conteneur web n'écoute que sur 127.0.0.1, derrière le Nginx de l'hôte.
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: ghcr.io/jitsi/web:{{ visio_version }}
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /run:size=16M,mode=1750,exec
|
||||||
|
- /tmp:size=16M,mode=1777,noexec
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:{{ visio_port_web }}:8000'
|
||||||
|
volumes:
|
||||||
|
- {{ visio_dossier }}/config/web:/config:Z
|
||||||
|
- {{ visio_dossier }}/config/storage/web:/storage:Z
|
||||||
|
- {{ visio_dossier }}/config/storage/transcripts:/usr/share/jitsi-meet/transcripts:ro,z
|
||||||
|
- {{ visio_dossier }}/config/tmp/web-load-test:/usr/share/jitsi-meet/load-test:Z
|
||||||
|
env_file: [/etc/visio/visio.env, /etc/visio/secrets.env]
|
||||||
|
networks:
|
||||||
|
meet.jitsi:
|
||||||
|
depends_on:
|
||||||
|
- prosody
|
||||||
|
|
||||||
|
prosody:
|
||||||
|
image: ghcr.io/jitsi/prosody:{{ visio_version }}
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /run:size=16M,mode=1750,exec
|
||||||
|
- /tmp:size=16M,mode=1777,noexec
|
||||||
|
ports:
|
||||||
|
# Pour JVB, sur le réseau de l'hôte.
|
||||||
|
- '127.0.0.1:5222:5222'
|
||||||
|
expose:
|
||||||
|
- '5269'
|
||||||
|
- '5347'
|
||||||
|
- '5280'
|
||||||
|
volumes:
|
||||||
|
- {{ visio_dossier }}/config/prosody/config:/config:Z
|
||||||
|
- {{ visio_dossier }}/config/prosody/prosody-plugins-custom:/prosody-plugins-custom:Z
|
||||||
|
- {{ visio_dossier }}/config/storage/prosody:/var/lib/prosody:Z
|
||||||
|
env_file: [/etc/visio/visio.env, /etc/visio/secrets.env]
|
||||||
|
networks:
|
||||||
|
meet.jitsi:
|
||||||
|
aliases:
|
||||||
|
- xmpp.meet.jitsi
|
||||||
|
|
||||||
|
jicofo:
|
||||||
|
image: ghcr.io/jitsi/jicofo:{{ visio_version }}
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /run:size=32M,mode=1750,exec
|
||||||
|
- /tmp:size=16M,mode=1777,noexec
|
||||||
|
volumes:
|
||||||
|
- {{ visio_dossier }}/config/jicofo:/config:Z
|
||||||
|
env_file: [/etc/visio/visio.env, /etc/visio/secrets.env]
|
||||||
|
networks:
|
||||||
|
meet.jitsi:
|
||||||
|
depends_on:
|
||||||
|
- prosody
|
||||||
|
|
||||||
|
jvb:
|
||||||
|
image: ghcr.io/jitsi/jvb:{{ visio_version }}
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /run:size=32M,mode=1750,exec
|
||||||
|
- /tmp:size=16M,mode=1777,noexec
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- {{ visio_dossier }}/config/jvb:/config:Z
|
||||||
|
env_file: [/etc/visio/visio.env, /etc/visio/secrets.env]
|
||||||
|
environment:
|
||||||
|
XMPP_SERVER: 127.0.0.1
|
||||||
|
depends_on:
|
||||||
|
- prosody
|
||||||
|
|
||||||
|
networks:
|
||||||
|
meet.jitsi:
|
||||||
|
# Nom fixe : le pare-feu n'autorise le trafic entre conteneurs que
|
||||||
|
# sur ce pont (rôle durcissement, durcissement_ponts_internes).
|
||||||
|
driver_opts:
|
||||||
|
com.docker.network.bridge.name: br-visio
|
||||||
44
ansible/roles/visio/templates/visio.env.j2
Normal file
44
ansible/roles/visio/templates/visio.env.j2
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Géré par Ansible (rôle visio) — aucun secret ici (voir /etc/visio/secrets.env).
|
||||||
|
PUBLIC_URL=https://{{ visio_domaine }}
|
||||||
|
TZ=Europe/Paris
|
||||||
|
|
||||||
|
# HTTPS assuré par le Nginx de l'hôte (certificat Let's Encrypt du portail).
|
||||||
|
DISABLE_HTTPS=1
|
||||||
|
ENABLE_HTTP_REDIRECT=0
|
||||||
|
ENABLE_LETSENCRYPT=0
|
||||||
|
|
||||||
|
# Entrée seulement avec un jeton signé par le portail ; pas d'invités.
|
||||||
|
ENABLE_AUTH=1
|
||||||
|
AUTH_TYPE=jwt
|
||||||
|
ENABLE_GUESTS=0
|
||||||
|
JWT_APP_ID={{ visio_jwt_app_id }}
|
||||||
|
JWT_ACCEPTED_ISSUERS={{ visio_jwt_app_id }}
|
||||||
|
JWT_ACCEPTED_AUDIENCES={{ visio_jwt_audience }}
|
||||||
|
|
||||||
|
# Le prof seul modérateur : rôle lu dans le jeton (context.user.moderator,
|
||||||
|
# module natif token_affiliation), jamais attribué au premier arrivé.
|
||||||
|
XMPP_MUC_MODULES=token_affiliation
|
||||||
|
ENABLE_AUTO_OWNER=0
|
||||||
|
# Jicofo ne fait PAS sa propre authentification : sinon il donne le rôle de
|
||||||
|
# propriétaire à tout utilisateur authentifié, élèves compris (constaté le
|
||||||
|
# 2026-10-12 : un élève pouvait ouvrir son micro sans la parole). Prosody
|
||||||
|
# vérifie toujours chaque jeton ; token_affiliation seul décide du rôle.
|
||||||
|
JICOFO_ENABLE_AUTH=0
|
||||||
|
|
||||||
|
# Salle d'attente, modération du micro et de la caméra.
|
||||||
|
ENABLE_LOBBY=1
|
||||||
|
ENABLE_AV_MODERATION=1
|
||||||
|
|
||||||
|
# Tout le monde arrive micro et caméra coupés ; pas de page d'accueil ni de
|
||||||
|
# pré-réunion (le nom vient du jeton et ne se modifie pas).
|
||||||
|
START_AUDIO_MUTED=0
|
||||||
|
START_VIDEO_MUTED=0
|
||||||
|
ENABLE_WELCOME_PAGE=0
|
||||||
|
ENABLE_PREJOIN_PAGE=0
|
||||||
|
# Sans « chat » : désactivé par défaut.
|
||||||
|
TOOLBAR_BUTTONS=microphone,camera,desktop,raisehand,participants-pane,tileview,settings,fullscreen,hangup
|
||||||
|
|
||||||
|
JVB_ADVERTISE_IPS={{ visio_ip_publique }}
|
||||||
|
JVB_PORT={{ visio_port_media }}
|
||||||
|
VIDEOBRIDGE_MAX_MEMORY={{ visio_memoire_jvb }}
|
||||||
|
JICOFO_MAX_MEMORY={{ visio_memoire_jicofo }}
|
||||||
11
ansible/visio.yml
Normal file
11
ansible/visio.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
# Étape 15 du déploiement Syllabus : visio de classe (Jitsi Meet).
|
||||||
|
# Rejoue aussi `portail` : il porte le vhost Nginx de meet.syllabuslab.fr et
|
||||||
|
# le certificat (activés dès que le nom DNS répond).
|
||||||
|
- name: Portail et visio
|
||||||
|
hosts: syllabus
|
||||||
|
become: true
|
||||||
|
gather_facts: true
|
||||||
|
roles:
|
||||||
|
- portail
|
||||||
|
- visio
|
||||||
54
docs/adr/0005-visio-jitsi.md
Normal file
54
docs/adr/0005-visio-jitsi.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# ADR 0005 — Visio de classe : Jitsi auto-hébergé, jetons du portail, prof seul modérateur
|
||||||
|
|
||||||
|
- **Date** : 2026-10-12
|
||||||
|
- **Statut** : accepté
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Étape 15 : une visio pour la classe, sur le même serveur, où seuls les élèves
|
||||||
|
de la classe entrent (nom non modifiable), où tout le monde arrive en
|
||||||
|
sourdine et où seul le prof donne la parole.
|
||||||
|
|
||||||
|
## Décisions
|
||||||
|
|
||||||
|
1. **Jitsi Meet en Docker** (docker-jitsi-meet, version figée `stable-11248`),
|
||||||
|
rôle Ansible `visio`, derrière le Nginx de l'hôte sur `meet.syllabuslab.fr`.
|
||||||
|
2. **JVB sur le réseau de l'hôte** : Docker tourne sans pont par défaut ni règles
|
||||||
|
iptables (étape 5), donc la publication de l'UDP 10000 par NAT Docker est
|
||||||
|
impossible. JVB écoute directement l'UDP 10000 (ouvert dans nftables, accord
|
||||||
|
d'Adam) et joint Prosody par `127.0.0.1:5222`.
|
||||||
|
3. **Pont interne `br-visio` autorisé** dans la chaîne `forward` (accord d'Adam) :
|
||||||
|
avec `br_netfilter`, le trafic entre conteneurs d'un même réseau Docker passe
|
||||||
|
par `forward`, dont la politique `drop` bloquait Jicofo et le conteneur web.
|
||||||
|
Seul le trafic qui entre ET ressort par `br-visio` est accepté.
|
||||||
|
4. **Accès par jeton JWT (HS256) signé par le portail**, un par personne, pour
|
||||||
|
une salle au nom imprévisible ; pas d'invités (`ENABLE_GUESTS=0`). Le secret
|
||||||
|
est généré sur le serveur et copié dans le `.env` du portail par Ansible.
|
||||||
|
5. **Rôle de modérateur lu dans le jeton** (`context.user.moderator`, module
|
||||||
|
natif `token_affiliation`) ; `ENABLE_AUTO_OWNER=0` et **`JICOFO_ENABLE_AUTH=0`** :
|
||||||
|
sinon Jicofo authentifie chaque participant et donne le rôle de propriétaire à
|
||||||
|
tous (constaté : un élève pouvait ouvrir son micro sans la parole).
|
||||||
|
6. **Modération du micro** activée par la page du prof à son arrivée
|
||||||
|
(`toggleModeration`), « Donner la parole » = `askToUnmute` (qui autorise le
|
||||||
|
micro quand la modération est active, d'après la documentation de l'IFrame API).
|
||||||
|
7. **État de la classe tenu par le portail** (cache Redis, verrou) : mains levées
|
||||||
|
par ordre d'arrivée, correspondance élève ↔ participant Jitsi, salle d'attente.
|
||||||
|
La main levée suit toujours l'événement Jitsi (`raiseHandUpdated`) : une seule source.
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
- **Publier l'UDP 10000 par Docker** : demande de rendre à Docker la main sur
|
||||||
|
iptables, ce qu'on a refusé dès l'étape 5.
|
||||||
|
- **Mettre tous les conteneurs sur le réseau de l'hôte** : conflits de ports avec
|
||||||
|
Kolla (ex. 10.10.0.1:8000) et services internes exposés sur toutes les interfaces.
|
||||||
|
- **Relais TURN sur 443** pour les réseaux qui bloquent l'UDP : impose de
|
||||||
|
restructurer l'entrée HTTPS (aiguillage par nom) ; reporté (choix d'Adam).
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- Réseaux scolaires qui bloquent l'UDP : pas de son ni d'image tant que le relais
|
||||||
|
TURN sur 443 n'existe pas (`docs/dette.md`).
|
||||||
|
- Capacité ramenée de 22 à 20 VM (`docs/capacite.md`).
|
||||||
|
- `Permissions-Policy` du portail délègue caméra, micro et partage d'écran au seul
|
||||||
|
`meet.syllabuslab.fr` ; le site `meet` n'est intégrable que dans le portail
|
||||||
|
(`frame-ancestors`).
|
||||||
|
|
@ -6,7 +6,14 @@ Kolla 2025.2 tout-en-un. Script : `openstack/tests/capacite.py` ; données
|
||||||
brutes sur le serveur : `/root/capacite-20261009/` (`hote.csv` toutes les
|
brutes sur le serveur : `/root/capacite-20261009/` (`hote.csv` toutes les
|
||||||
2 s, `demarrage.csv`, `resume.json`).
|
2 s, `demarrage.csv`, `resume.json`).
|
||||||
|
|
||||||
## Capacité retenue : 22 VM `eleve.petit` en même temps
|
## Capacité retenue : 20 VM `eleve.petit` en même temps
|
||||||
|
|
||||||
|
Ramenée de 22 à 20 le 2026-10-12 pour la visio (Jitsi) : après une visio à
|
||||||
|
trois, Jitsi occupe ≈ 710 Mo (JVB 459, Jicofo 206, Prosody 28, web 16) ; JVB est
|
||||||
|
plafonné à 1 Go et Jicofo à 512 Mo, soit 1,5 Go réservés en plus
|
||||||
|
(`reserved_host_memory_mb` 15000 → 16500 ; (31993 − 16500) / 768 = 20).
|
||||||
|
|
||||||
|
### Historique : 22 VM (étape 13)
|
||||||
|
|
||||||
Sessions d'élèves et stock chaud compris. Choisie par Adam d'après les
|
Sessions d'élèves et stock chaud compris. Choisie par Adam d'après les
|
||||||
mesures ci-dessous. Elle est appliquée à deux endroits, à garder alignés :
|
mesures ci-dessous. Elle est appliquée à deux endroits, à garder alignés :
|
||||||
|
|
@ -17,7 +24,7 @@ mesures ci-dessous. Elle est appliquée à deux endroits, à garder alignés :
|
||||||
| Portail (plafond du stock chaud) | `LABS_CAPACITE_VM`, défaut 22 (`portail/config/labs.php`) |
|
| Portail (plafond du stock chaud) | `LABS_CAPACITE_VM`, défaut 22 (`portail/config/labs.php`) |
|
||||||
| Quota du projet (filet de sécurité, au-dessus) | 40 instances, 40 vCPU, 30 Go (rôle `openstack-projet`) |
|
| Quota du projet (filet de sécurité, au-dessus) | 40 instances, 40 vCPU, 30 Go (rôle `openstack-projet`) |
|
||||||
|
|
||||||
Ce que ça permet : une classe de 22 sur un lab à 1 VM ; 7 élèves sur le
|
Ce que ça permet (20 VM) : une classe de 20 sur un lab à 1 VM ; 6 élèves sur le
|
||||||
lab 08 (3 VM chacun). Moins le stock en attente (1 stack par lab hors séance).
|
lab 08 (3 VM chacun). Moins le stock en attente (1 stack par lab hors séance).
|
||||||
|
|
||||||
## Mesures
|
## Mesures
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,12 @@ retirer la ligne (et le noter dans le journal) quand c'est fait.
|
||||||
| Correction séquentielle (~5 s pour le lab 08) | Acceptable ; à paralléliser si la charge l'exige. | Bloc B | Étape 11 |
|
| Correction séquentielle (~5 s pour le lab 08) | Acceptable ; à paralléliser si la charge l'exige. | Bloc B | Étape 11 |
|
||||||
| Jeton de console : ≈ 1,1 s par appel Nova `remote-consoles` | Plus gros poste de l'ouverture d'un lab (2,8 s au total, critère < 5 s tenu). Préparer les jetons à l'attribution si besoin. | Bloc B | Étape 13 |
|
| Jeton de console : ≈ 1,1 s par appel Nova `remote-consoles` | Plus gros poste de l'ouverture d'un lab (2,8 s au total, critère < 5 s tenu). Préparer les jetons à l'attribution si besoin. | Bloc B | Étape 13 |
|
||||||
| Tâches Ansible toujours « modifiées » (composer, npm, build, cache) | Brouille la lecture de l'idempotence. | Bloc B | Étape 8 |
|
| Tâches Ansible toujours « modifiées » (composer, npm, build, cache) | Brouille la lecture de l'idempotence. | Bloc B | Étape 8 |
|
||||||
| `Permissions-Policy` ferme caméra et micro | À ouvrir pour la visio. | Étape 15 | Étape 12 |
|
|
||||||
| Mentions légales à compléter | Article « Disponibilité et responsabilité » des CGU ; délégué à la protection des données s'il en est désigné (éditeur, SIREN et adresse complétés le 2026-10-09). | Avant le pilote | Étape 14 |
|
| Mentions légales à compléter | Article « Disponibilité et responsabilité » des CGU ; délégué à la protection des données s'il en est désigné (éditeur, SIREN et adresse complétés le 2026-10-09). | Avant le pilote | Étape 14 |
|
||||||
| Slogans « du lycée au master » | Le catalogue va aujourd'hui du lycée au BUT. Positionnement laissé tel quel : décision éditoriale d'Adam. | À décider | Étape 14 |
|
| Slogans « du lycée au master » | Le catalogue va aujourd'hui du lycée au BUT. Positionnement laissé tel quel : décision éditoriale d'Adam. | À décider | Étape 14 |
|
||||||
| Ancien `storage/logs/laravel.log` | Fichier unique d'avant la rotation quotidienne (étape 14) : jamais purgé, à supprimer ou archiver (accord d'Adam). | Dès que possible | Étape 14 |
|
| Ancien `storage/logs/laravel.log` | Fichier unique d'avant la rotation quotidienne (étape 14) : jamais purgé, à supprimer ou archiver (accord d'Adam). | Dès que possible | Étape 14 |
|
||||||
| Pages d'aperçu publiques (32) | Signalées comme aperçus avec données fictives ; à retirer ou remplacer au fur et à mesure que les fonctions réelles arrivent. | Au fil du bloc B | Étape 14 |
|
| Pages d'aperçu publiques (32) | Signalées comme aperçus avec données fictives ; à retirer ou remplacer au fur et à mesure que les fonctions réelles arrivent. | Au fil du bloc B | Étape 14 |
|
||||||
| Invitation des élèves majeurs par e-mail (V2 du CLAUDE.md) | Demande un service d'envoi d'e-mails (compte ouvert par Adam, DNS SPF/DKIM). Jamais pour les mineurs. | V2 | 2026-10-09 |
|
| Invitation des élèves majeurs par e-mail (V2 du CLAUDE.md) | Demande un service d'envoi d'e-mails (compte ouvert par Adam, DNS SPF/DKIM). Jamais pour les mineurs. | V2 | 2026-10-09 |
|
||||||
| Code de classe : caractères ambigus | Généré avec `Str::random` (0/O, 1/I/l possibles), contrairement aux codes élèves. Utiliser le même alphabet sans ambiguïté. | Bloc B | 2026-10-09 |
|
| Code de classe : caractères ambigus | Généré avec `Str::random` (0/O, 1/I/l possibles), contrairement aux codes élèves. Utiliser le même alphabet sans ambiguïté. | Bloc B | 2026-10-09 |
|
||||||
|
| **Visio : pas de relais TURN sur 443** | Les réseaux scolaires qui bloquent l'UDP n'auront ni son ni image (CLAUDE.md : tout par 443). Restructurer l'entrée HTTPS (aiguillage Nginx par nom) + coturn ; demande un nom DNS (turn.syllabuslab.fr). | Avant le pilote | Étape 15 |
|
||||||
|
| Visio : chat activé à chaud non vérifié | L'interrupteur « Chat des élèves » change la barre d'outils par `overwriteConfig` ; non vérifié en navigateur. Vue mobile de la maquette non reprise (la page élève est simplement adaptative). | Bloc B | Étape 15 |
|
||||||
|
| Image Jitsi figée sans procédure de mise à jour | `stable-11248` : comme pour Kuma, prévoir la montée de version par Ansible (variable `visio_version`) et rejouer `visio-e2e.mjs`. | Bloc B | Étape 15 |
|
||||||
|
|
|
||||||
|
|
@ -625,3 +625,49 @@ Vérifié en production : `/ide` 1 script en ligne, 0 erreur de syntaxe, `sandbo
|
||||||
- 6 tests Pest (41 au total) ; vérifié en production : `/rejoindre` 200 avec CSP stricte, code faux refusé avec message, table créée, nettoyage planifié.
|
- 6 tests Pest (41 au total) ; vérifié en production : `/rejoindre` 200 avec CSP stricte, code faux refusé avec message, table créée, nettoyage planifié.
|
||||||
|
|
||||||
**Clé des sauvegardes** : je ne peux ni ne dois voir la copie d'Adam. Procédure de preuve ajoutée au runbook des sauvegardes : un message chiffré sur le serveur avec la clé publique, que la copie doit déchiffrer sur le Mac. Fichier de test déposé sur le Bureau d'Adam. Corrigé aussi la commande de copie du runbook (un `scp` d'un fichier de root échouait).
|
**Clé des sauvegardes** : je ne peux ni ne dois voir la copie d'Adam. Procédure de preuve ajoutée au runbook des sauvegardes : un message chiffré sur le serveur avec la clé publique, que la copie doit déchiffrer sur le Mac. Fichier de test déposé sur le Bureau d'Adam. Corrigé aussi la commande de copie du runbook (un `scp` d'un fichier de root échouait).
|
||||||
|
|
||||||
|
## 2026-10-12 — Étape 15 : visio de classe
|
||||||
|
|
||||||
|
Démarrée après validation des étapes 1 à 11 par Adam. Décisions : [ADR 0005](adr/0005-visio-jitsi.md).
|
||||||
|
|
||||||
|
**Fait**
|
||||||
|
|
||||||
|
- **Jitsi Meet auto-hébergé** (docker-jitsi-meet `stable-11248`, version vérifiée sur le dépôt officiel, images figées), rôle Ansible `visio` : configuration d'après le `docker-compose.yml` officiel, JVB sur le réseau de l'hôte, secrets générés une fois sur le serveur, contrôle au déploiement que Jicofo voit un JVB opérationnel.
|
||||||
|
- **Pare-feu** (deux accords explicites d'Adam, filet anti-coupure de 5 min, vérification SSH depuis le Mac, règles de NAT d'OpenStack intactes) : UDP 10000 ouvert ; pont `br-visio` autorisé en `forward` pour les échanges entre conteneurs.
|
||||||
|
- **Accès par jeton JWT** signé par le portail (HS256, une salle imprévisible par séance, nom non modifiable : prénom et initiale pour les élèves) ; pas d'invités. **Prof seul modérateur** par le module natif `token_affiliation` (vérifié dans l'image : `context.user.moderator`).
|
||||||
|
- **Pages prof et élève** reprises de `design/resources/views/visio.blade.php` : « En direct » et chrono, « Couper tous les micros », « Terminer la visio », mains levées (premier arrivé en haut, « Donner la parole »), salle d'attente (élèves présents tant que la visio est fermée, « Tout admettre » l'ouvre), chat des élèves désactivé par défaut avec interrupteur ; côté élève : statut, micro, caméra, « Lever la main », « Quitter ». La grille factice est remplacée par la vraie visio (IFrame API, `external_api.js` servi par `meet`).
|
||||||
|
- **Main levée synchronisée** : l'événement Jitsi `raiseHandUpdated` met à jour l'état du portail (cache Redis, verrou) ; quand le prof donne la parole (`askToUnmute`, qui autorise le micro sous modération d'après la documentation officielle), le portail baisse la main.
|
||||||
|
- `meet.syllabuslab.fr` ajouté au certificat ; site intégrable dans le seul portail (`frame-ancestors`) ; `Permissions-Policy` du portail : caméra, micro et partage d'écran délégués à `meet` seulement ; CSP stricte complétée (`frame-src` et `script-src` vers `meet`).
|
||||||
|
- **Capacité ramenée à 20 VM** (Jitsi ≈ 710 Mo après une visio à trois, plafonds 1 Go + 512 Mo) : Nova et portail alignés.
|
||||||
|
- 6 tests Pest de la visio (47 au total) ; test réel en navigateurs `portail/tests/Reels/visio-e2e.mjs` (Playwright 1.64, Chromium, caméra et micro simulés) ; politique de confidentialité complétée (visio sans enregistrement).
|
||||||
|
|
||||||
|
**Problèmes rencontrés et solution**
|
||||||
|
|
||||||
|
1. Conteneurs Jitsi en boucle : leurs volumes doivent appartenir à l'uid 1000 (ils tournent sous cet utilisateur).
|
||||||
|
2. **Jicofo ne joignait pas Prosody** (« Connect timed out ») : le module `br_netfilter` fait passer le trafic entre conteneurs d'un même réseau Docker par la chaîne `forward`, dont la politique est `drop`. Jamais vu avant parce que tous les autres conteneurs ne sont joints que depuis l'hôte. Règle `br-visio` (accord d'Adam).
|
||||||
|
3. DNS de `meet` absent au départ : test complet d'abord joué en résolvant `meet` vers le serveur dans Chromium (`--host-resolver-rules`, certificat du portail accepté), puis rejoué en conditions réelles une fois le DNS créé par Adam et le certificat étendu.
|
||||||
|
4. Tinker resté ouvert en attente de saisie sur le serveur, bloquant la suite : lancé désormais avec `< /dev/null` (runbook).
|
||||||
|
5. **Un élève pouvait ouvrir son micro sans la parole** — trouvé par le test en navigateurs. En observant les messages XMPP envoyés par chaque navigateur, aucun client n'approuvait les élèves : c'est **Jicofo**, avec son authentification active par défaut, qui donnait le rôle de propriétaire à tout utilisateur authentifié, élèves compris (ils échappaient donc aussi à la modération et pouvaient modérer les autres). `JICOFO_ENABLE_AUTH=0` : Prosody vérifie toujours chaque jeton, `token_affiliation` seul attribue les rôles. Vérifié dans les journaux : élèves `PARTICIPANT`, liste des autorisés = le prof, puis l'élève à qui il donne la parole.
|
||||||
|
|
||||||
|
**Critère vérifié (vrai DNS, vrai certificat, trois navigateurs + un intrus)**
|
||||||
|
|
||||||
|
```
|
||||||
|
prof : visio ouverte, Jitsi chargé
|
||||||
|
prof : · 3 en ligne (le prof et les deux élèves)
|
||||||
|
prof : main levée reçue de « Lea M. »
|
||||||
|
Yanis tente d'ouvrir son micro sans la parole → « En sourdine »
|
||||||
|
Léa : sa main est baissée (parole donnée)
|
||||||
|
Léa ouvre son micro → « Tu as la parole »
|
||||||
|
intrus sans jeton : le prof voit toujours « · 3 en ligne » ; écran de l'intrus : « … Authentication required … »
|
||||||
|
CRITÈRE DE L’ÉTAPE 15 : RÉUSSI
|
||||||
|
```
|
||||||
|
|
||||||
|
Données et fichiers de test supprimés (accord d'Adam).
|
||||||
|
|
||||||
|
**Comprendre**
|
||||||
|
|
||||||
|
Dans une visio, l'image et le son ne sont que la partie visible : la sécurité tient dans *qui a quel rôle*. Ici, trois composants pouvaient en décider — Prosody (le serveur de messagerie), le module `token_affiliation` (qui lit le jeton signé par le portail) et Jicofo (l'organisateur des conférences) — et le moins attendu, Jicofo, accordait silencieusement le rôle de propriétaire à tout le monde. Aucune lecture de configuration ne l'aurait montré : il a fallu un test qui joue la situation réelle (un élève tente de parler sans y être autorisé) puis observer les messages échangés pour remonter à la cause. L'autre leçon est réseau : un pare-feu « tout refuser » protège aussi contre ses propres conteneurs, et une règle doit alors être la plus étroite possible (ici, un seul pont, dans les deux sens).
|
||||||
|
|
||||||
|
**Suite**
|
||||||
|
|
||||||
|
Étape 16 : nettoyage.
|
||||||
|
|
|
||||||
54
docs/runbooks/visio.md
Normal file
54
docs/runbooks/visio.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Runbook — Visio de classe (Jitsi, étape 15)
|
||||||
|
|
||||||
|
Décisions : [ADR 0005](../adr/0005-visio-jitsi.md).
|
||||||
|
|
||||||
|
## Où sont les choses
|
||||||
|
|
||||||
|
| Élément | Emplacement |
|
||||||
|
|---|---|
|
||||||
|
| Conteneurs (web, prosody, jicofo, jvb) | `/opt/visio/docker-compose.yml`, projet Docker `visio` (rôle `visio`) |
|
||||||
|
| Configuration (sans secret) | `/etc/visio/visio.env` (modèle `ansible/roles/visio/templates/visio.env.j2`) |
|
||||||
|
| Secrets (mots de passe internes, secret des jetons) | `/etc/visio/secrets.env`, `600 root:root`, générés une fois ; `VISIO_JWT_SECRET` copié dans le `.env` du portail |
|
||||||
|
| Site public | `https://meet.syllabuslab.fr` (vhost du rôle `portail`, activé quand le DNS répond) |
|
||||||
|
| Pare-feu | UDP 10000 ouvert ; pont `br-visio` autorisé en `forward` (rôle `durcissement`) |
|
||||||
|
| Portail | `VisioController`, `JetonVisio`, vues `seances/visio-prof` et `seances/visio-eleve` |
|
||||||
|
| Test réel | `portail/tests/Reels/preparer-visio.php` + `visio-e2e.mjs` (Playwright) |
|
||||||
|
|
||||||
|
## Déployer
|
||||||
|
|
||||||
|
```
|
||||||
|
cd ansible && ansible-playbook visio.yml -i inventory.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
Le rôle vérifie que Jitsi web répond, que JVB écoute l'UDP 10000, et que
|
||||||
|
**Jicofo voit un JVB opérationnel** (preuve que les services se parlent).
|
||||||
|
|
||||||
|
## Vérifier
|
||||||
|
|
||||||
|
```
|
||||||
|
D="sudo docker compose -p visio -f /opt/visio/docker-compose.yml"
|
||||||
|
$D ps
|
||||||
|
$D exec -T jicofo curl -s http://127.0.0.1:8888/stats | python3 -m json.tool | grep -E "operational_bridge_count|conferences"
|
||||||
|
$D logs --since 10m jicofo | grep -E "Member joined|Setting whitelist|Moderation for"
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu dans les journaux de Jicofo : le prof `role=OWNER`, les élèves
|
||||||
|
`role=PARTICIPANT` ; la liste des autorisés à parler ne contient que le prof,
|
||||||
|
puis chaque élève au moment où il reçoit la parole. **Si un élève y apparaît dès
|
||||||
|
son arrivée, Jicofo a repris l'authentification : vérifier `JICOFO_ENABLE_AUTH=0`.**
|
||||||
|
|
||||||
|
## Test de bout en bout (navigateurs)
|
||||||
|
|
||||||
|
1. Sur le serveur : `sudo -u www-data HOME=/tmp php8.3 artisan tinker /var/www/portail/tests/Reels/preparer-visio.php < /dev/null`
|
||||||
|
(le `< /dev/null` évite que Tinker reste ouvert en attente d'une saisie).
|
||||||
|
2. Sur le Mac : récupérer `/tmp/visio-identifiants.json` (fichier `600`), puis
|
||||||
|
`node visio-e2e.mjs identifiants.json` (Playwright, Chromium).
|
||||||
|
3. Supprimer les données et fichiers de test.
|
||||||
|
|
||||||
|
## Pièges rencontrés
|
||||||
|
|
||||||
|
- Conteneurs refusant de démarrer : leurs volumes doivent appartenir à l'uid 1000.
|
||||||
|
- Conteneurs qui ne se joignent pas : `br_netfilter` + politique `forward drop` (règle `br-visio`).
|
||||||
|
- Jicofo ne se reconnecte pas tout seul après une longue indisponibilité de Prosody : `$D restart jicofo`.
|
||||||
|
- Élèves modérateurs malgré `token_affiliation` : `JICOFO_ENABLE_AUTH=0` obligatoire.
|
||||||
|
- `Permissions-Policy` trop stricte (caméra interdite) ou `X-Frame-Options DENY` : la visio ne s'affiche pas sans erreur visible.
|
||||||
|
|
@ -8,8 +8,10 @@ resume_guests_state_on_host_boot = true
|
||||||
|
|
||||||
# Défaut : 512. Nova se croyait libre de distribuer 31,5 Go alors que Kolla et
|
# Défaut : 512. Nova se croyait libre de distribuer 31,5 Go alors que Kolla et
|
||||||
# le portail en occupent ~15,5 : il aurait accepté ~41 VM et l'hôte aurait
|
# le portail en occupent ~15,5 : il aurait accepté ~41 VM et l'hôte aurait
|
||||||
# manqué de mémoire bien avant qu'il refuse. (31993 - 15000) / 768 = 22 VM
|
# manqué de mémoire bien avant qu'il refuse. Capacité retenue par Adam le
|
||||||
# eleve.petit au plus : capacité retenue par Adam le 2026-10-09 d'après le
|
# 2026-10-09 d'après le test de capacité : 22 VM (réserve 15000) ; ramenée à
|
||||||
# test de capacité (docs/capacite.md). Au-delà, Nova refuse proprement
|
# 20 VM le 2026-10-12 pour la visio (Jitsi : JVB plafonné à 1 Go, Jicofo à
|
||||||
# (« No valid host ») et la session passe en échec, visible du prof.
|
# 512 Mo) : (31993 - 16500) / 768 = 20 VM eleve.petit au plus (docs/capacite.md).
|
||||||
reserved_host_memory_mb = 15000
|
# Au-delà, Nova refuse proprement (« No valid host ») et la session passe en
|
||||||
|
# échec, visible du prof.
|
||||||
|
reserved_host_memory_mb = 16500
|
||||||
|
|
|
||||||
230
portail/app/Http/Controllers/VisioController.php
Normal file
230
portail/app/Http/Controllers/VisioController.php
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
<?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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,9 +50,13 @@ class PolitiqueSecuriteContenu
|
||||||
'report-uri' => '/csp-rapport',
|
'report-uri' => '/csp-rapport',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Visio (étape 15) : script d'intégration et cadre servis par Jitsi.
|
||||||
|
$visio = config('visio.domaine') ? ' https://'.config('visio.domaine') : '';
|
||||||
|
|
||||||
$directives = $this->estApplication($request)
|
$directives = $this->estApplication($request)
|
||||||
? [
|
? [
|
||||||
'script-src' => "'self' 'nonce-".Vite::cspNonce()."'",
|
'script-src' => "'self' 'nonce-".Vite::cspNonce()."'".$visio,
|
||||||
|
'frame-src' => "'self'".$visio,
|
||||||
// Attributs style= présents dans le gabarit commun : sans
|
// Attributs style= présents dans le gabarit commun : sans
|
||||||
// risque d'exécution de code, tolérés.
|
// risque d'exécution de code, tolérés.
|
||||||
'style-src' => "'self' 'unsafe-inline'",
|
'style-src' => "'self' 'unsafe-inline'",
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,21 @@ class Seance extends Model
|
||||||
|
|
||||||
public const TERMINEE = 'terminee';
|
public const TERMINEE = 'terminee';
|
||||||
|
|
||||||
protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a'];
|
protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a', 'visio_ouverte_a', 'visio_salle', 'visio_chat'];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'prevue_a' => 'datetime',
|
'prevue_a' => 'datetime',
|
||||||
'demarree_a' => 'datetime',
|
'demarree_a' => 'datetime',
|
||||||
'terminee_a' => 'datetime',
|
'terminee_a' => 'datetime',
|
||||||
|
'visio_ouverte_a' => 'datetime',
|
||||||
|
'visio_chat' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function visioOuverte(): bool
|
||||||
|
{
|
||||||
|
return $this->visio_ouverte_a !== null && $this->visio_salle !== null;
|
||||||
|
}
|
||||||
|
|
||||||
public function classe(): BelongsTo
|
public function classe(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Classe::class);
|
return $this->belongsTo(Classe::class);
|
||||||
|
|
|
||||||
52
portail/app/Services/Visio/JetonVisio.php
Normal file
52
portail/app/Services/Visio/JetonVisio.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Visio;
|
||||||
|
|
||||||
|
use App\Models\Seance;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jeton JWT (HS256) qui ouvre une salle Jitsi : Prosody le vérifie avec le
|
||||||
|
* même secret. Le nom vient du jeton et ne se modifie pas ; seul le prof a
|
||||||
|
* moderator = true (module natif token_affiliation de Prosody).
|
||||||
|
*/
|
||||||
|
class JetonVisio
|
||||||
|
{
|
||||||
|
public function pour(Seance $seance, string $identifiant, string $nom, bool $moderateur): string
|
||||||
|
{
|
||||||
|
$secret = config('visio.jwt.secret');
|
||||||
|
|
||||||
|
if (! is_string($secret) || strlen($secret) < 32 || $seance->visio_salle === null) {
|
||||||
|
throw new RuntimeException('Visio non configurée ou salle non ouverte.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$maintenant = time();
|
||||||
|
$charge = [
|
||||||
|
'iss' => config('visio.jwt.app_id'),
|
||||||
|
'aud' => config('visio.jwt.audience'),
|
||||||
|
'sub' => '*',
|
||||||
|
'room' => $seance->visio_salle,
|
||||||
|
'iat' => $maintenant,
|
||||||
|
'nbf' => $maintenant - 10,
|
||||||
|
'exp' => $maintenant + 60 * (int) config('visio.jwt.duree_minutes'),
|
||||||
|
'context' => [
|
||||||
|
'user' => [
|
||||||
|
'id' => $identifiant,
|
||||||
|
'name' => $nom,
|
||||||
|
'moderator' => $moderateur,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$entete = $this->base64Url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||||
|
$corps = $this->base64Url(json_encode($charge, JSON_UNESCAPED_UNICODE));
|
||||||
|
$signature = $this->base64Url(hash_hmac('sha256', "{$entete}.{$corps}", $secret, true));
|
||||||
|
|
||||||
|
return "{$entete}.{$corps}.{$signature}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function base64Url(string $donnees): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($donnees), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ return [
|
||||||
// même temps, sessions d'élèves et stock compris : mesuré à l'étape 13
|
// même temps, sessions d'élèves et stock compris : mesuré à l'étape 13
|
||||||
// (docs/capacite.md). Doit rester aligné sur la réserve mémoire de Nova
|
// (docs/capacite.md). Doit rester aligné sur la réserve mémoire de Nova
|
||||||
// (kolla/config/nova/nova-compute.conf), qui refuse au-delà.
|
// (kolla/config/nova/nova-compute.conf), qui refuse au-delà.
|
||||||
'capacite_vm' => (int) env('LABS_CAPACITE_VM', 22),
|
'capacite_vm' => (int) env('LABS_CAPACITE_VM', 20),
|
||||||
|
|
||||||
'stock' => [
|
'stock' => [
|
||||||
// Stacks prêtes gardées en permanence pour chaque lab du catalogue,
|
// Stacks prêtes gardées en permanence pour chaque lab du catalogue,
|
||||||
|
|
|
||||||
14
portail/config/visio.php
Normal file
14
portail/config/visio.php
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// Étape 15 : visio de classe (Jitsi Meet auto-hébergé, ansible/roles/visio).
|
||||||
|
// Le secret est généré sur le serveur et copié dans .env par Ansible.
|
||||||
|
return [
|
||||||
|
'domaine' => env('VISIO_DOMAINE'),
|
||||||
|
'jwt' => [
|
||||||
|
'app_id' => env('VISIO_JWT_APP_ID', 'syllabus'),
|
||||||
|
'audience' => env('VISIO_JWT_AUDIENCE', 'syllabus-visio'),
|
||||||
|
'secret' => env('VISIO_JWT_SECRET'),
|
||||||
|
// Durée de validité d'un jeton : le temps d'une séance.
|
||||||
|
'duree_minutes' => 180,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?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('seances', function (Blueprint $table) {
|
||||||
|
$table->timestamp('visio_ouverte_a')->nullable()->after('terminee_a');
|
||||||
|
// Nom de salle Jitsi imprévisible : il figure dans les jetons, et
|
||||||
|
// un jeton ne vaut que pour cette salle.
|
||||||
|
$table->string('visio_salle', 64)->nullable()->after('visio_ouverte_a');
|
||||||
|
$table->boolean('visio_chat')->default(false)->after('visio_salle');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('seances', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['visio_ouverte_a', 'visio_salle', 'visio_chat']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
<li>sessions de lab : date, état, machines virtuelles attribuées ;</li>
|
<li>sessions de lab : date, état, machines virtuelles attribuées ;</li>
|
||||||
<li>résultats de la vérification automatique (réussi ou non, note, message par vérification).</li>
|
<li>résultats de la vérification automatique (réussi ou non, note, message par vérification).</li>
|
||||||
<li>s'il rejoint sa classe avec le code de classe : le prénom et le nom (facultatif) qu'il saisit, le temps que l'enseignant accepte ou refuse sa demande.</li>
|
<li>s'il rejoint sa classe avec le code de classe : le prénom et le nom (facultatif) qu'il saisit, le temps que l'enseignant accepte ou refuse sa demande.</li>
|
||||||
|
<li>pendant une visio de classe : le nom affiché aux autres participants (prénom et initiale du nom, non modifiable) et les mains levées. Le son et l’image transitent par notre serveur, en France, <strong>sans être enregistrés</strong>.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Ce que l’élève tape dans la console de sa machine virtuelle n’est pas enregistré par Syllabus. Son enseignant peut suivre la console en direct, en lecture seule, pendant la séance.</p>
|
<p>Ce que l’élève tape dans la console de sa machine virtuelle n’est pas enregistré par Syllabus. Son enseignant peut suivre la console en direct, en lecture seule, pendant la séance.</p>
|
||||||
<h3>Enseignants</h3>
|
<h3>Enseignants</h3>
|
||||||
|
|
@ -73,6 +74,7 @@
|
||||||
<li><strong>Classe et données de ses élèves</strong> : jusqu’à l’archivage de la classe par l’enseignant, puis <strong>supprimées automatiquement 90 jours après l’archivage</strong>.</li>
|
<li><strong>Classe et données de ses élèves</strong> : jusqu’à l’archivage de la classe par l’enseignant, puis <strong>supprimées automatiquement 90 jours après l’archivage</strong>.</li>
|
||||||
<li><strong>Demande pour rejoindre une classe</strong> : supprimée dès que l’élève a reçu son identifiant, et au plus tard une heure après son envoi.</li>
|
<li><strong>Demande pour rejoindre une classe</strong> : supprimée dès que l’élève a reçu son identifiant, et au plus tard une heure après son envoi.</li>
|
||||||
<li><strong>Compte enseignant</strong> : tant qu’il est utilisé ; supprimé sur demande.</li>
|
<li><strong>Compte enseignant</strong> : tant qu’il est utilisé ; supprimé sur demande.</li>
|
||||||
|
<li><strong>Visio</strong> : rien n’est enregistré ; l’état de la séance (mains levées, présence) reste en mémoire au plus 6 heures.</li>
|
||||||
<li><strong>Journaux techniques</strong> : 14 jours.</li>
|
<li><strong>Journaux techniques</strong> : 14 jours.</li>
|
||||||
<li><strong>Sauvegardes</strong> : chiffrées, conservées au plus 5 semaines (7 sauvegardes quotidiennes et 4 hebdomadaires). Une donnée supprimée disparaît donc des sauvegardes au plus tard 5 semaines après sa suppression.</li>
|
<li><strong>Sauvegardes</strong> : chiffrées, conservées au plus 5 semaines (7 sauvegardes quotidiennes et 4 hebdomadaires). Une donnée supprimée disparaît donc des sauvegardes au plus tard 5 semaines après sa suppression.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -112,7 +114,7 @@
|
||||||
<h2 id="t-cookies">Cookies</h2>
|
<h2 id="t-cookies">Cookies</h2>
|
||||||
<div class="mt-6 grid gap-4 text-lg font-medium leading-relaxed">
|
<div class="mt-6 grid gap-4 text-lg font-medium leading-relaxed">
|
||||||
<p>Syllabus n’utilise que deux cookies, strictement nécessaires : <span class="font-mono">syllabus-session</span>, qui vous garde connecté, et <span class="font-mono">XSRF-TOKEN</span>, qui protège les formulaires. Aucun cookie publicitaire ni de mesure d’audience. Ils ne demandent donc pas de consentement.</p>
|
<p>Syllabus n’utilise que deux cookies, strictement nécessaires : <span class="font-mono">syllabus-session</span>, qui vous garde connecté, et <span class="font-mono">XSRF-TOKEN</span>, qui protège les formulaires. Aucun cookie publicitaire ni de mesure d’audience. Ils ne demandent donc pas de consentement.</p>
|
||||||
<p>L’éditeur de code et la page de visio gardent aussi, dans le stockage local de votre navigateur, votre code et une préférence d’affichage. Ces informations ne nous sont pas transmises.</p>
|
<p>L’éditeur de code et la page de visio gardent aussi, dans le stockage local de votre navigateur, votre code et des préférences d’affichage (y compris celles du lecteur de visio, sur <span class="font-mono">meet.syllabuslab.fr</span>). Ces informations ne nous sont pas transmises.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@
|
||||||
@csrf
|
@csrf
|
||||||
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white">Rejoindre la séance</button>
|
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white">Rejoindre la séance</button>
|
||||||
</form>
|
</form>
|
||||||
|
@if ($seance->visioOuverte())
|
||||||
|
<a href="{{ route('seances.visio.eleve', $seance) }}" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Rejoindre la visio</a>
|
||||||
|
@endif
|
||||||
</li>
|
</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@
|
||||||
@section('contenu')
|
@section('contenu')
|
||||||
<div class="mx-auto max-w-5xl px-5 py-14 lg:px-10">
|
<div class="mx-auto max-w-5xl px-5 py-14 lg:px-10">
|
||||||
<p class="font-mono text-sm font-semibold">{{ $seance->lab->titre }}</p>
|
<p class="font-mono text-sm font-semibold">{{ $seance->lab->titre }}</p>
|
||||||
|
@if ($seance->visioOuverte())
|
||||||
|
<p class="mt-3"><a href="{{ route('seances.visio.eleve', $seance) }}" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Rejoindre la visio de la classe</a></p>
|
||||||
|
@endif
|
||||||
@if ($labSession->etat === $etats::EN_ATTENTE)
|
@if ($labSession->etat === $etats::EN_ATTENTE)
|
||||||
<div class="py-20 text-center">
|
<div class="py-20 text-center">
|
||||||
<h1 class="text-4xl font-extrabold tracking-tight">En attente que le prof t'admette…</h1>
|
<h1 class="text-4xl font-extrabold tracking-tight">En attente que le prof t'admette…</h1>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
<p class="font-mono text-sm font-semibold">{{ $seance->classe->nom }} · {{ $seance->lab->titre }}</p>
|
<p class="font-mono text-sm font-semibold">{{ $seance->classe->nom }} · {{ $seance->lab->titre }}</p>
|
||||||
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">Salle d'attente</h1>
|
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">Salle d'attente</h1>
|
||||||
<p class="mt-2 font-semibold">La page se rafraîchit toutes les 10 secondes.</p>
|
<p class="mt-2 font-semibold">La page se rafraîchit toutes les 10 secondes.</p>
|
||||||
|
<p class="mt-4"><a href="{{ route('seances.visio.prof', $seance) }}" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">{{ $seance->visioOuverte() ? 'Retourner à la visio' : 'Visio de la classe' }}</a></p>
|
||||||
|
|
||||||
@if ($seance->labSessions->isEmpty())
|
@if ($seance->labSessions->isEmpty())
|
||||||
<p class="mt-8 font-semibold">Personne n'a encore rejoint la séance.</p>
|
<p class="mt-8 font-semibold">Personne n'a encore rejoint la séance.</p>
|
||||||
|
|
|
||||||
100
portail/resources/views/seances/visio-eleve.blade.php
Normal file
100
portail/resources/views/seances/visio-eleve.blade.php
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
@extends('layouts.vitrine')
|
||||||
|
|
||||||
|
@section('titre', 'Visio · '.$seance->classe->nom)
|
||||||
|
|
||||||
|
{{-- Étape 15 : reprend design/resources/views/visio.blade.php (vue élève) ;
|
||||||
|
l'image factice est remplacée par la vraie visio (Jitsi, IFrame API). --}}
|
||||||
|
@section('contenu')
|
||||||
|
<main style="background:#f8f9fa">
|
||||||
|
<section style="padding:20px 20px 72px"><div style="max-width:1100px;margin:0 auto;background:#fff;border:1px solid #e3e5e8;border-radius:20px;overflow:hidden">
|
||||||
|
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid #e3e5e8"><span style="font-size:14px;font-weight:700;color:#0b1220">{{ $seance->classe->nom }} · {{ $seance->lab->titre }}</span><span data-statut-e style="display:inline-flex;align-items:center;gap:8px;height:32px;padding:0 14px;border-radius:999px;font-size:14px;font-weight:700;background:#eef0f3;color:#3c4043">{{ $jeton ? 'En sourdine' : 'En attente' }}</span></div>
|
||||||
|
@if ($jeton)
|
||||||
|
<div style="padding:20px"><div id="v-scene" style="position:relative;aspect-ratio:16/9;border-radius:16px;overflow:hidden;background:#202124"></div></div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:12px;padding:4px 20px 24px">
|
||||||
|
<button type="button" data-micro-e aria-label="Micro" style="width:52px;height:52px;border-radius:50%;display:grid;place-items:center;border:1px solid #dadce0;background:#fff;cursor:pointer"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3"/></svg></button>
|
||||||
|
<button type="button" data-cam-e aria-label="Caméra" style="width:52px;height:52px;border-radius:50%;display:grid;place-items:center;border:1px solid #dadce0;background:#fff;cursor:pointer"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg></button>
|
||||||
|
<button type="button" data-main-e aria-pressed="false" class="border border-[#e3e5e8] rounded-2xl bg-[#fce8e6] px-5 py-4 text-left text-lg font-extrabold aria-pressed:bg-[#fef7e0]" style="display:inline-flex;align-items:center;gap:10px;cursor:pointer"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 11V6a2 2 0 0 0-4 0v5"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10.5V6a2 2 0 0 0-4 0v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg><span>Lever la main</span></button>
|
||||||
|
<a href="{{ route('seances.eleve.attente', $seance) }}" data-quitter style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700;cursor:pointer;border-radius:999px;height:52px;padding:0 22px;background:#d93025;color:#fff;text-decoration:none"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>Quitter</a>
|
||||||
|
</div>
|
||||||
|
<p data-aide-e style="margin:0;padding:0 20px 24px;text-align:center;font-size:14px;font-weight:600;color:#5f6368">Tu es en sourdine. Lève la main pour demander la parole.</p>
|
||||||
|
@else
|
||||||
|
<p style="margin:0;padding:48px 20px;text-align:center;font-size:18px;font-weight:700;color:#3c4043">Ton prof n’a pas encore ouvert la visio. Garde cette page ouverte : tu entreras dès qu’il l’ouvrira.</p>
|
||||||
|
@endif
|
||||||
|
</div></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
@if ($jeton)
|
||||||
|
<script nonce="{{ Vite::cspNonce() }}" src="https://{{ config('visio.domaine') }}/external_api.js"></script>
|
||||||
|
@endif
|
||||||
|
<script nonce="{{ Vite::cspNonce() }}">
|
||||||
|
(function () {
|
||||||
|
var URL_ETAT = @json(route('seances.visio.eleve.etat', $seance));
|
||||||
|
var URL_MAIN = @json(route('seances.visio.eleve.main', $seance));
|
||||||
|
var URL_PARTICIPANT = @json(route('seances.visio.eleve.participant', $seance));
|
||||||
|
var CSRF = @json(csrf_token());
|
||||||
|
var OUVERTE = @json((bool) $jeton);
|
||||||
|
var $ = function (s) { return document.querySelector(s); };
|
||||||
|
var poster = function (url, donnees) {
|
||||||
|
return fetch(url, { method: 'POST', headers: { 'X-CSRF-TOKEN': CSRF, 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(donnees || {}) });
|
||||||
|
};
|
||||||
|
var api = null, moi = null, mainLevee = false, chat = null;
|
||||||
|
var BOUTONS = ['microphone', 'camera', 'raisehand', 'tileview', 'fullscreen', 'hangup'];
|
||||||
|
|
||||||
|
function statut(texte, couleur, fond, aide) {
|
||||||
|
var s = $('[data-statut-e]'); s.textContent = texte; s.style.color = couleur; s.style.background = fond;
|
||||||
|
var a = $('[data-aide-e]'); if (a && aide) a.textContent = aide;
|
||||||
|
}
|
||||||
|
|
||||||
|
@if ($jeton)
|
||||||
|
api = new JitsiMeetExternalAPI(@json(config('visio.domaine')), {
|
||||||
|
roomName: @json($seance->visio_salle),
|
||||||
|
jwt: @json($jeton),
|
||||||
|
parentNode: $('#v-scene'),
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
configOverwrite: {
|
||||||
|
startWithAudioMuted: true, startWithVideoMuted: true, readOnlyName: true,
|
||||||
|
prejoinConfig: { enabled: false }, disableDeepLinking: true,
|
||||||
|
toolbarButtons: BOUTONS.concat(@json($seance->visio_chat) ? ['chat'] : [])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
api.addListener('videoConferenceJoined', function (e) {
|
||||||
|
moi = e.id;
|
||||||
|
poster(URL_PARTICIPANT, { participant_id: e.id });
|
||||||
|
});
|
||||||
|
// La main levée passe toujours par Jitsi (bouton de la page ou de Jitsi) ;
|
||||||
|
// l'application suit l'événement : une seule source, pas de désaccord.
|
||||||
|
api.addListener('raiseHandUpdated', function (e) {
|
||||||
|
if (e.id !== moi) return;
|
||||||
|
mainLevee = !!e.handRaised;
|
||||||
|
$('[data-main-e]').setAttribute('aria-pressed', String(mainLevee));
|
||||||
|
$('[data-main-e] span').textContent = mainLevee ? 'Baisser la main' : 'Lever la main';
|
||||||
|
poster(URL_MAIN, { levee: mainLevee });
|
||||||
|
});
|
||||||
|
api.addListener('audioMuteStatusChanged', function (e) {
|
||||||
|
if (e.muted) statut('En sourdine', '#3c4043', '#eef0f3', 'Tu es en sourdine. Lève la main pour demander la parole.');
|
||||||
|
else statut('Tu as la parole', '#0d652d', '#e6f4ea', 'Ton prof t’a donné la parole. Coupe ton micro quand tu as fini.');
|
||||||
|
});
|
||||||
|
api.addListener('readyToClose', function () { location.href = $('[data-quitter]').href; });
|
||||||
|
$('[data-main-e]').addEventListener('click', function () { api.executeCommand('toggleRaiseHand'); });
|
||||||
|
$('[data-micro-e]').addEventListener('click', function () { api.executeCommand('toggleAudio'); });
|
||||||
|
$('[data-cam-e]').addEventListener('click', function () { api.executeCommand('toggleVideo'); });
|
||||||
|
$('[data-quitter]').addEventListener('click', function () { try { api.executeCommand('hangup'); } catch (e) {} });
|
||||||
|
@endif
|
||||||
|
|
||||||
|
function rafraichir() {
|
||||||
|
fetch(URL_ETAT, { headers: { 'Accept': 'application/json' } }).then(function (r) { return r.json(); }).then(function (e) {
|
||||||
|
if (e.ouverte !== OUVERTE) { location.reload(); return; }
|
||||||
|
if (!api) return;
|
||||||
|
// Le prof a donné la parole : l'application a baissé la main, Jitsi suit.
|
||||||
|
if (mainLevee && !e.main_levee) api.executeCommand('toggleRaiseHand');
|
||||||
|
if (chat !== e.chat) {
|
||||||
|
chat = e.chat;
|
||||||
|
api.executeCommand('overwriteConfig', { toolbarButtons: BOUTONS.concat(chat ? ['chat'] : []) });
|
||||||
|
}
|
||||||
|
}).catch(function () {});
|
||||||
|
}
|
||||||
|
rafraichir();
|
||||||
|
setInterval(rafraichir, 2000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
138
portail/resources/views/seances/visio-prof.blade.php
Normal file
138
portail/resources/views/seances/visio-prof.blade.php
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
@extends('layouts.vitrine')
|
||||||
|
|
||||||
|
@section('titre', 'Visio · '.$seance->classe->nom)
|
||||||
|
|
||||||
|
{{-- Étape 15 : reprend design/resources/views/visio.blade.php (vue prof) ; la
|
||||||
|
grille factice est remplacée par la vraie visio (Jitsi, IFrame API). --}}
|
||||||
|
@section('contenu')
|
||||||
|
<main style="background:#f8f9fa">
|
||||||
|
<section style="padding:40px 20px 8px"><div style="max-width:1280px;margin:0 auto">
|
||||||
|
<p style="margin:0;display:inline-flex;align-items:center;gap:8px;border:1px solid #b8c1cf;border-radius:999px;background:#fff;padding:4px 12px;font-size:14px;font-weight:700;color:#1d3fa8">Visio de classe</p>
|
||||||
|
<h1 style="margin:14px 0 0;font-size:clamp(30px,4vw,44px);line-height:1.1;font-weight:800;letter-spacing:-0.02em;color:#0b1220">{{ $seance->classe->nom }} · {{ $seance->lab->titre }}</h1>
|
||||||
|
<p style="margin:12px 0 0;max-width:720px;font-size:18px;line-height:1.6;font-weight:500;color:#3c4043">Tous les élèves arrivent en sourdine. Ils lèvent la main, vous donnez la parole, un à la fois.</p>
|
||||||
|
</div></section>
|
||||||
|
|
||||||
|
<section style="padding:20px 20px 72px"><div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]" style="max-width:1280px;margin:0 auto">
|
||||||
|
<div style="background:#fff;border:1px solid #e3e5e8;border-radius:20px;overflow:hidden;min-width:0">
|
||||||
|
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid #e3e5e8">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
|
@if ($jeton)
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border-radius:999px;background:#fce8e6;color:#b3261e;font-size:13px;font-weight:800"><span style="width:8px;height:8px;border-radius:50%;background:#d93025"></span>En direct · <span id="v-chrono" style="font-family:'IBM Plex Mono',monospace">00:00:00</span></span>
|
||||||
|
@else
|
||||||
|
<span style="display:inline-flex;align-items:center;height:28px;padding:0 10px;border-radius:999px;background:#eef0f3;color:#3c4043;font-size:13px;font-weight:800">Visio fermée</span>
|
||||||
|
@endif
|
||||||
|
<span style="font-size:14px;font-weight:700;color:#0b1220">{{ $seance->classe->nom }}</span><span id="v-nb" style="font-size:14px;font-weight:600;color:#5f6368"></span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
@if ($jeton)
|
||||||
|
<button type="button" id="v-mute-all" style="display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 18px;border-radius:999px;font-size:14px;font-weight:700;cursor:pointer;border:1px solid #dadce0;background:#fff;color:#0b1220"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3M3 3l18 18"/></svg>Couper tous les micros</button>
|
||||||
|
<form id="v-terminer" action="{{ route('seances.visio.fermer', $seance) }}" method="post" style="margin:0">@csrf<button type="submit" style="display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 18px;border-radius:999px;font-size:14px;font-weight:700;cursor:pointer;border:0;background:#d93025;color:#fff">Terminer la visio</button></form>
|
||||||
|
@else
|
||||||
|
<form action="{{ route('seances.visio.ouvrir', $seance) }}" method="post" style="margin:0">@csrf<button type="submit" style="display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 18px;border-radius:999px;font-size:14px;font-weight:700;cursor:pointer;border:0;background:#0b57d0;color:#fff">Ouvrir la visio</button></form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if ($jeton)
|
||||||
|
<div id="v-scene" style="height:min(70vh,620px);background:#202124"></div>
|
||||||
|
@else
|
||||||
|
<p style="margin:0;padding:40px 20px;font-size:16px;font-weight:600;color:#3c4043">La visio n’est pas ouverte. Les élèves qui la rejoignent attendent dans la salle d’attente ; ouvrez-la pour les faire entrer.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;gap:20px;align-content:start;min-width:0">
|
||||||
|
<section style="background:#fff;border:1px solid #e3e5e8;border-radius:20px" aria-labelledby="t-mains"><div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #e3e5e8"><h2 id="t-mains" style="margin:0;font-size:16px;font-weight:800;color:#0b1220">Mains levées</h2><span id="v-nb-mains" style="min-width:28px;height:28px;padding:0 8px;border-radius:999px;background:#fef7e0;color:#7a4b00;display:inline-grid;place-items:center;font-size:13px;font-weight:800">0</span></div><ol id="v-mains" style="margin:0;padding:8px 12px;list-style:none"></ol><p style="margin:0;padding:0 20px 14px;font-size:13px;font-weight:600;color:#5f6368">Premier arrivé en haut.</p></section>
|
||||||
|
<section style="background:#fff;border:1px solid #e3e5e8;border-radius:20px" aria-labelledby="t-attente"><div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:16px 20px;border-bottom:1px solid #e3e5e8"><h2 id="t-attente" style="margin:0;font-size:16px;font-weight:800;color:#0b1220">Salle d’attente</h2>@unless ($jeton)<form action="{{ route('seances.visio.ouvrir', $seance) }}" method="post" style="margin:0">@csrf<button type="submit" id="v-tout-admettre" style="border:0;background:none;color:#0b57d0;font-size:14px;font-weight:700;cursor:pointer">Tout admettre</button></form>@endunless</div><ul id="v-attente" style="margin:0;padding:8px 12px;list-style:none"></ul></section>
|
||||||
|
<section style="background:#fff;border:1px solid #e3e5e8;border-radius:20px" aria-labelledby="t-chat"><div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:16px 20px;border-bottom:1px solid #e3e5e8"><h2 id="t-chat" style="margin:0;font-size:16px;font-weight:800;color:#0b1220">Chat des élèves</h2><button type="button" id="v-chat-switch" role="switch" aria-checked="{{ $seance->visio_chat ? 'true' : 'false' }}" aria-label="Activer le chat des élèves" @disabled(! $jeton) style="position:relative;width:44px;height:26px;border-radius:999px;border:0;cursor:pointer;transition:background .2s;background:{{ $seance->visio_chat ? '#0b57d0' : '#c4c7c5' }}"><span style="position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);transition:transform .2s;transform:translateX({{ $seance->visio_chat ? '18px' : '0' }})"></span></button></div><div id="v-chat" style="padding:16px 20px;font-size:14px;font-weight:600;color:#5f6368">{{ $seance->visio_chat ? 'Le chat est ouvert aux élèves.' : 'Le chat est désactivé.' }}</div></section>
|
||||||
|
</div>
|
||||||
|
</div></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
@if ($jeton)
|
||||||
|
<script nonce="{{ Vite::cspNonce() }}" src="https://{{ config('visio.domaine') }}/external_api.js"></script>
|
||||||
|
@endif
|
||||||
|
<script nonce="{{ Vite::cspNonce() }}">
|
||||||
|
(function () {
|
||||||
|
var URL_ETAT = @json(route('seances.visio.etat', $seance));
|
||||||
|
var URL_CHAT = @json(route('seances.visio.chat', $seance));
|
||||||
|
var URL_PAROLE = @json(route('seances.visio.parole', [$seance, '__ELEVE__']));
|
||||||
|
var CSRF = @json(csrf_token());
|
||||||
|
var $ = function (s) { return document.querySelector(s); };
|
||||||
|
var poster = function (url, donnees) {
|
||||||
|
return fetch(url, { method: 'POST', headers: { 'X-CSRF-TOKEN': CSRF, 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(donnees || {}) });
|
||||||
|
};
|
||||||
|
var esc = function (t) { var d = document.createElement('span'); d.textContent = t; return d.innerHTML; };
|
||||||
|
var api = null;
|
||||||
|
var BOUTONS = ['microphone', 'camera', 'desktop', 'raisehand', 'participants-pane', 'tileview', 'settings', 'fullscreen', 'hangup'];
|
||||||
|
|
||||||
|
@if ($jeton)
|
||||||
|
api = new JitsiMeetExternalAPI(@json(config('visio.domaine')), {
|
||||||
|
roomName: @json($seance->visio_salle),
|
||||||
|
jwt: @json($jeton),
|
||||||
|
parentNode: $('#v-scene'),
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
configOverwrite: {
|
||||||
|
startWithAudioMuted: true, startWithVideoMuted: true, readOnlyName: true,
|
||||||
|
prejoinConfig: { enabled: false }, disableDeepLinking: true,
|
||||||
|
toolbarButtons: BOUTONS.concat(@json($seance->visio_chat) ? ['chat'] : [])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Seul le prof donne la parole : modération du micro dès l'arrivée.
|
||||||
|
api.addListener('videoConferenceJoined', function () {
|
||||||
|
api.executeCommand('toggleModeration', true, 'audio');
|
||||||
|
});
|
||||||
|
var compter = function () { $('#v-nb').textContent = '· ' + api.getNumberOfParticipants() + ' en ligne'; };
|
||||||
|
api.addListener('participantJoined', compter);
|
||||||
|
api.addListener('participantLeft', compter);
|
||||||
|
api.addListener('readyToClose', function () { $('#v-terminer').submit(); });
|
||||||
|
$('#v-mute-all').addEventListener('click', function () { api.executeCommand('muteEveryone', 'audio'); });
|
||||||
|
$('#v-terminer').addEventListener('submit', function () { try { api.executeCommand('endConference'); } catch (e) {} });
|
||||||
|
|
||||||
|
var debut = new Date(@json($seance->visio_ouverte_a?->toIso8601String())).getTime();
|
||||||
|
setInterval(function () {
|
||||||
|
var s = Math.max(0, Math.floor((Date.now() - debut) / 1000));
|
||||||
|
$('#v-chrono').textContent = [Math.floor(s / 3600), Math.floor(s / 60) % 60, s % 60].map(function (n) { return String(n).padStart(2, '0'); }).join(':');
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
$('#v-chat-switch').addEventListener('click', function () {
|
||||||
|
var actif = this.getAttribute('aria-checked') !== 'true';
|
||||||
|
poster(URL_CHAT, { actif: actif }).then(function () { rafraichir(); });
|
||||||
|
});
|
||||||
|
@endif
|
||||||
|
|
||||||
|
function rafraichir() {
|
||||||
|
fetch(URL_ETAT, { headers: { 'Accept': 'application/json' } }).then(function (r) { return r.json(); }).then(function (e) {
|
||||||
|
$('#v-nb-mains').textContent = e.mains.length;
|
||||||
|
$('#v-mains').innerHTML = e.mains.length ? e.mains.map(function (m) {
|
||||||
|
var attente = Math.max(0, Math.floor(Date.now() / 1000) - m.depuis);
|
||||||
|
return '<li style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px;border-radius:12px">'
|
||||||
|
+ '<span><strong>' + esc(m.nom) + '</strong> <span style="font-size:13px;color:#5f6368">' + (attente < 60 ? attente + ' s' : Math.floor(attente / 60) + ' min') + '</span></span>'
|
||||||
|
+ (api && m.participant_id ? '<button type="button" data-parole="' + m.eleve_id + '" data-participant="' + esc(m.participant_id) + '" style="display:inline-flex;align-items:center;height:32px;padding:0 14px;border-radius:999px;border:0;cursor:pointer;font-size:13px;font-weight:700;background:#0b57d0;color:#fff">Donner la parole</button>' : '')
|
||||||
|
+ '</li>';
|
||||||
|
}).join('') : '<li style="padding:8px;font-size:14px;color:#5f6368">Aucune main levée.</li>';
|
||||||
|
$('#v-attente').innerHTML = e.attente.length ? e.attente.map(function (a) {
|
||||||
|
return '<li style="padding:8px;font-weight:700">' + esc(a.nom) + '</li>';
|
||||||
|
}).join('') : '<li style="padding:8px;font-size:14px;color:#5f6368">Personne n’attend.</li>';
|
||||||
|
var sw = $('#v-chat-switch');
|
||||||
|
if (sw.getAttribute('aria-checked') !== String(e.chat)) {
|
||||||
|
sw.setAttribute('aria-checked', String(e.chat));
|
||||||
|
sw.style.background = e.chat ? '#0b57d0' : '#c4c7c5';
|
||||||
|
sw.firstElementChild.style.transform = e.chat ? 'translateX(18px)' : 'translateX(0)';
|
||||||
|
$('#v-chat').textContent = e.chat ? 'Le chat est ouvert aux élèves.' : 'Le chat est désactivé.';
|
||||||
|
if (api) api.executeCommand('overwriteConfig', { toolbarButtons: BOUTONS.concat(e.chat ? ['chat'] : []) });
|
||||||
|
}
|
||||||
|
}).catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Donner la parole » : askToUnmute autorise le micro quand la modération
|
||||||
|
// est active (documentation de l'IFrame API), puis la main est baissée.
|
||||||
|
$('#v-mains').addEventListener('click', function (ev) {
|
||||||
|
var b = ev.target.closest('[data-parole]');
|
||||||
|
if (!b || !api) return;
|
||||||
|
api.executeCommand('askToUnmute', b.dataset.participant);
|
||||||
|
poster(URL_PAROLE.replace('__ELEVE__', b.dataset.parole)).then(rafraichir);
|
||||||
|
});
|
||||||
|
|
||||||
|
rafraichir();
|
||||||
|
setInterval(rafraichir, 2000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
|
|
@ -9,6 +9,7 @@ use App\Http\Controllers\InscriptionController;
|
||||||
use App\Http\Controllers\RapportCspController;
|
use App\Http\Controllers\RapportCspController;
|
||||||
use App\Http\Controllers\RejoindreClasseController;
|
use App\Http\Controllers\RejoindreClasseController;
|
||||||
use App\Http\Controllers\SeanceController;
|
use App\Http\Controllers\SeanceController;
|
||||||
|
use App\Http\Controllers\VisioController;
|
||||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
|
@ -87,6 +88,10 @@ Route::middleware(['auth:eleve', 'session.unique:eleve'])->group(function () {
|
||||||
Route::post('/seances/{seance}/rejoindre', [SeanceController::class, 'rejoindre'])->name('seances.rejoindre');
|
Route::post('/seances/{seance}/rejoindre', [SeanceController::class, 'rejoindre'])->name('seances.rejoindre');
|
||||||
Route::get('/seances/{seance}/attente-eleve', [SeanceController::class, 'attenteEleve'])->name('seances.eleve.attente');
|
Route::get('/seances/{seance}/attente-eleve', [SeanceController::class, 'attenteEleve'])->name('seances.eleve.attente');
|
||||||
Route::get('/seances/{seance}/etat-eleve', [SeanceController::class, 'etatEleve'])->name('seances.eleve.etat');
|
Route::get('/seances/{seance}/etat-eleve', [SeanceController::class, 'etatEleve'])->name('seances.eleve.etat');
|
||||||
|
Route::get('/seances/{seance}/visio/eleve', [VisioController::class, 'eleve'])->name('seances.visio.eleve');
|
||||||
|
Route::get('/seances/{seance}/visio/eleve/etat', [VisioController::class, 'etatEleve'])->name('seances.visio.eleve.etat');
|
||||||
|
Route::post('/seances/{seance}/visio/eleve/main', [VisioController::class, 'main'])->name('seances.visio.eleve.main');
|
||||||
|
Route::post('/seances/{seance}/visio/eleve/participant', [VisioController::class, 'participant'])->name('seances.visio.eleve.participant');
|
||||||
Route::post('/lab-sessions/{labSession}/corriger', [CorrectionController::class, 'corriger'])
|
Route::post('/lab-sessions/{labSession}/corriger', [CorrectionController::class, 'corriger'])
|
||||||
->middleware('throttle:6,1')
|
->middleware('throttle:6,1')
|
||||||
->name('lab-sessions.corriger');
|
->name('lab-sessions.corriger');
|
||||||
|
|
@ -108,6 +113,12 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () {
|
||||||
Route::post('/mes-classes/{classe}/desarchiver', [ClasseController::class, 'desarchiver'])->name('mes-classes.desarchiver');
|
Route::post('/mes-classes/{classe}/desarchiver', [ClasseController::class, 'desarchiver'])->name('mes-classes.desarchiver');
|
||||||
Route::post('/mes-classes/{classe}/seances', [SeanceController::class, 'store'])->name('mes-classes.seances.store');
|
Route::post('/mes-classes/{classe}/seances', [SeanceController::class, 'store'])->name('mes-classes.seances.store');
|
||||||
Route::get('/seances/{seance}/attente', [SeanceController::class, 'attente'])->name('seances.attente');
|
Route::get('/seances/{seance}/attente', [SeanceController::class, 'attente'])->name('seances.attente');
|
||||||
|
Route::get('/seances/{seance}/visio', [VisioController::class, 'prof'])->name('seances.visio.prof');
|
||||||
|
Route::post('/seances/{seance}/visio/ouvrir', [VisioController::class, 'ouvrir'])->name('seances.visio.ouvrir');
|
||||||
|
Route::post('/seances/{seance}/visio/fermer', [VisioController::class, 'fermer'])->name('seances.visio.fermer');
|
||||||
|
Route::get('/seances/{seance}/visio/etat', [VisioController::class, 'etatProf'])->name('seances.visio.etat');
|
||||||
|
Route::post('/seances/{seance}/visio/eleves/{eleve}/parole', [VisioController::class, 'parole'])->name('seances.visio.parole');
|
||||||
|
Route::post('/seances/{seance}/visio/chat', [VisioController::class, 'chat'])->name('seances.visio.chat');
|
||||||
Route::post('/seances/{seance}/ouvrir', [SeanceController::class, 'ouvrir'])->name('seances.ouvrir');
|
Route::post('/seances/{seance}/ouvrir', [SeanceController::class, 'ouvrir'])->name('seances.ouvrir');
|
||||||
Route::post('/seances/{seance}/admettre/{labSession}', [SeanceController::class, 'admettre'])->name('seances.admettre');
|
Route::post('/seances/{seance}/admettre/{labSession}', [SeanceController::class, 'admettre'])->name('seances.admettre');
|
||||||
Route::get('/seances/{seance}/labsessions/{labSession}/console', [SeanceController::class, 'consoleProf'])->name('seances.console.prof.page');
|
Route::get('/seances/{seance}/labsessions/{labSession}/console', [SeanceController::class, 'consoleProf'])->name('seances.console.prof.page');
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ test('les pages légales sont en ligne et la page de statut redirige vers le sit
|
||||||
$this->get(route('confidentialite'))->assertOk()
|
$this->get(route('confidentialite'))->assertOk()
|
||||||
->assertSee('90 jours après l’archivage', false)
|
->assertSee('90 jours après l’archivage', false)
|
||||||
->assertSee('aucune adresse e-mail', false)
|
->assertSee('aucune adresse e-mail', false)
|
||||||
->assertSee('cdn.tailwindcss.com');
|
->assertSee('cdn.tailwindcss.com')
|
||||||
|
->assertSee('sans être enregistrés', false);
|
||||||
$this->get(route('statut'))->assertRedirect('https://status.syllabuslab.fr');
|
$this->get(route('statut'))->assertRedirect('https://status.syllabuslab.fr');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
139
portail/tests/Feature/VisioTest.php
Normal file
139
portail/tests/Feature/VisioTest.php
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Classe;
|
||||||
|
use App\Models\Eleve;
|
||||||
|
use App\Models\Lab;
|
||||||
|
use App\Models\Seance;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Visio\JetonVisio;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config(['visio.domaine' => 'meet.test', 'visio.jwt.secret' => str_repeat('s', 64)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function seanceAvecClasse(): Seance
|
||||||
|
{
|
||||||
|
$classe = Classe::factory()->create();
|
||||||
|
|
||||||
|
return Seance::create(['classe_id' => $classe->id, 'lab_id' => Lab::factory()->create()->id, 'statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prof($test, User $prof)
|
||||||
|
{
|
||||||
|
return $test->withSession(['jeton_session_web' => $prof->demarreNouvelleSession()])->actingAs($prof, 'web');
|
||||||
|
}
|
||||||
|
|
||||||
|
function eleve($test, Eleve $eleve)
|
||||||
|
{
|
||||||
|
return $test->withSession(['jeton_session_eleve' => $eleve->demarreNouvelleSession()])->actingAs($eleve, 'eleve');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Décode un JWT HS256 en vérifiant sa signature, comme le fait Prosody. */
|
||||||
|
function decoderJeton(string $jeton): array
|
||||||
|
{
|
||||||
|
[$entete, $corps, $signature] = explode('.', $jeton);
|
||||||
|
$attendue = rtrim(strtr(base64_encode(hash_hmac('sha256', "{$entete}.{$corps}", str_repeat('s', 64), true)), '+/', '-_'), '=');
|
||||||
|
expect(hash_equals($attendue, $signature))->toBeTrue();
|
||||||
|
|
||||||
|
return json_decode(base64_decode(strtr($corps, '-_', '+/')), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('le jeton est signé, limité à la salle, et seul le prof est modérateur', function () {
|
||||||
|
$seance = seanceAvecClasse();
|
||||||
|
$seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-1-abc']);
|
||||||
|
|
||||||
|
$prof = decoderJeton(app(JetonVisio::class)->pour($seance, 'prof-1', 'M. Prof', true));
|
||||||
|
$eleve = decoderJeton(app(JetonVisio::class)->pour($seance, 'eleve-1', 'Léa M.', false));
|
||||||
|
|
||||||
|
expect($prof['room'])->toBe('syllabus-1-abc')
|
||||||
|
->and($prof['iss'])->toBe('syllabus')->and($prof['aud'])->toBe('syllabus-visio')
|
||||||
|
->and($prof['context']['user']['moderator'])->toBeTrue()
|
||||||
|
->and($eleve['context']['user']['moderator'])->toBeFalse()
|
||||||
|
->and($eleve['context']['user']['name'])->toBe('Léa M.')
|
||||||
|
->and($eleve['exp'])->toBeGreaterThan(time());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le prof ouvre la visio ; un autre prof ne peut ni l’ouvrir ni la voir', function () {
|
||||||
|
$seance = seanceAvecClasse();
|
||||||
|
|
||||||
|
prof($this, User::factory()->create())->post(route('seances.visio.ouvrir', $seance))->assertForbidden();
|
||||||
|
prof($this, $seance->classe->prof)->post(route('seances.visio.ouvrir', $seance))->assertRedirect(route('seances.visio.prof', $seance));
|
||||||
|
|
||||||
|
$seance->refresh();
|
||||||
|
expect($seance->visioOuverte())->toBeTrue()->and($seance->visio_salle)->toMatch('/^syllabus-\d+-[a-z0-9]{16}$/');
|
||||||
|
|
||||||
|
$page = prof($this, $seance->classe->prof)->get(route('seances.visio.prof', $seance))->assertOk()->assertSee('https://meet.test/external_api.js', false);
|
||||||
|
preg_match('/jwt: "([^"]+)"/', $page->getContent(), $m);
|
||||||
|
expect(decoderJeton($m[1])['context']['user']['moderator'])->toBeTrue();
|
||||||
|
expect($page->headers->get('Content-Security-Policy'))->toContain('frame-src \'self\' https://meet.test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un élève de la classe attend, puis entre sans être modérateur, avec un nom non modifiable ; un élève d’une autre classe est refusé', function () {
|
||||||
|
$seance = seanceAvecClasse();
|
||||||
|
$lea = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Léa', 'nom' => 'Martin']);
|
||||||
|
|
||||||
|
eleve($this, $lea)->get(route('seances.visio.eleve', $seance))->assertOk()->assertSee('pas encore ouvert la visio', false)->assertDontSee('jwt:', false);
|
||||||
|
|
||||||
|
$seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-x']);
|
||||||
|
$page = eleve($this, $lea)->get(route('seances.visio.eleve', $seance))->assertOk()->assertSee('readOnlyName: true', false);
|
||||||
|
preg_match('/jwt: "([^"]+)"/', $page->getContent(), $m);
|
||||||
|
$jeton = decoderJeton($m[1]);
|
||||||
|
expect($jeton['context']['user']['moderator'])->toBeFalse()->and($jeton['context']['user']['name'])->toBe('Léa M.');
|
||||||
|
|
||||||
|
$intrus = Eleve::factory()->create();
|
||||||
|
eleve($this, $intrus)->get(route('seances.visio.eleve', $seance))->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les mains levées arrivent chez le prof dans l’ordre ; donner la parole baisse la main', function () {
|
||||||
|
$seance = seanceAvecClasse();
|
||||||
|
$seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-x']);
|
||||||
|
$a = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Yanis', 'nom' => 'B']);
|
||||||
|
$b = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Adam', 'nom' => 'D']);
|
||||||
|
|
||||||
|
eleve($this, $a)->postJson(route('seances.visio.eleve.participant', $seance), ['participant_id' => 'abc123']);
|
||||||
|
eleve($this, $a)->postJson(route('seances.visio.eleve.main', $seance), ['levee' => true]);
|
||||||
|
$this->travel(2)->seconds();
|
||||||
|
eleve($this, $b)->postJson(route('seances.visio.eleve.main', $seance), ['levee' => true]);
|
||||||
|
|
||||||
|
$etat = prof($this, $seance->classe->prof)->getJson(route('seances.visio.etat', $seance))->json();
|
||||||
|
expect(collect($etat['mains'])->pluck('nom')->all())->toBe(['Yanis B.', 'Adam D.'])
|
||||||
|
->and($etat['mains'][0]['participant_id'])->toBe('abc123');
|
||||||
|
|
||||||
|
prof($this, $seance->classe->prof)->postJson(route('seances.visio.parole', [$seance, $a]))->assertOk();
|
||||||
|
expect(collect(prof($this, $seance->classe->prof)->getJson(route('seances.visio.etat', $seance))->json('mains'))->pluck('nom')->all())->toBe(['Adam D.'])
|
||||||
|
->and(eleve($this, $a)->getJson(route('seances.visio.eleve.etat', $seance))->json('main_levee'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la salle d’attente montre les élèves présents tant que la visio est fermée', function () {
|
||||||
|
$seance = seanceAvecClasse();
|
||||||
|
$lea = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Léa', 'nom' => 'M']);
|
||||||
|
|
||||||
|
eleve($this, $lea)->getJson(route('seances.visio.eleve.etat', $seance))->assertJson(['ouverte' => false]);
|
||||||
|
|
||||||
|
expect(prof($this, $seance->classe->prof)->getJson(route('seances.visio.etat', $seance))->json('attente'))->toBe([['nom' => 'Léa M.']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les scripts des pages de visio sont du JavaScript valide', function () {
|
||||||
|
if (Process::run('node --version')->failed()) {
|
||||||
|
$this->markTestSkipped('Node absent');
|
||||||
|
}
|
||||||
|
$seance = seanceAvecClasse();
|
||||||
|
$seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-x']);
|
||||||
|
$lea = Eleve::factory()->create(['classe_id' => $seance->classe_id]);
|
||||||
|
|
||||||
|
$pages = [
|
||||||
|
prof($this, $seance->classe->prof)->get(route('seances.visio.prof', $seance))->getContent(),
|
||||||
|
eleve($this, $lea)->get(route('seances.visio.eleve', $seance))->getContent(),
|
||||||
|
];
|
||||||
|
foreach ($pages as $html) {
|
||||||
|
preg_match_all('#<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>#s', $html, $scripts);
|
||||||
|
foreach ($scripts[1] as $code) {
|
||||||
|
$fichier = tempnam(sys_get_temp_dir(), 'js').'.js';
|
||||||
|
file_put_contents($fichier, $code);
|
||||||
|
$r = Process::run(['node', '--check', $fichier]);
|
||||||
|
unlink($fichier);
|
||||||
|
expect($r->successful())->toBeTrue($r->errorOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
31
portail/tests/Reels/preparer-visio.php
Normal file
31
portail/tests/Reels/preparer-visio.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// Données de test de la visio (étape 15), sur le serveur :
|
||||||
|
// sudo -u www-data HOME=/tmp php8.3 artisan tinker tests/Reels/preparer-visio.php
|
||||||
|
// Identifiants dans un fichier 600, jamais affichés. À supprimer après le test.
|
||||||
|
|
||||||
|
use App\Models\Classe;
|
||||||
|
use App\Models\Eleve;
|
||||||
|
use App\Models\Etablissement;
|
||||||
|
use App\Models\Lab;
|
||||||
|
use App\Models\Seance;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
$etablissement = Etablissement::create(['nom' => 'Lycée Test Visio', 'ville' => 'Test']);
|
||||||
|
$motDePasse = Str::password(24, symbols: false);
|
||||||
|
$prof = User::create(['name' => 'Prof Visio', 'email' => 'prof-visio@syllabus.test', 'password' => $motDePasse, 'etablissement_id' => $etablissement->id]);
|
||||||
|
$classe = Classe::create(['etablissement_id' => $etablissement->id, 'user_id' => $prof->id, 'nom' => 'Classe Test Visio', 'formation' => 'Test']);
|
||||||
|
$eleves = [];
|
||||||
|
foreach ([['Lea', 'Martin', 'lea.visio'], ['Yanis', 'Bernard', 'yanis.visio']] as [$prenom, $nom, $identifiant]) {
|
||||||
|
$code = Str::upper(Str::random(6));
|
||||||
|
Eleve::create(['classe_id' => $classe->id, 'prenom' => $prenom, 'nom' => $nom, 'identifiant' => $identifiant, 'code_secret' => Hash::make($code), 'jeton_qr' => Str::random(32)]);
|
||||||
|
$eleves[] = ['identifiant' => $identifiant, 'code' => $code];
|
||||||
|
}
|
||||||
|
$seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => Lab::where('identifiant', '08-deux-lan-routeur')->value('id'), 'statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now()]);
|
||||||
|
|
||||||
|
$fichier = '/tmp/visio-identifiants.json';
|
||||||
|
file_put_contents($fichier, json_encode(['prof' => ['email' => $prof->email, 'mot_de_passe' => $motDePasse], 'eleves' => $eleves, 'seance_id' => $seance->id]));
|
||||||
|
chmod($fichier, 0600);
|
||||||
|
echo "données de test prêtes (séance {$seance->id})\n";
|
||||||
91
portail/tests/Reels/visio-e2e.mjs
Normal file
91
portail/tests/Reels/visio-e2e.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
// Critère de l'étape 15, dans de vrais navigateurs (Chromium, caméra et micro
|
||||||
|
// simulés) : un prof et deux élèves rejoignent la visio ; un élève lève la
|
||||||
|
// main ; le prof lui donne la parole ; un intrus sans jeton ne peut pas entrer.
|
||||||
|
// node visio-e2e.mjs <fichier d'identifiants> [https://syllabuslab.fr]
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const ids = JSON.parse(readFileSync(process.argv[2], 'utf8'));
|
||||||
|
const BASE = process.argv[3] ?? 'https://syllabuslab.fr';
|
||||||
|
const S = ids.seance_id;
|
||||||
|
const navigateur = await chromium.launch({ args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'] });
|
||||||
|
const contexte = () => navigateur.newContext({ locale: 'fr-FR', permissions: ['camera', 'microphone'] });
|
||||||
|
const etape = (texte) => console.log(`${new Date().toISOString().slice(11, 19)} ${texte}`);
|
||||||
|
const attendre = async (fn, delai = 30000, pas = 500) => {
|
||||||
|
const fin = Date.now() + delai;
|
||||||
|
while (Date.now() < fin) { const r = await fn(); if (r) return r; await new Promise((ok) => setTimeout(ok, pas)); }
|
||||||
|
throw new Error('délai dépassé');
|
||||||
|
};
|
||||||
|
|
||||||
|
async function connexion(page, champs, bouton) {
|
||||||
|
await page.goto(`${BASE}/connexion`);
|
||||||
|
for (const [nom, valeur] of Object.entries(champs)) await page.fill(`input[name="${nom}"]`, valeur);
|
||||||
|
await page.click(bouton);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Le prof ouvre la visio.
|
||||||
|
const prof = await (await contexte()).newPage();
|
||||||
|
await connexion(prof, { email: ids.prof.email, password: ids.prof.mot_de_passe }, 'form[action$="/connexion/prof"] button[type=submit]');
|
||||||
|
await prof.goto(`${BASE}/seances/${S}/visio`);
|
||||||
|
if (await prof.locator('text=Ouvrir la visio').count()) await prof.click('text=Ouvrir la visio');
|
||||||
|
await prof.waitForSelector('#v-scene iframe', { timeout: 30000 });
|
||||||
|
etape('prof : visio ouverte, Jitsi chargé');
|
||||||
|
|
||||||
|
// 2. Deux élèves la rejoignent.
|
||||||
|
const eleves = [];
|
||||||
|
for (const e of ids.eleves) {
|
||||||
|
const page = await (await contexte()).newPage();
|
||||||
|
await connexion(page, { identifiant: e.identifiant, code: e.code }, 'form[action$="/connexion/eleve"] button[type=submit]');
|
||||||
|
await page.goto(`${BASE}/seances/${S}/visio/eleve`);
|
||||||
|
await page.waitForSelector('#v-scene iframe', { timeout: 30000 });
|
||||||
|
eleves.push(page);
|
||||||
|
}
|
||||||
|
const enLigne = await attendre(async () => {
|
||||||
|
const t = await prof.textContent('#v-nb');
|
||||||
|
return /3 en ligne/.test(t ?? '') && t;
|
||||||
|
}, 60000);
|
||||||
|
etape(`prof : ${enLigne.trim()} (le prof et les deux élèves)`);
|
||||||
|
|
||||||
|
// 3. Léa lève la main ; elle apparaît chez le prof.
|
||||||
|
const [lea, yanis] = eleves;
|
||||||
|
await lea.click('[data-main-e]');
|
||||||
|
await attendre(async () => (await prof.locator('#v-mains [data-parole]').count()) === 1, 30000);
|
||||||
|
etape(`prof : main levée reçue de « ${(await prof.locator('#v-mains strong').first().textContent())} »`);
|
||||||
|
|
||||||
|
// 4. Yanis, sans la parole, ne peut pas réactiver son micro.
|
||||||
|
await yanis.click('[data-micro-e]');
|
||||||
|
await new Promise((ok) => setTimeout(ok, 4000));
|
||||||
|
const statutYanis = (await yanis.textContent('[data-statut-e]')).trim();
|
||||||
|
etape(`Yanis tente d'ouvrir son micro sans la parole → « ${statutYanis} »`);
|
||||||
|
|
||||||
|
// 5. Le prof donne la parole à Léa : elle peut ouvrir son micro.
|
||||||
|
await prof.click('#v-mains [data-parole]');
|
||||||
|
await attendre(async () => (await lea.getAttribute('[data-main-e]', 'aria-pressed')) === 'false', 30000);
|
||||||
|
etape('Léa : sa main est baissée (parole donnée)');
|
||||||
|
await lea.click('[data-micro-e]');
|
||||||
|
const statutLea = await attendre(async () => {
|
||||||
|
const t = (await lea.textContent('[data-statut-e]')).trim();
|
||||||
|
return t === 'Tu as la parole' && t;
|
||||||
|
}, 30000);
|
||||||
|
etape(`Léa ouvre son micro → « ${statutLea} »`);
|
||||||
|
|
||||||
|
// 6. Un intrus, sans jeton, tente d'entrer directement dans la salle.
|
||||||
|
const salle = await prof.evaluate(() => document.querySelector('#v-scene iframe').src.split('?')[0].split('#')[0].split('/').pop());
|
||||||
|
const intrus = await (await contexte()).newPage();
|
||||||
|
await intrus.goto(`${BASE.replace('://', '://meet.')}/${salle}`);
|
||||||
|
await new Promise((ok) => setTimeout(ok, 12000));
|
||||||
|
const toujours = (await prof.textContent('#v-nb')).trim();
|
||||||
|
const ecranIntrus = (await intrus.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 160);
|
||||||
|
etape(`intrus sans jeton : le prof voit toujours « ${toujours} » ; écran de l'intrus : « ${ecranIntrus} »`);
|
||||||
|
|
||||||
|
const ok = /3 en ligne/.test(toujours) && statutYanis === 'En sourdine' && statutLea === 'Tu as la parole';
|
||||||
|
console.log(ok ? 'CRITÈRE DE L’ÉTAPE 15 : RÉUSSI' : 'CRITÈRE DE L’ÉTAPE 15 : ÉCHOUÉ');
|
||||||
|
process.exitCode = ok ? 0 : 1;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ÉCHEC :', e.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await navigateur.close();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue