Güvenli Dosya Yükleme Sistemi

PHP ile katmanlı güvenlik uygulanmış dosya yükleme: içerikten MIME tespiti, beyaz liste, uzantının MIME'den türetilmesi ve çalıştırma kilidi. Her katmanın gerekçesi ölçümle yazılı.

PHP 8 PDO MySQL finfo GD JavaScript Bootstrap 5
Seviye
İleri
Dosya
40
Kod satırı
~12.759
Proje boyutu
2.1 MB
Veritabanı
cy_upload
Lisans
MIT
İnceleme
62
Beğeni
1
Yayın

Ekran Görüntüleri 2 görsel

Bu Örnek Ne Yapıyor?

  • On iki katmanlı savunma; her katman diğerleri atlatılsa bile tek başına anlamlı
  • MIME türü istemcinin başlığından değil, finfo ile dosyanın ilk baytlarından tespit ediliyor
  • Diske yazılan uzantı kullanıcının dosya adından değil MIME türünden türetiliyor
  • Kara liste yerine beyaz liste; SVG bilerek dışarıda (içine betik gömülebilen bir XML)
  • Rastgele dosya adı: üzerine yazmayı, ad üzerinden bilgi sızmasını ve adres tahminini engeller
  • Görsel gerçekten çözülerek doğrulanıyor; 50 megapiksel üstü dosyalar çözülmeden reddediliyor
  • Yükleme klasöründe .htaccess ile çalıştırma kilidi, nosniff ve CSP; indirme zorunlu başlıklarla
  • Content-Disposition başlık enjeksiyonu gerçek istekle bulunup kapatılmış

Nerede İşe Yarar?

  • Kullanıcıdan belge veya görsel toplayan her form (başvuru, destek talebi, ilan)
  • Var olan bir yükleme kodunun güvenlik açısından gözden geçirilmesi
  • Profil fotoğrafı ve galeri yüklemelerinde tür doğrulamasının doğru yapılması
  • Paylaşımlı hostingte yükleme klasörünün çalıştırmaya kapatılması
  • Ekip içi eğitimde dosya yükleme açıklarının somut örneklerle anlatılması

Gereksinimler

  • PHP 8.0+ (fileinfo, gd eklentileri) · MySQL 5.7+ veya MariaDB 10.3+ · Apache (.htaccess desteği)

