Yonetilmeyen Medyanin Sorunu

Ilk JekCMS sitelerimizi alti aydir calistirdiktan sonra, bir kurulumun uploads klasoru 4.2 GB'a ulasti. Sitede belki 800 yazi vardi. Bu yazi başına ortalama 5.25 MB demek — bir blog için sacma bir rakam. Suclu belliydi: yazarlar telefonlarindan doğrudan 4000x3000 JPEG fotograf yukluyordu ve sistem bunlari tam cozunurlukle sakliyordu.

Ayni görsel kenar cubugunda 200x200 thumbnail olarak, ana sayfada 400px kart görseli olarak ve yazi sayfasinda 800px hero olarak sunuluyordu — hepsi ayni 4.8 MB kaynak dosyadan. Her sayfa yüklemesi gerekenden 10-15 kat daha fazla veri aktariyordu.

Medya pipeline'ini o zaman kurduk. JekCMS'e giren her görsel artik kalici depolamaya tek bayt yazilmadan once doğrulama, EXIF temizleme, format donusumu ve çoklu boyut thumbnail oluşturma işlemlerinden geciyor.

Yukleme Akisi: Gonder Tusuna Bastiginda Ne Olur

Yukleme sureci bes farkli asamaya sahiptir. Bunlari anlamak bir sey ters gittiginde hata ayiklamaya yardimci olur.

Asama 1: Dogrulama

Herhangi bir işleme baslamadan once yuklenen dosya doğrulama kontrollerinden gecer:

