transfer from private server/git server
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
web/static/assets/uploads/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.egg-info/
|
||||
|
||||
15
Caddyfile
15
Caddyfile
@@ -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
179
README.en.md
Normal 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
|
||||
55
README.md
55
README.md
@@ -1,6 +1,31 @@
|
||||
# 📘 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;">
|
||||
<img src="https://img.shields.io/badge/Flask-000000.svg?style=flat&logo=Flask&logoColor=white" alt="Flask">
|
||||
@@ -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` :
|
||||
|
||||
@@ -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
|
||||
volumes:
|
||||
- ./web:/app
|
||||
ports:
|
||||
- "5000:5000"
|
||||
networks:
|
||||
- gesthub
|
||||
|
||||
mariadb:
|
||||
image: mariadb:latest
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- uploads_data:/app/static/assets/uploads
|
||||
expose:
|
||||
- "5000"
|
||||
networks:
|
||||
- gesthub-net
|
||||
|
||||
# --- Tier données : base applicative MariaDB ---
|
||||
mariadb:
|
||||
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:
|
||||
mattermost_data:
|
||||
postgres_data:
|
||||
keycloak_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
uploads_data:
|
||||
keycloakdb_data:
|
||||
mattermost_data:
|
||||
mattermostdb_data:
|
||||
|
||||
133
docs/01_journal_de_bord.md
Normal file
133
docs/01_journal_de_bord.md
Normal 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 — 18–19 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 (janvier–aoû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.
|
||||
34
docs/02_planning_previsionnel.md
Normal file
34
docs/02_planning_previsionnel.md
Normal 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).
|
||||
185
docs/03_cahier_des_charges.md
Normal file
185
docs/03_cahier_des_charges.md
Normal 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)]
|
||||
86
docs/04_annexe_A1_qualite_code.md
Normal file
86
docs/04_annexe_A1_qualite_code.md
Normal 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
|
||||
8–15 % 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).
|
||||
27
docs/05_annexe_A2_estimation_charge.md
Normal file
27
docs/05_annexe_A2_estimation_charge.md
Normal 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 | 2–5 | 1 (serveur Flask dev) | 512 Mo | 1 |
|
||||
| Usage promo ydays | 10–30 | 4 (formule 2×2+1≈5, arrondi à 4 threads×2) | 1 Go | 2 |
|
||||
| Déploiement département | 50–100 | 9 (2×4+1) | 2 Go | 4 |
|
||||
| Pic événementiel | 100–200 | 9–17 selon CPU disponible | 4 Go | 4–8 |
|
||||
|
||||
## 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
66
docs/06_partie3_devops.md
Normal 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.
|
||||
110
docs/07_partie4_plan_de_tests.md
Normal file
110
docs/07_partie4_plan_de_tests.md
Normal 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` |
|
||||
75
docs/08_partie5_retrodocumentation.md
Normal file
75
docs/08_partie5_retrodocumentation.md
Normal 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
15
docs/09_note_rgpd.md
Normal 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
34
docs/10_captures_ecran.md
Normal 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]
|
||||
70
docs/11_table_correspondance.md
Normal file
70
docs/11_table_correspondance.md
Normal 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.
|
||||
BIN
docs/Dossier_technique_GestHub_v2.docx
Normal file
BIN
docs/Dossier_technique_GestHub_v2.docx
Normal file
Binary file not shown.
BIN
docs/Dossier_technique_GestHub_v2.pdf
Normal file
BIN
docs/Dossier_technique_GestHub_v2.pdf
Normal file
Binary file not shown.
486
docs/build_docx.js
Normal file
486
docs/build_docx.js
Normal 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é");
|
||||
});
|
||||
30
docs/evidence/coverage_output.txt
Normal file
30
docs/evidence/coverage_output.txt
Normal 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
|
||||
1
docs/evidence/flake8_output.txt
Normal file
1
docs/evidence/flake8_output.txt
Normal file
@@ -0,0 +1 @@
|
||||
flake8: 0 lignes (0 = aucune violation)
|
||||
43
docs/evidence/pytest_output.txt
Normal file
43
docs/evidence/pytest_output.txt
Normal 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 ==============================
|
||||
113
docs/evidence/radon_cc_output.txt
Normal file
113
docs/evidence/radon_cc_output.txt
Normal 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)
|
||||
264
docs/evidence/radon_raw_output.txt
Normal file
264
docs/evidence/radon_raw_output.txt
Normal 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%
|
||||
BIN
docs/evidence/schema_couches_logiques.png
Normal file
BIN
docs/evidence/schema_couches_logiques.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
docs/evidence/schema_infrastructure.png
Normal file
BIN
docs/evidence/schema_infrastructure.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/evidence/screenshot_404.png
Normal file
BIN
docs/evidence/screenshot_404.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/evidence/screenshot_dashboard_admin.png
Normal file
BIN
docs/evidence/screenshot_dashboard_admin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/evidence/screenshot_dashboard_membre.png
Normal file
BIN
docs/evidence/screenshot_dashboard_membre.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
102
docs/make_diagrams.py
Normal file
102
docs/make_diagrams.py
Normal 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
12
docs/package.json
Normal 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
30
scripts/backup.sh
Normal 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
22
web/.env.example
Normal 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
|
||||
@@ -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"]
|
||||
|
||||
197
web/app.py
197
web/app.py
@@ -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']
|
||||
def _wants_json():
|
||||
from flask import request
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({"status": "saved"})
|
||||
return request.path.startswith("/api/")
|
||||
|
||||
@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
|
||||
|
||||
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())
|
||||
app = create_app()
|
||||
|
||||
@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
|
||||
|
||||
# --- 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
69
web/config.py
Normal 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
0
web/cookie.txt
Normal file
93
web/db.py
Normal file
93
web/db.py
Normal 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
101
web/init.sql
Normal 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 ;
|
||||
-- -----------------------------------------------------------------------------
|
||||
@@ -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():
|
||||
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:
|
||||
db.session.add(block)
|
||||
for block in INITIAL_BLOCKS:
|
||||
block_model.insert(db, block["type"], block["column"], block["data"])
|
||||
print("Terminé.")
|
||||
|
||||
db.session.commit()
|
||||
print("Terminé ! Tes widgets sont en base de données.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_data()
|
||||
main()
|
||||
|
||||
13
web/models/__init__.py
Normal file
13
web/models/__init__.py
Normal 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)
|
||||
"""
|
||||
57
web/models/annonce_model.py
Normal file
57
web/models/annonce_model.py
Normal 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
17
web/models/audit_model.py
Normal 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
59
web/models/block_model.py
Normal 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
|
||||
32
web/models/evenement_model.py
Normal file
32
web/models/evenement_model.py
Normal 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
|
||||
32
web/models/fichier_model.py
Normal file
32
web/models/fichier_model.py
Normal 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
54
web/models/rgpd_model.py
Normal 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
15
web/requirements.txt
Normal 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
6
web/routes/__init__.py
Normal 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
62
web/routes/annonces.py
Normal 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
86
web/routes/auth.py
Normal 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
55
web/routes/dashboard.py
Normal 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
44
web/routes/evenements.py
Normal 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
59
web/routes/fichiers.py
Normal 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
46
web/routes/rgpd.py
Normal 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
1
web/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Couche services/ — logique métier. Les routes n'appellent jamais les models/ directement."""
|
||||
55
web/services/annonce_service.py
Normal file
55
web/services/annonce_service.py
Normal 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
|
||||
11
web/services/audit_service.py
Normal file
11
web/services/audit_service.py
Normal 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)
|
||||
83
web/services/auth_service.py
Normal file
83
web/services/auth_service.py
Normal 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
|
||||
25
web/services/block_service.py
Normal file
25
web/services/block_service.py
Normal 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)
|
||||
42
web/services/evenement_service.py
Normal file
42
web/services/evenement_service.py
Normal 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
|
||||
160
web/services/fichier_service.py
Normal file
160
web/services/fichier_service.py
Normal 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)
|
||||
69
web/services/rgpd_service.py
Normal file
69
web/services/rgpd_service.py
Normal 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}
|
||||
15
web/templates/errors/403.html
Normal file
15
web/templates/errors/403.html
Normal 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>
|
||||
15
web/templates/errors/404.html
Normal file
15
web/templates/errors/404.html
Normal 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>
|
||||
15
web/templates/errors/500.html
Normal file
15
web/templates/errors/500.html
Normal 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>
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
<main>
|
||||
<aside class="left-column" id="left">
|
||||
<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>
|
||||
|
||||
<main role="main">
|
||||
<aside class="left-column" id="left" aria-label="Colonne gauche du tableau de bord">
|
||||
</aside>
|
||||
|
||||
<section class="center-column" id="center">
|
||||
<h1 class="main-title">Gesthub</h1>
|
||||
<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">
|
||||
<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,6 +76,8 @@
|
||||
.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>
|
||||
|
||||
0
web/tests/__init__.py
Normal file
0
web/tests/__init__.py
Normal file
88
web/tests/conftest.py
Normal file
88
web/tests/conftest.py
Normal 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
|
||||
73
web/tests/test_integration_routes.py
Normal file
73
web/tests/test_integration_routes.py
Normal 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
97
web/tests/test_rgpd.py
Normal 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
|
||||
89
web/tests/test_security.py
Normal file
89
web/tests/test_security.py
Normal 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 "<script>" 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 !"
|
||||
41
web/tests/test_unit_annonce_service.py
Normal file
41
web/tests/test_unit_annonce_service.py
Normal 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")
|
||||
22
web/tests/test_unit_auth_service.py
Normal file
22
web/tests/test_unit_auth_service.py
Normal 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
|
||||
56
web/tests/test_unit_fichier_service.py
Normal file
56
web/tests/test_unit_fichier_service.py
Normal 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
|
||||
Reference in New Issue
Block a user