Nasıl Kurulur?

  1. Proje dosyalarını web köküne kopyalayın (örn. XAMPP'ta htdocs veya kendi web sunucunuzun kök dizini)
  2. cy_upload.sql dosyasını içe aktarın — dosya ve ayar tablolarını oluşturur
  3. system/config.php içindeki veritabanı bilgilerini düzenleyin
  4. uploads klasörüne yazma izni verin ve içindeki .htaccess dosyasının durduğundan emin olun
  5. PHP tarafında fileinfo ve gd eklentilerinin açık olduğunu doğrulayın
  6. Ayarlar ekranından izin verilen dosya türlerini kendi ihtiyacınıza göre daraltın

Veritabanı şeması projedeki cy_upload.sql dosyasında.

Nasıl Çalışıyor?

Çılgın Yazılım

Güvenli Dosya Yükleme Sistemi

PHP ile katmanlı güvenlik uygulanmış dosya yükleme örneği.
Sürükle-bırak · İlerleme çubuğu · Tür/tarih klasörleme · İndirme sayacı · Ayarlanabilir tür beyaz listesi
Arama · Izgara/liste görünümü · Görsel önizleme · Açık/koyu tema · Mobil uyumlu arayüz

cilginyazilim.com · MIT Lisansı · Sürüm 1.2.0

📚 Örnek Kod Kütüphanesi · Bu uygulamanın sayfası

🇹🇷 Türkçe · 🇬🇧 English


Canlı Demo

Kurulum yok, kayıt yok, indirme yok — tarayıcınızdan 3 saniyede deneyin.

Canlı Demoyu Aç
Kaynak Kodu İncele
ZIP İndir

Güvenli dosya yükleme canlı demo önizlemesi

▲ Görsele tıklayarak demoyu açabilirsiniz
Örnek dosyaları yükleyin; sonra uzantısı .png yapılmış bir PHP dosyasını deneyin.

İçindekiler


Bu proje ne yapıyor?

Genel amaçlı bir dosya yükleme sistemi: görseller (JPG/PNG/GIF/WEBP) ve belgeler (PDF/TXT/ZIP/DOCX/XLSX) yükler, tür ve tarihe göre klasörler, listeler, süzer, indirir, sayar ve siler.

Odak noktası arayüz değil güvenliktir. Dosya yükleme, bir web uygulamasının en tehlikeli özelliğidir: yanlış yapılırsa saldırgan sunucuya kod yükleyip çalıştırabilir (remote code execution). Bu depo, o riski katman katman nasıl kapatacağınızı ölçülmüş kanıtlarla gösterir.

Bu depo hem öğretici hem portfolyodur. Koddaki her kararın gerekçesi, kodun içinde Türkçe yorum olarak yazılıdır. "Şunu yaptık" değil, "neden böyle yaptık, alternatifi neden yetersizdi" anlatılır.

Katmanlı güvenlik savunması

Her katman, diğerleri atlatılsa bile tek başına anlamlı bir engel oluşturacak şekilde tasarlanmıştır (defense in depth). Aşağıdaki tabloda her katmanın neden orada olduğu yazılıdır.

#KatmanNeredeNeden orada?
1İçerikten MIME tespitistore_upload()finfoİstemcinin gönderdiği Content-Type başlığı saldırganın yazdığı bir metindir, doğrulama değildir. finfo dosyanın ilk baytlarına ("magic bytes") bakar; bu, dosyanın içeriğini gerçekten değiştirmeden taklit edilemez.
2Katalog beyaz listesiconfig.phpKara liste ("şunlar yasak") her zaman eksiktir — unutulan tek uzantı sistemi çökertir. Beyaz liste ("yalnızca şunlar serbest") varsayılanı reddetmek yapar. SVG bilerek dışarıdadır: içine ` gömülebilen bir XML formatıdır.
3Uzantı MIME'den türetilirstore_upload()Kullanıcının dosya adı hiçbir zaman diske yazılan adı belirlemez. fatura.pdf.php gönderilse bile, içerik PDF ise diskte .pdf olur; .php diske asla ulaşmaz. Çift uzantı saldırısı bu satırda ölür.
4Rastgele dosya adırandom_bytes(16)Üç fayda: üzerine yazmayı önler, orijinal addaki bilgilerin URL'de sızmasını engeller, "başkasının dosyasının adresini tahmin etme"yi imkânsız kılar.
5Görselin gerçekten çözülmesiimagecreatefromstring()getimagesize() dosyayı çözmez, yalnızca başlığını okur — sahte başlıklı bir dosyayı geçirir (bkz. ölçüm). GD ile gerçek çözümleme, polyglot dosyaları eler.
6Piksel bombası sınırıstore_upload()50 MP üzeri bir görsel çözülürken yüzlerce MB bellek ister. Çözmeden önce reddedilir; küçük bir dosya sunucuyu düşüremesin diye.
7.htaccess çalıştırma kilidiuploads/.htaccess1–6 katmanlarının hepsi atlatılıp bir .php diske yazılsa bile Apache o klasörde hiçbir betiği çalıştırmaz. Son çare savunması. Alt klasörlere de miras kalır (ölçüldü).
8nosniff + CSPuploads/.htaccessBazı tarayıcılar Content-Type'a rağmen içeriğe bakıp "bu aslında HTML" diyebilir (MIME sniffing) ve dosyayı sitenin kendi alan adında çalıştırır → depolanmış XSS. nosniff bunu yasaklar, CSP sandbox ikinci kemerdir.
9Zorunlu indirme başlıklarıdownload.phpapplication/octet-stream + attachment: tarayıcı içeriği asla sayfa olarak render etmez, yalnızca indirir.
10Başlık enjeksiyonu temizliğidownload_filename_header()Dosya adı bir HTTP başlığının içine giriyorsa kullanıcı girdisi başlığa yazılıyor demektir. Tırnak/noktalı virgül temizlenmezse saldırgan başlıktan kaçabilir (bkz. ölçüm).
11CSRF anahtarırequire_csrf()Durum değiştiren her istek (yükleme, silme, ayar) oturuma bağlı bir anahtar ister; başka bir sitenin sizin adınıza istek göndermesini engeller.
12Ayar ≠ güvenlik sınırıallowed_upload_types()Ayarlar veritabanındadır ve veritabanı değişebilir. Etkin liste her zaman katalog ∩ ayarlar olarak hesaplanır — ayarlara .php` yazılsa bile etkinleşmez.

Ölçülmüş bulgular ve kapatılan açıklar

Bu bölümdeki her madde tahmin değil, ölçümdür: gerçek HTTP istekleriyle denendi, sonucu kaydedildi, düzeltildi, tekrar denendi.

1) Content-Disposition başlık enjeksiyonu (uzaktan erişilebilir)

Bulgu. download.php, veritabanındaki original_name değerini doğrudan başlığa yazıyordu. basename() yol bilgisini atar ama tırnak işaretini temizlemez.

PHP'nin çok parçalı (multipart) yükleme çözümleyicisi normalde tırnağı geçirmez — ancak ters bölü ile kaçırılırsa geçirir:

Content-Disposition: form-data; name="files[]"; filename="a\"; filename=kurulum.exe.png"

Bu istek sıradan bir yükleme olarak kabul edildi ve veritabanına şu ad yazıldı:

a"; filename=kurulum.exe.png

Sunucunun ürettiği indirme başlığı (ölçüldü):

Content-Disposition: attachment; filename="a"; filename=kurulum.exe.png"
                                            ↑ başlıktan kaçış, İKİNCİ filename parametresi

Yani kimlik doğrulaması olmayan bir yükleyici, indirme başlığına ikinci bir filename parametresi enjekte edebiliyordu. Bazı istemciler sonuncuyu dikkate alır; kullanıcı beklemediği bir adla (örn. .exe) dosya kaydedebilir.

Düzeltme. download_filename_header() iki temsil üretir: temizlenmiş ASCII (" \ ; ve kontrol karakterleri _ olur) + RFC 5987 yüzde kodlu UTF-8.

Düzeltme sonrası ölçüm:

Content-Disposition: attachment; filename="a__ filename=kurulum.exe.png"; filename*=UTF-8''a%22%3B%20filename%3Dkurulum.exe.png
                                          ↑ tek parametre, kaçış yok

2) Görsel doğrulama baypası (getimagesize() yetersiz)

Bulgu. 5. katman getimagesize() kullanıyordu. Bu fonksiyon dosyayı çözmez, yalnızca başlığını okur.

İçeriği GIF89a olan bir dosya gönderildiğinde:

DenetimSonuç
finfoimage/gif (ilk 6 bayt GIF imzası)
getimagesize()GEÇTİ — üstelik 16188x26736 gibi uydurma bir boyut döndürdü (PHP kodunun baytlarını genişlik/yükseklik sandı)
SonuçDosya diske yazıldı

