· Admin

Laravel'de S3 Mantığında Yerel Object Storage Sistemi

Laravel'de S3 Mantığında Yerel Object Storage Sistemi

Dosya yükleme her web uygulamasının temel ihtiyacı ama çoğu projede "dosyayı al, storage'a at, adını DB'ye yaz" seviyesinde kalıyor. Peki ya S3'ün sunduğu object ID, bucket, metadata ve presigned URL gibi özellikleri kendi sunucunuzda, Laravel ile kurabilseniz? Bu yazıda tam olarak bunu yapacağız: güvenlik katmanları, UUID tabanlı object yönetimi ve geçici erişim linkleri dahil.

Neden Kendi Object Storage'ınız?

S3 mükemmel bir servis ama her proje için gerekmez. Küçük-orta ölçekli projelerde, özellikle Türkiye'deki müşteriler için, "dosyalar yurtdışında" demek bazen sorun oluyor. KVKK hassasiyeti, bütçe kısıtlaması veya sadece basitlik istiyorsanız, S3 mantığını kendi sunucunuzda kurabilirsiniz.

Bu sistemin sunduğu özellikler:

  • UUID tabanlı Object ID: Dosyalara tahmin edilemez ID'lerle erişim
  • Bucket sistemi: Dosyaları mantıksal gruplara ayırma (user-uploads, invoices, avatars)
  • Metadata: Dosya bilgileri JSON olarak DB'de
  • Presigned URL: Geçici, süresi dolan indirme linkleri
  • Çok katmanlı güvenlik: MIME doğrulama, file signature kontrolü, malware tarama

Veritabanı: Migration

// database/migrations/xxxx_create_file_objects_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('file_objects', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->string('original_name');
            $table->string('stored_name');
            $table->string('mime_type', 100);
            $table->unsignedBigInteger('size');
            $table->string('extension', 10);
            $table->string('bucket', 50)->default('default');
            $table->string('disk', 20)->default('local');
            $table->string('path', 500);
            $table->boolean('is_public')->default(false);
            $table->json('metadata')->nullable();
            $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
            $table->timestamps();

            $table->index('bucket');
            $table->index('user_id');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('file_objects');
    }
};

Eloquent Model

// app/Models/FileObject.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Storage;

class FileObject extends Model
{
    use HasUuids;

    protected $fillable = [
        'original_name', 'stored_name', 'mime_type', 'size',
        'extension', 'bucket', 'disk', 'path', 'is_public',
        'metadata', 'user_id',
    ];

    protected $casts = [
        'metadata' => 'array',
        'is_public' => 'boolean',
        'size' => 'integer',
    ];

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    // Dosya boyutunu okunabilir formatta döndür
    public function getHumanSizeAttribute(): string
    {
        $bytes = $this->size;
        $units = ['B', 'KB', 'MB', 'GB'];

        for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
            $bytes /= 1024;
        }

        return round($bytes, 2) . ' ' . $units[$i];
    }

    // Tam dosya yolunu döndür
    public function getFullPathAttribute(): string
    {
        return Storage::disk($this->disk)->path($this->path);
    }

    // Presigned (geçici) URL oluştur
    public function temporaryUrl(int $minutes = 60): string
    {
        return URL::temporarySignedRoute(
            'file-objects.download',
            now()->addMinutes($minutes),
            ['fileObject' => $this->id]
        );
    }

    // Scope: Bucket'a göre filtrele
    public function scopeBucket($query, string $bucket)
    {
        return $query->where('bucket', $bucket);
    }

    // Scope: MIME type'a göre filtrele
    public function scopeOfType($query, string $type)
    {
        return $query->where('mime_type', 'like', $type . '/%');
    }
}

HasUuids trait'i Laravel 12'de dahili olarak geliyor. Her kayıt için otomatik UUID oluşturuyor, S3'ün object key mantığının aynısı.

Güvenlik Servisi

Dosya yüklemenin en kritik kısmı güvenlik. Kullanıcıdan gelen dosyaya asla güvenmeyin. Uzantısı .jpg olan bir PHP shell olabilir:

// app/Services/FileSecurityService.php
namespace App\Services;

use Illuminate\Http\UploadedFile;

class FileSecurityService
{
    // Dosya imzaları (magic bytes)
    private array $signatures = [
        'jpg'  => "\xFF\xD8\xFF",
        'jpeg' => "\xFF\xD8\xFF",
        'png'  => "\x89\x50\x4E\x47",
        'gif'  => "GIF8",
        'pdf'  => "%PDF-",
        'webp' => "RIFF",
        'zip'  => "PK\x03\x04",
    ];

