false, 'error' => 'Invalid CSRF token.'], 400); } return $token; } function ensure_gd(): void { if (!extension_loaded('gd')) { respond(['ok' => false, 'error' => 'GD extension is required on the server.'], 500); } } function load_project(int $id): array { $st = pdo()->prepare("SELECT id, slug, title FROM projects WHERE id = ? LIMIT 1"); $st->execute([$id]); $row = $st->fetch(PDO::FETCH_ASSOC); if (!$row) { respond(['ok' => false, 'error' => 'Project not found.'], 404); } return $row; } function ensure_media_dir(string $dir): void { if (!is_dir($dir)) { if (!@mkdir($dir, 0755, true) && !is_dir($dir)) { respond(['ok' => false, 'error' => 'Cannot create media directory.'], 500); } } if (!is_writable($dir)) { respond(['ok' => false, 'error' => 'Media directory is not writable.'], 500); } } function list_media(string $dir, string $folder): array { $files = []; if (!is_dir($dir)) return $files; foreach (glob($dir . DIRECTORY_SEPARATOR . '*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [] as $path) { if (!is_file($path)) continue; $name = basename($path); $stat = @stat($path) ?: []; $size = (int)($stat['size'] ?? 0); $mtime = (int)($stat['mtime'] ?? time()); $dim = @getimagesize($path) ?: []; $files[] = [ 'name' => $name, 'url' => project_media_url($folder, $name), 'size' => $size, 'width' => (int)($dim[0] ?? 0), 'height' => (int)($dim[1] ?? 0), 'modified'=> $mtime, ]; } usort($files, fn($a, $b) => ($b['modified'] ?? 0) <=> ($a['modified'] ?? 0)); return $files; } function normalize_orientation($img, array $info, string $binary) { if (!function_exists('exif_read_data')) return $img; $mime = strtolower((string)($info['mime'] ?? '')); if ($mime !== 'image/jpeg') return $img; $orientation = 0; try { $exif = @exif_read_data('data://image/jpeg;base64,' . base64_encode($binary), 'IFD0'); if (!empty($exif['Orientation'])) $orientation = (int)$exif['Orientation']; } catch (Throwable $e) { return $img; } if ($orientation < 2 || $orientation > 8) return $img; $canFlip = function_exists('imageflip'); switch ($orientation) { case 2: if ($canFlip) imageflip($img, IMG_FLIP_HORIZONTAL); break; case 3: $img = imagerotate($img, 180, 0); break; case 4: if ($canFlip) imageflip($img, IMG_FLIP_VERTICAL); break; case 5: $img = imagerotate($img, 90, 0); if ($canFlip) imageflip($img, IMG_FLIP_HORIZONTAL); break; case 6: $img = imagerotate($img, -90, 0); break; case 7: $img = imagerotate($img, -90, 0); if ($canFlip) imageflip($img, IMG_FLIP_HORIZONTAL); break; case 8: $img = imagerotate($img, 90, 0); break; } return $img; } function save_image(string $binary, string $dir, string $folder): array { ensure_gd(); if (strlen($binary) > MEDIA_MAX_BYTES) { respond(['ok' => false, 'error' => 'Image exceeds 8MB limit.'], 400); } $info = @getimagesizefromstring($binary); if (!$info || empty($info['mime'])) { respond(['ok' => false, 'error' => 'Unsupported or invalid image.'], 400); } $mime = strtolower((string)$info['mime']); if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) { respond(['ok' => false, 'error' => 'Only JPEG, PNG or WebP images are allowed.'], 400); } $img = @imagecreatefromstring($binary); if (!$img) { respond(['ok' => false, 'error' => 'Unable to read the image data.'], 400); } $img = normalize_orientation($img, $info, $binary); $width = imagesx($img); $height = imagesy($img); $maxSide = MEDIA_MAX_SIDE; if ($width > $maxSide || $height > $maxSide) { $scale = $maxSide / max($width, $height); $newW = (int)max(1, floor($width * $scale)); $newH = (int)max(1, floor($height * $scale)); $dst = imagecreatetruecolor($newW, $newH); imagealphablending($dst, true); imagesavealpha($dst, true); imagecopyresampled($dst, $img, 0, 0, 0, 0, $newW, $newH, $width, $height); imagedestroy($img); $img = $dst; $width = $newW; $height = $newH; } $ext = 'jpg'; if ($mime === 'image/png') $ext = 'png'; if ($mime === 'image/webp' && function_exists('imagewebp')) $ext = 'webp'; $filename = 'img-' . date('Ymd-His') . '-' . bin2hex(random_bytes(3)) . '.' . $ext; $path = $dir . DIRECTORY_SEPARATOR . $filename; switch ($ext) { case 'png': imagepng($img, $path, 6); break; case 'webp': imagewebp($img, $path, 85); break; default: imagejpeg($img, $path, 85); break; } imagedestroy($img); $stat = @stat($path) ?: []; $dim2 = @getimagesize($path) ?: []; return [ 'name' => $filename, 'url' => project_media_url($folder, $filename), 'size' => (int)($stat['size'] ?? 0), 'width' => (int)($dim2[0] ?? $width), 'height' => (int)($dim2[1] ?? $height), 'modified' => (int)($stat['mtime'] ?? time()), ]; } function read_uploaded_file(array $files, int $idx): string { $tmp = (string)($files['tmp_name'][$idx] ?? ''); $size = (int)($files['size'][$idx] ?? 0); $err = (int)($files['error'][$idx] ?? UPLOAD_ERR_NO_FILE); if ($err !== UPLOAD_ERR_OK) { throw new RuntimeException('Upload failed for one file.'); } if (!is_uploaded_file($tmp)) { throw new RuntimeException('Suspicious upload rejected.'); } if ($size > MEDIA_MAX_BYTES) { throw new RuntimeException('File is larger than 8MB.'); } $data = file_get_contents($tmp); if ($data === false) { throw new RuntimeException('Unable to read upload.'); } if (strlen($data) > MEDIA_MAX_BYTES) { throw new RuntimeException('File is larger than 8MB.'); } return $data; } function fetch_remote_image(string $url): string { $url = trim($url); if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) { throw new RuntimeException('Please provide a valid image URL.'); } if (!preg_match('#^https?://#i', $url)) { throw new RuntimeException('Only http/https URLs are allowed.'); } $data = ''; if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0, CURLOPT_USERAGENT => 'PortfolioMediaFetcher/1.0', CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_BUFFERSIZE => 102400, ]); $data = (string)curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); if (curl_errno($ch) || $code >= 400 || $code === 0) { curl_close($ch); throw new RuntimeException('Failed to download image (HTTP ' . $code . ').'); } curl_close($ch); } else { $ctx = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => 10, 'header' => "User-Agent: PortfolioMediaFetcher/1.0\r\n", ], ]); $fh = @fopen($url, 'rb', false, $ctx); if (!$fh) throw new RuntimeException('Could not open the URL.'); while (!feof($fh) && strlen($data) <= MEDIA_MAX_BYTES) { $chunk = fread($fh, 8192); if ($chunk === false) break; $data .= $chunk; if (strlen($data) > MEDIA_MAX_BYTES) { fclose($fh); throw new RuntimeException('Remote image is larger than 8MB.'); } } fclose($fh); } if ($data === '' || strlen($data) === 0) { throw new RuntimeException('Image download returned empty data.'); } if (strlen($data) > MEDIA_MAX_BYTES) { throw new RuntimeException('Remote image is larger than 8MB.'); } return $data; } // ----- Main routing ----- $csrfToken = require_csrf(); $projectId = (int)($_GET['project_id'] ?? $_POST['project_id'] ?? 0); if ($projectId <= 0) respond(['ok' => false, 'error' => 'Missing project id.'], 400); $project = load_project($projectId); $folder = project_media_folder($project); $dir = project_media_dir($project); ensure_media_dir($dir); $action = strtolower((string)($_REQUEST['action'] ?? ($_SERVER['REQUEST_METHOD'] === 'GET' ? 'list' : ''))); try { if ($action === 'list') { respond(['ok' => true, 'files' => list_media($dir, $folder)]); } if ($action === 'upload') { $files = $_FILES['files'] ?? null; if (!$files || !isset($files['tmp_name']) || !is_array($files['tmp_name'])) { respond(['ok' => false, 'error' => 'No files received.'], 400); } $saved = []; $total = count($files['tmp_name']); for ($i = 0; $i < $total; $i++) { $data = read_uploaded_file($files, $i); $saved[] = save_image($data, $dir, $folder); if (count($saved) >= 10) break; // prevent huge multi-upload bursts } respond(['ok' => true, 'saved' => count($saved), 'files' => $saved]); } if ($action === 'fetch_url') { $url = (string)($_POST['image_url'] ?? ''); $data = fetch_remote_image($url); $file = save_image($data, $dir, $folder); respond(['ok' => true, 'saved' => 1, 'files' => [$file]]); } if ($action === 'delete') { $name = (string)($_POST['name'] ?? ''); if ($name === '' || !preg_match('/^[a-zA-Z0-9._-]+$/', $name)) { respond(['ok' => false, 'error' => 'Bad filename.'], 400); } $path = realpath($dir . DIRECTORY_SEPARATOR . $name); $dirReal = realpath($dir); if (!$path || !$dirReal || strpos($path, $dirReal) !== 0 || !is_file($path)) { respond(['ok' => false, 'error' => 'File not found.'], 404); } @unlink($path); respond(['ok' => true]); } respond(['ok' => false, 'error' => 'Unknown action.'], 400); } catch (Throwable $e) { respond(['ok' => false, 'error' => $e->getMessage()], 400); }