Proje: OkulUp API · Hub: OkulUp API — Decisions

S3 Bucket Public/Private Hiyerarşisi

Karar

okulup-dev bucket’ı iki üst düzey prefix’e ayrıldı:

public/    — bucket policy ile public-read, Storage::url() döner
private/   — public policy yok, Storage::temporaryUrl() (15 dk TTL) döner

Yeni yüklemelerin tamamı App\Support\StoragePath helper’ı üzerinden gidiyor; eski (root-level) path’ler olduğu yerde kalıyor ve geriye uyumluluk için yine public-read.

Path Eşlemesi

Public (public/ altı):

PathNe için
public/avatars/{userId}/Kullanıcı/öğrenci/veli avatar
public/events/{eventId}/cover/Etkinlik kapak görseli
public/galleries/{galleryId}/[thumbnails/]Onaylı galeri medya + video thumbnail
public/announcements/{announcementId}/Duyuru ekleri
public/meal-menus/{mealMenuId}/Yemek menüsü (PDF/xlsx)
public/assignments/{assignmentId}/Öğretmen ödev materyalleri

Private (private/ altı, presigned URL):

PathNe için
private/tracking/{studentId}/Öğrenci takip fotoğrafları
private/messages/{conversationId}/Özel mesaj ekleri
private/submissions/{assignmentId}/Öğrenci ödev teslimi
private/document-requests/{drId}/Belge talebi ekleri (transkript vs.)
private/payments/{paymentId}/receipts/Ödeme dekontları
private/exports/{userId}/CSV rapor export’ları (zaten backend route üzerinden indiriliyor)

Gerekçe

  • Bucket root’unda 12 farklı kategori karışık duruyordu; hangi prefix kim için belirsizdi. Tek okulup-dev bucket aynı zamanda kullanıcı içeriği ve hassas veri tutuyordu, IAM policy granülarite zordu.
  • “Public” / “private” ayrımı bucket policy seviyesinde işletilince kod sözleşmesinin doğru olduğu garanti edilir — yeni endpoint eklerken sadece doğru helper metodu seçmek yeterli, “bunu URL olarak dönmeli miyim?” diye düşünmek gerekmiyor.
  • Tracking/messages/submissions gibi gerçekten hassas veri presigned URL ile servis edilince link sızsa bile 15 dk içinde geçersiz olur.

Alternatifler (red)

  • İki ayrı disk (s3_public / s3_private) — Flysystem disk başına ayrı root. Daha “Laravel-vari” ama 30+ controller çağrısının disk seçimini değiştirmek + URL üretimini iki ayrı yere bağlamak external API surface’i karıştırırdı.
  • Tüm bucket private + her şey presigned — avatar ve duyuru gibi yüksek hacimli public içerikler her istekte presigned URL ister, mobil cache invalidate olur (URL her seferinde değişir), CDN tarafında cache yapılamaz.
  • Per-resource ACL (object_acl='public-read'/'private') — S3 ACL yerine bucket policy önerilir (AWS resmi tavsiye); ACL granülerliği bizim için gereksiz karmaşıklık.

Backward Compatibility

Karar: Mevcut dosyalar dokunulmuyor, kod hem eski hem yeni path’i bilir.

  • StoragePath::url($path): private/ prefix’i varsa temporaryUrl(), yoksa (eski root-level avatars/... dahil) url().
  • UserResource::resolveAvatarUrl() ve DirectRecipientResource::avatarUrl(): hem avatars/ hem public/avatars/ prefix’lerini tanır; başka bir şey ise (external URL, null) olduğu gibi döner.
  • Avatar silme (UserController::deleteAvatarFile): aynı iki prefix kuralı.

Etkilenen DB kolonları (mevcut data dokunulmadı):

  • users.avatar_url, events.cover_image_path, gallery_media.file_path/thumbnail_path, announcement_attachments.file_path/thumbnail_path, meal_menu_attachments.file_path, assignment_attachments.file_path, assignment_submissions.file_path, document_request_attachments.file_path, messages.attachment_path, tracking_entries.photo_path, payments.receipt_url, report_exports.file_path.

Yeni Bucket Policy (AWS Console’da güncellenecek)

Mevcut policy Resource: arn:aws:s3:::okulup-dev/* ile her şeyi public-read açıyor. Yeni policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadNewLayout",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::okulup-dev/public/*"
    },
    {
      "Sid": "PublicReadLegacyPaths",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": [
        "arn:aws:s3:::okulup-dev/avatars/*",
        "arn:aws:s3:::okulup-dev/events/*",
        "arn:aws:s3:::okulup-dev/galleries/*",
        "arn:aws:s3:::okulup-dev/announcements/*",
        "arn:aws:s3:::okulup-dev/meal-menus/*",
        "arn:aws:s3:::okulup-dev/assignments/*"
      ]
    }
  ]
}

private/* için açık bir statement YOK → default deny, sadece IAM kullanıcısı (presigned URL yoluyla) erişebilir. Bu policy ile eski submissions/... ve messages/... root-level dosyalar artık 403 olur; bu kabul edildi (dev environment, prod’a geçilmedi).

Legacy private cleanup (manuel, console’dan): Console’da bucket root’undaki submissions/, messages/, tracking/, document-requests/, payments/, exports/ klasörlerini silmek isterse silebilir. URL’leri dönmüyor olsa bile bucket’ta hassas dosya tutmak istemiyoruz.

IAM Policy (kullanıcı için)

Değişmedi:

s3:PutObject, s3:GetObject, s3:DeleteObject
Resource: arn:aws:s3:::okulup-dev/*

GetObject yetkisi IAM seviyesinde olduğu için presigned URL üretimi private/* için de çalışır (bucket policy explicit deny etmiyor, sadece “allow” eklemiyor).

CORS (Web uygulaması için ileride)

Mobile native presigned URL’i fetch ettiğinde CORS uygulanmaz. Ancak web tarayıcısından (React/Vue admin paneli) presigned URL fetch edilirse CORS gerekir. Şu an web admin S3 fetch yapmıyor; ileride yaparsa:

[{
  "AllowedHeaders": ["*"],
  "AllowedMethods": ["GET"],
  "AllowedOrigins": ["https://admin.okulup.com"],
  "MaxAgeSeconds": 3600
}]

Test Davranışı

Storage::fake('s3') lokal disk kullandığı için temporaryUrl() desteklemez. StoragePath::url() \RuntimeException yakalayıp url() fallback’ine geçiyor; testler bozulmuyor.

Etkilenen Dosyalar

Yeni:

  • app/Support/StoragePath.php — path sabitleri + url() helper

Controller’lar (12, hepsi ->store($path, 's3') çağrısında StoragePath::* kullanıyor):

  • UserController (avatar) — özel: deleteAvatarFile() helper iki prefix de tanır
  • EventController (cover), GalleryController (media + thumbnail), AnnouncementController (attachment), MealMenuController, AssignmentController (material + submission), DocumentRequestController, MessageController, TrackingController (store+update), PaymentController (receipt)
  • ClassController — student avatar URL helper güncellendi

Service:

  • ExportServiceexports/{userId}private/exports/{userId}

Resources (private → StoragePath::url() ki o da temporaryUrl):

  • MessageResource, AssignmentSubmissionResource, TrackingEntryResource, DocumentRequestAttachmentResource, PaymentResource
  • UserResource, DirectRecipientResource — avatar resolver hem eski hem yeni prefix’i tanır