Files
Arnaud adb2bd3fc1 feat: galerie d'images projet (upload disque) + dashboard /admin
==== Galerie d'images ====

Backend
  - Migration 003 : table project_images (FK CASCADE sur projects)
  - ImagesController : CRUD complet, deux modes de création
      * URL externe via JSON body
      * Upload disque via multipart/form-data avec :
          - validation MIME RÉELLE via finfo (jpeg/png/webp/gif)
          - taille max 5 Mo, vérifiée explicitement (au-delà du php.ini)
          - vérification UPLOAD_ERR_* avec messages clairs
          - stockage dans public/uploads/projects/:slug/<hash>.<ext>
          - dimensions image extraites via getimagesize (best-effort)
          - row DB avec mime_type / size_bytes / width / height
  - Request : ajout isMultipart(), formField(), file()
  - public/index.php : 3 routes (index public, store/update/destroy admin)

Proxy
  - caddy.conf : /uploads/* ajouté au reverse_proxy comme /api/*
  - vite.config.ts : pareil pour le dev local

Frontend
  - Type ProjectImage (api.ts) avec url + storage_path + dimensions
  - uploadFile() helper dans client.ts (FormData + Bearer)
  - ImageGallery.vue : grille thumbnails + lightbox plein-écran avec
    nav clavier (← → Esc), bouton supprimer par image en mode admin
  - ImageUploader.vue : drag-drop + file picker, multi-fichiers en série,
    validation taille côté client, légende commune optionnelle
  - ProjectDetailPage : nouvel onglet Images entre Roadmap et Discussions

==== Dashboard /admin ====

5 nouvelles pages sous /admin :
  - /admin                           : KPI + répartition catégorie + flux activité
  - /admin/projects                  : table CRUD (recherche + filtres status/categorie),
                                       actions Éditer / Archiver / Supprimer par ligne
  - /admin/projects/:slug/edit       : form complet d'édition (envoie un diff au PATCH)
  - /admin/members                   : annuaire avec recherche + Supprimer
  - /admin/categories                : table avec compte de projets ;
                                       Supprimer désactivé si la catégorie est utilisée
  - AdminSubNav.vue                  : 4 onglets en haut de chaque page admin

Garde d'auth :
  - beforeEnter sur les routes /admin/* (sauf /admin/login)
  - Redirige vers /admin/login?next=<url> si pas de token
  - Après login, retour automatique vers next= (sinon /admin)

Sidebar :
  - Nouvelle section "Administration" visible UNIQUEMENT quand le token
    est en localStorage, avec un lien vers le dashboard

Vérifié localement
  - Build vue-tsc : 91 modules, OK
  - Upload disque : fichier PNG stocké dans public/uploads/, accessible via /uploads/
  - GET /api/projects/nos-ecoles/images : 2 entrées renvoyées (1 URL + 1 disque)
  - Toutes les routes /admin/* répondent 200 via le proxy Vite

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:07:59 +02:00

272 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace Infolab\Controllers;
use Infolab\Database;
use Infolab\Request;
use Infolab\Response;
use Infolab\Validator;
/**
* Galerie d'images par projet.
*
* Deux modes de création :
* - URL externe (Content-Type: application/json + body {"url":"https://..."})
* - Upload disque (Content-Type: multipart/form-data + file=<binaire>)
*
* Les uploads vont dans `public/uploads/projects/:slug/<hash>.<ext>`, accessibles
* en direct via Apache/Caddy. Le DELETE supprime aussi le fichier sur disque.
*/
final class ImagesController
{
private const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
private const ALLOWED_MIMES = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'image/gif' => 'gif',
];
private const KINDS = ['link', 'document', 'dataset', 'video', 'other'];
public function index(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$rows = Database::fetchAll(
'SELECT id, project_id, url, caption, sort_order, storage_path, mime_type, size_bytes, width, height, created_at, updated_at
FROM project_images WHERE project_id = :pid ORDER BY sort_order ASC, id ASC',
[':pid' => (int) $p['id']],
);
Response::json(['data' => array_map([self::class, 'present'], $rows)]);
}
public function store(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
// Branche A : upload disque (multipart/form-data)
if ($req->isMultipart() && $req->file('file') !== null) {
$this->storeUpload($req, $p);
return;
}
// Branche B : URL externe (JSON body)
$this->storeUrl($req, $p);
}
public function update(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$img = self::findOr404((int) $params['id'], (int) $p['id']);
$body = $req->json();
$v = new Validator($body);
$v->string('caption', 255);
$v->integer('sort_order', 0);
$v->check();
$update = [];
foreach (['caption', 'sort_order'] as $f) {
if (array_key_exists($f, $body)) {
$update[$f] = $body[$f];
}
}
if (isset($update['sort_order'])) {
$update['sort_order'] = (int) $update['sort_order'];
}
if ($update !== []) {
Database::update('project_images', $update, 'id = :id', [':id' => (int) $img['id']]);
}
$fresh = Database::fetchOne('SELECT * FROM project_images WHERE id = :id', [':id' => (int) $img['id']]);
Response::json(self::present($fresh));
}
public function destroy(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$img = self::findOr404((int) $params['id'], (int) $p['id']);
// Supprime la ligne en DB d'abord, puis le fichier disque (best-effort).
Database::delete('project_images', 'id = :id', [':id' => (int) $img['id']]);
if (!empty($img['storage_path'])) {
$absolute = $this->docroot() . '/' . ltrim((string) $img['storage_path'], '/');
if (is_file($absolute)) {
@unlink($absolute);
}
}
Response::noContent();
}
// -------------------------------------------------------------------------
// Helpers privés
// -------------------------------------------------------------------------
/** @param array<string,mixed> $project */
private function storeUrl(Request $req, array $project): void
{
$body = $req->json();
$v = new Validator($body);
$v->required('url')->url('url', 1024);
$v->string('caption', 255);
$v->integer('sort_order', 0);
$v->check();
$sortOrder = isset($body['sort_order'])
? (int) $body['sort_order']
: self::nextSortOrder((int) $project['id']);
$id = Database::insert('project_images', [
'project_id' => (int) $project['id'],
'url' => (string) $body['url'],
'caption' => $body['caption'] ?? null,
'sort_order' => $sortOrder,
]);
$row = Database::fetchOne('SELECT * FROM project_images WHERE id = :id', [':id' => $id]);
Response::json(self::present($row), 201);
}
/** @param array<string,mixed> $project */
private function storeUpload(Request $req, array $project): void
{
$file = $req->file('file');
if ($file === null) {
throw new \ApiException(400, ['error' => 'bad_request', 'message' => 'no file uploaded']);
}
// 1) Erreur PHP d'upload ?
if ($file['error'] !== UPLOAD_ERR_OK) {
$msg = match ($file['error']) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'file too large (PHP upload limit)',
UPLOAD_ERR_PARTIAL => 'upload incomplete',
UPLOAD_ERR_NO_TMP_DIR => 'server tmp dir missing',
UPLOAD_ERR_CANT_WRITE => 'server cannot write to disk',
UPLOAD_ERR_EXTENSION => 'PHP extension blocked the upload',
default => 'upload failed',
};
throw new \ApiException(400, ['error' => 'bad_request', 'message' => $msg]);
}
// 2) Taille
if ($file['size'] > self::MAX_BYTES) {
throw new \ApiException(422, [
'error' => 'validation_failed',
'fields' => ['file' => 'too_large'],
]);
}
// 3) MIME type RÉEL (pas l'header fourni par le client, qui est falsifiable)
$tmp = $file['tmp_name'];
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$detected = (string) $finfo->file($tmp);
if (!isset(self::ALLOWED_MIMES[$detected])) {
throw new \ApiException(422, [
'error' => 'validation_failed',
'fields' => ['file' => 'unsupported_mime'],
'message' => sprintf('MIME %s non autorisé (autorisés : %s)', $detected, implode(', ', array_keys(self::ALLOWED_MIMES))),
]);
}
$ext = self::ALLOWED_MIMES[$detected];
// 4) Dimensions image (peut échouer sur GIF/webp anciens, best-effort)
$width = null;
$height = null;
$info = @getimagesize($tmp);
if (is_array($info)) {
$width = (int) $info[0];
$height = (int) $info[1];
}
// 5) Construit le chemin cible : public/uploads/projects/:slug/<random>.<ext>
$slug = preg_replace('/[^a-z0-9\-]/', '', strtolower((string) $project['slug'])) ?? 'project';
$dirRelative = "uploads/projects/$slug";
$dirAbsolute = $this->docroot() . '/' . $dirRelative;
if (!is_dir($dirAbsolute) && !@mkdir($dirAbsolute, 0755, true) && !is_dir($dirAbsolute)) {
error_log("[Images] cannot create $dirAbsolute");
throw new \ApiException(500, ['error' => 'internal_server_error']);
}
$basename = bin2hex(random_bytes(12)) . '.' . $ext;
$pathRelative = "$dirRelative/$basename";
$pathAbsolute = "$dirAbsolute/$basename";
// 6) Déplace le fichier upload
if (!move_uploaded_file($tmp, $pathAbsolute)) {
error_log("[Images] move_uploaded_file failed -> $pathAbsolute");
throw new \ApiException(500, ['error' => 'internal_server_error']);
}
// chmod défensif pour Apache.
@chmod($pathAbsolute, 0644);
// 7) Lit la légende & sort_order optionnels passés en multipart
$caption = $req->formField('caption');
$sortOrderRaw = $req->formField('sort_order');
$sortOrder = $sortOrderRaw !== null && $sortOrderRaw !== ''
? (int) $sortOrderRaw
: self::nextSortOrder((int) $project['id']);
$publicUrl = '/' . $pathRelative;
$id = Database::insert('project_images', [
'project_id' => (int) $project['id'],
'url' => $publicUrl,
'caption' => $caption,
'sort_order' => $sortOrder,
'storage_path' => $pathRelative,
'mime_type' => $detected,
'size_bytes' => (int) $file['size'],
'width' => $width,
'height' => $height,
]);
$row = Database::fetchOne('SELECT * FROM project_images WHERE id = :id', [':id' => $id]);
Response::json(self::present($row), 201);
}
private static function findOr404(int $id, int $projectId): array
{
$row = Database::fetchOne(
'SELECT * FROM project_images WHERE id = :id AND project_id = :pid',
[':id' => $id, ':pid' => $projectId],
);
if ($row === null) {
throw new \ApiException(404, ['error' => 'not_found']);
}
return $row;
}
private static function nextSortOrder(int $projectId): int
{
return (int) Database::fetchValue(
'SELECT COALESCE(MAX(sort_order), 0) + 10 FROM project_images WHERE project_id = :pid',
[':pid' => $projectId],
);
}
/**
* Racine document du serveur web (Apache DocumentRoot = .../public).
* Le contrôleur tourne depuis public/index.php donc __DIR__ . '/../..' = public/
* Sauf qu'on est dans src/Controllers/ → racine = src/../../public = public.
*/
private function docroot(): string
{
return dirname(__DIR__, 2) . '/public';
}
/** @param array<string,mixed> $row */
private static function present(array $row): array
{
return [
'id' => (int) $row['id'],
'project_id' => (int) $row['project_id'],
'url' => $row['url'],
'caption' => $row['caption'],
'sort_order' => (int) $row['sort_order'],
'storage_path' => $row['storage_path'],
'mime_type' => $row['mime_type'],
'size_bytes' => $row['size_bytes'] !== null ? (int) $row['size_bytes'] : null,
'width' => $row['width'] !== null ? (int) $row['width'] : null,
'height' => $row['height'] !== null ? (int) $row['height'] : null,
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at'],
];
}
}