Yunus Emre Alpak

Bir Linkten Fazlası — Paylaşım, Sürümler ve Olaylar · Vault Serisi 3/3

2026 · 03 · 319 dk okuma

Bir kasanın içi ne kadar güvenli olursa olsun, dış dünyaya açılan tek bir kapısı vardır: paylaşım linki. Ve o link, bir kasanın en zayıf ya da en güçlü yanı olabilir. Vault'ta asıl zihinsel sıçrama şuydu: bir paylaşım linki bir URL değil, süresi, sınırı ve ölümü olan canlı bir nesne.

Serinin üçüncüsü ve sonu. İlk iki yazıda "neden bir kasa" ve "çekirdek mimari" vardı. Şimdi Vault'un dış dünyayla konuştuğu yüze geliyoruz: paylaşım, sürümler, ve onu canlı hissettiren şey — olaylar.

Paylaşım linki bir URL değil, canlı bir nesne

Vault'un dış dünyaya açılan tek kapısı paylaşım linki. Ve bir kasada bu kapının tasarımı, içerideki şifrelemeden daha az kritik değil — çünkü saldırı yüzeyinin tam ortası.

İlk versiyonda token'lar 48 karakterdi — güvenli ama çirkin, paylaşılması zor. Sonra 8 karaktere indirdim ve kısa bir alan adına taşıdım (s.tepvox.com). Buradaki denge ince: token ne kadar kısaysa, kaba kuvvetle tahmin edilme riski o kadar artar. Ama token tek başına bir yetki değil — arkasında süre sınırı, indirme sayısı sınırı, opsiyonel parola ve iptal edilebilirlik var. Yani 8 karakter "zayıf" değil; kısa ömürlü ve sınırlı olduğu için kısa olabiliyor.

Her paylaşım linkinin üç ölüm şekli var:

Link "canlı bir nesne" çünkü bu üç koşuldan herhangi biri onu öldürebiliyor. Ve öldüğünde, opsiyonel olarak ardındaki dosyayı da götürebiliyor — buna birazdan geleceğim.

Bir linkte birden çok dosya: bundle paylaşımları

En sık ihtiyaç şuydu: aynı uygulamanın APK ve IPA'sını tek linkte göndermek. Önce her dosya için ayrı link üretiyordum, sonra bu saçmalığı düzelttim: bir paylaşım linki artık bir dosya demeti taşıyabiliyor. Junction tablosu (share_link_files) üyeliği tutuyor; link tek bir sayfada her dosya için ayrı bir kart, ayrı ikon, ayrı indirme düğmesi gösteriyor.

Burada güvenlik açısından tek bir kuralı servis katmanında sertçe uyguladım: bir demetteki tüm dosyalar aynı projeye ait olmalı.

if i == 0 {
    projectID = f.ProjectID
} else if f.ProjectID != projectID {
    return nil, fmt.Errorf("all files in a bundle must belong to the same project")
}

Neden? Çünkü paylaşım linki yetki sınırını taşıyan bir nesne. Farklı projelerden dosyaları aynı demete koymak, projeye dayalı erişim modelinde sessiz bir delik açardı. max_downloads da demet geneli — her dosya indirme toplama sayılıyor. Tek sınır, tüm demet.

Ve paylaşım sayfası sadece bir indirme düğmesi değil: APK/IPA dosyalarından uygulama metadata'sı (paket adı, sürüm, ikon) çıkarılıyor, bir QR kod üretiliyor, proje adı gösteriliyor. Testçiye gönderdiğin şey ham bir dosya değil, kendini açıklayan bir indirme deneyimi.

Aynı isim, yeni sürüm: değişmezlik disiplini

Bir kasada "üzerine yaz" tehlikeli bir kelime. O yüzden Vault hiçbir şeyin üzerine yazmıyor.

Aynı projeye aynı isimde bir dosya yüklersen, eskisini ezmek yerine yeni bir sürüm oluşturuyor. files tablosunda version_group_id, version_number, is_latest kolonları bunu yönetiyor. Listeleme ve arama varsayılan olarak yalnızca is_latest = true dönüyor; eski sürümler kaybolmuyor, sadece geri çekiliyor.

Bir sürümü geri yüklemek (restore) eski MinIO nesnesinin üzerine yazmıyor — eski nesneyi işaret eden yeni bir sürüm yaratıyor. Yani depodaki her nesne değişmez (immutable); sürüm tablosu sadece hangi nesnenin "şu an güncel" olduğunu işaret eden bir katman. En güncel sürümü silersen, bir önceki sürüm otomatik olarak güncel konumuna terfi ediyor.

Ve önemli bir incelik: paylaşım linkleri sürüm grubuna değil, belirli bir sürüme (dosya kimliğine) bağlanıyor. Yani birine gönderdiğin link, sen yeni bir sürüm yüklesen bile tam o anki dosyayı göstermeye devam ediyor. Sürpriz yok.

Linkin ölümü dosyayı da götürebilir: retention

Bazen bir dosyanın var oluş sebebi tek bir paylaşımdır. Link ölünce dosyanın da gitmesini istersin. Bunun için linke bir bayrak ekledim: DeleteFilesOnExpiry. Link bittiğinde (süre, indirme sınırı ya da manuel iptal), demetteki dosyaların expires_at'i "şimdi" yapılıyor ve bir retention worker'ı — başka aktif bir paylaşım yoksa — dosyaları kalıcı olarak siliyor. Tek seferlik gönderimler için bu, "gönder ve unut"u gerçek anlamıyla mümkün kılıyor: dosya, işini bitirince kendiliğinden ortadan kalkıyor.

