04.06.20269 dk okuma5

Express.js Dosya Yönetimi Rehberi: Statik Görsellerden Güvenli Fatura İndirmeye


Express.js Dosya Yönetimi Rehberi: Statik Görsellerden Güvenli Fatura İndirmeye

Web uygulamalarında dosya sunumu iki temel senaryoya ayrılır: Herkesin erişebileceği genel (public) dosyalar ve sadece yetkili kullanıcıların görebileceği özel (private) dökümanlar.

Bu rehberde, statik dosyalarda sık yaşanan rota hatalarının çözümünden, HTTP başlıkları (headers) ile güvenli dosya indirme kurgusuna kadar tüm süreci adım adım inceleyeceğiz.

BÖLÜM 1: Herkese Açık (Statik) Dosyalar ve Rota Hatalarının Çözümü

Ürün görselleri, CSS ve frontend JavaScript kodları gibi herkesin erişimine açık dosyalar için express.static ara yazılımı (middleware) kullanılır. Ancak bu kurguda iki büyük hata ile karşılaşılır:

1. Kök Dizin (Root) Yanılgısı ve Çözümü

Express'te bir klasörü app.use(express.static('images')) şeklinde doğrudan dışarı açarsanız, Express bu klasörün içindeki dosyaları sanki ana dizindeymiş (root) gibi sunar. Yani resme ulaşmak için URL'de /images/resim.jpg değil, doğrudan /resim.jpg yazılması gerekir.

  • Çözüm (Path Prefix): Tarayıcıda /images ile başlayan isteklerin fiziksel images klasörüne gitmesi için ara yazılıma sanal bir yol filtresi eklenmelidir:

// app.js
// Tarayıcıdan gelen "/images/..." isteklerini fiziksel "images" klasörüne yönlendirir.
app.use('/images', express.static('images'));

BÖLÜM 2: Güvenli ve Özel Dosya Yönetimi (Fatura İndirme)

Müşteri faturaları, kişisel dökümanlar veya sistem raporları asla express.static ile sunulamaz. Aksi takdirde linki tahmin eden herkes başkasının verisine erişebilir.

1. Güvenli Mimari Kurulumu

  • Gizli Klasör: Faturalar projenin kök dizininde, dışarıya kapalı olan data/invoices/ klasöründe saklanır.

  • Rota Koruması: Doğrudan dosya linki vermek yerine, önünde kimlik doğrulama ara yazılımı (auth middleware) olan özel bir rota tanımlanır.

// routes/shop.js
const express = require('express');
const router = express.Router();
const shopController = require('../controllers/shop');
const isAuth = require('../middleware/is-auth'); // Oturum kontrolü

router.get('/orders/invoice/:orderId', isAuth, shopController.getInvoice);
module.exports = router;

2. HTTP Başlıkları (Headers) ile Tarayıcıyı Yönetmek

Dosyayı diskten fs.readFile ile okuyup sadece res.send(data) ile ham veri (Buffer) olarak gönderirsek tarayıcı bunun ne olduğunu anlayamaz; dosyayı uzantısız ve garip bir isimle indirir. Tarayıcıya rehberlik etmek için iki temel HTTP başlığı ayarlarız:

  • Content-Type: Tarayıcıya gelen verinin türünü söyler (Örn: application/pdf).

  • Content-Disposition: Dosyanın indirilmesini mi (attachment), yoksa tarayıcı içinde yeni sekmede açılmasını mı (inline) istediğimizi belirtir. Ayrıca filename parametresi ile dosyanın orijinal adını koruruz.

// controllers/shop.js
const fs = require('fs');
const path = require('path');

exports.getInvoice = (req, res, next) => {
    const orderId = req.params.orderId;
    const invoiceName = 'invoice-' + orderId + '.pdf';
    
    // İşletim sistemleri arası yol uyumluluğu için path.join kullanımı
    const invoicePath = path.join('data', 'invoices', invoiceName);

    // Dosyayı diskten okuma
    fs.readFile(invoicePath, (err, data) => {
        if (err) {
            // Dosya yoksa veya okunamazsa sistemi çökertme, merkezi hata yönetimine pasla (Operational Error)
            return next(err);
        }

        // 1. Veri türünü PDF olarak tarayıcıya bildir
        res.setHeader('Content-Type', 'application/pdf');
        
        // 2. Tarayıcıda aç (inline) ve orijinal ismini ata
        res.setHeader('Content-Disposition', 'inline; filename="' + invoiceName + '"');
        
        // NOT: Tarayıcıda açmak yerine direkt bilgisayara indirtmek isteseydik:
        // res.setHeader('Content-Disposition', 'attachment; filename="' + invoiceName + '"');

        // Ham veriyi (Buffer) gönder
        res.send(data);
    });
};

