diff --git a/.gitignore b/.gitignore index e689032..626515a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ site/ __pycache__/ +dynamic/__pycache__/ +dynamic/routes/__pycache__/ +dynamic/venv/ +dynamic/.env # Assets binaires — générés par scripts/build-assets.py # Hébergés sur https://static.alpinux.org/logo/ diff --git a/docs/technique/deploiement-dynamic.md b/docs/technique/deploiement-dynamic.md new file mode 100644 index 0000000..bb96a87 --- /dev/null +++ b/docs/technique/deploiement-dynamic.md @@ -0,0 +1,234 @@ +--- +description: Déploiement de dynamic.alpinux.org — app Flask, Gunicorn, Apache reverse proxy, AlpID OIDC. +--- + +# Déploiement de dynamic.alpinux.org + +Application Flask de quiz interactifs, partiellement publique et partiellement réservée aux membres AlpID. + +!!! note "Pour qui ?" + Procédure pour les mainteneurs avec accès SSH au serveur. + +--- + +## Architecture + +``` +Navigateur + │ HTTPS + ▼ +Apache (reverse proxy, SSL) + │ HTTP 127.0.0.1:5001 + ▼ +Gunicorn (2 workers) + │ + ▼ +Flask app (dynamic/) + │ │ + ▼ ▼ +SQLite AlpID OIDC +(scores.db) (alpid.alpinux.org) +``` + +--- + +## Installation (première fois) + +### 1. Cloner le dépôt sur le serveur + +```bash +ssh alpinux@alpinux.org +git clone https://gitea.alpinux.org/alpinux.cedrica5l/alpinux.site.2026.git \ + /home/alpinux/site +``` + +### 2. Créer l'environnement Python + +```bash +cd /home/alpinux/site/dynamic +python3 -m venv venv +venv/bin/pip install -r requirements.txt gunicorn +``` + +### 3. Configurer les variables d'environnement + +```bash +sudo mkdir /etc/dynamic-alpinux +sudo cp /home/alpinux/site/dynamic/.env.example /etc/dynamic-alpinux/config.env +sudo nano /etc/dynamic-alpinux/config.env +``` + +Remplissez les valeurs : + +```bash +SECRET_KEY= +ALPID_CLIENT_ID=dynamic-alpinux +ALPID_CLIENT_SECRET= +ALPID_DISCOVERY_URL=https://alpid.alpinux.org/realms/alpinux/.well-known/openid-configuration +DATABASE=/var/lib/dynamic-alpinux/scores.db +``` + +```bash +sudo chmod 600 /etc/dynamic-alpinux/config.env +``` + +### 4. Créer les répertoires de données et de logs + +```bash +sudo mkdir -p /var/lib/dynamic-alpinux +sudo mkdir -p /var/log/dynamic-alpinux +sudo chown alpinux:alpinux /var/lib/dynamic-alpinux /var/log/dynamic-alpinux +``` + +### 5. Configurer AlpID (Keycloak) + +Dans la console d'administration Keycloak (`https://alpid.alpinux.org`) : + +1. **Clients** → **Créer un client** +2. **Client ID** : `dynamic-alpinux` +3. **Client authentication** : activé (pour obtenir un `client_secret`) +4. **Valid redirect URIs** : `https://dynamic.alpinux.org/auth/callback` +5. **Web origins** : `https://dynamic.alpinux.org` +6. Notez le **Client secret** dans l'onglet *Credentials* + +### 6. Installer le service systemd + +```bash +sudo cp /home/alpinux/site/scripts/dynamic.alpinux.org.service \ + /etc/systemd/system/dynamic-alpinux.service +sudo systemctl daemon-reload +sudo systemctl enable --now dynamic-alpinux +sudo systemctl status dynamic-alpinux +``` + +### 7. Configurer Apache + +```bash +# Activer les modules nécessaires +sudo a2enmod proxy proxy_http headers ssl + +# Copier le vhost +sudo cp /home/alpinux/site/scripts/dynamic.alpinux.org.vhost.conf \ + /etc/apache2/sites-available/dynamic.alpinux.org.conf +sudo a2ensite dynamic.alpinux.org +sudo apachectl configtest +sudo systemctl reload apache2 +``` + +### 8. Obtenir le certificat SSL + +```bash +sudo certbot --apache -d dynamic.alpinux.org +``` + +--- + +## Mise à jour + +```bash +ssh alpinux@alpinux.org +cd /home/alpinux/site +git pull +cd dynamic +venv/bin/pip install -r requirements.txt # si requirements.txt a changé +sudo systemctl restart dynamic-alpinux +``` + +--- + +## Gestion du service + +| Action | Commande | +|---|---| +| Démarrer | `sudo systemctl start dynamic-alpinux` | +| Arrêter | `sudo systemctl stop dynamic-alpinux` | +| Redémarrer | `sudo systemctl restart dynamic-alpinux` | +| État | `sudo systemctl status dynamic-alpinux` | +| Logs en direct | `sudo journalctl -u dynamic-alpinux -f` | +| Logs accès | `tail -f /var/log/dynamic-alpinux/access.log` | +| Logs erreurs | `tail -f /var/log/dynamic-alpinux/error.log` | + +--- + +## Structure de l'application + +``` +dynamic/ +├── app.py ← point d'entrée Flask +├── auth_utils.py ← décorateur @login_required +├── db.py ← SQLite (scores) +├── quiz.py ← chargement du JSON +├── requirements.txt +├── .env.example +├── data/ +│ └── quizzes.json ← toutes les questions (source de vérité) +├── routes/ +│ ├── public.py ← accueil, liste, jeu, résultat +│ ├── auth.py ← /auth/login, /auth/callback, /auth/logout +│ └── protected.py ← /profil/ +├── static/ +│ ├── style.css +│ └── quiz.js +└── templates/ + ├── base.html + ├── index.html + ├── quiz/ + │ ├── intro.html + │ ├── play.html + │ └── result.html + └── profil/ + └── index.html +``` + +--- + +## Accès public vs membres + +| URL | Accès | +|---|---| +| `/` | Public | +| `/quiz/` | Public (aperçu de tous les quiz) | +| `/quiz//` | Public (page intro) | +| `/quiz//jouer` | Public si `members_only: false`, sinon AlpID requis | +| `/profil/` | AlpID requis | +| `/auth/login` | Redirect → AlpID | +| `/auth/callback` | Retour OIDC (interne) | + +Les quiz avancés (niveau 4) et experts (niveau 5) ont `"members_only": true` dans `data/quizzes.json`. + +--- + +## Ajouter un quiz + +Éditez `dynamic/data/quizzes.json` et ajoutez un objet au tableau : + +```json +{ + "id": "mon-quiz", + "title": "Titre du quiz", + "description": "Description courte.", + "level": "Intermédiaire", + "level_id": 3, + "members_only": false, + "duration_min": 5, + "icon": "🐧", + "questions": [ + { + "id": 1, + "text": "Question ?", + "choices": ["Réponse A", "Réponse B", "Réponse C", "Réponse D"], + "answer": 0 + } + ] +} +``` + +- `level_id` : 1=Découverte, 2=Débutant, 3=Intermédiaire, 4=Avancé, 5=Expert +- `answer` : index 0-based de la bonne réponse dans `choices` +- `members_only` : `true` pour restreindre aux membres AlpID + +Après modification, redémarrez le service pour recharger le JSON : + +```bash +sudo systemctl restart dynamic-alpinux +``` diff --git a/dynamic/.env.example b/dynamic/.env.example new file mode 100644 index 0000000..99d201f --- /dev/null +++ b/dynamic/.env.example @@ -0,0 +1,15 @@ +# Copier ce fichier en .env et remplir les valeurs +# Ne jamais commiter le fichier .env + +SECRET_KEY=changez-moi-avec-une-valeur-aleatoire-longue + +# AlpID / OIDC (obtenir les valeurs dans la console Keycloak) +ALPID_CLIENT_ID=dynamic-alpinux +ALPID_CLIENT_SECRET= +ALPID_DISCOVERY_URL=https://alpid.alpinux.org/realms/alpinux/.well-known/openid-configuration + +# Base de données SQLite +DATABASE=/var/lib/dynamic-alpinux/scores.db + +# Mode debug (mettre à 0 en production) +FLASK_DEBUG=0 diff --git a/dynamic/app.py b/dynamic/app.py new file mode 100644 index 0000000..887f6f3 --- /dev/null +++ b/dynamic/app.py @@ -0,0 +1,46 @@ +import os +from flask import Flask +from authlib.integrations.flask_client import OAuth + +from db import close_db, init_db +from routes.public import public_bp +from routes.auth import auth_bp +from routes.protected import protected_bp + + +def create_app(): + app = Flask(__name__) + + app.secret_key = os.environ['SECRET_KEY'] + + # OIDC AlpID + oauth = OAuth(app) + oauth.register( + name='alpid', + server_metadata_url=os.environ['ALPID_DISCOVERY_URL'], + client_id=os.environ['ALPID_CLIENT_ID'], + client_secret=os.environ['ALPID_CLIENT_SECRET'], + client_kwargs={'scope': 'openid profile email'}, + ) + app.extensions['oauth'] = oauth + + # Filtre Jinja2 utilitaire + app.jinja_env.filters['enumerate'] = enumerate + + # Blueprints + app.register_blueprint(public_bp) + app.register_blueprint(auth_bp, url_prefix='/auth') + app.register_blueprint(protected_bp) + + # Base de données + app.teardown_appcontext(close_db) + with app.app_context(): + init_db() + + return app + + +app = create_app() + +if __name__ == '__main__': + app.run(debug=os.environ.get('FLASK_DEBUG', '0') == '1') diff --git a/dynamic/auth_utils.py b/dynamic/auth_utils.py new file mode 100644 index 0000000..1844b69 --- /dev/null +++ b/dynamic/auth_utils.py @@ -0,0 +1,16 @@ +from functools import wraps +from flask import session, redirect, url_for, request + + +def current_user(): + return session.get('user') + + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not current_user(): + session['next_url'] = request.url + return redirect(url_for('auth.login')) + return f(*args, **kwargs) + return decorated diff --git a/dynamic/data/quizzes.json b/dynamic/data/quizzes.json new file mode 100644 index 0000000..a1e022c --- /dev/null +++ b/dynamic/data/quizzes.json @@ -0,0 +1,679 @@ +[ + { + "id": "bureau-decouverte", + "title": "Le bureau Linux", + "description": "Maîtrisez les bases de l'interface graphique de Linux Mint.", + "level": "Découverte", + "level_id": 1, + "members_only": false, + "duration_min": 3, + "icon": "🖥️", + "questions": [ + { + "id": 1, + "text": "Sur Linux Mint, comment ouvre-t-on une application depuis le menu ?", + "choices": [ + "On double-clique sur l'icône dans la barre des tâches", + "On clique sur le menu principal puis on cherche l'application", + "On appuie sur F5", + "On tape le nom dans la barre d'adresse" + ], + "answer": 1 + }, + { + "id": 2, + "text": "Que se passe-t-il quand on clique sur le bouton ✕ d'une fenêtre ?", + "choices": [ + "Le programme se met en veille", + "La fenêtre se réduit dans la barre des tâches", + "Le programme se ferme", + "L'ordinateur s'éteint" + ], + "answer": 2 + }, + { + "id": 3, + "text": "Qu'est-ce que la barre des tâches sous Linux Mint (bureau Cinnamon) ?", + "choices": [ + "Une barre de progression pour les téléchargements", + "La barre en bas de l'écran qui affiche les fenêtres ouvertes", + "Un outil de traduction de texte", + "La barre de menu d'une application" + ], + "answer": 1 + }, + { + "id": 4, + "text": "Comment déplacer une fenêtre sur le bureau ?", + "choices": [ + "En cliquant droit dessus et en choisissant « Déplacer »", + "En faisant glisser la barre de titre de la fenêtre", + "En maintenant Ctrl et en cliquant", + "Ce n'est pas possible sous Linux" + ], + "answer": 1 + }, + { + "id": 5, + "text": "Où vont les fichiers quand on les supprime dans le gestionnaire de fichiers ?", + "choices": [ + "Ils sont définitivement effacés immédiatement", + "Ils vont dans la Corbeille", + "Ils sont déplacés vers /tmp", + "Ils sont compressés automatiquement" + ], + "answer": 1 + }, + { + "id": 6, + "text": "Comment prendre une capture d'écran sous Linux Mint ?", + "choices": [ + "Ctrl+P", + "Alt+Impr écran (pour la fenêtre active)", + "La touche Impr écran (Print Screen) pour tout l'écran", + "Ctrl+C" + ], + "answer": 2 + } + ] + }, + { + "id": "internet-decouverte", + "title": "Internet et le navigateur web", + "description": "Naviguez sur le web en toute confiance avec Firefox.", + "level": "Découverte", + "level_id": 1, + "members_only": false, + "duration_min": 3, + "icon": "🌐", + "questions": [ + { + "id": 1, + "text": "Qu'est-ce qu'une URL ?", + "choices": [ + "Un type de virus informatique", + "L'adresse d'une page web", + "Un logiciel de navigation", + "Un protocole de messagerie" + ], + "answer": 1 + }, + { + "id": 2, + "text": "Que signifie HTTPS par rapport à HTTP ?", + "choices": [ + "HTTPS est plus rapide que HTTP", + "HTTPS chiffre la communication entre votre navigateur et le serveur", + "HTTPS est réservé aux sites professionnels", + "Il n'y a aucune différence pratique" + ], + "answer": 1 + }, + { + "id": 3, + "text": "Comment ouvrir un nouvel onglet dans Firefox ?", + "choices": [ + "Ctrl+N", + "Ctrl+T", + "Ctrl+O", + "F5" + ], + "answer": 1 + }, + { + "id": 4, + "text": "Qu'est-ce qu'un moteur de recherche ?", + "choices": [ + "Un logiciel qui accélère votre ordinateur", + "Un service qui référence des pages web et permet de les trouver", + "Un programme qui traduit les pages web automatiquement", + "Un type de navigateur spécialisé" + ], + "answer": 1 + }, + { + "id": 5, + "text": "Comment recharger une page web dans Firefox ?", + "choices": [ + "Ctrl+R ou F5", + "Ctrl+L", + "Alt+F4", + "Ctrl+Z" + ], + "answer": 0 + }, + { + "id": 6, + "text": "Qu'est-ce qu'un favori (bookmark) dans un navigateur ?", + "choices": [ + "Une page web enregistrée pour y accéder rapidement", + "Le site web le plus visité de la semaine", + "L'historique complet de navigation", + "Un mot de passe enregistré" + ], + "answer": 0 + } + ] + }, + { + "id": "fichiers-debutant", + "title": "Fichiers et dossiers", + "description": "Comprenez l'organisation des fichiers sous Linux.", + "level": "Débutant", + "level_id": 2, + "members_only": false, + "duration_min": 4, + "icon": "📁", + "questions": [ + { + "id": 1, + "text": "Quel est le dossier personnel de l'utilisateur « alice » sous Linux ?", + "choices": [ + "/home/alice", + "C:\\Users\\alice", + "/usr/alice", + "/root/alice" + ], + "answer": 0 + }, + { + "id": 2, + "text": "Qu'est-ce qu'une extension de fichier ?", + "choices": [ + "La taille d'un fichier en octets", + "Les lettres après le point dans le nom d'un fichier (.pdf, .mp3…)", + "Le dossier où se trouve un fichier", + "La date de création d'un fichier" + ], + "answer": 1 + }, + { + "id": 3, + "text": "Que représente le dossier / (barre oblique seule) sous Linux ?", + "choices": [ + "Le dossier personnel de l'utilisateur", + "Un dossier temporaire vide", + "La racine du système de fichiers (le point de départ de tout)", + "Le dossier des fichiers de configuration" + ], + "answer": 2 + }, + { + "id": 4, + "text": "Comment afficher les fichiers cachés dans Nemo (le gestionnaire de fichiers de Linux Mint) ?", + "choices": [ + "Clic droit → « Afficher les fichiers cachés »", + "Menu Affichage → « Afficher les fichiers cachés » ou raccourci Ctrl+H", + "Il n'est pas possible d'afficher les fichiers cachés", + "En tapant « hidden » dans la barre d'adresse" + ], + "answer": 1 + }, + { + "id": 5, + "text": "Sous Linux, par quel caractère commence le nom d'un fichier caché ?", + "choices": [ + "Un underscore _", + "Un point . (exemple : .bashrc)", + "Un tiret -", + "Un dièse #" + ], + "answer": 1 + }, + { + "id": 6, + "text": "Que fait le raccourci Ctrl+Z dans la plupart des applications ?", + "choices": [ + "Fermer l'application", + "Annuler la dernière action", + "Enregistrer le fichier", + "Couper la sélection" + ], + "answer": 1 + } + ] + }, + { + "id": "logiciels-debutant", + "title": "Installer des logiciels", + "description": "Gérez vos logiciels sous Linux Mint sans ligne de commande.", + "level": "Débutant", + "level_id": 2, + "members_only": false, + "duration_min": 4, + "icon": "📦", + "questions": [ + { + "id": 1, + "text": "Quel est l'outil graphique pour installer des logiciels sous Linux Mint ?", + "choices": [ + "Le Gestionnaire de logiciels", + "L'App Store", + "Le Centre de téléchargement Windows", + "Le Panneau de configuration" + ], + "answer": 0 + }, + { + "id": 2, + "text": "Qu'est-ce qu'un dépôt de logiciels (repository) sous Linux ?", + "choices": [ + "Un disque dur externe pour stocker des sauvegardes", + "Un serveur qui héberge des paquets logiciels prêts à installer", + "Un logiciel de gestion de fichiers", + "Une partition dédiée aux programmes" + ], + "answer": 1 + }, + { + "id": 3, + "text": "Pourquoi faut-il régulièrement mettre à jour son système Linux ?", + "choices": [ + "Pour améliorer les performances uniquement", + "Pour corriger les bugs et les failles de sécurité", + "Pour changer l'apparence du bureau", + "Les mises à jour ne sont pas nécessaires sous Linux" + ], + "answer": 1 + }, + { + "id": 4, + "text": "Qu'est-ce que LibreOffice ?", + "choices": [ + "Un système d'exploitation", + "Un navigateur web libre", + "Une suite bureautique libre, alternative à Microsoft Office", + "Un antivirus" + ], + "answer": 2 + }, + { + "id": 5, + "text": "Comment installe-t-on un logiciel en ligne de commande sous Linux Mint ?", + "choices": [ + "install ", + "sudo apt install ", + "get-app ", + "download " + ], + "answer": 1 + } + ] + }, + { + "id": "terminal-intermediaire", + "title": "Le terminal — premiers pas", + "description": "Découvrez la puissance de la ligne de commande Linux.", + "level": "Intermédiaire", + "level_id": 3, + "members_only": false, + "duration_min": 5, + "icon": "⌨️", + "questions": [ + { + "id": 1, + "text": "Quelle commande affiche le répertoire courant (là où vous êtes) ?", + "choices": ["ls", "cd", "pwd", "dir"], + "answer": 2 + }, + { + "id": 2, + "text": "Quelle commande liste les fichiers et dossiers du répertoire courant ?", + "choices": ["list", "ls", "dir", "files"], + "answer": 1 + }, + { + "id": 3, + "text": "Que fait la commande `cd ..` ?", + "choices": [ + "Revient au dossier personnel (~)", + "Monte d'un niveau dans l'arborescence (dossier parent)", + "Change le nom du dossier courant", + "Crée un nouveau dossier" + ], + "answer": 1 + }, + { + "id": 4, + "text": "Quelle commande crée un nouveau dossier nommé « projets » ?", + "choices": ["newdir projets", "create projets", "mkdir projets", "touch projets"], + "answer": 2 + }, + { + "id": 5, + "text": "Que fait la commande `man ls` ?", + "choices": [ + "Affiche la version de la commande ls", + "Exécute ls avec toutes les options disponibles", + "Affiche le manuel de la commande ls", + "Liste les fichiers en mode manuel" + ], + "answer": 2 + }, + { + "id": 6, + "text": "Que signifie le ~ (tilde) dans le prompt du terminal ?", + "choices": [ + "Le dossier racine /", + "Le dossier /tmp", + "Le dossier personnel de l'utilisateur (/home/utilisateur)", + "Le dernier dossier visité" + ], + "answer": 2 + } + ] + }, + { + "id": "permissions-intermediaire", + "title": "Les permissions Linux", + "description": "Comprenez qui peut lire, écrire et exécuter quoi.", + "level": "Intermédiaire", + "level_id": 3, + "members_only": false, + "duration_min": 5, + "icon": "🔐", + "questions": [ + { + "id": 1, + "text": "Que signifient les permissions `rwxr-xr--` sur un fichier ?", + "choices": [ + "Lecture seule pour tout le monde", + "Propriétaire : lire/écrire/exécuter | Groupe : lire/exécuter | Autres : lire seulement", + "Propriétaire : tout | Groupe : tout | Autres : rien", + "Ce format de permissions n'existe pas sous Linux" + ], + "answer": 1 + }, + { + "id": 2, + "text": "Quelle commande modifie les permissions d'un fichier ?", + "choices": ["perm", "chperm", "chmod", "access"], + "answer": 2 + }, + { + "id": 3, + "text": "Que fait `chmod 755 script.sh` ?", + "choices": [ + "Supprime tous les droits sur le fichier", + "Donne tous les droits au propriétaire, lecture+exécution au groupe et aux autres", + "Rend le fichier lisible uniquement par le propriétaire", + "Change le propriétaire du fichier" + ], + "answer": 1 + }, + { + "id": 4, + "text": "Quelle commande change le propriétaire d'un fichier ?", + "choices": ["owner", "chown", "chmod", "usermod"], + "answer": 1 + }, + { + "id": 5, + "text": "Que signifie la lettre `x` dans les permissions Linux ?", + "choices": [ + "Exclure (exclude)", + "eXtended (étendu)", + "Exécuter (execute)", + "eXport" + ], + "answer": 2 + } + ] + }, + { + "id": "git-avance", + "title": "Git — les bases", + "description": "Maîtrisez la gestion de versions avec Git.", + "level": "Avancé", + "level_id": 4, + "members_only": true, + "duration_min": 6, + "icon": "🌿", + "questions": [ + { + "id": 1, + "text": "Que fait `git init` dans un dossier ?", + "choices": [ + "Télécharge un dépôt distant", + "Initialise un nouveau dépôt Git local dans le dossier", + "Initialise la connexion avec GitHub/Gitea", + "Réinitialise toutes les modifications en cours" + ], + "answer": 1 + }, + { + "id": 2, + "text": "Quelle commande affiche l'état actuel du dépôt (fichiers modifiés, staged, etc.) ?", + "choices": ["git log", "git diff", "git status", "git show"], + "answer": 2 + }, + { + "id": 3, + "text": "Comment ajouter tous les fichiers modifiés à l'index (staging area) ?", + "choices": ["git commit -a", "git add .", "git stage all", "git push all"], + "answer": 1 + }, + { + "id": 4, + "text": "Que fait `git commit -m \"message\"` ?", + "choices": [ + "Envoie les modifications vers le dépôt distant", + "Crée un point de sauvegarde (commit) avec le message donné", + "Ajoute les fichiers à l'index", + "Crée une nouvelle branche nommée « message »" + ], + "answer": 1 + }, + { + "id": 5, + "text": "Quelle commande envoie les commits locaux vers le dépôt distant ?", + "choices": ["git send", "git upload", "git push", "git commit --remote"], + "answer": 2 + }, + { + "id": 6, + "text": "Que fait `git pull` ?", + "choices": [ + "Supprime les modifications locales non committées", + "Récupère et fusionne les modifications du dépôt distant", + "Crée une nouvelle branche à partir du distant", + "Affiche les derniers commits du dépôt distant" + ], + "answer": 1 + } + ] + }, + { + "id": "admin-avance", + "title": "Administration système", + "description": "Gérez votre système Linux comme un pro.", + "level": "Avancé", + "level_id": 4, + "members_only": true, + "duration_min": 6, + "icon": "⚙️", + "questions": [ + { + "id": 1, + "text": "Quelle commande met à jour la liste des paquets disponibles sous Debian/Ubuntu/Mint ?", + "choices": ["sudo apt upgrade", "sudo apt update", "sudo apt refresh", "sudo dpkg update"], + "answer": 1 + }, + { + "id": 2, + "text": "Quelle commande affiche les processus en cours d'exécution de façon interactive ?", + "choices": ["ps aux", "top ou htop", "process", "list-proc"], + "answer": 1 + }, + { + "id": 3, + "text": "Comment arrêter le service `nginx` avec systemd ?", + "choices": [ + "service nginx kill", + "nginx --stop", + "sudo systemctl stop nginx", + "sudo kill nginx" + ], + "answer": 2 + }, + { + "id": 4, + "text": "Que fait la commande `sudo` ?", + "choices": [ + "Exécute une commande avec les droits d'un autre utilisateur (par défaut root)", + "Sécurise une commande contre les erreurs", + "Vérifie si une commande existe sur le système", + "Exécute une commande en arrière-plan" + ], + "answer": 0 + }, + { + "id": 5, + "text": "Quelle commande affiche l'espace disque disponible sur les partitions montées ?", + "choices": ["diskspace", "du -h", "df -h", "free -h"], + "answer": 2 + }, + { + "id": 6, + "text": "Comment afficher les dernières lignes d'un fichier de log en temps réel ?", + "choices": [ + "cat /var/log/syslog", + "tail -f /var/log/syslog", + "watch /var/log/syslog", + "log -f /var/log/syslog" + ], + "answer": 1 + } + ] + }, + { + "id": "securite-expert", + "title": "Sécurité Linux", + "description": "Bonnes pratiques et outils de sécurité sous Linux.", + "level": "Expert", + "level_id": 5, + "members_only": true, + "duration_min": 7, + "icon": "🛡️", + "questions": [ + { + "id": 1, + "text": "Pourquoi ne faut-il pas utiliser le compte root au quotidien ?", + "choices": [ + "Root est plus lent que les autres utilisateurs", + "Une erreur ou un programme malveillant peut endommager tout le système sans restriction", + "Root ne peut pas accéder aux fichiers personnels", + "Il n'y a aucun risque particulier à utiliser root" + ], + "answer": 1 + }, + { + "id": 2, + "text": "Qu'est-ce qu'une clé SSH ?", + "choices": [ + "Un mot de passe chiffré pour les connexions web HTTPS", + "Une paire cryptographique (clé publique/privée) pour s'authentifier sans mot de passe", + "Un certificat SSL pour serveurs web", + "Un outil de chiffrement de disque comme LUKS" + ], + "answer": 1 + }, + { + "id": 3, + "text": "Quelle commande génère une paire de clés SSH de type Ed25519 ?", + "choices": [ + "ssh-create -t ed25519", + "openssl genkey ed25519", + "ssh-keygen -t ed25519", + "gpg --gen-key --type ed25519" + ], + "answer": 2 + }, + { + "id": 4, + "text": "Quel est le principe du moindre privilège (least privilege) ?", + "choices": [ + "Toujours utiliser le compte root pour les tâches système", + "Accorder à chaque processus et utilisateur uniquement les droits strictement nécessaires", + "Réduire les performances du système pour plus de sécurité", + "Interdire l'accès internet aux utilisateurs non-root" + ], + "answer": 1 + }, + { + "id": 5, + "text": "Quelle commande affiche les connexions réseau actives et les ports en écoute ?", + "choices": ["ifconfig -a", "ss -tulpn (ou netstat -tulpn)", "ping -l", "route -n"], + "answer": 1 + }, + { + "id": 6, + "text": "Qu'est-ce qu'un pare-feu (firewall) ?", + "choices": [ + "Un antivirus spécialisé pour les applications web", + "Un système qui filtre le trafic réseau entrant et sortant selon des règles", + "Un logiciel qui chiffre les applications installées", + "Un outil de sauvegarde automatique" + ], + "answer": 1 + } + ] + }, + { + "id": "bash-expert", + "title": "Bash scripting", + "description": "Automatisez vos tâches avec des scripts shell.", + "level": "Expert", + "level_id": 5, + "members_only": true, + "duration_min": 7, + "icon": "📜", + "questions": [ + { + "id": 1, + "text": "Quelle doit être la première ligne d'un script bash ?", + "choices": ["#!/usr/bin/python3", "#!/bin/bash", "#bash", "// bash script"], + "answer": 1 + }, + { + "id": 2, + "text": "Comment rendre un script bash exécutable ?", + "choices": ["bash +x script.sh", "chmod +x script.sh", "exec script.sh", "run script.sh"], + "answer": 1 + }, + { + "id": 3, + "text": "Que représente `$1` dans un script bash ?", + "choices": [ + "Le nom du script lui-même", + "La valeur de la variable nommée « 1 »", + "Le premier argument passé au script lors de son appel", + "Le code de retour de la dernière commande exécutée" + ], + "answer": 2 + }, + { + "id": 4, + "text": "Quelle est la différence entre `>` et `>>` en redirection ?", + "choices": [ + ">> est plus rapide que >", + ">> ajoute à la fin du fichier existant, > écrase le fichier", + ">> redirige les erreurs, > redirige la sortie standard", + "Aucune différence pratique entre les deux" + ], + "answer": 1 + }, + { + "id": 5, + "text": "Comment tester si le fichier `/etc/hosts` existe dans un script bash ?", + "choices": [ + "if exists \"/etc/hosts\"; then", + "if [ -f \"/etc/hosts\" ]; then", + "if file \"/etc/hosts\"; then", + "if check -f \"/etc/hosts\"; then" + ], + "answer": 1 + } + ] + } +] diff --git a/dynamic/db.py b/dynamic/db.py new file mode 100644 index 0000000..ec5e022 --- /dev/null +++ b/dynamic/db.py @@ -0,0 +1,61 @@ +import sqlite3 +import os +from flask import g + + +def get_db(): + if 'db' not in g: + db_path = os.environ.get('DATABASE', '/var/lib/dynamic-alpinux/scores.db') + os.makedirs(os.path.dirname(db_path), exist_ok=True) + g.db = sqlite3.connect(db_path) + g.db.row_factory = sqlite3.Row + return g.db + + +def close_db(e=None): + db = g.pop('db', None) + if db is not None: + db.close() + + +def init_db(): + db = get_db() + db.executescript(''' + CREATE TABLE IF NOT EXISTS quiz_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_sub TEXT NOT NULL, + user_name TEXT NOT NULL DEFAULT '', + quiz_id TEXT NOT NULL, + score INTEGER NOT NULL, + total INTEGER NOT NULL, + completed_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_results_user ON quiz_results(user_sub); + ''') + db.commit() + + +def save_result(user_sub, user_name, quiz_id, score, total): + db = get_db() + db.execute( + 'INSERT INTO quiz_results (user_sub, user_name, quiz_id, score, total) VALUES (?,?,?,?,?)', + (user_sub, user_name, quiz_id, score, total), + ) + db.commit() + + +def get_user_results(user_sub): + db = get_db() + return db.execute( + 'SELECT * FROM quiz_results WHERE user_sub=? ORDER BY completed_at DESC', + (user_sub,), + ).fetchall() + + +def get_best_score(user_sub, quiz_id): + db = get_db() + row = db.execute( + 'SELECT MAX(score) as best, total FROM quiz_results WHERE user_sub=? AND quiz_id=?', + (user_sub, quiz_id), + ).fetchone() + return row if row and row['best'] is not None else None diff --git a/dynamic/quiz.py b/dynamic/quiz.py new file mode 100644 index 0000000..0374a25 --- /dev/null +++ b/dynamic/quiz.py @@ -0,0 +1,31 @@ +import json +from pathlib import Path + +_QUIZZES = None + +LEVEL_ORDER = {1: "Découverte", 2: "Débutant", 3: "Intermédiaire", 4: "Avancé", 5: "Expert"} +LEVEL_COLOR = {1: "green", 2: "teal", 3: "orange", 4: "red", 5: "purple"} + + +def load_quizzes(): + global _QUIZZES + if _QUIZZES is None: + path = Path(__file__).parent / "data" / "quizzes.json" + _QUIZZES = json.loads(path.read_text(encoding="utf-8")) + return _QUIZZES + + +def get_all(): + return load_quizzes() + + +def get_by_id(quiz_id): + return next((q for q in load_quizzes() if q["id"] == quiz_id), None) + + +def get_public(): + return [q for q in load_quizzes() if not q["members_only"]] + + +def get_members(): + return [q for q in load_quizzes() if q["members_only"]] diff --git a/dynamic/requirements.txt b/dynamic/requirements.txt new file mode 100644 index 0000000..ad7a527 --- /dev/null +++ b/dynamic/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +authlib>=1.3 +requests>=2.31 diff --git a/dynamic/routes/__init__.py b/dynamic/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dynamic/routes/auth.py b/dynamic/routes/auth.py new file mode 100644 index 0000000..5f523c1 --- /dev/null +++ b/dynamic/routes/auth.py @@ -0,0 +1,34 @@ +from flask import Blueprint, redirect, url_for, session, current_app, request + +auth_bp = Blueprint('auth', __name__) + + +@auth_bp.route('/login') +def login(): + redirect_uri = url_for('auth.callback', _external=True) + return current_app.extensions['oauth'].alpid.authorize_redirect(redirect_uri) + + +@auth_bp.route('/callback') +def callback(): + token = current_app.extensions['oauth'].alpid.authorize_access_token() + userinfo = token.get('userinfo') or \ + current_app.extensions['oauth'].alpid.userinfo(token=token) + session['user'] = { + 'sub': userinfo['sub'], + 'name': userinfo.get('name') or userinfo.get('preferred_username', 'Membre'), + 'username': userinfo.get('preferred_username', ''), + 'email': userinfo.get('email', ''), + } + return redirect(session.pop('next_url', url_for('public.index'))) + + +@auth_bp.route('/logout') +def logout(): + session.clear() + # Déconnexion côté AlpID si end_session_endpoint disponible + end_session = current_app.config.get('ALPID_END_SESSION_URL') + if end_session: + post_logout = url_for('public.index', _external=True) + return redirect(f"{end_session}?post_logout_redirect_uri={post_logout}") + return redirect(url_for('public.index')) diff --git a/dynamic/routes/protected.py b/dynamic/routes/protected.py new file mode 100644 index 0000000..84b561b --- /dev/null +++ b/dynamic/routes/protected.py @@ -0,0 +1,37 @@ +from flask import Blueprint, render_template + +from auth_utils import current_user, login_required +from quiz import get_all +from db import get_user_results, get_best_score + +protected_bp = Blueprint('protected', __name__) + + +@protected_bp.route('/profil/') +@login_required +def profil(): + user = current_user() + results = get_user_results(user['sub']) + quizzes = get_all() + + # Calcule le meilleur score par quiz + best_scores = {} + for q in quizzes: + row = get_best_score(user['sub'], q['id']) + if row: + best_scores[q['id']] = {'score': row['best'], 'total': row['total']} + + # Statistiques globales + completed_ids = {r['quiz_id'] for r in results} + stats = { + 'quizzes_done': len(completed_ids), + 'quizzes_total': len(quizzes), + 'attempts': len(results), + } + + return render_template('profil/index.html', + user=user, + results=results, + quizzes={q['id']: q for q in quizzes}, + best_scores=best_scores, + stats=stats) diff --git a/dynamic/routes/public.py b/dynamic/routes/public.py new file mode 100644 index 0000000..03d7b54 --- /dev/null +++ b/dynamic/routes/public.py @@ -0,0 +1,85 @@ +import json +from flask import Blueprint, render_template, request, redirect, url_for, session, abort + +from auth_utils import current_user, login_required +from quiz import get_all, get_by_id +from db import save_result + +public_bp = Blueprint('public', __name__) + + +@public_bp.route('/') +def index(): + quizzes = get_all() + return render_template('index.html', quizzes=quizzes, user=current_user()) + + +@public_bp.route('/quiz/') +def quiz_list(): + quizzes = get_all() + return render_template('quiz/list.html', quizzes=quizzes, user=current_user()) + + +@public_bp.route('/quiz//') +def quiz_intro(quiz_id): + quiz = get_by_id(quiz_id) + if not quiz: + abort(404) + return render_template('quiz/intro.html', quiz=quiz, user=current_user()) + + +@public_bp.route('/quiz//jouer', methods=['GET', 'POST']) +def quiz_play(quiz_id): + quiz = get_by_id(quiz_id) + if not quiz: + abort(404) + + if quiz['members_only'] and not current_user(): + session['next_url'] = url_for('public.quiz_play', quiz_id=quiz_id) + return redirect(url_for('auth.login')) + + if request.method == 'POST': + answers = {} + for i in range(len(quiz['questions'])): + val = request.form.get(f'q{i}') + answers[i] = int(val) if val is not None else -1 + + score = sum( + 1 for i, q in enumerate(quiz['questions']) + if answers.get(i) == q['answer'] + ) + total = len(quiz['questions']) + + if current_user(): + save_result( + current_user()['sub'], + current_user()['name'], + quiz_id, score, total, + ) + + # Stocke résultat et détail en session pour la page résultat + session['last_result'] = { + 'quiz_id': quiz_id, + 'score': score, + 'total': total, + 'answers': answers, + } + return redirect(url_for('public.quiz_result', quiz_id=quiz_id)) + + return render_template('quiz/play.html', + quiz=quiz, + quiz_json=json.dumps(quiz), + user=current_user()) + + +@public_bp.route('/quiz//resultat') +def quiz_result(quiz_id): + quiz = get_by_id(quiz_id) + result = session.pop('last_result', None) + if not quiz or not result or result['quiz_id'] != quiz_id: + return redirect(url_for('public.quiz_intro', quiz_id=quiz_id)) + + return render_template('quiz/result.html', + quiz=quiz, + result=result, + user=current_user()) diff --git a/dynamic/static/quiz.js b/dynamic/static/quiz.js new file mode 100644 index 0000000..ddc4980 --- /dev/null +++ b/dynamic/static/quiz.js @@ -0,0 +1,49 @@ +/* Quiz navigation — vanilla JS, aucune dépendance */ +(function () { + const quiz = JSON.parse(document.getElementById('quiz-data').textContent); + const total = quiz.questions.length; + let current = 0; + + function showQuestion(index) { + document.querySelectorAll('.question-block').forEach(function (el) { + el.hidden = parseInt(el.dataset.index) !== index; + }); + + document.getElementById('progress-text').textContent = + (index + 1) + ' / ' + total; + document.getElementById('progress-bar').style.width = + Math.round((index / total) * 100) + '%'; + + document.getElementById('btn-prev').hidden = index === 0; + document.getElementById('btn-next').hidden = index === total - 1; + document.getElementById('btn-submit').hidden = index !== total - 1; + } + + window.navigate = function (delta) { + var next = current + delta; + if (next < 0 || next >= total) return; + current = next; + showQuestion(current); + }; + + window.onChoice = function (questionIndex, choiceIndex) { + /* Mise à jour visuelle */ + document.querySelectorAll('.question-block[data-index="' + questionIndex + '"] .choice-label') + .forEach(function (lbl, j) { + lbl.classList.toggle('selected', j === choiceIndex); + }); + + /* Le radio button natif sert de valeur soumise avec le formulaire. + On synchronise aussi un hidden input de secours. */ + var hidden = document.getElementById('ans-' + questionIndex); + if (hidden) hidden.value = choiceIndex; + + /* Avancer automatiquement sauf sur la dernière question */ + if (questionIndex < total - 1) { + setTimeout(function () { navigate(1); }, 350); + } + }; + + /* Init */ + showQuestion(0); +})(); diff --git a/dynamic/static/style.css b/dynamic/static/style.css new file mode 100644 index 0000000..0ecc9f7 --- /dev/null +++ b/dynamic/static/style.css @@ -0,0 +1,219 @@ +/* ── Variables ────────────────────────────────────────────────── */ +:root { + --blue: #1a6bbf; + --blue-dark: #0f4e8f; + --blue-light: #e8f1fb; + --orange: #e8820c; + --bg: #f3f6fb; + --text: #1a1a2e; + --text-muted: #666; + --card-shadow: 0 2px 12px rgba(26,107,191,.1); + --radius: 10px; +} + +/* ── Reset / base ─────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; } +a { color: var(--blue); text-decoration: none; } +a:hover { text-decoration: underline; } +main { flex: 1; max-width: 1000px; margin: 0 auto; padding: 2rem 1.5rem; width: 100%; } +h1, h2, h3 { line-height: 1.2; } + +/* ── Header ───────────────────────────────────────────────────── */ +header { background: var(--blue-dark); color: #fff; } +.header-inner { max-width: 1000px; margin: 0 auto; padding: .8rem 1.5rem; display: flex; align-items: center; gap: 1.5rem; } +.brand { display: flex; align-items: center; gap: .6rem; color: #fff; font-size: 1.15rem; font-weight: 400; flex-shrink: 0; } +.brand:hover { text-decoration: none; opacity: .9; } +.brand strong { font-weight: 800; } +.brand-sub { font-weight: 300; font-size: .9em; opacity: .8; } +.brand-logo { border-radius: 6px; } +.main-nav { display: flex; align-items: center; gap: .3rem; margin-left: auto; flex-wrap: wrap; } +.main-nav a { color: rgba(255,255,255,.85); font-size: .88rem; padding: .4rem .8rem; border-radius: 5px; transition: background .15s; } +.main-nav a:hover { background: rgba(255,255,255,.15); color: #fff; text-decoration: none; } +.btn-nav { background: var(--blue); color: #fff !important; border-radius: 5px; padding: .4rem .9rem !important; } +.btn-nav-outline { border: 1px solid rgba(255,255,255,.5); border-radius: 5px; padding: .35rem .8rem !important; } +.nav-toggle { display: none; background: none; border: none; color: #fff; font-size: 1.4rem; cursor: pointer; margin-left: auto; } + +/* ── Footer ───────────────────────────────────────────────────── */ +footer { background: var(--blue-dark); color: rgba(255,255,255,.7); padding: 1rem 1.5rem; font-size: .85rem; } +.footer-inner { max-width: 1000px; margin: 0 auto; display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.footer-links { display: flex; gap: 1rem; } +.footer-links a { color: rgba(255,255,255,.75); } +.footer-links a:hover { color: #fff; } + +/* ── Buttons ──────────────────────────────────────────────────── */ +.btn { display: inline-block; padding: .6rem 1.4rem; border-radius: 6px; font-size: .95rem; font-weight: 600; cursor: pointer; border: none; transition: all .15s; text-decoration: none !important; } +.btn-primary { background: var(--blue); color: #fff; } +.btn-primary:hover { background: var(--blue-dark); } +.btn-outline { background: transparent; color: var(--blue); border: 2px solid var(--blue); } +.btn-outline:hover { background: var(--blue-light); } +.btn-success { background: #1e8a3e; color: #fff; } +.btn-success:hover { background: #166b30; } +.btn-lg { padding: .8rem 2rem; font-size: 1.05rem; } +.btn-sm { padding: .35rem .8rem; font-size: .84rem; } + +/* ── Badges ───────────────────────────────────────────────────── */ +.badge { display: inline-block; padding: .2rem .6rem; border-radius: 20px; font-size: .78rem; font-weight: 700; } +.badge.public { background: #d4f0dc; color: #1a6b35; } +.badge.members { background: #fde8cc; color: #a35800; } +.lock-badge { font-size: .8rem; color: #a35800; } + +.level-badge { display: inline-block; padding: .15rem .55rem; border-radius: 20px; font-size: .78rem; font-weight: 700; } +.level-badge-1 { background: #d4f0dc; color: #1a6b35; } +.level-badge-2 { background: #d0edf5; color: #0d6779; } +.level-badge-3 { background: #fff3d4; color: #8a5c00; } +.level-badge-4 { background: #ffe0d0; color: #8a2800; } +.level-badge-5 { background: #ead4f5; color: #5e0a8a; } + +/* ── Flash messages ───────────────────────────────────────────── */ +.flash { padding: .8rem 1.2rem; border-radius: 6px; margin-bottom: 1rem; } +.flash-error { background: #fde8e8; color: #8a0000; border-left: 4px solid #c0392b; } +.flash-success { background: #d4f0dc; color: #1a6b35; border-left: 4px solid #27ae60; } + +/* ── Section titles ───────────────────────────────────────────── */ +.section-title { font-size: 1.1rem; font-weight: 700; color: var(--blue-dark); margin-bottom: 1.2rem; display: flex; align-items: center; gap: .6rem; } +.section-title::after { content: ''; flex: 1; height: 2px; background: var(--blue-light); } + +/* ── Hero ─────────────────────────────────────────────────────── */ +.hero { background: linear-gradient(135deg, var(--blue-dark) 0%, var(--blue) 100%); color: #fff; border-radius: var(--radius); padding: 3rem 2rem; text-align: center; margin-bottom: 2.5rem; } +.hero h1 { font-size: 2.2rem; font-weight: 800; } +.hero-sub { font-weight: 300; opacity: .8; } +.hero-desc { margin: 1rem auto; max-width: 560px; opacity: .88; line-height: 1.6; font-size: 1.05rem; } +.hero-actions { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; margin-top: 1.5rem; } + +/* ── Levels grid ──────────────────────────────────────────────── */ +.levels-section { margin-bottom: 2.5rem; } +.levels-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; } +.level-card { background: #fff; border-radius: var(--radius); padding: 1.2rem 1rem; text-align: center; box-shadow: var(--card-shadow); } +.level-card p { font-size: .82rem; color: var(--text-muted); margin: .3rem 0 .5rem; } +.level-icon { font-size: 1.8rem; display: block; margin-bottom: .4rem; } + +/* ── Quiz grid / cards ────────────────────────────────────────── */ +.quiz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 1rem; } +.quiz-card { display: flex; gap: 1rem; background: #fff; border-radius: var(--radius); padding: 1.2rem; box-shadow: var(--card-shadow); color: var(--text); transition: transform .15s, box-shadow .15s; text-decoration: none !important; border: 2px solid transparent; } +.quiz-card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(26,107,191,.15); border-color: var(--blue-light); } +.quiz-card--members { border-left: 3px solid var(--orange); } +.quiz-icon { font-size: 2rem; flex-shrink: 0; align-self: flex-start; } +.quiz-card-body h3 { font-size: 1rem; margin-bottom: .3rem; } +.quiz-card-body p { font-size: .84rem; color: var(--text-muted); line-height: 1.4; margin-bottom: .5rem; } +.quiz-meta { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; } +.quiz-duration, .quiz-count { font-size: .78rem; color: var(--text-muted); } + +/* ── Members CTA ──────────────────────────────────────────────── */ +.members-cta { display: flex; align-items: center; gap: 1.5rem; background: #fff8f0; border: 2px solid #f5d0a0; border-radius: var(--radius); padding: 1.4rem 1.8rem; margin-top: 2rem; flex-wrap: wrap; } +.cta-icon { font-size: 2rem; flex-shrink: 0; } +.members-cta strong { display: block; margin-bottom: .2rem; } +.members-cta p { font-size: .9rem; color: var(--text-muted); margin: 0; } +.members-cta .btn { margin-left: auto; } + +/* ── Level sections in list ───────────────────────────────────── */ +.level-section { margin-bottom: 2.5rem; } +.level-heading { font-size: 1.2rem; font-weight: 700; padding-bottom: .6rem; border-bottom: 2px solid var(--blue-light); margin-bottom: 1.2rem; display: flex; align-items: center; gap: .7rem; } +.level-heading-4, .level-heading-5 { border-bottom-color: #f5d0a0; } + +/* ── Quiz intro ───────────────────────────────────────────────── */ +.quiz-intro-wrap { display: flex; justify-content: center; padding: 2rem 0; } +.quiz-intro-card { background: #fff; border-radius: var(--radius); box-shadow: var(--card-shadow); padding: 2.5rem 2rem; max-width: 560px; width: 100%; text-align: center; } +.quiz-intro-icon { font-size: 3.5rem; display: block; margin-bottom: .8rem; } +.quiz-intro-card h1 { font-size: 1.6rem; margin-bottom: .6rem; } +.quiz-intro-desc { color: var(--text-muted); line-height: 1.6; margin-bottom: 1.5rem; } +.quiz-intro-meta { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; background: var(--bg); border-radius: 8px; padding: 1.2rem; margin-bottom: 1.8rem; text-align: left; } +.meta-item { display: flex; flex-direction: column; gap: .2rem; } +.meta-label { font-size: .78rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; } +.intro-lock-notice { background: #fff8f0; border: 1px solid #f5d0a0; border-radius: 8px; padding: 1.2rem; margin-bottom: 1.5rem; } +.intro-lock-notice p { color: var(--text-muted); margin-bottom: 1rem; } +.intro-note { font-size: .85rem; color: var(--text-muted); margin-top: .8rem; } + +/* ── Quiz play ────────────────────────────────────────────────── */ +.quiz-play-wrap { max-width: 680px; margin: 0 auto; } +.quiz-play-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.8rem; flex-wrap: wrap; } +.quiz-play-title { font-weight: 700; font-size: 1.05rem; flex: 1; } +.progress-bar-wrap { flex: 1; height: 8px; background: var(--blue-light); border-radius: 4px; overflow: hidden; min-width: 100px; } +.progress-bar { height: 100%; background: var(--blue); transition: width .3s; border-radius: 4px; } +.progress-text { font-size: .85rem; color: var(--text-muted); white-space: nowrap; } + +.question-block { background: #fff; border-radius: var(--radius); box-shadow: var(--card-shadow); padding: 2rem 1.8rem; } +.question-num { font-size: .82rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: .6rem; } +.question-text { font-size: 1.2rem; line-height: 1.5; margin-bottom: 1.5rem; } +.choices { display: flex; flex-direction: column; gap: .7rem; } +.choice-label { display: flex; align-items: center; gap: .9rem; padding: .9rem 1.1rem; border: 2px solid var(--blue-light); border-radius: 8px; cursor: pointer; transition: all .15s; } +.choice-label:hover { border-color: var(--blue); background: var(--blue-light); } +.choice-label.selected { border-color: var(--blue); background: var(--blue-light); } +.choice-label input[type=radio] { position: absolute; opacity: 0; width: 0; } +.choice-letter { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: var(--blue-light); color: var(--blue); font-weight: 700; font-size: .85rem; flex-shrink: 0; transition: all .15s; } +.choice-label.selected .choice-letter { background: var(--blue); color: #fff; } +.choice-text { font-size: .95rem; } + +.quiz-nav { display: flex; gap: 1rem; justify-content: space-between; margin-top: 1.5rem; flex-wrap: wrap; } + +/* ── Quiz result ──────────────────────────────────────────────── */ +.result-wrap { display: flex; justify-content: center; } +.result-card { background: #fff; border-radius: var(--radius); box-shadow: var(--card-shadow); padding: 2.5rem 2rem; max-width: 680px; width: 100%; } +.result-icon { font-size: 3.5rem; display: block; text-align: center; margin-bottom: .8rem; } +.result-card h1 { text-align: center; font-size: 1.5rem; margin-bottom: 1.2rem; } +.score-display { display: flex; align-items: baseline; justify-content: center; gap: .5rem; margin-bottom: .8rem; } +.score-num { font-size: 4rem; font-weight: 800; color: var(--blue); line-height: 1; } +.score-sep { font-size: 2rem; color: var(--text-muted); } +.score-total { font-size: 2rem; color: var(--text-muted); } +.score-bar-wrap { height: 12px; background: var(--blue-light); border-radius: 6px; overflow: hidden; margin-bottom: .8rem; } +.score-bar { height: 100%; border-radius: 6px; transition: width .5s ease; } +.score-bar-great { background: #27ae60; } +.score-bar-good { background: var(--blue); } +.score-bar-ok { background: var(--orange); } +.score-bar-low { background: #e74c3c; } +.score-comment { text-align: center; font-size: 1.05rem; margin-bottom: .6rem; font-weight: 600; } +.result-saved { text-align: center; font-size: .9rem; color: var(--text-muted); margin-bottom: 1.8rem; } + +.detail-title { font-size: 1.05rem; margin-bottom: 1rem; padding-top: 1rem; border-top: 2px solid var(--blue-light); } +.answer-review { display: flex; flex-direction: column; gap: .7rem; margin-bottom: 1.8rem; } +.answer-item { padding: .9rem 1.1rem; border-radius: 8px; border-left: 4px solid; } +.answer-correct { background: #f0fdf4; border-color: #27ae60; } +.answer-wrong { background: #fff5f5; border-color: #e74c3c; } +.answer-q { font-weight: 600; margin-bottom: .3rem; font-size: .93rem; } +.answer-given { font-size: .9rem; } +.answer-correct-text { font-size: .88rem; color: #1a6b35; margin-top: .2rem; } +.tag-correct { background: #d4f0dc; color: #1a6b35; padding: .1rem .5rem; border-radius: 4px; font-size: .8rem; font-weight: 700; margin-left: .4rem; } +.tag-wrong { background: #fde8e8; color: #8a0000; padding: .1rem .5rem; border-radius: 4px; font-size: .8rem; font-weight: 700; margin-left: .4rem; } +.result-actions { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; } + +/* ── Profile ──────────────────────────────────────────────────── */ +.page-header { margin-bottom: 2rem; } +.page-header h1 { font-size: 1.8rem; } +.text-muted { color: var(--text-muted); } +.profil-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 2.5rem; } +.stat-card { background: #fff; border-radius: var(--radius); padding: 1.4rem 1rem; text-align: center; box-shadow: var(--card-shadow); } +.stat-num { display: block; font-size: 2.2rem; font-weight: 800; color: var(--blue); } +.stat-label { font-size: .85rem; color: var(--text-muted); } +.scores-grid { display: flex; flex-direction: column; gap: .8rem; } +.score-item { display: flex; align-items: center; gap: 1rem; background: #fff; border-radius: var(--radius); padding: 1rem 1.2rem; box-shadow: var(--card-shadow); } +.score-item-icon { font-size: 1.8rem; flex-shrink: 0; } +.score-item-body { flex: 1; } +.score-item-body strong { display: block; margin-bottom: .2rem; } +.mini-score-bar-wrap { height: 6px; background: var(--blue-light); border-radius: 3px; overflow: hidden; margin: .3rem 0; } +.mini-score-bar { height: 100%; border-radius: 3px; } +.mini-score-bar.great { background: #27ae60; } +.mini-score-bar.good { background: var(--blue); } +.mini-score-bar.ok { background: var(--orange); } +.mini-score-text { font-size: .82rem; color: var(--text-muted); } +.history-table-wrap { overflow-x: auto; } +.history-table { width: 100%; border-collapse: collapse; background: #fff; border-radius: var(--radius); overflow: hidden; box-shadow: var(--card-shadow); } +.history-table th { background: var(--blue); color: #fff; padding: .7rem 1rem; text-align: left; font-size: .9rem; } +.history-table td { padding: .6rem 1rem; border-bottom: 1px solid var(--blue-light); font-size: .9rem; } +.history-table tr:last-child td { border-bottom: none; } +.score-great { color: #1a6b35; font-weight: 700; } +.score-good { color: var(--blue); font-weight: 700; } +.score-ok { color: #a35800; font-weight: 700; } + +/* ── Responsive ───────────────────────────────────────────────── */ +@media (max-width: 600px) { + .hero h1 { font-size: 1.6rem; } + .hero-actions { flex-direction: column; } + .levels-grid { grid-template-columns: repeat(2, 1fr); } + .quiz-grid { grid-template-columns: 1fr; } + .profil-stats { grid-template-columns: 1fr; } + .nav-toggle { display: block; } + .main-nav { display: none; flex-direction: column; width: 100%; background: var(--blue-dark); padding: .5rem 1.5rem 1rem; } + header.nav-open .main-nav { display: flex; } + .main-nav a { padding: .6rem .8rem; } + .members-cta .btn { margin-left: 0; width: 100%; text-align: center; } +} diff --git a/dynamic/templates/base.html b/dynamic/templates/base.html new file mode 100644 index 0000000..772c7fb --- /dev/null +++ b/dynamic/templates/base.html @@ -0,0 +1,59 @@ + + + + + + {% block title %}Quiz Linux{% endblock %} — Alpinux + + + + + + {% block head %}{% endblock %} + + + +
+
+ + + Alpinux Quiz + + + +
+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + +{% block scripts %}{% endblock %} + + diff --git a/dynamic/templates/index.html b/dynamic/templates/index.html new file mode 100644 index 0000000..0b7333e --- /dev/null +++ b/dynamic/templates/index.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block title %}Accueil{% endblock %} + +{% block content %} +
+

