İçeriğe atla

2025-09-04

AWS Lambda + S3 Signed URLs: Büyük Dosya Yükleme için Pratik Çözüm

Lambda proxy yerine S3 signed URL'leri kullanarak büyük dosya yüklemelerini işlemeye yönelik pratik bir yaklaşım. CDK implementasyonu, güvenlik hususları ve production deneyimlerinden çı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 Maliyet Kabusu

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

// Bütçemizi ve kullanıcı deneyimini öldüren Lambda
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.

Oyun Değiştiren 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

İşte 18 aydır production’da çalıştırdığımız gerçek CDK stack:

// 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>
  );
};

Production’da Öğrenilen Güvenlik Dersleri

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ı

18 aydır production’da 100.000+ upload ile:

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ç: Her Şeyi Değiştiren Mimari

Lambda-proxied upload’lardan S3 signed URL’lere geçmek platformumuzu dönüştürdü:

Teknik Kazanımlar:

  • Compute harcamalarında 99% maliyet azaltımı
  • Timeout sorunlarını tamamen elimine etti
  • S3’ün native kapasitesi ile sınırsız concurrent upload
  • Gerçek progress tracking ile daha iyi kullanıcı deneyimi

İş Etkisi:

  • Kullanıcı memnuniyet skorları 40% arttı
  • Upload sorunları için destek biletleri 85% azaldı
  • Platform artık multi-GB dosyalara sahip enterprise clientları handle edebiliyor
  • Geliştirme ekibi upload debug’ı yerine özellik geliştirmeye odaklanabiliyor

Anahtar Öğrenimler:

  1. Process etmeyeceğin şeyi proxy yapma - Lambda güçlü, ama basit dosya depolama için S3 direct upload daha verimli
  2. Güvenlik çok katmanlı - Signed URL’ler, dosya validation, virüs tarama ve audit trail’ler
  3. Progressive enhancement işe yarar - Basit signed URL’lerle başla, büyük dosyalar için multipart/resumable upload ekle
  4. Her şeyi monitörle - Dosya upload’ları birçok şekilde başarısız olabilir, kapsamlı monitoring şart

Bu mimari şimdi aylık 500K+ dosyayı neredeyse mükemmel güvenilirlikle işliyor. Pattern her dosya tipi için çalışır - video’lar, resimler, dokümanlar, veri export’ları. Anahtar, Lambda’nın ne zaman değer kattığını (processing, transformation) ne zaman sadece pahalı bir proxy olduğunu (raw file upload) ayırt etmek.

Kaynaklar

İlgili yazılar

Lambda Layer Versiyon Yönetimi: Çok Ortamlı Deployment Stratejileri

Dev, staging ve production ortamlarında Lambda Layer versiyonlarını yönetmek için pratik yaklaşımlar. AWS CDK implementasyonları, otomatik deployment pipeline'ları ve rollback stratejileri ile.

awslambdaaws-cdk+4
AWS Bedrock AgentCore'u CDK ile deploy etmek: hızlı başlangıç

AgentCore Runtime üzerinde minimal bir Strands agent'ı CDK ile deploy etme rehberi: parametrize stack, arm64 build, deploy ve invoke akışı, ve ilk çağrıdan önce gereken IAM ve Marketplace ön koşulları.

aws-bedrockai-agentsaws-cdk+3
Harici Yetkilendirme Yönetim Sistemleri: Mimarınız İçin Doğru Platformu Seçmek

AWS Verified Permissions, SpiceDB, OpenFGA, Cerbos ve OPA dahil harici yetkilendirme platformlarının tarafsız değerlendirmesi. Mimari desenler, maliyet analizi ve mühendislik ekipleri için karar çerçevesi.

authorizationsecurityarchitecture+5
AWS ile Edge Computing: CloudFront Functions vs Lambda@Edge

Global uygulamalar için AWS edge computing çözümlerini seçme ve uygulama üzerine pratik örnekler ve maliyet optimizasyonu stratejileri içeren kapsamlı teknik rehber.

awscloudfrontlambda+6
Amazon Cognito Derinlemesine: Temel Authentication'ın Ötesinde

Amazon Cognito'nun gelişmiş özellikleri üzerine kapsamlı teknik kılavuz: özel authentication akışları, federation pattern'leri, multi-tenancy mimarileri, migration stratejileri ve production-grade güvenlik implementasyonu.

awscognitoauthentication+7