3. Kimlik Doğrulama Yetmez: IDOR (BOLA) Açığı ve Nesne Seviyesinde Yetkilendirme

Dosya indirme rotasının önüne bir kimlik doğrulama ara yazılımı (isAuth) koymak, güvenliği sağlamak için tek başına yeterli değildir. isAuth mekanizması bize yalnızca şu sorunun cevabını verir: "Bu istekte bulunan kişi sisteme kayıtlı, geçerli bir kullanıcımız mı?" (Authentication).


Ancak gözden kaçırılan ve sistemlerde devasa veri sızıntılarına yol açan asıl kritik soru şudur: "Evet, bu kişi bizim geçerli bir kullanıcımız; fakat talep ettiği spesifik dosya veya fatura gerçekten ona mı ait?" (Authorization).

Tehdit Senaryosu (IDOR / BOLA): Sisteme giriş yapmış meşru bir kullanıcının (Kullanıcı A) sipariş numarası 6a2b3c... olsun. Kullanıcı faturasını indirmek istediğinde istemci tarafı (React/Next.js) şu API uç noktasına (endpoint) istek atar: /orders/invoice/6a2b3c.

isAuth ara yazılımı token'ı veya oturumu doğrular. Ancak Kullanıcı A, tarayıcıdaki URL'i veya giden API isteğindeki bu benzersiz ID'yi manipüle ederek başka bir müşteriye ait olan 7x8y9z... değerini denerse ne olur? Eğer nesne seviyesinde bir mülkiyet kontrolü yapmadıysak, sunucu istek atan kişinin sisteme giriş yaptığını onaylar, veritabanından hedef siparişi çeker ve Kullanıcı B'nin gizli faturasını Kullanıcı A'ya teslim eder. Siber güvenlik literatüründe bu zafiyete IDOR (Insecure Direct Object Reference) veya BOLA (Broken Object Level Authorization) denir.

Bu açığı kapatmak için veritabanındaki sipariş nesnesinin sahibi ile o an isteği atan aktif kullanıcının benzersiz kimliklerini (ID) kod seviyesinde eşleştirmek zorundayız.

// controllers/shop.js
const path = require('path');
const Order = require('../models/order');

exports.getInvoice = async (req, res, next) => {
  try {
    const orderId = req.params.orderId;
    const userId = req.user._id;

    // 1. Siparişi veritabanından bul
    const order = await Order.findById(orderId);

    if (!order) {
      return res.status(404).send('Sipariş bulunamadı.');
    }

    // 2. Sipariş gerçekten bu kullanıcıya mı ait?
    if (order.user.toString() !== userId.toString()) {
      return res.status(403).send('Bu faturaya erişim yetkiniz yok.');
    }

    // 3. Dosya adını kullanıcıdan değil, sistemdeki güvenilir veriden üret
    const invoiceName = `invoice-${order._id}.pdf`;

    const invoicePath = path.join(
      __dirname,
      '..',
      'data',
      'invoices',
      invoiceName
    );

    // 4. Tarayıcıya dosyanın PDF olduğunu bildir
    res.setHeader('Content-Type', 'application/pdf');

    // 5. Tarayıcıda açmak için inline kullan
    res.setHeader(
      'Content-Disposition',
      `inline; filename="${invoiceName}"`
    );

    // 6. Dosyayı gönder
    res.sendFile(invoicePath);
  } catch (err) {
    next(err);
  }
};

Buradaki kritik güvenlik noktası, sadece kullanıcının giriş yapmış olmasını kontrol etmek değildir. Sunucu, istenen siparişin gerçekten o kullanıcıya ait olup olmadığını da doğrulamalıdır.

Bu nedenle önce orderId ile sipariş veritabanından bulunur. Ardından siparişin user alanı ile oturumdaki aktif kullanıcının id değeri karşılaştırılır. Eğer bu iki değer eşleşmiyorsa sunucu 403 Forbidden cevabı döner ve dosyayı asla göndermez.

Daha güvenli bir varyasyon olarak siparişi en baştan hem _id hem de user ile aramak da çok temizdir:

const order = await Order.findOne({
  _id: orderId,
  user: req.user._id
});

if (!order) {
  return res.status(404).send('Sipariş bulunamadı.');
}

Bu yaklaşımda başka kullanıcıya ait sipariş zaten bulunamaz. Güvenlik açısından güzel tarafı şu: saldırgan geçerli ama başkasına ait bir orderId denese bile sistem o siparişi “bu kullanıcıya ait kayıtlar” içinde aradığı için sonuç dönmez.


