TEKNİK YAZI / API Tasarımı ve Entegrasyonlar

PHP ile Base64 Görsel Yükleme: Güvenli Doğrulama Adımları

PHP ile Base64 görsel yükleme: JSON gövdesini okuma, katı base64_decode, piksel sınırı, sunucuda tür tespiti, yeniden kodlama ve multipart ile karşılaştırma.

PHP’de Base64 görsel yükleme, görüntünün ikili verisini metne çevirip bir JSON isteğinin içinde sunucuya göndermek anlamına gelir. Canvas çıktısı, kırpma aracı veya imza alanı gibi tarayıcıda üretilen küçük görüntülerde pratiktir; karşılığında istek yaklaşık üçte bir büyür, sunucu aynı veriyi bellekte birkaç kez tutar ve doğrulama sorumluluğu tamamen uygulamaya kalır.

Aşağıda data URI biçimi, istemci tarafı, PHP’de güvenli doğrulama sırası, dosyanın saklanması ve multipart/form-data yönteminin hangi durumlarda daha iyi olduğu ele alınıyor. PHP örnekleri PHP 8.1+ ve GD eklentisi gerektirir.

Data URI biçimi

Tarayıcının ürettiği Base64 görüntü bir data URI’dir:

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...

Dört parçadan oluşur:

Base64 her 3 baytı 4 karakterle gösterir. Bu nedenle 3 MB’lık bir görüntü yaklaşık 4 MB’lık bir metne dönüşür.

İstemci tarafı

Bir canvas’ın içeriği toDataURL() ile, seçilen bir dosya ise FileReader ile data URI’ye çevrilir. İstek JSON olarak gönderilir ve HTTP durum kodu kontrol edilir:

// Seçilen dosyayı data URI'ye çevirir
function fileToDataUrl(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = () => reject(reader.error);
    reader.readAsDataURL(file);
  });
}

async function uploadImage(dataUrl) {
  const response = await fetch("/upload.php", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ image: dataUrl }),
  });
  if (!response.ok) {
    throw new Error(`Yükleme başarısız: ${response.status}`);
  }
  return response.json();
}

// Sayfadaki canvas'ın çıktısını %85 kaliteli JPEG olarak gönderir
const canvas = document.querySelector("canvas");
uploadImage(canvas.toDataURL("image/jpeg", 0.85)).then(console.log);

Gövde JSON olduğu için PHP bu veriyi $_POST dizisine koymaz; $_POST yalnız application/x-www-form-urlencoded ve multipart/form-data isteklerinde dolar. Ham gövde php://input akışından okunur.

Sunucuda doğrulama sırası

Sıralama güvenliğin parçasıdır. Ucuz kontroller pahalı işlemlerden önce gelir; görüntü, boyutu doğrulanmadan belleğe açılmaz:

  1. Gövde sınırı: php://input en fazla belirli bir uzunlukta okunur, ardından json_decode çalışır.
  2. Önek: data URI öneki ayrılır. İstemcinin bildirdiği MIME türü kullanılmaz.
  3. Kodlu uzunluk: Base64 metni çözülmeden önce izin verilen boyuta karşılık gelen uzunlukla karşılaştırılır.
  4. Katı çözme: base64_decode($s, true) alfabe dışı karakter görürse false döndürür. Katı mod olmadan geçersiz karakterler sessizce atılır.
  5. Tür tespiti: Gerçek tür içerikten, finfo ile belirlenir ve izin listesiyle karşılaştırılır.
  6. Piksel sınırı: getimagesizefromstring() genişlik ve yüksekliği dosya başlığından okur, piksel verisini açmaz.
  7. Çözme ve yeniden kodlama: Görüntü GD ile açılır ve yeni bir dosyaya yeniden yazılır.
  8. Kayıt: Rastgele bir adla, web kökünün dışındaki bir dizine kaydedilir.

Piksel sınırının dosya boyutundan ayrı kontrol edilmesinin nedeni şudur: çözülmüş bir görüntünün bellek ihtiyacı dosya boyutuna değil, genişlik ile yüksekliğin çarpımına bağlıdır. Birkaç kilobaytlık bir PNG, başlığında on binlerce piksel genişlik ve yükseklik bildirebilir ve açıldığında memory_limit değerini aşabilir. Bu tür dosyalara piksel bombası (decompression bomb) denir.

