fix #29 : envoyer le lien magique par email (envoyer_mail_smtp)
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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(...) */
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user