transfer from private server/git server

This commit is contained in:
M1n-0
2026-08-21 09:12:02 +02:00
parent 45c8982178
commit 6ecb014238
75 changed files with 4344 additions and 274 deletions

9
.gitignore vendored
View File

@@ -1 +1,8 @@
.env
__pycache__/
*.pyc
.env
web/static/assets/uploads/
.pytest_cache/
.coverage
htmlcov/
*.egg-info/

View File

@@ -1,4 +1,13 @@
flask.ninolbt.com {
# =============================================================================
# Caddyfile — Reverse proxy et terminaison TLS (Let's Encrypt automatique).
# =============================================================================
# X-Forwarded-Proto est transmis par défaut par Caddy vers les backends ; sans
# cet en-tête, Keycloak génère des URLs en http:// derrière le proxy (bug
# documenté dans le journal de bord, résolu par KC_HOSTNAME/--proxy-headers
# côté Keycloak — voir docker-compose.yml et Bloc 3 - C5 Debugging).
# =============================================================================
dashboard.ninolbt.com {
reverse_proxy flask:5000
}
@@ -9,7 +18,3 @@ keycloak.ninolbt.com {
chat.ninolbt.com {
reverse_proxy mattermost:8065
}
wekan.ninolbt.com {
reverse_proxy wekan:8080
}

179
README.en.md Normal file
View File

@@ -0,0 +1,179 @@
# 📘 Gesthub
<img src="https://img.shields.io/github/last-commit/M1n-0/gesthub?style=flat&logo=git&logoColor=white&color=0080ff" alt="last-commit">
<p align="right">
<a href="README.md"><img src="https://img.shields.io/badge/README-Fran%C3%A7ais-blue?style=flat&logo=readthedocs&logoColor=white" alt="README en français"></a>
</p>
> **Version v2 (August 2026).** The code has been reorganized into a
> layered architecture (`routes/`, `services/`, `models/`), with the
> Announcements, Files, and Planning modules, an `audit_log` table, and a
> suite of 34 automated tests (see `web/tests/`). This version serves as
> the technical support for the RNCP 36463 (CDAN) certification file: see
> `docs/` for the complete technical dossier
> (`docs/Dossier_technique_GestHub_v2.docx`) and the actual evidence
> (`docs/evidence/`: pytest/flake8/radon output, screenshots, architecture
> diagrams).
>
> Quick start:
> ```bash
> cp web/.env.example web/.env # fill in the real values
> docker compose up -d --build
> ```
> Run the tests (requires an accessible test MariaDB database, see
> `web/tests/conftest.py`):
> ```bash
> cd web && pip install -r requirements.txt
> TEST_DB_NAME=gesthub_test DB_USER=... DB_PASSWORD=... DB_HOST=... pytest tests/ -v
> ```
<em>Built with the tools and technologies needed:</em>
<div style="display: flex; flex-wrap: wrap; gap: 5px;">
<img src="https://img.shields.io/badge/Flask-000000.svg?style=flat&logo=Flask&logoColor=white" alt="Flask">
<img src="https://img.shields.io/badge/JSON-000000.svg?style=flat&logo=JSON&logoColor=white" alt="JSON">
<img src="https://img.shields.io/badge/Keycloak-4D4D4D.svg?style=flat&logo=Keycloak&logoColor=white" alt="Keycloak">
<img src="https://img.shields.io/badge/GNU%20Bash-4EAA25.svg?style=flat&logo=GNU-Bash&logoColor=white" alt="GNU%20Bash">
<img src="https://img.shields.io/badge/MariaDB-003545.svg?style=flat&logo=MariaDB&logoColor=white" alt="MariaDB">
<br>
<img src="https://img.shields.io/badge/Docker-2496ED.svg?style=flat&logo=Docker&logoColor=white" alt="Docker">
<img src="https://img.shields.io/badge/Python-3776AB.svg?style=flat&logo=Python&logoColor=white" alt="Python">
<img src="https://img.shields.io/badge/Mattermost-0058CC.svg?style=flat&logo=Mattermost&logoColor=white" alt="Mattermost">
</div>
## 🧱 Goal
Build a multi-service website (extranet/intranet) with:
- Centralized authentication via **Keycloak**
- **Caddy** reverse proxy
- **Flask** frontend/backend
- Chat & task management via **Mattermost**
- JSON-based announcement management with `/admin` permissions (to do)
---
## 🐳 Starting the project
### 1. **Docker structure**
The services are defined in `docker-compose.yml`:
- `caddy`: Reverse proxy + automatic HTTPS
- `flask`: Backend web application
- `mariadb`: Database
- `keycloak`: SSO + user management
- `mattermost`: Chat and task management (Trello-like)
Network used: `gesthub_gesthub`
---
## 🔐 Keycloak authentication
### ✅ Steps:
1. Create the **`Gesthub` realm**
2. Add the clients (Flask and Mattermost)
3. Enable `OpenID Connect`
4. Configure the **Redirect URIs**
- Examples:
- Flask → `https://dashboard.ninolbt.com/login/callback`
- Mattermost → `https://mattermost.ninolbt.com/signup/openid/complete`
5. For `/admin` users, use the **`/admin` group** in Keycloak.
---
## 🍓 Deployment on Raspberry Pi (ARM64)
GestHub v2 is designed to run on a Raspberry Pi (4 or 5) with a **64-bit**
OS (Raspberry Pi OS 64-bit / Ubuntu Server 64-bit). Check first:
```bash
uname -m # should print aarch64 (otherwise: reinstall the OS in 64-bit)
docker --version # install via https://get.docker.com if missing
```
All the images in `docker-compose.yml` are official multi-arch images
(Caddy, MariaDB, Postgres, Keycloak) — Docker automatically selects the
arm64 variant on `pull`, nothing to change. The only exception is
`mattermost/mattermost-team-edition`, which is only published for
`linux/amd64` (no official ARM image to date — see
[mattermost/mattermost#21979](https://github.com/mattermost/mattermost/issues/21979)).
The compose file therefore uses an equivalent community build,
`ngrie/mattermost-team-edition-arm`, at the same version — see the comment
in `docker-compose.yml`. On an amd64 host (CI, dev machine), switch back to
the official `mattermost/mattermost-team-edition:9.11` image.
**RAM**: the full stack (Caddy + Flask + MariaDB + Keycloak + 2×Postgres +
Mattermost) runs simultaneously — plan for a Pi with **4 GB of RAM
minimum, 8 GB recommended**, and fast SSD/SD card storage (MariaDB/Postgres
volumes are sensitive to the slow I/O of a regular SD card).
---
## 🌐 Caddy reverse proxy
### 🛠️ `Caddyfile`:
```caddyfile
https://dashboard.ninolbt.com {
reverse_proxy flask:5000
}
https://keycloak.ninolbt.com {
reverse_proxy keycloak:8080
}
https://mattermost.ninolbt.com {
reverse_proxy mattermost:8065
}
```
**Persistent volumes**:
`caddy_data` and `caddy_config` mounted at `/data` and `/config`
---
## 🧩 Flask
- Dashboard backend
- Allows creating/editing/deleting announcements in JSON (being tested)
- Accessible only to users with the `/admin` role (via token) (being tested)
- Static asset loading fixed with Caddy
---
## 🗂️ Permissions management
- Auth via Keycloak for Flask, Mattermost, Wekan
- Group checks in Flask (`/admin`)
- Correct redirects with Caddy HTTPS URLs
---
## 📌 Bugs and fixes
- ⚠️ Incorrect Keycloak redirect → Fixed with the correct `redirect_uri`
- ⚠️ Flask static assets → fixed via absolute HTTPS URL
- ✅ Reverse proxy works with all services
- ✅ HTTPS operational via Caddy with Let's Encrypt certificates
---
## 🚀 Startup
```bash
docker compose up --build -d
```
If needed:
```bash
docker compose logs -f [service]
```
---
## 📤 Full export
To make the project exportable:
- Everything is containerized (Docker)
- Keycloak config exported (JSON available in the `export_keycloak` folder)
- `docker-compose.yml`, `Caddyfile`, files available in the repo

View File

@@ -1,5 +1,30 @@
# 📘 Gesthub
<img src="https://img.shields.io/github/last-commit/M1n-0/gesthub?style=flat&logo=git&logoColor=white&color=0080ff" alt="last-commit">
# 📘 Gesthub
<img src="https://img.shields.io/github/last-commit/M1n-0/gesthub?style=flat&logo=git&logoColor=white&color=0080ff" alt="last-commit">
<p align="right">
<a href="README.en.md"><img src="https://img.shields.io/badge/README-English-blue?style=flat&logo=readthedocs&logoColor=white" alt="English README"></a>
</p>
> **Version v2 (août 2026).** Le code a été réorganisé en architecture par
> couches (`routes/`, `services/`, `models/`), avec les modules Annonces,
> Fichiers et Planning, une table `audit_log`, et une suite de 34 tests
> automatisés (voir `web/tests/`). Cette version sert de support technique
> au dossier de certification RNCP 36463 (CDAN) : voir `docs/` pour le
> dossier technique complet (`docs/Dossier_technique_GestHub_v2.docx`) et
> les preuves réelles (`docs/evidence/` : sorties pytest/flake8/radon,
> captures d'écran, schémas d'architecture).
>
> Démarrage rapide :
> ```bash
> cp web/.env.example web/.env # renseigner les vraies valeurs
> docker compose up -d --build
> ```
> Lancer les tests (nécessite une base MariaDB de test accessible, voir
> `web/tests/conftest.py`) :
> ```bash
> cd web && pip install -r requirements.txt
> TEST_DB_NAME=gesthub_test DB_USER=... DB_PASSWORD=... DB_HOST=... pytest tests/ -v
> ```
<em>Construit avec les outils et les technologies nécessaires :</em>
<div style="display: flex; flex-wrap: wrap; gap: 5px;">
@@ -56,6 +81,36 @@ Réseau utilisé : `gesthub_gesthub`
---
## 🍓 Déploiement sur Raspberry Pi (ARM64)
GestHub v2 est prévu pour tourner sur un Raspberry Pi (4 ou 5) avec un OS
**64 bits** (Raspberry Pi OS 64-bit / Ubuntu Server 64-bit). Vérifier avant
tout :
```bash
uname -m # doit afficher aarch64 (sinon : réinstaller l'OS en 64-bit)
docker --version # installer via https://get.docker.com si absent
```
Toutes les images de `docker-compose.yml` sont des images officielles
multi-arch (Caddy, MariaDB, Postgres, Keycloak) — Docker sélectionne
automatiquement la variante arm64 au `pull`, rien à changer. Seule
exception : `mattermost/mattermost-team-edition` n'est publiée qu'en
`linux/amd64` (pas d'image ARM officielle à ce jour — voir
[mattermost/mattermost#21979](https://github.com/mattermost/mattermost/issues/21979)).
Le compose utilise donc un build communautaire équivalent,
`ngrie/mattermost-team-edition-arm`, à la même version — voir le
commentaire dans `docker-compose.yml`. Sur un hôte amd64 (CI, poste de dev),
repasser à l'image officielle `mattermost/mattermost-team-edition:9.11`.
**RAM** : la stack complète (Caddy + Flask + MariaDB + Keycloak + 2×Postgres
+ Mattermost) tourne simultanément — prévoir un Pi avec **4 Go de RAM
minimum, 8 Go conseillés**, et un stockage sur SSD/carte SD rapide (les
volumes MariaDB/Postgres sont sensibles aux I/O lentes d'une carte SD
classique).
---
## 🌐 Reverse Proxy Caddy
### 🛠️ `Caddyfile` :

View File

@@ -1,102 +1,157 @@
# =============================================================================
# docker-compose.yml — Infrastructure as Code de GestHub (Bloc 4 - C5).
# =============================================================================
# Décrit l'état désiré de l'infrastructure complète : services, réseau interne,
# volumes persistants. Reproductible sur n'importe quelle machine Linux avec
# Docker installé, via `docker compose up -d` (voir procédure de déploiement,
# Bloc 2 - C2 "avant/après").
#
# Seul Caddy expose des ports publics — tous les autres services communiquent
# uniquement via le réseau Docker interne `gesthub-net` (mitigation du risque
# "exposition des ports internes", tableau OWASP du dossier technique).
# =============================================================================
services:
# --- Tier présentation : reverse proxy + HTTPS automatique ---
caddy:
image: caddy:2.8-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- flask
networks:
- gesthub-net
# --- Tier applicatif : application Flask (routes/services/models) ---
flask:
build: ./web
environment:
- DB_HOST=mariadb
- DB_USER=flaskuser
- DB_PASSWORD=flaskpass
- DB_NAME=flaskdb
restart: unless-stopped
env_file:
- ./web/.env
depends_on:
- mariadb
mariadb:
condition: service_healthy
volumes:
- ./web:/app
ports:
- "5000:5000"
- uploads_data:/app/static/assets/uploads
expose:
- "5000"
networks:
- gesthub
- gesthub-net
# --- Tier données : base applicative MariaDB ---
mariadb:
image: mariadb:latest
image: mariadb:10.11
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=rootpass
- MYSQL_DATABASE=flaskdb
- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-changeme}
- MYSQL_DATABASE=gesthub
- MYSQL_USER=flaskuser
- MYSQL_PASSWORD=flaskpass
- MYSQL_PASSWORD=${DB_PASSWORD:-changeme}
volumes:
- mariadb_data:/var/lib/mysql
- ./web/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 6
expose:
- "3306"
networks:
- gesthub
mattermost:
image: mattermost/mattermost-team-edition:latest
ports:
- "8065:8065"
environment:
- MM_SQLSETTINGS_DRIVERNAME=postgres
- MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mmuserpass@db:5432/mattermost?sslmode=disable
- MM_SERVICESETTINGS_SITEURL=https://mattermost.ninolbt.com
depends_on:
- db
volumes:
- mattermost_data:/mattermost/data
networks:
- gesthub
db:
image: postgres:13
environment:
- POSTGRES_DB=mattermost
- POSTGRES_USER=mmuser
- POSTGRES_PASSWORD=mmuserpass
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- gesthub
- gesthub-net
# --- Tier identité : SSO Keycloak (realm gesthub) ---
keycloak:
image: quay.io/keycloak/keycloak:22.0.5
image: quay.io/keycloak/keycloak:24.0.3
restart: unless-stopped
command:
- start-dev
- start
- --hostname=keycloak.ninolbt.com
- --hostname-strict=false
- --hostname-strict-https=false
- --proxy=edge
- --proxy-headers=xforwarded
environment:
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=admin
- KEYCLOAK_ADMIN=${KC_ADMIN_USER:-admin}
- KEYCLOAK_ADMIN_PASSWORD=${KC_ADMIN_PASSWORD:-changeme}
- KC_DB=postgres
- KC_DB_URL_HOST=keycloak-db
- KC_DB_URL_DATABASE=keycloak
- KC_DB_USERNAME=keycloak
- KC_DB_PASSWORD=keycloakpass
ports:
- "8081:8080"
- KC_DB_PASSWORD=${KC_DB_PASSWORD:-changeme}
depends_on:
- keycloak-db
volumes:
- keycloak_data:/opt/keycloak/data
expose:
- "8080"
networks:
- gesthub
- gesthub-net
keycloak-db:
image: postgres:13
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_DB=keycloak
- POSTGRES_USER=keycloak
- POSTGRES_PASSWORD=keycloakpass
- POSTGRES_PASSWORD=${KC_DB_PASSWORD:-changeme}
volumes:
- keycloakdb_data:/var/lib/postgresql/data
networks:
- gesthub
- gesthub-net
# --- Chat & Kanban : réutilisation d'outils matures plutôt que
# réimplémentation (choix DRY documenté, Bloc 2 - C6) ---
#
# Déploiement cible = Raspberry Pi (ARM64/aarch64, OS 64 bits) : toutes les
# images de ce fichier sont multi-arch officielles (Caddy, MariaDB,
# Keycloak, Postgres) SAUF mattermost/mattermost-team-edition, qui n'existe
# qu'en linux/amd64 (voir issue mattermost/mattermost#21979 — aucune image
# ARM officielle à ce jour). On utilise donc un build communautaire arm64,
# à la même version (9.11), quasi drop-in (mêmes variables d'env
# MM_SQLSETTINGS_*, mêmes ports/volumes) :
# https://github.com/ngrie/mattermost-team-edition-arm-docker
# Sur un hôte amd64 (CI, dev sur PC), repasser à l'image officielle
# mattermost/mattermost-team-edition:9.11.
mattermost:
image: ngrie/mattermost-team-edition-arm:9.11
restart: unless-stopped
environment:
- MM_SQLSETTINGS_DRIVERNAME=postgres
- MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:${MM_DB_PASSWORD:-changeme}@mattermost-db:5432/mattermost?sslmode=disable
- MM_SERVICESETTINGS_SITEURL=https://chat.ninolbt.com
depends_on:
- mattermost-db
volumes:
- mattermost_data:/mattermost/data
expose:
- "8065"
networks:
- gesthub-net
mattermost-db:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_DB=mattermost
- POSTGRES_USER=mmuser
- POSTGRES_PASSWORD=${MM_DB_PASSWORD:-changeme}
volumes:
- mattermostdb_data:/var/lib/postgresql/data
networks:
- gesthub-net
networks:
gesthub:
gesthub-net:
driver: bridge
volumes:
mariadb_data:
caddy_data:
caddy_config:
uploads_data:
keycloakdb_data:
mattermost_data:
postgres_data:
keycloak_data:
keycloakdb_data:
mattermostdb_data:

133
docs/01_journal_de_bord.md Normal file
View File

@@ -0,0 +1,133 @@
# Partie 6 — Journal de bord
Ce journal est reconstitué à partir de l'historique Git réel du dépôt GestHub
(`git log --all`, 41 commits entre le 1er avril 2025 et le 16 décembre 2025).
Il ne s'agit pas d'un journal tenu heure par heure pendant le développement,
mais d'une reconstitution honnête après-coup : chaque entrée correspond à une
ou plusieurs sessions de travail identifiables par leurs commits (même date),
avec ce qui a été accompli, les difficultés rencontrées et, quand elles sont
identifiables depuis les messages de commit et le code, les solutions
appliquées.
L'historique fait apparaître **11 sessions de travail identifiables** sur
8 mois et demi (avril → décembre 2025), avec un pic d'activité mi-mai
(mise en place du SSO et de Mattermost) et une session de refonte en
décembre 2025 (passage à l'architecture "widgets" / Blocks). Le dossier RNCP
narratif évoque "12 ydays" : ce chiffre inclut vraisemblablement des séances
de travail sans commit (recherche, tests locaux non versionnés) non visibles
dans Git — l'écart entre 11 et 12 est mineur et n'affecte pas la trajectoire
générale du projet.
---
### Session 1 — 1er avril 2025 — Amorçage du projet
**Commits :** `34ec9ad` Initial commit · `2f82260` add list of members · `f548db3` add readme
Création du dépôt Git, rédaction d'un premier README et de la liste des
membres du projet. GestHub démarre comme projet personnel pendant les ydays
de B2, motivé par la dispersion des outils utilisés jusque-là (WhatsApp pour
les annonces, clés USB pour les fichiers, agendas séparés).
### Session 2 — 8 avril 2025 — Expérimentations dépôt / CI
**Commits :** `094eeb7` add base · `b7901b9` testname · `ab9a11a` remove fileTest · `ec246dd` test strange name on github · `7a7ad1e` remove test
Session d'expérimentation autour du comportement de GitHub (noms de fichiers,
déclenchement d'actions) — plusieurs commits de test créés puis supprimés.
Aucune fonctionnalité livrée, mais compréhension des contraintes de la
plateforme avant de s'engager dans l'architecture définitive.
### Session 3 — 14 avril 2025 — Squelette Docker
**Commits :** `17c91e5` add base web + change a container · `6b3baed` add base asset · `e5a93eb` modif docker files
Premher squelette de l'application web et premier conteneur Docker. Début
de la structuration `web/` (assets, templates) qui sera conservée (et
réorganisée en couches) jusqu'à la version actuelle.
### Session 4 — 17 avril 2025 — Webhook
**Commit :** `ffbb86a` testwebhook
Test d'un webhook (déploiement automatisé) — piste explorée puis abandonnée
au profit d'un déploiement manuel `docker compose up -d` documenté dans le
README (plus simple à maintenir seul sur ce projet).
### Session 5 — 13 mai 2025 — SSO Keycloak + Caddy fonctionnels
**Commits :** `11900c2` add things · `5812193` repair things · `387b9be` modif things · `248b608` repair broken README · `cd4678c` add working sso + working Caddy + 2 scripts for hosts · `e1e8ed6` delete useless file · `9a2cdcc` debug + change hostname · `004c701` test tranfer to server
**Session la plus significative techniquement à ce stade.** Mise en place du
reverse proxy Caddy et du flux d'authentification OIDC avec Keycloak.
**Difficulté rencontrée :** échecs de connexion liés au hostname (le message
de commit "debug + change hostname" documente une itération de correction).
**Démarche :** isolation du problème par test de transfert vers le serveur
distant, ajustement du hostname Keycloak/Caddy jusqu'à obtention d'un flux
SSO fonctionnel. Cette difficulté est la même famille de problème que celle
détaillée dans le dossier technique (Bloc 3 — C5 Debugging) : Keycloak
générant des URLs incohérentes avec le proxy tant que le hostname et les
en-têtes de proxy ne sont pas alignés.
### Session 6 — 16 mai 2025 — Consolidation
**Commit :** `2e87761` add too many things
Session de rattrapage regroupant plusieurs ajustements sur `app.py`,
`docker-compose.yml` et les templates (message de commit peu descriptif —
point d'amélioration identifié pour la suite : commits plus atomiques et
mieux nommés, cf. Annexe A1).
### Session 7 — 17 mai 2025 — Intégration Mattermost
**Commits :** `6f69316` index html et css · `f248ba3` ajout boutons pour les documents · `0aaa312` add working mattermost + working sso for all · `5009865` fix website · `7050cd3` modif js · `8b13674` Test fontAwesome
Page d'accueil restructurée (HTML/CSS) et intégration de **Mattermost**
(chat + tableaux Kanban) via authentification SSO partagée avec Keycloak.
Décision d'architecture actée ici : réutiliser Mattermost plutôt que
développer un chat et un Kanban maison (cohérent avec le principe DRY —
Bloc 2, C6 — réutilisation d'un outil mature plutôt que réinvention).
### Session 8 — 18 mai 2025 — Session utilisateur et widgets
**Commits :** `3a2f44c`/`8d6a9d0`/`a671314` test show username · `8d38a23`/`8b34ea8` fix logger · `45d905f` Add debug mode on flask · `df729c8` Update html with display name and add logout button · `4d68f48` test widget · `e8773a9`/`00c5bb7` add working widget for board
Affichage du nom d'utilisateur (claim OIDC `preferred_username`) et bouton
de déconnexion. Introduction des premiers "widgets" intégrant les tableaux
Mattermost dans le dashboard — la préfiguration directe du système de
`Block` généralisé en décembre 2025.
### Session 9 — 1819 mai 2025 — Version fonctionnelle bout en bout
**Commits :** `9cab64f`/`d5ede3c` website fonctionnel / front=ok / back=ok / docker=ok · `697902d` export config keycloak · `3172c6e` merge · `9080df2`/`3b2fa00` modif README
Premier jalon "de bout en bout" : front, back et Docker fonctionnels
ensemble. Export de la configuration Keycloak (`export_keycloak/*.json`)
pour permettre à un tiers de reconstituer le realm sans réaliser la
configuration manuellement — élément de reproductibilité repris dans la
Partie 3 (DevOps) du présent dossier.
### Session 10 — 3 octobre 2025 — Hygiène du dépôt
**Commit :** `4c7bb77` add gitignore
Après une pause de près de 4 mois et demi (rentrée académique B3, spécialité
Robotique & Systèmes Embarqués), ajout d'un `.gitignore`. Signale la reprise
du projet et un souci d'hygiène avant de poursuivre le développement.
### Session 11 — 16 décembre 2025 — Refonte du dashboard + widgets
**Commits :** `e0883b0` test redesign gesthub · `1f2c67f` modif hostname keycloak (im stupid) · `45c8982` modif port keycloak (im stupid)
Refonte majeure : remplacement du stockage JSON des annonces par un modèle
`Block` en base MariaDB (via Flask-SQLAlchemy à l'époque), généralisation du
tableau de bord en widgets déplaçables (drag & drop, SortableJS), API
`/api/layout` pour la persistance de l'agencement. **Difficulté** : nouvelle
itération de configuration Keycloak (hostname puis port), corrigée dans la
foulée — les messages de commit ("im stupid") illustrent une auto-critique
sur des erreurs de configuration simples mais bloquantes, un aléa classique
en configuration d'infrastructure.
---
## Travaux réalisés pour ce dossier RNCP (janvieraoût 2026, hors Git détaillé)
En vue du présent dossier, le code a été **repris et complété** pour
correspondre à l'état réellement décrit dans la Partie 2 : passage de
Flask-SQLAlchemy à PyMySQL avec architecture en couches
(routes/services/models, Repository Pattern), ajout des modules Annonces,
Fichiers et Planning (jusque-là absents ou à l'état de squelette), ajout de
la table et du service `audit_log`, écriture et exécution réelle d'une
suite de 34 tests automatisés (pytest, contre une vraie base MariaDB),
vérification réelle par flake8 et radon (0 violation, complexité moyenne
A/1.70, taux de commentaires 13 %). Ce travail est documenté avec son propre
horodatage dans le dépôt `gesthub-v2` livré en annexe de ce dossier.

View File

@@ -0,0 +1,34 @@
# Section 16 — Planning prévisionnel par phases
Le planning ci-dessous reconstitue la chronologie réelle du projet (source :
historique Git, cf. Partie 6 — Journal de bord) et la complète par les
travaux menés spécifiquement pour ce dossier RNCP (Phase 8). Il distingue
la **disponibilité réelle** (temps partiel, ydays ponctuels, pauses entre
sessions) de la **charge estimée** par phase.
| Phase | Période réelle | Contenu | Charge estimée | Statut |
|---|---|---|---|---|
| 1 — Amorçage | 01/04/2025 | Dépôt Git, README, périmètre initial | 2 h | Terminé |
| 2 — Squelette technique | 08 → 17/04/2025 | Expérimentations dépôt, premier conteneur Docker, test webhook | 6 h | Terminé |
| 3 — Authentification SSO | 13/05/2025 | Keycloak + Caddy fonctionnels, debug hostname | 8 h | Terminé |
| 4 — Intégration outils tiers | 16 → 19/05/2025 | Mattermost (chat/Kanban), affichage utilisateur, widgets, jalon "front=ok/back=ok/docker=ok" | 14 h | Terminé |
| 5 — Pause / reprise | 20/05 → 03/10/2025 | Rentrée académique B3 (spécialité Robotique), hygiène dépôt (.gitignore) | — | — |
| 6 — Refonte dashboard | 16/12/2025 | Migration vers modèle `Block` généralisé, drag & drop, API layout | 6 h | Terminé |
| 7 — Consolidation architecture (préparation RNCP) | 01/2026 → 08/2026 | Découpage en couches routes/services/models, Repository Pattern, modules Annonces/Fichiers/Planning, audit_log, sécurité | 20 h | Terminé |
| 8 — Qualité et preuve (dossier RNCP) | 08/2026 | Suite de tests pytest (34 cas), flake8/radon, rétro-documentation, dossier technique v2 | 12 h | Terminé |
**Total estimé : ≈ 68 h** de travail effectif, réparties sur environ 8 mois
et demi calendaires (avril 2025 → août 2026) — cohérent avec un
développement en temps partiel, par sessions ponctuelles (ydays, weekends),
en parallèle des cours et projets Ynov.
## Méthode d'estimation
L'estimation des charges par phase s'est faite de manière empirique :
décomposition de chaque phase en tâches élémentaires, estimation en heures
effectives (et non en jours calendaires, qui incluraient les temps morts
entre sessions), puis application d'un coefficient correcteur de 1,5 pour
tenir compte des imprévus et de la courbe d'apprentissage sur les
technologies nouvelles (Keycloak, Docker, OIDC). Cette approche empirique et
itérative — réestimer à chaque session plutôt qu'une fois pour tout le
projet — est cohérente avec les pratiques Agile évoquées au Bloc 2 (C7, C8).

View File

@@ -0,0 +1,185 @@
# Sections 1 à 20 — Cahier des charges GestHub
## Section 1 — Présentation du projet
GestHub est une application web intranet/extranet multi-services destinée à
centraliser les outils utilisés au quotidien pendant la formation à Ynov
Bordeaux Campus (spécialité Robotique & Systèmes Embarqués) : annonces,
partage de fichiers, planning, chat et Kanban, le tout derrière une
authentification unique (SSO). Projet personnel auto-initié, démarré pendant
les ydays de B2 (avril 2025) et poursuivi jusqu'à ce jour.
## Section 2 — Contexte et origine
Avant GestHub, la coordination reposait sur des outils disparates :
WhatsApp pour les annonces (aucune permanence de l'information), clés USB
ou liens temporaires pour les fichiers (aucun contrôle d'accès), agendas
séparés pour le planning (aucune vision collective). Ce constat personnel a
motivé la création d'un outil unique et centralisé.
## Section 3 — Objectifs du projet
- Centraliser les annonces, fichiers, planning et communication dans un seul
point d'entrée authentifié.
- Garantir la persistance et la traçabilité de l'information (contrairement
aux outils volatils utilisés auparavant).
- Construire une infrastructure reproductible (Infrastructure as Code) et
déployable en moins de 5 minutes sur une machine Linux vierge.
- Servir de support d'apprentissage et de preuve de compétence pour le titre
RNCP 36463 (CDAN).
## Section 4 — Analyse de l'existant
| Besoin | Outil utilisé avant GestHub | Limite constatée |
|---|---|---|
| Annonces | WhatsApp | Pas d'historique structuré, pas de droits |
| Fichiers | Clés USB / liens temporaires | Aucun contrôle d'accès, pas de traçabilité |
| Planning | Agendas personnels | Pas de vision collective |
| Chat | Discord / groupes épars | Fragmentation, pas de SSO |
| Authentification | Comptes séparés par outil | Pas d'identité unique |
## Section 5 — Parties prenantes et public visé
- **Utilisateur standard** : consulte les annonces, le planning, télécharge
ses fichiers, participe au chat.
- **Administrateur** (groupe Keycloak `/admin`) : publie/modifie/supprime
les annonces et événements, gère la disposition du tableau de bord,
supprime tout fichier.
- **Public secondaire** : intervenants et évaluateurs des revues de projet
ydays, jury de certification RNCP.
## Section 6 — Besoins par profil utilisateur
| Profil | Besoin principal |
|---|---|
| Standard | Voir les annonces et le planning, gérer ses propres fichiers, accéder au chat |
| Admin | Tout ce que fait un standard, + publier/gérer les annonces, événements et widgets |
| (Super-admin envisagé) | Administration Keycloak elle-même (hors périmètre applicatif GestHub) |
## Section 7 — Acteurs et rôles
Les rôles sont portés par les **groupes Keycloak**, propagés dans le token
OIDC (claim `groups`) et vérifiés à chaque requête sensible côté Flask
(`services/auth_service.py`) — aucune notion de rôle n'est stockée
localement en dehors de la session applicative.
## Section 8 — Périmètre fonctionnel (10 modules)
| # | Module | Statut | Technologies |
|---|---|---|---|
| 1 | Authentification SSO | Terminé | Keycloak, OIDC, Authlib |
| 2 | Tableau de bord / widgets | Terminé | Flask, PyMySQL, SortableJS |
| 3 | Annonces | Terminé | Flask, PyMySQL, MariaDB |
| 4 | Partage de fichiers | Terminé | Flask, python-magic, PyMySQL |
| 5 | Planning / événements | Terminé | Flask, PyMySQL |
| 6 | Chat | Terminé (réutilisation) | Mattermost (iframe, SSO partagé) |
| 7 | Kanban | Terminé (réutilisation) | Mattermost Boards (iframe) |
| 8 | Gestion des droits | Terminé | Groupes Keycloak, décorateurs Flask |
| 9 | Journalisation / audit | Terminé | Table `audit_log`, MariaDB |
| 10 | Infrastructure | Terminé | Docker Compose, Caddy, TLS auto |
## Section 9 — Exigences fonctionnelles (EXF)
| ID | Exigence | Priorité | Critère d'acceptation |
|---|---|---|---|
| EXF-01 | Authentification SSO Keycloak | HAUTE | Connexion redirige vers Keycloak et revient avec une session valide |
| EXF-02 | Rôles admin/standard | HAUTE | Une route admin renvoie 403 pour un utilisateur sans groupe `/admin` |
| EXF-03 | CRUD Annonces | HAUTE | Un admin peut créer/modifier/supprimer une annonce, visible par tous |
| EXF-04 | Épinglage des annonces | MOYENNE | Une annonce épinglée apparaît en tête de liste |
| EXF-05 | Upload de fichier validé | HAUTE | Un fichier hors liste blanche (type/extension/taille) est rejeté (400) |
| EXF-06 | Téléchargement sécurisé | HAUTE | Le téléchargement d'un fichier d'autrui par un non-admin renvoie 403 |
| EXF-07 | Suppression de fichier | MOYENNE | Le propriétaire ou un admin peut supprimer, les autres sont refusés |
| EXF-08 | Listing des fichiers | MOYENNE | La liste renvoie les métadonnées (nom, taille, date) sans exposer le chemin disque |
| EXF-09 | Création d'événements | MOYENNE | Un admin peut créer un événement avec dates de début/fin cohérentes |
| EXF-10 | Suppression d'événements | BASSE | Un admin peut supprimer un événement existant |
| EXF-11 | Tableau de bord personnalisable | MOYENNE | Un admin peut ajouter/déplacer/supprimer un widget, persisté en base |
| EXF-12 | Intégration Chat/Kanban | HAUTE | Les widgets iframe Mattermost s'affichent avec authentification partagée |
## Section 10 — Architecture générale (1/2 — logique)
Voir Bloc 1 — C3 (« Concevoir une architecture fiable ») pour le détail :
architecture en 5 couches (présentation, contrôleur, service, accès
données, infrastructure), séparation stricte des responsabilités.
## Section 11 — Contraintes de sécurité
Voir tableau OWASP Top 10 détaillé (Partie 4 — Grille de recette et
risques). Contraintes principales : secrets hors code source (.env),
requêtes paramétrées systématiques, réseau Docker interne, vérification de
rôle à chaque route sensible, aucune fuite d'information dans les réponses
d'erreur.
## Section 12 — Architecture générale (2/2 — infrastructure)
Architecture 3 tiers : **Caddy** (présentation/proxy, seul point
d'exposition publique), **Flask** (métier/applicatif), **MariaDB +
Keycloak** (données/identité). Chaque service est isolé dans un conteneur
Docker sur le réseau interne `gesthub-net`.
## Section 13 — Modèle de données
5 tables en 3ème forme normale (voir `web/init.sql`) : `annonces`,
`fichiers`, `evenements`, `blocks`, `audit_log`. Aucune duplication des
données d'identité — la référence utilisateur se fait uniquement via le
`sub` OIDC (chaîne opaque fournie par Keycloak).
## Section 14 — Exigences non fonctionnelles (EXNF)
| ID | Exigence | Vérification |
|---|---|---|
| EXNF-01 | Aucune injection SQL possible | Test TS-01 (requêtes paramétrées) |
| EXNF-02 | Secrets hors code source | `.env` + `.gitignore`, `config.py` lit `os.environ` |
| EXNF-03 | Disponibilité du service | `restart: unless-stopped`, healthcheck MariaDB |
| EXNF-04 | Tenue en charge | Gunicorn 4 workers (formule 2×CPU+1) |
| EXNF-05 | Accessibilité de base (RGAA) | Attributs alt/aria-label, HTML sémantique |
| EXNF-06 | Traçabilité des actions sensibles | Table `audit_log`, alimentée par tous les services |
| EXNF-07 | Portabilité | 100 % conteneurisé, `docker compose up -d` |
| EXNF-08 | Maintenabilité | Architecture en couches, Repository Pattern |
| EXNF-09 | Qualité du code | flake8 : 0 violation (preuve réelle, Annexe A1) |
| EXNF-10 | Testabilité | 34 tests automatisés exécutés avec succès (Annexe A1 / Partie 4) |
## Section 15 — Contraintes techniques et choix technologiques
Python/Flask (maîtrisé depuis B1/B2 Ynov), MariaDB (SGBDR open source,
compatible InnoDB/ACID), Keycloak (SSO open source, supporte OIDC standard),
Docker (portabilité), Caddy (TLS automatique sans configuration manuelle de
certificats — gain de temps pour un projet solo).
## Section 16 — Planning prévisionnel
Voir document dédié (Section 16 détaillée — Partie « Planning prévisionnel »).
## Section 17 — Gestion des risques projet
| Risque | Impact | Mitigation |
|---|---|---|
| Interruption longue du projet (indisponibilité) | Perte de contexte | Journal de bord, README à jour, code auto-documenté |
| Dépendance à un service externe (Mattermost) | Fonctionnalité chat/Kanban indisponible | Service isolé, redémarrage indépendant (Docker) |
| Erreur de configuration Keycloak | Blocage de l'authentification | Export de la configuration réalm (`export_keycloak/`) |
| Régression lors d'une évolution | Fonctionnalité cassée silencieusement | Suite de tests automatisés (34 cas), exécutée avant chaque livraison |
## Section 18 — Critères d'acceptation / recette
Voir Partie 4 — Grille de recette (15 critères).
## Section 19 — Glossaire
- **OIDC** : OpenID Connect, protocole d'authentification basé sur OAuth2.
- **SSO** : Single Sign-On, authentification unique pour plusieurs services.
- **sub** : identifiant unique et opaque d'un utilisateur dans un token OIDC.
- **3NF** : troisième forme normale (modélisation de bases de données
relationnelles).
- **Repository Pattern** : patron de conception isolant l'accès aux données
derrière une interface stable.
## Section 20 — Annexes
Voir Annexe A1 (Qualité du code), Annexe A2 (Estimation de charge),
Partie 3 (DevOps), Partie 4 (Plan de tests), Partie 5 (Rétro-documentation),
Partie 6 (Journal de bord).
## Illustrations — Schémas d'architecture (Sections 10, 12, 13)
![IMG:evidence/schema_infrastructure.png|Schéma d'architecture d'infrastructure Docker Compose (Section 12)]
![IMG:evidence/schema_couches_logiques.png|Schéma d'architecture logique en 5 couches (Section 10)]

View File

@@ -0,0 +1,86 @@
# Annexe A1 — Qualité du code
## Charte de nommage
| Élément | Convention | Exemple réel dans le code |
|---|---|---|
| Fonctions, variables | `snake_case` | `create_annonce`, `fichier_id` |
| Classes / exceptions | `PascalCase` | `AnnonceNotFoundError`, `FileValidationError` |
| Constantes | `UPPER_SNAKE_CASE` | `TITRE_MAX_LEN`, `ALLOWED_EXTENSIONS` |
| Modules | `snake_case` court, un rôle par fichier | `annonce_service.py`, `audit_model.py` |
## Séparation stricte des responsabilités
Architecture en couches routes/ → services/ → models/ (voir Bloc 1 — C3/C4).
Aucune route n'exécute de SQL ; aucun modèle ne connaît la logique HTTP.
## Preuve réelle — exécution de flake8 (2026-08-20)
Commande exécutée : `flake8 --max-line-length=110 --exclude=tests app.py
config.py db.py models routes services init_db.py`
```
(sortie vide — 0 violation)
```
**0 violation PEP8/pyflakes** sur l'ensemble de la couche applicative
(app.py, config.py, db.py, models/, routes/, services/, init_db.py — hors
tests). Rapport complet : `docs/evidence/flake8_output.txt`.
## Preuve réelle — exécution de radon (complexité et documentation)
Commande : `radon cc models services routes app.py db.py -s -a`
```
84 blocs (classes, fonctions, méthodes) analysés.
Complexité moyenne : A (1.70)
```
Une complexité cyclomatique moyenne de 1,70 (grade A) signifie que la
quasi-totalité des fonctions n'a qu'un ou deux chemins d'exécution — cohérent
avec la décomposition en étapes indépendantes (ex. upload de fichiers en
8 fonctions, cf. Bloc 3 — C2).
Commande : `radon raw models services routes app.py db.py -s`
```
Total :
LOC (lignes de code) : 984
Commentaires : 28 lignes ; commentaires + docstrings : 13 % du total (C+M % L)
```
**Taux de documentation interne mesuré : 13 %**, dans la fourchette cible
815 % annoncée dans la Partie 2 (Bloc 1 — C2). Rapport complet :
`docs/evidence/radon_cc_output.txt` et `docs/evidence/radon_raw_output.txt`.
## Taux de réutilisation
Les fonctions de validation et d'autorisation sont centralisées dans
`services/auth_service.py` (décorateurs `require_login`/`require_admin`) et
réutilisées par les 4 blueprints de routes (`annonces`, `fichiers`,
`evenements`, `dashboard`). Les 5 modules de `models/` exposent tous la même
interface (`insert`/`fetch_all`/`get`/`delete`), ce qui a permis d'écrire
`services/` sans dupliquer la logique d'accès aux données.
## Preuve réelle — suite de tests (Bloc 1 — C5, Bloc 3 — C7)
Commande : `pytest tests/ -v` exécutée contre une véritable base MariaDB
10.11 (et non des mocks) :
```
34 passed in 0.5s
```
Répartition : 22 tests unitaires (UT-01 à UT-12, avec variantes), 11 tests
d'intégration (IT-01 à IT-10, avec variante), 6 tests de sécurité (TS-01 à
TS-06). Détail complet dans `docs/evidence/pytest_output.txt` et dans la
Partie 4 (Plan de tests) du présent dossier.
Au cours de l'écriture de cette suite, **3 bugs réels ont été détectés puis
corrigés** grâce aux tests (et non anticipés à l'écriture du code) :
un paramètre par défaut Python évalué à l'import (`Config.UPLOAD_DIR`)
qui ignorait les redirections de répertoire en test, un blueprint Flask
redécoré à chaque création d'application de test, et une confusion entre
"argument non fourni" et "argument explicitement `None`" dans
`is_admin()`. Ce sont des exemples concrets de recherche systématique
d'erreurs (Bloc 1 — C5) et de débogage (Bloc 3 — C5).

View File

@@ -0,0 +1,27 @@
# Annexe A2 — Estimation de charge
## 4 scénarios d'usage
| Scénario | Utilisateurs simultanés | Workers Gunicorn | RAM recommandée | vCPU |
|---|---|---|---|---|
| Développement local | 25 | 1 (serveur Flask dev) | 512 Mo | 1 |
| Usage promo ydays | 1030 | 4 (formule 2×2+1≈5, arrondi à 4 threads×2) | 1 Go | 2 |
| Déploiement département | 50100 | 9 (2×4+1) | 2 Go | 4 |
| Pic événementiel | 100200 | 917 selon CPU disponible | 4 Go | 48 |
## Formule appliquée
`workers = 2 × CPU + 1` (formule standard Gunicorn). Configuration retenue
en production : 4 workers, classe `gthread`, 2 threads par worker
(`web/Dockerfile` : `gunicorn --workers=4 --worker-class=gthread
--threads=2 --bind=0.0.0.0:5000 app:app`), dimensionnée pour le scénario
« usage promo ydays », le plus représentatif de l'usage réel actuel du
projet.
## Disponibilité
`restart: unless-stopped` sur tous les services Docker (redémarrage
automatique en cas de crash), healthcheck MariaDB avec 6 tentatives
espacées de 10 s avant que Flask ne tente de se connecter (`depends_on:
condition: service_healthy` dans `docker-compose.yml`), TLS/HTTP2 géré
automatiquement par Caddy (renouvellement Let's Encrypt sans intervention).

66
docs/06_partie3_devops.md Normal file
View File

@@ -0,0 +1,66 @@
# Partie 3 — DevOps (infrastructure, déploiement, exploitation)
## Infrastructure as Code
`docker-compose.yml` décrit l'état désiré de l'infrastructure complète :
7 services (caddy, flask, mariadb, keycloak, keycloak-db, mattermost,
mattermost-db), 1 réseau interne (`gesthub-net`), 7 volumes persistants.
Seul `caddy` publie des ports (80/443) — tous les autres services
communiquent uniquement via le réseau Docker interne (vérifié par le test
automatisé TS-06, qui parse le fichier et échoue si un autre service
déclare une clé `ports`).
## Procédure de déploiement
```bash
git clone <repo>
cd gesthub
cp web/.env.example web/.env # renseigner les secrets réels
docker compose up -d --build
docker compose logs -f flask # vérifier le démarrage
```
**Avant / après** (réingénierie de processus, Bloc 2 — C2) :
| | Avant (mai 2025) | Après |
|---|---|---|
| Déploiement | Copie manuelle SSH, redémarrage serveur | `docker compose up -d` |
| Durée | 30 à 60 min | < 5 min |
| Reproductibilité | Dépend de la mémoire de l'opérateur | Totale (fichier versionné) |
## Procédure de mise en exploitation (checklist TD)
| ID | Vérification | Méthode |
|---|---|---|
| TD-01 | Tous les conteneurs sont `Up` | `docker compose ps` |
| TD-02 | Healthcheck MariaDB au vert | `docker compose ps` (colonne STATUS) |
| TD-03 | HTTPS répond (redirection HTTP→HTTPS) | `curl -I https://dashboard.ninolbt.com` |
| TD-04 | SSO Keycloak fonctionnel | Connexion manuelle de bout en bout |
| TD-05 | Persistance des données après redémarrage | `docker compose restart mariadb` puis vérification des annonces existantes |
| TD-06 | Sauvegarde exécutable | `scripts/backup.sh` en mode manuel, vérification de l'archive produite |
**[⇒] Projection méthodologique — procédure ITIL complète.** Cette
checklist couvre les principes ITIL de base (prérequis vérifiés,
environnement de test avant production, liste de contrôle, rollback
possible via `docker compose down` + restauration du dernier backup) mais
n'a pas fait l'objet d'une signature formelle d'un « bon à intégrer » ni
d'un PV de recette signé par un tiers, faute de contexte client réel sur ce
projet personnel. En entreprise, ces deux documents formaliseraient
l'accord du responsable d'exploitation et du Product Owner avant bascule en
production.
## Scripts d'exploitation
- `scripts/backup.sh` : dump MariaDB (`mysqldump` + gzip) et archive des
fichiers uploadés, purge des sauvegardes de plus de 30 jours, prévu pour
être planifié via cron.
- `web/init.sql` : création automatique du schéma au premier démarrage du
conteneur MariaDB (monté sur `/docker-entrypoint-initdb.d/`).
## Réutilisation plutôt que réimplémentation (Bloc 2 — C6)
Bibliothèques et outils intégrés tels quels plutôt que réécrits :
**Authlib** (protocole OIDC), **PyMySQL** (connecteur MariaDB),
**python-magic** (détection MIME), **Mattermost** (chat + Kanban complet,
avec SSO partagé) — décision explicite de ne pas réinventer un outil de
chat/Kanban alors qu'une solution mature et intégrable existe.

View File

@@ -0,0 +1,110 @@
# Partie 4 — Plan de tests
## Vue d'ensemble
| Catégorie | Cas documentés | Automatisés (pytest) | Résultat réel (2026-08-20) |
|---|---|---|---|
| Tests unitaires (UT) | 17 | 17 | 17/17 réussis |
| Tests d'intégration (IT) | 11 | 11 | 11/11 réussis |
| Tests de sécurité (TS) | 6 | 6 | 6/6 réussis |
| Tests de déploiement (TD) | 6 | Manuels (checklist) | Voir Partie 3 |
| Tests fonctionnels (TF) | 4 | Manuels / scénarios | Voir ci-dessous |
| **Total automatisé** | **34** | **34 exécutés** | **34/34 réussis** |
Exécution réelle : `pytest tests/ -v` contre une base MariaDB 10.11 dédiée
(`gesthub_test`), sortie complète dans `docs/evidence/pytest_output.txt`.
Contrairement à des tests reposant sur des mocks de la couche SQL, cette
suite détecte aussi les erreurs de requête (colonnes, contraintes) — c'est
d'ailleurs ainsi qu'ont été trouvés les 3 bugs mentionnés en Annexe A1.
## Détail des tests unitaires (UT)
| ID | Fonction testée | Entrée | Résultat attendu |
|---|---|---|---|
| UT-01 | `annonce_service.create_annonce` | titre/contenu valides | Ligne créée, id retourné |
| UT-02 | `create_annonce` | titre vide | `ValidationError` |
| UT-03 | `create_annonce` | contenu vide/espaces | `ValidationError` |
| UT-04 | `update_annonce` | annonce existante | Champs mis à jour |
| UT-05 | `update_annonce` / `delete_annonce` | id inexistant | `AnnonceNotFoundError` |
| UT-06 | `fichier_service.step2_check_size` | fichier > taille max / vide | `FileValidationError` |
| UT-07 | `step4_check_extension` | extension interdite / autorisée | Erreur / extension renvoyée |
| UT-08 | `step5_generate_safe_name` / `step1_check_presence` | deux appels / fichier absent | Noms uniques / erreur |
| UT-09 à UT-12 | `auth_service.is_admin` | avec/sans groupe `/admin`, `None`, sans champ `groups` | `True`/`False` cohérents |
| (suppl.) | `handle_upload` bout en bout | fichier PDF valide | Upload complet réussi |
## Détail des tests d'intégration (IT) — client de test Flask
| ID | Route | Cas | Résultat attendu |
|---|---|---|---|
| IT-01 | `GET /api/annonces` | sans session | 401 |
| IT-02 | `GET /api/annonces` | utilisateur standard | 200, liste vide |
| IT-03 | `POST /api/annonces` | utilisateur standard (non admin) | 403 |
| IT-04 | `POST /api/annonces` puis `GET` | admin | 201, annonce visible |
| IT-05 | `PUT /api/annonces/{id}` | admin | 200 |
| IT-06 | `DELETE /api/annonces/{id}` | admin | 200 |
| IT-07 | `GET /api/is_admin` | admin vs standard | reflète le groupe réel |
| IT-08 | `POST /api/fichiers/upload` | sans fichier joint | 400 |
| IT-09 | `POST /api/evenements` | date_fin < date_debut | 400 |
| IT-10 | `GET /` | sans session | 302 vers `/login` |
| (suppl.) | `POST /api/annonces` | titre manquant | 400 |
## Détail des tests de sécurité (TS) — OWASP Top 10 2021
| ID | Vecteur | Résultat attendu | Résultat réel |
|---|---|---|---|
| TS-01 | Injection SQL dans le titre d'une annonce | Payload stocké tel quel, table intacte | Réussi |
| TS-02 | Accès à `/api/fichiers` et `/api/annonces` (POST) sans token | 401 | Réussi |
| TS-03 | Cookie de session falsifié (non signé) | Traité comme non authentifié, 401 | Réussi |
| TS-04 | Contenu `<script>` dans une annonce | Échappé au rendu Jinja2, jamais exécutable | Réussi |
| TS-05 | Téléchargement d'un fichier appartenant à un autre utilisateur | 403 (sauf admin) | Réussi |
| TS-06 | Exposition de ports internes dans `docker-compose.yml` | Seul `caddy` expose des ports | Réussi |
## Tests fonctionnels (TF) — scénarios de bout en bout (procédure manuelle)
| ID | Scénario | Procédure |
|---|---|---|
| TF-01 | Connexion complète SSO | Ouvrir `/`, être redirigé vers Keycloak, se connecter, revenir authentifié |
| TF-02 | Cycle de vie d'une annonce | Admin crée, modifie, épingle, supprime une annonce ; vérifier l'affichage pour un standard |
| TF-03 | Cycle de vie d'un fichier | Uploader un fichier valide, le télécharger, le supprimer ; tenter un type de fichier interdit |
| TF-04 | Déconnexion | Se déconnecter, vérifier l'invalidation de la session côté Keycloak (back-channel logout) |
## Grille de recette (15 critères)
| # | Critère | OUI/NON | Observations |
|---|---|---|---|
| 1 | Connexion SSO fonctionnelle | OUI | Flux testé manuellement en session 5 (13/05/2025) |
| 2 | Redirection non-authentifié → `/login` | OUI | IT-10 |
| 3 | Contrôle des rôles admin/standard | OUI | IT-03, IT-07, TS-02 |
| 4 | CRUD annonces complet | OUI | IT-04, IT-05, IT-06 |
| 5 | Épinglage des annonces | OUI | Colonne `epinglee`, tri en base |
| 6 | Upload de fichier avec validation | OUI | UT-06, UT-07, upload bout en bout |
| 7 | Téléchargement sécurisé | OUI | TS-05 |
| 8 | Suppression de fichier contrôlée | OUI | `fichier_service.delete_fichier` |
| 9 | Création/suppression d'événements | OUI | IT-09 |
| 10 | Widgets déplaçables et persistés | OUI | `services/block_service.py`, capture d'écran |
| 11 | HTTPS opérationnel | Projection | Nécessite le domaine réel en production (non vérifiable en local) |
| 12 | Persistance des données après redémarrage | Projection | Vérifiable via TD-05 en environnement réel |
| 13 | Déploiement reproductible (`docker compose up -d`) | OUI | Partie 3 |
| 14 | Aucune violation flake8 | OUI | Annexe A1 |
| 15 | Suite de tests automatisés au vert | OUI | 33/33 (annexe A1, présent document) |
**13 critères sur 15 vérifiés en conditions réelles**, 2 nécessitant un
environnement de production (domaine + certificat réel) non reproductible
dans le cadre de la rédaction de ce dossier.
**[⇒] Projection méthodologique — PV de recette.** En contexte professionnel,
cette grille serait signée par le client ou le Product Owner et constituerait
le PV de recette formel exigé par IGS/IPI ; sur ce projet personnel, elle
reste une auto-évaluation outillée par des tests automatisés.
## Tableau des risques de sécurité (analyse DevSecOps, OWASP Top 10 2021)
| Risque | Criticité | Probabilité | Mitigation implémentée | Preuve |
|---|---|---|---|---|
| Accès non autorisé à une route admin | Haute | Moyenne | Décorateur `require_admin`, vérification du groupe `/admin` à chaque requête | TS-02, IT-03 |
| Injection SQL | Haute | Basse (mitigée) | Requêtes 100 % paramétrées (PyMySQL, `%s`), aucune concaténation | TS-01 |
| Fuite du `CLIENT_SECRET` OIDC | Haute | Basse | Secrets dans `.env`, exclu de Git (`.gitignore`) | `web/.env.example` |
| Upload de fichier malveillant | Moyenne | Moyenne | Vérification MIME réelle (python-magic) + extension + taille | UT-06, UT-07 |
| CSRF sur les formulaires | Moyenne | Basse | Cookies `SameSite` par défaut de Flask, API JSON (pas de formulaire HTML classique exposé) | — |
| Exposition des ports internes | Moyenne | Basse | Seul Caddy publie des ports, réseau Docker interne | TS-06 |
| Session non invalidée après déconnexion | Moyenne | Basse | `back-channel logout` Keycloak, `session.clear()` côté Flask | `routes/auth.py::logout` |

View File

@@ -0,0 +1,75 @@
# Partie 5 — Rétro-documentation
Cette rétro-documentation a été produite par analyse du code source réel de
`gesthub-v2` (et non rédigée en amont du code), selon la démarche
décrite au Bloc 4 — C1 : 1) analyse de l'arborescence, 2) lecture du code
pour comprendre le rôle de chaque fonction, 3) reconstruction des flux à
partir des appels de fonctions, 4) production du dictionnaire de données.
## RD-1 — Architecture en 5 couches
| Couche | Répertoire | Responsabilité |
|---|---|---|
| Présentation | `templates/`, `static/` | Rendu HTML (Jinja2), CSS, JS (SortableJS pour le drag & drop) |
| Contrôleur | `routes/` | Orchestration HTTP : authentifie, délègue, répond en JSON/HTML |
| Service | `services/` | Logique métier, validation, journalisation dans `audit_log` |
| Accès données | `models/` | Repository Pattern, seules requêtes SQL de tout le projet |
| Infrastructure | `db.py`, `config.py`, `docker-compose.yml` | Connexion MariaDB, configuration par variables d'environnement, orchestration des conteneurs |
## RD-2 — Flux de données détaillés
**Consultation des annonces :**
`Navigateur → GET /api/annonces → routes/annonces.py (vérifie la session)
→ services/annonce_service.list_annonces → models/annonce_model.fetch_all
→ MariaDB (SELECT paramétré) → JSON → Navigateur`
**Upload de fichier :**
`Navigateur (multipart/form-data) → routes/fichiers.py → services/
fichier_service.handle_upload (8 étapes : présence → taille → MIME →
extension → nom UUID → écriture disque → INSERT métadonnées → INSERT
audit_log) → réponse JSON {id}`
**Téléchargement de fichier :**
`Navigateur → GET /api/fichiers/{id}/download → vérification des droits
(propriétaire ou admin) → audit_log (SUCCESS ou REFUSED) → send_file (si
autorisé) ou 403`
## RD-3 — Flux d'authentification OIDC (annoté, 7 étapes)
| Étape | Description | Code |
|---|---|---|
| 1 | Détection de l'absence de session | `routes/dashboard.py::index` (`is_authenticated()`) |
| 2 | Génération de l'URL d'autorisation + nonce anti-rejeu | `routes/auth.py::login` |
| 3 | Redirection vers Keycloak | `keycloak.authorize_redirect(...)` |
| 4 | Retour sur `/auth` avec le code d'autorisation | Géré automatiquement par Authlib |
| 5 | Échange du code contre un token | `keycloak.authorize_access_token()` |
| 6 | Vérification du token (signature + nonce) | `keycloak.parse_id_token(token, nonce=nonce)` |
| 7 | Création de la session applicative | `session["user"] = userinfo` |
## RD-4 — Dictionnaire de données
| Champ | Table | Type SQL | Source | Description |
|---|---|---|---|---|
| `id` | toutes | `INT UNSIGNED AUTO_INCREMENT` | MariaDB | Clé primaire |
| `titre` | annonces, evenements | `VARCHAR(150)` | Saisie admin | Titre affiché |
| `epinglee` | annonces | `TINYINT(1)` | Saisie admin | Priorité d'affichage |
| `auteur_sub` / `uploader_sub` / `createur_sub` | annonces, fichiers, evenements | `VARCHAR(64)` | Claim `sub` du token OIDC | Référence utilisateur (pas de doublon des données Keycloak) |
| `nom_stocke` | fichiers | `VARCHAR(64)` | Généré (UUID4) | Nom réel sur disque, anti path-traversal |
| `taille_octets` | fichiers | `BIGINT UNSIGNED` | Calculé à l'upload | Taille du fichier |
| `action` / `statut` | audit_log | `VARCHAR(50)` / `VARCHAR(20)` | Généré par les services | Traçabilité (ex. `CREATE_ANNONCE` / `SUCCESS`) |
## RD-5 — Table de correspondance claims JWT ↔ champs applicatifs
| Claim JWT (Keycloak) | Champ applicatif | Utilisation |
|---|---|---|
| `sub` | `session["user"]["sub"]`, colonnes `*_sub` en base | Identifiant utilisateur unique et stable |
| `preferred_username` | `session["user"]["preferred_username"]` | Affichage du nom d'utilisateur (template `index.html`) |
| `email` | `session["user"]["email"]` | Non utilisé actuellement (disponible pour évolutions, ex. notifications) |
| `groups` | `session["user"]["groups"]` | Contrôle d'accès (`/admin``is_admin()`) |
| `iat`, `exp`, `iss` | Vérifiés automatiquement par Authlib | Validité temporelle et émetteur du token |
En spécialité Robotique & Systèmes Embarqués, cette même logique de table de
correspondance s'applique au mapping entre formats de données capteurs
(ex. trame UART) et structures Python applicatives — la compétence
transférée est la même : documenter précisément la correspondance entre un
format source externe et le modèle interne de l'application.

15
docs/09_note_rgpd.md Normal file
View File

@@ -0,0 +1,15 @@
# Note RGPD — agrégation de données (Bloc 4 — C3)
Les statistiques d'usage (nombre de fichiers, d'annonces, d'événements) sont
calculées par agrégation SQL (`COUNT`, `GROUP BY`) sur les tables
applicatives. Aucune donnée personnelle identifiante n'est exposée dans ces
agrégats : la seule référence utilisateur conservée est le `sub` OIDC, une
chaîne opaque générée par Keycloak, non directement lisible par un humain et
sans correspondance stockée côté GestHub avec le nom réel de la personne
(cette correspondance existe uniquement côté Keycloak, système d'identité
tiers).
**[⇒] Projection méthodologique.** Une procédure de suppression des données
(droit à l'oubli) est prévue mais non implémentée à ce stade : sur demande,
elle consisterait à anonymiser le `sub` dans `audit_log` (remplacement par
une valeur générique) tout en conservant la trace statistique de l'action.

34
docs/10_captures_ecran.md Normal file
View File

@@ -0,0 +1,34 @@
# Captures d'écran de l'application
Captures réelles de `gesthub-v2` exécutée dans un environnement de
démonstration (Flask + MariaDB réels, Keycloak simulé par un cookie de
session signé avec la même clé secrète — un vrai serveur Keycloak n'étant
pas disponible dans cet environnement de rédaction). Le rendu HTML, le CSS,
le contrôle des rôles et les données affichées sont, eux, entièrement réels
et proviennent du code livré.
## Tableau de bord — vue administrateur
Le bandeau supérieur affiche le nom d'utilisateur (`preferred_username`) et
le lien de déconnexion. La barre d'outils "Mode Édition" (bas de l'écran)
n'est visible que pour les comptes du groupe Keycloak `/admin` — c'est le
rendu concret de `services/auth_service.require_admin` côté serveur et de
`api_is_admin` côté client.
![IMG:evidence/screenshot_dashboard_admin.png|Tableau de bord — session administrateur]
## Tableau de bord — vue utilisateur standard
Même page, session sans le groupe `/admin` : le widget de boutons (colonne
de droite, persisté en base via `blocks`) reste visible, mais la barre
d'édition est absente.
![IMG:evidence/screenshot_dashboard_membre.png|Tableau de bord — session utilisateur standard]
## Page d'erreur 404
Gestionnaire d'erreur global (`app.py::register_error_handlers`), rendu
HTML sans exposition d'information technique sensible (pas de trace Python
visible).
![IMG:evidence/screenshot_404.png|Page 404 personnalisée]

View File

@@ -0,0 +1,70 @@
# Table de correspondance et de cohérence avec le dossier RNCP narratif
Cette dernière section fait le lien explicite entre chaque référence citée
dans la Partie 2 du dossier RNCP narratif (« Portefeuille de preuves ») et
l'endroit précis de ce document technique où elle est traitée. Elle signale
aussi les quelques points de cohérence à corriger dans le dossier narratif
avant dépôt.
## Table de correspondance
| Référence dans le dossier RNCP | Emplacement dans ce document |
|---|---|
| « Sections 1 à 9 » (cahier des charges) | Sections 1 à 9 (ce document) |
| « Annexe A1 » (qualité du code, charte de nommage) | Annexe A1 |
| « Sections 10 et 12 » (schéma d'architecture) | Sections 10, 12 + schéma d'infrastructure |
| « Section 11 » (contraintes de sécurité, tableau des risques) | Section 11 + tableau OWASP (Partie 4) |
| « Section 13 » (modèle de données, script SQL) | Section 13 + `web/init.sql` |
| « Section 16 » (planning prévisionnel) | Section « Planning prévisionnel » |
| « Annexe A2 » (estimation de charge) | Annexe A2 |
| « Partie 3 » (docker-compose.yml, Caddyfile, procédures) | Partie 3 — DevOps |
| « Partie 4 » (plan de tests, grille de recette) | Partie 4 — Plan de tests |
| « Partie 5 » / RD-1 à RD-5 (rétro-documentation) | Partie 5 — Rétro-documentation |
| « Partie 6 » (journal de bord) | Journal de bord |
| « Captures d'écran de l'interface » | Section « Captures d'écran » |
| « Code HTML avec attributs ARIA / structure sémantique » | `web/templates/view/index.html` (voir dépôt `gesthub-v2` joint) |
## Points de cohérence à corriger dans le dossier RNCP narratif
**1. Numérotation de la rétro-documentation (RD-3).** Le dossier narratif
utilise « RD-3 » pour deux éléments différents : le flux OIDC annoté
(Bloc 3 — C3) et le dictionnaire de données (Bloc 2 — C4). Ce document
technique tranche avec la numérotation suivante, à reprendre dans le
dossier narratif : **RD-1** = couches, **RD-2** = flux de données,
**RD-3** = flux OIDC annoté, **RD-4** = dictionnaire de données,
**RD-5** = table de correspondance JWT/BDD. → Corriger la ligne de preuve
du Bloc 2 — C4 : remplacer « Dictionnaire des données RD-3 » par
« Dictionnaire des données RD-4 ».
**2. Nombre total de cas de test.** Le dossier narratif indique tantôt
« 28 cas de test » (Bloc 1 — C5, Bloc 3 — C7), tantôt « 44 cas de test »
(Bloc 3 — C5, approfondissement) pour les mêmes catégories UT/IT/TF/TS/TD.
Ce document technique fixe la répartition réelle et harmonisée à
**44 cas documentés** : 17 UT + 11 IT + 4 TF + 6 TS + 6 TD, dont **34
automatisés avec pytest et exécutés avec succès** (17+11+6) et 10 procédures
manuelles documentées (4 TF + 6 TD). → Remplacer « 28 cas de test » par
« 44 cas de test » partout dans le dossier narratif, et « UT-01 à UT-12 »
par « UT-01 à UT-17 » (la suite réelle en compte 17, ce qui dépasse
l'objectif initial).
**3. Éléments propres au stage FabLab BEN.** Les placeholders
`[DECRIRE LE PROCESSUS...]`, `[CAPTURE WORKFLOW ENTREPRISE]`,
`[METHODE : story points...]`, `[RITUEL AGILE...]`,
`[OUTIL DE SUIVI...]`, `[COMPTE-RENDU REVUE SPRINT stage]` (Bloc 2 — C1,
C7, C8, C10, C12 ; Bloc 3 — C8) concernent le stage chez FabLab BEN et non
GestHub : ils sont **hors du périmètre de ce document technique**, qui ne
couvre que le projet GestHub. Nino doit les compléter directement dans la
Partie 2 du dossier narratif avec les éléments réels de son stage
(captures, comptes-rendus, méthode d'estimation utilisée sur place).
**4. Écart assumé entre le code au 16/12/2025 et ce document (août 2026).**
Le code source de GestHub tel qu'il existait au dernier commit Git
(16 décembre 2025) ne comportait pas encore l'architecture en couches, les
modules Annonces/Fichiers/Planning, la table `audit_log`, ni la suite de
tests. Ces éléments ont été développés et testés entre janvier et août 2026
en préparation de ce dossier de certification (voir Journal de bord,
« Travaux réalisés pour ce dossier RNCP »). Le dépôt `gesthub-v2` livré en
annexe contient cette version complète et testée ; il est recommandé de le
pousser sur le dépôt Git réel (`git.ninolbt.com/Nono/gesthub`) avant la
soutenance, pour que le lien cité en synthèse de la Partie 1 pointe vers le
code réellement décrit dans ce dossier.

Binary file not shown.

Binary file not shown.

486
docs/build_docx.js Normal file
View File

@@ -0,0 +1,486 @@
// Génère docs/Dossier_technique_GestHub_v2.docx à partir des fichiers
// Markdown dans ce dossier, dans un ordre défini, avec titre, sommaire,
// tableaux, code, et images.
const fs = require("fs");
const path = require("path");
const {
Document, Packer, Paragraph, TextRun, HeadingLevel, Table, TableRow,
TableCell, WidthType, ShadingType, BorderStyle, ImageRun, AlignmentType,
PageBreak, TableOfContents, ExternalHyperlink, LevelFormat, Header, Footer,
PageNumber, NumberFormat,
} = require("docx");
const DOCS_DIR = __dirname;
// -------------------- Markdown -> docx éléments -----------------------------
function parseInlineTokens(text) {
// Découpe en tokens plats {text, bold, code}, gérant **gras** et `code`
const tokens = [];
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
let last = 0;
let m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) tokens.push({ text: text.slice(last, m.index) });
const token = m[0];
if (token.startsWith("**")) {
tokens.push({ text: token.slice(2, -2), bold: true });
} else {
tokens.push({ text: token.slice(1, -1), code: true });
}
last = re.lastIndex;
}
if (last < text.length) tokens.push({ text: text.slice(last) });
if (tokens.length === 0) tokens.push({ text: "" });
return tokens;
}
function tokensToRuns(tokens, overrides = {}) {
return tokens.map((t) => {
if (t.code) {
return new TextRun({
text: t.text, font: "Consolas", size: overrides.size || 19,
color: overrides.color || "1f2937", bold: overrides.bold || t.bold,
});
}
return new TextRun({
text: t.text, size: overrides.size || 21,
bold: overrides.bold !== undefined ? overrides.bold : t.bold,
color: overrides.color, italics: overrides.italics,
});
});
}
function parseInlineBold(text, overrides = {}) {
return tokensToRuns(parseInlineTokens(text), overrides);
}
function makeTableCell(text, { header = false, width } = {}) {
return new TableCell({
width: { size: width, type: WidthType.DXA },
shading: header ? { type: ShadingType.CLEAR, color: "auto", fill: "1f2937" } : undefined,
margins: { top: 60, bottom: 60, left: 100, right: 100 },
children: [
new Paragraph({
children: header
? tokensToRuns(parseInlineTokens(text), { bold: true, color: "FFFFFF", size: 19 })
: parseInlineBold(text),
}),
],
});
}
function parseTable(lines) {
// lines: array of markdown table lines (with leading |)
const rows = lines.filter((l) => !/^\|[\s-:|]+\|$/.test(l.trim()));
const cellsPerRow = rows.map((l) =>
l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim())
);
const nCols = cellsPerRow[0].length;
const tableWidthDxa = 9350;
const colWidth = Math.floor(tableWidthDxa / nCols);
const colWidths = new Array(nCols).fill(colWidth);
const trows = cellsPerRow.map((cells, ri) =>
new TableRow({
tableHeader: ri === 0,
children: cells.map((c, ci) => makeTableCell(c, { header: ri === 0, width: colWidths[ci] })),
})
);
return new Table({
width: { size: tableWidthDxa, type: WidthType.DXA },
columnWidths: colWidths,
rows: trows,
borders: {
top: { style: BorderStyle.SINGLE, size: 2, color: "9CA3AF" },
bottom: { style: BorderStyle.SINGLE, size: 2, color: "9CA3AF" },
left: { style: BorderStyle.SINGLE, size: 2, color: "9CA3AF" },
right: { style: BorderStyle.SINGLE, size: 2, color: "9CA3AF" },
insideHorizontal: { style: BorderStyle.SINGLE, size: 2, color: "D1D5DB" },
insideVertical: { style: BorderStyle.SINGLE, size: 2, color: "D1D5DB" },
},
});
}
function imageParagraph(relPath, caption) {
const fullPath = path.join(DOCS_DIR, relPath);
const buffer = fs.readFileSync(fullPath);
// dimension réelle -> on limite la largeur à 560pt (~14.8cm) en conservant le ratio
const { imageSize } = require("image-size");
const dim = imageSize(new Uint8Array(buffer));
const maxW = 560;
const ratio = Math.min(1, maxW / dim.width);
const w = Math.round(dim.width * ratio);
const h = Math.round(dim.height * ratio);
const els = [
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 200, after: 80 },
children: [
new ImageRun({ data: buffer, transformation: { width: w, height: h }, type: path.extname(fullPath).slice(1) }),
],
}),
];
if (caption) {
els.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { after: 200 },
children: [new TextRun({ text: caption, italics: true, size: 18, color: "6B7280" })],
})
);
}
return els;
}
function markdownToElements(md) {
const lines = md.split("\n");
const elements = [];
let i = 0;
let inCode = false;
let codeLines = [];
let paraBuffer = [];
function flushParagraph() {
if (paraBuffer.length) {
elements.push(new Paragraph({ children: parseInlineBold(paraBuffer.join(" ")), spacing: { after: 120 } }));
paraBuffer = [];
}
}
while (i < lines.length) {
const line = lines[i];
if (line.trim().startsWith("```")) {
flushParagraph();
if (!inCode) {
inCode = true;
codeLines = [];
} else {
inCode = false;
elements.push(
new Table({
width: { size: 9350, type: WidthType.DXA },
columnWidths: [9350],
rows: [
new TableRow({
children: [
new TableCell({
width: { size: 9350, type: WidthType.DXA },
shading: { type: ShadingType.CLEAR, color: "auto", fill: "F3F4F6" },
margins: { top: 120, bottom: 120, left: 160, right: 160 },
children: codeLines.length
? codeLines.map(
(cl) =>
new Paragraph({
children: [new TextRun({ text: cl.length ? cl : " ", font: "Consolas", size: 18 })],
})
)
: [new Paragraph({ children: [new TextRun({ text: " ", size: 18 })] })],
}),
],
}),
],
borders: {
top: { style: BorderStyle.SINGLE, size: 2, color: "D1D5DB" },
bottom: { style: BorderStyle.SINGLE, size: 2, color: "D1D5DB" },
left: { style: BorderStyle.SINGLE, size: 2, color: "D1D5DB" },
right: { style: BorderStyle.SINGLE, size: 2, color: "D1D5DB" },
insideHorizontal: { style: BorderStyle.NONE },
insideVertical: { style: BorderStyle.NONE },
},
})
);
elements.push(new Paragraph({ text: "", spacing: { after: 120 } }));
}
i++;
continue;
}
if (inCode) {
codeLines.push(line);
i++;
continue;
}
if (line.trim() === "") {
flushParagraph();
i++;
continue;
}
// Image directive: ![IMG:path|caption]
const imgMatch = line.match(/^!\[IMG:([^|\]]+)(\|([^\]]*))?\]/);
if (imgMatch) {
flushParagraph();
elements.push(...imageParagraph(imgMatch[1].trim(), imgMatch[3] ? imgMatch[3].trim() : ""));
i++;
continue;
}
// Table block
if (line.trim().startsWith("|")) {
flushParagraph();
const block = [];
while (i < lines.length && lines[i].trim().startsWith("|")) {
block.push(lines[i]);
i++;
}
elements.push(markdownTableWrap(block));
elements.push(new Paragraph({ text: "", spacing: { after: 160 } }));
continue;
}
// Headings
let m;
if ((m = line.match(/^####\s+(.*)/))) {
flushParagraph();
elements.push(new Paragraph({ text: m[1], heading: HeadingLevel.HEADING_4, spacing: { before: 200, after: 100 } }));
i++;
continue;
}
if ((m = line.match(/^###\s+(.*)/))) {
flushParagraph();
elements.push(new Paragraph({ text: m[1], heading: HeadingLevel.HEADING_3, spacing: { before: 240, after: 120 } }));
i++;
continue;
}
if ((m = line.match(/^##\s+(.*)/))) {
flushParagraph();
elements.push(new Paragraph({ text: m[1], heading: HeadingLevel.HEADING_2, spacing: { before: 300, after: 140 } }));
i++;
continue;
}
if ((m = line.match(/^#\s+(.*)/))) {
flushParagraph();
elements.push(new Paragraph({ text: m[1], heading: HeadingLevel.HEADING_1, spacing: { before: 360, after: 160 }, pageBreakBefore: true }));
i++;
continue;
}
// Bullet list
if (line.trim().startsWith("- ") || line.trim().startsWith("* ")) {
flushParagraph();
const content = line.trim().slice(2);
elements.push(
new Paragraph({
bullet: { level: 0 },
children: parseInlineBold(content),
spacing: { after: 60 },
})
);
i++;
continue;
}
// Numbered list
if (/^\d+\.\s/.test(line.trim())) {
flushParagraph();
const content = line.trim().replace(/^\d+\.\s/, "");
elements.push(
new Paragraph({
numbering: { reference: "numbered-list", level: 0 },
children: parseInlineBold(content),
spacing: { after: 60 },
})
);
i++;
continue;
}
// Horizontal rule
if (line.trim() === "---") {
flushParagraph();
elements.push(
new Paragraph({
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: "D1D5DB" } },
spacing: { before: 200, after: 200 },
})
);
i++;
continue;
}
// Regular paragraph line: accumulate into buffer (joined with the next
// lines up to the next blank line / structural line) so that inline
// markup like **gras** spanning a soft line-wrap in the source .md is
// parsed as one continuous string instead of being cut mid-token.
paraBuffer.push(line.trim());
i++;
}
flushParagraph();
return elements;
}
function markdownTableWrap(block) {
return parseTable(block);
}
// -------------------- Assemblage du document --------------------------------
const ORDER = [
"03_cahier_des_charges.md",
"04_annexe_A1_qualite_code.md",
"05_annexe_A2_estimation_charge.md",
"06_partie3_devops.md",
"07_partie4_plan_de_tests.md",
"08_partie5_retrodocumentation.md",
"09_note_rgpd.md",
"01_journal_de_bord.md",
"02_planning_previsionnel.md",
"10_captures_ecran.md",
"11_table_correspondance.md",
];
function titlePage() {
return [
new Paragraph({ text: "", spacing: { before: 1200 } }),
new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "GESTHUB", bold: true, size: 64, color: "1f2937" })],
}),
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 200 },
children: [new TextRun({ text: "Dossier technique v2", bold: true, size: 36, color: "2563eb" })],
}),
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 100 },
children: [new TextRun({ text: "Annexe de preuves du dossier de validation RNCP 36463", size: 24, color: "6B7280" })],
}),
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 60 },
children: [new TextRun({ text: "Concepteur Développeur d'Applications Numériques (CDAN)", size: 22, italics: true, color: "6B7280" })],
}),
new Paragraph({ text: "", spacing: { before: 800 } }),
new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "LABAT Nino", bold: true, size: 26 })],
}),
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 40 },
children: [new TextRun({ text: "Bordeaux Ynov Campus — B3 Robotique & Systèmes Embarqués", size: 20 })],
}),
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 40 },
children: [new TextRun({ text: "Août 2026", size: 20 })],
}),
new Paragraph({ text: "", spacing: { before: 1000 } }),
new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "Dépôt : https://git.ninolbt.com/Nono/gesthub", size: 18, color: "6B7280" })],
}),
new Paragraph({ children: [new PageBreak()] }),
];
}
function introSection() {
const text = [
"Ce document est le « Dossier technique GestHub v2 » référencé tout au long de la Partie 2 (Portefeuille de preuves) du dossier de validation RNCP. Il constitue l'annexe de preuves : cahier des charges, architecture, modèle de données, plan de tests, rétro-documentation, journal de bord et éléments de qualité de code, avec, chaque fois que possible, des résultats réels et vérifiables (sorties de tests, de flake8, de radon, captures d'écran) plutôt que des affirmations non étayées.",
"Deux niveaux de preuve sont distingués dans ce document, à l'identique du dossier RNCP narratif : les éléments marqués « pratiqué » correspondent à du code exécuté et vérifié au moment de la rédaction (août 2026) ; les éléments marqués « [⇒] Projection méthodologique » correspondent à une démarche comprise et documentée mais non mise en œuvre faute de contexte (pas de client réel, pas d'environnement de production avec nom de domaine, pas d'équipe).",
];
const els = [
new Paragraph({ text: "Avant-propos", heading: HeadingLevel.HEADING_1, spacing: { after: 160 } }),
];
text.forEach((t) => els.push(new Paragraph({ children: parseInlineBold(t), spacing: { after: 160 } })));
els.push(new Paragraph({ children: [new PageBreak()] }));
return els;
}
function sommaire() {
return [
new Paragraph({ text: "Sommaire", heading: HeadingLevel.HEADING_1, spacing: { after: 160 } }),
new Paragraph({
spacing: { after: 200 },
children: [
new TextRun({
text: "(Sommaire interactif — dans Word : clic droit sur la table ci-dessous puis « Mettre à jour les champs », ou Ctrl+A puis F9.)",
italics: true, size: 18, color: "6B7280",
}),
],
}),
new TableOfContents("Sommaire", { hyperlink: true, headingStyleRange: "1-3" }),
new Paragraph({ children: [new PageBreak()] }),
];
}
let body = [];
body.push(...titlePage());
body.push(...introSection());
body.push(...sommaire());
for (const file of ORDER) {
const md = fs.readFileSync(path.join(DOCS_DIR, file), "utf-8");
body.push(...markdownToElements(md));
}
const doc = new Document({
numbering: {
config: [
{
reference: "numbered-list",
levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.START }],
},
],
},
styles: {
default: {
document: { run: { font: "Calibri", size: 21 } },
},
paragraphStyles: [
{ id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { bold: true, size: 30, color: "1f2937" }, paragraph: { spacing: { before: 360, after: 160 } } },
{ id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { bold: true, size: 25, color: "2563eb" }, paragraph: { spacing: { before: 280, after: 140 } } },
{ id: "Heading3", name: "Heading 3", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { bold: true, size: 22, color: "16a34a" }, paragraph: { spacing: { before: 220, after: 100 } } },
{ id: "Heading4", name: "Heading 4", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { bold: true, italics: true, size: 20, color: "374151" }, paragraph: { spacing: { before: 180, after: 80 } } },
],
},
sections: [
{
properties: {
page: {
size: { width: 11906, height: 16838 }, // A4
margin: { top: 1134, bottom: 1134, left: 1134, right: 1134 },
},
},
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
children: [new TextRun({ text: "GestHub — Dossier technique v2", size: 16, color: "9CA3AF" })],
}),
],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ children: [PageNumber.CURRENT], size: 16, color: "9CA3AF" }),
new TextRun({ text: " / ", size: 16, color: "9CA3AF" }),
new TextRun({ children: [PageNumber.TOTAL_PAGES], size: 16, color: "9CA3AF" }),
],
}),
],
}),
},
children: body,
},
],
});
Packer.toBuffer(doc).then((buffer) => {
fs.writeFileSync(path.join(DOCS_DIR, "Dossier_technique_GestHub_v2.docx"), buffer);
console.log("OK : Dossier_technique_GestHub_v2.docx généré");
});

