Bir yazının başlığını veritabanına kaydetmek ile kapağını yüklemek aynı işlem değil. Dosyanın boyutu, türü, adı, erişim adresi ve ömrü var. API’nin diski değiştiğinde görsel kayboluyorsa, veritabanı düzgün çalışsa bile blog eksik kalıyor. PortfolioBlog’da kullanıcı yüklemelerini bu yüzden Supabase Storage’a aldım; repo içindeki pixel art tasarım varlıkları ise frontend ile dağıtılmaya devam ediyor.
Bu yazı sitedeki gerçek akışa dayanıyor: yönetici tarayıcısı → kendi API’m → MediaService → Storage adapter’ı → public nesne adresi. Veritabanı bağlantısını ayrı PostgreSQL yazısında anlatıyorum. Supabase’i dosya deposu olarak kullanmam, yönetici girişini Supabase Auth’a taşıdığım anlamına gelmiyor.
Bucket’ı görünürlük kararıyla birlikte kurmak
Storage bölümünde portfolio-media adlı bucket kullanıyorum. Blog kapağı herkes tarafından görüntüleneceği için bucket public. Dosya sınırı 5 MiB; izin verilen içerik türleri image/png, image/jpeg ve image/webp. Aynı kuralları API’de de uyguluyorum. Bucket ayarı dış servisteki ikinci sınır; API doğrulamasının yerine geçen bir kontrol değil.
Public olması herkesin dosya yükleyebildiği anlamına gelmiyor. Görüntüleme herkese açık, yazma yalnız sunucu üzerinden. Buna karşılık bir yazıyı taslağa almak mevcut görsel URL’sini özel hâle getirmiyor: public nesnenin adresini bilen biri dosyayı okumaya devam edebilir. Kimlik belgesi veya özel dosya için bu tasarımı kullanmam; private bucket ve yetkili indirme/süreli URL akışı gerekir.
Ayarlar: URL başka, veritabanı parolası başka, API anahtarı başka
Supabase__Url=https://YOUR_PROJECT_REF.supabase.co
Supabase__SecretKey=YOUR_SERVER_SECRET
Supabase__Bucket=portfolio-media
Bu üç değer yalnız backend tarafından okunur. Supabase__Url proje kökü, SecretKey sunucu kimliği, Bucket nesnenin ait olduğu alan. Bağlantı dizesindeki SQL parolasını burada kullanmıyorum. Yeni sb_secret_... anahtarları JWT değildir; apikey başlığına yazılır. Eski service_role JWT anahtarı kullanılıyorsa Bearer başlığı da eklenir. Adapter bu iki biçimi ayırıyor; tarayıcı ikisini de almıyor.
Sorumlulukları küçük bir sözleşmede toplamak
namespace PortfolioBlog.Core.Abstractions.Interfaces;
public interface IMediaFileStore
{
Task<string> WriteAsync(ReadOnlyMemory<byte> content, string extension, CancellationToken cancellationToken);
string? LegacyUrl(string name);
}
Core yalnız dosya deposunun sözleşmesini biliyor. MediaService boyut ve içerik doğrulamasını yapıyor. Business/Providers/SupabaseStorageProvider HTTP çağrısını gerçekleştiriyor. WebApi controller/middleware ise kullanıcı yetkisini, CSRF’yi ve HTTP dosya girişini yönetiyor. Böylece depolama sağlayıcısı değiştiğinde profil doğrulama veya Angular formunun Supabase başlıklarını öğrenmesi gerekmiyor.
Dosya adına güvenmeden önce dosyanın kendisine bakmak
public const int MaximumBytes = 5 * 1024 * 1024;
// Inside the read loop: enforce the actual byte count, not just IFormFile.Length.
if (content.Length + read > MaximumBytes)
return Invalid();
// WebP additionally needs RIFF at offset 0 and WEBP at offset 8.
if (extension == ".webp" && !bytes.AsSpan(8, 4).SequenceEqual("WEBP"u8))
return Invalid();
Kullanıcının verdiği dosya adı yol olarak kullanılmıyor. Uzantı küçük harfe çevriliyor, jpeg jpg olarak birleştiriliyor, PNG/JPEG/WebP dışında yükleme reddediliyor. Beyan edilen uzunluk sıfır veya 5 MiB üstündeyse erken çıkılıyor. Yine de akış okunurken gerçek bayt sayısı tekrar sınırlandırılıyor; HTTP metadatasının doğru olduğunu varsaymıyorum.
PNG ve JPEG için dosya imzası, WebP için RIFF ve WEBP alanları kontrol ediliyor. Bu, rastgele bir HTML dosyasını .png diye gönderme gibi basit yanlışları yakalar; tam görüntü çözme, zararlı içerik taraması veya boyut/decompression-bomb denetimi değildir. İhtiyaç büyüdüğünde gerçek decoder ve kaynak sınırları ayrı eklenir. Mevcut kontrolün yaptığından fazlasını vaat etmek güvenliği artırmıyor.
Controller 6 MiB HTTP isteği sınırı kullanır; multipart zarfının da boyutu vardır. Dosyanın kendi sınırı MediaService içinde 5 MiB kalır. Bu iki değerin farklı olması bir çelişki değil. Tarayıcının aynı dosyayı üç kez seçmesi de kendiliğinden tek nesneye dönüşmez; burada içerik hash’iyle deduplication yapılmıyor.
HTTP adapter’ını kurmak ve yükleme yapmak
var client = new HttpClient(new SocketsHttpHandler
{
AllowAutoRedirect = false,
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
})
{
BaseAddress = new Uri(options.Url.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(30)
};
HttpClient uygulama ömründe tekrar kullanılıyor. BaseAddress’in sonunda slash bulunması göreli yolların doğru birleşmesi için önemli. Redirect takibini kapatıyorum; kimlik başlıklarının başka bir hedefe taşınmasını istemiyorum. Timeout ve CancellationToken ayrı işler yapar: biri çağrının süresini sınırlar, diğeri isteği çağıranın iptalini taşımasına izin verir.
Aşağıdaki dar örnek daha önce yetkilendirilmiş ve gerçek PNG içeriği doğrulanmış baytları bekler. Boyut/HTTPS/bucket adını kontrol eder; dosyanın bütün güvenlik akışı değildir. Asıl projedeki MediaService önkoşulunu atlayıp bunu anonim bir endpoint’e bağlamamak gerekir.
using System.Net.Http.Headers;
public static class StorageUpload
{
// Call after application authorization and content/size validation.
// The caller owns and reuses an HttpClient with an HTTPS project BaseAddress.
public static async Task<Uri> UploadValidatedPngAsync(
HttpClient client,
string bucket,
string serverKey,
ReadOnlyMemory<byte> bytes,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(client.BaseAddress);
if (client.BaseAddress.Scheme != Uri.UriSchemeHttps)
throw new ArgumentException("An HTTPS project URL is required.");
if (!System.Text.RegularExpressions.Regex.IsMatch(bucket, "^[a-z0-9][a-z0-9-]{0,62}$"))
throw new ArgumentException("Invalid bucket name.");
if (bytes.IsEmpty || bytes.Length > 5 * 1024 * 1024)
throw new ArgumentException("Invalid file size.");
var objectPath = $"uploads/{Guid.NewGuid():N}.png";
using var request = new HttpRequestMessage(HttpMethod.Post, $"storage/v1/object/{bucket}/{objectPath}");
request.Headers.Add("apikey", serverKey);
if (!serverKey.StartsWith("sb_secret_", StringComparison.Ordinal))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", serverKey);
request.Headers.Add("x-upsert", "false");
request.Content = new ReadOnlyMemoryContent(bytes);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Storage upload failed ({(int)response.StatusCode}).", null, response.StatusCode);
return new Uri(client.BaseAddress, $"storage/v1/object/public/{bucket}/{objectPath}");
}
}
GUID dosya adı kullanıcı yolunu nesne anahtarına karıştırmaz. x-upsert=false mevcut nesnenin üzerine yazmayı engeller. Yeni kapak yeni ad alır; tarayıcı/CDN aynı URL’de eski görseli tutuyor mu sorununu azaltır. Başarılı HTTP sonucu gelmeden URL döndürülmüyor. Hata durumunda dış servisin gövdesi veya anahtar kullanıcıya taşınmıyor; uygulamanın hata zarfı kullanılıyor.
Tarayıcı yalnız kendi API’sine yükler
// The administrator is already signed in on this same origin.
const sessionResponse = await fetch('/api/auth/session', {
credentials: 'same-origin'
});
if (!sessionResponse.ok) throw new Error('Cannot read session.');
const session = (await sessionResponse.json()).data;
const form = new FormData();
form.append('file', selectedFile);
const response = await fetch('/api/admin/media', {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-CSRF-TOKEN': session.csrfToken },
body: form
});
const result = await response.json();
if (!response.ok || !result.success) throw new Error('Upload failed.');
const imageUrl = result.data; // Persist this URL in the article with a separate save.
Örnek, adminin aynı origin üzerinde zaten oturum açtığını varsayıyor. Cookie HttpOnly olduğu için JavaScript’te okumaya çalışmıyorum; tarayıcı gönderiyor. CSRF token’ı oturum endpoint’inden alıp yazma isteğine ekliyorum. FormData kullanırken Content-Type başlığını elle vermiyorum; multipart boundary’yi tarayıcı üretir. Angular uygulamasında aynı iş tipli API servisi ve interceptor üzerinden yürür; bu fetch örneği HTTP sözleşmesini açıkça gösteriyor.
Dosya yüklenmesi ile yazının kaydedilmesi ayrı sonuçlar
Storage başarılı döndükten sonra kapak URL’si yazı nesnesine bağlanır ve admin kaydı ayrıca yapılır. PostgreSQL transaction’ı, daha önce tamamlanmış HTTP yüklemesini geri alamaz. Kaydetme 409/500 dönerse veya kullanıcı editörden çıkarsa nesne kullanılmadan kalabilir. Yükleme timeout olduğunda sunucu dosyayı kabul etmiş de olabilir; cevabın kaybolması ile işlemin yapılmaması aynı şey değil.
Bu projede kör otomatik POST tekrarı veya nesne temizleme worker’ı yok. İş hacmi gerektirirse sabit işlem kimliği, kayıtlı nesne metadatası ve referans kontrolüyle temizlik tasarlanır. Özellikle bir görsel birkaç yazıda kullanılıyorsa, bir yazıyı silmek dosyayı hemen silme gerekçesi değildir. Küçük blogda otomatik temizliğin yanlış dosyayı kaldırması, birkaç sahipsiz nesneden daha pahalı bir hata olabilir.
Eski adresler ve yedek bütünlüğü
Önceki /uploads/{guid}.png adresleri boşa düşmüyor. API yalnız izin verilen GUID ve uzantı desenini kabul ederek aynı nesnenin Storage URL’sine 301 yönlendiriyor; fiziksel yerel dosya sunmuyor. Yeni yüklemeler doğrudan HTTPS public URL alıyor. Eski adresin kendisini dosya yolu gibi birleştirmediğim için ../ ile başka dosyaya erişme yolu açılmıyor.
Geçiş sırasında iki eski resim aynı adla taşındı ve indirilen baytlar karşılaştırıldı. Veritabanı yedeğinin bu nesneleri içerdiğini varsaymadım. npm run db:backup, site.json, security-configuration.json ve uploads kopyalarını aynı yedek klasöründe topluyor. Anahtar dosyası ve içerik yedeği özel; public bucket’a yedek atmak burada yapılan bir işlem değil.
Kontrol ettiğim hata yolları
- Oturumsuz yükleme 401 alır; oturum olup CSRF yoksa yazma reddedilir. Public görsel okumak admin yetkisi istemez.
- Büyük dosya, izin verilmeyen uzantı veya yanlış imza Storage’a gönderilmeden reddedilir.
- Storage 5xx dönerse kullanıcıya başarılı URL verilmez. Test fixture’ı bu durumu gerçek bucket’a yazmadan üretir.
- Eski geçerli dosya adı 301 alır; geçersiz ad genel dosya okuyucusuna dönüşmez.
- Gerçek entegrasyonda API üzerinden yükleme ve indirilen dosyanın bayt eşleşmesi ayrıca kontrol edildi.
Dosya yüklemek bir POST çağrısından ibaret görünebilir. İşin anlaşılır kalmasını sağlayan, bu çağrıdan önce kimin neyi doğruladığı ve çağrıdan sonra hangi kaydın gerçekten kalıcı olduğunun belli olması.