    // Tehlikeli pattern'ler
    private array $dangerousPatterns = [
        '/<\?php/i',
        '/eval\s*\(/i',
        '/base64_decode\s*\(/i',
        '/gzinflate\s*\(/i',
        '/system\s*\(/i',
        '/shell_exec\s*\(/i',
        '/passthru\s*\(/i',
        '/exec\s*\(/i',
        '/<\?=/i',
    ];

    /**
     * Dosyayı tüm katmanlardan geçir
     */
    public function validate(UploadedFile $file): void
    {
        $this->checkMimeType($file);
        $this->checkFileSignature($file);
        $this->scanForMalware($file);

        if (str_starts_with($file->getMimeType(), 'image/')) {
            $this->validateImage($file);
        }
    }

    /**
     * Gerçek MIME type kontrolü (finfo ile)
     */
    private function checkMimeType(UploadedFile $file): void
    {
        $allowedMimes = config('filestorage.allowed_mimes', [
            'image/jpeg', 'image/png', 'image/gif', 'image/webp',
            'application/pdf',
            'application/msword',
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        ]);

        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $realMime = finfo_file($finfo, $file->getPathname());
        finfo_close($finfo);

        if (!in_array($realMime, $allowedMimes)) {
            throw new \InvalidArgumentException(
                "İzin verilmeyen dosya türü: {$realMime}"
            );
        }

        // Uzantı ile MIME type uyumu
        $declaredMime = $file->getMimeType();
        if ($realMime !== $declaredMime) {
            throw new \InvalidArgumentException(
                "Dosya içeriği ile uzantı uyuşmuyor"
            );
        }
    }

    /**
     * Dosya imzası (magic bytes) kontrolü
     */
    private function checkFileSignature(UploadedFile $file): void
    {
        $extension = strtolower($file->getClientOriginalExtension());

        if (!isset($this->signatures[$extension])) {
            return; // İmza tanımlı değilse atla
        }

        $handle = fopen($file->getPathname(), 'rb');
        $header = fread($handle, 8);
        fclose($handle);

        $expected = $this->signatures[$extension];
        if (!str_starts_with($header, $expected)) {
            throw new \InvalidArgumentException(
                "Dosya imzası geçersiz: {$extension}"
            );
        }
    }

    /**
     * Zararlı kod taraması
     */
    private function scanForMalware(UploadedFile $file): void
    {
        // 10MB üzeri dosyaları taramayı atla (performans)
        if ($file->getSize() > 10 * 1024 * 1024) {
            return;
        }

        $content = file_get_contents($file->getPathname());

        foreach ($this->dangerousPatterns as $pattern) {
            if (preg_match($pattern, $content)) {
                throw new \InvalidArgumentException(
                    'Potansiyel zararlı içerik tespit edildi'
                );
            }
        }
    }

    /**
     * Resim dosyasını doğrula ve yeniden oluştur
     */
    private function validateImage(UploadedFile $file): void
    {
        $imageInfo = getimagesize($file->getPathname());
        if ($imageInfo === false) {
            throw new \InvalidArgumentException('Geçersiz resim dosyası');
        }

        // Resmi GD ile yeniden oluştur (gömülü zararlı kodu temizler)
        $this->recreateImage($file->getPathname(), $imageInfo[2]);
    }

    /**
     * Resmi yeniden oluşturarak olası zararlı kodu temizle
     */
    private function recreateImage(string $path, int $type): void
    {
        $image = match ($type) {
            IMAGETYPE_JPEG => imagecreatefromjpeg($path),
            IMAGETYPE_PNG  => imagecreatefrompng($path),
            IMAGETYPE_GIF  => imagecreatefromgif($path),
            IMAGETYPE_WEBP => imagecreatefromwebp($path),
            default        => null,
        };

        if (!$image) {
            return;
        }

        match ($type) {
            IMAGETYPE_JPEG => imagejpeg($image, $path, 90),
            IMAGETYPE_PNG  => imagepng($image, $path, 9),
            IMAGETYPE_GIF  => imagegif($image, $path),
            IMAGETYPE_WEBP => imagewebp($image, $path, 90),
        };

        imagedestroy($image);
    }
}

Buradaki recreateImage() methodu kritik. Bir saldırgan JPEG dosyasının EXIF verisine veya comment alanına PHP kodu gömebilir. Resmi GD kütüphanesi ile yeniden oluşturduğunuzda tüm bu gömülü veriler temizlenir. Sadece piksel verisi kalır.

Object Storage Servisi

Ana servis sınıfı, S3 benzeri API'yi sağlıyor:

// app/Services/ObjectStorageService.php
namespace App\Services;