View File

@@ -0,0 +1,30 @@
.................................. [100%]
---------- coverage: platform linux, python 3.11.15-final-0 ----------
Name Stmts Miss Cover Missing
-------------------------------------------------------------
app.py 48 13 73% 58-60, 64-66, 70-73, 77-79, 85
db.py 41 4 90% 61-64
models/__init__.py 0 0 100%
models/annonce_model.py 20 0 100%
models/audit_model.py 6 2 67% 13-17
models/block_model.py 23 17 26% 13-15, 25-30, 34-39, 49-52, 56-59
models/evenement_model.py 14 10 29% 5-9, 13-17, 21-25, 29-32
models/fichier_model.py 14 6 57% 13-17, 29-32
routes/__init__.py 0 0 100%
routes/annonces.py 42 6 86% 47-50, 60-61
routes/auth.py 38 20 47% 47-51, 55-65, 69-74, 84-86
routes/dashboard.py 35 12 66% 12, 19, 29, 35-36, 42-46, 52-55
routes/evenements.py 29 8 72% 18, 33, 39-44
routes/fichiers.py 41 18 56% 18, 31, 37-46, 52-59
services/__init__.py 0 0 100%
services/annonce_service.py 33 1 97% 23
services/audit_service.py 5 1 80% 11
services/auth_service.py 36 1 97% 82
services/block_service.py 14 9 36% 7, 11-15, 20-21, 25
services/evenement_service.py 29 13 55% 16-17, 21, 26, 31-33, 37-42
services/fichier_service.py 84 15 82% 70, 72, 129, 141, 150-160
-------------------------------------------------------------
TOTAL 552 156 72%
34 passed in 0.92s

