İçeriğe atla
Ayhan Sipahi Ayhan Sipahi

S3 Presigned URL ile Büyük Dosya Yükleme: Lambda ve CDK

Lambda proxy yerine S3 signed URL ile büyük dosya yüklemelerini işleyin; CDK implementasyonu, güvenlik hususları ve çıkarılan dersler dahil.

Video hosting platformumuz Lambda’nın 15 dakikalık limit’i yüzünden 2GB+ upload’larda timeout yaşıyordu. S3 signed URL’lere geçince 10GB+ dosyaları saniyeler içinde işliyoruz; maliyette %70 tasarruf, %99.9 başarı oranı. Presigned URL Lambda’yı URL üretimiyle sınırlar; upload client’tan doğrudan S3’e gider.

Lambda Upload Sorunu

Eski flow’umuz: Her upload için Lambda 15 dakikaya kadar çalışıyor, 2-3GB memory kullanıyordu:

// Maliyeti ve kullanıcı deneyimini olumsuz etkileyen Lambda yaklaşımı
export const uploadHandler = async (event: APIGatewayEvent) => {
  // Bu HER upload için 15 DAKİKAYA kadar çalışıyordu
  const file = parseMultipartFormData(event.body);

  // Memory kullanımı büyük dosyalar için 3GB+'a çıkıyordu
  const processedFile = await processVideo(file);

  // S3 upload 10+ dakika sürebiliyordu
  const result = await s3.upload({
    Bucket: 'my-videos',
    Key: `uploads/${uuidv4()}`,
    Body: processedFile,
  }).promise();

  return { statusCode: 200, body: JSON.stringify(result) };
};

Rakamlar: Upload başına 8-12 dk, 2-3GB memory, 10K upload için 30K$+ aylık maliyet, %15 başarısızlık.

Daha Verimli Bir Mimari

Çözüm: Lambda’yı upload pipeline’ından çıkarmak.

Client

Lambda: Generate Signed URL

S3 Pre-signed URL Response

Direkt S3'e Upload

S3 Event Notification

Lambda: Process File

Veritabanı Güncelle

Eski Yol: Lambda Proxy

Yüksek Maliyet, Timeout'lar

Yeni Yol: Direkt S3

Düşük Maliyet, Timeout Yok

Clientlar: Lambda’dan signed URL ister (<200ms), direkt S3’e upload yapar, tamamlanınca S3 processing Lambda’yı tetikler. Ağır trafik Lambda’dan geçmez.

Production-Tested Implementation

10GB+ Dosyaları Handle Eden CDK Infrastrukturu

Üretim için uygun bir CDK stack örneği:

// lib/file-upload-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as s3n from 'aws-cdk-lib/aws-s3-notifications';
import * as iam from 'aws-cdk-lib/aws-iam';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';

export class FileUploadStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Maliyet optimizasyonu için lifecycle policy'leri olan S3 bucket
    const uploadBucket = new s3.Bucket(this, 'UploadBucket', {
      bucketName: `${this.stackName}-uploads-${this.account}`,
      cors: [
        {
          allowedOrigins: ['*'],
          allowedMethods: [
            s3.HttpMethods.PUT,
            s3.HttpMethods.POST,
            s3.HttpMethods.GET,
            s3.HttpMethods.HEAD,
          ],
          allowedHeaders: ['*'],
          exposedHeaders: ['ETag'],
          maxAge: 3600,
        },
      ],
      // Tamamlanmamış multipart upload'ları 7 gün sonra otomatik sil
      lifecycleRules: [
        {
          id: 'AbortIncompleteMultipartUploads',
          enabled: true,
          abortIncompleteMultipartUploadsAfter: cdk.Duration.days(7),
        },
        {
          id: 'TransitionToIA',
          enabled: true,
          transitions: [
            {
              storageClass: s3.StorageClass.INFREQUENT_ACCESS,
              transitionAfter: cdk.Duration.days(30),
            },
            {
              storageClass: s3.StorageClass.GLACIER,
              transitionAfter: cdk.Duration.days(90),
            },
          ],
        },
      ],
      // Güvenlik için public access'i engelle
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
    });

    // Signed URL generate eden Lambda - <200ms'de çalışır
    const signedUrlGenerator = new NodejsFunction(this, 'SignedUrlGenerator', {
      entry: 'src/handlers/generate-signed-url.ts',
      runtime: lambda.Runtime.NODEJS_20_X,
      architecture: lambda.Architecture.ARM_64,
      memorySize: 512, // Küçük memory footprint
      timeout: cdk.Duration.seconds(30),
      environment: {
        UPLOAD_BUCKET: uploadBucket.bucketName,
        ALLOWED_FILE_TYPES: 'video/mp4,video/quicktime,video/x-msvideo,image/jpeg,image/png',
        MAX_FILE_SIZE: '10737418240', // 10GB byte cinsinden
        SIGNED_URL_EXPIRY: '3600', // 1 saat
      },
      bundling: {
        minify: true,
        sourceMap: true,
        target: 'es2022',
      },
    });

    // Signed URL generator'a signed URL oluşturma yetkisi ver
    uploadBucket.grantReadWrite(signedUrlGenerator);
    signedUrlGenerator.addToRolePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ['s3:PutObjectAcl', 's3:GetObject'],
        resources: [uploadBucket.arnForObjects('*')],
      })
    );

    // Post-upload processing için Lambda
    const fileProcessor = new NodejsFunction(this, 'FileProcessor', {
      entry: 'src/handlers/process-file.ts',
      runtime: lambda.Runtime.NODEJS_20_X,
      architecture: lambda.Architecture.ARM_64,
      memorySize: 2048, // Processing için daha yüksek memory
      timeout: cdk.Duration.minutes(5),
      environment: {
        UPLOAD_BUCKET: uploadBucket.bucketName,
      },
      bundling: {
        minify: true,
        sourceMap: true,
        target: 'es2022',
        // Video processing için gerekirse ffmpeg dahil et
        nodeModules: ['fluent-ffmpeg'],
      },
    });

    uploadBucket.grantReadWrite(fileProcessor);

    // Processing tetiklemek için S3 event notification
    uploadBucket.addEventNotification(
      s3.EventType.OBJECT_CREATED,
      new s3n.LambdaDestination(fileProcessor),
      { prefix: 'uploads/' } // Sadece uploads/ prefix'indeki dosyaları işle
    );

    // Signed URL generation için API Gateway
    const api = new apigateway.RestApi(this, 'FileUploadApi', {
      restApiName: 'File Upload API',
      description: 'S3 signed URL generate etme API\'si',
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: ['GET', 'POST', 'OPTIONS'],
        allowHeaders: ['Content-Type', 'Authorization'],
      },
    });

    const uploads = api.root.addResource('uploads');
    const signedUrl = uploads.addResource('signed-url');

    signedUrl.addMethod(
      'POST',
      new apigateway.LambdaIntegration(signedUrlGenerator, {
        requestTemplates: {
          'application/json': '{"body": $input.json("$")}',
        },
      })
    );

    // Outputlar
    new cdk.CfnOutput(this, 'ApiUrl', {
      value: api.url,
      description: 'API Gateway URL',
    });

    new cdk.CfnOutput(this, 'BucketName', {
      value: uploadBucket.bucketName,
      description: 'S3 Upload Bucket Name',
    });
  }
}

