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

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

+92
View File
@@ -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.
+118
View File
@@ -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();
});
+48
View File
@@ -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…';
}
});
}
});
+462
View File
@@ -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}](${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 ? `![](${url})` : `[${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 ? `![](${insertUrl})` : `[${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);
});
});
+14
View File
@@ -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');
});
})();
File diff suppressed because one or more lines are too long
+29
View File
@@ -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();
}
})();
+62
View File
@@ -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();
});
+45
View File
@@ -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(); });
});
});
});
+37
View File
@@ -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
View File
@@ -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>
+35
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+136
View File
@@ -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>
+216
View File
@@ -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';
+80
View File
@@ -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);
}
+6
View File
@@ -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';
+32
View File
@@ -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;
+200
View File
@@ -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;
+194
View File
@@ -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>
+72
View File
@@ -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 dauth ---
$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;
+16
View File
@@ -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
+7
View File
@@ -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;
+49
View File
@@ -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";
+16
View File
@@ -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
+202
View File
@@ -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);
}
}
}
+114
View File
@@ -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');
}
}
+16
View File
@@ -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,
) {
}
}
+185
View File
@@ -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)
);
}
}
+85
View File
@@ -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';
}
}
+24
View 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]); // onetime token
return $ok;
}
}
+94
View File
@@ -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;
}
}
}
+36
View File
@@ -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,
]);
}
}
+42
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+69
View File
@@ -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]);
}
}
+46
View File
@@ -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;
}
}
+63
View File
@@ -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');
}
}
+54
View File
@@ -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);
}
}
+158
View File
@@ -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 dobtenir 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]);
}
}
+129
View File
@@ -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 lid (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;
}
}
+276
View File
@@ -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")
* 00.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 01 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);
}
}
+127
View File
@@ -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;
}
}
+105
View File
@@ -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 lutilisateur (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 lancien 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();
}
}
+218
View File
@@ -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,
]);
}
}
+287
View File
@@ -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]';
}
}
+76
View File
@@ -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;
}
}
+79
View File
@@ -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 dextension si tu veux appeler une callable par nom
break;
}
}
return $errors;
}
}
+84
View File
@@ -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)
);
}
+53
View File
@@ -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)
);
}
+186
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]
* 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
View File
@@ -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