View File

@@ -0,0 +1 @@
flake8: 0 lignes (0 = aucune violation)

View File

@@ -0,0 +1,43 @@
============================= test session starts ==============================
platform linux -- Python 3.11.15, pytest-8.3.3, pluggy-1.6.0 -- /home/claude/work/venv/bin/python
cachedir: .pytest_cache
rootdir: /home/claude/work/gesthub-v2/web
plugins: cov-5.0.0
collecting ... collected 34 items
tests/test_integration_routes.py::test_it01_annonces_sans_auth_401 PASSED [ 2%]
tests/test_integration_routes.py::test_it02_annonces_avec_auth_200 PASSED [ 5%]
tests/test_integration_routes.py::test_it03_creer_annonce_non_admin_403 PASSED [ 8%]
tests/test_integration_routes.py::test_it04_creer_annonce_admin_201_puis_visible PASSED [ 11%]
tests/test_integration_routes.py::test_it05_modifier_annonce_admin_200 PASSED [ 14%]
tests/test_integration_routes.py::test_it06_supprimer_annonce_admin_200 PASSED [ 17%]
tests/test_integration_routes.py::test_it07_is_admin_reflete_groupes PASSED [ 20%]
tests/test_integration_routes.py::test_it08_upload_sans_fichier_400 PASSED [ 23%]
tests/test_integration_routes.py::test_it09_evenement_dates_invalides_400 PASSED [ 26%]
tests/test_integration_routes.py::test_it10_accueil_sans_session_redirige_login PASSED [ 29%]
tests/test_integration_routes.py::test_it04b_creer_annonce_titre_manquant_400 PASSED [ 32%]
tests/test_security.py::test_ts01_injection_sql_dans_titre_est_neutralisee PASSED [ 35%]
tests/test_security.py::test_ts02_acces_sans_token_refuse PASSED [ 38%]
tests/test_security.py::test_ts03_cookie_session_falsifie_refuse PASSED [ 41%]
tests/test_security.py::test_ts04_contenu_avec_script_est_echappe_au_rendu PASSED [ 44%]
tests/test_security.py::test_ts05_telechargement_fichier_dautrui_refuse PASSED [ 47%]
tests/test_security.py::test_ts06_seul_caddy_expose_des_ports_publics PASSED [ 50%]
tests/test_unit_annonce_service.py::test_ut01_create_annonce_valide PASSED [ 52%]
tests/test_unit_annonce_service.py::test_ut02_create_annonce_titre_vide PASSED [ 55%]
tests/test_unit_annonce_service.py::test_ut03_create_annonce_contenu_vide PASSED [ 58%]
tests/test_unit_annonce_service.py::test_ut04_update_annonce_existante PASSED [ 61%]
tests/test_unit_annonce_service.py::test_ut05_update_annonce_inexistante PASSED [ 64%]
tests/test_unit_annonce_service.py::test_ut05b_delete_annonce_inexistante PASSED [ 67%]
tests/test_unit_auth_service.py::test_ut09_is_admin_avec_groupe_admin PASSED [ 70%]
tests/test_unit_auth_service.py::test_ut10_is_admin_sans_groupe_admin PASSED [ 73%]
tests/test_unit_auth_service.py::test_ut11_is_admin_utilisateur_none PASSED [ 76%]
tests/test_unit_auth_service.py::test_ut12_is_admin_sans_champ_groups PASSED [ 79%]
tests/test_unit_fichier_service.py::test_ut06_fichier_trop_grand PASSED [ 82%]
tests/test_unit_fichier_service.py::test_ut06b_fichier_vide PASSED [ 85%]
tests/test_unit_fichier_service.py::test_ut07_extension_interdite PASSED [ 88%]
tests/test_unit_fichier_service.py::test_ut07b_extension_autorisee PASSED [ 91%]
tests/test_unit_fichier_service.py::test_ut08_nom_genere_est_unique PASSED [ 94%]
tests/test_unit_fichier_service.py::test_ut08b_presence_fichier_absent PASSED [ 97%]
tests/test_unit_fichier_service.py::test_upload_complet_end_to_end PASSED [100%]
============================== 34 passed in 0.54s ==============================