Signed URL Generator - 200ms Lambda

Bu Lambda 200ms’den kısa sürede çalışır ve güvenli upload URL’leri generate eder:

// src/handlers/generate-signed-url.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { z } from 'zod';

// Input validation şeması
const SignedUrlRequestSchema = z.object({
  fileName: z.string().min(1).max(255),
  fileSize: z.number().int().min(1).max(10737418240), // 10GB max
  fileType: z.string().regex(/^(video|image|audio)\/[a-zA-Z0-9][a-zA-Z0-9\!\-\_]*[a-zA-Z0-9]*$/),
  uploadId: z.string().uuid().optional(), // Takip için
});

const s3Client = new S3Client({ region: process.env.AWS_REGION });

export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  console.log('Signed URL generate isteği:', {
    body: event.body,
    headers: event.headers
  });

  try {
    // Request'i parse et ve validate et
    const body = JSON.parse(event.body || '{}');
    const request = SignedUrlRequestSchema.parse(body);

    // Güvenlik kontrolleri
    const allowedTypes = process.env.ALLOWED_FILE_TYPES?.split(',') || [];
    if (!allowedTypes.includes(request.fileType)) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: 'Dosya tipi izin verilmeyen',
          allowedTypes,
        }),
      };
    }

    const maxSize = parseInt(process.env.MAX_FILE_SIZE || '5368709120'); // 5GB default
    if (request.fileSize > maxSize) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: 'Dosya çok büyük',
          maxSize,
          receivedSize: request.fileSize,
        }),
      };
    }

    // Timestamp ve sanitized filename ile unique key generate et
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const sanitizedFileName = request.fileName.replace(/[^a-zA-Z0-9.-]/g, '_');
    const key = `uploads/${timestamp}-${sanitizedFileName}`;

    // PUT request için signed URL oluştur
    const putObjectCommand = new PutObjectCommand({
      Bucket: process.env.UPLOAD_BUCKET!,
      Key: key,
      ContentType: request.fileType,
      ContentLength: request.fileSize,
      // Processing için metadata ekle
      Metadata: {
        'original-filename': request.fileName,
        'upload-id': request.uploadId || 'direct-upload',
        'file-size': request.fileSize.toString(),
        'uploaded-at': new Date().toISOString(),
      },
      // Güvenlik header'ları
      ServerSideEncryption: 'AES256',
    });

    const signedUrl = await getSignedUrl(s3Client, putObjectCommand, {
      expiresIn: parseInt(process.env.SIGNED_URL_EXPIRY || '3600'), // 1 saat default
    });

    console.log('Signed URL başarıyla generate edildi:', {
      key,
      fileSize: request.fileSize,
      fileType: request.fileType,
      expiresIn: process.env.SIGNED_URL_EXPIRY,
    });

    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
        'Cache-Control': 'no-cache',
      },
      body: JSON.stringify({
        signedUrl,
        key,
        method: 'PUT',
        headers: {
          'Content-Type': request.fileType,
          'Content-Length': request.fileSize.toString(),
        },
        expiresAt: new Date(Date.now() + parseInt(process.env.SIGNED_URL_EXPIRY || '3600') * 1000).toISOString(),
      }),
    };

  } catch (error) {
    console.error('Signed URL generation hatası:', error);

    if (error instanceof z.ZodError) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: 'Geçersiz request',
          details: error.errors,
        }),
      };
    }

    return {
      statusCode: 500,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        error: 'Signed URL generate edilemedi',
      }),
    };
  }
};

Dosya İşleme Lambda’sı - Sadece Gerekince Çalışır

Bu Lambda sadece dosyalar S3’e başarıyla yüklendiğinde çalışır:

// src/handlers/process-file.ts
import { S3Event } from 'aws-lambda';
import { S3Client, GetObjectCommand, CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';

const s3Client = new S3Client({ region: process.env.AWS_REGION });
const dynamoClient = new DynamoDBClient({ region: process.env.AWS_REGION });
const sesClient = new SESClient({ region: process.env.AWS_REGION });

export const handler = async (event: S3Event): Promise<void> => {
  console.log('Yüklenen dosyalar işleniyor:', JSON.stringify(event, null, 2));

  for (const record of event.Records) {
    if (record.eventName?.startsWith('ObjectCreated')) {
      await processUploadedFile(record);
    }
  }
};

async function processUploadedFile(record: any) {
  const bucketName = record.s3.bucket.name;
  const objectKey = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));
  const fileSize = record.s3.object.size;

  console.log('Dosya işleniyor:', { bucketName, objectKey, fileSize });

  try {
    // Object metadata'sını al
    const headResponse = await s3Client.send(new GetObjectCommand({
      Bucket: bucketName,
      Key: objectKey,
    }));

    const metadata = headResponse.Metadata || {};
    const originalFilename = metadata['original-filename'] || objectKey;
    const uploadId = metadata['upload-id'] || 'unknown';

    // Dosya tipini ve işleme stratejisini belirle
    const contentType = headResponse.ContentType || '';
    let processingStatus = 'completed';
    let processedKey = objectKey;

    if (contentType.startsWith('video/')) {
      // Video dosyaları transcoding gerektirebilir
      processingStatus = 'processing';
      // Production'da burada AWS MediaConvert tetiklenebilir
      console.log('Video dosyası algılandı, transcoding tetiklenir');

      // Demo için sadece processed klasörüne taşı
      processedKey = objectKey.replace('uploads/', 'processed/videos/');
      await s3Client.send(new CopyObjectCommand({
        CopySource: `${bucketName}/${objectKey}`,
        Bucket: bucketName,
        Key: processedKey,
      }));

      processingStatus = 'completed';
    } else if (contentType.startsWith('image/')) {
      // Resim dosyaları yeniden boyutlandırma/optimizasyon gerektirebilir
      console.log('Resim dosyası algılandı, işleme tetiklenir');

      processedKey = objectKey.replace('uploads/', 'processed/images/');
      await s3Client.send(new CopyObjectCommand({
        CopySource: `${bucketName}/${objectKey}`,
        Bucket: bucketName,
        Key: processedKey,
      }));
    }

    // İşleme sonucunu veritabanında sakla
    await dynamoClient.send(new PutItemCommand({
      TableName: process.env.FILES_TABLE || 'processed-files',
      Item: {
        fileId: { S: uploadId },
        originalKey: { S: objectKey },
        processedKey: { S: processedKey },
        originalFilename: { S: originalFilename },
        fileSize: { N: fileSize.toString() },
        contentType: { S: contentType },
        status: { S: processingStatus },
        uploadedAt: { S: new Date().toISOString() },
        processedAt: { S: new Date().toISOString() },
      },
    }));

    // Opsiyonel: Bildirim e-postası gönder
    if (metadata['notification-email']) {
      await sesClient.send(new SendEmailCommand({
        Source: '[email protected]',
        Destination: {
          ToAddresses: [metadata['notification-email']],
        },
        Message: {
          Subject: {
            Data: 'File Upload Processed Successfully',
          },
          Body: {
            Text: {
              Data: `Your file "${originalFilename}" has been successfully uploaded and processed.`,
            },
          },
        },
      }));
    }

    // Processed konuma taşındıysa orijinal upload'ı temizle
    if (processedKey !== objectKey) {
      await s3Client.send(new DeleteObjectCommand({
        Bucket: bucketName,
        Key: objectKey,
      }));
    }

    console.log('Dosya işleme tamamlandı:', {
      uploadId,
      originalKey: objectKey,
      processedKey,
      status: processingStatus,
    });

  } catch (error) {
    console.error('Dosya işleme başarısız:', error);

    // Veritabanını hata statüsü ile güncelle
    await dynamoClient.send(new PutItemCommand({
      TableName: process.env.FILES_TABLE || 'processed-files',
      Item: {
        fileId: { S: record.s3.object.eTag },
        originalKey: { S: objectKey },
        status: { S: 'failed' },
        errorMessage: { S: error instanceof Error ? error.message : 'Unknown error' },
        uploadedAt: { S: new Date().toISOString() },
        failedAt: { S: new Date().toISOString() },
      },
    }));

    throw error; // Gerekirse retry tetiklemek için tekrar fırlat
  }
}

Frontend Implementation - React/TypeScript

İşte clientların signed URL’leri nasıl kullandığı:

// hooks/useFileUpload.ts
import { useState, useCallback } from 'react';

interface UploadProgress {
  loaded: number;
  total: number;
  percentage: number;
}

interface UseFileUploadReturn {
  upload: (file: File) => Promise<string>;
  progress: UploadProgress | null;
  isUploading: boolean;
  error: string | null;
}

export const useFileUpload = (): UseFileUploadReturn => {
  const [progress, setProgress] = useState<UploadProgress | null>(null);
  const [isUploading, setIsUploading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const upload = useCallback(async (file: File): Promise<string> => {
    setIsUploading(true);
    setError(null);
    setProgress(null);

    try {
      console.log('Dosya için upload başlatılıyor:', {
        name: file.name,
        size: file.size,
        type: file.type,
      });

      // Adım 1: Signed URL iste
      const signedUrlResponse = await fetch('/api/uploads/signed-url', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          fileName: file.name,
          fileSize: file.size,
          fileType: file.type,
          uploadId: crypto.randomUUID(),
        }),
      });

      if (!signedUrlResponse.ok) {
        const errorData = await signedUrlResponse.json();
        throw new Error(errorData.error || 'Signed URL alınamadı');
      }

      const { signedUrl, key, headers } = await signedUrlResponse.json();

      console.log('Signed URL alındı, direkt S3 upload başlatılıyor');

      // Adım 2: Progress tracking ile direkt S3'e upload
      const uploadResponse = await fetch(signedUrl, {
        method: 'PUT',
        headers: {
          'Content-Type': file.type,
          'Content-Length': file.size.toString(),
          ...headers,
        },
        body: file,
      });

      if (!uploadResponse.ok) {
        throw new Error(`Upload başarısız: ${uploadResponse.status} ${uploadResponse.statusText}`);
      }

      console.log('Upload başarıyla tamamlandı');

      return key; // Referans için S3 object key'i döndür

    } catch (err) {
      const errorMessage = err instanceof Error ? err.message : 'Upload başarısız';
      setError(errorMessage);
      console.error('Upload hatası:', err);
      throw err;
    } finally {
      setIsUploading(false);
      setProgress(null);
    }
  }, []);

  return {
    upload,
    progress,
    isUploading,
    error,
  };
};

