From dc69d1fd0d2c012c06953458d492e01be14d2729 Mon Sep 17 00:00:00 2001 From: Carlos Arias Date: Thu, 23 Jul 2026 22:45:45 +0000 Subject: [PATCH] Phase 2: media upload pipeline - cja_media table; POST /api/media/upload (admin-gated, multipart). - Images optimised with GD (downscale to 1600px, re-encode; WebP for transparency, JPG for photos). Video via ffmpeg (scale, compress, drop audio). - Stored in app/public/media/ (source tree) so uploads survive rebuilds and are copied into public/ on build. - requireAdmin() moved to PublicController base (fixes a static/non-static clash with AdminAuth). Type detection uses getimagesize (fileinfo ext absent). Note: php-fpm must be in the caweb group to write app/public/media (restart after adding www to caweb). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DoFYZY9gkGPNDqZ7NuEa9a --- api/app/Controllers/PublicController.php | 12 ++ api/db/migrations/010_create_cja_media.sql | 21 +++ api/public/controllers/adminauth.php | 13 +- api/public/controllers/media.php | 168 +++++++++++++++++++++ api/system/errors.json | 126 ++++++++++++++++ 5 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 api/db/migrations/010_create_cja_media.sql create mode 100644 api/public/controllers/media.php diff --git a/api/app/Controllers/PublicController.php b/api/app/Controllers/PublicController.php index 3df172b..ee6aa64 100644 --- a/api/app/Controllers/PublicController.php +++ b/api/app/Controllers/PublicController.php @@ -30,6 +30,18 @@ class PublicController extends JsonController } /** Guard helper: enforce origin + rate limit, or emit the error envelope and stop. */ + /** + * Gate an admin-only endpoint. Emits 401 and stops unless the current + * session is an authenticated admin (set by AdminAuth on login). Every + * structured-content and media endpoint calls this first. + */ + protected function requireAdmin(): void + { + if (empty($_SESSION['admin']['ok'])) { + $this->json(null, 401, ['code' => 'unauthorized', 'message' => 'Admin login required']); + } + } + protected function guardPublic(string $key, int $max = 60, int $window = 60): void { if (!$this->checkOrigin()) { diff --git a/api/db/migrations/010_create_cja_media.sql b/api/db/migrations/010_create_cja_media.sql new file mode 100644 index 0000000..7ad0b20 --- /dev/null +++ b/api/db/migrations/010_create_cja_media.sql @@ -0,0 +1,21 @@ +-- Media library for the admin console. +-- +-- Every uploaded image/video, optimised and stored under app/public/media/ +-- (source tree, so it survives rebuilds and is copied into public/ on build). +-- Rows let the admin browse and reuse uploads rather than re-uploading. + +CREATE TABLE IF NOT EXISTS `cja_media` ( + `media_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `kind` enum('image','video') NOT NULL DEFAULT 'image', + `url` varchar(255) NOT NULL, -- e.g. /media/ginny-goldman-a1b2.jpg + `filename` varchar(255) NOT NULL, + `mime` varchar(80) NOT NULL DEFAULT '', + `width` int(10) unsigned DEFAULT NULL, + `height` int(10) unsigned DEFAULT NULL, + `bytes` int(10) unsigned DEFAULT NULL, + `alt` varchar(255) NOT NULL DEFAULT '', + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`media_id`), + UNIQUE KEY `uq_cja_media_url` (`url`), + KEY `idx_cja_media_feed` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/api/public/controllers/adminauth.php b/api/public/controllers/adminauth.php index 97d5518..c8af832 100644 --- a/api/public/controllers/adminauth.php +++ b/api/public/controllers/adminauth.php @@ -19,18 +19,7 @@ use App\Controllers\PublicController; */ class AdminAuth extends PublicController { - /** Guard for privileged console endpoints. Emits 401 and stops if not admin. */ - public static function requireAdmin(): void - { - if (empty($_SESSION['admin']['ok'])) { - http_response_code(401); - header('Content-Type: application/json; charset=UTF-8'); - echo json_encode(['ok' => false, 'data' => null, 'error' => [ - 'code' => 'unauthorized', 'message' => 'Admin login required', - ]]); - exit; - } - } + // requireAdmin() is inherited from PublicController (non-static). private function logAttempt(string $action, string $detail): void { diff --git a/api/public/controllers/media.php b/api/public/controllers/media.php new file mode 100644 index 0000000..25e8534 --- /dev/null +++ b/api/public/controllers/media.php @@ -0,0 +1,168 @@ +, alt?) -> { url, ... } + * GET /api/media/list -> recent uploads + * + * Admin-gated. Images are optimised with GD (downscaled, re-encoded); videos + * are passed through ffmpeg (scaled, compressed, audio dropped). Everything is + * written to app/public/media/ — the SOURCE tree — so it survives rebuilds and + * is copied into public/ on the next build. A cja_media row is recorded. + */ +class Media extends PublicController +{ + private const MAX_IMAGE_W = 1600; // downscale wider images + private const MAX_VIDEO_W = 900; + private const MEDIA_DIR = '/www/wwwroot/CarlosAriasPersonal/app/public/media'; + + private const IMAGE_MIME = [ + 'image/jpeg' => 'jpg', 'image/png' => 'png', + 'image/webp' => 'webp', 'image/gif' => 'gif', + ]; + private const VIDEO_MIME = ['video/mp4' => 'mp4', 'video/quicktime' => 'mp4']; + + public function upload(): void + { + $this->requireAdmin(); + $this->guardPublic('media_upload', 60, 300); + + if (empty($_FILES['file']) || ($_FILES['file']['error'] ?? 1) !== UPLOAD_ERR_OK) { + $this->json(null, 400, ['code' => 'no_file', 'message' => 'No file received.']); + } + + $tmp = $_FILES['file']['tmp_name']; + $orig = $_FILES['file']['name']; + $ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION)); + $alt = mb_substr(trim((string) ($_POST['alt'] ?? '')), 0, 255); + + if (!is_dir(self::MEDIA_DIR)) { + @mkdir(self::MEDIA_DIR, 0775, true); + } + + $base = $this->slug(pathinfo($orig, PATHINFO_FILENAME)); + $rand = substr(bin2hex(random_bytes(4)), 0, 6); + + // Detect type without fileinfo: getimagesize() covers images (and gives + // the mime); video is recognised by extension and validated by ffmpeg. + $info = @getimagesize($tmp); + if ($info && !empty($info['mime']) && isset(self::IMAGE_MIME[$info['mime']])) { + $result = $this->handleImage($tmp, $base, $rand, $info['mime']); + } elseif (in_array($ext, ['mp4', 'mov', 'm4v'], true)) { + $result = $this->handleVideo($tmp, $base, $rand); + } else { + $this->json(null, 415, ['code' => 'bad_type', 'message' => 'Only images (JPG, PNG, WebP, GIF) and MP4 video are allowed.']); + } + + \Db::insert('cja_media', [ + 'kind' => $result['kind'], + 'url' => $result['url'], + 'filename' => $result['filename'], + 'mime' => $result['mime'], + 'width' => $result['width'] ?? null, + 'height' => $result['height'] ?? null, + 'bytes' => $result['bytes'] ?? null, + 'alt' => $alt, + ]); + + $this->json($result + ['alt' => $alt]); + } + + public function list(): void + { + $this->requireAdmin(); + $rows = \Db::select('SELECT kind, url, filename, width, height, alt, created_at + FROM cja_media ORDER BY created_at DESC LIMIT 200'); + $this->json(['media' => $rows]); + } + + // ---- image: GD downscale + re-encode ------------------------------------ + private function handleImage(string $tmp, string $base, string $rand, string $mime): array + { + // GIFs (often animated) pass through untouched; others are re-encoded. + if ($mime === 'image/gif') { + $name = "{$base}-{$rand}.gif"; + $dest = self::MEDIA_DIR . "/{$name}"; + copy($tmp, $dest); + @chmod($dest, 0664); + [$w, $h] = @getimagesize($dest) ?: [null, null]; + return ['kind' => 'image', 'url' => "/media/{$name}", 'filename' => $name, + 'mime' => 'image/gif', 'width' => $w, 'height' => $h, 'bytes' => filesize($dest)]; + } + + $src = match ($mime) { + 'image/jpeg' => imagecreatefromjpeg($tmp), + 'image/png' => imagecreatefrompng($tmp), + 'image/webp' => imagecreatefromwebp($tmp), + }; + if (!$src) { + $this->json(null, 422, ['code' => 'decode_failed', 'message' => 'Could not read that image.']); + } + + $w = imagesx($src); + $h = imagesy($src); + if ($w > self::MAX_IMAGE_W) { + $nw = self::MAX_IMAGE_W; + $nh = (int) round($h * ($nw / $w)); + $dst = imagecreatetruecolor($nw, $nh); + // preserve alpha for png/webp + imagealphablending($dst, false); + imagesavealpha($dst, true); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h); + imagedestroy($src); + $src = $dst; + $w = $nw; $h = $nh; + } + + // JPG output for photos; keep PNG/WebP for graphics with transparency. + $hasAlpha = in_array($mime, ['image/png', 'image/webp'], true); + $ext = $hasAlpha ? 'webp' : 'jpg'; + $name = "{$base}-{$rand}.{$ext}"; + $dest = self::MEDIA_DIR . "/{$name}"; + + if ($ext === 'webp') { + imagewebp($src, $dest, 82); + } else { + imagejpeg($src, $dest, 82); + } + imagedestroy($src); + @chmod($dest, 0664); + + return ['kind' => 'image', 'url' => "/media/{$name}", 'filename' => $name, + 'mime' => $ext === 'webp' ? 'image/webp' : 'image/jpeg', + 'width' => $w, 'height' => $h, 'bytes' => filesize($dest)]; + } + + // ---- video: ffmpeg scale + compress ------------------------------------- + private function handleVideo(string $tmp, string $base, string $rand): array + { + $name = "{$base}-{$rand}.mp4"; + $dest = self::MEDIA_DIR . "/{$name}"; + + // scale down to <= MAX_VIDEO_W wide (keep even dims), drop audio, web-optimise + $cmd = sprintf( + 'ffmpeg -y -v error -i %s -vf "scale=%d:-2:flags=lanczos" -an ' . + '-c:v libx264 -preset veryfast -crf 30 -pix_fmt yuv420p -movflags +faststart %s', + escapeshellarg($tmp), self::MAX_VIDEO_W, escapeshellarg($dest) + ); + exec($cmd, $out, $code); + if ($code !== 0 || !file_exists($dest)) { + $this->json(null, 422, ['code' => 'video_failed', 'message' => 'Could not process that video.']); + } + @chmod($dest, 0664); + + return ['kind' => 'video', 'url' => "/media/{$name}", 'filename' => $name, + 'mime' => 'video/mp4', 'width' => null, 'height' => null, 'bytes' => filesize($dest)]; + } + + private function slug(string $s): string + { + $s = strtolower(trim($s)); + $s = preg_replace('/[^a-z0-9]+/', '-', $s); + $s = trim($s, '-'); + return $s !== '' ? mb_substr($s, 0, 40) : 'file'; + } +} diff --git a/api/system/errors.json b/api/system/errors.json index ee82609..e36baae 100644 --- a/api/system/errors.json +++ b/api/system/errors.json @@ -1,4 +1,130 @@ [ + { + "id": "err_6a629906221e95.58240486", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/carlosarias.co\/api\/media\/upload", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/media.php", + "line": 39, + "fullerror": "Call to undefined function mime_content_type()", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(227): Media->upload()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#4 {main}", + "method": "POST", + "ip_address": "207.246.79.95", + "user_agent": "curl\/8.18.0", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 18:43:18" + }, + { + "id": "err_6a6298f253b530.01848186", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/carlosarias.co\/api\/media\/upload", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/media.php", + "line": 39, + "fullerror": "Call to undefined function mime_content_type()", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(227): Media->upload()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#4 {main}", + "method": "POST", + "ip_address": "207.246.79.95", + "user_agent": "curl\/8.18.0", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 18:42:58" + }, + { + "id": "err_6a6298d60d6d97.99170107", + "userid": null, + "org_id": null, + "errortype": "E_COMPILE_ERROR", + "errorcode": "64", + "url": "http:\/\/carlosarias.co", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/adminauth.php", + "line": 23, + "fullerror": "Cannot make non static method App\\Controllers\\PublicController::requireAdmin() static in class AdminAuth", + "trace": null, + "method": "GET", + "ip_address": null, + "user_agent": null, + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 18:42:30" + }, + { + "id": "err_6a6298be759dd9.23504396", + "userid": null, + "org_id": null, + "errortype": "E_COMPILE_ERROR", + "errorcode": "64", + "url": "https:\/\/carlosarias.co\/api\/adminauth\/me", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/adminauth.php", + "line": 23, + "fullerror": "Cannot make non static method App\\Controllers\\PublicController::requireAdmin() static in class AdminAuth", + "trace": null, + "method": "GET", + "ip_address": "207.246.79.95", + "user_agent": "curl\/8.18.0", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 18:42:06" + }, + { + "id": "err_6a6298acccfc65.81224266", + "userid": null, + "org_id": null, + "errortype": "E_COMPILE_ERROR", + "errorcode": "64", + "url": "https:\/\/carlosarias.co\/api\/adminauth\/me", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/adminauth.php", + "line": 23, + "fullerror": "Cannot make non static method App\\Controllers\\PublicController::requireAdmin() static in class AdminAuth", + "trace": null, + "method": "GET", + "ip_address": "207.246.79.95", + "user_agent": "curl\/8.18.0", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 18:41:48" + }, + { + "id": "err_6a6298acc52754.63468900", + "userid": null, + "org_id": null, + "errortype": "E_COMPILE_ERROR", + "errorcode": "64", + "url": "https:\/\/carlosarias.co\/api\/adminauth\/login", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/adminauth.php", + "line": 23, + "fullerror": "Cannot make non static method App\\Controllers\\PublicController::requireAdmin() static in class AdminAuth", + "trace": null, + "method": "POST", + "ip_address": "207.246.79.95", + "user_agent": "curl\/8.18.0", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 18:41:48" + }, + { + "id": "err_6a62989570c429.43724336", + "userid": null, + "org_id": null, + "errortype": "E_COMPILE_ERROR", + "errorcode": "64", + "url": "https:\/\/carlosarias.co\/api\/adminauth\/login", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/adminauth.php", + "line": 23, + "fullerror": "Cannot make non static method App\\Controllers\\PublicController::requireAdmin() static in class AdminAuth", + "trace": null, + "method": "POST", + "ip_address": "207.246.79.95", + "user_agent": "curl\/8.18.0", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 18:41:25" + }, { "id": "err_6a627fa4393a50.82526004", "userid": null,