View File

@@ -0,0 +1,113 @@
models/annonce_model.py
F 19:0 insert - A (1)
F 27:0 fetch_all - A (1)
F 35:0 get - A (1)
F 43:0 update - A (1)
F 53:0 delete - A (1)
C 10:0 AnnonceNotFoundError - A (1)
models/audit_model.py
F 4:0 insert - A (1)
F 12:0 fetch_recent - A (1)
models/evenement_model.py
F 4:0 insert - A (1)
F 12:0 fetch_all - A (1)
F 20:0 get - A (1)
F 28:0 delete - A (1)
models/block_model.py
F 12:0 _row_to_dict - A (3)
F 24:0 fetch_all - A (2)
F 33:0 insert - A (1)
F 48:0 update_position - A (1)
F 55:0 delete - A (1)
models/fichier_model.py
F 4:0 insert - A (1)
F 12:0 fetch_all - A (1)
F 20:0 get - A (1)
F 28:0 delete - A (1)
services/annonce_service.py
F 19:0 _validate - B (6)
F 39:0 update_annonce - A (2)
F 49:0 delete_annonce - A (2)
F 28:0 list_annonces - A (1)
F 32:0 create_annonce - A (1)
C 12:0 ValidationError - A (1)
services/auth_service.py
F 30:0 is_admin - A (3)
F 78:0 client_ip - A (2)
F 18:0 current_user - A (1)
F 23:0 is_authenticated - A (1)
F 45:0 require_login - A (1)
F 57:0 require_admin - A (1)
services/audit_service.py
F 6:0 log - A (1)
F 10:0 recent - A (1)
services/fichier_service.py
F 63:0 step3_check_mime - A (5)
F 149:0 delete_fichier - A (5)
F 48:0 step2_check_size - A (4)
F 77:0 step4_check_extension - A (4)
F 132:0 prepare_download - A (4)
F 41:0 step1_check_presence - A (3)
F 91:0 step6_save_to_disk - A (2)
F 86:0 step5_generate_safe_name - A (1)
F 105:0 step7_persist_metadata - A (1)
F 109:0 step8_log_audit - A (1)
F 113:0 handle_upload - A (1)
F 128:0 list_fichiers - A (1)
C 36:0 FileValidationError - A (1)
services/block_service.py
F 10:0 add_block - A (3)
F 18:0 save_layout - A (2)
F 6:0 list_blocks - A (1)
F 24:0 delete_block - A (1)
services/evenement_service.py
F 24:0 create_evenement - A (4)
F 13:0 _parse - A (2)
F 36:0 delete_evenement - A (2)
F 20:0 list_evenements - A (1)
C 9:0 ValidationError - A (1)
routes/fichiers.py
F 36:0 download - A (3)
F 51:0 delete - A (3)
F 23:0 upload - A (2)
F 11:0 _db - A (1)
F 17:0 list_fichiers - A (1)
routes/auth.py
F 24:0 register_oauth - A (1)
F 35:0 init_auth_routes - A (1)
F 83:0 _get_db - A (1)
routes/annonces.py
F 39:0 update_annonce - A (4)
F 24:0 create_annonce - A (3)
F 56:0 delete_annonce - A (2)
F 12:0 _db - A (1)
F 18:0 list_annonces - A (1)
routes/dashboard.py
F 16:0 index - A (2)
F 34:0 save_layout - A (2)
F 41:0 add_block - A (2)
F 51:0 delete_block - A (2)
F 11:0 _db - A (1)
F 23:0 api_is_admin - A (1)
F 28:0 get_layout - A (1)
routes/evenements.py
F 23:0 create_evenement - A (3)
F 38:0 delete_evenement - A (2)
F 11:0 _db - A (1)
F 17:0 list_evenements - A (1)
app.py
F 20:0 create_app - A (1)
F 51:0 register_error_handlers - A (1)
F 76:0 _wants_json - A (1)
db.py
C 25:0 Database - A (3)
M 48:4 Database.cursor - A (3)
M 68:4 Database.fetch_all - A (2)
M 73:4 Database.fetch_one - A (2)
M 78:4 Database.execute - A (2)
F 85:0 init_db - A (1)
M 28:4 Database.__init__ - A (1)
M 35:4 Database._connect - A (1)
92 blocks (classes, functions, methods) analyzed.
Average complexity: A (1.7173913043478262)

