) * * Les uploads vont dans `public/uploads/projects/:slug/.`, 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 $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 $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/. $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 $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'], ]; } }