Token tarayıcıya hiç dokunmasın: BFF deseni

Web arayüzünü Next.js 16 (App Router) ile yazdım, ama bir kasanın web arayüzünde tek bir kural her şeyi belirledi: JWT token'ı asla tarayıcı JavaScript'ine düşmesin.

Bu yüzden klasik "token'ı localStorage'a koy" yaklaşımını reddedip BFF (Backend-For-Frontend) deseni kurdum. Token'lar httpOnly cookie'lerde (vault_access, vault_refresh) yaşıyor — JavaScript onları okuyamıyor bile. Tarayıcı hiçbir zaman Go API'siyle doğrudan konuşmuyor; her istek bir Next.js route handler'ından (/bff/proxy/*) geçiyor, token oraya cookie'den enjekte ediliyor, 401 alınırsa proxy sessizce refresh edip yeniden deniyor. Yükleme bile bu proxy üzerinden stream ediliyor.

Burada beni bir gün yakan bir tuzak vardı, not düşüyorum çünkü öğreticiydi: Next.js 16'da route handler bir istek gövdesini ileri taşırken proxyClientMaxBodySize varsayılanı 10 MB. Bunu aşmazsan 10 MB'ı geçen yüklemeler sessizce kırpılıyor — hata bile vermeden. 600 MB'lık bir kasada bu, fark edilmesi en zor türden bir böcek. next.config.ts'de bu değeri ezmek şart.

Her eylem bir olay: NATS'a yayın

Ve geldik Vault'u "canlı" hissettiren şeye. Vault'taki her anlamlı eylem — dosya yüklendi, silindi, link oluşturuldu, link açıldı, link iptal edildi — bir olaya dönüşüp NATS JetStream'e yayınlanıyor. Olay zarfı bilinçli olarak sade, CloudEvents'ten esinli:

type Event struct {
    ID        string                 `json:"id"`
    Source    string                 `json:"source"`    // her zaman "vault"
    Type      string                 `json:"type"`      // ör. "file.uploaded"
    Timestamp time.Time              `json:"timestamp"`
    Actor     string                 `json:"actor"`     // "user:yunus", "agent:claude-code", "system"
    Version   int                    `json:"v"`
    Data      map[string]interface{} `json:"data"`      // yalnızca metadata, asla sır içeriği
}

Bu tasarımın felsefesi tek bir cümlede: "Yapan kim, dinleyen kim — ayır."

Vault dosya yükler. Bunu yaparken kimin haberdar olacağını umursamaz — sadece "olduğunu söyler", yayınlar. Kimin dinlediği onu ilgilendirmez. Bu ayrım sayesinde yarın yeni bir tüketici eklemek istediğimde Vault'ta tek bir satır kod değişmiyor. Webhook tabanlı bir entegrasyon yapsaydım, her yeni dinleyici için Vault'un config'i değişecekti. Pub/sub modelinde gerekmiyor.

Bir kasa için kritik olan kısım şu: olaylar yalnızca metadata taşır, asla dosya içeriği ya da sır değeri taşımaz. Hatta public paylaşım erişimlerindeki IP adresleri bile anonimleştiriliyor (IPv4 → /24, IPv6 → /48). Olay akışı, kasanın gizlilik sözünü bozan bir arka kapı olmamalı.

Ve yayın best-effort: bir goroutine içinde async yapılıyor, istek yolunu asla bloklamıyor. NATS kapalıysa veya NATS_ENABLED=false ise, sessizce bir NoopPublisher'a düşüyor — Vault HTTP trafiğini servis etmeye devam ediyor. Stream'i (VAULT_EVENTS) Vault yaratmıyor bile; onu ops kuruyor. Vault sadece konuşan taraf. Kapanışta 5 saniyelik bir drain ile bekleyen yayınlara land etme şansı veriliyor.

Bir sistemin yan etkisi, başka bir sistemin besini

Bu yazıyı — ve seriyi — bir düşünceyle bitirmek istiyorum. Vault bu olayları şu an kimse için yayınlamıyor. Dinleyen yok. Bir boşluğa konuşuyor.

Ama bu bir israf değil, bir yatırım. Çünkü bir sistemin yan etkisi olarak ürettiği olaylar, ileride başka bir sistemin ana besini olabilir. Vault'un "şu dosya yüklendi", "şu link açıldı" diye ürettiği bu sade cümleler, bir gün "geçen hafta Vault'a ne yükledim?" sorusuna saniyeler içinde cevap verebilen bir şeyin temeli olabilir.

O şeyin adını henüz koymadım. Ama Vault'u event yayan bir sistem olarak tasarlama kararı, o gelecek için bugünden bırakılmış bir kapı. Olayları yayınlamak — kimse dinlemiyorken bile — ekosistemini, sen daha az kod yazarak büyütmenin en sessiz yoludur. Ama o, başka bir serinin hikâyesi.

Seriyi bitirirken

Üç yazı oldu. Bir rahatsızlıkla başladık — sırların gidecek bir yeri yoktu. Üç sütun kurduk — şifreli, yetkili, izlenebilir. Çekirdeği olabildiğince küçük tuttuk — az parça, çok güven. Ve dış dünyaya tek bir kapıdan, kontrollü biçimde açıldık — bir linkten fazlası.

Asıl ders teknik değil. Şu: bir sırrı güvenle taşımak, onu şifrelemekten ibaret değil; onun nereye, ne kadar, kime, kaç kez gittiğini — ve bunu kimin bildiğini — baştan sona kontrol etmektir. Vault benim için bu kontrolün adı. Senin için belki başka bir şey olur. Önemli olan biçimi değil; bir sırrın gideceği yere senin kurallarınla gitmesi.