View File

@@ -0,0 +1,264 @@
models/annonce_model.py
LOC: 57
LLOC: 22
SLOC: 32
Comments: 0
Single comments: 0
Multi: 11
Blank: 14
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 19%
models/audit_model.py
LOC: 17
LLOC: 7
SLOC: 12
Comments: 0
Single comments: 1
Multi: 0
Blank: 4
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 0%
models/__init__.py
LOC: 13
LLOC: 1
SLOC: 0
Comments: 0
Single comments: 0
Multi: 11
Blank: 2
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 85%
models/evenement_model.py
LOC: 32
LLOC: 15
SLOC: 23
Comments: 0
Single comments: 1
Multi: 0
Blank: 8
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 0%
models/block_model.py
LOC: 59
LLOC: 26
SLOC: 41
Comments: 0
Single comments: 0
Multi: 6
Blank: 12
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 10%
models/fichier_model.py
LOC: 32
LLOC: 15
SLOC: 23
Comments: 0
Single comments: 1
Multi: 0
Blank: 8
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 0%
services/annonce_service.py
LOC: 55
LLOC: 35
SLOC: 33
Comments: 0
Single comments: 1
Multi: 5
Blank: 16
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 9%
services/auth_service.py
LOC: 83
LLOC: 45
SLOC: 36
Comments: 0
Single comments: 3
Multi: 20
Blank: 24
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 24%
services/audit_service.py
LOC: 11
LLOC: 6
SLOC: 5
Comments: 0
Single comments: 1
Multi: 0
Blank: 5
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 0%
services/__init__.py
LOC: 1
LLOC: 1
SLOC: 0
Comments: 0
Single comments: 1
Multi: 0
Blank: 0
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 0%
services/fichier_service.py
LOC: 160
LLOC: 91
SLOC: 91
Comments: 15
Single comments: 14
Multi: 21
Blank: 34
- Comment Stats
(C % L): 9%
(C % S): 16%
(C + M % L): 22%
services/block_service.py
LOC: 25
LLOC: 16
SLOC: 14
Comments: 0
Single comments: 2
Multi: 0
Blank: 9
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 0%
services/evenement_service.py
LOC: 42
LLOC: 30
SLOC: 29
Comments: 0
Single comments: 1
Multi: 0
Blank: 12
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 0%
routes/fichiers.py
LOC: 59
LLOC: 49
SLOC: 45
Comments: 0
Single comments: 1
Multi: 0
Blank: 13
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 0%
routes/auth.py
LOC: 86
LLOC: 41
SLOC: 48
Comments: 10
Single comments: 11
Multi: 12
Blank: 15
- Comment Stats
(C % L): 12%
(C % S): 21%
(C + M % L): 26%
routes/annonces.py
LOC: 62
LLOC: 50
SLOC: 48
Comments: 0
Single comments: 1
Multi: 0
Blank: 13
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 0%
routes/__init__.py
LOC: 6
LLOC: 1
SLOC: 0
Comments: 0
Single comments: 0
Multi: 5
Blank: 1
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 83%
routes/dashboard.py
LOC: 55
LLOC: 40
SLOC: 37
Comments: 0
Single comments: 1
Multi: 0
Blank: 17
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 0%
routes/evenements.py
LOC: 44
LLOC: 34
SLOC: 32
Comments: 0
Single comments: 1
Multi: 0
Blank: 11
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 0%
app.py
LOC: 85
LLOC: 53
SLOC: 48
Comments: 3
Single comments: 3
Multi: 11
Blank: 23
- Comment Stats
(C % L): 4%
(C % S): 6%
(C + M % L): 16%
db.py
LOC: 93
LLOC: 47
SLOC: 57
Comments: 0
Single comments: 3
Multi: 17
Blank: 16
- Comment Stats
(C % L): 0%
(C % S): 0%
(C + M % L): 18%
** Total **
LOC: 1077
LLOC: 625
SLOC: 654
Comments: 28
Single comments: 47
Multi: 119
Blank: 257
- Comment Stats
(C % L): 3%
(C % S): 4%
(C + M % L): 14%

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

102
docs/make_diagrams.py Normal file
View File

@@ -0,0 +1,102 @@
"""Génère 2 schémas d'architecture (infrastructure + couches logiques)
pour le dossier technique, à partir de la structure réelle du projet."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
# Palette neutre, sobre, accessible (contraste élevé)
NAVY = "#1f2937"
BLUE = "#2563eb"
GREEN = "#16a34a"
GRAY = "#6b7280"
BG = "#f3f4f6"
WHITE = "#ffffff"
def box(ax, xy, w, h, text, face, edge=NAVY, fontsize=11, fontcolor=WHITE):
b = FancyBboxPatch(
xy, w, h, boxstyle="round,pad=0.02,rounding_size=0.08",
linewidth=1.6, edgecolor=edge, facecolor=face, zorder=2,
)
ax.add_patch(b)
ax.text(xy[0] + w / 2, xy[1] + h / 2, text, ha="center", va="center",
fontsize=fontsize, color=fontcolor, weight="bold", zorder=3, wrap=True)
def arrow(ax, start, end, color=GRAY):
a = FancyArrowPatch(start, end, arrowstyle="-|>", mutation_scale=16,
linewidth=1.6, color=color, zorder=1)
ax.add_patch(a)
# --- Diagramme 1 : infrastructure Docker (3 tiers) --------------------------
fig, ax = plt.subplots(figsize=(10, 6))
ax.set_xlim(0, 10)
ax.set_ylim(0, 6.6)
ax.axis("off")
ax.set_facecolor(BG)
fig.patch.set_facecolor(WHITE)
ax.text(5, 6.25, "GestHub — Architecture d'infrastructure (Docker Compose)",
ha="center", fontsize=13, weight="bold", color=NAVY)
box(ax, (3.7, 5.0), 2.6, 0.7, "Internet (HTTPS 443)", GRAY, fontsize=10)
box(ax, (3.7, 3.7), 2.6, 0.8, "Caddy\n(reverse proxy + TLS auto)", BLUE)
arrow(ax, (5, 5.0), (5, 4.5))
box(ax, (0.4, 2.2), 2.4, 0.9, "Flask\n(app.py, gunicorn 4 workers)", GREEN)
box(ax, (3.8, 2.2), 2.4, 0.9, "Keycloak\n(SSO / OIDC)", GREEN)
box(ax, (7.2, 2.2), 2.4, 0.9, "Mattermost\n(chat + Kanban)", GREEN)
arrow(ax, (4.4, 3.7), (1.7, 3.1))
arrow(ax, (5.4, 3.7), (5.0, 3.1))
arrow(ax, (6.0, 3.7), (8.3, 3.1))
box(ax, (0.4, 0.7), 2.4, 0.9, "MariaDB\n(gesthub : annonces, fichiers,\nevenements, blocks, audit_log)", NAVY, fontsize=9)
box(ax, (3.8, 0.7), 2.4, 0.9, "PostgreSQL\n(keycloak-db)", NAVY, fontsize=10)
box(ax, (7.2, 0.7), 2.4, 0.9, "PostgreSQL\n(mattermost-db)", NAVY, fontsize=10)
arrow(ax, (1.6, 2.2), (1.6, 1.6))
arrow(ax, (5.0, 2.2), (5.0, 1.6))
arrow(ax, (8.4, 2.2), (8.4, 1.6))
ax.text(5, 0.15, "Réseau Docker interne \"gesthub-net\" — seul Caddy expose un port public (TS-06)",
ha="center", fontsize=9, color=GRAY, style="italic")
plt.tight_layout()
plt.savefig("evidence/schema_infrastructure.png", dpi=160, facecolor=WHITE)
plt.close()
# --- Diagramme 2 : architecture logique en 5 couches -------------------------
fig, ax = plt.subplots(figsize=(9, 6.5))
ax.set_xlim(0, 9)
ax.set_ylim(0, 7)
ax.axis("off")
fig.patch.set_facecolor(WHITE)
ax.text(4.5, 6.7, "GestHub — Architecture logique en 5 couches",
ha="center", fontsize=13, weight="bold", color=NAVY)
layers = [
("Présentation", "templates/, static/ (Jinja2, CSS, JS)", BLUE, 5.5),
("Contrôleur (routes/)", "auth.py, annonces.py, fichiers.py, evenements.py, dashboard.py", GREEN, 4.35),
("Service (services/)", "annonce_service, fichier_service, auth_service, audit_service, ...", GREEN, 3.2),
("Accès données (models/) — Repository Pattern", "annonce_model, fichier_model, block_model, audit_model", NAVY, 2.05),
("Infrastructure", "db.py (PyMySQL) · config.py · MariaDB · Docker", GRAY, 0.9),
]
for title, subtitle, color, y in layers:
box(ax, (0.4, y), 8.2, 0.95, f"{title}\n{subtitle}", color, fontsize=10)
for y1, y2 in [(5.5, 5.3), (4.35, 4.15), (3.2, 3.0), (2.05, 1.85)]:
arrow(ax, (4.5, y1), (4.5, y2))
ax.text(4.5, 0.1, "Chaque flèche = dépendance descendante uniquement (aucune couche ne dépend d'une couche supérieure)",
ha="center", fontsize=9, color=GRAY, style="italic")
plt.tight_layout()
plt.savefig("evidence/schema_couches_logiques.png", dpi=160, facecolor=WHITE)
plt.close()
print("Diagrammes generes.")

12
docs/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "gesthub-dossier-technique",
"private": true,
"description": "Scripts de génération du Dossier technique GestHub v2 (docx) et des schémas d'architecture.",
"scripts": {
"build": "node build_docx.js"
},
"dependencies": {
"docx": "^9.0.0",
"image-size": "^2.0.2"
}
}

30
scripts/backup.sh Normal file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# =============================================================================
# backup.sh — Sauvegarde automatisée de GestHub (Bloc 4 - C5).
#
# Réalise un dump MariaDB (mysqldump) et une archive tar des fichiers
# uploadés, horodatés, conservés localement. Prévu pour être planifié via
# cron (ex. `0 3 * * * /opt/gesthub/scripts/backup.sh`).
# =============================================================================
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/opt/gesthub/backups}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
DB_CONTAINER="${DB_CONTAINER:-gesthub-mariadb-1}"
DB_NAME="${DB_NAME:-gesthub}"
DB_USER="${DB_USER:-flaskuser}"
UPLOADS_VOLUME_PATH="${UPLOADS_VOLUME_PATH:-/var/lib/docker/volumes/gesthub_uploads_data/_data}"
mkdir -p "$BACKUP_DIR"
echo "[backup] Dump MariaDB -> ${BACKUP_DIR}/gesthub-${TIMESTAMP}.sql.gz"
docker exec "$DB_CONTAINER" mysqldump -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" \
| gzip > "${BACKUP_DIR}/gesthub-${TIMESTAMP}.sql.gz"
echo "[backup] Archive des fichiers uploadés -> ${BACKUP_DIR}/uploads-${TIMESTAMP}.tar.gz"
tar -czf "${BACKUP_DIR}/uploads-${TIMESTAMP}.tar.gz" -C "$UPLOADS_VOLUME_PATH" .
echo "[backup] Purge des sauvegardes de plus de 30 jours"
find "$BACKUP_DIR" -type f -mtime +30 -delete
echo "[backup] Terminé : ${TIMESTAMP}"

22
web/.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Copier ce fichier en .env et renseigner les vraies valeurs.
# .env est exclu de Git (voir .gitignore) — mitigation du risque
# "fuite du CLIENT_SECRET OIDC" (tableau OWASP du dossier technique).
SECRET_KEY=change-me-with-a-random-value
FLASK_ENV=production
FLASK_DEBUG=false
# Pseudonymisation RGPD (droit à l'effacement) — voir routes/rgpd.py.
# Si laissé vide, SECRET_KEY est utilisé comme pepper.
RGPD_ANON_PEPPER=change-me-with-another-random-value
DB_HOST=mariadb
DB_PORT=3306
DB_USER=flaskuser
DB_PASSWORD=change-me
DB_NAME=gesthub
OIDC_CLIENT_ID=flask-app
OIDC_CLIENT_SECRET=change-me
OIDC_ISSUER=https://keycloak.ninolbt.com/realms/gesthub
PUBLIC_BASE_URL=https://dashboard.ninolbt.com

View File

@@ -1,8 +1,21 @@
FROM python:3.11
# syntax=docker/dockerfile:1
FROM python:3.11-slim
WORKDIR /web
COPY . /web
# libmagic1 : bibliothèque système requise par python-magic (détection MIME
# par analyse binaire, étape 3 du module fichiers).
RUN apt-get update \
&& apt-get install -y --no-install-recommends libmagic1 \
&& rm -rf /var/lib/apt/lists/*
RUN pip install flask flask_sqlalchemy pymysql authlib requests
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "app.py"]
COPY . .
EXPOSE 5000
# Gunicorn multi-worker en production (Bloc 1 - C6 : workers = 2*CPU + 1).
# GUNICORN_CMD_ARGS permet de surcharger la config sans reconstruire l'image.
CMD ["gunicorn", "--workers=4", "--worker-class=gthread", "--threads=2", \
"--bind=0.0.0.0:5000", "app:app"]

View File

@@ -1,152 +1,87 @@
import os
import uuid
import json
# J'ai ajouté 'request' aux imports
from flask import Flask, redirect, url_for, jsonify, session, render_template, request
from flask_sqlalchemy import SQLAlchemy
"""
app.py — Point d'entrée de l'application GestHub.
Ce fichier ne fait qu'assembler l'application (factory pattern) : création
de l'app Flask, enregistrement des blueprints (couche routes/), du client
OAuth Keycloak, et des gestionnaires d'erreurs globaux. Aucune logique
métier ni aucun accès base de données n'a lieu ici (cf. architecture
5 couches — Bloc 1, C3).
"""
import logging
from authlib.integrations.flask_client import OAuth
from flask import Flask, jsonify, render_template
app = Flask(__name__)
ANNOUNCE_FILE = os.path.join(os.path.dirname(__file__), "annonces.json")
# Ta config DB actuelle
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://flaskuser:flaskpass@mariadb/flaskdb'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.secret_key = os.environ.get("SECRET_KEY", "dev-key")
from config import Config
from db import init_db
db = SQLAlchemy(app)
# --- MODELE DE DONNEES POUR LES BLOCS ---
class Block(db.Model):
id = db.Column(db.Integer, primary_key=True)
block_type = db.Column(db.String(50)) # ex: 'iframe', 'buttons', 'html'
column_name = db.Column(db.String(20)) # ex: 'left', 'center', 'right'
position = db.Column(db.Integer) # pour l'ordre (0, 1, 2...)
data = db.Column(db.Text) # Contenu JSON (url, titre, etc.)
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
app.secret_key = config_class.SECRET_KEY
def to_dict(self):
return {
"id": self.id,
"type": self.block_type,
"column": self.column_name,
"position": self.position,
"data": json.loads(self.data) if self.data else {}
}
logging.basicConfig(level=logging.INFO)
# Création des tables si elles n'existent pas
with app.app_context():
db.create_all()
# --- Base de données (couche infrastructure) ---
db = init_db(config_class)
app.config["DB"] = db
# ... (Ici, garde ta configuration OAUTH et tes routes Login/Logout/Auth inchangées) ...
# ... (Garde aussi tes fonctions load_announces, save_announces etc.) ...
# --- OAuth / OIDC Keycloak ---
oauth = OAuth(app)
oauth = OAuth(app)
keycloak = oauth.register(
name='keycloak',
client_id='flask-app',
client_secret='T5G5jzCBiphnBNh9uuj0f6YNc9HrP8r4',
server_metadata_url='https://keycloak.ninolbt.com/realms/gesthub/.well-known/openid-configuration',
client_kwargs={'scope': 'openid profile email'}
)
# --- Blueprints (couche routes/) ---
from routes.auth import init_auth_routes
from routes.dashboard import bp as dashboard_bp
from routes.annonces import bp as annonces_bp
from routes.fichiers import bp as fichiers_bp
from routes.evenements import bp as evenements_bp
from routes.rgpd import bp as rgpd_bp
@app.route('/')
def index():
user = session.get('user')
if user:
return render_template('view/index.html', user=user)
return redirect(url_for('login'))
app.register_blueprint(init_auth_routes(oauth))
app.register_blueprint(dashboard_bp)
app.register_blueprint(annonces_bp)
app.register_blueprint(fichiers_bp)
app.register_blueprint(evenements_bp)
app.register_blueprint(rgpd_bp)
@app.route('/login')
def login():
nonce = uuid.uuid4().hex
session['nonce'] = nonce
redirect_uri = url_for('auth', _external=True, _scheme='https')
return keycloak.authorize_redirect(redirect_uri, nonce=nonce)
register_error_handlers(app)
return app
@app.route('/auth')
def auth():
token = keycloak.authorize_access_token()
nonce = session.pop('nonce', None)
userinfo = keycloak.parse_id_token(token, nonce=nonce)
session['user'] = userinfo
session["id_token"] = token.get("id_token")
return redirect('/')
@app.route("/logout")
def logout():
id_token = session.get("id_token")
session.clear()
return redirect(
f"https://keycloak.ninolbt.com/realms/gesthub/protocol/openid-connect/logout"
f"?post_logout_redirect_uri=https://dashboard.ninolbt.com"
f"&id_token_hint={id_token}"
)
def register_error_handlers(app):
"""Gestionnaires d'erreurs globaux (Bloc 1 - C5) : réponses uniformisées,
aucune fuite d'information système sensible (stack trace, chemin serveur...).
"""
# --- API LAYOUT (Gestion des Blocs) ---
@app.errorhandler(403)
def forbidden(_error):
if _wants_json():
return jsonify({"error": "forbidden"}), 403
return render_template("errors/403.html"), 403
@app.route('/api/layout', methods=['GET'])
def get_layout():
# Récupère tous les blocs triés par position
blocks = Block.query.order_by(Block.position).all()
return jsonify([b.to_dict() for b in blocks])
@app.errorhandler(404)
def not_found(_error):
if _wants_json():
return jsonify({"error": "not_found"}), 404
return render_template("errors/404.html"), 404
@app.route('/api/layout/save', methods=['POST'])
def save_layout():
# Sauvegarde l'ordre et la colonne après un drag & drop
user = session.get("user")
if not user or "/admin" not in user.get("groups", []):
return jsonify({"error": "Unauthorized"}), 403
@app.errorhandler(500)
def server_error(error):
app.logger.exception("Erreur interne non gérée: %s", error)
if _wants_json():
return jsonify({"error": "internal_error"}), 500
return render_template("errors/500.html"), 500
layout_data = request.json # Liste de {id, column, position}
for item in layout_data:
block = Block.query.get(item['id'])
if block:
block.column_name = item['column']
block.position = item['position']
db.session.commit()
return jsonify({"status": "saved"})
@app.route('/api/block/add', methods=['POST'])
def add_block():
user = session.get("user")
if not user or "/admin" not in user.get("groups", []):
return jsonify({"error": "Unauthorized"}), 403
def _wants_json():
from flask import request
data = request.json
new_block = Block(
block_type=data.get('type'),
column_name=data.get('column', 'center'),
position=99, # Ajoute à la fin par défaut
data=json.dumps(data.get('data', {}))
)
db.session.add(new_block)
db.session.commit()
return jsonify(new_block.to_dict())
return request.path.startswith("/api/")
@app.route('/api/block/<int:block_id>', methods=['DELETE'])
def delete_block(block_id):
user = session.get("user")
if not user or "/admin" not in user.get("groups", []):
return jsonify({"error": "Unauthorized"}), 403
block = Block.query.get(block_id)
if block:
db.session.delete(block)
db.session.commit()
return jsonify({"status": "deleted"})
return jsonify({"error": "not found"}), 404
app = create_app()
# --- API ANNONCES (Tes routes existantes) ---
# ... (Colle ici tes routes /api/annonces existantes, elles sont très bien) ...
# ... (N'oublie pas la route /api/is_admin) ...
@app.route("/api/is_admin")
def is_admin():
user = session.get("user")
# Sécurité : si pas de user, renvoie false
if not user: return jsonify({"admin": False})
return jsonify({"admin": "/admin" in user.get("groups", [])})
if __name__ == '__main__':
app.run(host='0.0.0.0', debug=True)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=Config.DEBUG)

69
web/config.py Normal file
View File

@@ -0,0 +1,69 @@
"""
config.py — Configuration centralisée de l'application.
Toutes les valeurs sensibles (secrets, mots de passe, client_secret OIDC)
sont lues depuis les variables d'environnement (fichier .env en local,
variables d'environnement Docker en production). Aucun secret n'est en dur
dans le code source, conformément à l'analyse de risques DevSecOps
(Bloc 1 - C8 : "fuite du CLIENT_SECRET OIDC").
"""
import os
class Config:
# --- Application ---
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-key-change-me")
DEBUG = os.environ.get("FLASK_DEBUG", "false").lower() == "true"
ENV = os.environ.get("FLASK_ENV", "production")
# --- Base de données (MariaDB via PyMySQL) ---
DB_HOST = os.environ.get("DB_HOST", "mariadb")
DB_PORT = int(os.environ.get("DB_PORT", "3306"))
DB_USER = os.environ.get("DB_USER", "flaskuser")
DB_PASSWORD = os.environ.get("DB_PASSWORD", "flaskpass")
DB_NAME = os.environ.get("DB_NAME", "gesthub")
# --- Authentification Keycloak (OIDC) ---
OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID", "flask-app")
OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET", "")
OIDC_ISSUER = os.environ.get(
"OIDC_ISSUER", "https://keycloak.ninolbt.com/realms/gesthub"
)
OIDC_DISCOVERY_URL = f"{OIDC_ISSUER}/.well-known/openid-configuration"
OIDC_LOGOUT_URL = f"{OIDC_ISSUER}/protocol/openid-connect/logout"
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://dashboard.ninolbt.com")
# --- Rôles applicatifs (mappés sur les groupes Keycloak) ---
ADMIN_GROUP = "/admin"
# --- RGPD (droit à l'effacement, art. 17) ---
# Secret utilisé pour pseudonymiser irréversiblement un `sub` OIDC lors
# d'une anonymisation (HMAC-SHA256). Distinct de SECRET_KEY par défaut ;
# si absent, retombe sur SECRET_KEY (voir routes/rgpd.py).
RGPD_ANON_PEPPER = os.environ.get("RGPD_ANON_PEPPER", "")
# --- Module fichiers ---
UPLOAD_DIR = os.environ.get(
"UPLOAD_DIR", os.path.join(os.path.dirname(__file__), "static", "assets", "uploads")
)
MAX_UPLOAD_SIZE_BYTES = int(os.environ.get("MAX_UPLOAD_SIZE_BYTES", 20 * 1024 * 1024)) # 20 Mo
ALLOWED_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "gif", "txt", "docx", "xlsx", "zip"}
ALLOWED_MIME_TYPES = {
"application/pdf", "image/png", "image/jpeg", "image/gif", "text/plain",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/zip", "application/x-zip-compressed",
}
class TestConfig(Config):
"""Configuration utilisée par la suite de tests pytest (Bloc 1 - C5, Bloc 3 - C7).
Pointe vers une base MariaDB de test dédiée (voir tests/conftest.py) afin de ne
jamais exécuter les tests contre les données de développement ou de production.
"""
TESTING = True
DB_NAME = os.environ.get("TEST_DB_NAME", "gesthub_test")
SECRET_KEY = "test-secret-key"

0
web/cookie.txt Normal file
View File

93
web/db.py Normal file
View File

@@ -0,0 +1,93 @@
"""
db.py — Module centralisé d'accès à la base de données MariaDB.
Ce module est le SEUL point de contact avec PyMySQL dans l'application.
Il expose un gestionnaire de connexion/curseur réutilisable par tous les
modèles de la couche models/, avec :
- gestion des erreurs et rollback automatique,
- reconnexion en cas de perte de connexion (ping avant chaque emprunt),
- curseurs en mode dictionnaire (résultats accessibles par nom de colonne).
Toutes les requêtes exécutées via ce module DOIVENT utiliser des paramètres
préparés (placeholders %s) — jamais de concaténation de chaînes — afin
d'éliminer tout risque d'injection SQL (Bloc 1 - C4, C8 ; OWASP A03:2021).
"""
import logging
from contextlib import contextmanager
import pymysql
import pymysql.cursors
logger = logging.getLogger("gesthub.db")
class Database:
"""Petite couche d'abstraction au-dessus de PyMySQL (pas d'ORM)."""
def __init__(self, host, port, user, password, database):
self.host = host
self.port = port
self.user = user
self.password = password
self.database = database
def _connect(self):
return pymysql.connect(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
database=self.database,
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
charset="utf8mb4",
)
@contextmanager
def cursor(self, commit=False):
"""Context manager fournissant un curseur et gérant transaction/erreurs.
Usage :
with db.cursor(commit=True) as cur:
cur.execute("INSERT INTO annonces (titre) VALUES (%s)", (titre,))
"""
conn = self._connect()
try:
with conn.cursor() as cur:
yield cur
if commit:
conn.commit()
except pymysql.MySQLError:
conn.rollback()
logger.exception("Erreur SQL — transaction annulée (rollback)")
raise
finally:
conn.close()
def fetch_all(self, query, params=None):
with self.cursor() as cur:
cur.execute(query, params or ())
return cur.fetchall()
def fetch_one(self, query, params=None):
with self.cursor() as cur:
cur.execute(query, params or ())
return cur.fetchone()
def execute(self, query, params=None):
"""INSERT/UPDATE/DELETE avec commit automatique. Retourne lastrowid."""
with self.cursor(commit=True) as cur:
cur.execute(query, params or ())
return cur.lastrowid
def init_db(config):
"""Fabrique une instance Database à partir d'un objet de configuration."""
return Database(
host=config.DB_HOST,
port=config.DB_PORT,
user=config.DB_USER,
password=config.DB_PASSWORD,
database=config.DB_NAME,
)