Quiz Linux par Alpinux

+

+ Testez vos connaissances sur Linux — du premier clic sur le bureau + jusqu'aux scripts Bash et la sécurité système. +

+ +
+ +
+

Cinq niveaux progressifs

+
+
+ 🌱 + Découverte +

Bureau, internet, navigateur web

+ Public +
+
+ 📘 + Débutant +

Fichiers, logiciels, Linux Mint

+ Public +
+
+ + Intermédiaire +

Terminal, permissions

+ Public +
+
+ 🔧 + Avancé +

Git, administration système

+ Membres +
+
+ 🏆 + Expert +

Sécurité, Bash scripting

+ Membres +
+
+
+ +
+

Quiz disponibles

+ + {% if quizzes|length > 6 %} + + {% endif %} +
+{% endblock %} diff --git a/dynamic/templates/profil/index.html b/dynamic/templates/profil/index.html new file mode 100644 index 0000000..8379c90 --- /dev/null +++ b/dynamic/templates/profil/index.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block title %}Mon profil{% endblock %} + +{% block content %} + + +
+
+ {{ stats.quizzes_done }} + quiz terminés +
+
+ {{ stats.quizzes_total }} + quiz disponibles +
+
+ {{ stats.attempts }} + tentatives au total +
+
+ +

Mes meilleurs scores