4. Performans ve Ölçeklenebilirlik: Büyük Dosyalarda Bellek Şişmesi ve Stream Çözümü

Dosya indirme süreçlerinde güvenliği ve yetkilendirmeyi sağlamak mimarinin yalnızca ilk adımıdır. Sürdürülebilir bir backend tasarımının diğer önemli ayağı ise sunucu kaynaklarını korumaktır.

Başka bir ifadeyle, sadece “bu dosyayı kim indirebilir?” sorusunu değil, “bu dosya sunucuyu yormadan nasıl indirilebilir?” sorusunu da cevaplamamız gerekir.

Geliştirme aşamasında küçük boyutlu test dosyalarıyla çalışırken bu problem çoğu zaman fark edilmez. Ancak uygulama production ortamına alındığında, büyük dosyalar ve eş zamanlı kullanıcı istekleri sunucuda ciddi bellek baskısı oluşturabilir.

Geleneksel Yöntemin Gizli Tehlikesi: fs.readFile

Önceki örneklerde dosya okuma işlemini fs.readFile ile yapmıştık. Bu yöntem küçük dosyalar için anlaşılır ve kullanılabilir görünür. Ancak çalışma mantığı gereği büyük dosyalarda risklidir.

fs.readFile şu şekilde çalışır:

  1. Node.js diske gider ve ilgili dosyayı bulur.

  2. Dosyanın tamamını tek seferde okur.

  3. Okunan dosya RAM üzerinde geçici bir Buffer olarak tutulur.

  4. Dosyanın tamamı belleğe yüklendikten sonra res.send(data) ile istemciye gönderilir.

Küçük PDF dosyaları veya birkaç yüz kilobaytlık görseller için bu yöntem genellikle sorun çıkarmaz. Fakat sisteminizin büyüdüğünü ve kullanıcıların 500 MB boyutunda raporlar, fatura arşivleri veya dokümanlar indirdiğini düşünelim.

Aynı anda 20 kullanıcının 500 MB boyutunda bir dosyayı indirmeye çalışması durumunda teorik bellek baskısı şu seviyeye çıkabilir:

  • 500 MB x 20 kullanıcı = 10 GB veri yükü

Bu senaryoda sunucu, dosyaların tamamını bellekte tutmaya çalıştığı için ciddi RAM tüketimiyle karşılaşır. Bu durum performans düşüşüne, isteklerin yavaşlamasına ve bazı durumlarda Node.js sürecinin bellek hatasıyla kapanmasına yol açabilir.

Bu nedenle büyük dosyaları servis ederken fs.readFile yerine stream tabanlı bir yaklaşım tercih edilmelidir.

Optimum Çözüm: Node.js Streams ve .pipe() Mekanizması

Büyük dosyaları RAM’e tamamen yüklemek yerine, onları küçük parçalar halinde okumak çok daha sağlıklı bir yaklaşımdır. Node.js stream yapısı tam olarak bunu sağlar.

Bu yöntemde dosya tek seferde belleğe alınmaz. Bunun yerine dosya küçük parçalar halinde okunur ve her parça hazır oldukça istemciye gönderilir.

Bu işlem için iki temel yapı kullanılır:

  • fs.createReadStream(): Dosyayı parça parça okumamızızı sağlayan bir Readable Stream oluşturur.

  • .pipe(): Okunan parçaları doğrudan Express’in response nesnesine aktarır.

Yani dosya diskten küçük parçalar halinde okunur ve bu parçalar bekletilmeden tarayıcıya gönderilir. Böylece dosyanın tamamını RAM’e yüklemek zorunda kalmayız.

Bu yaklaşımın bir diğer önemli avantajı da backpressure yönetimidir. Eğer istemcinin internet bağlantısı yavaşsa veya tarayıcı veriyi yavaş tüketiyorsa, Node.js dosyayı gereğinden hızlı okumaya devam etmez. Akış mekanizması, okuma ve yazma hızını dengeleyerek sunucu kaynaklarının daha kontrollü kullanılmasını sağlar.

Güvenli ve Ölçeklenebilir Stream Örneği

Aşağıdaki örnekte önce kullanıcının ilgili faturaya erişim yetkisi kontrol edilir, ardından dosya fs.createReadStream ile parça parça istemciye gönderilir.

// controllers/shop.js
const fs = require('fs');
const path = require('path');
const Order = require('../models/order');