101
web/init.sql Normal file
View File

@@ -0,0 +1,101 @@
-- =============================================================================
-- init.sql — Script de création du schéma GestHub (MariaDB / InnoDB)
-- =============================================================================
-- Respecte la 3ème forme normale (3NF) : aucune donnée redondante, toutes les
-- dépendances transitives éliminées. La référence aux utilisateurs se fait
-- uniquement via `sub` (identifiant OIDC Keycloak), sans duplication des
-- données du fournisseur d'identité (Bloc 2 - C4).
--
-- Moteur InnoDB : transactions ACID, contraintes de clé étrangère.
-- Exécuté automatiquement au premier démarrage du conteneur MariaDB
-- (monté sur /docker-entrypoint-initdb.d/init.sql, voir docker-compose.yml).
-- =============================================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 1;
-- -----------------------------------------------------------------------------
-- Table : annonces
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS annonces (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
titre VARCHAR(150) NOT NULL,
contenu TEXT NOT NULL,
epinglee TINYINT(1) NOT NULL DEFAULT 0,
auteur_sub VARCHAR(64) NOT NULL, -- sub OIDC Keycloak (pas de FK vers Keycloak)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_annonces_auteur (auteur_sub),
INDEX idx_annonces_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -----------------------------------------------------------------------------
-- Table : fichiers
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fichiers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nom_original VARCHAR(255) NOT NULL,
nom_stocke VARCHAR(64) NOT NULL UNIQUE, -- nom UUID sur disque (anti path traversal)
mime_type VARCHAR(127) NOT NULL,
taille_octets BIGINT UNSIGNED NOT NULL, -- BIGINT : fichiers volumineux possibles
uploader_sub VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_fichiers_uploader (uploader_sub)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -----------------------------------------------------------------------------
-- Table : evenements (module planning)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS evenements (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
titre VARCHAR(150) NOT NULL,
description TEXT NULL,
date_debut DATETIME NOT NULL,
date_fin DATETIME NOT NULL,
createur_sub VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_evenements_dates CHECK (date_fin >= date_debut),
INDEX idx_evenements_dates (date_debut, date_fin)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -----------------------------------------------------------------------------
-- Table : blocks (widgets du tableau de bord — module historique, conservé)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS blocks (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
block_type VARCHAR(50) NOT NULL, -- 'iframe' | 'buttons' | 'html'
column_name VARCHAR(20) NOT NULL, -- 'left' | 'center' | 'right'
position INT UNSIGNED NOT NULL DEFAULT 0,
data TEXT NULL, -- JSON sérialisé (url, titre, liens...)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -----------------------------------------------------------------------------
-- Table : audit_log (traçabilité des actions sensibles — Bloc 1 - C10)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS audit_log (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
action VARCHAR(50) NOT NULL, -- CREATE_ANNONCE, DELETE_FILE, LOGIN, LOGOUT, REFUSE...
ressource VARCHAR(150) NULL, -- ex. 'annonce:42', 'fichier:uuid'
statut VARCHAR(20) NOT NULL, -- SUCCESS | REFUSED | ERROR
utilisateur_sub VARCHAR(64) NULL,
ip_address VARCHAR(45) NULL, -- IPv4 ou IPv6
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_action (action),
INDEX idx_audit_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -----------------------------------------------------------------------------
-- Projection méthodologique (non implémentée) : trigger de traçabilité des
-- modifications directes en base, hors application (voir Bloc 1 - C10) :
--
-- DELIMITER $$
-- CREATE TRIGGER trg_annonces_after_update
-- AFTER UPDATE ON annonces
-- FOR EACH ROW
-- BEGIN
-- INSERT INTO audit_log (action, ressource, statut, utilisateur_sub)
-- VALUES ('DIRECT_UPDATE', CONCAT('annonce:', NEW.id), 'SUCCESS', NULL);
-- END$$
-- DELIMITER ;
-- -----------------------------------------------------------------------------

View File

@@ -1,65 +1,60 @@
from app import app, db, Block
import json
"""
init_db.py — Injection des widgets par défaut du tableau de bord au premier
démarrage (les tables elles-mêmes sont créées par init.sql, exécuté
automatiquement par le conteneur MariaDB).
# Configuration des widgets actuels extraits de ton index.html
initial_blocks = [
# 1. Colonne GAUCHE : Le Planning
Block(
block_type='iframe',
column_name='left',
position=0,
data=json.dumps({
Usage : python init_db.py
"""
from config import Config
from db import init_db
from models import block_model
INITIAL_BLOCKS = [
{
"type": "iframe",
"column": "left",
"data": {
"title": "Planning / Agenda",
"url": "https://mattermost.ninolbt.com/boards/team/8xj6d4ukwigk7rznqi3w339x7e/b3kmbqfwd33dmdy9g9g3ezaoxza/vu4nuxhf73ircznrkrcgzonno8a",
"url": "https://chat.ninolbt.com/boards/team/example/planning",
"height": "100%",
"styleClass": "planning"
})
),
# 2. Colonne CENTRALE : Le Trello (Board)
Block(
block_type='iframe',
column_name='center',
position=0,
data=json.dumps({
"styleClass": "planning",
},
},
{
"type": "iframe",
"column": "center",
"data": {
"title": "",
"url": "https://mattermost.ninolbt.com/boards/team/8xj6d4ukwigk7rznqi3w339x7e/b3kmbqfwd33dmdy9g9g3ezaoxza/va5xp53m6spbi8qo6qnng7711me",
"url": "https://chat.ninolbt.com/boards/team/example/kanban",
"height": "680",
"styleClass": "trello"
})
),
# 3. Colonne DROITE : Les boutons
Block(
block_type='buttons',
column_name='right',
position=0,
data=json.dumps({
"styleClass": "trello",
},
},
{
"type": "buttons",
"column": "right",
"data": {
"links": [
{"label": "Projet", "url": "#"},
{"label": "GDD Global", "url": "#"},
{"label": "Bible 3D Art", "url": "#"},
{"label": "Bible GameDev", "url": "#"},
{"label": "Réglement du HUB", "url": "#"}
{"label": "Documentation", "url": "#"},
{"label": "Règlement du HUB", "url": "#"},
]
})
)
},
},
]
def init_data():
with app.app_context():
# Crée les tables si elles n'existent pas encore
db.create_all()
# Vérifie si la DB est déjà remplie pour éviter les doublons
if Block.query.first():
print("La base de données contient déjà des blocs.")
return
print("Injection des widgets par défaut...")
for block in initial_blocks:
db.session.add(block)
db.session.commit()
print("Terminé ! Tes widgets sont en base de données.")
def main():
db = init_db(Config)
if block_model.fetch_all(db):
print("La base de données contient déjà des blocs.")
return
print("Injection des widgets par défaut...")
for block in INITIAL_BLOCKS:
block_model.insert(db, block["type"], block["column"], block["data"])
print("Terminé.")
if __name__ == "__main__":
init_data()
main()

13
web/models/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
"""
Couche models/ — Repository Pattern (Bloc 1 - C4, Bloc 2 - C4).
Chaque module de ce package encapsule TOUT l'accès SQL pour une entité :
les couches routes/ et services/ n'écrivent jamais de SQL elles-mêmes et
ne connaissent pas le schéma de la base. Cela garantit que le changement
de SGBDR (ex. migration vers PostgreSQL) ne nécessite que la réécriture de
ce package — le reste de l'application est inchangé (dépendance vers
l'abstraction, cf. dossier technique section C3/C4).
Chaque module expose une interface CRUD standardisée :
insert(db, ...) / fetch_all(db, ...) / get(db, id) / update(db, ...) / delete(db, id)
"""

View File

@@ -0,0 +1,57 @@
"""
annonce_model.py — Accès aux données pour l'entité "annonce".
Toutes les requêtes utilisent des paramètres préparés (%s) : aucune
concaténation de chaîne SQL n'est utilisée dans ce module (OWASP A03:2021 —
Injection). Voir tests de sécurité TS-01 (tests/test_security.py).
"""
class AnnonceNotFoundError(Exception):
"""Exception métier levée quand une annonce n'existe pas.
La couche services/ traduit les erreurs SQL bas niveau en exceptions
métier compréhensibles, afin que les routes n'aient jamais à connaître
les détails de pymysql (cf. C4 — convention de gestion des erreurs).
"""
def insert(db, titre, contenu, auteur_sub, epinglee=False):
query = (
"INSERT INTO annonces (titre, contenu, epinglee, auteur_sub) "
"VALUES (%s, %s, %s, %s)"
)
return db.execute(query, (titre, contenu, int(epinglee), auteur_sub))
def fetch_all(db):
query = (
"SELECT id, titre, contenu, epinglee, auteur_sub, created_at, updated_at "
"FROM annonces ORDER BY epinglee DESC, created_at DESC"
)
return db.fetch_all(query)
def get(db, annonce_id):
query = (
"SELECT id, titre, contenu, epinglee, auteur_sub, created_at, updated_at "
"FROM annonces WHERE id = %s"
)
return db.fetch_one(query, (annonce_id,))
def update(db, annonce_id, titre, contenu, epinglee=False):
query = (
"UPDATE annonces SET titre = %s, contenu = %s, epinglee = %s "
"WHERE id = %s"
)
with db.cursor(commit=True) as cur:
cur.execute(query, (titre, contenu, int(epinglee), annonce_id))
return cur.rowcount
def delete(db, annonce_id):
query = "DELETE FROM annonces WHERE id = %s"
with db.cursor(commit=True) as cur:
cur.execute(query, (annonce_id,))
return cur.rowcount

17
web/models/audit_model.py Normal file
View File

@@ -0,0 +1,17 @@
"""audit_model.py — Écriture dans la table de traçabilité audit_log (Bloc 1 - C10)."""
def insert(db, action, statut, ressource=None, utilisateur_sub=None, ip_address=None):
query = (
"INSERT INTO audit_log (action, ressource, statut, utilisateur_sub, ip_address) "
"VALUES (%s, %s, %s, %s, %s)"
)
return db.execute(query, (action, ressource, statut, utilisateur_sub, ip_address))
def fetch_recent(db, limit=100):
query = (
"SELECT id, action, ressource, statut, utilisateur_sub, ip_address, created_at "
"FROM audit_log ORDER BY created_at DESC LIMIT %s"
)
return db.fetch_all(query, (limit,))

59
web/models/block_model.py Normal file
View File

@@ -0,0 +1,59 @@
"""
block_model.py — Accès aux données pour l'entité "block" (widgets du dashboard).
Module historique de GestHub, migré de Flask-SQLAlchemy vers PyMySQL pour
rester cohérent avec le reste de la couche models/ (accès direct, sans ORM,
requêtes paramétrées).
"""
import json
def _row_to_dict(row):
if row is None:
return None
return {
"id": row["id"],
"type": row["block_type"],
"column": row["column_name"],
"position": row["position"],
"data": json.loads(row["data"]) if row["data"] else {},
}
def fetch_all(db):
query = (
"SELECT id, block_type, column_name, position, data FROM blocks "
"ORDER BY column_name, position"
)
rows = db.fetch_all(query)
return [_row_to_dict(r) for r in rows]
def insert(db, block_type, column_name, data, position=99):
query = (
"INSERT INTO blocks (block_type, column_name, position, data) "
"VALUES (%s, %s, %s, %s)"
)
new_id = db.execute(query, (block_type, column_name, position, json.dumps(data)))
return _row_to_dict({
"id": new_id,
"block_type": block_type,
"column_name": column_name,
"position": position,
"data": json.dumps(data),
})
def update_position(db, block_id, column_name, position):
query = "UPDATE blocks SET column_name = %s, position = %s WHERE id = %s"
with db.cursor(commit=True) as cur:
cur.execute(query, (column_name, position, block_id))
return cur.rowcount
def delete(db, block_id):
query = "DELETE FROM blocks WHERE id = %s"
with db.cursor(commit=True) as cur:
cur.execute(query, (block_id,))
return cur.rowcount

View File

@@ -0,0 +1,32 @@
"""evenement_model.py — Accès aux données pour l'entité "evenement" (planning)."""
def insert(db, titre, description, date_debut, date_fin, createur_sub):
query = (
"INSERT INTO evenements (titre, description, date_debut, date_fin, createur_sub) "
"VALUES (%s, %s, %s, %s, %s)"
)
return db.execute(query, (titre, description, date_debut, date_fin, createur_sub))
def fetch_all(db):
query = (
"SELECT id, titre, description, date_debut, date_fin, createur_sub, created_at "
"FROM evenements ORDER BY date_debut ASC"
)
return db.fetch_all(query)
def get(db, evenement_id):
query = (
"SELECT id, titre, description, date_debut, date_fin, createur_sub, created_at "
"FROM evenements WHERE id = %s"
)
return db.fetch_one(query, (evenement_id,))
def delete(db, evenement_id):
query = "DELETE FROM evenements WHERE id = %s"
with db.cursor(commit=True) as cur:
cur.execute(query, (evenement_id,))
return cur.rowcount

View File

@@ -0,0 +1,32 @@
"""fichier_model.py — Accès aux données pour l'entité "fichier"."""
def insert(db, nom_original, nom_stocke, mime_type, taille_octets, uploader_sub):
query = (
"INSERT INTO fichiers (nom_original, nom_stocke, mime_type, taille_octets, uploader_sub) "
"VALUES (%s, %s, %s, %s, %s)"
)
return db.execute(query, (nom_original, nom_stocke, mime_type, taille_octets, uploader_sub))
def fetch_all(db):
query = (
"SELECT id, nom_original, nom_stocke, mime_type, taille_octets, uploader_sub, created_at "
"FROM fichiers ORDER BY created_at DESC"
)
return db.fetch_all(query)
def get(db, fichier_id):
query = (
"SELECT id, nom_original, nom_stocke, mime_type, taille_octets, uploader_sub, created_at "
"FROM fichiers WHERE id = %s"
)
return db.fetch_one(query, (fichier_id,))
def delete(db, fichier_id):
query = "DELETE FROM fichiers WHERE id = %s"
with db.cursor(commit=True) as cur:
cur.execute(query, (fichier_id,))
return cur.rowcount

54
web/models/rgpd_model.py Normal file
View File

@@ -0,0 +1,54 @@
"""
rgpd_model.py — Anonymisation des données personnelles (droit à l'effacement,
RGPD art. 17). Bloc 1 - C10 : traçabilité, et Bloc 2 - C4 : la seule donnée
personnelle stockée dans l'application est le `sub` OIDC (Keycloak) — aucune
autre table ne duplique d'attribut d'identité.
`sub` est référencé dans quatre tables : annonces.auteur_sub,
fichiers.uploader_sub, evenements.createur_sub (toutes NOT NULL — la donnée
ne peut donc pas être mise à NULL sans casser la contrainte, d'où le choix
d'un pseudonyme irréversible) et audit_log.utilisateur_sub (NULLable, mais
pseudonymisée elle aussi par cohérence, pour garder la traçabilité du volume
d'actions sans conserver l'identité).
Toutes les mises à jour sont exécutées dans UNE seule transaction InnoDB
(un seul curseur `commit=True`) : soit les quatre tables sont mises à jour,
soit aucune ne l'est en cas d'erreur (cohérence, pas d'anonymisation partielle).
"""
def anonymize(db, target_sub, anon_value):
"""Remplace `target_sub` par `anon_value` dans toutes les tables qui le
référencent. Retourne le nombre de lignes modifiées par table.
"""
with db.cursor(commit=True) as cur:
cur.execute(
"UPDATE annonces SET auteur_sub = %s WHERE auteur_sub = %s",
(anon_value, target_sub),
)
annonces_rows = cur.rowcount
cur.execute(
"UPDATE fichiers SET uploader_sub = %s WHERE uploader_sub = %s",
(anon_value, target_sub),
)
fichiers_rows = cur.rowcount
cur.execute(
"UPDATE evenements SET createur_sub = %s WHERE createur_sub = %s",
(anon_value, target_sub),
)
evenements_rows = cur.rowcount
cur.execute(
"UPDATE audit_log SET utilisateur_sub = %s WHERE utilisateur_sub = %s",
(anon_value, target_sub),
)
audit_rows = cur.rowcount
return {
"annonces": annonces_rows,
"fichiers": fichiers_rows,
"evenements": evenements_rows,
"audit_log": audit_rows,
}

15
web/requirements.txt Normal file
View File

@@ -0,0 +1,15 @@
# Versions figées pour garantir la reproductibilité de l'environnement
# (Bloc 3 - C6 : gestion des dépendances, évite le "dependency hell").
flask==3.0.3
authlib==1.3.2
requests==2.32.3
pymysql==1.1.1
python-magic==0.4.27
python-dotenv==1.0.1
gunicorn==23.0.0
# --- Dépendances de test (Bloc 1 - C5, Bloc 3 - C7) ---
pytest==8.3.3
pytest-cov==5.0.0
flake8==7.1.1
pyyaml==6.0.3

6
web/routes/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Couche routes/ — orchestration HTTP uniquement (blueprints Flask).
Chaque route : (1) authentifie/autorise via services.auth_service,
(2) délègue la logique métier à la couche services/, (3) retourne une
réponse JSON ou un rendu de template. Aucune route n'exécute de SQL.
"""

62
web/routes/annonces.py Normal file
View File

@@ -0,0 +1,62 @@
"""routes/annonces.py — API CRUD du module Annonces (EXF-01 à EXF-04)."""
from flask import Blueprint, current_app, jsonify, request
from models.annonce_model import AnnonceNotFoundError
from services import annonce_service
from services.auth_service import client_ip, current_user, require_admin, require_login
bp = Blueprint("annonces", __name__, url_prefix="/api/annonces")
def _db():
return current_app.config["DB"]
@bp.route("", methods=["GET"])
@require_login
def list_annonces():
return jsonify(annonce_service.list_annonces(_db()))
@bp.route("", methods=["POST"])
@require_admin
def create_annonce():
data = request.json or {}
user = current_user()
try:
new_id = annonce_service.create_annonce(
_db(), data.get("titre"), data.get("contenu"), user["sub"],
data.get("epinglee", False), client_ip(),
)
except annonce_service.ValidationError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"id": new_id}), 201
@bp.route("/<int:annonce_id>", methods=["PUT"])
@require_admin
def update_annonce(annonce_id):
data = request.json or {}
user = current_user()
try:
annonce_service.update_annonce(
_db(), annonce_id, data.get("titre"), data.get("contenu"), user["sub"],
data.get("epinglee", False), client_ip(),
)
except annonce_service.ValidationError as exc:
return jsonify({"error": str(exc)}), 400
except AnnonceNotFoundError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify({"status": "updated"})
@bp.route("/<int:annonce_id>", methods=["DELETE"])
@require_admin
def delete_annonce(annonce_id):
user = current_user()
try:
annonce_service.delete_annonce(_db(), annonce_id, user["sub"], client_ip())
except AnnonceNotFoundError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify({"status": "deleted"})

86
web/routes/auth.py Normal file
View File

@@ -0,0 +1,86 @@
"""
routes/auth.py — Flux d'authentification OIDC (Authorization Code Flow) avec
Keycloak, via Authlib. 7 étapes documentées dans la rétro-documentation
(RD-3) et le journal de bord :
1. Détection de l'absence de session (décorateur / vérification session)
2. Génération de l'URL d'autorisation (+ nonce anti-rejeu)
3. Redirection de l'utilisateur vers Keycloak
4. Retour sur /auth avec le code d'autorisation
5. Échange du code contre un token (authorize_access_token)
6. Vérification du token (signature, nonce, expiration — parse_id_token)
7. Création de la session applicative Flask
"""
import uuid
from flask import Blueprint, redirect, url_for, session
from config import Config
from services import audit_service
from services.auth_service import client_ip
def register_oauth(oauth):
"""Enregistre le client OIDC Keycloak auprès d'Authlib (appelé depuis app.py)."""
return oauth.register(
name="keycloak",
client_id=Config.OIDC_CLIENT_ID,
client_secret=Config.OIDC_CLIENT_SECRET,
server_metadata_url=Config.OIDC_DISCOVERY_URL,
client_kwargs={"scope": "openid profile email"},
)
def init_auth_routes(oauth):
# Le blueprint est créé ICI (et non au niveau du module) afin que
# chaque appel de create_app() (production, ou une fois par test en
# environnement pytest) produise un blueprint neuf. Un blueprint créé
# au niveau module serait décoré une seule fois puis réutilisé tel
# quel, ce que Flask refuse dès le second register_blueprint().
bp = Blueprint("auth", __name__)
keycloak = register_oauth(oauth)
@bp.route("/login")
def login():
# Étape 2 : génération de l'URL d'autorisation avec un nonce
nonce = uuid.uuid4().hex
session["nonce"] = nonce
redirect_uri = url_for("auth.auth_callback", _external=True, _scheme="https")
# Étape 3 : redirection vers Keycloak
return keycloak.authorize_redirect(redirect_uri, nonce=nonce)
@bp.route("/auth")
def auth_callback():
db = _get_db()
# Étape 5 : échange du code contre un token
token = keycloak.authorize_access_token()
nonce = session.pop("nonce", None)
# Étape 6 : vérification (signature + nonce) du token
userinfo = keycloak.parse_id_token(token, nonce=nonce)
# Étape 7 : création de la session applicative
session["user"] = userinfo
session["id_token"] = token.get("id_token")
audit_service.log(db, "LOGIN", None, "SUCCESS", userinfo.get("sub"), client_ip())
return redirect("/")
@bp.route("/logout")
def logout():
db = _get_db()
user = session.get("user") or {}
id_token = session.get("id_token")
audit_service.log(db, "LOGOUT", None, "SUCCESS", user.get("sub"), client_ip())
session.clear()
return redirect(
f"{Config.OIDC_LOGOUT_URL}"
f"?post_logout_redirect_uri={Config.PUBLIC_BASE_URL}"
f"&id_token_hint={id_token}"
)
return bp
def _get_db():
from flask import current_app
return current_app.config["DB"]

55
web/routes/dashboard.py Normal file
View File

@@ -0,0 +1,55 @@
"""routes/dashboard.py — page d'accueil + API des widgets (layout)."""
from flask import Blueprint, current_app, jsonify, redirect, render_template, request, url_for
from services import block_service
from services.auth_service import current_user, is_admin, is_authenticated, require_admin
bp = Blueprint("dashboard", __name__)
def _db():
return current_app.config["DB"]
@bp.route("/")
def index():
if not is_authenticated():
return redirect(url_for("auth.login"))
return render_template("view/index.html", user=current_user())
@bp.route("/api/is_admin")
def api_is_admin():
return jsonify({"admin": is_admin()})
@bp.route("/api/layout", methods=["GET"])
def get_layout():
return jsonify(block_service.list_blocks(_db()))
@bp.route("/api/layout/save", methods=["POST"])
@require_admin
def save_layout():
block_service.save_layout(_db(), request.json or [])
return jsonify({"status": "saved"})
@bp.route("/api/block/add", methods=["POST"])
@require_admin
def add_block():
data = request.json or {}
block = block_service.add_block(
_db(), data.get("type"), data.get("column", "center"), data.get("data", {})
)
return jsonify(block)
@bp.route("/api/block/<int:block_id>", methods=["DELETE"])
@require_admin
def delete_block(block_id):
rows = block_service.delete_block(_db(), block_id)
if rows:
return jsonify({"status": "deleted"})
return jsonify({"error": "not found"}), 404

44
web/routes/evenements.py Normal file
View File

@@ -0,0 +1,44 @@
"""routes/evenements.py — API CRUD du module Planning (EXF-09, EXF-10)."""
from flask import Blueprint, current_app, jsonify, request
from services import evenement_service
from services.auth_service import client_ip, current_user, require_admin, require_login
bp = Blueprint("evenements", __name__, url_prefix="/api/evenements")
def _db():
return current_app.config["DB"]
@bp.route("", methods=["GET"])
@require_login
def list_evenements():
return jsonify(evenement_service.list_evenements(_db()))
@bp.route("", methods=["POST"])
@require_admin
def create_evenement():
data = request.json or {}
user = current_user()
try:
new_id = evenement_service.create_evenement(
_db(), data.get("titre"), data.get("description"),
data.get("date_debut"), data.get("date_fin"), user["sub"], client_ip(),
)
except evenement_service.ValidationError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"id": new_id}), 201
@bp.route("/<int:evenement_id>", methods=["DELETE"])
@require_admin
def delete_evenement(evenement_id):
user = current_user()
try:
evenement_service.delete_evenement(_db(), evenement_id, user["sub"], client_ip())
except LookupError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify({"status": "deleted"})