+{% if best_scores %} +
+ {% for quiz_id, q in quizzes.items() %} + {% if quiz_id in best_scores %} + {% set bs = best_scores[quiz_id] %} + {% set pct = (bs.score / bs.total * 100) | int %} +
+ {{ q.icon }} +
+ {{ q.title }} + {{ q.level }} +
+
+
+ {{ bs.score }} / {{ bs.total }} ({{ pct }}%) +
+ Rejouer +
+ {% endif %} + {% endfor %} +
+{% else %} +

Vous n'avez pas encore complété de quiz. Commencez maintenant !

+{% endif %} + +

Historique des tentatives

+{% if results %} +
+ + + + + + {% for r in results %} + + + + + + {% endfor %} + +
QuizScoreDate
+ {% if r['quiz_id'] in quizzes %} + {{ quizzes[r['quiz_id']].icon }} {{ quizzes[r['quiz_id']].title }} + {% else %} + {{ r['quiz_id'] }} + {% endif %} + + + {{ r['score'] }} / {{ r['total'] }} + + {{ r['completed_at'][:16] }}
+
+{% else %} +

Aucune tentative enregistrée.

+{% endif %} +{% endblock %} diff --git a/dynamic/templates/quiz/intro.html b/dynamic/templates/quiz/intro.html new file mode 100644 index 0000000..e3fcd78 --- /dev/null +++ b/dynamic/templates/quiz/intro.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}{{ quiz.title }}{% endblock %} +{% block description %}{{ quiz.description }} Niveau {{ quiz.level }} — {{ quiz.questions|length }} questions.{% endblock %} + +{% block content %} +
+
+ {{ quiz.icon }} +

