
Ajans Web Sitesi

Kurucu ortağı olduğum web geliştirme ajansı için tasarladığım ve geliştirdiğim kurumsal web sitesi. Dijital çözümlerimizi ve hizmetlerimizi tanıtıyor.
Kendi ajansımızın sitesi, satmaya çalıştığımız işin kendisinin kanıtı olmak zorundaydı: bir müşteriye "iletişim formunuzu sertleştirelim" diyorsak kendi formumuzun da sertleştirilmiş olması gerekiyordu. Asıl mesele buradaki tek yazma yolunun — herkese açık iletişim ucunun — kimlik doğrulaması olmayan bir uç olmasıydı: oturum yok, dolayısıyla isteği kimin gönderdiğine dair sunucuda tutulacak bir bağlam da yok. Bot trafiği, çift gönderim, başlık enjeksiyonu ve tek bir kaynaktan gelen taşkın; hepsi bu tek uçta karşılanmak zorundaydı. İkinci mesele yasaldı: Almanya'ya satış yapan bir site DSGVO altında IP'yi kişisel veri olarak görüyor, ama oran sınırlama tam olarak IP'ye dayanıyor.
Üç dilde üç ayrı bağlam. Almanca ziyaretçi birincil hedef: Impressum, Datenschutz ve AGB sayfalarını arıyor, fiyat şeffaflığı bekliyor. Türkçe ziyaretçi hizmet kapsamına ve referanslara bakıyor. İngilizce ziyaretçi ikisinin arasında. Dördüncü kullanıcı biziz: gelen talebi e-posta kutusunda, spam'e boğulmadan görmek ve blog yazısını bir panele girmeden repoya commit ederek yayınlamak istiyoruz.
Tek geliştirici. Üç dilli bilgi mimarisi, yerelleştirilmiş rota tasarımı, markdown içerik hattı, iletişim ucunun güvenlik katmanları, e-posta şablonları, çerez onayına bağlı analitik, güvenlik header'ları ve dağıtım.
Next.js 16 App Router, veritabanı yok. İçerik iki kaynaktan geliyor: blog yazıları content/blog/<slug>/<locale>.md dosyalarından, portföy ve referans metinleri content/ altındaki JSON dosyalarından. İkisi de derleme zamanında okunuyor; JSON tarafı Zod ile doğrulanıyor ve bozuk bir yerel dosya safeParse ile boş kümeye düşerek tüm derlemeyi yıkmıyor. Markdown gray-matter ile ayrıştırılıp marked ile HTML'e çevriliyor, iki katmanlı sanitizasyondan geçtikten sonra basılıyor. Üç dil (de/tr/en) her rotada önekle yayınlanıyor ve yol adları dile göre yerelleştirilmiş: /leistungen, /hizmetler, /services aynı rotanın üç yüzü. Tek yazma yolu /api/contact; istek sırayla IP çıkarma ve özetleme, oran sınırlama, Zod doğrulama, başlık enjeksiyonu reddi, idempotency kontrolü, honeypot, CSRF ve Turnstile aşamalarından geçtikten sonra Resend'e iki e-posta bırakıyor. Üçüncü bir dönüşüm yolu olarak Cal.com randevu gömülüsü, yalnız ilgili ortam değişkeni tanımlıysa render ediliyor.
İlk sürümde limit süreç içi bir Map'te tutuluyordu. Serverless'ta bu limiti anlamsız kılıyor: her örnek kendi sayacını taşıdığı için yeterince paralel istek gönderen biri tavanı hiç görmüyor, üstelik Map konteyner ömrü boyunca büyümeye devam ediyordu. Sayaç Upstash Redis'e taşındı ve IP başına dakikada 5 istekle kayan pencere kuruldu. Upstash isteği hata verirse kapılar tamamen açılmıyor: istek bellekteki sayaca düşüyor ve o Map artık beş dakikada bir süresi dolmuş kayıtlardan temizleniyor.
ÖdünleşimHer form gönderimi artık dış bir servise fazladan bir ağ turu ödüyor ve site, kendi altyapısı ayakta olsa bile üçüncü bir sağlayıcıya bağımlı hâle geldi. Geri düşüş devreye girdiğinde koruma gerçek koruma değil: limit yine örnek başına kalıyor, yani en çok ihtiyaç duyulan anda en zayıf hâline dönüyor. Bir de taşınan yük var — geri düşüş yolunu canlı tutmak için süreç ömrü boyunca çalışan bir temizleme timer'ı ve iki ayrı kod yolu bakılmak zorunda.
IP burada yalnızca bir anahtar: oran sınırlama ve idempotency için aynı istemciyi tanımaya yarıyor, saklanması gereken bir bilgi değil. DSGVO'nun veri minimizasyonu ilkesi gereği IP, süreç başına üretilen bir tuzla SHA-256'dan geçiriliyor ve haritalara yalnız özet yazılıyor; ham değer isteğin kapsamından çıkmıyor. İkinci karar daha keskin: ne x-real-ip ne x-forwarded-for varsa istek 400 ile reddediliyor. Alternatif olan ortak bir "unknown" kovası, tek bir kötü aktörün başlıksız gelen herkesin limitini tüketmesine izin verirdi.
ÖdünleşimIP başlığı taşımayan meşru istemciler — bazı proxy zincirleri, sunucu tarafı istemciler, kimi test araçları — formu hiç gönderemiyor; hata mesajı da onlara ne yapacaklarını söylemiyor. Tuz süreç ömrüyle sınırlı olduğu için konteyner yenilendiğinde aynı IP yeni bir özet alıyor ve bellek geri düşüşündeki sayacı sıfırlanıyor: mahremiyet kazancının bedeli, tam da geri düşüş anında limitin sıfırlanabilmesi.
Blog yazıları repoya commit ediliyor, yani yazarlar güvenilir ve çıktı dangerouslySetInnerHTML ile basılıyor. Buna rağmen marked çıktısı önce ucuz bir regex geçişinden (script/style blokları, on* öznitelikleri, javascript: ve data:text/html URL'leri) sonra DOMPurify'dan geçiyor. Gerekçe tehdit modelinin yazar olmaması: hasmane bir pull request veya markdown'a HTML sızdıran, ele geçirilmiş bir bağımlılık, güvenilir yazar varsayımını tek adımda geçersiz kılar.
ÖdünleşimHer yazı render'ında iki ayrı geçiş çalışıyor ve isomorphic-dompurify sunucuda jsdom yüklüyor — küçük bir içerik hattı için hafif olmayan bir bağımlılık. Daha can sıkıcı bedel yazarlık tarafında: DOMPurify beyaz listesi dışındaki meşru işaretlemeyi (gömülü çerçeve, özel öznitelik) sessizce siliyor. Yazar HTML'inin neden kaybolduğuna dair hiçbir uyarı görmüyor, çıktıyı gözle kontrol etmek zorunda.
Sitede oturum yok, dolayısıyla token'ı karşılaştırmak için sunucuda tutulacak bir durum da yok. /api/csrf rastgele bir token üretip hem yanıt gövdesinde döndürüyor hem SameSite=Strict ve Secure bir çerezle yazıyor; form ikisini birden gönderiyor ve sunucu sabit zamanlı karşılaştırmayla eşleşmeyi doğruluyor. Farklı köken bir sayfa bu çerezi okuyamadığı için gövdedeki değeri uyduramıyor.
ÖdünleşimÇerezin JavaScript tarafından okunabilmesi gerektiği için HttpOnly olamıyor. Bu, katmanın sınırını da tanımlıyor: siteye bir XSS girerse token doğrudan okunabilir, yani bu mekanizma yalnız CSRF'i durduruyor, XSS'e karşı hiçbir şey ifade etmiyor. Ayrıca form gösterilmeden önce fazladan bir ağ isteği gerekiyor; o istek başarısız olursa kullanıcı formu hiç gönderemiyor ve gördüğü tek şey "sayfayı yenileyin" mesajı oluyor.
Kalıcı katman kurulmadı çünkü kurulmasını gerektiren tek bir ihtiyaç yoktu: içerik repoda, tek yazma yolu bir e-posta gönderimi. İçeriğin dosya sisteminde durması sürüm geçmişini ve dil paritesini git'in kendisine devrediyor — bir yazının üç dilinden biri eksikse bu, diff'te görünüyor. Upstash, tek bir sayaç için sunucu işletmeden dağıtık durum tutmanın en ucuz yolu olduğu için seçildi; kalıcı veri saklamıyoruz, yalnız kayan pencere. Zod hem iletişim isteğinin hem JSON içerik dosyalarının tek doğrulama kaynağı: birincisi çalışma zamanında 400 üretiyor, ikincisi derleme zamanında bozuk içeriği yakalıyor. Resend ve React Email, talebin zaten açık olan e-posta kutusuna düşmesini sağlıyor — kimsenin girmeyeceği bir panel yazmaktan iyisi.
On HTTP header'ı tanımlı: HSTS (iki yıl, alt alan adları dahil, preload uyumlu), çerçeveleme engeli, nosniff, referrer politikası, Permissions-Policy, X-Permitted-Cross-Domain-Policies, COOP, CORP ve CSP. CSP production'da enforce modunda; object-src 'none', base-uri ve form-action 'self', frame-ancestors 'none', img-src'de http şeması kapalı ve upgrade-insecure-requests açık — 'unsafe-eval' yalnız geliştirme ortamında veriliyor. İletişim ucunda katmanlar sırayla: tuzlanmış IP özeti, Upstash kayan pencere limiti, Zod şema doğrulaması (uzunluk sınırları dahil), CR/LF/NUL içeren alanların reddi — replyTo başlığına giden e-posta alanı için başlık kaçakçılığını kapatıyor — idempotency anahtarıyla çift gönderim kısa devresi, honeypot, double-submit CSRF ve Turnstile. Turnstile daha önce "geçici" olarak devre dışıydı ve token'ı göndermeyen her istemci CAPTCHA'yı atlayabiliyordu; anahtar tanımlıysa artık katı biçimde zorunlu. Metin alanları önce etiketlerden arındırılıp sonra HTML olarak kodlanıyor. Üçüncü taraf çağrıları AbortController ve Promise.race ile zaman aşımına bağlı, mutasyon yanıtları no-store, hata gövdeleri iç ayrıntı sızdırmıyor. Analitik yalnız çerez onayı verildikten sonra yükleniyor.
Animasyon ve karusel paketleri optimizePackageImports ile ağaç sarsımına açıldı — lucide-react, framer-motion, lenis, Turnstile ve iki Embla paketi. /images ve /fonts altındaki değişmez varlıklara bir yıllık immutable önbellek header'ı verildi. İçerik JSON'ları süreç düzeyinde (dizin, dil) anahtarıyla bellekte tutuluyor: aynı dosya, dil başına on'dan fazla rotada okunuyordu. E-posta şablonları ve iki gönderim paralel çalışıyor, ardışık bekleme yok. @next/bundle-analyzer ayrı bir komut olarak repoda duruyor. Bu proje için saklanmış, tarih damgalı bir Lighthouse çıktısı bulunmadığından buraya sayfa hızı skoru yazılmadı.
Turnstile'ı en baştan katı modda açardım. "Geçici olarak" yorum satırına alınan bir güvenlik kontrolü geçici kalmıyor; formun birincil bot savunması, kimsenin fark etmediği bir süre boyunca istemcinin isteğine bağlı çalıştı. Bir kontrolü gevşetmek gerekiyorsa bunun bir son kullanma tarihi olmalı. İkinci olarak oran sınırlamanın geri düşüşünü yeniden düşünürdüm: bellek Map'i, Upstash düştüğünde koruma sağlıyormuş hissi veriyor ama serverless'ta vermiyor — Bergaz Gıda'da aynı sorunu limitleri veritabanına yazarak çözmüştüm, çünkü orada zaten bir veritabanı vardı. Burada doğru cevap muhtemelen geri düşüşte fail-open değil fail-closed davranmak: Upstash yoksa formu geçici olarak kapatmak, korumasız açık tutmaktan iyi. Üçüncüsü içerik tarafında: blog markdown'ı Zod'dan geçen JSON'un aksine doğrulanmıyor, frontmatter alanları boş string'e düşüyor. Bu portfolyoda proje içeriğini MDX frontmatter'ında şemayla doğrulamak derlemeyi kırarak hatayı anında gösteriyor; aynı disiplin blog tarafında da olmalıydı.
Ne yapmak istediğinizi anlatın; ne kadar süreceğini ve nereden başlanacağını baştan söylerim.