PHP’de doğrulama ve kayıt

Doğrulama mantığı HTTP katmanından ayrı iki fonksiyonda durur; böylece aynı kod multipart yüklemelerde de kullanılabilir:

<?php
// src/image_upload.php
declare(strict_types=1);

const MAX_BYTES = 5 * 1024 * 1024; // çözülmüş dosya için üst sınır (5 MiB)
const MAX_SIDE = 4096;             // genişlik ve yükseklik için üst sınır
const ALLOWED_TYPES = [            // içerikten tespit edilen MIME => uzantı
    'image/jpeg' => 'jpg',
    'image/png' => 'png',
    'image/webp' => 'webp',
];

function decodeDataUri(string $dataUri): string
{
    // İstemcinin bildirdiği MIME türüne güvenilmez; önek yalnızca ayrılır
    if (!preg_match('#^data:image/[a-z0-9.+-]+;base64,#i', $dataUri, $m)) {
        throw new InvalidArgumentException('Geçersiz data URI');
    }
    $encoded = substr($dataUri, strlen($m[0]));

    // Çözmeden önce kodlu uzunluk: her 4 karakter en fazla 3 bayta karşılık gelir
    if (strlen($encoded) > intdiv(MAX_BYTES + 2, 3) * 4) {
        throw new LengthException('Dosya çok büyük');
    }

    // Katı mod: Base64 alfabesi dışında karakter varsa false döner
    $binary = base64_decode($encoded, true);
    if ($binary === false || $binary === '') {
        throw new InvalidArgumentException('Geçersiz Base64');
    }

    return $binary;
}

function storeImage(string $binary, string $dir): string
{
    if (strlen($binary) > MAX_BYTES) {
        throw new LengthException('Dosya çok büyük');
    }

    // Tür, istemcinin beyanından değil içerikten belirlenir
    $mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($binary);
    $ext = ALLOWED_TYPES[$mime] ?? null;
    if ($ext === null) {
        throw new InvalidArgumentException('Desteklenmeyen dosya türü');
    }

    // Boyut başlıktan okunur; piksel verisi henüz belleğe açılmaz
    $size = getimagesizefromstring($binary);
    if ($size === false || $size['mime'] !== $mime
        || $size[0] < 1 || $size[1] < 1
        || $size[0] > MAX_SIDE || $size[1] > MAX_SIDE) {
        throw new InvalidArgumentException('Geçersiz görüntü boyutu');
    }

    $image = imagecreatefromstring($binary);
    if ($image === false) {
        throw new InvalidArgumentException('Görüntü çözülemedi');
    }

    // Rastgele ad; dizin web kökünün dışında olmalı
    $name = bin2hex(random_bytes(16)) . '.' . $ext;
    $path = $dir . DIRECTORY_SEPARATOR . $name;

    // Yeniden kodlama: yalnız piksel verisi yazılır, ek baytlar ve meta veri atılır
    imagesavealpha($image, true);
    $saved = match ($ext) {
        'jpg' => imagejpeg($image, $path, 85),
        'png' => imagepng($image, $path),
        'webp' => imagewebp($image, $path, 85),
    };
    if (!$saved) {
        throw new RuntimeException('Dosya kaydedilemedi');
    }

    return $name;
}

HTTP katmanı yalnızca isteği okur, fonksiyonları çağırır ve hatayı durum koduna çevirir:

<?php
// public/upload.php
declare(strict_types=1);

require __DIR__ . '/../src/image_upload.php';

const UPLOAD_DIR = '/var/app-data/uploads'; // web kökünün dışında

header('Content-Type: application/json');

function respond(int $status, array $body): never
{
    http_response_code($status);
    echo json_encode($body);
    exit;
}

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST'
    || !str_starts_with($_SERVER['CONTENT_TYPE'] ?? '', 'application/json')) {
    respond(415, ['error' => 'JSON gövdesi bekleniyor']);
}