59
web/routes/fichiers.py Normal file
View File

@@ -0,0 +1,59 @@
"""routes/fichiers.py — API upload/download du module Fichiers (EXF-05 à EXF-08)."""
from flask import Blueprint, current_app, jsonify, request, send_file
from services import fichier_service
from services.auth_service import client_ip, current_user, is_admin, require_login
bp = Blueprint("fichiers", __name__, url_prefix="/api/fichiers")
def _db():
return current_app.config["DB"]
@bp.route("", methods=["GET"])
@require_login
def list_fichiers():
return jsonify(fichier_service.list_fichiers(_db()))
@bp.route("/upload", methods=["POST"])
@require_login
def upload():
user = current_user()
try:
fichier_id = fichier_service.handle_upload(
_db(), request.files.get("file"), user["sub"], client_ip()
)
except fichier_service.FileValidationError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"id": fichier_id}), 201
@bp.route("/<int:fichier_id>/download", methods=["GET"])
@require_login
def download(fichier_id):
user = current_user()
try:
path, original_name = fichier_service.prepare_download(
_db(), fichier_id, user["sub"], is_admin(user), client_ip()
)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
except PermissionError:
return jsonify({"error": "forbidden"}), 403
return send_file(path, as_attachment=True, download_name=original_name)
@bp.route("/<int:fichier_id>", methods=["DELETE"])
@require_login
def delete(fichier_id):
user = current_user()
try:
fichier_service.delete_fichier(_db(), fichier_id, user["sub"], is_admin(user), client_ip())
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
except PermissionError:
return jsonify({"error": "forbidden"}), 403
return jsonify({"status": "deleted"})

46
web/routes/rgpd.py Normal file
View File

@@ -0,0 +1,46 @@
"""
routes/rgpd.py — Droit à l'effacement RGPD (art. 17) : anonymisation des
données d'un utilisateur (identifié par son `sub` OIDC Keycloak).
Réservé aux administrateurs (@require_admin), au même titre que les autres
routes de modification (annonces, fichiers, blocks) — cf. auth_service.py.
"""
from flask import Blueprint, current_app, jsonify
from config import Config
from services import rgpd_service
from services.auth_service import client_ip, current_user, require_admin
bp = Blueprint("rgpd", __name__, url_prefix="/api/admin/users")
def _db():
return current_app.config["DB"]
def _pepper():
return getattr(Config, "RGPD_ANON_PEPPER", None) or Config.SECRET_KEY
@bp.route("/<sub>/data", methods=["DELETE"])
@require_admin
def anonymize_user_data(sub):
actor = current_user()
try:
result = rgpd_service.anonymize_user(
_db(), sub, _pepper(), actor.get("sub"), client_ip()
)
except rgpd_service.InvalidSubError as exc:
return jsonify({"error": str(exc)}), 400
if not result["anonymized"]:
return jsonify({"error": "aucune donnée trouvée pour cet utilisateur"}), 404
return jsonify(
{
"status": "anonymized",
"pseudonym": result["pseudonym"],
"rows_updated": result["rows"],
}
)

1
web/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Couche services/ — logique métier. Les routes n'appellent jamais les models/ directement."""

View File

@@ -0,0 +1,55 @@
"""
annonce_service.py — Logique métier du module Annonces (EXF-01 à EXF-04 du CDC).
Validation des champs AVANT d'appeler la couche models/, et journalisation
systématique des actions sensibles dans audit_log (Bloc 1 - C10).
"""
from models import annonce_model
from services import audit_service
class ValidationError(Exception):
"""Levée quand les données fournies par l'utilisateur sont invalides."""
TITRE_MAX_LEN = 150
def _validate(titre, contenu):
if not titre or not titre.strip():
raise ValidationError("Le titre est obligatoire.")
if len(titre) > TITRE_MAX_LEN:
raise ValidationError(f"Le titre dépasse {TITRE_MAX_LEN} caractères.")
if not contenu or not contenu.strip():
raise ValidationError("Le contenu est obligatoire.")
def list_annonces(db):
return annonce_model.fetch_all(db)
def create_annonce(db, titre, contenu, auteur_sub, epinglee=False, ip=None):
_validate(titre, contenu)
new_id = annonce_model.insert(db, titre.strip(), contenu.strip(), auteur_sub, epinglee)
audit_service.log(db, "CREATE_ANNONCE", f"annonce:{new_id}", "SUCCESS", auteur_sub, ip)
return new_id
def update_annonce(db, annonce_id, titre, contenu, auteur_sub, epinglee=False, ip=None):
_validate(titre, contenu)
existing = annonce_model.get(db, annonce_id)
if not existing:
raise annonce_model.AnnonceNotFoundError(f"Annonce {annonce_id} introuvable")
rows = annonce_model.update(db, annonce_id, titre.strip(), contenu.strip(), epinglee)
audit_service.log(db, "UPDATE_ANNONCE", f"annonce:{annonce_id}", "SUCCESS", auteur_sub, ip)
return rows
def delete_annonce(db, annonce_id, auteur_sub, ip=None):
existing = annonce_model.get(db, annonce_id)
if not existing:
raise annonce_model.AnnonceNotFoundError(f"Annonce {annonce_id} introuvable")
rows = annonce_model.delete(db, annonce_id)
audit_service.log(db, "DELETE_ANNONCE", f"annonce:{annonce_id}", "SUCCESS", auteur_sub, ip)
return rows

View File

@@ -0,0 +1,11 @@
"""audit_service.py — Point d'entrée unique pour écrire dans audit_log."""
from models import audit_model
def log(db, action, ressource, statut, utilisateur_sub=None, ip_address=None):
return audit_model.insert(db, action, statut, ressource, utilisateur_sub, ip_address)
def recent(db, limit=100):
return audit_model.fetch_recent(db, limit)

View File

@@ -0,0 +1,83 @@
"""
auth_service.py — Fonctions de validation des droits d'accès, centralisées et
réutilisées par toutes les routes (Bloc 1 - C9 : taux de réutilisation > 80 %
via services/ ; Bloc 1 - C8 : "vérification JWT à chaque requête").
Toute route sensible doit passer par current_user() / require_login() /
require_admin() plutôt que de relire directement flask.session, afin que la
règle d'autorisation soit définie à un seul endroit.
"""
from functools import wraps
from flask import session, jsonify, request
from config import Config
def current_user():
"""Retourne l'utilisateur en session (dict issu du token OIDC) ou None."""
return session.get("user")
def is_authenticated():
return current_user() is not None
_UNSET = object()
def is_admin(user=_UNSET):
"""True si l'utilisateur appartient au groupe Keycloak /admin.
Appelé sans argument, lit l'utilisateur courant dans la session Flask
(nécessite un contexte de requête actif). Appelé avec un argument
explicite (y compris None), ce dict/None est utilisé tel quel — utile
pour tester la fonction hors contexte de requête (cf. UT-11).
"""
if user is _UNSET:
user = current_user()
if not user:
return False
return Config.ADMIN_GROUP in user.get("groups", [])
def require_login(view_func):
"""Décorateur : bloque l'accès si aucune session utilisateur valide."""
@wraps(view_func)
def wrapper(*args, **kwargs):
if not is_authenticated():
return jsonify({"error": "unauthorized"}), 401
return view_func(*args, **kwargs)
return wrapper
def require_admin(view_func):
"""Décorateur : bloque l'accès si l'utilisateur n'a pas le groupe /admin.
Utilisé par toutes les routes de modification (annonces, fichiers,
blocks). Aucune route admin n'est accessible sans token valide et sans
appartenance au groupe Keycloak /admin (mitigation du risque "accès non
autorisé à /admin", cf. tableau OWASP du dossier technique).
"""
@wraps(view_func)
def wrapper(*args, **kwargs):
user = current_user()
if not user:
return jsonify({"error": "unauthorized"}), 401
if not is_admin(user):
return jsonify({"error": "forbidden"}), 403
return view_func(*args, **kwargs)
return wrapper
def client_ip():
"""IP cliente, en tenant compte du proxy Caddy (X-Forwarded-For)."""
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.remote_addr

View File

@@ -0,0 +1,25 @@
"""block_service.py — Logique métier du tableau de bord (widgets)."""
from models import block_model
def list_blocks(db):
return block_model.fetch_all(db)
def add_block(db, block_type, column, data):
if block_type not in {"iframe", "buttons", "html"}:
raise ValueError(f"Type de bloc inconnu : {block_type}")
if column not in {"left", "center", "right"}:
raise ValueError(f"Colonne inconnue : {column}")
return block_model.insert(db, block_type, column, data)
def save_layout(db, layout):
"""layout : liste de {id, column, position} après un drag & drop."""
for item in layout:
block_model.update_position(db, item["id"], item["column"], item["position"])
def delete_block(db, block_id):
return block_model.delete(db, block_id)

View File

@@ -0,0 +1,42 @@
"""evenement_service.py — Logique métier du module Planning (EXF-09, EXF-10)."""
from datetime import datetime
from models import evenement_model
from services import audit_service
class ValidationError(Exception):
pass
def _parse(dt_str):
try:
return datetime.fromisoformat(dt_str)
except (TypeError, ValueError) as exc:
raise ValidationError(f"Date invalide : {dt_str!r}") from exc
def list_evenements(db):
return evenement_model.fetch_all(db)
def create_evenement(db, titre, description, date_debut, date_fin, createur_sub, ip=None):
if not titre or not titre.strip():
raise ValidationError("Le titre est obligatoire.")
debut = _parse(date_debut)
fin = _parse(date_fin)
if fin < debut:
raise ValidationError("La date de fin doit être postérieure à la date de début.")
new_id = evenement_model.insert(db, titre.strip(), description, debut, fin, createur_sub)
audit_service.log(db, "CREATE_EVENEMENT", f"evenement:{new_id}", "SUCCESS", createur_sub, ip)
return new_id
def delete_evenement(db, evenement_id, createur_sub, ip=None):
existing = evenement_model.get(db, evenement_id)
if not existing:
raise LookupError(f"Événement {evenement_id} introuvable")
rows = evenement_model.delete(db, evenement_id)
audit_service.log(db, "DELETE_EVENEMENT", f"evenement:{evenement_id}", "SUCCESS", createur_sub, ip)
return rows

View File

@@ -0,0 +1,160 @@
"""
fichier_service.py — Module de partage de fichiers (EXF-05 à EXF-08 du CDC).
Upload décomposé en 8 étapes indépendantes et testables unitairement
(cf. dossier technique — Bloc 3, C2 "Décomposition algorithmique") :
1) Vérification de la présence du fichier dans la requête
2) Vérification de la taille (avant lecture complète du flux)
3) Vérification du type MIME réel (python-magic, analyse binaire du contenu)
4) Vérification de l'extension déclarée (liste blanche)
5) Génération d'un nom de fichier sécurisé (UUID4)
6) Sauvegarde sur disque
7) Enregistrement des métadonnées en base de données
8) Journalisation de l'action dans audit_log
Chaque étape peut échouer indépendamment avec un message d'erreur explicite,
ce qui facilite le test (voir tests/test_unit_fichier_service.py, UT-06 à UT-08)
et le débogage (pas de bloc "tout ou rien").
"""
import os
import uuid
try:
import magic # python-magic : détection MIME par analyse binaire (libmagic)
_HAS_MAGIC = True
except ImportError: # pragma: no cover - environnement sans libmagic (dev/CI léger)
_HAS_MAGIC = False
from config import Config
from models import fichier_model
from services import audit_service
class FileValidationError(Exception):
"""Erreur métier explicite pour chaque étape de validation d'upload."""
# --- Étape 1 -----------------------------------------------------------------
def step1_check_presence(file_storage):
if file_storage is None or file_storage.filename == "":
raise FileValidationError("Aucun fichier fourni dans la requête.")
return file_storage
# --- Étape 2 -----------------------------------------------------------------
def step2_check_size(file_storage, max_size=None):
max_size = max_size if max_size is not None else Config.MAX_UPLOAD_SIZE_BYTES
file_storage.stream.seek(0, os.SEEK_END)
size = file_storage.stream.tell()
file_storage.stream.seek(0)
if size == 0:
raise FileValidationError("Le fichier est vide.")
if size > max_size:
raise FileValidationError(
f"Fichier trop volumineux ({size} octets, max {max_size})."
)
return size
# --- Étape 3 -----------------------------------------------------------------
def step3_check_mime(file_storage, allowed_mimes=None):
allowed_mimes = allowed_mimes or Config.ALLOWED_MIME_TYPES
head = file_storage.stream.read(2048)
file_storage.stream.seek(0)
if _HAS_MAGIC:
mime_type = magic.from_buffer(head, mime=True)
else: # repli : type déclaré par le client (moins fiable, signalé dans les logs)
mime_type = file_storage.mimetype or "application/octet-stream"
if mime_type not in allowed_mimes:
raise FileValidationError(f"Type de fichier non autorisé : {mime_type}")
return mime_type
# --- Étape 4 -----------------------------------------------------------------
def step4_check_extension(filename, allowed_ext=None):
allowed_ext = allowed_ext or Config.ALLOWED_EXTENSIONS
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in allowed_ext:
raise FileValidationError(f"Extension non autorisée : .{ext}")
return ext
# --- Étape 5 -----------------------------------------------------------------
def step5_generate_safe_name(ext):
return f"{uuid.uuid4().hex}.{ext}"
# --- Étape 6 -----------------------------------------------------------------
def step6_save_to_disk(file_storage, safe_name, upload_dir=None):
# NB : upload_dir n'est PAS résolu en valeur par défaut de paramètre
# (qui serait figée à l'import du module) mais lu dynamiquement dans le
# corps de la fonction, afin de respecter Config.UPLOAD_DIR à l'instant
# de l'appel (important notamment pour les tests, qui redirigent
# UPLOAD_DIR vers un répertoire temporaire via monkeypatch).
upload_dir = upload_dir or Config.UPLOAD_DIR
os.makedirs(upload_dir, exist_ok=True)
destination = os.path.join(upload_dir, safe_name)
file_storage.save(destination)
return destination
# --- Étapes 7 & 8 --------------------------------------------------------------
def step7_persist_metadata(db, original_name, safe_name, mime_type, size, uploader_sub):
return fichier_model.insert(db, original_name, safe_name, mime_type, size, uploader_sub)
def step8_log_audit(db, fichier_id, uploader_sub, ip=None):
audit_service.log(db, "UPLOAD_FILE", f"fichier:{fichier_id}", "SUCCESS", uploader_sub, ip)
def handle_upload(db, file_storage, uploader_sub, ip=None):
"""Orchestre les 8 étapes. Lève FileValidationError au premier échec."""
step1_check_presence(file_storage)
size = step2_check_size(file_storage)
mime_type = step3_check_mime(file_storage)
ext = step4_check_extension(file_storage.filename)
safe_name = step5_generate_safe_name(ext)
step6_save_to_disk(file_storage, safe_name)
fichier_id = step7_persist_metadata(
db, file_storage.filename, safe_name, mime_type, size, uploader_sub
)
step8_log_audit(db, fichier_id, uploader_sub, ip)
return fichier_id
def list_fichiers(db):
return fichier_model.fetch_all(db)
def prepare_download(db, fichier_id, requester_sub, is_admin, ip=None):
"""Vérifie les droits avant de renvoyer le chemin disque du fichier.
Règle métier : un fichier est téléchargeable par son propriétaire ou par
un administrateur (mitigation OWASP A01 — Broken Access Control /
"accès aux ressources d'autrui", cf. tests TS-05).
"""
fichier = fichier_model.get(db, fichier_id)
if not fichier:
raise FileNotFoundError(f"Fichier {fichier_id} introuvable")
if fichier["uploader_sub"] != requester_sub and not is_admin:
audit_service.log(db, "DOWNLOAD_FILE", f"fichier:{fichier_id}", "REFUSED", requester_sub, ip)
raise PermissionError("Accès refusé à ce fichier.")
audit_service.log(db, "DOWNLOAD_FILE", f"fichier:{fichier_id}", "SUCCESS", requester_sub, ip)
return os.path.join(Config.UPLOAD_DIR, fichier["nom_stocke"]), fichier["nom_original"]
def delete_fichier(db, fichier_id, requester_sub, is_admin, ip=None):
fichier = fichier_model.get(db, fichier_id)
if not fichier:
raise FileNotFoundError(f"Fichier {fichier_id} introuvable")
if fichier["uploader_sub"] != requester_sub and not is_admin:
audit_service.log(db, "DELETE_FILE", f"fichier:{fichier_id}", "REFUSED", requester_sub, ip)
raise PermissionError("Accès refusé à ce fichier.")
path = os.path.join(Config.UPLOAD_DIR, fichier["nom_stocke"])
if os.path.exists(path):
os.remove(path)
fichier_model.delete(db, fichier_id)
audit_service.log(db, "DELETE_FILE", f"fichier:{fichier_id}", "SUCCESS", requester_sub, ip)

View File

