Less home code more F3
This commit is contained in:
@@ -50,6 +50,25 @@ class Media extends DB\SQL\Mapper
|
||||
);
|
||||
}
|
||||
|
||||
public function findByIds(array $ids): array
|
||||
{
|
||||
$ids = array_filter(array_unique(array_map('intval', $ids)));
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$results = $this->find(["id IN ($placeholders)", ...array_values($ids)]);
|
||||
|
||||
$map = [];
|
||||
foreach ($results ?: [] as $m) {
|
||||
$row = $this->decorate($m->cast());
|
||||
$map[$row['id']] = $row;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
@@ -66,18 +85,19 @@ class Media extends DB\SQL\Mapper
|
||||
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.
|
||||
// Traite le fichier temporaire déposé par Web::receive() et publie l'image.
|
||||
public function upload(string $srcPath, string $originalName = ''): int
|
||||
{
|
||||
$target = null;
|
||||
$image = null;
|
||||
$committed = false; // Contrôle le nettoyage de $target dans finally.
|
||||
|
||||
try {
|
||||
$meta = self::inspectUpload($srcPath);
|
||||
$image = self::openImageResource($srcPath, $meta['mime']);
|
||||
|
||||
[$format, $extension] = self::targetFormat($meta['mime']);
|
||||
// Nom aléatoire : empêche le path traversal et la devinabilité des URLs.
|
||||
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
|
||||
$target = app_public_media_dir() . '/' . $fileName;
|
||||
|
||||
@@ -93,19 +113,15 @@ class Media extends DB\SQL\Mapper
|
||||
$this->created_at = app_now();
|
||||
$this->save();
|
||||
$this->db->commit();
|
||||
$committed = true;
|
||||
} catch (Throwable $e) {
|
||||
$this->db->rollback();
|
||||
if ($target !== null && is_file($target)) {
|
||||
@unlink($target);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return (int) $this->get('id');
|
||||
} catch (RuntimeException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable) {
|
||||
throw new RuntimeException('Impossible d\'enregistrer cette image.');
|
||||
} catch (Throwable $e) {
|
||||
throw $e instanceof RuntimeException ? $e : new RuntimeException('Impossible d\'enregistrer cette image.');
|
||||
} finally {
|
||||
if ($image instanceof GdImage) {
|
||||
imagedestroy($image);
|
||||
@@ -113,6 +129,9 @@ class Media extends DB\SQL\Mapper
|
||||
if (is_file($srcPath)) {
|
||||
@unlink($srcPath);
|
||||
}
|
||||
if (!$committed && $target !== null && is_file($target)) {
|
||||
@unlink($target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,16 +148,12 @@ class Media extends DB\SQL\Mapper
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$item = $this->findById($id);
|
||||
if ($item === null) {
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
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'];
|
||||
$path = app_public_media_dir() . '/' . $this->file_name;
|
||||
|
||||
$this->db->begin();
|
||||
try {
|
||||
@@ -203,6 +218,8 @@ class Media extends DB\SQL\Mapper
|
||||
return $image;
|
||||
}
|
||||
|
||||
// PNG/WebP → PNG pour préserver la transparence de manière fiable.
|
||||
// JPG reste en JPG (pas de canal alpha).
|
||||
private static function targetFormat(string $mime): array
|
||||
{
|
||||
return match ($mime) {
|
||||
@@ -239,15 +256,6 @@ class Media extends DB\SQL\Mapper
|
||||
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'];
|
||||
|
||||
@@ -39,7 +39,7 @@ class Post extends DB\SQL\Mapper
|
||||
];
|
||||
}
|
||||
|
||||
public function paginateList(int $page = 1, int $perPage = 12): array
|
||||
public function paginateList(int $page, int $perPage, Media $media): array
|
||||
{
|
||||
$result = $this->paginate(
|
||||
max(0, $page - 1),
|
||||
@@ -49,11 +49,13 @@ class Post extends DB\SQL\Mapper
|
||||
);
|
||||
|
||||
$posts = array_map(fn (self $p): array => $this->summaryRow($p->cast()), $result['subset']);
|
||||
$covers = $this->loadCovers($posts);
|
||||
$coverIds = array_filter(array_unique(array_column($posts, 'cover_media_id')));
|
||||
$covers = $media->findByIds($coverIds);
|
||||
|
||||
foreach ($posts as &$post) {
|
||||
$cover = $covers[$post['cover_media_id']] ?? null;
|
||||
$post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : '';
|
||||
$post['cover_url'] = $cover['url'] ?? '';
|
||||
$post['cover_alt'] = $cover['alt'] ?? '';
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -63,7 +65,7 @@ class Post extends DB\SQL\Mapper
|
||||
];
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?array
|
||||
public function findBySlug(string $slug, Media $media): ?array
|
||||
{
|
||||
$this->load(['slug = ?', $slug]);
|
||||
if ($this->dry()) {
|
||||
@@ -71,9 +73,9 @@ class Post extends DB\SQL\Mapper
|
||||
}
|
||||
|
||||
$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']) : '';
|
||||
$cover = $post['cover_media_id'] > 0 ? $media->findById($post['cover_media_id']) : null;
|
||||
$post['cover_url'] = $cover['url'] ?? '';
|
||||
$post['cover_alt'] = $cover['alt'] ?? '';
|
||||
$post['body_html'] = (string) $this->body_html;
|
||||
|
||||
return $post;
|
||||
@@ -99,10 +101,10 @@ class Post extends DB\SQL\Mapper
|
||||
];
|
||||
}
|
||||
|
||||
public function create(array $input): int
|
||||
public function create(array $input, Media $media): int
|
||||
{
|
||||
$payload = $this->payload($input);
|
||||
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->slugExists($candidate));
|
||||
$payload = $this->payload($input, $media);
|
||||
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->count(['slug = ?', $candidate]) > 0);
|
||||
$now = app_now();
|
||||
|
||||
$this->reset();
|
||||
@@ -116,20 +118,31 @@ class Post extends DB\SQL\Mapper
|
||||
return (int) $this->get('id');
|
||||
}
|
||||
|
||||
public function updatePost(int $id, array $input): bool
|
||||
public function updatePost(int $id, array $input, Media $media): bool
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $this->payload($input);
|
||||
$payload = $this->payload($input, $media);
|
||||
$this->copyfrom($payload + ['updated_at' => app_now()]);
|
||||
$this->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Vérifie les deux usages possibles : couverture (cover_media_id)
|
||||
// et images insérées dans le corps (media:filename dans body_markdown).
|
||||
public function isMediaUsed(int $mediaId, string $fileName): bool
|
||||
{
|
||||
return $this->count([
|
||||
'cover_media_id = ? OR body_markdown LIKE ?',
|
||||
$mediaId,
|
||||
'%media:' . $fileName . '%',
|
||||
]) > 0;
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
@@ -140,7 +153,7 @@ class Post extends DB\SQL\Mapper
|
||||
$this->erase();
|
||||
}
|
||||
|
||||
private function payload(array $input): array
|
||||
private function payload(array $input, Media $media): array
|
||||
{
|
||||
$title = trim((string) ($input['title'] ?? ''));
|
||||
$excerpt = trim((string) ($input['excerpt'] ?? ''));
|
||||
@@ -160,8 +173,6 @@ class Post extends DB\SQL\Mapper
|
||||
throw new RuntimeException("L'extrait est trop long.");
|
||||
}
|
||||
|
||||
$media = new Media($this->db);
|
||||
|
||||
$coverId = null;
|
||||
if ($coverMediaId !== '') {
|
||||
$coverId = (int) $coverMediaId;
|
||||
@@ -181,11 +192,6 @@ class Post extends DB\SQL\Mapper
|
||||
];
|
||||
}
|
||||
|
||||
private function slugExists(string $slug): bool
|
||||
{
|
||||
return $this->count(['slug = ?', $slug]) > 0;
|
||||
}
|
||||
|
||||
private function summaryRow(array $row): array
|
||||
{
|
||||
return [
|
||||
@@ -199,24 +205,4 @@ class Post extends DB\SQL\Mapper
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class User extends DB\SQL\Mapper
|
||||
}
|
||||
|
||||
$data = $this->cast();
|
||||
unset($data['password_hash']);
|
||||
unset($data['password_hash']); // Ne jamais exposer le hash hors de l'authentification.
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user