use App\Models\FileObject;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class ObjectStorageService
{
    public function __construct(
        private FileSecurityService $security
    ) {}

    /**
     * Dosya yükle ve FileObject döndür (S3 putObject gibi)
     */
    public function put(
        UploadedFile $file,
        string $bucket = 'default',
        bool $isPublic = false,
        array $metadata = [],
        string $disk = 'local'
    ): FileObject {
        // Güvenlik kontrollerinden geçir
        $this->security->validate($file);

        // Benzersiz dosya adı oluştur
        $extension = strtolower($file->getClientOriginalExtension());
        $storedName = Str::uuid() . '.' . $extension;
        $path = "objects/{$bucket}/{$storedName}";

        // Dosyayı diske yaz
        Storage::disk($disk)->putFileAs(
            "objects/{$bucket}",
            $file,
            $storedName
        );

        // Metadata kaydet ve döndür
        return FileObject::create([
            'original_name' => $file->getClientOriginalName(),
            'stored_name'   => $storedName,
            'mime_type'     => $file->getMimeType(),
            'size'          => $file->getSize(),
            'extension'     => $extension,
            'bucket'        => $bucket,
            'disk'          => $disk,
            'path'          => $path,
            'is_public'     => $isPublic,
            'metadata'      => $metadata,
            'user_id'       => auth()->id(),
        ]);
    }

    /**
     * Object bilgilerini getir (S3 getObject gibi)
     */
    public function get(string $objectId): ?FileObject
    {
        return FileObject::find($objectId);
    }

    /**
     * Dosyayı sil (S3 deleteObject gibi)
     */
    public function delete(string $objectId): bool
    {
        $object = FileObject::findOrFail($objectId);

        // Önce diskten sil
        Storage::disk($object->disk)->delete($object->path);

        // Sonra DB'den sil
        return $object->delete();
    }

    /**
     * Dosya içeriğini stream olarak döndür
     */
    public function stream(string $objectId)
    {
        $object = FileObject::findOrFail($objectId);

        return Storage::disk($object->disk)->readStream($object->path);
    }

    /**
     * Bucket içindeki dosyaları listele (S3 listObjects gibi)
     */
    public function list(string $bucket, int $perPage = 20)
    {
        return FileObject::bucket($bucket)
            ->latest()
            ->paginate($perPage);
    }

    /**
     * Dosyayı başka bucket'a taşı
     */
    public function move(string $objectId, string $newBucket): FileObject
    {
        $object = FileObject::findOrFail($objectId);

        $newPath = "objects/{$newBucket}/{$object->stored_name}";

        Storage::disk($object->disk)->move($object->path, $newPath);

        $object->update([
            'bucket' => $newBucket,
            'path'   => $newPath,
        ]);

        return $object->fresh();
    }

    /**
     * Dosyayı kopyala
     */
    public function copy(string $objectId, string $targetBucket = null): FileObject
    {
        $source = FileObject::findOrFail($objectId);
        $bucket = $targetBucket ?? $source->bucket;

        $newStoredName = Str::uuid() . '.' . $source->extension;
        $newPath = "objects/{$bucket}/{$newStoredName}";

        Storage::disk($source->disk)->copy($source->path, $newPath);

        return FileObject::create([
            'original_name' => $source->original_name,
            'stored_name'   => $newStoredName,
            'mime_type'     => $source->mime_type,
            'size'          => $source->size,
            'extension'     => $source->extension,
            'bucket'        => $bucket,
            'disk'          => $source->disk,
            'path'          => $newPath,
            'is_public'     => $source->is_public,
            'metadata'      => $source->metadata,
            'user_id'       => auth()->id(),
        ]);
    }
}

API'nin S3 ile benzerliğine dikkat edin: put, get, delete, list, move, copy. Yarın gerçek S3'e geçmek isterseniz sadece bu servisi değiştirmeniz yeterli, controller'a dokunmazsınız.

Controller

// app/Http/Controllers/FileObjectController.php
namespace App\Http\Controllers;

use App\Services\ObjectStorageService;
use Illuminate\Http\Request;

class FileObjectController extends Controller
{
    public function __construct(
        private ObjectStorageService $storage
    ) {}

    public function store(Request $request)
    {
        $request->validate([
            'file'   => 'required|file|max:10240', // 10MB
            'bucket' => 'sometimes|string|max:50|alpha_dash',
            'public' => 'sometimes|boolean',
        ]);

        $object = $this->storage->put(
            file: $request->file('file'),
            bucket: $request->input('bucket', 'default'),
            isPublic: $request->boolean('public', false),
        );

        return response()->json([
            'id'           => $object->id,
            'original_name'=> $object->original_name,
            'size'         => $object->human_size,
            'mime_type'    => $object->mime_type,
            'bucket'       => $object->bucket,
            'download_url' => $object->temporaryUrl(60),
        ], 201);
    }

    public function show(string $id)
    {
        $object = $this->storage->get($id);

        if (!$object) {
            return response()->json(['message' => 'Object bulunamadı'], 404);
        }

        return response()->json($object);
    }