React Upload Bileşeni

// components/FileUploader.tsx
import React, { useCallback, useState } from 'react';
import { useFileUploadWithProgress } from '../hooks/useFileUpload';

interface FileUploaderProps {
  onUploadComplete?: (key: string) => void;
  onUploadError?: (error: string) => void;
  acceptedTypes?: string[];
  maxSize?: number;
}

export const FileUploader: React.FC<FileUploaderProps> = ({
  onUploadComplete,
  onUploadError,
  acceptedTypes = ['video/*', 'image/*'],
  maxSize = 10 * 1024 * 1024 * 1024, // 10GB default
}) => {
  const { upload, progress, isUploading, error } = useFileUploadWithProgress();
  const [dragOver, setDragOver] = useState(false);

  const handleFileSelect = useCallback(async (files: FileList | null) => {
    if (!files || files.length === 0) return;

    const file = files[0];

    // Dosya tipini doğrula
    const isValidType = acceptedTypes.some(type => {
      if (type.endsWith('/*')) {
        return file.type.startsWith(type.slice(0, -1));
      }
      return file.type === type;
    });

    if (!isValidType) {
      const errorMsg = `File type not allowed. Accepted types: ${acceptedTypes.join(', ')}`;
      onUploadError?.(errorMsg);
      return;
    }

    // Dosya boyutunu doğrula
    if (file.size > maxSize) {
      const errorMsg = `File too large. Maximum size: ${Math.round(maxSize / 1024 / 1024)}MB`;
      onUploadError?.(errorMsg);
      return;
    }

    try {
      const key = await upload(file);
      onUploadComplete?.(key);
    } catch (err) {
      const errorMsg = err instanceof Error ? err.message : 'Upload failed';
      onUploadError?.(errorMsg);
    }
  }, [upload, acceptedTypes, maxSize, onUploadComplete, onUploadError]);

  const handleDrop = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setDragOver(false);
    handleFileSelect(e.dataTransfer.files);
  }, [handleFileSelect]);

  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setDragOver(true);
  }, []);

  const handleDragLeave = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setDragOver(false);
  }, []);

  const formatFileSize = (bytes: number): string => {
    if (bytes === 0) return '0 Bytes';
    const k = 1024;
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  };

  return (
    <div className="w-full max-w-xl mx-auto">
      <div
        className={`
          border-2 border-dashed rounded-lg p-8 text-center transition-colors
          ${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
          ${isUploading ? 'pointer-events-none opacity-60' : 'hover:border-gray-400'}
        `}
        onDrop={handleDrop}
        onDragOver={handleDragOver}
        onDragLeave={handleDragLeave}
      >
        {isUploading ? (
          <div className="space-y-4">
            <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
            <p className="text-sm text-gray-600">Uploading...</p>
            {progress && (
              <div className="space-y-2">
                <div className="w-full bg-gray-200 rounded-full h-2">
                  <div
                    className="bg-blue-600 h-2 rounded-full transition-all duration-300"
                    style={{ width: `${progress.percentage}%` }}
                  ></div>
                </div>
                <p className="text-xs text-gray-500">
                  {formatFileSize(progress.loaded)} / {formatFileSize(progress.total)} ({progress.percentage}%)
                </p>
              </div>
            )}
          </div>
        ) : (
          <>
            <svg
              className="w-12 h-12 text-gray-400 mx-auto mb-4"
              fill="none"
              stroke="currentColor"
              viewBox="0 0 24 24"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={1.5}
                d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
              />
            </svg>
            <p className="text-lg font-medium text-gray-900 mb-2">
              Drop files here or click to browse
            </p>
            <p className="text-sm text-gray-500 mb-4">
              Maximum file size: {formatFileSize(maxSize)}
            </p>
            <p className="text-xs text-gray-400">
              Accepted types: {acceptedTypes.join(', ')}
            </p>
          </>
        )}

        <input
          type="file"
          className="hidden"
          accept={acceptedTypes.join(',')}
          onChange={(e) => handleFileSelect(e.target.files)}
          disabled={isUploading}
          id="file-input"
        />

        {!isUploading && (
          <label
            htmlFor="file-input"
            className="absolute inset-0 cursor-pointer"
          />
        )}
      </div>

      {error && (
        <div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
          <p className="text-sm text-red-600">{error}</p>
        </div>
      )}
    </div>
  );
};

Güvenlik Hususları

1. Dosya Tipi Validation (Hem Client hem Server)

// Client-side validation'a asla tek başına güvenme
const ALLOWED_MIME_TYPES = {
  'image/jpeg': [0xFF, 0xD8, 0xFF],
  'image/png': [0x89, 0x50, 0x4E, 0x47],
  'video/mp4': [0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70],
} as const;

