1 <?php 2 require_once __DIR__ . '/../includes/bootstrap.php'; 3 4 // ── Auth & method ──────────────────────────────────────────────── 5 if (!$USER) 6 json_out(['error' => 'Not logged in'], 401); 7 if ($_SERVER['REQUEST_METHOD'] !== 'POST') 8 json_out(['error' => 'Method not allowed'], 405); 9 if (!csrf_ok()) 10 json_out(['error' => 'Invalid CSRF token'], 403); 11 12 // ── File present? ──────────────────────────────────────────────── 13 // $_FILES is empty when PHP silently drops the upload because it exceeds 14 // php.ini upload_max_filesize or post_max_size. 15 if (empty($_FILES['file']) || !isset($_FILES['file']['tmp_name'])) { 16 $phpLimit = ini_get('upload_max_filesize') ?: '?'; 17 json_out(['error' => 'No file received. Check PHP upload_max_filesize (currently: ' . $phpLimit . ')'], 400); 18 } 19 20 $f = $_FILES['file']; 21 $code = $f['error'] ?? UPLOAD_ERR_OK; 22 23 // ── PHP upload error codes ──────────────────────────────────────── 24 if ($code !== UPLOAD_ERR_OK) { 25 $msgs = [ 26 UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit (upload_max_filesize)', 27 UPLOAD_ERR_FORM_SIZE => 'File exceeds form size limit', 28 UPLOAD_ERR_PARTIAL => 'File was only partially uploaded', 29 UPLOAD_ERR_NO_FILE => 'No file was uploaded', 30 UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder', 31 UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk', 32 UPLOAD_ERR_EXTENSION => 'Upload blocked by a PHP extension', 33 ]; 34 json_out(['error' => $msgs[$code] ?? 'Upload error code ' . $code], 400); 35 } 36 37 // ── Detect real MIME type from file content (not browser header) ── 38 // finfo is reliable; fall back to getimagesize if finfo not available. 39 $realMime = null; 40 if (function_exists('finfo_open')) { 41 $fi = finfo_open(FILEINFO_MIME_TYPE); 42 $realMime = finfo_file($fi, $f['tmp_name']); 43 finfo_close($fi); 44 } elseif (function_exists('mime_content_type')) { 45 $realMime = mime_content_type($f['tmp_name']); 46 } 47 48 // Validate the real MIME type 49 $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; 50 if ($realMime && !in_array($realMime, $allowed)) { 51 json_out(['error' => 'Only JPEG, PNG, GIF and WebP images are allowed (detected: ' . $realMime . ')'], 400); 52 } 53 54 // ── Size limit ──────────────────────────────────────────────────── 55 $maxMb = max(1, min(50, (int) cfg('max_upload_mb', '5'))); 56 $maxBytes = $maxMb * 1024 * 1024; 57 if ($f['size'] > $maxBytes) { 58 json_out(['error' => 'Image too large — maximum is ' . $maxMb . ' MB'], 400); 59 } 60 61 // ── Validate it is a real image (catches non-images finfo might miss) ── 62 $info = @getimagesize($f['tmp_name']); 63 if (!$info) { 64 json_out(['error' => 'File does not appear to be a valid image'], 400); 65 } 66 67 // ── Safe extension from detected MIME ──────────────────────────── 68 $mimeToExt = [ 69 'image/jpeg' => 'jpg', 70 'image/png' => 'png', 71 'image/gif' => 'gif', 72 'image/webp' => 'webp', 73 ]; 74 $detectedMime = $realMime ?: $info['mime']; 75 $ext = $mimeToExt[$detectedMime] ?? ($mimeToExt[$info['mime']] ?? 'jpg'); 76 $name = 'img_' . uniqid('', true) . '.' . $ext; 77 78 // ── Ensure upload directory exists and is writable ──────────────── 79 $dir = UPLOADS . '/'; 80 if (!is_dir($dir)) { 81 if (!mkdir($dir, 0755, true)) { 82 json_out(['error' => 'Upload directory could not be created'], 500); 83 } 84 } 85 if (!is_writable($dir)) { 86 json_out(['error' => 'Upload directory is not writable'], 500); 87 } 88 89 // ── Move file ───────────────────────────────────────────────────── 90 if (!move_uploaded_file($f['tmp_name'], $dir . $name)) { 91 json_out(['error' => 'Could not save uploaded file'], 500); 92 } 93 94 // ── Add security .htaccess to uploads dir (prevent PHP execution) ─ 95 $ht = $dir . '.htaccess'; 96 if (!file_exists($ht)) { 97 file_put_contents($ht, 98 "# Deny PHP execution in uploads\n" . 99 "<FilesMatch \"\\.php\$\">\n" . 100 " Require all denied\n" . 101 "</FilesMatch>\n" 102 ); 103 } 104 105 json_out(['ok' => true, 'url' => BASE . '/public/uploads/' . $name]);