feat : magic link confirm, notif auteur, rate-limit IP, duplicate, cache MD, lazy img (v1.6.18)
- magic.php : GET=confirmation page, POST=consommation (protège vs scanners) (#27) - verify_comment : email de notification à l'auteur de l'article (#44) - login/index.php : rate limit par IP (MAGIC_MAX_PER_IP_HOUR=10) (#23) - ArticleManager::duplicate() + route POST /duplicate/{uuid} + bouton ⧉ admin/articles (#7) - post_view.php : cache JSON du rendu Markdown (invalidé sur mtime index.md) (#17) - post_view.php : loading="lazy" sur toutes les <img> du contenu (#21) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,18 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.6.18] - 2026-05-16
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Lien magique : page de confirmation GET avant consommation POST — protège contre les scanners email (#27)
|
||||||
|
- Lien magique : notification email à l'auteur de l'article lors de la vérification d'un commentaire (#44)
|
||||||
|
- Lien magique : rate limit par IP (`MAGIC_MAX_PER_IP_HOUR`, défaut 10/h) en plus du rate limit par email (#23)
|
||||||
|
- `ArticleManager::duplicate()` + route `/duplicate/{uuid}` + bouton ⧉ dans `admin/articles` (#7)
|
||||||
|
- Cache du rendu Markdown par article (`_cache/content_rendered.json`, invalidé sur `mtime` de `index.md`) (#17)
|
||||||
|
- Lazy loading (`loading="lazy"`) sur toutes les images du contenu Markdown (#21)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.6.17] - 2026-05-16
|
## [1.6.17] - 2026-05-16
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ RewriteRule ^edit/([0-9a-f-]{36})/?$ /index.php?action=edit&uuid=$1 [L,QSA]
|
|||||||
RewriteRule ^new/([0-9a-f-]{36})/([1-5])/?$ /index.php?action=create&uuid=$1&step=$2 [L,QSA]
|
RewriteRule ^new/([0-9a-f-]{36})/([1-5])/?$ /index.php?action=create&uuid=$1&step=$2 [L,QSA]
|
||||||
RewriteRule ^new/?$ /index.php?action=create [L,QSA]
|
RewriteRule ^new/?$ /index.php?action=create [L,QSA]
|
||||||
RewriteRule ^delete/([0-9a-f-]{36})/?$ /index.php?action=delete&uuid=$1 [L,QSA]
|
RewriteRule ^delete/([0-9a-f-]{36})/?$ /index.php?action=delete&uuid=$1 [L,QSA]
|
||||||
|
RewriteRule ^duplicate/([0-9a-f-]{36})/?$ /index.php?action=duplicate&uuid=$1 [L,QSA]
|
||||||
|
|
||||||
# Sources et diff
|
# Sources et diff
|
||||||
RewriteRule ^sources/([0-9a-f-]{36})/?$ /index.php?action=sources&uuid=$1 [L,QSA]
|
RewriteRule ^sources/([0-9a-f-]{36})/?$ /index.php?action=sources&uuid=$1 [L,QSA]
|
||||||
|
|||||||
@@ -1122,6 +1122,27 @@ switch ($action) {
|
|||||||
header('Location: /edit/' . rawurlencode($uuid));
|
header('Location: /edit/' . rawurlencode($uuid));
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
|
case 'duplicate':
|
||||||
|
requireAuth();
|
||||||
|
if ($uuid !== '' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$srcArticle = $articles->getByUuid($uuid);
|
||||||
|
if (!$srcArticle) {
|
||||||
|
header('Location: /admin/articles');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!isAdmin() && ($srcArticle['author'] ?? '') !== (currentUserEmail() ?? '')) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$newUuid = $articles->duplicate($uuid, currentUserEmail() ?? '');
|
||||||
|
if ($newUuid) {
|
||||||
|
header('Location: /edit/' . rawurlencode($newUuid) . '/1');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Location: /admin/articles');
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'delete':
|
case 'delete':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
if ($uuid !== '') {
|
if ($uuid !== '') {
|
||||||
@@ -2171,10 +2192,43 @@ switch ($action) {
|
|||||||
if ($pdo && preg_match('/^[0-9]{6}$/', $vcCode)) {
|
if ($pdo && preg_match('/^[0-9]{6}$/', $vcCode)) {
|
||||||
require_once BASE_PATH . '/src/CommentManager.php';
|
require_once BASE_PATH . '/src/CommentManager.php';
|
||||||
$cm = new CommentManager($pdo);
|
$cm = new CommentManager($pdo);
|
||||||
|
|
||||||
|
// Récupère les données du commentaire avant vérification (le token est effacé après)
|
||||||
|
$vcPreSt = $pdo->prepare(
|
||||||
|
'SELECT author_name, content FROM comments WHERE verify_token = :t AND verified = FALSE LIMIT 1'
|
||||||
|
);
|
||||||
|
$vcPreSt->execute([':t' => $vcToken]);
|
||||||
|
$vcPreInfo = $vcPreSt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||||
|
|
||||||
$result = $cm->verify($vcToken, $vcCode);
|
$result = $cm->verify($vcToken, $vcCode);
|
||||||
if (is_string($result)) {
|
if (is_string($result)) {
|
||||||
$vcArticle = $articles->getByUuid($result);
|
$vcArticle = $articles->getByUuid($result);
|
||||||
$vcSlug = $vcArticle ? ($vcArticle['slug'] ?? $result) : $result;
|
$vcSlug = $vcArticle ? ($vcArticle['slug'] ?? $result) : $result;
|
||||||
|
|
||||||
|
// Notification email à l'auteur de l'article
|
||||||
|
$vcAuthorEmail = $vcArticle['author'] ?? '';
|
||||||
|
if ($vcAuthorEmail !== '' && $vcPreInfo) {
|
||||||
|
require_once BASE_PATH . '/src/mailer.php';
|
||||||
|
$vcPostUrl = rtrim(APP_URL, '/') . '/post/' . rawurlencode($vcSlug) . '#comments';
|
||||||
|
$vcAdminUrl = rtrim(APP_URL, '/') . '/admin/comments';
|
||||||
|
$vcExcerpt = mb_strimwidth(trim((string)$vcPreInfo['content']), 0, 200, '…');
|
||||||
|
$vcSubject = '[' . siteTitle() . '] Nouveau commentaire sur « ' . ($vcArticle['title'] ?? '') . ' »';
|
||||||
|
$vcHtml = '<!DOCTYPE html><html><body style="font-family:sans-serif;max-width:560px;margin:0 auto">'
|
||||||
|
. '<p>Bonjour,</p>'
|
||||||
|
. '<p><strong>' . htmlspecialchars((string)$vcPreInfo['author_name']) . '</strong>'
|
||||||
|
. ' a commenté votre article <em>' . htmlspecialchars($vcArticle['title'] ?? '') . '</em> :</p>'
|
||||||
|
. '<blockquote style="border-left:3px solid #ddd;margin:0;padding:0 1em;color:#555">'
|
||||||
|
. nl2br(htmlspecialchars($vcExcerpt)) . '</blockquote>'
|
||||||
|
. '<p><a href="' . htmlspecialchars($vcPostUrl) . '">Voir le commentaire</a>'
|
||||||
|
. ' · <a href="' . htmlspecialchars($vcAdminUrl) . '">Modérer</a></p>'
|
||||||
|
. '</body></html>';
|
||||||
|
try {
|
||||||
|
envoyer_mail_smtp($vcAuthorEmail, $vcSubject, $vcHtml);
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
// Taux limité ou SMTP indisponible, on ne bloque pas le visiteur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
header('Location: /post/' . rawurlencode($vcSlug) . '?verified=1#comments');
|
header('Location: /post/' . rawurlencode($vcSlug) . '?verified=1#comments');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ $ttlMin = (int) env('MAGIC_LINK_TTL_MINUTES', '30');
|
|||||||
$coolMin = (int) env('MAGIC_COOLDOWN_MINUTES', '5');
|
$coolMin = (int) env('MAGIC_COOLDOWN_MINUTES', '5');
|
||||||
$winHours = (int) env('MAGIC_WINDOW_HOURS', '12');
|
$winHours = (int) env('MAGIC_WINDOW_HOURS', '12');
|
||||||
$maxPerWin = (int) env('MAGIC_MAX_PER_WINDOW', '5');
|
$maxPerWin = (int) env('MAGIC_MAX_PER_WINDOW', '5');
|
||||||
|
$maxPerIpHour = (int) env('MAGIC_MAX_PER_IP_HOUR', '10');
|
||||||
|
|
||||||
// --- return_to ---
|
// --- return_to ---
|
||||||
$defaultReturn = '/';
|
$defaultReturn = '/';
|
||||||
@@ -94,6 +95,15 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
|||||||
throw new RuntimeException('Quota atteint. Réessayez plus tard.');
|
throw new RuntimeException('Quota atteint. Réessayez plus tard.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3) rate limit par IP
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT COUNT(*) FROM auth_magic_links WHERE ip = :ip AND created_at >= NOW() - INTERVAL '1 hour'"
|
||||||
|
);
|
||||||
|
$stmt->execute([':ip' => $ip]);
|
||||||
|
if ((int)$stmt->fetchColumn() >= $maxPerIpHour) {
|
||||||
|
throw new RuntimeException('Quota atteint. Réessayez plus tard.');
|
||||||
|
}
|
||||||
|
|
||||||
// Génère et enregistre le lien avec TTL ttlMin
|
// Génère et enregistre le lien avec TTL ttlMin
|
||||||
$raw = random_bytes(32);
|
$raw = random_bytes(32);
|
||||||
$token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
$token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
||||||
|
|||||||
+43
-10
@@ -1,8 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// projet : mug.a5l.fr
|
|
||||||
// fichier : pages/login/magic.php
|
|
||||||
// version : 20251011
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
if (!defined('BASE_PATH')) {
|
if (!defined('BASE_PATH')) {
|
||||||
@@ -15,14 +11,53 @@ require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
|||||||
$token = (string)($_GET['token'] ?? '');
|
$token = (string)($_GET['token'] ?? '');
|
||||||
if ($token === '' || preg_match('/[^A-Za-z0-9\-\_]/', $token)) {
|
if ($token === '' || preg_match('/[^A-Za-z0-9\-\_]/', $token)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
exit('Lien invalide.');
|
exit(renderMagicPage('Lien invalide', '<p>Ce lien de connexion est invalide.</p>', null));
|
||||||
}
|
}
|
||||||
|
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
|
|
||||||
|
// ─── Rendu minimal standalone ────────────────────────────────────────────────
|
||||||
|
function renderMagicPage(string $title, string $body, ?string $token): string
|
||||||
|
{
|
||||||
|
$formHtml = $token !== null
|
||||||
|
? '<form method="post" action="' . htmlspecialchars($_SERVER['REQUEST_URI'] ?? '') . '">'
|
||||||
|
. '<input type="hidden" name="confirm" value="1">'
|
||||||
|
. '<button type="submit" style="display:inline-block;padding:10px 24px;background:#0d6efd;color:#fff;border:none;border-radius:4px;font-size:1rem;cursor:pointer">Se connecter</button>'
|
||||||
|
. '</form>'
|
||||||
|
: '';
|
||||||
|
return '<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||||
|
. '<title>' . htmlspecialchars($title) . '</title>'
|
||||||
|
. '<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 1rem;text-align:center}'
|
||||||
|
. 'h1{font-size:1.4rem;margin-bottom:1rem}</style></head>'
|
||||||
|
. '<body><h1>' . htmlspecialchars($title) . '</h1>' . $body . $formHtml . '</body></html>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET : afficher la page de confirmation ──────────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$stmt = $pdo->prepare('SELECT id, expires_at, consumed_at FROM auth_magic_links WHERE token = :t');
|
||||||
|
$stmt->execute([':t' => $token]);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit(renderMagicPage('Lien inconnu', '<p>Ce lien de connexion est introuvable.</p>', null));
|
||||||
|
}
|
||||||
|
if ($row['consumed_at'] !== null) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit(renderMagicPage('Lien déjà utilisé', '<p>Ce lien de connexion a déjà été utilisé.</p><p><a href="/login">Demander un nouveau lien</a></p>', null));
|
||||||
|
}
|
||||||
|
if (strtotime((string)$row['expires_at']) < time()) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit(renderMagicPage('Lien expiré', '<p>Ce lien de connexion a expiré.</p><p><a href="/login">Demander un nouveau lien</a></p>', null));
|
||||||
|
}
|
||||||
|
|
||||||
|
exit(renderMagicPage('Connexion', '<p style="color:#555;margin-bottom:1.5rem">Cliquez sur le bouton ci-dessous pour vous connecter.</p>', $token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST : consommer le token et ouvrir la session ──────────────────────────
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
try {
|
try {
|
||||||
// récupère lien non consommé et non expiré
|
$sql = 'SELECT id, email, expires_at, consumed_at, return_to
|
||||||
$sql = 'SELECT id, email, token, created_at, expires_at, consumed_at, return_to
|
|
||||||
FROM auth_magic_links
|
FROM auth_magic_links
|
||||||
WHERE token = :t
|
WHERE token = :t
|
||||||
FOR UPDATE';
|
FOR UPDATE';
|
||||||
@@ -40,7 +75,6 @@ try {
|
|||||||
throw new RuntimeException('Lien expiré.');
|
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->prepare('UPDATE auth_magic_links SET consumed_at = NOW() WHERE id = :id')->execute([':id' => $row['id']]);
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
|
|
||||||
@@ -51,7 +85,6 @@ try {
|
|||||||
$_SESSION['user_email'] = strtolower(trim((string)$row['email']));
|
$_SESSION['user_email'] = strtolower(trim((string)$row['email']));
|
||||||
|
|
||||||
$dest = $row['return_to'] ?? '/';
|
$dest = $row['return_to'] ?? '/';
|
||||||
// sécurité: ne renvoyer que des chemins relatifs
|
|
||||||
if (!is_string($dest) || !str_starts_with($dest, '/')) {
|
if (!is_string($dest) || !str_starts_with($dest, '/')) {
|
||||||
$dest = '/';
|
$dest = '/';
|
||||||
}
|
}
|
||||||
@@ -62,5 +95,5 @@ try {
|
|||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
}
|
}
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo htmlspecialchars($e->getMessage(), ENT_QUOTES);
|
exit(renderMagicPage('Erreur', '<p>' . htmlspecialchars($e->getMessage()) . '</p><p><a href="/login">Retour à la connexion</a></p>', null));
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.6.17
|
1.6.18
|
||||||
|
|||||||
@@ -150,6 +150,21 @@ class ArticleManager
|
|||||||
return $uuid;
|
return $uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Crée un brouillon en copiant titre, contenu, catégorie et tags d'un article existant. */
|
||||||
|
public function duplicate(string $sourceUuid, string $author = ''): ?string
|
||||||
|
{
|
||||||
|
$source = $this->getByUuid($sourceUuid);
|
||||||
|
if (!$source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$newTitle = 'Copie de ' . ($source['title'] ?? '');
|
||||||
|
$content = $source['content'] ?? '';
|
||||||
|
$category = $source['category'] ?? '';
|
||||||
|
$tags = $source['tags'] ?? [];
|
||||||
|
$newAuthor = $author !== '' ? $author : ($source['author'] ?? '');
|
||||||
|
return $this->create($newTitle, $content, false, '', '', $newAuthor, '', '', '', $category, $tags);
|
||||||
|
}
|
||||||
|
|
||||||
public function update(string $uuid, string $title, string $content, bool $published, string $slug, string $publishedAt, string $revisionComment = '', string $seoTitle = '', string $seoDescription = '', string $ogImage = '', string $category = '', ?array $tags = null, bool $skipGit = false): void
|
public function update(string $uuid, string $title, string $content, bool $published, string $slug, string $publishedAt, string $revisionComment = '', string $seoTitle = '', string $seoDescription = '', string $ogImage = '', string $category = '', ?array $tags = null, bool $skipGit = false): void
|
||||||
{
|
{
|
||||||
$article = $this->getByUuid($uuid);
|
$article = $this->getByUuid($uuid);
|
||||||
|
|||||||
@@ -365,6 +365,10 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
<td class="text-end text-nowrap">
|
<td class="text-end text-nowrap">
|
||||||
<a href="/edit/<?= htmlspecialchars($a['uuid']) ?>"
|
<a href="/edit/<?= htmlspecialchars($a['uuid']) ?>"
|
||||||
class="btn btn-outline-secondary btn-sm">Modifier</a>
|
class="btn btn-outline-secondary btn-sm">Modifier</a>
|
||||||
|
<form method="post" action="/duplicate/<?= htmlspecialchars($a['uuid']) ?>" class="d-inline ms-1">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm"
|
||||||
|
title="Dupliquer en brouillon">⧉</button>
|
||||||
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
+34
-5
@@ -9,9 +9,26 @@ $_accentMap = [
|
|||||||
];
|
];
|
||||||
$_tocItems = [];
|
$_tocItems = [];
|
||||||
$_tocSeen = [];
|
$_tocSeen = [];
|
||||||
// Le titre H1 est déjà affiché par le template ; on le retire du rendu.
|
|
||||||
$_rawForRender = preg_replace('/^\s*# [^\n]*\n*/u', '', $rawContent);
|
// Cache du rendu Markdown (invalidé si index.md est plus récent)
|
||||||
$_renderedContent = preg_replace_callback(
|
$_mdFile = defined('DATA_PATH') ? DATA_PATH . '/' . ($article['uuid'] ?? '') . '/index.md' : '';
|
||||||
|
$_cacheFile = defined('DATA_PATH') ? DATA_PATH . '/' . ($article['uuid'] ?? '') . '/_cache/content_rendered.json' : '';
|
||||||
|
$_mdMtime = ($_mdFile !== '' && file_exists($_mdFile)) ? (int)filemtime($_mdFile) : 0;
|
||||||
|
|
||||||
|
$_renderedContent = null;
|
||||||
|
if ($_cacheFile !== '' && file_exists($_cacheFile)) {
|
||||||
|
$_tmp = json_decode((string)file_get_contents($_cacheFile), true);
|
||||||
|
if (is_array($_tmp) && isset($_tmp['ts'], $_tmp['html'], $_tmp['toc'])
|
||||||
|
&& (int)$_tmp['ts'] >= $_mdMtime && $_mdMtime > 0) {
|
||||||
|
$_renderedContent = $_tmp['html'];
|
||||||
|
$_tocItems = $_tmp['toc'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_renderedContent === null) {
|
||||||
|
// Le titre H1 est déjà affiché par le template ; on le retire du rendu.
|
||||||
|
$_rawForRender = preg_replace('/^\s*# [^\n]*\n*/u', '', $rawContent);
|
||||||
|
$_renderedContent = preg_replace_callback(
|
||||||
'/<(h[23])>(.+?)<\/h[23]>/i',
|
'/<(h[23])>(.+?)<\/h[23]>/i',
|
||||||
function ($m) use (&$_tocItems, &$_tocSeen, $_accentMap) {
|
function ($m) use (&$_tocItems, &$_tocSeen, $_accentMap) {
|
||||||
$tag = $m[1];
|
$tag = $m[1];
|
||||||
@@ -34,8 +51,20 @@ $_renderedContent = preg_replace_callback(
|
|||||||
return "<{$tag} id=\"" . htmlspecialchars($id) . "\">{$inner}</{$tag}>";
|
return "<{$tag} id=\"" . htmlspecialchars($id) . "\">{$inner}</{$tag}>";
|
||||||
},
|
},
|
||||||
$Parsedown->text($_rawForRender)
|
$Parsedown->text($_rawForRender)
|
||||||
);
|
);
|
||||||
$_renderedContent = typographieHtml($_renderedContent ?? '');
|
$_renderedContent = typographieHtml($_renderedContent ?? '');
|
||||||
|
// Lazy loading sur toutes les images du contenu
|
||||||
|
$_renderedContent = preg_replace('/<img\b([^>]*)>/i', '<img$1 loading="lazy">', $_renderedContent ?? '') ?? $_renderedContent;
|
||||||
|
|
||||||
|
// Écriture du cache
|
||||||
|
if ($_cacheFile !== '' && $_mdMtime > 0) {
|
||||||
|
@mkdir(dirname($_cacheFile), 0755, true);
|
||||||
|
@file_put_contents($_cacheFile, json_encode(
|
||||||
|
['ts' => $_mdMtime, 'html' => $_renderedContent, 'toc' => $_tocItems],
|
||||||
|
JSON_UNESCAPED_UNICODE
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user