fix #29 : envoyer le lien magique par email (envoyer_mail_smtp)

This commit is contained in:
Cedric Abonnel
2026-05-13 23:41:58 +02:00
commit 8a85c15372
129 changed files with 22818 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
logique complète en PHP framework maison), avec un système de routing clair, base postgres et extensible pour gérer :
* les posts (CRUD + publication + masquage (au lieu de suppression)),
* les commentaires (publier, masquer, privé),
* les pièces jointes (upload, masquage (au lieu de supprimer), inutile de réuploadé si le fichier à déjà été poussé dans un autre poste par exemple.).
architecture MVC, avec un routeur maison et des contrôleurs structurés.
Tout sera modulaire et facile à maintenir.
---
## 🏗️ Structure du projet
```
project/
├─ public/
│ ├─ index.php # Point d'entrée (router)
│ └─ uploads/ # Dossier des fichiers uploadés
├─ app/
│ ├─ Core/
│ │ ├─ Router.php # Routeur maison
| │ ├─ Model.php
| │ ├─ View.php
│ │ └─ Controller.php # Classe de base pour les contrôleurs
│ │
│ ├─ Controllers/
│ │ ├─ PostController.php
│ │ ├─ CommentController.php
│ │ └─ AttachmentController.php
│ │
│ ├─ Models/
│ │ ├─ Post.php
│ │ ├─ Comment.php
│ │ └─ Attachment.php
│ │
│ ├── Views/
│ │ ├── posts/
│ │ │ ├── index.php
│ │ │ ├── show.php
│ │ │ └── form.php
│ │ ├── comments/
│ │ └── attachments/
│ │
│ └─ config.php # Configuration (DB, etc.)
└─ composer.json
```
+98
View File
@@ -0,0 +1,98 @@
# Authentification — Lien magique
Mécanisme d'authentification par email sans mot de passe. L'utilisateur reçoit un lien à usage unique, valide un temps limité, qui ouvre une session PHP.
## Fichiers concernés
| Fichier | Rôle |
|---|---|
| `public/login/index.php` | Formulaire de demande + génération du token |
| `public/login/magic.php` | Consommation du token + ouverture de session |
| Table BDD `auth_magic_links` | Persistance des tokens |
## Configuration (`.env`)
| Variable | Défaut | Description |
|---|---|---|
| `MAGIC_LINK_TTL_MINUTES` | `30` | Durée de validité du lien |
| `MAGIC_COOLDOWN_MINUTES` | `5` | Délai minimal entre deux demandes pour le même email |
| `MAGIC_WINDOW_HOURS` | `12` | Fenêtre glissante pour le plafond |
| `MAGIC_MAX_PER_WINDOW` | `5` | Nombre maximal de liens émis par fenêtre |
## Schéma BDD — `auth_magic_links`
```sql
id UUID PRIMARY KEY (gen_random_uuid())
email TEXT NOT NULL
token TEXT NOT NULL UNIQUE
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
expires_at TIMESTAMPTZ NOT NULL
consumed_at TIMESTAMPTZ NULL -- NULL = non consommé
ip TEXT
user_agent TEXT
return_to TEXT NULL -- chemin relatif de redirection post-login
```
## Phase 1 — Demande du lien (`index.php`, POST)
```
[Formulaire email] → POST /login
├─ Validation CSRF
├─ Validation syntaxe email
├─ Purge BDD : DELETE liens expirés ou consommés pour cet email
├─ Cooldown : un lien < MAGIC_COOLDOWN_MINUTES → refus
├─ Plafond : >= MAGIC_MAX_PER_WINDOW liens dans MAGIC_WINDOW_HOURS → refus
├─ Génération token : random_bytes(32) → base64url (43 chars, URL-safe)
├─ INSERT auth_magic_links (expires_at = NOW() + TTL)
└─ [stub] Envoi email : /login/magic.php?token=<token>
```
Le token est un base64url sans padding (`+/` remplacés par `-_`, `=` supprimés) — il ne contient que des caractères `[A-Za-z0-9\-_]`.
L'envoi de l'email est actuellement un **stub** : le code construit `$magicUrl` mais l'appel SMTP n'est pas implémenté (`index.php:142`).
## Phase 2 — Consommation du lien (`magic.php`, GET)
```
[Clic sur le lien] → GET /login/magic.php?token=<token>
├─ Validation format token (regex [A-Za-z0-9\-\_])
├─ BEGIN TRANSACTION
│ ├─ SELECT ... FOR UPDATE (verrou anti double-consommation)
│ ├─ Token inconnu → 400
│ ├─ consumed_at non null → "Lien déjà utilisé"
│ ├─ expires_at dépassé → "Lien expiré"
│ ├─ UPDATE consumed_at = NOW()
│ └─ COMMIT
├─ session_start() + session_regenerate_id(true)
├─ $_SESSION['user_email'] = email
└─ Redirection 303 → return_to (validé : doit commencer par /)
```
Le `FOR UPDATE` garantit qu'un token ne peut pas être consommé deux fois en cas de double-clic ou de requêtes concurrentes.
## Sécurités notables
- **CSRF** sur le formulaire de demande
- **Rate limiting** double (cooldown + plafond glissant) par email
- **Token URL-safe** généré par CSPRNG (`random_bytes`)
- **Verrou transactionnel** (`FOR UPDATE`) à la consommation
- **`session_regenerate_id(true)`** — prévient la fixation de session
- **`return_to` filtré** : seuls les chemins relatifs (commençant par `/`) sont acceptés
## État actuel — ce qui reste à câbler
L'envoi SMTP n'est pas implémenté. Le token est correctement généré et persisté en BDD, mais le mail n'est pas envoyé. Voir `public/login/index.php:142` :
```php
$magicUrl = url('/login/magic.php') . '?token=' . urlencode($token);
/* envoyer_mail_smtp(...) ou mail(...) */
```
+184
View File
@@ -0,0 +1,184 @@
# Architecture du cache — Moteur Folio
## Contexte et problème initial
Le moteur stocke chaque article dans un sous-répertoire `data/{uuid}/` contenant deux fichiers :
- `meta.json` — métadonnées (titre, slug, catégorie, cover, dates…)
- `index.md` — contenu Markdown
Avec 1 000+ articles, chaque vue de page déclenchait **3 appels à `getAll()`** (via `getBySlug()`, `getCategories()` et directement pour les articles liés), ce qui représentait ~6 000 lectures de fichiers par requête. La page mettait **+5 secondes** à charger.
---
## Les quatre niveaux de cache
### 1. Cache mémoire de requête — `$allCache` et `$searchIndexCache`
**Scope** : durée de vie d'une requête PHP (in-process).
`ArticleManager` mémoïse deux tableaux en propriétés privées :
- `$allCache` — résultat de `loadAll()` (tous les articles avec contenu)
- `$searchIndexCache` — contenu de `search_index.json`
```php
// Premier appel : scan disque + construction du tableau
$this->allCache = $this->loadAll();
// Appels suivants dans la même requête : tableau déjà en mémoire
return $this->allCache;
```
**Invalidation** : `writeMeta()` et `delete()` mettent les deux propriétés à `null`.
---
### 2. Cache disque par article — `_cache/articles/{uuid}.json`
**Scope** : persistant entre les requêtes, jusqu'à modification de l'article.
`loadArticle()` vérifie si le cache est plus récent que `meta.json` avant de lire les sources :
```
_cache/articles/{uuid}.json <-- filemtime >= meta.json ? → utiliser le cache
→ lire meta.json + index.md, écrire le cache
```
Le fichier cache contient toutes les données de l'article (métadonnées + contenu), ce qui réduit les lectures de **2 fichiers à 1** par article chargé.
**Invalidation** : `writeMeta()` supprime `_cache/articles/{uuid}.json` avant d'écrire le nouveau `meta.json`. `delete()` le supprime aussi.
---
### 3. Index slug → UUID — `_cache/slug_index.json`
**Scope** : persistant, mis à jour incrémentalement.
Permet à `getBySlug()` de trouver un article en **O(1)** (lecture de l'index + lecture du cache article) au lieu de parcourir tous les articles.
```
slug_index.json : {"mon-article": "uuid-xxxx", "autre-article": "uuid-yyyy", ...}
```
**Construction** : à la première utilisation, `buildSlugIndex()` lit le `search_index.json` (un seul fichier) pour construire la correspondance. Si le search_index n'existe pas encore, il tombe en repli sur `loadAll()`.
**Invalidation** : `writeMeta()` supprime le fichier (reconstruction automatique à la prochaine requête). `delete()` fait de même.
> Suppression plutôt que mise à jour incrémentale : la reconstruction depuis `search_index.json` est quasi instantanée (lecture d'un seul fichier JSON), donc il n'y a pas d'intérêt à maintenir des mises à jour partielles.
---
### 4. Index de recherche — `search_index.json`
**Scope** : persistant, reconstruit après chaque modification d'article.
Fichier JSON plat contenant un tableau de tous les articles avec leurs champs essentiels et leur texte brut pré-calculé (`plain`, sans syntaxe Markdown). Utilisé pour :
- La recherche plein-texte (`SearchEngine`)
- La liste des articles publiés pour les articles liés/similaires (évite `getAll()`)
- Les catégories (`getCategories()`)
- La construction du slug index
**Champs stockés** :
```json
{
"uuid": "...",
"slug": "...",
"title": "...",
"category": "...",
"author": "...",
"cover": "...",
"published": true,
"published_at": "2026-01-15 10:00:00",
"created_at": "2026-01-14 09:30:00",
"updated_at": "2026-01-15 10:00:00",
"plain": "texte brut de l'article sans markdown..."
}
```
**Rebuild automatique** : si le fichier ne contient pas le champ `cover` (format antérieur à la v2 du cache), `getSearchIndex()` déclenche automatiquement un rebuild.
**Invalidation** : `rebuildSearchIndex()` (appelé par `create()`, `update()`, `delete()`).
---
## Chemin d'une requête de vue d'article après optimisation
```
GET /post/{slug}
├── getBySlug(slug)
│ ├── Lire slug_index.json [1 lecture]
│ ├── → UUID trouvé
│ └── getByUuid(uuid)
│ └── Lire _cache/articles/{uuid}.json [1 lecture]
├── getCategories()
│ └── getSearchIndex() [lecture de search_index.json, mise en cache mémoire]
├── $_allPublished (articles liés + similaires)
│ └── getSearchIndex() [déjà en cache mémoire → 0 lecture]
├── scorePool(mots_du_titre, $_allPublished)
│ └── Tokenisation unique par article, pas de re-calcul par mot
└── getBacklinks(slug)
└── Lire _cache/backlinks.json [1 lecture]
Total : ~4 lectures de fichiers, indépendamment du nombre total d'articles.
```
Avant optimisation (1 062 articles) : ~6 300 lectures de fichiers.
---
## Performances mesurées
| Scénario | Avant | Après |
|---|---|---|
| Cold cache (aucun cache disque) | +5 s | ~0,6 s |
| Warm cache (cache disque présent) | +5 s | ~0,4 s |
---
## Scalabilité
| Volume d'articles | Lectures de fichiers par vue (après) |
|---|---|
| 1 000 | ~4 |
| 100 000 | ~4 |
| 500 000 | ~4 |
Le nombre de lectures est **constant** : le chemin de vue ne dépend plus du nombre total d'articles, seulement de la présence des fichiers de cache.
La seule opération encore en O(N) est `rebuildSearchIndex()`, mais elle n'est déclenchée que sur écriture (création, modification, suppression d'article), jamais sur lecture.
---
## Invalidation — résumé
| Événement | Caches invalidés |
|---|---|
| `writeMeta()` (toute écriture d'article) | `$allCache`, `$searchIndexCache`, cache article (`{uuid}.json`), slug index |
| `delete()` | idem + suppression physique du cache article |
| `rebuildSearchIndex()` | `$searchIndexCache` (remplacé par les nouvelles données) |
---
## Maintenance
### Vider manuellement les caches disque
En cas de besoin (migration, incohérence) :
```bash
ssh varlog "sudo rm -rf /var/www/lan.acegrp.varlog/data/_cache/articles/"
ssh varlog "sudo rm /var/www/lan.acegrp.varlog/data/_cache/slug_index.json"
```
Les caches se reconstruisent automatiquement à la première requête suivante.
### Forcer un rebuild du search_index
Modifier et sauvegarder n'importe quel article depuis l'interface admin déclenche un rebuild complet. Il n'existe pas de commande CLI dédiée pour l'instant.
+39
View File
@@ -0,0 +1,39 @@
# Notes de développement
## Structure du projet (serveur)
```
/var/www/lan.acegrp.varlog/
├── public/
│ ├── index.php # Point d'entrée
│ ├── route.php # Routeur (actions GET/POST)
│ └── assets/ # CSS, JS, uploads
├── templates/ # Vues PHP (incluses via extract() + include)
│ ├── layout.php
│ ├── post_form.php
│ └── post_view.php
├── src/
│ ├── db.php # Connexion PDO PostgreSQL
│ ├── PostManager.php
│ └── FileManager.php
├── config/
│ └── config.php # Charge .env, définit les constantes DB
└── docs/
```
## Conventions templates
Les templates reçoivent leurs variables via `extract()` depuis `route.php`. Toute variable optionnelle (non transmise dans tous les contextes) doit utiliser `??` pour éviter un `Undefined variable` warning :
```php
// Bon
$dateValue = $published_at ?? date('Y-m-d\TH:i');
<?= ($published ?? false) ? 'checked' : '' ?>
// À éviter
<?= $published ? 'checked' : '' ?> // Warning si create (pas d'édition)
```
## Permissions serveur
PHP-FPM tourne en `www-data`. Les fichiers sensibles (`.env`) appartiennent à `cedrix:www-data 640`. Voir `PROJET.md` § Permissions serveur.