{{ quiz.title }}

+

{{ quiz.description }}

+ +
+
+ Niveau + {{ quiz.level }} +
+
+ Questions + {{ quiz.questions | length }} +
+
+ Durée estimée + {{ quiz.duration_min }} min +
+
+ Accès + {% if quiz.members_only %} + 🔒 Membres + {% else %} + Public + {% endif %} +
+
+ + {% if quiz.members_only and not user %} +
+

Ce quiz est réservé aux membres Alpinux.
+ Connectez-vous avec votre compte AlpID pour y accéder.

+ Se connecter avec AlpID +
+ {% else %} + + Commencer le quiz → + + {% if user %} +

Votre score sera enregistré dans votre profil.

+ {% else %} +

+ Connectez-vous pour enregistrer votre score. +

+ {% endif %} + {% endif %} +
+
+{% endblock %} diff --git a/dynamic/templates/quiz/list.html b/dynamic/templates/quiz/list.html new file mode 100644 index 0000000..a0ddd39 --- /dev/null +++ b/dynamic/templates/quiz/list.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Tous les quiz{% endblock %} +{% block description %}10 quiz Linux de tous niveaux — découverte, débutant, intermédiaire, avancé, expert.{% endblock %} + +{% block content %} + + +{% set levels = [ + (1, "🌱", "Découverte", "public"), + (2, "📘", "Débutant", "public"), + (3, "⚡", "Intermédiaire", "public"), + (4, "🔧", "Avancé", "members"), + (5, "🏆", "Expert", "members"), +] %} + +{% for level_id, icon, label, access in levels %} + {% set level_quizzes = quizzes | selectattr("level_id", "equalto", level_id) | list %} + {% if level_quizzes %} +
+

