Dosya Depolama Abstraction'ı: Local Disk, Azure Blob Storage ve S3; Presigned/SAS URL ile Doğrudan Yükleme
Dosyaları tek bir IFileStore arkasında tut: local diskte path'i iki kez doğrula, Azure Blob ve S3'te indirme ve yükleme için birkaç dakikalık SAS ya da presigned URL ver ve yüklenen dosyayı ancak "tamamla" adımında kontrol et, çünkü storage boyuta ve içeriğe bakmıyor. Bookstore'un faturaları ve kapakları Azurite ve RustFS ile denendi; metadata veritabanında kalıyor.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Azure.Storage.Blobs 12.30.1, AWSSDK.S3 4.1.0, Azurite 3.37.0 ve RustFS 1.0.5 ile derlenip denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) faturaları ve kitap kapaklarını şimdiye kadar local diskte tuttu. Bu yazıda aynı IFileStore'u Azure Blob Storage ve S3 (Amazon'un object storage servisi) üzerinde çalıştırıyoruz. Üç soru:
Birincisi: local implementasyon yolu Path.GetFullPath(Path.Combine(root, path)) ile kuruyor ve sonucun root ile başlayıp başlamadığına bakıyor. covers/../../files-backup/db.bak bu kontrolden geçer mi?
İkincisi: kapak yüklemesi için tarayıcıya 10 dakikalık bir SAS (Shared Access Signature; süresi ve yetkisi sınırlı erişim imzası) URL'i verdin. Kapak sınırı 10 MB. Tarayıcı bu URL'e 12 MB yükleyebilir mi?
Üçüncüsü: S3 istemcisinin ServiceURL'i http://127.0.0.1:52582. SDK'nın ürettiği presigned URL hangi şemayla başlar?
Cevaplar gerçek koşudan. İlki geçiyor: root .../files ise .../files-backup da aynı harflerle başlıyor. İkincisinde storage 201 dönüyor; SAS boyut sınırlamıyor. Üçüncüsünde URL https:// ile başlıyor ve istek TLS (HTTPS'in kullandığı şifreleme protokolü) handshake'inde düşüyor.
IFileStore'un Son Hâli
Beşinci yazıda faturayı bir kez üretip sakladık; indirme her seferinde API'den geçiyordu. Büyük dosyalarda ve yüksek trafikte bu, API'nin byte taşıması demek. Interface'e istemcinin doğrudan kullanacağı kısa ömürlü bir URL ekledik:
// Path'ler her backend'de göreli ve '/' ayrımlı anahtarlar: "invoices/2026/BKS-2026-001042.pdf"
public interface IFileStore
{
Task<StoredFile> SaveAsync(string path, Stream content, string contentType, CancellationToken cancellationToken = default);
// Path yoksa FileNotFoundException
Task<Stream> OpenReadAsync(string path, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string path, CancellationToken cancellationToken = default);
Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default);
// İstemcinin doğrudan indireceği kısa ömürlü URL; byte'lar API'den geçmez
Task<Uri> GetDownloadUrlAsync(string path, TimeSpan lifetime, string? downloadFileName = null, CancellationToken cancellationToken = default);
}
// Object storage tarayıcının yükleyeceği bir URL verebilir; local disk veremez
public interface IDirectUploadStore
{
Task<DirectUpload> CreateUploadAsync(string path, string contentType, TimeSpan lifetime, CancellationToken cancellationToken = default);
}
Doğrudan yükleme ayrı bir interface'te, çünkü local disk bunu yapamıyor. Her implementasyonu yapamadığı bir method'u implement etmeye zorlamak yerine, yeteneği DI'dan (dependency injection; bağımlılıkların dışarıdan verilmesi) soruyoruz. Hangi implementasyonun kullanılacağını configuration seçiyor: Notifications:FileStore:Provider değeri Local, AzureBlob ya da S3.
Local Disk ve Path Traversal
Path traversal, dışarıdan gelen bir değerin ../ ile dosya sisteminde izin verilen klasörün dışına çıkması. Bookstore'da path'leri kendi kodumuz kuruyor, ama içine ISBN (kitapların uluslararası standart numarası) ya da sipariş numarası gibi dışarıdan gelen değerler giriyor. İki savunma hattı kurduk. Birincisi, path'i dosya sistemine dokunmadan önce biçim olarak reddetmek:
// Düz bir göreli anahtar olmayan her şeyi dosya sistemine ya da URL'e ulaşmadan reddeder
public static string Validate(string path)
{
if (string.IsNullOrWhiteSpace(path) || path.Length > 512 || path.StartsWith('/') || path.Contains('\\') || path.Contains(':')
|| path.Any(char.IsControl) || path.Split('/').Any(segment => segment is "" or "." or ".."))
{
throw new ArgumentException($"'{path}' is not a valid storage path.", nameof(path));
}
return path;
}
İkincisi, birleştirilmiş tam yolun root'un altında kaldığını, sonunda ayırıcı karakter olan root ile kontrol etmek:
private readonly string _root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(options.Value.RootPath)) + Path.DirectorySeparatorChar;
private string Resolve(string path)
{
StoragePath.Validate(path);
// İkinci savunma hattı: string nasıl görünürse görünsün, son yol root'un altında kalmalı
string fullPath = Path.GetFullPath(Path.Combine(_root, path));
if (!fullPath.StartsWith(_root, StringComparison.Ordinal))
{
throw new ArgumentException($"Path '{path}' is outside the file store root.", nameof(path));
}
return fullPath;
}
Sondaki ayırıcısız StartsWith(root) kontrolüyle karşılaştırdık:
$ dotnet run -- traversal
invoices/2026/BKS-2026-001042.pdf naive check: passes LocalFileStore: accepted
../appsettings.json naive check: blocked LocalFileStore: rejected
covers/../../files-backup/db.bak naive check: passes LocalFileStore: rejected
/etc/passwd naive check: blocked LocalFileStore: rejected
invoices/./2026//x.pdf naive check: passes LocalFileStore: rejected
Üçüncü satır sık görülen hata: kontrol geçiyor ama dosya root'un yanındaki klasörde. /etc/passwd satırı da öğretici: Path.Combine, ikinci argüman köklü bir yolsa birinciyi tamamen yok sayıyor. Windows'ta \ da ayırıcı olduğu için Validate ters bölüyü her işletim sisteminde reddediyor; böylece aynı path Linux'ta dosya adı, Windows'ta klasör değiştirme olmuyor. Testlerde C:/Windows/win.ini ve covers\..\..\web.config da var.
Local disk imzalı URL üretemiyor; o işi API yapıyor. GetDownloadUrlAsync, path'i, son geçerlilik zamanını ve dosya adını HMACSHA256 ile, yani gizli bir anahtarla hesaplanan bir mesaj doğrulama koduyla imzalıyor ve /files/... endpoint'i imzayı sabit zamanlı karşılaştırmayla doğruluyor:
$ curl -s http://127.0.0.1:52560/api/orders/1042/invoice/link
{"url":"http://127.0.0.1:52560/files/invoices/2026/BKS-2026-001042.pdf?expires=1794726330&name=invoice-BKS-2026-001042.pdf&sig=<redacted>","expiresAt":"2026-11-15T07:05:30.446474+00:00"}
$ curl -s -o /dev/null -w 'GET -> %{http_code} %{size_download} bytes\n' "$URL"
GET -> 200 39870 bytes
$ curl -s -o /dev/null -w 'GET tampered -> %{http_code}\n' "${URL/001042/001043}"
GET tampered -> 403
İmzaları çıktılarda <redacted> ile kapattık; geçerli oldukları süre boyunca bir parola gibiler ve log'a yazılmamalılar.
Azure Blob Storage ve Azurite
Azurite, Microsoft'un Blob, Queue ve Table için yerel emulator'ü. Varsayılan hesapla docker run -p 10000:10000 mcr.microsoft.com/azure-storage/azurite azurite-blob --blobHost 0.0.0.0 yeterli ve connection string UseDevelopmentStorage=true; bu kısayol Azurite'ın dokümante edilmiş geliştirme anahtarını kullanıyor. Biz başka bir port kullandığımız ve iyi bilinen anahtarı hiçbir yere yazmak istemediğimiz için Azurite'ı AZURITE_ACCOUNTS ile kendi ürettiğimiz bir anahtarla, bookstoredev adlı bir hesapla başlattık ve connection string'i environment variable olarak verdik. SDK 12.30 istekleri 2026-10-06 API sürümüyle gönderiyor; kullandığımız Azurite 3.37.0 bu sürümü tanımadığı için ilk istekte The API version 2026-10-06 is not supported by Azurite hatasını verdi ve --skipApiVersionCheck bayrağı gerekti.
Kaydetmek ve okumak SDK'nın doğrudan karşılığı. Asıl iş SAS üretmekte:
private BlobSasBuilder NewSas(string path, TimeSpan lifetime, BlobSasPermissions permissions)
{
DateTimeOffset now = time.GetUtcNow();
BlobSasBuilder sas = new(permissions, now + lifetime)
{
BlobContainerName = container.Name,
BlobName = StoragePath.Validate(path),
Resource = "b",
// Geride çalışan saatler için birkaç dakika tolerans
StartsOn = now.AddMinutes(-5),
};
return sas;
}
private async Task<Uri> SignAsync(string path, BlobSasBuilder sas, CancellationToken cancellationToken)
{
BlobClient blob = container.GetBlobClient(path);
if (blob.CanGenerateSasUri)
{
// Client hesap anahtarıyla kurulmuş (Azurite ya da Key Vault'tan gelen anahtar)
return blob.GenerateSasUri(sas);
}
// Managed identity / Entra ID: servisin verdiği bir anahtarla imzalanan user delegation SAS
BlobServiceClient service = container.GetParentBlobServiceClient();
UserDelegationKey key = await service.GetUserDelegationKeyAsync(sas.StartsOn, sas.ExpiresOn, cancellationToken);
BlobUriBuilder builder = new(blob.Uri) { Sas = sas.ToSasQueryParameters(key, service.AccountName) };
return builder.ToUri();
}
İndirme URL'i yalnız okuma (sp=r) yetkisi taşıyor ve ContentDisposition ile tarayıcıya dosya adını söylüyor. Production'da hesap anahtarı yerine managed identity kullanıyorsan ikinci dal çalışıyor: SAS'ı hesap anahtarı değil, Entra ID'nin verdiği ve en fazla 7 gün geçerli bir user delegation key imzalıyor. Bu dalı Azurite'ta denemedik; yalnız derlendi. StorageLab ile Azurite'a karşı tam tur:
$ dotnet run -- roundtrip invoice.pdf
store: AzureBlobFileStore
saved invoices/2026/BKS-2026-001042.pdf (39,870 bytes) in 22 ms, exists=True
url: http://127.0.0.1:52581/bookstoredev/bookstore/invoices/2026/BKS-2026-001042.pdf?sv=2026-10-06&st=2026-11-15T06%3A54%3A56Z&se=2026-11-15T07%3A04%3A56Z&sr=b&sp=r&rscd=attachment%3B+filename%3Dinvoice-BKS-2026-001042.pdf&sig=<redacted>
GET url -> 200, 39,870 bytes, same=True, attachment; filename=invoice-BKS-2026-001042.pdf
GET other invoice with the same signature -> 403
GET after expiry -> 403
delete -> True, delete again -> False
Path'in tek karakterini değiştirmek imzayı geçersiz kılıyor ve süresi dolan URL 403 dönüyor. Container private; SAS'sız aynı adres de 403 döndü. Storage hesabı, tier ve SAS türlerinin kavramsal tarafı Cloud Temelleri serisinin Blob Storage yazısında.
S3 ve S3 Uyumlu Sunucular
S3 tarafını yerelde denemek için ilk tercihimiz MinIO'ydu, ama Docker Hub'da minio/minio image'ı artık yok (15 Kasım 2026'da denedik). Aynı API'yi konuşan, Apache 2.0 lisanslı RustFS'i rustfs/rustfs:1.0.5 image'ıyla kullandık. AWS (Amazon'un bulut platformu) SDK'sına ServiceURL ve ForcePathStyle = true veriyoruz; erişim anahtarını SDK'nın varsayılan sırası environment variable'lardan okuyor. İlk koşu şöyle bitti:
url: https://127.0.0.1:52582/bookstore/invoices/2026/BKS-2026-001042.pdf?X-Amz-Expires=301&...&X-Amz-Signature=<redacted>
Unhandled exception. System.Net.Http.HttpRequestException: The SSL connection could not be established, see inner exception.
---> System.Security.Authentication.AuthenticationException: Cannot determine the frame size or a corrupted frame was received.
Kaydetme ve okuma http üzerinden çalışıyordu, ama presigned URL https ile üretildi. GetPreSignedUrlRequest.Protocol varsayılan olarak HTTPS ve ServiceURL'den çıkarılmıyor:
// Presigned URL'ler, ServiceUrl http olsa bile, aksi söylenmedikçe https üretilir
private Protocol UrlProtocol => options.Value.ServiceUrl?.StartsWith("http://", StringComparison.OrdinalIgnoreCase) == true ? Protocol.HTTP : Protocol.HTTPS;
public async Task<Uri> GetDownloadUrlAsync(string path, TimeSpan lifetime, string? downloadFileName = null, CancellationToken cancellationToken = default)
{
GetPreSignedUrlRequest request = new()
{
BucketName = Bucket,
Key = StoragePath.Validate(path),
Verb = HttpVerb.GET,
Expires = time.GetUtcNow().Add(lifetime).UtcDateTime,
Protocol = UrlProtocol,
};
request.ResponseHeaderOverrides.ContentDisposition = StoragePath.ContentDisposition(downloadFileName ?? Path.GetFileName(path));
return new Uri(await s3.GetPreSignedURLAsync(request));
}
Düzeltmeden sonra aynı tur S3'te de geçti: indirme 200 ve byte'lar aynı, başka fatura ve süresi dolmuş URL 403, ikinci silme False. S3'ün DeleteObject'i nesne olsa da olmasa da 204 döndüğü için DeleteAsync önce varlığa bakıyor. AWS'te imzalı URL'i hesap anahtarıyla değil, uygulamanın IAM (AWS'nin kimlik ve yetki servisi) rolüyle imzalarsın; SigV4 (AWS'nin istek imzalama yöntemi) ile imzalanmış bir presigned URL en fazla 7 gün geçerli.
Tarayıcıdan Doğrudan Yükleme
Kapak yüklemesi iki adımlı. Önce API, yalnız uploads/ altındaki tek bir anahtara yazma yetkisi veren 10 dakikalık bir URL dönüyor. Tarayıcı dosyayı doğrudan storage'a koyuyor. Sonra API'ye "bitti" diyor ve API yüklenen dosyaya ilk kez o zaman bakıyor. Bu, birinci yazıdaki asenkron akışın upload karşılığı. Azure'da yazma yetkisini Create seçtik:
public async Task<DirectUpload> CreateUploadAsync(string path, string contentType, TimeSpan lifetime, CancellationToken cancellationToken = default)
{
// Yalnız oluşturma: URL okuyamaz, listeleyemez, var olan bir blob'un üzerine yazamaz
BlobSasBuilder sas = NewSas(path, lifetime, BlobSasPermissions.Create);
Uri url = await SignAsync(path, sas, cancellationToken);
return new DirectUpload(url, "PUT", new Dictionary<string, string> { ["x-ms-blob-type"] = "BlockBlob", ["Content-Type"] = contentType }, sas.ExpiresOn);
}
İki backend'de aynı deneme; backend'i Notifications__FileStore__Provider environment variable'ı seçiyor:
$ dotnet run -- upload cover-dune.jpg
store: AzureBlobFileStore
PUT image -> 201 Created
PUT again with the same URL -> 403 This request is not authorized to perform this operation using this permission.
GET with the upload URL -> 403
PUT 12 MB of zeros -> 201 Created
$ dotnet run -- upload cover-dune.jpg
store: S3FileStore
PUT image -> 200 OK
PUT again with the same URL -> 200 OK
GET with the upload URL -> 403
PUT 12 MB of zeros -> 200 OK
İki önemli sonuç var. Birincisi, iki storage da 12 MB'ı kabul etti: SAS'ın da basit bir presigned PUT'un da boyut ya da içerik sınırı yok. İkincisi, S3'teki presigned PUT süresi dolana kadar tekrar kullanılabiliyor. Bu yüzden her yükleme, tahmin edilemeyen bir uploadId ile yeni bir anahtara gidiyor ve URL'in ömrü kısa. Kontrolleri "tamamla" adımında, yedinci yazıdaki CoverImageProcessor ile yapıyoruz:
$ curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:52560/api/books/9780441172719/cover/uploads/01a1de022a8070459c6d00b32e55516b/complete
{"format":"Jpeg","width":2400,"height":1600,"length":112704}
HTTP 202
$ curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:52560/api/books/9780441172719/cover/uploads/01a1de022b687eec8b7b6b5768d0ef3b/complete
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"detail":"The upload is larger than 10,485,760 bytes."}
HTTP 415
Reddedilen dosya staging alanından siliniyor; kabul edilen dosya covers/{isbn}/original.jpg'ye taşınıyor ve thumbnail işi queue'ya giriyor. Tarayıcının başka bir origin'e PUT atabilmesi için storage hesabında CORS (Cross-Origin Resource Sharing; tarayıcının başka bir adrese istek atmasına izin veren kurallar) kuralı gerekiyor. Geliştirme ortamında başlangıçta localhost:4200 için kuruyoruz ve Azurite'ın preflight cevabı Access-Control-Allow-Origin: http://localhost:4200 ve Access-Control-Allow-Headers: content-type,x-ms-blob-type döndü. bookstore-web tarafı şöyle olabilir:
// Çalıştırılmadı: bookstore-web'de kapak yükleme
uploadCover(isbn: string, file: File) {
return this.http.post<CoverUpload>(`/api/books/${isbn}/cover/uploads`, null, { params: { contentType: file.type } }).pipe(
switchMap(upload => this.http.put(upload.url, file, { headers: upload.headers }).pipe(map(() => upload.uploadId))),
switchMap(uploadId => this.http.post(`/api/books/${isbn}/cover/uploads/${uploadId}/complete`, null)));
}
API'ye token ekleyen bir HTTP interceptor varsa storage adresini hariç tut; yoksa kullanıcının token'ını başka bir host'a göndermiş olursun.
Metadata Veritabanında, Byte'lar Storage'da
Storage dosyanın kime ait olduğunu, hangi sipariş için üretildiğini ya da ne zaman silinmesi gerektiğini bilmiyor. Bu bilgiler veritabanında duruyor:
// Byte'lar dosya deposunda; onlar hakkında bildiklerimiz veritabanında
public sealed class StoredFileRecord
{
public required string Path { get; init; }
public required string Purpose { get; init; }
public required string ContentType { get; init; }
public long Length { get; init; }
public required string Sha256 { get; init; }
// İndirme URL'i alabilecek kişi, ör. siparişin müşterisi
public string? OwnerId { get; init; }
public DateTime CreatedAtUtc { get; init; }
}
$ sqlite3 bookstore.db 'select Path, Purpose, Length, substr(Sha256, 1, 16), OwnerId from StoredFiles'
covers/9780441172719/original.jpg|cover-original|112704|9391743a5d647222|
invoices/2026/BKS-2026-001042.pdf|invoice|39870|7cc12affe7311e53|ada@example.com
Sahiplik kontrolü link endpoint'inde bir kez yapılıyor; storage URL'inin kendisi kimlik taşımıyor ve elinde olan herkes süresi dolana kadar indirebiliyor. Bu yüzden indirme URL'leri 5 dakika. Sha256 değeri, aynı dosyanın iki kez yüklendiğini anlamaya ve bütünlük kontrolüne yarıyor. Silme iki adımlı: önce kaydı işaretle, sonra bir job storage'daki nesneyi sil. Ters sırada, veritabanı işlemi geri alınırsa kayıt var ama dosya yok kalır. Yaşam döngüsü kuralları (eski staging nesnelerini bir gün sonra silmek gibi) storage tarafında tanımlanabiliyor. Kullanıcıdan gelen dosyaları bir antivirüs taramasından geçirmek de bu adımın parçası; ClamAV gibi bir motor ya da storage sağlayıcısının malware taraması "tamamla" adımına eklenebilir.
Dosyayı veritabanında varbinary olarak tutmak küçük ve az sayıda dosyada makul. Ama veritabanı yedeği büyür, her indirme veritabanı bağlantısı tutar ve SAS gibi bir doğrudan erişim yolu olmaz. Karar vermeden önce dosya sayısını ve ortalama boyutunu ölç.
Üretimde Dikkat
Container'ı private bırak. Public bir container, tahmin edilebilir path'lerdeki (invoices/2026/...) bütün faturaları herkese açar.
SAS ve presigned URL'lerin ömrünü kısa tut ve log'a yazma. İndirme için dakikalar, yükleme için 10–15 dakika yeterli.
Production'da hesap anahtarı ya da uzun ömürlü erişim anahtarı yerine managed identity ve IAM rolü kullan; anahtar gerekiyorsa Configuration ve Secret Management serisindeki gibi bir secret store'dan gelsin.
Bağımlılık ağacını izle. Azure.Storage.Blobs 12.30.1 transitive olarak Apache.Arrow 22.1.0'ı getirdi ve lisans kontrolü kırıldı: lisans bir dosya olarak geldiği için araç tipini belirleyemedi. Dosyayı okuduk, Apache 2.0 olduğunu gördük ve sürüme bağlı bir override ekledik.
Saat farkı: SAS'ta StartsOn'u birkaç dakika geriye almak, saati geride çalışan istemcilerde "henüz geçerli değil" hatalarını önlüyor.
Sık Düşülen Tuzaklar
Public container ile "nasılsa adres tahmin edilemez" demek. Fatura numaraları ardışık.
Uzun ömürlü SAS. Bir e-postaya konan 30 günlük link, e-posta iletildiğinde 30 gün boyunca herkese açık bir dosya demek.
Doğrudan yüklemede kontrolü atlamak. Storage boyuta ve içeriğe bakmıyor; kontrol "tamamla" adımında yapılmazsa hiç yapılmamış olur.
Kendi root kontrolünü sondaki ayırıcı olmadan yazmak. files-backup örneği gibi.
Dosyayı veritabanında tutmaya boyutu ölçmeden karar vermek.
Küçük Bir Deneme
★☆☆ StorageLab traversal komutuna invoices/2026/%2e%2e/x.pdf ekle. LocalFileStore ne diyor? Bu değer bir URL'den gelseydi ASP.NET Core routing onu hangi aşamada decode ederdi?
★★☆ Azure'daki doğrudan yükleme URL'ine BlobSasPermissions.Write ekle ve aynı URL ile ikinci PUT'u tekrar dene. Sonuç neden değişiyor?
★★★ Staging alanında 24 saatten eski nesneleri silen bir job yaz ve StoredFileRecord'u silme için iki aşamalı hâle getir: önce DeletedAtUtc'yi doldur, sonra storage'dan sil. Job yarıda kesilirse ne olur?
Seriyi bir sonraki yazıda bitiriyoruz: bir sipariş verildiğinde faturayı üreten, storage'a yazan ve linkli onay e-postasını gönderen job zincirini Hangfire ile kuruyor, aylık Excel raporunu recurring job'a bağlıyor ve hepsini Mailpit ve Azurite ile bir integration test'te doğruluyoruz.