private function validateUpload(array $file): void
{
    // 1. PHP yükleme hatalarini kontrol et
    if ($file['error'] !== UPLOAD_ERR_OK) {
        throw new MediaException($this->uploadErrorMessage($file['error']));
    }

    // 2. Dosya boyutunu kontrol et (görseller için max 10MB)
    $maxSize = 10 * 1024 * 1024;
    if ($file['size'] > $maxSize) {
        throw new MediaException('Dosya 10MB sinirini asiyor');
    }

    // 3. MIME tipini uzantidan degil dosya iceriginden doğrula
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime = $finfo->file($file['tmp_name']);

    $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
    if (!in_array($mime, $allowed)) {
        throw new MediaException('Dosya tipi izin verilmiyor: ' . $mime);
    }

    // 4. Gecerli bir görsel oldugunu doğrula
    $info = getimagesize($file['tmp_name']);
    if ($info === false) {
        throw new MediaException('Dosya gecerli bir görsel degil');
    }

    // 5. Gomulu PHP kodu kontrolu (güvenlik)
    $content = file_get_contents($file['tmp_name']);
    if (preg_match('/

MIME tipi kontrolu gerçek dosya basligini okumak için finfo kullanir. malware.php dosyasini malware.jpg olarak yeniden adlandiran birisi bu kontrolde basarisiz olur cunku dosya basligi hicbir görsel MIME tipiyle eslesmez.

Asama 2: EXIF Temizleme

Telefonlardan gelen JPEG görseller EXIF meta verileri icerir: GPS koordinatlari, cihaz modeli, tarih/saat ve bazen fotografcinin adi. Bu verileri yayinlamak gizlilik riskidir. Ofisinizin bir fotografi, indirip EXIF verisini kontrol eden herkese tam konumunuzu ifsa eder.

private function stripExifData(string $sourcePath, string $mime): string
{
    if ($mime !== 'image/jpeg') return $sourcePath;

    $image = imagecreatefromjpeg($sourcePath);
    if ($image === false) return $sourcePath;

    $stripped = tempnam(sys_get_temp_dir(), 'exif_');
    imagejpeg($image, $stripped, 95);
    imagedestroy($image);
    return $stripped;
}

Yaklasim basittir: JPEG'yi GD ile yukle ve yeniden kaydet. GD EXIF verilerini korumaz, dolayisiyla yeniden kaydedilen dosya temizdir.

Asama 3: Format Donusumu

JekCMS tum yuklenen görselleri birincil format olarak AVIF'e, yedek olarak WebP'ye dönüştürur. Basarili donusumden sonra orijinal dosya atilir.

Neden once AVIF? Cunku sikistirma olculebilir sekilde daha iyi. Gerçek JekCMS sitelerinden 500 görsel üzerindeki testlerimizde, kalite 80'de AVIF, kalite 85'teki WebP'den %38 daha küçük dosyalar uretti ve görsel kalite karşılaştırmada esit. Bu marjinal bir iyileştirme degil — görsel başına ucte birden fazla bant genisligi tasarrufu.

private function convertToModernFormats(string $sourcePath, string $destBase): array
{
    $image = $this->loadImage($sourcePath);
    $results = [];

    if (function_exists('imageavif')) {
        $avifPath = $destBase . '.avif';
        imageavif($image, $avifPath, 80);
        $results['avif'] = $avifPath;
    }

    if (function_exists('imagewebp')) {
        $webpPath = $destBase . '.webp';
        imagewebp($image, $webpPath, 85);
        $results['webp'] = $webpPath;
    }

    imagedestroy($image);
    return empty($results) ? ['original' => $sourcePath] : $results;
}

Asama 4: Thumbnail Oluşturma

Yuklenen her görsel dort thumbnail boyutu uretir:

  • thumbnail — 400x400, merkez kirpilmis. Admin panellerde, küçük widget'larda kullanilir.
  • medium — 800x800, orantili olceklenmis. Yazi kartlari ve listeleme sayfalari icin.
  • large — 1600x1600, orantili olceklenmis. Tekil yazi sayfalarinda içerik alani icin.
  • pinterest — 1000x1500, 2:3 oraninda merkez kirpilmis. Pinterest paylaşımi için özel — dikey görseller kare olanlardan 2-3 kat daha fazla etkilesim alir.
$sizes = [
    'thumbnail' => ['width' => 400,  'height' => 400,  'crop' => true],
    'medium'    => ['width' => 800,  'height' => 800,  'crop' => false],
    'large'     => ['width' => 1600, 'height' => 1600, 'crop' => false],
    'pinterest' => ['width' => 1000, 'height' => 1500, 'crop' => true],
];

"Kaynak daha küçükse atla" kontrolu kritiktir. 300x200 görsel yuklendiginde 1600x1600 "large" versiyonu oluşturmak onu buyutur ve bulanik, daha büyük bir dosya uretir.

Asama 5: Depolama ve Veritabani Kaydi

Son asama dosyalari diske yazar ve yüklemeyi medya tablosuna kaydeder. Veritabani yalnizca temel yolu saklar — uzanti olmadan: images/2026/03/resim. URL oluşturucu render sirasinda uygun boyut soneki ve formati ekler.

Picture Elementi Ciktisi

Dogru formati dogru tarayıcıya sunmak HTML <picture> elemani gerektirir:

function get_featured_picture(array $post, string $size = 'medium'): string
{
    $basePath = $post['featured_image'] ?? '';
    if (empty($basePath)) return '';

    $baseUrl = UPLOADS_URL . '/' . $basePath;
    $suffix = ($size !== 'full') ? '-' . $size : '';

    return sprintf(
        '<picture>
            <source srcset="%s" type="image/avif">
            <source srcset="%s" type="image/webp">
            <img src="%s" alt="%s" loading="lazy" width="%d" height="%d">
        </picture>',
        $baseUrl . $suffix . '.avif',
        $baseUrl . $suffix . '.webp',
        $baseUrl . $suffix . '.webp',
        htmlspecialchars($post['title'] ?? ''),
        $size === 'thumbnail' ? 400 : ($size === 'medium' ? 800 : 1600),
        $size === 'thumbnail' ? 400 : ($size === 'medium' ? 500 : 1000)
    );
}

Tarayici destekledigi ilk <source> ogresini secer. Acik width ve height özellikleri Cumulative Layout Shift'i (CLS) onler — bunlar olmadan tarayıcı görsel yuklenene kadar ne kadar alan ayiracagini bilemez.

CDN Entegrasyonu

CDN kullanan siteler için medya URL oluşturma tek bir degisiklik gerektirir:

define('CDN_URL', 'https://cdn.example.com');

$baseUrl = defined('CDN_URL') && CDN_URL
    ? CDN_URL . '/uploads/' . $basePath
    : UPLOADS_URL . '/' . $basePath;

CDN ilk istekte kaynak sunucudan ceker ve yaniti dunya capinda kenar dugumlerde önbellegee alir. Dikkat edilecek sey: CDN önbellek gecersiz kilma. Ayni dosya adiyla görsel yeniden yuklendiginde CDN eski önbellek versiyonunu sunmaya devam edebilir. Bunu önlemek için içerik tabanli dosya adlari kullanin.

Sahipsiz Dosyalar Icin Cop Toplama

Zamanla uploads dizininde hicbir yazi veya sayfanin referans vermedigi dosyalar birikir. Bu, yazarlar görsel yukleyip kullanmamaya karar verdiginde veya yazilar medyalari temizlenmeden silindiginde olur.

class MediaGarbageCollector
{
    public function collectOrphans(): array
    {
        $referenced = [];
        $posts = $this->db->fetchAll(
            "SELECT featured_image FROM posts WHERE featured_image != ''"
        );
        foreach ($posts as $p) $referenced[$p['featured_image']] = true;

        $orphans = [];
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($this->uploadsDir)
        );

        foreach ($iterator as $file) {
            if ($file->isDir()) continue;
            $relativePath = str_replace($this->uploadsDir . DIRECTORY_SEPARATOR, '', $file->getPathname());
            $basePath = preg_replace('/-(thumbnail|medium|large|pinterest).(avif|webp)$/', '', $relativePath);
            $basePath = preg_replace('/.(avif|webp|jpg|jpeg|png|gif)$/', '', $basePath);

            if (!isset($referenced[$basePath])) {
                $orphans[] = ['path' => $relativePath, 'size' => $file->getSize()];
            }
        }
        return $orphans;
    }
}

Cop toplayici dosyalari otomatik silmez. Yoneticinin inceleyip onaylayacagi bir liste dondurur. Otomatik silme tehlikelidir — özel HTML bloklarindan veya harici içerikten referans verilen görseller olabilir.

Bu taramayi haftalik cron ile calistiriyoruz. Tipik sonuçlar: herhangi bir sitede uploads dizinindeki dosyalarin %3-5'i sahipsiz, 1000+ yazisi olan sitelerde 50-200 MB.

Yaygin Yol Tuzaklari

JekCMS kurulumlarinda en sik karsilasilan medya hatasi cift uploads/ yoludur. Bu, veritabanı uploads/images/2026/03/foto gibi bir yol sakladiginda ve URL oluşturucu zaten /uploads ile biten UPLOADS_URL ekledigi zaman olusur. Sonuc: https://site.com/uploads/uploads/images/2026/03/foto.avif — bir 404.

Kural basittir: veritabanı, uploads dizinine goreli yollar saklar, uploads/ onekini asla icermez. Dogru format: images/2026/03/foto.

// Dogru: veritabanında "images/2026/03/foto"
$url = UPLOADS_URL . '/' . $path;
// Sonuc: https://site.com/uploads/images/2026/03/foto.avif

// Yanlis: veritabanında "uploads/images/2026/03/foto"
$url = UPLOADS_URL . '/' . $path;
// Sonuc: https://site.com/uploads/uploads/images/2026/03/foto.avif (404!)

Production'dan Gerçek Rakamlar

  • Ortalama görsel boyutu: 3.8 MB (orijinal JPEG) 142 KB'a (AVIF medium) — %96 azalma
  • Ana sayfa agirligi (12 yazi kartli): 18 MB'dan 1.7 MB'a
  • Largest Contentful Paint: Tum sitelerde ortalama 1.4 saniye iyilesme
  • 1000 yazi başına depolama: 3.2 GB'dan 890 MB'a
  • Yukleme işleme süresi: 4 MB JPEG için ortalama 800ms

800ms işleme süresi odunlesimdir. Yukleme daha yavas cunku sunucu gerçek is yapiyor. Ama o 800ms, görselin omru boyunca her sayfa yüklemesinde megabaytlarca bant genisligi tasarrufu sağlar. Bir ayda 10.000 kez görüntulen tek bir populer yazi karti görseli yaklasik 36 GB aktarim tasarrufu sağlar.

Farkli Ne Yapardik

Bu pipeline'i sifirdan bugün kursaydim iki özellik eklerdim. Birincisi, arka plan işleme: donusum ve thumbnail oluşturmayi senkron yapmak yerine bir kuyruga tasiyin. 800ms gecikmesi kabul edilebilir ama galeri sayfasi için 50 görsel toplu yuklerken toplanir.

Ikincisi, responsive srcset oluşturma. Sabit thumbnail boyutlari yerine birden cok genislik (320, 640, 960, 1280, 1600) oluşturun ve tarayıcınin genislik tanimlayicilariyla srcset kullanarak en iyisini secmesine izin verin. Bu, 400 piksel genisligindeki kartin 800 piksel görsele ihtiyac duymadigi mobil cihazlarda daha küçük dosyalar sunacaktir.

Her iki özellik de bu yilin ilerleyen donemlerinde yol haritamizda. Su an dort boyutlu yaklasim kullanim durumlarinin %95'ini yeterince iyi karsilliyor.