+ {{ icon }} Niveau {{ label }} + {% if access == "members" %} + Membres AlpID + {% else %} + Public + {% endif %} +

+ +
+ {% endif %} +{% endfor %} + +{% if not user %} +
+ 🔒 +
+ Quiz Avancé et Expert réservés aux membres +

Créez un compte AlpID pour accéder aux 4 quiz avancés.

+
+ Se connecter +
+{% endif %} +{% endblock %} diff --git a/dynamic/templates/quiz/play.html b/dynamic/templates/quiz/play.html new file mode 100644 index 0000000..5405a4c --- /dev/null +++ b/dynamic/templates/quiz/play.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}{{ quiz.title }}{% endblock %} + +{% block content %} +
+
+ {{ quiz.icon }} {{ quiz.title }} +
+
+
+ 1 / {{ quiz.questions|length }} +
+ +
+ {% for i, q in quiz.questions | enumerate %} +
0 %}hidden{% endif %}> +

Question {{ i + 1 }} / {{ quiz.questions|length }}

+

{{ q.text }}

+
+ {% for j, choice in q.choices | enumerate %} + + {% endfor %} +
+ +
+ {% endfor %} + +
+ + + +
+
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/dynamic/templates/quiz/result.html b/dynamic/templates/quiz/result.html new file mode 100644 index 0000000..4385e3a --- /dev/null +++ b/dynamic/templates/quiz/result.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Résultat — {{ quiz.title }}{% endblock %} + +{% block content %} +{% set pct = (result.score / result.total * 100) | int %} +
+
+ + {% if pct >= 80 %}🏆 + {% elif pct >= 60 %}👍 + {% elif pct >= 40 %}📚 + {% else %}💪{% endif %} + + +