    public function download(string $fileObject, Request $request)
    {
        // Signed URL doğrulaması route middleware'de yapılıyor
        $object = $this->storage->get($fileObject);

        if (!$object) {
            abort(404);
        }

        return response()->download(
            $object->full_path,
            $object->original_name,
            ['Content-Type' => $object->mime_type]
        );
    }

    public function destroy(string $id)
    {
        $this->storage->delete($id);

        return response()->json(['message' => 'Object silindi']);
    }
}

Route Tanımları

// routes/web.php
use App\Http\Controllers\FileObjectController;

Route::middleware('auth')->group(function () {
    Route::post('files', [FileObjectController::class, 'store']);
    Route::get('files/{id}', [FileObjectController::class, 'show']);
    Route::delete('files/{id}', [FileObjectController::class, 'destroy']);
});

// Presigned URL ile erişim (auth gerektirmez, signed URL yeterli)
Route::get('files/{fileObject}/download', [FileObjectController::class, 'download'])
    ->name('file-objects.download')
    ->middleware('signed');

signed middleware'i buranın kilit noktası. Laravel'in URL::temporarySignedRoute() ile oluşturduğu URL'ler imzalı ve süreli. İmza geçersizse veya süre dolmuşsa otomatik 403 döner. S3'ün presigned URL'inin aynı mantığı, sıfır ek kod ile.

Presigned URL Nasıl Çalışıyor?

S3'te presigned URL, dosyaya kimlik doğrulama olmadan geçici erişim sağlar. Laravel'de bunu signed route'lar ile yapıyoruz:

// Bir dosyanın 30 dakikalık indirme linkini oluştur
$object = FileObject::find('550e8400-e29b-41d4-a716-446655440000');
$url = $object->temporaryUrl(30);

// Sonuç: https://site.com/files/550e8400.../download?expires=1234567890&signature=abc123...

Bu URL'i e-posta ile gönderebilirsiniz, API response'ta döndürebilirsiniz. Alıcı login olmadan dosyayı indirebilir ama sadece 30 dakika içinde. Süre dolunca link ölür.

Blade'de Kullanım

<!-- Upload formu -->
<form action="/files" method="POST" enctype="multipart/form-data">
    @csrf
    <input type="file" name="file" required>
    <input type="hidden" name="bucket" value="user-documents">
    <button type="submit">Yükle</button>
</form>

<!-- Dosya listesi -->
@foreach($files as $file)
    <div class="flex items-center gap-4 p-3 border rounded">
        <div>
            <p class="font-medium">{{ $file->original_name }}</p>
            <p class="text-sm text-gray-500">
                {{ $file->human_size }} &middot; {{ $file->mime_type }}
            </p>
        </div>
        <a href="{{ $file->temporaryUrl(60) }}" class="text-blue-600">
            İndir (1 saat geçerli)
        </a>
    </div>
@endforeach

Gerçek S3'e Geçiş

Sistemin güzelliği, yarın gerçek S3'e geçmek istediğinizde değişikliğin minimal olması:

// .env
FILESYSTEM_DISK=s3

// ObjectStorageService::put() içinde disk parametresini değiştirin
$object = $this->storage->put(
    file: $request->file('file'),
    bucket: 'user-uploads',
    disk: 's3',  // local yerine s3
);

Storage facade'ı zaten S3 driver'ını destekliyor. Dosya yolları ve metadata DB'de kalmaya devam ediyor, sadece fiziksel depolama yeri değişiyor.

Güvenlik Checklist

Dosya yükleme sistemi kurarken bu listeyi kontrol edin:

  • MIME type kontrolü: finfo ile gerçek içerik tipini doğrulayın, kullanıcının gönderdiği Content-Type'a güvenmeyin
  • File signature: Dosyanın ilk birkaç byte'ını (magic bytes) kontrol edin
  • Image recreation: Resimleri GD ile yeniden oluşturup gömülü kodu temizleyin
  • Malware tarama: eval, base64_decode, shell_exec gibi pattern'leri arayın
  • Upload dizini koruması: Nginx/Apache'de PHP çalıştırmayı engelleyin
  • Dosya boyutu limiti: Hem PHP (upload_max_filesize) hem Laravel (max rule) tarafında
  • Tahmin edilemez dosya adları: UUID kullanın, orijinal adı sadece DB'de tutun
# Nginx: storage dizininde PHP çalıştırmayı engelle
location ~* /storage/.*\.php$ {
    deny all;
    return 404;
}

Bu yapı S3 kadar ölçeklenebilir olmasa da, çoğu proje için fazlasıyla yeterli. Önemli olan doğru soyutlama: bugün yerel diskte çalışsın, yarın S3'e geçiş tek satır değişiklikle olsun. Gerisi Laravel'in Storage facade'ına emanet.