Daha sinsi bir sürümde (geçerli 100×100 başlık + PHP kodu) sonuç aynıydı: getimagesize()GEÇTİ (100x100).

Düzeltme. imagecreatefromstring() ile gerçek çözümleme eklendi + 50 MP piksel sınırı.

Düzeltme sonrası ölçüm (aynı 100×100 polyglot):

getimagesize          -> GECTI (100x100)     ← eski katman hâlâ kanardı
imagecreatefromstring -> RED                 ← yeni katman yakaladı
sunucu yaniti         -> {"success":false,"description":"... Dosya geçerli bir görsel değil (içerik çözümlenemedi)."}

Meşru bir PNG aynı testte sorunsuz yüklendi — düzeltme normal kullanımı bozmadı.

3) Yükleme klasöründe güvenlik başlığı yokluğu

Bulgu. download.php yanıtlarında nosniff vardı, ama küçük resim önizlemelerinin kullandığı doğrudan erişim yolunda hiçbir güvenlik başlığı yoktu:

GET /uploads/....gif
Content-Type: image/gif        ← başka başlık YOK

Bir GIF/PNG içine HTML gömülüp tarayıcı içerik tahmini yaparsa, betik sitenin kendi alan adında çalışır (depolanmış XSS).

Düzeltme + ölçüm:

X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox
X-Frame-Options: DENY

4) CSRF reddi HTTP 500 döndürüyordu

Bulgu. require_csrf() başarısızlıkta 419 döndürüyordu. 419 resmî bir HTTP durum kodu değildir (Laravel'in icadı) ve bu kurulumda Apache onu sessizce 500'e çeviriyordu — istemci "sunucu çöktü" sanıyordu.

bozuk token -> HTTP 500      (düzeltme öncesi)
bozuk token -> HTTP 403      (düzeltme sonrası)

5) .htaccess kilidi — doğrulandı, açık bulunamadı

Bu katman atlatılamadı. Denenen ve hepsi 403 dönen yollar:

uploads/zz.php            uploads/zz.php/         uploads/./zz.php
uploads//zz.php           uploads/ZZ.PHP          uploads/zz.php.
uploads/zz.phtml          uploads/zz.php%00.png   (404)
uploads/image/2026/08/zz.php   ← alt klasörlerde de geçerli

Dizin listeleme de her seviyede kapalı (403).

6) Dizin aşımı (path traversal) — savunma doğrulandı

safe_upload_path() şu girdilerin tamamını reddetti; meşru yolu kabul etti:

../../system/config.php                      -> REDDEDILDI
image/2026/08/../../../../system/config.php  -> REDDEDILDI
image/2026/08/../../../.htaccess             -> REDDEDILDI
..\..\system\config.php                      -> REDDEDILDI
image/2026/08/%2e%2e%2fconfig.php            -> REDDEDILDI
document/2026/08/AAAA.php                    -> REDDEDILDI
image/2026/08/deada12d...474.png             -> kabul  ← meşru

7) SQL enjeksiyonu — savunma doğrulandı

Sıralama parametresine id; DROP TABLE files; -- gönderildi. Sıralama sütunu parametre olamayacağı için beyaz listeden seçilir; girdi eşleşmediğinden varsayılana düştü, tablo yerinde kaldı.

8) Ayarlar güvenlik sınırı — doğrulandı

Ayar kaydetme isteğine application/x-php ve application/x-httpd-php eklendi, boyut 500 MB, dosya sayısı 9999 istendi:

ETKIN turler: image/png                    ← .php türleri sessizce elendi
max_bytes=8388608  max_files=10            ← tavana kelepçelendi

Dosya yapısı ve hangi dosya ne yapar

secure-file-upload/
├── index.php                  ← Arayüz: sürükle-bırak, arama, süzgeçler, özet, ayarlar + önizleme pencereleri
├── .env.example               ← Veritabanı bilgileri (isteğe bağlı) — .gitignore içinde
├── cy_upload.sql              ← Veritabanı kurulumu (files + settings tabloları)
│
├── system/
│   ├── config.php             ← Ayarlar, TÜR KATALOĞU, sınır tavanları, PDO bağlantısı
│   ├── function.php           ← Çekirdek: doğrulama, kaydetme, ayarlar, yol güvenliği
│   ├── ajax.php               ← JSON uç noktası (list / upload / delete / settings)
│   └── download.php           ← İndirme uç noktası (GET, zorunlu attachment, sayaç)
│
├── assets/
│   ├── css/cilginyazilim.css  ← ORTAK MARKA KALIBI — dokunulmaz
│   ├── css/style.css          ← Yalnızca bu sayfaya özel stiller + mobil uyum
│   ├── js/upload.js           ← Sürükle-bırak, ilerleme, arama, süzgeç, tema, ayar arayüzü
│   └── images/                ← Logo + ekran görüntüleri
│
├── ornek-dosyalar/            ← Denemek için hazır örnekler (PNG/JPG/WEBP/GIF/PDF/TXT/ZIP)
│
└── uploads/                   ← Yüklenen dosyalar (tür/yıl/ay ağacı)
    └── .htaccess              ← Çalıştırma kilidi + güvenlik başlıkları

Sorumluluk ayrımı