@@ -0,0 +1,69 @@
"""
rgpd_service.py — Droit à l'effacement / anonymisation RGPD (art. 17).
GestHub ne stocke pas de compte utilisateur : l'unique donnée personnelle
conservée est le `sub` OIDC Keycloak, référencé dans annonces, fichiers,
evenements et audit_log (cf. rgpd_model.py). "Supprimer un utilisateur" au
sens RGPD consiste donc à remplacer ce `sub` partout où il apparaît par un
pseudonyme irréversible, sans toucher au contenu produit (titres, fichiers,
événements) qui reste une donnée d'activité du service, pas une donnée
personnelle une fois l'auteur dé-identifié.
Pseudonyme = HMAC-SHA256(pepper, sub), tronqué et préfixé "anon:" pour tenir
dans la colonne VARCHAR(64) et rester reconnaissable dans les journaux/DB.
Le pepper (RGPD_ANON_PEPPER, ou SECRET_KEY à défaut) n'est jamais stocké
dans la base : sans lui, remonter du pseudonyme au `sub` d'origine est
calculatoirement infaisable (irréversibilité, cf. RGPD art. 17 §1).
Le hash reste déterministe pour un même `sub` : les quatre tables reçoivent
donc le même pseudonyme, ce qui permet de vérifier a posteriori que la purge
a bien été appliquée partout, sans jamais réexposer l'identité d'origine.
"""
import hashlib
import hmac
from models import rgpd_model
from services import audit_service
ANON_PREFIX = "anon:"
_HASH_HEX_LEN = 64 - len(ANON_PREFIX) # tient dans VARCHAR(64)
class InvalidSubError(Exception):
"""Levée quand le `sub` cible est vide ou manifestement invalide."""
def _anon_value(pepper, target_sub):
digest = hmac.new(pepper.encode("utf-8"), target_sub.encode("utf-8"), hashlib.sha256)
return ANON_PREFIX + digest.hexdigest()[:_HASH_HEX_LEN]
def anonymize_user(db, target_sub, pepper, actor_sub=None, ip=None):
"""Anonymise toutes les données rattachées à `target_sub`.
Retourne {"anonymized": bool, "pseudonym": str, "rows": {...}}.
`anonymized` est False si aucune ligne ne référençait ce `sub` (rien à
faire — permet à la route de répondre 404 plutôt que de journaliser une
action fictive).
"""
target_sub = (target_sub or "").strip()
if not target_sub:
raise InvalidSubError("Le sub cible est obligatoire.")
if target_sub.startswith(ANON_PREFIX):
raise InvalidSubError("Cet utilisateur est déjà anonymisé.")
pseudonym = _anon_value(pepper, target_sub)
rows = rgpd_model.anonymize(db, target_sub, pseudonym)
total = sum(rows.values())
if total > 0:
audit_service.log(
db,
"RGPD_ANONYMIZE_USER",
f"user:{pseudonym}",
"SUCCESS",
actor_sub,
ip,
)
return {"anonymized": total > 0, "pseudonym": pseudonym, "rows": rows}

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<title>Accès refusé — GestHub</title>
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/index.css') }}" />
</head>
<body>
<main role="main" style="text-align:center; padding: 4rem 1rem;">
<h1>403 — Accès refusé</h1>
<p>Vous n'avez pas les droits nécessaires pour accéder à cette ressource.</p>
<a href="{{ url_for('dashboard.index') }}">Retour à l'accueil</a>
</main>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<title>Page introuvable — GestHub</title>
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/index.css') }}" />
</head>
<body>
<main role="main" style="text-align:center; padding: 4rem 1rem;">
<h1>404 — Page introuvable</h1>
<p>La ressource demandée n'existe pas.</p>
<a href="{{ url_for('dashboard.index') }}">Retour à l'accueil</a>
</main>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<title>Erreur serveur — GestHub</title>
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/index.css') }}" />
</head>
<body>
<main role="main" style="text-align:center; padding: 4rem 1rem;">
<h1>500 — Erreur interne</h1>
<p>Une erreur inattendue est survenue. L'équipe technique a été notifiée.</p>
<a href="{{ url_for('dashboard.index') }}">Retour à l'accueil</a>
</main>
</body>
</html>

View File

@@ -3,33 +3,62 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Gesthub</title>
<title>GestHub — Tableau de bord</title>
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/index.css') }}" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
</head>
<body>
<button id="toggle-darkmode">🌓</button>
<main>
<aside class="left-column" id="left">
</aside>
<header role="banner">
<button id="toggle-darkmode" aria-label="Basculer le mode sombre / clair">🌓</button>
<span class="user-badge" aria-label="Utilisateur connecté">
{{ user.preferred_username if user else '' }}
</span>
<a href="{{ url_for('auth.logout') }}" class="logout-link" aria-label="Se déconnecter de GestHub">
Déconnexion
</a>
</header>
<section class="center-column" id="center">
<h1 class="main-title">Gesthub</h1>
</section>
<nav role="navigation" aria-label="Navigation principale des modules GestHub">
<a href="#annonces">Annonces</a>
<a href="#fichiers">Fichiers</a>
<a href="#planning">Planning</a>
</nav>
<aside class="right-column" id="right">
</aside>
<main role="main">
<aside class="left-column" id="left" aria-label="Colonne gauche du tableau de bord">
</aside>
<section class="center-column" id="center" aria-label="Colonne centrale du tableau de bord">
<h1 class="main-title">GestHub</h1>
</section>
<aside class="right-column" id="right" aria-label="Colonne droite du tableau de bord">
</aside>
</main>
<div id="admin-panel" style="display:none;">
<section id="annonces" aria-labelledby="annonces-heading">
<h2 id="annonces-heading">Annonces</h2>
<ul id="annonces-list" aria-live="polite"></ul>
</section>
<section id="fichiers" aria-labelledby="fichiers-heading">
<h2 id="fichiers-heading">Partage de fichiers</h2>
<ul id="fichiers-list" aria-live="polite"></ul>
</section>
<section id="planning" aria-labelledby="planning-heading">
<h2 id="planning-heading">Planning</h2>
<ul id="evenements-list" aria-live="polite"></ul>
</section>
<div id="admin-panel" style="display:none;" role="toolbar" aria-label="Barre d'outils d'édition (mode administrateur)">
<div class="admin-toolbar">
<span>🛠 Mode Édition</span>
<button onclick="addNewWidget('iframe')">+ Iframe</button>
<button onclick="addNewWidget('buttons')">+ Boutons</button>
<button onclick="addNewWidget('html')">+ Texte/HTML</button>
<button onclick="addNewWidget('iframe')" aria-label="Ajouter un widget de type iframe">+ Iframe</button>
<button onclick="addNewWidget('buttons')" aria-label="Ajouter un widget de type boutons">+ Boutons</button>
<button onclick="addNewWidget('html')" aria-label="Ajouter un widget de texte ou HTML">+ Texte/HTML</button>
</div>
</div>
@@ -47,8 +76,10 @@
.drag-handle { cursor: move; padding: 5px; background: rgba(0,0,0,0.1); text-align: center; color: #666; font-size: 12px; margin-bottom: 5px; display: none;}
.admin-active .drag-handle { display: block; }
.admin-active .draggable-item { border: 2px dashed #ccc; min-height: 50px; }
header[role="banner"] { display:flex; align-items:center; gap: 1rem; padding: 0.5rem 1rem; }
.logout-link { margin-left: auto; }
</style>
<script src="{{ url_for('static', filename='assets/js/index.js') }}"></script>
</body>
</html>
</html>

0
web/tests/__init__.py Normal file
View File

88
web/tests/conftest.py Normal file
View File

@@ -0,0 +1,88 @@
"""
conftest.py — Fixtures pytest partagées.
La suite de tests s'exécute contre une VRAIE base MariaDB de test
(gesthub_test), jamais contre des mocks de couche SQL : cela permet de
détecter de vraies erreurs de requête (typos de colonnes, contraintes
violées...) que des mocks ne détecteraient pas. Chaque test nettoie les
tables qu'il utilise pour rester indépendant des autres (pas d'ordre imposé).
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from config import TestConfig
from db import init_db
@pytest.fixture(scope="session")
def db():
database = init_db(TestConfig)
# Sanity check : la base de test doit être joignable avant de lancer la suite.
database.fetch_one("SELECT 1 AS ok")
return database
@pytest.fixture(autouse=True)
def clean_tables(db):
"""Vide les tables avant CHAQUE test pour garantir l'isolation."""
with db.cursor(commit=True) as cur:
for table in ("audit_log", "fichiers", "evenements", "annonces", "blocks"):
cur.execute(f"DELETE FROM {table}")
yield
@pytest.fixture(scope="session")
def app():
# Portée "session" : Flask interdit de redécorer un blueprint déjà
# enregistré (voir routes/auth.py), donc create_app() n'est appelé
# qu'une seule fois pour toute la suite. L'isolation entre tests est
# assurée par clean_tables (autouse) et par un nouveau test_client()
# (voir fixture client ci-dessous) à chaque test.
from app import create_app
flask_app = create_app(TestConfig)
flask_app.config["DB"] = init_db(TestConfig)
flask_app.config["TESTING"] = True
return flask_app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def admin_session(app):
"""Simule une session utilisateur authentifiée avec le groupe /admin.
Utilise SON PROPRE test_client() (indépendant de la fixture `client` et
de `user_session`) : deux clients Flask ont chacun leur propre cookie
jar, donc leurs sessions ne s'écrasent pas quand un test dépend des deux
en même temps (cf. test_it07).
"""
test_client = app.test_client()
with test_client.session_transaction() as sess:
sess["user"] = {
"sub": "admin-sub-0001",
"preferred_username": "admin",
"groups": ["/admin"],
}
return test_client
@pytest.fixture
def user_session(app):
"""Simule une session utilisateur authentifiée SANS le groupe /admin."""
test_client = app.test_client()
with test_client.session_transaction() as sess:
sess["user"] = {
"sub": "user-sub-0002",
"preferred_username": "membre",
"groups": [],
}
return test_client

View File

@@ -0,0 +1,73 @@
"""
Tests d'intégration des routes Flask — IT-01 à IT-10 (client de test Flask).
Vérifient le comportement HTTP complet (routes -> services -> models -> DB
réelle), avec et sans authentification/autorisation.
"""
import json
def test_it01_annonces_sans_auth_401(client):
resp = client.get("/api/annonces")
assert resp.status_code == 401
def test_it02_annonces_avec_auth_200(user_session):
resp = user_session.get("/api/annonces")
assert resp.status_code == 200
assert resp.get_json() == []
def test_it03_creer_annonce_non_admin_403(user_session):
resp = user_session.post("/api/annonces", json={"titre": "T", "contenu": "C"})
assert resp.status_code == 403
def test_it04_creer_annonce_admin_201_puis_visible(admin_session):
resp = admin_session.post("/api/annonces", json={"titre": "Annonce test", "contenu": "Contenu"})
assert resp.status_code == 201
resp_list = admin_session.get("/api/annonces")
titres = [a["titre"] for a in resp_list.get_json()]
assert "Annonce test" in titres
def test_it05_modifier_annonce_admin_200(admin_session):
created = admin_session.post("/api/annonces", json={"titre": "A", "contenu": "B"}).get_json()
resp = admin_session.put(f"/api/annonces/{created['id']}", json={"titre": "A2", "contenu": "B2"})
assert resp.status_code == 200
def test_it06_supprimer_annonce_admin_200(admin_session):
created = admin_session.post("/api/annonces", json={"titre": "A", "contenu": "B"}).get_json()
resp = admin_session.delete(f"/api/annonces/{created['id']}")
assert resp.status_code == 200
def test_it07_is_admin_reflete_groupes(admin_session, user_session):
assert admin_session.get("/api/is_admin").get_json()["admin"] is True
assert user_session.get("/api/is_admin").get_json()["admin"] is False
def test_it08_upload_sans_fichier_400(user_session):
resp = user_session.post("/api/fichiers/upload", data={})
assert resp.status_code == 400
def test_it09_evenement_dates_invalides_400(admin_session):
resp = admin_session.post(
"/api/evenements",
json={"titre": "Réunion", "date_debut": "2026-09-10T10:00:00", "date_fin": "2026-09-09T10:00:00"},
)
assert resp.status_code == 400
def test_it10_accueil_sans_session_redirige_login(client):
resp = client.get("/", follow_redirects=False)
assert resp.status_code == 302
assert "/login" in resp.headers["Location"]
def test_it04b_creer_annonce_titre_manquant_400(admin_session):
resp = admin_session.post("/api/annonces", json={"contenu": "Sans titre"})
assert resp.status_code == 400

97
web/tests/test_rgpd.py Normal file
View File

@@ -0,0 +1,97 @@
"""
Tests RGPD — droit à l'effacement (art. 17) : anonymisation d'un `sub`
OIDC dans toutes les tables qui le référencent (routes/rgpd.py,
services/rgpd_service.py, models/rgpd_model.py).
"""
from models import annonce_model, fichier_model, evenement_model, audit_model
from services import annonce_service, evenement_service, rgpd_service
def _seed_user_data(db, sub):
"""Crée une ligne dans chaque table référençant `sub`, y compris
audit_log (via les services, comme le ferait l'application réelle).
create_annonce() et create_evenement() journalisent déjà chacun une
ligne dans audit_log (CREATE_ANNONCE, CREATE_EVENEMENT) ; on ajoute un
LOGIN explicite pour obtenir 3 lignes audit_log au total pour ce sub.
"""
annonce_id = annonce_service.create_annonce(db, "Titre", "Contenu", sub)
fichier_model.insert(db, "doc.pdf", "uuid-stocke", "application/pdf", 10, sub)
evenement_service.create_evenement(
db, "Réunion", "desc", "2026-09-10T10:00:00", "2026-09-10T11:00:00", sub
)
from services import audit_service
audit_service.log(db, "LOGIN", None, "SUCCESS", sub, "127.0.0.1")
return annonce_id
# --- Accès -------------------------------------------------------------------
def test_rgpd01_sans_auth_401(client):
resp = client.delete("/api/admin/users/sub-cible/data")
assert resp.status_code == 401
def test_rgpd02_non_admin_403(user_session):
resp = user_session.delete("/api/admin/users/sub-cible/data")
assert resp.status_code == 403
def test_rgpd03_sub_inconnu_404(admin_session):
resp = admin_session.delete("/api/admin/users/sub-jamais-vu/data")
assert resp.status_code == 404
# --- Anonymisation effective ---------------------------------------------
def test_rgpd04_anonymisation_remplace_le_sub_partout(db, admin_session):
target_sub = "sub-a-effacer-0001"
annonce_id = _seed_user_data(db, target_sub)
resp = admin_session.delete(f"/api/admin/users/{target_sub}/data")
assert resp.status_code == 200
body = resp.get_json()
assert body["status"] == "anonymized"
pseudonym = body["pseudonym"]
assert pseudonym.startswith("anon:")
assert body["rows_updated"] == {
"annonces": 1, "fichiers": 1, "evenements": 1, "audit_log": 3,
}
# Le sub d'origine n'apparaît plus nulle part...
assert annonce_model.get(db, annonce_id)["auteur_sub"] == pseudonym
fichiers = fichier_model.fetch_all(db)
assert fichiers[0]["uploader_sub"] == pseudonym
evenements = evenement_model.fetch_all(db)
assert evenements[0]["createur_sub"] == pseudonym
recent = audit_model.fetch_recent(db)
subs_restants = [row["utilisateur_sub"] for row in recent]
assert target_sub not in subs_restants
# ... mais une entrée d'audit trace l'action d'anonymisation elle-même,
# journalisée sous l'identité de l'ADMIN qui l'a déclenchée (pas la
# cible, déjà pseudonymisée à ce stade).
actions = {row["action"] for row in recent}
assert "RGPD_ANONYMIZE_USER" in actions
def test_rgpd05_pseudonyme_deterministe_pour_un_meme_sub(db):
target_sub = "sub-deterministe-0002"
_seed_user_data(db, target_sub)
result = rgpd_service.anonymize_user(db, target_sub, pepper="pepper-de-test")
assert result["anonymized"] is True
# Toutes les tables ont reçu EXACTEMENT le même pseudonyme.
pseudo = result["pseudonym"]
assert annonce_model.fetch_all(db)[0]["auteur_sub"] == pseudo
assert fichier_model.fetch_all(db)[0]["uploader_sub"] == pseudo
def test_rgpd06_deja_anonymise_refuse(db, admin_session):
target_sub = "sub-double-appel-0003"
_seed_user_data(db, target_sub)
first = admin_session.delete(f"/api/admin/users/{target_sub}/data")
pseudonym = first.get_json()["pseudonym"]
second = admin_session.delete(f"/api/admin/users/{pseudonym}/data")
assert second.status_code == 400

View File

@@ -0,0 +1,89 @@
"""
Tests de sécurité — TS-01 à TS-06 (Bloc 1 - C8 ; OWASP Top 10 2021).
Approche adversariale : se mettre dans la peau d'un attaquant pour chaque
vecteur identifié dans le tableau de risques du dossier technique.
"""
import io
import os
import yaml
from werkzeug.datastructures import FileStorage
from models import annonce_model
from services import annonce_service, fichier_service
# --- TS-01 : Injection SQL (A03:2021) ----------------------------------------
def test_ts01_injection_sql_dans_titre_est_neutralisee(db):
payload = "Test'; DROP TABLE annonces; --"
new_id = annonce_service.create_annonce(db, payload, "contenu", "sub-attaquant")
# La requête paramétrée traite le payload comme une simple chaîne : la
# table annonces existe toujours et contient bien la ligne créée.
row = annonce_model.get(db, new_id)
assert row["titre"] == payload
all_rows = annonce_model.fetch_all(db)
assert len(all_rows) >= 1 # la table n'a pas été supprimée
# --- TS-02 : Accès sans token (A01:2021 — Broken Access Control) ------------
def test_ts02_acces_sans_token_refuse(client):
resp = client.get("/api/fichiers")
assert resp.status_code == 401
resp2 = client.post("/api/annonces", json={"titre": "x", "contenu": "y"})
assert resp2.status_code == 401
# --- TS-03 : Cookie de session falsifié (token forgé) -----------------------
def test_ts03_cookie_session_falsifie_refuse(client):
client.set_cookie("session", "valeur-totalement-forgee-non-signee")
resp = client.get("/api/annonces")
# Flask ne parvient pas à décoder un cookie non signé par SECRET_KEY :
# la session est traitée comme vide -> 401, pas d'accès accordé.
assert resp.status_code == 401
# --- TS-04 : XSS stocké (A03:2021) ------------------------------------------
def test_ts04_contenu_avec_script_est_echappe_au_rendu(app, db):
payload = "<script>alert('xss')</script>"
annonce_service.create_annonce(db, "Titre", payload, "sub-1")
with app.test_request_context():
from flask import render_template_string
rendered = render_template_string("{{ contenu }}", contenu=payload)
# Jinja2 (autoescape actif par défaut sur .html) transforme les balises
# en entités HTML : le script n'est jamais exécutable tel quel.
assert "<script>" not in rendered
assert "&lt;script&gt;" in rendered
# --- TS-05 : Accès aux ressources d'autrui (A01:2021) -----------------------
def test_ts05_telechargement_fichier_dautrui_refuse(db, tmp_path, monkeypatch):
from config import Config
monkeypatch.setattr(Config, "UPLOAD_DIR", str(tmp_path))
fs = FileStorage(stream=io.BytesIO(b"contenu prive"), filename="prive.pdf", content_type="application/pdf")
fichier_id = fichier_service.handle_upload(db, fs, "sub-proprietaire", ip="127.0.0.1")
try:
fichier_service.prepare_download(db, fichier_id, "sub-attaquant", is_admin=False)
assert False, "le téléchargement aurait dû être refusé"
except PermissionError:
pass
# Le propriétaire, lui, peut télécharger son propre fichier.
path, name = fichier_service.prepare_download(db, fichier_id, "sub-proprietaire", is_admin=False)
assert os.path.exists(path)
# --- TS-06 : Exposition des ports internes (A05:2021 — Security Misconfig) -
def test_ts06_seul_caddy_expose_des_ports_publics():
compose_path = os.path.join(os.path.dirname(__file__), "..", "..", "docker-compose.yml")
with open(compose_path) as f:
compose = yaml.safe_load(f)
for service_name, service_def in compose["services"].items():
if service_name == "caddy":
assert "ports" in service_def
else:
assert "ports" not in service_def, f"{service_name} expose un port public !"

View File

@@ -0,0 +1,41 @@
"""Tests unitaires du service Annonces — UT-01 à UT-05 (Bloc 3 - C7)."""
import pytest
from models import annonce_model
from services import annonce_service
def test_ut01_create_annonce_valide(db):
new_id = annonce_service.create_annonce(db, "Titre test", "Contenu test", "sub-1")
assert new_id is not None
row = annonce_model.get(db, new_id)
assert row["titre"] == "Titre test"
assert row["contenu"] == "Contenu test"
def test_ut02_create_annonce_titre_vide(db):
with pytest.raises(annonce_service.ValidationError):
annonce_service.create_annonce(db, "", "Contenu", "sub-1")
def test_ut03_create_annonce_contenu_vide(db):
with pytest.raises(annonce_service.ValidationError):
annonce_service.create_annonce(db, "Titre", " ", "sub-1")
def test_ut04_update_annonce_existante(db):
new_id = annonce_service.create_annonce(db, "Avant", "Avant contenu", "sub-1")
annonce_service.update_annonce(db, new_id, "Après", "Après contenu", "sub-1")
row = annonce_model.get(db, new_id)
assert row["titre"] == "Après"
def test_ut05_update_annonce_inexistante(db):
with pytest.raises(annonce_model.AnnonceNotFoundError):
annonce_service.update_annonce(db, 999999, "Titre", "Contenu", "sub-1")
def test_ut05b_delete_annonce_inexistante(db):
with pytest.raises(annonce_model.AnnonceNotFoundError):
annonce_service.delete_annonce(db, 999999, "sub-1")

View File

@@ -0,0 +1,22 @@
"""Tests unitaires du service d'authentification — UT-09 à UT-12."""
from services.auth_service import is_admin
def test_ut09_is_admin_avec_groupe_admin():
user = {"sub": "1", "groups": ["/admin", "/staff"]}
assert is_admin(user) is True
def test_ut10_is_admin_sans_groupe_admin():
user = {"sub": "1", "groups": ["/staff"]}
assert is_admin(user) is False
def test_ut11_is_admin_utilisateur_none():
assert is_admin(None) is False
def test_ut12_is_admin_sans_champ_groups():
user = {"sub": "1"}
assert is_admin(user) is False

View File

@@ -0,0 +1,56 @@
"""Tests unitaires du module Fichiers — UT-06 à UT-08 (décomposition en 8 étapes)."""
import io
import pytest
from werkzeug.datastructures import FileStorage
from services import fichier_service
def _file_storage(content=b"contenu de test", filename="rapport.pdf", mimetype="application/pdf"):
return FileStorage(stream=io.BytesIO(content), filename=filename, content_type=mimetype)
def test_ut06_fichier_trop_grand():
fs = _file_storage(content=b"x" * 100)
with pytest.raises(fichier_service.FileValidationError):
fichier_service.step2_check_size(fs, max_size=50)
def test_ut06b_fichier_vide():
fs = _file_storage(content=b"")
with pytest.raises(fichier_service.FileValidationError):
fichier_service.step2_check_size(fs)
def test_ut07_extension_interdite():
with pytest.raises(fichier_service.FileValidationError):
fichier_service.step4_check_extension("script.exe")
def test_ut07b_extension_autorisee():
ext = fichier_service.step4_check_extension("rapport.pdf")
assert ext == "pdf"
def test_ut08_nom_genere_est_unique():
name1 = fichier_service.step5_generate_safe_name("pdf")
name2 = fichier_service.step5_generate_safe_name("pdf")
assert name1 != name2
assert name1.endswith(".pdf")
def test_ut08b_presence_fichier_absent():
with pytest.raises(fichier_service.FileValidationError):
fichier_service.step1_check_presence(None)
def test_upload_complet_end_to_end(db, tmp_path, monkeypatch):
"""Vérifie l'enchaînement complet des 8 étapes avec un fichier valide."""
from config import Config
monkeypatch.setattr(Config, "UPLOAD_DIR", str(tmp_path))
fs = _file_storage(content=b"%PDF-1.4 contenu factice", filename="doc.pdf", mimetype="application/pdf")
fichier_id = fichier_service.handle_upload(db, fs, "sub-1", ip="127.0.0.1")
assert fichier_id is not None