function validateFileType(buffer: Buffer, declaredType: string): boolean {
  const signature = ALLOWED_MIME_TYPES[declaredType as keyof typeof ALLOWED_MIME_TYPES];
  if (!signature) return false;

  return signature.every((byte, index) => buffer[index] === byte);
}

// Processing Lambda'nızda
const fileBuffer = await s3Client.send(new GetObjectCommand({
  Bucket: bucketName,
  Key: objectKey,
  Range: 'bytes=0-10', // Signature kontrolü için sadece ilk birkaç byte'ı al
}));

const isValidType = validateFileType(fileBuffer.Body as Buffer, contentType);
if (!isValidType) {
  throw new Error('Dosya tipi validation başarısız');
}

2. Boyut Limitleri ve Timeout Koruması

// Signed URL generator'da
const generateSignedUrl = async (request: SignedUrlRequest) => {
  // Kullanıcı tier'ına göre kademeli boyut limitleri uygula
  const userTier = await getUserTier(request.userId);
  const maxSize = SIZE_LIMITS[userTier] || SIZE_LIMITS.free;

  if (request.fileSize > maxSize) {
    throw new Error(`File size exceeds ${userTier} tier limit`);
  }

  // Dosya boyutuna göre uygun expiry ayarla
  // Daha büyük dosyalar daha uzun upload penceresi alır
  const expirySeconds = Math.min(
    3600, // 1 saat max
    Math.max(300, request.fileSize / 1024 / 1024 * 10) // MB başına 10 saniye
  );

  return getSignedUrl(s3Client, putCommand, { expiresIn: expirySeconds });
};

3. Erişim Kontrolü ve Audit Logging

// Processing Lambda'da
import { CloudTrailClient, PutEventsCommand } from '@aws-sdk/client-cloudtrail';

const logFileUpload = async (uploadData: UploadData) => {
  const event = {
    eventTime: new Date().toISOString(),
    eventName: 'FileUploaded',
    eventSource: 'custom.fileupload',
    userIdentity: {
      type: 'Unknown',
      principalId: uploadData.userId,
    },
    resources: [{
      resourceName: uploadData.s3Key,
      resourceType: 'AWS::S3::Object',
    }],
    requestParameters: {
      bucketName: uploadData.bucketName,
      key: uploadData.s3Key,
      fileSize: uploadData.fileSize,
      contentType: uploadData.contentType,
    },
  };

  // Monitoring için CloudWatch'a logla
  console.log('File upload audit log:', event);

  // Opsiyonel olarak CloudTrail veya özel audit sistemine gönder
};

Production Performance Rakamları

Pattern’in üretim profilinde gözlemlenen davranış:

Maliyet Karşılaştırması (Aylık, 10.000 upload ortalama 2GB her biri):

ComponentEski (Proxy)Yeni (Signed URLs)Tasarruf
Lambda Compute$18.000$50$17.950
S3 Transfer$0$0$0
API Gateway$1.150$150$0
Toplam$18.150$1200$17.950

Performance İyileştirmeleri:

  • Upload başarı oranı: 85% → 99.9%
  • Ortalama upload süresi: 8-12 dakika → 2-5 dakika (network bağımlı)
  • Lambda cold start’lar: Upload path’inden tamamen elimine edildi
  • Concurrent upload’lar: Lambda concurrency ile sınırlı → Sınırsız S3 kapasitesi
  • Kullanıcı deneyimi: Güvenilmez → Progress tracking ile sorunsuz

Gelişmiş Production Pattern’leri

1. 100MB+ Dosyalar için Multipart Upload

// Multipart upload'lar için genişletilmiş signed URL generator
import { CreateMultipartUploadCommand, UploadPartCommand } from '@aws-sdk/client-s3';

const generateMultipartUrls = async (request: LargeFileRequest) => {
  const partSize = 100 * 1024 * 1024; // 100MB parça
  const numParts = Math.ceil(request.fileSize / partSize);

  // Multipart upload'ı başlat
  const multipart = await s3Client.send(new CreateMultipartUploadCommand({
    Bucket: process.env.UPLOAD_BUCKET!,
    Key: request.key,
    ContentType: request.fileType,
  }));

  // Her parça için signed URL generate et
  const partUrls = await Promise.all(
    Array.from({ length: numParts }, async (_, index) => {
      const partNumber = index + 1;
      const command = new UploadPartCommand({
        Bucket: process.env.UPLOAD_BUCKET!,
        Key: request.key,
        PartNumber: partNumber,
        UploadId: multipart.UploadId,
      });

      const signedUrl = await getSignedUrl(s3Client, command, {
        expiresIn: 3600,
      });

      return {
        partNumber,
        signedUrl,
        size: Math.min(partSize, request.fileSize - index * partSize),
      };
    })
  );

  return {
    uploadId: multipart.UploadId,
    parts: partUrls,
  };
};

2. State Takibi ile Devam Ettirilebilir Upload’lar

// Client-side resumable upload mantığı
export class ResumableUpload {
  private uploadId: string;
  private parts: UploadPart[];
  private completedParts: CompletedPart[] = [];

  async resumeUpload(file: File, uploadId?: string): Promise<string> {
    if (uploadId) {
      // Mevcut upload'a devam et
      this.uploadId = uploadId;
      this.completedParts = await this.getCompletedParts(uploadId);
    } else {
      // Yeni multipart upload başlat
      const response = await this.initializeUpload(file);
      this.uploadId = response.uploadId;
      this.parts = response.parts;
    }

    // Kalan parçaları yükle
    const pendingParts = this.parts.filter(
      part => !this.completedParts.some(c => c.partNumber === part.partNumber)
    );

    for (const part of pendingParts) {
      await this.uploadPart(file, part);
    }

    // Multipart upload'ı tamamla
    return this.completeUpload();
  }