{{ quiz.title }}

+ +
+ {{ result.score }} + / + {{ result.total }} +
+ +
+
+
+ +

+ {% if pct == 100 %}Score parfait ! 🎉 + {% elif pct >= 80 %}Excellent résultat ! + {% elif pct >= 60 %}Bon travail, continuez ! + {% elif pct >= 40 %}C'est un début, réessayez ! + {% else %}Pas de souci, la pratique fait le maître ! + {% endif %} +

+ + {% if user %} +

✓ Résultat enregistré dans votre profil.

+ {% else %} +

+ Connectez-vous pour enregistrer vos scores. +

+ {% endif %} + +

Détail des réponses

+
+ {% for i, q in quiz.questions | enumerate %} + {% set user_ans = result.answers.get(i | string, result.answers.get(i, -1)) %} + {% set correct = (user_ans == q.answer) %} +
+

{{ i + 1 }}. {{ q.text }}

+

+ Votre réponse : + {% if user_ans >= 0 %}{{ q.choices[user_ans] }}{% else %}(sans réponse){% endif %} + {% if correct %}✓ Correct + {% else %}{% endif %} +

+ {% if not correct %} +

+ Bonne réponse : {{ q.choices[q.answer] }} +

+ {% endif %} +
+ {% endfor %} +
+ + +
+
+{% endblock %} diff --git a/mkdocs.yml b/mkdocs.yml index b377be8..ed163e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -87,6 +87,7 @@ nav: - guides/ventoy.md - Technique: - technique/deploiement-wiki.md + - technique/deploiement-dynamic.md - technique/git.md - technique/nextcloud.md - technique/matrix.md diff --git a/scripts/dynamic.alpinux.org.service b/scripts/dynamic.alpinux.org.service new file mode 100644 index 0000000..02fbbde --- /dev/null +++ b/scripts/dynamic.alpinux.org.service @@ -0,0 +1,24 @@ +# Systemd unit pour l'app Flask dynamic.alpinux.org +# Copier dans /etc/systemd/system/dynamic-alpinux.service +# puis : sudo systemctl enable --now dynamic-alpinux + +[Unit] +Description=Alpinux Dynamic — Quiz interactifs (Flask + Gunicorn) +After=network.target + +[Service] +User=alpinux +Group=alpinux +WorkingDirectory=/home/alpinux/dynamic +EnvironmentFile=/etc/dynamic-alpinux/config.env +ExecStart=/home/alpinux/dynamic/venv/bin/gunicorn \ + --workers 2 \ + --bind 127.0.0.1:5001 \ + --access-logfile /var/log/dynamic-alpinux/access.log \ + --error-logfile /var/log/dynamic-alpinux/error.log \ + app:app +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/dynamic.alpinux.org.vhost.conf b/scripts/dynamic.alpinux.org.vhost.conf new file mode 100644 index 0000000..3bd2ebe --- /dev/null +++ b/scripts/dynamic.alpinux.org.vhost.conf @@ -0,0 +1,33 @@ +# Apache vhost pour dynamic.alpinux.org +# L'app Flask tourne derrière Gunicorn sur 127.0.0.1:5001 + + + ServerName dynamic.alpinux.org + Redirect permanent / https://dynamic.alpinux.org/ + + + + ServerName dynamic.alpinux.org + + # ── Proxy vers Gunicorn ────────────────────────────────────── + ProxyPreserveHost On + ProxyPass / http://127.0.0.1:5001/ + ProxyPassReverse / http://127.0.0.1:5001/ + + # En-têtes transmis à Flask + RequestHeader set X-Forwarded-Proto "https" + RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s" + + # ── Sécurité ───────────────────────────────────────────────── + Header always set X-Content-Type-Options "nosniff" + Header always set X-Frame-Options "SAMEORIGIN" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + # ── Logs ───────────────────────────────────────────────────── + ErrorLog /var/log/apache2/dynamic.alpinux.org-error.log + CustomLog /var/log/apache2/dynamic.alpinux.org-access.log combined + + SSLEngine on + SSLCertificateFile /etc/letsencrypt/live/dynamic.alpinux.org/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/dynamic.alpinux.org/privkey.pem +