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>
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -128,4 +128,40 @@ final class Request
|
||||
{
|
||||
return $this->jsonBody[$key] ?? $default;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// multipart/form-data — utilisé par les endpoints d'upload (galerie images).
|
||||
// PHP peuple lui-même $_POST et $_FILES quand le Content-Type est multipart,
|
||||
// on se contente d'exposer ça proprement.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function isMultipart(): bool
|
||||
{
|
||||
$ct = $this->header('content-type', '') ?? '';
|
||||
return str_contains(strtolower($ct), 'multipart/form-data');
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un champ du form (multipart ou x-www-form-urlencoded).
|
||||
*/
|
||||
public function formField(string $name, ?string $default = null): ?string
|
||||
{
|
||||
$v = $_POST[$name] ?? null;
|
||||
return $v === null ? $default : (string) $v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un fichier uploadé. Retourne null si absent ou si erreur d'upload.
|
||||
*
|
||||
* @return array{name:string,type:string,tmp_name:string,error:int,size:int}|null
|
||||
*/
|
||||
public function file(string $name): ?array
|
||||
{
|
||||
$f = $_FILES[$name] ?? null;
|
||||
if (!is_array($f) || !isset($f['error']) || $f['error'] === UPLOAD_ERR_NO_FILE) {
|
||||
return null;
|
||||
}
|
||||
return $f;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user