fix #29 : envoyer le lien magique par email (envoyer_mail_smtp)
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
# Credentials
|
||||
.env
|
||||
|
||||
# Composer dependencies
|
||||
vendor/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Fichiers uploadés et cache générés (propriété www-data)
|
||||
data/*/files/
|
||||
data/_cache/
|
||||
_cache/
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
$finder = PhpCsFixer\Finder::create()->in(__DIR__);
|
||||
return (new PhpCsFixer\Config())
|
||||
->setRiskyAllowed(true)
|
||||
->setRules([
|
||||
'@PSR12' => true,
|
||||
'strict_param' => true,
|
||||
'declare_strict_types' => true,
|
||||
'no_unused_imports' => true,
|
||||
'single_quote' => true,
|
||||
])
|
||||
->setFinder($finder);
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
# Changelog — varlog
|
||||
|
||||
## [Unreleased] — 2026-05-13
|
||||
|
||||
### Performances
|
||||
|
||||
- **Cache multi-niveaux pour les vues d'articles** : temps de chargement réduit
|
||||
de +5 s à ~0,4 s sur 1 062 articles.
|
||||
- Mémoïsation de `getAll()` et `getSearchIndex()` dans la requête PHP
|
||||
(`$allCache`, `$searchIndexCache`) — évite les appels répétés.
|
||||
- Cache disque par article (`_cache/articles/{uuid}.json`) avec invalidation
|
||||
par comparaison `mtime` — 1 lecture au lieu de 2 par article.
|
||||
- Slug index (`_cache/slug_index.json`) : `getBySlug()` en O(1) sans scanner
|
||||
tous les articles ; construit depuis `search_index.json` en un seul fichier.
|
||||
- `getCategories()` et `$_allPublished` chargés depuis `search_index.json`
|
||||
au lieu de `getAll()` — 1 fichier lu quelle que soit la taille du catalogue.
|
||||
- `search_index.json` enrichi avec `cover`, `created_at`, `author` ; rebuild
|
||||
automatique si le format est obsolète.
|
||||
- `SearchEngine::scorePool()` : tokenise chaque article une seule fois pour
|
||||
N mots de titre (vs N passes séparées qui retokenisaient chaque article
|
||||
N fois et calculaient la similarité trigramme sur le contenu).
|
||||
- Le nombre de lectures de fichiers par vue d'article est désormais constant
|
||||
(~4), indépendamment du nombre total d'articles.
|
||||
- Documentation : `docs/cache-architecture.md`.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Upload de fichiers (#48)** : les fichiers > 8 Mo étaient rejetés silencieusement.
|
||||
Le serveur utilise `mod_php` (non PHP-FPM) ; les limites ont été corrigées dans
|
||||
`/etc/php/8.3/apache2/php.ini` : `upload_max_filesize = 500M`, `post_max_size = 2048M`.
|
||||
Le handler `add_files` détecte désormais le dépassement et affiche un message
|
||||
d'erreur explicite au lieu de rediriger sans rien faire.
|
||||
|
||||
### Fonctionnalités
|
||||
|
||||
- **Réactions visiteurs** : trois boutons (👍 Utile / 🔥 Important / 🤔 À creuser)
|
||||
affichés sous chaque article. Toggle : recliquer retire la réaction. Accessible sans
|
||||
compte via un cookie UUID (`vl_vid`, 1 an, `HttpOnly`). Comportement async fetch avec
|
||||
fallback formulaire natif (compatible CSP `script-src 'self'`). Routes :
|
||||
`POST /react`. Table BDD : `article_reactions`.
|
||||
|
||||
- **Commentaires avec vérification email** : formulaire nom + email (non publié) +
|
||||
texte (2 000 caractères max). Protection honeypot + CSRF en session. Un code à
|
||||
6 chiffres est envoyé par email (expire 24 h) ; le commentaire est auto-publié au clic
|
||||
sur le lien de confirmation. Routes : `POST /comment`,
|
||||
`GET /verify-comment/<6chiffres>`. Table BDD : `comments`.
|
||||
|
||||
- **Modération commentaires** : onglet **Commentaires** dans `/admin/comments` listant
|
||||
tous les commentaires avec statut (vérifié / publié) et actions masquer/republier.
|
||||
Route : `POST /comment-moderate`.
|
||||
|
||||
- **Page de confirmation à l'enregistrement** : cliquer sur "Enregistrer" affiche une
|
||||
page intermédiaire avec le diff du contenu, le slug (déplacé ici depuis le formulaire,
|
||||
avec suggestion auto si le titre a changé), un commentaire de révision pré-rempli
|
||||
d'après les modifications détectées, et un aperçu SEO (snippet Google). La
|
||||
sauvegarde effective n'a lieu qu'après confirmation.
|
||||
|
||||
- **URLs propres** : toutes les routes internes migrent vers des chemins lisibles.
|
||||
Les anciennes URLs `/?action=…` restent fonctionnelles (compatibilité).
|
||||
| Ancienne URL | Nouvelle URL |
|
||||
|---|---|
|
||||
| `/?action=edit&uuid=<u>` | `/edit/<u>` |
|
||||
| `/?action=sources&uuid=<u>` | `/sources/<u>` |
|
||||
| `/?action=diff&uuid=<u>&rev=<n>` | `/diff/<u>/<n>` |
|
||||
| `/?action=create` | `/new` |
|
||||
| `/?action=admin[&tab=<t>]` | `/admin[/<t>]` |
|
||||
| `/?action=categories` | `/categories` |
|
||||
| `/?action=profile` | `/profile` |
|
||||
| `/?action=about\|legal\|licenses\|contact` | `/about`, `/legal`… |
|
||||
| `/?action=regen_thumbs` | `/admin/regen-thumbs` |
|
||||
| `/?action=add_files&uuid=<u>` | `/files/<u>/add` |
|
||||
| `/?action=import_image&uuid=<u>` | `/import/<u>` |
|
||||
| `/?cat=<cat>` | `/categorie/<cat>` |
|
||||
| `/?cursor=<uuid>` | `/page/<uuid>` |
|
||||
|
||||
- **Moteur de recherche** : index trigram+substring pré-construit (`search_index.json`,
|
||||
reconstruit à chaque écriture), accessible depuis la navbar.
|
||||
|
||||
### Corrections
|
||||
|
||||
- **Métadonnées fichiers (sources)** : `addFileMeta()` ne sauvegardait pas l'auteur et
|
||||
l'URL source en raison d'un guard `file_exists()` trop strict — supprimé.
|
||||
- **Authentification OIDC** (`State invalide.`) : `session_start()` était appelé avant
|
||||
`bootstrap.php` dans les fichiers OIDC, écrasant les paramètres de cookie
|
||||
(`SameSite=Lax`, `Secure`, `HttpOnly`) — corrigé dans `start.php`, `callback.php`
|
||||
et `me.php`.
|
||||
- **Sidebar droite de l'article** : classe Bootstrap `flex-nowrap-lg` inexistante,
|
||||
remplacée par `flex-lg-nowrap` — la sidebar ne tombe plus en bas de page.
|
||||
- **Date d'affichage en liste** : `created_at` affiché à la place de `published_at`
|
||||
— corrigé avec fallback approprié.
|
||||
- **Formulaire d'édition** : "Fichiers existants" déplacé dans la colonne de droite ;
|
||||
attribution auteur/source étendue à tous les types de fichiers (pas seulement images).
|
||||
- **Historique des révisions** : plus de révision créée si le contenu et le titre
|
||||
sont inchangés. Ajout des boutons de suppression par révision et suppression globale.
|
||||
- **Canonical URL catégorie** : passe de `/?cat=…` à `/categorie/…`.
|
||||
- **Flux RSS** : `/rss` et `/rss.xml` redirigent en 301 vers `/feed` (URL
|
||||
canonique) ; les articles des catégories privées sont exclus du flux ;
|
||||
la description est convertie depuis Markdown en texte brut.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-09
|
||||
|
||||
### Fonctionnalités
|
||||
|
||||
- **SEO** : balises canonical, `sitemap.xml`, `robots.txt`, JSON-LD (`BlogPosting` /
|
||||
`WebSite`), `noindex` sur les pages d'administration.
|
||||
- **Recherche** : page de résultats avec score de pertinence, mise en évidence des
|
||||
termes, lien vers la catégorie depuis les résultats.
|
||||
- **Support HEIC/HEIF** : conversion automatique en JPEG à l'upload.
|
||||
- **Support SVG** : upload autorisé, servi avec Content-Type correct.
|
||||
- **Avant-première** : article visible en liste mais verrouillé avant sa date de
|
||||
publication.
|
||||
- **Pagination curseur** : navigation par UUID de dernier article vu, sans offset SQL.
|
||||
- **Layout article 3 colonnes** : sidebar gauche (catégorie), contenu central,
|
||||
sidebar droite (pièces jointes, liens externes, articles liés).
|
||||
- **Import depuis URL** : téléchargement de fichiers distants avec extraction
|
||||
automatique des métadonnées (EXIF, OpenGraph, PDF).
|
||||
- **Gestion des pièces jointes** dans le formulaire d'édition, avec attribution
|
||||
auteur/source affichée dans la vue article.
|
||||
|
||||
### Corrections
|
||||
|
||||
- Login intégré dans `layout.php`, chemins CSS en absolu.
|
||||
- Redéclaration de `url()` dans `config.php` — fatal error corrigée.
|
||||
- Correction permissions `www-data` sur `data/`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04 et antérieur
|
||||
|
||||
- Flux RSS paginé (`/feed`, `/rss`, `/rss.xml`) avec autodiscovery.
|
||||
- Stockage des articles en fichiers Markdown (migration depuis base de données).
|
||||
- SSO via Keycloak/OIDC avec PKCE.
|
||||
- Images de couverture (liste, vue article, `og:image`).
|
||||
- Brouillons visibles uniquement par l'auteur.
|
||||
- Formulaire de contact (CSRF, honeypot, rate-limit).
|
||||
- Pages : mentions légales (LCEN/RGPD), licences, à propos.
|
||||
- Auto-hébergement Bootstrap 5, police Inter, favicon SVG.
|
||||
- Headers HTTP de sécurité, CSP stricte.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024–2026 Cédric Abonnel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!defined('BASE_PATH')) {
|
||||
define('BASE_PATH', __DIR__);
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'secure' => $isHttps,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "varlog/folio",
|
||||
"description": "Folio — moteur de blog PHP minimaliste",
|
||||
"type": "project",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"ext-pdo": "*",
|
||||
"php": ">=8.2",
|
||||
"vlucas/phpdotenv": "^5.6",
|
||||
"phpmailer/phpmailer": "^6.11",
|
||||
"jumbojett/openid-connect-php": "^1.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.11",
|
||||
"friendsofphp/php-cs-fixer": "^3.64"
|
||||
},
|
||||
"scripts": {
|
||||
"fix": "php-cs-fixer fix --config=.php-cs-fixer.dist.php",
|
||||
"stan": "phpstan analyse"
|
||||
}
|
||||
}
|
||||
Generated
+3304
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
// config/config.php
|
||||
|
||||
require_once BASE_PATH . '/vendor/autoload.php';
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
|
||||
// Load .env file
|
||||
$dotenv = Dotenv::createImmutable(BASE_PATH);
|
||||
$dotenv->load();
|
||||
|
||||
if (!$_ENV['APP_URL']) {
|
||||
http_response_code(500);
|
||||
echo 'Configuration manquante : définis APP_URL ou APP_URL dans le .env';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Normalise: toujours un trailing slash unique
|
||||
define('APP_URL', rtrim($_ENV['APP_URL'], '/') . '/');
|
||||
|
||||
// (Optionnel) Expose dans $_ENV si besoin
|
||||
$_ENV['APP_URL'] = APP_URL;
|
||||
|
||||
/**
|
||||
* Helper pour construire des liens absolus propres.
|
||||
* url('ressources/user/login.php')
|
||||
* url('api/items', ['page'=>2])
|
||||
*/
|
||||
if (!function_exists('url')) {
|
||||
function url(string $path = '', array $qs = []): string
|
||||
{
|
||||
$u = APP_URL . ltrim($path, '/');
|
||||
if ($qs) {
|
||||
$u .= (str_contains($u, '?') ? '&' : '?') . http_build_query($qs);
|
||||
}
|
||||
return $u;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"uuid": "a3d8f2c1-7b4e-4f9a-8c3d-2e5a9b6f1d4c",
|
||||
"slug": "about",
|
||||
"title": "À propos",
|
||||
"author": "cedric@abonnel.fr",
|
||||
"published": true,
|
||||
"published_at": "2021-01-16 04:02:40",
|
||||
"created_at": "2021-01-16 04:02:40",
|
||||
"updated_at": "2026-05-13 00:00:00",
|
||||
"revisions": [],
|
||||
"cover": "",
|
||||
"files_meta": [],
|
||||
"external_links": [],
|
||||
"seo_title": "",
|
||||
"seo_description": "",
|
||||
"og_image": "",
|
||||
"category": ""
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# À propos
|
||||
|
||||
Qui se cache derrière varlog ?
|
||||
|
||||
Je m'appelle **Cédric**. Passionné d'informatique depuis longtemps, je gère un **HomeLab** à la maison — un petit laboratoire personnel où je fais tourner des serveurs, expérimente des configs réseau et casse des choses pour mieux les comprendre.
|
||||
|
||||
varlog est mon carnet de bord technique. J'y documente ce que je fais, ce que j'apprends, et parfois ce qui tourne mal — les incidents sont souvent les meilleures leçons.
|
||||
|
||||
Le blog a été lancé publiquement aux **JDLL 2025** (Journées Du Logiciel Libre), à Lyon.
|
||||
|
||||
## Ce dont je parle ici
|
||||
|
||||
### HomeLab & infrastructure
|
||||
|
||||
Proxmox, virtualisation, domotique (Zigbee, MQTT, Home Assistant), supervision avec Uptime Kuma, auto-hébergement de services (Gitea, Keycloak…), incidents réseau et leurs post-mortems.
|
||||
|
||||
### Réseaux & télécom
|
||||
|
||||
Passionné par les réseaux mobiles (3G/4G/5G/6G), la fibre optique (50G-PON), les stratégies des opérateurs et les infrastructures qui font fonctionner tout ça sans qu'on y pense.
|
||||
|
||||
### Linux & développement
|
||||
|
||||
Debian au quotidien, scripts, administration système, et un peu de PHP — dont ce blog lui-même, développé maison sous le nom de code *Folio*.
|
||||
|
||||
### Numérique & société
|
||||
|
||||
Souveraineté numérique, données personnelles, IA et plateformes qui monétisent nos contenus — des sujets qui m'intéressent autant qu'ils m'inquiètent.
|
||||
|
||||
### Le reste
|
||||
|
||||
Bricolage, travaux, anecdotes techniques, lectures, liseuses Kobo, et quelques billets qui n'entrent dans aucune case. La vie ne se range pas en catégories.
|
||||
|
||||
## Contact
|
||||
|
||||
Vous pouvez me joindre via le [formulaire de contact](/contact). Je lis tous les messages, même si je ne réponds pas toujours vite.
|
||||
|
||||
---
|
||||
|
||||
Le contenu de ce blog est publié sous licence [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) sauf mention contraire. Le moteur *Folio* est distribué sous [licence MIT](/LICENSE).
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"uuid": "b2c7e1f4-4a3d-4e8b-9f2a-1d6c8e3f5a7b",
|
||||
"slug": "legal",
|
||||
"title": "Mentions légales",
|
||||
"author": "cedric@abonnel.fr",
|
||||
"published": true,
|
||||
"published_at": "2021-01-16 04:02:40",
|
||||
"created_at": "2021-01-16 04:02:40",
|
||||
"updated_at": "2026-05-13 00:00:00",
|
||||
"revisions": [],
|
||||
"cover": "",
|
||||
"files_meta": [],
|
||||
"external_links": [],
|
||||
"seo_title": "",
|
||||
"seo_description": "",
|
||||
"og_image": "",
|
||||
"category": ""
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Mentions légales
|
||||
|
||||
Conformément à la loi n° 2004-575 du 21 juin 2004 pour la confiance dans l'économie numérique (LCEN).
|
||||
|
||||
## Éditeur du site
|
||||
|
||||
**Responsable de publication :** Cédric Abonnel
|
||||
**Qualité :** Particulier — site personnel non commercial
|
||||
**Contact :** [formulaire de contact](/contact)
|
||||
|
||||
## Hébergement
|
||||
|
||||
**Type :** Auto-hébergement sur infrastructure personnelle (HomeLab)
|
||||
**Exploitant :** Cédric Abonnel
|
||||
**Fournisseur d'accès à internet :** Infrastructure personnelle auto-hébergée
|
||||
|
||||
## Propriété intellectuelle
|
||||
|
||||
Le **contenu éditorial** de ce site (articles, textes, images produites par l'auteur) est publié sous licence [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/), sauf mention contraire.
|
||||
|
||||
Le **moteur du site** (*Folio*) est un logiciel libre distribué sous [licence MIT](/LICENSE).
|
||||
|
||||
Les composants tiers (Bootstrap, PHPMailer, police Inter…) sont soumis à leurs licences respectives, détaillées sur la [page des licences](/licenses).
|
||||
|
||||
## Données personnelles (RGPD)
|
||||
|
||||
Ce site est un blog personnel **sans publicité, sans pistage, sans système de commentaires** ni inscription publique.
|
||||
|
||||
Les seules données traitées automatiquement sont les **journaux de connexion du serveur web** (adresse IP, horodatage, page demandée), conservés conformément aux obligations légales (article L34-1 du Code des postes et des communications électroniques — durée maximale : 1 an).
|
||||
|
||||
Ces données ne sont ni vendues, ni transmises à des tiers, ni utilisées à des fins commerciales.
|
||||
|
||||
Conformément au RGPD (règlement UE 2016/679), vous disposez d'un droit d'accès, de rectification et de suppression des données vous concernant. Pour exercer ces droits : [formulaire de contact](/contact).
|
||||
|
||||
## Cookies
|
||||
|
||||
Ce site utilise uniquement un **cookie de session technique**, nécessaire au fonctionnement de l'authentification. Il n'est déposé que lors d'une connexion au compte d'administration et n'est pas utilisé à des fins de suivi ou de profilage. Aucun cookie tiers n'est déposé.
|
||||
|
||||
## Responsabilité
|
||||
|
||||
L'éditeur s'efforce de maintenir les informations publiées à jour et exactes, mais ne peut garantir l'exhaustivité ou l'absence d'erreurs du contenu.
|
||||
|
||||
Les liens vers des sites tiers sont fournis à titre informatif. L'éditeur n'est pas responsable du contenu de ces sites externes.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"uuid": "fdff8ad3-d369-4bd7-bbb9-e14d433868d7",
|
||||
"slug": "licenses",
|
||||
"title": "Licences",
|
||||
"author": "cedric@abonnel.fr",
|
||||
"published": true,
|
||||
"published_at": "2021-01-16 04:02:40",
|
||||
"created_at": "2021-01-16 04:02:40",
|
||||
"updated_at": "2021-01-16 04:02:40",
|
||||
"revisions": [],
|
||||
"cover": "",
|
||||
"files_meta": [],
|
||||
"external_links": [],
|
||||
"seo_title": "",
|
||||
"seo_description": "",
|
||||
"og_image": "",
|
||||
"category": ""
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Licences
|
||||
|
||||
Composants logiciels utilisés par ce site et leurs licences.
|
||||
|
||||
## Ce site
|
||||
|
||||
| Composant | Licence | Usage |
|
||||
|-----------|---------|-------|
|
||||
| **Folio** — moteur de blog PHP | MIT | Moteur de ce blog — par Cédric Abonnel ([voir la licence](/LICENSE)) |
|
||||
| **Contenu éditorial** | CC BY 4.0 | Articles et textes du blog — [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/) |
|
||||
|
||||
## Bibliothèques (production)
|
||||
|
||||
| Composant | Version | Licence | Usage |
|
||||
|-----------|---------|---------|-------|
|
||||
| **Bootstrap** | 5.3.3 | MIT | Framework CSS/JS — auto-hébergé ([voir la licence](/assets/css/LICENSE-Bootstrap.txt)) |
|
||||
| **PHPMailer** | 6.12.0 | LGPL-2.1 | Envoi d'e-mails SMTP |
|
||||
| **phpdotenv** | 5.6.2 | BSD-3-Clause | Variables d'environnement |
|
||||
| **openid-connect-php** | 1.0.2 | Apache-2.0 | Authentification SSO (OIDC) |
|
||||
| **Police Inter** | v20 | OFL-1.1 | Typographie — auto-hébergée ([voir la licence](/assets/fonts/LICENSE-Inter.txt)) |
|
||||
|
||||
## Outils de développement
|
||||
|
||||
| Composant | Version | Licence | Usage |
|
||||
|-----------|---------|---------|-------|
|
||||
| **PHPStan** | 1.12.32 | MIT | Analyse statique PHP |
|
||||
| **PHP-CS-Fixer** | 3.89.1 | MIT | Formatage du code |
|
||||
| **Claude Code CLI** | — | Commercial | Outil de développement (Anthropic) — [Conditions d'utilisation](https://www.anthropic.com/legal/aup) |
|
||||
|
||||
## Infrastructure
|
||||
|
||||
| Composant | Licence | Usage |
|
||||
|-----------|---------|-------|
|
||||
| **PHP 8.3** | PHP License v3.01 | Langage côté serveur |
|
||||
| **PostgreSQL** | PostgreSQL License | Base de données relationnelle |
|
||||
| **Apache HTTP Server** | Apache-2.0 | Serveur web |
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Réactions visiteurs (cookie anti-doublon)
|
||||
CREATE TABLE article_reactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
article_uuid TEXT NOT NULL,
|
||||
reaction_type TEXT NOT NULL CHECK (reaction_type IN ('useful', 'important', 'interesting')),
|
||||
visitor_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (article_uuid, reaction_type, visitor_hash)
|
||||
);
|
||||
CREATE INDEX ON article_reactions (article_uuid);
|
||||
|
||||
-- Commentaires avec vérification par email
|
||||
CREATE TABLE comments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
article_uuid TEXT NOT NULL,
|
||||
author_name TEXT NOT NULL,
|
||||
author_email TEXT NOT NULL,
|
||||
content TEXT NOT NULL CHECK (LENGTH(content) <= 2000),
|
||||
verify_token TEXT,
|
||||
verification_code TEXT,
|
||||
verify_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
CREATE INDEX ON comments (article_uuid, verified, published);
|
||||
CREATE INDEX ON comments (verify_token)
|
||||
WHERE verified = FALSE AND verify_token IS NOT NULL;
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
// À exécuter UNE SEULE FOIS pour enregistrer les migrations déjà appliquées.
|
||||
// php database/migrate-init.php
|
||||
declare(strict_types=1);
|
||||
|
||||
$baseDir = dirname(__DIR__);
|
||||
$envFile = $baseDir . '/.env';
|
||||
|
||||
$env = [];
|
||||
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
|
||||
$env[trim($k)] = trim($v, '"\'');
|
||||
}
|
||||
|
||||
$pdo = new PDO($env['DB_DSN'], $env['DB_USER'] ?? '', $env['DB_PASS'] ?? '', [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
]);
|
||||
|
||||
$pdo->exec('
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT NOT NULL PRIMARY KEY,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
');
|
||||
|
||||
// Migrations déjà appliquées avant la mise en place de ce système
|
||||
$alreadyApplied = [
|
||||
'migration_001_roles_ratings.sql',
|
||||
'migration_002_profile_url.sql',
|
||||
'migration_003_profile_slug.sql',
|
||||
'migration_004_profile_bio.sql',
|
||||
'migration_005_rss_feeds.sql',
|
||||
'migration_006_profile_links.sql',
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare('INSERT INTO schema_migrations (name) VALUES (?) ON CONFLICT DO NOTHING');
|
||||
foreach ($alreadyApplied as $name) {
|
||||
$stmt->execute([$name]);
|
||||
echo " ✓ marquée : $name\n";
|
||||
}
|
||||
|
||||
echo "\nInitialisation terminée. Vous pouvez supprimer ce fichier.\n";
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Runner de migrations SQL.
|
||||
// Appelé sur le serveur après chaque rsync : php database/migrate.php
|
||||
|
||||
$baseDir = dirname(__DIR__);
|
||||
$envFile = $baseDir . '/.env';
|
||||
|
||||
if (!file_exists($envFile)) {
|
||||
fwrite(STDERR, "Fichier .env introuvable : $envFile\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$env = [];
|
||||
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
|
||||
$env[trim($k)] = trim($v, '"\'');
|
||||
}
|
||||
|
||||
$dsn = $env['DB_DSN'] ?? '';
|
||||
$user = $env['DB_USER'] ?? '';
|
||||
$pass = $env['DB_PASS'] ?? '';
|
||||
|
||||
if (!$dsn) {
|
||||
fwrite(STDERR, "DB_DSN manquant dans .env\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$pdo = new PDO($dsn, $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
|
||||
// Crée la table de suivi si elle n'existe pas encore
|
||||
$pdo->exec('
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT NOT NULL PRIMARY KEY,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
');
|
||||
|
||||
// Migrations déjà appliquées
|
||||
$applied = array_flip(
|
||||
$pdo->query('SELECT name FROM schema_migrations ORDER BY name')
|
||||
->fetchAll(PDO::FETCH_COLUMN)
|
||||
);
|
||||
|
||||
// Fichiers de migration dans le même dossier, triés par nom
|
||||
$files = glob(__DIR__ . '/migration_*.sql') ?: [];
|
||||
sort($files);
|
||||
|
||||
$count = 0;
|
||||
foreach ($files as $file) {
|
||||
$name = basename($file);
|
||||
if (isset($applied[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = file_get_contents($file);
|
||||
if ($sql === false || trim($sql) === '') {
|
||||
echo " ⚠ $name : fichier vide ou illisible, ignoré\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo " → $name ... ";
|
||||
$pdo->exec($sql);
|
||||
$pdo->prepare('INSERT INTO schema_migrations (name) VALUES (?)')
|
||||
->execute([$name]);
|
||||
echo "✓\n";
|
||||
$count++;
|
||||
}
|
||||
|
||||
if ($count === 0) {
|
||||
echo " (aucune migration en attente)\n";
|
||||
} else {
|
||||
echo " $count migration(s) appliquée(s).\n";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Migration 001 : Système de rôles et notes d'articles
|
||||
-- À exécuter une seule fois sur le serveur PostgreSQL
|
||||
|
||||
-- Rôles disponibles
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
label TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO roles (name, label) VALUES
|
||||
('admin', 'Administrateur'),
|
||||
('editor', 'Rédacteur'),
|
||||
('reader', 'Lecteur')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Association utilisateur ↔ rôle (clé : email, pour compatibilité OIDC sans FK)
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_email TEXT NOT NULL,
|
||||
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
granted_at TIMESTAMP DEFAULT NOW(),
|
||||
granted_by TEXT,
|
||||
PRIMARY KEY (user_email, role_id)
|
||||
);
|
||||
|
||||
-- Seed : cedric@abonnel.fr → admin
|
||||
INSERT INTO user_roles (user_email, role_id, granted_by)
|
||||
SELECT 'cedric@abonnel.fr', id, 'migration'
|
||||
FROM roles WHERE name = 'admin'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Notes d'articles (1-5 étoiles, une note par utilisateur par article)
|
||||
CREATE TABLE IF NOT EXISTS article_ratings (
|
||||
article_uuid VARCHAR(36) NOT NULL,
|
||||
user_email TEXT NOT NULL,
|
||||
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||
rated_at TIMESTAMP DEFAULT NOW(),
|
||||
PRIMARY KEY (article_uuid, user_email)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS profile_url TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS profile_slug TEXT NOT NULL DEFAULT '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_profile_slug_idx ON user_profiles (profile_slug) WHERE profile_slug <> '';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS bio TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS rss_feeds (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_email TEXT NOT NULL,
|
||||
feed_url TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT now(),
|
||||
UNIQUE (user_email, feed_url)
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS profile_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_email TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Ajout du token UUID dans l'URL de vérification et du compteur de tentatives
|
||||
ALTER TABLE comments ADD COLUMN IF NOT EXISTS verify_token TEXT;
|
||||
ALTER TABLE comments ADD COLUMN IF NOT EXISTS verify_attempts INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_verify_token ON comments (verify_token)
|
||||
WHERE verified = FALSE AND verify_token IS NOT NULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
is_published BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE post_files (
|
||||
id SERIAL PRIMARY KEY,
|
||||
post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
|
||||
file_type TEXT,
|
||||
file_path TEXT,
|
||||
original_name TEXT,
|
||||
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
parameters:
|
||||
ignoreErrors:
|
||||
-
|
||||
message: "#^Unreachable statement \\- code above always terminates\\.$#"
|
||||
count: 1
|
||||
path: src/Repository/ProfileRepository.php
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
define('BASE_PATH', __DIR__);
|
||||
@@ -0,0 +1,11 @@
|
||||
includes:
|
||||
- phpstan-baseline.neon
|
||||
|
||||
parameters:
|
||||
level: 5
|
||||
paths:
|
||||
- src
|
||||
excludePaths:
|
||||
- src/Parsedown.php
|
||||
bootstrapFiles:
|
||||
- phpstan-bootstrap.php
|
||||
@@ -0,0 +1,83 @@
|
||||
Options -Indexes
|
||||
DirectoryIndex index.php
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Fichiers et répertoires réels servis directement
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# URL propre pour les articles : /post/<slug>
|
||||
RewriteRule ^post/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=view&slug=$1 [L,QSA]
|
||||
|
||||
# Filtre par catégorie : /categorie/<nom>
|
||||
RewriteRule ^categorie/(.+?)/?$ /index.php?cat=$1 [L,QSA,B]
|
||||
|
||||
# Pagination par curseur : /page/<uuid>
|
||||
RewriteRule ^page/([0-9a-f-]{36})/?$ /index.php?cursor=$1 [L,QSA]
|
||||
|
||||
# Édition / création
|
||||
RewriteRule ^edit/([0-9a-f-]{36})/tags/(.+?)/?$ /index.php?action=edit_tags&uuid=$1&tag_type=$2 [L,QSA,B]
|
||||
RewriteRule ^edit/([0-9a-f-]{36})/?$ /index.php?action=edit&uuid=$1 [L,QSA]
|
||||
RewriteRule ^new/?$ /index.php?action=create [L,QSA]
|
||||
RewriteRule ^delete/([0-9a-f-]{36})/?$ /index.php?action=delete&uuid=$1 [L,QSA]
|
||||
|
||||
# Sources et diff
|
||||
RewriteRule ^sources/([0-9a-f-]{36})/?$ /index.php?action=sources&uuid=$1 [L,QSA]
|
||||
RewriteRule ^diff/([0-9a-f-]{36})/(\d+)/?$ /index.php?action=diff&uuid=$1&rev=$2 [L,QSA]
|
||||
|
||||
# Fichiers / import
|
||||
RewriteRule ^files/([0-9a-f-]{36})/add/?$ /index.php?action=add_files&uuid=$1 [L,QSA]
|
||||
RewriteRule ^import/([0-9a-f-]{36})/?$ /index.php?action=import_image&uuid=$1 [L,QSA]
|
||||
|
||||
# Admin (regen-thumbs et role/<email> avant la règle générique admin/<tab>)
|
||||
RewriteRule ^admin/regen-thumbs/?$ /index.php?action=regen_thumbs [L,QSA]
|
||||
RewriteRule ^admin/role/([a-z0-9_-]+)/?$ /index.php?action=admin_role_edit&role_name=$1 [L,QSA]
|
||||
RewriteRule ^admin/([a-z0-9-]+)/?$ /index.php?action=admin&tab=$1 [L,QSA]
|
||||
RewriteRule ^admin/?$ /index.php?action=admin [L,QSA]
|
||||
|
||||
# Réactions et commentaires
|
||||
RewriteRule ^react/?$ /index.php?action=react [L,QSA]
|
||||
RewriteRule ^comment/?$ /index.php?action=comment [L,QSA]
|
||||
RewriteRule ^comment-moderate/?$ /index.php?action=comment_moderate [L,QSA]
|
||||
RewriteRule ^verify-comment/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/?$ /index.php?action=verify_comment&token=$1 [L,QSA]
|
||||
|
||||
# Pages de gestion
|
||||
RewriteRule ^categories/?$ /index.php?action=categories [L,QSA]
|
||||
RewriteRule ^profile/?$ /index.php?action=profile [L,QSA]
|
||||
RewriteRule ^search/?$ /index.php?action=search [L,QSA]
|
||||
RewriteRule ^flux/?$ /index.php?action=flux [L,QSA]
|
||||
RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA]
|
||||
RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA]
|
||||
|
||||
# Profil public auteur + page liens
|
||||
RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/article/cursor/([0-9a-f-]{36})/?$ /index.php?action=author_articles&slug=$1&cursor=$2 [L,QSA]
|
||||
RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/article/?$ /index.php?action=author_articles&slug=$1 [L,QSA]
|
||||
RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=author&slug=$1 [L,QSA]
|
||||
RewriteRule ^liens/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=liens&slug=$1 [L,QSA]
|
||||
RewriteRule ^link/add/?$ /index.php?action=add_link [L,QSA]
|
||||
RewriteRule ^link/delete/?$ /index.php?action=delete_link [L,QSA]
|
||||
RewriteRule ^link/reorder/?$ /index.php?action=reorder_links [L,QSA]
|
||||
|
||||
# Pages statiques
|
||||
RewriteRule ^about/?$ /index.php?action=about [L,QSA]
|
||||
RewriteRule ^legal/?$ /index.php?action=legal [L,QSA]
|
||||
RewriteRule ^licenses/?$ /index.php?action=licenses [L,QSA]
|
||||
RewriteRule ^contact/?$ /index.php?action=contact [L,QSA]
|
||||
|
||||
# Flux RSS — /feed est canonique, /rss et /rss.xml redirigent en 301
|
||||
RewriteRule ^rss/?$ /feed [R=301,L]
|
||||
RewriteRule ^rss\.xml$ /feed [R=301,L]
|
||||
RewriteRule ^feed/([0-9a-f-]{36})/?$ /feed.php?after=$1 [L,QSA]
|
||||
RewriteRule ^feed/?$ /feed.php [L,QSA]
|
||||
|
||||
# Sitemap
|
||||
RewriteRule ^sitemap\.xml$ /sitemap.php [L]
|
||||
|
||||
# Ajoute .php si le fichier correspondant existe
|
||||
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI}.php -f
|
||||
RewriteRule ^(.+?)/?$ /$1.php [L,QSA]
|
||||
|
||||
# 404 intelligent : redirige vers l'article le plus proche
|
||||
ErrorDocument 404 /index.php?action=not_found
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024–2026 Cédric Abonnel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2011-2024 The Bootstrap Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#4f46e5"/>
|
||||
<text x="16" y="23" text-anchor="middle" font-family="system-ui,sans-serif" font-weight="700" font-size="20" fill="white">v</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 256 B |
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,118 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var panel = document.getElementById('sf-panel');
|
||||
if (!panel) return;
|
||||
|
||||
var input = document.getElementById('sf-input');
|
||||
var btn = document.getElementById('sf-btn');
|
||||
var box = document.getElementById('sf-results');
|
||||
var toUuid = panel.dataset.uuid;
|
||||
|
||||
function fileIcon(mime) {
|
||||
if (mime.startsWith('video/')) return '🎬';
|
||||
if (mime.startsWith('audio/')) return '🎵';
|
||||
if (mime === 'application/pdf') return '📑';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
var q = input.value.trim();
|
||||
if (!q) return;
|
||||
btn.disabled = true;
|
||||
box.innerHTML = '<p class="text-muted small">Recherche…</p>';
|
||||
try {
|
||||
var res = await fetch('/?action=search_files&q=' + encodeURIComponent(q) + '&exclude=' + encodeURIComponent(toUuid));
|
||||
var data = await res.json();
|
||||
box.innerHTML = '';
|
||||
if (!data.length) {
|
||||
box.innerHTML = '<p class="text-muted small">Aucun fichier trouvé.</p>';
|
||||
return;
|
||||
}
|
||||
data.forEach(function (group) {
|
||||
var section = document.createElement('div');
|
||||
section.className = 'mb-4';
|
||||
|
||||
var header = document.createElement('p');
|
||||
header.className = 'fw-semibold small mb-2';
|
||||
header.textContent = group.article.title;
|
||||
section.appendChild(header);
|
||||
|
||||
var grid = document.createElement('div');
|
||||
grid.className = 'd-flex flex-wrap gap-2';
|
||||
|
||||
group.files.forEach(function (f) {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:relative;cursor:pointer';
|
||||
wrap.title = f.name + ' (' + (f.size / 1024).toFixed(1) + ' Ko)';
|
||||
|
||||
if (f.is_image) {
|
||||
var img = document.createElement('img');
|
||||
img.src = f.url;
|
||||
img.alt = f.name;
|
||||
img.style.cssText = 'width:72px;height:72px;object-fit:cover;border-radius:6px;border:2px solid transparent;transition:border-color .15s,opacity .15s;display:block';
|
||||
wrap.appendChild(img);
|
||||
} else {
|
||||
var icon = document.createElement('div');
|
||||
icon.style.cssText = 'width:72px;height:72px;border-radius:6px;border:2px solid #dee2e6;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:1.6rem;background:#f8f9fa;transition:border-color .15s';
|
||||
icon.innerHTML = fileIcon(f.mime) + '<span style="font-size:.6rem;margin-top:2px;color:#6c757d;max-width:68px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + f.name.split('.').pop().toUpperCase() + '</span>';
|
||||
wrap.appendChild(icon);
|
||||
}
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:absolute;inset:0;border-radius:6px;display:none;align-items:center;justify-content:center;background:rgba(25,135,84,.8);color:#fff;font-size:1.4rem';
|
||||
overlay.textContent = '✓';
|
||||
wrap.appendChild(overlay);
|
||||
|
||||
wrap.addEventListener('mouseenter', function () {
|
||||
if (!wrap._copied) wrap.firstChild.style.borderColor = '#0d6efd';
|
||||
});
|
||||
wrap.addEventListener('mouseleave', function () {
|
||||
if (!wrap._copied) wrap.firstChild.style.borderColor = 'transparent';
|
||||
});
|
||||
|
||||
wrap.addEventListener('click', async function () {
|
||||
if (wrap._copying || wrap._copied) return;
|
||||
wrap._copying = true;
|
||||
wrap.firstChild.style.opacity = '.5';
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('from_uuid', group.article.uuid);
|
||||
fd.append('name', f.name);
|
||||
fd.append('to_uuid', toUuid);
|
||||
var r = await fetch('/?action=copy_file&uuid=' + encodeURIComponent(toUuid), {method: 'POST', body: fd});
|
||||
var d = await r.json();
|
||||
if (d.ok) {
|
||||
wrap._copied = true;
|
||||
wrap.firstChild.style.opacity = '1';
|
||||
wrap.firstChild.style.borderColor = '#198754';
|
||||
overlay.style.display = 'flex';
|
||||
} else {
|
||||
wrap.firstChild.style.opacity = '1';
|
||||
wrap.firstChild.style.borderColor = '#dc3545';
|
||||
wrap.title = d.error || 'Erreur';
|
||||
}
|
||||
} catch (e) {
|
||||
wrap.firstChild.style.opacity = '1';
|
||||
} finally {
|
||||
wrap._copying = false;
|
||||
}
|
||||
});
|
||||
|
||||
grid.appendChild(wrap);
|
||||
});
|
||||
|
||||
section.appendChild(grid);
|
||||
box.appendChild(section);
|
||||
});
|
||||
} catch (e) {
|
||||
box.innerHTML = '<p class="text-danger small">Erreur de recherche.</p>';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', doSearch);
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
|
||||
});
|
||||
doSearch();
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Confirmation data-confirm sur les formulaires (evite confirm() inline bloqué par CSP)
|
||||
document.querySelectorAll('form[data-confirm]').forEach(function (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
var msg = form.getAttribute('data-confirm') || 'Confirmer ?';
|
||||
if (!window.confirm(msg)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sélection globale articles
|
||||
var checkAll = document.getElementById('check-all');
|
||||
if (checkAll) {
|
||||
checkAll.addEventListener('change', function () {
|
||||
document.querySelectorAll('.bulk-check').forEach(function (cb) {
|
||||
cb.checked = checkAll.checked;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Indicateurs de traitement formulaire SMTP (config + tester connexion)
|
||||
var smtpForm = document.getElementById('smtp-config-form');
|
||||
if (smtpForm) {
|
||||
smtpForm.addEventListener('submit', function (e) {
|
||||
var clicked = e.submitter;
|
||||
if (!clicked) return;
|
||||
smtpForm.querySelectorAll('button[type="submit"]').forEach(function (btn) {
|
||||
btn.disabled = true;
|
||||
});
|
||||
var isSave = clicked.id === 'smtp-save-btn';
|
||||
clicked.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>'
|
||||
+ (isSave ? 'Enregistrement…' : 'En cours…');
|
||||
});
|
||||
}
|
||||
|
||||
// Indicateur de traitement envoi email de test
|
||||
var smtpTestForm = document.getElementById('smtp-test-form');
|
||||
if (smtpTestForm) {
|
||||
smtpTestForm.addEventListener('submit', function () {
|
||||
var btn = document.getElementById('smtp-send-btn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>En cours…';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
// varlog — app.js
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
// ─── Auto-resize textareas ───────────────────────────────────────────────
|
||||
document.querySelectorAll('textarea.form-control').forEach(function (ta) {
|
||||
function resize() {
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = ta.scrollHeight + 'px';
|
||||
}
|
||||
ta.addEventListener('input', resize);
|
||||
resize();
|
||||
});
|
||||
|
||||
// ─── Ctrl+Enter : soumettre le formulaire ────────────────────────────────
|
||||
var form = document.querySelector('form[method="POST"]');
|
||||
if (form) {
|
||||
form.addEventListener('keydown', function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Slug auto-génération ────────────────────────────────────────────────
|
||||
const titleInput = document.getElementById('title');
|
||||
const slugField = document.getElementById('slug');
|
||||
const slugPreview = document.getElementById('slug-preview');
|
||||
|
||||
if (titleInput && slugField) {
|
||||
if (slugField.value !== '') slugField._auto = false;
|
||||
|
||||
titleInput.addEventListener('input', function () {
|
||||
if (slugField._auto !== false) {
|
||||
const generated = slugify(this.value);
|
||||
slugField.value = generated;
|
||||
if (slugPreview) slugPreview.textContent = generated;
|
||||
}
|
||||
});
|
||||
|
||||
slugField.addEventListener('input', function () {
|
||||
this._auto = (this.value === '');
|
||||
if (slugPreview) slugPreview.textContent = this.value;
|
||||
});
|
||||
}
|
||||
|
||||
function slugify(s) {
|
||||
const map = {'à':'a','â':'a','ä':'a','é':'e','è':'e','ê':'e','ë':'e','î':'i','ï':'i','ô':'o','ö':'o','ù':'u','û':'u','ü':'u','ç':'c','æ':'ae','œ':'oe'};
|
||||
return s.toLowerCase().replace(/[àâäéèêëîïôöùûüçæœ]/g, c => map[c] || c)
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
// ─── Rôle : nom technique auto depuis le label ───────────────────────────
|
||||
var roleLabelInput = document.getElementById('role-label');
|
||||
var roleNameInput = document.getElementById('role-name');
|
||||
if (roleLabelInput && roleNameInput) {
|
||||
roleLabelInput.addEventListener('input', function () {
|
||||
if (roleNameInput._manual) return;
|
||||
roleNameInput.value = slugify(this.value);
|
||||
});
|
||||
roleNameInput.addEventListener('input', function () {
|
||||
this._manual = (this.value !== '');
|
||||
});
|
||||
roleNameInput.addEventListener('blur', function () {
|
||||
if (this.value === '') {
|
||||
this._manual = false;
|
||||
this.value = slugify(roleLabelInput.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Aperçu couleur catégorie ────────────────────────────────────────────
|
||||
const KNOWN_CATS = {
|
||||
'actualité': 10, 'travaux': 35, 'scolaire': 55,
|
||||
'linux': 120, 'domotique': 160, 'télécom': 190,
|
||||
'blog': 220, 'informatique': 255, 'réflexion': 285,
|
||||
'loisirs': 320, 'perso': 345,
|
||||
};
|
||||
const FREE_HUES = [87, 140, 205, 237, 302];
|
||||
|
||||
function gradient(hue) {
|
||||
return `linear-gradient(135deg,hsl(${hue},70%,88%) 0%,hsl(${hue},60%,28%) 100%)`;
|
||||
}
|
||||
|
||||
function hashHue(str) {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < str.length; i++) h = (((h << 5) + h) + str.charCodeAt(i)) | 0;
|
||||
return ((Math.abs(h) * 0.6180339887) * 360 | 0) % 360;
|
||||
}
|
||||
|
||||
function nearestKnown(hue) {
|
||||
let best = null, bestDist = Infinity;
|
||||
for (const [name, h] of Object.entries(KNOWN_CATS)) {
|
||||
const d = Math.min(Math.abs(hue - h), 360 - Math.abs(hue - h));
|
||||
if (d < bestDist) { bestDist = d; best = name; }
|
||||
}
|
||||
return { name: best, dist: bestDist };
|
||||
}
|
||||
|
||||
function updateCatPreview(val) {
|
||||
const key = val.trim().toLowerCase();
|
||||
const swatch = document.getElementById('cat-swatch');
|
||||
const hint = document.getElementById('cat-hint');
|
||||
const freeEl = document.getElementById('cat-free-swatches');
|
||||
if (!swatch) return;
|
||||
freeEl.innerHTML = '';
|
||||
|
||||
if (!key) {
|
||||
swatch.style.background = '#e5e7eb';
|
||||
swatch.title = '';
|
||||
hint.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (KNOWN_CATS[key] !== undefined) {
|
||||
const hue = KNOWN_CATS[key];
|
||||
swatch.style.background = gradient(hue);
|
||||
swatch.title = `${hue}°`;
|
||||
hint.textContent = `Catégorie existante · teinte fixe (${hue}°)`;
|
||||
hint.className = 'text-muted d-block mt-1';
|
||||
return;
|
||||
}
|
||||
|
||||
const hue = hashHue(key);
|
||||
const { name, dist } = nearestKnown(hue);
|
||||
|
||||
swatch.style.background = gradient(hue);
|
||||
swatch.title = `${hue}°`;
|
||||
|
||||
if (dist < 20) {
|
||||
hint.innerHTML = `⚠ Teinte proche de <strong>${name}</strong> (${dist}° d'écart) · couleurs disponibles :`;
|
||||
hint.className = 'text-warning d-block mt-1';
|
||||
FREE_HUES.forEach(h => {
|
||||
const el = document.createElement('span');
|
||||
el.title = `${h}°`;
|
||||
el.style.cssText = `display:inline-block;width:28px;height:20px;border-radius:4px;cursor:help;background:${gradient(h)}`;
|
||||
freeEl.appendChild(el);
|
||||
});
|
||||
} else {
|
||||
hint.textContent = `Nouvelle catégorie · teinte libre (${hue}°)`;
|
||||
hint.className = 'text-muted d-block mt-1';
|
||||
}
|
||||
}
|
||||
|
||||
const catInput = document.getElementById('category');
|
||||
if (catInput) {
|
||||
catInput.addEventListener('input', function () { updateCatPreview(this.value); });
|
||||
updateCatPreview(catInput.value);
|
||||
}
|
||||
|
||||
// ─── Copier la référence Markdown ────────────────────────────────────────
|
||||
document.querySelectorAll('[data-copy-md-name]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const name = this.dataset.copyMdName;
|
||||
const isImage = this.dataset.copyMdIsImage === '1';
|
||||
const ref = isImage ? `` : `[${name}](${name})`;
|
||||
navigator.clipboard.writeText(ref).then(() => {
|
||||
const orig = this.textContent;
|
||||
this.textContent = 'Copié !';
|
||||
setTimeout(() => { this.textContent = orig; }, 1500);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Boîtes de confirmation (suppression) ───────────────────────────────
|
||||
document.querySelectorAll('button[data-confirm], a[data-confirm]').forEach(function (el) {
|
||||
el.addEventListener('click', function (e) {
|
||||
if (!confirm(this.dataset.confirm)) e.preventDefault();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('form[data-confirm]').forEach(function (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
if (!confirm(this.dataset.confirm)) e.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Insérer une référence Markdown au curseur ───────────────────────────
|
||||
const ta = document.getElementById('content');
|
||||
if (ta) {
|
||||
ta._savedStart = null;
|
||||
ta._savedEnd = null;
|
||||
|
||||
function saveCursor() {
|
||||
if (document.activeElement === ta) {
|
||||
ta._savedStart = ta.selectionStart;
|
||||
ta._savedEnd = ta.selectionEnd;
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', saveCursor);
|
||||
ta.addEventListener('keyup', saveCursor);
|
||||
ta.addEventListener('mouseup', saveCursor);
|
||||
|
||||
document.querySelectorAll('[data-insert-ref]').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
insertRef(this.dataset.insertRef);
|
||||
});
|
||||
if (el.tagName === 'IMG') {
|
||||
el.addEventListener('mouseenter', function () { this.style.borderColor = '#0d6efd'; });
|
||||
el.addEventListener('mouseleave', function () { this.style.borderColor = 'transparent'; });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function insertRef(url) {
|
||||
if (!ta) return;
|
||||
const isImage = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(url);
|
||||
const label = url.startsWith('http')
|
||||
? (decodeURIComponent(url.split('/').pop().split('?')[0]) || url)
|
||||
: url;
|
||||
const ref = isImage ? `` : `[${label}](${url})`;
|
||||
const len = ta.value.length;
|
||||
const start = ta._savedStart !== null ? ta._savedStart : len;
|
||||
const end = ta._savedEnd !== null ? ta._savedEnd : len;
|
||||
ta.focus();
|
||||
ta.setRangeText(ref, start, end, 'end');
|
||||
ta._savedStart = ta._savedEnd = start + ref.length;
|
||||
ta.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
// ─── Compteurs SEO ───────────────────────────────────────────────────────
|
||||
function initCounter(inputId, counterId, max) {
|
||||
const input = document.getElementById(inputId);
|
||||
const counter = document.getElementById(counterId);
|
||||
if (!input || !counter) return;
|
||||
function update() {
|
||||
const len = input.value.length;
|
||||
counter.textContent = `${len} / ${max}`;
|
||||
counter.className = len > max ? 'text-danger' : 'text-muted';
|
||||
}
|
||||
input.addEventListener('input', update);
|
||||
update();
|
||||
}
|
||||
initCounter('seo_title', 'seo_title_counter', 60);
|
||||
initCounter('seo_description', 'seo_desc_counter', 155);
|
||||
|
||||
// ─── Page catégories ─────────────────────────────────────────────────────
|
||||
function catComputeGradient(val) {
|
||||
const key = val.trim().toLowerCase();
|
||||
if (!key) return null;
|
||||
if (KNOWN_CATS[key] !== undefined) return { hue: KNOWN_CATS[key], known: true };
|
||||
const hue = hashHue(key);
|
||||
const { name, dist } = nearestKnown(hue);
|
||||
return { hue, known: false, conflict: dist < 20 ? name : null };
|
||||
}
|
||||
|
||||
document.querySelectorAll('form[action="/?action=rename_category"] input[name="new"]').forEach(function (input) {
|
||||
input.addEventListener('input', function () {
|
||||
const swatch = input.closest('form').querySelector('.rename-swatch');
|
||||
const result = catComputeGradient(input.value);
|
||||
if (swatch) swatch.style.background = result ? gradient(result.hue) : '#e5e7eb';
|
||||
});
|
||||
});
|
||||
|
||||
const newCatInput = document.getElementById('new-cat-input');
|
||||
if (newCatInput) {
|
||||
newCatInput.addEventListener('input', function () {
|
||||
const swatch = document.getElementById('new-cat-swatch');
|
||||
const hint = document.getElementById('new-cat-hint');
|
||||
const result = catComputeGradient(this.value);
|
||||
|
||||
if (!result) {
|
||||
swatch.style.background = '#e5e7eb';
|
||||
hint.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
swatch.style.background = gradient(result.hue);
|
||||
|
||||
if (result.known) {
|
||||
hint.textContent = `Catégorie existante · teinte fixe (${result.hue}°)`;
|
||||
hint.className = 'text-muted d-block mb-3';
|
||||
} else if (result.conflict) {
|
||||
hint.textContent = `⚠ Teinte proche de « ${result.conflict} » — choisissez un autre nom ou une couleur disponible ci-dessous`;
|
||||
hint.className = 'text-warning d-block mb-3';
|
||||
} else {
|
||||
hint.textContent = `Couleur libre · teinte ${result.hue}°`;
|
||||
hint.className = 'text-success d-block mb-3';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Import image : récupérer les métadonnées ────────────────────────────
|
||||
const fetchMetaBtn = document.getElementById('fetch-meta-btn');
|
||||
if (fetchMetaBtn) {
|
||||
fetchMetaBtn.addEventListener('click', async function () {
|
||||
const urlInput = document.getElementById('import-url');
|
||||
const resultDiv = document.getElementById('meta-result');
|
||||
const url = urlInput ? urlInput.value.trim() : '';
|
||||
|
||||
if (!url) {
|
||||
resultDiv.innerHTML = '<small class="text-danger">Saisissez une URL d\'abord.</small>';
|
||||
return;
|
||||
}
|
||||
|
||||
fetchMetaBtn.disabled = true;
|
||||
fetchMetaBtn.textContent = 'Chargement…';
|
||||
resultDiv.innerHTML = '';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/?action=fetch_file_meta&url=${encodeURIComponent(url)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.ok) {
|
||||
resultDiv.innerHTML = `<small class="text-danger">${data.error || 'Erreur lors de la récupération.'}</small>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-remplissage dynamique des champs (si vides)
|
||||
const AUTOFILL = {
|
||||
img_author: { keys: ['author', 'credit'], label: 'Auteur / crédit' },
|
||||
img_source: { keys: ['canonical', 'source'], label: 'URL source' },
|
||||
};
|
||||
const autofillKeys = new Set();
|
||||
const autofillNotice = [];
|
||||
for (const [fieldName, cfg] of Object.entries(AUTOFILL)) {
|
||||
const f = document.querySelector(`input[name="${fieldName}"]`);
|
||||
if (!f || f.value) continue;
|
||||
for (const key of cfg.keys) {
|
||||
if (data[key]) {
|
||||
f.value = data[key];
|
||||
autofillKeys.add(key);
|
||||
autofillNotice.push(`<strong>${cfg.label}</strong> : ${data[key]}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Affichage dynamique de tous les champs retournés
|
||||
const isPdf = (data.mime === 'application/pdf');
|
||||
const isHtml = (data.mime || '').startsWith('text/html');
|
||||
|
||||
const META_ORDER = ['mime','size','pages','page_size','pdf_version',
|
||||
'width','site_name','og_type','language',
|
||||
'title','description','author','subject','keywords',
|
||||
'credit','source','creator','producer','date','camera','copyright',
|
||||
'canonical','og_image'];
|
||||
const META_LABELS = {
|
||||
mime: 'Type', size: 'Taille', width: 'Dimensions',
|
||||
pages: 'Pages', page_size: 'Format', pdf_version: 'Version PDF',
|
||||
site_name: 'Site', og_type: 'Type OG', language: 'Langue',
|
||||
title: isPdf || isHtml ? 'Titre' : 'Titre EXIF/IPTC',
|
||||
author: isPdf || isHtml ? 'Auteur' : 'Auteur EXIF/IPTC',
|
||||
date: isPdf ? 'Créé le' : isHtml ? 'Publié le' : 'Prise de vue',
|
||||
description: 'Description', subject: 'Sujet', keywords: 'Mots-clés',
|
||||
credit: 'Crédit', source: 'Source IPTC',
|
||||
creator: 'Créé avec', producer: 'Produit par',
|
||||
camera: 'Appareil', copyright: 'Copyright',
|
||||
canonical: 'URL canonique', og_image: 'Image OG',
|
||||
};
|
||||
|
||||
function fmtVal(key, val) {
|
||||
if (key === 'size') return (val/1024).toFixed(0) + ' Ko' + (val >= 1048576 ? ` (${(val/1048576).toFixed(1)} Mo)` : '');
|
||||
if (key === 'width') return `${data.width} × ${data.height} px`;
|
||||
if (key === 'og_image') return `<img src="${val}" style="max-width:120px;max-height:80px;border-radius:4px" alt="">`;
|
||||
if (key === 'canonical') return `<a href="${val}" target="_blank" rel="noopener">${val}</a>`;
|
||||
return String(val);
|
||||
}
|
||||
|
||||
const SKIP = new Set(['ok', 'height']);
|
||||
const seen = new Set();
|
||||
const rows = [];
|
||||
|
||||
for (const key of META_ORDER) {
|
||||
const val = data[key];
|
||||
if (val == null || val === '' || key === 'height') continue;
|
||||
seen.add(key);
|
||||
const badge = autofillKeys.has(key)
|
||||
? ' <span class="badge text-bg-primary ms-1" title="Pré-rempli dans le formulaire">↓ pré-rempli</span>'
|
||||
: '';
|
||||
rows.push([META_LABELS[key] ?? key, fmtVal(key, val) + badge]);
|
||||
}
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (seen.has(key) || SKIP.has(key) || val == null || val === '') continue;
|
||||
rows.push([key, fmtVal(key, val)]);
|
||||
}
|
||||
|
||||
let html = '';
|
||||
if (rows.length > 0) {
|
||||
const trs = rows.map(([k, v]) =>
|
||||
`<tr><th class="text-muted fw-normal pe-3 text-nowrap">${k}</th><td>${v}</td></tr>`
|
||||
).join('');
|
||||
html = `<table class="table table-sm table-borderless mb-0 small"><tbody>${trs}</tbody></table>`;
|
||||
} else {
|
||||
html = '<small class="text-muted">Aucune métadonnée disponible pour ce fichier.</small>';
|
||||
}
|
||||
if (autofillNotice.length > 0) {
|
||||
html += `<div class="small text-primary mt-1">✓ Pré-rempli — ${autofillNotice.join(' · ')}</div>`;
|
||||
}
|
||||
resultDiv.innerHTML = html;
|
||||
} catch {
|
||||
resultDiv.innerHTML = '<small class="text-danger">Erreur de connexion.</small>';
|
||||
} finally {
|
||||
fetchMetaBtn.disabled = false;
|
||||
fetchMetaBtn.textContent = 'Métadonnées';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Import image : toggle mode download ────────────────────────────────
|
||||
document.querySelectorAll('input[name="mode"]').forEach(function (r) {
|
||||
r.addEventListener('change', function () {
|
||||
const dl = this.value === 'download';
|
||||
const ss = this.value === 'screenshot';
|
||||
const warn = document.getElementById('copyright-warning');
|
||||
const fields = document.getElementById('download-fields');
|
||||
if (warn) warn.style.display = dl ? 'block' : 'none';
|
||||
if (fields) fields.style.display = (dl || ss) ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Données page (mode édition uniquement) ──────────────────────────────
|
||||
const pageEl = document.getElementById('vl-page');
|
||||
if (!pageEl) return;
|
||||
|
||||
const uuid = pageEl.dataset.uuid;
|
||||
const insertUrl = pageEl.dataset.insertUrl;
|
||||
|
||||
// Auto-insertion après import d'image
|
||||
if (insertUrl && ta) {
|
||||
const isImage = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(insertUrl);
|
||||
const name = decodeURIComponent(insertUrl.split('/').pop().split('?')[0]) || 'fichier';
|
||||
const ref = isImage ? `` : `[${name}](${insertUrl})`;
|
||||
const sep = ta.value.length > 0 && !ta.value.endsWith('\n') ? '\n' : '';
|
||||
ta.value += sep + ref;
|
||||
ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = ta.value.length;
|
||||
ta.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
// ─── Autosave ────────────────────────────────────────────────────────────
|
||||
const indicator = document.getElementById('autosave-indicator');
|
||||
if (!indicator || !uuid) return;
|
||||
|
||||
let timer = null;
|
||||
|
||||
function scheduleAutosave() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(doAutosave, 3000);
|
||||
}
|
||||
|
||||
async function doAutosave() {
|
||||
const title = document.getElementById('title').value;
|
||||
const slug = document.getElementById('slug').value;
|
||||
const content = document.getElementById('content').value;
|
||||
indicator.textContent = 'Sauvegarde…';
|
||||
try {
|
||||
const res = await fetch(`/?action=autosave&uuid=${encodeURIComponent(uuid)}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({title, slug, content}),
|
||||
});
|
||||
const data = await res.json();
|
||||
indicator.textContent = data.ok ? `Brouillon sauvegardé à ${data.time}` : 'Erreur de sauvegarde';
|
||||
} catch {
|
||||
indicator.textContent = 'Erreur de sauvegarde';
|
||||
}
|
||||
}
|
||||
|
||||
['title', 'slug', 'content'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', scheduleAutosave);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
(function(){
|
||||
var bio = document.getElementById('author-bio');
|
||||
var btn = document.getElementById('bio-toggle');
|
||||
if (!bio || !btn) return;
|
||||
requestAnimationFrame(function() {
|
||||
if (bio.scrollHeight > bio.clientHeight + 2) { btn.hidden = false; }
|
||||
});
|
||||
btn.addEventListener('click', function() {
|
||||
var exp = btn.getAttribute('aria-expanded') === 'true';
|
||||
bio.classList.toggle('bio-clamped', exp);
|
||||
btn.textContent = exp ? 'plus' : 'moins';
|
||||
btn.setAttribute('aria-expanded', exp ? 'false' : 'true');
|
||||
});
|
||||
})();
|
||||
+6
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
(function() {
|
||||
const list = document.getElementById('links-sortable');
|
||||
if (!list) return;
|
||||
let dragged = null;
|
||||
list.querySelectorAll('li').forEach(li => {
|
||||
li.setAttribute('draggable', true);
|
||||
li.addEventListener('dragstart', () => { dragged = li; li.style.opacity = '.4'; });
|
||||
li.addEventListener('dragend', () => { dragged = null; li.style.opacity = ''; saveOrder(); });
|
||||
li.addEventListener('dragover', e => { e.preventDefault(); const after = getDragAfter(list, e.clientY); after ? list.insertBefore(dragged, after) : list.appendChild(dragged); });
|
||||
});
|
||||
function getDragAfter(container, y) {
|
||||
return [...container.querySelectorAll('li:not([style*="opacity"])')].reduce((closest, el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
const offset = y - box.top - box.height / 2;
|
||||
return offset < 0 && offset > closest.offset ? { offset, element: el } : closest;
|
||||
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
||||
}
|
||||
function saveOrder() {
|
||||
const form = document.getElementById('reorder-form');
|
||||
if (!form) return;
|
||||
form.querySelectorAll('input').forEach(i => i.remove());
|
||||
list.querySelectorAll('li[data-id]').forEach(li => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'order[]'; inp.value = li.dataset.id;
|
||||
form.appendChild(inp);
|
||||
});
|
||||
form.submit();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,62 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var data = document.getElementById('pc-data');
|
||||
if (!data) return;
|
||||
|
||||
var defaultTitle = data.dataset.defaultTitle;
|
||||
var defaultDesc = data.dataset.defaultDesc;
|
||||
var baseUrl = data.dataset.baseUrl;
|
||||
|
||||
function initCounter(inputId, counterId, max) {
|
||||
var el = document.getElementById(inputId);
|
||||
var ct = document.getElementById(counterId);
|
||||
if (!el || !ct) return;
|
||||
function upd() {
|
||||
var n = el.value.length;
|
||||
ct.textContent = n + ' / ' + max;
|
||||
ct.className = n > max ? 'text-danger' : 'text-muted';
|
||||
}
|
||||
el.addEventListener('input', upd);
|
||||
upd();
|
||||
}
|
||||
initCounter('seo_title', 'seo_title_counter', 60);
|
||||
initCounter('seo_description', 'seo_desc_counter', 155);
|
||||
|
||||
function updatePreview() {
|
||||
var seoTitle = document.getElementById('seo_title').value.trim();
|
||||
var seoDesc = document.getElementById('seo_description').value.trim();
|
||||
var slug = document.getElementById('confirm-slug').value.trim();
|
||||
document.getElementById('preview-title').textContent = seoTitle || defaultTitle;
|
||||
document.getElementById('preview-desc').textContent = seoDesc || defaultDesc;
|
||||
document.getElementById('preview-url').textContent = baseUrl + slug;
|
||||
}
|
||||
|
||||
['seo_title', 'seo_description', 'confirm-slug'].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.addEventListener('input', updatePreview);
|
||||
});
|
||||
|
||||
var slugInput = document.getElementById('confirm-slug');
|
||||
var slugDisplay = document.getElementById('slug-display');
|
||||
|
||||
var btnSuggest = document.getElementById('slug-btn-suggest');
|
||||
if (btnSuggest) {
|
||||
btnSuggest.addEventListener('click', function () {
|
||||
var val = btnSuggest.dataset.slugSuggest;
|
||||
slugInput.value = val;
|
||||
slugDisplay.textContent = val;
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
|
||||
var btnKeep = document.getElementById('slug-btn-keep');
|
||||
if (btnKeep) {
|
||||
btnKeep.addEventListener('click', function () {
|
||||
var val = btnKeep.dataset.slugKeep;
|
||||
slugInput.value = val;
|
||||
slugDisplay.textContent = val;
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
|
||||
updatePreview();
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// reactions.js — toggle réactions via fetch, fallback formulaire natif
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.reaction-form').forEach(function (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var btn = form.querySelector('.reaction-btn');
|
||||
var type = form.querySelector('[name="type"]').value;
|
||||
var uuid = form.querySelector('[name="uuid"]').value;
|
||||
var badge = form.querySelector('.reaction-count');
|
||||
var active = btn.classList.contains('btn-primary');
|
||||
|
||||
var data = new URLSearchParams();
|
||||
data.append('uuid', uuid);
|
||||
data.append('type', type);
|
||||
data.append('_ajax', '1');
|
||||
|
||||
fetch('/react', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: data.toString(),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
if (!json.ok) { form.submit(); return; }
|
||||
|
||||
var nowActive = json.active;
|
||||
if (btn.classList.contains('hero-reaction-btn')) {
|
||||
btn.classList.toggle('hero-reaction-btn--active', nowActive);
|
||||
} else {
|
||||
btn.classList.toggle('btn-primary', nowActive);
|
||||
btn.classList.toggle('btn-outline-secondary', !nowActive);
|
||||
if (badge) {
|
||||
badge.classList.toggle('bg-light', nowActive);
|
||||
badge.classList.toggle('text-primary', nowActive);
|
||||
badge.classList.toggle('bg-secondary', !nowActive);
|
||||
}
|
||||
}
|
||||
if (badge) { badge.textContent = json.count; }
|
||||
})
|
||||
.catch(function () { form.submit(); });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
(function () {
|
||||
var headings = document.querySelectorAll('.post-content h2, .post-content h3');
|
||||
var links = document.querySelectorAll('.toc-list a');
|
||||
|
||||
if (headings.length && links.length) {
|
||||
var map = {};
|
||||
links.forEach(function (a) {
|
||||
map[decodeURIComponent(a.getAttribute('href').slice(1))] = a;
|
||||
});
|
||||
|
||||
var active = null;
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
if (active) active.classList.remove('toc-active');
|
||||
active = map[entry.target.id] || null;
|
||||
if (active) active.classList.add('toc-active');
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '-8% 0px -82% 0px', threshold: 0 });
|
||||
|
||||
headings.forEach(function (h) { observer.observe(h); });
|
||||
}
|
||||
|
||||
var btnTop = document.getElementById('toc-go-top');
|
||||
var btnBot = document.getElementById('toc-go-bottom');
|
||||
if (btnTop) {
|
||||
btnTop.addEventListener('click', function () {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
if (btnBot) {
|
||||
btnBot.addEventListener('click', function () {
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
})();
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
define('BASE_PATH', realpath(__DIR__ . '/../'));
|
||||
|
||||
require_once BASE_PATH . '/src/auth.php';
|
||||
require_once BASE_PATH . '/src/SiteSettings.php';
|
||||
require_once BASE_PATH . '/config/config.php';
|
||||
require_once BASE_PATH . '/src/ArticleManager.php';
|
||||
require_once BASE_PATH . '/src/Parsedown.php';
|
||||
|
||||
const FEED_PAGE_SIZE = 20;
|
||||
|
||||
$articles = new ArticleManager(BASE_PATH . '/data');
|
||||
$privateCats = $articles->getPrivateCategories();
|
||||
$Parsedown = new Parsedown();
|
||||
|
||||
$now = time();
|
||||
$base = rtrim(APP_URL, '/');
|
||||
|
||||
$all = array_values(array_filter(
|
||||
$articles->getAll(publishedOnly: true),
|
||||
static function (array $a) use ($now, $privateCats): bool {
|
||||
if (strtotime((string)($a['published_at'] ?? '')) > $now) {
|
||||
return false;
|
||||
}
|
||||
$cat = trim($a['category'] ?? '');
|
||||
return $cat === '' || !in_array($cat, $privateCats, true);
|
||||
}
|
||||
));
|
||||
|
||||
// ─── Pagination curseur ──────────────────────────────────────────────────────
|
||||
$after = trim($_GET['after'] ?? '');
|
||||
$offset = 0;
|
||||
if ($after !== '') {
|
||||
foreach ($all as $i => $a) {
|
||||
if ($a['uuid'] === $after) {
|
||||
$offset = $i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$items = array_slice($all, $offset, FEED_PAGE_SIZE);
|
||||
$nextCursor = (count($all) > $offset + FEED_PAGE_SIZE)
|
||||
? ($all[$offset + FEED_PAGE_SIZE - 1]['uuid'] ?? null)
|
||||
: null;
|
||||
|
||||
$feedUrl = $base . '/feed';
|
||||
$feedNextUrl = $nextCursor !== null ? $base . '/feed/' . $nextCursor : null;
|
||||
|
||||
// ─── lastBuildDate ───────────────────────────────────────────────────────────
|
||||
$lastBuild = '';
|
||||
foreach ($all as $a) {
|
||||
$ts = (int)strtotime((string)($a['updated_at'] ?? $a['published_at'] ?? ''));
|
||||
if ($ts > (int)strtotime($lastBuild ?: '1970-01-01')) {
|
||||
$lastBuild = date(DATE_RSS, $ts);
|
||||
}
|
||||
}
|
||||
if ($lastBuild === '') {
|
||||
$lastBuild = date(DATE_RSS);
|
||||
}
|
||||
|
||||
header('Content-Type: application/rss+xml; charset=UTF-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
?>
|
||||
<rss version="2.0"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom"
|
||||
xmlns:fh="http://purl.org/syndication/history/1.0">
|
||||
<channel>
|
||||
<title><?= htmlspecialchars(siteTitle()) ?></title>
|
||||
<link><?= htmlspecialchars($base) ?></link>
|
||||
<description><?= htmlspecialchars(siteClaim()) ?></description>
|
||||
<language><?= htmlspecialchars(siteLang()) ?></language>
|
||||
<lastBuildDate><?= htmlspecialchars($lastBuild) ?></lastBuildDate>
|
||||
|
||||
<atom:link href="<?= htmlspecialchars($feedUrl) ?>" rel="self" type="application/rss+xml"/>
|
||||
|
||||
<?php if ($offset > 0): ?>
|
||||
<atom:link href="<?= htmlspecialchars($feedUrl) ?>" rel="first" type="application/rss+xml"/>
|
||||
<?php endif; ?>
|
||||
<?php if ($feedNextUrl !== null): ?>
|
||||
<atom:link href="<?= htmlspecialchars($feedNextUrl) ?>" rel="next" type="application/rss+xml"/>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($feedNextUrl !== null || $offset > 0): ?>
|
||||
<fh:archive/>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($items as $article):
|
||||
$pubDate = date(DATE_RSS, (int)strtotime((string)($article['published_at'] ?? $article['created_at'] ?? '')));
|
||||
$link = $base . '/post/' . rawurlencode($article['slug'] ?? '');
|
||||
$title = htmlspecialchars($article['title'] ?? '', ENT_XML1);
|
||||
$plain = preg_replace('/\s+/', ' ', strip_tags($Parsedown->text($article['content'] ?? '')));
|
||||
$desc = htmlspecialchars(mb_strimwidth(trim((string)$plain), 0, 300, '…'), ENT_XML1);
|
||||
$guid = htmlspecialchars($base . '/post/' . rawurlencode($article['slug'] ?? ''), ENT_XML1);
|
||||
?>
|
||||
<item>
|
||||
<title><?= $title ?></title>
|
||||
<link><?= htmlspecialchars($link) ?></link>
|
||||
<description><?= $desc ?></description>
|
||||
<pubDate><?= htmlspecialchars($pubDate) ?></pubDate>
|
||||
<guid isPermaLink="true"><?= $guid ?></guid>
|
||||
</item>
|
||||
<?php endforeach; ?>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
define('BASE_PATH', realpath(__DIR__ . '/../'));
|
||||
|
||||
$uuid = $_GET['uuid'] ?? '';
|
||||
$name = $_GET['name'] ?? '';
|
||||
|
||||
// Valide le format UUID v4
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $uuid)) {
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Sécurise le nom de fichier (pas de traversal)
|
||||
$name = basename($name);
|
||||
if ($name === '' || $name[0] === '.') {
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$path = BASE_PATH . '/data/' . $uuid . '/files/' . $name;
|
||||
|
||||
if (!is_file($path)) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$mime = mime_content_type($path) ?: 'application/octet-stream';
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . filesize($path));
|
||||
header('Cache-Control: public, max-age=31536000, immutable');
|
||||
readfile($path);
|
||||
exit;
|
||||
+3140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
// public/login/config.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
|
||||
if (!defined('BASE_PATH')) {
|
||||
require_once dirname(__DIR__, 2) . '/config/config.php';
|
||||
}
|
||||
require_once BASE_PATH . '/includes/db.php';
|
||||
require_once BASE_PATH . '/includes/csrf.php';
|
||||
require_once BASE_PATH . '/src/ConfigRepo.php';
|
||||
|
||||
Session::startSecure(getenv('SESSION_NAME') ?: 'SID_IDENT');
|
||||
ensure_admin();
|
||||
csrf_start();
|
||||
|
||||
$cfg = config_repo_get();
|
||||
$msg = null;
|
||||
$err = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!csrf_check($_POST['csrf'] ?? '')) {
|
||||
http_response_code(403);
|
||||
exit('CSRF');
|
||||
}
|
||||
|
||||
$in = [
|
||||
'oidc_issuer' => trim((string)($_POST['oidc_issuer'] ?? '')),
|
||||
'oidc_name' => trim((string)($_POST['oidc_name'] ?? '')),
|
||||
'oidc_client_id' => trim((string)($_POST['oidc_client_id'] ?? '')),
|
||||
'oidc_client_secret' => trim((string)($_POST['oidc_client_secret'] ?? '')),
|
||||
'oidc_redirect_uri' => trim((string)($_POST['oidc_redirect_uri'] ?? '')),
|
||||
];
|
||||
|
||||
// validations simples
|
||||
if ($in['allow_oidc']) {
|
||||
if ($in['oidc_issuer'] === '' || $in['oidc_client_id'] === '' || $in['oidc_client_secret'] === '' || $in['oidc_redirect_uri'] === '') {
|
||||
$err = 'OIDC activé mais champs incomplets.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$err) {
|
||||
config_repo_save($in);
|
||||
|
||||
// Mise à jour du .env
|
||||
$envPairs = [
|
||||
'OIDC_ISSUER' => $in['oidc_issuer'] !== '' ? $in['oidc_issuer'] : null,
|
||||
'OIDC_NAME' => $in['oidc_name'] !== '' ? $in['oidc_name'] : null,
|
||||
'OIDC_CLIENT_ID' => $in['oidc_client_id'] !== '' ? $in['oidc_client_id'] : null,
|
||||
'OIDC_CLIENT_SECRET' => $in['oidc_client_secret'] !== '' ? $in['oidc_client_secret'] : null,
|
||||
'OIDC_REDIRECT_URI' => $in['oidc_redirect_uri'] !== '' ? $in['oidc_redirect_uri'] : null,
|
||||
];
|
||||
env_set_pairs(BASE_PATH.'/.env', $envPairs);
|
||||
|
||||
$cfg = config_repo_get();
|
||||
$msg = 'Configuration enregistrée.';
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Configuration authentification</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link href="/assets/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-4">
|
||||
<h1 class="h3 mb-3">Configuration authentification</h1>
|
||||
|
||||
<?php if ($msg): ?><div class="alert alert-success"><?=htmlspecialchars($msg)?></div><?php endif; ?>
|
||||
<?php if ($err): ?><div class="alert alert-danger"><?=htmlspecialchars($err)?></div><?php endif; ?>
|
||||
|
||||
<form method="post" class="card p-3">
|
||||
<input type="hidden" name="csrf" value="<?=htmlspecialchars(csrf_token())?>">
|
||||
<fieldset class="mb-3">
|
||||
<legend class="h5">Modes de connexion</legend>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="allow_password" name="allow_password" <?= $cfg['allow_password'] ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="allow_password">Login + mot de passe autorisé</label>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="allow_oidc" name="allow_oidc" <?= $cfg['allow_oidc'] ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="allow_oidc">Connexion OIDC autorisée</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-3">
|
||||
<legend class="h5">Inscriptions</legend>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" id="reg_open" name="registrations_open" value="open" <?= $cfg['registrations_open'] ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="reg_open">Ouvertes à tous</label>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="radio" id="reg_closed" name="registrations_open" value="closed" <?= !$cfg['registrations_open'] ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="reg_closed">Fermées</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-3">
|
||||
<legend class="h5">Paramètres OIDC</legend>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Issuer URL</label>
|
||||
<input type="url" name="oidc_issuer" class="form-control" value="<?=htmlspecialchars((string)$cfg['oidc_issuer'])?>" placeholder="https://idp.example.com/realms/xxx">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Nom affiché</label>
|
||||
<input type="text" name="oidc_name" class="form-control" value="<?=htmlspecialchars((string)$cfg['oidc_name'])?>" placeholder="Keycloak, Azure AD…">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Client ID</label>
|
||||
<input type="text" name="oidc_client_id" class="form-control" value="<?=htmlspecialchars((string)$cfg['oidc_client_id'])?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Client Secret</label>
|
||||
<input type="password" name="oidc_client_secret" class="form-control" value="<?=htmlspecialchars((string)$cfg['oidc_client_secret'])?>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Redirect URI</label>
|
||||
<input type="url" name="oidc_redirect_uri" class="form-control" value="<?=htmlspecialchars((string)$cfg['oidc_redirect_uri'])?>" placeholder="<?=htmlspecialchars(rtrim(getenv('APP_URL') ?: '', '/').'/oidc/callback')?>">
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-text mt-2">Ces champs alimentent le fichier <code>.env</code>.</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
// projet : mug.a5l.fr
|
||||
// fichier : pages/login/index.php
|
||||
// version : 20251011
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Csrf;
|
||||
|
||||
// --- Helpers AVANT tout usage ---
|
||||
if (!function_exists('env')) {
|
||||
function env(string $key, ?string $default = null): ?string
|
||||
{
|
||||
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
|
||||
return (string)$_ENV[$key];
|
||||
}
|
||||
$v = getenv($key);
|
||||
if ($v !== false && $v !== '') {
|
||||
return (string)$v;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
if (!function_exists('db')) {
|
||||
function db(): \PDO
|
||||
{
|
||||
return \App\Infrastructure\Database::get();
|
||||
}
|
||||
}
|
||||
if (!function_exists('url')) {
|
||||
function url(string $path = '/'): string
|
||||
{
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
return $scheme . '://' . $host . $path;
|
||||
}
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
||||
require_once dirname(__DIR__, 2) . '/config/config.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/SiteSettings.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/mailer.php';
|
||||
|
||||
// Paramètres (env)
|
||||
$ttlMin = (int) env('MAGIC_LINK_TTL_MINUTES', '30');
|
||||
$coolMin = (int) env('MAGIC_COOLDOWN_MINUTES', '5');
|
||||
$winHours = (int) env('MAGIC_WINDOW_HOURS', '12');
|
||||
$maxPerWin = (int) env('MAGIC_MAX_PER_WINDOW', '5');
|
||||
|
||||
// --- return_to ---
|
||||
$defaultReturn = '/';
|
||||
$sanitize = static function (string $url) use ($defaultReturn): string {
|
||||
$url = trim($url);
|
||||
if ($url === '' || !str_starts_with($url, '/')) {
|
||||
return $defaultReturn;
|
||||
}
|
||||
return $url;
|
||||
};
|
||||
$returnTo = $sanitize((string)($_GET['return_to'] ?? ($_SERVER['HTTP_REFERER'] ?? $defaultReturn)));
|
||||
|
||||
// --- OIDC ---
|
||||
$oidcEnabled = (bool) (env('OIDC_ISSUER') && env('OIDC_CLIENT_ID'));
|
||||
$oidcLoginUrl = '/login/oidc' . ($returnTo ? ('?return_to=' . urlencode($returnTo)) : '');
|
||||
$oidcAuto = (isset($_GET['sso']) && $_GET['sso'] === '1') || (env('OIDC_AUTO', '0') === '1');
|
||||
if ($oidcEnabled && $oidcAuto) {
|
||||
header('Location: ' . $oidcLoginUrl, true, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- form: demande de lien magique ---
|
||||
$errors = [];
|
||||
$okMsg = '';
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
if (!Csrf::validate($_POST['_csrf'] ?? null)) {
|
||||
http_response_code(400);
|
||||
$errors[] = 'Jeton CSRF invalide.';
|
||||
} else {
|
||||
$email = strtolower(trim((string)($_POST['email'] ?? '')));
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = 'Adresse email invalide.';
|
||||
} else {
|
||||
// rate limit simple par email et IP
|
||||
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
if (strpos($ip, ',') !== false) {
|
||||
$ip = trim(explode(',', $ip, 2)[0]);
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// purge expirés / consommés
|
||||
$pdo->prepare('DELETE FROM auth_magic_links WHERE email = :e AND (expires_at < NOW() OR consumed_at IS NOT NULL)')
|
||||
->execute([':e' => $email]);
|
||||
|
||||
// 1) cooldown: refuser si un envoi récent < coolMin
|
||||
$sql = sprintf(
|
||||
"SELECT 1 FROM auth_magic_links
|
||||
WHERE email = :e AND created_at >= NOW() - INTERVAL '%d minutes'
|
||||
LIMIT 1",
|
||||
max(0, $coolMin)
|
||||
);
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([':e' => $email]);
|
||||
if ($stmt->fetchColumn()) {
|
||||
throw new RuntimeException(sprintf('Un lien vient d’être envoyé. Réessayez dans %d min.
|
||||
Si vous ne recevez toujours rien, envisagez d\'utiliser un fournisseur de messagerie respectueux de la vie privée,
|
||||
comme Proton Mail, Tuta, Posteo, Mailfence ou Infomaniak, qui garantissent un hébergement européen
|
||||
et ne revendent pas vos données. -- Cédrix, le 11/10/2025', $coolMin));
|
||||
}
|
||||
|
||||
// 2) plafond: maxPerWin liens sur winHours
|
||||
$sql = sprintf(
|
||||
"SELECT COUNT(*) FROM auth_magic_links
|
||||
WHERE email = :e AND created_at >= NOW() - INTERVAL '%d hours'",
|
||||
max(0, $winHours)
|
||||
);
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([':e' => $email]);
|
||||
if ((int)$stmt->fetchColumn() >= $maxPerWin) {
|
||||
throw new RuntimeException('Quota atteint. Réessayez plus tard.');
|
||||
}
|
||||
|
||||
// Génère et enregistre le lien avec TTL ttlMin
|
||||
$raw = random_bytes(32);
|
||||
$token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
||||
|
||||
$sql = sprintf(
|
||||
"INSERT INTO auth_magic_links (id,email,token,created_at,expires_at,ip,user_agent,return_to)
|
||||
VALUES (gen_random_uuid(), :email, :token, NOW(), NOW() + INTERVAL '%d minutes', :ip, :ua, :rt)
|
||||
RETURNING token",
|
||||
max(1, $ttlMin)
|
||||
);
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
':email' => $email,
|
||||
':token' => $token,
|
||||
':ip' => $ip,
|
||||
':ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512),
|
||||
':rt' => ($returnTo !== '/' ? $returnTo : null),
|
||||
]);
|
||||
$pdo->commit();
|
||||
|
||||
// construit l'URL et envoie le mail
|
||||
$magicUrl = url('/login/magic.php') . '?token=' . urlencode($token);
|
||||
$siteName = htmlspecialchars(env('SMTP_FROM_NAME', 'varlog'), ENT_QUOTES);
|
||||
$html = <<<HTML
|
||||
<p>Bonjour,</p>
|
||||
<p>Cliquez sur le lien ci-dessous pour vous connecter à <strong>{$siteName}</strong> :</p>
|
||||
<p><a href="{$magicUrl}">{$magicUrl}</a></p>
|
||||
<p>Ce lien est valable {$ttlMin} minutes et ne peut être utilisé qu'une seule fois.</p>
|
||||
<p>Si vous n'avez pas demandé ce lien, ignorez cet email.</p>
|
||||
HTML;
|
||||
envoyer_mail_smtp(
|
||||
$email,
|
||||
"Votre lien de connexion — {$siteName}",
|
||||
$html,
|
||||
null,
|
||||
['bypass_rate_limit' => true]
|
||||
);
|
||||
|
||||
// message utilisateur
|
||||
$okMsg = 'Un lien vient d’être envoyé. Vérifiez votre boîte de réception et le dossier spam/indésirables.';
|
||||
|
||||
} catch (\Throwable $ex) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$errors[] = $ex->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$csrf = Csrf::token();
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<style>.or-sep{display:flex;align-items:center;gap:.75rem;margin:1.25rem 0}.or-sep::before,.or-sep::after{content:"";flex:1;height:1px;background:#ddd}</style>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-sm-10 col-md-7 col-lg-5">
|
||||
<h1 class="mb-1">Connexion</h1>
|
||||
<p class="text-muted mb-4">Vous n'êtes pas connecté.</p>
|
||||
|
||||
<?php foreach ($errors as $e): ?>
|
||||
<div class="alert alert-danger"><?= htmlspecialchars($e, ENT_QUOTES) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($okMsg): ?>
|
||||
<div class="alert alert-success"><?= htmlspecialchars($okMsg, ENT_QUOTES) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($oidcEnabled): ?>
|
||||
<div class="mb-3">
|
||||
<a class="btn btn-primary w-100" href="<?= htmlspecialchars($oidcLoginUrl, ENT_QUOTES) ?>">Se connecter avec A5L</a>
|
||||
</div>
|
||||
<div class="or-sep"><span>ou</span></div>
|
||||
<?php else: ?>
|
||||
<div class="alert alert-warning">A5L indisponible : configurez <code>OIDC_ISSUER</code> et <code>OIDC_CLIENT_ID</code> dans <code>.env</code>.</div>
|
||||
<div class="or-sep"><span>ou</span></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/login<?= $returnTo ? ('?return_to=' . urlencode($returnTo)) : '' ?>" novalidate>
|
||||
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($csrf, ENT_QUOTES) ?>">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="email">Adresse email</label>
|
||||
<input class="form-control" id="email" type="email" name="email" required autocomplete="email" inputmode="email" placeholder="vous@domaine.tld">
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Recevoir un lien magique</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
$title = 'Connexion';
|
||||
include BASE_PATH . '/templates/layout.php';
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
// projet : mug.a5l.fr
|
||||
// fichier : pages/login/magic.php
|
||||
// version : 20251011
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
||||
require_once dirname(__DIR__, 2) . '/config/config.php';
|
||||
|
||||
// si tu as un service pour ouvrir une session
|
||||
|
||||
if (!function_exists('db')) {
|
||||
function db(): PDO
|
||||
{
|
||||
return \App\Infrastructure\Database::get();
|
||||
}
|
||||
}
|
||||
if (!function_exists('url')) {
|
||||
function url(string $path = '/'): string
|
||||
{
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
return $scheme . '://' . $host . $path;
|
||||
}
|
||||
}
|
||||
|
||||
$token = (string)($_GET['token'] ?? '');
|
||||
if ($token === '' || preg_match('/[^A-Za-z0-9\-\_]/', $token)) {
|
||||
http_response_code(400);
|
||||
exit('Lien invalide.');
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// récupère lien non consommé et non expiré
|
||||
$sql = 'SELECT id, email, token, created_at, expires_at, consumed_at, return_to
|
||||
FROM auth_magic_links
|
||||
WHERE token = :t
|
||||
FOR UPDATE';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([':t' => $token]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new RuntimeException('Lien inconnu.');
|
||||
}
|
||||
if ($row['consumed_at'] !== null) {
|
||||
throw new RuntimeException('Lien déjà utilisé.');
|
||||
}
|
||||
if (strtotime((string)$row['expires_at']) < time()) {
|
||||
throw new RuntimeException('Lien expiré.');
|
||||
}
|
||||
|
||||
// consomme le lien
|
||||
$pdo->prepare('UPDATE auth_magic_links SET consumed_at = NOW() WHERE id = :id')->execute([':id' => $row['id']]);
|
||||
$pdo->commit();
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_email'] = strtolower(trim((string)$row['email']));
|
||||
|
||||
$dest = $row['return_to'] ?? '/';
|
||||
// sécurité: ne renvoyer que des chemins relatifs
|
||||
if (!is_string($dest) || !str_starts_with($dest, '/')) {
|
||||
$dest = '/';
|
||||
}
|
||||
header('Location: ' . $dest, true, 303);
|
||||
exit;
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
http_response_code(400);
|
||||
echo htmlspecialchars($e->getMessage(), ENT_QUOTES);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
// proxy vers pages/oidc/start.php avec flow=login
|
||||
$_GET['flow'] = 'login';
|
||||
require_once dirname(__DIR__) . '/oidc/start.php';
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
define('BASE_PATH', realpath(__DIR__ . '/../'));
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once BASE_PATH . '/src/auth.php';
|
||||
require_once BASE_PATH . '/config/config.php';
|
||||
|
||||
$logoutUrl = ssoLogoutUrl();
|
||||
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(
|
||||
session_name(),
|
||||
'',
|
||||
time() - 42000,
|
||||
$params['path'],
|
||||
$params['domain'],
|
||||
$params['secure'],
|
||||
$params['httponly']
|
||||
);
|
||||
}
|
||||
session_destroy();
|
||||
|
||||
header('Location: ' . $logoutUrl, true, 303);
|
||||
exit;
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
||||
require_once dirname(__DIR__, 2) . '/config/config.php';
|
||||
|
||||
if (!function_exists('env')) {
|
||||
function env(string $key, ?string $default = null): ?string
|
||||
{
|
||||
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
|
||||
return (string)$_ENV[$key];
|
||||
}
|
||||
$v = getenv($key);
|
||||
if ($v !== false && $v !== '') {
|
||||
return (string)$v;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
$debug = (env('APP_DEBUG', '0') === '1');
|
||||
|
||||
$OIDC_ISSUER = rtrim((string)(env('OIDC_ISSUER') ?? ''), '/');
|
||||
$OIDC_CLIENT_ID = (string)(env('OIDC_CLIENT_ID') ?? '');
|
||||
$OIDC_CLIENT_SECRET = (string)(env('OIDC_CLIENT_SECRET') ?? '');
|
||||
$OIDC_REDIRECT_URI = (string)(env('OIDC_REDIRECT_URI') ?: url('oidc/callback'));
|
||||
|
||||
if (!$OIDC_ISSUER || !$OIDC_CLIENT_ID || !$OIDC_REDIRECT_URI) {
|
||||
http_response_code(500);
|
||||
echo $debug ? 'OIDC config manquante.' : 'Erreur.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$tokenEndpoint = $OIDC_ISSUER . '/protocol/openid-connect/token';
|
||||
$userInfoEndpoint = $OIDC_ISSUER . '/protocol/openid-connect/userinfo';
|
||||
|
||||
if (!isset($_GET['state'], $_SESSION['oidc_state']) || !hash_equals((string)$_SESSION['oidc_state'], (string)$_GET['state'])) {
|
||||
http_response_code(400);
|
||||
echo $debug ? 'State invalide.' : 'Requête invalide.';
|
||||
exit;
|
||||
}
|
||||
unset($_SESSION['oidc_state']);
|
||||
|
||||
if (empty($_GET['code'])) {
|
||||
http_response_code(400);
|
||||
echo $debug ? 'Code manquant.' : 'Requête invalide.';
|
||||
exit;
|
||||
}
|
||||
$code = (string)$_GET['code'];
|
||||
|
||||
$codeVerifier = $_SESSION['oidc_code_verifier'] ?? null;
|
||||
unset($_SESSION['oidc_code_verifier'], $_SESSION['oidc_nonce']);
|
||||
|
||||
if (!$codeVerifier) {
|
||||
http_response_code(400);
|
||||
echo $debug ? 'PKCE code_verifier manquant.' : 'Requête invalide.';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Échange code → tokens
|
||||
$post = [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $OIDC_REDIRECT_URI,
|
||||
'client_id' => $OIDC_CLIENT_ID,
|
||||
'code_verifier' => $codeVerifier,
|
||||
];
|
||||
if ($OIDC_CLIENT_SECRET !== '') {
|
||||
$post['client_secret'] = $OIDC_CLIENT_SECRET;
|
||||
}
|
||||
|
||||
$ch = curl_init($tokenEndpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($post, '', '&', PHP_QUERY_RFC3986),
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
$tokenResponse = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($tokenResponse === false || $httpCode !== 200) {
|
||||
http_response_code(500);
|
||||
echo $debug ? 'Échec échange token : ' . htmlspecialchars($curlErr ?: (string)$tokenResponse) : 'Erreur d\'authentification.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$tokens = json_decode((string)$tokenResponse, true) ?: [];
|
||||
$accessToken = $tokens['access_token'] ?? null;
|
||||
$idToken = $tokens['id_token'] ?? null;
|
||||
|
||||
if (!$accessToken) {
|
||||
http_response_code(500);
|
||||
echo $debug ? 'Access token manquant.' : 'Erreur d\'authentification.';
|
||||
exit;
|
||||
}
|
||||
|
||||
// UserInfo
|
||||
$ch = curl_init($userInfoEndpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accessToken],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
$userInfoResponse = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($userInfoResponse === false || $httpCode !== 200) {
|
||||
http_response_code(500);
|
||||
echo $debug ? 'Échec UserInfo.' : 'Erreur d\'authentification.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$claims = json_decode((string)$userInfoResponse, true) ?: [];
|
||||
$email = $claims['email'] ?? null;
|
||||
|
||||
// Fallback : lire l'email depuis le payload du id_token
|
||||
if (!$email && $idToken && substr_count($idToken, '.') === 2) {
|
||||
[, $p, ] = explode('.', $idToken, 3);
|
||||
$payload = json_decode((string)base64_decode(strtr($p, '-_', '+/'), true), true);
|
||||
if (is_array($payload) && !empty($payload['email'])) {
|
||||
$email = $payload['email'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$email) {
|
||||
http_response_code(400);
|
||||
echo $debug ? 'Email non fourni par l\'IdP.' : 'Impossible de récupérer votre email.';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Nom d'affichage depuis les claims SSO
|
||||
$ssoName = '';
|
||||
if (!empty($claims['given_name']) || !empty($claims['family_name'])) {
|
||||
$ssoName = trim(($claims['given_name'] ?? '') . ' ' . ($claims['family_name'] ?? ''));
|
||||
} elseif (!empty($claims['name'])) {
|
||||
$ssoName = trim($claims['name']);
|
||||
} elseif (!empty($claims['preferred_username'])) {
|
||||
$ssoName = trim($claims['preferred_username']);
|
||||
}
|
||||
|
||||
// Charge le nom personnalisé depuis la base (prioritaire sur le SSO)
|
||||
require_once dirname(__DIR__, 2) . '/src/auth.php';
|
||||
$pdo = dbPdo();
|
||||
$dbName = '';
|
||||
if ($pdo) {
|
||||
try {
|
||||
$st = $pdo->prepare('SELECT display_name FROM user_profiles WHERE email = :e');
|
||||
$st->execute([':e' => strtolower(trim($email))]);
|
||||
$dbName = (string)($st->fetchColumn() ?: '');
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
if ($dbName !== '') {
|
||||
// Nom personnalisé existant → on le conserve, le SSO ne l'écrase pas
|
||||
$sessionName = $dbName;
|
||||
} else {
|
||||
// Première connexion → on persiste le nom SSO
|
||||
$sessionName = $ssoName;
|
||||
if ($ssoName !== '' && $pdo) {
|
||||
try {
|
||||
$pdo->prepare(
|
||||
'INSERT INTO user_profiles (email, display_name, updated_at)
|
||||
VALUES (:e, :n, now())
|
||||
ON CONFLICT (email) DO NOTHING'
|
||||
)->execute([':e' => strtolower(trim($email)), ':n' => $ssoName]);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ouvre la session authentifiée
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_email'] = strtolower(trim($email));
|
||||
$_SESSION['user_display_name'] = $sessionName;
|
||||
$_SESSION['oidc'] = [
|
||||
'issuer' => $OIDC_ISSUER,
|
||||
'sub' => $claims['sub'] ?? null,
|
||||
'access_token' => $accessToken,
|
||||
'id_token' => $idToken,
|
||||
'expires_at' => time() + (int)($tokens['expires_in'] ?? 3600),
|
||||
];
|
||||
|
||||
$target = $_SESSION['oidc_return_to'] ?? '/';
|
||||
unset($_SESSION['oidc_return_to'], $_SESSION['oidc_flow']);
|
||||
if (!is_string($target) || $target === '' || $target[0] !== '/') {
|
||||
$target = '/';
|
||||
}
|
||||
header('Location: ' . $target, true, 303);
|
||||
exit;
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
// projet : mug.a5l.fr
|
||||
// fichier : pages/oidc/me.php
|
||||
// version : 20251005
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
||||
require_once dirname(__DIR__, 2) . '/config/config.php';
|
||||
|
||||
function maskToken(?string $t): string
|
||||
{
|
||||
if (!$t) {
|
||||
return '';
|
||||
}
|
||||
$len = strlen($t);
|
||||
if ($len <= 12) {
|
||||
return str_repeat('•', $len);
|
||||
}
|
||||
return substr($t, 0, 6) . str_repeat('•', max(0, $len - 12)) . substr($t, -6);
|
||||
}
|
||||
function b64url_decode_str(string $s): string|false
|
||||
{
|
||||
$s = strtr($s, '-_', '+/');
|
||||
$pad = strlen($s) % 4;
|
||||
if ($pad) {
|
||||
$s .= str_repeat('=', 4 - $pad);
|
||||
}
|
||||
return base64_decode($s, true);
|
||||
}
|
||||
function decode_jwt(string $jwt): array
|
||||
{
|
||||
if (substr_count($jwt, '.') !== 2) {
|
||||
return [];
|
||||
}
|
||||
[, $payload, ] = explode('.', $jwt, 3);
|
||||
$json = b64url_decode_str($payload);
|
||||
if ($json === false) {
|
||||
return [];
|
||||
}
|
||||
$arr = json_decode($json, true);
|
||||
return is_array($arr) ? $arr : [];
|
||||
}
|
||||
|
||||
$env = static function (string $k, ?string $d = null): ?string {
|
||||
if (array_key_exists($k, $_ENV) && $_ENV[$k] !== '') {
|
||||
return (string)$_ENV[$k];
|
||||
}
|
||||
$v = getenv($k);
|
||||
if ($v !== false && $v !== '') {
|
||||
return (string)$v;
|
||||
}
|
||||
return $d;
|
||||
};
|
||||
|
||||
$debugEnabled = ($env('DEBUG_OIDC') === 'true') || (isset($_GET['debug']) && $_GET['debug'] === '1');
|
||||
|
||||
$oidc = $_SESSION['oidc'] ?? [];
|
||||
$claims = $_SESSION['oidc_userinfo'] ?? [];
|
||||
$issuer = (string)($oidc['issuer'] ?? '');
|
||||
$sub = (string)($oidc['sub'] ?? '');
|
||||
$idToken = (string)($oidc['id_token'] ?? '');
|
||||
$accTok = (string)($oidc['access_token'] ?? '');
|
||||
$expAt = (int) ($oidc['expires_at'] ?? 0);
|
||||
|
||||
$now = time();
|
||||
$left = $expAt ? max(0, $expAt - $now) : null;
|
||||
|
||||
// Fallback 1 : si pas de claims userinfo, essayer de les lire dans l'id_token
|
||||
if (!$claims && $idToken) {
|
||||
$claims = decode_jwt($idToken);
|
||||
}
|
||||
|
||||
// Fallback 2 (debug) : tenter un appel live au UserInfo si access_token présent
|
||||
if ($debugEnabled && $claims === [] && $accTok && $issuer) {
|
||||
$userinfoEndpoint = rtrim($issuer, '/') . '/protocol/openid-connect/userinfo';
|
||||
$ch = curl_init($userinfoEndpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accTok],
|
||||
CURLOPT_TIMEOUT => 6,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($resp !== false && $code === 200) {
|
||||
$tmp = json_decode((string)$resp, true);
|
||||
if (is_array($tmp)) {
|
||||
$claims = $tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extraire rôles groupés (Keycloak)
|
||||
$roles = [];
|
||||
if (isset($claims['realm_access']['roles']) && is_array($claims['realm_access']['roles'])) {
|
||||
$roles = array_merge($roles, $claims['realm_access']['roles']);
|
||||
}
|
||||
if (isset($claims['resource_access']) && is_array($claims['resource_access'])) {
|
||||
foreach ($claims['resource_access'] as $clientId => $data) {
|
||||
if (!empty($data['roles']) && is_array($data['roles'])) {
|
||||
foreach ($data['roles'] as $r) {
|
||||
$roles[] = $clientId . ':' . $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$roles = array_values(array_unique($roles));
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OIDC • Profil</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="/assets/bootstrap/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.kv { display:grid; grid-template-columns: 220px 1fr; gap:.5rem 1rem; }
|
||||
.kv dt { font-weight: 600; color: #555; }
|
||||
pre { background: #f8f9fa; padding: .75rem; border-radius: .5rem; overflow:auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="container py-4">
|
||||
<h1 class="mb-4">Profil A5L</h1>
|
||||
|
||||
<?php if (!$oidc): ?>
|
||||
<div class="alert alert-warning">Aucune session A5L. Connecte-toi via A5L d'abord.</div>
|
||||
<?php else: ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Session / Jetons</div>
|
||||
<div class="card-body">
|
||||
<dl class="kv">
|
||||
<dt>Issuer</dt><dd><?= htmlspecialchars($issuer) ?></dd>
|
||||
<dt>Subject (sub)</dt><dd><?= htmlspecialchars($sub) ?></dd>
|
||||
<dt>ID Token</dt><dd><code><?= htmlspecialchars(maskToken($idToken)) ?></code></dd>
|
||||
<dt>Access Token</dt><dd><code><?= htmlspecialchars(maskToken($accTok)) ?></code></dd>
|
||||
<dt>Expire à</dt><dd><?= $expAt ? date('Y-m-d H:i:s', $expAt) : '—' ?></dd>
|
||||
<dt>Temps restant</dt><dd><?= $left !== null ? ($left . ' s') : '—' ?></dd>
|
||||
</dl>
|
||||
<?php if ($debugEnabled): ?>
|
||||
<details class="mt-3">
|
||||
<summary>Voir jetons non masqués (danger)</summary>
|
||||
<div class="mt-2">
|
||||
<div><strong>ID Token</strong></div>
|
||||
<pre><?= htmlspecialchars($idToken) ?></pre>
|
||||
<div><strong>Access Token</strong></div>
|
||||
<pre><?= htmlspecialchars($accTok) ?></pre>
|
||||
</div>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Claims</div>
|
||||
<div class="card-body">
|
||||
<dl class="kv">
|
||||
<dt>Email</dt><dd><?= htmlspecialchars((string)($claims['email'] ?? '')) ?></dd>
|
||||
<dt>Preferred username</dt><dd><?= htmlspecialchars((string)($claims['preferred_username'] ?? '')) ?></dd>
|
||||
<dt>Given name</dt><dd><?= htmlspecialchars((string)($claims['given_name'] ?? '')) ?></dd>
|
||||
<dt>Family name</dt><dd><?= htmlspecialchars((string)($claims['family_name'] ?? '')) ?></dd>
|
||||
<dt>Name</dt><dd><?= htmlspecialchars((string)($claims['name'] ?? '')) ?></dd>
|
||||
<dt>Locale</dt><dd><?= htmlspecialchars((string)($claims['locale'] ?? '')) ?></dd>
|
||||
<dt>Rôles</dt>
|
||||
<dd>
|
||||
<?php if ($roles): ?>
|
||||
<ul class="mb-0">
|
||||
<?php foreach ($roles as $r): ?>
|
||||
<li><?= htmlspecialchars((string)$r) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php else: ?>
|
||||
—
|
||||
<?php endif; ?>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<?php if ($debugEnabled): ?>
|
||||
<h6 class="mt-3">Claims (JSON complet)</h6>
|
||||
<pre><?= htmlspecialchars(json_encode($claims, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) ?></pre>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$claims): ?>
|
||||
<div class="alert alert-info mt-3">
|
||||
Aucun claim reçu. Vérifie que ton <code>callback</code> remplit bien <code>$_SESSION['oidc_userinfo']</code> ou que l’<code>ID Token</code> contient les champs.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<a class="btn btn-secondary" href="<?= htmlspecialchars(url('')) ?>">Retour</a>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
||||
require_once dirname(__DIR__, 2) . '/config/config.php';
|
||||
|
||||
if (!function_exists('env')) {
|
||||
function env(string $key, ?string $default = null): ?string
|
||||
{
|
||||
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
|
||||
return (string)$_ENV[$key];
|
||||
}
|
||||
$v = getenv($key);
|
||||
if ($v !== false && $v !== '') {
|
||||
return (string)$v;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
$flow = $_GET['flow'] ?? 'login'; // 'login' ou 'register'
|
||||
if (!in_array($flow, ['login','register'], true)) {
|
||||
$flow = 'login';
|
||||
}
|
||||
|
||||
// return_to (URL relative uniquement)
|
||||
$defaultReturn = '/';
|
||||
$rawReturn = $_GET['return_to'] ?? ($_SERVER['HTTP_REFERER'] ?? $defaultReturn);
|
||||
$returnTo = (is_string($rawReturn) && str_starts_with($rawReturn, '/')) ? $rawReturn : $defaultReturn;
|
||||
|
||||
// Mémorise flow + cible
|
||||
$_SESSION['oidc_flow'] = $flow;
|
||||
$_SESSION['oidc_return_to'] = $returnTo;
|
||||
|
||||
// --- OIDC conf ---
|
||||
$issuer = rtrim((string)env('OIDC_ISSUER', ''), '/');
|
||||
$clientId = (string)env('OIDC_CLIENT_ID', '');
|
||||
$redirectUri = (string)(env('OIDC_REDIRECT_URI') ?: url('oidc/callback'));
|
||||
if (!$issuer || !$clientId || !$redirectUri) {
|
||||
http_response_code(500);
|
||||
echo 'OIDC non configuré (OIDC_ISSUER / OIDC_CLIENT_ID / OIDC_REDIRECT_URI).';
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Endpoints & PKCE ---
|
||||
$authEndpoint = $issuer . '/protocol/openid-connect/auth';
|
||||
$state = bin2hex(random_bytes(16));
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$codeVerifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
||||
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$_SESSION['oidc_state'] = $state;
|
||||
$_SESSION['oidc_nonce'] = $nonce;
|
||||
$_SESSION['oidc_code_verifier'] = $codeVerifier;
|
||||
|
||||
// --- URL d’auth ---
|
||||
$params = [
|
||||
'response_type' => 'code',
|
||||
'client_id' => $clientId,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'scope' => 'openid email profile',
|
||||
'state' => $state,
|
||||
'nonce' => $nonce,
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
'ui_locales' => 'fr',
|
||||
];
|
||||
|
||||
header('Location: ' . $authEndpoint . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986), true, 302);
|
||||
exit;
|
||||
@@ -0,0 +1,16 @@
|
||||
User-agent: *
|
||||
Disallow: /?action=edit
|
||||
Disallow: /?action=create
|
||||
Disallow: /?action=admin
|
||||
Disallow: /?action=delete
|
||||
Disallow: /?action=diff
|
||||
Disallow: /?action=categories
|
||||
Disallow: /?action=add_files
|
||||
Disallow: /?action=import_image
|
||||
Disallow: /?action=sources
|
||||
Disallow: /?action=profile
|
||||
Disallow: /login
|
||||
Disallow: /logout.php
|
||||
Disallow: /oidc/
|
||||
|
||||
Sitemap: https://varlog.a5l.fr/sitemap.xml
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
// Ce fichier est conservé pour compatibilité ascendante.
|
||||
// Toute la logique est désormais dans index.php.
|
||||
header('Location: /' . ($_SERVER['QUERY_STRING'] ? '?' . $_SERVER['QUERY_STRING'] : ''), true, 301);
|
||||
exit;
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
define('BASE_PATH', realpath(__DIR__ . '/../'));
|
||||
|
||||
require_once BASE_PATH . '/src/helpers.php';
|
||||
require_once BASE_PATH . '/config/config.php';
|
||||
require_once BASE_PATH . '/src/ArticleManager.php';
|
||||
|
||||
$articles = new ArticleManager(BASE_PATH . '/data');
|
||||
$privateCats = $articles->getPrivateCategories();
|
||||
|
||||
$published = array_filter($articles->getAll(true), static function (array $a) use ($privateCats): bool {
|
||||
$cat = trim($a['category'] ?? '');
|
||||
if ($cat !== '' && in_array($cat, $privateCats, true)) {
|
||||
return false;
|
||||
}
|
||||
if (strtotime((string)($a['published_at'] ?? '')) > time()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
header('Content-Type: application/xml; charset=UTF-8');
|
||||
header('X-Robots-Tag: noindex');
|
||||
|
||||
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
||||
|
||||
// Homepage
|
||||
echo ' <url>' . "\n";
|
||||
echo ' <loc>' . htmlspecialchars(rtrim(APP_URL, '/') . '/') . '</loc>' . "\n";
|
||||
echo ' <changefreq>daily</changefreq>' . "\n";
|
||||
echo ' <priority>1.0</priority>' . "\n";
|
||||
echo ' </url>' . "\n";
|
||||
|
||||
foreach ($published as $article) {
|
||||
$loc = htmlspecialchars(rtrim(APP_URL, '/') . '/post/' . rawurlencode($article['slug'] ?? ''));
|
||||
$lastmod = date('Y-m-d', strtotime((string)($article['updated_at'] ?? $article['published_at'] ?? 'now')));
|
||||
echo ' <url>' . "\n";
|
||||
echo ' <loc>' . $loc . '</loc>' . "\n";
|
||||
echo ' <lastmod>' . $lastmod . '</lastmod>' . "\n";
|
||||
echo ' <changefreq>monthly</changefreq>' . "\n";
|
||||
echo ' <priority>0.8</priority>' . "\n";
|
||||
echo ' </url>' . "\n";
|
||||
}
|
||||
|
||||
echo '</urlset>' . "\n";
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Récupère l'IP publique et les infos AS du FAI courant.
|
||||
# Résultat dans config/network-info.json — lu par templates/legal.php
|
||||
set -euo pipefail
|
||||
|
||||
OUT="/var/www/lan.acegrp.varlog/config/network-info.json"
|
||||
|
||||
json=$(curl -sf --max-time 10 "https://ipinfo.io/json")
|
||||
|
||||
if [ -z "$json" ]; then
|
||||
echo "Erreur : réponse vide de ip-api.com" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$json" > "$OUT"
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) — network-info mis à jour : $json"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class CommentManager
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un commentaire non vérifié.
|
||||
* Retourne ['token' => UUID (dans l'URL), 'code' => 6 chiffres (saisi par le visiteur)].
|
||||
*/
|
||||
public function submit(
|
||||
string $articleUuid,
|
||||
string $name,
|
||||
string $email,
|
||||
string $content,
|
||||
string $ip,
|
||||
string $ua
|
||||
): array {
|
||||
$bytes = random_bytes(16);
|
||||
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
|
||||
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
|
||||
$token = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
||||
$code = sprintf('%06d', random_int(100000, 999999));
|
||||
$this->pdo->prepare(
|
||||
'INSERT INTO comments
|
||||
(article_uuid, author_name, author_email, content, verify_token, verification_code, ip_address, user_agent)
|
||||
VALUES (:uuid, :name, :email, :content, :token, :code, :ip, :ua)'
|
||||
)->execute([
|
||||
':uuid' => $articleUuid,
|
||||
':name' => $name,
|
||||
':email' => $email,
|
||||
':content' => $content,
|
||||
':token' => $token,
|
||||
':code' => $code,
|
||||
':ip' => $ip,
|
||||
':ua' => substr($ua, 0, 512),
|
||||
]);
|
||||
return ['token' => $token, 'code' => $code];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie le code PIN pour un token donné.
|
||||
* Retourne l'article_uuid en cas de succès.
|
||||
* Retourne int > 0 : tentatives restantes (code incorrect).
|
||||
* Retourne 0 : commentaire supprimé après 3 tentatives échouées.
|
||||
* Retourne null : token introuvable ou expiré.
|
||||
*/
|
||||
public function verify(string $token, string $code): string|int|null
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
"SELECT id, verification_code, verify_attempts, article_uuid
|
||||
FROM comments
|
||||
WHERE verify_token = :token
|
||||
AND verified = FALSE
|
||||
AND created_at >= NOW() - INTERVAL '24 hours'
|
||||
LIMIT 1"
|
||||
);
|
||||
$st->execute([':token' => $token]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($row['verification_code'] !== $code) {
|
||||
$newAttempts = (int)$row['verify_attempts'] + 1;
|
||||
if ($newAttempts >= 3) {
|
||||
$this->pdo->prepare('DELETE FROM comments WHERE id = :id')
|
||||
->execute([':id' => $row['id']]);
|
||||
return 0;
|
||||
}
|
||||
$this->pdo->prepare('UPDATE comments SET verify_attempts = :a WHERE id = :id')
|
||||
->execute([':a' => $newAttempts, ':id' => $row['id']]);
|
||||
return 3 - $newAttempts;
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
'UPDATE comments
|
||||
SET verified = TRUE, published = TRUE, verification_code = NULL, verify_token = NULL
|
||||
WHERE id = :id'
|
||||
)->execute([':id' => $row['id']]);
|
||||
|
||||
return (string)$row['article_uuid'];
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function forArticle(string $uuid): array
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT id, author_name, content, created_at
|
||||
FROM comments
|
||||
WHERE article_uuid = :uuid AND verified = TRUE AND published = TRUE
|
||||
ORDER BY created_at ASC'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid]);
|
||||
return $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function setPublished(int $id, bool $published): void
|
||||
{
|
||||
$this->pdo->prepare('UPDATE comments SET published = :pub WHERE id = :id')
|
||||
->execute([':pub' => $published, ':id' => $id]);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->pdo->prepare('DELETE FROM comments WHERE id = :id')
|
||||
->execute([':id' => $id]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT id, article_uuid, author_name, author_email, content,
|
||||
verify_token, verification_code, verify_attempts, verified, published, created_at, ip_address
|
||||
FROM comments WHERE id = :id LIMIT 1'
|
||||
);
|
||||
$st->execute([':id' => $id]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/** @return array{all:int,pending:int,verified:int,hidden:int} */
|
||||
public function countsByStatus(): array
|
||||
{
|
||||
try {
|
||||
$row = $this->pdo->query(
|
||||
'SELECT
|
||||
COUNT(*) AS all,
|
||||
COUNT(*) FILTER (WHERE verified = FALSE) AS pending,
|
||||
COUNT(*) FILTER (WHERE verified = TRUE AND published = TRUE) AS verified,
|
||||
COUNT(*) FILTER (WHERE verified = TRUE AND published = FALSE) AS hidden
|
||||
FROM comments'
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
return [
|
||||
'all' => (int)($row['all'] ?? 0),
|
||||
'pending' => (int)($row['pending'] ?? 0),
|
||||
'verified' => (int)($row['verified'] ?? 0),
|
||||
'hidden' => (int)($row['hidden'] ?? 0),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return ['all' => 0, 'pending' => 0, 'verified' => 0, 'hidden' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne tous les commentaires pour l'admin, avec statut email depuis journal_smtp.
|
||||
*
|
||||
* @param string $filterStatus '' = tous, 'pending' = non vérifié,
|
||||
* 'verified' = vérifié+publié, 'hidden' = vérifié+non publié
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allForAdmin(string $filterStatus = ''): array
|
||||
{
|
||||
$where = match($filterStatus) {
|
||||
'pending' => 'WHERE c.verified = FALSE',
|
||||
'verified' => 'WHERE c.verified = TRUE AND c.published = TRUE',
|
||||
'hidden' => 'WHERE c.verified = TRUE AND c.published = FALSE',
|
||||
default => '',
|
||||
};
|
||||
|
||||
$sqlWithJoin = "
|
||||
SELECT c.id, c.article_uuid, c.author_name, c.author_email, c.content,
|
||||
c.verification_code, c.verified, c.published, c.created_at, c.ip_address,
|
||||
j.status AS mail_status,
|
||||
j.error_message AS mail_error,
|
||||
j.sent_at AS mail_sent_at
|
||||
FROM comments c
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT status, error_message, sent_at
|
||||
FROM journal_smtp
|
||||
WHERE to_email = c.author_email
|
||||
AND created_at BETWEEN c.created_at - INTERVAL '1 minute'
|
||||
AND c.created_at + INTERVAL '10 minutes'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
) j ON TRUE
|
||||
$where
|
||||
ORDER BY c.created_at DESC
|
||||
";
|
||||
|
||||
try {
|
||||
return $this->pdo->query($sqlWithJoin)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (\Throwable) {
|
||||
// journal_smtp absent ou jointure échouée : requête de secours sans jointure
|
||||
$sqlFallback = "
|
||||
SELECT c.id, c.article_uuid, c.author_name, c.author_email, c.content,
|
||||
c.verification_code, c.verified, c.published, c.created_at, c.ip_address,
|
||||
NULL AS mail_status, NULL AS mail_error, NULL AS mail_sent_at
|
||||
FROM comments c
|
||||
$where
|
||||
ORDER BY c.created_at DESC
|
||||
";
|
||||
return $this->pdo->query($sqlFallback)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
// includes/ConfigRepo.php
|
||||
declare(strict_types=1);
|
||||
|
||||
function config_repo_get(): array
|
||||
{
|
||||
$pdo = db();
|
||||
$row = $pdo->query('SELECT * FROM app_config WHERE id=1')->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return [
|
||||
'allow_password' => true,'allow_oidc' => false,'registrations_open' => true,
|
||||
'oidc_issuer' => null,'oidc_name' => null,'oidc_client_id' => null,'oidc_client_secret' => null,'oidc_redirect_uri' => null
|
||||
];
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
function config_repo_save(array $in): void
|
||||
{
|
||||
$pdo = db();
|
||||
$sql = 'INSERT INTO app_config
|
||||
(id, allow_password, allow_oidc, registrations_open, oidc_issuer, oidc_name, oidc_client_id, oidc_client_secret, oidc_redirect_uri, updated_at)
|
||||
VALUES (1,:pw,:oidc,:open,:iss,:name,:cid,:sec,:redir, now())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
allow_password=:pw, allow_oidc=:oidc, registrations_open=:open,
|
||||
oidc_issuer=:iss, oidc_name=:name, oidc_client_id=:cid, oidc_client_secret=:sec, oidc_redirect_uri=:redir,
|
||||
updated_at=now()';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
':pw' => (bool)$in['allow_password'],
|
||||
':oidc' => (bool)$in['allow_oidc'],
|
||||
':open' => (bool)$in['registrations_open'],
|
||||
':iss' => trim((string)($in['oidc_issuer'] ?? '')) ?: null,
|
||||
':name' => trim((string)($in['oidc_name'] ?? '')) ?: null,
|
||||
':cid' => trim((string)($in['oidc_client_id'] ?? '')) ?: null,
|
||||
':sec' => trim((string)($in['oidc_client_secret'] ?? '')) ?: null,
|
||||
':redir' => trim((string)($in['oidc_redirect_uri'] ?? '')) ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le fichier .env en conservant les autres lignes.
|
||||
* $pairs = ['KEY'=>'value', ...] ; value null => supprime la clé.
|
||||
*/
|
||||
function env_set_pairs(string $envPath, array $pairs): void
|
||||
{
|
||||
if (!is_file($envPath)) {
|
||||
file_put_contents($envPath, '');
|
||||
}
|
||||
$lines = file($envPath, FILE_IGNORE_NEW_LINES);
|
||||
$map = [];
|
||||
foreach ($lines as $i => $line) {
|
||||
if (preg_match('/^\s*#/', $line) || trim($line) === '') {
|
||||
$map[$i] = $line;
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($line, '=')) {
|
||||
$map[$i] = $line;
|
||||
continue;
|
||||
}
|
||||
[$k,$v] = explode('=', $line, 2);
|
||||
$k = trim($k);
|
||||
if ($k === '') {
|
||||
$map[$i] = $line;
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists($k, $pairs)) {
|
||||
if ($pairs[$k] === null) {
|
||||
$map[$i] = null;
|
||||
} // supprimé
|
||||
else {
|
||||
$map[$i] = $k.'='.env_quote((string)$pairs[$k]);
|
||||
}
|
||||
unset($pairs[$k]);
|
||||
} else {
|
||||
$map[$i] = $line;
|
||||
}
|
||||
}
|
||||
// append keys restantes
|
||||
foreach ($pairs as $k => $v) {
|
||||
if ($v === null) {
|
||||
continue;
|
||||
}
|
||||
$map[] = $k.'='.env_quote((string)$v);
|
||||
}
|
||||
// re-écriture
|
||||
$out = [];
|
||||
foreach ($map as $line) {
|
||||
if ($line === null) {
|
||||
continue;
|
||||
} $out[] = $line;
|
||||
}
|
||||
file_put_contents($envPath, implode(PHP_EOL, $out).PHP_EOL);
|
||||
}
|
||||
|
||||
function env_quote(string $v): string
|
||||
{
|
||||
if ($v === '' || preg_match('/\s|[#"\'=]/', $v)) {
|
||||
// met entre guillemets et échappe
|
||||
$v = str_replace(['\\','"'], ['\\\\','\\"'], $v);
|
||||
return "\"$v\"";
|
||||
}
|
||||
return $v;
|
||||
}
|
||||
|
||||
function ensure_admin(): void
|
||||
{
|
||||
// adapte à ton système
|
||||
if (empty($_SESSION['user']['is_admin'])) {
|
||||
http_response_code(403);
|
||||
exit('Forbidden');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain;
|
||||
|
||||
final class User
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $email,
|
||||
public string $passwordHash,
|
||||
public bool $isActive = true,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class FeedFetcher
|
||||
{
|
||||
private const MIN_TTL = 900; // 15 min
|
||||
private const MAX_TTL = 86400; // 24 h
|
||||
|
||||
public function __construct(private string $cacheDir)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les items du feed (depuis le cache si valide, sinon refetch).
|
||||
* @return array{items: array, feed_title: string, fetched_at: int, ttl: int}|null
|
||||
*/
|
||||
public function get(string $url): ?array
|
||||
{
|
||||
$cached = $this->cacheRead($url);
|
||||
if ($cached !== null && time() < (int)$cached['fetched_at'] + (int)$cached['ttl']) {
|
||||
return $cached;
|
||||
}
|
||||
return $this->fetch($url);
|
||||
}
|
||||
|
||||
/** Force le refetch et met le cache à jour. */
|
||||
public function fetch(string $url): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_USERAGENT => 'varlog/1.0 FeedFetcher (+' . (defined('APP_URL') ? APP_URL : '') . ')',
|
||||
CURLOPT_HEADER => true,
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$hSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($raw === false || !is_int($httpCode) || $httpCode < 200 || $httpCode >= 400) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$headers = substr((string)$raw, 0, $hSize);
|
||||
$body = substr((string)$raw, $hSize);
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_string($body);
|
||||
libxml_clear_errors();
|
||||
if ($xml === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$isAtom = ($xml->getName() === 'feed');
|
||||
$items = $isAtom ? $this->parseAtom($xml) : $this->parseRss($xml);
|
||||
$feedTitle = $isAtom
|
||||
? (string)($xml->title ?? '')
|
||||
: (string)($xml->channel->title ?? '');
|
||||
|
||||
$ttl = $this->resolveTtl($xml, $isAtom, $headers);
|
||||
|
||||
$data = [
|
||||
'feed_title' => $feedTitle,
|
||||
'fetched_at' => time(),
|
||||
'ttl' => $ttl,
|
||||
'items' => $items,
|
||||
];
|
||||
$this->cacheWrite($url, $data);
|
||||
return $data;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
private function parseRss(\SimpleXMLElement $xml): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($xml->channel->item ?? [] as $item) {
|
||||
$date = (string)($item->pubDate ?? '');
|
||||
$items[] = [
|
||||
'title' => trim((string)($item->title ?? '')),
|
||||
'url' => trim((string)($item->link ?? '')),
|
||||
'summary' => $this->cleanSummary((string)($item->description ?? '')),
|
||||
'date' => $date !== '' ? (int)strtotime($date) : 0,
|
||||
'author' => trim((string)($item->author ?? '')),
|
||||
];
|
||||
}
|
||||
return $this->sortItems($items);
|
||||
}
|
||||
|
||||
private function parseAtom(\SimpleXMLElement $xml): array
|
||||
{
|
||||
$ns = $xml->getNamespaces(true);
|
||||
$items = [];
|
||||
foreach ($xml->entry ?? [] as $entry) {
|
||||
$url = '';
|
||||
foreach ($entry->link ?? [] as $link) {
|
||||
$rel = (string)($link['rel'] ?? 'alternate');
|
||||
if ($rel === 'alternate' || $rel === '') {
|
||||
$url = (string)($link['href'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
$date = (string)($entry->published ?? $entry->updated ?? '');
|
||||
$author = (string)($entry->author->name ?? '');
|
||||
$summary = (string)($entry->summary ?? $entry->content ?? '');
|
||||
$items[] = [
|
||||
'title' => trim((string)($entry->title ?? '')),
|
||||
'url' => trim($url),
|
||||
'summary' => $this->cleanSummary($summary),
|
||||
'date' => $date !== '' ? (int)strtotime($date) : 0,
|
||||
'author' => trim($author),
|
||||
];
|
||||
}
|
||||
return $this->sortItems($items);
|
||||
}
|
||||
|
||||
private function cleanSummary(string $html): string
|
||||
{
|
||||
$text = strip_tags($html);
|
||||
$text = preg_replace('/\s+/', ' ', $text) ?? $text;
|
||||
return mb_strimwidth(trim($text), 0, 200, '…');
|
||||
}
|
||||
|
||||
private function sortItems(array $items): array
|
||||
{
|
||||
usort($items, static fn ($a, $b) => $b['date'] <=> $a['date']);
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function resolveTtl(\SimpleXMLElement $xml, bool $isAtom, string $headers): int
|
||||
{
|
||||
// 1. TTL déclaré dans le flux RSS (<ttl> en minutes)
|
||||
if (!$isAtom) {
|
||||
$rssttl = (int)($xml->channel->ttl ?? 0);
|
||||
if ($rssttl > 0) {
|
||||
return $this->clampTtl($rssttl * 60);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Cache-Control: max-age depuis les headers HTTP
|
||||
if (preg_match('/max-age=(\d+)/i', $headers, $m)) {
|
||||
return $this->clampTtl((int)$m[1]);
|
||||
}
|
||||
|
||||
// 3. Valeur par défaut : 1 heure
|
||||
return 3600;
|
||||
}
|
||||
|
||||
private function clampTtl(int $seconds): int
|
||||
{
|
||||
return max(self::MIN_TTL, min(self::MAX_TTL, $seconds));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
private function cachePath(string $url): string
|
||||
{
|
||||
return $this->cacheDir . '/' . md5($url) . '.json';
|
||||
}
|
||||
|
||||
private function cacheRead(string $url): ?array
|
||||
{
|
||||
$path = $this->cachePath($url);
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode((string)file_get_contents($path), true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
private function cacheWrite(string $url, array $data): void
|
||||
{
|
||||
if (!is_dir($this->cacheDir)) {
|
||||
mkdir($this->cacheDir, 0755, true);
|
||||
}
|
||||
file_put_contents(
|
||||
$this->cachePath($url),
|
||||
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class FileManager
|
||||
{
|
||||
private PDO $db;
|
||||
private string $uploadDir;
|
||||
|
||||
public function __construct(PDO $db, string $uploadDir)
|
||||
{
|
||||
$this->db = $db;
|
||||
$this->uploadDir = rtrim($uploadDir, '/');
|
||||
}
|
||||
|
||||
public function upload(int $postId, array $file): ?int
|
||||
{
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $this->guessType($file['type']);
|
||||
$originalName = basename($file['name']);
|
||||
$ext = pathinfo($originalName, PATHINFO_EXTENSION);
|
||||
$filename = uniqid('file_') . '.' . $ext;
|
||||
$destination = $this->uploadDir . '/' . $filename;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $destination)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO post_files (post_id, file_type, file_path, original_name)
|
||||
VALUES (:post_id, :file_type, :file_path, :original_name)
|
||||
');
|
||||
$stmt->execute([
|
||||
'post_id' => $postId,
|
||||
'file_type' => $type,
|
||||
'file_path' => $filename,
|
||||
'original_name' => $originalName
|
||||
]);
|
||||
|
||||
return (int)$this->db->lastInsertId();
|
||||
}
|
||||
|
||||
public function getFilesForPost(int $postId): array
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM post_files WHERE post_id = :post_id ORDER BY uploaded_at');
|
||||
$stmt->execute(['post_id' => $postId]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function delete(int $fileId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT file_path FROM post_files WHERE id = :id');
|
||||
$stmt->execute(['id' => $fileId]);
|
||||
$file = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fullPath = $this->uploadDir . '/' . $file['file_path'];
|
||||
if (file_exists($fullPath)) {
|
||||
unlink($fullPath);
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('DELETE FROM post_files WHERE id = :id');
|
||||
return $stmt->execute(['id' => $fileId]);
|
||||
}
|
||||
|
||||
private function guessType(string $mime): string
|
||||
{
|
||||
if (str_starts_with($mime, 'image/')) {
|
||||
return 'image';
|
||||
}
|
||||
if (str_starts_with($mime, 'video/')) {
|
||||
return 'video';
|
||||
}
|
||||
if (str_starts_with($mime, 'audio/')) {
|
||||
return 'audio';
|
||||
}
|
||||
return 'file';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
final class Csrf
|
||||
{
|
||||
private const KEY = '_csrf';
|
||||
|
||||
public static function token(): string
|
||||
{
|
||||
$t = bin2hex(random_bytes(32));
|
||||
$_SESSION[self::KEY] = $t;
|
||||
return $t;
|
||||
}
|
||||
|
||||
public static function validate(?string $token): bool
|
||||
{
|
||||
$ok = is_string($token) && isset($_SESSION[self::KEY]) && hash_equals($_SESSION[self::KEY], $token);
|
||||
unset($_SESSION[self::KEY]); // one‑time token
|
||||
return $ok;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use RuntimeException;
|
||||
|
||||
final class Database
|
||||
{
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function get(): PDO
|
||||
{
|
||||
if (self::$pdo instanceof PDO) {
|
||||
return self::$pdo;
|
||||
}
|
||||
|
||||
$get = static function (string $k, ?string $default = null): ?string {
|
||||
$v = getenv($k);
|
||||
if ($v !== false && $v !== '') {
|
||||
return (string)$v;
|
||||
}
|
||||
return $_ENV[$k] ?? $default;
|
||||
};
|
||||
|
||||
$dsn = $get('DB_DSN');
|
||||
$user = $get('DB_USER');
|
||||
$pass = $get('DB_PASS');
|
||||
|
||||
if (!$dsn) {
|
||||
$host = $get('DB_HOST', 'localhost');
|
||||
$port = $get('DB_PORT', '5432');
|
||||
$name = $get('DB_NAME');
|
||||
if ($name) {
|
||||
$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dsn) {
|
||||
throw new RuntimeException('DB_DSN manquant (ni DB_DSN ni DB_HOST/DB_PORT/DB_NAME).');
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, (string)($user ?? ''), (string)($pass ?? ''), [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
return self::$pdo = $pdo;
|
||||
} catch (PDOException $e) {
|
||||
throw new RuntimeException('Connexion BDD échouée.', previous: $e);
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Utiliser Database::get() */
|
||||
public static function pdo(): PDO
|
||||
{
|
||||
@trigger_error(__METHOD__.' est déprécié. Utiliser Database::get()', E_USER_DEPRECATED);
|
||||
return self::get();
|
||||
}
|
||||
|
||||
/** @deprecated Utiliser Database::get() */
|
||||
public static function getPdo(): PDO
|
||||
{
|
||||
@trigger_error(__METHOD__.' est déprécié. Utiliser Database::get()', E_USER_DEPRECATED);
|
||||
return self::get();
|
||||
}
|
||||
|
||||
/** @deprecated Utiliser Database::get() */
|
||||
public static function getInstance(): PDO
|
||||
{
|
||||
@trigger_error(__METHOD__.' est déprécié. Utiliser Database::get()', E_USER_DEPRECATED);
|
||||
return self::get();
|
||||
}
|
||||
|
||||
public static function transactional(callable $fn)
|
||||
{
|
||||
$pdo = self::get();
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$ret = $fn($pdo);
|
||||
$pdo->commit();
|
||||
return $ret;
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class DbAdapter
|
||||
{
|
||||
public static function pdo(): PDO
|
||||
{
|
||||
if (!empty($GLOBALS['pdo']) && $GLOBALS['pdo'] instanceof PDO) {
|
||||
return $GLOBALS['pdo'];
|
||||
}
|
||||
$dsn = getenv('DB_DSN') ?: ($_ENV['DB_DSN'] ?? null);
|
||||
$user = getenv('DB_USER') ?: ($_ENV['DB_USER'] ?? null);
|
||||
$pass = getenv('DB_PASS') ?: ($_ENV['DB_PASS'] ?? null);
|
||||
if (!$dsn) {
|
||||
$host = getenv('DB_HOST') ?: ($_ENV['DB_HOST'] ?? 'localhost');
|
||||
$port = getenv('DB_PORT') ?: ($_ENV['DB_PORT'] ?? '5432');
|
||||
$name = getenv('DB_NAME') ?: ($_ENV['DB_NAME'] ?? null);
|
||||
if ($name) {
|
||||
$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $name);
|
||||
}
|
||||
}
|
||||
if (!$dsn) {
|
||||
throw new \RuntimeException('Aucun DSN pour initialiser PDO');
|
||||
}
|
||||
return new PDO($dsn, (string)$user, (string)$pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure;
|
||||
|
||||
final class Session
|
||||
{
|
||||
public static function startSecure(string $name): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session_name($name);
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => true,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
session_start();
|
||||
|
||||
// Verrouillage basique contre session hijacking
|
||||
$key = '_sess_fingerprint';
|
||||
$fp = hash('xxh3', ($_SERVER['REMOTE_ADDR'] ?? '') . '|' . ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
if (!isset($_SESSION[$key])) {
|
||||
$_SESSION[$key] = $fp;
|
||||
} elseif ($_SESSION[$key] !== $fp) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION = [];
|
||||
$_SESSION[$key] = $fp;
|
||||
}
|
||||
}
|
||||
|
||||
public static function regenerate(): void
|
||||
{
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
}
|
||||
+1828
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class PostManager
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
public function __construct(PDO $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
$stmt = $this->db->query('SELECT * FROM posts ORDER BY created_at DESC');
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function get(int $id): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM posts WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$post = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $post ?: null;
|
||||
}
|
||||
|
||||
public function create(string $title, string $content, string $published_at): int
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO posts (title, content, created_at, is_published)
|
||||
VALUES (:title, :content, :published_at, true)
|
||||
');
|
||||
$stmt->execute([
|
||||
'title' => $title,
|
||||
'content' => $content,
|
||||
'published_at' => $published_at,
|
||||
]);
|
||||
return (int)$this->db->lastInsertId();
|
||||
}
|
||||
|
||||
|
||||
public function update(int $id, string $title, string $content, string $published_at, bool $published): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
UPDATE posts
|
||||
SET title = :title,
|
||||
content = :content,
|
||||
created_at = :published_at,
|
||||
is_published = :published,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id
|
||||
');
|
||||
return $stmt->execute([
|
||||
'id' => $id,
|
||||
'title' => $title,
|
||||
'content' => $content,
|
||||
'published_at' => $published_at,
|
||||
'published' => $published,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
|
||||
return $stmt->execute(['id' => $id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class RatingManager
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function rate(string $uuid, string $email, int $rating): void
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'INSERT INTO article_ratings (article_uuid, user_email, rating)
|
||||
VALUES (:uuid, :email, :r)
|
||||
ON CONFLICT (article_uuid, user_email)
|
||||
DO UPDATE SET rating = :r, rated_at = NOW()'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid, ':email' => strtolower($email), ':r' => $rating]);
|
||||
}
|
||||
|
||||
/** @return array{avg: float|null, count: int} */
|
||||
public function statsForArticle(string $uuid): array
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT ROUND(AVG(rating)::numeric, 1) as avg, COUNT(*) as count
|
||||
FROM article_ratings WHERE article_uuid = :uuid'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
return [
|
||||
'avg' => $row && $row['avg'] !== null ? (float)$row['avg'] : null,
|
||||
'count' => $row ? (int)$row['count'] : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function userRating(string $uuid, string $email): ?int
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT rating FROM article_ratings WHERE article_uuid = :uuid AND user_email = :email'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid, ':email' => strtolower($email)]);
|
||||
$v = $st->fetchColumn();
|
||||
return $v !== false ? (int)$v : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class ReactionManager
|
||||
{
|
||||
public const TYPES = ['useful', 'important', 'interesting'];
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/** Ajoute ou retire une réaction. Retourne true si ajoutée, false si retirée. */
|
||||
public function toggle(string $uuid, string $type, string $visitorHash): bool
|
||||
{
|
||||
if (!in_array($type, self::TYPES, true)) {
|
||||
return false;
|
||||
}
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT id FROM article_reactions
|
||||
WHERE article_uuid = :uuid AND reaction_type = :type AND visitor_hash = :hash'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid, ':type' => $type, ':hash' => $visitorHash]);
|
||||
if ($st->fetchColumn() !== false) {
|
||||
$this->pdo->prepare(
|
||||
'DELETE FROM article_reactions
|
||||
WHERE article_uuid = :uuid AND reaction_type = :type AND visitor_hash = :hash'
|
||||
)->execute([':uuid' => $uuid, ':type' => $type, ':hash' => $visitorHash]);
|
||||
return false;
|
||||
}
|
||||
$this->pdo->prepare(
|
||||
'INSERT INTO article_reactions (article_uuid, reaction_type, visitor_hash)
|
||||
VALUES (:uuid, :type, :hash) ON CONFLICT DO NOTHING'
|
||||
)->execute([':uuid' => $uuid, ':type' => $type, ':hash' => $visitorHash]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, int> */
|
||||
public function statsForArticle(string $uuid): array
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT reaction_type, COUNT(*) AS cnt
|
||||
FROM article_reactions WHERE article_uuid = :uuid GROUP BY reaction_type'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid]);
|
||||
$stats = array_fill_keys(self::TYPES, 0);
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$stats[$row['reaction_type']] = (int) $row['cnt'];
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function visitorReactions(string $uuid, string $visitorHash): array
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT reaction_type FROM article_reactions
|
||||
WHERE article_uuid = :uuid AND visitor_hash = :hash'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid, ':hash' => $visitorHash]);
|
||||
return array_column($st->fetchAll(PDO::FETCH_ASSOC), 'reaction_type');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class DictionaryRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function getEntityByCode(string $code): ?array
|
||||
{
|
||||
$st = $this->pdo->prepare('SELECT * FROM dd_entities WHERE code = :c AND is_active IS TRUE');
|
||||
$st->execute([':c' => $code]);
|
||||
$e = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$e['fields'] = $this->getFields((int)$e['id']);
|
||||
$e['rules'] = $this->getRules((int)$e['id']);
|
||||
return $e;
|
||||
}
|
||||
|
||||
public function getFields(int $entityId): array
|
||||
{
|
||||
$st = $this->pdo->prepare('SELECT * FROM dd_fields WHERE entity_id = :id ORDER BY ui_order NULLS LAST, id');
|
||||
$st->execute([':id' => $entityId]);
|
||||
return $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getRules(int $entityId): array
|
||||
{
|
||||
$st = $this->pdo->prepare('SELECT * FROM dd_rules WHERE entity_id = :id AND active IS TRUE');
|
||||
$st->execute([':id' => $entityId]);
|
||||
return $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getEnum(string $name): array
|
||||
{
|
||||
$st = $this->pdo->prepare('
|
||||
SELECT ev.code, ev.label
|
||||
FROM dd_enums e JOIN dd_enum_values ev ON ev.enum_id = e.id
|
||||
WHERE e.name = :n AND ev.active IS TRUE
|
||||
ORDER BY ev.sort_order, ev.id
|
||||
');
|
||||
$st->execute([':n' => $name]);
|
||||
return $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use PDO;
|
||||
use App\Infrastructure\Database;
|
||||
|
||||
final class ProfileRepository
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(?PDO $pdo = null)
|
||||
{
|
||||
// 0) DI directe
|
||||
if ($pdo instanceof PDO) {
|
||||
$this->pdo = $pdo;
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) App\Infrastructure\Database (si elle expose quelque chose)
|
||||
if (class_exists(Database::class)) {
|
||||
if (method_exists(Database::class, 'pdo')) {
|
||||
$try = Database::pdo();
|
||||
if ($try instanceof PDO) {
|
||||
$this->pdo = $try;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (method_exists(Database::class, 'getPdo')) {
|
||||
$try = Database::getPdo();
|
||||
if ($try instanceof PDO) {
|
||||
$this->pdo = $try;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (method_exists(Database::class, 'getInstance')) {
|
||||
$db = Database::getInstance();
|
||||
if ($db instanceof PDO) {
|
||||
$this->pdo = $db;
|
||||
return;
|
||||
}
|
||||
if (is_object($db) && method_exists($db, 'pdo')) {
|
||||
$try = $db->pdo();
|
||||
if ($try instanceof PDO) {
|
||||
$this->pdo = $try;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fonction globale éventuelle
|
||||
if (function_exists('db')) {
|
||||
$try = db();
|
||||
if ($try instanceof PDO) {
|
||||
$this->pdo = $try;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Variable globale éventuelle
|
||||
if (!empty($GLOBALS['pdo']) && $GLOBALS['pdo'] instanceof PDO) {
|
||||
$this->pdo = $GLOBALS['pdo'];
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) Fallback env/const : compose un DSN pgsql si nécessaire
|
||||
$dsn = getenv('DB_DSN') ?: ($_ENV['DB_DSN'] ?? null);
|
||||
$user = getenv('DB_USER') ?: ($_ENV['DB_USER'] ?? null);
|
||||
$pass = getenv('DB_PASS') ?: ($_ENV['DB_PASS'] ?? null);
|
||||
if (!$dsn) {
|
||||
$host = getenv('DB_HOST') ?: ($_ENV['DB_HOST'] ?? 'localhost');
|
||||
$port = getenv('DB_PORT') ?: ($_ENV['DB_PORT'] ?? '5432');
|
||||
$name = getenv('DB_NAME') ?: ($_ENV['DB_NAME'] ?? null);
|
||||
if ($name) {
|
||||
$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $name);
|
||||
}
|
||||
}
|
||||
if ($dsn) {
|
||||
$pdo = new PDO($dsn, (string)$user, (string)$pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
$this->pdo = $pdo;
|
||||
return;
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Impossible d’obtenir un PDO (aucune source valide trouvée).');
|
||||
}
|
||||
|
||||
public function all(?bool $onlyActive = null): array
|
||||
{
|
||||
$sql = 'SELECT * FROM profiles';
|
||||
if ($onlyActive !== null) {
|
||||
$sql .= ' WHERE is_active = :act';
|
||||
}
|
||||
$sql .= ' ORDER BY slug';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
if ($onlyActive !== null) {
|
||||
$stmt->bindValue(':act', $onlyActive, PDO::PARAM_BOOL);
|
||||
}
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM profiles WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM profiles WHERE slug = :slug');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public function create(string $slug, string $label, ?string $description, array $permissions, bool $isSystem, bool $isActive): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare('INSERT INTO profiles(slug,label,description,permissions,is_system,is_active) VALUES(:slug,:label,:desc,CAST(:perms AS jsonb),:sys,:act) RETURNING id');
|
||||
$stmt->execute([
|
||||
':slug' => $slug,
|
||||
':label' => $label,
|
||||
':desc' => $description,
|
||||
':perms' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
':sys' => $isSystem,
|
||||
':act' => $isActive,
|
||||
]);
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function update(int $id, string $slug, string $label, ?string $description, array $permissions, bool $isSystem, bool $isActive): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare('UPDATE profiles SET slug=:slug,label=:label,description=:desc,permissions=CAST(:perms AS jsonb),is_system=:sys,is_active=:act WHERE id=:id');
|
||||
$stmt->execute([
|
||||
':id' => $id,
|
||||
':slug' => $slug,
|
||||
':label' => $label,
|
||||
':desc' => $description,
|
||||
':perms' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
':sys' => $isSystem,
|
||||
':act' => $isActive,
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare('DELETE FROM profiles WHERE id=:id AND is_system = FALSE');
|
||||
$stmt->execute([':id' => $id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Domain\User;
|
||||
use PDO;
|
||||
|
||||
final class UserRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée (si besoin) un utilisateur OIDC.
|
||||
* - Idempotent par email : si existe, retourne l'id existant.
|
||||
* - Génère un password_hash aléatoire inutilisable (compte OIDC).
|
||||
*
|
||||
* @return string ID (uuid) sous forme de chaîne
|
||||
*/
|
||||
public function createFromOidc(string $email): string
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new \InvalidArgumentException('Email OIDC invalide.');
|
||||
}
|
||||
|
||||
// 1) Existe déjà ?
|
||||
$st = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$st->execute([':email' => $email]);
|
||||
$id = $st->fetchColumn();
|
||||
if ($id !== false && $id !== null) {
|
||||
return (string)$id;
|
||||
}
|
||||
|
||||
// 2) Création
|
||||
// Génère un hash robuste sur une valeur aléatoire (aucune chance de connexion par mot de passe).
|
||||
$randomSecret = bin2hex(random_bytes(32));
|
||||
$randomHash = password_hash($randomSecret, PASSWORD_DEFAULT);
|
||||
|
||||
$sql = <<<SQL
|
||||
INSERT INTO users (email, password_hash)
|
||||
VALUES (:email, :hash)
|
||||
RETURNING id
|
||||
SQL;
|
||||
|
||||
try {
|
||||
$st = $this->pdo->prepare($sql);
|
||||
$st->execute([
|
||||
':email' => $email,
|
||||
':hash' => $randomHash,
|
||||
]);
|
||||
return (string)$st->fetchColumn();
|
||||
} catch (\PDOException $e) {
|
||||
// Unique violation sur email (23505) → on relit l’id (race condition)
|
||||
if ($e->getCode() === '23505') {
|
||||
$st = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$st->execute([':email' => $email]);
|
||||
$id = $st->fetchColumn();
|
||||
if ($id !== false && $id !== null) {
|
||||
return (string)$id;
|
||||
}
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function findByEmail(string $email): ?User
|
||||
{
|
||||
$sql = 'SELECT id, email, password_hash, is_active FROM users WHERE email = :email LIMIT 1';
|
||||
$st = $this->pdo->prepare($sql);
|
||||
$st->execute([':email' => $email]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$isActive = $this->toBool($row['is_active']);
|
||||
|
||||
return new User(
|
||||
(string)$row['id'],
|
||||
(string)$row['email'],
|
||||
(string)$row['password_hash'],
|
||||
$isActive
|
||||
);
|
||||
}
|
||||
|
||||
public function create(string $email, string $passwordHash): string
|
||||
{
|
||||
// PostgreSQL
|
||||
$sql = 'INSERT INTO users (email, password_hash) VALUES (:email, :hash) RETURNING id';
|
||||
$st = $this->pdo->prepare($sql);
|
||||
$st->execute([':email' => $email, ':hash' => $passwordHash]);
|
||||
return (string)$st->fetchColumn();
|
||||
}
|
||||
|
||||
public function updatePassword(string $userId, string $newHash): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
UPDATE users
|
||||
SET password_hash = :h,
|
||||
updated_at = NOW(),
|
||||
password_changed_at = NOW()
|
||||
WHERE id = :id
|
||||
SQL;
|
||||
$st = $this->pdo->prepare($sql);
|
||||
$st->execute([':h' => $newHash, ':id' => $userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un bool venant de PDO/pgsql ('t','f',1,0,true,false,'1','0','true','false')
|
||||
*/
|
||||
private function toBool(mixed $v): bool
|
||||
{
|
||||
if (is_bool($v)) {
|
||||
return $v;
|
||||
}
|
||||
if (is_int($v)) {
|
||||
return $v === 1;
|
||||
}
|
||||
if (is_string($v)) {
|
||||
$v = strtolower($v);
|
||||
return in_array($v, ['t', '1', 'true', 'on', 'yes'], true);
|
||||
}
|
||||
return (bool)$v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Moteur de recherche plein-texte en mémoire.
|
||||
*
|
||||
* Algorithme : scoring multi-champ avec correspondance exacte, sous-chaîne et
|
||||
* similarité trigramme. Logique AND : tous les tokens de la requête doivent
|
||||
* matcher quelque part pour qu'un article soit retourné.
|
||||
*
|
||||
* Score par token :
|
||||
* 1.0 → mot identique (ex. "Linky" = "Linky")
|
||||
* 0.75 → sous-chaîne (ex. "voiture" ⊂ "voitures")
|
||||
* 0–0.5 → similarité trigramme (ex. "linki" ≈ "linky")
|
||||
*
|
||||
* Poids par champ : titre × 6, catégorie × 3, contenu × 1.
|
||||
*/
|
||||
class SearchEngine
|
||||
{
|
||||
private const TITLE_WEIGHT = 6.0;
|
||||
private const CAT_WEIGHT = 3.0;
|
||||
private const CONTENT_WEIGHT = 1.0;
|
||||
private const FUZZY_FLOOR = 0.55; // seuil min. de similarité trigramme
|
||||
private const SNIPPET_LEN = 220;
|
||||
|
||||
/**
|
||||
* @param array<array> $articles Liste brute d'articles (depuis ArticleManager)
|
||||
* @return array<array{article: array, score: float, snippet: string}>
|
||||
*/
|
||||
public function search(string $query, array $articles): array
|
||||
{
|
||||
$tokens = $this->tokenize($query);
|
||||
if (empty($tokens)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($articles as $article) {
|
||||
// 'plain' est pré-calculé dans search_index.json, sinon on stripe à la volée
|
||||
$plain = $article['plain'] ?? $this->stripMarkdown($article['content'] ?? '');
|
||||
$tWords = $this->tokenize($article['title'] ?? '');
|
||||
$cWords = $this->tokenize($article['category'] ?? '');
|
||||
$pWords = $this->tokenize($plain);
|
||||
|
||||
$score = $this->scoreArticle($tokens, $tWords, $cWords, $pWords);
|
||||
if ($score > 0.0) {
|
||||
$results[] = [
|
||||
'article' => $article,
|
||||
'score' => $score,
|
||||
'snippet' => $this->buildSnippet($plain, $tokens),
|
||||
'tier' => $this->determineTier($tokens, $tWords, $cWords, $pWords),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($results, static function (array $a, array $b): int {
|
||||
if ($a['tier'] !== $b['tier']) {
|
||||
return $a['tier'] <=> $b['tier'];
|
||||
}
|
||||
return $b['score'] <=> $a['score'];
|
||||
});
|
||||
return $results;
|
||||
}
|
||||
|
||||
// ─── Scoring ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function scoreArticle(array $tokens, array $tWords, array $cWords, array $pWords): float
|
||||
{
|
||||
$total = 0.0;
|
||||
foreach ($tokens as $token) {
|
||||
$ts = $this->tokenScore($token, $tWords) * self::TITLE_WEIGHT
|
||||
+ $this->tokenScore($token, $cWords) * self::CAT_WEIGHT
|
||||
+ $this->tokenScore($token, $pWords) * self::CONTENT_WEIGHT;
|
||||
|
||||
if ($ts <= 0.0) {
|
||||
return 0.0; // AND strict : token introuvable → article exclu
|
||||
}
|
||||
$total += $ts;
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classe un résultat en tier :
|
||||
* 1 → tous les tokens trouvés exactement dans le titre
|
||||
* 2 → tous les tokens trouvés exactement dans titre, catégorie ou contenu
|
||||
* 3 → au moins un token uniquement en correspondance floue
|
||||
*/
|
||||
private function determineTier(array $tokens, array $tWords, array $cWords, array $pWords): int
|
||||
{
|
||||
$inTitle = true;
|
||||
foreach ($tokens as $token) {
|
||||
if ($this->tokenScore($token, $tWords, false) < 0.75) {
|
||||
$inTitle = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($inTitle) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$allWords = array_merge($tWords, $cWords, $pWords);
|
||||
foreach ($tokens as $token) {
|
||||
if ($this->tokenScore($token, $allWords, false) < 0.75) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne un score 0–1 mesurant à quel point $token correspond
|
||||
* au meilleur mot de la liste $words.
|
||||
*/
|
||||
private function tokenScore(string $token, array $words, bool $fuzzy = true): float
|
||||
{
|
||||
$best = 0.0;
|
||||
$tLen = mb_strlen($token);
|
||||
foreach ($words as $w) {
|
||||
if ($w === $token) {
|
||||
return 1.0; // exact
|
||||
}
|
||||
if ($tLen >= 3 && (str_contains($w, $token) || str_contains($token, $w))) {
|
||||
$best = max($best, 0.75); // sous-chaîne (pluriels, conjugaisons)
|
||||
}
|
||||
if ($fuzzy && $tLen >= 4) {
|
||||
$sim = $this->trigramSimilarity($token, $w);
|
||||
if ($sim >= self::FUZZY_FLOOR) {
|
||||
$best = max($best, $sim * 0.55); // fuzzy (fautes de frappe)
|
||||
}
|
||||
}
|
||||
}
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule un score cumulé (OR) pour plusieurs tokens sur un ensemble d'articles.
|
||||
* Tokenise chaque article une seule fois — évite N tokenisations avec N appels à search().
|
||||
* Le fuzzy (trigramme) est désactivé sur le contenu (poids 1.0) pour des raisons de perf.
|
||||
*
|
||||
* @param string[] $tokens Mots normalisés (lowercase, sans accents)
|
||||
* @param array[] $articles Articles (doivent avoir uuid, title, category, plain|content)
|
||||
* @return array{0: array<string, float>, 1: array<string, array>}
|
||||
*/
|
||||
public function scorePool(array $tokens, array $articles): array
|
||||
{
|
||||
if (empty($tokens) || empty($articles)) {
|
||||
return [[], []];
|
||||
}
|
||||
|
||||
$scoreMap = [];
|
||||
$articleMap = [];
|
||||
|
||||
foreach ($articles as $article) {
|
||||
$plain = $article['plain'] ?? $this->stripMarkdown($article['content'] ?? '');
|
||||
$tWords = $this->tokenize($article['title'] ?? '');
|
||||
$cWords = $this->tokenize($article['category'] ?? '');
|
||||
$pWords = $this->tokenize($plain);
|
||||
|
||||
$total = 0.0;
|
||||
foreach ($tokens as $token) {
|
||||
$ts = $this->tokenScore($token, $tWords, true) * self::TITLE_WEIGHT
|
||||
+ $this->tokenScore($token, $cWords, true) * self::CAT_WEIGHT
|
||||
+ $this->tokenScore($token, $pWords, false) * self::CONTENT_WEIGHT;
|
||||
$total += $ts;
|
||||
}
|
||||
|
||||
if ($total > 0.0) {
|
||||
$uuid = $article['uuid'];
|
||||
$scoreMap[$uuid] = $total;
|
||||
$articleMap[$uuid] = $article;
|
||||
}
|
||||
}
|
||||
|
||||
return [$scoreMap, $articleMap];
|
||||
}
|
||||
|
||||
// ─── Trigramme ───────────────────────────────────────────────────────────
|
||||
|
||||
private function trigramSimilarity(string $a, string $b): float
|
||||
{
|
||||
$tA = $this->trigrams($a);
|
||||
$tB = $this->trigrams($b);
|
||||
if (empty($tA) || empty($tB)) {
|
||||
return 0.0;
|
||||
}
|
||||
$common = count(array_intersect($tA, $tB));
|
||||
return $common / max(count($tA), count($tB));
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private function trigrams(string $s): array
|
||||
{
|
||||
$out = [];
|
||||
$len = mb_strlen($s);
|
||||
for ($i = 0; $i + 2 < $len; $i++) {
|
||||
$out[] = mb_substr($s, $i, 3);
|
||||
}
|
||||
return array_unique($out);
|
||||
}
|
||||
|
||||
// ─── Snippet avec surbrillance ────────────────────────────────────────────
|
||||
|
||||
private function buildSnippet(string $text, array $tokens): string
|
||||
{
|
||||
$norm = $this->normalize($text);
|
||||
$pos = 0;
|
||||
foreach ($tokens as $token) {
|
||||
$p = mb_strpos($norm, $token);
|
||||
if ($p !== false) {
|
||||
$pos = max(0, $p - 60);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$raw = mb_substr($text, $pos, self::SNIPPET_LEN);
|
||||
if ($pos > 0) {
|
||||
$raw = '…' . ltrim($raw);
|
||||
}
|
||||
if ($pos + self::SNIPPET_LEN < mb_strlen($text)) {
|
||||
$raw .= '…';
|
||||
}
|
||||
|
||||
$escaped = htmlspecialchars($raw, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
// Surbrillance : on cherche les tokens dans le texte HTML-échappé
|
||||
foreach ($tokens as $token) {
|
||||
$escaped = (string) preg_replace(
|
||||
'/(' . preg_quote(htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), '/') . ')/iu',
|
||||
'<mark>$1</mark>',
|
||||
$escaped
|
||||
);
|
||||
}
|
||||
|
||||
return $escaped;
|
||||
}
|
||||
|
||||
// ─── Helpers texte ────────────────────────────────────────────────────────
|
||||
|
||||
/** Découpe en mots normalisés (min. 2 caractères). */
|
||||
private function tokenize(string $text): array
|
||||
{
|
||||
$norm = $this->normalize($text);
|
||||
$words = preg_split('/\W+/u', $norm, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
return array_values(array_filter($words, fn ($w) => mb_strlen($w) >= 2));
|
||||
}
|
||||
|
||||
/** Minuscule + translittération des accents français. */
|
||||
private function normalize(string $text): string
|
||||
{
|
||||
$text = mb_strtolower($text, 'UTF-8');
|
||||
return strtr($text, [
|
||||
'à' => 'a', 'â' => 'a', 'ä' => 'a',
|
||||
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
|
||||
'î' => 'i', 'ï' => 'i',
|
||||
'ô' => 'o', 'ö' => 'o',
|
||||
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
|
||||
'ç' => 'c', 'æ' => 'ae', 'œ' => 'oe', 'ñ' => 'n',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Retire la syntaxe Markdown pour extraire le texte brut. */
|
||||
private function stripMarkdown(string $md): string
|
||||
{
|
||||
$t = preg_replace('/!\[[^\]]*\]\([^)]+\)/', '', $md) ?? $md; // images
|
||||
$t = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $t) ?? $t; // liens
|
||||
$t = preg_replace('/```[\s\S]*?```/', '', $t) ?? $t; // blocs code
|
||||
$t = preg_replace('/`[^`]+`/', '', $t) ?? $t; // code inline
|
||||
$t = preg_replace('/^#{1,6}\s*/m', '', $t) ?? $t; // titres
|
||||
$t = preg_replace('/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/', '$1', $t) ?? $t; // gras/italique
|
||||
$t = preg_replace('/^\s*[-*+|>]\s*/m', '', $t) ?? $t; // listes, citations, tableaux
|
||||
$t = preg_replace('/\n{2,}/', ' ', $t) ?? $t;
|
||||
return trim($t);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class SearchLogParser
|
||||
{
|
||||
private string $logDir;
|
||||
private string $vhostBase;
|
||||
private string $cacheFile;
|
||||
private int $cacheTtl;
|
||||
|
||||
public function __construct(
|
||||
string $logDir = '/var/log/apache2',
|
||||
string $vhostBase = 'lan.acegrp.varlog-access.log',
|
||||
string $cacheFile = '',
|
||||
int $cacheTtl = 600
|
||||
) {
|
||||
$this->logDir = rtrim($logDir, '/');
|
||||
$this->vhostBase = $vhostBase;
|
||||
$this->cacheFile = $cacheFile !== ''
|
||||
? $cacheFile
|
||||
: dirname(__DIR__) . '/_cache/search_terms.json';
|
||||
$this->cacheTtl = $cacheTtl;
|
||||
}
|
||||
|
||||
/** @return array<string,int> terme => nombre d'occurrences, trié desc */
|
||||
public function topTerms(int $limit = 100): array
|
||||
{
|
||||
if ($this->cacheValid()) {
|
||||
$data = json_decode((string) file_get_contents($this->cacheFile), true);
|
||||
if (is_array($data)) {
|
||||
return array_slice($data, 0, $limit, true);
|
||||
}
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
foreach ($this->logFiles() as $file) {
|
||||
$this->parseFile($file, $counts);
|
||||
}
|
||||
arsort($counts);
|
||||
|
||||
@mkdir(dirname($this->cacheFile), 0755, true);
|
||||
file_put_contents($this->cacheFile, json_encode($counts, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return array_slice($counts, 0, $limit, true);
|
||||
}
|
||||
|
||||
public function isReadable(): bool
|
||||
{
|
||||
$f = $this->logDir . '/' . $this->vhostBase;
|
||||
return file_exists($f) && is_readable($f);
|
||||
}
|
||||
|
||||
private function cacheValid(): bool
|
||||
{
|
||||
return file_exists($this->cacheFile)
|
||||
&& (time() - filemtime($this->cacheFile)) < $this->cacheTtl;
|
||||
}
|
||||
|
||||
/** @return list<array{path:string,gz:bool}> */
|
||||
private function logFiles(): array
|
||||
{
|
||||
$base = $this->logDir . '/' . $this->vhostBase;
|
||||
$files = [];
|
||||
|
||||
if (file_exists($base) && is_readable($base)) {
|
||||
$files[] = ['path' => $base, 'gz' => false];
|
||||
}
|
||||
|
||||
for ($i = 1; $i <= 14; $i++) {
|
||||
$plain = $base . '.' . $i;
|
||||
$gz = $plain . '.gz';
|
||||
if (file_exists($plain) && is_readable($plain)) {
|
||||
$files[] = ['path' => $plain, 'gz' => false];
|
||||
} elseif (file_exists($gz) && is_readable($gz)) {
|
||||
$files[] = ['path' => $gz, 'gz' => true];
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function parseFile(array $file, array &$counts): void
|
||||
{
|
||||
if ($file['gz']) {
|
||||
$h = @gzopen($file['path'], 'rb');
|
||||
if (!$h) {
|
||||
return;
|
||||
}
|
||||
while (!gzeof($h)) {
|
||||
$line = gzgets($h, 8192);
|
||||
if ($line !== false) {
|
||||
$this->parseLine($line, $counts);
|
||||
}
|
||||
}
|
||||
gzclose($h);
|
||||
} else {
|
||||
$h = @fopen($file['path'], 'rb');
|
||||
if (!$h) {
|
||||
return;
|
||||
}
|
||||
while (($line = fgets($h)) !== false) {
|
||||
$this->parseLine($line, $counts);
|
||||
}
|
||||
fclose($h);
|
||||
}
|
||||
}
|
||||
|
||||
private function parseLine(string $line, array &$counts): void
|
||||
{
|
||||
if (!str_contains($line, 'GET /search?')) {
|
||||
return;
|
||||
}
|
||||
if (!preg_match('/"GET \/search\?([^"]*) HTTP\//', $line, $m)) {
|
||||
return;
|
||||
}
|
||||
|
||||
parse_str($m[1], $params);
|
||||
$q = trim(urldecode($params['q'] ?? ''));
|
||||
|
||||
if ($q === '' || mb_strlen($q) > 200) {
|
||||
return;
|
||||
}
|
||||
$q = mb_strtolower($q);
|
||||
$counts[$q] = ($counts[$q] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
|
||||
final class AuthService
|
||||
{
|
||||
public function __construct(private UserRepository $users)
|
||||
{
|
||||
}
|
||||
|
||||
public function canAttempt(string $email, string $ip): bool
|
||||
{
|
||||
// backoff: 5 dernières tentatives/5 min
|
||||
$sql = "select count(*)
|
||||
from login_attempts
|
||||
where ip = :ip
|
||||
and attempted_at > now() - interval '5 minutes'
|
||||
and success = false";
|
||||
$st = \App\Infrastructure\Database::pdo()->prepare($sql);
|
||||
$st->execute([':ip' => $ip]);
|
||||
$fails = (int)$st->fetchColumn();
|
||||
return $fails < 10; // à ajuster
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function login(string $email, string $password, string $ip): bool
|
||||
{
|
||||
$user = $this->users->findByEmail($email);
|
||||
$ok = $user && $user->isActive && password_verify($password, $user->passwordHash);
|
||||
|
||||
$pdo = \App\Infrastructure\Database::pdo();
|
||||
$st = $pdo->prepare('insert into login_attempts(email, ip, success) values(:e, :ip, :s)');
|
||||
$st->bindValue(':e', $email, \PDO::PARAM_STR);
|
||||
$st->bindValue(':ip', $ip, \PDO::PARAM_STR);
|
||||
$st->bindValue(':s', $ok, \PDO::PARAM_BOOL);
|
||||
$st->execute();
|
||||
|
||||
if ($ok) {
|
||||
\App\Infrastructure\Session::regenerate();
|
||||
$_SESSION['uid'] = $user->id;
|
||||
$_SESSION['email'] = $user->email;
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
|
||||
public function changePassword(string $userId, string $currentPassword, string $newPassword): bool
|
||||
{
|
||||
// Récupération de l’utilisateur (rapide : requête directe ; tu peux créer findById() si tu préfères)
|
||||
$pdo = \App\Infrastructure\Database::pdo();
|
||||
$st = $pdo->prepare('select id, email, password_hash, is_active from users where id = :id');
|
||||
$st->execute([':id' => $userId]);
|
||||
$row = $st->fetch(\PDO::FETCH_ASSOC);
|
||||
if (!$row || !(bool)$row['is_active']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier l’ancien mot de passe
|
||||
if (!password_verify($currentPassword, (string)$row['password_hash'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Politique minimale : longueur uniquement (espaces autorisés)
|
||||
if (mb_strlen($newPassword) < 7) {
|
||||
return false;
|
||||
}
|
||||
// (optionnel) interdire seulement le caractère NUL
|
||||
if (strpos($newPassword, "\0") !== false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mettre à jour le hash
|
||||
$newHash = password_hash($newPassword, PASSWORD_ARGON2ID);
|
||||
(new \App\Repository\UserRepository(\App\Infrastructure\Database::get()))->updatePassword($row['id'], $newHash);
|
||||
|
||||
// (Optionnel) rotation session
|
||||
\App\Infrastructure\Session::regenerate();
|
||||
return true;
|
||||
}
|
||||
|
||||
public function register(string $email, string $password): string
|
||||
{
|
||||
$hash = password_hash($password, PASSWORD_ARGON2ID);
|
||||
return $this->users->create($email, $hash);
|
||||
}
|
||||
|
||||
public static function requireAuth(): void
|
||||
{
|
||||
if (!isset($_SESSION['uid'])) {
|
||||
header('Location: /login');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function logout(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* File d'attente SMTP.
|
||||
*
|
||||
* Responsabilités :
|
||||
* - Enregistrer des emails à envoyer (enqueue)
|
||||
* - Réserver/traiter par batch en évitant les collisions (SKIP LOCKED)
|
||||
* - Gérer les retries avec backoff exponentiel + plafond
|
||||
*
|
||||
* Dépendances :
|
||||
* - App\Infrastructure\Database (PDO)
|
||||
* - App\Service\MailService (pour l'envoi réel + journal)
|
||||
*/
|
||||
final class MailQueue
|
||||
{
|
||||
/** Backoff exponentiel de base (en secondes) : 60s, 120s, 240s, ... */
|
||||
private const BASE_BACKOFF_SECONDS = 60;
|
||||
/** Délai max avant retry (plafond) */
|
||||
private const MAX_BACKOFF_SECONDS = 86400; // 24h
|
||||
/** Durée de "lease" (verrou doux) pendant le traitement */
|
||||
private const LEASE_SECONDS = 120;
|
||||
|
||||
private PDO $pdo;
|
||||
private MailService $mailService;
|
||||
|
||||
public function __construct(\PDO $pdo, MailService $mailService)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->mailService = $mailService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un email dans la file.
|
||||
*
|
||||
* @param array{delay?:int} $options delay en secondes avant éligibilité (facultatif)
|
||||
*/
|
||||
public function enqueue(string $to, string $subject, string $body, array $options = []): int
|
||||
{
|
||||
$delay = max(0, (int)($options['delay'] ?? 0));
|
||||
|
||||
$sql = <<<SQL
|
||||
INSERT INTO mail_queue (to_email, subject, body, available_at)
|
||||
VALUES (:to, :subject, :body, (NOW() AT TIME ZONE 'UTC') + (:delay || ' seconds')::interval)
|
||||
RETURNING id
|
||||
SQL;
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
':to' => trim($to),
|
||||
':subject' => $subject,
|
||||
':body' => $body,
|
||||
':delay' => $delay,
|
||||
]);
|
||||
|
||||
/** @var int $id */
|
||||
$id = (int) $stmt->fetchColumn();
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite la file en batch.
|
||||
* - Récupère jusqu'à $max jobs "disponibles"
|
||||
* - Pose un lease (locked_at) pour éviter les doublons de traitement
|
||||
* - Envoie via MailService
|
||||
* - Met à jour le statut (sent/failed) + planifie un retry si besoin
|
||||
*
|
||||
* @return array{processed:int, sent:int, failed:int, retried:int}
|
||||
*/
|
||||
public function process(int $max = 100): array
|
||||
{
|
||||
$max = max(1, min(1000, $max));
|
||||
|
||||
$jobs = $this->reserveBatch($max);
|
||||
$processed = $sent = $failed = $retried = 0;
|
||||
|
||||
foreach ($jobs as $job) {
|
||||
$processed++;
|
||||
$ok = false;
|
||||
try {
|
||||
// Anti-abus est appliqué par MailService::send()
|
||||
$ok = $this->mailService->send($job['to_email'], $job['subject'], $job['body']);
|
||||
} catch (Throwable $e) {
|
||||
// On traitera en "retry" sous ce catch
|
||||
$this->appendError((int)$job['id'], 'unexpected: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
$this->markAsSent((int)$job['id']);
|
||||
$sent++;
|
||||
} else {
|
||||
// incrémente attempts et programme retry/backoff
|
||||
$didRetry = $this->scheduleRetry((int)$job['id'], (int)$job['attempts'] + 1);
|
||||
if ($didRetry) {
|
||||
$retried++;
|
||||
} else {
|
||||
$this->markAsFailed((int)$job['id']);
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return compact('processed', 'sent', 'failed', 'retried');
|
||||
}
|
||||
|
||||
/**
|
||||
* Réserve jusqu'à $max jobs prêts (pending) en posant locked_at (lease).
|
||||
* Utilise FOR UPDATE SKIP LOCKED pour le parallélisme côté SQL.
|
||||
*
|
||||
* @return array<int, array{id:int,to_email:string,subject:string,body:string,attempts:int}>
|
||||
*/
|
||||
private function reserveBatch(int $max): array
|
||||
{
|
||||
// 1) Sélection des candidats
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
$sql = <<<SQL
|
||||
SELECT id, to_email, subject, body, attempts
|
||||
FROM mail_queue
|
||||
WHERE status = 'pending'
|
||||
AND available_at <= (NOW() AT TIME ZONE 'UTC')
|
||||
AND (locked_at IS NULL OR locked_at <= (NOW() AT TIME ZONE 'UTC') - INTERVAL ':lease seconds')
|
||||
ORDER BY available_at ASC, id ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :max
|
||||
SQL;
|
||||
|
||||
// Interpolation sécurisée du LEASE_SECONDS pour l'INTERVAL
|
||||
$sql = str_replace(':lease', (string) self::LEASE_SECONDS, $sql);
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->bindValue(':max', $max, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
if ($rows) {
|
||||
// 2) Marquer locked_at + sending
|
||||
$ids = array_map(static fn ($r) => (int)$r['id'], $rows);
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
|
||||
$up = $this->pdo->prepare(
|
||||
"UPDATE mail_queue
|
||||
SET locked_at = (NOW() AT TIME ZONE 'UTC'),
|
||||
status = 'sending'
|
||||
WHERE id IN ($in)"
|
||||
);
|
||||
$up->execute($ids);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
return $rows;
|
||||
} catch (Throwable $e) {
|
||||
$this->pdo->rollBack();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function markAsSent(int $id): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare("UPDATE mail_queue SET status='sent', locked_at=NULL WHERE id=:id");
|
||||
$stmt->execute([':id' => $id]);
|
||||
}
|
||||
|
||||
private function markAsFailed(int $id): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare("UPDATE mail_queue SET status='failed', locked_at=NULL WHERE id=:id");
|
||||
$stmt->execute([':id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Définit la prochaine tentative avec backoff exponentiel plafonné.
|
||||
* Retourne false si on considère que c'est "trop" et qu'il faut passer en failed.
|
||||
*/
|
||||
private function scheduleRetry(int $id, int $nextAttempt): bool
|
||||
{
|
||||
// Politique simple : jusqu'à 8 tentatives (≈ ~4h de backoff cumulé, puis plafond 24h)
|
||||
if ($nextAttempt > 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$delay = min(self::BASE_BACKOFF_SECONDS * (2 ** ($nextAttempt - 1)), self::MAX_BACKOFF_SECONDS);
|
||||
|
||||
$sql = <<<SQL
|
||||
UPDATE mail_queue
|
||||
SET attempts = :attempts,
|
||||
status = 'pending',
|
||||
locked_at = NULL,
|
||||
available_at = (NOW() AT TIME ZONE 'UTC') + (:delay || ' seconds')::interval
|
||||
WHERE id = :id
|
||||
SQL;
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
':attempts' => $nextAttempt,
|
||||
':delay' => $delay,
|
||||
':id' => $id,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function appendError(int $id, string $message): void
|
||||
{
|
||||
$sql = "UPDATE mail_queue SET last_error = COALESCE(last_error,'') || :e || E'\n' WHERE id = :id";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
':e' => '[' . (new DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('c') . '] ' . $message,
|
||||
':id' => $id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception as MailException;
|
||||
use PDO;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Service d'envoi d'emails via SMTP avec journalisation en base (journal_smtp)
|
||||
*
|
||||
* Dépendances :
|
||||
* - PHPMailer\PHPMailer (SMTP)
|
||||
* - App\Infrastructure\Database (retourne un PDO)
|
||||
*
|
||||
* Anti-abus (larges) :
|
||||
* - Min. 5 minutes entre deux envois au même destinataire
|
||||
* - Max. 5 envois au même destinataire sur 12 heures
|
||||
* - Garde-fous globaux (désactivables) : max X envois / heure
|
||||
*/
|
||||
final class MailService
|
||||
{
|
||||
/** Règles anti-abus (ajustables) */
|
||||
private const MIN_INTERVAL_BETWEEN_SENDS_SECONDS = 300; // 5 minutes
|
||||
private const MAX_SENDS_PER_12H_PER_RECIPIENT = 5; // 5 en 12h
|
||||
private const MAX_GLOBAL_PER_HOUR = 200; // global guardrail (0 pour désactiver)
|
||||
private const MAX_SUBJECT_LEN = 255; // coupe en journal
|
||||
private const MAX_BODY_LEN_FOR_LOG = 50000; // évite de gaver la base
|
||||
|
||||
private PDO $pdo;
|
||||
private PHPMailer $mailer;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $smtpConfig;
|
||||
|
||||
/**
|
||||
* @param \PDO $pdo PDO connecté (PostgreSQL recommandé)
|
||||
* @param array<string,mixed> $smtpConfig [
|
||||
* 'host' => 'smtp.example.tld',
|
||||
* 'port' => 587,
|
||||
* 'username' => 'user',
|
||||
* 'password' => 'pass',
|
||||
* 'encryption' => 'tls'|'ssl'|null,
|
||||
* 'from' => 'no-reply@example.tld',
|
||||
* 'from_name' => 'Mon appli',
|
||||
* 'reply_to' => 'contact@example.tld' (optionnel),
|
||||
* 'reply_to_name' => 'Support' (optionnel),
|
||||
* 'smtp_options' => [...] (optionnel, cf. PHPMailer::SMTPOptions)
|
||||
* ]
|
||||
*/
|
||||
public function __construct(\PDO $pdo, array $smtpConfig)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->smtpConfig = $smtpConfig;
|
||||
|
||||
$this->mailer = new PHPMailer(true);
|
||||
$this->configureMailer($this->mailer, $smtpConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un mail et journalise l'opération dans journal_smtp
|
||||
*/
|
||||
public function send(string $to, string $subject, string $body): bool
|
||||
{
|
||||
$to = trim($to);
|
||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->log('blocked', $to, $subject, $body, 'invalid_email');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->passesRateLimits($to)) {
|
||||
$this->log('blocked', $to, $subject, $body, 'rate_limited');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->mailer->clearAddresses();
|
||||
$this->mailer->clearReplyTos();
|
||||
|
||||
$this->mailer->addAddress($to);
|
||||
if (!empty($this->smtpConfig['reply_to'])) {
|
||||
$this->mailer->addReplyTo(
|
||||
(string) $this->smtpConfig['reply_to'],
|
||||
(string)($this->smtpConfig['reply_to_name'] ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
$this->mailer->Subject = $subject;
|
||||
$this->mailer->Body = $body;
|
||||
$this->mailer->AltBody = $this->buildAltBody($body);
|
||||
$this->mailer->isHTML($this->looksLikeHtml($body));
|
||||
|
||||
$sent = $this->mailer->send();
|
||||
|
||||
$this->log(
|
||||
$sent ? 'sent' : 'error',
|
||||
$to,
|
||||
$subject,
|
||||
$body,
|
||||
$sent ? null : 'phpmailer_send_returned_false',
|
||||
$this->mailer->getLastMessageID() ?: null
|
||||
);
|
||||
|
||||
return $sent;
|
||||
} catch (MailException $e) {
|
||||
$this->log('error', $to, $subject, $body, 'phpmailer_exception: ' . $e->getMessage());
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('error', $to, $subject, $body, 'unexpected: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les derniers envois (journal)
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function list(int $limit = 50): array
|
||||
{
|
||||
$limit = max(1, min(500, $limit));
|
||||
$sql = <<<SQL
|
||||
SELECT id, created_at, script, recipient, subject, status, error, smtp_host, smtp_user, message_id
|
||||
FROM journal_smtp
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit
|
||||
SQL;
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
/** @var array<int, array<string,mixed>> $rows */
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
return $rows ?: [];
|
||||
}
|
||||
|
||||
// ---------- Internals ----------
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $cfg
|
||||
*/
|
||||
private function configureMailer(PHPMailer $m, array $cfg): void
|
||||
{
|
||||
$m->isSMTP();
|
||||
$m->Host = (string) $cfg['host'];
|
||||
$m->Port = (int) ($cfg['port'] ?? 587);
|
||||
$m->SMTPAuth = true;
|
||||
$m->Username = (string) $cfg['username'];
|
||||
$m->Password = (string) $cfg['password'];
|
||||
$m->SMTPSecure = $cfg['encryption'] ?? PHPMailer::ENCRYPTION_STARTTLS;
|
||||
if (!empty($cfg['smtp_options']) && is_array($cfg['smtp_options'])) {
|
||||
$m->SMTPOptions = $cfg['smtp_options'];
|
||||
}
|
||||
|
||||
$from = (string) ($cfg['from'] ?? $cfg['username']);
|
||||
$fromName = (string) ($cfg['from_name'] ?? '');
|
||||
$m->setFrom($from, $fromName);
|
||||
|
||||
// Hygiène SMTP
|
||||
$m->CharSet = 'UTF-8';
|
||||
$m->Encoding = 'base64';
|
||||
$m->Timeout = 15; // secondes
|
||||
}
|
||||
|
||||
/** Règles anti-abus : per-recipient + garde-fous globaux */
|
||||
private function passesRateLimits(string $recipient): bool
|
||||
{
|
||||
// 1) Min interval per destinataire (5 min)
|
||||
$sql1 = <<<SQL
|
||||
SELECT created_at
|
||||
FROM journal_smtp
|
||||
WHERE recipient = :r
|
||||
AND status IN ('sent','error','blocked') -- tout envoi/essai compte
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
SQL;
|
||||
$stmt1 = $this->pdo->prepare($sql1);
|
||||
$stmt1->execute([':r' => $recipient]);
|
||||
$last = $stmt1->fetchColumn();
|
||||
if ($last) {
|
||||
$lastTs = (new DateTimeImmutable((string) $last))->getTimestamp();
|
||||
$delta = time() - $lastTs;
|
||||
if ($delta < self::MIN_INTERVAL_BETWEEN_SENDS_SECONDS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Max 5 en 12h par destinataire
|
||||
$sql2 = <<<SQL
|
||||
SELECT COUNT(*)::int
|
||||
FROM journal_smtp
|
||||
WHERE recipient = :r
|
||||
AND created_at >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '12 hours'
|
||||
AND status = 'sent'
|
||||
SQL;
|
||||
$stmt2 = $this->pdo->prepare($sql2);
|
||||
$stmt2->execute([':r' => $recipient]);
|
||||
$count12h = (int) $stmt2->fetchColumn();
|
||||
if ($count12h >= self::MAX_SENDS_PER_12H_PER_RECIPIENT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3) Garde-fou global / heure
|
||||
$sql3 = <<<SQL
|
||||
SELECT COUNT(*)::int
|
||||
FROM journal_smtp
|
||||
WHERE created_at >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '1 hour'
|
||||
AND status = 'sent'
|
||||
SQL;
|
||||
$stmt3 = $this->pdo->query($sql3);
|
||||
$global1h = (int) $stmt3->fetchColumn();
|
||||
if ($global1h >= self::MAX_GLOBAL_PER_HOUR) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Journalise un envoi / tentative
|
||||
*
|
||||
* @param 'sent'|'error'|'blocked' $status
|
||||
*/
|
||||
private function log(
|
||||
string $status,
|
||||
string $recipient,
|
||||
string $subject,
|
||||
string $body,
|
||||
?string $error = null,
|
||||
?string $messageId = null
|
||||
): void {
|
||||
$script = $this->detectScript();
|
||||
$host = (string) ($this->smtpConfig['host'] ?? '');
|
||||
$user = (string) ($this->smtpConfig['username'] ?? '');
|
||||
$subjectDb = mb_strimwidth($subject, 0, self::MAX_SUBJECT_LEN, '…', 'UTF-8');
|
||||
$bodyDb = mb_strimwidth($body, 0, self::MAX_BODY_LEN_FOR_LOG, '…', 'UTF-8');
|
||||
|
||||
$sql = <<<SQL
|
||||
INSERT INTO journal_smtp
|
||||
(created_at, script, recipient, subject, body, status, error, smtp_host, smtp_user, message_id)
|
||||
VALUES
|
||||
(NOW() AT TIME ZONE 'UTC', :script, :recipient, :subject, :body, :status, :error, :smtp_host, :smtp_user, :message_id)
|
||||
SQL;
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
':script' => $script,
|
||||
':recipient' => $recipient,
|
||||
':subject' => $subjectDb,
|
||||
':body' => $bodyDb,
|
||||
':status' => $status,
|
||||
':error' => $error,
|
||||
':smtp_host' => $host,
|
||||
':smtp_user' => $user,
|
||||
':message_id' => $messageId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function detectScript(): string
|
||||
{
|
||||
// Exemple : /public/pages/notifications/send.php
|
||||
$script = $_SERVER['SCRIPT_NAME'] ?? ($_SERVER['PHP_SELF'] ?? '');
|
||||
if ($script === '' && \PHP_SAPI === 'cli') {
|
||||
$script = $_SERVER['argv'][0] ?? 'cli';
|
||||
}
|
||||
return (string) $script;
|
||||
}
|
||||
|
||||
private function looksLikeHtml(string $body): bool
|
||||
{
|
||||
return (bool) preg_match('~<(?:html|body|div|p|span|table|br|h[1-6]|a)\b~i', $body);
|
||||
}
|
||||
|
||||
private function buildAltBody(string $body): string
|
||||
{
|
||||
if (!$this->looksLikeHtml($body)) {
|
||||
// Déjà texte brut
|
||||
return $body;
|
||||
}
|
||||
// Version simplifiée : strip tags (on peut faire mieux selon besoins)
|
||||
$text = trim((string) @html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
|
||||
return $text !== '' ? $text : '[Voir version HTML]';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\DictionaryRepository;
|
||||
|
||||
final class UiFormRenderer
|
||||
{
|
||||
public function __construct(private DictionaryRepository $dict)
|
||||
{
|
||||
}
|
||||
|
||||
public function renderControls(string $entityCode, array $values = []): string
|
||||
{
|
||||
$e = $this->dict->getEntityByCode($entityCode);
|
||||
if (!$e) {
|
||||
return '<div class="alert alert-danger">Entité inconnue</div>';
|
||||
}
|
||||
|
||||
$html = '';
|
||||
foreach ($e['fields'] as $f) {
|
||||
if (!$f['form_visible']) {
|
||||
continue;
|
||||
}
|
||||
if ($f['read_only']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $f['code'];
|
||||
$label = $f['label'];
|
||||
$help = $f['help_text'] ?? '';
|
||||
$widget = $f['ui_widget'] ?? 'text';
|
||||
$val = $values[$name] ?? '';
|
||||
|
||||
$html .= '<div class="mb-3">';
|
||||
$html .= '<label class="form-label" for="'.$name.'">'.htmlspecialchars($label).'</label>';
|
||||
|
||||
if ($widget === 'select' && $f['enum_domain']) {
|
||||
$opts = $this->dict->getEnum($f['enum_domain']);
|
||||
$html .= '<select id="'.$name.'" name="'.$name.'" class="form-select">';
|
||||
foreach ($opts as $opt) {
|
||||
$sel = ($val !== '' && (string)$val === (string)$opt['code']) ? ' selected' : '';
|
||||
$html .= '<option value="'.htmlspecialchars($opt['code']).'"'.$sel.'>'
|
||||
. htmlspecialchars($opt['label']).'</option>';
|
||||
}
|
||||
$html .= '</select>';
|
||||
} else {
|
||||
$type = match ($widget) {
|
||||
'email' => 'email',
|
||||
'number' => 'number',
|
||||
'date' => 'date',
|
||||
'checkbox' => 'checkbox',
|
||||
default => 'text',
|
||||
};
|
||||
if ($type === 'checkbox') {
|
||||
$chk = $val ? ' checked' : '';
|
||||
$html .= '<input class="form-check-input" type="checkbox" id="'.$name.'" name="'.$name.'" value="1"'.$chk.'>';
|
||||
} else {
|
||||
$placeholder = $f['placeholder'] ?? '';
|
||||
$html .= '<input class="form-control" type="'.$type.'" id="'.$name.'" name="'.$name.'"'
|
||||
. ' value="'.htmlspecialchars((string)$val, ENT_QUOTES).'"'
|
||||
. ($placeholder ? ' placeholder="'.htmlspecialchars($placeholder, ENT_QUOTES).'"' : '')
|
||||
. '>';
|
||||
}
|
||||
}
|
||||
|
||||
if ($help) {
|
||||
$html .= '<div class="form-text">'.htmlspecialchars($help).'</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\DictionaryRepository;
|
||||
|
||||
final class Validator
|
||||
{
|
||||
public function __construct(private DictionaryRepository $dict)
|
||||
{
|
||||
}
|
||||
|
||||
public function validate(string $entityCode, array $payload): array
|
||||
{
|
||||
$errors = [];
|
||||
$e = $this->dict->getEntityByCode($entityCode);
|
||||
if (!$e) {
|
||||
return ['_global' => ['Entité inconnue']];
|
||||
}
|
||||
|
||||
// Index les champs
|
||||
$fields = [];
|
||||
foreach ($e['fields'] as $f) {
|
||||
$fields[$f['code']] = $f;
|
||||
}
|
||||
|
||||
foreach ($e['rules'] as $r) {
|
||||
$code = $r['field_code'];
|
||||
$type = $r['rule_type'];
|
||||
$val = $r['rule_value'];
|
||||
$msg = $r['message'];
|
||||
|
||||
$v = $code ? ($payload[$code] ?? null) : null;
|
||||
|
||||
switch ($type) {
|
||||
case 'required':
|
||||
if ($code && ($v === null || $v === '')) {
|
||||
$errors[$code][] = $msg;
|
||||
}
|
||||
break;
|
||||
case 'regex':
|
||||
if ($code && $v !== null && $v !== '' && !preg_match('#'.$val.'#u', (string)$v)) {
|
||||
$errors[$code][] = $msg;
|
||||
}
|
||||
break;
|
||||
case 'min':
|
||||
if ($code && is_numeric($v) && (float)$v < (float)$val) {
|
||||
$errors[$code][] = $msg;
|
||||
}
|
||||
break;
|
||||
case 'max':
|
||||
if ($code && is_numeric($v) && (float)$v > (float)$val) {
|
||||
$errors[$code][] = $msg;
|
||||
}
|
||||
break;
|
||||
case 'between':
|
||||
if ($code && is_numeric($v)) {
|
||||
[$a,$b] = array_map('floatval', explode(',', $val));
|
||||
$fv = (float)$v;
|
||||
if ($fv < $a || $fv > $b) {
|
||||
$errors[$code][] = $msg;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'unique':
|
||||
// à implémenter côté repo (SELECT COUNT(*) FROM table WHERE col=:v AND id<>:id)
|
||||
// Laisse un hook ici.
|
||||
break;
|
||||
case 'custom':
|
||||
// point d’extension si tu veux appeler une callable par nom
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function siteSettingsPath(): string
|
||||
{
|
||||
return BASE_PATH . '/data/site_settings.json';
|
||||
}
|
||||
|
||||
function siteSettings(): array
|
||||
{
|
||||
static $settings = null;
|
||||
if ($settings !== null) {
|
||||
return $settings;
|
||||
}
|
||||
$settings = [];
|
||||
$path = siteSettingsPath();
|
||||
if (is_file($path)) {
|
||||
$data = @json_decode((string)file_get_contents($path), true);
|
||||
if (is_array($data)) {
|
||||
$settings = $data;
|
||||
}
|
||||
}
|
||||
return $settings;
|
||||
}
|
||||
|
||||
function siteTitle(): string
|
||||
{
|
||||
return siteSettings()['site_title'] ?? 'varlog';
|
||||
}
|
||||
|
||||
function siteClaim(): string
|
||||
{
|
||||
return siteSettings()['site_claim'] ?? 'journal de Cédrix · informatique, hack & loisirs';
|
||||
}
|
||||
|
||||
function siteLang(): string
|
||||
{
|
||||
return siteSettings()['site_lang'] ?? 'fr-FR';
|
||||
}
|
||||
|
||||
function siteLangOgLocale(): string
|
||||
{
|
||||
return str_replace('-', '_', siteLang());
|
||||
}
|
||||
|
||||
function postsPerPage(): int
|
||||
{
|
||||
return max(1, (int)(siteSettings()['posts_per_page'] ?? 12));
|
||||
}
|
||||
|
||||
function siteLicenseLabel(): string
|
||||
{
|
||||
return siteSettings()['site_license_label'] ?? 'CC BY 4.0';
|
||||
}
|
||||
|
||||
function siteLicenseUrl(): string
|
||||
{
|
||||
return siteSettings()['site_license_url'] ?? 'https://creativecommons.org/licenses/by/4.0/';
|
||||
}
|
||||
|
||||
function saveSiteSettings(array $data): void
|
||||
{
|
||||
$current = siteSettings();
|
||||
$stringKeys = ['site_title', 'site_claim', 'site_lang', 'site_license_label', 'site_license_url'];
|
||||
foreach ($stringKeys as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
$val = trim((string)$data[$key]);
|
||||
if ($val !== '') {
|
||||
$current[$key] = $val;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (array_key_exists('posts_per_page', $data)) {
|
||||
$val = (int)$data['posts_per_page'];
|
||||
if ($val > 0) {
|
||||
$current['posts_per_page'] = $val;
|
||||
}
|
||||
}
|
||||
file_put_contents(
|
||||
siteSettingsPath(),
|
||||
json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function smtpSettingsPath(): string
|
||||
{
|
||||
return BASE_PATH . '/data/smtp_settings.json';
|
||||
}
|
||||
|
||||
function smtpSettings(): array
|
||||
{
|
||||
static $s = null;
|
||||
if ($s !== null) {
|
||||
return $s;
|
||||
}
|
||||
$path = smtpSettingsPath();
|
||||
if (is_file($path)) {
|
||||
$data = @json_decode((string)file_get_contents($path), true);
|
||||
if (is_array($data)) {
|
||||
$s = $data;
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
$s = [];
|
||||
return $s;
|
||||
}
|
||||
|
||||
function smtpCfg(string $key, string $envKey, string $default = ''): string
|
||||
{
|
||||
$s = smtpSettings();
|
||||
if (isset($s[$key]) && (string)$s[$key] !== '') {
|
||||
return (string)$s[$key];
|
||||
}
|
||||
$v = $_ENV[$envKey] ?? getenv($envKey);
|
||||
return ($v !== false && $v !== '') ? (string)$v : $default;
|
||||
}
|
||||
|
||||
function saveSmtpSettings(array $data): void
|
||||
{
|
||||
$current = smtpSettings();
|
||||
foreach (['host', 'port', 'secure', 'user', 'from', 'from_name'] as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
$current[$key] = trim((string)$data[$key]);
|
||||
}
|
||||
}
|
||||
if (!empty($data['pass']) && trim((string)$data['pass']) !== '') {
|
||||
$current['pass'] = trim((string)$data['pass']);
|
||||
}
|
||||
file_put_contents(
|
||||
smtpSettingsPath(),
|
||||
json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class TagSuggester
|
||||
{
|
||||
// Mots courants capitalisés en début de phrase française — ignorés sauf si connus
|
||||
private const STOP = [
|
||||
'Le','La','Les','Un','Une','Des','Du','De','Au','Aux',
|
||||
'En','Et','Ou','Si','Car','Mais','Donc','Or','Ni','Que','Qui','Quoi','Dont','Où',
|
||||
'Ce','Cette','Ces','Mon','Ma','Mes','Son','Sa','Ses','Notre','Votre','Leur','Leurs',
|
||||
'Il','Elle','Ils','Elles','Je','Tu','Nous','Vous','On','Se','Lui','Eux',
|
||||
'Tout','Tous','Toute','Toutes','Très','Plus','Moins','Aussi','Même','Bien',
|
||||
'Dans','Sur','Sous','Avec','Sans','Pour','Par','Vers','Depuis','Pendant',
|
||||
'Comme','Puis','Après','Avant','Quand','Alors','Ainsi','Ici','Là',
|
||||
'Voici','Voilà','Ceci','Cela','Ça',
|
||||
'Une','Deux','Trois','Quatre','Cinq','Six','Sept','Huit','Neuf','Dix',
|
||||
'Enfin','Ensuite','Sinon','Donc','Cependant','Toutefois','Néanmoins',
|
||||
'Cette','Chaque','Aucun','Aucune','Plusieurs',
|
||||
];
|
||||
|
||||
/**
|
||||
* Analyse le contenu markdown et retourne des candidats pour un type de tag.
|
||||
*
|
||||
* @param string $markdown Contenu brut de l'article
|
||||
* @param string[] $existingValues Valeurs déjà utilisées dans d'autres articles (pour ce type)
|
||||
* @param string[] $currentTags Tags déjà assignés à CET article pour ce type
|
||||
* @return array<string, array{count:int, known:bool, current:bool, groups:list<string>}>
|
||||
*/
|
||||
public function suggest(
|
||||
string $markdown,
|
||||
array $existingValues = [],
|
||||
array $currentTags = []
|
||||
): array {
|
||||
$plain = $this->stripMarkdown($markdown);
|
||||
|
||||
$candidates = [];
|
||||
|
||||
// ── 1. Valeurs connues dans le système ──────────────────────────────
|
||||
foreach ($existingValues as $val) {
|
||||
$cnt = $this->countOccurrences($plain, $val);
|
||||
if ($cnt > 0) {
|
||||
$this->add($candidates, $val, $cnt, true, in_array($val, $currentTags, true), 'known');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Abréviations : 2-7 majuscules consécutives (avec chiffres/tirets) ──
|
||||
preg_match_all('/\b([A-Z][A-Z0-9]{1,6}(?:-[A-Z0-9]+)?)\b/', $plain, $m);
|
||||
foreach (array_count_values($m[1]) as $abbr => $cnt) {
|
||||
if (!isset($candidates[$abbr])) {
|
||||
$this->add($candidates, $abbr, $cnt, false, in_array($abbr, $currentTags, true), 'abbrev');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. CamelCase / PascalCase (ex: Zigbee2MQTT, OpenWrt, HomeAssistant) ──
|
||||
preg_match_all('/\b([A-Z][a-z]+(?:[A-Z0-9][a-z0-9]*)+)\b/', $plain, $m);
|
||||
foreach (array_count_values($m[1]) as $word => $cnt) {
|
||||
if (!isset($candidates[$word])) {
|
||||
$this->add($candidates, $word, $cnt, false, in_array($word, $currentTags, true), 'camel');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Noms propres multi-mots (ex: "Home Assistant", "Raspberry Pi") ──
|
||||
preg_match_all(
|
||||
'/\b([A-ZÀÂÄÉÈÊËÎÏÔÙÛÜŸÇ][a-zàâäéèêëîïôùûüÿç]{1,}(?:\s+[A-ZÀÂÄÉÈÊËÎÏÔÙÛÜŸÇ][a-zàâäéèêëîïôùûüÿç]{1,})+)\b/u',
|
||||
$plain,
|
||||
$m
|
||||
);
|
||||
foreach (array_count_values($m[1]) as $phrase => $cnt) {
|
||||
if (!isset($candidates[$phrase]) && !$this->isStop($phrase)) {
|
||||
$this->add($candidates, $phrase, $cnt, false, in_array($phrase, $currentTags, true), 'proper');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Mots capitalisés simples présents ≥ 2 fois ───────────────────
|
||||
preg_match_all('/\b([A-ZÀÂÄÉÈÊËÎÏÔÙÛÜŸÇ][a-zàâäéèêëîïôùûüÿç]{2,})\b/u', $plain, $m);
|
||||
foreach (array_count_values($m[1]) as $word => $cnt) {
|
||||
if ($cnt >= 2 && !isset($candidates[$word]) && !in_array($word, self::STOP, true)) {
|
||||
$this->add($candidates, $word, $cnt, false, in_array($word, $currentTags, true), 'proper');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filtrage ─────────────────────────────────────────────────────────
|
||||
foreach (array_keys($candidates) as $key) {
|
||||
if (mb_strlen($key) < 2 || is_numeric($key)) {
|
||||
unset($candidates[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tri : actuels > connus > fréquence ───────────────────────────────
|
||||
uasort(
|
||||
$candidates,
|
||||
fn ($a, $b) =>
|
||||
((int)$b['current'] <=> (int)$a['current'])
|
||||
?: ((int)$b['known'] <=> (int)$a['known'])
|
||||
?: ($b['count'] <=> $a['count'])
|
||||
);
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggestions spéciales pour le champ « catégorie » :
|
||||
* retourne les catégories existantes classées par fréquence dans le texte.
|
||||
*
|
||||
* @param string[] $allCats Clés = nom de catégorie, valeurs = nb articles
|
||||
* @return array<string, int> catégorie => nb occurrences dans le texte
|
||||
*/
|
||||
public function suggestCategory(string $markdown, array $allCats, string $currentCat = ''): array
|
||||
{
|
||||
$plain = $this->stripMarkdown($markdown);
|
||||
$result = [];
|
||||
foreach (array_keys($allCats) as $cat) {
|
||||
$cnt = $this->countOccurrences($plain, $cat);
|
||||
$result[$cat] = $cnt;
|
||||
}
|
||||
// Trier : catégorie courante en premier, puis par occurrence décroissante, puis alphabétique
|
||||
uksort($result, function ($a, $b) use ($result, $currentCat) {
|
||||
if ($a === $currentCat) {
|
||||
return -1;
|
||||
}
|
||||
if ($b === $currentCat) {
|
||||
return 1;
|
||||
}
|
||||
return ($result[$b] <=> $result[$a]) ?: strcmp($a, $b);
|
||||
});
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function add(array &$c, string $key, int $cnt, bool $known, bool $current, string $group): void
|
||||
{
|
||||
$c[$key] = [
|
||||
'count' => $cnt,
|
||||
'known' => $known,
|
||||
'current' => $current,
|
||||
'group' => $group,
|
||||
];
|
||||
}
|
||||
|
||||
private function countOccurrences(string $haystack, string $needle): int
|
||||
{
|
||||
if ($needle === '') {
|
||||
return 0;
|
||||
}
|
||||
return substr_count(mb_strtolower($haystack), mb_strtolower($needle));
|
||||
}
|
||||
|
||||
private function isStop(string $phrase): bool
|
||||
{
|
||||
$words = explode(' ', $phrase);
|
||||
// Phrase stop si tous les mots sont des stop words
|
||||
foreach ($words as $w) {
|
||||
if (!in_array($w, self::STOP, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function stripMarkdown(string $md): string
|
||||
{
|
||||
// Blocs de code → on retire pour éviter les faux positifs de variables/commandes
|
||||
$md = preg_replace('/```[\s\S]*?```/m', ' ', $md);
|
||||
$md = preg_replace('/`[^`\n]+`/', ' ', $md);
|
||||
// En-têtes
|
||||
$md = preg_replace('/^#{1,6}\s+/m', '', $md);
|
||||
// Gras/italique
|
||||
$md = preg_replace('/\*{1,3}([^*]+)\*{1,3}/', '$1', $md);
|
||||
$md = preg_replace('/_{1,3}([^_]+)_{1,3}/', '$1', $md);
|
||||
// Liens et images
|
||||
$md = preg_replace('/!\[[^\]]*\]\([^\)]*\)/', ' ', $md);
|
||||
$md = preg_replace('/\[([^\]]+)\]\([^\)]+\)/', '$1', $md);
|
||||
// URLs brutes
|
||||
$md = preg_replace('/https?:\/\/\S+/', ' ', $md);
|
||||
// Balises HTML
|
||||
$md = strip_tags($md);
|
||||
// Marqueurs de liste
|
||||
$md = preg_replace('/^[\*\-\+]\s+/m', '', $md);
|
||||
// Lignes horizontales
|
||||
$md = preg_replace('/^[-*_]{3,}\s*$/m', '', $md);
|
||||
// Espaces multiples
|
||||
return trim((string)preg_replace('/\s+/', ' ', $md));
|
||||
}
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function isLoggedIn(): bool
|
||||
{
|
||||
return !empty($_SESSION['user_email']);
|
||||
}
|
||||
|
||||
function requireAuth(): void
|
||||
{
|
||||
if (!isLoggedIn()) {
|
||||
$return = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
header('Location: /login' . ($return !== '/' ? '?return_to=' . urlencode($return) : ''), true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
function currentUserEmail(): ?string
|
||||
{
|
||||
return $_SESSION['user_email'] ?? null;
|
||||
}
|
||||
|
||||
function currentUserName(): string
|
||||
{
|
||||
if (!isLoggedIn()) {
|
||||
return '';
|
||||
}
|
||||
if (isset($_SESSION['user_display_name']) && $_SESSION['user_display_name'] !== '') {
|
||||
return $_SESSION['user_display_name'];
|
||||
}
|
||||
$name = authorDisplayName(currentUserEmail() ?? '');
|
||||
$_SESSION['user_display_name'] = $name;
|
||||
return $name;
|
||||
}
|
||||
|
||||
function authorDisplayName(string $email): string
|
||||
{
|
||||
return authorProfile($email)['name'];
|
||||
}
|
||||
|
||||
function authorProfileUrl(string $email): string
|
||||
{
|
||||
return authorProfile($email)['url'];
|
||||
}
|
||||
|
||||
function authorProfile(string $email): array
|
||||
{
|
||||
static $cache = [];
|
||||
$key = strtolower(trim($email));
|
||||
if ($key === '') {
|
||||
return ['name' => '', 'url' => '', 'slug' => ''];
|
||||
}
|
||||
if (array_key_exists($key, $cache)) {
|
||||
return $cache[$key];
|
||||
}
|
||||
$pdo = dbPdo();
|
||||
if ($pdo) {
|
||||
try {
|
||||
$st = $pdo->prepare('SELECT display_name, profile_url, profile_slug, bio FROM user_profiles WHERE email = :e');
|
||||
$st->execute([':e' => $key]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) {
|
||||
$cache[$key] = [
|
||||
'name' => ($row['display_name'] !== '') ? $row['display_name'] : explode('@', $key)[0],
|
||||
'url' => $row['profile_url'] ?? '',
|
||||
'slug' => $row['profile_slug'] ?? '',
|
||||
'bio' => $row['bio'] ?? '',
|
||||
];
|
||||
return $cache[$key];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
$cache[$key] = ['name' => explode('@', $key)[0], 'url' => '', 'slug' => ''];
|
||||
return $cache[$key];
|
||||
}
|
||||
|
||||
function authorSlug(string $email): string
|
||||
{
|
||||
return authorProfile($email)['slug'];
|
||||
}
|
||||
|
||||
function profileBySlug(string $slug): ?array
|
||||
{
|
||||
if ($slug === '') {
|
||||
return null;
|
||||
}
|
||||
$pdo = dbPdo();
|
||||
if (!$pdo) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$st = $pdo->prepare('SELECT email, display_name, profile_url, profile_slug, bio FROM user_profiles WHERE profile_slug = :s');
|
||||
$st->execute([':s' => $slug]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function dbPdo(): ?PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
static $failed = false;
|
||||
if ($failed) {
|
||||
return null;
|
||||
}
|
||||
if ($pdo !== null) {
|
||||
return $pdo;
|
||||
}
|
||||
$dsn = $_ENV['DB_DSN'] ?? (getenv('DB_DSN') ?: '');
|
||||
$user = $_ENV['DB_USER'] ?? (getenv('DB_USER') ?: '');
|
||||
$pass = $_ENV['DB_PASS'] ?? (getenv('DB_PASS') ?: '');
|
||||
if (!$dsn) {
|
||||
$failed = true;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$pdo = new PDO($dsn, $user ?: null, $pass ?: null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
} catch (\Throwable) {
|
||||
$failed = true;
|
||||
return null;
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function currentUserRoles(): array
|
||||
{
|
||||
if (!isLoggedIn()) {
|
||||
return [];
|
||||
}
|
||||
if (isset($_SESSION['user_roles'])) {
|
||||
return $_SESSION['user_roles'];
|
||||
}
|
||||
$pdo = dbPdo();
|
||||
if (!$pdo) {
|
||||
$_SESSION['user_roles'] = [];
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$st = $pdo->prepare(
|
||||
'SELECT r.name FROM roles r
|
||||
JOIN user_roles ur ON ur.role_id = r.id
|
||||
WHERE ur.user_email = :e'
|
||||
);
|
||||
$st->execute([':e' => strtolower(currentUserEmail() ?? '')]);
|
||||
$_SESSION['user_roles'] = $st->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
} catch (\Throwable) {
|
||||
$_SESSION['user_roles'] = [];
|
||||
}
|
||||
return $_SESSION['user_roles'];
|
||||
}
|
||||
|
||||
function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, currentUserRoles(), true);
|
||||
}
|
||||
|
||||
// Capacités connues — clé => label affiché dans l'admin
|
||||
const KNOWN_CAPABILITIES = [
|
||||
'propose_articles' => 'Proposer des articles',
|
||||
'validate_articles_all' => 'Valider des articles',
|
||||
'validate_articles_own' => 'Valider ses articles uniquement',
|
||||
'publish_articles_all' => 'Publier des articles',
|
||||
'publish_articles_own' => 'Publier ses articles uniquement',
|
||||
'edit_articles_all' => 'Modifier des articles',
|
||||
'edit_articles_own' => 'Modifier ses articles uniquement',
|
||||
'rate_articles' => 'Noter des articles',
|
||||
'view_previews' => 'Lire des avant-premières',
|
||||
'view_drafts_all' => 'Voir tous les brouillons',
|
||||
'view_drafts_own' => 'Voir ses brouillons',
|
||||
'view_sources_all' => 'Voir les sources (tous les articles)',
|
||||
'view_sources_own' => 'Voir les sources de ses articles',
|
||||
];
|
||||
|
||||
// Groupes pour l'interface d'administration
|
||||
const CAPABILITY_GROUPS = [
|
||||
'Articles' => [
|
||||
'propose_articles',
|
||||
'validate_articles_all',
|
||||
'validate_articles_own',
|
||||
'publish_articles_all',
|
||||
'publish_articles_own',
|
||||
'edit_articles_all',
|
||||
'edit_articles_own',
|
||||
],
|
||||
'Accès & lecture' => [
|
||||
'rate_articles',
|
||||
'view_previews',
|
||||
'view_drafts_all',
|
||||
'view_drafts_own',
|
||||
'view_sources_all',
|
||||
'view_sources_own',
|
||||
],
|
||||
];
|
||||
|
||||
function currentUserCapabilities(): array
|
||||
{
|
||||
if (!isLoggedIn()) {
|
||||
return [];
|
||||
}
|
||||
if (isset($_SESSION['user_capabilities'])) {
|
||||
return $_SESSION['user_capabilities'];
|
||||
}
|
||||
$pdo = dbPdo();
|
||||
if (!$pdo) {
|
||||
$_SESSION['user_capabilities'] = [];
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$st = $pdo->prepare(
|
||||
'SELECT DISTINCT rc.capability
|
||||
FROM role_capabilities rc
|
||||
JOIN user_roles ur ON ur.role_id = rc.role_id
|
||||
WHERE ur.user_email = :e'
|
||||
);
|
||||
$st->execute([':e' => strtolower(currentUserEmail() ?? '')]);
|
||||
$_SESSION['user_capabilities'] = $st->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
} catch (\Throwable) {
|
||||
$_SESSION['user_capabilities'] = [];
|
||||
}
|
||||
return $_SESSION['user_capabilities'];
|
||||
}
|
||||
|
||||
function hasCapability(string $cap): bool
|
||||
{
|
||||
if (isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
return in_array($cap, currentUserCapabilities(), true);
|
||||
}
|
||||
|
||||
function canDoOnArticle(string $baseCap, array $article): bool
|
||||
{
|
||||
if (isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
if (hasCapability($baseCap . '_all')) {
|
||||
return true;
|
||||
}
|
||||
if (hasCapability($baseCap . '_own')) {
|
||||
$owner = strtolower($article['author'] ?? '');
|
||||
return $owner !== '' && $owner === strtolower(currentUserEmail() ?? '');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAdmin(): bool
|
||||
{
|
||||
$email = currentUserEmail();
|
||||
if (!$email) {
|
||||
return false;
|
||||
}
|
||||
// Fallback bootstrap : var d'env
|
||||
$rawAdmin = $_ENV['ADMIN_EMAIL'] ?? (getenv('ADMIN_EMAIL') ?: '');
|
||||
$allowed = array_filter(array_map('trim', explode(',', (string)$rawAdmin)));
|
||||
if (in_array(strtolower($email), array_map('strtolower', $allowed), true)) {
|
||||
return true;
|
||||
}
|
||||
return hasRole('admin');
|
||||
}
|
||||
|
||||
function ssoLogoutUrl(): string
|
||||
{
|
||||
$issuer = rtrim((string)($_ENV['OIDC_ISSUER'] ?? (getenv('OIDC_ISSUER') ?: '')), '/');
|
||||
$clientId = (string)($_ENV['OIDC_CLIENT_ID'] ?? (getenv('OIDC_CLIENT_ID') ?: ''));
|
||||
$baseUrl = rtrim((string)($_ENV['APP_URL'] ?? (getenv('APP_URL') ?: '/')), '/');
|
||||
|
||||
$params = [
|
||||
'client_id' => $clientId,
|
||||
'post_logout_redirect_uri' => $baseUrl . '/',
|
||||
];
|
||||
if (!empty($_SESSION['oidc']['id_token'])) {
|
||||
$params['id_token_hint'] = $_SESSION['oidc']['id_token'];
|
||||
}
|
||||
|
||||
if (!$issuer) {
|
||||
return $baseUrl . '/';
|
||||
}
|
||||
|
||||
return $issuer . '/protocol/openid-connect/logout?' . http_build_query($params);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once BASE_PATH . '/config/config.php';
|
||||
|
||||
// Comment récupérer les valeurs de .env
|
||||
$dsn = $_ENV['DB_DSN'];
|
||||
$user = $_ENV['DB_USER'];
|
||||
$pass = $_ENV['DB_PASS'];
|
||||
|
||||
// Se connecter
|
||||
try {
|
||||
$db = new PDO($dsn, $user, $pass);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
} catch (PDOException $e) {
|
||||
die('Connexion échouée : ' . $e->getMessage());
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function vd($var, ...$moreVars)
|
||||
{
|
||||
ob_start();
|
||||
var_dump($var, ...$moreVars);
|
||||
$output = ob_get_clean();
|
||||
echo "<pre>$output</pre>";
|
||||
}
|
||||
|
||||
function slugify(string $s): string
|
||||
{
|
||||
$map = ['à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e','î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c','æ' => 'ae','œ' => 'oe'];
|
||||
$s = mb_strtolower($s);
|
||||
$s = strtr($s, $map);
|
||||
$s = (string)preg_replace('/[^a-z0-9]+/', '-', $s);
|
||||
return trim($s, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff ligne-à-ligne via LCS. Retourne un tableau de [op, line] où
|
||||
* op est '=' (inchangé), '-' (supprimé), '+' (ajouté).
|
||||
*/
|
||||
function lineDiff(string $old, string $new): array
|
||||
{
|
||||
$a = explode("\n", $old);
|
||||
$b = explode("\n", $new);
|
||||
$n = count($a);
|
||||
$m = count($b);
|
||||
|
||||
if ($n * $m > 300000) {
|
||||
return [['!', "Diff trop grand ({$n}×{$m} lignes), affichage brut."], ['-', $old], ['+', $new]];
|
||||
}
|
||||
|
||||
$dp = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
|
||||
for ($i = $n - 1; $i >= 0; $i--) {
|
||||
for ($j = $m - 1; $j >= 0; $j--) {
|
||||
$dp[$i][$j] = $a[$i] === $b[$j]
|
||||
? 1 + $dp[$i + 1][$j + 1]
|
||||
: max($dp[$i + 1][$j], $dp[$i][$j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
$diff = [];
|
||||
$i = 0;
|
||||
$j = 0;
|
||||
while ($i < $n || $j < $m) {
|
||||
if ($i < $n && $j < $m && $a[$i] === $b[$j]) {
|
||||
$diff[] = ['=', $a[$i]];
|
||||
$i++;
|
||||
$j++;
|
||||
} elseif ($j < $m && ($i >= $n || $dp[$i][$j + 1] >= $dp[$i + 1][$j])) {
|
||||
$diff[] = ['+', $b[$j++]];
|
||||
} else {
|
||||
$diff[] = ['-', $a[$i++]];
|
||||
}
|
||||
}
|
||||
return $diff;
|
||||
}
|
||||
|
||||
// 16 couleurs RGB de base — distribuées sur le spectre, visuellement distinctes
|
||||
const COLOR_PALETTE_16 = [
|
||||
[220, 38, 38], // rouge
|
||||
[234, 88, 12], // orange
|
||||
[217, 119, 6], // ambre
|
||||
[161, 142, 14], // jaune-olive
|
||||
[77, 124, 15], // citron
|
||||
[22, 163, 74], // vert
|
||||
[4, 120, 87], // émeraude
|
||||
[15, 118, 110], // sarcelle
|
||||
[8, 145, 178], // cyan
|
||||
[3, 105, 161], // ciel
|
||||
[37, 99, 235], // bleu
|
||||
[79, 70, 229], // indigo
|
||||
[109, 40, 217], // violet
|
||||
[147, 51, 234], // pourpre
|
||||
[192, 38, 211], // fuchsia
|
||||
[219, 39, 119], // rose
|
||||
];
|
||||
|
||||
/**
|
||||
* Génère un dégradé CSS pour une catégorie.
|
||||
* Avec $allCats, l'assignation est séquentielle (par ordre alpha) ;
|
||||
* au-delà de 16, un décalage de teinte et d'angle différencie les palettes.
|
||||
* Sans $allCats, fallback par hachage sur la palette.
|
||||
*/
|
||||
function coverGradient(string $seed, array $allCats = []): string
|
||||
{
|
||||
$key = strtolower(trim($seed));
|
||||
|
||||
if (!empty($allCats)) {
|
||||
$keys = array_map(fn ($k) => strtolower(trim((string)$k)), array_keys($allCats));
|
||||
$pos = array_search($key, $keys, true);
|
||||
if ($pos !== false) {
|
||||
$idx = (int) $pos;
|
||||
$tier = (int) floor($idx / 16);
|
||||
$ci = $idx % 16;
|
||||
return _paletteGradient(COLOR_PALETTE_16[$ci], $tier);
|
||||
}
|
||||
}
|
||||
|
||||
// Hachage déterministe en l'absence de liste
|
||||
$ci = abs(crc32($key)) % 16;
|
||||
return _paletteGradient(COLOR_PALETTE_16[$ci], 0);
|
||||
}
|
||||
|
||||
function _paletteGradient(array $rgb, int $tier): string
|
||||
{
|
||||
[$r, $g, $b] = $rgb;
|
||||
|
||||
// Tier 0 : dégradé standard clair → foncé, 135°
|
||||
// Tier 1 : plus saturé, angle inversé, 315°
|
||||
// Tier 2+ : plus sombre encore, 225°
|
||||
$tintMix = match ($tier) {
|
||||
0 => 0.65, 1 => 0.48, default => 0.35
|
||||
};
|
||||
$shadeK = match ($tier) {
|
||||
0 => 0.35, 1 => 0.25, default => 0.18
|
||||
};
|
||||
$angle = match ($tier) {
|
||||
0 => 135, 1 => 315, default => 225
|
||||
};
|
||||
|
||||
$tr = (int) round($r * (1 - $tintMix) + 255 * $tintMix);
|
||||
$tg = (int) round($g * (1 - $tintMix) + 255 * $tintMix);
|
||||
$tb = (int) round($b * (1 - $tintMix) + 255 * $tintMix);
|
||||
|
||||
$sr = (int) round($r * $shadeK);
|
||||
$sg = (int) round($g * $shadeK);
|
||||
$sb = (int) round($b * $shadeK);
|
||||
|
||||
return "linear-gradient({$angle}deg,rgb($tr,$tg,$tb) 0%,rgb($sr,$sg,$sb) 100%)";
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
// projet : mug.a5l.fr
|
||||
// fichier : includes/mailer.php
|
||||
// version : 20251011
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
require_once dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
if (defined('BASE_PATH') && !function_exists('smtpCfg')) {
|
||||
require_once dirname(__DIR__) . '/src/SmtpSettings.php';
|
||||
}
|
||||
|
||||
if (!function_exists('env')) {
|
||||
function env(string $key, ?string $default = null): ?string
|
||||
{
|
||||
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
|
||||
return (string)$_ENV[$key];
|
||||
}
|
||||
$v = getenv($key);
|
||||
if ($v !== false && $v !== '') {
|
||||
return (string)$v;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
if (!function_exists('db')) {
|
||||
function db(): \PDO
|
||||
{
|
||||
return \App\Infrastructure\Database::get();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anti-abus simple : 1 envoi / 5 min et 5 en 12 h par destinataire (status in ('sent','queued')).
|
||||
*/
|
||||
function mailer_can_send(string $email, int $coolMin = 5, int $maxPer12h = 5): array
|
||||
{
|
||||
// bypass complet si désactivé
|
||||
$enabled = (int) (env('SMTP_RATE_LIMIT_ENABLE', '1'));
|
||||
if ($enabled === 0) {
|
||||
return [true, ''];
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// Cooldown (actif seulement si >0)
|
||||
if ($coolMin > 0) {
|
||||
$q1 = "SELECT 1 FROM journal_smtp
|
||||
WHERE to_email = :e AND created_at >= NOW() - INTERVAL :cool
|
||||
AND status IN ('sent','queued')
|
||||
LIMIT 1";
|
||||
$stmt = $pdo->prepare($q1);
|
||||
$stmt->execute([':e' => $email, ':cool' => sprintf('%d minutes', $coolMin)]);
|
||||
if ($stmt->fetchColumn()) {
|
||||
return [false, "Un email vient d’être envoyé. Réessayez dans {$coolMin} min."];
|
||||
}
|
||||
}
|
||||
|
||||
// Plafond 12h (actif seulement si >0)
|
||||
if ($maxPer12h > 0) {
|
||||
$q2 = "SELECT COUNT(*) FROM journal_smtp
|
||||
WHERE to_email = :e AND created_at >= NOW() - INTERVAL '12 hours'
|
||||
AND status IN ('sent','queued')";
|
||||
$stmt = $pdo->prepare($q2);
|
||||
$stmt->execute([':e' => $email]);
|
||||
if ((int)$stmt->fetchColumn() >= $maxPer12h) {
|
||||
return [false, 'Quota atteint. Réessayez plus tard.'];
|
||||
}
|
||||
}
|
||||
|
||||
return [true, ''];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Envoi immédiat SMTP avec PHPMailer + journalisation.
|
||||
* @param string $to destinataire
|
||||
* @param string $subject objet
|
||||
* @param string $html corps HTML
|
||||
* @param string|null $text corps texte brut (optionnel, auto-généré si null)
|
||||
* @param array $opts ['reply_to'=>['email','name']]
|
||||
*/
|
||||
function envoyer_mail_smtp(string $to, string $subject, string $html, ?string $text = null, array $opts = []): bool
|
||||
{
|
||||
if (!($opts['bypass_rate_limit'] ?? false)) {
|
||||
[$ok, $msg] = mailer_can_send($to, (int)env('SMTP_COOLDOWN_MINUTES', '5'), (int)env('SMTP_MAX_PER_12H', '5'));
|
||||
if (!$ok) {
|
||||
throw new RuntimeException($msg);
|
||||
}
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare("INSERT INTO journal_smtp
|
||||
(created_at, script_path, to_email, subject, content_html, content_text, status, ip, user_agent)
|
||||
VALUES (NOW(), :script, :to, :subj, :html, :text, 'queued', :ip, :ua)
|
||||
RETURNING id");
|
||||
$stmt->execute([
|
||||
':script' => ($_SERVER['SCRIPT_NAME'] ?? ''),
|
||||
':to' => $to,
|
||||
':subj' => $subject,
|
||||
':html' => $html,
|
||||
':text' => $text ?? trim(html_entity_decode(strip_tags($html), ENT_QUOTES)),
|
||||
':ip' => ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? ''),
|
||||
':ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512),
|
||||
]);
|
||||
$rowId = (int)$stmt->fetchColumn();
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
try {
|
||||
$mail->isSMTP();
|
||||
$_smtpRead = function_exists('smtpCfg');
|
||||
$mail->Host = $_smtpRead ? smtpCfg('host', 'SMTP_HOST', 'localhost') : (string)env('SMTP_HOST', 'localhost');
|
||||
$mail->Port = (int)($_smtpRead ? smtpCfg('port', 'SMTP_PORT', '587') : env('SMTP_PORT', '587'));
|
||||
$_smtpUser = $_smtpRead ? smtpCfg('user', 'SMTP_USER') : (string)env('SMTP_USER', '');
|
||||
$_smtpPass = $_smtpRead ? smtpCfg('pass', 'SMTP_PASS') : (string)env('SMTP_PASS', '');
|
||||
$mail->SMTPAuth = ($_smtpUser !== '' || $_smtpPass !== '');
|
||||
$mail->Username = $_smtpUser;
|
||||
$mail->Password = $_smtpPass;
|
||||
$secure = strtolower($_smtpRead ? smtpCfg('secure', 'SMTP_SECURE', 'tls') : (string)env('SMTP_SECURE', 'tls'));
|
||||
if ($secure === 'ssl') {
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
||||
} elseif ($secure === 'tls') {
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
}
|
||||
|
||||
$mail->SMTPKeepAlive = true; // réutilise la connexion
|
||||
$mail->Timeout = 30; // évite les blocages longs
|
||||
$mail->SMTPOptions = ['ssl' => ['verify_peer' => true,'verify_peer_name' => true,'allow_self_signed' => false]];
|
||||
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->isHTML(true);
|
||||
|
||||
// Expéditeur
|
||||
$from = $_smtpRead ? smtpCfg('from', 'SMTP_FROM', 'no-reply@varlog.a5l.fr') : (string)env('SMTP_FROM', 'no-reply@varlog.a5l.fr');
|
||||
$fromName = $_smtpRead ? smtpCfg('from_name', 'SMTP_FROM_NAME', 'varlog') : (string)env('SMTP_FROM_NAME', 'varlog');
|
||||
$mail->setFrom($from, $fromName);
|
||||
|
||||
// Reply-To
|
||||
if (!empty($opts['reply_to']) && is_array($opts['reply_to']) && filter_var($opts['reply_to'][0] ?? '', FILTER_VALIDATE_EMAIL)) {
|
||||
$mail->addReplyTo($opts['reply_to'][0], $opts['reply_to'][1] ?? '');
|
||||
} elseif ($rt = env('SMTP_REPLY_TO')) {
|
||||
$mail->addReplyTo($rt, (string)env('SMTP_REPLY_TO_NAME', 'Support'));
|
||||
}
|
||||
|
||||
// DKIM optionnel
|
||||
if ($d = env('DKIM_DOMAIN')) {
|
||||
$mail->DKIM_domain = $d;
|
||||
$mail->DKIM_selector = (string)env('DKIM_SELECTOR', 'default');
|
||||
$mail->DKIM_private = (string)env('DKIM_PRIVATE_KEY_PATH', '');
|
||||
$mail->DKIM_passphrase = (string)env('DKIM_PASSPHRASE', '');
|
||||
$mail->DKIM_identity = $from;
|
||||
}
|
||||
|
||||
$mail->addAddress($to);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $html;
|
||||
$mail->AltBody = $text ?? trim(html_entity_decode(strip_tags($html), ENT_QUOTES));
|
||||
|
||||
$mail->send();
|
||||
|
||||
$pdo->prepare("UPDATE journal_smtp SET status='sent', sent_at=NOW() WHERE id=:id")->execute([':id' => $rowId]);
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
$pdo->prepare("UPDATE journal_smtp SET status='error', error_message=:err, sent_at=NOW() WHERE id=:id")
|
||||
->execute([':id' => $rowId, ':err' => substr($e->getMessage(), 0, 1000)]);
|
||||
throw new RuntimeException('Envoi email impossible: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user