, 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'; } }