DosyaSorumluluğuSorumluluğu olmayan
index.phpYalnızca HTML çizerHiçbir güvenlik kararı vermez
upload.jsYalnızca kullanıcı deneyimiHiçbir kontrolü güvenlik önlemi değildir — JS atlanabilir
config.phpKatalog + tavanlarİş mantığı içermez
function.phpTüm güvenlik kararlarıÇıktı üretmez (JSON hariç yardımcılar)
ajax.phpİstek yönlendirme + yetkiDoğrulama mantığı içermez, function.php'ye devreder
Altın kural: upload.js içindeki tür ve boyut kontrolleri güvenlik değildir. Kötü niyetli biri JavaScript'i hiç çalıştırmadan doğrudan system/ajax.php'ye istek gönderebilir. Gerçek doğrulama her zaman sunucudadır.

Fonksiyon referansı

system/function.php

FonksiyonNe yapar
e()HTML kaçışı (htmlspecialchars) — XSS'e karşı çıktı temizliği
json_response() / json_success() / json_error()Tek biçimli JSON yanıtı üretir; her yanıta nosniff ekler
csrf_token()Oturuma bağlı 32 baytlık anahtar üretir/döndürür
require_csrf()Anahtarı hash_equals() ile sabit sürede doğrular; başarısızsa 403
settings_all()Ayarları okur, istek boyunca önbelleğe alır (10 dosyalık yüklemede 10 sorgu olmasın)
setting_int()Sayısal ayarı min(ayar, tavan) ile kelepçeler
allowed_upload_types()Etkin türler = katalog ∩ ayarlar — güvenlik sınırının kalbi
settings_save()Ayarları yazar; katalog dışı türleri yazmadan önce eler
store_upload()Çekirdek. Doğrulama → klasörleme → rastgele ad → taşıma → kayıt
download_filename_header()Dosya adını başlığa güvenle gömer (ASCII + RFC 5987)
safe_upload_path()Göreli yolu doğrular (kalıp + realpath sınır denetimi)
delete_stored_file()Dosyayı siler, boş kalan tür/yıl/ay klasörlerini toplar
increment_download_count()Sayacı tek SQL sorgusuyla artırır (yarış durumuna kapalı)
find_file() / fetch_files() / fetch_file_stats()Veri erişimi; süzgeçler (tür/ay/arama) parametreli, sıralama beyaz listeli, LIKE jokerleri kaçırılmış
format_bytes() / format_date() / file_icon()Biçimlendirme (güvenlik kararı vermez)

Diskteki klasör yapısı

Dosyalar düz bir klasöre değil, tür + tarih ağacına yazılır:

uploads/
├── .htaccess
├── image/
│   └── 2026/
│       └── 08/
│           ├── deada12df99150cb71bc731822d9b474.png
│           └── 2516fdb120e4253593217a923eac0580.jpg
└── document/
    └── 2026/
        └── 08/
            ├── f4384d8cf06ccaa65b0e70da95f75036.pdf
            └── 372c1b698c106b7e94500fb620d138f2.zip

Neden?

  1. Performans — Tek klasörde on binlerce dosya biriktiğinde dosya sistemi dizin taramasında yavaşlar.
  2. Yönetilebilirlik — "2025'in tamamını arşivle" tek komuta iner.
  3. Yedekleme — Aylık artımlı yedek almak kolaylaşır.

Güvenlik notu: Bu yolun hiçbir parçası kullanıcı girdisinden gelmez — kategori katalogdan, yıl/ay sunucu saatinden, dosya adı random_bytes()'tan. uploads/.htaccess alt klasörlere de miras kalır (ölçüldü); her derinlikte .php isteği 403 döner.

Bir dosya silindiğinde boş kalan 08/, 2026/ klasörleri otomatik toplanır.


Ayarlar ekranı

Ayarlar ekranı

Yönetici, arayüzden izin verilen dosya türlerini ve boyut/sayı sınırlarını koda dokunmadan değiştirebilir.

Güvenlik sınırı — bu ekranın yapamadıkları

Ayarlar veritabanında durur ve bir veritabanı, koddan farklı olarak, yanlış bir yedek geri yüklemesiyle veya başka bir açıkla değişebilir. Bu yüzden ayarlar hiçbir zaman güvenliği gevşetemez:

etkin türler = SUPPORTED_UPLOAD_TYPES (config.php)  ∩  settings tablosu
DenemeSonuç
Ayarlara application/x-php eklemekSessizce elenir — katalogda yok
Boyut sınırını 500 MB yapmak8 MB'a kelepçelenir (config.php tavanı)
Dosya sayısını 9999 yapmak10'a kelepçelenir
Bir türü kapatmakGerçekten reddedilir (ölçüldü)

Yani tehlikeli bir türü etkinleştirmenin tek yolu system/config.php dosyasını düzenlemektir — bu da sunucuya dosya yazma yetkisi gerektirir.


Arayüz özellikleri ve mobil uyum

Sürüm 1.1.0 ile arayüz masaüstünde olduğu kadar telefonda da kullanılabilir hâle getirildi. Aşağıda ne değişti ve daha önemlisi neden değişti yazılıdır.

Yeni arayüz özellikleri