  private async uploadPart(file: File, part: UploadPart): Promise<void> {
    const start = (part.partNumber - 1) * part.size;
    const end = Math.min(start + part.size, file.size);
    const chunk = file.slice(start, end);

    const response = await fetch(part.signedUrl, {
      method: 'PUT',
      body: chunk,
    });

    if (response.ok) {
      this.completedParts.push({
        partNumber: part.partNumber,
        etag: response.headers.get('ETag')!,
      });

      // Devam ettirme için ilerlemeyi localStorage'a kaydet
      localStorage.setItem(`upload_${this.uploadId}`, JSON.stringify({
        completedParts: this.completedParts,
        totalParts: this.parts.length,
      }));
    }
  }
}

3. Virus Tarama Entegrasyonu

// Upload sonrası virus taraması
import { ClamAVClient } from 'clamav-js'; // Örnek kütüphane

const scanUploadedFile = async (s3Event: S3Event) => {
  for (const record of s3Event.Records) {
    const bucket = record.s3.bucket.name;
    const key = record.s3.object.key;

    // Tarama için dosyayı indir (büyük dosyalar için stream)
    const fileStream = (await s3Client.send(new GetObjectCommand({
      Bucket: bucket,
      Key: key,
    }))).Body as Readable;

    // ClamAV veya benzeri ile tara
    const scanResult = await clamav.scanStream(fileStream);

    if (scanResult.isInfected) {
      console.warn('Infected file detected:', { bucket, key, virus: scanResult.viruses });

      // Dosyayı karantinaya al
      await s3Client.send(new CopyObjectCommand({
        CopySource: `${bucket}/${key}`,
        Bucket: `${bucket}-quarantine`,
        Key: key,
      }));

      // Orijinali sil
      await s3Client.send(new DeleteObjectCommand({
        Bucket: bucket,
        Key: key,
      }));

      // Kullanıcıyı bilgilendir
      await notifyUser(key, 'File rejected due to security scan');
    } else {
      // Dosya temiz, normal işleme devam et
      await processCleanFile(bucket, key);
    }
  }
};

Monitoring ve Alerting

CloudWatch Dashboard’ları

// CDK monitoring stack
const dashboard = new cloudwatch.Dashboard(this, 'FileUploadDashboard', {
  dashboardName: 'FileUploadMetrics',
  widgets: [
    [
      new cloudwatch.GraphWidget({
        title: 'Signed URL Generation',
        left: [
          signedUrlGenerator.metricDuration(),
          signedUrlGenerator.metricErrors(),
        ],
        right: [signedUrlGenerator.metricInvocations()],
      }),
    ],
    [
      new cloudwatch.GraphWidget({
        title: 'S3 Upload Metrics',
        left: [
          new cloudwatch.Metric({
            namespace: 'AWS/S3',
            metricName: 'NumberOfObjects',
            dimensionsMap: { BucketName: uploadBucket.bucketName },
          }),
        ],
      }),
    ],
    [
      new cloudwatch.GraphWidget({
        title: 'File Processing',
        left: [
          fileProcessor.metricDuration(),
          fileProcessor.metricErrors(),
        ],
      }),
    ],
  ],
});

// Production monitoring için alarm'lar
new cloudwatch.Alarm(this, 'HighErrorRate', {
  metric: signedUrlGenerator.metricErrors(),
  threshold: 10,
  evaluationPeriods: 2,
  treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});

Form Verileri ve Dosya Metadata’sı

Dosya Upload’larıyla Birlikte Ek Form Alanları İşleme

Production’da genellikle yüklenen dosyalarla metadata ilişkilendirmeniz gerekir. Form verilerini signed URL upload’larla birlikte şöyle ele alırsınız:

// Metadata'lı genişletilmiş request şeması
const FileUploadWithMetadataSchema = z.object({
  // Dosya bilgisi
  fileName: z.string().min(1).max(255),
  fileSize: z.number().int().min(1).max(10737418240),
  fileType: z.string().regex(/^(video|image|audio)\/[a-zA-Z0-9][a-zA-Z0-9\!\-\_]*[a-zA-Z0-9]*$/),

  // İş metadata'sı
  title: z.string().min(1).max(200),
  description: z.string().max(1000).optional(),
  category: z.enum(['education', 'entertainment', 'business', 'other']),
  tags: z.array(z.string()).max(10),
  isPublic: z.boolean(),

  // Upload metadata'sı
  uploadId: z.string().uuid(),
  userId: z.string().uuid(),
  organizationId: z.string().uuid().optional(),
});

// S3 objesine metadata dahil etmek için değiştirilmiş signed URL generator
const putObjectCommand = new PutObjectCommand({
  Bucket: process.env.UPLOAD_BUCKET!,
  Key: key,
  ContentType: request.fileType,
  ContentLength: request.fileSize,
  Metadata: {
    'original-filename': request.fileName,
    'upload-id': request.uploadId,
    'user-id': request.userId,
    'title': request.title,
    'description': request.description || '',
    'category': request.category,
    'tags': JSON.stringify(request.tags),
    'is-public': request.isPublic.toString(),
    'uploaded-at': new Date().toISOString(),
  },
  // Daha iyi organizasyon ve faturalandırma için object tag'leri ekle
  Tagging: `Category=${request.category}&IsPublic=${request.isPublic}&UserId=${request.userId}`,
});

İki Aşamalı Upload Pattern’i

Karmaşık formlar için iki aşamalı bir yaklaşım uygulayın:

