feat: stockage articles en fichiers Markdown, SSO intégré, URLs propres
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class ArticleManager
|
||||
{
|
||||
public function __construct(private string $dataDir)
|
||||
{
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Lecture
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
public function getAll(bool $publishedOnly = false): array
|
||||
{
|
||||
$articles = [];
|
||||
if (!is_dir($this->dataDir)) {
|
||||
return $articles;
|
||||
}
|
||||
|
||||
foreach (scandir($this->dataDir) as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$dir = $this->dataDir . '/' . $entry;
|
||||
$file = $dir . '/index.md';
|
||||
if (!is_dir($dir) || !file_exists($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$article = $this->parseFile($file);
|
||||
if (!$article) {
|
||||
continue;
|
||||
}
|
||||
if ($publishedOnly && !$article['published']) {
|
||||
continue;
|
||||
}
|
||||
$articles[] = $article;
|
||||
}
|
||||
|
||||
usort($articles, static fn ($a, $b) => strcmp($b['created_at'] ?? '', $a['created_at'] ?? ''));
|
||||
|
||||
return $articles;
|
||||
}
|
||||
|
||||
public function getBySlug(string $slug): ?array
|
||||
{
|
||||
foreach ($this->getAll() as $article) {
|
||||
if (($article['slug'] ?? '') === $slug) {
|
||||
return $article;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getByUuid(string $uuid): ?array
|
||||
{
|
||||
if (!$this->isValidUuid($uuid)) {
|
||||
return null;
|
||||
}
|
||||
$file = $this->dataDir . '/' . $uuid . '/index.md';
|
||||
if (!file_exists($file)) {
|
||||
return null;
|
||||
}
|
||||
return $this->parseFile($file);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Écriture
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
public function create(string $title, string $content, bool $published, string $slug = '', string $publishedAt = ''): string
|
||||
{
|
||||
$uuid = $this->generateUuid();
|
||||
$slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title);
|
||||
$slug = $this->uniqueSlug($slug, $uuid);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$publishedAt = $publishedAt !== '' ? $publishedAt : $now;
|
||||
|
||||
$dir = $this->dataDir . '/' . $uuid;
|
||||
mkdir($dir, 0755, true);
|
||||
mkdir($dir . '/files', 0755, true);
|
||||
|
||||
$meta = [
|
||||
'uuid' => $uuid,
|
||||
'slug' => $slug,
|
||||
'title' => $title,
|
||||
'published' => $published,
|
||||
'published_at' => $publishedAt,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
file_put_contents($dir . '/index.md', $this->writeFrontmatter($meta, $content));
|
||||
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
public function update(string $uuid, string $title, string $content, bool $published, string $slug, string $publishedAt): void
|
||||
{
|
||||
$article = $this->getByUuid($uuid);
|
||||
if (!$article) {
|
||||
return;
|
||||
}
|
||||
|
||||
$slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title);
|
||||
$slug = $this->uniqueSlug($slug, $uuid);
|
||||
|
||||
$meta = [
|
||||
'uuid' => $uuid,
|
||||
'slug' => $slug,
|
||||
'title' => $title,
|
||||
'published' => $published,
|
||||
'published_at' => $publishedAt !== '' ? $publishedAt : ($article['published_at'] ?? date('Y-m-d H:i:s')),
|
||||
'created_at' => $article['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
file_put_contents($this->dataDir . '/' . $uuid . '/index.md', $this->writeFrontmatter($meta, $content));
|
||||
}
|
||||
|
||||
public function delete(string $uuid): void
|
||||
{
|
||||
if (!$this->isValidUuid($uuid)) {
|
||||
return;
|
||||
}
|
||||
$dir = $this->dataDir . '/' . $uuid;
|
||||
if (is_dir($dir)) {
|
||||
$this->removeDir($dir);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Fichiers associés
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
public function getFiles(string $uuid): array
|
||||
{
|
||||
$dir = $this->dataDir . '/' . $uuid . '/files';
|
||||
if (!is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
foreach (scandir($dir) as $name) {
|
||||
if ($name === '.' || $name === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . '/' . $name;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
$mime = mime_content_type($path) ?: 'application/octet-stream';
|
||||
$files[] = [
|
||||
'name' => $name,
|
||||
'size' => filesize($path),
|
||||
'mime' => $mime,
|
||||
'is_image' => str_starts_with($mime, 'image/'),
|
||||
'is_video' => str_starts_with($mime, 'video/'),
|
||||
'is_audio' => str_starts_with($mime, 'audio/'),
|
||||
'uploaded_at' => date('Y-m-d H:i:s', (int)filemtime($path)),
|
||||
];
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
public function addFile(string $uuid, array $uploadedFile): ?string
|
||||
{
|
||||
if (!$this->isValidUuid($uuid)) {
|
||||
return null;
|
||||
}
|
||||
$dir = $this->dataDir . '/' . $uuid . '/files';
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$name = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($uploadedFile['name']));
|
||||
$dest = $dir . '/' . $name;
|
||||
$i = 1;
|
||||
$info = pathinfo($name);
|
||||
while (file_exists($dest)) {
|
||||
$dest = $dir . '/' . $info['filename'] . '_' . $i . (isset($info['extension']) ? '.' . $info['extension'] : '');
|
||||
$i++;
|
||||
}
|
||||
|
||||
if (!move_uploaded_file($uploadedFile['tmp_name'], $dest)) {
|
||||
return null;
|
||||
}
|
||||
return basename($dest);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Rendu : résout les chemins relatifs dans le contenu Markdown
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
public function resolveFileUrls(string $uuid, string $markdown): string
|
||||
{
|
||||
$base = '/file?uuid=' . rawurlencode($uuid) . '&name=';
|
||||
|
||||
//  et [texte](fichier.ext) sans http/https ni /
|
||||
return preg_replace_callback(
|
||||
'/(!?\[([^\]]*)\])\((?!https?:\/\/)(?!\/)([^)]+)\)/',
|
||||
static function (array $m) use ($base): string {
|
||||
return $m[1] . '(' . $base . rawurlencode($m[3]) . ')';
|
||||
},
|
||||
$markdown
|
||||
) ?? $markdown;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Helpers privés
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
private function parseFile(string $path): ?array
|
||||
{
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
['meta' => $meta, 'content' => $content] = $this->parseFrontmatter($raw);
|
||||
if (empty($meta['uuid'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta['content'] = $content;
|
||||
$meta['published'] = filter_var($meta['published'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
private function parseFrontmatter(string $raw): array
|
||||
{
|
||||
if (!str_starts_with($raw, '---')) {
|
||||
return ['meta' => [], 'content' => $raw];
|
||||
}
|
||||
$end = strpos($raw, "\n---", 3);
|
||||
if ($end === false) {
|
||||
return ['meta' => [], 'content' => $raw];
|
||||
}
|
||||
|
||||
$yaml = substr($raw, 4, $end - 4);
|
||||
$content = ltrim(substr($raw, $end + 4));
|
||||
$meta = [];
|
||||
|
||||
foreach (explode("\n", $yaml) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') {
|
||||
continue;
|
||||
}
|
||||
$colon = strpos($line, ':');
|
||||
if ($colon === false) {
|
||||
continue;
|
||||
}
|
||||
$key = trim(substr($line, 0, $colon));
|
||||
$val = trim(substr($line, $colon + 1));
|
||||
if ($val === 'true') {
|
||||
$val = true;
|
||||
} elseif ($val === 'false') {
|
||||
$val = false;
|
||||
}
|
||||
$meta[$key] = $val;
|
||||
}
|
||||
|
||||
return ['meta' => $meta, 'content' => $content];
|
||||
}
|
||||
|
||||
private function writeFrontmatter(array $meta, string $content): string
|
||||
{
|
||||
$yaml = '';
|
||||
foreach ($meta as $key => $val) {
|
||||
if (is_bool($val)) {
|
||||
$val = $val ? 'true' : 'false';
|
||||
}
|
||||
$yaml .= $key . ': ' . $val . "\n";
|
||||
}
|
||||
return "---\n" . $yaml . "---\n\n" . ltrim($content);
|
||||
}
|
||||
|
||||
private function generateSlug(string $title): string
|
||||
{
|
||||
$map = [
|
||||
'à' => 'a', 'â' => 'a', 'ä' => 'a',
|
||||
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
|
||||
'î' => 'i', 'ï' => 'i',
|
||||
'ô' => 'o', 'ö' => 'o',
|
||||
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
|
||||
'ç' => 'c', 'æ' => 'ae', 'œ' => 'oe',
|
||||
];
|
||||
$slug = mb_strtolower($title, 'UTF-8');
|
||||
$slug = strtr($slug, $map);
|
||||
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
|
||||
return trim((string)$slug, '-');
|
||||
}
|
||||
|
||||
private function sanitizeSlug(string $slug): string
|
||||
{
|
||||
$slug = mb_strtolower(trim($slug), 'UTF-8');
|
||||
$slug = preg_replace('/[^a-z0-9-]/', '-', $slug);
|
||||
$slug = preg_replace('/-+/', '-', $slug);
|
||||
return trim((string)$slug, '-') ?: 'article';
|
||||
}
|
||||
|
||||
private function uniqueSlug(string $slug, string $excludeUuid): string
|
||||
{
|
||||
$taken = array_column(
|
||||
array_filter($this->getAll(), static fn ($a) => $a['uuid'] !== $excludeUuid),
|
||||
'slug'
|
||||
);
|
||||
|
||||
if (!in_array($slug, $taken, true)) {
|
||||
return $slug;
|
||||
}
|
||||
$i = 2;
|
||||
while (in_array($slug . '-' . $i, $taken, true)) {
|
||||
$i++;
|
||||
}
|
||||
return $slug . '-' . $i;
|
||||
}
|
||||
|
||||
private function generateUuid(): string
|
||||
{
|
||||
$bytes = random_bytes(16);
|
||||
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
|
||||
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
||||
}
|
||||
|
||||
private function isValidUuid(string $uuid): bool
|
||||
{
|
||||
return (bool)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
|
||||
);
|
||||
}
|
||||
|
||||
private function removeDir(string $dir): void
|
||||
{
|
||||
foreach (scandir($dir) as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . '/' . $entry;
|
||||
is_dir($path) ? $this->removeDir($path) : unlink($path);
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
+43
-17
@@ -1,28 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
use Jumbojett\OpenIDConnectClient;
|
||||
|
||||
require_once BASE_PATH . '/vendor/autoload.php';
|
||||
session_start();
|
||||
|
||||
function require_auth()
|
||||
function isLoggedIn(): bool
|
||||
{
|
||||
if (!isset($_SESSION['user'])) {
|
||||
// Redirige vers la page de login
|
||||
header('Location: /auth/login.php');
|
||||
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 get_oidc_client(): OpenIDConnectClient
|
||||
function currentUserEmail(): ?string
|
||||
{
|
||||
$oidc = new OpenIDConnectClient(
|
||||
'https://idp.a5l.fr/realms/master',
|
||||
'varlog-client-id',
|
||||
'varlog-client-secret'
|
||||
);
|
||||
$oidc->setRedirectURL('http://varlog.acegrp.lan/auth/callback.php');
|
||||
$oidc->addScope(['openid', 'email', 'profile']);
|
||||
return $oidc;
|
||||
return $_SESSION['user_email'] ?? null;
|
||||
}
|
||||
|
||||
function isAdmin(): bool
|
||||
{
|
||||
$email = currentUserEmail();
|
||||
if (!$email) {
|
||||
return false;
|
||||
}
|
||||
$rawAdmin = $_ENV['ADMIN_EMAIL'] ?? (getenv('ADMIN_EMAIL') ?: '');
|
||||
$allowed = array_filter(array_map('trim', explode(',', (string)$rawAdmin)));
|
||||
return in_array(strtolower($email), array_map('strtolower', $allowed), true);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user