ÖzellikNasıl çalışırNeden böyle?
Dosya adında aramaSunucuda LIKE ... ESCAPE ile (fetch_files())Filtrelemeyi tarayıcıda yapmak, 10.000 kayıtlık bir arşivde tüm listeyi indirmek demektir. Süzgeç zaten sunucudaydı, arama da aynı yolu izler.
Yazarken 300 ms bekleme (debounce)upload.jssetTimeoutHer tuş vuruşunda istek atmak 10 harflik bir aramada 10 gereksiz sorgu üretir. Mobil veri paketini de boşuna harcar.
Izgara / liste görünümüCSS sınıfı + localStorageDar ekranda uzun dosya adlarını okumak için liste, göz gezdirmek için ızgara daha uygun. Tercih tarayıcıda saklanır.
Görsel önizleme (lightbox)Bootstrap modalKüçük resme dokunmak dosyayı indirmeden büyütür. Kaynak uploads/ altındadır ve o klasör .htaccess ile hem çalıştırmaya hem MIME tahminine kapalıdır — yeni bir risk doğmaz.
Açık / koyu tema anahtarı` + localStoragecilginyazilim.css` zaten koyu tema token'larını taşıyordu; eksik olan yalnızca kullanıcının elle seçebilmesiydi.
Tema titremesi (FOUC) neden ` içinde çözüldü? Tercihi okuyan betik upload.js içine konsaydı sayfa önce işletim sistemi temasıyla çizilir, betik yüklendiğinde bir anda diğer temaya sıçrardı. Bu sıçramayı önlemenin tek yolu, ilk boyamadan önce çalışan satır içi bir betiktir — bu yüzden index.php içindeki o küçük ` bilerek oradadır.

Arama neden ESCAPE kullanıyor?

Aranan metin hazırlanmış ifade parametresi olarak geçer, yani SQL enjeksiyonu riski yoktur. Ama LIKE'ın kendi joker karakterleri (% ve _) parametre içinde de anlamlıdır:

Kullanıcı ne yazarsaKaçış olmasaydıŞimdi
%Tüm kayıtlar eşleşirdi0 sonuç (aranan gerçekten % karakteri)
_Herhangi bir tek karakter eşleşirdi0 sonuç
a'b(zaten güvenliydi)0 sonuç

Bu bir güvenlik açığı değil, doğruluk sorunudur; yine de sessizce yanlış sonuç vermek kabul edilebilir değildir.

Mobil düzenlemeler

Bu sayfada mobilin asıl sorunu "sığmamak" değil, dokunma hedeflerinin küçüklüğüydü: 34×34 piksellik indir/sil düğmeleri parmakla ıskalanıyordu. WCAG 2.5.5 en az 44×44 piksel önerir.

