First commit
This commit is contained in:
155
app/Models/Media.php
Normal file
155
app/Models/Media.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Media extends DB\SQL\Mapper
|
||||
{
|
||||
public function __construct(DB\SQL $db)
|
||||
{
|
||||
parent::__construct($db, 'media');
|
||||
}
|
||||
|
||||
public static function bootstrap(DB\SQL $db): void
|
||||
{
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_name TEXT NOT NULL UNIQUE,
|
||||
alt TEXT NOT NULL DEFAULT \'\',
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_media_created_at ON media(created_at DESC)');
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (self $m): array => $this->decorate($m->cast()),
|
||||
$this->find(null, ['order' => 'created_at DESC, id DESC']) ?: []
|
||||
);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->load(['id = ?', $id]);
|
||||
return $this->dry() ? null : $this->decorate($this->cast());
|
||||
}
|
||||
|
||||
public function findByFileName(string $fileName): ?array
|
||||
{
|
||||
$this->load(['file_name = ?', $fileName]);
|
||||
return $this->dry() ? null : $this->decorate($this->cast());
|
||||
}
|
||||
|
||||
// Reçoit le chemin absolu déposé par Web::receive() et le nom d'origine
|
||||
// pour dériver un texte alternatif lisible.
|
||||
public function upload(string $srcPath, string $originalName = ''): int
|
||||
{
|
||||
// Image::dump() gère le chargement, la transparence et la compression.
|
||||
// $path='' indique à F3 que le chemin est absolu.
|
||||
try {
|
||||
$img = new Image($srcPath, false, '');
|
||||
} catch (Throwable) {
|
||||
throw new RuntimeException('Fichier image invalide ou format non supporté (JPG, PNG, WebP).');
|
||||
}
|
||||
|
||||
$data = $img->dump('png', 9); // sans perte, compression maximale
|
||||
|
||||
// Supprimer le fichier intermédiaire déposé par Web::receive().
|
||||
@unlink($srcPath);
|
||||
|
||||
$fileName = bin2hex(random_bytes(16)) . '.png';
|
||||
$target = app_public_media_dir() . '/' . $fileName;
|
||||
|
||||
if (!Base::instance()->write($target, $data)) {
|
||||
throw new RuntimeException('Impossible d\'enregistrer cette image.');
|
||||
}
|
||||
|
||||
$this->reset();
|
||||
$this->file_name = $fileName;
|
||||
$this->alt = $originalName !== '' ? self::altFromFilename($originalName) : '';
|
||||
$this->width = $img->width();
|
||||
$this->height = $img->height();
|
||||
$this->created_at = app_now();
|
||||
$this->save();
|
||||
|
||||
return (int) $this->get('id');
|
||||
}
|
||||
|
||||
public function updateAlt(int $id, string $alt): void
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
throw new RuntimeException('Image introuvable.');
|
||||
}
|
||||
|
||||
$this->alt = trim($alt);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$item = $this->findById($id);
|
||||
if ($item === null) {
|
||||
throw new RuntimeException('Image introuvable.');
|
||||
}
|
||||
|
||||
if ($this->isUsed($item)) {
|
||||
throw new RuntimeException('Cette image est encore utilisée par un article.');
|
||||
}
|
||||
|
||||
$path = app_public_media_dir() . '/' . $item['file_name'];
|
||||
|
||||
$this->db->begin();
|
||||
try {
|
||||
$this->erase();
|
||||
if (is_file($path) && !unlink($path)) {
|
||||
throw new RuntimeException('Impossible de supprimer le fichier image.');
|
||||
}
|
||||
$this->db->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->db->rollback();
|
||||
throw $e instanceof RuntimeException ? $e : new RuntimeException('Suppression impossible.');
|
||||
}
|
||||
}
|
||||
|
||||
// Dérive un texte alternatif lisible depuis le nom de fichier d'origine.
|
||||
private static function altFromFilename(string $filename): string
|
||||
{
|
||||
$name = pathinfo($filename, PATHINFO_FILENAME);
|
||||
$name = trim((string) preg_replace('/[-_]+/', ' ', $name));
|
||||
// mb_ucfirst() n'existe qu'en PHP 8.4 — on l'émule.
|
||||
return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1));
|
||||
}
|
||||
|
||||
// Une seule requête SQL pour les deux cas d'utilisation (couverture et body).
|
||||
private function isUsed(array $item): bool
|
||||
{
|
||||
return $this->db->exec(
|
||||
'SELECT 1 FROM posts WHERE cover_media_id = ? OR body_markdown LIKE ? LIMIT 1',
|
||||
[$item['id'], '%media:' . $item['file_name'] . '%']
|
||||
) !== [];
|
||||
}
|
||||
|
||||
private function decorate(array $row): array
|
||||
{
|
||||
$alt = (string) $row['alt'];
|
||||
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'file_name' => (string) $row['file_name'],
|
||||
'alt' => $alt,
|
||||
'width' => (int) $row['width'],
|
||||
'height' => (int) $row['height'],
|
||||
'created_at' => (string) $row['created_at'],
|
||||
'created_at_label' => app_format_datetime_fr((string) $row['created_at']),
|
||||
'url' => app_media_url((string) $row['file_name']),
|
||||
'markdown' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
223
app/Models/Post.php
Normal file
223
app/Models/Post.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Post extends DB\SQL\Mapper
|
||||
{
|
||||
public const TITLE_MAX_LENGTH = 120;
|
||||
public const EXCERPT_MAX_LENGTH = 240;
|
||||
|
||||
public function __construct(DB\SQL $db)
|
||||
{
|
||||
parent::__construct($db, 'posts');
|
||||
}
|
||||
|
||||
public static function bootstrap(DB\SQL $db): void
|
||||
{
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
excerpt TEXT NOT NULL,
|
||||
body_markdown TEXT NOT NULL,
|
||||
body_html TEXT NOT NULL,
|
||||
cover_media_id INTEGER DEFAULT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (cover_media_id) REFERENCES media(id) ON DELETE SET NULL
|
||||
)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC)');
|
||||
}
|
||||
|
||||
public static function emptyForm(): array
|
||||
{
|
||||
return [
|
||||
'title' => '',
|
||||
'excerpt' => '',
|
||||
'cover_media_id' => '',
|
||||
'body_markdown' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public function paginateList(int $page = 1, int $perPage = 12): array
|
||||
{
|
||||
$result = $this->paginate(
|
||||
max(0, $page - 1),
|
||||
$perPage,
|
||||
null,
|
||||
['order' => 'created_at DESC, id DESC']
|
||||
);
|
||||
|
||||
$posts = array_map(fn (self $p): array => $this->summaryRow($p->cast()), $result['subset']);
|
||||
$covers = $this->loadCovers($posts);
|
||||
|
||||
foreach ($posts as &$post) {
|
||||
$cover = $covers[$post['cover_media_id']] ?? null;
|
||||
$post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : '';
|
||||
}
|
||||
|
||||
return [
|
||||
'posts' => $posts,
|
||||
'page' => max(1, min($page, $result['count'] ?: 1)),
|
||||
'pages' => $result['count'] ?: 1,
|
||||
];
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?array
|
||||
{
|
||||
$this->load(['slug = ?', $slug]);
|
||||
if ($this->dry()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$post = $this->summaryRow($this->cast());
|
||||
$covers = $this->loadCovers([$post]);
|
||||
$cover = $covers[$post['cover_media_id']] ?? null;
|
||||
$post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : '';
|
||||
$post['body_html'] = (string) $this->body_html;
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
public function findForEdit(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $this->get('id'),
|
||||
'title' => (string) $this->title,
|
||||
'excerpt' => (string) $this->excerpt,
|
||||
'body_markdown' => (string) $this->body_markdown,
|
||||
'cover_media_id' => $this->cover_media_id !== null ? (string) ((int) $this->cover_media_id) : '',
|
||||
];
|
||||
}
|
||||
|
||||
public function create(array $input): int
|
||||
{
|
||||
$payload = $this->payload($input);
|
||||
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->slugExists($candidate));
|
||||
$now = app_now();
|
||||
|
||||
$this->reset();
|
||||
$this->copyfrom($payload + [
|
||||
'slug' => $slug,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$this->save();
|
||||
|
||||
return (int) $this->get('id');
|
||||
}
|
||||
|
||||
public function updatePost(int $id, array $input): bool
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $this->payload($input);
|
||||
$this->copyfrom($payload + ['updated_at' => app_now()]);
|
||||
$this->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
if (!$this->dry()) {
|
||||
$this->erase();
|
||||
}
|
||||
}
|
||||
|
||||
private function payload(array $input): array
|
||||
{
|
||||
$title = trim((string) ($input['title'] ?? ''));
|
||||
$excerpt = trim((string) ($input['excerpt'] ?? ''));
|
||||
$bodyMarkdown = trim((string) ($input['body_markdown'] ?? ''));
|
||||
$coverMediaId = trim((string) ($input['cover_media_id'] ?? ''));
|
||||
|
||||
if ($title === '') {
|
||||
throw new RuntimeException('Ajoute un titre.');
|
||||
}
|
||||
if (mb_strlen($title) > self::TITLE_MAX_LENGTH) {
|
||||
throw new RuntimeException('Le titre est trop long.');
|
||||
}
|
||||
if ($excerpt === '') {
|
||||
throw new RuntimeException('Ajoute un extrait.');
|
||||
}
|
||||
if (mb_strlen($excerpt) > self::EXCERPT_MAX_LENGTH) {
|
||||
throw new RuntimeException("L'extrait est trop long.");
|
||||
}
|
||||
|
||||
$media = new Media($this->db);
|
||||
|
||||
$coverId = null;
|
||||
if ($coverMediaId !== '') {
|
||||
$coverId = (int) $coverMediaId;
|
||||
if ($media->findById($coverId) === null) {
|
||||
throw new RuntimeException('Image de couverture introuvable.');
|
||||
}
|
||||
}
|
||||
|
||||
$bodyHtml = MarkdownService::compile($bodyMarkdown, $media);
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'excerpt' => $excerpt,
|
||||
'body_markdown' => $bodyMarkdown,
|
||||
'body_html' => $bodyHtml,
|
||||
'cover_media_id' => $coverId,
|
||||
];
|
||||
}
|
||||
|
||||
private function slugExists(string $slug): bool
|
||||
{
|
||||
return $this->count(['slug = ?', $slug]) > 0;
|
||||
}
|
||||
|
||||
private function summaryRow(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'title' => (string) $row['title'],
|
||||
'slug' => (string) $row['slug'],
|
||||
'excerpt' => (string) $row['excerpt'],
|
||||
'cover_media_id' => (int) ($row['cover_media_id'] ?? 0),
|
||||
'created_at' => (string) $row['created_at'],
|
||||
'created_at_label' => app_format_datetime_fr((string) $row['created_at']),
|
||||
'updated_at' => (string) $row['updated_at'],
|
||||
'updated_at_label' => app_format_datetime_fr((string) $row['updated_at']),
|
||||
'has_updated_at' => (string) $row['updated_at'] !== (string) $row['created_at'],
|
||||
];
|
||||
}
|
||||
|
||||
private function loadCovers(array $posts): array
|
||||
{
|
||||
$ids = array_filter(array_unique(array_column($posts, 'cover_media_id')));
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$rows = $this->db->exec(
|
||||
"SELECT id, file_name FROM media WHERE id IN ($placeholders)",
|
||||
array_values($ids)
|
||||
);
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int) $row['id']] = $row;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
67
app/Models/User.php
Normal file
67
app/Models/User.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class User extends DB\SQL\Mapper
|
||||
{
|
||||
public function __construct(DB\SQL $db)
|
||||
{
|
||||
parent::__construct($db, 'users');
|
||||
}
|
||||
|
||||
public static function bootstrap(DB\SQL $db): void
|
||||
{
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)');
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $this->cast();
|
||||
unset($data['password_hash']);
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function findByUsername(string $username): ?array
|
||||
{
|
||||
$this->load(['username = ?', $username]);
|
||||
return $this->dry() ? null : $this->cast();
|
||||
}
|
||||
|
||||
public function create(string $username, string $password): int
|
||||
{
|
||||
$username = trim($username);
|
||||
if ($username === '' || $password === '') {
|
||||
throw new RuntimeException('Nom d’utilisateur et mot de passe obligatoires.');
|
||||
}
|
||||
|
||||
if (mb_strlen($password) < 10) {
|
||||
throw new RuntimeException('Le mot de passe doit contenir au moins 10 caractères.');
|
||||
}
|
||||
|
||||
if ($this->findByUsername($username) !== null) {
|
||||
throw new RuntimeException('Cet utilisateur existe déjà.');
|
||||
}
|
||||
|
||||
$this->reset();
|
||||
$this->username = $username;
|
||||
$this->password_hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->created_at = app_now();
|
||||
$this->save();
|
||||
|
||||
return (int) $this->get('id');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user