// Aşama 1: Veritabanında upload kaydı oluştur
const createUploadRecord = async (metadata: FileMetadata) => {
  const uploadRecord = {
    id: metadata.uploadId,
    userId: metadata.userId,
    fileName: metadata.fileName,
    fileSize: metadata.fileSize,
    title: metadata.title,
    description: metadata.description,
    category: metadata.category,
    tags: metadata.tags,
    isPublic: metadata.isPublic,
    status: 'pending', // pending -> uploading -> processing -> completed
    createdAt: new Date().toISOString(),
  };

  await dynamoClient.send(new PutItemCommand({
    TableName: process.env.UPLOAD_RECORDS_TABLE!,
    Item: marshall(uploadRecord),
  }));

  return uploadRecord;
};

// Aşama 2: S3 upload tamamlandığında kaydı güncelle
const updateUploadStatus = async (uploadId: string, s3Key: string, status: string) => {
  await dynamoClient.send(new UpdateItemCommand({
    TableName: process.env.UPLOAD_RECORDS_TABLE!,
    Key: marshall({ id: uploadId }),
    UpdateExpression: 'SET #status = :status, #s3Key = :s3Key, #updatedAt = :updatedAt',
    ExpressionAttributeNames: {
      '#status': 'status',
      '#s3Key': 's3Key',
      '#updatedAt': 'updatedAt',
    },
    ExpressionAttributeValues: marshall({
      ':status': status,
      ':s3Key': s3Key,
      ':updatedAt': new Date().toISOString(),
    }),
  }));
};

Data Retention ve Lifecycle Management

S3 Lifecycle Rules ile Otomatik Data Lifecycle

// Kapsamlı lifecycle management ile genişletilmiş CDK stack
const uploadBucket = new s3.Bucket(this, 'UploadBucket', {
  lifecycleRules: [
    // Kural 1: Başarısız multipart upload'ları temizle
    {
      id: 'CleanupFailedUploads',
      enabled: true,
      abortIncompleteMultipartUploadsAfter: cdk.Duration.days(1),
    },

    // Kural 2: Erişim patterns'a göre transition
    {
      id: 'StorageClassTransitions',
      enabled: true,
      transitions: [
        {
          storageClass: s3.StorageClass.INFREQUENT_ACCESS,
          transitionAfter: cdk.Duration.days(30),
        },
        {
          storageClass: s3.StorageClass.GLACIER,
          transitionAfter: cdk.Duration.days(90),
        },
        {
          storageClass: s3.StorageClass.DEEP_ARCHIVE,
          transitionAfter: cdk.Duration.days(365),
        },
      ],
    },

    // Kural 3: Geçici/processing dosyalarını sil
    {
      id: 'CleanupTempFiles',
      enabled: true,
      filter: s3.LifecycleFilter.prefix('temp/'),
      expiration: cdk.Duration.days(7),
    },

    // Kural 4: Kullanıcı-spesifik retention (örnek: ücretsiz tier kullanıcıları)
    {
      id: 'FreeTierRetention',
      enabled: true,
      filter: s3.LifecycleFilter.tag('UserTier', 'free'),
      expiration: cdk.Duration.days(90),
    },
  ],

  // Versioning'i yanlışlıkla silinme koruması için etkinleştir
  versioned: true,
});

Kullanıcı Kontrollü Data Retention

// Kullanıcı silme isteklerini işleyen Lambda
export const deleteFileHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  try {
    const { fileId } = JSON.parse(event.body || '{}');
    const userId = getUserIdFromJWT(event.headers.authorization);

    // Sahiplik doğrula
    const fileRecord = await dynamoClient.send(new GetItemCommand({
      TableName: process.env.UPLOAD_RECORDS_TABLE!,
      Key: marshall({ id: fileId }),
    }));

    if (!fileRecord.Item) {
      return { statusCode: 404, body: JSON.stringify({ error: 'Dosya bulunamadı' }) };
    }

    const file = unmarshall(fileRecord.Item);
    if (file.userId !== userId) {
      return { statusCode: 403, body: JSON.stringify({ error: 'Erişim reddedildi' }) };
    }

    // Önce soft delete (silinmiş olarak işaretle, ama S3'den hemen kaldırma)
    await dynamoClient.send(new UpdateItemCommand({
      TableName: process.env.UPLOAD_RECORDS_TABLE!,
      Key: marshall({ id: fileId }),
      UpdateExpression: 'SET #status = :status, #deletedAt = :deletedAt',
      ExpressionAttributeNames: {
        '#status': 'status',
        '#deletedAt': 'deletedAt',
      },
      ExpressionAttributeValues: marshall({
        ':status': 'deleted',
        ':deletedAt': new Date().toISOString(),
      }),
    }));

    // Grace period'dan sonra lifecycle cleanup için S3 delete tag'i ekle
    await s3Client.send(new PutObjectTaggingCommand({
      Bucket: process.env.UPLOAD_BUCKET!,
      Key: file.s3Key,
      Tagging: {
        TagSet: [
          { Key: 'Status', Value: 'deleted' },
          { Key: 'DeletedAt', Value: new Date().toISOString() },
          { Key: 'GracePeriodDays', Value: '30' },
        ],
      },
    }));

    return {
      statusCode: 200,
      body: JSON.stringify({
        message: 'Dosya silme için planlandı',
        gracePeriod: '30 gün',
      }),
    };

  } catch (error) {
    console.error('Dosya silme hatası:', error);
    return { statusCode: 500, body: JSON.stringify({ error: 'Silme başarısız' }) };
  }
};

GDPR Uyumluluk ve Data Portability