// Gövdeyi sınırlı oku: Base64 metni, data URI öneki ve JSON için küçük bir pay
$maxBody = intdiv(MAX_BYTES + 2, 3) * 4 + 1024;
$raw = file_get_contents('php://input', false, null, 0, $maxBody + 1);
if ($raw === false || strlen($raw) > $maxBody) {
    respond(413, ['error' => 'İstek çok büyük']);
}

try {
    $data = json_decode($raw, true, 8, JSON_THROW_ON_ERROR);
    if (!is_array($data) || !is_string($data['image'] ?? null)) {
        respond(400, ['error' => 'image alanı eksik']);
    }

    $name = storeImage(decodeDataUri($data['image']), UPLOAD_DIR);
    respond(201, ['id' => $name]);
} catch (JsonException) {
    respond(400, ['error' => 'Geçersiz JSON']);
} catch (LengthException) {
    respond(413, ['error' => 'Dosya çok büyük']);
} catch (InvalidArgumentException $e) {
    respond(422, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
    error_log((string) $e);
    respond(500, ['error' => 'Sunucu hatası']);
}

Kodla ilgili birkaç not:

Dosyayı saklamak ve sunmak

Yüklenen dosya web kökünün dışında tutulduğunda, uzantısı ne olursa olsun web sunucusu onu betik olarak çalıştıramaz. Dosyayı sunmak için kimliği ^[a-f0-9]{32}\.(jpg|png|webp)$ gibi katı bir desenle doğrulayan bir uç nokta yazılır; yanıt, kayıt sırasında belirlenen Content-Type ve X-Content-Type-Options: nosniff başlığıyla gönderilir. Bulut nesne depolama kullanılıyorsa aynı ilke geçerlidir: dosya adını istemci değil sunucu belirler.

Küçük resim üretmek veya görüntüyü ImageMagick gibi harici bir araçla işlemek gibi ağır işler istek içinde yapılmak zorunda değildir. Bu işler bir kuyruğa bırakılıp RabbitMQ gibi bir mesaj aracısıyla ayrı bir arka plan işçisine (worker) devredilebilir. Harici araç çağırırken komutu kabuk olmadan çalıştırmak için PHP exec() ve proc_open() yazısındaki yöntem kullanılır.

Ne zaman multipart daha iyidir?

Base64 bir zorunluluk değildir. Canvas çıktısı toBlob() ile doğrudan ikili veri olarak alınabilir, panodan yapıştırılan bir görsel de paste olayında File nesnesi olarak gelir. Her ikisi FormData ile gönderilebilir. Aşağıdaki canvas, önceki örnekte seçilen aynı öğedir:

canvas.toBlob(
  async blob => {
    const form = new FormData();
    form.append("image", blob, "image.jpg");
    // Content-Type başlığını elle yazmayın; tarayıcı sınır (boundary) değerini ekler
    const response = await fetch("/upload-multipart.php", {
      method: "POST",
      body: form,
    });
    if (!response.ok) {
      throw new Error(`Yükleme başarısız: ${response.status}`);
    }
  },
  "image/jpeg",
  0.85
);

PHP tarafında dosya $_FILES['image'] altında gelir. error alanı UPLOAD_ERR_OK ise geçici dosyanın içeriği storeImage(file_get_contents($_FILES['image']['tmp_name']), UPLOAD_DIR) ile aynı doğrulama zincirinden geçirilir. Multipart bu durumlarda daha uygundur:

Base64 ise API’nin yalnız JSON kabul ettiği, görüntünün başka alanlarla tek bir JSON nesnesinde gitmesi gereken ve görüntünün küçük olduğu (avatar, imza, kırpılmış önizleme) durumlarda makul bir seçimdir.

Sonuç

PHP ile Base64 görsel yükleme, tarayıcıda üretilen küçük görüntüler ve yalnız JSON kabul eden API’ler için uygundur. Güvenli kalması için gövde ve kodlu uzunluk çözmeden önce sınırlanmalı, base64_decode katı modda çalışmalı, tür içerikten belirlenmeli, piksel sınırı görüntü açılmadan uygulanmalı ve dosya yeniden kodlanıp rastgele bir adla web kökünün dışına kaydedilmelidir. Büyük veya kullanıcının seçtiği özgün dosyalarda multipart ya da imzalı URL ile doğrudan yükleme daha az bellek ve bant genişliği harcar.

Birincil kaynaklar