AlanÖnceSonra
Kart işlem düğmeleri34×34 pxSatırı paylaşan 44 px yükseklikte düğmeler
Özet şeridi4 kutu alt alta (uzun şerit)2×2 ızgara (yarı yükseklik)
Süzgeç çubuğuTek sarma kutusu, etiketler karışıyorduHer süzgeç kendi satırında; etiket üstte
Tür/klasör düğmeleriSararak 3-4 satır kaplıyorduTek satır, yatay kaydırma
Dosya ızgarasıminmax(180px) → telefonda tek sütun2 sütun (≤380 px'te tek sütun)
Başlık düğmeleriSabit genişlik, sıkışıyorduTam genişliğe yayılır; ≤380 px'te yalnızca simge
BildirimlerSağ üstte dar kutuTam genişlik
Kart :hover efektiDokunmada "takılı" kalıyordu@media (hover: none) ile kapatıldı
@media (hover: none) neden gerekli? Dokunmatik ekranda :hover bir kez tetiklendiğinde başka bir yere dokunulana kadar sürer — kullanıcı bir karta dokunur, kart yukarıda asılı kalır. Bu kural, kaldırma efektini yalnızca gerçek imleci olan cihazlarda çalıştırır.

API uç noktaları

Tümü system/ajax.php üzerinden POST ile çalışır ve CSRF anahtarı zorunludur (csrf_token alanı veya X-CSRF-Token başlığı).

action=list — Dosyaları listele

ParametreDeğerAçıklama
categoryimage \document \boşTür süzgeci
periodYYYY-AAAy süzgeci
searchmetin (en fazla 100 karakter)Dosya adında arama. % ve _ kaçırılır (ESCAPE), parametreli sorgu
sortnewest \oldest \largest \popular \nameSıralama (beyaz liste)
{
  "success": true,
  "total": 7,
  "files": [
    {
      "id": 1, "original_name": "ornek-gorsel-1.png",
      "extension": "png", "category": "image",
      "size": "8,7 KB", "uploaded_at": "15.08.2026 15:16",
      "downloads": 3, "folder": "image/2026/08",
      "download_url": "system/download.php?id=1",
      "thumb_url": "uploads/image/2026/08/deada12d....png"
    }
  ],
  "stats": {
    "by_category": [{"category": "image", "total": 4}],
    "by_period":   [{"period": "2026-08", "total": 7}],
    "totals":      {"files": 7, "bytes": 111923, "downloads": 3}
  }
}

action=upload — Dosya yükle

multipart/form-data ile files[] alanı (çoklu). Her dosya bağımsız doğrulanır: biri reddedilse bile diğerleri kaydedilir.

{ "success": true, "description": "7 dosya başarıyla yüklendi.", "uploaded": 7, "failed": 0 }

action=delete — Dosya sil

ParametreDeğer
idDosya numarası (pozitif tamsayı)

action=settings — Ayarları oku / kaydet

ParametreDeğer
moderead (varsayılan) \save
allowed_mimes[]Etkinleştirilecek MIME türleri
max_bytesDosya başına bayt sınırı
max_filesTek istekte dosya sayısı

system/download.php?id=N — İndirme (GET)

Ayrı bir uç noktadır çünkü tarayıcının kendi indirme akışını kullanır (``, "Farklı Kaydet", yeni sekme). GET olması güvenli, çünkü hiçbir veri değiştirmez — yalnızca indirme sayacını artırır.

Durum kodları

KodAnlamı
200Başarılı
403CSRF doğrulaması başarısız (419 değil — Apache 419'u 500'e çeviriyor)
404Dosya bulunamadı
405POST dışı yöntem
422Doğrulama hatası (tür/boyut/sayı)
500Beklenmeyen sunucu hatası

Veritabanı şeması

Veritabanı adı cy_upload, kurulum dosyası cy_upload.sql.

Adlandırma kuralı: Çılgın Yazılım projelerinde veritabanları cy_ önekiyle adlandırılır ve kurulum dosyası veritabanıyla aynı adı taşır. Bir sunucuda onlarca .sql arasında hangisinin nereye ait olduğu tek bakışta anlaşılsın diye.

files

SütunTürAçıklama
idINT UNSIGNEDBirincil anahtar
original_nameVARCHAR(255)Kullanıcının adı — yalnızca gösterim, dosya işlemine asla girmez
stored_pathVARCHAR(255)Göreli yol: kategori/yıl/ay/rastgele.uzantı (benzersiz)
mimeVARCHAR(127)Sunucunun içerikten tespit ettiği tür (istemci başlığı değil)
extensionVARCHAR(10)MIME'den türetilen güvenli uzantı
size_bytesINT UNSIGNEDBoyut
categoryENUM('image','document')Klasörleme ve önizleme kararı
download_countINT UNSIGNEDİndirme sayacı
last_downloaded_atTIMESTAMP NULLSon indirme zamanı
uploaded_atTIMESTAMPYükleme zamanı

settings

SütunTürAçıklama
nameVARCHAR(64)Ayar adı (birincil anahtar)
valueTEXTJSON değer — tek tablo hem liste hem sayı taşısın diye
updated_atTIMESTAMPOtomatik güncellenir

Kurulum

Gereksinimler: PHP 8.1+ (fileinfo, pdo_mysql, gd), MySQL/MariaDB, Apache (mod_headers, AllowOverride All).

cd C:/xampp/htdocs
git clone https://github.com/CilginYazilim/secure-file-upload.git

mysql -u root -p < secure-file-upload/cy_upload.sql
İsteğe bağlı — kendi veritabanı bilgileriniz:
cp .env.example .env (Windows: copy .env.example .env) deyip DB_*
satırlarını doldurun. Bu dosya olmadan da çalışır; varsayılanlar yerel bir
XAMPP kurulumuna (root, boş parola) göredir. .env .gitignore
içindedir — parolanız depoya gitmez.

Veritabanı bilgilerini depo kökündeki .env dosyasına yazın; system/config.php
dosyasına dokunmanız gerekmez:

cp .env.example .env        # Windows: copy .env.example .env

Ayrıntı için aşağıdaki Ortam değişkenleri bölümüne bakın.

Ardından: http://localhost/secure-file-upload/

ornek-dosyalar/ klasöründeki hazır dosyaları sürükle-bırak alanına bırakarak sistemi hemen deneyebilirsiniz.

Canlıya alırken

  1. APP_DEBUGfalse (hata ayrıntıları kullanıcıya gösterilmesin)
  2. mod_headers etkin olmalı — yoksa 8. katman (nosniff/CSP) sessizce devre dışı kalır
  3. Sunucunuz .htaccess okumuyorsa (Nginx) eşdeğer kuralları sunucu yapılandırmasına taşıyın
  4. Bu demoda indirme kimlik doğrulamasızdır; gerçek projede yetki kontrolü ekleyin

Ortam değişkenleri

Depo kökündeki .env dosyasına yazın; system/config.php dosyasına
hiç dokunmayın:

cp .env.example .env        # Windows: copy .env.example .env

.env .gitignore içindedir: depoya gönderilmez ve dağıtım (deploy) onu
silmez. system/config.php ise depoda durur ve her dağıtımda depodaki
sürümle değiştirilir — parolayı oraya yazarsanız hem GitHub'a gider hem de
ilk deploy'da kaybolur.

Dosyayı hiç oluşturmasanız da uygulama çalışır; aşağıdaki varsayılanlar
yerel bir XAMPP kurulumuna göredir.

Değer arama sırası: .env → sunucunun gerçek ortam değişkeni
(Apache SetEnv, systemd…) → buradaki varsayılan.

DeğişkenVarsayılanNe işe yarar
DB_HOST127.0.0.1Veritabanı sunucusu
DB_NAMEcy_uploadVeritabanı adı
DB_USERrootKullanıcı
DB_PASS(boş)Şifre — koda yazmayın
APP_TIMEZONEEurope/IstanbulPHP'nin saat dilimi
APP_DEBUGortamdanHataların ekrana basılıp basılmayacağı

APP_TIMEZONE neden var? XAMPP'ın php.ini dosyasındaki
date.timezone, MySQL'in kullandığı sistem diliminden farklı olabilir.
Test makinesinde PHP Europe/Berlin, MySQL Europe/Istanbul
kullanıyordu; aynı anı anlatan iki satır bir saat farklı görünüyordu.
Zaman hesapları SQL tarafında yapıldığı için doğruydu, ama ekrana
basılan saat kayıyordu. Artık dilim açıkça sabitleniyor — sunucunuz başka
bir bölgedeyse bu değişkeni tanımlamanız yeterli, koda dokunmayın.


Özelleştirme

Yeni bir dosya türü eklemek

system/config.php içindeki SUPPORTED_UPLOAD_TYPES kataloğuna bir satır ekleyin:

'audio/mpeg' => ['ext' => 'mp3', 'category' => 'document', 'label' => 'MP3 ses'],

Arayüzdeki ipucu metni, accept özniteliği, ayarlar ekranı ve sunucu doğrulaması bu tek tanımdan beslenir.

Dikkat: category => 'image' yalnızca GD'nin çözebildiği formatlar için kullanılın; aksi hâlde 5. katman geçerli dosyaları reddeder.

Sınırları değiştirmek

config.php içindeki UPLOAD_MAX_BYTES / UPLOAD_MAX_FILES tavandır. Ayarlar ekranından bu tavanın altında herhangi bir değer seçilebilir. Tavanı yükseltirseniz php.ini içindeki upload_max_filesize ve post_max_size değerlerini de yükseltin.

Klasör düzenini değiştirmek

store_upload() içindeki $relativeDir satırı klasör düzenini belirler:

$relativeDir = $category . '/' . date('Y') . '/' . date('m');   // image/2026/08
$relativeDir = date('Y/m/d');                                    // 2026/08/15
$relativeDir = $category;                                        // yalnızca tür

Değiştirirseniz safe_upload_path() içindeki kalıbı da güncelleyin — yoksa yeni yollar reddedilir.

Görünümü değiştirmek

Sayfaya özel stiller assets/css/style.css içindedir. assets/css/cilginyazilim.css ortak marka kalıbıdır, değiştirmeyin — tüm Çılgın Yazılım projeleri onu paylaşır.


Örnek kullanım alanları

Bu kod, "dosya kabul eden" hemen her işin başlangıç noktası olabilir:

AlanNasıl kullanılır
Kurumsal destek/talep sistemiMüşteri ekran görüntüsü ve fatura eki yükler. Tür beyaz listesi, gelen ekin gerçekten görsel/PDF olmasını garanti eder.
İnsan kaynakları — CV toplamaYalnızca PDF/DOCX açılır; .exe/.php başvuru dosyası olarak gelemez. Tarih klasörleme, dönemsel arşivi kendiliğinden oluşturur.
E-ticaret ürün görselleriSatıcı panelinden görsel yükleme. GD çözümleme katmanı, "görsel gibi görünen" zararlı dosyaları eler.
Muhasebe / e-fatura arşiviXLSX/PDF kabul edilir, ay bazlı klasörlenir; yıl sonu arşivi tek klasör kopyalamaya iner.
Okul / kurs ödev teslimiÖğrenci ödev yükler; indirme sayacı, dosyanın kaç kez alındığını gösterir.
Ajans müşteri portalıMüşteri marka varlıklarını yükler, ekip indirir; en çok indirilen dosyalar sıralamayla görünür.
İç dokümantasyon deposuKüçük ekipler için hafif bir dosya paylaşımı; ayarlardan yalnızca PDF açılarak "belge arşivi" moduna alınabilir.
Eğitim materyaliGüvenli dosya yükleme dersi: her katmanın neden var olduğu ve atlatıldığında ne olduğu kod içinde yazılıdır.

Bu kodu kullanırken eklemeniz gerekenler

Bu bir demodur; gerçek projede ayrıca şunlar gerekir:

  • Yetkilendirme — Şu an dosya numarasını bilen herkes indirebilir. download.php içine oturum/sahiplik kontrolü ekleyin.
  • Hız sınırı — Aynı IP'den saniyede onlarca yükleme engellenmiyor.
  • Virüs taraması — ClamAV gibi bir tarayıcı, MIME doğrulamanın yakalayamadığı zararlı içerikleri yakalar.
  • Depolama kotası — Kullanıcı başına toplam boyut sınırı.

Sürüm geçmişi

Sürüm numarası tek bir yerde tutulur: system/config.php içindeki APP_VERSION. Arayüzün alt bilgisinde görünen değer de oradan okunur.

1.1.0

Arayüz

  • Dosya adında arama — sunucu tarafında LIKE ... ESCAPE, 300 ms debounce, temizleme düğmesi
  • Izgara / liste görünümü anahtarı, tercih localStorage'da saklanır
  • Görsel önizleme penceresi — küçük resme tıklayınca tam boy, indirme bağlantısıyla birlikte
  • Açık / koyu tema anahtarı — `` içinde erken uygulanır, tema titremesi (FOUC) yok
  • Boş liste mesajı artık duruma göre değişir ("süzgeçlere uyan dosya yok" ↔ "henüz dosya yüklenmedi")
  • Alt bilgiye örnek kod kütüphanesi ve uygulama sayfası bağlantıları eklendi
  • Alt bilgide sürüm numarası gösterilir

Mobil

  • Dokunma hedefleri 44 px'e çıkarıldı (WCAG 2.5.5)
  • Özet şeridi 2×2 ızgara, dosya kartları 2 sütun (≤380 px'te tek sütun)
  • Süzgeçler satır satır ayrıldı; tür/klasör düğmeleri yatay kaydırmalı
  • Pencereler, alt bilgi ve bildirimler dar ekrana uyarlandı
  • @media (hover: none) ile dokunmatikte "takılı kalan" hover efektleri kapatıldı
  • theme-color üst verisi eklendi (mobil adres çubuğu rengi)

Erişilebilirlik

  • Küçük resimler klavyeyle de açılabilir (role="button" + Enter/Space)
  • Arama, sıralama ve görünüm anahtarlarına aria-label / `` bağları eklendi

Kod

  • APP_VERSION sabiti eklendi (system/config.php)
  • fetch_files() artık search süzgecini destekler; % ve _ jokerleri kaçırılır

1.0.0

  • İlk sürüm: katmanlı güvenlik savunması, tür/tarih klasörleme, indirme sayacı, ayarlar ekranı
  • Ölçülmüş ve kapatılan açıklar: Content-Disposition başlık enjeksiyonu, getimagesize() baypası, yükleme klasöründe eksik güvenlik başlıkları, CSRF reddinin 500 dönmesi

Lisans

MIT — dilediğiniz gibi indirip kullanabilirsiniz.

Telif © Çılgın Yazılım (cilginyazilim.com)

github.com/CilginYazilim/secure-file-upload · 📚 Örnek Kod Kütüphanesi

Kaynak Kod soldaki ağaçtan bir dosya seçin

  • assets
    • css
      • bootstrap.min.css 227.5 KB
      • cilginyazilim.css 19.4 KB
      • style.css 21.7 KB
    • images
      • ekran-ayarlar.png 347.9 KB
      • ekran-goruntusu.png 400.9 KB
      • logo.png 70.4 KB
    • js
      • bootstrap.bundle.js 203.2 KB
      • jquery-3.7.0.js 278.3 KB
      • upload.js 27.9 KB
  • ornek-dosyalar
    • ornek-arsiv.zip 328 B
    • ornek-belge.pdf 726 B
    • ornek-gorsel-1.png 8.7 KB
    • ornek-gorsel-2.jpg 25.7 KB
    • ornek-gorsel-3.webp 8.4 KB
    • ornek-gorsel-4.gif 65 KB
    • ornek-metin.txt 481 B
  • system
    • .htaccess 2.3 KB
    • ajax.php 10.2 KB
    • config.local.php.example 1.3 KB
    • config.php 11.8 KB
    • download.php 4.4 KB
    • function.php 29.1 KB
  • uploads
    • document
      • 2026
        • 08
          • 372c1b698c106b7e94500fb620d138f2.zip 328 B
          • 8f200574d676b6e71575b70f4e713ebd.txt 481 B
          • f4384d8cf06ccaa65b0e70da95f75036.pdf 726 B
    • image
      • 2026
        • 08
          • 2516fdb120e4253593217a923eac0580.jpg 25.7 KB
          • 88bcda734f1e0b2dbbd020672d592cb3.gif 65 KB
          • deada12df99150cb71bc731822d9b474.png 8.7 KB
          • edd9c20eba82e34909a96ef6483591c5.webp 8.4 KB
          • f75c61d2270acb1ba1f923c19da95c27.png 70.4 KB
        • 09
          • 611f185eec582e91a25d6e974d308a96.png 70.4 KB
    • .htaccess 3.1 KB
  • .gitignore 1.3 KB
  • .htaccess 2.7 KB
  • CHANGELOG.md 3.9 KB
  • cy_upload.sql 5.8 KB
  • index.php 22 KB
  • LICENSE 1.1 KB
  • README.en.md 32.4 KB
  • README.md 34.4 KB
Dosya seçilmedi
İncelemek istediğiniz dosyayı soldaki ağaçtan seçin.

Güvenlik gereği kaynak dosyalardaki parola, API anahtarı ve benzeri gizli değerler gösterilmeden önce maskelenir (••••••••).

Sık Sorulan Sorular

İstemcinin gönderdiği Content-Type başlığına bakılarak değil — o başlık saldırganın yazdığı bir metindir. Doğru yol finfo ile dosyanın ilk baytlarına (magic bytes) bakmaktır; bu, dosyanın içeriği gerçekten değiştirilmeden taklit edilemez. Bu örnekte tür her zaman içerikten tespit edilir ve diske yazılan uzantı da o türden türetilir.

Kullanıcının dosya adı hiçbir zaman diske yazılan adı belirlemez. Ad rastgele üretilir, uzantı ise tespit edilen MIME türünden türetilir. İçerik PDF ise diskte .pdf olur; .php uzantısı diske hiç ulaşmaz. Çift uzantı saldırısı bu satırda ölür.

Hayır. getimagesize dosyayı çözmez, yalnızca başlığını okur; sahte başlık taşıyan bir dosyayı geçirir. Bu örnekte görsel GD ile gerçekten çözümlenir (imagecreatefromstring); çözülemeyen dosya reddedilir ve polyglot dosyalar elenir. Ayrıca 50 megapikselin üzerindeki görseller, çözülürken yüzlerce megabayt bellek isteyeceği için çözülmeden önce reddedilir.

Beyaz liste. Kara liste ("şunlar yasak") her zaman eksiktir; unutulan tek bir uzantı sistemi çökertebilir. Beyaz liste ("yalnızca şunlar serbest") varsayılanı reddetmek yapar. Bu örnekte SVG bilerek listenin dışındadır: içine betik gömülebilen bir XML biçimidir. Ayarlardan tür eklense bile etkin liste her zaman katalog ile ayarların kesişimi olarak hesaplanır, yani ayarlara .php yazılsa dahi etkinleşmez.

Yükleme klasöründeki .htaccess dosyası o klasörde betik çalıştırmayı tamamen kapatır ve bu kural alt klasörlere de miras kalır. Buna ek olarak nosniff başlığı tarayıcının içeriğe bakıp "bu aslında HTML" demesini (MIME sniffing) engeller, CSP ikinci kemerdir ve indirme her zaman application/octet-stream ile attachment başlığı kullanır, yani dosya asla sayfa olarak render edilmez.

Yorumlar 0 konuşma

Bu kod örneğine henüz yorum yapılmamış. Takıldığınız bir yer veya merak ettiğiniz bir ayrıntı varsa ilk soruyu siz sorun.

Soru Sor veya Yorum Yaz

Yorumunuz onaylandıktan sonra yayınlanır. Teknik sorularınıza ekibimiz yanıt verir.

Yayınlanmaz; yalnızca yanıt bildirimi için kullanılır.
En az 10 karakter.

İlgili Kod Örnekleri