// Kullanıcı veri export'u için Lambda (GDPR Madde 20)
export const exportUserDataHandler = async (event: APIGatewayProxyEvent) => {
  const userId = getUserIdFromJWT(event.headers.authorization);

  // Kullanıcının tüm dosyalarını al
  const userFiles = await dynamoClient.send(new ScanCommand({
    TableName: process.env.UPLOAD_RECORDS_TABLE!,
    FilterExpression: '#userId = :userId',
    ExpressionAttributeNames: { '#userId': 'userId' },
    ExpressionAttributeValues: marshall({ ':userId': userId }),
  }));

  // Tüm dosyalar için download link'leri generate et
  const fileExports = await Promise.all(
    (userFiles.Items || []).map(async (item) => {
      const file = unmarshall(item);

      if (file.status !== 'completed') return null;

      // Geçici download URL generate et
      const downloadUrl = await getSignedUrl(
        s3Client,
        new GetObjectCommand({
          Bucket: process.env.UPLOAD_BUCKET!,
          Key: file.s3Key,
        }),
        { expiresIn: 3600 * 24 } // 24 saat
      );

      return {
        fileId: file.id,
        originalFileName: file.fileName,
        title: file.title,
        description: file.description,
        category: file.category,
        tags: file.tags,
        uploadedAt: file.createdAt,
        fileSize: file.fileSize,
        downloadUrl,
      };
    })
  );

  const exportData = {
    userId,
    exportedAt: new Date().toISOString(),
    files: fileExports.filter(Boolean),
    summary: {
      totalFiles: fileExports.filter(Boolean).length,
      totalSize: fileExports.reduce((sum, file) => sum + (file?.fileSize || 0), 0),
    },
  };

  return {
    statusCode: 200,
    body: JSON.stringify({
      exportUrl: exportDownloadUrl,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
      summary: exportData.summary,
    }),
  };
};

Maliyet Optimizasyon Stratejileri

Akıllı Storage Class Seçimi

// Kullanım patterns'ı analiz edip storage class'ları optimize eden Lambda
export const optimizeStorageHandler = async (event: ScheduledEvent) => {
  const s3Inventory = await getS3Inventory(); // S3 inventory report'larından

  for (const object of s3Inventory) {
    const lastAccessed = await getObjectAccessTime(object.key);
    const daysSinceAccess = (Date.now() - lastAccessed) / (1000 * 60 * 60 * 24);

    // Erişim patterns'a göre otomatik transition
    if (daysSinceAccess > 90 && object.storageClass === 'STANDARD') {
      await s3Client.send(new CopyObjectCommand({
        CopySource: `${object.bucket}/${object.key}`,
        Bucket: object.bucket,
        Key: object.key,
        StorageClass: 'GLACIER',
        MetadataDirective: 'COPY',
      }));

      console.log('Glacier\'a geçirildi:', { key: object.key, daysSinceAccess });
    }

    // Çok eski dosyalar için deep archive
    if (daysSinceAccess > 365 && object.storageClass === 'GLACIER') {
      await s3Client.send(new CopyObjectCommand({
        CopySource: `${object.bucket}/${object.key}`,
        Bucket: object.bucket,
        Key: object.key,
        StorageClass: 'DEEP_ARCHIVE',
        MetadataDirective: 'COPY',
      }));

      console.log('Deep Archive\'a geçirildi:', { key: object.key, daysSinceAccess });
    }
    }
};

Kullanım Bazlı Faturalandırma Entegrasyonu

// Kullanım bazlı faturalandırma için dosya erişimini takip et
const trackFileAccess = async (userId: string, fileId: string, operation: string) => {
  await dynamoClient.send(new PutItemCommand({
    TableName: process.env.USAGE_TRACKING_TABLE!,
    Item: marshall({
      id: `${userId}#${Date.now()}`,
      userId,
      fileId,
      operation, // 'upload', 'download', 'view', 'delete'
      timestamp: new Date().toISOString(),
      month: new Date().toISOString().substring(0, 7), // Faturalandırma için YYYY-MM
    }),
  }));

  // Kullanıcının aylık kullanım sayacını güncelle
  await dynamoClient.send(new UpdateItemCommand({
    TableName: process.env.USER_USAGE_TABLE!,
    Key: marshall({
      userId,
      month: new Date().toISOString().substring(0, 7),
    }),
    UpdateExpression: 'ADD #operation :increment',
    ExpressionAttributeNames: { '#operation': operation },
    ExpressionAttributeValues: marshall({ ':increment': 1 }),
  }));
};

Sonuç: Karar Kriterleri

Signed URL pattern’i, büyük dosya upload’larında belirgin avantajlar sağlar:

Teknik Kazanımlar:

  • Compute harcamalarında belirgin maliyet düşüşü
  • Timeout sorunlarının ortadan kalkması
  • S3’ün native kapasitesi sayesinde daha iyi ölçeklenebilirlik
  • Gerçek progress tracking ile geliştirilmiş kullanıcı deneyimi

İmplementasyon Düşünceleri:

  • Anında işleme gerektiren dosyalar için Lambda proxy’yi tercih edin
  • Basit depolama senaryoları için signed URL kullanın
  • Kompleks akışlar için hybrid yaklaşımları değerlendirin
  • Güvenlik önlemlerini baştan planlayın

Anahtar Çıkarımlar:

  1. Doğru iş için doğru araç: Lambda işlemede, S3 depolamada güçlü
  2. Güvenlik çok katmanlı: Signed URL’ler, validation, tarama ve monitoring
  3. Basit başla, gerektikçe genişlet: Önce basit signed URL, sonra multipart upload
  4. Monitor ve ölç: Dosya upload’ları çok sayıda şekilde başarısız olabilir; izleme şart

Bu pattern, anında işleme gerektirmeyen her dosya tipi için çalışır. Asıl karar noktası: Lambda upload sürecinde pahalı bir proxy olmanın ötesinde değer katıyor mu?

Kaynaklar

İlgili yazılar