feat: dynamic.alpinux.org — quiz interactifs Flask + AlpID OIDC

App Flask complète pour https://dynamic.alpinux.org :
- 10 quiz Linux, 5 niveaux (Découverte → Expert), 50+ questions
- Public : Découverte, Débutant, Intermédiaire (6 quiz)
- Membres AlpID : Avancé, Expert (4 quiz — Git, Admin, Sécurité, Bash)
- Navigation question par question avec avance automatique après choix
- Score calculé côté serveur, enregistré en SQLite si connecté
- Page profil : meilleurs scores par quiz + historique des tentatives

Authentification :
- OIDC via authlib + AlpID (Keycloak), SSO partagé avec Gitea/Nextcloud
- Décorateur @login_required, redirection post-login sur l'URL d'origine
- /auth/login, /auth/callback, /auth/logout

Structure :
- dynamic/app.py, db.py, quiz.py, auth_utils.py
- dynamic/routes/ (public.py, auth.py, protected.py)
- dynamic/templates/ (base, index, quiz/*, profil/)
- dynamic/static/ (style.css thème Alpinux, quiz.js vanilla)
- dynamic/data/quizzes.json (source de vérité des questions)
- dynamic/.env.example

Infrastructure :
- scripts/dynamic.alpinux.org.vhost.conf (Apache reverse proxy)
- scripts/dynamic.alpinux.org.service (systemd Gunicorn)
- docs/technique/deploiement-dynamic.md (procédure complète)
- mkdocs.yml : page de déploiement ajoutée à la nav Technique
- .gitignore : exclut venv/ et .env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cédrix 2026-05-03 12:09:37 +02:00
parent 557e363480
commit 27847dfad3
25 changed files with 2040 additions and 0 deletions

4
.gitignore vendored
View file

@ -1,5 +1,9 @@
site/ site/
__pycache__/ __pycache__/
dynamic/__pycache__/
dynamic/routes/__pycache__/
dynamic/venv/
dynamic/.env
# Assets binaires — générés par scripts/build-assets.py # Assets binaires — générés par scripts/build-assets.py
# Hébergés sur https://static.alpinux.org/logo/ # Hébergés sur https://static.alpinux.org/logo/

View file

@ -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=<générer avec : python3 -c "import secrets; print(secrets.token_hex(32))">
ALPID_CLIENT_ID=dynamic-alpinux
ALPID_CLIENT_SECRET=<obtenir depuis la console Keycloak AlpID>
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/<id>/` | Public (page intro) |
| `/quiz/<id>/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
```

15
dynamic/.env.example Normal file
View file

@ -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

46
dynamic/app.py Normal file
View file

@ -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')

16
dynamic/auth_utils.py Normal file
View file

@ -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

679
dynamic/data/quizzes.json Normal file
View file

@ -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 <logiciel>",
"sudo apt install <logiciel>",
"get-app <logiciel>",
"download <logiciel>"
],
"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
}
]
}
]

61
dynamic/db.py Normal file
View file

@ -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

31
dynamic/quiz.py Normal file
View file

@ -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"]]

3
dynamic/requirements.txt Normal file
View file

@ -0,0 +1,3 @@
flask>=3.0
authlib>=1.3
requests>=2.31

View file

34
dynamic/routes/auth.py Normal file
View file

@ -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'))

View file

@ -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)

85
dynamic/routes/public.py Normal file
View file

@ -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/<quiz_id>/')
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/<quiz_id>/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/<quiz_id>/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())

49
dynamic/static/quiz.js Normal file
View file

@ -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);
})();

219
dynamic/static/style.css Normal file
View file

@ -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; }
}

View file

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Quiz Linux{% endblock %} — Alpinux</title>
<meta name="description" content="{% block description %}Quiz interactifs sur Linux, du niveau découverte à expert — par l'association Alpinux, LUG de Savoie.{% endblock %}">
<link rel="icon" type="image/x-icon" href="https://static.alpinux.org/logo/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="https://static.alpinux.org/logo/favicon-32.png">
<link rel="apple-touch-icon" sizes="192x192" href="https://static.alpinux.org/logo/favicon-192.png">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<header>
<div class="header-inner">
<a href="{{ url_for('public.index') }}" class="brand">
<img src="https://static.alpinux.org/logo/alpinux-logo.png"
alt="Alpinux" width="44" height="44" class="brand-logo">
<span>A<strong>l</strong>p<strong>inux</strong> <span class="brand-sub">Quiz</span></span>
</a>
<nav class="main-nav">
<a href="{{ url_for('public.index') }}">Accueil</a>
<a href="{{ url_for('public.quiz_list') }}">Tous les quiz</a>
{% if user %}
<a href="{{ url_for('protected.profil') }}">Mon profil</a>
<a href="{{ url_for('auth.logout') }}" class="btn-nav-outline">Déconnexion</a>
{% else %}
<a href="{{ url_for('auth.login') }}" class="btn-nav">Se connecter</a>
{% endif %}
</nav>
<button class="nav-toggle" aria-label="Menu" onclick="this.closest('header').classList.toggle('nav-open')"></button>
</div>
</header>
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer>
<div class="footer-inner">
<span>© Alpinux — LUG de Savoie</span>
<span class="footer-links">
<a href="https://alpinux.org">alpinux.org</a>
<a href="https://wiki.alpinux.org">Wiki</a>
<a href="https://mamot.fr/@alpinux">Mastodon</a>
</span>
</div>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Accueil{% endblock %}
{% block content %}
<section class="hero">
<h1>Quiz Linux <span class="hero-sub">par Alpinux</span></h1>
<p class="hero-desc">
Testez vos connaissances sur Linux — du premier clic sur le bureau
jusqu'aux scripts Bash et la sécurité système.
</p>
<div class="hero-actions">
<a href="{{ url_for('public.quiz_list') }}" class="btn btn-primary">Voir tous les quiz</a>
{% if not user %}
<a href="{{ url_for('auth.login') }}" class="btn btn-outline">
Se connecter pour accéder aux quiz Avancé &amp; Expert
</a>
{% endif %}
</div>
</section>
<section class="levels-section">
<h2 class="section-title">Cinq niveaux progressifs</h2>
<div class="levels-grid">
<div class="level-card level-1">
<span class="level-icon">🌱</span>
<strong>Découverte</strong>
<p>Bureau, internet, navigateur web</p>
<span class="badge public">Public</span>
</div>
<div class="level-card level-2">
<span class="level-icon">📘</span>
<strong>Débutant</strong>
<p>Fichiers, logiciels, Linux Mint</p>
<span class="badge public">Public</span>
</div>
<div class="level-card level-3">
<span class="level-icon"></span>
<strong>Intermédiaire</strong>
<p>Terminal, permissions</p>
<span class="badge public">Public</span>
</div>
<div class="level-card level-4">
<span class="level-icon">🔧</span>
<strong>Avancé</strong>
<p>Git, administration système</p>
<span class="badge members">Membres</span>
</div>
<div class="level-card level-5">
<span class="level-icon">🏆</span>
<strong>Expert</strong>
<p>Sécurité, Bash scripting</p>
<span class="badge members">Membres</span>
</div>
</div>
</section>
<section class="quizzes-preview">
<h2 class="section-title">Quiz disponibles</h2>
<div class="quiz-grid">
{% for quiz in quizzes[:6] %}
<a href="{{ url_for('public.quiz_intro', quiz_id=quiz.id) }}" class="quiz-card {% if quiz.members_only %}quiz-card--members{% endif %}">
<span class="quiz-icon">{{ quiz.icon }}</span>
<div class="quiz-card-body">
<h3>{{ quiz.title }}</h3>
<p>{{ quiz.description }}</p>
<div class="quiz-meta">
<span class="level-badge level-badge-{{ quiz.level_id }}">{{ quiz.level }}</span>
<span class="quiz-duration">⏱ {{ quiz.duration_min }} min</span>
{% if quiz.members_only %}
<span class="lock-badge">🔒 Membres</span>
{% endif %}
</div>
</div>
</a>
{% endfor %}
</div>
{% if quizzes|length > 6 %}
<div style="text-align:center;margin-top:2rem">
<a href="{{ url_for('public.quiz_list') }}" class="btn btn-outline">Voir les {{ quizzes|length }} quiz</a>
</div>
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Mon profil{% endblock %}
{% block content %}
<div class="page-header">
<h1>👤 {{ user.name }}</h1>
<p class="text-muted">{{ user.email }}</p>
</div>
<div class="profil-stats">
<div class="stat-card">
<span class="stat-num">{{ stats.quizzes_done }}</span>
<span class="stat-label">quiz terminés</span>
</div>
<div class="stat-card">
<span class="stat-num">{{ stats.quizzes_total }}</span>
<span class="stat-label">quiz disponibles</span>
</div>
<div class="stat-card">
<span class="stat-num">{{ stats.attempts }}</span>
<span class="stat-label">tentatives au total</span>
</div>
</div>
<h2 class="section-title">Mes meilleurs scores</h2>
{% if best_scores %}
<div class="scores-grid">
{% 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 %}
<div class="score-item">
<span class="score-item-icon">{{ q.icon }}</span>
<div class="score-item-body">
<strong>{{ q.title }}</strong>
<span class="level-badge level-badge-{{ q.level_id }}">{{ q.level }}</span>
<div class="mini-score-bar-wrap">
<div class="mini-score-bar {% if pct >= 80 %}great{% elif pct >= 60 %}good{% else %}ok{% endif %}"
style="width:{{ pct }}%"></div>
</div>
<span class="mini-score-text">{{ bs.score }} / {{ bs.total }} ({{ pct }}%)</span>
</div>
<a href="{{ url_for('public.quiz_play', quiz_id=quiz_id) }}" class="btn btn-sm btn-outline">Rejouer</a>
</div>
{% endif %}
{% endfor %}
</div>
{% else %}
<p>Vous n'avez pas encore complété de quiz. <a href="{{ url_for('public.quiz_list') }}">Commencez maintenant !</a></p>
{% endif %}
<h2 class="section-title" style="margin-top:2.5rem">Historique des tentatives</h2>
{% if results %}
<div class="history-table-wrap">
<table class="history-table">
<thead>
<tr><th>Quiz</th><th>Score</th><th>Date</th></tr>
</thead>
<tbody>
{% for r in results %}
<tr>
<td>
{% if r['quiz_id'] in quizzes %}
{{ quizzes[r['quiz_id']].icon }} {{ quizzes[r['quiz_id']].title }}
{% else %}
{{ r['quiz_id'] }}
{% endif %}
</td>
<td>
<span class="{% if (r['score'] / r['total']) >= 0.8 %}score-great{% elif (r['score'] / r['total']) >= 0.6 %}score-good{% else %}score-ok{% endif %}">
{{ r['score'] }} / {{ r['total'] }}
</span>
</td>
<td class="text-muted">{{ r['completed_at'][:16] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>Aucune tentative enregistrée.</p>
{% endif %}
{% endblock %}

View file

@ -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 %}
<div class="quiz-intro-wrap">
<div class="quiz-intro-card">
<span class="quiz-intro-icon">{{ quiz.icon }}</span>
<h1>{{ quiz.title }}</h1>
<p class="quiz-intro-desc">{{ quiz.description }}</p>
<div class="quiz-intro-meta">
<div class="meta-item">
<span class="meta-label">Niveau</span>
<span class="level-badge level-badge-{{ quiz.level_id }}">{{ quiz.level }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Questions</span>
<strong>{{ quiz.questions | length }}</strong>
</div>
<div class="meta-item">
<span class="meta-label">Durée estimée</span>
<strong>{{ quiz.duration_min }} min</strong>
</div>
<div class="meta-item">
<span class="meta-label">Accès</span>
{% if quiz.members_only %}
<span class="badge members">🔒 Membres</span>
{% else %}
<span class="badge public">Public</span>
{% endif %}
</div>
</div>
{% if quiz.members_only and not user %}
<div class="intro-lock-notice">
<p>Ce quiz est réservé aux membres Alpinux.<br>
Connectez-vous avec votre compte AlpID pour y accéder.</p>
<a href="{{ url_for('auth.login') }}" class="btn btn-primary">Se connecter avec AlpID</a>
</div>
{% else %}
<a href="{{ url_for('public.quiz_play', quiz_id=quiz.id) }}" class="btn btn-primary btn-lg">
Commencer le quiz →
</a>
{% if user %}
<p class="intro-note">Votre score sera enregistré dans votre profil.</p>
{% else %}
<p class="intro-note">
<a href="{{ url_for('auth.login') }}">Connectez-vous</a> pour enregistrer votre score.
</p>
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -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 %}
<div class="page-header">
<h1>Tous les quiz</h1>
<p>{{ quizzes|length }} quiz — du niveau Découverte à Expert</p>
</div>
{% 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 %}
<section class="level-section">
<h2 class="level-heading level-heading-{{ level_id }}">
{{ icon }} Niveau {{ label }}
{% if access == "members" %}
<span class="badge members">Membres AlpID</span>
{% else %}
<span class="badge public">Public</span>
{% endif %}
</h2>
<div class="quiz-grid">
{% for quiz in level_quizzes %}
<a href="{{ url_for('public.quiz_intro', quiz_id=quiz.id) }}"
class="quiz-card {% if quiz.members_only %}quiz-card--members{% endif %}">
<span class="quiz-icon">{{ quiz.icon }}</span>
<div class="quiz-card-body">
<h3>{{ quiz.title }}</h3>
<p>{{ quiz.description }}</p>
<div class="quiz-meta">
<span class="quiz-duration">⏱ {{ quiz.duration_min }} min</span>
<span class="quiz-count">{{ quiz.questions | length }} questions</span>
{% if quiz.members_only and not user %}
<span class="lock-badge">🔒 Connexion requise</span>
{% endif %}
</div>
</div>
</a>
{% endfor %}
</div>
</section>
{% endif %}
{% endfor %}
{% if not user %}
<div class="members-cta">
<span class="cta-icon">🔒</span>
<div>
<strong>Quiz Avancé et Expert réservés aux membres</strong>
<p>Créez un compte AlpID pour accéder aux 4 quiz avancés.</p>
</div>
<a href="{{ url_for('auth.login') }}" class="btn btn-primary">Se connecter</a>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}{{ quiz.title }}{% endblock %}
{% block content %}
<div class="quiz-play-wrap">
<div class="quiz-play-header">
<span class="quiz-play-title">{{ quiz.icon }} {{ quiz.title }}</span>
<div class="progress-bar-wrap">
<div class="progress-bar" id="progress-bar" style="width:0%"></div>
</div>
<span class="progress-text" id="progress-text">1 / {{ quiz.questions|length }}</span>
</div>
<form method="post" id="quiz-form" action="{{ url_for('public.quiz_play', quiz_id=quiz.id) }}">
{% for i, q in quiz.questions | enumerate %}
<div class="question-block" data-index="{{ i }}" {% if i > 0 %}hidden{% endif %}>
<p class="question-num">Question {{ i + 1 }} / {{ quiz.questions|length }}</p>
<h2 class="question-text">{{ q.text }}</h2>
<div class="choices">
{% for j, choice in q.choices | enumerate %}
<label class="choice-label" id="label-{{ i }}-{{ j }}">
<input type="radio" name="q{{ i }}" value="{{ j }}"
onchange="onChoice({{ i }}, {{ j }})">
<span class="choice-letter">{{ ['A','B','C','D'][j] }}</span>
<span class="choice-text">{{ choice }}</span>
</label>
{% endfor %}
</div>
<input type="hidden" name="q{{ i }}" id="ans-{{ i }}" value="">
</div>
{% endfor %}
<div class="quiz-nav">
<button type="button" id="btn-prev" class="btn btn-outline" onclick="navigate(-1)" hidden>
← Précédente
</button>
<button type="button" id="btn-next" class="btn btn-primary" onclick="navigate(1)">
Suivante →
</button>
<button type="submit" id="btn-submit" class="btn btn-success" hidden>
Terminer le quiz ✓
</button>
</div>
</form>
</div>
<script id="quiz-data" type="application/json">{{ quiz_json | safe }}</script>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='quiz.js') }}"></script>
{% endblock %}

View file

@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}Résultat — {{ quiz.title }}{% endblock %}
{% block content %}
{% set pct = (result.score / result.total * 100) | int %}
<div class="result-wrap">
<div class="result-card">
<span class="result-icon">
{% if pct >= 80 %}🏆
{% elif pct >= 60 %}👍
{% elif pct >= 40 %}📚
{% else %}💪{% endif %}
</span>
<h1>{{ quiz.title }}</h1>
<div class="score-display">
<span class="score-num">{{ result.score }}</span>
<span class="score-sep">/</span>
<span class="score-total">{{ result.total }}</span>
</div>
<div class="score-bar-wrap">
<div class="score-bar score-bar-{% if pct >= 80 %}great{% elif pct >= 60 %}good{% elif pct >= 40 %}ok{% else %}low{% endif %}"
style="width: {{ pct }}%"></div>
</div>
<p class="score-comment">
{% 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 %}
</p>
{% if user %}
<p class="result-saved">✓ Résultat enregistré dans <a href="{{ url_for('protected.profil') }}">votre profil</a>.</p>
{% else %}
<p class="result-saved">
<a href="{{ url_for('auth.login') }}">Connectez-vous</a> pour enregistrer vos scores.
</p>
{% endif %}
<h2 class="detail-title">Détail des réponses</h2>
<div class="answer-review">
{% 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) %}
<div class="answer-item {% if correct %}answer-correct{% else %}answer-wrong{% endif %}">
<p class="answer-q">{{ i + 1 }}. {{ q.text }}</p>
<p class="answer-given">
Votre réponse :
<strong>{% if user_ans >= 0 %}{{ q.choices[user_ans] }}{% else %}(sans réponse){% endif %}</strong>
{% if correct %}<span class="tag-correct">✓ Correct</span>
{% else %}<span class="tag-wrong"></span>{% endif %}
</p>
{% if not correct %}
<p class="answer-correct-text">
Bonne réponse : <strong>{{ q.choices[q.answer] }}</strong>
</p>
{% endif %}
</div>
{% endfor %}
</div>
<div class="result-actions">
<a href="{{ url_for('public.quiz_play', quiz_id=quiz.id) }}" class="btn btn-outline">Recommencer</a>
<a href="{{ url_for('public.quiz_list') }}" class="btn btn-primary">Autres quiz</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -87,6 +87,7 @@ nav:
- guides/ventoy.md - guides/ventoy.md
- Technique: - Technique:
- technique/deploiement-wiki.md - technique/deploiement-wiki.md
- technique/deploiement-dynamic.md
- technique/git.md - technique/git.md
- technique/nextcloud.md - technique/nextcloud.md
- technique/matrix.md - technique/matrix.md

View file

@ -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

View file

@ -0,0 +1,33 @@
# Apache vhost pour dynamic.alpinux.org
# L'app Flask tourne derrière Gunicorn sur 127.0.0.1:5001
<VirtualHost *:80>
ServerName dynamic.alpinux.org
Redirect permanent / https://dynamic.alpinux.org/
</VirtualHost>
<VirtualHost *:443>
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
</VirtualHost>