exports.getInvoice = async (req, res, next) => {
  try {
    const orderId = req.params.orderId;

    // Sipariş yalnızca aktif kullanıcıya ait kayıtlar içinde aranır.
    // Böylece başka bir kullanıcının orderId değerini deneyen kişi
    // o siparişe erişemez.
    const order = await Order.findOne({
      _id: orderId,
      user: req.user._id
    });

    if (!order) {
      return res.status(404).send('Sipariş bulunamadı.');
    }

    const invoiceName = `invoice-${order._id}.pdf`;

    const invoicePath = path.join(
      __dirname,
      '..',
      'data',
      'invoices',
      invoiceName
    );

    res.setHeader('Content-Type', 'application/pdf');

    res.setHeader(
      'Content-Disposition',
      `inline; filename="${invoiceName}"`
    );

    const fileStream = fs.createReadStream(invoicePath);

    fileStream.on('error', err => {
      next(err);
    });

    fileStream.pipe(res);
  } catch (err) {
    next(err);
  }
};

Bu örnekte dikkat edilmesi gereken nokta, stream kullanımının güvenlik kontrolünün yerine geçmemesidir. Stream yalnızca dosyanın daha verimli gönderilmesini sağlar.

Doğru sıralama şu şekilde olmalıdır:

  1. Kullanıcı giriş yapmış mı?

  2. İstenen sipariş gerçekten bu kullanıcıya mı ait?

  3. Dosya yolu güvenli şekilde oluşturuldu mu?

  4. HTTP başlıkları doğru ayarlandı mı?

  5. Dosya stream ile istemciye gönderildi mi?

Sonuç

fs.readFile küçük dosyalar için basit bir çözüm olabilir. Ancak büyük dosyalar ve eş zamanlı indirme senaryolarında belleği gereksiz yere şişirir.

fs.createReadStream ve .pipe() kullanımı ise dosyayı parça parça okuyarak istemciye aktarır. Bu sayede sunucu, dosya boyutundan bağımsız olarak çok daha kontrollü bellek kullanımıyla çalışır.

Bu nedenle production ortamında büyük PDF, rapor, arşiv veya medya dosyalarını indirilebilir hale getirirken stream tabanlı yaklaşım tercih edilmelidir.

5. Production-Ready Entegre Controller Mimarisi

Hem kimlik doğrulama/yetkilendirme (IDOR) kontrollerini hem de Stream optimizasyonunu içeren, projenizin son haline ekleyebileceğiniz nihai getInvoice controller kurgusu şu şekildedir:

// controllers/shop.js
const fs = require('fs');
const path = require('path');
const Order = require('../models/order'); // Sipariş Mongoose modeli

exports.getInvoice = (req, res, next) => {
    const orderId = req.params.orderId;

    // 1. GÜVENLİK ADIMI: Veritabanından siparişi sorgula
    Order.findById(orderId)
        .then(order => {
            // Sipariş sistemde hiç yoksa 404 hatası fırlat
            if (!order) {
                const error = new Error('Talep edilen fatura bulunamadı.');
                error.statusCode = 404;
                return next(error);
            }

            // IDOR / BOLA Koruması: Siparişi veren ile istek atan kullanıcı eşleşiyor mu?
            // Mongoose ObjectId referansları .toString() ile string olarak kıyaslanır.
            if (order.user.userId.toString() !== req.user._id.toString()) {
                const error = new Error('Bu faturayı görüntülemek için yetkiniz yok.');
                error.statusCode = 403; // Forbidden
                return next(error); // Merkezi hata yönetimine pasla
            }

            // 2. HTTP BAŞLIKLARI: Tarayıcı ve Client Davranış Yönetimi
            const invoiceName = 'invoice-' + orderId + '.pdf';
            const invoicePath = path.join('data', 'invoices', invoiceName);

            res.setHeader('Content-Type', 'application/pdf');
            res.setHeader('Content-Disposition', 'inline; filename="' + invoiceName + '"');
            
            // Modern API-Client mimarileri (React/Next.js) için CORS başlık izni
            res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');

            // 3. PERFORMANS ADIMI: Stream & Pipe Yapılandırması
            // Dosyayı bütünüyle RAM'e yüklemiyor, bir okuma akışı (stream) başlatıyoruz
            const fileStream = fs.createReadStream(invoicePath);
            
            // Akış esnasında oluşabilecek anlık donanımsal/işletimsel hataları yakalıyoruz
            fileStream.on('error', (err) => {
                return next(err); 
            });

            // Okunabilir dosya akışını, yazılabilir HTTP yanıt akışına hatasız bağlıyoruz
            fileStream.pipe(res);
        })
        .catch(err => {
            // Veritabanı sorgu hatalarını yakala (Programmer Error)
            const error = new Error(err);
            error.statusCode = 500;
            return next(error);
        });
};